From 84d8a388adde1a36ed600c65e9f87a4599d0b101 Mon Sep 17 00:00:00 2001 From: Ethan Shen <42264778+nczitzk@users.noreply.github.com> Date: Fri, 8 Aug 2025 22:51:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(route):=20add=20TiDB=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E4=B8=93=E6=A0=8F=E5=88=86=E7=B1=BB=20(#19715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(route): add TiDB社区专栏分类 * fix typo * fix typo * fix typo --- lib/routes/tidb/blog.ts | 314 +++++++++++++++++++++++++++++++++++ lib/routes/tidb/namespace.ts | 9 + 2 files changed, 323 insertions(+) create mode 100644 lib/routes/tidb/blog.ts create mode 100644 lib/routes/tidb/namespace.ts diff --git a/lib/routes/tidb/blog.ts b/lib/routes/tidb/blog.ts new file mode 100644 index 000000000..09a17f79c --- /dev/null +++ b/lib/routes/tidb/blog.ts @@ -0,0 +1,314 @@ +import { type Data, type DataItem, type Route, ViewType } from '@/types'; + +import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +import { type CheerioAPI, load } from 'cheerio'; +import { type Context } from 'hono'; + +const escapeHtml = (text: string): string => text?.replace(/&/g, '&')?.replace(//g, '>')?.replace(/'/g, '"')?.replace(/'/g, ''') ?? text; + +const parseTextChildren = (children: any[]): string => children.map((child: any) => escapeHtml(child.text)).join(''); + +const parseImageNode = (node: any): string => { + const titleAttr = node.title ? ` title="${escapeHtml(node.title)}"` : ''; + const altAttr = node.alt ? ` alt="${escapeHtml(node.alt)}"` : ''; + const styleAttr = node.size ? ` style="width:${node.size.width}px;height:${node.size.height}px;"` : ''; + return ``; +}; + +const parseListItemNode = (listItem: any): string => `
  • ${parseContentToHtml(listItem.children)}
  • `; + +const parseListNode = (node: any): string => { + const tag = node.ordered ? 'ol' : 'ul'; + const startAttr = node.ordered && node.start !== 1 ? ` start="${node.start}"` : ''; + const listItemsHtml = node.children.map((item: any) => parseListItemNode(item)).join(''); + return `<${tag}${startAttr}>${listItemsHtml}`; +}; + +const parseParagraphChildren = (children: any[]): string => + children + .map((child: any) => { + if (child.text !== undefined) { + return escapeHtml(child.text); + } else if (child.type === 'image') { + return parseImageNode(child); + } + return ''; + }) + .join(''); + +const parseContentToHtml = (content: any[]): string => + content + ?.map((node: any) => { + switch (node.type) { + case 'paragraph': + return `

    ${parseParagraphChildren(node.children)}

    `; + case 'image': + return parseImageNode(node); + case 'heading': + return `${parseTextChildren(node.children)}`; + case 'code': + return `
    ${parseTextChildren(node.children)}
    `; + case 'list': + return parseListNode(node); + case 'blockquote': + return `
    ${parseContentToHtml(node.children)}
    `; + default: + return ''; + } + }) + .join('') ?? ''; + +export const handler = async (ctx: Context): Promise => { + const { category = 'latest' } = ctx.req.param(); + const limit: number = Number.parseInt(ctx.req.query('limit') ?? '20', 10); + + const baseUrl: string = 'https://tidb.net'; + const targetUrl: string = new URL(`blog${category === 'latest' ? '' : `/c/${category}`}`, baseUrl).href; + const targetResponse = await ofetch(targetUrl); + + const buildId: string | undefined = targetResponse.match(/"buildId":"(.*?)"/)?.[1]; + + if (!buildId) { + throw new Error('Build ID not found.'); + } + + const $: CheerioAPI = load(targetResponse); + const language = $('html').attr('lang') ?? 'zh'; + + const apiUrl: string = new URL(`_next/data/${buildId}/${language}/blog${category === 'latest' ? '' : `/c/${category}`}.json`, baseUrl).href; + + const response = await ofetch(apiUrl, { + query: { + latest: true, + }, + }); + + let items: DataItem[] = []; + + items = response.pageProps.blogs.content.slice(0, limit).map((item): DataItem => { + const title: string = item.title; + const description: string | undefined = item.summary; + const pubDate: number | string = item.publishedAt; + const linkUrl: string | undefined = item.slug ? `blog/${item.slug}` : undefined; + const categories: string[] = [...new Set([item.category?.name, ...(item.tags ?? []).map((c) => c.name)].filter(Boolean))]; + const authors: DataItem['author'] = item.author?.username + ? [ + { + name: item.author.username, + url: new URL(`u/${item.author.username}`, baseUrl).href, + avatar: item.author.avatarURL, + }, + ] + : undefined; + const guid: string = item.slug ?? ''; + const updated: number | string = item.lastModifiedAt ?? pubDate; + + const processedItem: DataItem = { + title, + description, + pubDate: pubDate ? parseDate(pubDate) : undefined, + link: linkUrl ? new URL(linkUrl, baseUrl).href : undefined, + category: categories, + author: authors, + guid, + id: guid, + content: { + html: description, + text: description, + }, + updated: updated ? parseDate(updated) : undefined, + language, + }; + + return processedItem; + }); + + items = await Promise.all( + items.map((item) => { + if (!item.link) { + return item; + } + + return cache.tryGet(item.link, async (): Promise => { + const detailUrl: string = new URL(`blog/api/posts/${item.guid}/detail`, baseUrl).href; + + const detailResponse = await ofetch(detailUrl, { + query: { + visit: true, + }, + }); + + const title: string = detailResponse.title; + const description: string | undefined = detailResponse.content ? parseContentToHtml(JSON.parse(detailResponse.content)) : item.description; + const pubDate: number | string = detailResponse.publishedAt; + const linkUrl: string | undefined = `blog/${detailResponse.slug}`; + const categories: string[] = [...new Set([detailResponse.category?.name, ...(detailResponse.tags ?? []).map((c) => c.name)].filter(Boolean))]; + const authors: DataItem['author'] = detailResponse.author?.username + ? [ + { + name: detailResponse.author.username, + url: new URL(`u/${detailResponse.author.username}`, baseUrl).href, + avatar: detailResponse.author.avatarURL, + }, + ] + : undefined; + const guid: string = `tidb-blog-${detailResponse.slug}`; + const updated: number | string = detailResponse.lastModifiedAt ?? pubDate; + + const processedItem: DataItem = { + title, + description, + pubDate: pubDate ? parseDate(pubDate) : undefined, + link: new URL(linkUrl, baseUrl).href, + category: categories, + author: authors, + guid, + id: guid, + content: { + html: description, + text: description, + }, + updated: updated ? parseDate(updated) : undefined, + language, + }; + + return { + ...item, + ...processedItem, + }; + }); + }) + ); + + const title: string = $('title').text(); + + return { + title, + description: $('meta[property="og:description"]').attr('content'), + link: targetUrl, + item: items, + allowEmpty: true, + image: $('meta[property="og:image"]').attr('content'), + author: title.split(/\|/).pop()?.trim(), + language, + id: targetUrl, + }; +}; + +export const route: Route = { + path: '/blog/c/:category?', + name: '专栏分类', + url: 'tidb.net', + maintainers: ['nczitzk'], + handler, + example: '/tidb/blog/c/latest', + parameters: { + category: { + description: '分类,默认为 `latest`,即全部文章,可在对应分类页 URL 中找到', + options: [ + { + label: '全部文章', + value: 'latest', + }, + { + label: '管理与运维', + value: 'management-and-operation', + }, + { + label: '实践案例', + value: 'practical-case', + }, + { + label: '架构选型', + value: 'architecture-selection', + }, + { + label: '原理解读', + value: 'principle-interpretation', + }, + { + label: '应用开发', + value: 'application-development', + }, + { + label: '社区动态', + value: 'community-feeds', + }, + ], + }, + }, + description: `:::tip +订阅 [管理与运维](https://tidb.net/blog/c/management-and-operation),其源网址为 \`https://tidb.net/blog/c/management-and-operation\`,请参考该 URL 指定部分构成参数,此时路由为 [\`/tidb/blog/c/management-and-operation\`](https://rsshub.app/tidb/blog/c/management-and-operation)。 +::: + +| 分类 | ID | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| [全部文章](https://tidb.net/blog) | [latest](https://rsshub.app/tidb/blog) | +| [管理与运维](https://tidb.net/blog/c/management-and-operation) | [management-and-operation](https://rsshub.app/tidb/blog/c/management-and-operation) | +| [实践案例](https://tidb.net/blog/c/practical-case) | [practical-case](https://rsshub.app/tidb/blog/c/practical-case) | +| [架构选型](https://tidb.net/blog/c/architecture-selection) | [architecture-selection](https://rsshub.app/tidb/blog/c/architecture-selection) | +| [原理解读](https://tidb.net/blog/c/principle-interpretation) | [principle-interpretation](https://rsshub.app/tidb/blog/c/principle-interpretation) | +| [应用开发](https://tidb.net/blog/c/application-development) | [application-development](https://rsshub.app/tidb/blog/c/application-development) | +| [社区动态](https://tidb.net/blog/c/community-feeds) | [community-feeds](https://rsshub.app/tidb/blog/c/community-feeds) | + +`, + categories: ['programming'], + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportRadar: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['tidb.net/blog', 'tidb.net/blog/c/:category'], + target: (params) => { + const category: string = params.category; + + return `/tidb/blog/c${category ? `/${category}` : ''}`; + }, + }, + { + title: '全部文章', + source: ['tidb.net/blog'], + target: '/blog/c/latest', + }, + { + title: '管理与运维', + source: ['tidb.net/blog/c/management-and-operation'], + target: '/blog/c/management-and-operation', + }, + { + title: '实践案例', + source: ['tidb.net/blog/c/practical-case'], + target: '/blog/c/practical-case', + }, + { + title: '架构选型', + source: ['tidb.net/blog/c/architecture-selection'], + target: '/blog/c/architecture-selection', + }, + { + title: '原理解读', + source: ['tidb.net/blog/c/principle-interpretation'], + target: '/blog/c/principle-interpretation', + }, + { + title: '应用开发', + source: ['tidb.net/blog/c/application-development'], + target: '/blog/c/application-development', + }, + { + title: '社区动态', + source: ['tidb.net/blog/c/community-feeds'], + target: '/blog/c/community-feeds', + }, + ], + view: ViewType.Articles, +}; diff --git a/lib/routes/tidb/namespace.ts b/lib/routes/tidb/namespace.ts new file mode 100644 index 000000000..7d7bcc00d --- /dev/null +++ b/lib/routes/tidb/namespace.ts @@ -0,0 +1,9 @@ +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'TiDB 社区', + url: 'tidb.net', + categories: ['programming'], + description: '', + lang: 'zh-CN', +};