From 9cced9a4cf4e51f2d925acd5036478cd38b6a345 Mon Sep 17 00:00:00 2001 From: ZEN <68309938+ZEN-GUO@users.noreply.github.com> Date: Mon, 13 Oct 2025 23:06:31 -0700 Subject: [PATCH] feat(route): add resetera thread latest posts (text + images) (#20098) --- lib/routes/resetera/namespace.ts | 8 ++ lib/routes/resetera/thread.ts | 188 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 lib/routes/resetera/namespace.ts create mode 100644 lib/routes/resetera/thread.ts diff --git a/lib/routes/resetera/namespace.ts b/lib/routes/resetera/namespace.ts new file mode 100644 index 000000000..940f18e87 --- /dev/null +++ b/lib/routes/resetera/namespace.ts @@ -0,0 +1,8 @@ +// lib/routes/resetera/namespace.ts +import type { Namespace } from '@/types'; + +export const namespace: Namespace = { + name: 'ResetEra', + url: 'resetera.com', + lang: 'en', +}; diff --git a/lib/routes/resetera/thread.ts b/lib/routes/resetera/thread.ts new file mode 100644 index 000000000..bc980b322 --- /dev/null +++ b/lib/routes/resetera/thread.ts @@ -0,0 +1,188 @@ +// lib/routes/resetera/thread.ts +import type { Route } from '@/types'; +import ofetch from '@/utils/ofetch'; +import { load } from 'cheerio'; + +const BASE = 'https://www.resetera.com'; + +const absolutize = (u?: string, root = BASE) => { + if (!u) { + return u; + } + if (u.startsWith('#')) { + return root + u; + } + if (u.startsWith('//')) { + return 'https:' + u; + } + if (u.startsWith('/')) { + return BASE + u; + } + return u; +}; + +// 提取 post-XXXX 楼层 id,便于排序/去重 +const getPostId = (link?: string) => Number(link?.match(/post-(\d+)/)?.[1] || 0); + +// 从 srcset 中挑第一张 URL +const pickFromSrcset = (v?: string) => + v + ? v + .split(',') + .map((s) => s.trim().split(' ')[0]) + .find(Boolean) + : undefined; + +const handler: Route['handler'] = async (ctx) => { + const { id } = ctx.req.param(); // 例如 1076160 + const threadRoot = `${BASE}/threads/${id}/`; + + // 1) 抓首页,解析“最后一页”(扫描所有分页链接取最大) + const firstHtml = await ofetch(threadRoot); + const $ = load(firstHtml); + + let lastPage = 1; + $('a[href*="page-"]').each((_, a) => { + const h = $(a).attr('href') || ''; + const m = h.match(/page-(\d+)/); + if (m) { + lastPage = Math.max(lastPage, Number(m[1])); + } + }); + + // 最新页 URL + const targetUrl = lastPage === 1 ? threadRoot : `${threadRoot}page-${lastPage}`; + + // 2) 抓取倒数第 1、2 页并合并(首页已抓过,避免重复请求) + const pages = lastPage > 1 ? [lastPage - 1, lastPage] : [1]; + const htmlMap = new Map([[1, firstHtml]]); + const htmlList = await Promise.all( + pages.map(async (p) => { + if (htmlMap.has(p)) { + return htmlMap.get(p)!; + } + const html = await ofetch(p === 1 ? threadRoot : `${threadRoot}page-${p}`); + htmlMap.set(p, html); + return html; + }) + ); + + // 3) 解析两页帖子,去重 + const seen = new Set(); + const items = htmlList.flatMap((html) => { + const $$ = load(html); + return $$('article.message') + .toArray() + .map((el) => { + const $el = $$(el); + + // 作者 + const author = $el.find('.message-author, .username, .message-name a, [itemprop="name"]').first().text().trim() || ''; + + // 楼层直达链接(永久链接) + const perma = $el.find('.message-attribution-opposite a[href*="/post-"]').last().attr('href') || $el.find('a[href*="#post"]').last().attr('href') || ''; + const link = perma ? absolutize(perma, threadRoot) : targetUrl; + if (!link || seen.has(link)) { + return null; + } + seen.add(link); + + // 时间 + const timeEl = $el.find('time').first(); + const dataTime = Number(timeEl.attr('data-time') || 0); + const pubDate = timeEl.attr('datetime') || (dataTime ? new Date(dataTime * 1000).toUTCString() : undefined); + + // 正文容器(clone 后处理) + const $body = $el.find('.message-body .bbWrapper, .message-content .bbWrapper, .bbWrapper').first().clone(); + + // 去掉引用块(回复别人的引用) + $body.find('.bbCodeBlock--quote, blockquote.bbCodeBlock').remove(); + // 展开剧透(移除标题,保留内容) + $body.find('.bbCodeBlock--spoiler .bbCodeBlock-title').remove(); + + // 收集图片(在去引用之后统计,避免“只有引用里有图”也被当作图帖) + const imgs = $body + .find('img, picture source') + .toArray() + .map((node) => { + const $n = $$(node); + const src = $n.attr('src') || $n.attr('data-src') || $n.attr('data-original') || $n.attr('data-url') || pickFromSrcset($n.attr('srcset')) || pickFromSrcset($n.attr('data-srcset')); + return absolutize(src || '', threadRoot); + }) + .filter((u): u is string => !!u); + + const hasImage = imgs.length > 0; + + // 文字 HTML:移除图片再取 HTML + const $textOnly = $body.clone(); + $textOnly.find('img, picture').remove(); + const textHtml = ($textOnly.html() || '').trim(); + + // 标题:作者 + 楼层号(若能取到) + const floor = $el.find('.message-attribution-opposite a').last().text().trim(); + const title = author ? `${author}${floor ? ' - ' + floor : ''}` : floor || 'New post'; + + // 描述:Source 链接 + 文字 + 图片 + const imagesHtml = hasImage ? imgs.map((u) => `

`).join('') : ''; + const description = ` +

🔗 Source post

+ ${textHtml}${imagesHtml} + `; + + return { + title, + link, + guid: link, + description, + author, + pubDate, + category: hasImage ? ['image'] : undefined, // 有图打标签,供全局过滤使用 + }; + }) + .filter(Boolean) as NonNullable[]; + }); + + // 4) 显式排序:postId 降序;再按时间兜底 + items.sort((a, b) => { + const ida = getPostId(a.link); + const idb = getPostId(b.link); + if (idb !== ida) { + return idb - ida; + } + const ta = a.pubDate ? new Date(a.pubDate).getTime() : 0; + const tb = b.pubDate ? new Date(b.pubDate).getTime() : 0; + return tb - ta; + }); + + // 5) 标题取“最新页”的

+ const title = + load(htmlList.at(-1) ?? '')('h1') + .first() + .text() + .trim() || `ResetEra Thread ${id}`; + + return { + title, + link: targetUrl, + item: items, + }; +}; + +export const route: Route = { + path: '/thread/:id', + name: 'Thread latest posts (text & images)', + url: 'resetera.com', + example: '/resetera/thread/1076160', + parameters: { + id: 'Numeric thread ID at the end of the URL', + }, + maintainers: ['ZEN-GUO'], + categories: ['bbs'], + radar: [ + { + source: ['resetera.com/threads/:slug.:id/'], + target: '/thread/:id', + }, + ], + handler, +};