#!/usr/bin/env python3 """站点提取策略化重构(v2)单元测试。 核心断言:策略链(轨B)与旧逻辑(轨A)对同一 HTML 的输出完全一致。 运行:cd backend && python3 test_crawl_strategies.py """ import sys import os sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from routers.crawl import CrawlImage, extract_images from routers.crawl_strategies import ( JuejinExtractor, CsdnExtractor, GenericExtractor, SITE_EXTRACTORS, get_extractor_for_url, is_strategy_first, is_strategy_success, run_extractor, ExtractorResult, STRATEGY_FIRST_DOMAINS, FALLBACK_TITLE, ) from routers.universal_crawl import ( _extract_juejin_title, _extract_juejin_content, _extract_csdn_title, _extract_csdn_content, _filter_csdn_images, _legacy_extract, ) PASS = 0 FAIL = 0 def check(name, cond): global PASS, FAIL if cond: PASS += 1 print(f" PASS {name}") else: FAIL += 1 print(f" FAIL {name}") # 掘金 HTML:article.article 为正文,sidebar 为噪音 JUEGIN_HTML = f"""深入理解 React 并发特性 - 掘金

深入理解 React 并发特性

{'这是掘金正文内容,用于验证策略链与旧逻辑提取一致性。' * 12}

""" # CSDN HTML:content_views 为正文,含 UI 图标噪音图(avatar/copyright 全小写 pattern 能命中; # newHeart2023 等混合大小写 pattern 因 img_url.lower() 永远匹配不上,属既有 bug,保持原样) CSDN_HTML = f"""Python 异步编程指南 - CSDN博客

{'这是 CSDN 正文内容,用于验证策略链与旧逻辑提取一致性。' * 12}

推荐文章列表不应被提取
""" # 未知站点 HTML:走 GenericExtractor GENERIC_HTML = f"""某某技术博客

{'普通网站正文内容,用于验证通用兜底策略。' * 12}

""" URL_JUEGIN = "https://juejin.cn/post/7420000000000000000" URL_CSDN = "https://blog.csdn.net/xxx/article/details/140000000" URL_GENERIC = "https://example.com/post/123" def test_registry(): print("[1] 注册表匹配") check("juejin -> JuejinExtractor", isinstance(get_extractor_for_url(URL_JUEGIN), JuejinExtractor)) check("csdn -> CsdnExtractor", isinstance(get_extractor_for_url(URL_CSDN), CsdnExtractor)) check("unknown -> GenericExtractor", isinstance(get_extractor_for_url(URL_GENERIC), GenericExtractor)) check("GenericExtractor 恒兜底", SITE_EXTRACTORS[-1].name == "generic") check("策略链默认关闭(白名单为空)", is_strategy_first(URL_JUEGIN) is False) check("白名单为空集", STRATEGY_FIRST_DOMAINS == set()) def test_juejin_consistency(): print("[2] 掘金策略与旧逻辑一致性") ext = JuejinExtractor() title_new = ext.extract_title(JUEGIN_HTML, URL_JUEGIN) title_old = _extract_juejin_title(JUEGIN_HTML) check("标题一致且去掉「- 掘金」后缀", title_new == title_old == "深入理解 React 并发特性") content_new = ext.extract_content_html(JUEGIN_HTML, URL_JUEGIN) content_old = _extract_juejin_content(JUEGIN_HTML) check("正文容器一致", content_new == content_old) check("正文容器不含侧栏", "侧栏内容" not in content_new and "sidebar" not in content_new) check("needs_browser=True", ext.needs_browser(URL_JUEGIN) is True) def test_csdn_consistency(): print("[3] CSDN 策略与旧逻辑一致性") ext = CsdnExtractor() title_new = ext.extract_title(CSDN_HTML, URL_CSDN) title_old = _extract_csdn_title(CSDN_HTML) check("标题一致且去掉「- CSDN博客」后缀", title_new == title_old == "Python 异步编程指南") content_new = ext.extract_content_html(CSDN_HTML, URL_CSDN) content_old = _extract_csdn_content(CSDN_HTML) check("正文容器一致", content_new == content_old) check("正文容器不含推荐列表", "推荐文章列表" not in content_new) images = extract_images(CSDN_HTML, URL_CSDN) check("图片提取到 3 张", len(images) == 3) filtered_new = ext.filter_images(images, URL_CSDN) filtered_old = _filter_csdn_images(images) check("图片过滤结果一致", [i.original_url for i in filtered_new] == [i.original_url for i in filtered_old]) check("UI 图标被过滤(avatar/copyright)", len(filtered_new) == 1 and filtered_new[0].original_url.endswith("real-article.png")) # 既有 bug 行为记录:newHeart2023 混合大小写 pattern 因 img_url.lower() 永不匹配,保持原样不修 mixed_case = extract_images('', URL_CSDN) check("历史行为:newHeart2023 不被过滤(既有 bug,保持原样)", len(ext.filter_images(mixed_case, URL_CSDN)) == 1) def test_run_extractor(): print("[4] 策略链集成(run_extractor)") images = extract_images(JUEGIN_HTML, URL_JUEGIN) result = run_extractor(JuejinExtractor(), URL_JUEGIN, JUEGIN_HTML, images) check("标题正确", result.title == "深入理解 React 并发特性") check("Markdown 含正文", "掘金正文内容" in result.markdown) check("Markdown 不含侧栏", "侧栏内容" not in result.markdown) check("图片 1 张", len(result.images) == 1) # 标题降级:专用提取失败时回退通用 degraded_html = "<html><head><title>通用标题兜底" + JUEGIN_HTML.split("")[1] result2 = run_extractor(JuejinExtractor(), URL_JUEGIN, degraded_html, images) check("标题降级到通用提取", result2.title == "通用标题兜底") def test_quality_gate(): print("[5] 质量门控 is_strategy_success") ok = ExtractorResult(title="好标题", markdown="x" * 500, images=[]) check("正常结果通过", is_strategy_success(ok) is True) check("空标题失败", is_strategy_success(ExtractorResult(title="", markdown="x" * 500, images=[])) is False) check("兜底标题失败", is_strategy_success(ExtractorResult(title=FALLBACK_TITLE, markdown="x" * 500, images=[])) is False) check("内容过短失败", is_strategy_success(ExtractorResult(title="好标题", markdown="x" * 100, images=[])) is False) def test_legacy_vs_strategy_equivalence(): print("[6] 新旧轨全链路等价(title/markdown/images)") for url, html, extractor in [ (URL_JUEGIN, JUEGIN_HTML, JuejinExtractor()), (URL_CSDN, CSDN_HTML, CsdnExtractor()), (URL_GENERIC, GENERIC_HTML, GenericExtractor()), ]: old_title, old_md, old_images = _legacy_extract(url, html) new = run_extractor(extractor, url, html, extract_images(html, url)) check(f"{extractor.name}: 标题等价", old_title == new.title) check(f"{extractor.name}: Markdown 等价", old_md == new.markdown) check(f"{extractor.name}: 图片等价", [i.original_url for i in old_images] == [i.original_url for i in new.images]) if __name__ == "__main__": test_registry() test_juejin_consistency() test_csdn_consistency() test_run_extractor() test_quality_gate() test_legacy_vs_strategy_equivalence() print(f"\n结果: {PASS} 通过, {FAIL} 失败") sys.exit(1 if FAIL else 0)