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 +3 -1
- package/dist/cli.js +11 -1
- package/dist/steps/setupProject.js +105 -0
- package/package.json +1 -1
- package/src/templates/artifacts/06-agent-skill.template.md +30 -0
- package/src/templates/skills/confluence-doc.py.template +289 -0
- package/src/templates/skills/doc-skill.SKILL.md.template +55 -0
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
|
@@ -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"", 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
|
+
|