@shulex/cxclawctl 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/README.md +228 -2
  2. package/bin/cxclawctl.mjs +11 -0
  3. package/capabilities.lock.json +898 -0
  4. package/dist/cli.js +363 -0
  5. package/dist/cli.js.map +1 -0
  6. package/package.json +33 -4
  7. package/requirements-cli.txt +2 -0
  8. package/skills/__lib__/__init__.py +0 -0
  9. package/skills/__lib__/skill-runtime/bootstrap.py +134 -0
  10. package/skills/__lib__/solvea-api/__init__.py +0 -0
  11. package/skills/__lib__/solvea-api/client.py +1944 -0
  12. package/skills/__lib__/solvea-api/dashboard.py +100 -0
  13. package/skills/__lib__/solvea-api/solvea_api.py +1341 -0
  14. package/skills/agent-analytics/SKILL.md +74 -0
  15. package/skills/agent-analytics/references/chat-report-fields.md +36 -0
  16. package/skills/agent-analytics/references/report-fields.md +130 -0
  17. package/skills/agent-analytics/references/transcript.md +110 -0
  18. package/skills/agent-builder/SKILL.md +42 -0
  19. package/skills/agent-builder/interfaces/fallback-chatflow.md +47 -0
  20. package/skills/agent-builder/references/chatflow/graph-spec.md +259 -0
  21. package/skills/agent-builder/references/chatflow/node-selection.md +89 -0
  22. package/skills/agent-builder/references/chatflow/nodes/agent.md +350 -0
  23. package/skills/agent-builder/references/chatflow/nodes/business-track.md +43 -0
  24. package/skills/agent-builder/references/chatflow/nodes/code.md +157 -0
  25. package/skills/agent-builder/references/chatflow/nodes/collect-order.md +47 -0
  26. package/skills/agent-builder/references/chatflow/nodes/finish.md +34 -0
  27. package/skills/agent-builder/references/chatflow/nodes/human-review.md +60 -0
  28. package/skills/agent-builder/references/chatflow/nodes/if-else.md +100 -0
  29. package/skills/agent-builder/references/chatflow/nodes/intent.md +79 -0
  30. package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +43 -0
  31. package/skills/agent-builder/references/chatflow/nodes/llm.md +117 -0
  32. package/skills/agent-builder/references/chatflow/nodes/note.md +38 -0
  33. package/skills/agent-builder/references/chatflow/nodes/question.md +97 -0
  34. package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +68 -0
  35. package/skills/agent-builder/references/chatflow/nodes/reply.md +97 -0
  36. package/skills/agent-builder/references/chatflow/nodes/start.md +35 -0
  37. package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +48 -0
  38. package/skills/agent-builder/references/chatflow/nodes/tool.md +138 -0
  39. package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +63 -0
  40. package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +52 -0
  41. package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +50 -0
  42. package/skills/agent-builder/references/chatflow/nodes/variable-process.md +70 -0
  43. package/skills/agent-builder/references/chatflow/overview.md +53 -0
  44. package/skills/agent-builder/references/chatflow/visual-translation.md +64 -0
  45. package/skills/agent-builder/references/chatflow/workflow.md +134 -0
  46. package/skills/agent-builder/references/multi-agent/create-update.md +185 -0
  47. package/skills/agent-builder/references/multi-agent/graph-spec.md +246 -0
  48. package/skills/agent-builder/references/multi-agent/graph-structure.md +12 -0
  49. package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +13 -0
  50. package/skills/agent-builder/references/multi-agent/migration/large.md +554 -0
  51. package/skills/agent-builder/references/multi-agent/migration/principles.md +446 -0
  52. package/skills/agent-builder/references/multi-agent/migration/small.md +225 -0
  53. package/skills/agent-builder/references/multi-agent/orchestration.md +11 -0
  54. package/skills/agent-builder/references/multi-agent/overview.md +48 -0
  55. package/skills/agent-builder/references/multi-agent/update.md +128 -0
  56. package/skills/agent-builder/references/multi-agent/workflow.md +87 -0
  57. package/skills/agent-builder/references/single-agent/overview.md +48 -0
  58. package/skills/agent-builder/references/single-agent/prompt.md +16 -0
  59. package/skills/agent-builder/references/single-agent/tools.md +36 -0
  60. package/skills/agent-builder/references/single-agent/voice.md +46 -0
  61. package/skills/agent-builder/references/tools/http-tool.md +315 -0
  62. package/skills/agent-builder/references/tools/mock-tool.md +279 -0
  63. package/skills/agent-builder/references/tools/overview.md +46 -0
  64. package/skills/agent-builder/references/tools/tool-config.md +43 -0
  65. package/skills/agent-builder/references/tools/x-token.md +34 -0
  66. package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +3458 -0
  67. package/skills/agent-builder/scripts/chatflow/validate_graph.py +1697 -0
  68. package/skills/agent-builder/scripts/copy_marketplace_agent.py +61 -0
  69. package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +5974 -0
  70. package/skills/agent-builder/scripts/multi-agent/replay_compare.py +679 -0
  71. package/skills/agent-builder/scripts/patch_agent_tools.py +135 -0
  72. package/skills/agent-builder/scripts/tools/validate_tool_config.py +384 -0
  73. package/skills/agent-tester/SKILL.md +35 -0
  74. package/skills/agent-tester/batch-test/overview.md +86 -0
  75. package/skills/agent-tester/scripts/batch_test_sheet.py +213 -0
  76. package/skills/agent-tester/single-test/badcase-retest.md +85 -0
  77. package/skills/agent-tester/single-test/direct-test.md +24 -0
  78. package/skills/agent-tester/single-test/overview.md +142 -0
  79. package/skills/delivery/SKILL.md +112 -0
  80. package/skills/delivery/interfaces/classify.md +38 -0
  81. package/skills/delivery/interfaces/create-agents.md +89 -0
  82. package/skills/delivery/interfaces/delivery-test.md +67 -0
  83. package/skills/delivery/references/flow.md +40 -0
  84. package/skills/delivery/references/validation.md +15 -0
  85. package/skills/delivery/scripts/batch_test.py +704 -0
  86. package/skills/delivery/scripts/classify.py +22 -0
  87. package/skills/delivery/scripts/create_agents.py +695 -0
  88. package/skills/delivery/scripts/gen_delivery_report.py +335 -0
  89. package/skills/delivery/scripts/gen_excel.py +234 -0
  90. package/skills/delivery/scripts/gen_report.py +208 -0
  91. package/skills/delivery/scripts/plan.py +163 -0
  92. package/skills/delivery/scripts/render.py +713 -0
  93. package/skills/delivery/scripts/render_summary.py +38 -0
  94. package/skills/delivery/scripts/sample_history_cases.py +109 -0
  95. package/skills/kb-builder/SKILL.md +80 -0
  96. package/skills/kb-builder/interfaces/qa-upload.md +55 -0
  97. package/skills/kb-builder/interfaces/web-kb.md +84 -0
  98. package/skills/kb-builder/references/directory.md +373 -0
  99. package/skills/kb-builder/references/export.md +282 -0
  100. package/skills/kb-builder/references/isolation-keywords.md +113 -0
  101. package/skills/kb-builder/references/knowledge.md +153 -0
  102. package/skills/kb-builder/references/product.md +52 -0
  103. package/skills/kb-builder/references/qa-from-help-center.md +58 -0
  104. package/skills/kb-builder/references/qa-from-pdf.md +94 -0
  105. package/skills/kb-builder/references/qa-from-tickets.md +168 -0
  106. package/skills/kb-builder/references/qa-from-web.md +98 -0
  107. package/skills/kb-builder/references/qa-quality.md +73 -0
  108. package/skills/kb-builder/references/reply-template.md +237 -0
  109. package/skills/kb-builder/references/tag-isolation-migration.md +61 -0
  110. package/skills/kb-builder/references/tags.md +45 -0
  111. package/skills/kb-builder/references/upload.md +130 -0
  112. package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +333 -0
  113. package/skills/kb-builder/scripts/build_directory_plan.py +144 -0
  114. package/skills/kb-builder/scripts/crawl_site.py +433 -0
  115. package/skills/kb-builder/scripts/create_dirs.py +146 -0
  116. package/skills/kb-builder/scripts/export_knowledge.py +454 -0
  117. package/skills/kb-builder/scripts/extract_ticket_qa.py +441 -0
  118. package/skills/kb-builder/scripts/extract_web_qa.py +118 -0
  119. package/skills/kb-builder/scripts/import_qa.py +219 -0
  120. package/skills/kb-builder/scripts/import_source_documents.py +143 -0
  121. package/skills/kb-builder/scripts/import_templates.py +409 -0
  122. package/skills/poc/SKILL.md +125 -0
  123. package/skills/poc/assets/default_manual.css +279 -0
  124. package/skills/poc/assets/notice_escalate.png +0 -0
  125. package/skills/poc/assets/notice_escalate2.png +0 -0
  126. package/skills/poc/assets/notice_new_chat.png +0 -0
  127. package/skills/poc/assets/poc_test_template.html +645 -0
  128. package/skills/poc/assets/poc_test_template.md +104 -0
  129. package/skills/poc/assets/step3_preview.png +0 -0
  130. package/skills/poc/assets/step4_order_note.png +0 -0
  131. package/skills/poc/interfaces/classify.md +38 -0
  132. package/skills/poc/interfaces/create-agents.md +108 -0
  133. package/skills/poc/interfaces/poc-test.md +94 -0
  134. package/skills/poc/references/flow.md +47 -0
  135. package/skills/poc/references/kb-directory.md +22 -0
  136. package/skills/poc/references/validation.md +17 -0
  137. package/skills/poc/scripts/classify.py +22 -0
  138. package/skills/poc/scripts/create_agents.py +818 -0
  139. package/skills/poc/scripts/gen_excel.py +234 -0
  140. package/skills/poc/scripts/gen_poc_manual.py +249 -0
  141. package/skills/poc/scripts/gen_report.py +208 -0
  142. package/skills/poc/scripts/inspect_chatflow.py +209 -0
  143. package/skills/poc/scripts/plan.py +178 -0
  144. package/skills/poc/scripts/poc_test.py +495 -0
  145. package/skills/poc/scripts/render.py +713 -0
  146. package/skills/poc/scripts/render_summary.py +41 -0
  147. package/skills/runtime-inspector/SKILL.md +51 -0
  148. package/skills/runtime-inspector/references/frontend-rendering.md +126 -0
  149. package/skills/runtime-inspector/references/model.md +59 -0
  150. package/skills/runtime-inspector/references/sandbox.md +131 -0
  151. package/skills/runtime-inspector/scripts/model_inspect.py +68 -0
  152. package/skills/runtime-inspector/scripts/prepare.py +98 -0
  153. package/skills/runtime-inspector/scripts/process.py +84 -0
  154. package/skills/runtime-inspector/scripts/read_state.py +32 -0
  155. package/skills/runtime-inspector/scripts/sandbox_inspect.py +463 -0
  156. package/skills/runtime-inspector/scripts/test_claude_proxy.py +49 -0
  157. package/skills/runtime-inspector/scripts/test_openai_proxy.py +41 -0
  158. package/skills/runtime-inspector/scripts/verify_persistence.py +101 -0
  159. package/skills/runtime-inspector/scripts/write_test.py +41 -0
  160. package/skills/setup/SKILL.md +17 -0
  161. package/skills/shulex-support/SKILL.md +39 -0
  162. package/skills/shulex-support/references/badcase-analysis/overview.md +34 -0
  163. package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +105 -0
  164. package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +89 -0
  165. package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +36 -0
  166. package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +53 -0
  167. package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +259 -0
  168. package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +148 -0
  169. package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +57 -0
  170. package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +120 -0
  171. package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +126 -0
  172. package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +121 -0
  173. package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +208 -0
  174. package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +115 -0
  175. package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +72 -0
  176. package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +92 -0
  177. package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +147 -0
  178. package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +283 -0
  179. package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +77 -0
  180. package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +19 -0
  181. package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +23 -0
  182. package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +31 -0
  183. package/skills/shulex-support/references/product-docs.md +70 -0
  184. package/skills/shulex-support/references/tickets.md +184 -0
  185. package/skills/shulex-support/scripts/support.py +427 -0
  186. package/skills/solvea-desk/SKILL.md +39 -0
  187. package/skills/solvea-desk/references/report/fields.md +82 -0
  188. package/skills/solvea-desk/references/report/report-design.md +200 -0
  189. package/skills/solvea-desk/references/report/report-template.html +44 -0
  190. package/skills/solvea-desk/references/report/report.css +517 -0
  191. package/skills/solvea-desk/references/report/workflow.md +464 -0
  192. package/skills/solvea-desk/references/tickets/query.md +103 -0
  193. package/skills/solvea-desk/references/tickets/tags.md +83 -0
  194. package/skills/solvea-desk/scripts/report/fetch_sources.py +580 -0
  195. package/skills/solvea-desk/scripts/report/render_report.py +763 -0
  196. package/skills/solvea-desk/scripts/tickets/desk_api.py +73 -0
  197. package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +403 -0
  198. package/skills/solvea-desk/scripts/tickets/ticket_tags.py +305 -0
  199. package/skills/solvea-kit/SKILL.md +84 -0
  200. package/skills/solvea-kit/references/agent.md +145 -0
  201. package/skills/solvea-kit/references/chat.md +244 -0
  202. package/skills/solvea-kit/references/intent.md +69 -0
  203. package/skills/solvea-kit/references/plugin.md +53 -0
  204. package/skills/solvea-kit/references/quality-inspection.md +75 -0
  205. package/skills/solvea-kit/references/strategy.md +62 -0
  206. package/skills/solvea-kit/references/tagging.md +231 -0
  207. package/skills/solvea-kit/references/variables.md +53 -0
  208. package/skills/solvea-kit/scripts/generate_label_import_file.py +151 -0
  209. package/skills/solvea-kit/scripts/intent_classify.py +843 -0
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env python3
2
+ """KB website crawl pipeline entry.
3
+
4
+ Phase 4 implementation:
5
+ - 固定 `--mode auto|crawl|legacy` 与 `--provider auto|crawl4ai|tavily` CLI。
6
+ - `legacy` 明确输出兼容旧 reference 路径的结果,不执行抓取。
7
+ - `crawl/provider=auto` 使用内置轻量 HTTP BFS crawler,输出稳定 crawl_result。
8
+ - `crawl4ai` provider 使用 sandbox 内 Python 依赖抓取 JS/动态站点,失败时给出显式错误。
9
+
10
+ 本脚本只负责 KB builder 内部网页抓取能力,不暴露给 POC / Delivery 业务细节。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import html
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ import time
22
+ from collections import deque
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any
26
+ from urllib.parse import urldefrag, urljoin, urlparse
27
+ from urllib.request import Request, urlopen
28
+
29
+ sys.path.insert(0, os.path.join(os.environ.get("SOLVEA_LIB_DIR", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "__lib__"))), "skill-runtime"))
30
+ from bootstrap import * # noqa: F401,F403
31
+
32
+
33
+ USER_AGENT = "SolveaKit-KB-Crawler/1.0"
34
+
35
+
36
+ def resolve_workspace_path(path: str | Path) -> Path:
37
+ p = Path(path)
38
+ if p.is_absolute() and str(p).startswith("/workspace"):
39
+ return WORKSPACE / p.relative_to("/workspace")
40
+ return p
41
+
42
+
43
+ def virtual_workspace_path(path: Path) -> str:
44
+ try:
45
+ rel = path.resolve().relative_to(WORKSPACE.resolve())
46
+ return str(Path("/workspace") / rel)
47
+ except Exception:
48
+ return str(path)
49
+
50
+
51
+ def slugify(value: str, fallback: str) -> str:
52
+ text = re.sub(r"[^a-zA-Z0-9._-]+", "-", value.strip().lower()).strip("-")
53
+ return text[:80] or fallback
54
+
55
+
56
+ def normalize_url(url: str) -> str:
57
+ clean, _frag = urldefrag(url.strip())
58
+ return clean.rstrip("/")
59
+
60
+
61
+ def same_domain(url: str, base_netloc: str) -> bool:
62
+ netloc = urlparse(url).netloc.lower()
63
+ return netloc == base_netloc.lower()
64
+
65
+
66
+ def fetch_html(url: str, timeout: int) -> tuple[int, str, str]:
67
+ req = Request(url, headers={"User-Agent": USER_AGENT})
68
+ with urlopen(req, timeout=timeout) as resp:
69
+ status = getattr(resp, "status", 200)
70
+ final_url = resp.geturl()
71
+ raw = resp.read()
72
+ charset = resp.headers.get_content_charset() or "utf-8"
73
+ try:
74
+ text = raw.decode(charset, errors="replace")
75
+ except LookupError:
76
+ text = raw.decode("utf-8", errors="replace")
77
+ return status, final_url, text
78
+
79
+
80
+ def extract_title(html_text: str, url: str) -> str:
81
+ m = re.search(r"<title[^>]*>(.*?)</title>", html_text, flags=re.I | re.S)
82
+ if not m:
83
+ return urlparse(url).path.strip("/") or url
84
+ title = re.sub(r"\s+", " ", html.unescape(m.group(1))).strip()
85
+ return title or url
86
+
87
+
88
+ def strip_noise(html_text: str) -> str:
89
+ text = re.sub(r"(?is)<(script|style|noscript|svg|canvas).*?</\1>", " ", html_text)
90
+ text = re.sub(r"(?is)<(nav|footer|header)[^>]*>.*?</\1>", " ", text)
91
+ return text
92
+
93
+
94
+ def html_to_markdown(html_text: str) -> str:
95
+ cleaned = strip_noise(html_text)
96
+ try:
97
+ from markdownify import markdownify as md # type: ignore
98
+
99
+ markdown = md(cleaned, heading_style="ATX")
100
+ except Exception:
101
+ markdown = re.sub(r"(?i)<br\s*/?>", "\n", cleaned)
102
+ markdown = re.sub(r"(?i)</(p|div|section|article|li|h[1-6])>", "\n", markdown)
103
+ markdown = re.sub(r"<[^>]+>", " ", markdown)
104
+ markdown = html.unescape(markdown)
105
+ lines = [re.sub(r"[ \t]+", " ", line).strip() for line in markdown.splitlines()]
106
+ compact: list[str] = []
107
+ blank = False
108
+ for line in lines:
109
+ if not line:
110
+ if not blank:
111
+ compact.append("")
112
+ blank = True
113
+ continue
114
+ compact.append(line)
115
+ blank = False
116
+ return "\n".join(compact).strip()
117
+
118
+
119
+ def extract_links(html_text: str, page_url: str, base_netloc: str) -> list[str]:
120
+ links: list[str] = []
121
+ for href in re.findall(r'''(?is)<a[^>]+href=["']([^"']+)["']''', html_text):
122
+ if href.startswith(("mailto:", "tel:", "javascript:")):
123
+ continue
124
+ absolute = normalize_url(urljoin(page_url, html.unescape(href)))
125
+ parsed = urlparse(absolute)
126
+ if parsed.scheme not in ("http", "https"):
127
+ continue
128
+ if not same_domain(absolute, base_netloc):
129
+ continue
130
+ if re.search(r"\.(jpg|jpeg|png|gif|webp|svg|pdf|zip|rar|7z|mp4|mov|avi)$", parsed.path, re.I):
131
+ continue
132
+ links.append(absolute)
133
+ return sorted(set(links))
134
+
135
+
136
+ def classify_page(url: str, title: str, markdown: str) -> str:
137
+ path = urlparse(url).path.lower()
138
+ if path.startswith("/products/") or path.startswith("/collections/"):
139
+ return "product"
140
+ if path.startswith("/policies/") or re.search(r"/(warranty|return|refund|shipping|privacy|terms|policy)", path):
141
+ return "policy"
142
+ if re.search(r"/(help|support|docs|documentation|faq|knowledge-base|troubleshoot)", path):
143
+ return "help_center"
144
+
145
+ hay = f"{url}\n{title}\n{markdown[:3000]}".lower()
146
+ if re.search(r"\b(help|support|docs|documentation|faq|knowledge-base|troubleshoot)\b", hay):
147
+ return "help_center"
148
+ if re.search(r"\b(product|products|collection|collections|sku|model|add to cart|buy now|shop)\b", hay):
149
+ return "product"
150
+ if re.search(r"\b(warranty|return|refund|shipping|privacy|terms|policy|guarantee)\b", hay):
151
+ return "policy"
152
+ return "other"
153
+
154
+
155
+ def write_page_markdown(pages_dir: Path, index: int, url: str, title: str, markdown: str) -> Path:
156
+ pages_dir.mkdir(parents=True, exist_ok=True)
157
+ slug = slugify(title or urlparse(url).path, f"page-{index:03d}")
158
+ out = pages_dir / f"{index:03d}_{slug}.md"
159
+ out.write_text(f"# {title}\n\nURL: {url}\n\n{markdown}\n", encoding="utf-8")
160
+ return out
161
+
162
+
163
+ def crawl_builtin(base_url: str, *, max_depth: int, max_pages: int, timeout: int, output_path: Path) -> dict[str, Any]:
164
+ parsed = urlparse(base_url)
165
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
166
+ raise ValueError(f"invalid URL: {base_url}")
167
+
168
+ base_url = normalize_url(base_url)
169
+ queue: deque[tuple[str, int]] = deque([(base_url, 0)])
170
+ seen: set[str] = set()
171
+ pages: list[dict[str, Any]] = []
172
+ errors: list[dict[str, Any]] = []
173
+ pages_dir = output_path.parent / f"{output_path.stem}_pages"
174
+
175
+ while queue and len(pages) < max_pages:
176
+ url, depth = queue.popleft()
177
+ url = normalize_url(url)
178
+ if url in seen:
179
+ continue
180
+ seen.add(url)
181
+ try:
182
+ status, final_url, html_text = fetch_html(url, timeout=timeout)
183
+ title = extract_title(html_text, final_url)
184
+ markdown = html_to_markdown(html_text)
185
+ links = extract_links(html_text, final_url, parsed.netloc)
186
+ page_type = classify_page(final_url, title, markdown)
187
+ md_path = write_page_markdown(pages_dir, len(pages) + 1, final_url, title, markdown)
188
+ pages.append({
189
+ "url": final_url,
190
+ "title": title,
191
+ "type": page_type,
192
+ "depth": depth,
193
+ "status": status,
194
+ "markdown_path": virtual_workspace_path(md_path),
195
+ "fit_markdown_path": virtual_workspace_path(md_path),
196
+ "links": links[:100],
197
+ "images": [],
198
+ })
199
+ if depth < max_depth:
200
+ for link in links:
201
+ if link not in seen:
202
+ queue.append((link, depth + 1))
203
+ except Exception as exc:
204
+ errors.append({"url": url, "depth": depth, "error": str(exc)})
205
+
206
+ return {
207
+ "base_url": base_url,
208
+ "provider": "builtin",
209
+ "mode": "crawl",
210
+ "crawled_at": datetime.now(timezone.utc).isoformat(),
211
+ "max_depth": max_depth,
212
+ "max_pages": max_pages,
213
+ "pages": pages,
214
+ "errors": errors,
215
+ }
216
+
217
+
218
+ def result_value(obj: Any, name: str, default: Any = None) -> Any:
219
+ if isinstance(obj, dict):
220
+ return obj.get(name, default)
221
+ return getattr(obj, name, default)
222
+
223
+
224
+ def markdown_value(result: Any) -> tuple[str, str]:
225
+ markdown_obj = result_value(result, "markdown")
226
+ fit_markdown = result_value(markdown_obj, "fit_markdown") or result_value(result, "fit_markdown") or ""
227
+ markdown = (
228
+ result_value(markdown_obj, "raw_markdown")
229
+ or result_value(markdown_obj, "markdown_with_citations")
230
+ or result_value(result, "markdown")
231
+ or ""
232
+ )
233
+ if not isinstance(markdown, str):
234
+ markdown = str(markdown or "")
235
+ if not isinstance(fit_markdown, str):
236
+ fit_markdown = str(fit_markdown or "")
237
+ return markdown.strip(), fit_markdown.strip()
238
+
239
+
240
+ def links_from_crawl4ai(result: Any, page_url: str, base_netloc: str) -> list[str]:
241
+ raw_links = result_value(result, "links", {}) or {}
242
+ candidates: list[Any] = []
243
+ if isinstance(raw_links, dict):
244
+ for key in ("internal", "external"):
245
+ value = raw_links.get(key) or []
246
+ if isinstance(value, list):
247
+ candidates.extend(value)
248
+ elif isinstance(raw_links, list):
249
+ candidates.extend(raw_links)
250
+
251
+ links: list[str] = []
252
+ for item in candidates:
253
+ href = item.get("href") if isinstance(item, dict) else str(item or "")
254
+ if not href or href.startswith(("mailto:", "tel:", "javascript:")):
255
+ continue
256
+ absolute = normalize_url(urljoin(page_url, html.unescape(href)))
257
+ parsed = urlparse(absolute)
258
+ if parsed.scheme not in ("http", "https"):
259
+ continue
260
+ if not same_domain(absolute, base_netloc):
261
+ continue
262
+ if re.search(r"\.(jpg|jpeg|png|gif|webp|svg|pdf|zip|rar|7z|mp4|mov|avi)$", parsed.path, re.I):
263
+ continue
264
+ links.append(absolute)
265
+ return sorted(set(links))
266
+
267
+
268
+ async def crawl4ai_page(crawler: Any, url: str, timeout: int) -> Any:
269
+ try:
270
+ from crawl4ai import CacheMode, CrawlerRunConfig # type: ignore
271
+
272
+ config = CrawlerRunConfig(
273
+ cache_mode=CacheMode.BYPASS,
274
+ page_timeout=timeout * 1000,
275
+ remove_overlay_elements=True,
276
+ word_count_threshold=10,
277
+ )
278
+ return await crawler.arun(url=url, config=config)
279
+ except TypeError:
280
+ return await crawler.arun(url=url)
281
+
282
+
283
+ async def crawl4ai_async(base_url: str, *, max_depth: int, max_pages: int, timeout: int, output_path: Path) -> dict[str, Any]:
284
+ try:
285
+ from crawl4ai import AsyncWebCrawler, BrowserConfig # type: ignore
286
+ except Exception as exc:
287
+ raise RuntimeError(
288
+ "crawl4ai is not installed in the current runtime; run this script inside the sandbox dependency group"
289
+ ) from exc
290
+
291
+ parsed = urlparse(base_url)
292
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
293
+ raise ValueError(f"invalid URL: {base_url}")
294
+
295
+ base_url = normalize_url(base_url)
296
+ queue: deque[tuple[str, int]] = deque([(base_url, 0)])
297
+ seen: set[str] = set()
298
+ pages: list[dict[str, Any]] = []
299
+ errors: list[dict[str, Any]] = []
300
+ pages_dir = output_path.parent / f"{output_path.stem}_pages"
301
+
302
+ browser_config = BrowserConfig(headless=True, verbose=False)
303
+ async with AsyncWebCrawler(config=browser_config) as crawler:
304
+ while queue and len(pages) < max_pages:
305
+ url, depth = queue.popleft()
306
+ url = normalize_url(url)
307
+ if url in seen:
308
+ continue
309
+ seen.add(url)
310
+ try:
311
+ result = await crawl4ai_page(crawler, url, timeout)
312
+ success = result_value(result, "success", True)
313
+ if success is False:
314
+ error = result_value(result, "error_message", "crawl4ai returned success=false")
315
+ errors.append({"url": url, "depth": depth, "error": str(error)})
316
+ continue
317
+
318
+ final_url = normalize_url(result_value(result, "url", url) or url)
319
+ title = result_value(result, "metadata", {}) or {}
320
+ title_text = title.get("title") if isinstance(title, dict) else ""
321
+ markdown, fit_markdown = markdown_value(result)
322
+ body = fit_markdown or markdown
323
+ links = links_from_crawl4ai(result, final_url, parsed.netloc)
324
+ page_type = classify_page(final_url, title_text or final_url, body)
325
+ md_path = write_page_markdown(pages_dir, len(pages) + 1, final_url, title_text or final_url, body)
326
+ pages.append({
327
+ "url": final_url,
328
+ "title": title_text or final_url,
329
+ "type": page_type,
330
+ "depth": depth,
331
+ "status": result_value(result, "status_code", 200),
332
+ "markdown_path": virtual_workspace_path(md_path),
333
+ "fit_markdown_path": virtual_workspace_path(md_path),
334
+ "links": links[:100],
335
+ "images": [],
336
+ })
337
+ if depth < max_depth:
338
+ for link in links:
339
+ if link not in seen:
340
+ queue.append((link, depth + 1))
341
+ except Exception as exc:
342
+ errors.append({"url": url, "depth": depth, "error": str(exc)})
343
+
344
+ return {
345
+ "base_url": base_url,
346
+ "provider": "crawl4ai",
347
+ "mode": "crawl",
348
+ "crawled_at": datetime.now(timezone.utc).isoformat(),
349
+ "max_depth": max_depth,
350
+ "max_pages": max_pages,
351
+ "pages": pages,
352
+ "errors": errors,
353
+ }
354
+
355
+
356
+ def crawl4ai_provider(base_url: str, *, max_depth: int, max_pages: int, timeout: int, output_path: Path) -> dict[str, Any]:
357
+ return asyncio.run(crawl4ai_async(
358
+ base_url,
359
+ max_depth=max_depth,
360
+ max_pages=max_pages,
361
+ timeout=timeout,
362
+ output_path=output_path,
363
+ ))
364
+
365
+
366
+ def legacy_result(url: str, reason: str) -> dict[str, Any]:
367
+ return {
368
+ "base_url": url,
369
+ "provider": "legacy",
370
+ "mode": "legacy",
371
+ "crawled_at": datetime.now(timezone.utc).isoformat(),
372
+ "pages": [],
373
+ "errors": [{"url": url, "error": reason}],
374
+ "next_step": "Use kb-builder legacy references and agent web_crawl/web_fetch tools.",
375
+ }
376
+
377
+
378
+ def error_result(url: str, provider: str, mode: str, error: Exception) -> dict[str, Any]:
379
+ return {
380
+ "base_url": url,
381
+ "provider": provider,
382
+ "mode": mode,
383
+ "crawled_at": datetime.now(timezone.utc).isoformat(),
384
+ "pages": [],
385
+ "errors": [{"url": url, "error": str(error), "error_type": error.__class__.__name__}],
386
+ }
387
+
388
+
389
+ def main() -> None:
390
+ parser = argparse.ArgumentParser(description="Crawl website pages for kb-builder")
391
+ parser.add_argument("--url", required=True, help="Website / product / help center URL")
392
+ parser.add_argument("--mode", choices=["auto", "crawl", "legacy"], default="auto")
393
+ parser.add_argument("--provider", choices=["auto", "crawl4ai", "tavily"], default="auto")
394
+ parser.add_argument("--max-depth", type=int, default=2)
395
+ parser.add_argument("--max-pages", type=int, default=50)
396
+ parser.add_argument("--timeout", type=int, default=20)
397
+ parser.add_argument("--output", default="", help="Output crawl_result JSON path")
398
+ args = parser.parse_args()
399
+
400
+ OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
401
+ output = resolve_workspace_path(args.output) if args.output else OUTPUTS_DIR / f"crawl_result_{timestamp()}.json"
402
+ output.parent.mkdir(parents=True, exist_ok=True)
403
+
404
+ started = time.time()
405
+ try:
406
+ if args.mode == "legacy" or args.provider == "tavily":
407
+ result = legacy_result(args.url, "legacy/Tavily path is intentionally preserved; script provider not used.")
408
+ elif args.provider == "crawl4ai":
409
+ result = crawl4ai_provider(
410
+ args.url,
411
+ max_depth=max(0, args.max_depth),
412
+ max_pages=max(1, args.max_pages),
413
+ timeout=max(1, args.timeout),
414
+ output_path=output,
415
+ )
416
+ else:
417
+ result = crawl_builtin(
418
+ args.url,
419
+ max_depth=max(0, args.max_depth),
420
+ max_pages=max(1, args.max_pages),
421
+ timeout=max(1, args.timeout),
422
+ output_path=output,
423
+ )
424
+ except Exception as exc:
425
+ result = error_result(args.url, args.provider, "legacy" if args.mode == "legacy" else "crawl", exc)
426
+
427
+ result["elapsed_seconds"] = round(time.time() - started, 3)
428
+ output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
429
+ print(f"[crawl_site] output={virtual_workspace_path(output)} pages={len(result.get('pages') or [])} errors={len(result.get('errors') or [])}")
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ create_dirs.py — Recursively create Solvea KB folders from confirmed_tree.json.
4
+
5
+ Reads `STATE_DIR/confirmed_tree.json`, deep-first creates folders via SolveaClient.
6
+ Idempotent: if a folder with same (name, parentId) already exists, reuse its id
7
+ instead of creating a duplicate.
8
+
9
+ Tree schema (each node):
10
+ {"name": "...", "children": [ ... ]} # children optional
11
+
12
+ Usage:
13
+ python3 /skills/kb-builder/scripts/create_dirs.py
14
+ python3 /skills/kb-builder/scripts/create_dirs.py --tree-file <path>
15
+ python3 /skills/kb-builder/scripts/create_dirs.py --dry-run
16
+ """
17
+ from __future__ import annotations
18
+ import argparse, json, os, sys
19
+ sys.path.insert(0, os.path.join(os.environ.get("SOLVEA_LIB_DIR", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "__lib__"))), "skill-runtime"))
20
+ from bootstrap import *
21
+ from pathlib import Path
22
+
23
+
24
+ def flatten_folders(nodes, result=None):
25
+ if result is None:
26
+ result = []
27
+ for node in nodes:
28
+ result.append(node)
29
+ children = node.get("children", [])
30
+ if children:
31
+ flatten_folders(children, result)
32
+ return result
33
+
34
+
35
+ def fetch_existing_index(client: SolveaClient) -> dict:
36
+ """Return {(parent_id, name): folder_id} for all existing folders."""
37
+ folders = client.list_directories()
38
+ if isinstance(folders, list):
39
+ flat = flatten_folders(folders)
40
+ else:
41
+ raw = folders.get("list") or folders.get("records") or folders.get("data") or []
42
+ flat = flatten_folders(raw)
43
+ return {
44
+ ((f.get("parentId") or 0), f.get("name", "")): f["id"]
45
+ for f in flat if f.get("id") is not None
46
+ }
47
+
48
+
49
+ def walk(nodes, parent_id, depth, *, client, existing, dry_run, stats, max_depth=3):
50
+ """Depth-first create folders. Mutates existing index as new folders are created.
51
+
52
+ In dry_run mode, only existing folders have a real id for child placement;
53
+ not-yet-created folders use parent_id=None for their children (children counted
54
+ but not nested under a real parent).
55
+ """
56
+ for node in nodes:
57
+ if depth >= max_depth:
58
+ raise ValueError(
59
+ f"directory tree exceeds the default {max_depth}-level limit; "
60
+ f"cannot create '{node.get('name', '')}' at level {depth + 1}. "
61
+ "Pass an explicitly approved --max-depth to override."
62
+ )
63
+ name = node.get("name", "").strip()
64
+ if not name:
65
+ print(f" {' ' * depth}[skip] empty name at depth {depth}", file=sys.stderr)
66
+ continue
67
+
68
+ key = (parent_id, name)
69
+ fid = None
70
+ if key in existing:
71
+ fid = existing[key]
72
+ stats["existing"] += 1
73
+ print(f" {' ' * depth}[exist] {name} (id={fid})")
74
+ elif dry_run:
75
+ stats["to_create"] += 1
76
+ print(f" {' ' * depth}[dry] {name}")
77
+ else:
78
+ result = client.create_folder(name, parent_id=parent_id)
79
+ fid = _extract_folder_id(result)
80
+ if fid is None:
81
+ stats["failed"] += 1
82
+ print(f" {' ' * depth}[FAIL] {name} parent={parent_id} resp={result}", file=sys.stderr)
83
+ continue
84
+ existing[key] = fid
85
+ stats["created"] += 1
86
+ print(f" {' ' * depth}[ok] {name} (id={fid})")
87
+
88
+ children = node.get("children", [])
89
+ if children:
90
+ walk(children, fid, depth + 1, client=client, existing=existing,
91
+ dry_run=dry_run, stats=stats, max_depth=max_depth)
92
+
93
+
94
+ def _extract_folder_id(result):
95
+ """Pull folder id from create_folder response. Returns None on failure."""
96
+ if not isinstance(result, dict):
97
+ return None
98
+ if "id" in result:
99
+ return result["id"]
100
+ data = result.get("data")
101
+ if isinstance(data, dict) and "id" in data:
102
+ return data["id"]
103
+ return None
104
+
105
+
106
+ def run(tree_file: str, dry_run: bool, max_depth: int = 3) -> None:
107
+ from client import SolveaClient
108
+
109
+ tree_path = STATE_DIR / "confirmed_tree.json" if tree_file == "" else Path(tree_file)
110
+ if not tree_path.exists():
111
+ print(f"[error] tree file not found: {tree_path}", file=sys.stderr)
112
+ sys.exit(1)
113
+
114
+ tree = json.loads(tree_path.read_text(encoding="utf-8"))
115
+ if not isinstance(tree, list):
116
+ print(f"[error] tree must be a list of root nodes, got {type(tree).__name__}", file=sys.stderr)
117
+ sys.exit(1)
118
+
119
+ print(f"[create_dirs] tree={tree_path} root_nodes={len(tree)} dry_run={dry_run}")
120
+ client = SolveaClient()
121
+ existing = fetch_existing_index(client)
122
+ print(f"[create_dirs] existing folders: {len(existing)}")
123
+
124
+ stats = {"created": 0, "existing": 0, "to_create": 0, "failed": 0}
125
+ walk(tree, parent_id=0, depth=0, client=client, existing=existing,
126
+ dry_run=dry_run, stats=stats, max_depth=max_depth)
127
+
128
+ print(f"\n[create_dirs] Done: created={stats['created']} existing={stats['existing']} "
129
+ f"to_create={stats['to_create']} failed={stats['failed']}")
130
+
131
+
132
+ def main():
133
+ ap = argparse.ArgumentParser()
134
+ ap.add_argument("--tree-file", default="", dest="tree_file",
135
+ help="Path to confirmed_tree.json (default: STATE_DIR/confirmed_tree.json)")
136
+ ap.add_argument("--dry-run", action="store_true", dest="dry_run")
137
+ ap.add_argument("--max-depth", type=int, default=3,
138
+ help="Maximum folder depth (default: 3; override only when explicitly requested)")
139
+ args = ap.parse_args()
140
+ if args.max_depth < 1:
141
+ ap.error("--max-depth must be positive")
142
+ run(args.tree_file, args.dry_run, args.max_depth)
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()