@shulex/cxclawctl 0.1.2 → 0.1.3
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.
- package/README.md +25 -22
- package/dist/cli.js +2202 -127
- package/dist/cli.js.map +1 -1
- package/package.json +19 -6
- package/capabilities.lock.json +0 -898
- package/requirements-cli.txt +0 -2
- package/skills/__lib__/__init__.py +0 -0
- package/skills/__lib__/skill-runtime/bootstrap.py +0 -134
- package/skills/__lib__/solvea-api/__init__.py +0 -0
- package/skills/__lib__/solvea-api/client.py +0 -1944
- package/skills/__lib__/solvea-api/dashboard.py +0 -100
- package/skills/__lib__/solvea-api/solvea_api.py +0 -1341
- package/skills/agent-analytics/SKILL.md +0 -74
- package/skills/agent-analytics/references/chat-report-fields.md +0 -36
- package/skills/agent-analytics/references/report-fields.md +0 -130
- package/skills/agent-analytics/references/transcript.md +0 -110
- package/skills/agent-builder/SKILL.md +0 -42
- package/skills/agent-builder/interfaces/fallback-chatflow.md +0 -47
- package/skills/agent-builder/references/chatflow/graph-spec.md +0 -259
- package/skills/agent-builder/references/chatflow/node-selection.md +0 -89
- package/skills/agent-builder/references/chatflow/nodes/agent.md +0 -350
- package/skills/agent-builder/references/chatflow/nodes/business-track.md +0 -43
- package/skills/agent-builder/references/chatflow/nodes/code.md +0 -157
- package/skills/agent-builder/references/chatflow/nodes/collect-order.md +0 -47
- package/skills/agent-builder/references/chatflow/nodes/finish.md +0 -34
- package/skills/agent-builder/references/chatflow/nodes/human-review.md +0 -60
- package/skills/agent-builder/references/chatflow/nodes/if-else.md +0 -100
- package/skills/agent-builder/references/chatflow/nodes/intent.md +0 -79
- package/skills/agent-builder/references/chatflow/nodes/knowledge-reply.md +0 -43
- package/skills/agent-builder/references/chatflow/nodes/llm.md +0 -117
- package/skills/agent-builder/references/chatflow/nodes/note.md +0 -38
- package/skills/agent-builder/references/chatflow/nodes/question.md +0 -97
- package/skills/agent-builder/references/chatflow/nodes/rag-reply.md +0 -68
- package/skills/agent-builder/references/chatflow/nodes/reply.md +0 -97
- package/skills/agent-builder/references/chatflow/nodes/start.md +0 -35
- package/skills/agent-builder/references/chatflow/nodes/sub-graph.md +0 -48
- package/skills/agent-builder/references/chatflow/nodes/tool.md +0 -138
- package/skills/agent-builder/references/chatflow/nodes/transfer-to-human.md +0 -63
- package/skills/agent-builder/references/chatflow/nodes/variable-assignment.md +0 -52
- package/skills/agent-builder/references/chatflow/nodes/variable-extract.md +0 -50
- package/skills/agent-builder/references/chatflow/nodes/variable-process.md +0 -70
- package/skills/agent-builder/references/chatflow/overview.md +0 -53
- package/skills/agent-builder/references/chatflow/visual-translation.md +0 -64
- package/skills/agent-builder/references/chatflow/workflow.md +0 -134
- package/skills/agent-builder/references/multi-agent/create-update.md +0 -185
- package/skills/agent-builder/references/multi-agent/graph-spec.md +0 -246
- package/skills/agent-builder/references/multi-agent/graph-structure.md +0 -12
- package/skills/agent-builder/references/multi-agent/migrate-to-chatflow.md +0 -13
- package/skills/agent-builder/references/multi-agent/migration/large.md +0 -554
- package/skills/agent-builder/references/multi-agent/migration/principles.md +0 -446
- package/skills/agent-builder/references/multi-agent/migration/small.md +0 -225
- package/skills/agent-builder/references/multi-agent/orchestration.md +0 -11
- package/skills/agent-builder/references/multi-agent/overview.md +0 -48
- package/skills/agent-builder/references/multi-agent/update.md +0 -128
- package/skills/agent-builder/references/multi-agent/workflow.md +0 -87
- package/skills/agent-builder/references/single-agent/overview.md +0 -48
- package/skills/agent-builder/references/single-agent/prompt.md +0 -16
- package/skills/agent-builder/references/single-agent/tools.md +0 -36
- package/skills/agent-builder/references/single-agent/voice.md +0 -46
- package/skills/agent-builder/references/tools/http-tool.md +0 -315
- package/skills/agent-builder/references/tools/mock-tool.md +0 -279
- package/skills/agent-builder/references/tools/overview.md +0 -46
- package/skills/agent-builder/references/tools/tool-config.md +0 -43
- package/skills/agent-builder/references/tools/x-token.md +0 -34
- package/skills/agent-builder/scripts/chatflow/chatflow_builder.py +0 -3458
- package/skills/agent-builder/scripts/chatflow/validate_graph.py +0 -1697
- package/skills/agent-builder/scripts/copy_marketplace_agent.py +0 -61
- package/skills/agent-builder/scripts/multi-agent/legacy_graph_analyzer.py +0 -5974
- package/skills/agent-builder/scripts/multi-agent/replay_compare.py +0 -679
- package/skills/agent-builder/scripts/patch_agent_tools.py +0 -135
- package/skills/agent-builder/scripts/tools/validate_tool_config.py +0 -384
- package/skills/agent-tester/SKILL.md +0 -35
- package/skills/agent-tester/batch-test/overview.md +0 -86
- package/skills/agent-tester/scripts/batch_test_sheet.py +0 -213
- package/skills/agent-tester/single-test/badcase-retest.md +0 -85
- package/skills/agent-tester/single-test/direct-test.md +0 -24
- package/skills/agent-tester/single-test/overview.md +0 -142
- package/skills/delivery/SKILL.md +0 -112
- package/skills/delivery/interfaces/classify.md +0 -38
- package/skills/delivery/interfaces/create-agents.md +0 -89
- package/skills/delivery/interfaces/delivery-test.md +0 -67
- package/skills/delivery/references/flow.md +0 -40
- package/skills/delivery/references/validation.md +0 -15
- package/skills/delivery/scripts/batch_test.py +0 -704
- package/skills/delivery/scripts/classify.py +0 -22
- package/skills/delivery/scripts/create_agents.py +0 -695
- package/skills/delivery/scripts/gen_delivery_report.py +0 -335
- package/skills/delivery/scripts/gen_excel.py +0 -234
- package/skills/delivery/scripts/gen_report.py +0 -208
- package/skills/delivery/scripts/plan.py +0 -163
- package/skills/delivery/scripts/render.py +0 -713
- package/skills/delivery/scripts/render_summary.py +0 -38
- package/skills/delivery/scripts/sample_history_cases.py +0 -109
- package/skills/kb-builder/SKILL.md +0 -80
- package/skills/kb-builder/interfaces/qa-upload.md +0 -55
- package/skills/kb-builder/interfaces/web-kb.md +0 -84
- package/skills/kb-builder/references/directory.md +0 -373
- package/skills/kb-builder/references/export.md +0 -282
- package/skills/kb-builder/references/isolation-keywords.md +0 -113
- package/skills/kb-builder/references/knowledge.md +0 -153
- package/skills/kb-builder/references/product.md +0 -52
- package/skills/kb-builder/references/qa-from-help-center.md +0 -58
- package/skills/kb-builder/references/qa-from-pdf.md +0 -94
- package/skills/kb-builder/references/qa-from-tickets.md +0 -168
- package/skills/kb-builder/references/qa-from-web.md +0 -98
- package/skills/kb-builder/references/qa-quality.md +0 -73
- package/skills/kb-builder/references/reply-template.md +0 -237
- package/skills/kb-builder/references/tag-isolation-migration.md +0 -61
- package/skills/kb-builder/references/tags.md +0 -45
- package/skills/kb-builder/references/upload.md +0 -130
- package/skills/kb-builder/scripts/batch_extract_ticket_qa.py +0 -333
- package/skills/kb-builder/scripts/build_directory_plan.py +0 -144
- package/skills/kb-builder/scripts/crawl_site.py +0 -433
- package/skills/kb-builder/scripts/create_dirs.py +0 -146
- package/skills/kb-builder/scripts/export_knowledge.py +0 -454
- package/skills/kb-builder/scripts/extract_ticket_qa.py +0 -441
- package/skills/kb-builder/scripts/extract_web_qa.py +0 -118
- package/skills/kb-builder/scripts/import_qa.py +0 -219
- package/skills/kb-builder/scripts/import_source_documents.py +0 -143
- package/skills/kb-builder/scripts/import_templates.py +0 -409
- package/skills/poc/SKILL.md +0 -125
- package/skills/poc/assets/default_manual.css +0 -279
- package/skills/poc/assets/notice_escalate.png +0 -0
- package/skills/poc/assets/notice_escalate2.png +0 -0
- package/skills/poc/assets/notice_new_chat.png +0 -0
- package/skills/poc/assets/poc_test_template.html +0 -645
- package/skills/poc/assets/poc_test_template.md +0 -104
- package/skills/poc/assets/step3_preview.png +0 -0
- package/skills/poc/assets/step4_order_note.png +0 -0
- package/skills/poc/interfaces/classify.md +0 -38
- package/skills/poc/interfaces/create-agents.md +0 -108
- package/skills/poc/interfaces/poc-test.md +0 -94
- package/skills/poc/references/flow.md +0 -47
- package/skills/poc/references/kb-directory.md +0 -22
- package/skills/poc/references/validation.md +0 -17
- package/skills/poc/scripts/classify.py +0 -22
- package/skills/poc/scripts/create_agents.py +0 -818
- package/skills/poc/scripts/gen_excel.py +0 -234
- package/skills/poc/scripts/gen_poc_manual.py +0 -249
- package/skills/poc/scripts/gen_report.py +0 -208
- package/skills/poc/scripts/inspect_chatflow.py +0 -209
- package/skills/poc/scripts/plan.py +0 -178
- package/skills/poc/scripts/poc_test.py +0 -495
- package/skills/poc/scripts/render.py +0 -713
- package/skills/poc/scripts/render_summary.py +0 -41
- package/skills/runtime-inspector/SKILL.md +0 -51
- package/skills/runtime-inspector/references/frontend-rendering.md +0 -126
- package/skills/runtime-inspector/references/model.md +0 -59
- package/skills/runtime-inspector/references/sandbox.md +0 -131
- package/skills/runtime-inspector/scripts/model_inspect.py +0 -68
- package/skills/runtime-inspector/scripts/prepare.py +0 -98
- package/skills/runtime-inspector/scripts/process.py +0 -84
- package/skills/runtime-inspector/scripts/read_state.py +0 -32
- package/skills/runtime-inspector/scripts/sandbox_inspect.py +0 -463
- package/skills/runtime-inspector/scripts/test_claude_proxy.py +0 -49
- package/skills/runtime-inspector/scripts/test_openai_proxy.py +0 -41
- package/skills/runtime-inspector/scripts/verify_persistence.py +0 -101
- package/skills/runtime-inspector/scripts/write_test.py +0 -41
- package/skills/setup/SKILL.md +0 -17
- package/skills/shulex-support/SKILL.md +0 -39
- package/skills/shulex-support/references/badcase-analysis/overview.md +0 -34
- package/skills/shulex-support/references/badcase-analysis/text/conversation-record.md +0 -105
- package/skills/shulex-support/references/badcase-analysis/text/core-flow.md +0 -89
- package/skills/shulex-support/references/badcase-analysis/text/log-query-rules.md +0 -36
- package/skills/shulex-support/references/badcase-analysis/text/m1-entry.md +0 -53
- package/skills/shulex-support/references/badcase-analysis/text/m10-chatflow-service.md +0 -259
- package/skills/shulex-support/references/badcase-analysis/text/m11-algorithm-service.md +0 -148
- package/skills/shulex-support/references/badcase-analysis/text/m12-quality-inspection.md +0 -57
- package/skills/shulex-support/references/badcase-analysis/text/m2-intent.md +0 -120
- package/skills/shulex-support/references/badcase-analysis/text/m3-strategy.md +0 -126
- package/skills/shulex-support/references/badcase-analysis/text/m4-aifield.md +0 -121
- package/skills/shulex-support/references/badcase-analysis/text/m5-agent.md +0 -208
- package/skills/shulex-support/references/badcase-analysis/text/m6-tool.md +0 -115
- package/skills/shulex-support/references/badcase-analysis/text/m7-reply.md +0 -72
- package/skills/shulex-support/references/badcase-analysis/text/m8-error.md +0 -92
- package/skills/shulex-support/references/badcase-analysis/text/m9-knowledge-search.md +0 -147
- package/skills/shulex-support/references/badcase-analysis/text/sls-query.md +0 -283
- package/skills/shulex-support/references/badcase-analysis/voice/core-flow.md +0 -77
- package/skills/shulex-support/references/badcase-analysis/voice/dependencies.md +0 -19
- package/skills/shulex-support/references/badcase-analysis/voice/platform-api.md +0 -23
- package/skills/shulex-support/references/badcase-analysis/voice/sls-playbook.md +0 -31
- package/skills/shulex-support/references/product-docs.md +0 -70
- package/skills/shulex-support/references/tickets.md +0 -184
- package/skills/shulex-support/scripts/support.py +0 -427
- package/skills/solvea-desk/SKILL.md +0 -39
- package/skills/solvea-desk/references/report/fields.md +0 -82
- package/skills/solvea-desk/references/report/report-design.md +0 -200
- package/skills/solvea-desk/references/report/report-template.html +0 -44
- package/skills/solvea-desk/references/report/report.css +0 -517
- package/skills/solvea-desk/references/report/workflow.md +0 -464
- package/skills/solvea-desk/references/tickets/query.md +0 -103
- package/skills/solvea-desk/references/tickets/tags.md +0 -83
- package/skills/solvea-desk/scripts/report/fetch_sources.py +0 -580
- package/skills/solvea-desk/scripts/report/render_report.py +0 -763
- package/skills/solvea-desk/scripts/tickets/desk_api.py +0 -73
- package/skills/solvea-desk/scripts/tickets/fetch_tickets.py +0 -403
- package/skills/solvea-desk/scripts/tickets/ticket_tags.py +0 -305
- package/skills/solvea-kit/SKILL.md +0 -84
- package/skills/solvea-kit/references/agent.md +0 -145
- package/skills/solvea-kit/references/chat.md +0 -244
- package/skills/solvea-kit/references/intent.md +0 -69
- package/skills/solvea-kit/references/plugin.md +0 -53
- package/skills/solvea-kit/references/quality-inspection.md +0 -75
- package/skills/solvea-kit/references/strategy.md +0 -62
- package/skills/solvea-kit/references/tagging.md +0 -231
- package/skills/solvea-kit/references/variables.md +0 -53
- package/skills/solvea-kit/scripts/generate_label_import_file.py +0 -151
- package/skills/solvea-kit/scripts/intent_classify.py +0 -843
|
@@ -1,433 +0,0 @@
|
|
|
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()
|
|
@@ -1,146 +0,0 @@
|
|
|
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()
|