// NexusPHP 默认脚本 // 适用: carpt / cbg / ptfans / ptskit / hdfans / cyanbug / qingwa 等 // medibo 负责 HTTP,脚本只做解析 + 状态码判断 // ==================== medibo 入口 ==================== // medibo 根据 pageKey 调用对应方法,如 parseSync(context) // 约定方法名: parse + pageKey 首字母大写 // medibo 直接调用的入口方法 Map parseSync(context) { if (context.status != 200) return [matched: false, error: true, status: context.status] def doc = Jsoup.parse(context.content) def profile = parseUserProfile(doc) // 如果需要详情,通过 context.fetch 子请求 if (profile.matched && false /* 需要详情 */) { def resp = context.fetch("detail", [id: "xxx"]) if (resp.status == 200) profile.detail = parseDetailPage(Jsoup.parse(resp.content)) } return profile } Map parseSearch(context) { return checkStatus(context) ?: parseSearchPage(Jsoup.parse(context.content)) } Map parseSignIn(context) { return checkStatus(context) ?: parseSignPage(Jsoup.parse(context.content)) } Map parseDownload(context) { return checkStatus(context) ?: parseDownloadPage(context) } Map parseRss(context) { return checkStatus(context) ?: parseRssPage(context) } Map parseDetail(context) { return checkStatus(context) ?: parseDetailPage(Jsoup.parse(context.content)) } /** 获取资源元数据:搜索 → 详情 → 提取豆瓣链接(Douban 解析由 Java 层负责) */ Map parseMetadata(context) { if (context.status != 200) return [matched: false, error: true] // 1. 搜索 String title = context.params?.title ?: '' def searchResp = context.fetch("search", [keyword: title]) if (searchResp.status != 200) return [matched: false, message: '搜索失败'] def searchItems = parseSearchPage(Jsoup.parse(searchResp.content)).items if (!searchItems || searchItems.isEmpty()) return [matched: false, message: '未搜到'] // 2. 取第一个结果 → 访问详情(复用 parseDetailPage 统一解析) def first = searchItems[0] def detailResp = context.fetch("detail", [id: first.detailUrl]) if (detailResp.status != 200) return [matched: false, message: '详情页访问失败'] def detail = parseDetailPage(Jsoup.parse(detailResp.content)) return detail.doubanUrl ? [matched: true, doubanUrl: detail.doubanUrl, title: first.title, seedTitle: first.title] : [matched: false, message: '未找到豆瓣链接', seedTitle: first.title] } /** 通用状态码检查,非 200 直接返回错误 */ private Map checkStatus(context) { if (context.status == 200) return null return switch(context.status) { case 302 -> [matched: false, redirect: context.headers?.Location ?: ''] case 403 -> [matched: false, banned: true, message: 'IP被禁'] case 429 -> [matched: false, rateLimited: true] default -> [matched: false, error: true, status: context.status, message: "HTTP ${context.status}"] } } private Map parseUserProfile(doc) { def fields = extractUserInfo(doc) if (fields.isEmpty()) return [pageName: '用户信息', matched: false, message: '未提取到数据'] def result = [pageName: '用户信息', matched: true] if (fields.upload) result.upload = parseTraffic(fields.upload) if (fields.download) result.download = parseTraffic(fields.download) if (fields.ratio) result.shareRatio = fields.ratio == '无限' ? 999.0 : parseDouble(fields.ratio) if (fields.bonus) result.bonus = parseDouble(fields.bonus) if (fields.seeding) result.seeding = fields.seeding as int if (fields.leeching) result.leeching = fields.leeching as int if (fields.invites) result.invites = fields.invites as int if (fields.hnr) result.hnr = fields.hnr as int return result } // ==================== 搜索页 ==================== private Map parseSearchPage(doc) { def items = [] doc.select('table.torrents tr:has(td.rowfollow):not(:has(.colhead))').each { row -> def titleEl = row.selectFirst('td.embedded a[href*=details]') if (!titleEl) return def freeEl = row.selectFirst('font.promotion') def tds = row.select('td.rowfollow') def size = '', seeders = '', leechers = '', completed = '', time = '' for (int i = 0; i < tds.size(); i++) { def cell = tds[i].text() if (!cell) continue if (cell.contains('GB')||cell.contains('MB')||cell.contains('TB')||cell.contains('KB')) size = cell else if (cell ==~ /^\d+$/ && !seeders) seeders = cell else if (cell ==~ /^\d+$/ && seeders && !leechers) leechers = cell else if (cell ==~ /^\d+$/ && leechers) completed = cell else if (cell ==~ /^\d+年|^\d+月|^\d+天|^\d+时/) time = cell } items << [ title: titleEl.text(), detailUrl: titleEl.attr('href'), free: freeEl?.text(), size: parseSize(size), seeders: seeders as int, leechers: leechers as int, grabs: completed as int, publishDate: time, ] } return [pageName: '搜索', matched: !items.isEmpty(), items: items] } // ==================== 种子详情 ==================== private Map parseDetailPage(doc) { def title = doc.selectFirst('#top h1, h1')?.text() def subtitle = doc.selectFirst('td.embedded b')?.text() def size = doc.selectFirst('td:has(b:contains(大小)) + td, td.rowhead:contains(大小) + td')?.text() def seeders = doc.selectFirst('td:contains(做种者) + td')?.text() def leechers = doc.selectFirst('td:contains(下载者) + td')?.text() // 提取下载链接 def dlUrl = doc.selectFirst('a[href*=download]:not([href*=details])')?.attr('href') // 提取促销信息:font.promotion、img.promotion、alt 属性等 def freeEl = doc.selectFirst('font.promotion, img.promotion, img[alt*=Free], img[alt*=free]') def free = freeEl?.text() ?: freeEl?.attr('alt') // 提取豆瓣链接 def dbLink = doc.selectFirst('a[href*=movie.douban.com], a[href*=douban.com/subject]')?.attr('href') if (!dbLink) dbLink = doc.selectFirst('.rowhead:contains(简介) + td a[href*=douban], #kdescr a[href*=douban]')?.attr('href') return [pageName: '种子详情', matched: title != null, title: title, subtitle: subtitle, size: parseSize(size), seeders: seeders as int, leechers: leechers as int, downloadUrl: dlUrl, free: free, doubanUrl: dbLink ] } // ==================== 签到 ==================== private Map parseSignPage(doc) { def t = doc.body().text() def matched = t ==~ /.*(签到成功|已签到|重复签到|今日已签|已经签到|获得.*).*/ return [pageName: '签到', matched: matched, sign: true, message: matched ? '成功' : '结果未匹配'] } // ==================== 下载 ==================== private Map parseDownloadPage(context) { // 下载页通常是重定向到种子文件 if (context.status == 302 || context.status == 301) { return [pageName: '下载', matched: false, redirect: context.headers?.Location ?: ''] } if (context.headers?.'Content-Type'?.contains('octet-stream')) { return [pageName: '下载', matched: true, downloaded: true, size: context.content.length()] } return [pageName: '下载', matched: false, error: true, status: context.status, message: "非种子文件 response"] } // ==================== RSS ==================== /** RSS 页面解析:解析 NexusPHP 标准 RSS XML,返回 items 列表 */ private Map parseRssPage(context) { def xml = context.content if (!xml) return [pageName: 'RSS', matched: false, message: '无内容'] // 解析 RSS XML def rss = new XmlSlurper().parseText(xml) def items = [] rss.channel.item.each { item -> // 下载链接优先从 enclosure url 获取 def dlUrl = '' def size = 0 def enclosure = item.enclosure if (enclosure && enclosure.@url) { dlUrl = enclosure.@url.text() try { size = enclosure.@length.toLong() } catch (e) {} } if (!dlUrl) dlUrl = item.link.text() def title = item.title.text() items << [ title: title, detailUrl: item.link.text(), downloadUrl: dlUrl, torrentUrl: dlUrl, size: size, publishDate: item.pubDate.text(), category: item.category.text(), author: item.author.text(), ] } return [pageName: 'RSS', matched: !items.isEmpty(), items: items] } // ==================== 工具方法 ==================== /** 从 #info_block 提取原始字段 */ private Map extractUserInfo(doc) { def fields = [:] def el = doc.selectFirst('#info_block') if (!el) return fields def t = el.text() def um = t =~ /上传[量]?[::]\s*([0-9.,]+)\s*(TB|GB|MB|KB|B)?/ def dm = t =~ /下载[量]?[::]\s*([0-9.,]+)\s*(TB|GB|MB|KB|B)?/ def rm = t =~ /分享率[::]\s*([\d.,]+|无限)/ def bm = t =~ /(?:魔力[值]?|时魔|蝌蚪)\s*[^0-9]*[::]\s*([\d.,]+)/ def im = t =~ /邀请[^0-9]*[::]\s*(\d+)/ def seed = null, leech = null def sm = t =~ /⬆\s*(\d+)/; if (sm) seed = sm[0][1] def lm = t =~ /⬇\s*(\d+)/; if (lm) leech = lm[0][1] if (seed == null || leech == null) { def am = t =~ /当前活动[^0-9]*[::]\s*(\d+)\s+(\d+)/ if (am) { seed = am[0][1]; leech = am[0][2] } } if (seed == null) { sm = t =~ /当前做种[^0-9]*(\d+)/; if (sm) seed = sm[0][1] } if (leech == null) { lm = t =~ /当前下载[^0-9]*(\d+)/; if (lm) leech = lm[0][1] } if (um) fields.upload = um[0][1] + (um[0][2] ?: '') if (dm) fields.download = dm[0][1] + (dm[0][2] ?: '') if (rm) fields.ratio = rm[0][1] if (bm) fields.bonus = bm[0][1] if (seed != null) fields.seeding = seed if (leech != null) fields.leeching = leech if (im) fields.invites = im[0][1] def hrm = t =~ /H&R[::]\s*\[\d+\/\d+\/(\d+)\]/ if (hrm) fields.hnr = hrm[0][1] else { def hrEl = doc.selectFirst('#info_block font[class=fcr], #info_block font[color=red], #info_block .red') if (hrEl) { def hrm2 = hrEl.text() =~ /(\d+)/ if (hrm2) fields.hnr = hrm2[0][1] } } return fields } private double parseDouble(s) { try { return (s as String).replace(',','') as double } catch (e) { return 0 } } private long parseSize(s) { if (!s) return 0 try { def m = s =~ /([0-9.,]+)\s*(GB|MB|TB|KB|B)?/i if (!m) return 0 def v = parseDouble(m[0][1]) def u = (m[0][2] ?: 'B').toUpperCase() return (v * [B:1, KB:1024, MB:1024*1024, GB:1024L*1024*1024, TB:1024L*1024*1024*1024][u]) as long } catch (e) { return 0 } } private long parseTraffic(s) { return parseSize(s) } private String encode(String s) { try { return URLEncoder.encode(s, 'UTF-8') } catch (e) { return s } }