From 225a3eaaeb50e8316bac988be9ad528f5b5f31c3 Mon Sep 17 00:00:00 2001 From: magazian <16831220+magazian@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:00:02 +0800 Subject: [PATCH 1/2] feat(route): add Anhui Museum news and exhibition route --- lib/routes/ahm/abxw.ts | 80 ++++++++++++++++++++ lib/routes/ahm/namespace.ts | 10 +++ lib/routes/ahm/xztj.tsx | 147 ++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 lib/routes/ahm/abxw.ts create mode 100644 lib/routes/ahm/namespace.ts create mode 100644 lib/routes/ahm/xztj.tsx diff --git a/lib/routes/ahm/abxw.ts b/lib/routes/ahm/abxw.ts new file mode 100644 index 000000000000..7345c639becd --- /dev/null +++ b/lib/routes/ahm/abxw.ts @@ -0,0 +1,80 @@ +import { load } from 'cheerio'; + +import type { DataItem, Route } from '@/types'; +import { parseDate } from '@/utils/parse-date'; +import { getPlaywrightPage } from '@/utils/playwright'; +import timezone from '@/utils/timezone'; + +import { namespace } from './namespace'; + +const baseUrl = 'https://www.ahm.cn'; + +export const route: Route = { + path: '/news/abxw', + categories: ['travel'], + example: '/ahm/news/abxw', + features: { + requireConfig: false, + requirePuppeteer: true, + antiCrawler: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['www.ahm.cn/News/List/abxw'], + target: '/news/abxw', + }, + ], + name: '安博新闻', + maintainers: ['magazian'], + handler: async () => { + const museumName = namespace.zh?.name || namespace.name; + const listUrl = `${baseUrl}/News/List/abxw`; + + // Anhui Museum website use CT2-WAAP to prevent web scraping, so need to use Playwright to get the page content. + const { page, destroy } = await getPlaywrightPage(listUrl, { + gotoConfig: { waitUntil: 'domcontentloaded' }, + }); + + let html: string; + try { + await page.waitForSelector('ul.img-cont-list li', { + timeout: 15000, + }); + html = await page.content(); + } finally { + await destroy(); + } + + const $ = load(html); + + const items: DataItem[] = $('ul.img-cont-list li') + .toArray() + .map((el) => { + const $el = $(el); + const a = $el.find('a'); + const href = a.attr('href') ?? ''; + const link = new URL(href, baseUrl).href; + + const dateP = $el.find('.date p'); + const monthDay = dateP.eq(0).text(); + const year = dateP.eq(1).text(); + const dateStr = `${year}/${monthDay}`; + + return { + title: $el.find('.cont h3').text(), + link, + pubDate: timezone(parseDate(dateStr, 'YYYY/MM/DD'), 8), + }; + }); + + return { + title: `${museumName} - 安博新闻`, + link: listUrl, + language: 'zh-CN', + item: items, + }; + }, +}; diff --git a/lib/routes/ahm/namespace.ts b/lib/routes/ahm/namespace.ts new file mode 100644 index 000000000000..c2975fdf15a9 --- /dev/null +++ b/lib/routes/ahm/namespace.ts @@ -0,0 +1,10 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Anhui Museum', + url: 'www.ahm.cn', + + zh: { + name: '安徽博物院', + }, +}; diff --git a/lib/routes/ahm/xztj.tsx b/lib/routes/ahm/xztj.tsx new file mode 100644 index 000000000000..779edc0e7dfa --- /dev/null +++ b/lib/routes/ahm/xztj.tsx @@ -0,0 +1,147 @@ +import { load } from 'cheerio'; +import dayjs from 'dayjs'; +import { renderToString } from 'hono/jsx/dom/server'; + +import type { DataItem, Route } from '@/types'; +import { parseDate } from '@/utils/parse-date'; +import { getPlaywrightPage } from '@/utils/playwright'; + +import { namespace } from './namespace'; + +const baseUrl = 'https://www.ahm.cn'; + +const parseExhibitionDate = (dateStr?: string): string | undefined => (dateStr ? dayjs(dateStr).format('YYYY-MM-DD') : undefined); + +const parseExhibitionDuration = (durationText: string) => { + const [start, end] = durationText.split('-').map((s) => s.trim()); + return { + startDate: parseExhibitionDate(start), + endDate: parseExhibitionDate(end), + }; +}; + +export const route: Route = { + path: '/exhibition/xztj', + categories: ['travel'], + example: '/ahm/exhibition/xztj', + features: { + requireConfig: false, + requirePuppeteer: true, + antiCrawler: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['www.ahm.cn/Exhibition/TListNow/xztj'], + target: '/exhibition/xztj', + }, + ], + name: 'Special Exhibition', + maintainers: ['magazian'], + handler: async () => { + const museumName = namespace.zh?.name || namespace.name; + const listUrl = `${baseUrl}/Exhibition/TListNow/xztj`; + + // Anhui Museum website use CT2-WAAP to prevent web scraping, so need to use Playwright to get the page content. + const { page, destroy } = await getPlaywrightPage(listUrl, { + gotoConfig: { waitUntil: 'domcontentloaded' }, + }); + + let html: string; + try { + await page.waitForSelector('ul.exhibition-new li', { + timeout: 15000, + }); + html = await page.content(); + } finally { + await destroy(); + } + + const $ = load(html); + + const items: DataItem[] = $('ul.exhibition-new li') + .toArray() + .flatMap((el) => { + const $el = $(el); + + const onclickAttr = $el.find('a[onclick]').first().attr('onclick') ?? ''; + const onclickMatch = onclickAttr.match(/f_visitCount\('(\d+)','([^']+)','([^']+)'\)/); + + const exhibitionId = onclickMatch?.[1] ?? ''; + const link = onclickMatch?.[2] ?? ''; + + const title = $el.find('.cont h3').text(); + const imgUrl = $el.find('img').attr('src') ?? ''; + + const durationText = + $el + .find('p') + .toArray() + .map((p) => $(p).text()) + .find((t) => t.startsWith('展出时间:')) ?? ''; + + const locationText = + $el + .find('p') + .toArray() + .map((p) => $(p).text()) + .find((t) => t.startsWith('展出地点:')) ?? ''; + + const location = locationText.replace('展出地点:', ''); + const fullDuration = durationText.replace('展出时间:', ''); + + const { startDate, endDate } = parseExhibitionDuration(fullDuration); + + const pubDate = startDate ? parseDate(startDate) : undefined; + + const description = renderToString( +
+ +
+

+ 地点: + {location || '参考详情'} +

+

+ 开展: + {startDate || '未定/常设'} +

+

+ 闭展: + {endDate || '未定/常设'} +

+ {fullDuration && ( +

+ 原始展期:{fullDuration} +

+ )} +
+ ); + + const guid = `${baseUrl}/Exhibition/Details/xztj?nid=${exhibitionId}`; + + return { + title, + link, + guid, + pubDate, + description, + _extra: { + museumName, + location, + startDate, + endDate, + }, + }; + }); + + return { + title: `${museumName} - 新展推介`, + link: listUrl, + language: 'zh-CN', + item: items, + }; + }, +}; From 86b76cbeba0db9ee97f2bc5f9cea5ecdc881236b Mon Sep 17 00:00:00 2001 From: magazian <16831220+magazian@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:15:56 +0800 Subject: [PATCH 2/2] fix: skip unmatched exhibition in flatMap --- lib/routes/ahm/xztj.tsx | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/routes/ahm/xztj.tsx b/lib/routes/ahm/xztj.tsx index 779edc0e7dfa..b14be4266d80 100644 --- a/lib/routes/ahm/xztj.tsx +++ b/lib/routes/ahm/xztj.tsx @@ -68,9 +68,12 @@ export const route: Route = { const onclickAttr = $el.find('a[onclick]').first().attr('onclick') ?? ''; const onclickMatch = onclickAttr.match(/f_visitCount\('(\d+)','([^']+)','([^']+)'\)/); + if (!onclickMatch) { + return []; + } - const exhibitionId = onclickMatch?.[1] ?? ''; - const link = onclickMatch?.[2] ?? ''; + const exhibitionId = onclickMatch[1]; + const link = onclickMatch[2]; const title = $el.find('.cont h3').text(); const imgUrl = $el.find('img').attr('src') ?? ''; @@ -122,19 +125,21 @@ export const route: Route = { const guid = `${baseUrl}/Exhibition/Details/xztj?nid=${exhibitionId}`; - return { - title, - link, - guid, - pubDate, - description, - _extra: { - museumName, - location, - startDate, - endDate, + return [ + { + title, + link, + guid, + pubDate, + description, + _extra: { + museumName, + location, + startDate, + endDate, + }, }, - }; + ]; }); return {