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 = { 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(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/�?39;|'/g, "'") .replace(/"/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 = /
  • ([\s\S]*?)<\/li>/g let m: RegExpExecArray | null while ((m = liRe.exec(html))) { const block = m[1] const titleM = block.match(/
    ([\s\S]*?)<\/div>/) const fileM = block.match(/
    ([\s\S]*?)<\/div>/) const sizeM = block.match(/
    ([\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(items: T[], concurrency: number, fn: (item: T) => Promise): Promise { 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 { 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]*
    \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 { 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) } }