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/.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/.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 }