sdd-flow-kit 1.0.0 → 1.0.1

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 CHANGED
@@ -16,13 +16,15 @@
16
16
  1. 进入你要开发的项目目录
17
17
  2. 安装:`npm i -D sdd-flow-kit`
18
18
  3. 安装流程(安装即用,默认 `claude-code`):`npx sdd-flow-kit install`
19
+ - 会自动安装对应 AI 环境并写入项目级触发 Skill(仅在“开发 <产品> <版本> 需求/版本/PRD”场景触发)
20
+ - 会在项目根目录生成 `docs/<项目>-doc-skill/`(用于 PRD 下载脚本与说明),可通过 `--project` 选择:`OMS` / `ADI` / `欢盟` / `AD Tools`(默认 `OMS`)
19
21
  4. (可选)预演安装:`npx sdd-flow-kit install --dry-run --plan-json`
20
22
  5. (可选)环境健检:`npx sdd-flow-kit doctor --agent claude-code`
21
23
  6. 完成后,直接在 Cursor / Claude Code / Codex / OpenClaw 中用自然语言发起需求,例如:`开发 ADI 2.3.2 需求`
22
24
 
23
25
  #### 使用说明
24
26
 
25
- 1. `install`(等价 `setup`):提示选择开发环境(cursor/claude-code/codex/openclaw,默认 claude-code)与开发分支(无则自动创建),并检测/安装 openspec+superpower 基础环境
27
+ 1. `install`(等价 `setup`):提示选择开发环境(cursor/claude-code/codex/openclaw,默认 claude-code)与开发分支(无则自动创建),并检测/安装 openspec+superpower 基础环境,同时写入项目级 Skill
26
28
  2. 安装完成后,本工具主要负责“流程环境与模板”落地;业务需求由你在目标 AI 工具中自然语言驱动
27
29
  3. `start`/`run`/`invoke` 属于进阶能力(脚本化或一键编排时使用)
28
30
  4. `doctor`:检查 git/npx/agent CLI/openspec/tool 目录,输出可机读诊断结果
