Files
alert-contract-expiring-hr/scripts/service/platform_collector.py
taojianmin 213ab5e209 v1.1.1: platform_collector RPA 流程跑通(headless 抓 3 行)
修复 Playwright 自动化流程稳定性问题:

1. 提取辅助函数 _goto / _fill_root_login / _click_campus_link / _fill_spa_login,
   每个失败独立处理,避免一处报错阻塞后续步骤

2. 强化导航路径:强制 goto /select → click 智慧校园 → goto sandbox SPA login →
   SPA login → 强制 goto SPA_HOME → 等侧边栏 link 出现 → click hr/contracts
   - 即使 sandbox 后端跳走不一致(root login demo/123456 不稳定),也能继续

3. SPA 路由 vs 整页 reload:
   - click 侧边栏 link 优先(SPA pushState,避免 goto 触发整页 reload 被踢回 login)
   - 兜底用 history.pushState + dispatchEvent('popstate') 触发 SPA 路由

4. 表格渲染等待:
   - 增加 3s 初始等待 + 20 次重试(共 40s),给 SPA 异步填充 mock 数据时间

5. 材料下载按钮检查:
   - 检查 '续聘材料包' 按钮是否存在;当前 sandbox mock 未部署此按钮,
     自动跳过下载(仅采集 6 列:姓名/单位/到期日/剩余天数/预警/建议)

6. 调试 dump:
   - SPA home → spa_home_dump.html
   - hr/contracts → hr_contracts_dump.html
   - 失败兜底 → sandbox_dump.html

实测结果(headless=True,profile cookie 复用):
- 抓 3 行合同落库(RPA-钱实验-20260415 / 孙助理 / 王明远)
- sandbox 未部署材料包按钮,跳过下载(不阻塞采集)
- 所有 11 个 CLI exit code = 0

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-14 15:36:19 +08:00

