feat: Groovy 脚本解析引擎,脚本与配置一同管理,提交时支持上传 script 字段

This commit is contained in:
mediabot-pt
2026-07-01 10:24:41 +08:00
parent 1dd600c713
commit 70b1aeda3f
10 changed files with 175 additions and 10 deletions

View File

@@ -25,6 +25,7 @@ import java.util.stream.Collectors;
public class AdapterController {
private final AdapterEngine adapterEngine;
private final GroovyScriptEngine groovyEngine;
private final SiteConfigMapper siteConfigMapper;
private final AccessResultMapper accessResultMapper;
private final CachedStorageService storageService;
@@ -137,14 +138,24 @@ 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 脚本,否则用 Java 引擎
try {
String content = storageService.read(ar.getStoragePath());
if (content != null) {
Map<String, Object> parsed = adapterEngine.extract(adapterConfig, content, ar.getContentType(), url);
Map<String, Object> parsed;
if (siteConfig.getScriptStoragePath() != null && !siteConfig.getScriptStoragePath().isBlank()) {
String script = storageService.read(siteConfig.getScriptStoragePath());
if (script != null && !script.isBlank()) {
parsed = groovyEngine.execute(script, content, url);
} else {
parsed = adapterEngine.extract(adapterConfig, content, ar.getContentType(), url);
}
} else {
parsed = adapterEngine.extract(adapterConfig, content, ar.getContentType(), url);
}
item.put("parsed", parsed);
} else {
item.put("parsed", Map.of("matched", false, "message", "内容为空,可能已从存储中删除"));
item.put("parsed", Map.of("matched", false, "message", "内容为空"));
}
} catch (Exception e) {
item.put("parsed", Map.of("matched", false, "message", "解析异常: " + e.getMessage()));

View File

@@ -100,6 +100,16 @@ public class AdminController {
config.setStoragePath(approvedPath);
}
// Groovy 脚本也移到 approved
if (config.getScriptStoragePath() != null && !config.getScriptStoragePath().isBlank()) {
String approvedScriptPath = config.getScriptStoragePath().replace("/pending/", "/approved/");
String script = storageService.read(config.getScriptStoragePath());
if (script != null) {
storageService.store(approvedScriptPath, script);
config.setScriptStoragePath(approvedScriptPath);
}
}
config.setStatus(ConfigStatus.APPROVED);
config.setReviewerId(reviewerId);
config.setReviewedAt(LocalDateTime.now());

View File

@@ -129,6 +129,7 @@ public class ConfigController {
String version = (String) payload.get("version");
String configJson = (String) payload.get("config");
String schemaVersion = (String) payload.get("schemaVersion");
String groovyScript = (String) payload.get("script");
if (version == null || configJson == null) {
return ApiResponse.error(400, "version and config are required");
@@ -136,7 +137,7 @@ public class ConfigController {
Long submitterId = (Long) request.getAttribute("accountId");
SiteConfig config = configService.submitConfig(siteId, version, configJson, schemaVersion, submitterId);
SiteConfig config = configService.submitConfig(siteId, version, configJson, schemaVersion, groovyScript, submitterId);
return ApiResponse.success(configService.toDTO(config));
}
}

View File

@@ -102,5 +102,11 @@
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
</dependency>
<!-- Groovy 脚本引擎 -->
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,117 @@
package com.par.core.adapter;
import com.fasterxml.jackson.databind.ObjectMapper;
import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* Groovy 脚本解析引擎。
* 脚本可访问以下内置变量:
* content - String,页面内容(HTML 或 JSON)
* url - String,访问的 URL
* jsoup - 可直接调用 Jsoup.parse() 等
* json - ObjectMapper,可直接调用 readTree() 等
* utils - 工具方法集合
* 脚本应返回 Map<String, Object> 或 List<Map<String, Object>>
*/
@Slf4j
@Service
public class GroovyScriptEngine {
private static final ObjectMapper objectMapper = new ObjectMapper();
@SuppressWarnings("unchecked")
public Map<String, Object> execute(String groovyScript, String content, String url) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("engine", "groovy");
try {
Binding binding = new Binding();
binding.setVariable("content", content);
binding.setVariable("url", url);
binding.setVariable("jsoup", new JsoupHelper());
binding.setVariable("json", objectMapper);
binding.setVariable("utils", new ScriptUtils());
GroovyShell shell = new GroovyShell(binding);
Object scriptResult = shell.evaluate(groovyScript);
if (scriptResult instanceof Map) {
result.put("matched", true);
Map<String, Object> m = (Map<String, Object>) scriptResult;
result.put("pageName", m.getOrDefault("pageName", "Groovy脚本"));
result.put("fieldCount", m.containsKey("fields") ? ((Map<?, ?>) m.get("fields")).size() : 0);
// 有 fields map → 展示为一行
if (m.containsKey("fields")) {
result.put("extracted", List.of(m.get("fields")));
result.put("rowCount", 1);
} else if (m.containsKey("extracted")) {
result.put("extracted", m.get("extracted"));
result.put("rowCount", m.getOrDefault("rowCount", ((List<?>) m.get("extracted")).size()));
} else {
result.put("extracted", List.of(m));
result.put("rowCount", 1);
}
result.put("message", m.getOrDefault("message", "解析成功"));
} else if (scriptResult instanceof List) {
result.put("matched", true);
result.put("pageName", "Groovy脚本");
result.put("extracted", (List<?>) scriptResult);
result.put("rowCount", ((List<?>) scriptResult).size());
result.put("fieldCount", !((List<?>) scriptResult).isEmpty() && ((List<?>) scriptResult).get(0) instanceof Map
? ((Map<?, ?>) ((List<?>) scriptResult).get(0)).size() : 0);
result.put("message", "共 " + ((List<?>) scriptResult).size() + " 行");
} else if (scriptResult != null) {
result.put("matched", true);
result.put("pageName", "Groovy脚本");
result.put("extracted", List.of(Map.of("result", scriptResult)));
result.put("rowCount", 1);
result.put("message", String.valueOf(scriptResult));
} else {
result.put("matched", true);
result.put("message", "脚本返回 null");
result.put("extracted", List.of());
result.put("rowCount", 0);
}
} catch (Exception e) {
result.put("matched", false);
result.put("message", "Groovy 脚本执行异常: " + e.getMessage());
log.error("Groovy script error for url={}", url, e);
}
return result;
}
/** Jsoup 辅助类 */
public static class JsoupHelper {
public Object parse(String html) { return Jsoup.parse(html); }
public Object parse(String html, String baseUri) { return Jsoup.parse(html, baseUri); }
}
/** 脚本工具方法 */
public static class ScriptUtils {
public String text(Object node) {
try { return (String) node.getClass().getMethod("text").invoke(node); }
catch (Exception e) { return null; }
}
public String attr(Object node, String attr) {
try { return (String) node.getClass().getMethod("attr", String.class).invoke(node, attr); }
catch (Exception e) { return null; }
}
public Object select(Object node, String cssQuery) {
try { return node.getClass().getMethod("select", String.class).invoke(node, cssQuery); }
catch (Exception e) { return null; }
}
public Object selectFirst(Object node, String cssQuery) {
try {
Object elements = select(node, cssQuery);
if (elements instanceof List && !((List<?>) elements).isEmpty())
return ((List<?>) elements).get(0);
return null;
} catch (Exception e) { return null; }
}
}
}

View File

@@ -28,6 +28,9 @@ public class SiteConfig {
@TableField("storage_path")
private String storagePath;
@TableField("script_storage_path")
private String scriptStoragePath;
@TableField("file_size")
private Long fileSize;

View File

@@ -31,10 +31,10 @@ public interface ConfigService {
SiteConfig getConfig(String siteId, String version);
/**
* 提交新配置(需审核)
* 提交新配置(需审核),可选 Groovy 脚本
*/
SiteConfig submitConfig(String siteId, String version, String configJson,
String schemaVersion, Long submitterId);
String schemaVersion, String groovyScript, Long submitterId);
/**
* 转换为 DTO

View File

@@ -55,7 +55,7 @@ public class ConfigServiceImpl implements ConfigService {
@Override
public SiteConfig submitConfig(String siteId, String version, String configJson,
String schemaVersion, Long submitterId) {
String schemaVersion, String groovyScript, Long submitterId) {
String configHash = HmacUtil.sha256(configJson);
String storagePath = String.format("configs/%s/pending/%s.json", siteId, configHash.substring(0, 8));
@@ -70,7 +70,7 @@ public class ConfigServiceImpl implements ConfigService {
return sameHash;
}
// 存储到抽象存储层
// 存储配置
try {
storageService.store(storagePath, configJson);
} catch (Exception e) {
@@ -78,11 +78,19 @@ public class ConfigServiceImpl implements ConfigService {
throw new RuntimeException("存储配置文件失败: " + e.getMessage(), e);
}
// 存储 Groovy 脚本(可选)
String scriptPath = null;
if (groovyScript != null && !groovyScript.isBlank()) {
scriptPath = String.format("configs/%s/pending/%s.groovy", siteId, configHash.substring(0, 8));
storageService.store(scriptPath, groovyScript);
}
SiteConfig config = new SiteConfig();
config.setSiteId(siteId);
config.setVersion(version); // 客户端传入的版本,仅供参考;批准时会被自动分配的新版本号覆盖
config.setVersion(version);
config.setConfigHash(configHash);
config.setStoragePath(storagePath);
config.setScriptStoragePath(scriptPath);
config.setFileSize((long) configJson.getBytes(StandardCharsets.UTF_8).length);
config.setSchemaVersion(schemaVersion != null ? schemaVersion : "1.0.0");
config.setStatus(ConfigStatus.PENDING);
@@ -90,7 +98,7 @@ public class ConfigServiceImpl implements ConfigService {
config.setIsLatest(false);
siteConfigMapper.insert(config);
log.info("Config submitted: siteId={}, version={}, id={}, hash={}", siteId, version, config.getId(), configHash);
log.info("Config submitted: siteId={}, version={}, id={}, hash={}, hasScript={}", siteId, version, config.getId(), configHash, scriptPath != null);
return config;
}

View File

@@ -0,0 +1,2 @@
-- PAR Flyway V14: site_configs 增加 script_storage_path 字段存储 Groovy 脚本路径
ALTER TABLE site_configs ADD COLUMN script_storage_path VARCHAR(500) COMMENT 'Groovy 脚本存储路径(七牛云/本地)';

View File

@@ -124,6 +124,13 @@
<artifactId>jsoup</artifactId>
<version>1.18.1</version>
</dependency>
<!-- Groovy 脚本引擎 -->
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
<version>4.0.22</version>
</dependency>
</dependencies>
</dependencyManagement>