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}${tag}>`;
+};
+
+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',
+};