Compare commits

...

2 Commits

Author SHA1 Message Date
7584e69714 chore: auto release commit (2026-09-15 01:39:08)
Some checks failed
技能自动化发布 / release (push) Failing after 6s
2026-09-15 01:39:08 +08:00
d3fee3f46c chore: auto release commit (2026-09-15 01:30:18)
Some checks failed
技能自动化发布 / release (push) Failing after 4s
2026-09-15 01:30:18 +08:00
5 changed files with 163 additions and 8 deletions

View File

@@ -2,6 +2,23 @@
本文件对应技能市场 **「更新日志」** Tab。
## 1.1.29
- **修复「老 DB 没 download_tasks 表导致入队失败」问题**:
- 用户本地 `data/alert-contract-expiring-hr.db` 是 v1.1.17 之前 init 的(只有前 6 张表),没跑过 v1.1.18 的 init-db → 没 `download_tasks` 表
- `_enqueue_download_tasks_safe` 在 `owns_conn=True` 路径下,先 idempotent `CREATE TABLE IF NOT EXISTS download_tasks ...` + 2 索引,再 INSERT
- 解决 `OperationalError: no such table: download_tasks` + 下载队列 `failed=3`
- 新增测试 `enqueue_safe_auto_create_table_v1_29` 模拟「老 DB 没建过 download_tasks」场景(用 monkeypatch `rp.get_db_path` + `db.connection.get_db_path`),合计 67/67
## 1.1.28
- **彻底修复「SPA 二级登录页卡顿」**:
- v1.1.27 失败根因:URL 已稳定在 `/login` 时,React SPA input 可能还没渲染(hydrate 慢),`_fill_demo_on_spa_login` 找不到 input → False → 卡 180 秒等用户输入
- 修复:步骤 4.5 先 `wait_for_selector(input, timeout=10s, state="attached")` 等 input 真正 hydrate + 0.5s React 稳定,再 fill demo/123456 + 点登录
- demo fill 后如果 URL 仍卡在 login 页(demo 失效)→ headed 模式 fallback 等用户手工登录,headless 模式抛 `LoginTimeoutError`
- 不再依赖「input 是否预填」—— sandbox SPA 二级登录页 input 默认不预填,必须强制覆盖 fill
- 合计 66/66
## 1.1.27
- **修复「到 SPA 二级登录页卡 30 秒」问题**:

View File

@@ -1,7 +1,7 @@
---
name: 合同到期智能预警与续聘辅助
description: "当用户说要扫描即将到期的合同、查 90/60/30 天预警名单、给某位教师生成续聘任务书、下发单位版续聘清单、从 SmartCampus HR 平台抓合同数据、解析续聘材料 PDF/Word、查看本月合同到期人员清单时,加载本技能。支持定时扫描(每日凌晨)+ 数据管理 + Agent 三入口,覆盖合同到期智能预警与续聘辅助全流程。"
version: 1.1.27
version: 1.1.29
author: 深圳匠厂科技有限公司
metadata:
openclaw:

View File

