diff --git a/lib/routes/capitalmuseum/exhibition.tsx b/lib/routes/capitalmuseum/exhibition.tsx new file mode 100644 index 0000000000..b540ab5c9b --- /dev/null +++ b/lib/routes/capitalmuseum/exhibition.tsx @@ -0,0 +1,170 @@ +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 timezone from '@/utils/timezone'; + +import { namespace } from './namespace'; + +// parse exhibition date string like "2024年5月1日 - 2024年6月30日" or "2024年5月1日-" or "2024年5月1日 - 6月30日" +const parseExhibitionDates = (fullDuration: string | undefined) => { + let startDate: string | undefined; + let endDate: string | undefined; + + if (fullDuration) { + // Regex to capture start and optional end dates. + const dateRegex = /(\d{4})年(\d{1,2})月(\d{1,2})日(?:\D+(?:(\d{4})年)?(\d{1,2})月(\d{1,2})日)?/; + const match = fullDuration.match(dateRegex); + + if (match) { + const startYear = match[1]; + startDate = `${startYear}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`; + + if (match[5] && match[6]) { + const endYear = match[4] || startYear; // If end year is not present, use start year + endDate = `${endYear}-${match[5].padStart(2, '0')}-${match[6].padStart(2, '0')}`; + } + } + } + + return { startDate, endDate }; +}; + +export const route: Route = { + path: '/exhibition/:type?', + categories: ['travel'], + example: '/capitalmuseum/exhibition', + parameters: { + type: 'Exhibition type, supported values: new(最新展览), review(展览回顾), default: All exhibitions.', + }, + name: 'Exhibitions', + maintainers: ['magazian'], + radar: [ + { + source: ['www.capitalmuseum.org.cn/exhibition'], + target: '/exhibition', + }, + ], + + handler: async (ctx) => { + const typeParam = ctx.req.param('type') || 'all'; + + const typeMap: Record = { + new: '最新展览', + review: '展览回顾', + }; + + const baseUrl = 'https://www.capitalmuseum.org.cn'; + const apiUrl = `${baseUrl}/exhibition`; + const museumName = namespace.zh?.name || namespace.name; + + const response = await got({ + method: 'get', + url: apiUrl, + }); + + const $ = load(response.data); + const nuxtDataStr = $('#__NUXT_DATA__').text() || ''; // use __NUXT_DATA__ to get the data structure of the page + const nuxtData = JSON.parse(nuxtDataStr); + const targetType = typeMap[typeParam]; + + const exhibitionList = nuxtData.filter((item: any) => { + if (typeof item === 'object' && 'eid' in item) { + const itemType = nuxtData[item.cid]; + // when typeParam is 'all', include all items; otherwise, filter by the specific type + return typeParam === 'all' ? Object.values(typeMap).includes(itemType) : itemType === targetType; + } + return false; + }); + + interface ExhibitionListItem { + title: string; + itemlink: string; + imgUrl: string; + } + + const listItems: ExhibitionListItem[] = exhibitionList.map((item: any) => { + const eid = nuxtData[item.eid]; + const title = nuxtData[item.title]; + const link = `${apiUrl}/${eid}`; + const imgUrl = nuxtData[item.titileurl]; + + return { + title, + itemlink: link, + imgUrl, + }; + }); + + // get location and fullDuration from the detail page of each exhibition + const items: DataItem[] = await Promise.all( + listItems.map((item: ExhibitionListItem) => + cache.tryGet(item.itemlink, async () => { + const detailResponse = await got({ + method: 'get', + url: item.itemlink, + }); + + const detail$ = load(detailResponse.data); + const detailNuxtDataStr = detail$('#__NUXT_DATA__').text() || ''; + const detailNuxtData = JSON.parse(detailNuxtDataStr); + + const detailObj = detailNuxtData.find((obj: any) => typeof obj === 'object' && 'address' in obj); + + const location = detailNuxtData[detailObj.address]; + const fullDuration = detailNuxtData[detailObj.open_time]; + const { startDate, endDate } = parseExhibitionDates(fullDuration); + const pubDate = startDate ? timezone(parseDate(startDate, 'YYYY-MM-DD'), 8) : undefined; + + const description = renderToString( +
+ +
+

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

