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

---------
This commit is contained in:
Zhang Yan 2026-02-01 16:51:10 +08:00 committed by GitHub
parent 4dc4c165d4
commit bb1d219d5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 206 additions and 55 deletions

View File

@ -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<DataItem> {
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('<font color="#E6E6DD">cool18.com</font>', '');
}
}
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(/<font color="#E6E6DD">cool18.com<\/font>/g, '') : '';
}
return item;
})
)
);
const items = await Promise.all(list.map((item) => fetchArticleDetail(item)));
return {
title: $('title').text(),