Files
skill-convertor/main.go
2026-04-15 01:17:01 +08:00

1487 lines
43 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"errors"
"io"
"os/exec"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
)
const (
defaultVersion = "0.1.0"
defaultModel = "glm-5.1"
defaultBaseURL = "https://open.bigmodel.cn/api/paas/v4"
defaultAITimeout = 30
defaultModeCompact = "compact"
defaultModeFull = "full"
)
var (
defaultVersionTags = []string{"sgclaw", "browser_script", "converter"}
riskPatterns = map[string][]string{
"shell_like": {"require(", "process.", "child_process", "spawn(", "exec(", "shell", "subprocess"},
"network_like": {"fetch(", "XMLHttpRequest", "axios", ".ajax(", "WebSocket(", ".open("},
"xpath_like": {":contains(", "document.evaluate", "XPathResult", "getByXPath"},
"expected_domain": {"expected_domain"},
}
)
type AiConfig struct {
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
TimeoutSecond int `json:"timeout"`
Enabled bool `json:"enabled"`
}
type SkillResult struct {
SkillName string `json:"skill_name"`
TargetDir string `json:"target_dir"`
Files map[string]string `json:"files"`
Metadata map[string]interface{} `json:"metadata"`
Validation []string `json:"validation"`
RiskCheck []string `json:"risk_check"`
AIAttempted bool `json:"ai_attempted"`
AIUsed bool `json:"ai_used"`
AIFallbackReason string `json:"ai_fallback_reason,omitempty"`
BlockingReason string `json:"blocking_reason,omitempty"`
}
type AIGenerated struct {
ToolName string `json:"tool_name"`
ToolDescription string `json:"tool_description"`
SkillDescription string `json:"skill_description"`
ArtifactFields []string `json:"artifact_fields"`
FailureModes []string `json:"failure_modes"`
SkillToml string `json:"skill_toml"`
SkillMd string `json:"skill_md"`
ImplementationNote string `json:"implementation_notes"`
AssetNote string `json:"asset_notes"`
RiskCheck []string `json:"risk_check"`
}
func main() {
// Shared args
jsPath := flag.String("js-script-path", "", "")
jsCode := flag.String("js-script-code", "", "")
skillName := flag.String("skill-name", "", "")
skillDomain := flag.String("skill-domain", "", "")
skillUseCase := flag.String("skill-use-case", "", "")
toolName := flag.String("tool-name", "", "")
toolDesc := flag.String("tool-description", "", "")
argSpec := flag.String("arg-spec", "", "")
artifactFields := flag.String("artifact-fields", "", "")
preferredMode := flag.String("preferred-mode", defaultModeCompact, "")
write := flag.Bool("write", false, "")
skillRoot := flag.String("skill-root", "skills", "")
noAI := flag.Bool("no-ai", false, "")
serve := flag.Bool("serve", false, "")
host := flag.String("host", "127.0.0.1", "")
port := flag.Int("port", 8787, "")
flag.Parse()
if *serve {
runServer(*host, *port, *skillRoot)
return
}
artifactList := parseCommaValues(*artifactFields)
args, err := parseArgSpec(*argSpec)
if err != nil {
panic(fmt.Sprintf("arg-spec 解析失败: %v", err))
}
result, err := generateSkillPackage(
*jsPath,
*jsCode,
*skillName,
*skillDomain,
*skillUseCase,
*toolName,
*toolDesc,
args,
*preferredMode,
artifactList,
*write,
*skillRoot,
!*noAI,
nil,
)
if err != nil {
panic(err)
}
out, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(out))
}
func runServer(host string, port int, skillRoot string) {
mux := http.NewServeMux()
mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) {
configHandler(w, r, skillRoot)
})
mux.HandleFunc("/api/convert", func(w http.ResponseWriter, r *http.Request) {
convertHandler(w, r, skillRoot)
})
mux.HandleFunc("/api/open-dir", func(w http.ResponseWriter, r *http.Request) {
openDirHandler(w, r)
})
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
staticHandler(w, r)
}))
addr := fmt.Sprintf("%s:%d", host, port)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: time.Second * 30,
WriteTimeout: time.Second * 120,
}
fmt.Printf("SGClaw converter server running on http://%s\n", addr)
fmt.Printf("Open http://%s in browser\n", addr)
if err := server.ListenAndServe(); err != nil {
fmt.Println("server exit:", err)
}
}
func staticHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
handleCORS(w)
w.WriteHeader(http.StatusNoContent)
return
}
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "not found", http.StatusNotFound)
return
}
urlPath := r.URL.Path
rel := strings.TrimPrefix(urlPath, "/")
if rel == "" || rel == "." {
rel = "index.html"
}
rel = filepath.Clean(filepath.ToSlash(rel))
if rel == ".." || strings.HasPrefix(rel, "../") {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
if strings.HasSuffix(urlPath, "/") && rel != "index.html" {
rel = filepath.ToSlash(filepath.Join(rel, "index.html"))
}
fp := filepath.Join("web", rel)
info, err := os.Stat(fp)
if err != nil {
http.NotFound(w, r)
return
}
if info.IsDir() {
fp = filepath.Join(fp, "index.html")
if _, err := os.Stat(fp); err != nil {
http.NotFound(w, r)
return
}
}
handleCORS(w)
http.ServeFile(w, r, fp)
}
func configHandler(w http.ResponseWriter, r *http.Request, skillRoot string) {
handleCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"error": "method not allowed"})
return
}
cfg := resolveAIConfig(nil)
writeJSON(w, http.StatusOK, map[string]interface{}{
"ai_enabled": cfg.Enabled,
"skill_root": skillRoot,
"model": cfg.Model,
"base_url": cfg.BaseURL,
"timeout": cfg.TimeoutSecond,
"api_key_masked": maskSecret(cfg.APIKey),
"api_key_present": strings.TrimSpace(cfg.APIKey) != "",
})
}
func openDirHandler(w http.ResponseWriter, r *http.Request) {
handleCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"error": "method not allowed"})
return
}
targetDir := strings.TrimSpace(r.URL.Query().Get("path"))
if targetDir == "" {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "missing path"})
return
}
abs, err := filepath.Abs(targetDir)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "invalid path"})
return
}
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "target directory not found"})
return
}
if err := openFolder(abs); err != nil {
msg := err.Error()
if errors.Is(err, exec.ErrNotFound) {
msg = "open command not available"
}
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": msg})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "target_dir": filepath.ToSlash(abs)})
}
func convertHandler(w http.ResponseWriter, r *http.Request, skillRoot string) {
if r.Method == http.MethodOptions {
handleCORS(w)
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
handleCORS(w)
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{"error": "method not allowed"})
return
}
handleCORS(w)
body, err := io.ReadAll(r.Body)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "invalid body"})
return
}
defer r.Body.Close()
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "bad json"})
return
}
jsCode := strValue(payload["js_script_code"])
jsPath := strValue(payload["js_script_path"])
if jsCode == "" && jsPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]interface{}{"error": "missing js_script_code or js_script_path"})
return
}
mode := strValue(payload["preferred_mode"])
if mode == "" {
mode = defaultModeCompact
}
artifactList := []string{}
if af, ok := payload["artifact_fields"].([]interface{}); ok {
for _, x := range af {
if s, ok := x.(string); ok {
artifactList = append(artifactList, strings.TrimSpace(s))
}
}
}
rawArtifact := strValue(payload["artifact_fields_raw"])
if rawArtifact != "" {
artifactList = append(artifactList, parseCommaValues(rawArtifact)...)
}
artifactList = dedupeStrings(artifactList)
spec := map[string]string{}
if rawArgSpec, ok := payload["arg_spec"]; ok {
switch v := rawArgSpec.(type) {
case string:
s, err := parseArgSpec(v)
if err == nil {
spec = s
}
case map[string]interface{}:
for k, vv := range v {
spec[k] = fmt.Sprintf("%v", vv)
}
}
}
write, _ := payload["write"].(bool)
useAI := true
if raw, ok := payload["use_ai"].(bool); ok {
useAI = raw
}
overrideSkillRoot := firstNonEmpty(strValue(payload["skill_root"]), skillRoot)
aiConfig := parseAIConfig(payload["ai_config"])
result, err := generateSkillPackage(
jsPath,
jsCode,
strValue(payload["skill_name"]),
strValue(payload["skill_domain"]),
strValue(payload["skill_use_case"]),
strValue(payload["tool_name"]),
strValue(payload["tool_description"]),
spec,
mode,
artifactList,
write,
overrideSkillRoot,
useAI,
aiConfig,
)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
type APIResponse map[string]interface{}
func writeJSON(w http.ResponseWriter, status int, obj interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
out, _ := json.MarshalIndent(obj, "", " ")
_, _ = w.Write(out)
}
func handleCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
}
func generateSkillPackage(
jsScriptPath string,
jsScriptCode string,
skillName string,
skillDomain string,
skillUseCase string,
toolName string,
toolDescription string,
argSpec map[string]string,
preferredMode string,
artifactFields []string,
write bool,
skillRoot string,
useAI bool,
aiConfig *AiConfig,
) (*SkillResult, error) {
if jsScriptCode == "" && jsScriptPath == "" {
return nil, fmt.Errorf("js_script_path 或 js_script_code 需要提供其一")
}
var scriptCode string
if jsScriptCode != "" {
scriptCode = jsScriptCode
} else {
b, e := os.ReadFile(jsScriptPath)
if e != nil {
return nil, fmt.Errorf("读取 js_script_path 失败: %w", e)
}
scriptCode = string(b)
}
if strings.TrimSpace(scriptCode) == "" {
return nil, fmt.Errorf("脚本内容为空")
}
resolvedDomain := stripDomain(skillDomain)
resolvedSkillName := slugify(filepath.Base(deriveDefaultName(skillName, jsScriptPath)))
defaultToolName := strings.ReplaceAll(resolvedSkillName, "-", "_")
if !strings.HasPrefix(defaultToolName, "extract_") {
defaultToolName = "extract_" + defaultToolName
}
resolvedToolName := normalizeToolName(toolName, defaultToolName)
resolvedToolDesc := firstNonEmpty(toolDescription, skillUseCase, "基于现有脚本生成结构化结果")
resolvedSkillDesc := firstNonEmpty(skillUseCase, resolvedSkillName+" browser_script tool")
inferredArgs := inferArgsFromScript(scriptCode)
mergedArgs := map[string]string{}
for k, v := range inferredArgs {
mergedArgs[k] = v
}
for k, v := range argSpec {
mergedArgs[k] = v
}
aiCtx := map[string]interface{}{
"skill_name": resolvedSkillName,
"tool_name": resolvedToolName,
"resolved_domain": resolvedDomain,
"script_snippet": scriptCode,
"inferred_args": keysFromMap(mergedArgs),
"preferred_mode": preferredMode,
}
var aiOut *AIGenerated
aiAttempted := false
aiUsed := false
aiFallbackReason := ""
resolvedAI := resolveAIConfig(aiConfig)
if useAI {
aiOut, aiFallbackReason, aiAttempted = callAI(aiCtx, &resolvedAI)
aiUsed = aiOut != nil
} else {
aiFallbackReason = "AI disabled by request"
}
if aiOut != nil && aiOut.ToolName != "" {
resolvedToolName = normalizeToolName(aiOut.ToolName, resolvedToolName)
}
if strings.HasPrefix(resolvedToolName, "extract_extract_") {
resolvedToolName = strings.TrimPrefix(resolvedToolName, "extract_")
}
if aiOut != nil && aiOut.ToolDescription != "" {
resolvedToolDesc = aiOut.ToolDescription
}
if aiOut != nil && aiOut.SkillDescription != "" {
resolvedSkillDesc = aiOut.SkillDescription
}
if len(artifactFields) == 0 {
if aiOut != nil && len(aiOut.ArtifactFields) > 0 {
artifactFields = aiOut.ArtifactFields
} else {
artifactFields = inferArtifactFields(scriptCode)
}
}
failureModes := []string{
"blocked_page: 登录/验证码/反爬场景要抛错",
"partial_data: 数据不足要在注释或结果中说明",
}
if aiOut != nil && len(aiOut.FailureModes) > 0 {
failureModes = aiOut.FailureModes
}
tomlContent := ""
mdContent := ""
notesContent := ""
assetContent := ""
riskCheck := scanRisk(scriptCode)
if aiOut != nil && aiOut.SkillToml != "" {
tomlContent = strings.TrimSpace(aiOut.SkillToml)
} else {
tomlContent = buildSkillToml(resolvedSkillName, resolvedSkillDesc, resolvedToolName, resolvedToolDesc, preferredMode, mergedArgs)
}
if aiOut != nil && aiOut.SkillMd != "" {
mdContent = strings.TrimSpace(aiOut.SkillMd)
} else {
mdContent = buildSkillMD(resolvedSkillName, resolvedSkillDesc, resolvedToolName, resolvedToolDesc, resolvedDomain, mergedArgs, artifactFields, preferredMode, failureModes)
}
if aiOut != nil && aiOut.ImplementationNote != "" {
notesContent = strings.TrimSpace(aiOut.ImplementationNote)
} else {
notesContent = buildReferenceNotes(resolvedSkillName, resolvedToolName, mergedArgs, artifactFields)
}
if aiOut != nil && aiOut.AssetNote != "" {
assetContent = strings.TrimSpace(aiOut.AssetNote)
} else {
assetContent = buildAssetNotes(resolvedSkillName, resolvedDomain, preferredMode)
}
if aiOut != nil && len(aiOut.RiskCheck) > 0 {
riskCheck = append(riskCheck, aiOut.RiskCheck...)
}
// Enforce core SGClaw invariants even when AI returns near-valid content.
commandPath := fmt.Sprintf("scripts/%s.js", resolvedToolName)
if !strings.Contains(tomlContent, `kind = "browser_script"`) {
tomlContent = buildSkillToml(resolvedSkillName, resolvedSkillDesc, resolvedToolName, resolvedToolDesc, preferredMode, mergedArgs)
}
tomlContent = rewriteTomlCommand(tomlContent, commandPath)
mdContent = rewriteMarkdownCommand(mdContent, commandPath)
mdContent = rewriteMarkdownToolName(mdContent, resolvedToolName)
files := map[string]string{
"SKILL.toml": ensureTrailNewline(tomlContent),
"SKILL.md": ensureTrailNewline(mdContent),
fmt.Sprintf("scripts/%s.js", resolvedToolName): ensureTrailNewline(strings.TrimRight(scriptCode, "\n") + "\n"),
"references/implementation-notes.md": ensureTrailNewline(notesContent),
"assets/notes.md": ensureTrailNewline(assetContent),
}
validation := []string{
"PASS: 基础文件结构字段已生成",
"PASS: kind 已固定为 browser_script",
"PASS: 命令参数已映射为 [tools.args]",
}
if resolvedDomain != "" {
validation = append(validation, fmt.Sprintf("PASS: expected_domain 已在 contract 中声明为 %s", resolvedDomain))
}
if len(riskCheck) > 0 {
validation = append(validation, "WARN: 存在高风险文本片段,需人工复核")
}
if len(artifactFields) == 0 && preferredMode == defaultModeFull {
validation = append(validation, "WARN: 当前未识别到结构化 artifact 字段,建议手工补充")
}
targetDir := filepath.Join(skillRoot, resolvedSkillName)
if write {
for p, c := range files {
fp := filepath.Join(targetDir, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(fp), 0o755); err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
if err := os.WriteFile(fp, []byte(c), 0o644); err != nil {
return nil, fmt.Errorf("写入 %s 失败: %w", p, err)
}
}
}
meta := map[string]interface{}{
"skill_contract": map[string]interface{}{
"expected_domain": resolvedDomain,
"tool_name": resolvedToolName,
"arguments": keysFromMap(mergedArgs),
"artifact_fields": artifactFields,
"failure_modes": failureModes,
},
}
result := &SkillResult{
SkillName: resolvedSkillName,
TargetDir: filepath.ToSlash(targetDir),
Files: files,
Metadata: meta,
Validation: validation,
RiskCheck: riskCheck,
AIAttempted: aiAttempted,
AIUsed: aiUsed,
AIFallbackReason: aiFallbackReason,
}
if len(artifactFields) == 0 && preferredMode == defaultModeFull {
result.BlockingReason = "无法可靠推断 artifact_fields建议补充脚本返回字段说明"
}
return result, nil
}
func callAI(ctx map[string]interface{}, cfg *AiConfig) (*AIGenerated, string, bool) {
if cfg == nil {
resolved := resolveAIConfig(nil)
cfg = &resolved
}
if cfg.BaseURL == "" {
return nil, "missing AI base_url", false
}
if cfg.APIKey == "" {
return nil, "missing AI api_key", false
}
if !cfg.Enabled {
return nil, "AI disabled by resolved config", false
}
systemPrompt := strings.TrimSpace(`
你是 SGClaw 技能打包器。
你的唯一任务:把一段“已经存在的 JS 脚本”包装为可落地的 SGClaw browser_script skill 元数据与文档。
硬性约束:
1. 不重写脚本业务逻辑,只做轻量包装和文档化。
2. 只输出合法 JSON不输出 markdown 代码块外文字。
3. 输出必须服务于 SGClaw 运行时,不要生成与运行时无关的花哨描述。
4. 如果无法可靠推断字段,返回空字符串、空数组,或保留调用方给定值;不要编造。
5. 优先保证命名、运行契约、错误处理、artifact 结构正确。
SGClaw 规则:
- tool kind 固定为 browser_script
- command 指向 scripts/<tool>.js
- tool_name 使用小写英文和下划线,不含空格
- expected_domain 是裸域名,属于运行契约,不要伪造脚本参数
- 优先结构化 artifact 输出,避免纯文本二次采集
- 浏览器提取优先 CSS 选择器,禁止 XPath 和 jQuery :contains
- 阻断页(登录、验证码、权限不足、反爬)必须显式报错
- 不引入 shell、网络请求、危险命令或绝对路径
`)
rawCtx, _ := json.Marshal(ctx)
userPrompt := fmt.Sprintf(`下面是待包装脚本的上下文,请生成严格符合 SGClaw 规范的结果 JSON
%s
你的目标不是“改写脚本”,而是“产出稳定的 skill 包装元数据”。
请严格输出以下字段:
- tool_name
- tool_description
- skill_description
- artifact_fields
- failure_modes
- skill_toml
- skill_md
- implementation_notes
- asset_notes
- risk_check
请按下面规则生成:
1. 保留脚本原始行为,不生成新的抓取逻辑,不改变输入输出语义。
2. tool_name
- 若调用方已给定可用名称,则保持兼容;
- 否则基于 skill_name 生成;
- 小写英文/数字/下划线;
- 若名称已带 extract_ 前缀,不要再重复添加。
3. tool_description 和 skill_description
- 简洁、具体、面向执行;
- 不写营销文案;
- 不要出现“智能”“强大”等空话。
4. artifact_fields
- 只列脚本明显返回的结构化字段;
- 无法可靠识别时返回空数组。
5. failure_modes
- 至少考虑 blocked_page、partial_data
- 仅在脚本场景明显相关时增加其他项。
6. skill_toml
- 必须是可落地内容;
- [skill] 至少含 name/description/version/author/tags
- [[tools]] 必须含 name/description/kind/browser_script/command
- 如存在参数则写 [tools.args]
- 不要写绝对路径;
- 不要引入与 SGClaw 无关字段。
7. skill_md
- 必须包含 Runtime Contract、Blocked-Page Rule、Output Contract
- 明确 CSS 选择器优先、禁止 XPath 和 :contains
- 明确 expected_domain 是运行契约而不是脚本参数;
- 明确返回结构化产物优先。
8. implementation_notes / asset_notes
- 简短、可读、可落地;
- 不要远程链接。
9. risk_check
- 只列真实风险,不要为了凑数输出空泛警告。
输出要求:
- 只输出一个合法 JSON 对象
- 不要输出解释性文字
- 如果某字段无法可靠生成,返回空字符串或空数组
- 优先保证可落地、可审计、可被 SGClaw 运行时消费`, string(rawCtx))
requestBody := map[string]interface{}{
"model": cfg.Model,
"temperature": 0.2,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
}
body, _ := json.Marshal(requestBody)
timeout := cfg.TimeoutSecond
if timeout <= 0 {
timeout = defaultAITimeout
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(cfg.BaseURL, "/")+"/chat/completions", bytes.NewReader(body))
if err != nil {
return nil, fmt.Sprintf("build request failed: %v", err), false
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("X-API-Key", cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Sprintf("request failed: %v", err), true
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Sprintf("http %d: %s", resp.StatusCode, compactErrorText(raw)), true
}
var parsed map[string]interface{}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Sprintf("response json parse failed: %v", err), true
}
if errObj, ok := parsed["error"]; ok && errObj != nil {
return nil, fmt.Sprintf("provider error: %v", errObj), true
}
var contentText string
if choices, ok := parsed["choices"].([]interface{}); ok && len(choices) > 0 {
if first, ok := choices[0].(map[string]interface{}); ok {
if msg, ok := first["message"].(map[string]interface{}); ok {
contentText = fmt.Sprintf("%v", msg["content"])
}
if contentText == "" {
contentText = fmt.Sprintf("%v", first["text"])
}
}
}
if contentText == "" {
contentText = string(raw)
}
jsonText := extractJSONFromText(contentText)
if jsonText == nil {
return nil, "response missing valid JSON payload", true
}
out, err := parseAIGenerated(*jsonText)
if err != nil {
return nil, fmt.Sprintf("response schema parse failed: %v", err), true
}
if !hasUsableAIOutput(out) {
return nil, "response did not contain usable AI fields", true
}
return out, "", true
}
func extractJSONFromText(raw string) *string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
re := regexp.MustCompile("(?s)```json(.*?)```")
m := re.FindStringSubmatch(raw)
if len(m) >= 2 {
v := strings.TrimSpace(m[1])
return &v
}
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start >= 0 && end > start {
v := strings.TrimSpace(raw[start : end+1])
return &v
}
return nil
}
func buildSkillToml(skillName, description, toolName, toolDesc, mode string, args map[string]string) string {
lines := []string{
"[skill]",
fmt.Sprintf("name = %s", toTOMLString(skillName)),
fmt.Sprintf("description = %s", toTOMLString(description)),
fmt.Sprintf("version = %s", toTOMLString(defaultVersion)),
"author = \"sgclaw-skill-converter\"",
fmt.Sprintf("tags = %s", toTOMLArray(defaultVersionTags)),
"",
"[[tools]]",
fmt.Sprintf("name = %s", toTOMLString(toolName)),
fmt.Sprintf("description = %s", toTOMLString(toolDesc)),
`kind = "browser_script"`,
fmt.Sprintf(`command = %s`, toTOMLString(fmt.Sprintf("scripts/%s.js", toolName))),
}
if len(args) > 0 {
lines = append(lines, "", "[tools.args]")
for k, v := range args {
lines = append(lines, fmt.Sprintf("%s = %s", k, toTOMLString(v)))
}
}
if mode == defaultModeFull {
lines = append(lines, "", "prompts = [")
lines = append(lines,
fmt.Sprintf("%s", toTOMLString("只输出可被 SGClaw 安全执行的结构化结果")),
",",
fmt.Sprintf("%s", toTOMLString("禁止网络请求与系统命令,仅保留 browser_script 提取逻辑")),
"]",
)
}
return strings.Join(lines, "\n") + "\n"
}
func buildSkillMD(skillName, description, toolName, toolDesc, domain string, args map[string]string, fields []string, mode string, failures []string) string {
var b strings.Builder
b.WriteString("---\n")
b.WriteString(fmt.Sprintf("name: %s\n", skillName))
b.WriteString(fmt.Sprintf("description: %s\n", description))
b.WriteString(fmt.Sprintf("version: %s\n", defaultVersion))
b.WriteString("author: sgclaw-skill-converter\n")
b.WriteString("tags:\n - sgclaw\n - browser_script\n - converter\n---\n\n")
b.WriteString("# Use Cases\n")
b.WriteString("- 输入 JS 脚本并生成可被 sgclaw 加载的技能包\n")
b.WriteString("- 适配 browser_script 执行模型,优先返回结构化结果\n")
b.WriteString("- 与运行时兼容,遵循 expected_domain 与 CSS 选择器约束\n\n")
b.WriteString("# Workflow\n")
b.WriteString("1. 解析脚本参数与返回结构\n2. 生成 SKILL.toml 与 SKILL.md\n3. 保持 JS 逻辑原样,落盘到 scripts/<tool>.js\n4. 输出 references 与 assets 文档\n\n")
b.WriteString("# Runtime Contract\n")
b.WriteString(fmt.Sprintf("- expected_domain: `%s`\n", orPlaceholder(domain, "未设置")))
b.WriteString(fmt.Sprintf("- tool name: `%s`\n", toolName))
b.WriteString(fmt.Sprintf("- tool description: %s\n", toolDesc))
b.WriteString("- args:\n")
if len(args) == 0 {
b.WriteString(" - 无\n")
}
for k, v := range args {
b.WriteString(fmt.Sprintf(" - %s: %s\n", k, v))
}
b.WriteString("- kind: browser_script\n")
b.WriteString(fmt.Sprintf("- command: scripts/%s.js\n\n", toolName))
b.WriteString("# Blocked-Page Rule\n")
b.WriteString("- 遇到登录、验证码、权限不足时必须 throw Error不得返回空数组或空对象。\n")
b.WriteString("- 明确报错文案,例如:登录/验证码拦截/权限不足。\n\n")
b.WriteString("# Output Contract\n")
b.WriteString("- structured-first返回对象或对象数组避免 getText 二次采集。\n")
b.WriteString(fmt.Sprintf("- artifact_fields: %v\n", fields))
b.WriteString("- superrpa_browser 优先;使用 CSS 选择器,禁止 XPath 与 jQuery :contains\n\n")
b.WriteString("# Partial/Fallback Rule\n")
for _, f := range failures {
b.WriteString(fmt.Sprintf("- %s\n", f))
}
b.WriteString("\n# References\n- `references/implementation-notes.md`\n- `assets/notes.md`\n\n")
b.WriteString(fmt.Sprintf("## 模式\n- preferred_mode: %s\n", mode))
return b.String() + "\n"
}
func buildReferenceNotes(skillName, toolName string, args map[string]string, fields []string) string {
var argKeys []string
for k := range args {
argKeys = append(argKeys, k)
}
return fmt.Sprintf(
"## Implementation Notes\n\n- skill_name: %s\n- tool: %s\n- args: %s\n- artifact_fields: %s\n- generator: main.go\n- 说明: 保持脚本业务逻辑不变,仅包装与文档产出。\n",
skillName, toolName, strings.Join(argKeys, ", "), strings.Join(fields, ", "),
)
}
func buildAssetNotes(skillName, domain, mode string) string {
return fmt.Sprintf(
"## Asset Notes\n\n- skill_name: %s\n- expected_domain: %s\n- mode: %s\n- 资源仅使用本地相对文档,不包含远程 markdown 链接。\n",
skillName,
orPlaceholder(domain, "未设置"),
mode,
)
}
func parseArgSpec(raw string) (map[string]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return map[string]string{}, nil
}
if strings.HasPrefix(raw, "@") {
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" {
return map[string]string{}, nil
}
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var data map[string]interface{}
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
out := map[string]string{}
for k, v := range data {
out[k] = fmt.Sprintf("%v", v)
}
return out, nil
}
if strings.HasPrefix(raw, "{") && strings.HasSuffix(raw, "}") {
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return nil, err
}
out := map[string]string{}
for k, v := range parsed {
out[k] = fmt.Sprintf("%v", v)
}
return out, nil
}
out := map[string]string{}
for _, p := range strings.Split(raw, ",") {
item := strings.TrimSpace(p)
if item == "" {
continue
}
if eq := strings.Index(item, "="); eq >= 0 {
k := strings.TrimSpace(item[:eq])
v := strings.TrimSpace(item[eq+1:])
if k != "" {
out[k] = v
}
} else if eq := strings.Index(item, ":"); eq >= 0 {
k := strings.TrimSpace(item[:eq])
v := strings.TrimSpace(item[eq+1:])
if k != "" {
out[k] = orPlaceholder(v, "参数")
}
} else {
out[item] = "参数"
}
}
return out, nil
}
func parseCommaValues(raw string) []string {
if strings.TrimSpace(raw) == "" {
return []string{}
}
var out []string
for _, x := range strings.Split(strings.ReplaceAll(raw, "", ","), ",") {
t := strings.TrimSpace(x)
if t != "" {
out = append(out, t)
}
}
return dedupeStrings(out)
}
func inferArgsFromScript(script string) map[string]string {
out := map[string]string{}
reDirect := regexp.MustCompile(`\bargs\.([A-Za-z_][A-Za-z0-9_]*)\b`)
for _, g := range reDirect.FindAllStringSubmatch(script, -1) {
if len(g) >= 2 {
out[g[1]] = "脚本参数 " + g[1]
}
}
reDestruct := regexp.MustCompile(`\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*\}\s*=\s*args`)
for _, g := range reDestruct.FindAllStringSubmatch(script, -1) {
if len(g) >= 2 {
for _, k := range strings.Split(g[1], ",") {
kk := strings.TrimSpace(k)
if kk != "" {
out[kk] = "脚本参数 " + kk
}
}
}
}
return out
}
func inferArtifactFields(script string) []string {
keys := []string{"source", "sheet_name", "columns", "rows", "items", "payload", "data"}
found := []string{}
s := strings.ToLower(script)
for _, k := range keys {
if strings.Contains(s, k) {
found = append(found, k)
}
}
if !strings.Contains(s, "return") && !strings.Contains(s, "throw") {
found = append(found, "rows")
}
return dedupeStrings(found)
}
func scanRisk(script string) []string {
s := strings.ToLower(script)
out := []string{}
for k, pats := range riskPatterns {
for _, p := range pats {
if strings.Contains(s, strings.ToLower(p)) {
out = append(out, fmt.Sprintf("%s: 检测到可疑片段 %s", k, p))
break
}
}
}
if !strings.Contains(s, "return") && !strings.Contains(s, "throw") {
out = append(out, "脚本未发现明显 return/throw 输出分支,运行后可能无法返回 payload")
}
return out
}
func resolveAIConfig(overrides *AiConfig) AiConfig {
settingsCfg := AiConfig{}
if settings, ok := readClaudeSettings(); ok {
settingsCfg.BaseURL = firstNonEmpty(
getMapString(settings, "base_url"),
getMapString(settings, "baseUrl"),
getSectionValue(settings, "env", "ANTHROPIC_BASE_URL"),
getSectionValue(settings, "envs", "ANTHROPIC_BASE_URL"),
getSectionValue(settings, "environment", "ANTHROPIC_BASE_URL"),
)
settingsCfg.APIKey = firstNonEmpty(
getMapString(settings, "api_key"),
getMapString(settings, "apiKey"),
getSectionValue(settings, "env", "ANTHROPIC_AUTH_TOKEN"),
getSectionValue(settings, "envs", "ANTHROPIC_AUTH_TOKEN"),
getSectionValue(settings, "environment", "ANTHROPIC_AUTH_TOKEN"),
)
settingsCfg.Model = firstNonEmpty(
getSectionValue(settings, "env", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
getSectionValue(settings, "envs", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
getSectionValue(settings, "environment", "ANTHROPIC_DEFAULT_OPUS_MODEL"),
getSectionValue(settings, "env", "GLM_MODEL"),
getSectionValue(settings, "envs", "GLM_MODEL"),
getSectionValue(settings, "environment", "GLM_MODEL"),
getMapString(settings, "defaultModel"),
)
settingsCfg.TimeoutSecond = firstNonZeroInt(
parseMillisToSeconds(getSectionValue(settings, "env", "API_TIMEOUT_MS")),
parseMillisToSeconds(getSectionValue(settings, "envs", "API_TIMEOUT_MS")),
parseMillisToSeconds(getSectionValue(settings, "environment", "API_TIMEOUT_MS")),
parseInt(getSectionValue(settings, "env", "GLM_TIMEOUT")),
parseInt(getSectionValue(settings, "envs", "GLM_TIMEOUT")),
parseInt(getSectionValue(settings, "environment", "GLM_TIMEOUT")),
parseInt(getSectionValue(settings, "env", "ANTHROPIC_TIMEOUT")),
parseInt(getSectionValue(settings, "envs", "ANTHROPIC_TIMEOUT")),
parseInt(getSectionValue(settings, "environment", "ANTHROPIC_TIMEOUT")),
)
}
envCfg := AiConfig{
BaseURL: firstNonEmpty(os.Getenv("GLM_BASE_URL"), os.Getenv("ANTHROPIC_BASE_URL"), os.Getenv("GLM_API_BASE_URL")),
APIKey: firstNonEmpty(os.Getenv("GLM_API_KEY"), os.Getenv("ANTHROPIC_AUTH_TOKEN"), os.Getenv("OPENAI_API_KEY")),
Model: firstNonEmpty(os.Getenv("GLM_MODEL"), os.Getenv("ANTHROPIC_DEFAULT_OPUS_MODEL"), os.Getenv("OPENAI_MODEL")),
TimeoutSecond: firstNonZeroInt(
parseMillisToSeconds(os.Getenv("API_TIMEOUT_MS")),
parseInt(os.Getenv("GLM_TIMEOUT")),
parseInt(os.Getenv("ANTHROPIC_TIMEOUT")),
parseInt(os.Getenv("OPENAI_TIMEOUT")),
),
}
cfg := AiConfig{
BaseURL: firstNonEmpty(settingsCfg.BaseURL, envCfg.BaseURL, defaultBaseURL),
APIKey: firstNonEmpty(settingsCfg.APIKey, envCfg.APIKey),
Model: firstNonEmpty(settingsCfg.Model, envCfg.Model, defaultModel),
TimeoutSecond: firstNonZeroInt(settingsCfg.TimeoutSecond, envCfg.TimeoutSecond, defaultAITimeout),
}
cfg.BaseURL = normalizeAIBaseURL(cfg.BaseURL)
if overrides != nil {
cfg.BaseURL = firstNonEmpty(strings.TrimSpace(overrides.BaseURL), cfg.BaseURL)
cfg.APIKey = firstNonEmpty(strings.TrimSpace(overrides.APIKey), cfg.APIKey)
cfg.Model = firstNonEmpty(strings.TrimSpace(overrides.Model), cfg.Model)
if overrides.TimeoutSecond > 0 {
cfg.TimeoutSecond = overrides.TimeoutSecond
}
cfg.BaseURL = normalizeAIBaseURL(cfg.BaseURL)
}
cfg.Enabled = cfg.BaseURL != "" && cfg.APIKey != ""
return cfg
}
func parseAIConfig(raw interface{}) *AiConfig {
m, ok := raw.(map[string]interface{})
if !ok {
return nil
}
cfg := &AiConfig{}
if v, ok := m["base_url"].(string); ok {
cfg.BaseURL = strings.TrimSpace(v)
}
if v, ok := m["api_key"].(string); ok {
cfg.APIKey = strings.TrimSpace(v)
}
if v, ok := m["model"].(string); ok {
cfg.Model = strings.TrimSpace(v)
}
if v, ok := m["timeout"]; ok {
if timeout, ok := toInt(v); ok && timeout > 0 {
cfg.TimeoutSecond = timeout
}
}
if cfg.BaseURL == "" && cfg.APIKey == "" && cfg.Model == "" && cfg.TimeoutSecond == 0 {
return nil
}
return cfg
}
func readClaudeSettings() (map[string]interface{}, bool) {
home, err := os.UserHomeDir()
if err != nil {
return nil, false
}
p := filepath.Join(home, ".claude", "settings.json")
raw, err := os.ReadFile(p)
if err != nil {
return nil, false
}
var data map[string]interface{}
if err := json.Unmarshal(raw, &data); err != nil {
return nil, false
}
return data, true
}
func getSectionValue(settings map[string]interface{}, section string, key string) string {
v, ok := settings[section]
if !ok {
return ""
}
if m, ok := v.(map[string]interface{}); ok {
if x, ok := m[key]; ok {
if s, ok := x.(string); ok {
return s
}
}
}
return ""
}
func getMapString(settings map[string]interface{}, key string) string {
value, ok := settings[key]
if !ok {
return ""
}
s, ok := value.(string)
if !ok {
return ""
}
return s
}
func toTOMLString(v string) string {
return "\"" + strings.ReplaceAll(strings.ReplaceAll(v, "\\", "\\\\"), "\"", "\\\"") + "\""
}
func toTOMLArray(arr []string) string {
if len(arr) == 0 {
return "[]"
}
parts := make([]string, 0, len(arr))
for _, a := range arr {
parts = append(parts, toTOMLString(a))
}
return "[" + strings.Join(parts, ", ") + "]"
}
func slugify(s string) string {
s = strings.ToLower(s)
re := regexp.MustCompile(`[^a-z0-9_-]+`)
s = re.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
s = regexp.MustCompile(`-+`).ReplaceAllString(s, "-")
if s == "" {
return "skill"
}
if len(s) > 56 {
s = s[:56]
}
return s
}
func normalizeToolName(v, fallback string) string {
v = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(v, "-", "_"), " ", "_")))
re := regexp.MustCompile(`[^a-z0-9_]`)
v = re.ReplaceAllString(v, "_")
v = strings.Trim(v, "_")
if v == "" {
return fallback
}
if v[0] >= '0' && v[0] <= '9' {
return fallback + "_" + v
}
return v
}
func stripDomain(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
raw = strings.TrimPrefix(raw, "http://")
raw = strings.TrimPrefix(raw, "https://")
if i := strings.Index(raw, "/"); i >= 0 {
raw = raw[:i]
}
return raw
}
func dedupeStrings(arr []string) []string {
m := map[string]struct{}{}
out := []string{}
for _, a := range arr {
a = strings.TrimSpace(a)
if a == "" {
continue
}
if _, ok := m[a]; ok {
continue
}
m[a] = struct{}{}
out = append(out, a)
}
return out
}
func deriveDefaultName(skillName, jsPath string) string {
if skillName != "" {
return skillName
}
if jsPath != "" {
base := filepath.Base(jsPath)
return strings.TrimSuffix(base, filepath.Ext(base))
}
return "converted-skill"
}
func ensureTrailNewline(s string) string {
if strings.HasSuffix(s, "\n") {
return s
}
return s + "\n"
}
func orPlaceholder(v, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return v
}
func firstNonEmpty(items ...string) string {
for _, v := range items {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func firstNonZeroInt(items ...int) int {
for _, v := range items {
if v > 0 {
return v
}
}
return 0
}
func parseInt(v string) int {
if strings.TrimSpace(v) == "" {
return 0
}
i, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return i
}
func parseMillisToSeconds(v string) int {
if strings.TrimSpace(v) == "" {
return 0
}
i, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || i <= 0 {
return 0
}
if i < 1000 {
return 1
}
return i / 1000
}
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case float32:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case int32:
return int(n), true
case json.Number:
i, err := n.Int64()
if err != nil {
return 0, false
}
return int(i), true
case string:
if strings.TrimSpace(n) == "" {
return 0, false
}
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, false
}
return i, true
default:
return 0, false
}
}
func keysFromMap(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func openFolder(path string) error {
switch runtime.GOOS {
case "windows":
cmd := exec.Command("cmd", "/c", "start", "", path)
return cmd.Run()
case "darwin":
return exec.Command("open", path).Run()
default:
return exec.Command("xdg-open", path).Run()
}
}
func strValue(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
if num, ok := v.(float64); ok {
return strconv.FormatFloat(num, 'f', -1, 64)
}
return fmt.Sprintf("%v", v)
}
func maskSecret(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
runes := []rune(v)
if len(runes) <= 6 {
return string(runes[:1]) + "***" + string(runes[len(runes)-1:])
}
return string(runes[:3]) + "***" + string(runes[len(runes)-3:])
}
func normalizeAIBaseURL(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return v
}
if strings.Contains(v, "api.z.ai/api/anthropic") || strings.Contains(v, "docs.z.ai") {
return "https://api.z.ai/api/coding/paas/v4"
}
return strings.TrimRight(v, "/")
}
func hasUsableAIOutput(out *AIGenerated) bool {
if out == nil {
return false
}
return strings.TrimSpace(out.ToolName) != "" ||
strings.TrimSpace(out.ToolDescription) != "" ||
strings.TrimSpace(out.SkillDescription) != "" ||
strings.TrimSpace(out.SkillToml) != "" ||
strings.TrimSpace(out.SkillMd) != ""
}
func compactErrorText(raw []byte) string {
text := strings.TrimSpace(string(raw))
if text == "" {
return "empty response"
}
if len(text) > 180 {
return text[:180] + "..."
}
return text
}
func parseAIGenerated(raw string) (*AIGenerated, error) {
var data map[string]interface{}
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil, err
}
out := &AIGenerated{
ToolName: coerceString(data["tool_name"]),
ToolDescription: coerceString(data["tool_description"]),
SkillDescription: coerceString(data["skill_description"]),
ArtifactFields: coerceStringSlice(data["artifact_fields"]),
FailureModes: coerceStringSlice(data["failure_modes"]),
SkillToml: coerceString(data["skill_toml"]),
SkillMd: coerceString(data["skill_md"]),
ImplementationNote: coerceString(data["implementation_notes"]),
AssetNote: coerceString(data["asset_notes"]),
RiskCheck: coerceStringSlice(data["risk_check"]),
}
return out, nil
}
func coerceString(v interface{}) string {
switch x := v.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(x)
case json.Number:
return x.String()
case float64, float32, int, int32, int64, bool:
return strings.TrimSpace(fmt.Sprintf("%v", x))
default:
return ""
}
}
func coerceStringSlice(v interface{}) []string {
switch x := v.(type) {
case nil:
return []string{}
case string:
if strings.TrimSpace(x) == "" {
return []string{}
}
return dedupeStrings(parseCommaValues(strings.ReplaceAll(x, "\n", ",")))
case []interface{}:
out := make([]string, 0, len(x))
for _, item := range x {
switch y := item.(type) {
case string:
if s := strings.TrimSpace(y); s != "" {
out = append(out, s)
}
case map[string]interface{}:
for k := range y {
if s := strings.TrimSpace(k); s != "" {
out = append(out, s)
}
}
default:
if s := coerceString(y); s != "" {
out = append(out, s)
}
}
}
return dedupeStrings(out)
case map[string]interface{}:
out := make([]string, 0, len(x))
for k := range x {
if s := strings.TrimSpace(k); s != "" {
out = append(out, s)
}
}
return dedupeStrings(out)
default:
if s := coerceString(x); s != "" {
return []string{s}
}
return []string{}
}
}
func rewriteTomlCommand(tomlContent string, commandPath string) string {
lines := strings.Split(tomlContent, "\n")
replaced := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "command = ") {
lines[i] = fmt.Sprintf(`command = "%s"`, commandPath)
replaced = true
break
}
}
if !replaced {
return tomlContent
}
return strings.Join(lines, "\n")
}
func rewriteMarkdownCommand(mdContent string, commandPath string) string {
re := regexp.MustCompile(`(?m)^- command: .*$`)
if re.MatchString(mdContent) {
return re.ReplaceAllString(mdContent, fmt.Sprintf("- command: %s", commandPath))
}
return mdContent
}
func rewriteMarkdownToolName(mdContent string, toolName string) string {
re := regexp.MustCompile("(?m)^- tool name: `.*`$")
if re.MatchString(mdContent) {
return re.ReplaceAllString(mdContent, fmt.Sprintf("- tool name: `%s`", toolName))
}
return mdContent
}