feat(route): add Shan Dong Museum exhibition and news route (#22187)

* feat(route): add Shan Dong Museum exhibition and news route

* fix: replaceAll to replace

* fix: replace for fullDurationDate

* fix: change xmlmode to xml

* fix: oxlint warning

---------

Co-authored-by: Bian Jiamin <jiamin@BiandeMac-mini.local>
This commit is contained in:
Jiamin 2026-06-12 12:20:38 +08:00 committed by GitHub
parent cdf95e67fe
commit d3086acfe0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 235 additions and 0 deletions

View File

@ -0,0 +1,162 @@
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());
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: '/exhibitions',
categories: ['travel'],
example: '/sdmuseum/exhibitions',
parameters: {},
name: 'Temporary Exhibitions',
maintainers: ['magazian'],
radar: [
{
source: ['www.sdmuseum.com/col/col271702/index.html'],
target: '/exhibitions',
},
],
handler: async () => {
const baseUrl = 'https://www.sdmuseum.com';
const apiUrl = `${baseUrl}/col/col271702/index.html`;
const museumName = namespace.zh?.name || namespace.name;
const response = await got({
method: 'get',
url: apiUrl,
});
const $ = load(response.data, {
xml: true, // use xmlMode to preserve CDATA sections inside <script>
});
const items = await Promise.all(
$('record')
.toArray()
.map((el) => {
const itemHtml = $(el).text();
const $item = load(itemHtml);
const title = $item('.ltit a').attr('title');
const link = new URL($item('.ltit a').attr('href')!, baseUrl).href;
const imgUrlRaw = $item('.img img').attr('src') || '';
const imgUrl = new URL(imgUrlRaw, baseUrl).href;
const location = $item('.item.add').text().trim();
const fullDuration = $item('.item.time').text().trim();
const fullDurationDate = fullDuration.replace(/开展时间\s*[:]\s*/, '').trim(); // use regex to remove "开展时间" prefix if it exists, so replace is used here.
const { startDate, endDate } = extractDates(fullDurationDate);
// get pubDate from the detail page
return cache.tryGet(link, async () => {
const detailResponse = await got({
method: 'get',
url: link,
});
const $detail = load(detailResponse.data);
const pubDateStr = $detail('meta[name="PubDate"]').attr('content') || '';
const pubDate = parseDate(pubDateStr, 'YYYY-MM-DD HH:mm');
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,
pubDate,
description,
_extra: {
museumName,
title,
location,
startDate,
endDate,
itemLink: link,
},
};
});
})
);
return {
title: `${museumName} - 临时展览`,
link: apiUrl,
language: 'zh-CN',
item: items as DataItem[],
};
},
};

View File

@ -0,0 +1,10 @@
import type { Namespace } from '@/types';
export const namespace: Namespace = {
name: 'Shan Dong Museum',
url: 'www.sdmuseum.com/',
zh: {
name: '山东博物馆',
},
};

View File

@ -0,0 +1,63 @@
import { load } from 'cheerio';
import type { DataItem, Route } from '@/types';
import got from '@/utils/got';
import { parseDate } from '@/utils/parse-date';
import { namespace } from './namespace';
export const route: Route = {
path: '/news',
categories: ['travel'],
example: '/sdmuseum/news',
parameters: {},
name: 'News',
maintainers: ['magazian'],
radar: [
{
source: ['www.sdmuseum.com/col/col270324/index.html'],
target: '/news',
},
],
handler: async () => {
const baseUrl = 'https://www.sdmuseum.com';
const apiUrl = `${baseUrl}/col/col270324/index.html`;
const museumName = namespace.zh?.name || namespace.name;
const response = await got({
method: 'get',
url: apiUrl,
});
const $ = load(response.data, {
xml: true,
});
const items = $('record')
.toArray()
.map((el) => {
const itemHtml = $(el).text();
const $item = load(itemHtml);
const $a = $item('.ltit a');
const title = $a.attr('title');
const link = new URL($a.attr('href')!, baseUrl).href;
// date format 2026.06.03
const dateStr = $item('.data.av').text().trim();
return {
title,
link,
pubDate: parseDate(dateStr),
};
});
return {
title: `${museumName} - 公告`,
link: apiUrl,
language: 'zh-CN',
item: items as DataItem[],
};
},
};