From 48f7d4cf4cff36d35f1061c0c6174e06349934fa Mon Sep 17 00:00:00 2001 From: Jiamin <16831220+magazian@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:09:42 +0800 Subject: [PATCH] feat(route): add Hunan Museum exhibiton and news routes (#22430) * feat(route): add Hunan Museum exhibiton and news routes * fix: update news ts file name * fix: rm hnmnes.ts * fix: update hostname * fix: add ctx for special exhibitions * fix:use flatmap and remove redundant --- lib/routes/hnmuseum/exhibitions.tsx | 215 ++++++++++++++++++++++++++++ lib/routes/hnmuseum/hnmnews.ts | 59 ++++++++ lib/routes/hnmuseum/namespace.ts | 10 ++ 3 files changed, 284 insertions(+) create mode 100644 lib/routes/hnmuseum/exhibitions.tsx create mode 100644 lib/routes/hnmuseum/hnmnews.ts create mode 100644 lib/routes/hnmuseum/namespace.ts diff --git a/lib/routes/hnmuseum/exhibitions.tsx b/lib/routes/hnmuseum/exhibitions.tsx new file mode 100644 index 0000000000..c15a760898 --- /dev/null +++ b/lib/routes/hnmuseum/exhibitions.tsx @@ -0,0 +1,215 @@ +import { type Cheerio, load } from 'cheerio'; +import type { Element } from 'domhandler'; +import { renderToString } from 'hono/jsx/dom/server'; + +import type { DataItem, Route } from '@/types'; +import cache from '@/utils/cache'; +import got from '@/utils/got'; +import { parseDate } from '@/utils/parse-date'; + +import { namespace } from './namespace'; + +interface ExhibitionItem { + exhibitionType: 'permanent' | 'special' | 'temporary'; + title: string; + itemLink: string; + imgUrl: string; + location?: string; + fullDuration?: string; +} + +type ExhibitionConfig = { + selector: string; + type: 'permanent' | 'special' | 'temporary'; + extra: ($item: Cheerio) => Partial>; +}; + +// convert date string like "YYYY年M月D日" to "YYYY-MM-DD" +const formatStr = (dateStr: string | undefined): string | undefined => { + if (!dateStr) { + return undefined; + } + + const match = dateStr.trim().match(/(\d{4})年\s*(\d{1,2})月\s*(\d{1,2})日/); + + if (match) { + const year = match[1]; + const month = match[2].padStart(2, '0'); + const day = match[3].padStart(2, '0'); + return `${year}-${month}-${day}`; + } + return undefined; +}; + +const extractDates = (durationStr?: string): { startDate?: string; endDate?: string } => { + if (!durationStr || durationStr.includes('永久')) { + return { startDate: undefined, endDate: undefined }; + } + const parts = durationStr.split(/—/); + + return { startDate: formatStr(parts[0]), endDate: formatStr(parts[1]) }; +}; + +const renderDescription = (imgUrl: string, location?: string, startDate?: string, endDate?: string, fullDuration?: string) => + renderToString( +
+ {} +
+

+ 地点: + {location ?? '参考展览详情或图片'} +

+

+ 开展: + {startDate ?? '参考展览详情或图片'} +

+

+ 闭展: + {endDate ?? '参考展览详情或图片'} +

+ {fullDuration && ( +

+ 原始展期:{fullDuration} +

+ )} +
+ ); + +export const route: Route = { + path: '/current-exhibitions/:type?', + categories: ['travel'], + example: '/hnmuseum/current-exhibitions/special', + parameters: { + type: 'Exhibition type, supported values: special(临时展览 special+temporary). Default: All exhibitions.', + }, + name: 'Current Exhibitions', + maintainers: ['magazian'], + radar: [ + { + source: ['www.hnmuseum.com/zh-hans/content/当前展览-基本陈列'], + target: '/current-exhibitions', + }, + ], + + handler: async (ctx) => { + const typeParam = ctx.req.param('type'); + const isSpecial = typeParam === 'special'; + + const baseUrl = 'https://www.hnmuseum.com'; + const listUrl = `${baseUrl}/zh-hans/content/当前展览-基本陈列`; + const museumName = namespace.zh?.name || namespace.name; + + const response = await got({ + method: 'get', + url: listUrl, + }); + + const $ = load(response.data); + + const exhibitionConfigs: ExhibitionConfig[] = [ + { + selector: '#block-views-a784821b4fd9f41563c7164fd2a2f96e .views-row', + type: 'permanent' as const, + extra: ($item: Cheerio) => ({ + location: $item.find('.views_zhanting .field-content').text().trim(), + fullDuration: $item.find('.views_startdate .field-content').text().trim(), + }), + }, + { + selector: '#block-views-chen-lie-block .views-row', + type: 'special' as const, + extra: ($item: Cheerio) => ({ + location: $item.find('.views_zhanting .field-content').text().trim(), + }), + }, + { + selector: '#block-views-zhan-lan-block .views-row', + type: 'temporary' as const, + extra: (_$item: Cheerio) => ({}), + }, + ]; + + // use flatMap to avoid pushing into an external array, making the code cleaner and more functional + const list = exhibitionConfigs.flatMap((config) => + $(config.selector) + .toArray() + .map((item) => { + const $item = $(item); + const $a = $item.find('.views_title a, .views_img_bg a').first(); + const link = $a.attr('href') || ''; + + return { + exhibitionType: config.type, + title: $a.text(), + itemLink: link.startsWith('http') ? link : `${baseUrl}${link}`, + imgUrl: $item.find('img').attr('src') || '', + ...config.extra($item as Cheerio), + }; + }) + ); + + const targetList = isSpecial ? list.filter((item) => item.exhibitionType === 'special' || item.exhibitionType === 'temporary') : list; + + const items = await Promise.all( + targetList.map((item) => { + const cacheKey = item.itemLink; + + return cache.tryGet(cacheKey, async (): Promise => { + if (item.exhibitionType === 'permanent' || item.exhibitionType === 'special') { + const { startDate, endDate } = extractDates(item.fullDuration); + return { + title: item.title, + link: item.itemLink, + pubDate: startDate ? parseDate(startDate) : undefined, + description: renderDescription(item.imgUrl, item.location, startDate, endDate, item.fullDuration), + _extra: { museumName, location: item.location, startDate, endDate }, + }; + } + + const url = new URL(item.itemLink); + // for theme exhibition, the detail page may be a SPA, so need to fetch the JS file to extract the data + if (url.hostname === 'vrexhibition.hnmuseum.com') { + const htmlRes = await got({ method: 'get', url: item.itemLink }); + const $spa = load(htmlRes.data); + const jsSrc = $spa('script[type="module"][src]').attr('src') || ''; + const jsUrl = new URL(jsSrc, item.itemLink).href; + const jsRes = await got({ method: 'get', url: jsUrl }); + const jsContent = jsRes.data; + const titleMatch = jsContent.match(/"title"[^)]*\)\s*\},[^"]*"([^"]+)"/); + const title = titleMatch?.[1]; + const dateMatch = jsContent.match(/"(\d{4}年\d{1,2}月\d{1,2}日—\d{4}年\d{1,2}月\d{1,2}日)"/); + const fullDuration = dateMatch?.[1]; + const locMatch = jsContent.match(/"(湖南省博物馆[^"]+厅)"/); + const location = locMatch?.[1]; + const { startDate, endDate } = extractDates(fullDuration); + + return { + title, + link: item.itemLink, + pubDate: startDate ? parseDate(startDate) : undefined, + description: renderDescription(item.imgUrl, location, startDate, endDate, fullDuration), + _extra: { museumName, location, startDate, endDate }, + }; + } + + const detailResponse = await got({ method: 'get', url: item.itemLink }); + const content = load(detailResponse.data); + const title = content('h1#page-title').text(); + return { + title, + link: item.itemLink, + description: renderDescription(item.imgUrl), + _extra: { museumName }, + }; + }) as Promise; + }) + ); + + return { + title: `${museumName} - 当前展览${isSpecial ? ' - 专题&临时展览' : ''}`, + link: listUrl, + language: 'zh-CN', + item: items.filter((item) => item.title && Object.keys(item).length > 0) as DataItem[], + }; + }, +}; diff --git a/lib/routes/hnmuseum/hnmnews.ts b/lib/routes/hnmuseum/hnmnews.ts new file mode 100644 index 0000000000..dd41cdbf99 --- /dev/null +++ b/lib/routes/hnmuseum/hnmnews.ts @@ -0,0 +1,59 @@ +import { load } from 'cheerio'; + +import type { DataItem, Route } from '@/types'; +import got from '@/utils/got'; +import { parseDate } from '@/utils/parse-date'; +import timezone from '@/utils/timezone'; + +import { namespace } from './namespace'; + +export const route: Route = { + path: '/hnmnews', + categories: ['travel'], + example: '/hnmuseum/hnmnews', + name: 'HNM News', + maintainers: ['magazian'], + radar: [ + { + source: ['www.hnmuseum.com/zh-hans/xiangbo_dongtai_news'], + target: '/hnmnews', + }, + ], + + handler: async () => { + const baseUrl = 'https://www.hnmuseum.com'; + const apiUrl = `${baseUrl}/zh-hans/xiangbo_dongtai_news`; + const museumName = namespace.zh?.name || namespace.name; + + const response = await got({ + method: 'get', + url: apiUrl, + }); + + const $ = load(response.data); + + const items = $('.view-content .views-row') + .toArray() + .map((el) => { + const $item = $(el); + const $a = $item.find('.views-field-title-1 a'); + const title = $a.text(); + const href = $a.attr('href'); + const link = new URL(href!, baseUrl).href; + const dateString = $item.find('.date-display-single').attr('content'); + + return { + title, + link, + pubDate: timezone(parseDate(dateString!), 8), + }; + }); + + return { + title: `${museumName} - 湘博动态`, + link: apiUrl, + language: 'zh-CN', + item: items as DataItem[], + }; + }, +}; diff --git a/lib/routes/hnmuseum/namespace.ts b/lib/routes/hnmuseum/namespace.ts new file mode 100644 index 0000000000..894154b0c1 --- /dev/null +++ b/lib/routes/hnmuseum/namespace.ts @@ -0,0 +1,10 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Hunan Museum', + url: 'www.hnmuseum.com/zh-hans', + + zh: { + name: '湖南省博物馆', + }, +};