sdd-flow-kit 0.1.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
@@ -13,22 +13,21 @@
13
13
 
14
14
  #### 安装教程
15
15
 
16
- 1. 进入你要开发的项目目录(例如 `adInsight-web` 或任意工程根目录)
17
- 2. 安装(本地/私有 npm 方式均可):`npm i -D /Users/lixinxin/Desktop/Huanwang/sdd-flow-kit` 或发布后 `npm i -D sdd-flow-kit`
18
- 3. 先初始化环境(默认 `claude-code`):`npx sdd-flow-kit setup`
19
- 4. 启动:`npx sdd-flow-kit run --product ADI --version 2.3.2`
20
- 5. 预演:`npx sdd-flow-kit run --product ADI --version 2.3.2 --dry-run`
21
- 6. 一句话触发:`npx sdd-flow-kit invoke "开发 ADI 2.3.2 需求"`
22
- 7. 环境健检:`npx sdd-flow-kit doctor --agent claude-code`
16
+ 1. 进入你要开发的项目目录
17
+ 2. 安装:`npm i -D sdd-flow-kit`
18
+ 3. 安装流程(安装即用,默认 `claude-code`):`npx sdd-flow-kit install`
19
+ - 会自动安装对应 AI 环境并写入项目级触发 Skill(仅在“开发 <产品> <版本> 需求/版本/PRD”场景触发)
20
+ - 会在项目根目录生成 `docs/<项目>-doc-skill/`(用于 PRD 下载脚本与说明),可通过 `--project` 选择:`OMS` / `ADI` / `欢盟` / `AD Tools`(默认 `OMS`)
21
+ 4. (可选)预演安装:`npx sdd-flow-kit install --dry-run --plan-json`
22
+ 5. (可选)环境健检:`npx sdd-flow-kit doctor --agent claude-code`
23
+ 6. 完成后,直接在 Cursor / Claude Code / Codex / OpenClaw 中用自然语言发起需求,例如:`开发 ADI 2.3.2 需求`
23
24
 
24
25
  #### 使用说明
25
26
 
26
- 1. `setup`:提示选择开发环境(cursor/claude-code/codex/openclaw,默认 claude-code)与开发分支(无则自动创建),并检测/安装 openspec+superpower 基础环境
27
- 2. `start`:创建 `openspec/PRD/<YYYYMMDD>-<product>-<version>/` 并生成 1~5 步所需模板
28
- 3. `run`:先执行 `setup` 逻辑,再生成流程骨架与 `AUTO-RUN-CHECKLIST.md`
29
- 4. 2 步 SDD 循环提示词与第 4/5 步入口文件可直接在 Cursor/Claude/Codex/OpenClaw 使用
30
- 5. `invoke`:从自然语言解析 product/version,并自动触发 `run`
31
- 6. `doctor`:检查 git/npx/agent CLI/openspec/tool 目录,输出可机读诊断结果
27
+ 1. `install`(等价 `setup`):提示选择开发环境(cursor/claude-code/codex/openclaw,默认 claude-code)与开发分支(无则自动创建),并检测/安装 openspec+superpower 基础环境,同时写入项目级 Skill
28
+ 2. 安装完成后,本工具主要负责“流程环境与模板”落地;业务需求由你在目标 AI 工具中自然语言驱动
29
+ 3. `start`/`run`/`invoke` 属于进阶能力(脚本化或一键编排时使用)
30
+ 4. `doctor`:检查 git/npx/agent CLI/openspec/tool 目录,输出可机读诊断结果
32
31
 
33
32
  #### 当前自动化边界
34
33
 
@@ -36,6 +35,23 @@
36
35
  - 已支持:环境检测与按工具自动执行 `npx @lixin5257xxx/opsx-workflow setup ...`
37
36
  - 自动化增强:`claude-code` adapter 支持 `claude -p` 自动执行并记录 `AUTO-RUN-LOG.md`(失败自动降级人工)
38
37
 
38
+ #### npm 发包流程
39
+
40
+ 1. 发布前检查:`npm whoami`(确认已登录正确 npm 账号)
41
+ 2. 构建产物:`npm run build`
42
+ 3. 本地打包检查:`npm pack`(确认 tar 包内容包含 `dist/`、`src/templates/`、`README.md`)
43
+ 4. (可选)在目标项目验证安装:`npm i -D ./sdd-flow-kit-<version>.tgz`,并执行 `npx sdd-flow-kit install --dry-run --plan-json`
44
+ 5. 正式发布:
45
+ - 首次发布 scoped 包:`npm publish --access public`
46
+ - 非 scoped 或后续版本:`npm publish`
47
+ 6. 发布后验证:在任意项目执行 `npm i -D sdd-flow-kit`,再执行 `npx sdd-flow-kit install`
48
+
49
+ 版本升级建议:
50
+
51
+ 1. 修改版本号:`npm version patch`(或 `minor` / `major`)
52
+ 2. 重新执行构建与发布步骤
53
+ 3. 在 `README.md` 或变更记录中补充本次升级说明
54
+
39
55
  #### 参与贡献
