Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions lib/routes/zaihua/index.ts
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,

Copy link
Copy Markdown
Collaborator

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?

});
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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide the site URL which has multiple .msg-prose elements that requires using first() to pick proper element from the matched elements.


cleanupContent($, content);

const imageUrls = extractImageUrls($);
const videos = extractVideos($);
Comment on lines +86 to +87

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide the site URL which has multiple h1 elements within article that requires using first() to pick proper element from the matched elements.


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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide the site URL which has these elements within content that can be removed.


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] : []));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide the site URL which has the same image URL in [data-lightbox-src] more than once.

};

const extractVideos = ($: CheerioAPI): Video[] => {
const videos = $('video[data-feed-video], video[src]')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.toArray()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please provide the site URL which has multiple video elements that requires using 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'),
},
];
});
Comment on lines +147 to +163

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
7 changes: 7 additions & 0 deletions lib/routes/zaihua/namespace.ts
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',
};