sdd-flow-kit 1.0.18 → 1.0.22

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
@@ -15,9 +15,12 @@
15
15
 
16
16
  1. 进入你要开发的项目目录
17
17
  2. 安装:`pnpm add -D sdd-flow-kit `
18
- 检查是否安装 - `ls .cursor/skills/sdd-flow-kit/SKILL.md`
19
- - `ls .claude/skills/sdd-flow-kit/SKILL.md`
20
- - `ls docs/*-doc-skill/SKILL.md`
18
+ 检查是否安装(Cursor 用户看 `.cursor`,不是 `.claude`):
19
+ - `ls .cursor/skills/sdd-flow-kit/SKILL.md`
20
+ - `ls docs/adi-doc-skill/SKILL.md`(adInsight 应为 **adi**,不是 oms)
21
+ - Claude Code 用户:`ls .claude/skills/sdd-flow-kit/SKILL.md`
22
+
23
+ > 若在 `adInsight-web/frontend` 等子目录安装,工具会**向上识别**仓库名;仍不对时:`SDD_FLOW_KIT_PROJECT=ADI pnpm add -D sdd-flow-kit` 或 `npx sdd-flow-kit install --project ADI -y`
21
24
  3. 若 skill 未生成,执行手动兜底:
22
25
  - `node ./node_modules/sdd-flow-kit/dist/postinstall.js`(轻量,秒级)
