feat(route): add Henan Museum exhibition route (#22138)

* feat(route): add Henan Museum exhibition route

* fix: remove trim for title
This commit is contained in:
Jiamin 2026-06-04 06:03:31 +08:00 committed by GitHub
parent 2fa4fd6d58
commit 7a8c5c6c62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 220 additions and 0 deletions

View File

@ -0,0 +1,210 @@
import { load } from 'cheerio';
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';
// format the date to YYYY-MM-DD and handle missing year or month
const extractDates = (durationStr: string) => {
let startDate: string | undefined;
let endDate: string | undefined;
if (!durationStr) {
return { startDate, endDate };
}
const parts = durationStr.split(/——|-|—|~/).map((p) => p.trim()); // currently ——and- is used, add — or ~ for redundency
const startStr = parts[0];
const endStr = parts[1];
let startYear: string | undefined;
let startMonth: string | undefined;
const startRegex = /(\d{4})年(\d{1,2})月(\d{1,2})日/;
const startMatch = startStr.match(startRegex);
if (startMatch) {
startYear = startMatch[1];
startMonth = startMatch[2].padStart(2, '0');
const startDay = startMatch[3].padStart(2, '0');
startDate = `${startYear}-${startMonth}-${startDay}`;
}
if (endStr && startDate) {
const endRegex = /(?:(\d{4})年)?(?:(\d{1,2})月)?(\d{1,2})日/;
const endMatch = endStr.match(endRegex);
if (endMatch) {
const matchYear = endMatch[1];
const matchMonth = endMatch[2]?.padStart(2, '0');
const matchDay = endMatch[3].padStart(2, '0');
const finalEndYear = matchYear || startYear;
const finalEndMonth = matchMonth || startMonth;
const finalEndDay = matchDay;
endDate = `${finalEndYear}-${finalEndMonth}-${finalEndDay}`;
}
}
return { startDate, endDate };
};
export const route: Route = {
path: '/information/exhibition/:type?',
categories: ['travel'],
example: '/chnmus/information/exhibition/special',
parameters: {
type: 'Exhibition type, supported values: special特展详情. Default: All.',
},
name: 'Special Exhibitions',
maintainers: ['magazian'],
radar: [
{
source: ['www.chnmus.net/ch/information/exhibition/index.html'],
target: '/information/exhibition',
},
],
handler: async (ctx) => {
const type = ctx.req.param('type');
const isSpecial = type === 'special';
const baseUrl = 'https://www.chnmus.net';
const apiUrl = `${baseUrl}/ch/information/exhibition/index.html`;
const museumName = namespace.zh?.name || namespace.name;
const response = await got({
method: 'get',
url: apiUrl,
});
const $ = load(response.data);
const list = $('.col-md-6.d-flex.fadeInBottom')
.toArray()
.map((item) => {
const $item = $(item);
const link = $item.attr('href');
const imgUrlRaw = $item.find('.lazyload').attr('data-bg');
const listTitle = $item.find('.common-component-box-title').text();
return {
title: listTitle,
itemLink: `https:${link}`,
imgUrl: `https:${imgUrlRaw}`,
};
});
const items = await Promise.all(
list.map((item) => {
// use seperate cache key for special path
const cacheKey = isSpecial ? `${item.itemLink}-special` : item.itemLink;
return cache.tryGet(cacheKey, async (): Promise<Record<string, any>> => {
const detailResponse = await got({
method: 'get',
url: item.itemLink,
});
const content = load(detailResponse.data);
const pubDateRaw = content('.common-component-content-attribute-item')
.toArray()
.map((el) => content(el).text())
.find((text) => text.includes('发布日期'))!
.replaceAll('发布日期:', '')
.trim();
const pubDate = parseDate(pubDateRaw);
// Default path: return as news, no detail information for return
if (!isSpecial) {
return {
title: item.title,
link: item.itemLink,
pubDate,
description: renderToString(
<div>
<img src={item.imgUrl} />
</div>
),
} as Record<string, any>;
}
// Special path to return detail exhibition information
const texts = content('.common-component-content-text p')
.toArray()
.map((el) => content(el).text());
const title = texts.find((text) => text.includes('展览名称:'))?.replaceAll('展览名称:', '');
// filter out items without title, for example: https://www.chnmus.net/ch/information/exhibition/details.html?id=7400076083917230080#list
if (!title) {
return {} as Record<string, any>;
}
const location = texts
.find((text) => text.includes('展览地点:'))!
.replaceAll('展览地点:', '')
.trim();
const fullDuration = texts
.find((text) => text.includes('展览时间:') || text.includes('开展时间:'))
?.replaceAll(/(展览|开展)时间:/g, '')
?.trim();
const { startDate, endDate } = extractDates(fullDuration || '');
const { imgUrl, itemLink } = item;
const description = renderToString(
<div>
<img src={imgUrl} />
<br />
<p>
<b></b>
{location}
</p>
<p>
<b></b>
{startDate ?? '未定/常设'}
</p>
<p>
<b></b>
{endDate ?? '未定/常设'}
</p>
{fullDuration && (
<p>
<small>{fullDuration}</small>
</p>
)}
</div>
);
return {
title,
link: itemLink,
pubDate,
description,
_extra: {
museumName,
title,
location,
startDate,
endDate,
itemLink,
},
} as Record<string, any>;
}) as Promise<DataItem>;
})
);
return {
title: `${museumName} - 展览资讯${isSpecial ? ' - 特展详情' : ''}`,
link: apiUrl,
language: 'zh-CN',
item: items.filter((item) => item.title) as DataItem[],
};
},
};

View File

@ -0,0 +1,10 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'Henan Museum',
url: 'www.chnmus.net',
zh: {
name: '河南博物院',
},
};