package/dist/cli.js CHANGED
@@ -47,6 +47,7 @@ function printHelp() {
47
47
  " --projectRoot <path> 目标项目根目录(默认当前目录)",
48
48
  " --agent <name> cursor | claude-code | codex | openclaw | custom(默认 claude-code)",
49
49
  " --branch <name> 开发分支;不存在会自动创建",
50
+ " --project <name> OMS | ADI | 欢盟 | AD Tools(默认 OMS,用于生成 docs/*-doc-skill)",
50
51
  " -y 非交互模式(使用默认值)",
51
52
  " --dry-run 预演模式:仅检测并输出计划,不执行写入/安装/切分支",
52
53
  " --plan-json 输出机器可读 JSON 计划结果",
@@ -79,6 +80,7 @@ async function main() {
79
80
  const dryRun = node_process_1.default.argv.includes("--dry-run");
80
81
  const planJson = node_process_1.default.argv.includes("--plan-json");
81
82
  const agentValue = getArgValue("--agent") ?? "claude-code";
83
+ const projectKind = (getArgValue("--project") ?? "OMS");
82
84
  if (!ALLOWED_AGENTS.includes(agentValue)) {
83
85
  // eslint-disable-next-line no-console
84
86
  console.error(`不支持的 --agent: ${agentValue}`);
@@ -87,6 +89,13 @@ async function main() {
87
89
  return;
88
90
  }
89
91
  const agent = agentValue;
92
+ const allowedProjects = ["OMS", "ADI", "欢盟", "AD Tools"];
93
+ if (!allowedProjects.includes(projectKind)) {
94
+ // eslint-disable-next-line no-console
95
+ console.error(`不支持的 --project: ${projectKind}(可选:${allowedProjects.join(" / ")})`);
96
+ node_process_1.default.exitCode = 1;
97
+ return;
98
+ }
90
99
  if (cmd === "doctor") {
91
100
  const doctor = await (0, doctor_1.runDoctor)({ projectRoot: node_path_1.default.resolve(projectRoot), agent });
92
101
  // eslint-disable-next-line no-console
@@ -110,9 +119,10 @@ async function main() {
110
119
  branch,
111
120
  yes,
112
121
  dryRun,
122
+ projectKind: projectKind,
113
123
  });
114
124
  // eslint-disable-next-line no-console
115
- console.log(`setup 完成:agent=${result.agent}, branch=${result.branch}, setupExecuted=${result.setupExecuted}, dryRun=${dryRun}`);
125
+ console.log(`setup 完成:agent=${result.agent}, project=${result.projectKind}, branch=${result.branch}, setupExecuted=${result.setupExecuted}, skillInstalled=${result.skillInstalled}, docsSkillInstalled=${result.docsSkillInstalled}, dryRun=${dryRun}`);
116
126
  if (result.plannedActions.length > 0) {
117
127
  // eslint-disable-next-line no-console
118
128
  console.log(`planned: ${result.plannedActions.join(" | ")}`);
@@ -10,7 +10,9 @@ const node_path_1 = __importDefault(require("node:path"));
10
10
  const promises_2 = require("node:readline/promises");
11
11
  const node_process_1 = __importDefault(require("node:process"));
12
12
  const fs_1 = require("../core/fs");
13
+ const templates_1 = require("../core/templates");
13
14
  const EDITOR_CHOICES = ["claude-code", "cursor", "codex", "openclaw"];
15
+ const PROJECT_CHOICES = ["OMS", "ADI", "欢盟", "AD Tools"];
14
16
  function runGit(args, cwd) {
15
17
  const out = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8" });
16
18
  if (out.status !== 0) {
@@ -71,6 +73,27 @@ async function askBranch(defaultBranch) {
71
73
  rl.close();
72
74
  return answer || defaultBranch;
73
75
  }
76
+ async function askProjectKind(defaultKind) {
77
+ if (!node_process_1.default.stdin.isTTY)
78
+ return defaultKind;
79
+ const rl = (0, promises_2.createInterface)({ input: node_process_1.default.stdin, output: node_process_1.default.stdout });
80
+ const prompt = [
81
+ "请选择当前项目类型(用于生成 PRD 下载 skill):",
82
+ "1) OMS (default)",
83
+ "2) ADI",
84
+ "3) 欢盟",
85
+ "4) AD Tools",
86
+ "输入编号(回车默认 1):",
87
+ ].join("\n");
88
+ const answer = (await rl.question(`${prompt}\n`)).trim();
89
+ rl.close();
90
+ if (!answer)
91
+ return defaultKind;
92
+ const idx = Number(answer);
93
+ if (Number.isNaN(idx) || idx < 1 || idx > PROJECT_CHOICES.length)
94
+ return defaultKind;
95
+ return PROJECT_CHOICES[idx - 1];
96
+ }
74
97
  function isEditorEnvironmentReady(projectRoot, agent) {
75
98
  const checks = {
76
99
  cursor: ["openspec", ".cursor"],
@@ -124,9 +147,69 @@ function ensureBranch(projectRoot, branch) {
124
147
  runGit(["checkout", "-b", branch], projectRoot);
125
148
  return { branch, created: true };
126
149
  }
150
+ function skillInstallPath(projectRoot, agent) {
151
+ const relMap = {
152
+ cursor: ".cursor/skills/sdd-flow-kit/SKILL.md",
153
+ "claude-code": ".claude/skills/sdd-flow-kit/SKILL.md",
154
+ codex: ".codex/skills/sdd-flow-kit/SKILL.md",
155
+ openclaw: ".openclaw/skills/sdd-flow-kit/SKILL.md",
156
+ custom: ".sdd-flow-kit/SKILL.md",
157
+ };
158
+ const rel = relMap[agent];
159
+ if (!rel)
160
+ return null;
161
+ return node_path_1.default.join(projectRoot, rel);
162
+ }
163
+ async function installAgentSkill(projectRoot, agent) {
164
+ const target = skillInstallPath(projectRoot, agent);
165
+ if (!target)
166
+ return;
167
+ const template = await (0, templates_1.loadTemplateText)("artifacts/06-agent-skill.template.md");
168
+ const content = template.replaceAll("{{AGENT_NAME}}", agent);
169
+ await (0, fs_1.writeTextFileEnsuredDir)(target, content);
170
+ }
171
+ function renderVars(template, vars) {
172
+ let out = template;
173
+ for (const [k, v] of Object.entries(vars)) {
174
+ out = out.replaceAll(`{{${k}}}`, v);
175
+ }
176
+ return out;
177
+ }
178
+ function docSkillDirName(kind) {
179
+ switch (kind) {
180
+ case "ADI":
181
+ return { dir: "adi-doc-skill", prefix: "ADI" };
182
+ case "欢盟":
183
+ return { dir: "huan-doc-skill", prefix: "HUAN" };
184
+ case "AD Tools":
185
+ return { dir: "ad-tools-doc-skill", prefix: "ADTOOLS" };
186
+ case "OMS":
187
+ default:
188
+ return { dir: "oms-doc-skill", prefix: "OMS" };
189
+ }
190
+ }
191
+ async function installDocsSkill(projectRoot, kind) {
192
+ const { dir, prefix } = docSkillDirName(kind);
193
+ const docsDir = node_path_1.default.join(projectRoot, "docs", dir);
194
+ const pyTemplate = await (0, templates_1.loadTemplateText)("skills/confluence-doc.py.template");
195
+ const mdTemplate = await (0, templates_1.loadTemplateText)("skills/doc-skill.SKILL.md.template");
196
+ const md = renderVars(mdTemplate, {
197
+ SKILL_NAME: `${prefix.toLowerCase()}-doc`,
198
+ PRODUCT: kind,
199
+ PRODUCT_PREFIX: prefix,
200
+ PRODUCT_PREFIX_LOWER: prefix.toLowerCase(),
201
+ SKILL_DIR: dir,
202
+ });
203
+ const py = pyTemplate.replaceAll("{{DOC_PRODUCT_PREFIX}}", prefix);
204
+ await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(docsDir, "SKILL.md"), md);
205
+ await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(docsDir, "confluence-doc.py"), py);
206
+ return { dirPath: docsDir };
207
+ }
127
208
  async function setupProject(options) {
128
209
  const defaultAgent = options.agent ?? "claude-code";
129
210
  const selectedAgent = options.yes ? defaultAgent : await askAgent(defaultAgent);
211
+ const defaultKind = options.projectKind ?? "OMS";
212
+ const selectedKind = options.yes ? defaultKind : await askProjectKind(defaultKind);
130
213
  const defaultBranch = options.branch ?? pickDefaultBranch(options.projectRoot);
131
214
  const selectedBranch = options.yes ? defaultBranch : await askBranch(defaultBranch);
132
215
  const plannedActions = [];
@@ -142,6 +225,8 @@ async function setupProject(options) {
142
225
  }
143
226
  let environmentReady = await isEditorEnvironmentReady(options.projectRoot, selectedAgent);
144
227
  let setupExecuted = false;
228
+ let skillInstalled = false;
229
+ let docsSkillInstalled = false;
145
230
  if (!environmentReady) {
146
231
  const { cmd, args } = buildSetupCommand(options.projectRoot, selectedAgent);
147
232
  if (options.dryRun) {
@@ -163,14 +248,31 @@ async function setupProject(options) {
163
248
  else if (options.dryRun) {
164
249
  plannedActions.push("environment already ready, no setup needed");
165
250
  }
251
+ if (options.dryRun) {
252
+ const skillPath = skillInstallPath(options.projectRoot, selectedAgent);
253
+ if (skillPath) {
254
+ plannedActions.push(`would install skill: ${skillPath}`);
255
+ }
256
+ const { dir } = docSkillDirName(selectedKind);
257
+ plannedActions.push(`would install docs skill: ${node_path_1.default.join(options.projectRoot, "docs", dir)}`);
258
+ }
259
+ else {
260
+ await installAgentSkill(options.projectRoot, selectedAgent);
261
+ skillInstalled = true;
262
+ await installDocsSkill(options.projectRoot, selectedKind);
263
+ docsSkillInstalled = true;
264
+ }
166
265
  const summary = [
167
266
  "# SETUP-SUMMARY",
168
267
  "",
169
268
  `agent: ${selectedAgent}`,
269
+ `projectKind: ${selectedKind}`,
170
270
  `branch: ${branchResult.branch}`,
171
271
  `branchCreated: ${branchResult.created}`,
172
272
  `environmentReady: ${environmentReady}`,
173
273
  `setupExecuted: ${setupExecuted}`,
274
+ `skillInstalled: ${skillInstalled}`,
275
+ `docsSkillInstalled: ${docsSkillInstalled}`,
174
276
  `dryRun: ${Boolean(options.dryRun)}`,
175
277
  "",
176
278
  "plannedActions:",
@@ -180,10 +282,13 @@ async function setupProject(options) {
180
282
  await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(options.projectRoot, ".sdd-flow-kit", "setup-summary.md"), summary);
181
283
  return {
182
284
  agent: selectedAgent,
285
+ projectKind: selectedKind,
183
286
  branch: branchResult.branch,
184
287
  branchCreated: branchResult.created,
185
288
  environmentReady,
186
289
  setupExecuted,
187
290
  plannedActions,
291
+ skillInstalled,
292
+ docsSkillInstalled,
188
293
  };
189
294
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdd-flow-kit",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "private": false,
5
5
  "description": "Cross-agent SDD automated development workflow kit (ADI/ADI-like).",
6
6
  "license": "MIT",
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: sdd-flow-kit-trigger
3
+ description: 当用户说“开发 <产品> <版本> 需求/版本/PRD”或类似表达时,必须启动 sdd-flow-kit 流程;其它对话保持原本 AI 行为。
4
+ ---
5
+
6
+ # SDD Flow Trigger Skill
7
+
8
+ ## 触发条件(仅这些场景触发)
9
+ - 用户说法包含类似:`开发 XXX 1.2 需求`、`开发 XXX 1.2.3 版本`、`开发 XXX PRD`
10
+ - 或明确要求“按 SDD 流程”推进某版本需求
11
+
12
+ ## 触发后必须执行
13
+ 1. 从用户输入中提取 `product` 与 `version`
14
+ 2. 在项目根目录执行:
15
+ - `npx sdd-flow-kit run --product <product> --version <version> -y`
16
+ 3. 按 `openspec/PRD/<runId>/RUNME.md` 严格推进第 1~5 步
17
+ 4. 交付物至少包含:
18
+ - `01-需求分析报告.md`
19
+ - `02-改动点清单.md`
20
+ - `03-待确认问题清单.md`
21
+ - `04-技术文档草稿.md`
22
+ - `05-openspec-提案草稿.md`
23
+
24
+ ## 非触发场景
25
+ - 如果用户没有表达“开发某产品某版本需求”,则不要强制进入该流程
26
+ - 继续按常规 AI 对话处理用户问题
27
+
28
+ ## 当前环境
29
+ - agent: {{AGENT_NAME}}
30
+ - 安装命令: `npx sdd-flow-kit install`
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Confluence 需求文档获取脚本(Playwright)
4
+
5
+ 用法:
6
+ python3 confluence-doc.py <版本号>
7
+
8
+ 环境变量:
9
+ CONFLUENCE_BASE_URL 例如 https://confluence.huan.tv
10
+ CONFLUENCE_SEARCH_URL 例如 https://confluence.huan.tv/dosearchsite.action?includeArchivedSpaces=false
11
+ CONFLUENCE_USERNAME
12
+ CONFLUENCE_PASSWORD
13
+ DOC_PRODUCT_PREFIX 例如 ADI / OMS / HUAN / ADTOOLS
14
+ DOC_DOWNLOAD_DIR 输出目录(默认当前脚本所在目录的上级 docs/)
15
+
16
+ 注意:
17
+ - 为安全起见,本模板不包含任何默认账号密码
18
+ - 你需要先安装 playwright:
19
+ pip install playwright && playwright install chromium
20
+ """
21
+
22
+ import sys
23
+ import os
24
+ import re
25
+ from pathlib import Path
26
+ from urllib.parse import urljoin, urlparse, unquote
27
+
28
+ try:
29
+ from playwright.sync_api import sync_playwright
30
+ except ImportError:
31
+ print("❌ 请先安装 playwright: pip install playwright && playwright install chromium")
32
+ sys.exit(1)
33
+
34
+
35
+ def env(name: str, default: str | None = None) -> str:
36
+ v = os.environ.get(name, default)
37
+ if v is None or str(v).strip() == "":
38
+ raise RuntimeError(f"缺少环境变量: {name}")
39
+ return str(v)
40
+
41
+
42
+ def normalize_version(raw: str) -> str:
43
+ """将 1.2.3 / V1.2.3 / XXX-V1.2.3 / XXX_V1.2.3 统一为 1.2.3"""
44
+ v = raw.strip()
45
+ v = re.sub(r"^[A-Z]+[-_]?V?", "", v, flags=re.IGNORECASE)
46
+ v = re.sub(r"^V", "", v, flags=re.IGNORECASE)
47
+ return v
48
+
49
+
50
+ def version_slug(prefix: str, version: str) -> str:
51
+ """1.2.3 -> <prefix>-v123"""
52
+ return prefix.lower() + "-v" + version.replace(".", "")
53
+
54
+
55
+ def search_keywords(prefix: str, version: str) -> list[str]:
56
+ return [f"{prefix}-V{version}", f"{prefix}_V{version}"]
57
+
58
+
59
+ def sanitize_filename(name: str) -> str:
60
+ name = re.sub(r'[\\/:*?"<>|]', "-", name)
61
+ return name.strip() or "需求文档"
62
+
63
+
64
+ def parse_html_to_markdown(html_content: str) -> str:
65
+ md = html_content
66
+ md = re.sub(r"<h1[^>]*>(.*?)</h1>", r"# \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
67
+ md = re.sub(r"<h2[^>]*>(.*?)</h2>", r"## \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
68
+ md = re.sub(r"<h3[^>]*>(.*?)</h3>", r"### \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
69
+ md = re.sub(r"<h4[^>]*>(.*?)</h4>", r"#### \1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
70
+ md = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", md, flags=re.DOTALL | re.IGNORECASE)
71
+ md = re.sub(r'<a[^>]*href="([^"]+)"[^>]*>(.*?)</a>', r"[\2](\1)", md, flags=re.DOTALL | re.IGNORECASE)
72
+ md = re.sub(r'<img[^>]*src="([^"]+)"[^>]*>', r"![](\1)", md, flags=re.DOTALL | re.IGNORECASE)
73
+ md = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", md, flags=re.DOTALL | re.IGNORECASE)
74
+ md = re.sub(r"<br[^>]*>", r"\n", md, flags=re.DOTALL | re.IGNORECASE)
75
+ md = re.sub(r"<strong[^>]*>(.*?)</strong>", r"**\1**", md, flags=re.DOTALL | re.IGNORECASE)
76
+ md = re.sub(r"<b[^>]*>(.*?)</b>", r"**\1**", md, flags=re.DOTALL | re.IGNORECASE)
77
+ md = re.sub(r"<em[^>]*>(.*?)</em>", r"*\1*", md, flags=re.DOTALL | re.IGNORECASE)
78
+ md = re.sub(r"<code[^>]*>(.*?)</code>", r"`\1`", md, flags=re.DOTALL | re.IGNORECASE)
79
+ md = re.sub(r"<pre[^>]*>(.*?)</pre>", r"```\n\1\n```", md, flags=re.DOTALL | re.IGNORECASE)
80
+ md = re.sub(r"<[^>]+>", "", md)
81
+ md = re.sub(r"\n{3,}", "\n\n", md)
82
+ return md.strip()
83
+
84
+
85
+ def is_wiki_page_href(href: str) -> bool:
86
+ if not href:
87
+ return False
88
+ href_lower = href.lower()
89
+ if any(x in href_lower for x in ("dosearchsite", "login.action", "dashboard")):
90
+ return False
91
+ return any(x in href_lower for x in ("/pages/", "/display/", "/spaces/"))
92
+
93
+
94
+ def absolutize_href(base_url: str, href: str) -> str:
95
+ return base_url + href if href.startswith("/") else href
96
+
97
+
98
+ def href_path_matches_keyword(href: str, keyword: str) -> bool:
99
+ path = unquote(urlparse(href).path).lower()
100
+ kw = keyword.lower()
101
+ compact = kw.replace(".", "")
102
+ path_compact = path.replace(".", "")
103
+ return kw in path or compact in path_compact
104
+
105
+
106
+ def find_search_result(page, base_url: str, keyword: str):
107
+ keyword_lower = keyword.lower()
108
+ seen: set[str] = set()
109
+ selectors = [
110
+ ".search-result .title a",
111
+ ".search-result-title a",
112
+ "#search-results .gs-title a",
113
+ f"a[href*='{keyword}']",
114
+ f"a:has-text('{keyword}')",
115
+ ".search-result a",
116
+ "#search-results a",
117
+ ]
118
+ scored: list[tuple[int, str]] = []
119
+
120
+ for selector in selectors:
121
+ links = page.locator(selector)
122
+ for i in range(links.count()):
123
+ href = links.nth(i).get_attribute("href") or ""
124
+ raw_text = (links.nth(i).inner_text() or "").strip()
125
+ title_text = raw_text.split("\n")[0].strip()
126
+ link_title_attr = (links.nth(i).get_attribute("title") or "").strip()
127
+ text = link_title_attr or title_text
128
+ if not is_wiki_page_href(href):
129
+ continue
130
+ full_url = absolutize_href(base_url, href)
131
+ if full_url in seen:
132
+ continue
133
+ seen.add(full_url)
134
+
135
+ text_hit = keyword_lower in text.lower()
136
+ path_hit = href_path_matches_keyword(href, keyword)
137
+ if not text_hit:
138
+ continue
139
+
140
+ score = 2 + (1 if path_hit else 0)
141
+ scored.append((score, full_url))
142
+
143
+ if not scored:
144
+ return None
145
+ scored.sort(key=lambda x: x[0], reverse=True)
146
+ best = scored[0][1]
147
+ print(f" 找到文档链接: {best[:100]}...")
148
+ return best
149
+
150
+
151
+ def run_search(page, base_url: str, keyword: str, search_url: str) -> str | None:
152
+ page.goto(search_url, wait_until="networkidle", timeout=60000)
153
+ page.wait_for_timeout(800)
154
+ search_selector = None
155
+ for selector in ["#search-query-input", "input[name='search-query']", "input.search-query", "input[id*='search']"]:
156
+ if page.locator(selector).count() > 0:
157
+ search_selector = selector
158
+ break
159
+ if not search_selector:
160
+ search_selector = "input[type='text']"
161
+ page.fill(search_selector, keyword)
162
+ page.keyboard.press("Enter")
163
+ page.wait_for_load_state("networkidle", timeout=30000)
164
+ page.wait_for_timeout(1500)
165
+ return find_search_result(page, base_url, keyword)
166
+
167
+
168
+ def main():
169
+ if len(sys.argv) < 2:
170
+ print("❌ 请提供版本号")
171
+ print(" 用法: python3 confluence-doc.py <版本号>")
172
+ sys.exit(1)
173
+
174
+ base_url = env("CONFLUENCE_BASE_URL")
175
+ search_url = env("CONFLUENCE_SEARCH_URL", base_url.rstrip("/") + "/dosearchsite.action?includeArchivedSpaces=false")
176
+ username = env("CONFLUENCE_USERNAME")
177
+ password = env("CONFLUENCE_PASSWORD")
178
+ prefix = os.environ.get("DOC_PRODUCT_PREFIX", "{{DOC_PRODUCT_PREFIX}}").strip() or "{{DOC_PRODUCT_PREFIX}}"
179
+ version = normalize_version(sys.argv[1])
180
+ keywords = search_keywords(prefix, version)
181
+
182
+ # 输出目录默认为仓库 docs/ 下
183
+ default_download = os.environ.get("DOC_DOWNLOAD_DIR")
184
+ if default_download:
185
+ docs_root = Path(default_download)
186
+ else:
187
+ docs_root = Path(__file__).resolve().parents[1]
188
+
189
+ slug = version_slug(prefix, version)
190
+ version_dir = docs_root / slug
191
+ version_dir.mkdir(parents=True, exist_ok=True)
192
+
193
+ print(f"\n📥 开始获取 {prefix} {keywords[0]} 需求文档")
194
+ print(f"🔍 将依次尝试: {', '.join(keywords)}")
195
+ print(f"📁 输出目录: {docs_root}")
196
+ print(f"🖼️ 图片目录: {version_dir}")
197
+
198
+ with sync_playwright() as p:
199
+ browser = p.chromium.launch(headless=True)
200
+ context = browser.new_context()
201
+ page = context.new_page()
202
+ try:
203
+ # 登录(如需要)
204
+ page.goto(search_url, wait_until="networkidle", timeout=60000)
205
+ if "login" in page.url.lower():
206
+ page.wait_for_selector("#username-field", timeout=10000)
207
+ page.fill("#username-field", username)
208
+ page.fill("#password-field", password)
209
+ page.click("#login-button")
210
+ page.wait_for_load_state("networkidle", timeout=30000)
211
+
212
+ target_url = None
213
+ matched_keyword = None
214
+ for kw in keywords:
215
+ print(f"\n🔍 搜索关键词: {kw}")
216
+ found = run_search(page, base_url, kw, search_url)
217
+ if found:
218
+ target_url = found
219
+ matched_keyword = kw
220
+ break
221
+ print(f" 未找到 {kw},尝试下一个关键词...")
222
+
223
+ if not target_url or not matched_keyword:
224
+ print(f"❌ 未找到 {prefix} {version} 的需求文档,请检查 Confluence 页面命名。")
225
+ sys.exit(1)
226
+
227
+ print(f"✅ 命中: {matched_keyword}")
228
+ print(f" 进入页面: {target_url[:120]}...")
229
+ page.goto(target_url, wait_until="networkidle", timeout=60000)
230
+
231
+ try:
232
+ title = page.locator("#title-text").text_content().strip()
233
+ except Exception:
234
+ title = f"{matched_keyword} 需求文档"
235
+
236
+ content_elem = page.locator("#main-content")
237
+ if not content_elem.count():
238
+ content_elem = page.locator(".wiki-content")
239
+ content_html = content_elem.inner_html() if content_elem.count() else ""
240
+
241
+ img_urls = re.findall(r'<img[^>]*src="([^"]+)"', content_html)
242
+ for i, img_url in enumerate(img_urls):
243
+ original_url = img_url
244
+ if img_url.startswith("//"):
245
+ img_url = "https:" + img_url
246
+ elif img_url.startswith("/"):
247
+ img_url = base_url.rstrip("/") + img_url
248
+ elif not img_url.startswith("http"):
249
+ img_url = urljoin(base_url, img_url)
250
+
251
+ if img_url.startswith("http") and not img_url.startswith("data:"):
252
+ parsed = urlparse(img_url)
253
+ ext = os.path.splitext(parsed.path)[1] or ".png"
254
+ filename = f"image_{i + 1}{ext}"
255
+ filepath = version_dir / filename
256
+ try:
257
+ resp = page.request.get(img_url)
258
+ if resp.ok:
259
+ filepath.write_bytes(resp.body())
260
+ content_html = content_html.replace(f'src="{original_url}"', f'src="{slug}/{filename}"')
261
+ except Exception:
262
+ pass
263
+
264
+ content_md = parse_html_to_markdown(content_html)
265
+ md_content = f"""# {title}
266
+
267
+ > 版本: {matched_keyword}
268
+ > 来源: {target_url}
269
+
270
+ ---
271
+
272
+ {content_md}
273
+ """
274
+ md_filename = sanitize_filename(title) + ".md"
275
+ if not re.search(rf"{prefix}[-_]?V", md_filename, flags=re.IGNORECASE):
276
+ md_filename = f"{matched_keyword}-{md_filename}"
277
+ md_path = docs_root / md_filename
278
+ md_path.write_text(md_content, encoding="utf-8")
279
+
280
+ print("\n✅ 获取完成!")
281
+ print(f"📄 主文档: {md_path}")
282
+ print(f"🖼️ 图片目录: {version_dir}")
283
+ finally:
284
+ browser.close()
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
289
+
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: {{SKILL_NAME}}
3
+ description: |
4
+ 当用户说「获取 {{PRODUCT}} Vx.y.z 需求文档」「下载 {{PRODUCT}}-Vx.y.z PRD」「查询 {{PRODUCT}} x.y.z 版本需求」时触发。
5
+ 自动从 Confluence 搜索对应版本 PRD 页面,下载图片并生成 Markdown 到本仓库 `docs/`。
6
+ ---
7
+
8
+ # {{PRODUCT}} 需求文档获取(docs/{{SKILL_DIR}})
9
+
10
+ ## 触发条件
11
+
12
+ 用户说:
13
+
14
+ - 「获取 {{PRODUCT}} V1.2.3 需求文档」
15
+ - 「下载 {{PRODUCT}}-V1.2.3 PRD」
16
+ - 「查询 {{PRODUCT}} 1.2.3 版本需求」
17
+
18
+ ## 执行方式
19
+
20
+ 在当前项目根目录执行:
21
+
22
+ ```bash
23
+ cd docs/{{SKILL_DIR}}
24
+ export CONFLUENCE_BASE_URL="https://confluence.huan.tv"
25
+ export CONFLUENCE_USERNAME="<你的账号>"
26
+ export CONFLUENCE_PASSWORD="<你的密码>"
27
+ export DOC_PRODUCT_PREFIX="{{PRODUCT_PREFIX}}"
28
+ python3 confluence-doc.py <版本号>
29
+ ```
30
+
31
+ 版本号支持多种写法:
32
+
33
+ ```bash
34
+ python3 confluence-doc.py 1.2.3
35
+ python3 confluence-doc.py V1.2.3
36
+ python3 confluence-doc.py {{PRODUCT_PREFIX}}-V1.2.3
37
+ python3 confluence-doc.py {{PRODUCT_PREFIX}}_V1.2.3
38
+ ```
39
+
40
+ ## 输出结构
41
+
42
+ ```
43
+ <repo>/docs/
44
+ ├── {{PRODUCT_PREFIX}}-V1.2.3-xxx.md
45
+ └── {{PRODUCT_PREFIX_LOWER}}-v123/
46
+ ├── image_1.png
47
+ └── image_2.png
48
+ ```
49
+
50
+ ## 注意事项
51
+
52
+ - 依赖 Playwright:`pip install playwright && playwright install chromium`
53
+ - 脚本:`docs/{{SKILL_DIR}}/confluence-doc.py`
54
+ - 为安全起见,脚本不包含默认账号密码,请用环境变量注入
55
+