feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署

This commit is contained in:
ice-a
2026-10-16 18:53:45 +08:00
commit d6999104bf
64 changed files with 16524 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpJson } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
/** The Pirate Bay 分类编号 → 可读标签(只保留常见项) */
const TPB_CATEGORIES: Record<string, string> = {
'101': '音乐', '102': '有声书', '103': '视频', '104': '电影', '105': '剧集', '106': 'MV', '199': '其他',
'201': '软件', '202': '安卓', '203': 'Mac', '204': '游戏', '205': 'PC游戏', '206': 'Mac游戏', '207': '其他',
'301': '视频', '302': '视频', '303': '图片', '304': '其他', '399': '其他',
'401': '游戏', '402': 'PC游戏', '403': 'Mac游戏', '404': '其他',
'501': '其他', '502': '电子书', '503': '漫画', '504': '图片', '505': '其他', '599': '其他',
'601': '其他', '602': '电子书', '603': '音频', '604': '视频', '605': '其他', '699': '其他'
}
function apibayBase() {
return (process.env.APIBAY_BASE || 'https://apibay.org').replace(/\/+$/, '')
}
export const apibayProvider: SearchProvider = {
id: 'apibay',
label: 'The Pirate Bay',
homepage: 'https://thepiratebay.org',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `${apibayBase()}/q.php?q=${encodeURIComponent(query)}&cat=0`
const raw = await httpJson<any[]>(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/json', 'user-agent': DEFAULT_UA }
})
if (!Array.isArray(raw)) return []
return raw
.filter((it) => {
if (!it || typeof it.info_hash !== 'string') return false
if (it.info_hash === '0000000000000000000000000000000000000000') return false
if (typeof it.name === 'string' && /no results returned/i.test(it.name)) return false
return true
})
.map((it) => {
const infoHash = String(it.info_hash).toLowerCase()
const name = String(it.name || infoHash)
return {
infoHash,
name,
size: Number(it.size) || 0,
seeders: Number(it.seeders) || 0,
leechers: Number(it.leechers) || 0,
fileCount: Number(it.num_files) || null,
category: TPB_CATEGORIES[String(it.category)] || (it.category ? `分类 ${it.category}` : null),
source: 'apibay',
sourceLabel: 'The Pirate Bay',
sourceId: it.id ? String(it.id) : null,
sourceUrl: it.id ? `https://thepiratebay.org/description.php?id=${it.id}` : null,
addedAt: it.added ? new Date(Number(it.added) * 1000) : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
}
}
+145
View File
@@ -0,0 +1,145 @@
import { buildMagnet } from '../torrent'
import { DEFAULT_UA, httpText } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
const HOMEPAGE = 'https://cilisousuo.cc'
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1024,
kib: 1024,
mb: 1024 ** 2,
mib: 1024 ** 2,
gb: 1024 ** 3,
gib: 1024 ** 3,
tb: 1024 ** 4,
tib: 1024 ** 4
}
/** "330.3MB" / "1.1TB" → 字节数 */
function parseSize(input: unknown): number {
const text = String(input || '').trim()
const m = text.match(/^([\d.]+)\s*([a-zA-Z]+)?$/)
if (!m) return 0
const value = Number(m[1]) || 0
const unit = (m[2] || 'b').toLowerCase()
return Math.round(value * (SIZE_UNITS[unit] ?? 1))
}
function cilisousuoBase() {
return (process.env.CILISOUSUO_BASE || HOMEPAGE).replace(/\/+$/, '')
}
/** 去掉 HTML 标签并解码常见实体 */
function stripHtml(input: string): string {
return input
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&#0?39;|&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
}
interface ListItem {
title: string
filename: string
size: number
code: string
}
/** 从列表页解析出条目(列表页只含名称/大小与 /magnet/{code},infoHash 在详情页) */
function parseList(html: string): ListItem[] {
const items: ListItem[] = []
const liRe = /<li class="item">([\s\S]*?)<\/li>/g
let m: RegExpExecArray | null
while ((m = liRe.exec(html))) {
const block = m[1]
const titleM = block.match(/<div class="result-title">([\s\S]*?)<\/div>/)
const fileM = block.match(/<div class="filename">([\s\S]*?)<\/div>/)
const sizeM = block.match(/<div class="size">([\s\S]*?)<\/div>/)
const codeM = block.match(/href="\/magnet\/([^"]+)"/)
if (!codeM) continue
const title = titleM ? stripHtml(titleM[1]) : ''
const filename = fileM ? stripHtml(fileM[1]) : ''
items.push({
title,
filename,
size: sizeM ? parseSize(sizeM[1]) : 0,
code: codeM[1]
})
}
return items
}
/** 并发受限地映射 */
async function mapWithConcurrency<T, R>(items: T[], concurrency: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length)
let cursor = 0
async function worker() {
while (cursor < items.length) {
const i = cursor++
out[i] = await fn(items[i])
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()))
return out
}
async function fetchDetail(it: ListItem, ctx: ProviderContext): Promise<SearchResult | null> {
const url = `${cilisousuoBase()}/magnet/${it.code}`
const html = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: Math.min(ctx.timeoutMs, 8000),
headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': DEFAULT_UA }
})
const hashM = html.match(/xt=urn:btih:([a-f0-9]{40})/i)
if (!hashM) return null
const infoHash = hashM[1].toLowerCase()
const fileCountM = html.match(/文件数量[^\d]*<dd>\s*(\d+)/)
const fileCount = fileCountM ? Number(fileCountM[1]) : null
const name = it.title || it.filename || infoHash
return {
infoHash,
name,
size: it.size,
seeders: null,
leechers: null,
fileCount,
category: null,
source: 'cilisousuo',
sourceLabel: '磁力搜索',
sourceId: it.code,
sourceUrl: `${cilisousuoBase()}/magnet/${it.code}`,
addedAt: null,
magnet: buildMagnet(infoHash, name)
} satisfies SearchResult
}
export const cilisousuoProvider: SearchProvider = {
id: 'cilisousuo',
label: '磁力搜索',
homepage: HOMEPAGE,
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `${cilisousuoBase()}/search?q=${encodeURIComponent(query)}`
const html = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'text/html,application/xhtml+xml', 'user-agent': DEFAULT_UA }
})
const items = parseList(html)
if (!items.length) return []
// 列表页不含 infoHash,逐条抓详情页补全磁力链;最多取前 24 条并发抓取
const capped = items.slice(0, 24)
const results = await mapWithConcurrency(capped, 5, (it) => fetchDetail(it, ctx).catch(() => null))
return results.filter((x): x is SearchResult => x !== null)
}
}
+86
View File
@@ -0,0 +1,86 @@
import { loadSettings } from '../appconfig'
import { apibayProvider } from './apibay'
import { cilisousuoProvider } from './cilisousuo'
import { nyaaProvider } from './nyaa'
import { solidtorrentsProvider } from './solidtorrents'
import type { ProviderReport, SearchProvider, SearchResult } from './types'
export const PROVIDERS: SearchProvider[] = [apibayProvider, nyaaProvider, solidtorrentsProvider, cilisousuoProvider]
export function listProviders() {
return PROVIDERS.map((p) => ({ id: p.id, label: p.label, homepage: p.homepage }))
}
function friendlyError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err)
if (/fetch failed|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket hang up/i.test(msg)) {
return '网络不可达,通常是站点被墙,请在「设置」里配置 HTTP 代理后重试'
}
if (/aborted|超时/.test(msg)) return msg
if (/HTTP 5\d\d/.test(msg)) return `源站异常:${msg}`
if (/HTTP 4\d\d/.test(msg)) return `源站拒绝访问:${msg}`
return msg
}
export interface RunSearchOptions {
providers?: string[]
limit?: number
}
export interface RunSearchResult {
results: SearchResult[]
reports: ProviderReport[]
}
export async function runSearch(query: string, options: RunSearchOptions = {}): Promise<RunSearchResult> {
const settings = await loadSettings()
const wanted = options.providers?.length ? options.providers : settings.enabledProviders
const targets = PROVIDERS.filter((p) => wanted.includes(p.id))
const ctx = { proxy: settings.searchProxy || null, timeoutMs: settings.searchTimeout }
const reports = await Promise.all(
targets.map(async (provider): Promise<ProviderReport & { results: SearchResult[] }> => {
const started = Date.now()
try {
const results = await provider.search(query, ctx)
return {
id: provider.id,
label: provider.label,
ok: true,
count: results.length,
ms: Date.now() - started,
error: null,
results
}
} catch (err) {
return {
id: provider.id,
label: provider.label,
ok: false,
count: 0,
ms: Date.now() - started,
error: friendlyError(err),
results: []
}
}
})
)
// 按 infoHash 去重,保留做种数更高的那条
const merged = new Map<string, SearchResult>()
for (const report of reports) {
for (const item of report.results) {
const exists = merged.get(item.infoHash)
if (!exists || (item.seeders ?? 0) > (exists.seeders ?? 0)) merged.set(item.infoHash, item)
}
}
const results = [...merged.values()].sort(
(a, b) => (b.seeders ?? 0) - (a.seeders ?? 0) || b.size - a.size
)
return {
results: options.limit ? results.slice(0, options.limit) : results,
reports: reports.map(({ results: _omit, ...rest }) => rest)
}
}
+94
View File
@@ -0,0 +1,94 @@
import { XMLParser } from 'fast-xml-parser'
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpText } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
const SIZE_UNITS: Record<string, number> = {
b: 1,
kb: 1024,
kib: 1024,
mb: 1024 ** 2,
mib: 1024 ** 2,
gb: 1024 ** 3,
gib: 1024 ** 3,
tb: 1024 ** 4,
tib: 1024 ** 4
}
/** "1.23 GiB" / "456 MB" → 字节数 */
function parseSize(input: unknown): number {
if (typeof input === 'number') return input
const text = String(input || '').trim()
const m = text.match(/^([\d.]+)\s*([a-zA-Z]+)?$/)
if (!m) return 0
const value = Number(m[1]) || 0
const unit = (m[2] || 'b').toLowerCase()
return Math.round(value * (SIZE_UNITS[unit] ?? 1))
}
function nyaaBase() {
return (process.env.NYAA_BASE || 'https://nyaa.si').replace(/\/+$/, '')
}
function asArray<T>(value: T | T[] | undefined): T[] {
if (value === undefined || value === null) return []
return Array.isArray(value) ? value : [value]
}
function pickString(value: unknown): string {
if (value === undefined || value === null) return ''
if (typeof value === 'object' && '#text' in (value as Record<string, unknown>)) {
return String((value as Record<string, unknown>)['#text'] ?? '')
}
return String(value)
}
export const nyaaProvider: SearchProvider = {
id: 'nyaa',
label: 'Nyaa',
homepage: 'https://nyaa.si',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
// c=0_0 全部分类;f=0 只过滤可信发布者之外的全部结果
const url = `${nyaaBase()}/?page=rss&q=${encodeURIComponent(query)}&c=0_0&f=0`
const xml = await httpText(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/rss+xml, application/xml, text/xml', 'user-agent': DEFAULT_UA }
})
const parser = new XMLParser({ removeNSPrefix: true, trimValues: true, parseTagValue: false })
const doc = parser.parse(xml) as Record<string, any>
const items = asArray(doc?.rss?.channel?.item)
return items
.map((item: Record<string, any>) => {
const infoHash = pickString(item.infoHash).toLowerCase()
if (!/^[a-f0-9]{40}$/.test(infoHash)) return null
const name = pickString(item.title) || infoHash
const torrentUrl = pickString(item.guid) || pickString(item.link)
const category = pickString(item.category)
const pubDate = pickString(item.pubDate)
const added = pubDate ? new Date(pubDate) : null
return {
infoHash,
name,
size: parseSize(item.size),
seeders: Number(pickString(item.seeders)) || 0,
leechers: Number(pickString(item.leechers)) || 0,
fileCount: null,
category: category || null,
source: 'nyaa',
sourceLabel: 'Nyaa',
sourceId: pickString(item.link) || null,
sourceUrl: torrentUrl || null,
addedAt: added && !Number.isNaN(added.getTime()) ? added : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
.filter((item): item is SearchResult => item !== null)
}
}
+42
View File
@@ -0,0 +1,42 @@
import { DEFAULT_TRACKERS } from '../torrent'
import { DEFAULT_UA, httpJson } from '../http'
import type { ProviderContext, SearchProvider, SearchResult } from './types'
export const solidtorrentsProvider: SearchProvider = {
id: 'solidtorrents',
label: 'SolidTorrents',
homepage: 'https://solidtorrents.net',
async search(query: string, ctx: ProviderContext): Promise<SearchResult[]> {
const url = `https://solidtorrents.net/api/v1/search?q=${encodeURIComponent(query)}&category=all&sort=seeders`
const raw = await httpJson<any>(url, {
proxy: ctx.proxy,
timeoutMs: ctx.timeoutMs,
headers: { accept: 'application/json', 'user-agent': DEFAULT_UA }
})
if (!raw || !Array.isArray(raw.results)) return []
return raw.results.map((it: any) => {
const infoHash = String(it.infohash).toLowerCase()
const name = String(it.title || infoHash)
return {
infoHash,
name,
size: Number(it.size) || 0,
seeders: Number(it.swarm?.seeders) || 0,
leechers: Number(it.swarm?.leechers) || 0,
fileCount: null, // API doesn't seem to provide this in search list
category: it.category || null,
source: 'solidtorrents',
sourceLabel: 'SolidTorrents',
sourceId: null,
sourceUrl: `https://solidtorrents.to/view/${infoHash}`,
addedAt: it.createdAt ? new Date(it.createdAt) : null,
magnet: `magnet:?xt=urn:btih:${infoHash}&dn=${encodeURIComponent(name)}${DEFAULT_TRACKERS.map(
(t) => `&tr=${encodeURIComponent(t)}`
).join('')}`
} satisfies SearchResult
})
}
}
+37
View File
@@ -0,0 +1,37 @@
export interface SearchResult {
infoHash: string
name: string
size: number
seeders: number | null
leechers: number | null
fileCount: number | null
category: string | null
source: string
sourceLabel: string
sourceId: string | null
sourceUrl: string | null
addedAt: Date | null
magnet: string
}
export interface ProviderContext {
proxy: string | null
timeoutMs: number
}
export interface SearchProvider {
id: string
label: string
/** 该站点所在的站点根地址,便于 UI 上提示 */
homepage: string
search(query: string, ctx: ProviderContext): Promise<SearchResult[]>
}
export interface ProviderReport {
id: string
label: string
ok: boolean
count: number
ms: number
error: string | null
}