From bb1d219d5fa1cbdb9c1f41964c54feddaa92e41b Mon Sep 17 00:00:00 2001 From: Zhang Yan <57052263+Gabrlie@users.noreply.github.com> Date: Sun, 1 Feb 2026 16:51:10 +0800 Subject: [PATCH] fix(route/cool18): fix broken search parsing & add global search (#21024) * fix(route/cool18): fix broken search parsing & add global search * fix(route/cool18): add missing await in fetchArticleDetail * fix(route/cool18): add extractGoldList to handle gold posts * fix: example path --------- --- lib/routes/cool18/index.ts | 261 +++++++++++++++++++++++++++++-------- 1 file changed, 206 insertions(+), 55 deletions(-) diff --git a/lib/routes/cool18/index.ts b/lib/routes/cool18/index.ts index f6a1bea15..f9d9da1b1 100644 --- a/lib/routes/cool18/index.ts +++ b/lib/routes/cool18/index.ts @@ -1,7 +1,8 @@ +import type { CheerioAPI } from 'cheerio'; import { load } from 'cheerio'; import type { Context } from 'hono'; -import type { Route } from '@/types'; +import type { DataItem, Route } from '@/types'; import cache from '@/utils/cache'; import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; @@ -14,15 +15,16 @@ type PageDataItem = { type: string; }; +type PostType = 'home' | 'gold' | 'threadsearch' | 'search'; + export const route: Route = { path: '/:id?/:type?/:keyword?', url: 'cool18.com', - example: 'cool18.com/bbs4', + example: '/cool18/bbs4', parameters: { - id: 'the name of the bbs', - type: 'the type of the post. Can be `home`, `gold` or `threadsearch`. Default: `home`', + id: 'the name of the bbs, use `global` for site-wide search', + type: 'the type of the post. Can be `home`, `gold`, `threadsearch`. Default: `home`', keyword: 'the keyword to search.', - pageSize: 'the number of posts to fetch. If the type is not in search, you can type any words. Default: 10', }, categories: ['bbs'], radar: [ @@ -39,66 +41,215 @@ export const route: Route = { }, }; +/** + * 构建请求 URL + */ +function buildUrl(rootUrl: string, type: PostType, keyword: string | undefined, isGlobal: boolean): string { + if (isGlobal && (type === 'search' || type === 'threadsearch')) { + return `${rootUrl}/search.php?keyword=${encodeURIComponent(keyword || '')}&sa=全成人区搜索`; + } + + const params = { + home: '', + gold: '?app=forum&act=gold', + threadsearch: `?action=search&act=threadsearch&app=forum&keywords=${encodeURIComponent(keyword || '')}&submit=查询`, + search: `?action=search&act=threadsearch&app=forum&keywords=${encodeURIComponent(keyword || '')}&submit=查询`, + }; + + return rootUrl + params[type]; +} + +/** + * 从首页 JSON 数据中提取列表 + */ +function extractHomeList($: CheerioAPI, rootUrl: string, limit: number): DataItem[] { + try { + const scriptText = $('script:contains("_PageData")').text(); + const match = scriptText.match(/const\s+_PageData\s*=\s*(\[[\s\S]*?]);/); + + if (!match?.[1]) { + return []; + } + + const pageData: PageDataItem[] = JSON.parse(match[1]); + + return pageData.slice(0, limit).map((item) => ({ + title: item.subject, + link: `${rootUrl}?app=forum&act=threadview&tid=${item.tid}`, + pubDate: parseDate(item.dateline, 'MM/DD/YY'), + author: item.username, + category: item.type ? [item.type] : [], + description: '', + })); + } catch { + return []; + } +} + +/** + * 从 HTML 列表中提取数据 + */ +function extractHtmlList($: CheerioAPI, rootUrl: string, limit: number): DataItem[] { + const selector = '#d_list ul li, #thread_list li, .t_l .t_subject, .post-list.thread-list li'; + const elements = $(selector); + + return elements + .slice(0, limit) + .toArray() + .map((item) => { + const $item = $(item); + const $link = $item.find('a').first(); + const href = $link.attr('href') || ''; + + const categoryText = $link.find('span').first().text().trim(); + + const authorFromFont = $item.find('font[color="black"]').text().trim(); + const authorFromLink = $item.find('a').last().text().trim(); + const author = authorFromFont || authorFromLink; + + return { + title: $link.text().trim(), + link: href.startsWith('http') ? href : `${rootUrl}/${href}`, + pubDate: parseDate($item.find('i').text(), 'MM/DD/YY'), + author, + category: categoryText ? [categoryText] : [], + description: '', + }; + }) + .filter((item) => item.link); +} + +/** + * 从全局搜索页面提取数据 + */ +function extractGlobalSearchList($: CheerioAPI, limit: number): DataItem[] { + const selector = '.search-content ul li'; + const elements = $(selector); + + return elements + .slice(0, limit) + .toArray() + .map((item) => { + const $item = $(item); + const $link = $item.find('a').first(); + const href = $link.attr('href') || ''; + + const $pElements = $link.find('p'); + const category = $pElements + .filter('.lf') + .first() + .text() + .trim() + .replaceAll(/[【】]/g, ''); + const title = $pElements.filter('.lf').eq(1).text().trim(); + const dateText = $pElements.filter('.lr').text().trim(); + + return { + title: title || $link.text().trim(), + link: href.startsWith('http') ? href : `https://www.cool18.com/${href}`, + pubDate: parseDate(dateText, 'YYYY-MM-DD HH:mm'), + author: '', + category: category ? [category] : [], + description: '', + }; + }) + .filter((item) => item.link && item.title); +} + +/** + * 从 gold 页面提取精华帖子列表 + */ +function extractGoldList($: CheerioAPI, rootUrl: string, limit: number): DataItem[] { + const selector = '.section .post-list .post-item'; + const elements = $(selector); + + return elements + .slice(0, limit) + .toArray() + .map((item) => { + const $item = $(item); + const $link = $item.find('a').first(); + const href = $link.attr('href') || ''; + const title = $link.text().trim(); + + const cleanTitle = title.replace(/^\.\s+/, ''); + + return { + title: cleanTitle, + link: href.startsWith('http') ? href : `${rootUrl}/${href}`, + pubDate: undefined, + author: '', + category: ['精华'], + description: '', + }; + }) + .filter((item) => item.link && item.title); +} + +/** + * 获取文章详情内容 + */ +async function fetchArticleDetail(item: DataItem): Promise { + if (!item.link) { + return item; + } + + return await cache.tryGet(item.link, async () => { + const detailResponse = await ofetch(item.link!); + const content = load(detailResponse); + const preElement = content('pre'); + + if (preElement.length > 0) { + const htmlContent = preElement.html(); + if (htmlContent) { + item.description = htmlContent.replaceAll('cool18.com', ''); + } + } + + return item; + }); +} + +/** + * 主处理函数 + */ async function handler(ctx: Context) { const { id = 'bbs4', type = 'home', keyword } = ctx.req.param(); + const postType = type as PostType; + const isGlobal = id === 'global'; - const rootUrl = 'https://www.cool18.com/' + id + '/index.php'; - const params = type === 'home' ? '' : type === 'gold' ? '?app=forum&act=gold' : `?action=search&act=threadsearch&app=forum&keywords=${keyword}&submit=查询`; - - const currentUrl = rootUrl + params; + const rootUrl = isGlobal ? 'https://www.cool18.com' : `https://www.cool18.com/${id}/index.php`; + const currentUrl = buildUrl(rootUrl, postType, keyword, isGlobal); const response = await ofetch(currentUrl); - const $ = load(response); - const limit = ctx.req.query('limit') ? Number.parseInt(ctx.req.query('limit') as string) : 20; + const limitQuery = ctx.req.query('limit'); + const limit = limitQuery ? Number.parseInt(limitQuery as string, 10) : 20; - const list = - type === 'home' - ? JSON.parse( - $('script:contains("_PageData")') - .text() - .match(/const\s+_PageData\s*=\s*(\[[\s\S]*?]);/)?.[1] || '[]' - ) - .slice(0, limit) - .map((item: PageDataItem) => ({ - title: item.subject, - link: `${rootUrl}?app=forum&act=threadview&tid=${item.tid}`, - pubDate: parseDate(item.dateline, 'MM/DD/YY'), - author: item.username, - category: item.type, - description: '', - })) - : $('#d_list ul li, #thread_list li, .t_l .t_subject') - .slice(0, limit) - .toArray() - .map((item) => { - const a = $(item).find('a').first(); - return { - title: a.text(), - link: `${rootUrl}/${a.attr('href')}`, - pubDate: parseDate($(item).find('i').text(), 'MM/DD/YY'), - author: $(item).find('a').last().text(), - category: a.find('span').first().text(), - description: '', - }; - }); + let list: DataItem[]; - const items = await Promise.all( - list.map((item) => - cache.tryGet(item.link, async () => { - const detailResponse = await ofetch(item.link); + if (isGlobal && (postType === 'search' || postType === 'threadsearch')) { + list = extractGlobalSearchList($, limit); + } else { + switch (postType) { + case 'home': + list = extractHomeList($, rootUrl, limit); + break; + case 'gold': + list = extractGoldList($, rootUrl, limit); + break; + case 'threadsearch': + case 'search': + list = extractHtmlList($, rootUrl, limit); + break; + default: + list = extractHtmlList($, rootUrl, limit); + break; + } + } - const content = load(detailResponse); - const preElement = content('pre'); - if (preElement.length > 0) { - const htmlContent = preElement.html(); - item.description = htmlContent ? htmlContent.replaceAll(/cool18.com<\/font>/g, '') : ''; - } - return item; - }) - ) - ); + const items = await Promise.all(list.map((item) => fetchArticleDetail(item))); return { title: $('title').text(),