/** * 纯解析层:只处理 HTML 字符串,不依赖 DOM。 * 这样 content script 和 node 测试可以共用同一份实现。 */ (function (root) { 'use strict'; const MAGNET_RE = /magnet:\?[^\s"'`<>\\]+/gi; const BTIH_RE = /xt=urn:btih:([A-Za-z0-9]{32,40})/; const SIZE_RE = /\b\d+(?:\.\d+)?\s?[KMGT]i?B\b/i; // 各镜像的帖子详情页路径特征 // 不锚死开头:代理站会把整个站点套在一层路径下, // 例如 knaben 的 /thepiratebay/description.php、/thepiratebay/torrent/123 const DETAIL_RES = [ /(?:^|\/)torrent\/[^/]+/i, // rargb.to / rarbg.to 等经典布局:/torrent/xxxxxxx /\/description\.php$/i, // The Pirate Bay 及其代理(knaben 的 /thepiratebay/ 走这个) /(?:^|\/)post\/detail\/[^/]+/i, // therarbg.com /(?:^|\/)ep\/\d+\//i, // eztvx.to /(?:^|\/)torrents\/\d+/i, ]; // 明显不是详情页的路径(列表页、导航等) const DENY_RES = [ /\.torrent$/i, /\/torrents\.php$/i, /\/get-posts\//i, /^\/search\/?$/i, /^\/(login|register|about-us|catalog|top10|box-office)\b/i, ]; // 分页链接里的页码 token // rargb.to 是 /search/2/?search=Pawn%20Stars 这种把页码放路径里的形式 const PAGE_TOKEN_RES = [ /([?&]page=)(\d+)/i, /([?&]p=)(\d+)/i, /(\/page\/)(\d+)(?=\/|$|\?)/i, /(\/page_)(\d+)(?=\/|$|\?)/i, // eztvx.to /(\/(?:search|browse|list|catalog)\/)(\d+)(?=\/|$|\?)/i, ]; // Cloudflare / DDoS-Guard 之类的校验页特征。撞上这个说明是被拦了, // 不是页面用 JS 渲染,别去开 iframe 重试。 const BLOCKED_RES = [ /just a moment/i, /checking your browser/i, /cf-browser-verification/i, /attention required/i, /enable javascript and cookies to continue/i, /ddos-guard/i, /protected by go-away/i, // knaben 的反爬 /loading challenge/i, ]; const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', }; const reCache = new Map(); function safeRe(src) { if (!src) return null; if (reCache.has(src)) return reCache.get(src); let re = null; try { re = new RegExp(src, 'i'); } catch (e) { re = null; } reCache.set(src, re); return re; } function decodeEntities(s) { return String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, function (m, g) { if (g[0] === '#') { const code = g[1] === 'x' || g[1] === 'X' ? parseInt(g.slice(2), 16) : parseInt(g.slice(1), 10); return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : m; } const v = NAMED_ENTITIES[g.toLowerCase()]; return v === undefined ? m : v; }); } function cleanText(html) { return decodeEntities(String(html).replace(/<[^>]*>/g, ' ')) .replace(/\s+/g, ' ') .trim(); } function attr(attrs, name) { const re = new RegExp( '\\b' + name + '\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\'|([^\\s">]+))', 'i' ); const m = re.exec(attrs || ''); if (!m) return ''; return m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3] || ''; } /** 抽出所有 ,返回 { href, text } */ function extractAnchors(html) { const out = []; const re = /]*)>([\s\S]*?)<\/a>/gi; let m; while ((m = re.exec(html))) { const href = attr(m[1], 'href'); if (!href) continue; out.push({ href: decodeEntities(href), text: cleanText(m[2]) || decodeEntities(attr(m[1], 'title')), }); } return out; } function toAbsolute(href, base) { if (!href) return ''; if (href.charAt(0) === '#' || /^(javascript|mailto|magnet|data):/i.test(href)) return ''; try { const u = new URL(href, base); u.hash = ''; return u.href; } catch (e) { return ''; } } function isDetailUrl(absUrl, opts) { let u; try { u = new URL(absUrl); } catch (e) { return false; } const path = u.pathname; const extra = safeRe((opts && opts.detailPattern) || ''); if (extra && extra.test(path + u.search)) return true; if (DENY_RES.some((re) => re.test(path))) return false; return DETAIL_RES.some((re) => re.test(path)); } /** 是不是反爬校验页(而不是正常内容页) */ function looksBlocked(html) { const head = String(html).slice(0, 4000); return BLOCKED_RES.some((re) => re.test(head)); } function infoHash(magnet) { const m = BTIH_RE.exec(magnet || ''); return m ? m[1].toLowerCase() : ''; } /** 从任意 HTML 片段里抽磁力链接,按 infohash 去重 */ function extractMagnets(html) { const out = []; const seen = new Set(); const hits = String(html).match(MAGNET_RE) || []; for (const raw of hits) { const magnet = decodeEntities(raw).replace(/[&?]+$/, ''); const h = infoHash(magnet); if (!h || seen.has(h)) continue; seen.add(h); out.push(magnet); } return out; } /** 从 HTML 里抽 .torrent 文件下载地址 */ function extractTorrentFileUrls(html, base) { const out = []; const seen = new Set(); for (const a of extractAnchors(html)) { const abs = toAbsolute(a.href, base); if (!abs) continue; let u; try { u = new URL(abs); } catch (e) { continue; } const hit = /\.torrent$/i.test(u.pathname) || /download\.php$/i.test(u.pathname) || /\/download\//i.test(u.pathname); if (hit && !seen.has(abs)) { seen.add(abs); out.push(abs); } } return out; } /** 把一行切成各个 的纯文本 */ function rowCells(rowHtml) { const out = []; const re = /]*>([\s\S]*?)<\/t[dh]>/gi; let m; while ((m = re.exec(rowHtml))) out.push(cleanText(m[1])); return out; } /** * 取种子数 / 下载数:定位到「大小」那一列,它后面的前两个纯整数列就是 * S. 和 L. —— rargb、EZTV、TPB 代理的表格都是这个排法。 * 拿不准就返回 0,宁可显示 0 也别瞎猜。 */ function rowPeers(rowHtml) { const cells = rowCells(rowHtml); let at = -1; for (let i = 0; i < cells.length; i++) { if (SIZE_RE.test(cells[i])) { at = i; break; } } if (at < 0) return { seeders: 0, leechers: 0 }; const nums = []; for (let i = at + 1; i < cells.length && nums.length < 2; i++) { const t = cells[i].replace(/,/g, ''); if (/^\d{1,7}$/.test(t)) nums.push(parseInt(t, 10)); } return { seeders: nums[0] || 0, leechers: nums[1] || 0 }; } /** 把 HTML 切成 块,一行一个帖子 */ function extractRows(html) { const out = []; const re = /]*>([\s\S]*?)<\/tr>/gi; let m; while ((m = re.exec(html))) out.push(m[0]); return out; } function guessTitleFromUrl(url) { try { const parts = new URL(url).pathname.split('/').filter(Boolean); return decodeURIComponent(parts[parts.length - 1] || '').replace(/[-_+]+/g, ' '); } catch (e) { return ''; } } /** * 一行里最多提取一个帖子。 * 标题必须来自指向同一个 URL 的链接 —— 否则会被站点塞进结果表的推荐海报条 * (一个 里好几部不相干的电影)串成「A 的标题 + B 的链接」。 */ function postFromRow(rowHtml, base, opts) { const byUrl = new Map(); let magnetTitle = ''; for (const a of extractAnchors(rowHtml)) { if (/^magnet:/i.test(a.href)) { if (a.text.length > magnetTitle.length) magnetTitle = a.text; continue; } const abs = toAbsolute(a.href, base); if (!abs || !isDetailUrl(abs, opts)) continue; // 正常结果行里封面链接和标题链接指向同一帖子,取文字最长的当标题 const prev = byUrl.get(abs) || ''; if (a.text.length > prev.length) byUrl.set(abs, a.text); } // 一行里出现多个不同帖子,那是推荐海报条,不是搜索结果 if (byUrl.size > 1) return null; const url = byUrl.size ? byUrl.keys().next().value : ''; const linkTitle = url ? byUrl.get(url) : ''; const magnets = extractMagnets(rowHtml); if (!url && !magnets.length) return null; // 三个候选里挑最长的:详情链接文字、磁力链接文字、磁力 dn 参数。 // 谁都可能是对的 —— knaben 的详情链接文字是来源站名「The Pirate Bay」, // therarbg 的磁力链接文字是「磁力」,rargb 压根没有行内磁力。 const title = pickTitle([ linkTitle, magnetTitle, magnets[0] ? titleFromMagnet(magnets[0]) : '', ]); const sizeHit = SIZE_RE.exec(cleanText(rowHtml)); const peers = rowPeers(rowHtml); return { url: url, title: title || guessTitleFromUrl(url), size: sizeHit ? sizeHit[0] : '', magnet: magnets[0] || '', // EZTV 这类站点行内就有 .torrent 链接;行内已有磁力就不会去抓详情页, // 这里不收下的话「下载 .torrent」会一直是空的 torrent: extractTorrentFileUrls(rowHtml, base)[0] || '', seeders: peers.seeders, leechers: peers.leechers, }; } // 「磁力」「Get this torrent」这类按钮文字不是标题 const GENERIC_TITLE_RE = /^(?:magnet(?:s*link)?|磁力(?:链接)?|下载|download(?:s+thiss+torrent)?(?:s+usings+magnet)?|gets+thiss+torrent|torrent|dl)$/i; /** 候选标题里挑最靠谱的:去掉按钮文字,取最长的 */ function pickTitle(cands) { let best = ''; for (const c of cands) { const t = String(c || '').trim(); if (!t || GENERIC_TITLE_RE.test(t)) continue; if (t.length > best.length) best = t; } return best; } function titleFromMagnet(magnet) { const m = /[?&]dn=([^&]+)/.exec(magnet || ''); if (!m) return ''; try { return decodeURIComponent(m[1].replace(/\+/g, ' ')); } catch (e) { return m[1]; } } /** * 从列表页 HTML 抽出所有帖子。 * 优先按 分组(能把标题/大小/内联磁力配对准确), * 没有表格结构时退化为「按详情链接去重」。 */ function extractPosts(html, base, opts) { const rows = extractRows(html); const out = []; const seen = new Set(); const push = (p) => { if (!p) return; const key = p.url || 'ih:' + infoHash(p.magnet); if (key === 'ih:' || seen.has(key)) return; seen.add(key); out.push(p); }; if (rows.length) { for (const row of rows) push(postFromRow(row, base, opts)); if (out.length) return out; } for (const a of extractAnchors(html)) { const abs = toAbsolute(a.href, base); if (!abs || !isDetailUrl(abs, opts)) continue; if (seen.has(abs)) { // 已存在则补一个更长的标题 const prev = out.find((p) => p.url === abs); if (prev && a.text.length > prev.title.length) prev.title = a.text; continue; } push({ url: abs, title: a.text || guessTitleFromUrl(abs), size: '', magnet: '', torrent: '', seeders: 0, leechers: 0, }); } return out; } /** 从 URL 里解析页码,并给出把页码换成 {n} 的模板(差异比对失败时的兜底) */ function pageInfo(url) { for (const re of PAGE_TOKEN_RES) { const m = re.exec(url); if (!m) continue; return { n: parseInt(m[2], 10), template: url.slice(0, m.index) + m[1] + '{n}' + url.slice(m.index + m[0].length), }; } return null; } const isDigits = (s) => typeof s === 'string' && /^\d+$/.test(s); /** * 把候选链接和当前页 URL 逐 token(路径段 + 查询参数)比对, * 只有「恰好一个 token 不同、且是数字」的才算同一批列表的分页链接。 * * 这样才躲得开真实站点的干扰:knaben 侧栏的 /browse/2001000/1 分类链接、 * rargb 表头的 ?order=data&by=ASC 排序链接,都不会再被当成页码。 * 返回 { n: 链接指向第几页, curN: 当前页是第几页(未知为 null), template } */ function diffPageInfo(currentUrl, linkUrl) { let cur, link; try { cur = new URL(currentUrl); link = new URL(linkUrl); } catch (e) { return null; } if (cur.origin !== link.origin) return null; const curSegs = cur.pathname.split('/'); const linkSegs = link.pathname.split('/'); if (curSegs.length !== linkSegs.length) return null; let hit = null; for (let i = 0; i < curSegs.length; i++) { if (sameToken(curSegs[i], linkSegs[i])) continue; if (hit || !isDigits(curSegs[i]) || !isDigits(linkSegs[i])) return null; hit = { kind: 'seg', key: i, raw: linkSegs[i], curRaw: curSegs[i] }; } const keys = new Set(); cur.searchParams.forEach((v, k) => keys.add(k)); link.searchParams.forEach((v, k) => keys.add(k)); for (const k of keys) { const a = cur.searchParams.get(k); const b = link.searchParams.get(k); if (a !== null && b !== null && sameToken(a, b)) continue; if (a === b) continue; // 链接里新增/改动的那个数字参数才是页码,其它差异一律否决 if (hit || !isDigits(b) || (a !== null && !isDigits(a))) return null; hit = { kind: 'param', key: k, raw: b, curRaw: a }; } if (!hit) return null; // 模板用字符串替换生成,不走 URL 序列化 —— 否则 %20 会被改写成 + let template = null; if (hit.kind === 'seg') { const segs = link.pathname.split('/'); segs[hit.key] = '{n}'; const at = link.href.indexOf(link.pathname); template = link.href.slice(0, at) + segs.join('/') + link.href.slice(at + link.pathname.length); } else { const re = new RegExp('([?&]' + escapeRe(hit.key) + '=)' + escapeRe(hit.raw) + '(?=&|#|$)'); if (!re.test(link.href)) return null; template = link.href.replace(re, '$1{n}'); } return { n: parseInt(hit.raw, 10), curN: hit.curRaw === null || hit.curRaw === undefined ? null : parseInt(hit.curRaw, 10), template: template, }; } /** token 比较忽略大小写和百分号转义 —— 站点分页链接常把关键词小写化 */ function sameToken(a, b) { if (a === b) return true; if (typeof a !== 'string' || typeof b !== 'string') return false; const norm = (v) => { try { return decodeURIComponent(v).toLowerCase(); } catch (e) { return v.toLowerCase(); } }; return norm(a) === norm(b); } const NAV_TEXT_RE = /^(?:»|›|>>|>|«|‹|<<|<|next|prev|previous|last|first|下一页|上一页|下页|上页|末页|首页|尾页)$/i; /** * 分页链接的文字要么是页码,要么是「下一页」这类导航词。 * 光看 URL 不够:TPB 的 ?orderby=7(排序)、knaben 的 /browse/2001000/1(分类) * 在 URL 上都长得像「只差一个数字」,只有文字能把它们和真分页区分开。 */ function looksLikePageLink(text) { const t = String(text || '').replace(/[\[\]()()\s]/g, ''); if (!t) return false; return /^\d{1,6}$/.test(t) || NAV_TEXT_RE.test(t); } function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * 收集页面里的分页链接,返回 Map<页码, { url, template, curN }>。 * 优先用差异比对的结果;一个都没比对上时(例如分页指向另一个路径的脚本页), * 才退回正则识别的宽松结果。 */ function collectPageLinks(html, base, currentUrl) { const strict = new Map(); const loose = new Map(); for (const a of extractAnchors(html)) { const abs = toAbsolute(a.href, base); if (!abs || isDetailUrl(abs)) continue; if (!looksLikePageLink(a.text)) continue; const d = diffPageInfo(currentUrl, abs); if (d && Number.isFinite(d.n)) { if (!strict.has(d.n)) strict.set(d.n, { url: abs, template: d.template, curN: d.curN }); continue; } const info = pageInfo(abs); if (info && Number.isFinite(info.n) && !loose.has(info.n)) { loose.set(info.n, { url: abs, template: info.template, curN: null }); } } return strict.size ? strict : loose; } /** * 探测分页:{ maxPage, template, current, pageBase }。 * 页码一律是站点自己的编号 —— 有的站从 0 开始(TPB 的 ?page=0 就是第一页), * pageBase 告诉调用方该怎么换算成给人看的「第 1 页」。 */ function findPagination(html, base, currentUrl) { const cur = pageInfo(currentUrl); const links = collectPageLinks(html, base, currentUrl); let current = cur && Number.isFinite(cur.n) ? cur.n : null; for (const hit of links.values()) { if (hit.curN !== null && Number.isFinite(hit.curN)) { current = hit.curN; break; } } let maxPage = current === null ? 1 : current; let minPage = current === null ? 1 : current; let template = cur ? cur.template : ''; for (const [n, hit] of links) { if (n < minPage) minPage = n; if (n >= maxPage) { maxPage = n; template = hit.template; } else if (!template) { template = hit.template; } } return { maxPage: maxPage, template: template, current: current === null ? (minPage <= 0 ? 0 : 1) : current, pageBase: minPage <= 0 ? 0 : 1, }; } /** * 在某一页的 HTML 里找出指向第 n 页的链接(n 是站点自己的编号)。 * 用站点自己的链接翻页,比照模板拼 URL 靠谱得多。 */ function findPageUrl(html, base, currentUrl, n) { const hit = collectPageLinks(html, base, currentUrl).get(n); return hit ? hit.url : ''; } function currentPageNo(url) { const info = pageInfo(url); return info && Number.isFinite(info.n) ? info.n : 1; } /** 拼出第 n 页的 URL */ function buildPageUrl(currentUrl, n, template) { if (template) return template.replace('{n}', String(n)); const info = pageInfo(currentUrl); if (info) return info.template.replace('{n}', String(n)); try { const u = new URL(currentUrl); u.searchParams.set('page', String(n)); return u.href; } catch (e) { return currentUrl; } } const api = { decodeEntities: decodeEntities, cleanText: cleanText, extractAnchors: extractAnchors, extractRows: extractRows, extractPosts: extractPosts, rowPeers: rowPeers, extractMagnets: extractMagnets, extractTorrentFileUrls: extractTorrentFileUrls, isDetailUrl: isDetailUrl, infoHash: infoHash, looksBlocked: looksBlocked, titleFromMagnet: titleFromMagnet, pageInfo: pageInfo, currentPageNo: currentPageNo, findPagination: findPagination, findPageUrl: findPageUrl, collectPageLinks: collectPageLinks, buildPageUrl: buildPageUrl, }; root.RarbgParse = api; if (typeof module !== 'undefined' && module.exports) module.exports = api; })(typeof globalThis !== 'undefined' ? globalThis : self);