feat: DSL 磁力聚合 — 搜索/解析/预览/投递 + cilisousuo 源 + 中英双语 + Docker 部署
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(/ /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 = /<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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user