diff --git a/par-api/src/main/java/com/par/api/controller/AdapterController.java b/par-api/src/main/java/com/par/api/controller/AdapterController.java index 05bce26..ec43ebf 100644 --- a/par-api/src/main/java/com/par/api/controller/AdapterController.java +++ b/par-api/src/main/java/com/par/api/controller/AdapterController.java @@ -26,6 +26,7 @@ public class AdapterController { private final SiteConfigMapper siteConfigMapper; private final AccessResultMapper accessResultMapper; private final CachedStorageService storageService; + private final com.par.core.mapper.SiteMapper siteMapper; /** * 用指定适配配置解析指定访问结果 @@ -113,21 +114,28 @@ public class AdapterController { item.put("invalidCount", allResults.stream().filter(r -> url.equals(r.getUrl()) && Boolean.FALSE.equals(r.getIsValid())).count()); item.put("uncheckedCount", allResults.stream().filter(r -> url.equals(r.getUrl()) && r.getIsValid() == null).count()); - // 解析内容:必须有 Groovy 脚本 + // 解析内容:优先自定义脚本,否则用站点类型默认脚本 try { - if (siteConfig.getScriptStoragePath() == null || siteConfig.getScriptStoragePath().isBlank()) { - item.put("parsed", Map.of("matched", false, "message", "该配置未绑定 Groovy 脚本")); + String script; + if (siteConfig.getScriptStoragePath() != null && !siteConfig.getScriptStoragePath().isBlank()) { + script = storageService.read(siteConfig.getScriptStoragePath()); } else { - String script = storageService.read(siteConfig.getScriptStoragePath()); - if (script == null || script.isBlank()) { - item.put("parsed", Map.of("matched", false, "message", "Groovy 脚本为空")); + script = null; + } + if (script == null || script.isBlank()) { + // 加载站点类型默认脚本 + var site = siteMapper.selectBySiteId(siteId); + String st = site != null && site.getSiteType() != null ? site.getSiteType().getValue() : null; + script = GroovyScriptEngine.loadDefaultByType(st); + } + if (script == null || script.isBlank()) { + item.put("parsed", Map.of("matched", false, "message", "未绑定脚本且无默认模板")); + } else { + String content = storageService.read(ar.getStoragePath()); + if (content == null) { + item.put("parsed", Map.of("matched", false, "message", "内容为空")); } else { - String content = storageService.read(ar.getStoragePath()); - if (content == null) { - item.put("parsed", Map.of("matched", false, "message", "内容为空")); - } else { - item.put("parsed", groovyEngine.execute(script, content, url)); - } + item.put("parsed", groovyEngine.execute(script, content, url)); } } } catch (Exception e) { diff --git a/par-core/src/main/java/com/par/core/adapter/GroovyScriptEngine.java b/par-core/src/main/java/com/par/core/adapter/GroovyScriptEngine.java index 6a06e8a..aea4ae3 100644 --- a/par-core/src/main/java/com/par/core/adapter/GroovyScriptEngine.java +++ b/par-core/src/main/java/com/par/core/adapter/GroovyScriptEngine.java @@ -23,11 +23,40 @@ public class GroovyScriptEngine { private static final ObjectMapper objectMapper = new ObjectMapper(); private static final GroovyClassLoader classLoader = new GroovyClassLoader( Thread.currentThread().getContextClassLoader()); + /** 类型 → 默认脚本 */ + private static final Map DEFAULT_SCRIPTS = new ConcurrentHashMap<>(); /** 脚本前缀:自动导入必要包 + PluginContext 桩 */ private static final String SCRIPT_PREFIX = "import groovy.json.*\n" + "import com.par.core.adapter.*\n"; + /** + * 根据站点 ID 加载默认脚本(按站点类型匹配) + */ + public String loadDefaultScript(String siteId) { + // 需要从外部注入 siteMapper,这里用静态方法获取 Spring Bean + // 实际由 AdapterController 调用时传入 + return null; // placeholder, 由 AdapterController 实现具体逻辑 + } + + /** + * 从 classpath 加载默认脚本文件 + */ + public static String loadDefaultByType(String siteType) { + if (siteType == null) return null; + String key = siteType.toLowerCase(); + return DEFAULT_SCRIPTS.computeIfAbsent(key, k -> { + String path = "scripts/default-" + k + ".groovy"; + try (var in = GroovyScriptEngine.class.getClassLoader().getResourceAsStream(path)) { + if (in == null) return null; + return new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + log.warn("Failed to load default script for type: {}", k, e); + return null; + } + }); + } + @SuppressWarnings("unchecked") public Map execute(String groovyScript, String content, String url) { Map result = new LinkedHashMap<>(); diff --git a/par-core/src/main/resources/scripts/default-gazelle.groovy b/par-core/src/main/resources/scripts/default-gazelle.groovy new file mode 100644 index 0000000..70c2525 --- /dev/null +++ b/par-core/src/main/resources/scripts/default-gazelle.groovy @@ -0,0 +1,111 @@ +// Gazelle 站点默认脚本 +// 适用: RED / OPS / BHD / PTP 等 Gazelle 架构站点 +// 覆盖: 搜索 / 种子详情 / 首页 + +Map parse(String content, String url) { + def doc = jsoup.parse(content) + + if (url.contains('/torrents.php')) return parseSearch(doc) + if (url.contains('/torrents.php?id=')) return parseDetail(doc) + if (url.contains('/index.php') && !url.contains('?')) return parseUserInfo(doc) + + return [pageName: '未知页面', message: "未配置: ${url}"] +} + +private Map parseUserInfo(doc) { + def fields = [:] + def stats = utils.selectFirst(doc, '#userinfo_stats, .user_stats, ul.stats') + if (stats) { + def t = utils.text(stats) + def um = t =~ /上传[::]\s*([0-9.]+)\s*(TB|GB|MB|KB|B)?/i + def dm = t =~ /下载[::]\s*([0-9.]+)\s*(TB|GB|MB|KB|B)?/i + def rm = t =~ /分享率[::]\s*([\d.]+)/ + 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] + } + return [pageName: '用户信息', fields: fields] +} + +private Map parseSearch(doc) { + def rows = [] + doc.select('table#torrent_table > tbody > tr.group, .torrent_table tr.torrent').each { row -> + def titleEl = utils.selectFirst(row, 'a[href*=torrents.php?id=]') + rows << [ + title: utils.text(titleEl), + detailUrl: utils.attr(titleEl, 'href'), + size: utils.text(utils.selectFirst(row, 'td.number_column.nobr, td.size')), + seeders: utils.text(utils.selectFirst(row, 'td.seeders')), + leechers: utils.text(utils.selectFirst(row, 'td.leechers')), + snatched: utils.text(utils.selectFirst(row, 'td.snatched')), + uploadTime: utils.text(utils.selectFirst(row, 'td.time, td.uploaded')), + ] + } + return [pageName: '搜索', extracted: rows, message: "共 ${rows.size()} 条"] +} + +private Map parseDetail(doc) { + return [pageName: '种子详情', fields: [ + title: utils.text(utils.selectFirst(doc, '#content h2, h2.header')), + artist: utils.text(utils.selectFirst(doc, '#content h3 a, .artist_name')), + format: utils.text(utils.selectFirst(doc, 'td:contains(Format) + td')), + bitrate: utils.text(utils.selectFirst(doc, 'td:contains(Bitrate) + td')), + size: utils.text(utils.selectFirst(doc, 'td:contains(Size) + td')), + seeders: utils.text(utils.selectFirst(doc, 'td:contains(Seeders) + td')), + leechers: utils.text(utils.selectFirst(doc, 'td:contains(Leechers) + td')), + ]] +} + +Map sync(PluginContext ctx) { + if (!ctx.site.auth) return [message: "未配置认证", loginRequired: true] + try { + def conn = ctx.httpGet('/index.php') + def doc = jsoup.parse(conn.body()) + def stats = utils.selectFirst(doc, '#userinfo_stats, .user_stats, ul.stats') + if (!stats) return [message: "未找到用户信息"] + def t = utils.text(stats) + def result = [:] + def um = t =~ /上传[::]\s*([0-9.]+)\s*(TB|GB|MB|KB|B)?/i + def dm = t =~ /下载[::]\s*([0-9.]+)\s*(TB|GB|MB|KB|B)?/i + def rm = t =~ /分享率[::]\s*([\d.]+)/ + if (um) result.upload = parseTraffic(um[0][1] as double, um[0][2]) + if (dm) result.download = parseTraffic(dm[0][1] as double, dm[0][2]) + if (rm) result.shareRate = rm[0][1] as double + return result + } catch (e) { return [message: "同步失败: ${e.message}"] } +} + +Map sign(PluginContext ctx) { + return [message: "Gazelle 不支持签到"] +} + +List search(PluginContext ctx, Map params) { + if (!ctx.site.auth) return [] + try { + def keyword = params.keyword ?: '' + def conn = ctx.httpGet("/torrents.php?searchstr=${URLEncoder.encode(keyword,'UTF-8')}") + def doc = jsoup.parse(conn.body()) + def rows = [] + doc.select('table#torrent_table > tbody > tr.group, .torrent_table tr.torrent').each { row -> + def titleEl = utils.selectFirst(row, 'a[href*=torrents.php?id=]') + rows << [ + title: utils.text(titleEl), + detailUrl: "${ctx.siteUrl}${utils.attr(titleEl,'href')}", + size: parseHtmlSize(utils.text(utils.selectFirst(row, 'td.size'))), + seeders: utils.toInt(utils.text(utils.selectFirst(row, 'td.seeders'))) ?: 0, + leechers: utils.toInt(utils.text(utils.selectFirst(row, 'td.leechers'))) ?: 0, + snatched: utils.toInt(utils.text(utils.selectFirst(row, 'td.snatched'))) ?: 0, + ] + } + return rows + } catch (e) { return [] } +} + +private long parseTraffic(double val, String unit) { + def m = [B:1,KB:1024,MB:1048576,GB:1073741824,TB:1099511627776] + return (val * (m[unit?.toUpperCase()] ?: 1)) as long +} +private long parseHtmlSize(String s) { + def m = s =~ /([\d.]+)\s*(TB|GB|MB|KB|B)?/ + return m ? parseTraffic(m[0][1] as double, m[0][2]) : 0 +} diff --git a/par-core/src/main/resources/scripts/default-mtorrent.groovy b/par-core/src/main/resources/scripts/default-mtorrent.groovy new file mode 100644 index 0000000..1a3359b --- /dev/null +++ b/par-core/src/main/resources/scripts/default-mtorrent.groovy @@ -0,0 +1,125 @@ +// MTorrent 站点默认脚本 +// 适用: M-Team 及其 API 兼容站点 +// 覆盖: 用户信息 / 做种统计 / 搜索 + +final String API_BASE = "https://api.m-team.io" + +Map parse(String content, String url) { + Map root + try { root = json.readValue(content, Map) } + catch (e) { return [pageName: '解析失败', message: 'JSON异常: ' + (e.message ?: e.toString())] } + + if (url.contains('/api/member/profile')) return parseProfile(root?.data) + if (url.contains('/api/tracker/myPeerStatistics')) return parsePeerStats(root?.data) + if (url.contains('/api/torrent/search')) return parseSearch(root?.data?.data ?: []) + + return [pageName: '未知接口', message: "未配置: ${url}"] +} + +private Map parseProfile(d) { + if (!d) return [pageName: '用户信息', message: 'data 为空'] + return [pageName: '用户信息', fields: [ + uid: d.id?.toString(), + username: d.username?.toString(), + email: d.email?.toString(), + upload: utils.formatSize((d.memberCount?.uploaded ?: 0) as long), + download: utils.formatSize((d.memberCount?.downloaded ?: 0) as long), + bonus: d.memberCount?.bonus?.toString(), + shareRate:d.memberCount?.shareRate?.toString(), + invites: d.invites?.toString(), + ]] +} + +private Map parsePeerStats(d) { + if (!d) return [pageName: '做种统计', message: 'data 为空'] + return [pageName: '做种统计', fields: [seeding: d.seederCount?.toString() ?: '0']] +} + +private Map parseSearch(List items) { + if (items.isEmpty()) return [pageName: '搜索', message: '无结果'] + def rows = items.collect { it -> + def st = it.status ?: [:] + return [ + id: it.id?.toString(), + title: it.name ?: '', + subTitle: it.smallDescr ?: '', + size: utils.formatSize((it.size ?: 0) as long), + seeders: (st.seeders ?: 0) as int, + leechers: (st.leechers ?: 0) as int, + completed: (st.timesCompleted ?: 0) as int, + category: it.category ?: '', + free: st.discount ?: 'NORMAL', + publisher: it.author ?: '', + cnSub: it.hasChineseSubtitle?.toString() ?: '', + standard: it.standard ?: '', + ] + } + return [pageName: '搜索', extracted: rows, message: "共 ${rows.size()} 条"] +} + +Map sync(PluginContext ctx) { + if (!ctx.site.authorization) return [message: "未配置 Token", loginRequired: true] + try { + def resp = apiPost(ctx, "/api/member/profile") + if (resp.statusCode() in [401,403]) return [message: "Token 无效", loginRequired: true] + if (resp.statusCode() != 200) return [message: "HTTP ${resp.statusCode()}", siteUnavailable: true] + def json = new groovy.json.JsonSlurper().parseText(resp.body()) + def d = json.data + def mc = d.memberCount ?: [:] + def result = [ + upload: mc.uploaded ? (mc.uploaded as long) * 1073741824 : null, + download: mc.downloaded ? (mc.downloaded as long) * 1073741824 : null, + bonus: mc.bonus ? mc.bonus as double : null, + shareRate: mc.shareRate ? mc.shareRate as double : null, + invites: d.invites ? d.invites as int : null, + ] + def seedResp = apiPost(ctx, "/api/tracker/myPeerStatistics") + if (seedResp.statusCode() == 200) { + def sj = new groovy.json.JsonSlurper().parseText(seedResp.body()) + if (sj?.data?.seederCount != null) result.seeding = sj.data.seederCount as int + } + return result + } catch (e) { return [message: "同步失败: ${e.message}"] } +} + +Map sign(PluginContext ctx) { + return [message: "M-Team 不支持签到"] +} + +List search(PluginContext ctx, Map params) { + if (!ctx.site.authorization) return [] + try { + def body = groovy.json.JsonOutput.toJson([ + keyword: params.keyword ?: '', pageNumber: (params.page ?: 1) as int, + pageSize: 100, mode: "normal", visible: 1 + ]) + def resp = apiPost(ctx, "/api/torrent/search", body) + def list = new groovy.json.JsonSlurper().parseText(resp.body())?.data?.data ?: [] + return list.collect { item -> + def st = item.status ?: [:] + return [ + title: item.name ?: "", + detailUrl: "${ctx.siteUrl}detail/${item.id}", + size: (item.size ?: 0) as long, + seeders: (st.seeders ?: 0) as int, + leechers: (st.leechers ?: 0) as int, + grabs: (st.timesCompleted ?: 0) as int, + category: item.category ?: "", + free: st.discount == "FREE" || st.discount == "_2X_FREE", + publishDate: item.createdDate ?: "", + ] + } + } catch (e) { return [] } +} + +private def apiPost(PluginContext ctx, String path, String body = null) { + def builder = java.net.http.HttpRequest.newBuilder() + .uri(new java.net.URI(API_BASE + path)) + .timeout(java.time.Duration.ofSeconds(20)) + .header("User-Agent", "Mozilla/5.0") + .header("x-api-key", ctx.site.authorization) + .header("Content-Type", "application/json") + if (body) builder.POST(java.net.http.HttpRequest.BodyPublishers.ofString(body)) + else builder.POST(java.net.http.HttpRequest.BodyPublishers.noBody()) + return ctx.httpClient.send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString()) +} diff --git a/par-core/src/main/resources/scripts/default-nexusphp.groovy b/par-core/src/main/resources/scripts/default-nexusphp.groovy new file mode 100644 index 0000000..55e8f64 --- /dev/null +++ b/par-core/src/main/resources/scripts/default-nexusphp.groovy @@ -0,0 +1,144 @@ +// NexusPHP 站点默认脚本 +// 适用: PThome / HDSky / OurBits / HDFans / 聆音 等 +// 覆盖: 搜索 / 详情 / 用户信息 / 签到 + +Map parse(String content, String url) { + def doc = jsoup.parse(content) + + if (url.contains('/torrents.php')) return parseSearch(doc) + if (url.contains('/details.php')) return parseDetail(doc) + if (url.contains('/attendance.php')) return parseSign(doc) + if (url.contains('/index.php') && !url.contains('?')) return parseUserInfo(doc) + if (url.contains('/usercp.php')) return parseUserPanel(doc) + + return [pageName: '未知页面', message: "未配置: ${url}"] +} + +private Map parseUserInfo(doc) { + def fields = [:] + def el = utils.selectFirst(doc, '#info_block') + if (el) { + def t = utils.text(el) + 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*([\d,]+)/ + 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] + } + return [pageName: '用户信息', fields: fields] +} + +private Map parseSearch(doc) { + def rows = [] + doc.select('table.torrents > tbody > tr:has(td.rowfollow):not(:has(th)):not(:has(.colhead))').each { row -> + def titleEl = utils.selectFirst(row, 'td.embedded a[href*=details]') + def catEl = utils.selectFirst(row, 'td.rowfollow a[href*=cat] img, td.rowfollow a[href*=torrents] img') + def freeEl = utils.selectFirst(row, 'td.rowfollow font.free') + def tds = row.select('td.rowfollow') + rows << [ + title: utils.text(titleEl), + detailUrl: utils.attr(titleEl, 'href'), + category: utils.attr(catEl, 'alt'), + free: utils.text(freeEl), + size: tds.size() > 4 ? utils.text(tds.get(4)) : '', + seeders: utils.text(tds.size() > 5 ? tds.get(5) : null), + leechers: utils.text(tds.size() > 6 ? tds.get(6) : null), + completed: utils.text(tds.size() > 7 ? tds.get(7) : null), + publishDate:utils.text(tds.size() > 3 ? tds.get(3) : null), + ] + } + return [pageName: '搜索', extracted: rows, message: "共 ${rows.size()} 条"] +} + +private Map parseDetail(doc) { + return [pageName: '种子详情', fields: [ + title: utils.text(utils.selectFirst(doc, '#top h1, h1')), + subtitle: utils.text(utils.selectFirst(doc, 'td.embedded b')), + size: utils.text(utils.selectFirst(doc, 'td:has(b:contains(大小)) + td, td.rowhead:contains(大小) + td')), + seeders: utils.text(utils.selectFirst(doc, 'td:contains(做种者) + td')), + leechers: utils.text(utils.selectFirst(doc, 'td:contains(下载者) + td')), + ]] +} + +private Map parseSign(doc) { + def t = utils.text(doc.body()) + def matched = t ==~ /.*(签到成功|已签到|重复签到|获得.*魔力值).*/ + return [pageName: '签到', fields: [result: t.take(200)], message: matched ? '成功' : '未知'] +} + +private Map parseUserPanel(doc) { + def fields = [:] + doc.select('td.text').each { td -> + def t = utils.text(td) + if (t.contains('分享率')) fields.shareRate = t.replaceAll(/[^0-9.]/, '') + if (t.contains('魔力')) fields.bonus = t.replaceAll(/[^0-9.,]/, '') + } + return [pageName: '控制面板', fields: fields] +} + +Map sync(PluginContext ctx) { + if (!ctx.site.auth) return [message: "未配置认证", loginRequired: true] + try { + def conn = ctx.httpGet('/index.php') + def doc = jsoup.parse(conn.body()) + def el = utils.selectFirst(doc, '#info_block') + if (!el) return [message: "未找到用户信息"] + def t = utils.text(el) + def result = [:] + 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*([\d,]+)/ + if (um) result.upload = parseTraffic(um[0][1] as double, um[0][2]) + if (dm) result.download = parseTraffic(dm[0][1] as double, dm[0][2]) + if (rm) result.shareRate = rm[0][1] as double + if (bm) result.bonus = bm[0][1].replace(',','') as double + return result + } catch (e) { return [message: "同步失败: ${e.message}"] } +} + +Map sign(PluginContext ctx) { + if (!ctx.site.auth) return [message: "未配置认证", loginRequired: true] + try { + def conn = ctx.httpGet('/attendance.php') + def matched = conn.body() ==~ /.*(签到成功|已签到|重复签到|获得.*魔力值).*/ + return [success: matched, message: matched ? '已签到' : '失败'] + } catch (e) { return [message: "签到异常: ${e.message}"] } +} + +List search(PluginContext ctx, Map params) { + if (!ctx.site.auth) return [] + try { + def keyword = params.keyword ?: '' + def page = (params.page ?: 0) as int + def conn = ctx.httpGet("/torrents.php?search=${URLEncoder.encode(keyword,'UTF-8')}&page=${page}") + def doc = jsoup.parse(conn.body()) + def rows = [] + doc.select('table.torrents > tbody > tr:has(td.rowfollow):not(:has(th)):not(:has(.colhead))').each { row -> + def titleEl = utils.selectFirst(row, 'td.embedded a[href*=details]') + def tds = row.select('td.rowfollow') + rows << [ + title: utils.text(titleEl), + detailUrl: "${ctx.siteUrl}${utils.attr(titleEl,'href')}", + size: tds.size() > 4 ? parseHtmlSize(utils.text(tds.get(4))) : 0, + seeders: tds.size() > 5 ? utils.toInt(utils.text(tds.get(5))) ?: 0 : 0, + leechers: tds.size() > 6 ? utils.toInt(utils.text(tds.get(6))) ?: 0 : 0, + grabs: tds.size() > 7 ? utils.toInt(utils.text(tds.get(7))) ?: 0 : 0, + publishDate: tds.size() > 3 ? utils.text(tds.get(3)) : '', + ] + } + return rows + } catch (e) { return [] } +} + +private long parseTraffic(double val, String unit) { + def m = [B:1,KB:1024,MB:1048576,GB:1073741824,TB:1099511627776] + return (val * (m[unit?.toUpperCase()] ?: 1)) as long +} +private long parseHtmlSize(String s) { + def m = s =~ /([\d.]+)\s*(TB|GB|MB|KB|B)?/ + return m ? parseTraffic(m[0][1] as double, m[0][2]) : 0 +} diff --git a/par-core/src/main/resources/scripts/default-unit3d.groovy b/par-core/src/main/resources/scripts/default-unit3d.groovy new file mode 100644 index 0000000..e1143f3 --- /dev/null +++ b/par-core/src/main/resources/scripts/default-unit3d.groovy @@ -0,0 +1,107 @@ +// UNIT3D 站点默认脚本 +// 适用: BLU / TL / ACM 等 UNIT3D 架构站点(HTML + 可选 API) +// 覆盖: 搜索 / 种子详情 / 首页 + +Map parse(String content, String url) { + def doc = jsoup.parse(content) + + if (url.contains('/torrents') && !url.contains('/torrents/')) return parseSearch(doc) + if (url.contains('/torrents/') && url.contains('/')) return parseDetail(doc) + + // 尝试 JSON API + if (content.trim().startsWith('{') || content.trim().startsWith('[')) { + try { + def root = json.readValue(content, Map) + if (root?.data) return parseApiData(root?.data, url) + } catch (e) {} + } + + return [pageName: '未知页面', message: "未配置: ${url}"] +} + +private Map parseApiData(d, url) { + if (url.contains('/user') || url.contains('/profile')) + return [pageName: '用户', fields: [username: d?.username, uploaded: d?.uploaded?.toString(), downloaded: d?.downloaded?.toString()]] + return [pageName: 'API数据', fields: d?.subMap(d.keySet().take(10))] +} + +private Map parseSearch(doc) { + def rows = [] + doc.select('table.table tbody tr, .torrent-row, div.torrent').each { row -> + def titleEl = utils.selectFirst(row, 'a[href*=torrents]') + def tds = row.select('td') + def size = '' + for (int i = 0; i < tds.size(); i++) { + def t = utils.text(tds.get(i)) + if (t =~ /[\d.]+\s*(GB|MB|TB)/) { size = t; break } + } + rows << [ + title: utils.text(titleEl), + detailUrl: utils.attr(titleEl, 'href'), + size: size, + seeders: utils.text(utils.selectFirst(row, 'td:nth-child(5), td:has(.text-success)')), + leechers: utils.text(utils.selectFirst(row, 'td:nth-child(6), td:has(.text-danger)')), + uploadDate: utils.text(utils.selectFirst(row, 'td:nth-child(4), time')), + ] + } + return [pageName: '搜索', extracted: rows, message: "共 ${rows.size()} 条"] +} + +private Map parseDetail(doc) { + return [pageName: '种子详情', fields: [ + title: utils.text(utils.selectFirst(doc, 'h1, .torrent-title, .meta-title')), + size: utils.text(utils.selectFirst(doc, 'td:contains(Size) + td, .torrent-size')), + seeders: utils.text(utils.selectFirst(doc, 'td:contains(Seeders) + td, .seeders-count')), + leechers: utils.text(utils.selectFirst(doc, 'td:contains(Leechers) + td, .leechers-count')), + description:utils.text(utils.selectFirst(doc, '.torrent-description, #description')).take(200), + ]] +} + +Map sync(PluginContext ctx) { + if (!ctx.site.auth) return [message: "未配置认证", loginRequired: true] + try { + def conn = ctx.httpGet('/') + def doc = jsoup.parse(conn.body()) + def upEl = utils.selectFirst(doc, '.user-uploaded, span:contains(Upload), dt:contains(Upload) + dd') + def dlEl = utils.selectFirst(doc, '.user-downloaded, span:contains(Download), dt:contains(Download) + dd') + def ratioEl = utils.selectFirst(doc, '.user-ratio, span:contains(Ratio), dt:contains(Ratio) + dd') + def result = [:] + if (upEl) result.upload = parseHtmlSize(utils.text(upEl)) + if (dlEl) result.download = parseHtmlSize(utils.text(dlEl)) + if (ratioEl) result.shareRate = (utils.text(ratioEl) =~ /([\d.]+)/)[0][1] as double + return result + } catch (e) { return [message: "同步失败: ${e.message}"] } +} + +Map sign(PluginContext ctx) { + return [message: "UNIT3D 不支持签到"] +} + +List search(PluginContext ctx, Map params) { + if (!ctx.site.auth) return [] + try { + def keyword = params.keyword ?: '' + def conn = ctx.httpGet("/torrents?search=${URLEncoder.encode(keyword,'UTF-8')}") + def doc = jsoup.parse(conn.body()) + def rows = [] + doc.select('table.table tbody tr, .torrent-row').each { row -> + def titleEl = utils.selectFirst(row, 'a[href*=torrents]') + rows << [ + title: utils.text(titleEl), + detailUrl: "${ctx.siteUrl}${utils.attr(titleEl,'href')}", + size: parseHtmlSize(utils.text(utils.selectFirst(row, 'td:nth-child(4)'))) ?: 0, + seeders: utils.toInt(utils.text(utils.selectFirst(row, 'td:nth-child(5)'))) ?: 0, + leechers: utils.toInt(utils.text(utils.selectFirst(row, 'td:nth-child(6)'))) ?: 0, + ] + } + return rows + } catch (e) { return [] } +} + +private long parseHtmlSize(String s) { + if (!s) return 0 + def m = s =~ /([\d.]+)\s*(TB|GB|MB|KB|B)?/ + if (!m) return 0 + def m2 = [B:1,KB:1024,MB:1048576,GB:1073741824,TB:1099511627776] + return ((m[0][1] as double) * (m2[m[0][2]?.toUpperCase()] ?: 1)) as long +}