-
Notifications
You must be signed in to change notification settings - Fork 10k
feat: add zaihua news route #22332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
feat: add zaihua news route #22332
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,236 @@ | ||
| 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<string, unknown> & { | ||
| headline?: string; | ||
| datePublished?: string; | ||
| dateModified?: string; | ||
| }; | ||
|
|
||
| type Video = { | ||
| src: string; | ||
| poster?: string; | ||
| width?: string; | ||
| height?: 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<DataItem> => { | ||
| const response = await ofetch(item.link); | ||
| const $ = load(response); | ||
| const jsonLd = extractNewsArticleJsonLd($); | ||
| const content = $('.msg-prose').first(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has multiple |
||
|
|
||
| cleanupContent($, content); | ||
|
|
||
| const imageUrls = extractImageUrls($); | ||
| const videos = extractVideos($); | ||
|
Comment on lines
+86
to
+87
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why waste precious CPU cycles extracting attributes from the HTML and rendering it again, instead of reusing the existing media elements? None of those media elements contain placeholder links. |
||
| const contentHtml = content.html() ?? item.content ?? item.contentSnippet; | ||
| const imageHtml = renderImages(imageUrls); | ||
| const videoHtml = renderVideos(videos); | ||
|
Comment on lines
+89
to
+90
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why waste precious CPU cycles extracting attributes from the HTML and rendering it again, instead of reusing the existing media elements? None of those media elements contain placeholder links. |
||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has multiple |
||
|
|
||
| return { | ||
| title, | ||
| link: item.link, | ||
| description: [contentHtml, imageHtml, videoHtml].filter(Boolean).join(''), | ||
| pubDate: pubDate ? parseDate(pubDate) : undefined, | ||
| updated: updated ? parseDate(updated) : undefined, | ||
| author, | ||
| }; | ||
| }); | ||
|
|
||
| const cleanupContent = ($: CheerioAPI, content: Cheerio<Element>) => { | ||
| content.find('script, style').remove(); | ||
| content.find('[class]').removeAttr('class'); | ||
| content.find('[style]').removeAttr('style'); | ||
| content.find('[data-astro-cid]').removeAttr('data-astro-cid'); | ||
|
Comment on lines
+106
to
+109
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has these elements within |
||
|
|
||
| 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] : [])); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unnecessary flatMap. Do not intentionally wrap the returned result in array and flatten it. |
||
|
|
||
| return [...new Set(galleryImages)]; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has the same image URL in |
||
| }; | ||
|
|
||
| const extractVideos = ($: CheerioAPI): Video[] => { | ||
| const videos = $('video[data-feed-video], video[src]') | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| .toArray() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has multiple |
||
| .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'), | ||
| }, | ||
| ]; | ||
| }); | ||
|
Comment on lines
+147
to
+163
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unnecessary flatMap. Do not intentionally wrap the returned result in array and flatten it. |
||
|
|
||
| const seen = new Set<string>(); | ||
|
|
||
| return videos.filter((video) => { | ||
| if (seen.has(video.src)) { | ||
| return false; | ||
| } | ||
|
|
||
| seen.add(video.src); | ||
|
|
||
| return true; | ||
| }); | ||
|
Comment on lines
+165
to
+175
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has the same video URL more than once. |
||
| }; | ||
|
|
||
| const renderImages = (imageUrls: string[]) => imageUrls.map((src) => `<p><img src="${escapeAttribute(src)}"></p>`).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 `<p><video ${attrs}></video></p>`; | ||
| }) | ||
| .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() | ||
|
Comment on lines
+203
to
+204
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make a proper use of :contains instead of looping every matched elements. |
||
| .flatMap((element) => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unnecessary flatMap. Do not intentionally wrap the returned result in array and flatten it. |
||
| try { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please provide the site URL which has malformed JSON-LD that causes JSON.parse to throw an exception. |
||
| 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<string, unknown> => typeof value === 'object' && value !== null; | ||
|
|
||
| const escapeAttribute = (value: string) => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import type { Namespace } from '@/types'; | ||
|
|
||
| export const namespace: Namespace = { | ||
| name: '在花', | ||
| url: 'www.zaihua.news', | ||
| lang: 'zh-CN', | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why specify the default option again? Does the route break if you don't specify it?