"""M1 爬虫模块测试""" from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from crawler.crawler import ( ARTICLE_DELAY_SEC, MAX_MEMORY_MB, SOURCE_TIMEOUT_SEC, compute_url_hash, crawl_source, ) from crawler.loader import get_source_by_id, load_sources from crawler.models import ArticleItem, CrawlResult, SourceConfig from crawler.storage import load_index, write_index_jsonl # ════════════════════════════════════════════════ # URL Hash # ════════════════════════════════════════════════ def test_compute_url_hash_consistency(): """同一 URL 多次计算 hash 一致""" h1 = compute_url_hash("https://example.com/article/123") h2 = compute_url_hash("https://example.com/article/123") assert h1 == h2 assert len(h1) == 16 def test_compute_url_hash_different(): """不同 URL 产生不同 hash""" h1 = compute_url_hash("https://example.com/a") h2 = compute_url_hash("https://example.com/b") assert h1 != h2 # ════════════════════════════════════════════════ # 配置加载 # ════════════════════════════════════════════════ def test_load_sources_from_real_config(): """从项目真实配置文件加载""" sources, settings = load_sources() assert len(sources) == 12 assert settings["concurrency"] == 5 reuters = sources[0] assert reuters.id == "reuters" assert reuters.name == "Reuters" assert reuters.enabled is True def test_get_source_by_id(): """按 ID 查找源""" sources, _ = load_sources() s = get_source_by_id("cnbc", sources) assert s is not None assert s.name == "CNBC" s = get_source_by_id("nonexistent", sources) assert s is None def test_load_sources_missing_file(): """配置文件不存在时抛出异常""" with pytest.raises(FileNotFoundError): load_sources(Path("/nonexistent/path.yaml")) def test_source_config_output_dir(): """SourceConfig.output_dir 属性""" s = SourceConfig( id="reuters", name="Reuters", homepage="https://example.com", article_url_pattern="/article/", ) today = __import__("datetime").datetime.now().strftime("%Y%m%d") assert str(s.output_dir) == f"data/raw/reuters/{today}" # ════════════════════════════════════════════════ # 存储 # ════════════════════════════════════════════════ def test_write_and_load_index_jsonl(tmp_path: Path, monkeypatch): """写入 index.jsonl 后再读取,数据一致""" # 临时替换 data/raw 路径 import crawler.storage as storage_mod articles = [ ArticleItem( source_id="test_source", source_name="Test Source", url=f"https://example.com/article/{i}", url_hash=f"hash{i:04d}", title=f"Test Article {i}", crawl_time="2026-06-21T00:00:00", html_path=f"data/raw/test_source/20260621/hash{i:04d}.html", status="success", ) for i in range(3) ] result = CrawlResult( source_id="test_source", source_name="Test Source", total_found=3, total_success=3, articles=articles, ) # Patch Path to use tmp_path orig_path = Path def mock_path(p: str) -> Path: p_str = str(p) if p_str.startswith("data/raw/"): return orig_path(tmp_path) / p_str return orig_path(p_str) monkeypatch.setattr(storage_mod, "Path", mock_path) index_path = write_index_jsonl(result) assert index_path.exists() # 读取 loaded = load_index("test_source", "20260621") assert len(loaded) == 3 assert loaded[0].source_id == "test_source" assert loaded[0].title == "Test Article 0" def test_load_index_missing_file(): """不存在的 index 返回空列表""" articles = load_index("nonexistent", "20990101") assert articles == [] def test_write_index_jsonl_dedup(tmp_path: Path, monkeypatch): """重复 url_hash 不重复写入""" import crawler.storage as storage_mod article = ArticleItem( source_id="dedup_test", source_name="Dedup Test", url="https://example.com/same", url_hash="same_hash_0001", title="Same Article", crawl_time="2026-06-21T00:00:00", html_path="data/raw/dedup_test/20260621/same_hash_0001.html", status="success", ) result1 = CrawlResult(source_id="dedup_test", source_name="Dedup Test", total_success=1, articles=[article]) result2 = CrawlResult(source_id="dedup_test", source_name="Dedup Test", total_success=1, articles=[article]) def mock_path(p: str) -> Path: p_str = str(p) if p_str.startswith("data/raw/"): return Path(tmp_path) / p_str return Path(p_str) monkeypatch.setattr(storage_mod, "Path", mock_path) write_index_jsonl(result1) write_index_jsonl(result2) # 重复写入 loaded = load_index("dedup_test", "20260621") assert len(loaded) == 1 # 去重 # ════════════════════════════════════════════════ # 爬虫引擎 (Mock) # ════════════════════════════════════════════════ @pytest.mark.asyncio async def test_crawl_source_with_mock(): """Mock Crawl4AI,测试 crawl_source 流程""" source = SourceConfig( id="mock_source", name="Mock Source", homepage="https://mock.example.com/", article_url_pattern="/news/", js_render=False, max_articles_per_run=5, ) # Mock Crawl4AI 的返回 mock_html = 'Article 1Article 2' mock_result = MagicMock() mock_result.success = True mock_result.html = mock_html mock_result.markdown = "# Test Article\n\nContent here." mock_result.metadata = {"title": "Test Article"} mock_result.error_message = "" with patch("crawler.crawler.AsyncWebCrawler") as mock_crawler_cls: mock_crawler = MagicMock() mock_crawler.arun = AsyncMock(return_value=mock_result) mock_crawler_cls.return_value.__aenter__ = AsyncMock(return_value=mock_crawler) mock_crawler_cls.return_value.__aexit__ = AsyncMock(return_value=False) result = await crawl_source(source) assert result.source_id == "mock_source" assert result.total_found == 2 assert result.total_success == 2 assert result.total_failed == 0 assert len(result.articles) == 2 # 每条 article 都有正确的 source_id for article in result.articles: assert article.source_id == "mock_source" assert article.status == "success" assert article.html_path @pytest.mark.asyncio async def test_crawl_source_homepage_failure(): """首页抓取失败时优雅降级""" source = SourceConfig( id="fail_source", name="Fail Source", homepage="https://fail.example.com/", article_url_pattern="/news/", js_render=False, ) mock_result = MagicMock() mock_result.success = False mock_result.html = "" mock_result.error_message = "Connection timeout" with patch("crawler.crawler.AsyncWebCrawler") as mock_crawler_cls: mock_crawler = MagicMock() mock_crawler.arun = AsyncMock(return_value=mock_result) mock_crawler_cls.return_value.__aenter__ = AsyncMock(return_value=mock_crawler) mock_crawler_cls.return_value.__aexit__ = AsyncMock(return_value=False) result = await crawl_source(source) assert result.total_found == 0 assert result.total_success == 0 # ════════════════════════════════════════════════ # 资源限制常量 # ════════════════════════════════════════════════ def test_max_memory_mb(): """内存限制 < 2000 MB""" assert 0 < MAX_MEMORY_MB < 2000 def test_source_timeout_sec(): """单源超时在合理范围""" assert SOURCE_TIMEOUT_SEC >= 3600 # 至少 1 小时 def test_article_delay_sec(): """文章间隔 ≥ 1 秒""" assert ARTICLE_DELAY_SEC >= 1.0