+

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

+

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

+ {fullDuration && ( +

+ 原始展期:{fullDuration} +

+ )} +
+ ); + + return { + title: item.title, + link: item.itemlink, + pubDate, + description, + // For further .ics file processing + _extra: { + museumName, + location, + startDate, + endDate, + }, + } as DataItem; + }) + ) + ); + + return { + title: `${museumName} - 展览陈列${targetType ? ` - ${targetType}` : ''}`, + link: apiUrl, + language: 'zh-CN', + item: items as DataItem[], + }; + }, +}; diff --git a/lib/routes/capitalmuseum/namespace.ts b/lib/routes/capitalmuseum/namespace.ts new file mode 100644 index 0000000000..2b705a4421 --- /dev/null +++ b/lib/routes/capitalmuseum/namespace.ts @@ -0,0 +1,10 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'Capital Museum', + url: 'www.capitalmuseum.org.cn/', + + zh: { + name: '首都博物馆', + }, +}; diff --git a/lib/routes/capitalmuseum/news.ts b/lib/routes/capitalmuseum/news.ts new file mode 100644 index 0000000000..c78af5622e --- /dev/null +++ b/lib/routes/capitalmuseum/news.ts @@ -0,0 +1,96 @@ +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: '/news/:type?', + categories: ['travel'], + example: '/capitalmuseum/news/notice', + parameters: { + type: 'News type, supported values: news(新闻资讯), notice(通知公告). Default: All news.', + }, + name: 'News', + maintainers: ['magazian'], + radar: [ + { + source: ['www.capitalmuseum.org.cn/news'], + target: '/news', + }, + ], + + handler: async (ctx) => { + const typeParam = ctx.req.param('type') || 'all'; + + const typeMap: Record = { + notice: '通知公告', + news: '新闻资讯', + }; + + const targetType = typeMap[typeParam]; + + const baseUrl = 'https://www.capitalmuseum.org.cn'; + const apiUrl = `${baseUrl}/news`; + const museumName = namespace.zh?.name || namespace.name; + + const response = await got({ + method: 'get', + url: apiUrl, + }); + + const $ = load(response.data); + const nuxtDataStr = $('#__NUXT_DATA__').text() || '{}'; // use __NUXT_DATA__ to get the data structure of the page + const nuxtData = JSON.parse(nuxtDataStr); + // get all the links of the news items by section name + const getLinksBySection = (sectionName: string) => { + // get the span element that contains the section name + const $titleSpan = $(`span:contains("${sectionName}")`); + + // for example, div class="oz09n6" is the container of the news items, which is the next sibling of the span element + const $container = $titleSpan.parent().nextAll('div').first(); + + // get all the links of the news items in the container + return $container + .find('a[href^="/news/"]') + .toArray() + .map((el) => $(el).attr('href') as string); + }; + + let targetHrefs: string[] = []; + + if (targetType) { + targetHrefs = getLinksBySection(targetType); + } else if (typeParam === 'all') { + targetHrefs = Object.values(typeMap).flatMap((sectionName) => getLinksBySection(sectionName)); + } + + // match the newsId in the href with the newsId in the nuxtData to get the title and publishtime + const items = targetHrefs.map((href) => { + const newsId = href.split('/').pop(); + const link = `${baseUrl}${href}`; + + const newsObj = nuxtData.find((item: any) => nuxtData[item.newsid] === newsId); + + const pubTime = nuxtData[newsObj.publishtime]; + const pubDate = timezone(parseDate(pubTime, 'YYYY-MM-DD'), 8); + const title = nuxtData[newsObj.title]; + + return { + title, + link, + pubDate, + } as DataItem; + }); + + return { + title: `${museumName} - 首博快讯${targetType ? ` - ${targetType}` : ''}`, + link: apiUrl, + language: 'zh-CN', + item: items, + }; + }, +};