23
26
  - 或 `npx sdd-flow-kit install -y`(完整环境)
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PRODUCT_PREFIX_BY_KIND = exports.DOC_SKILL_BY_KIND = exports.ALLOWED_PROJECTS = void 0;
7
+ exports.productToKind = productToKind;
8
+ exports.resolveDocSkillDir = resolveDocSkillDir;
9
+ exports.inferProjectKind = inferProjectKind;
10
+ const promises_1 = __importDefault(require("node:fs/promises"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ exports.ALLOWED_PROJECTS = ["OMS", "ADI", "欢盟", "AD Tools"];
13
+ exports.DOC_SKILL_BY_KIND = {
14
+ ADI: "adi-doc-skill",
15
+ OMS: "oms-doc-skill",
16
+ 欢盟: "huan-doc-skill",
17
+ "AD Tools": "ad-tools-doc-skill",
18
+ };
19
+ exports.PRODUCT_PREFIX_BY_KIND = {
20
+ ADI: "ADI",
21
+ OMS: "OMS",
22
+ 欢盟: "HUAN",
23
+ "AD Tools": "ADTOOLS",
24
+ };
25
+ function productToKind(product) {
26
+ const p = product.trim().toUpperCase();
27
+ if (p === "ADI" || p.includes("ADINSIGHT"))
28
+ return "ADI";
29
+ if (p === "OMS" || p.includes("OPERATION"))
30
+ return "OMS";
31
+ if (p === "欢盟" || p === "HUAN")
32
+ return "欢盟";
33
+ if (p.includes("AD TOOLS") || p === "ADTOOLS")
34
+ return "AD Tools";
35
+ return "OMS";
36
+ }
37
+ /** 从 projectRoot 向上查找 docs/*-doc-skill(适配 frontend 子目录 + 根目录 docs) */
38
+ async function resolveDocSkillDir(projectRoot, kind) {
39
+ const dirName = exports.DOC_SKILL_BY_KIND[kind];
40
+ let cur = node_path_1.default.resolve(projectRoot);
41
+ for (let i = 0; i < 8; i += 1) {
42
+ const candidate = node_path_1.default.join(cur, "docs", dirName);
43
+ if (await pathExists(node_path_1.default.join(candidate, "confluence-doc.py"))) {
44
+ const rel = node_path_1.default.relative(projectRoot, candidate);
45
+ return {
46
+ absPath: candidate,
47
+ relFromRoot: rel.startsWith("..") ? rel : node_path_1.default.join("docs", dirName),
48
+ };
49
+ }
50
+ const parent = node_path_1.default.dirname(cur);
51
+ if (parent === cur)
52
+ break;
53
+ cur = parent;
54
+ }
55
+ return {
56
+ absPath: node_path_1.default.join(projectRoot, "docs", dirName),
57
+ relFromRoot: node_path_1.default.join("docs", dirName),
58
+ };
59
+ }
60
+ async function pathExists(filePath) {
61
+ try {
62
+ await promises_1.default.access(filePath);
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ function matchKindInText(text) {
70
+ const t = text.toLowerCase().replace(/_/g, "-");
71
+ if (t.includes("adinsight") || t.includes("adi-web") || /(^|\/|-)adi(-|\/|$)/.test(t)) {
72
+ return "ADI";
73
+ }
74
+ if (t.includes("operation-web") || t.includes("operation") || /(^|\/|-)oms(-|\/|$)/.test(t)) {
75
+ return "OMS";
76
+ }
77
+ if (t.includes("huan-union") || t.includes("huanunion") || /\bhm\b/.test(t)) {
78
+ return "欢盟";
79
+ }
80
+ if (t.includes("ad-tools") || t.includes("adtools")) {
81
+ return "AD Tools";
82
+ }
83
+ return null;
84
+ }
85
+ /** 从 projectRoot 向上查找已存在的 docs/*-doc-skill */
86
+ async function inferFromExistingDocSkill(projectRoot) {
87
+ let dir = projectRoot;
88
+ for (let i = 0; i < 8; i += 1) {
89
+ for (const kind of exports.ALLOWED_PROJECTS) {
90
+ const rel = node_path_1.default.join(dir, "docs", exports.DOC_SKILL_BY_KIND[kind]);
91
+ if (await pathExists(node_path_1.default.join(rel, "SKILL.md"))) {
92
+ return kind;
93
+ }
94
+ }
95
+ const parent = node_path_1.default.dirname(dir);
96
+ if (parent === dir)
97
+ break;
98
+ dir = parent;
99
+ }
100
+ return null;
101
+ }
102
+ /** 从目录路径(含父级)推断产品类型 */
103
+ function inferFromPathSegments(projectRoot) {
104
+ const resolved = node_path_1.default.resolve(projectRoot);
105
+ const parts = resolved.split(node_path_1.default.sep);
106
+ for (let i = parts.length; i > 0; i -= 1) {
107
+ const segment = parts.slice(0, i).join(node_path_1.default.sep);
108
+ const hit = matchKindInText(segment);
109
+ if (hit)
110
+ return hit;
111
+ }
112
+ return null;
113
+ }
114
+ async function inferFromPackageJson(dir) {
115
+ try {
116
+ const pkgText = await promises_1.default.readFile(node_path_1.default.join(dir, "package.json"), "utf8");
117
+ const pkg = JSON.parse(pkgText);
118
+ const blob = `${pkg.name ?? ""} ${pkg.description ?? ""}`;
119
+ return matchKindInText(blob);
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ /**
126
+ * 推断 OMS / ADI / 欢盟 / AD Tools。
127
+ * monorepo 子目录(如 adInsight-web/frontend)会向上扫描路径,避免误判为 OMS。
128
+ */
129
+ async function inferProjectKind(projectRoot) {
130
+ const fromEnv = (process.env.SDD_FLOW_KIT_PROJECT ?? "").trim();
131
+ if (exports.ALLOWED_PROJECTS.includes(fromEnv)) {
132
+ return fromEnv;
133
+ }
134
+ // 路径优先于已存在的 docs skill(避免 frontend 子目录曾误装 oms-doc-skill 后一直错下去)
135
+ const fromPath = inferFromPathSegments(projectRoot);
136
+ if (fromPath)
137
+ return fromPath;
138
+ let dir = projectRoot;
139
+ for (let i = 0; i < 6; i += 1) {
140
+ const fromPkg = await inferFromPackageJson(dir);
141
+ if (fromPkg)
142
+ return fromPkg;
143
+ const parent = node_path_1.default.dirname(dir);
144
+ if (parent === dir)
145
+ break;
146
+ dir = parent;
147
+ }
148
+ const fromExisting = await inferFromExistingDocSkill(projectRoot);
149
+ if (fromExisting)
150
+ return fromExisting;
151
+ return "OMS";
152
+ }
@@ -1,56 +1,55 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  const node_path_1 = __importDefault(require("node:path"));
7
- const promises_1 = __importDefault(require("node:fs/promises"));
8
40
  const node_process_1 = __importDefault(require("node:process"));
41
+ const inferProject_1 = require("./core/inferProject");
9
42
  const opsxDelivery_1 = require("./core/opsxDelivery");
10
43
  const setupProject_1 = require("./steps/setupProject");
11
- const ALLOWED_PROJECTS = ["OMS", "ADI", "欢盟", "AD Tools"];
12
- async function inferProjectKind(projectRoot) {
13
- const fromEnv = (node_process_1.default.env.SDD_FLOW_KIT_PROJECT ?? "").trim();
14
- if (ALLOWED_PROJECTS.includes(fromEnv)) {
15
- return fromEnv;
16
- }
17
- const base = node_path_1.default.basename(projectRoot).toLowerCase();
18
- const hit = (s) => base.includes(s);
19
- if (hit("adi") || hit("adinsight"))
20
- return "ADI";
21
- if (hit("oms") || hit("operation"))
22
- return "OMS";
23
- if (hit("huan-union") || hit("hm"))
24
- return "欢盟";
25
- if (hit("ad-tools") || hit("adtools") || hit("tools"))
26
- return "AD Tools";
27
- try {
28
- const pkgText = await promises_1.default.readFile(node_path_1.default.join(projectRoot, "package.json"), "utf8");
29
- const pkg = JSON.parse(pkgText);
30
- const name = (pkg.name ?? "").toLowerCase();
31
- const has = (s) => name.includes(s);
32
- if (has("adi") || has("adinsight"))
33
- return "ADI";
34
- if (has("oms") || has("operation"))
35
- return "OMS";
36
- if (has("huan-union") || has("hm"))
37
- return "欢盟";
38
- if (has("ad-tools") || has("adtools") || has("tools"))
39
- return "AD Tools";
40
- }
41
- catch {
42
- // ignore and fallback
43
- }
44
- return "OMS";
45
- }
46
44
  async function inferAgent(projectRoot) {
47
45
  const fromEnv = (node_process_1.default.env.SDD_FLOW_KIT_AGENT ?? "").trim();
48
46
  const allowed = ["cursor", "claude-code", "codex", "openclaw"];
49
47
  if (allowed.includes(fromEnv))
50
48
  return fromEnv;
49
+ const fs = await Promise.resolve().then(() => __importStar(require("node:fs/promises")));
51
50
  const exists = async (rel) => {
52
51
  try {
53
- await promises_1.default.access(node_path_1.default.join(projectRoot, rel));
52
+ await fs.access(node_path_1.default.join(projectRoot, rel));
54
53
  return true;
55
54
  }
56
55
  catch {
@@ -76,7 +75,7 @@ async function main() {
76
75
  const projectRoot = initCwd ?? node_process_1.default.cwd();
77
76
  if (projectRoot === pkgDir)
78
77
  return;
79
- const projectKind = await inferProjectKind(projectRoot);
78
+ const projectKind = await (0, inferProject_1.inferProjectKind)(projectRoot);
80
79
  const agent = await inferAgent(projectRoot);
81
80
  // eslint-disable-next-line no-console
82
81
  console.log(`[sdd-flow-kit] postinstall (light) at: ${projectRoot}`);
@@ -105,7 +104,11 @@ async function main() {
105
104
  console.log("[sdd-flow-kit] opsx apply stack ready");
106
105
  }
107
106
  // eslint-disable-next-line no-console
108
- console.log("[sdd-flow-kit] light setup done (skills/rules). Full install: npx sdd-flow-kit install -y");
107
+ console.log("[sdd-flow-kit] light setup done (skills/rules). Full install: npx sdd-flow-kit install --project ADI -y");
108
+ if (agent === "cursor") {
109
+ // eslint-disable-next-line no-console
110
+ console.log("[sdd-flow-kit] Cursor skill: .cursor/skills/sdd-flow-kit/SKILL.md");
111
+ }
109
112
  }
110
113
  main().catch((err) => {
111
114
  // eslint-disable-next-line no-console
@@ -9,6 +9,7 @@ const promises_1 = __importDefault(require("node:fs/promises"));
9
9
  const utils_1 = require("../core/utils");
10
10
  const fs_1 = require("../core/fs");
11
11
  const templates_1 = require("../core/templates");
12
+ const inferProject_1 = require("../core/inferProject");
12
13
  function renderTemplate(template, vars) {
13
14
  let out = template;
14
15
  for (const [k, v] of Object.entries(vars)) {
@@ -22,8 +23,16 @@ async function startFlowScaffold(options) {
22
23
  const outputRoot = node_path_1.default.join(options.projectRoot, "openspec", "PRD", runId);
23
24
  const sourceDir = node_path_1.default.join(outputRoot, "source");
24
25
  await promises_1.default.mkdir(sourceDir, { recursive: true });
25
- // 1) Step1: adi-doc-skill 指引
26
- const step1 = await (0, templates_1.loadTemplateText)("artifacts/01-adi-doc-skill-指引.template.md");
26
+ // 1) Step1: doc-skill 指引(按产品解析脚本路径,支持 monorepo frontend)
27
+ const kind = (0, inferProject_1.productToKind)(options.product);
28
+ const docSkill = await (0, inferProject_1.resolveDocSkillDir)(options.projectRoot, kind);
29
+ const step1Template = await (0, templates_1.loadTemplateText)("artifacts/01-adi-doc-skill-指引.template.md");
30
+ const step1 = renderTemplate(step1Template, {
31
+ PRODUCT: options.product,
32
+ PRODUCT_PREFIX: inferProject_1.PRODUCT_PREFIX_BY_KIND[kind],
33
+ DOC_SKILL_REL: docSkill.relFromRoot.replace(/\\/g, "/"),
34
+ VERSION_EXAMPLE: options.version,
35
+ });
27
36
  await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(outputRoot, "01-获取需求文档指引.md"), step1);
28
37
  // source 占位:让第 2 步提示词在任何工具里都能直接读
29
38
  await (0, fs_1.writeTextFileEnsuredDir)(node_path_1.default.join(sourceDir, "PRD.md"), [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sdd-flow-kit",
3
- "version": "1.0.18",
3
+ "version": "1.0.22",
4
4
  "private": false,
5
5
  "description": "Cross-agent SDD automated development workflow kit (ADI/ADI-like).",
6
6
  "license": "MIT",
@@ -1,15 +1,29 @@
1
- # 01-获取需求文档(adi-doc-skill 指引)
1
+ # 01-获取需求文档({{PRODUCT}} / {{DOC_SKILL_REL}})
2
2
 
3
- 你的步骤 1 目标:从 Confluence 拉取指定需求版本的 Markdown,并落到本目录的 `source/` 下。
3
+ ## 必须执行(不要只用 AI 猜,要跑脚本)
4
4
 
5
- ## 请执行
6
- 1. 使用你当前 AI 工具(Cursor / Claude Code / Codex / OpenClaw)调用现有的 `adi-doc-skill`。
7
- 2. 命令/动作建议:让工具“获取 ADI 2.3.2 需求文档”(或等价的触发词),并把下载结果写入本目录:
8
- - `source/PRD.md`(PRD 主内容)
9
- - `source/接口文档.md`(接口文档,如有)
5
+ 1. 在**仓库根**(当前工作目录,可能是 `frontend/` 或 monorepo 根)执行:
10
6
 
11
- ## 成功校验
12
- - 你应该能在 `source/PRD.md` 看到完整 PRD markdown
7
+ ```bash
8
+ cd {{DOC_SKILL_REL}}
9
+ export DOC_PRODUCT_PREFIX="{{PRODUCT_PREFIX}}"
10
+ python3 confluence-doc.py {{VERSION_EXAMPLE}}
11
+ ```
13
12
 
14
- (如果你希望自动回填,请在 adapter 中实现对应能力。)
13
+ 版本号可传:`2.3.2`、`V2.3.2`、`ADI_V2.3.2`、`ADI-V2.3.2`(脚本会归一化)。
15
14
 
15
+ 2. 脚本会依次搜索:`{{PRODUCT_PREFIX}}-V*`、`{{PRODUCT_PREFIX}}_V*`、`{{PRODUCT_PREFIX}} V*`、`V*` 等。
16
+
17
+ 3. 成功后把生成的 `docs/*.md` **主文档**复制/回填到本 run 目录:
18
+ - `source/PRD.md`
19
+
20
+ ## 路径说明
21
+
22
+ - 推荐脚本目录:`{{DOC_SKILL_REL}}`(已向上解析,可能是 `docs/adi-doc-skill` 或 `../docs/adi-doc-skill`)
23
+ - **禁止**在 ADI 需求中使用 `docs/oms-doc-skill`(会搜 OMS 文档导致「未找到」)
24
+
25
+ ## 失败排查
26
+
27
+ - 输出 `❌ 未找到`:到 Confluence 确认页面标题是否含 `{{PRODUCT_PREFIX}}-V2.3.2` 或 `{{PRODUCT_PREFIX}}_V2.3.2`
28
+ - `playwright` 未安装:`pip install playwright && playwright install chromium`
29
+ - 仍失败:在仓库根执行 `npx sdd-flow-kit install --project {{PRODUCT}} -y` 后重试
@@ -41,7 +41,10 @@ description: 开发某版本需求、SDD 流程、待确认问题澄清、用户
41
41
  ---
42
42
 
43
43
  ### 阶段 A — Step1 + Step2(必须可停顿)
44
- 1. **Step1**:按 `01-获取需求文档指引.md` 拉取 PRD,回填 `source/PRD.md`(及可选接口文档)
44
+ 1. **Step1**:打开 `01-获取需求文档指引.md`,**在终端执行其中的 `confluence-doc.py` 命令**(不要跳过)。
45
+ - ADI 必须用 `docs/adi-doc-skill` 或上级 `../docs/adi-doc-skill`,**禁止**用 `docs/oms-doc-skill`。
46
+ - 用户说「开发 ADI_V2.3.2」时,脚本参数传 `2.3.2` 或 `ADI_V2.3.2` 均可。
47
+ - 将下载的 markdown 回填 `source/PRD.md`。
45
48
  2. **Step2**:执行 `02-需求分析提示词.md`(引用 `docs/` 下已下载的 PRD)
46
49
 
47
50
  **门禁(Step2)**
@@ -5,7 +5,7 @@ import sys
5
5
  import os
6
6
  import re
7
7
  from pathlib import Path
8
- from urllib.parse import urljoin, urlparse, unquote
8
+ from urllib.parse import quote, urljoin, urlparse, unquote
9
9
 
10
10
  try:
11
11
  from playwright.sync_api import sync_playwright
@@ -24,6 +24,11 @@ def env(name: str, default: str | None = None) -> str:
24
24
  return str(v).strip()
25
25
 
26
26
 
27
+ def env_bool(name: str, default: bool = False) -> bool:
28
+ v = env(name, "1" if default else "0").lower()
29
+ return v in ("1", "true", "yes", "on")
30
+
31
+
27
32
  def load_env_file(file_path: Path) -> None:
28
33
  """轻量加载 .env 文件(不依赖 python-dotenv)。"""
29
34
  if not file_path.exists() or not file_path.is_file():
@@ -57,11 +62,13 @@ def version_slug(prefix: str, version: str) -> str:
57
62
 
58
63
 
59
64
  def search_keywords(prefix: str, version: str) -> list[str]:
60
- # 搜索时强制带 V,并增加不带前缀的 V 兜底项:
61
- # 1) ADI-V2.3.3
62
- # 2) ADI_V2.3.3
63
- # 3) V2.3.3
64
- return [f"{prefix}-V{version}", f"{prefix}_V{version}", f"V{version}"]
65
+ """固定顺序(与用户 Confluence 全局搜索习惯一致)。无论用户输入 ADI 2.3.2 还是 ADI_V2.3.2 都走这一套。"""
66
+ return [
67
+ f"{prefix}_V{version}",
68
+ f"{prefix}-V{version}",
69
+ f"{prefix}_{version}",
70
+ f"{prefix}-{version}",
71
+ ]
65
72
 
66
73
 
67
74
  def sanitize_filename(name: str) -> str:
@@ -116,23 +123,54 @@ def href_path_matches_keyword(href: str, keyword: str) -> bool:
116
123
  return kw in path or compact in path_compact
117
124
 
118
125
 
119
- def find_search_result(page, base_url: str, keyword: str):
120
- keyword_lower = keyword.lower()
126
+ def score_link_match(text: str, href: str, prefix: str, version: str, keyword: str) -> int:
127
+ """对搜索结果链接打分;>=2 视为命中。"""
128
+ t = (text or "").lower()
129
+ path = unquote(urlparse(href).path).lower()
130
+ kw = keyword.lower()
131
+ p = prefix.lower()
132
+ v_compact = version.replace(".", "")
133
+ t_compact = re.sub(r"\s+", "", t)
134
+ path_compact = path.replace(".", "").replace("-", "").replace("_", "")
135
+
136
+ score = 0
137
+ if kw in t:
138
+ score += 4
139
+ if kw.replace(".", "") in t_compact:
140
+ score += 3
141
+ if p in t and version in t:
142
+ score += 4
143
+ if p in t and v_compact in t_compact:
144
+ score += 3
145
+ if href_path_matches_keyword(href, keyword):
146
+ score += 3
147
+ slug = f"{p}v{v_compact}"
148
+ if slug in path_compact or f"{p}-v{v_compact}" in path or f"{p}_v{v_compact}" in path:
149
+ score += 4
150
+ return score
151
+
152
+
153
+ def collect_search_candidates(
154
+ page, base_url: str, prefix: str, version: str, keyword: str
155
+ ) -> list[tuple[str, str, int]]:
156
+ """返回 [(url, title, score), ...]"""
121
157
  seen: set[str] = set()
158
+ scored: list[tuple[int, str, str]] = []
159
+
122
160
  selectors = [
123
161
  ".search-result .title a",
124
162
  ".search-result-title a",
125
163
  "#search-results .gs-title a",
126
- f"a[href*='{keyword}']",
127
- f"a:has-text('{keyword}')",
128
- ".search-result a",
129
- "#search-results a",
164
+ ".search-results-panel a",
165
+ "a[data-linked-resource-id]",
166
+ "a[href*='/pages/']",
167
+ "a[href*='/display/']",
130
168
  ]
131
- scored: list[tuple[int, str]] = []
132
169
 
133
170
  for selector in selectors:
134
171
  links = page.locator(selector)
135
- for i in range(links.count()):
172
+ count = min(links.count(), 200)
173
+ for i in range(count):
136
174
  href = links.nth(i).get_attribute("href") or ""
137
175
  raw_text = (links.nth(i).inner_text() or "").strip()
138
176
  title_text = raw_text.split("\n")[0].strip()
@@ -145,23 +183,66 @@ def find_search_result(page, base_url: str, keyword: str):
145
183
  continue
146
184
  seen.add(full_url)
147
185
 
148
- text_hit = keyword_lower in text.lower()
149
- path_hit = href_path_matches_keyword(href, keyword)
150
- if not text_hit:
151
- continue
152
-
153
- score = 2 + (1 if path_hit else 0)
154
- scored.append((score, full_url))
186
+ s = score_link_match(text, href, prefix, version, keyword)
187
+ if s >= 2:
188
+ scored.append((s, full_url, text[:120]))
155
189
 
156
- if not scored:
157
- return None
158
190
  scored.sort(key=lambda x: x[0], reverse=True)
159
- best = scored[0][1]
160
- print(f" 找到文档链接: {best[:100]}...")
161
- return best
191
+ return [(u, t, s) for s, u, t in scored]
162
192
 
163
193
 
164
- def run_search(page, base_url: str, keyword: str) -> str | None:
194
+ def read_breadcrumb_text(page) -> str:
195
+ parts: list[str] = []
196
+ for selector in [
197
+ "#breadcrumbs li",
198
+ "#breadcrumbs a",
199
+ "ol.aui-nav-breadcrumbs li",
200
+ ".breadcrumbs li",
201
+ "nav[aria-label='Breadcrumbs'] li",
202
+ ]:
203
+ loc = page.locator(selector)
204
+ for i in range(min(loc.count(), 20)):
205
+ txt = (loc.nth(i).inner_text() or "").strip()
206
+ if txt and txt not in parts:
207
+ parts.append(txt)
208
+ return " > ".join(parts)
209
+
210
+
211
+ def is_prd_page(page, page_url: str) -> bool:
212
+ """检查页面面包屑/父级是否属于 PRD(需求文档)。"""
213
+ page.goto(page_url, wait_until="networkidle", timeout=60000)
214
+ page.wait_for_timeout(1200)
215
+
216
+ crumbs = read_breadcrumb_text(page)
217
+ crumbs_upper = crumbs.upper()
218
+ if re.search(r"\bPRD\b", crumbs_upper) or "需求" in crumbs or "PRD" in crumbs_upper:
219
+ print(f" ✓ PRD 父级命中: {crumbs[:120]}")
220
+ return True
221
+
222
+ try:
223
+ title = (page.locator("#title-text").inner_text() or "").strip()
224
+ except Exception:
225
+ title = ""
226
+ title_upper = title.upper()
227
+ if re.search(r"\bPRD\b", title_upper) or "需求文档" in title:
228
+ print(f" ✓ 标题含 PRD/需求: {title[:80]}")
229
+ return True
230
+
231
+ print(f" ✗ 非 PRD 父级: {crumbs[:120] or title[:80]}")
232
+ return False
233
+
234
+
235
+ def run_search_dosearch(page, base_url: str, prefix: str, version: str, keyword: str) -> list[tuple[str, str, int]]:
236
+ url = f"{base_url.rstrip('/')}/dosearchsite.action?queryString={quote(keyword)}"
237
+ print(f" → 全站搜索: {url}")
238
+ page.goto(url, wait_until="networkidle", timeout=60000)
239
+ page.wait_for_timeout(2500)
240
+ if is_login_page(page):
241
+ return []
242
+ return collect_search_candidates(page, base_url, prefix, version, keyword)
243
+
244
+
245
+ def run_search_quick(page, base_url: str, prefix: str, version: str, keyword: str) -> list[tuple[str, str, int]]:
165
246
  search_selector = None
166
247
  for selector in [
167
248
  "#quick-search-query",
@@ -176,14 +257,92 @@ def run_search(page, base_url: str, keyword: str) -> str | None:
176
257
  search_selector = selector
177
258
  break
178
259
  if not search_selector:
179
- if page.locator("input[type='text']").count() == 0:
180
- return None
181
- search_selector = "input[type='text']"
260
+ return []
261
+ print(f" → 顶栏搜索框: {search_selector} 关键词: {keyword}")
182
262
  page.fill(search_selector, keyword, timeout=8000)
183
263
  page.keyboard.press("Enter")
184
264
  page.wait_for_load_state("networkidle", timeout=30000)
185
- page.wait_for_timeout(2000)
186
- return find_search_result(page, base_url, keyword)
265
+ page.wait_for_timeout(2500)
266
+ return collect_search_candidates(page, base_url, prefix, version, keyword)
267
+
268
+
269
+ def resolve_prd_target(
270
+ page, base_url: str, prefix: str, version: str, keywords: list[str]
271
+ ) -> tuple[str | None, str | None, str]:
272
+ """依次用每个关键词搜索,直到找到父级为 PRD 的页面。"""
273
+ seen_urls: set[str] = set()
274
+ fallback: tuple[str, str, str] | None = None # url, title, keyword
275
+
276
+ for kw in keywords:
277
+ print(f"\n🔍 搜索关键词: {kw}")
278
+ batches = [
279
+ ("dosearchsite", run_search_dosearch(page, base_url, prefix, version, kw)),
280
+ ("quick-search", run_search_quick(page, base_url, prefix, version, kw)),
281
+ ]
282
+ for method_name, candidates in batches:
283
+ if not candidates:
284
+ print(f" ({method_name}) 无结果")
285
+ continue
286
+ print(f" ({method_name}) {len(candidates)} 条候选,检查 PRD 父级...")
287
+ for url, title, _score in candidates:
288
+ if url in seen_urls:
289
+ continue
290
+ seen_urls.add(url)
291
+ print(f" · 候选: {title[:70]}")
292
+ if is_prd_page(page, url):
293
+ return url, kw, title
294
+ if fallback is None:
295
+ fallback = (url, kw, title)
296
+
297
+ if fallback:
298
+ url, kw, title = fallback
299
+ print(f" ⚠ 未找到 PRD 父级,回退使用: {title[:70]}")
300
+ return url, kw, title
301
+ return None, None, ""
302
+
303
+
304
+ def is_login_page(page) -> bool:
305
+ url = page.url.lower()
306
+ if "login.action" in url or "/login" in url:
307
+ return True
308
+ if page.locator("#login-form, form#login, #username-field").count() > 0:
309
+ if page.locator("#login-button, #login").count() > 0:
310
+ return True
311
+ return False
312
+
313
+
314
+ def ensure_logged_in(page, base_url: str, username: str, password: str) -> None:
315
+ login_url = f"{base_url.rstrip('/')}/login.action"
316
+ page.goto(login_url, wait_until="networkidle", timeout=60000)
317
+ if not is_login_page(page):
318
+ print("✅ 已处于登录状态")
319
+ return
320
+
321
+ if not username or not password:
322
+ raise RuntimeError("需要登录但未提供 CONFLUENCE_USERNAME / CONFLUENCE_PASSWORD")
323
+
324
+ print("🔐 正在登录 Confluence...")
325
+ page.wait_for_selector("#username-field, input[name='os_username']", timeout=15000)
326
+ user_sel = "#username-field" if page.locator("#username-field").count() else "input[name='os_username']"
327
+ pass_sel = "#password-field" if page.locator("#password-field").count() else "input[name='os_password']"
328
+ btn_sel = "#login-button" if page.locator("#login-button").count() else "#login"
329
+ page.fill(user_sel, username, timeout=8000)
330
+ page.fill(pass_sel, password, timeout=8000)
331
+ page.click(btn_sel)
332
+ page.wait_for_load_state("networkidle", timeout=45000)
333
+ page.wait_for_timeout(1500)
334
+
335
+ if is_login_page(page):
336
+ err = ""
337
+ for sel in [".aui-message.error", ".aui-message.aui-message-error", "#login-error"]:
338
+ loc = page.locator(sel)
339
+ if loc.count():
340
+ err = (loc.first.inner_text() or "").strip()
341
+ break
342
+ raise RuntimeError(
343
+ f"Confluence 登录失败(仍在登录页)。请检查账号密码或 .env 中的 CONFLUENCE_* 配置。{err}"
344
+ )
345
+ print("✅ 登录成功")
187
346
 
188
347
 
189
348
  def main():
@@ -192,10 +351,15 @@ def main():
192
351
  print(" 用法: python3 confluence-doc.py <版本号>")
193
352
  sys.exit(1)
194
353
 
195
- # 自动尝试从项目常见 env 文件加载配置,降低手工配置成本
196
- repo_root = Path(__file__).resolve().parents[2]
197
- for env_name in [".env.pre", ".env", ".env.local", ".env.development", ".env.test"]:
198
- load_env_file(repo_root / env_name)
354
+ # 从脚本目录向上查找 .env(兼容 adInsight-web/frontend 子包)
355
+ script_dir = Path(__file__).resolve().parent
356
+ cur = script_dir
357
+ for _ in range(8):
358
+ for env_name in [".env.pre", ".env", ".env.local", ".env.development", ".env.test"]:
359
+ load_env_file(cur / env_name)
360
+ if cur.parent == cur:
361
+ break
362
+ cur = cur.parent
199
363
 
200
364
  base_url = env("CONFLUENCE_BASE_URL", "https://confluence.huan.tv") or "https://confluence.huan.tv"
201
365
  search_url = env("CONFLUENCE_SEARCH_URL") or f"{base_url.rstrip('/')}/#all-updates"
@@ -229,8 +393,21 @@ def main():
229
393
  print(f"📁 输出目录: {docs_root}")
230
394
  print(f"🖼️ 图片目录: {version_dir}")
231
395
 
396
+ headed = env_bool("CONFLUENCE_HEADED") or env("CONFLUENCE_HEADLESS", "1").lower() in (
397
+ "0",
398
+ "false",
399
+ "no",
400
+ )
401
+ headless = not headed
402
+ slow_mo = int(env("CONFLUENCE_SLOW_MO", "400" if headed else "0") or "0")
403
+
404
+ print(f"🖥️ 浏览器: headless={headless}, slow_mo={slow_mo}ms(有头模式: CONFLUENCE_HEADED=1)")
405
+
232
406
  with sync_playwright() as p:
233
- browser = p.chromium.launch(headless=True)
407
+ launch_opts: dict = {"headless": headless}
408
+ if slow_mo > 0:
409
+ launch_opts["slow_mo"] = slow_mo
410
+ browser = p.chromium.launch(**launch_opts)
234
411
  context = browser.new_context(
235
412
  user_agent=(
236
413
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
@@ -239,44 +416,44 @@ def main():
239
416
  )
240
417
  page = context.new_page()
241
418
  try:
242
- print("\n📄 访问搜索页面...")
243
- response = page.goto(search_url, wait_until="networkidle", timeout=60000)
244
- if response:
245
- print(f" 状态码: {response.status}")
246
- if "login" in page.url.lower():
247
- if not username or not password:
248
- raise RuntimeError("检测到登录页但未提供 CONFLUENCE_USERNAME / CONFLUENCE_PASSWORD")
249
- print("🔐 检测到需要登录,开始自动登录...")
250
- page.wait_for_selector("#username-field", timeout=10000)
251
- page.fill("#username-field", username)
252
- page.fill("#password-field", password)
253
- page.click("#login-button")
254
- page.wait_for_load_state("networkidle", timeout=30000)
255
- print("✅ 登录完成")
256
-
257
- target_url = None
258
- matched_keyword = None
259
- for kw in keywords:
260
- print(f"\n🔍 搜索关键词: {kw}")
261
- found = run_search(page, base_url, kw)
262
- if found:
263
- target_url = found
264
- matched_keyword = kw
265
- break
266
- print(f" 未找到 {kw},尝试下一个关键词...")
419
+ print("\n📄 打开 Confluence 并登录...")
420
+ ensure_logged_in(page, base_url, username, password)
421
+ print(f"📄 当前页: {page.url[:120]}")
422
+
423
+ direct_url = env("CONFLUENCE_PAGE_URL")
424
+ target_url = direct_url if direct_url else None
425
+ matched_keyword = "CONFLUENCE_PAGE_URL" if direct_url else None
426
+
427
+ matched_title = ""
428
+ if not target_url:
429
+ target_url, matched_keyword, matched_title = resolve_prd_target(
430
+ page, base_url, prefix, version, keywords
431
+ )
267
432
 
268
433
  if not target_url or not matched_keyword:
269
- print(f"❌ 未找到 {prefix} {version} 的需求文档,请检查 Confluence 页面命名。")
434
+ debug_path = docs_root / "confluence-search-debug.png"
435
+ try:
436
+ page.screenshot(path=str(debug_path), full_page=True)
437
+ print(f" 已保存调试截图: {debug_path}")
438
+ except Exception:
439
+ pass
440
+ print(f"❌ 未找到 {prefix} {version} 的需求文档。")
441
+ print(
442
+ " 排查:1) 是否存在 PRD 父级页面 2) 依次尝试 ADI_V2.3.2 / ADI-V2.3.2 / ADI_2.3.2 / ADI-2.3.2"
443
+ )
444
+ print(" 3) 或设置 CONFLUENCE_PAGE_URL=<页面完整URL> 直接下载")
270
445
  sys.exit(1)
271
446
 
272
- print(f"✅ 命中: {matched_keyword}")
273
- print(f" 进入页面: {target_url[:120]}...")
447
+ print(f"✅ 命中关键词: {matched_keyword}")
448
+ if matched_title:
449
+ print(f" 页面: {matched_title[:100]}")
450
+ print(f" URL: {target_url[:120]}...")
274
451
  page.goto(target_url, wait_until="networkidle", timeout=60000)
275
452
 
276
453
  try:
277
454
  title = page.locator("#title-text").text_content().strip()
278
455
  except Exception:
279
- title = f"{matched_keyword} 需求文档"
456
+ title = matched_title or f"{matched_keyword} 需求文档"
280
457
 
281
458
  content_elem = page.locator("#main-content")
282
459
  if not content_elem.count():
@@ -331,6 +508,11 @@ def main():
331
508
  print(f"\n❌ 获取失败: {e}")
332
509
  raise
333
510
  finally:
511
+ if env_bool("CONFLUENCE_DEBUG_PAUSE", default=not headless):
512
+ try:
513
+ input("\n⏸ 调试暂停:按 Enter 关闭浏览器...")
514
+ except (EOFError, KeyboardInterrupt):
515
+ pass
334
516
  browser.close()
335
517
 
336
518
 
@@ -27,6 +27,12 @@ python3 confluence-doc.py <版本号>
27
27
 
28
28
  说明:脚本内置默认账号密码(`lixinxin` / `Xin@147258`),可直接执行;如需覆盖,再设置 `CONFLUENCE_USERNAME` / `CONFLUENCE_PASSWORD`。
29
29
 
30
+ 有头调试(可观察登录与搜索过程):
31
+
32
+ ```bash
33
+ CONFLUENCE_HEADED=1 CONFLUENCE_SLOW_MO=500 CONFLUENCE_DEBUG_PAUSE=1 python3 confluence-doc.py ADI_V2.3.2
34
+ ```
35
+
30
36
  版本号支持多种写法:
31
37
 
32
38
  ```bash
@@ -36,11 +42,14 @@ python3 confluence-doc.py {{PRODUCT_PREFIX}}-V1.2.3
36
42
  python3 confluence-doc.py {{PRODUCT_PREFIX}}_V1.2.3
37
43
  ```
38
44
 
39
- 脚本搜索顺序(强制带 `V`):
45
+ 脚本搜索顺序(**固定**,即使用户说「{{PRODUCT_PREFIX}} 1.2.3」也按此顺序):
46
+
47
+ 1. `{{PRODUCT_PREFIX}}_V<版本>`(如 ADI_V2.3.2)
48
+ 2. `{{PRODUCT_PREFIX}}-V<版本>`(如 ADI-V2.3.2)
49
+ 3. `{{PRODUCT_PREFIX}}_<版本>`(如 ADI_2.3.2)
50
+ 4. `{{PRODUCT_PREFIX}}-<版本>`(如 ADI-2.3.2)
40
51
 
41
- 1. `{{PRODUCT_PREFIX}}-V<版本>`
42
- 2. `{{PRODUCT_PREFIX}}_V<版本>`
43
- 3. `V<版本>`(兜底)
52
+ 每个关键词都会走全站搜索 + 顶栏搜索;在候选结果中**优先选面包屑父级含 PRD 的页面**。
44
53
 
45
54
  ## 输出结构
46
55