Files
alert-contract-expiring-hr/scripts/service/platform_collector_collect.py
taojianmin 22b42f8d30
All checks were successful
技能自动化发布 / release (push) Successful in 11s
chore: auto release commit (2026-09-15 03:29:00)
2026-09-15 03:29:00 +08:00

626 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
}