chore: auto release commit (2026-09-15 03:29:00)
All checks were successful
技能自动化发布 / release (push) Successful in 11s

This commit is contained in:
2026-09-15 03:29:00 +08:00
parent 689810eaeb
commit 22b42f8d30
9 changed files with 2063 additions and 1738 deletions

View File

@@ -0,0 +1,625 @@
"""Playwright RPA:SmartCampus HR 平台合同台账采集主流程。
v1.1.31:从 platform_collector.py 拆分出来的 collect_all 子模块。
包含 2 个函数:
- collect_all(登录 + 抓 hr/contracts 表格 + 落库 + 入队下载任务)
- _error_result(错误返回包装)
设计要点(保持 v1.1.30 不变):
- 登录子流程通过 service.platform_collector_login 顶部 import(login 是叶子模块,无 cycle)
- facade 持有的符号(_goto / _dump_page / _is_logged_in / 异常类 / PLATFORM_URL 等)
通过 `from service import platform_collector as _pc` lazy import
→ 避免 facade ↔ collect cycle;测试 patch facade attr 后立即生效
- persist / queue 持有的函数(_persist_contracts / _gen_contract_id 等)仍位于 facade,
Stage 3/4 移到 persist/queue 子模块后,collect 改为对应模块顶部 import
"""
from __future__ import annotations
import time
from datetime import datetime
from util.constants import (
CARD_APPEAR_WAIT_MS,
DEFAULT_PAGE_TIMEOUT_MS,
FULL_RENDER_WAIT_MS,
HUMAN_WAIT_TIMEOUT,
POST_LOGIN_SETTLE_MS,
POST_RELOAD_LOGIN_HYDRATION_MS,
POST_RELOGIN_GOTO_WAIT_MS,
PUSHSTATE_SETTLE_MS,
RELOAD_DOMCONTENTLOADED_TIMEOUT_MS,
SIDEBAR_CLICK_URL_TIMEOUT_MS,
SIDEBAR_LINK_TIMEOUT_MS,
SPA_HYDRATION_MS,
TABLE_INITIAL_WAIT_MS,
TABLE_POLL_INTERVAL_MS,
TABLE_POLL_MAX_ATTEMPTS,
TABLE_TR_TIMEOUT_MS,
)
from util.logging_setup import get_skill_logger
from service.platform_collector_login import (
_fill_spa_login,
_fill_spa_login_from_env,
_wait_for_login_inputs_filled,
_wait_for_manual_login,
_wait_for_url_stable,
)
logger = get_skill_logger()
def collect_all(
limit: int = 0,
headless: bool = True,
human_wait_timeout: int = HUMAN_WAIT_TIMEOUT,
) -> dict:
"""登录 sandbox 平台 → 抓合同表 + 材料 → 落库。
human_wait_timeout: 手工登录 fallback 等待秒数(默认 HUMAN_WAIT_TIMEOUT=180)。
失败时可能抛出 RPAError(LoginTimeoutError / MissingBrowserError / TableNotFoundError)。
"""
# Lazy import: 读 facade 持有的 _safe_getenv_dir / 异常类 / PLATFORM_URL 等
from service import platform_collector as _pc
try:
from playwright.sync_api import sync_playwright
except ImportError:
logger.error("playwright 未安装,跳过 RPA 采集")
return _error_result("playwright_not_installed", 0)
profile_dir = _pc._safe_getenv_dir("SMARTCAMPUS_PROFILE_DIR", "playwright-profile")
download_dir = _pc._safe_getenv_dir("MATERIALS_DOWNLOAD_DIR", "materials")
profile_dir.mkdir(parents=True, exist_ok=True)
download_dir.mkdir(parents=True, exist_ok=True)
logger.info(
"platform_collector: 启动 profile=%s download_dir=%s headless=%s limit=%d "
"human_wait_timeout=%d",
profile_dir, download_dir, headless, limit, human_wait_timeout,
)
contracts: list[dict] = []
fetched_rows = 0
downloaded = 0
with sync_playwright() as p:
# v1.1.9:headed 模式优先用系统 Edge(跟 Windows 桌面 session 同源,
# 能真正弹窗让你看到并手工登录);找不到则 fallback Playwright 内置 chromium
if not headless:
try:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=False,
accept_downloads=True,
channel="msedge",
viewport={"width": 1440, "height": 900},
)
logger.info(
"platform_collector: 使用系统 Edge(headed 弹窗)"
)
except Exception as e:
logger.warning(
"platform_collector: 系统 Edge 启动失败(%s),fallback Playwright chromium",
e,
)
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=False,
accept_downloads=True,
viewport={"width": 1440, "height": 900},
)
else:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=True,
accept_downloads=True,
viewport={"width": 1440, "height": 900},
)
try:
page = ctx.new_page()
# v1.1.6:覆盖 Playwright 默认操作超时(之前无 set_default_timeout,导致
# 所有 wait_for_selector 默认 30s,调试时不直观)
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
# 1. v1.1.21:先 goto **根域**(sandbox 登录页),让 SPA hydrate 2 秒
logger.info(
"platform_collector: 启动,goto ROOT_URL=%s (当前 URL=%s)",
_pc.ROOT_URL, page.url,
)
_pc._goto(page, _pc.ROOT_URL)
# 给 React/Vue 框架 2 秒 hydrate 时间,看登录 input 是否就绪
page.wait_for_timeout(2_000)
if _pc._is_logged_in(page):
logger.info(
"platform_collector: ✅ 已登录态(URL=%s),跳过登录流程",
page.url,
)
else:
# 2. 等 #jc-user / #jc-pass 出现(最多 2 秒)
# 若 sandbox 已预填 demo/123456 → 自动点 Sign in
# 若 input 为空 → 等用户输入(**v1.1.24 用户自己点登录按钮**)
auto_clicked = _pc._try_auto_sign_in(page, wait_input_sec=2)
if auto_clicked:
logger.info(
"platform_collector: ✅ 自动登录成功(预填 demo),等 URL 跳到 /app/"
)
elif not headless:
# 3. input 为空:等用户输入 → **用户自己点 Sign in**(脚本不抢按钮)
if _wait_for_login_inputs_filled(page, wait_sec=human_wait_timeout):
logger.info(
"platform_collector: ✅ 用户完成输入并跳转,等 URL 跳到 /app/"
)
else:
# 超时未跳转 → fallback 到原来的多信号等待
logger.warning(
"platform_collector: 用户未跳转,fallback 到 _wait_for_manual_login"
)
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
else:
raise _pc.LoginTimeoutError(
f"headless 模式下未登录,且无法手工登录"
f"(请用 --show-browser 重试)"
)
# 4. v1.1.27:等 URL 跳到任一「SPA 已路由」状态(最多 30s,**不做任何操作**)
# - /app/ = 已到 SPA 主页
# - /select = sandbox 中间页
# - /login 或 /signin = sandbox 又把人踢回登录页(根域登录后常见,
# sandbox 会跳到 /industries/campus/smartcampus/login SPA 二级登录页)
# - 如果 URL 跳到 login/signin,**立即**退出 30 秒等待(不再干等),
# 让 4.5 处理。否则会等满 30 秒才发现要进 SPA 登录兜底,造成卡顿。
spa_url_seen = False
if not _wait_for_url_stable(
page, ("/app/", "/select", "/login", "/signin"), max_wait_sec=30,
):
logger.warning(
"platform_collector: 登录后 30s 内 URL 未稳定到 /app/、/select、"
"/login 或 /signin(当前 URL=%s)",
page.url,
)
# 4.5. v1.1.28/31:URL 落在 SPA 二级登录页 → 主动等 input hydrate(最多 10 秒)
# → 从 .env 读凭证 fill(v1.1.31:密码不硬编码)→ 点登录 → 等 URL 跳离
# v1.1.27 失败的根因:URL 已稳定在 /login 时,React SPA input 可能还没渲染,
# 找不到 input → False → 卡 180 秒等用户输入。
# 修复:先等 input 真正 hydrate 出来,再 fill+click。
cur_url_after_stable = page.url.lower()
if "/login" in cur_url_after_stable or "/signin" in cur_url_after_stable:
logger.warning(
"platform_collector: 登录后落在 SPA 二级登录页(%s),"
"v1.1.31 触发 SPA 登录兜底(等 input hydrate + 从 .env 读凭证 fill)",
page.url,
)
# 1. 等 input 真正 hydrate 出来(最多 10 秒)
try:
page.wait_for_selector(
"#jc-user, #jc-pass, input[type='tel'], input[type='password']",
timeout=10_000,
state="attached",
)
page.wait_for_timeout(500) # 让 React 状态稳定
except Exception as e:
logger.warning(
"platform_collector: SPA 登录页 10s 内未渲染 input: %s", e,
)
# 2. 从 .env 读凭证 fill + 点登录
spa_filled = _fill_spa_login_from_env(page)
if spa_filled:
logger.info(
"platform_collector: ✅ SPA 二级登录页(来自 .env)已自动填好并点登录,"
"等 URL 跳到 /app/ 或 /select"
)
# 等 URL 跳离 login(30 秒内跳到 /app/ 或 /select 都算成功)
if not _wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=30):
logger.warning(
"platform_collector: SPA 登录后 30s 内 URL 未稳定到 /app/或/select"
"(当前 URL=%s,.env 凭证可能失效)",
page.url,
)
# 凭证失效:等用户手工登录(headless 模式直接抛错)
if not headless:
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
else:
raise _pc.LoginTimeoutError(
f"SPA 二级登录页 .env 凭证 fill 后 URL 不跳离"
f"(可能凭证失效,URL={page.url})"
)
else:
# .env 无凭证 / input 仍未渲染 → 等用户手工输入
logger.warning(
"platform_collector: SPA 登录页自动 fill 失败"
"(.env 无凭证或 input 未渲染),fallback 等用户输入"
)
if not headless:
if _wait_for_login_inputs_filled(page, wait_sec=human_wait_timeout):
_wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=30)
else:
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
else:
raise _pc.LoginTimeoutError(
f"SPA 二级登录页 .env 无凭证且 headless 无法手工登录"
f"(URL={page.url})"
)
# 5. v1.1.22:显式跳到 SPA_HOME_URL(/app/home),等侧边栏 link 出现
# 避免 sandbox 在 /select 中间页时直接找 sidebar link 找不到
# v1.1.32 优化:URL 不在 /app/home(可能在 /select 中间页)就 goto(5s 等)
if "/app/home" not in page.url.lower():
logger.info(
"platform_collector: 当前 URL=%s,跳到 SPA_HOME_URL=%s",
page.url, _pc.SPA_HOME_URL,
)
_pc._goto(page, _pc.SPA_HOME_URL)
page.wait_for_timeout(SPA_HYDRATION_MS)
# v1.1.32 优化:等 URL 稳定到 /app/home(最多 5s,比 15s 快很多)
if not _wait_for_url_stable(page, "/app/home", max_wait_sec=5):
logger.warning(
"platform_collector: 5s 内未稳定到 /app/home(当前 URL=%s)",
page.url,
)
# 6. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
try:
# dump SPA home 一次(用于调试 selector)
page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
_pc._dump_page(page, download_dir.parent / "spa_home_dump.html", "SPA home")
# 等 SPA 真正加载完(侧边栏 link 出现,最长 SIDEBAR_LINK_TIMEOUT_MS)
try:
page.wait_for_selector(
"a[href$='/hr/contracts']",
timeout=SIDEBAR_LINK_TIMEOUT_MS,
state="attached",
)
except Exception as e:
logger.warning("等侧边栏 hr/contracts link 超时: %s", e)
link = page.locator("a[href$='/hr/contracts']").first
if link.count() > 0:
link.click()
logger.info("platform_collector: click 侧边栏 hr/contracts")
try:
page.wait_for_url(
lambda u: "/hr/contracts" in u,
timeout=SIDEBAR_CLICK_URL_TIMEOUT_MS,
)
except Exception as e:
logger.warning("等 /hr/contracts URL 超时: %s;当前 URL=%s",
e, page.url)
else:
logger.info(
"platform_collector: 侧边栏 link 仍未出现,"
"使用 SPA 路由(pushState)到 hr/contracts, 当前 URL=%s",
page.url,
)
# 用 SPA 内部路由而非整页 goto,避免 session 被 server 端踢回 login
try:
page.evaluate(
"u => history.pushState({}, '', u) || "
"window.dispatchEvent(new PopStateEvent('popstate')),"
" '/industries/campus/smartcampus/app/hr/contracts'"
)
page.wait_for_timeout(PUSHSTATE_SETTLE_MS)
# v1.1.32:pushState 后 URL 可能没切(sandbox mock 偶发),
# 检查 URL 真到 /hr/contracts 才算成功,否则 fallback goto
if "/hr/contracts" not in page.url.lower():
logger.warning(
"platform_collector: pushState 后 URL=%s 未到 /hr/contracts,"
"fallback goto PLATFORM_URL",
page.url,
)
_pc._goto(page, _pc.PLATFORM_URL)
except Exception as e:
logger.warning("SPA pushState 失败: %s", e)
_pc._goto(page, _pc.PLATFORM_URL)
except Exception as e:
logger.warning("click 侧边栏失败: %s;fallback goto", e)
_pc._goto(page, _pc.PLATFORM_URL)
logger.info("platform_collector: 当前 URL=%s", page.url)
# 5.5. v1.1.8:**首次登录后不再强制刷新**,避免打断用户手工登录态。
# 改为「等表格出现」兜底:给 SPA 一个初始等待窗口(TABLE_INITIAL_WAIT_MS*2),
# 如果表格仍未出现,**才**刷新一次(sandbox mock 异步加载场景)。
logger.info(
"platform_collector: 进入 hr/contracts 后**不刷新**,等待表格出现"
"(首次登录态不要打断)"
)
# 5.6. 兜底刷新:仅当等表格超时后才触发;刷新后可能被踢回 login,需重新登录
# 修复:之前没检查 URL,刷新后停在 login 页继续往后走,找不到表格就 dump 报错
# v1.1.32 优化:刷回 login 时用 _fill_spa_login_from_env(从 .env 读),
# 不再调硬编码 demo/123456 的 _fill_spa_login(与 .env 凭证一致)。
def _try_reload_after_table_timeout():
logger.warning(
"platform_collector: 表格未在 %dms 内出现,触发兜底刷新",
TABLE_INITIAL_WAIT_MS * 2,
)
try:
page.reload(
wait_until="networkidle",
timeout=RELOAD_NETWORKIDLE_TIMEOUT_MS,
)
except Exception as e:
logger.warning("刷新 networkidle 失败: %s;尝试 domcontentloaded", e)
try:
page.reload(
wait_until="domcontentloaded",
timeout=RELOAD_DOMCONTENTLOADED_TIMEOUT_MS,
)
except Exception as e2:
logger.warning("刷新 domcontentloaded 也失败: %s", e2)
if "/login" in page.url.lower() or "/signin" in page.url.lower():
logger.warning(
"platform_collector: 刷新后回到登录页(%s),重新登录",
page.url,
)
# v1.1.32:先等 input hydrate,再走 .env 凭证 fill
try:
page.wait_for_selector(
"#jc-user, #jc-pass, input[type='tel'], input[type='password']",
timeout=10_000,
state="attached",
)
page.wait_for_timeout(500)
except Exception as e:
logger.warning(
"platform_collector: 刷新后 login 页 10s 内未渲染 input: %s", e,
)
if not _fill_spa_login_from_env(page):
# v1.1.32:.env 无凭证时 fallback 到硬编码 demo(保持 v1.1.10 兼容)
logger.warning(
"platform_collector: .env 无凭证,fallback 硬编码 demo/123456"
)
if not _fill_spa_login(page):
if not headless:
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
else:
raise _pc.LoginTimeoutError(
"刷新后回到 login 页且 headless 无法手工登录"
)
else:
page.wait_for_timeout(POST_LOGIN_SETTLE_MS)
else:
# .env 凭证 fill 成功 → 等 URL 跳离 login
_wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=15)
_pc._goto(page, _pc.PLATFORM_URL)
page.wait_for_timeout(POST_RELOAD_LOGIN_HYDRATION_MS)
# 7. dump 当前 hr/contracts 页面(用于调试)
_pc._dump_page(page, download_dir.parent / "hr_contracts_dump.html", "hr/contracts")
# 8. 等待表格加载(v1.1.8:先等,**表格超时未出现才触发兜底刷新**——
# 修复「首次登录后强制刷新打断手工登录态」问题)
page.wait_for_timeout(TABLE_INITIAL_WAIT_MS) # 给 React 初始化 + mock 数据填充时间
found_table = False
for attempt in range(TABLE_POLL_MAX_ATTEMPTS):
page.wait_for_timeout(TABLE_POLL_INTERVAL_MS)
cur_url = page.url
try:
page.wait_for_selector(
"table tbody tr",
timeout=TABLE_TR_TIMEOUT_MS,
)
found_table = True
break
except Exception:
logger.info(
"platform_collector: URL=%s 等待表格加载 (attempt %d/%d)...",
cur_url, attempt + 1, TABLE_POLL_MAX_ATTEMPTS,
)
if not found_table:
# v1.1.8:表格没出现,**先尝试兜底刷新一次**(sandbox mock 异步加载场景);
# 刷新后再次轮询表格,仍找不到才报 TableNotFoundError。
logger.warning(
"platform_collector: 首次轮询未找到表格,触发兜底刷新(sandbox mock 异步加载场景)"
)
_try_reload_after_table_timeout()
# 刷新后重新轮询一次(最多 5 次)
for attempt2 in range(5):
page.wait_for_timeout(TABLE_POLL_INTERVAL_MS)
try:
page.wait_for_selector(
"table tbody tr",
timeout=TABLE_TR_TIMEOUT_MS,
)
found_table = True
logger.info(
"platform_collector: 兜底刷新后找到表格 (attempt %d)", attempt2 + 1
)
break
except Exception:
logger.info(
"platform_collector: 兜底刷新后仍等待表格 (attempt %d/5)...",
attempt2 + 1,
)
if not found_table:
_pc._dump_page(page, download_dir.parent / "sandbox_dump.html", "sandbox")
logger.warning("platform_collector: 表格未找到(兜底刷新后仍未出现)")
raise _pc.TableNotFoundError(
f"{TABLE_POLL_MAX_ATTEMPTS * TABLE_POLL_INTERVAL_MS // 1000}s "
f"内未找到表格(兜底刷新后仍未出现) (URL={page.url})"
)
# 表格已找到,等 tbody 完全填充 + dump 一份完整 DOM(用于诊断按钮 selector)
try:
page.wait_for_function(
"document.querySelectorAll('table tbody tr').length > 0",
timeout=DEFAULT_PAGE_TIMEOUT_MS,
)
page.wait_for_timeout(FULL_RENDER_WAIT_MS)
_pc._dump_page(
page,
download_dir.parent / "hr_contracts_full_dump.html",
"完整 hr/contracts",
)
except Exception as e:
logger.warning("等完整渲染超时: %s", e)
rows = page.eval_on_selector_all(
"table tbody tr",
"rs => rs.map(r => Array.from(r.querySelectorAll('td')).map(td => td.innerText))",
)
fetched_rows = len(rows)
logger.info("platform_collector: 抓到 %d 行", fetched_rows)
# 检查每行是否有「续聘材料包」按钮
try:
mat_btn_count = page.locator(
"table tbody tr button:has-text('续聘材料包')"
).count()
logger.info(
"platform_collector: 「续聘材料包」按钮数=%d", mat_btn_count,
)
except Exception:
mat_btn_count = 0
# 抓「材料包」card 区段的 DOM(用于后续解析每行 click 后的卡片文本)
# sandbox 行为:click 按钮后会在表格下方追加「续聘准备任务书」+「单位清单」卡片
for i, r in enumerate(rows):
if limit and i >= limit:
break
# v1.1.8:表格列数自适应 —— sandbox 首次登录未刷新是 7 列
# (姓名/单位/到期日/剩余天数/预警/建议/按钮),刷新后才是 10 列
# (多 工号/岗位/合同类型/起止日期)。两种都支持。
ncols = len(r)
if ncols < 6:
logger.warning(
"行 %d 列数=%d < 6,跳过: %s", i, ncols, r
)
continue
# 列序探测:判断首列是不是工号(纯数字 + 字母,纯姓名是中文)
first_col = r[0].strip()
import re as _re
is_emp_id = bool(_re.match(r"^[A-Za-z]?\d+$", first_col))
if is_emp_id and ncols >= 9:
# 10 列布局(含工号):0 工号 1 姓名 2 单位 3 岗位 4 合同类型
# 5 起止(含~) 6 剩余天数 7 预警 8 建议 9 按钮
emp_id = first_col
teacher = r[1].strip()
unit = r[2].strip()
period = r[5].strip()
contract_end = period.split("~")[-1].strip() if "~" in period else period
days_raw = r[6].strip()
alert_label = r[7].strip()
suggestion_hint = r[8].strip()
elif ncols >= 6:
# 7 列布局(无工号):0 姓名 1 单位 2 到期日 3 剩余天数 4 预警 5 建议 6 按钮
teacher = first_col
unit = r[1].strip()
contract_end = r[2].strip()
days_raw = r[3].strip()
alert_label = r[4].strip()
suggestion_hint = r[5].strip()
emp_id = "" # 工号缺失,留空让 _gen_contract_id 用 teacher_name 兜底
else:
logger.warning(
"行 %d 列数=%d 无法识别布局,跳过: %s", i, ncols, r
)
continue
# 用工号生成 contract_id(稳定,不会随姓名变化)
# v1.1.8:7 列布局无工号 → 用 teacher_name 派生(避免与 10 列布局冲突)
if emp_id:
contract_id = _pc._gen_contract_id(emp_id, contract_end)
else:
contract_id = _pc._gen_contract_id(teacher, contract_end)
contract = {
"contract_id": contract_id,
"teacher_name": teacher,
"unit_name": unit,
"contract_end": _pc._normalize_date(contract_end),
"days_to_expire_hint": _pc._parse_days(days_raw),
"alert_level_hint": _pc._parse_alert(alert_label),
"ai_suggestion_hint": suggestion_hint,
"materials": [],
}
# 点「续聘材料包」按钮 → 等卡片展开 → 抓卡片文本(含 checklist)
if mat_btn_count > 0:
try:
btn = page.locator("table tbody tr").nth(i).locator(
"button:has-text('续聘材料包')"
)
btn.click()
# 等卡片出现(页面下方追加 h2「续聘准备任务书」/「单位清单」)
page.wait_for_timeout(CARD_APPEAR_WAIT_MS)
# 抓表格下方所有 card 文本
cards_text = page.locator(
"main .grid > div, main > div > div > div"
).all_inner_texts()
materials_text = "\n\n".join(
t.strip() for t in cards_text if t.strip()
)
if materials_text:
# 存到 materials_content(sandbox mock 卡片文本作为材料包内容)
contract["materials"].append({
"kind": "sandbox_card",
"file_name": f"{contract_id}_materials.txt",
"text": materials_text,
})
downloaded += 1
logger.info(
"platform_collector: 抓到卡片文本 %d 字 (%s)",
len(materials_text), contract_id,
)
# dump 一次(i==0)方便调试
if i == 0:
_pc._dump_page(
page,
download_dir.parent / "after_click_dump.html",
"首行 click 后",
)
except Exception as e:
logger.warning("抓卡片失败 %s: %s", contract_id, e)
else:
if i == 0:
logger.info(
"platform_collector: sandbox 未部署「续聘材料包」按钮,"
"跳过材料抓取(仅采集表格列)",
)
contracts.append(contract)
finally:
ctx.close()
saved = _pc._persist_contracts(contracts)
logger.info(
"platform_collector: contracts 落库完成 saved=%d / total_fetched=%d",
saved, len(contracts),
)
# v1.1.18:每个合同入队 download_tasks(独立事务,失败不影响主流程)
contract_ids = [c["contract_id"] for c in contracts if c.get("contract_id")]
enqueue_summary = _pc._enqueue_download_tasks_safe(contract_ids)
return {
"platform_url": _pc.PLATFORM_URL,
"fetched_rows": fetched_rows,
"saved_contracts": saved,
"downloaded_materials": downloaded,
"download_queue": enqueue_summary,
"contracts": contracts,
}
def _error_result(reason: str, fetched: int) -> dict:
# Lazy import: 读 facade 的 PLATFORM_URL
from service import platform_collector as _pc
return {
"platform_url": _pc.PLATFORM_URL,
"fetched_rows": fetched,
"saved_contracts": 0,
"downloaded_materials": 0,
"contracts": [],
"error": reason,
}