40
56
 
41
57
  1. Fork 本仓库
package/dist/cli.js CHANGED
@@ -30,14 +30,15 @@ function printHelp() {
30
30
  "1) 生成流程产物骨架:",
31
31
  " npx sdd-flow-kit start --product ADI --version 2.3.2",
32
32
  "",
33
- "2) 初始化环境(环境选择 + 分支处理 + opsx 检测安装):",
34
- " npx sdd-flow-kit setup --agent claude-code",
33
+ "2) 安装流程到当前项目(推荐,安装即用):",
34
+ " npx sdd-flow-kit install",
35
+ " (等价于: npx sdd-flow-kit setup)",
35
36
  "",
36
37
  "3) 生成骨架并进入可执行模式(推荐):",
37
38
  " npx sdd-flow-kit run --product ADI --version 2.3.2 --agent claude-code",
38
39
  "",
39
- "4) 一句话触发:",
40
- " npx sdd-flow-kit invoke \"开发 ADI 2.3.2 需求\"",
40
+ "4) 一句话触发(可选能力):",
41
+ " npx sdd-flow-kit invoke \"开发 XXX 1.2.3 需求\"",
41
42
  "",
42
43
  "5) 环境健检:",
43
44
  " npx sdd-flow-kit doctor --agent claude-code",
@@ -46,6 +47,7 @@ function printHelp() {
46
47
  " --projectRoot <path> 目标项目根目录(默认当前目录)",
47
48
  " --agent <name> cursor | claude-code | codex | openclaw | custom(默认 claude-code)",
48
49
  " --branch <name> 开发分支;不存在会自动创建",
50
+ " --project <name> OMS | ADI | 欢盟 | AD Tools(默认 OMS,用于生成 docs/*-doc-skill)",
49
51
  " -y 非交互模式(使用默认值)",
50
52
  " --dry-run 预演模式:仅检测并输出计划,不执行写入/安装/切分支",
51
53
  " --plan-json 输出机器可读 JSON 计划结果",
@@ -60,7 +62,12 @@ async function main() {
60
62
  printHelp();
61
63
  return;
62
64
  }
63
- if (cmd !== "start" && cmd !== "run" && cmd !== "setup" && cmd !== "doctor" && cmd !== "invoke") {
65
+ if (cmd !== "start" &&
66
+ cmd !== "run" &&
67
+ cmd !== "setup" &&
68
+ cmd !== "install" &&
69
+ cmd !== "doctor" &&
70
+ cmd !== "invoke") {
64
71
  printHelp();
65
72
  node_process_1.default.exitCode = 1;
66
73
  return;
@@ -73,6 +80,7 @@ async function main() {
73
80
  const dryRun = node_process_1.default.argv.includes("--dry-run");
74
81
  const planJson = node_process_1.default.argv.includes("--plan-json");
75
82
  const agentValue = getArgValue("--agent") ?? "claude-code";
83
+ const projectKind = (getArgValue("--project") ?? "OMS");
76
84
  if (!ALLOWED_AGENTS.includes(agentValue)) {
77
85
  // eslint-disable-next-line no-console
78
86
  console.error(`不支持的 --agent: ${agentValue}`);
@@ -81,6 +89,13 @@ async function main() {
81
89
  return;
82
90
  }
83
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
+ }
84
99
  if (cmd === "doctor") {
85
100
  const doctor = await (0, doctor_1.runDoctor)({ projectRoot: node_path_1.default.resolve(projectRoot), agent });
86
101
  // eslint-disable-next-line no-console
@@ -97,16 +112,17 @@ async function main() {
97
112
  node_process_1.default.exitCode = 1;
98
113
  return;
99
114
  }
100
- if (cmd === "setup") {
115
+ if (cmd === "setup" || cmd === "install") {
101
116
  const result = await (0, setupProject_1.setupProject)({
102
117
  projectRoot: node_path_1.default.resolve(projectRoot),
103
118
  agent,
104
119
  branch,
105
120
  yes,
106
121
  dryRun,
122
+ projectKind: projectKind,
107
123
  });
108
124
  // eslint-disable-next-line no-console
109
- 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}`);
110
126
  if (result.plannedActions.length > 0) {
111
127
  // eslint-disable-next-line no-console
112
128
  console.log(`planned: ${result.plannedActions.join(" | ")}`);
@@ -114,7 +130,7 @@ async function main() {
114
130
  if (planJson) {
115
131
  // eslint-disable-next-line no-console
116
132
  console.log(JSON.stringify({
117
- command: "setup",
133
+ command: cmd,
118
134
  projectRoot: node_path_1.default.resolve(projectRoot),
119
135
  dryRun,
120
136
  result,
@@ -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": "0.1.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
+