UNPKG

rsshub

Version:
159 lines (136 loc) 6.06 kB
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=ハーレム&notword=&type=r&mintime=&maxtime=&minlen=30000&maxlen=&min_globalpoint=&max_globalpoint=&minlastup=&maxlastup=&minfirstup=&maxfirstup=&isgl=1&notbl=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, }; }