From 4ac353304465f42d673f3aca5e5654dd41e0c70d Mon Sep 17 00:00:00 2001 From: "Jagger.H" Date: Tue, 16 Jun 2026 13:21:02 +0800 Subject: [PATCH] fix(route/btbtla): cover all seasons, tolerate dead rows, add lazy mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /detail route only handled a single exact-title match and a single quality tab, and a stale row could fail the whole feed. Changes: - Multi-season: a show listed with a season suffix (e.g. "上载新生 第三季") never matched the bare name. Now matches every search result whose title contains the query (deduped), so all seasons/variants are covered. - All quality tabs: previously only the active tab was parsed; now every tab is included and tagged via `category`. - Fault tolerance: btbtla leaves dead /tdown/ pages that 404 — magnet resolution now tolerates per-row failures and drops stale rows instead of throwing and 503-ing the feed. - Bounded concurrency: a popular show has 100+ rows across seasons; magnets are resolved with p-map (concurrency 5) + cache to avoid bursting requests. - Optional `/lazy`: resolving every magnet up front is the dominant cost for big shows. `/btbtla/detail/:name/lazy` returns the /tdown/ links unresolved for clients that resolve one magnet on demand; default stays eager so the feed carries real BT enclosures. Path param (not query) so it keys the route cache distinctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/routes/btbtla/detail.ts | 187 ++++++++++++++++++++++++++---------- 1 file changed, 136 insertions(+), 51 deletions(-) diff --git a/lib/routes/btbtla/detail.ts b/lib/routes/btbtla/detail.ts index 47adc7ec545..6c91747435b 100644 --- a/lib/routes/btbtla/detail.ts +++ b/lib/routes/btbtla/detail.ts @@ -1,14 +1,18 @@ import { load } from 'cheerio'; // 类似 jQuery 的 API HTML 解析器 +import pMap from 'p-map'; -import type { Route } from '@/types'; +import type { DataItem, Route } from '@/types'; import cache from '@/utils/cache'; import ofetch from '@/utils/ofetch'; // 统一使用的请求库 export const route: Route = { - path: '/detail/:name', + path: '/detail/:name/:lazy?', categories: ['multimedia'], example: '/btbtla/detail/雍正王朝', - parameters: { name: '电影 | 电视剧名称' }, + parameters: { + name: '电影 | 电视剧名称', + lazy: 'Pass `lazy` to skip up-front magnet resolution (fast; no BT enclosure)', + }, features: { requireConfig: false, requirePuppeteer: false, @@ -20,72 +24,153 @@ export const route: Route = { name: 'BTBTLA | 指定剧名', maintainers: ['Hermes1030'], handler, + description: 'By default every torrent row is resolved to its magnet, so the feed carries real BT enclosures. Add `/lazy` (e.g. `/btbtla/detail/雍正王朝/lazy`) for a fast response that skips up-front resolution: rows are returned with their `/tdown/` page as the link and no enclosure — useful for clients that resolve a single magnet on demand.', }; +const ROOT_URL = 'https://www.btbtla.com'; +// A search can match many seasons/variants (海绵宝宝 → seasons + movies + spinoffs). +// Cap how many detail pages we open. +const MAX_ENTRIES = 24; +// Bound concurrent requests so a popular show doesn't burst hundreds of requests +// at btbtla and get the instance IP throttled/banned. +const CONCURRENCY = 5; + +interface Row { + title: string; + downPage: string; // full URL to the row's /tdown/ page (where the magnet lives) + quality: string; + seasonTitle: string; // the season's detail-page title, e.g. "上载新生 第三季" + seasonUrl: string; // the season's detail-page URL +} + async function handler(ctx) { const name = ctx.req.param('name'); + // /lazy → skip up-front magnet resolution; return the /tdown/ page links and + // let the consumer resolve a single magnet on demand (much faster, but the feed + // carries no BT enclosure). A path param (not a query) so it keys the route + // cache distinctly — RSSHub's cache key is the path, query strings are ignored. + const lazy = ctx.req.param('lazy') === 'lazy'; - const idUrl = await getId(name); - if (!idUrl) { + // A title spans multiple seasons (上载新生 第一季…第四季), each its OWN detail + // page. Fetch ALL matching entries (bounded) so every season is covered. + const entries = await getEntries(name); + if (entries.length === 0) { return null; } - const detailLink = 'https://www.btbtla.com' + idUrl; - const detailResponse = await ofetch(detailLink); - const $ = load(detailResponse); - - const itemElements = $('div[name=download-list] .module-downlist.selected .module-row-one.active .module-row-info').toArray(); - // 使用缓存处理所有项目 - const items = await Promise.all( - itemElements.map(async (element) => { - const $row = $(element); - const title = $row.find('.module-row-title h4').text().trim(); - const link = $row.find('.module-row-text').attr('href'); + // Stage 1: open each season's detail page (bounded concurrency) and collect + // every download row, tagged with its quality tab + season. + const rows = (await pMap(entries.slice(0, MAX_ENTRIES), (entry) => parseDetail(entry), { concurrency: CONCURRENCY })).flat(); - // 使用缓存获取磁力链接 - const magnet = await cache.tryGet(`btbtla:magnet:${link}`, async () => { - if (link) { - return await getMagnet('https://www.btbtla.com' + link); + // Stage 2: build items. Eager (default) resolves each /tdown/ row's magnet + // (cache + bounded concurrency — magnets are immutable, so a warm cache makes + // refreshes nearly free) and drops stale rows whose page 404'd. Lazy mode + // returns the rows untouched. + let items: DataItem[]; + if (lazy) { + items = rows.map((row) => buildItem(row, '')); + } else { + items = []; + await pMap( + rows, + async (row) => { + const isMagnetRow = row.downPage.includes('/tdown/'); + const { magnet } = isMagnetRow ? await cache.tryGet(`btbtla:magnet:${row.downPage}`, async () => ({ magnet: await getMagnet(row.downPage) })) : { magnet: '' }; + // Drop stale /tdown/ rows whose page 404'd (no magnet) — btbtla keeps + // dead entries in its listing. Non-BT rows (e.g. /pdown/ netdisk) stay. + if (isMagnetRow && !magnet) { + return; } - return ''; - }); - - return { - title, - link, - enclosure_url: magnet, - enclosure_type: 'application/x-bittorrent', - }; - }) - ); - - const moduleTitle = 'BTBTLA | ' + $('.page-title').text(); + items.push(buildItem(row, magnet)); + }, + { concurrency: CONCURRENCY } + ); + } return { - title: moduleTitle, - link: detailLink, - description: moduleTitle, + title: 'BTBTLA | ' + name, + link: `${ROOT_URL}/search/${name}`, + description: name, item: items, }; } -async function getId(name: string) { - const searchLink = 'https://www.btbtla.com/search/'; - const response = await ofetch(searchLink + name); - const $ = load(response); - const link = $(`.module-items .module-item-titlebox a[title="${name}"]`).attr('href'); +/** Build a feed item. `category` carries the native quality tab; the magnet (when + * resolved) goes into the standard BT enclosure. */ +function buildItem(row: Row, magnet: string): DataItem { + return { + title: row.quality ? `${row.title} [${row.quality}]` : row.title, + link: row.downPage, + guid: row.downPage, + category: row.quality ? [row.quality] : undefined, + enclosure_url: magnet || undefined, + enclosure_type: magnet ? 'application/x-bittorrent' : undefined, + // Season info for structured consumers reading the raw object; _extra is + // RSSHub's sanctioned passthrough (ignored by RSS/Atom, kept in JSON Feed). + _extra: { seasonTitle: row.seasonTitle, seasonUrl: row.seasonUrl }, + }; +} + +/** Parse one season's detail page → all quality tabs × rows, each tagged with + * quality (native tab) + the season page title/url. No magnet fetch here — just + * collect the /tdown/ link; magnets are resolved later (eager mode), with caching. */ +async function parseDetail(entry: { href: string; title: string }): Promise { + const seasonUrl = ROOT_URL + entry.href; + const html = await ofetch(seasonUrl); + const $ = load(html); + const qualities = $('.module-tab-content .module-tab-item') + .toArray() + .map((el) => $(el).find('span[data-dropdown-value]').attr('data-dropdown-value')?.trim() || $(el).text().trim()); + const seasonTitle = $('.page-title').text().trim() || entry.title; - // format '/detail/46830832.html' - return link; + const rows: Row[] = []; + $('div[name=download-list] .module-downlist').each((i, dl) => { + const quality = qualities[i] || ''; + $(dl) + .find('.module-row-one .module-row-info') + .each((_, info) => { + const a = $(info).find('a.module-row-text'); + const href = a.attr('href'); + if (!href) { + return; + } + const title = $(info).find('.module-row-title h4').text().trim() || a.attr('title') || ''; + rows.push({ title, downPage: ROOT_URL + href, quality, seasonTitle, seasonUrl }); + }); + }); + return rows; } -async function getMagnet(link: string | undefined) { - if (!link) { - return null; - } - const response = await ofetch(link); - const $ = load(response); - const magnet = $('.btn-important').attr('href'); +/** All distinct search results whose title contains the query (each is a season / + * variant with its own detail page). btbtla lists shows with a season suffix + * (e.g. "上载新生 第三季"), so an exact-title match finds nothing — match by + * contains, deduped by href. */ +async function getEntries(name: string): Promise> { + const html = await ofetch(`${ROOT_URL}/search/${name}`); + const $ = load(html); + const seen = new Set(); + const out: Array<{ href: string; title: string }> = []; + $('.module-items .module-item-titlebox a[href^="/detail/"]').each((_, el) => { + const href = $(el).attr('href'); + const title = $(el).attr('title') ?? ''; + if (!href || seen.has(href) || !title.includes(name)) { + return; + } + seen.add(href); + out.push({ href, title }); + }); + return out; +} - return magnet; +/** Resolve a row's /tdown/ page → its magnet link. Returns '' on miss/failure so + * the value is cacheable AND one stale row (btbtla leaves dead /tdown/ pages that + * 404) can't take down the whole feed. */ +async function getMagnet(downPage: string): Promise { + try { + const html = await ofetch(downPage); + const $ = load(html); + return $('.btn-important').attr('href') ?? ''; + } catch { + return ''; + } }