feat: Groovy 脚本解析引擎,脚本与配置一同管理,提交时支持上传 script 字段
This commit is contained in:
@@ -25,6 +25,7 @@ import java.util.stream.Collectors;
|
|||||||
public class AdapterController {
|
public class AdapterController {
|
||||||
|
|
||||||
private final AdapterEngine adapterEngine;
|
private final AdapterEngine adapterEngine;
|
||||||
|
private final GroovyScriptEngine groovyEngine;
|
||||||
private final SiteConfigMapper siteConfigMapper;
|
private final SiteConfigMapper siteConfigMapper;
|
||||||
private final AccessResultMapper accessResultMapper;
|
private final AccessResultMapper accessResultMapper;
|
||||||
private final CachedStorageService storageService;
|
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("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());
|
item.put("uncheckedCount", allResults.stream().filter(r -> url.equals(r.getUrl()) && r.getIsValid() == null).count());
|
||||||
|
|
||||||
// 解析内容
|
// 解析内容:优先 Groovy 脚本,否则用 Java 引擎
|
||||||
try {
|
try {
|
||||||
String content = storageService.read(ar.getStoragePath());
|
String content = storageService.read(ar.getStoragePath());
|
||||||
if (content != null) {
|
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);
|
item.put("parsed", parsed);
|
||||||
} else {
|
} else {
|
||||||
item.put("parsed", Map.of("matched", false, "message", "内容为空,可能已从存储中删除"));
|
item.put("parsed", Map.of("matched", false, "message", "内容为空"));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
item.put("parsed", Map.of("matched", false, "message", "解析异常: " + e.getMessage()));
|
item.put("parsed", Map.of("matched", false, "message", "解析异常: " + e.getMessage()));
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ public class AdminController {
|
|||||||
config.setStoragePath(approvedPath);
|
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.setStatus(ConfigStatus.APPROVED);
|
||||||
config.setReviewerId(reviewerId);
|
config.setReviewerId(reviewerId);
|
||||||
config.setReviewedAt(LocalDateTime.now());
|
config.setReviewedAt(LocalDateTime.now());
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ public class ConfigController {
|
|||||||
String version = (String) payload.get("version");
|
String version = (String) payload.get("version");
|
||||||
String configJson = (String) payload.get("config");
|
String configJson = (String) payload.get("config");
|
||||||
String schemaVersion = (String) payload.get("schemaVersion");
|
String schemaVersion = (String) payload.get("schemaVersion");
|
||||||
|
String groovyScript = (String) payload.get("script");
|
||||||
|
|
||||||
if (version == null || configJson == null) {
|
if (version == null || configJson == null) {
|
||||||
return ApiResponse.error(400, "version and config are required");
|
return ApiResponse.error(400, "version and config are required");
|
||||||
@@ -136,7 +137,7 @@ public class ConfigController {
|
|||||||
|
|
||||||
Long submitterId = (Long) request.getAttribute("accountId");
|
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));
|
return ApiResponse.success(configService.toDTO(config));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,5 +102,11 @@
|
|||||||
<groupId>org.jsoup</groupId>
|
<groupId>org.jsoup</groupId>
|
||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Groovy 脚本引擎 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.groovy</groupId>
|
||||||
|
<artifactId>groovy</artifactId>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ public class SiteConfig {
|
|||||||
@TableField("storage_path")
|
@TableField("storage_path")
|
||||||
private String storagePath;
|
private String storagePath;
|
||||||
|
|
||||||
|
@TableField("script_storage_path")
|
||||||
|
private String scriptStoragePath;
|
||||||
|
|
||||||
@TableField("file_size")
|
@TableField("file_size")
|
||||||
private Long fileSize;
|
private Long fileSize;
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ public interface ConfigService {
|
|||||||
SiteConfig getConfig(String siteId, String version);
|
SiteConfig getConfig(String siteId, String version);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提交新配置(需审核)
|
* 提交新配置(需审核),可选 Groovy 脚本
|
||||||
*/
|
*/
|
||||||
SiteConfig submitConfig(String siteId, String version, String configJson,
|
SiteConfig submitConfig(String siteId, String version, String configJson,
|
||||||
String schemaVersion, Long submitterId);
|
String schemaVersion, String groovyScript, Long submitterId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换为 DTO
|
* 转换为 DTO
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SiteConfig submitConfig(String siteId, String version, String configJson,
|
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 configHash = HmacUtil.sha256(configJson);
|
||||||
String storagePath = String.format("configs/%s/pending/%s.json", siteId, configHash.substring(0, 8));
|
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;
|
return sameHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 存储到抽象存储层
|
// 存储配置
|
||||||
try {
|
try {
|
||||||
storageService.store(storagePath, configJson);
|
storageService.store(storagePath, configJson);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -78,11 +78,19 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
throw new RuntimeException("存储配置文件失败: " + e.getMessage(), e);
|
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();
|
SiteConfig config = new SiteConfig();
|
||||||
config.setSiteId(siteId);
|
config.setSiteId(siteId);
|
||||||
config.setVersion(version); // 客户端传入的版本,仅供参考;批准时会被自动分配的新版本号覆盖
|
config.setVersion(version);
|
||||||
config.setConfigHash(configHash);
|
config.setConfigHash(configHash);
|
||||||
config.setStoragePath(storagePath);
|
config.setStoragePath(storagePath);
|
||||||
|
config.setScriptStoragePath(scriptPath);
|
||||||
config.setFileSize((long) configJson.getBytes(StandardCharsets.UTF_8).length);
|
config.setFileSize((long) configJson.getBytes(StandardCharsets.UTF_8).length);
|
||||||
config.setSchemaVersion(schemaVersion != null ? schemaVersion : "1.0.0");
|
config.setSchemaVersion(schemaVersion != null ? schemaVersion : "1.0.0");
|
||||||
config.setStatus(ConfigStatus.PENDING);
|
config.setStatus(ConfigStatus.PENDING);
|
||||||
@@ -90,7 +98,7 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
config.setIsLatest(false);
|
config.setIsLatest(false);
|
||||||
|
|
||||||
siteConfigMapper.insert(config);
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 脚本存储路径(七牛云/本地)';
|
||||||
7
pom.xml
7
pom.xml
@@ -124,6 +124,13 @@
|
|||||||
<artifactId>jsoup</artifactId>
|
<artifactId>jsoup</artifactId>
|
||||||
<version>1.18.1</version>
|
<version>1.18.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Groovy 脚本引擎 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.groovy</groupId>
|
||||||
|
<artifactId>groovy</artifactId>
|
||||||
|
<version>4.0.22</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user