@@ -830,25 +830,59 @@ def collect_all(
page.url,
)
# 4.5. v1.1.24/27:URL 落在 SPA 二级登录页 → 自动填 demo/123456 再登录
# sandbox SPA 登录页 input 默认**不预填**(与根域不同),
# 所以 _try_auto_sign_in 不会自动点 —— 必须显式 fill demo/123456。
# 4.5. v1.1.28:URL 落在 SPA 二级登录页 → 主动等 input hydrate(最多 10 秒)
# → 主动 fill demo/123456(sandbox SPA 默认 mock)→ 点登录 → 等 URL 跳离
# v1.1.27 失败的根因:URL 已稳定在 /login 时,React SPA input 可能还没渲染,
# _fill_demo_on_spa_login 找不到 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.27 触发 demo/123456 自动 fill",
"v1.1.28 触发 SPA 登录兜底(等 input hydrate + fill demo/123456)",
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. fill demo/123456 + 点登录(不管当前 input 有没有值,强制覆盖)
spa_filled = _fill_demo_on_spa_login(page)
if spa_filled:
logger.info(
"platform_collector: ✅ SPA 二级登录页 demo/123456 自动填好,"
"已点登录,等 URL 跳到 /app/ 或 /select"
)
_wait_for_url_stable(page, ("/app/", "/select"), max_wait_sec=30)
# 等 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,demo 可能失效需手工登录)",
page.url,
)
# demo 失效:等用户手工登录(headless 模式直接抛错)
if not headless:
_wait_for_manual_login(page, wait_sec=human_wait_timeout)
else:
raise LoginTimeoutError(
f"SPA 二级登录页 demo/123456 填了但 URL 不跳离"
f"(可能 demo 失效或需人工登录,URL={page.url})"
)
else:
# demo fill 失败(input 不存在)→ 等用户手工输入
# fill 失败(input 仍未渲染)→ 等用户手工输入
logger.warning(
"platform_collector: SPA 登录页 _fill_demo 失败"
"(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)
@@ -1324,6 +1358,35 @@ def _enqueue_download_tasks_safe(
owns_conn = conn is None
if owns_conn:
conn = get_connection()
# v1.1.29:idempotent 表创建 —— 用户没跑 init-db 时,download_tasks 表不存在,
# 首次采集直接建表(CREATE TABLE IF NOT EXISTS)而不是报 "no such table"
try:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS download_tasks (
task_id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'platform_button',
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
error_msg TEXT,
enqueued_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
source_path TEXT,
result_path TEXT,
FOREIGN KEY (contract_id) REFERENCES contracts(contract_id)
);
CREATE INDEX IF NOT EXISTS idx_dlq_status ON download_tasks(status);
CREATE INDEX IF NOT EXISTS idx_dlq_contract ON download_tasks(contract_id);
"""
)
conn.commit()
except Exception as ensure_err:
logger.warning(
"platform_collector: ensure download_tasks 表创建失败(继续走入队流程): %s",
ensure_err,
)
try:
for cid in contract_ids:
try:

View File

@@ -6,7 +6,7 @@
"""
SKILL_SLUG = "alert-contract-expiring-hr"
SKILL_VERSION = "1.1.27"
SKILL_VERSION = "1.1.29"
LOG_LOGGER_NAME = "openclaw.skill.alert_contract_expiring_hr"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

View File

@@ -1271,6 +1271,80 @@ def test_enqueue_download_tasks_safe_empty_list() -> tuple[bool, str]:
return True, "空列表 no-op OK"
def test_enqueue_download_tasks_safe_auto_create_table_v1_29() -> tuple[bool, str]:
"""v1.1.29:用户没跑 init-db 时,首次调用 `_enqueue_download_tasks_safe`
不传 conn(owns_conn=True),函数内部 idempotent 创建 download_tasks 表
而非抛 "no such table"。
"""
import os
import sqlite3
import tempfile
from pathlib import Path
from service.platform_collector import _enqueue_download_tasks_safe
# 1. 临时文件 DB,**只建 contracts 表**(模拟老 DB 没 download_tasks)
with tempfile.TemporaryDirectory() as tmp:
tmp_db = Path(tmp) / "old.db"
# 重定向 JIANGCHANG_DATA_ROOT 或 get_db_path 用临时路径
# 这里用 monkeypatch get_db_path + get_connection
from util import runtime_paths as rp
from db import connection as dbconn
orig_rp_db_path = rp.get_db_path
orig_dbconn_db_path = dbconn.get_db_path
rp.get_db_path = lambda: tmp_db # type: ignore[assignment]
dbconn.get_db_path = lambda: tmp_db # type: ignore[assignment]
try:
# 模拟老 DB:只有 contracts 表(v1.1.17 之前 init 过)
_tmp_conn = sqlite3.connect(str(tmp_db))
try:
_tmp_conn.executescript(
"""
CREATE TABLE contracts (
contract_id TEXT PRIMARY KEY,
teacher_name TEXT, unit_id TEXT, unit_name TEXT,
position TEXT, contract_type TEXT, contract_start TEXT,
contract_end TEXT, trial_period_end TEXT,
status TEXT DEFAULT '可续聘',
last_material_id TEXT, renewed_at TEXT
);
INSERT INTO contracts
(contract_id, teacher_name, unit_id, unit_name,
position, contract_type, contract_start,
contract_end, trial_period_end)
VALUES ('ENQ-CREATE-1', '测试', 'U1', '物理学院',
'实验师', '专任教师聘用合同',
'2024-01-01', '2026-12-31', '2024-04-01');
"""
)
_tmp_conn.commit()
finally:
_tmp_conn.close()
# 2. 不传 conn 调用(owns_conn=True,会走 get_connection → 触发 ensure 建表)
summary = _enqueue_download_tasks_safe(["ENQ-CREATE-1"])
if summary.get("failed", 0) > 0:
return False, f"应自动建表后入队成功,但 failed={summary}"
# 3. 验证表确实被自动创建了
conn = sqlite3.connect(str(tmp_db))
try:
count = conn.execute(
"SELECT COUNT(*) FROM download_tasks"
).fetchone()[0]
finally:
conn.close()
if count != 1:
return False, f"download_tasks 应有 1 行,实际 {count}"
return True, f"ensure 建表 + 入队 OK(enqueued={summary.get('enqueued')})"
finally:
rp.get_db_path = orig_rp_db_path # type: ignore[assignment]
dbconn.get_db_path = orig_dbconn_db_path # type: ignore[assignment]
# ---------- v1.1.19: _wait_for_url_stable + login flow ----------
@@ -1916,6 +1990,7 @@ def main() -> int:
_run("download_tasks/enqueue_safe_idempotent", test_enqueue_download_tasks_safe_idempotent)
_run("download_tasks/enqueue_safe_fk_failure", test_enqueue_download_tasks_safe_fk_failure)
_run("download_tasks/enqueue_safe_empty_list", test_enqueue_download_tasks_safe_empty_list)
_run("download_tasks/enqueue_safe_auto_create_table_v1_29", test_enqueue_download_tasks_safe_auto_create_table_v1_29)
_run("login_flow/wait_url_stable_immediate", test_wait_for_url_stable_immediate_match)
_run("login_flow/wait_url_stable_transition", test_wait_for_url_stable_transition)
_run("login_flow/wait_url_stable_timeout", test_wait_for_url_stable_timeout)