From a04b5eb2e4d71770144df5af97ba665b14bab7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BB=BA=E8=BE=89?= <3479261099@qq.com> Date: Fri, 9 Oct 2026 20:18:55 +0800 Subject: [PATCH 1/2] feat(route/cloudflare): add full-text blog feed --- lib/routes/cloudflare/blog.ts | 102 +++++++++++++++++++++++++++++ lib/routes/cloudflare/namespace.ts | 7 ++ 2 files changed, 109 insertions(+) create mode 100644 lib/routes/cloudflare/blog.ts create mode 100644 lib/routes/cloudflare/namespace.ts diff --git a/lib/routes/cloudflare/blog.ts b/lib/routes/cloudflare/blog.ts new file mode 100644 index 000000000000..bd941fba42a5 --- /dev/null +++ b/lib/routes/cloudflare/blog.ts @@ -0,0 +1,102 @@ +import { load } from 'cheerio'; +import pMap from 'p-map'; +import Parser from 'rss-parser'; + +import type { Data, Route } from '@/types'; +import { ViewType } from '@/types'; +import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; +import { parseDate } from '@/utils/parse-date'; + +const parser = new Parser, { creators?: string[] }>({ + customFields: { + item: [['dc:creator', 'creators', { keepArray: true }]], + }, +}); + +export const route: Route = { + path: '/blog', + name: 'Blog', + url: 'blog.cloudflare.com', + example: '/cloudflare/blog', + categories: ['programming'], + maintainers: ['ljh12138164'], + description: 'Uses the official RSS feed for article metadata and fetches full articles, including inline images, code blocks, and copyable prompts.', + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportRadar: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['blog.cloudflare.com'], + target: '/blog', + }, + ], + view: ViewType.Articles, + handler, +}; + +async function handler(): Promise { + const feedXml = await ofetch('https://blog.cloudflare.com/rss/', { responseType: 'text' }); + const feed = await parser.parseString(feedXml); + + return { + title: feed.title!, + description: feed.description, + link: feed.link, + image: feed.image?.url, + language: feed.language, + item: await pMap( + feed.items, + async (item) => ({ + title: item.title!, + link: item.link, + description: await cache.tryGet(item.link!, () => fetchArticle(item.link!)), + pubDate: item.pubDate ? parseDate(item.pubDate) : undefined, + author: item.creators?.join(', '), + category: item.categories, + guid: item.guid, + enclosure_url: item.enclosure?.url, + enclosure_type: item.enclosure?.type, + // rss-parser returns XML strings despite declaring enclosure lengths as numbers. + // oxlint-disable-next-line typescript/no-unnecessary-type-conversion + enclosure_length: item.enclosure ? Number(item.enclosure.length) : undefined, + }), + { concurrency: 3 } + ), + }; +} + +async function fetchArticle(link: string): Promise { + const response = await ofetch(link); + const $ = load(response); + const article = $('.article-content').first(); + if (!article.length) { + throw new Error(`Cloudflare Blog article content not found: ${link}`); + } + + article.find('[data-copy-prompt]').each((_, element) => { + // The copy button stores the full prompt as a JSON string. + const prompt = JSON.parse($(element).find('[data-prompt]').attr('data-prompt')!); + $(element).replaceWith($('
').append($('').text(prompt)));
+    });
+
+    article.find('script, style, button, img[data-image-placeholder]').remove();
+    article.find('[srcset]').removeAttr('srcset');
+    article.find('[src], [href], [poster]').each((_, element) => {
+        const $element = $(element);
+        for (const attribute of ['src', 'href', 'poster']) {
+            const value = $element.attr(attribute);
+            if (value) {
+                $element.attr(attribute, new URL(value, link).href);
+            }
+        }
+    });
+
+    return article.html()!;
+}
diff --git a/lib/routes/cloudflare/namespace.ts b/lib/routes/cloudflare/namespace.ts
new file mode 100644
index 000000000000..14dccef11961
--- /dev/null
+++ b/lib/routes/cloudflare/namespace.ts
@@ -0,0 +1,7 @@
+import type { Namespace } from '@/types';
+
+export const namespace: Namespace = {
+    name: 'Cloudflare',
+    url: 'cloudflare.com',
+    lang: 'en',
+};

From cd7542aa35c9464170780c28be355ab238ab1f8b Mon Sep 17 00:00:00 2001
From: Vitality 
Date: Sat, 10 Oct 2026 17:27:48 +0800
Subject: [PATCH 2/2] fix(route/cloudflare): restore status feed and redirect
 legacy route

---
 lib/routes/cloudflare/blog.ts         |   6 +-
 lib/routes/cloudflare/namespace.ts    |   1 +
 lib/routes/cloudflare/status.tsx      | 130 ++++++++++++++++++++++++++
 lib/routes/cloudflarestatus/index.tsx | 105 ++-------------------
 4 files changed, 143 insertions(+), 99 deletions(-)
 create mode 100644 lib/routes/cloudflare/status.tsx

diff --git a/lib/routes/cloudflare/blog.ts b/lib/routes/cloudflare/blog.ts
index bd941fba42a5..70eb2051529c 100644
--- a/lib/routes/cloudflare/blog.ts
+++ b/lib/routes/cloudflare/blog.ts
@@ -21,7 +21,11 @@ export const route: Route = {
     example: '/cloudflare/blog',
     categories: ['programming'],
     maintainers: ['ljh12138164'],
-    description: 'Uses the official RSS feed for article metadata and fetches full articles, including inline images, code blocks, and copyable prompts.',
+    description:
+        'Uses the [official RSS feed](https://blog.cloudflare.com/rss/) for article metadata and fetches full articles from [Cloudflare Blog](https://blog.cloudflare.com/), including inline images, code blocks, and copyable prompts.',
+    zh: {
+        description: '来源:[Cloudflare Blog](https://blog.cloudflare.com/)。使用[官方 RSS](https://blog.cloudflare.com/rss/)获取文章列表、作者、日期和标签,再抓取并缓存网页正文,补齐图片、代码块和可复制的提示词。',
+    },
     features: {
         requireConfig: false,
         requirePuppeteer: false,
diff --git a/lib/routes/cloudflare/namespace.ts b/lib/routes/cloudflare/namespace.ts
index 14dccef11961..eecb1a257577 100644
--- a/lib/routes/cloudflare/namespace.ts
+++ b/lib/routes/cloudflare/namespace.ts
@@ -3,5 +3,6 @@ import type { Namespace } from '@/types';
 export const namespace: Namespace = {
     name: 'Cloudflare',
     url: 'cloudflare.com',
+    categories: ['programming'],
     lang: 'en',
 };
diff --git a/lib/routes/cloudflare/status.tsx b/lib/routes/cloudflare/status.tsx
new file mode 100644
index 000000000000..4eaad0848de3
--- /dev/null
+++ b/lib/routes/cloudflare/status.tsx
@@ -0,0 +1,130 @@
+import { load } from 'cheerio';
+import type { Context } from 'hono';
+import { raw } from 'hono/html';
+import { renderToString } from 'hono/jsx/dom/server';
+import MarkdownIt from 'markdown-it';
+
+import { config } from '@/config';
+import type { Data, DataItem, Route } from '@/types';
+import { ViewType } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
+
+interface IncidentUpdate {
+    status: string;
+    body: string;
+    display_at: string;
+}
+
+interface Incident {
+    id: string;
+    name: string;
+    incident_updates: IncidentUpdate[];
+}
+
+interface StatusResponse {
+    page: { name: string };
+    incidents: Incident[];
+    scheduled_maintenances: Incident[];
+}
+
+const baseUrl = 'https://www.cloudflarestatus.com';
+const md = MarkdownIt({ html: true, linkify: true });
+
+function createItem(incident: Incident, update: IncidentUpdate, index: number) {
+    const previousUpdate = incident.incident_updates[index + 1];
+    const type = update.status === 'scheduled' && incident.incident_updates.length === 1 ? '' : update.status === previousUpdate?.status ? 'Update' : update.status.replaceAll('_', ' ').replace(/^./, (char) => char.toUpperCase());
+    const body = md.renderInline(update.body.replaceAll(/\r?\n/g, '
')); + const text = load(body, null, false).text(); + // Statuspage's HTML timestamps use second precision; preserve existing GUIDs. + const timestamp = Math.floor(parseDate(update.display_at).getTime() / 1000) * 1000; + const pubDate = parseDate(timestamp, 'x'); + const link = `${baseUrl}/incidents/${incident.id}`; + const guid = `${link}#${timestamp}`; + const description = renderToString( + <> +

{incident.name}

+ {type ? ( + <> + {type} + {' - '} + + ) : null} + {raw(body)} +
+ + + {pubDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })} + {' - '} + {pubDate.toISOString().slice(11, 16)} UTC + + + ); + + return { + title: `${type ? `${type} - ` : ''}${text}`, + description, + pubDate, + link, + category: type ? [type] : [], + guid, + id: guid, + content: { html: description, text: description }, + updated: pubDate, + language: 'en', + } satisfies DataItem; +} + +export const handler = async (ctx: Context): Promise => { + const limit = Number(ctx.req.query('limit') ?? '100'); + const response = await ofetch(`${baseUrl}/api/v2/summary.json`, { + headers: { 'User-Agent': config.trueUA }, + }); + const items = [...response.incidents, ...response.scheduled_maintenances] + .flatMap((incident) => incident.incident_updates.map((update, index) => createItem(incident, update, index))) + .toSorted((a, b) => b.pubDate.getTime() - a.pubDate.getTime()) + .slice(0, limit); + + return { + title: response.page.name, + description: 'Real-time status and incident history for Cloudflare services, network locations, and scheduled maintenance.', + link: baseUrl, + item: items, + allowEmpty: true, + language: 'en', + id: baseUrl, + }; +}; + +export const route: Route = { + path: '/status', + name: 'Status', + url: 'www.cloudflarestatus.com', + maintainers: ['nczitzk', 'ljh12138164'], + handler, + example: '/cloudflare/status', + parameters: undefined, + description: + 'Uses the [official API](https://www.cloudflarestatus.com/api) to provide each update for ongoing incidents and upcoming or active maintenance from [Cloudflare Status](https://www.cloudflarestatus.com/) as a separate item. The legacy route `/cloudflarestatus` redirects here.', + zh: { + description: + '来源:[Cloudflare Status](https://www.cloudflarestatus.com/)。使用[官方 API](https://www.cloudflarestatus.com/api)获取未解决故障及计划中、进行中的维护通知,每次更新单独生成一条订阅内容。旧路由 `/cloudflarestatus` 会重定向到此路由。', + }, + categories: ['programming'], + features: { + requireConfig: false, + requirePuppeteer: false, + antiCrawler: false, + supportRadar: true, + supportBT: false, + supportPodcast: false, + supportScihub: false, + }, + radar: [ + { + source: ['www.cloudflarestatus.com'], + target: '/status', + }, + ], + view: ViewType.Notifications, +}; diff --git a/lib/routes/cloudflarestatus/index.tsx b/lib/routes/cloudflarestatus/index.tsx index 2709ac824c79..e1f2d71b9ae5 100644 --- a/lib/routes/cloudflarestatus/index.tsx +++ b/lib/routes/cloudflarestatus/index.tsx @@ -1,105 +1,14 @@ -import type { Cheerio, CheerioAPI } from 'cheerio'; -import { load } from 'cheerio'; -import type { Element } from 'domhandler'; -import type { Context } from 'hono'; -import { raw } from 'hono/html'; -import { renderToString } from 'hono/jsx/dom/server'; - -import type { Data, DataItem, Language, Route } from '@/types'; -import { ViewType } from '@/types'; -import ofetch from '@/utils/ofetch'; -import { parseDate } from '@/utils/parse-date'; - -export const handler = async (ctx: Context): Promise => { - const limit = Number(ctx.req.query('limit') ?? '100'); - - const baseUrl = 'https://www.cloudflarestatus.com'; - - const response = await ofetch(baseUrl); - const $: CheerioAPI = load(response); - const language = ($('html').attr('lang') ?? 'en') as Language; - - $('div.incidents-list').remove(); - - const items: DataItem[] = $('div.update') - .slice(0, limit) - .toArray() - .map((el) => { - const $el: Cheerio = $(el); - - const $actualTitleEl: Cheerio = $el.parent().parent().find('a'); - const actualTitle: string = $actualTitleEl.text(); - const type: string = $el.find('strong').first().text(); - const text: string = $el.find('span.whitespace-pre-wrap').first().text(); - - const title = `${type ? `${type} - ` : ''}${text}`; - const description: string | undefined = renderToString( - <> - {actualTitle ?

{actualTitle}

: null} - {$el.html() ? raw($el.html()) : null} - - ); - const pubDateStr: string | undefined = $el.find('span.ago').attr('data-datetime-unix'); - const href: string | undefined = $actualTitleEl.attr('href'); - const linkUrl: string | undefined = href ? new URL(href, baseUrl).href : undefined; - const categories: string[] = [type].filter(Boolean); - const guid: string = linkUrl ? `${linkUrl}#${pubDateStr}` : ''; - const upDatedStr: string | undefined = pubDateStr; - - const processedItem: DataItem = { - title, - description, - pubDate: pubDateStr ? parseDate(pubDateStr, 'x') : undefined, - link: linkUrl, - category: categories, - guid, - id: guid, - content: { - html: description, - text: description, - }, - updated: upDatedStr ? parseDate(upDatedStr, 'x') : undefined, - language, - }; - - return processedItem; - }); - - return { - title: $('title').text(), - description: $('meta[name="description"]').attr('content'), - link: baseUrl, - item: items, - allowEmpty: true, - language, - id: baseUrl, - }; -}; +import type { Route } from '@/types'; export const route: Route = { path: '/', - name: 'Status', + name: 'Status (legacy)', url: 'www.cloudflarestatus.com', - maintainers: ['nczitzk'], - handler, + maintainers: ['nczitzk', 'ljh12138164'], example: '/cloudflarestatus', - parameters: undefined, - description: undefined, - categories: ['programming'], - features: { - requireConfig: false, - requirePuppeteer: false, - antiCrawler: false, - supportRadar: true, - supportBT: false, - supportPodcast: false, - supportScihub: false, + description: 'This route redirects to `/cloudflare/status`.', + zh: { + description: '此旧路由会重定向到 `/cloudflare/status`。', }, - radar: [ - { - source: ['www.cloudflarestatus.com'], - target: '/', - }, - ], - view: ViewType.Notifications, + handler: (ctx) => ctx.set('redirect', `/cloudflare/status${new URL(ctx.req.url).search}`), };