feat(scheduler): 定时同步全面页面化(子进程跑页面脚本,API直调退出调度防风控)

- 每日 full: bash scripts/full_sync_all.sh;每4h messages: scrape_via_page --entity messages
- 子进程隔离+asyncio.Lock互斥+非零退出只记JSON日志;自动补DB_URL环境
- ENTITY_FUNCS/run_once/CLI保留为手动调试,不再挂定时触发器
- README同步更新(Docker镜像需Playwright+Chromium);413测试绿

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-20 09:33:53 +08:00
parent 02dd77a62f
commit 5923cd05b1
3 changed files with 170 additions and 34 deletions
+75 -2
View File
@@ -437,8 +437,11 @@ async def test_start_registers_two_jobs_with_expected_triggers(engine, session_m
# IntervalTrigger exposes its interval as a timedelta on .interval`.
assert interval.interval.total_seconds() == 6 * 3600
# The full-sync job passes args=["all"] (so it hits _run_all).
assert full_call.kwargs["args"] == ["all"]
# 2026-08-20 起:两个 job 都打到 _safe_page_run(页面方式子进程),
# full 传 "full"、messages 传 "messages"。
assert full_call.args[0].__name__ == "_safe_page_run"
assert full_call.kwargs["args"] == ["full"]
assert msg_call.args[0].__name__ == "_safe_page_run"
assert msg_call.kwargs["args"] == ["messages"]
# coalesce=True + max_instances=1 protects against overlap.
@@ -510,3 +513,73 @@ def _mk_result(
upserted=upserted,
error=error,
)
# ---------------------------------------------------------------------------
# Page-based scheduled jobs (2026-08-20 — API 直调退出定时任务,全部页面方式防风控)
# ---------------------------------------------------------------------------
class _FakeProc:
def __init__(self, rc: int = 0) -> None:
self._rc = rc
async def wait(self) -> int:
return self._rc
async def test_safe_page_run_full_invokes_bash_full_sync(engine, monkeypatch):
"""full job = bash scripts/full_sync_all.sh(页面方式全量序列,子进程隔离)。"""
sched = SyncScheduler(engine=engine)
captured: dict = {}
async def fake_exec(*args, cwd=None, env=None):
captured["args"] = list(args)
captured["cwd"] = cwd
return _FakeProc(0)
monkeypatch.setattr(scheduler_module.asyncio, "create_subprocess_exec", fake_exec)
monkeypatch.setattr(scheduler_module.shutil, "which", lambda name: "/usr/bin/bash")
await sched._safe_page_run("full")
assert captured["args"][0] == "/usr/bin/bash"
assert captured["args"][1].endswith("full_sync_all.sh")
# cwd 指向 yunke-server(脚本的相对路径基准)
assert str(captured["cwd"]).replace("\\", "/").endswith("yunke-server")
async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch):
"""messages job = python scripts/scrape_via_page.py --entity messages。"""
sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
captured: dict = {}
async def fake_exec(*args, cwd=None, env=None):
captured["args"] = list(args)
captured["env"] = env
return _FakeProc(0)
monkeypatch.setattr(scheduler_module.asyncio, "create_subprocess_exec", fake_exec)
await sched._safe_page_run("messages")
args = captured["args"]
assert args[1].endswith("scrape_via_page.py")
i = args.index("--entity")
assert args[i + 1] == "messages"
j = args.index("--max-conversations")
assert args[j + 1] == "123"
async def test_safe_page_run_nonzero_rc_is_logged_not_raised(engine, monkeypatch, capsys):
"""子进程非零退出只记日志不抛(调度器保住下一个周期)。"""
sched = SyncScheduler(engine=engine)
monkeypatch.setattr(scheduler_module.shutil, "which", lambda name: "/usr/bin/bash")
async def fake_exec(*args, cwd=None, env=None):
return _FakeProc(3)
monkeypatch.setattr(scheduler_module.asyncio, "create_subprocess_exec", fake_exec)
await sched._safe_page_run("full") # 不应抛异常
out = capsys.readouterr().out
assert '"rc": 3' in out or "'rc': 3" in out or "rc" in out