sdd-flow-kit 1.0.16 → 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.
@@ -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