feat(data): T2 corpus_download.py daily lane+断点+锁+36测全绿 [nas]
spec §18.7 T2(corpus 栈唯一新脚本,单脚本红线): - daily 四段: 公告增量(巨潮 T-1~T 两天窗自愈)/新闻增量(search-api 带摘要, (art_code,stock_code) 复合键=同稿多股 meta 多行)/新闻全文(昨日新 art_code, art_code 全局一条)/当日 PDF(五类服务端码 category 码+业绩快报标题谓词—— 实测 yjkb 码不存在,无效码被服务端静默忽略返回全量,故走客户端标题过滤) - 断点: unit marker tmp+rename;--until 墙钟=完成当前 unit 后 rc=3;--limit 冒烟 绝不落 marker(5m 09-17 教训) - 单实例锁: fcntl.flock(state/corpus.lock)——跨容器语义(bind mount 同 inode, 内核持有/进程死自动释放);5m 的 pid 活性检测在 docker run --rm 各自独立 PID namespace 下失效,corpus 换 flock(让路契约同款,锁忙 rc=3) - id 去重双层: IdLedger 全局账本(state/ids_*.parquet int64 hash,满足 §18.5 announcement_id 全局无重)+RecentIndex 近30日分区索引(兜账本flush周期崩溃窗) - 失败语义: 传输错 unit 不标done;真空=合法done;4xx确定性跳过不烧重试; 域名连续错×10或429冷却它域继续;rc 0完成/1致命/2限流/3墙钟或锁 - 股票池: 巨潮 szse_stock.json(实测含退市000003+北交920001,6247条)A前缀过滤, 非NAS副本库——orgId 为按股查询硬前提且退市覆盖更全(spec 偏差T6回写) - 测试 36 个全 mock 零网络零写死日期;全套 253 绿
This commit is contained in:
@@ -0,0 +1,534 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""TDD for corpus_download.py — NAS 基本面语料库(spec §18.7 T2)。
|
||||
|
||||
全 mock 零网络;日期全部相对 today(禁写死);CORPUS_ROOT 重定向 tmp_path。
|
||||
契约来源: docs/fundamental_corpus_probes_20260906.md 实测 + spec §18。
|
||||
"""
|
||||
import datetime as dt
|
||||
import fcntl
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.data_platform import corpus_download as cd
|
||||
|
||||
|
||||
# ---------- Fixtures ----------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast(monkeypatch):
|
||||
"""限速归零 + 抖动归零: 测试不 sleep。"""
|
||||
monkeypatch.setattr(cd, "RATE", {k: 0.0 for k in cd.RATE})
|
||||
monkeypatch.setattr(cd, "JITTER", (0.0, 0.0))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cd, "CORPUS_ROOT", tmp_path)
|
||||
for sub in ("news_meta", "news_fulltext", "ann_meta", "ann_pdf", "state", "logs"):
|
||||
(tmp_path / sub).mkdir(parents=True, exist_ok=True)
|
||||
return tmp_path
|
||||
|
||||
|
||||
POOL = [("000001", "gssz0000001"), ("600519", "gssh600519")]
|
||||
|
||||
|
||||
def _ann_raw(aid="1225275126", code="000001", title="关于xxx的公告",
|
||||
ts_ms=1777563098000, **over):
|
||||
row = {
|
||||
"id": None, "secCode": code, "secName": "平安银行", "orgId": "gssz0000001",
|
||||
"announcementId": aid, "announcementTitle": title,
|
||||
"announcementTime": ts_ms, "adjunctUrl": f"finalpage/2026-04-30/{aid}.PDF",
|
||||
"adjunctSize": 244, "adjunctType": "PDF", "storageTime": None,
|
||||
"columnId": "09020202||250101||251302", "pageColumn": "SZZB",
|
||||
"announcementType": "01010503||010112||010301", "associateAnnouncement": None,
|
||||
"important": None, "batchNum": None, "announcementContent": "",
|
||||
"orgName": None, "tileSecName": "平安银行", "shortTitle": title,
|
||||
"announcementTypeName": None, "secNameList": None,
|
||||
}
|
||||
row.update(over)
|
||||
return row
|
||||
|
||||
|
||||
def _resp(json_data=None, status=200, text=""):
|
||||
r = MagicMock()
|
||||
r.status_code = status
|
||||
r.json.return_value = json_data if json_data is not None else {}
|
||||
r.text = text
|
||||
r.content = b""
|
||||
return r
|
||||
|
||||
|
||||
def _cninfo_page(rows, total=None):
|
||||
total = len(rows) if total is None else total
|
||||
return {"announcements": rows, "totalAnnouncement": total, "hasMore": False}
|
||||
|
||||
|
||||
# ---------- 股票池过滤 ----------
|
||||
|
||||
def test_pool_prefix_filter():
|
||||
"""A 股前缀白名单: 00/30/60/68/43/83/87/92 过,B股(200/900)/基金(15/51/56)拒。"""
|
||||
raw = [("000001", "o1"), ("300750", "o2"), ("600519", "o3"), ("688981", "o4"),
|
||||
("920001", "o5"), ("430047", "o6"), ("830799", "o7"), ("871981", "o8"),
|
||||
("200002", "x1"), ("900901", "x2"), ("159915", "x3"), ("510300", "x4"),
|
||||
("561880", "x5"), ("110038", "x6"), ("113050", "x7"), ("161725", "x8")]
|
||||
got = cd.filter_stock_pool(raw)
|
||||
codes = [c for c, _ in got]
|
||||
assert codes == ["000001", "300750", "600519", "688981", "920001",
|
||||
"430047", "830799", "871981"]
|
||||
|
||||
|
||||
# ---------- 归一化 ----------
|
||||
|
||||
def test_norm_ann_row_epoch_ms_to_naive_ts():
|
||||
row = cd.norm_ann_row(_ann_raw(ts_ms=1777563098000), first_seen="2026-09-07")
|
||||
# 1777563098s UTC+8 = 2026-04-30 (Asia/Shanghai, 无时区后缀)
|
||||
assert row["ann_time"].startswith("2026-04-30")
|
||||
assert row["announcement_id"] == "1225275126"
|
||||
assert row["sec_code"] == "000001"
|
||||
assert row["first_seen_date"] == "2026-09-07"
|
||||
assert row["ann_type"] == "01010503||010112||010301"
|
||||
assert row["ann_type_name"] is None
|
||||
|
||||
|
||||
def test_norm_ann_row_strips_em_and_keeps_nulls():
|
||||
row = cd.norm_ann_row(_ann_raw(title="2022年度<em>业绩快报</em>"),
|
||||
first_seen="2026-09-07")
|
||||
assert row["title"] == "2022年度业绩快报"
|
||||
assert row["important"] is None
|
||||
assert row["batch_num"] is None
|
||||
|
||||
|
||||
def test_norm_news_search_row():
|
||||
raw = {"code": "art123", "date": "2026-09-06 10:11:51",
|
||||
"title": "贵州茅台(<em>600519</em>.SH)发布", "content": "正文摘要 有 全角",
|
||||
"mediaName": "界面新闻", "image": ""}
|
||||
row = cd.norm_news_search_row(raw, stock_code="600519")
|
||||
assert row["art_code"] == "art123"
|
||||
assert row["stock_code"] == "600519"
|
||||
assert row["title"] == "贵州茅台(600519.SH)发布"
|
||||
assert "<em>" not in row["summary"] and " " not in row["summary"]
|
||||
assert row["media_name"] == "界面新闻"
|
||||
assert row["url"] == "http://finance.eastmoney.com/a/art123.html"
|
||||
assert row["show_time"] == "2026-09-06 10:11:51"
|
||||
|
||||
|
||||
def test_norm_news_np_row_summary_null():
|
||||
"""np-listapi 回补段: summary/media_name 物理不存在 → None 如实(spec §18.2)。"""
|
||||
raw = {"Art_Code": "art9", "Art_ShowTime": "2023-09-01 08:00:00",
|
||||
"Art_Title": "旧新闻", "Art_Url": "http://finance.eastmoney.com/a/art9.html",
|
||||
"Np_dst": "CMS", "Art_SortStart": 1}
|
||||
row = cd.norm_news_np_row(raw, stock_code="600519")
|
||||
assert row["summary"] is None
|
||||
assert row["media_name"] is None
|
||||
assert row["art_code"] == "art9"
|
||||
|
||||
|
||||
def test_html_to_text():
|
||||
html = ("<html><head><script>var x=1;</script><style>.a{}</style></head>"
|
||||
"<body><div> 第一段 </div><p>第二段&nbsp;</p></body></html>")
|
||||
text = cd.html_to_text(html)
|
||||
assert "var x" not in text and ".a{}" not in text
|
||||
assert "第一段" in text and "第二段" in text
|
||||
|
||||
|
||||
# ---------- IdLedger(全局 id 去重账本, int64 hash) ----------
|
||||
|
||||
def test_ledger_roundtrip_and_dup_detect(root):
|
||||
led = cd.IdLedger("ann_meta")
|
||||
led.add(["a1", "a2", "a3"])
|
||||
assert led.flush() == 3
|
||||
led2 = cd.IdLedger("ann_meta")
|
||||
assert led2.has_any(["a2", "zzz"]) == [True, False]
|
||||
|
||||
|
||||
def test_ledger_empty_init(root):
|
||||
led = cd.IdLedger("ann_meta")
|
||||
assert led.has_any(["anything"]) == [False]
|
||||
|
||||
|
||||
# ---------- RecentIndex(近 N 日分区 id 索引) ----------
|
||||
|
||||
def test_recent_index_reads_partitions(root):
|
||||
today = dt.date.today()
|
||||
d = root / "ann_meta" / f"dt={today.isoformat()}"
|
||||
d.mkdir(parents=True)
|
||||
import pandas as pd
|
||||
pd.DataFrame({"announcement_id": ["old1", "old2"]}).to_parquet(d / "part-0.parquet")
|
||||
idx = cd.RecentIndex("ann_meta", days=7)
|
||||
assert idx.has_any(["old1", "new1"]) == [True, False]
|
||||
|
||||
|
||||
# ---------- parquet 追加(append-only + 原子) ----------
|
||||
|
||||
def test_append_parquet_dedup_and_atomic(root):
|
||||
import pandas as pd
|
||||
today = dt.date.today().isoformat()
|
||||
rows1 = [{"announcement_id": "a1", "title": "x"}, {"announcement_id": "a2", "title": "y"}]
|
||||
cd.append_parquet("ann_meta", rows1, id_col="announcement_id")
|
||||
part = root / "ann_meta" / f"dt={today}" / "part-0.parquet"
|
||||
df = pd.read_parquet(part)
|
||||
assert len(df) == 2
|
||||
# 同日追加含重复 id → 只进新 id
|
||||
rows2 = [{"announcement_id": "a2", "title": "y2"}, {"announcement_id": "a3", "title": "z"}]
|
||||
cd.append_parquet("ann_meta", rows2, id_col="announcement_id")
|
||||
df = pd.read_parquet(part)
|
||||
assert sorted(df["announcement_id"]) == ["a1", "a2", "a3"]
|
||||
assert not list((root / "ann_meta" / f"dt={today}").glob("*.tmp")) # 无残留 tmp
|
||||
|
||||
|
||||
def test_append_parquet_crash_leaves_no_partial(root):
|
||||
"""写中途抛错 → 不留 tmp/半截文件(下次重拉幂等前提)。"""
|
||||
import pandas as pd
|
||||
with patch.object(pd.DataFrame, "to_parquet", side_effect=RuntimeError("disk")):
|
||||
with pytest.raises(RuntimeError):
|
||||
cd.append_parquet("ann_meta", [{"announcement_id": "a1"}],
|
||||
id_col="announcement_id")
|
||||
today = dt.date.today().isoformat()
|
||||
assert not list((root / "ann_meta" / f"dt={today}").glob("*"))
|
||||
|
||||
|
||||
# ---------- unit marker ----------
|
||||
|
||||
def test_marker_done_and_skip(root):
|
||||
assert not cd.is_done("daily", "ann", "000001")
|
||||
cd.mark_done("daily", "ann", "000001")
|
||||
assert cd.is_done("daily", "ann", "000001")
|
||||
assert not cd.is_done("daily", "ann", "600519")
|
||||
assert not cd.is_done("backfill", "ann", "000001") # lane 隔离
|
||||
|
||||
|
||||
# ---------- 单实例锁(flock, 跨容器语义) ----------
|
||||
|
||||
def test_lock_acquire_second_refused_release_ok(root):
|
||||
fd1 = cd.acquire_lock()
|
||||
assert fd1 is not None
|
||||
fd2 = cd.acquire_lock()
|
||||
assert fd2 is None # 让路
|
||||
import os
|
||||
os.close(fd1) # 模拟进程退出(内核释放 flock)
|
||||
assert cd.acquire_lock() is not None
|
||||
|
||||
|
||||
def test_lock_stale_never(tmp_path):
|
||||
"""flock 由内核持有,进程死即释放 — 无陈旧锁文件问题(优于 pid 检测)。"""
|
||||
lock = tmp_path / "corpus.lock"
|
||||
lock.write_text("999999") # 内容无所谓
|
||||
import os
|
||||
f = open(lock, "r+")
|
||||
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
f.close() # 关闭即释放
|
||||
f2 = open(lock, "r+")
|
||||
fcntl.flock(f2, fcntl.LOCK_EX | fcntl.LOCK_NB) # 不抛 = 无陈旧
|
||||
f2.close()
|
||||
|
||||
|
||||
# ---------- DomainClient(限速/冷却/429) ----------
|
||||
|
||||
def test_client_cooldown_after_10_consecutive_errors(root):
|
||||
c = cd.DomainClient("cninfo")
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(side_effect=cd.requests.ConnectionError("timeout"))
|
||||
c.session = sess
|
||||
with pytest.raises(cd.DomainCooldown):
|
||||
for _ in range(11):
|
||||
try:
|
||||
c.request("POST", "http://x")
|
||||
except cd.TransportError:
|
||||
pass # 未达阈值的传输错让单元层处理, 这里只数到冷却
|
||||
assert c.consecutive_errors == 10
|
||||
|
||||
|
||||
def test_client_429_marks_rate_limited(root):
|
||||
c = cd.DomainClient("cninfo")
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(return_value=_resp(status=429))
|
||||
c.session = sess
|
||||
with pytest.raises(cd.DomainCooldown):
|
||||
c.request("POST", "http://x")
|
||||
assert c.rate_limited is True
|
||||
|
||||
|
||||
def test_client_success_resets_error_counter(root):
|
||||
c = cd.DomainClient("cninfo")
|
||||
seq = [_resp(status=500)] * 9 + [_resp(json_data={"ok": 1})] * 3
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(side_effect=seq)
|
||||
c.session = sess
|
||||
for r in seq:
|
||||
try:
|
||||
c.request("POST", "http://x")
|
||||
except cd.TransportError:
|
||||
pass
|
||||
assert c.consecutive_errors == 0
|
||||
|
||||
|
||||
# ---------- fetch 层(分页/两天窗) ----------
|
||||
|
||||
def test_fetch_cninfo_paginates_till_short_page(root):
|
||||
c = cd.DomainClient("cninfo")
|
||||
pages = [_resp(json_data=_cninfo_page([_ann_raw(aid=f"a{i}") for i in range(30)],
|
||||
total=45)),
|
||||
_resp(json_data=_cninfo_page([_ann_raw(aid="a45")], total=45))]
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(side_effect=pages)
|
||||
c.session = sess
|
||||
rows = cd.fetch_cninfo_announcements(c, "000001", "gssz0000001",
|
||||
"2026-09-05", "2026-09-06")
|
||||
assert len(rows) == 31
|
||||
sent = sess.request.call_args_list[0][1]["data"]
|
||||
assert sent["seDate"] == "2026-09-05~2026-09-06" # 两天窗原样
|
||||
assert sent["stock"] == "000001,gssz0000001" # orgId 必带
|
||||
|
||||
|
||||
def test_fetch_cninfo_empty_is_legal(root):
|
||||
c = cd.DomainClient("cninfo")
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(return_value=_resp(json_data=_cninfo_page([])))
|
||||
c.session = sess
|
||||
rows = cd.fetch_cninfo_announcements(c, "000001", "o", "2026-09-05", "2026-09-06")
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_fetch_news_recent_parses_jsonp(root):
|
||||
c = cd.DomainClient("eastmoney")
|
||||
inner = [{"code": "art1", "date": "2026-09-06 01:02:03", "title": "t",
|
||||
"content": "c", "mediaName": "m", "image": ""}]
|
||||
text = "cb(" + json.dumps({"result": {"cmsArticleWebOld": inner}}) + ")"
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(return_value=_resp(text=text))
|
||||
c.session = sess
|
||||
rows = cd.fetch_news_recent(c, "600519")
|
||||
assert len(rows) == 1 and rows[0]["code"] == "art1"
|
||||
p = sess.request.call_args_list[0][1]["params"]
|
||||
assert json.loads(p["param"])["keyword"] == "600519"
|
||||
|
||||
|
||||
def test_fetch_news_backfill_page(root):
|
||||
c = cd.DomainClient("eastmoney")
|
||||
lst = [{"Art_Code": "a1", "Art_ShowTime": "2024-01-01 00:00:00",
|
||||
"Art_Title": "t", "Art_Url": "http://x/a1.html", "Np_dst": "CMS"}]
|
||||
sess = MagicMock()
|
||||
sess.request = MagicMock(return_value=_resp(
|
||||
json_data={"data": {"list": lst}}))
|
||||
c.session = sess
|
||||
rows = cd.fetch_news_backfill_page(c, "600519", mkt_prefix="1", page=3)
|
||||
assert rows[0]["Art_Code"] == "a1"
|
||||
url = sess.request.call_args_list[0][0][1]
|
||||
assert "mTypeAndCode=1.600519" in url and "pageIndex=3" in url
|
||||
|
||||
|
||||
# ---------- daily lane 集成(全 mock) ----------
|
||||
|
||||
def _wire_daily(monkeypatch, root, ann_pages=None, news_rows=None,
|
||||
article_html="<html><body>正文内容</body></html>"):
|
||||
ann_pages = ann_pages or [_cninfo_page([_ann_raw(aid="a1", code="000001")])]
|
||||
news_rows = news_rows if news_rows is not None else [
|
||||
{"code": "art1", "date": "2026-09-06 10:00:00", "title": "新闻一",
|
||||
"content": "摘要", "mediaName": "源", "image": ""}]
|
||||
monkeypatch.setattr(cd, "load_stock_pool", lambda **kw: POOL)
|
||||
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
|
||||
MagicMock(return_value=list(ann_pages[0]["announcements"])))
|
||||
monkeypatch.setattr(cd, "fetch_news_recent", MagicMock(return_value=news_rows))
|
||||
monkeypatch.setattr(cd, "fetch_article_html", MagicMock(return_value=article_html))
|
||||
monkeypatch.setattr(cd, "download_pdf", MagicMock(return_value=1024))
|
||||
|
||||
|
||||
def test_daily_happy_path_writes_all_domains(root, monkeypatch):
|
||||
import pandas as pd
|
||||
_wire_daily(monkeypatch, root)
|
||||
rc = cd.run_lane("daily")
|
||||
assert rc == 0
|
||||
today = dt.date.today().isoformat()
|
||||
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(ann) == 1 and ann.iloc[0]["announcement_id"] == "a1"
|
||||
news = pd.read_parquet(root / "news_meta" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(news) == 2 # 每股 1 条 × 2 股
|
||||
ft = pd.read_parquet(root / "news_fulltext" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(ft) == 1 and "正文内容" in ft.iloc[0]["content_text"]
|
||||
# markers: ann/news 每股 done;fulltext 每 art done
|
||||
assert cd.is_done("daily", "ann", "000001") and cd.is_done("daily", "ann", "600519")
|
||||
assert cd.is_done("daily", "news", "600519")
|
||||
assert cd.is_done("daily", "fulltext", "art1")
|
||||
|
||||
|
||||
def test_daily_rerun_idempotent_no_dup(root, monkeypatch):
|
||||
import pandas as pd
|
||||
_wire_daily(monkeypatch, root)
|
||||
assert cd.run_lane("daily") == 0
|
||||
# markers 全 done → 第二趟零 fetch;强制清 marker 重跑同数据 → 零重复行
|
||||
for code, _ in POOL:
|
||||
(root / "state" / "markers" / "daily" / "ann" / f"{code}.done").unlink()
|
||||
(root / "state" / "markers" / "daily" / "news" / f"{code}.done").unlink()
|
||||
assert cd.run_lane("daily") == 0
|
||||
today = dt.date.today().isoformat()
|
||||
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
|
||||
news = pd.read_parquet(root / "news_meta" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(ann) == 1 and len(news) == 2 # id 去重,零重写
|
||||
|
||||
|
||||
def test_daily_two_day_window_relative_dates(root, monkeypatch):
|
||||
"""seDate = (today-1)~today 相对构造,漏日自愈契约。"""
|
||||
_wire_daily(monkeypatch, root)
|
||||
cd.run_lane("daily")
|
||||
sedate = cd.fetch_cninfo_announcements.call_args[0][3:5]
|
||||
t = dt.date.today()
|
||||
assert sedate == ((t - dt.timedelta(days=1)).isoformat(), t.isoformat())
|
||||
|
||||
|
||||
def test_daily_two_day_window_after_missed_day(root, monkeypatch):
|
||||
"""停跑一天 → 次日单次执行补齐: fetch 窗含昨天,昨日公告照入。"""
|
||||
import pandas as pd
|
||||
_wire_daily(monkeypatch, root)
|
||||
cd.run_lane("daily")
|
||||
# 清 marker 模拟「另一天再来」;同 id 仍是昨天发的 → 已在账,不重
|
||||
for code, _ in POOL:
|
||||
(root / "state" / "markers" / "daily" / "ann" / f"{code}.done").unlink()
|
||||
cd.run_lane("daily")
|
||||
today = dt.date.today().isoformat()
|
||||
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(ann) == 1
|
||||
|
||||
|
||||
def test_daily_unit_fail_not_marked_and_rc1(root, monkeypatch):
|
||||
"""fetch 单股失败 → 该股不标 done,它股继续,整体 rc=1 留痕。"""
|
||||
import pandas as pd
|
||||
_wire_daily(monkeypatch, root)
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky(client, code, org, start, end, category=""):
|
||||
calls["n"] += 1
|
||||
if code == "000001":
|
||||
raise cd.TransportError("boom")
|
||||
return [_ann_raw(aid="a2", code=code)]
|
||||
|
||||
monkeypatch.setattr(cd, "fetch_cninfo_announcements", flaky)
|
||||
rc = cd.run_lane("daily")
|
||||
assert rc == 1
|
||||
assert not cd.is_done("daily", "ann", "000001")
|
||||
assert cd.is_done("daily", "ann", "600519")
|
||||
today = dt.date.today().isoformat()
|
||||
ann = pd.read_parquet(root / "ann_meta" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(ann) == 1 # 只有好股的
|
||||
|
||||
|
||||
def test_daily_domain_cooldown_skips_domain_but_others_run(root, monkeypatch):
|
||||
"""巨潮连续错×10 → 该域冷却,东财域照跑;rc=1。"""
|
||||
import pandas as pd
|
||||
monkeypatch.setattr(cd, "load_stock_pool", lambda **kw: POOL)
|
||||
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
|
||||
MagicMock(side_effect=cd.TransportError("down")))
|
||||
monkeypatch.setattr(cd, "fetch_news_recent", MagicMock(return_value=[
|
||||
{"code": "art1", "date": "2026-09-06 10:00:00", "title": "t",
|
||||
"content": "c", "mediaName": "m", "image": ""}]))
|
||||
monkeypatch.setattr(cd, "fetch_article_html",
|
||||
MagicMock(return_value="<body>x</body>"))
|
||||
monkeypatch.setattr(cd, "download_pdf", MagicMock(return_value=1))
|
||||
rc = cd.run_lane("daily")
|
||||
assert rc == 1
|
||||
today = dt.date.today().isoformat()
|
||||
news = pd.read_parquet(root / "news_meta" / f"dt={today}" / "part-0.parquet")
|
||||
assert len(news) == 2 # 东财域全量完成
|
||||
assert not cd.is_done("daily", "ann", "000001") # 巨潮域整体未标
|
||||
|
||||
|
||||
def test_daily_429_gives_rc2(root, monkeypatch):
|
||||
monkeypatch.setattr(cd, "load_stock_pool", lambda **kw: POOL)
|
||||
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
|
||||
MagicMock(return_value=[]))
|
||||
monkeypatch.setattr(cd, "fetch_news_recent",
|
||||
MagicMock(side_effect=cd.DomainCooldown("eastmoney",
|
||||
rate_limited=True)))
|
||||
monkeypatch.setattr(cd, "fetch_article_html", MagicMock(return_value="x"))
|
||||
monkeypatch.setattr(cd, "download_pdf", MagicMock(return_value=1))
|
||||
# 东财域 429 冷却 → rc=2 限流让路
|
||||
rc = cd.run_lane("daily")
|
||||
assert rc == 2
|
||||
|
||||
|
||||
def test_daily_limit_smoke_writes_but_no_markers(root, monkeypatch):
|
||||
"""--limit 冒烟: 真数据可写,绝不落 marker(5m 09-17 教训同款)。"""
|
||||
_wire_daily(monkeypatch, root)
|
||||
rc = cd.run_lane("daily", limit=1)
|
||||
assert rc == 0
|
||||
assert not any((root / "state" / "markers").rglob("*.done"))
|
||||
|
||||
|
||||
def test_daily_express_title_routes_to_pdf(root, monkeypatch):
|
||||
"""标题含「业绩快报」→ 进当日 PDF 待下清单(无服务端码,客户端谓词)。"""
|
||||
todo = cd.collect_pdf_candidates(
|
||||
[_ann_raw(aid="e1", title="2025年度业绩快报"),
|
||||
_ann_raw(aid="n1", title="2025年年度报告")])
|
||||
assert [t["announcementId"] for t in todo] == ["e1"]
|
||||
|
||||
|
||||
def test_daily_pdf_downloads_fivecat_and_express(root, monkeypatch):
|
||||
import pandas as pd
|
||||
_wire_daily(monkeypatch, root)
|
||||
|
||||
def fake_download(client, url, dest):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(b"%PDF-1.4 fake")
|
||||
return len(b"%PDF-1.4 fake")
|
||||
|
||||
fivecat_rows = [_ann_raw(aid="r1", title="2025年年度报告")]
|
||||
monkeypatch.setattr(cd, "fetch_cninfo_announcements",
|
||||
MagicMock(return_value=fivecat_rows))
|
||||
monkeypatch.setattr(cd, "download_pdf", fake_download)
|
||||
cd.run_lane("daily")
|
||||
idx = pd.read_parquet(root / "state" / "ann_pdf_index.parquet")
|
||||
assert len(idx) == 1
|
||||
assert idx.iloc[0]["announcement_id"] == "r1"
|
||||
assert idx.iloc[0]["report_year"] == "2025"
|
||||
assert (root / "ann_pdf" / "2026" / "r1.pdf").read_bytes().startswith(b"%PDF")
|
||||
|
||||
|
||||
# ---------- 墙钟自停 ----------
|
||||
|
||||
def test_wallclock_stops_after_current_unit(root, monkeypatch):
|
||||
"""--until 已过 → 完成当前 unit 后退出,rc=3,已完 unit 有 marker。"""
|
||||
_wire_daily(monkeypatch, root)
|
||||
past = (dt.datetime.now() - dt.timedelta(minutes=1)).strftime("%H:%M")
|
||||
rc = cd.run_lane("daily", until=past)
|
||||
assert rc == 3
|
||||
assert cd.is_done("daily", "ann", "000001") # 首 unit 完成后停
|
||||
|
||||
|
||||
def test_wallclock_future_runs_to_completion(root, monkeypatch):
|
||||
_wire_daily(monkeypatch, root)
|
||||
future = dt.datetime.now() + dt.timedelta(minutes=30)
|
||||
if future.date() != dt.date.today(): # 跨午夜 → 取当天最晚时刻
|
||||
future = dt.datetime.combine(dt.date.today(), dt.time(23, 59))
|
||||
assert cd.run_lane("daily", until=future.strftime("%H:%M")) == 0
|
||||
|
||||
|
||||
# ---------- depth_summary(探测优先) ----------
|
||||
|
||||
def test_depth_summary_records_earliest(root, monkeypatch):
|
||||
_wire_daily(monkeypatch, root)
|
||||
cd.run_lane("daily")
|
||||
j = json.loads((root / "state" / "depth_summary.json").read_text())
|
||||
assert "ann" in j or "news" in j
|
||||
|
||||
|
||||
# ---------- main/退出码 ----------
|
||||
|
||||
def test_main_lock_busy_yields_exit3(root, monkeypatch):
|
||||
import os
|
||||
fd = cd.acquire_lock()
|
||||
assert fd is not None
|
||||
monkeypatch.setattr(sys, "argv", ["corpus_download.py", "--lane", "daily"])
|
||||
with pytest.raises(SystemExit) as e:
|
||||
cd.main()
|
||||
os.close(fd)
|
||||
assert e.value.code == 3 # 让路(2/3 类)
|
||||
|
||||
|
||||
def test_main_daily_exit0(root, monkeypatch):
|
||||
_wire_daily(monkeypatch, root)
|
||||
monkeypatch.setattr(sys, "argv", ["corpus_download.py", "--lane", "daily"])
|
||||
with pytest.raises(SystemExit) as e:
|
||||
cd.main()
|
||||
assert e.value.code == 0
|
||||
Reference in New Issue
Block a user