feat(route): add resetera thread latest posts (text + images) (#20098)
This commit is contained in:
parent
aa6140d23f
commit
9cced9a4cf
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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<string>(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<number, string>([[1, firstHtml]]);
|
||||
const htmlList = await Promise.all(
|
||||
pages.map(async (p) => {
|
||||
if (htmlMap.has(p)) {
|
||||
return htmlMap.get(p)!;
|
||||
}
|
||||
const html = await ofetch<string>(p === 1 ? threadRoot : `${threadRoot}page-${p}`);
|
||||
htmlMap.set(p, html);
|
||||
return html;
|
||||
})
|
||||
);
|
||||
|
||||
// 3) 解析两页帖子,去重
|
||||
const seen = new Set<string>();
|
||||
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) => `<p><img src="${u}" referrerpolicy="no-referrer" /></p>`).join('') : '';
|
||||
const description = `
|
||||
<p><a href="${link}">🔗 Source post</a></p>
|
||||
${textHtml}${imagesHtml}
|
||||
`;
|
||||
|
||||
return {
|
||||
title,
|
||||
link,
|
||||
guid: link,
|
||||
description,
|
||||
author,
|
||||
pubDate,
|
||||
category: hasImage ? ['image'] : undefined, // 有图打标签,供全局过滤使用
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as NonNullable<any>[];
|
||||
});
|
||||
|
||||
// 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) 标题取“最新页”的 <h1>
|
||||
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,
|
||||
};
|
||||
Loading…
Reference in New Issue