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 +6 -3
- package/dist/core/inferProject.js +152 -0
- package/dist/postinstall.js +42 -39
- package/dist/steps/startFlow.js +11 -2
- package/package.json +1 -1
- package/src/templates/artifacts/01-adi-doc-skill-/346/214/207/345/274/225.template.md +24 -10
- package/src/templates/artifacts/06-agent-skill.template.md +4 -1
- package/src/templates/skills/confluence-doc.py.template +248 -66
- package/src/templates/skills/doc-skill.SKILL.md.template +13 -4
package/README.md
CHANGED
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
1. 进入你要开发的项目目录
|
|
17
17
|
2. 安装:`pnpm add -D sdd-flow-kit `
|
|
18
|
-
|
|
19
|
-
- `ls .
|
|
20
|
-
- `ls docs
|
|
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
|
+
}
|
package/dist/postinstall.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/steps/startFlow.js
CHANGED
|
@@ -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:
|
|
26
|
-
const
|
|
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,15 +1,29 @@
|
|
|
1
|
-
# 01-获取需求文档(
|
|
1
|
+
# 01-获取需求文档({{PRODUCT}} / {{DOC_SKILL_REL}})
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
120
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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(
|
|
186
|
-
return
|
|
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
|
-
#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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"✅
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
2. `{{PRODUCT_PREFIX}}_V<版本>`
|
|
43
|
-
3. `V<版本>`(兜底)
|
|
52
|
+
每个关键词都会走全站搜索 + 顶栏搜索;在候选结果中**优先选面包屑父级含 PRD 的页面**。
|
|
44
53
|
|
|
45
54
|
## 输出结构
|
|
46
55
|
|