RSSHub/lib/routes/resetera/thread.ts

190 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/routes/resetera/thread.ts
import { load } from 'cheerio';
import type { Route } from '@/types';
import ofetch from '@/utils/ofetch';
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,
};