482 lines
19 KiB
Python
Raw 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 平台抓合同表 + 续聘材料包。
设计要点:
- 使用 launch_persistent_context 复用浏览器登录态,避免密码硬编码
- 首次使用需用户手工登录一次(CLI 默认 --headless false 便于交互)
- 后续使用无头模式(profile 已存 cookie)
- 抓表格:DOM eval_on_selector_all → JSON
- 抓材料:点 "续聘材料包" 按钮 → expect_download → 保存到本地
依赖为可选:playwright 未安装时 import 抛错,上层 CLI 捕获并降级报错。
导航流程(v1.1.1 强化版 — 强制 goto):
1. goto 根域
2. 根域 login(如仍显示表单)→ 等跳到 /home 或 /select
3. 强制 goto /select
4. 在 /select 页面 click 智慧校园 tile → 等 URL 含 /industries/campus
5. 若 URL 含 /login,填 sandbox SPA 登录表单 → 等跳走
6. 强制 goto SPA_HOME
7. click 侧边栏 hr/contracts
8. 等 table tbody tr
"""
from __future__ import annotations
import os
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from db import repository as repo
from util.constants import SKILL_SLUG
from util.logging_setup import get_skill_logger
from util.runtime_paths import ensure_dirs
logger = get_skill_logger()
PLATFORM_BASE = os.environ.get("SMARTCAMPUS_BASE_URL", "https://sandbox.jc2009.com")
PLATFORM_HR_PATH = os.environ.get(
"SMARTCAMPUS_HR_PATH", "/industries/campus/smartcampus/app/hr/contracts"
)
PLATFORM_URL = PLATFORM_BASE + PLATFORM_HR_PATH
ROOT_URL = PLATFORM_BASE + "/"
SELECT_URL = PLATFORM_BASE + "/select"
SPA_HOME_URL = PLATFORM_BASE + "/industries/campus/smartcampus/app/home"
SPA_LOGIN_URL = PLATFORM_BASE + "/industries/campus/smartcampus/login"
def _safe_getenv_dir(env_key: str, default_rel: str) -> Path:
raw = os.environ.get(env_key)
if raw:
return Path(raw).resolve()
from util.runtime_paths import get_data_dir
return (get_data_dir() / default_rel).resolve()
def _goto(page, url: str, timeout: int = 20_000) -> None:
"""带 networkidle / domcontentloaded 双兜底的 goto。"""
try:
page.goto(url, wait_until="networkidle", timeout=timeout)
except Exception as e:
logger.warning("goto networkidle 失败 %s: %s;尝试 domcontentloaded", url, e)
try:
page.goto(url, wait_until="domcontentloaded", timeout=timeout)
except Exception as e2:
logger.warning("goto domcontentloaded 也失败 %s: %s", url, e2)
def _fill_root_login(page) -> bool:
"""根域登录页(如还在):fill demo/123456 → click Sign in。返回是否执行了登录。"""
user_input = page.locator('#jc-user').first
if user_input.count() == 0:
return False
user_input.fill("demo")
page.locator('#jc-pass').first.fill("123456")
page.locator('button[type="submit"]:has-text("Sign in")').first.click()
logger.info("platform_collector: 根域自动登录 demo/123456...")
try:
page.wait_for_url(
lambda u: "/home" in u or "/select" in u or "/industries/" in u,
timeout=20_000,
)
except Exception as e:
logger.warning("等根域登录跳转超时: %s", e)
logger.info("platform_collector: 根域登录后 URL=%s", page.url)
return True
def _click_campus_link(page) -> bool:
"""在 /select 页面点智慧校园 tile,返回是否点中。"""
link = page.locator("a[href*='/industries/campus']").first
if link.count() == 0:
logger.warning("platform_collector: 未找到 智慧校园 link")
return False
link.click()
logger.info("platform_collector: click 智慧校园 链接")
try:
page.wait_for_url(lambda u: "/industries/campus" in u, timeout=15_000)
except Exception as e:
logger.warning("等 sandbox 路径超时: %s;当前 URL=%s", e, page.url)
return True
def _fill_spa_login(page) -> bool:
"""sandbox SPA 登录(如还在登录页):fill demo/123456 → click 登录。返回是否执行了登录。"""
if "/login" not in page.url and "/signin" not in page.url:
return False
try:
page.wait_for_selector('input[autocomplete="username"]', timeout=10_000)
except Exception as e:
logger.warning("SPA login input 未出现: %s", e)
return False
spa_user = page.locator('input[autocomplete="username"]').first
if spa_user.count() == 0:
logger.warning("SPA login input count=0")
return False
spa_user.fill("demo")
page.locator('input[autocomplete="current-password"]').first.fill("123456")
page.locator(
'button:has-text("登录"), button[type="submit"]:has-text("Sign in")'
).first.click()
logger.info("platform_collector: sandbox SPA 自动登录...")
try:
page.wait_for_url(
lambda u: "/login" not in u and "/signin" not in u,
timeout=20_000,
)
except Exception as e:
logger.warning("SPA 登录超时: %s;当前 URL=%s", e, page.url)
logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
return True
def collect_all(limit: int = 0, headless: bool = True) -> dict:
"""登录 sandbox 平台 → 抓合同表 + 材料 → 落库。"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
logger.error("playwright 未安装,跳过 RPA 采集")
return _error_result("playwright_not_installed", 0)
profile_dir = _safe_getenv_dir("SMARTCAMPUS_PROFILE_DIR", "playwright-profile")
download_dir = _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",
profile_dir, download_dir, headless, limit,
)
contracts: list[dict] = []
fetched_rows = 0
downloaded = 0
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
user_data_dir=str(profile_dir),
headless=headless,
accept_downloads=True,
viewport={"width": 1440, "height": 900},
)
try:
page = ctx.new_page()
# 1. 根域 goto + 根域登录(可选;root login 经常 demo/123456 失败)
_goto(page, ROOT_URL)
_fill_root_login(page)
# 2. 直接 goto sandbox SPA login(绕过 /select 和 root 跳转)
logger.info("platform_collector: 强制 goto sandbox SPA login, 当前 URL=%s", page.url)
_goto(page, SPA_LOGIN_URL)
# 3. sandbox SPA 登录(demo/123456 已知可用)
page.wait_for_timeout(1500)
_fill_spa_login(page)
# 4. 强制 goto SPA home
if "/app/" not in page.url:
logger.info("platform_collector: 强制 goto SPA_HOME, 当前 URL=%s", page.url)
_goto(page, SPA_HOME_URL)
# SPA home 也可能再次 redirect 到 /login(session 没保住)
page.wait_for_timeout(1500)
if _fill_spa_login(page):
_goto(page, SPA_HOME_URL)
# 5. 等 SPA 渲染 + click 侧边栏 hr/contracts(SPA 路由,避免 goto 触发整页 reload 丢 session)
try:
# dump SPA home 一次(用于调试 selector)
page.wait_for_timeout(2000)
try:
html = page.content()
dump_path = download_dir.parent / "spa_home_dump.html"
dump_path.write_text(html, encoding="utf-8")
logger.info(
"platform_collector: SPA home dump → %s (%d 字节)",
dump_path, len(html),
)
except Exception:
pass
# 等 SPA 真正加载完(侧边栏 link 出现,最长 15s)
try:
page.wait_for_selector(
"a[href$='/hr/contracts']", timeout=15_000, 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=10_000
)
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(1500)
except Exception as e:
logger.warning("SPA pushState 失败: %s", e)
_goto(page, PLATFORM_URL)
except Exception as e:
logger.warning("click 侧边栏失败: %s;fallback goto", e)
_goto(page, PLATFORM_URL)
logger.info("platform_collector: 当前 URL=%s", page.url)
# 7. dump 当前 hr/contracts 页面(用于调试)
try:
html = page.content()
dump_path = download_dir.parent / "hr_contracts_dump.html"
dump_path.write_text(html, encoding="utf-8")
logger.info(
"platform_collector: hr/contracts dump → %s (%d 字节)",
dump_path, len(html),
)
except Exception:
pass
# 8. 等待表格加载(SPA 异步填充 mock 数据,多等几秒)
page.wait_for_timeout(3000) # 给 React 初始化 + mock 数据填充时间
found_table = False
for attempt in range(20):
page.wait_for_timeout(2000)
cur_url = page.url
try:
page.wait_for_selector("table tbody tr", timeout=3000)
found_table = True
break
except Exception:
logger.info(
"platform_collector: URL=%s 等待表格加载 (attempt %d/20)...",
cur_url, attempt + 1,
)
if not found_table:
try:
html = page.content()
dump_path = download_dir.parent / "sandbox_dump.html"
dump_path.write_text(html, encoding="utf-8")
logger.warning(
"platform_collector: 40s 后未找到表格,已 dump 页面到 %s(%d 字节)",
dump_path, len(html),
)
except Exception as e:
logger.warning("dump 失败: %s", e)
return _error_result("table_not_found_after_40s", 0)
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
for i, r in enumerate(rows):
if limit and i >= limit:
break
if len(r) < 6:
logger.warning("行 %d 列数 < 6,跳过: %s", i, r)
continue
teacher = r[0].strip()
unit = r[1].strip()
contract_end = r[2].strip()
days_raw = r[3].strip()
alert_label = r[4].strip()
suggestion_hint = r[5].strip()
contract_id = _gen_contract_id(teacher, contract_end)
contract = {
"contract_id": contract_id,
"teacher_name": teacher,
"unit_name": unit,
"contract_end": _normalize_date(contract_end),
"days_to_expire_hint": _parse_days(days_raw),
"alert_level_hint": _parse_alert(alert_label),
"ai_suggestion_hint": suggestion_hint,
"materials": [],
}
# 只有在按钮存在时才尝试下载(sandbox mock 当前未部署此按钮)
if mat_btn_count > 0:
try:
btn = page.locator("table tbody tr").nth(i).locator(
"button:has-text('续聘材料包')"
)
with page.expect_download(timeout=10_000) as dl_info:
btn.click()
dl = dl_info.value
fname = dl.suggested_filename or "materials.zip"
dest = download_dir / f"{contract_id}_{fname}"
dl.save_as(str(dest))
contract["materials"].append(str(dest))
downloaded += 1
except Exception as e:
logger.warning("材料下载失败 %s: %s", contract_id, e)
else:
logger.info(
"platform_collector: sandbox 未部署「续聘材料包」按钮,"
"跳过材料下载(仅采集表格列)",
)
# 仅警告一次
if i == 0:
logger.warning(
"platform_collector: 提示:材料包缺失时,"
"parse-materials CLI 将标记为「无材料」"
)
contracts.append(contract)
finally:
ctx.close()
saved = _persist_contracts(contracts)
return {
"platform_url": PLATFORM_URL,
"fetched_rows": fetched_rows,
"saved_contracts": saved,
"downloaded_materials": downloaded,
"contracts": contracts,
}
def _persist_contracts(contracts: list[dict]) -> int:
"""把抓到的合同 + 材料落到 SQLite。仅插入 contracts 表的占位行,
materials_content 由 parse-materials CLI 后续解析写入。
真正的合同台账来自 HR 系统 API(生产)或样例 CSV(本地),
本采集仅作为"快照"记录抓取时间与材料路径。
"""
if not contracts:
return 0
from db.connection import get_connection
conn = get_connection()
try:
with conn:
for c in contracts:
# upsert contracts(仅更新与 sandbox 相关的字段)
conn.execute(
"""
INSERT INTO contracts
(contract_id, employee_id, teacher_name, unit_id, unit_name,
position, contract_type, contract_start, contract_end,
trial_period_end, status, last_material_id, renewed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '可续聘', NULL, NULL)
ON CONFLICT(contract_id) DO UPDATE SET
teacher_name = excluded.teacher_name,
unit_name = excluded.unit_name,
contract_end = excluded.contract_end
""",
(
c["contract_id"],
c["contract_id"], # employee_id 暂用 contract_id
c["teacher_name"],
"U-RPA", # unit_id 占位
c["unit_name"],
"(RPA 采集)",
"(RPA 采集)",
"1970-01-01",
c["contract_end"],
c["contract_end"],
),
)
# 写 materials_content(占位:file_path 写入,extracted_text 待 parse-materials)
from db.migrations import ensure_materials_content_schema
ensure_materials_content_schema(conn)
for fp in c["materials"]:
fname = Path(fp).name
conn.execute(
"""
INSERT OR REPLACE INTO materials_content
(contract_id, file_name, file_path, extracted_text,
parse_method, parsed_at)
VALUES (?, ?, ?, NULL, 'pending', ?)
""",
(
c["contract_id"],
fname,
fp,
datetime.now().isoformat(timespec="seconds"),
),
)
return len(contracts)
finally:
conn.close()
def _error_result(reason: str, fetched: int) -> dict:
return {
"platform_url": PLATFORM_URL,
"fetched_rows": fetched,
"saved_contracts": 0,
"downloaded_materials": 0,
"contracts": [],
"error": reason,
}
def _gen_contract_id(teacher: str, end_date: str) -> str:
"""从 (姓名, 到期日) 派 contract_id,确保幂等。"""
safe_teacher = re.sub(r"[^\w]", "", teacher)[:8] or "T"
safe_end = end_date.replace("-", "")[:8]
return f"RPA-{safe_teacher}-{safe_end}"
def _normalize_date(s: str) -> str:
"""尽量把 sandbox 返回的日期归一为 YYYY-MM-DD。"""
s = s.strip()
if re.match(r"^\d{4}-\d{2}-\d{2}$", s):
return s
# 兜底:截前 10 字符
return s[:10]
def _parse_days(s: str) -> int | None:
m = re.search(r"-?\d+", s)
return int(m.group(0)) if m else None
def _parse_alert(s: str) -> str:
s = s.strip()
if "红" in s:
return "red"
if "橙" in s:
return "orange"
if "黄" in s:
return "yellow"
return "unknown"