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
+242
View File
@@ -0,0 +1,242 @@
import { mkdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import parseTorrent, { toMagnetURI } from 'parse-torrent'
import type { TorrentFileEntry } from './types'
export interface TorrentInfo {
infoHash: string
name: string
size: number
files: TorrentFileEntry[] | null
fileCount: number | null
trackers: string[]
magnet: string
comment: string | null
creationDate: Date | null
pieceLength: number | null
isPrivate: boolean
}
export const DEFAULT_TRACKERS = [
'udp://tracker.opentrackr.org:1337/announce',
'udp://open.tracker.cl:1337/announce',
'udp://open.demonii.com:1337/announce',
'udp://tracker.openbittorrent.com:6969/announce',
'udp://exodus.desync.com:6969/announce',
'udp://tracker.torrent.eu.org:451/announce'
]
export function buildMagnet(infoHash: string, name?: string | null, trackers: string[] = DEFAULT_TRACKERS): string {
const params = [`xt=urn:btih:${infoHash}`]
if (name) params.push(`dn=${encodeURIComponent(name)}`)
for (const tr of trackers) params.push(`tr=${encodeURIComponent(tr)}`)
return `magnet:?${params.join('&')}`
}
/** 从磁力链里取出已有的 tracker */
function trackersInMagnet(magnet: string): string[] {
const out = new Set<string>()
for (const m of magnet.matchAll(/[?&]tr=([^&]+)/g)) {
try {
out.add(decodeURIComponent(m[1]))
} catch {
/* 编码坏了就跳过 */
}
}
return [...out]
}
/**
* 给没有 tracker 的磁力链补上 tracker。
* 社区里流传的裸磁力链(只有 xt + dn)常常一个 tracker 都没有,
* 纯靠 DHT 在家庭网络/被墙环境里几乎拉不到元数据,补公共 tracker 能显著提高成功率。
*/
export function withDefaultTrackers(magnet: string, extra: string[] = []): string {
if (!magnet || /[?&]tr=/.test(magnet)) return magnet
const list = extra.length ? extra : DEFAULT_TRACKERS
return magnet + list.map((t) => `&tr=${encodeURIComponent(t)}`).join('')
}
function toInfoHash(value: unknown): string {
if (!value) return ''
if (typeof value === 'string') return value.toLowerCase()
if (value instanceof Uint8Array || Buffer.isBuffer(value)) return Buffer.from(value as Uint8Array).toString('hex')
return String(value).toLowerCase()
}
function pickTrackers(parsed: Record<string, unknown>): string[] {
const out = new Set<string>()
const announce = parsed.announce
if (typeof announce === 'string') out.add(announce)
else if (Array.isArray(announce)) for (const a of announce) if (typeof a === 'string') out.add(a)
const list = parsed.announceList
if (Array.isArray(list)) {
for (const tier of list) {
if (Array.isArray(tier)) for (const a of tier) if (typeof a === 'string') out.add(a)
}
}
return [...out]
}
function normalize(parsed: Record<string, any>, magnetFallback?: string): TorrentInfo {
const infoHash = toInfoHash(parsed.infoHash)
let files: TorrentFileEntry[] | null = null
if (Array.isArray(parsed.files) && parsed.files.length) {
files = parsed.files.map((f: any) => ({
// parse-torrent 用 path.sep 拼接,Windows 下是反斜杠,统一成正斜杠便于展示
path: String(f.path ?? f.name ?? '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
} else if (typeof parsed.length === 'number') {
// 单文件种子:parse-torrent 只给出 length + name
files = [{ path: String(parsed.name || 'unknown'), length: Number(parsed.length) || 0 }]
}
const size = files ? files.reduce((sum, f) => sum + (f.length || 0), 0) : Number(parsed.length) || 0
let trackers = pickTrackers(parsed)
let magnet = magnetFallback || ''
if (!magnet) {
try {
magnet = toMagnetURI(parsed as never)
} catch {
magnet = infoHash ? buildMagnet(infoHash, parsed.name, trackers.length ? trackers : DEFAULT_TRACKERS) : ''
}
}
magnet = withDefaultTrackers(magnet, trackers)
// 补过 tracker 的磁力链,trackers 以最终磁力链为准,避免 UI 显示“无 tracker”
if (!trackers.length) trackers = trackersInMagnet(magnet)
return {
infoHash,
name: String(parsed.name || ''),
size,
files,
fileCount: files ? files.length : null,
trackers,
magnet,
comment: typeof parsed.comment === 'string' ? parsed.comment : null,
creationDate: parsed.created instanceof Date
? parsed.created
: parsed.creationDate instanceof Date
? parsed.creationDate
: null,
pieceLength: typeof parsed.pieceLength === 'number' ? parsed.pieceLength : null,
isPrivate: parsed.private === true
}
}
/** 解析 .torrent 文件内容(parse-torrent v11 起为异步 API) */
export async function parseTorrentBuffer(buf: Buffer): Promise<TorrentInfo> {
let parsed: any
try {
parsed = await parseTorrent(buf)
} catch {
throw new Error('种子文件解析失败,可能不是合法的 .torrent 文件')
}
const info = normalize(parsed)
if (!info.infoHash) throw new Error('种子文件中缺少 info 字段,无法计算 infoHash')
return info
}
/** 解析磁力链(不含文件清单,需要通过 fetchMetadataFromMagnet 才能拿到) */
export async function parseMagnet(uri: string): Promise<TorrentInfo> {
const raw = uri.trim()
if (!/^magnet:\?/i.test(raw)) throw new Error('不是合法的磁力链接,应以 magnet:? 开头')
if (!/xt=urn:btih:[a-z0-9]+/i.test(raw)) throw new Error('磁力链接中缺少 xt=urn:btih: 参数')
let parsed: any
try {
parsed = await parseTorrent(raw)
} catch {
throw new Error('磁力链接解析失败')
}
const info = normalize(parsed, raw)
if (!info.infoHash) throw new Error('无法从磁力链接中提取 infoHash')
return info
}
/** 判断字符串是否为磁力链 */
export function isMagnet(input: string): boolean {
return /^magnet:\?/i.test(input.trim())
}
let webTorrentMod: unknown = null
export async function loadWebTorrent(): Promise<any> {
if (webTorrentMod) return webTorrentMod
try {
const mod: any = await import('webtorrent')
webTorrentMod = mod?.default || mod
return webTorrentMod
} catch {
throw new Error('未安装 webtorrent 依赖,无法通过磁力链在线获取文件清单(仅能显示 infoHash)')
}
}
export function hasWebTorrent(): boolean {
return webTorrentMod !== null
}
/** 探测 webtorrent 依赖是否可用(用于健康检查与 UI 提示) */
export async function checkWebTorrent(): Promise<boolean> {
try {
await loadWebTorrent()
return true
} catch {
return false
}
}
/**
* 通过磁力链从 DHT / tracker 拉取种子元数据,从而得到文件清单。
* 只取元数据,取到后立刻销毁客户端并清理临时目录。
*/
export async function fetchMetadataFromMagnet(
magnetUri: string,
timeoutMs = 30000
): Promise<{ name: string; files: TorrentFileEntry[]; size: number }> {
const WebTorrent = await loadWebTorrent()
const dir = join(tmpdir(), `dsl-meta-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`)
await mkdir(dir, { recursive: true })
const client = new WebTorrent({ dht: true, lsd: false, utp: false })
try {
// 纯 DHT 很可能找不到节点,没 tracker 的磁力链先补上公共 tracker
const torrent = client.add(withDefaultTrackers(magnetUri), { path: dir })
torrent.on('warning', () => {})
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`获取种子元数据超时(${Math.round(timeoutMs / 1000)}s),可能没有可用节点或 tracker 被墙`))
}, timeoutMs)
const done = (fn: () => void) => {
clearTimeout(timer)
fn()
}
if (torrent.metadata) return done(resolve)
torrent.on('metadata', () => done(resolve))
torrent.on('error', (err: Error) => done(() => reject(err)))
client.on('error', (err: Error) => done(() => reject(err)))
})
const files: TorrentFileEntry[] = (torrent.files || []).map((f: any) => ({
// Windows 下 webtorrent 用反斜杠拼路径,统一成正斜杠便于展示
path: String(f.path || f.name || '').replace(/\\/g, '/'),
length: Number(f.length) || 0
}))
return {
name: String(torrent.name || ''),
files,
size: files.reduce((sum, f) => sum + f.length, 0)
}
} finally {
try {
// webtorrent v3 的 destroy 是回调式 API
await new Promise<void>((resolve) => client.destroy(() => resolve()))
} catch {
/* ignore */
}
rm(dir, { recursive: true, force: true }).catch(() => {})
}
}