From 70b1aeda3faccbff6ddceb0cb9b6c836ef962a56 Mon Sep 17 00:00:00 2001 From: mediabot-pt <295750538+mediabot-pt@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:24:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Groovy=20=E8=84=9A=E6=9C=AC=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E5=BC=95=E6=93=8E=EF=BC=8C=E8=84=9A=E6=9C=AC=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E4=B8=80=E5=90=8C=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E6=97=B6=E6=94=AF=E6=8C=81=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=20script=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../par/api/controller/AdapterController.java | 17 ++- .../par/api/controller/AdminController.java | 10 ++ .../par/api/controller/ConfigController.java | 3 +- par-core/pom.xml | 6 + .../par/core/adapter/GroovyScriptEngine.java | 117 ++++++++++++++++++ .../java/com/par/core/entity/SiteConfig.java | 3 + .../com/par/core/service/ConfigService.java | 4 +- .../core/service/impl/ConfigServiceImpl.java | 16 ++- .../V14__add_script_to_site_configs.sql | 2 + pom.xml | 7 ++ 10 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 par-core/src/main/java/com/par/core/adapter/GroovyScriptEngine.java create mode 100644 par-core/src/main/resources/db/migration/V14__add_script_to_site_configs.sql 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 9f877b0..da9d529 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 @@ -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 parsed = adapterEngine.extract(adapterConfig, content, ar.getContentType(), url); + Map 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())); diff --git a/par-api/src/main/java/com/par/api/controller/AdminController.java b/par-api/src/main/java/com/par/api/controller/AdminController.java index e150734..5ef89d8 100644 --- a/par-api/src/main/java/com/par/api/controller/AdminController.java +++ b/par-api/src/main/java/com/par/api/controller/AdminController.java @@ -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()); diff --git a/par-api/src/main/java/com/par/api/controller/ConfigController.java b/par-api/src/main/java/com/par/api/controller/ConfigController.java index a7e3b23..4e6ea86 100644 --- a/par-api/src/main/java/com/par/api/controller/ConfigController.java +++ b/par-api/src/main/java/com/par/api/controller/ConfigController.java @@ -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)); } } diff --git a/par-core/pom.xml b/par-core/pom.xml index 17210c4..dabc39a 100644 --- a/par-core/pom.xml +++ b/par-core/pom.xml @@ -102,5 +102,11 @@ org.jsoup jsoup + + + + org.apache.groovy + groovy + 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 new file mode 100644 index 0000000..f6603db --- /dev/null +++ b/par-core/src/main/java/com/par/core/adapter/GroovyScriptEngine.java @@ -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 或 List> + */ +@Slf4j +@Service +public class GroovyScriptEngine { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @SuppressWarnings("unchecked") + public Map execute(String groovyScript, String content, String url) { + Map 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 m = (Map) 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; } + } + } +} diff --git a/par-core/src/main/java/com/par/core/entity/SiteConfig.java b/par-core/src/main/java/com/par/core/entity/SiteConfig.java index cef3f2d..e065258 100644 --- a/par-core/src/main/java/com/par/core/entity/SiteConfig.java +++ b/par-core/src/main/java/com/par/core/entity/SiteConfig.java @@ -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; diff --git a/par-core/src/main/java/com/par/core/service/ConfigService.java b/par-core/src/main/java/com/par/core/service/ConfigService.java index 63dd52f..6a43744 100644 --- a/par-core/src/main/java/com/par/core/service/ConfigService.java +++ b/par-core/src/main/java/com/par/core/service/ConfigService.java @@ -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 diff --git a/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java index 2cbba96..d9a349a 100644 --- a/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java @@ -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; } diff --git a/par-core/src/main/resources/db/migration/V14__add_script_to_site_configs.sql b/par-core/src/main/resources/db/migration/V14__add_script_to_site_configs.sql new file mode 100644 index 0000000..6f094d5 --- /dev/null +++ b/par-core/src/main/resources/db/migration/V14__add_script_to_site_configs.sql @@ -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 脚本存储路径(七牛云/本地)'; diff --git a/pom.xml b/pom.xml index 5f267dc..6bf8d24 100644 --- a/pom.xml +++ b/pom.xml @@ -124,6 +124,13 @@ jsoup 1.18.1 + + + + org.apache.groovy + groovy + 4.0.22 +