From e138f557bd495f1de54e93d957b49c7c96d901eb Mon Sep 17 00:00:00 2001 From: Circloud Date: Tue, 23 Jun 2026 15:51:04 +0800 Subject: [PATCH 1/2] feat(route/zaihua): add zaihua route --- lib/routes/zaihua/index.ts | 171 +++++++++++++++++++++++++++++++++ lib/routes/zaihua/namespace.ts | 7 ++ 2 files changed, 178 insertions(+) create mode 100644 lib/routes/zaihua/index.ts create mode 100644 lib/routes/zaihua/namespace.ts diff --git a/lib/routes/zaihua/index.ts b/lib/routes/zaihua/index.ts new file mode 100644 index 000000000000..648593b841cb --- /dev/null +++ b/lib/routes/zaihua/index.ts @@ -0,0 +1,171 @@ +import type { Cheerio, CheerioAPI } from 'cheerio'; +import { load } from 'cheerio'; +import type { Element } from 'domhandler'; +import pMap from 'p-map'; +import type { Item } from 'rss-parser'; + +import type { DataItem, Route } from '@/types'; +import { ViewType } from '@/types'; +import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; +import parser from '@/utils/rss-parser'; + +const rootUrl = 'https://www.zaihua.news'; +const feedUrl = `${rootUrl}/rss.xml`; +const author = '在花'; + +type NewsArticleJsonLd = Record & { + headline?: string; + datePublished?: string; + dateModified?: string; +}; + +export const route: Route = { + path: '/', + categories: ['new-media'], + example: '/zaihua', + parameters: {}, + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportRadar: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['www.zaihua.news/', 'www.zaihua.news/rss.xml'], + target: '', + }, + ], + name: '最新', + maintainers: ['Circloud'], + handler, + url: 'www.zaihua.news', + description: '在花新闻的全文内容,包含文章图片。', + view: ViewType.Articles, +}; + +async function handler() { + const feedResponse = await ofetch(feedUrl, { + parseResponse: (text) => text, + }); + const feed = await parser.parseString(feedResponse); + const list = feed.items.filter((item): item is Item & { link: string } => Boolean(item.link)); + const items = await pMap(list, (item) => fetchArticle(item), { concurrency: 3 }); + + return { + title: feed.title ?? author, + link: rootUrl, + feedLink: feedUrl, + description: feed.description, + item: items, + language: 'zh-CN', + }; +} + +const fetchArticle = (item: Item & { link: string }) => + cache.tryGet(item.link, async (): Promise => { + const response = await ofetch(item.link); + const $ = load(response); + const jsonLd = extractNewsArticleJsonLd($); + const content = $('.msg-prose').first(); + + cleanupContent($, content); + + const imageUrls = extractImageUrls($); + const contentHtml = content.html() ?? item.content ?? item.contentSnippet; + const imageHtml = renderImages(imageUrls); + const pubDate = $('meta[property="article:published_time"]').attr('content') ?? jsonLd?.datePublished ?? item.isoDate ?? item.pubDate; + const updated = $('meta[property="article:modified_time"]').attr('content') ?? jsonLd?.dateModified; + const title = jsonLd?.headline || $('article h1').first().text() || item.title || item.link; + + return { + title, + link: item.link, + description: [contentHtml, imageHtml].filter(Boolean).join(''), + pubDate: pubDate ? parseDate(pubDate) : undefined, + updated: updated ? parseDate(updated) : undefined, + author, + }; + }); + +const cleanupContent = ($: CheerioAPI, content: Cheerio) => { + content.find('script, style').remove(); + content.find('[class]').removeAttr('class'); + content.find('[style]').removeAttr('style'); + content.find('[data-astro-cid]').removeAttr('data-astro-cid'); + + content.children('p').each((_, element) => { + const paragraph = $(element); + const links = new Set( + paragraph + .find('a') + .toArray() + .map((link) => $(link).attr('href')) + ); + + if (links.has('http://t.me/ZaiHuaPd') && links.has('https://t.me/zaihua_news') && links.has('http://t.me/ZaiHuabot')) { + paragraph.remove(); + } + }); + + content.find('a').each((_, element) => { + const link = $(element); + const href = link.attr('href'); + + if (href?.startsWith('/')) { + link.attr('href', new URL(href, rootUrl).href); + } + }); +}; + +const extractImageUrls = ($: CheerioAPI) => { + const galleryImages = $('[data-lightbox-src]') + .toArray() + .map((element) => $(element).attr('data-lightbox-src') ?? $(element).find('img').attr('src')) + .flatMap((src) => (src ? [src] : [])); + + return [...new Set(galleryImages)]; +}; + +const renderImages = (imageUrls: string[]) => imageUrls.map((src) => `

`).join(''); + +const extractNewsArticleJsonLd = ($: CheerioAPI) => + $('script[type="application/ld+json"]') + .toArray() + .flatMap((element) => { + try { + return collectJsonLdObjects(JSON.parse($(element).text())); + } catch { + return []; + } + }) + .find((item) => isNewsArticle(item)); + +const collectJsonLdObjects = (value: unknown): NewsArticleJsonLd[] => { + if (Array.isArray(value)) { + return value.flatMap((item) => collectJsonLdObjects(item)); + } + + if (!isRecord(value)) { + return []; + } + + const graph = Array.isArray(value['@graph']) ? value['@graph'].flatMap((item) => collectJsonLdObjects(item)) : []; + + return [value, ...graph] as NewsArticleJsonLd[]; +}; + +const isNewsArticle = (value: NewsArticleJsonLd) => { + const type = value['@type']; + + return type === 'NewsArticle' || (Array.isArray(type) && type.includes('NewsArticle')); +}; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const escapeAttribute = (value: string) => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); diff --git a/lib/routes/zaihua/namespace.ts b/lib/routes/zaihua/namespace.ts new file mode 100644 index 000000000000..cd4957669f9b --- /dev/null +++ b/lib/routes/zaihua/namespace.ts @@ -0,0 +1,7 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: '在花', + url: 'www.zaihua.news', + lang: 'zh-CN', +}; From c2eaea2ab651ebc41a864ca9346d57d13392d7ba Mon Sep 17 00:00:00 2001 From: Circloud Date: Tue, 23 Jun 2026 16:50:35 +0800 Subject: [PATCH 2/2] feat(route/zaihua): support article videos --- lib/routes/zaihua/index.ts | 69 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/lib/routes/zaihua/index.ts b/lib/routes/zaihua/index.ts index 648593b841cb..473528400dc0 100644 --- a/lib/routes/zaihua/index.ts +++ b/lib/routes/zaihua/index.ts @@ -21,6 +21,13 @@ type NewsArticleJsonLd = Record & { dateModified?: string; }; +type Video = { + src: string; + poster?: string; + width?: string; + height?: string; +}; + export const route: Route = { path: '/', categories: ['new-media'], @@ -45,7 +52,7 @@ export const route: Route = { maintainers: ['Circloud'], handler, url: 'www.zaihua.news', - description: '在花新闻的全文内容,包含文章图片。', + description: '在花新闻的全文内容,包含文章图片和视频。', view: ViewType.Articles, }; @@ -77,8 +84,10 @@ const fetchArticle = (item: Item & { link: string }) => cleanupContent($, content); const imageUrls = extractImageUrls($); + const videos = extractVideos($); const contentHtml = content.html() ?? item.content ?? item.contentSnippet; const imageHtml = renderImages(imageUrls); + const videoHtml = renderVideos(videos); const pubDate = $('meta[property="article:published_time"]').attr('content') ?? jsonLd?.datePublished ?? item.isoDate ?? item.pubDate; const updated = $('meta[property="article:modified_time"]').attr('content') ?? jsonLd?.dateModified; const title = jsonLd?.headline || $('article h1').first().text() || item.title || item.link; @@ -86,7 +95,7 @@ const fetchArticle = (item: Item & { link: string }) => return { title, link: item.link, - description: [contentHtml, imageHtml].filter(Boolean).join(''), + description: [contentHtml, imageHtml, videoHtml].filter(Boolean).join(''), pubDate: pubDate ? parseDate(pubDate) : undefined, updated: updated ? parseDate(updated) : undefined, author, @@ -132,8 +141,64 @@ const extractImageUrls = ($: CheerioAPI) => { return [...new Set(galleryImages)]; }; +const extractVideos = ($: CheerioAPI): Video[] => { + const videos = $('video[data-feed-video], video[src]') + .toArray() + .flatMap((element) => { + const video = $(element); + const src = video.attr('src'); + + if (!src) { + return []; + } + + return [ + { + src: normalizeUrl(src), + poster: normalizeOptionalUrl(video.attr('poster')), + width: video.attr('width'), + height: video.attr('height'), + }, + ]; + }); + + const seen = new Set(); + + return videos.filter((video) => { + if (seen.has(video.src)) { + return false; + } + + seen.add(video.src); + + return true; + }); +}; + const renderImages = (imageUrls: string[]) => imageUrls.map((src) => `

`).join(''); +const renderVideos = (videos: Video[]) => + videos + .map((video) => { + const attrs = [ + `src="${escapeAttribute(video.src)}"`, + video.poster ? `poster="${escapeAttribute(video.poster)}"` : '', + video.width ? `width="${escapeAttribute(video.width)}"` : '', + video.height ? `height="${escapeAttribute(video.height)}"` : '', + 'controls', + 'preload="metadata"', + ] + .filter(Boolean) + .join(' '); + + return `

`; + }) + .join(''); + +const normalizeUrl = (url: string) => (url.startsWith('/') ? new URL(url, rootUrl).href : url); + +const normalizeOptionalUrl = (url?: string) => (url ? normalizeUrl(url) : undefined); + const extractNewsArticleJsonLd = ($: CheerioAPI) => $('script[type="application/ld+json"]') .toArray()