rsshub
Version:
Make RSS Great Again!
159 lines (136 loc) • 6.06 kB
text/typescript
import { Route, Data } from '@/types';
import { art } from '@/utils/render';
import path from 'node:path';
import { Context } from 'hono';
import { Genre, GenreNotation, NarouNovelFetch, NovelTypeParam, Order, R18Site, SearchBuilder, SearchBuilderR18, SearchParams } from 'narou';
import queryString from 'query-string';
import { Join } from 'narou/util/type';
import InvalidParameterError from '@/errors/types/invalid-parameter';
import { SyosetuSub, NarouSearchParams, syosetuSubToJapanese } from './types/search';
export const route: Route = {
path: '/search/:sub/:query',
categories: ['reading'],
example: '/syosetu/search/noc/word=ハーレム¬word=&type=r&mintime=&maxtime=&minlen=30000&maxlen=&min_globalpoint=&max_globalpoint=&minlastup=&maxlastup=&minfirstup=&maxfirstup=&isgl=1¬bl=1&order=new?limit=5',
parameters: {
sub: {
description: 'The target Syosetu subsite.',
options: Object.entries(SyosetuSub).map(([, value]) => ({
value,
label: syosetuSubToJapanese[value],
})),
},
query: 'Search parameters in Syosetu format.',
},
features: {
requireConfig: false,
requirePuppeteer: false,
antiCrawler: false,
supportBT: false,
supportPodcast: false,
supportScihub: false,
},
name: 'Search',
maintainers: ['SnowAgar25'],
handler,
};
const setIfExists = (value) => value ?? undefined;
/**
* This function converts query string generated by Syosetu website into API-compatible format.
* It is not intended for users to freely adjust values.
*
* @see https://deflis.github.io/node-narou/index.html
* @see https://dev.syosetu.com/man/api/
*/
function mapToSearchParams(query: string, limit: number): SearchParams {
const params = queryString.parse(query) as NarouSearchParams;
const searchParams: SearchParams = {
gzip: 5,
lim: limit,
};
searchParams.word = setIfExists(params.word);
searchParams.notword = setIfExists(params.notword);
searchParams.title = setIfExists(params.title);
searchParams.ex = setIfExists(params.ex);
searchParams.keyword = setIfExists(params.keyword);
searchParams.wname = setIfExists(params.wname);
searchParams.sasie = setIfExists(params.sasie);
searchParams.iszankoku = setIfExists(params.iszankoku);
searchParams.isbl = setIfExists(params.isbl);
searchParams.isgl = setIfExists(params.isgl);
searchParams.istensei = setIfExists(params.istensei);
searchParams.istenni = setIfExists(params.istenni);
searchParams.stop = setIfExists(params.stop);
searchParams.notzankoku = setIfExists(params.notzankoku);
searchParams.notbl = setIfExists(params.notbl);
searchParams.notgl = setIfExists(params.notgl);
searchParams.nottensei = setIfExists(params.nottensei);
searchParams.nottenni = setIfExists(params.nottenni);
searchParams.minlen = setIfExists(params.minlen);
searchParams.maxlen = setIfExists(params.maxlen);
searchParams.type = setIfExists(params.type as NovelTypeParam);
searchParams.order = setIfExists(params.order as Order);
searchParams.genre = setIfExists(params.genre as Join<Genre> | Genre);
searchParams.nocgenre = setIfExists(params.nocgenre as Join<R18Site> | R18Site);
if (params.mintime || params.maxtime) {
searchParams.time = `${params.mintime || ''}-${params.maxtime || ''}`;
}
return searchParams;
}
const isGeneral = (sub: string): boolean => sub === SyosetuSub.YOMOU;
function createNovelSearchBuilder(sub: string, searchParams: SearchParams) {
if (isGeneral(sub)) {
return new SearchBuilder(searchParams, new NarouNovelFetch());
}
const r18Params = { ...searchParams };
switch (sub) {
case SyosetuSub.NOCTURNE:
r18Params.nocgenre = R18Site.Nocturne;
break;
case SyosetuSub.MOONLIGHT:
// If either 女性向け/BL is chosen, nocgenre will be in query string
// If no specific genre selected, include both
if (!r18Params.nocgenre) {
r18Params.nocgenre = [R18Site.MoonLight, R18Site.MoonLightBL].join('-') as Join<R18Site>;
}
break;
case SyosetuSub.MIDNIGHT:
r18Params.nocgenre = R18Site.Midnight;
break;
default:
throw new InvalidParameterError('Invalid Syosetu subsite.\nValid subsites are: yomou, noc, mnlt, mid');
}
return new SearchBuilderR18(r18Params, new NarouNovelFetch());
}
async function handler(ctx: Context): Promise<Data> {
const { sub, query } = ctx.req.param();
const searchUrl = `https://${sub}.syosetu.com/search/search/search.php?${query}`;
const limit = Math.min(Number(ctx.req.query('limit') ?? 40), 40);
const searchParams = mapToSearchParams(query, limit);
const builder = createNovelSearchBuilder(sub, searchParams);
const result = await builder.execute();
const items = result.values.map((novel) => ({
title: novel.title,
link: `https://${isGeneral(sub) ? 'ncode' : 'novel18'}.syosetu.com/${String(novel.ncode).toLowerCase()}`,
description: art(path.join(__dirname, 'templates/description.art'), {
novel,
genreText: GenreNotation[novel.genre],
}),
// Skip pubDate - search results prioritize search sequence over timestamps
// pubDate: novel.general_lastup,
author: novel.writer,
// Split by whitespace characters(\s), slash(/), full-width slash(/)
category: novel.keyword.split(/[\s/\uFF0F]/).filter(Boolean),
}));
const searchTerms: string[] = [];
if (searchParams.word) {
searchTerms.push(searchParams.word);
}
if (searchParams.notword) {
searchTerms.push(`-${searchParams.notword}`);
}
return {
title: searchTerms.length > 0 ? `Syosetu Search: ${searchTerms.join(' ')}` : 'Syosetu Search',
link: searchUrl,
item: items,
};
}