feat: 四类站点默认脚本+无自定义脚本时按siteType自动加载默认模板

This commit is contained in:
mediabot-pt
2026-07-01 14:53:07 +08:00
parent cd13335b4a
commit f2c98582a4
6 changed files with 536 additions and 12 deletions

View File

@@ -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<String, String> 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<String, Object> execute(String groovyScript, String content, String url) {
Map<String, Object> result = new LinkedHashMap<>();

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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
}