From 60aa1b72e4fd4b282a108b12f9cab3f5186b036a Mon Sep 17 00:00:00 2001 From: Tsuyumi <40047364+SnowAgar25@users.noreply.github.com> Date: Tue, 10 Sep 2024 01:46:27 +0800 Subject: [PATCH] feat(route): add route for chikubi.jp (#16656) * feat(route): add route for chikubi.jp * refactor: Adjust metadata and selector usage --- lib/routes/chikubi/index.ts | 75 ++++++++++++++++++++++++++++++ lib/routes/chikubi/namespace.ts | 6 +++ lib/routes/chikubi/utils.ts | 82 +++++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 lib/routes/chikubi/index.ts create mode 100644 lib/routes/chikubi/namespace.ts create mode 100644 lib/routes/chikubi/utils.ts diff --git a/lib/routes/chikubi/index.ts b/lib/routes/chikubi/index.ts new file mode 100644 index 00000000000000..41ef3cbc60a2a5 --- /dev/null +++ b/lib/routes/chikubi/index.ts @@ -0,0 +1,75 @@ +import { Route, Data } from '@/types'; +import got from '@/utils/got'; +import { load } from 'cheerio'; +import { processItems } from './utils'; + +export const route: Route = { + path: '/:category?', + categories: ['multimedia'], + example: '/chikubi', + parameters: { category: '分類,見下表,默認爲最新' }, + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + name: 'Category', + maintainers: ['snowagar25'], + handler, + description: `| 最新 | 殿堂 | 動畫 | VR | 漫畫 | 音聲 | CG | + | ------ | ---- | ----- | -- | ----- | ----- | -- | + | (empty) | best | video | vr | comic | voice | cg |`, + radar: [ + { + source: ['chikubi.jp/:category', 'chikubi.jp/'], + target: '/:category', + }, + ], +}; + +const categoryMap = { + '': { url: '/page/1', title: '最新', selector: '.article_list_area > article > a' }, + best: { url: '/best-nipple-article', title: '殿堂', selector: '.article-list:first .title > a' }, + video: { url: '/nipple-video', title: '動畫', selector: 'ul.video_list > li > a' }, + vr: { url: '/nipple-video-category/cat-nipple-video-vr', title: 'VR', selector: 'ul.video_list > li > a' }, + comic: { url: '/comic', title: '漫畫', selector: '.section:nth-of-type(2) .list-doujin .photo a' }, + voice: { url: '/voice', title: '音聲', selector: 'ul.list-doujin > li > .photo > a' }, + cg: { url: '/cg', title: 'CG', selector: 'ul.list-doujin > li > .photo > a' }, +}; + +async function handler(ctx): Promise { + const category = ctx.req.param('category') ?? ''; + const baseUrl = 'https://chikubi.jp'; + + const { url, title, selector } = categoryMap[category]; + + const response = await got(`${baseUrl}${url}`); + const $ = load(response.data); + + let list = $(selector) + .toArray() + .map((item) => { + const $item = $(item); + return { + title: $item.text().trim(), + link: new URL($item.attr('href') ?? '', baseUrl).href, + }; + }); + + // 限制殿堂最多獲取30個 + if (category === 'best') { + list = list.slice(0, 30); + } + + // 獲取內文 + const items = await processItems(list); + + return { + title: `${title} - chikubi.jp`, + link: `${baseUrl}${url}`, + item: items, + }; +} diff --git a/lib/routes/chikubi/namespace.ts b/lib/routes/chikubi/namespace.ts new file mode 100644 index 00000000000000..b63d4cd211799d --- /dev/null +++ b/lib/routes/chikubi/namespace.ts @@ -0,0 +1,6 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: '乳首ふぇち', + url: 'chikubi.jp', +}; diff --git a/lib/routes/chikubi/utils.ts b/lib/routes/chikubi/utils.ts new file mode 100644 index 00000000000000..59321c768e700e --- /dev/null +++ b/lib/routes/chikubi/utils.ts @@ -0,0 +1,82 @@ +import { DataItem } from '@/types'; +import cache from '@/utils/cache'; +import got from '@/utils/got'; +import { load } from 'cheerio'; +import { parseDate } from '@/utils/parse-date'; + +interface ListItem { + title: string; + link: string; +} + +interface ContentSelectors { + title: string; + description: string[]; +} + +const contentTypes: Record = { + doujin: { + title: '.doujin-title', + description: ['.doujin-detail', '.section', '.area-buy > a.btn'], + }, + video: { + title: '.video-title', + description: ['.video-data', '.section', '.lp-samplearea a.btn'], + }, + article: { + title: '.article_title', + description: ['.article_icatch', '.article_contents'], + }, +}; + +function getContentType(link: string): keyof typeof contentTypes { + const typePatterns = { + doujin: ['/cg/', '/comic/', '/voice/'], + video: ['/nipple-video/'], + article: ['/post-'], + }; + + for (const [type, patterns] of Object.entries(typePatterns)) { + if (patterns.some((pattern) => link.includes(pattern))) { + return type as keyof typeof contentTypes; + } + } + + throw new Error(`Unknown content type for link: ${link}`); +} + +export async function processItems(list: ListItem[]): Promise { + const items = await Promise.all( + list.map((item) => + cache.tryGet(item.link, async () => { + const detailResponse = await got(item.link); + const $ = load(detailResponse.data); + + const contentType = getContentType(item.link); + const selectors = contentTypes[contentType]; + + const title = $(selectors.title).text().trim() || item.title; + const description = selectors.description + .map((selector) => + $(selector) + .map((_, el) => $(el).clone().wrap('
').parent().html()) + .toArray() + .join('') + ) + .join(''); + + const pubDateStr = $('meta[property="article:published_time"]').attr('content'); + const pubDate = pubDateStr ? parseDate(pubDateStr) : undefined; + + return { + title, + description, + link: item.link, + pubDate, + } as DataItem; + }) + ) + ); + + return items.filter((item): item is DataItem => item !== null); +}