From 26a94999dca261f6824a99d600005dafa5749bc9 Mon Sep 17 00:00:00 2001 From: Misaka Date: Fri, 17 Jul 2026 21:27:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E7=AB=99=E7=82=B9=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E6=97=A5=E6=9C=9F=E5=81=8F=E7=A7=BB=EF=BC=88?= =?UTF-8?q?0=3D=E4=BB=8A=E5=A4=A9=E2=80=A630=EF=BC=89+=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=90=AF=E5=8A=A8=E9=99=88=E6=97=A7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 偏移量功能(前端/API 可配,服务端持久化、多用户共享): - state_store:新增 site_config 表 + get_offset / set_offset(0..30 钳制) / get_all_offsets - server:新增 GET/PUT /config(百世锁定当天,不可配置) - 顺心/中通/韵达/安能(各 expected+actual):日期逻辑由 query_days 范围改为读 offset 算单日 target=今天-offset,起止同日;百世仍走当日 - config.example.yaml:query_days 标记为已废弃 修复启动时显示上一会话陈旧登录态: - state_store:新增 reset_login_states(登录态会话级,启动重置为 unknown;数据态会话无关、保留) - runtime.launch_and_prepare:启动时对各站重置登录态,心跳就绪后重新探测写真实值 Co-Authored-By: Claude --- config.example.yaml | 9 ++++---- runtime.py | 7 ++++++ server.py | 25 +++++++++++++++++++++ site_anneng.py | 22 +++++++++--------- site_shunxin.py | 47 +++++++++++++-------------------------- site_yunda.py | 44 ++++++++++++++---------------------- site_zto.py | 49 +++++++++++++++------------------------- state_store.py | 54 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 151 insertions(+), 106 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index c339cf3..eae651c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -24,7 +24,7 @@ debug: # 顺心捷达 (https://sxne.sxjdfreight.com) # ---------------------------------------------------------------------------- shunxin: - # 应到 / 实到数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 + # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移(0=今天,1=昨天…,存 state.db),此项不再生效。 query_days: 1 # ---------------------------------------------------------------------------- @@ -39,8 +39,7 @@ baishi: # 中通快运 (https://ws.zto56.com/) # ---------------------------------------------------------------------------- zto: - # 应到货物数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 - # 建议保持 1。设置过大且超出日历视窗时,会自动降级为仅查询当天。 + # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移(0=今天,1=昨天…,存 state.db),此项不再生效。 query_days: 1 # ---------------------------------------------------------------------------- @@ -53,7 +52,7 @@ yunda: username: "YOUR_USERNAME_HERE" password: "YOUR_PASSWORD_HERE" - # 应到 / 实到数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 + # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移(0=今天,1=昨天…,存 state.db),此项不再生效。 query_days: 1 # ---------------------------------------------------------------------------- @@ -63,7 +62,7 @@ yunda: # Electron 可执行文件(自动选取一个空闲端口作为 --remote-debugging-port,避免端口冲突), # 启动后请在应用内手动登录,main_router 会自动轮询判断是否进入主页。 anneng: - # 应到(运单信息)数据的查询时间范围(单位:天,向前回溯 N 天至今天)。 + # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移(0=今天,1=昨天…,存 state.db),此项不再生效。 query_days: 1 # 安能 Electron 应用的可执行文件路径。 diff --git a/runtime.py b/runtime.py index b4d34e3..8ad8fbe 100644 --- a/runtime.py +++ b/runtime.py @@ -227,6 +227,13 @@ def launch_and_prepare(debug_mode=False, debug_target=""): print("⚠️ 已启用安能但 config.yaml 未配置 anneng.app_path,将跳过安能。") anneng_active = False + # 2.5 重置各站登录态为 unknown:登录态是会话级的,避免显示上一会话残留的陈旧登录态。 + # 数据态(文件就绪)会话无关、保留不动;心跳就绪后会重新探测写真实值。 + reset_sites = list(active_sites.keys()) + if anneng_active: + reset_sites.append("安能") + state_store.reset_login_states(reset_sites) + # 3. 启动 Playwright(不用 with,改 .start(),由 RuntimeContext.stop() 收尾) pw = sync_playwright().start() browser = pw.chromium.launch(headless=False) diff --git a/server.py b/server.py index 4bcc8f9..929f52c 100644 --- a/server.py +++ b/server.py @@ -33,6 +33,10 @@ from runtime import ( run_heartbeat, ) +# 全部站点;百世固定下载当天,不可配置偏移 +ALL_SITES = ["顺心", "百世", "中通", "韵达", "安能"] +CONFIGURABLE_SITES = {"顺心", "中通", "韵达", "安能"} + # 任务队列:元素 (task_id, task_spec)。主线程投递,worker 消费。 task_queue: "queue.Queue" = queue.Queue() # worker 运行状态(主线程只读,worker 写) @@ -134,6 +138,27 @@ def get_status(): } +class OffsetRequest(BaseModel): + date_offset: int + + +@app.get("/config") +def get_config(): + """各站下载日期偏移(0=今天,1=昨天…);百世恒 0。""" + offsets = state_store.get_all_offsets() + return {site: offsets.get(site, 0) for site in ALL_SITES} + + +@app.put("/config/{site}") +def set_config(site: str, req: OffsetRequest): + if site == "百世": + raise HTTPException(status_code=400, detail="百世固定下载当天,不可配置偏移") + if site not in CONFIGURABLE_SITES: + raise HTTPException(status_code=400, detail=f"未知站点: {site}") + stored = state_store.set_offset(site, req.date_offset) + return {"site": site, "date_offset": stored} + + @app.get("/data/{filename}") def download_data(filename: str): """下载 downloads/ 下的数据文件(防路径穿越)。""" diff --git a/site_anneng.py b/site_anneng.py index 52835d5..486e10b 100644 --- a/site_anneng.py +++ b/site_anneng.py @@ -40,6 +40,7 @@ import yaml sys.stdout.reconfigure(encoding="utf-8") from paths import DOWNLOAD_DIR, CONFIG_PATH +import state_store def with_retry(site_name, label, flow, reset, max_attempts=3): @@ -866,12 +867,13 @@ def anneng_expected_download_impl(): # 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对 _remove_if_exists(os.path.join(download_dir, FINAL_FILENAME)) - query_days = _load_query_days() + offset = state_store.get_offset("安能") today = datetime.now() - start = today - timedelta(days=(query_days - 1)) - start_str = f"{start.year}-{start.month:02d}-{start.day:02d}" - today_str = f"{today.year}-{today.month:02d}-{today.day:02d}" - print(f">> 查询时间范围: [{start_str}] 至 [{today_str}](近 {query_days} 天)") + target = today - timedelta(days=offset) + target_str = f"{target.year}-{target.month:02d}-{target.day:02d}" + start_str = target_str + today_str = target_str + print(f">> 查询日期: [{target_str}](偏移 {offset},0=今天)") main_cdp = find_main_page_cdp() export_times = [] @@ -1171,12 +1173,12 @@ def anneng_actual_download_impl(): # 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对 _remove_if_exists(os.path.join(download_dir, ACTUAL_FINAL_FILENAME)) - days = _load_query_days() + offset = state_store.get_offset("安能") today = datetime.now() - start = today - timedelta(days=days - 1) - start_str = f"{start.year}/{start.month:02d}/{start.day:02d} 00:00:00" - end_str = f"{today.year}/{today.month:02d}/{today.day:02d} 23:59:59" - print(f">> 扫描时间范围: [{start_str}] 至 [{end_str}](近 {days} 天)") + target = today - timedelta(days=offset) + start_str = f"{target.year}/{target.month:02d}/{target.day:02d} 00:00:00" + end_str = f"{target.year}/{target.month:02d}/{target.day:02d} 23:59:59" + print(f">> 扫描日期: [{start_str} 至 {end_str}](偏移 {offset},0=今天)") main_cdp = find_main_page_cdp() try: diff --git a/site_shunxin.py b/site_shunxin.py index e5f0104..28c976d 100644 --- a/site_shunxin.py +++ b/site_shunxin.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta import pandas as pd from paths import DOWNLOAD_DIR, CONFIG_PATH +import state_store def with_retry(site_name, label, flow, reset, max_attempts=3): @@ -214,23 +215,14 @@ def shunxin_expected_download_impl(page, out_tag=""): page.get_by_role("button", name="强卸").wait_for(state="visible") print("✅ 车辆点到界面加载完毕") - # 2. 从配置读取时间偏移量并动态设置日期选择器 - query_days = 1 - try: - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - query_days = int(config.get("shunxin", {}).get("query_days", 1)) - except Exception: - pass - + # 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日 + offset = state_store.get_offset("顺心") today = datetime.now() - start_date = today - timedelta(days=(query_days - 1)) - - today_str = today.strftime("%Y-%m-%d") - start_date_str = start_date.strftime("%Y-%m-%d") - - print(f">> 正在设置查询时间范围: [{start_date_str}] 至 [{today_str}]...") + target = today - timedelta(days=offset) + target_str = target.strftime("%Y-%m-%d") + start_date_str = target_str + today_str = target_str + print(f">> 正在设置查询日期: [{target_str}](偏移 {offset},0=今天)...") # 分两步精准呼出和点击时间控件 print(" >> 设置起始时间...") @@ -541,23 +533,14 @@ def shunxin_actual_download_impl(page, out_tag=""): page.get_by_role("radio", name="1天").wait_for(state="visible") print("✅ 卸车扫描记录界面加载完毕") - # 2. 从配置读取时间并设置日期选择器 - query_days = 1 - try: - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - query_days = int(config.get("shunxin", {}).get("query_days", 1)) - except Exception: - pass - + # 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日 + offset = state_store.get_offset("顺心") today = datetime.now() - start_date = today - timedelta(days=(query_days - 1)) - - today_str = today.strftime("%Y-%m-%d") - start_date_str = start_date.strftime("%Y-%m-%d") - - print(f">> 正在设置查询时间范围: [{start_date_str}] 至 [{today_str}]...") + target = today - timedelta(days=offset) + target_str = target.strftime("%Y-%m-%d") + start_date_str = target_str + today_str = target_str + print(f">> 正在设置查询日期: [{target_str}](偏移 {offset},0=今天)...") # 分两步精准呼出和点击时间控件 print(" >> 设置起始时间...") diff --git a/site_yunda.py b/site_yunda.py index dfaf9f6..2ab75fc 100644 --- a/site_yunda.py +++ b/site_yunda.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta import pandas as pd from paths import DOWNLOAD_DIR, CONFIG_PATH +import state_store def with_retry(site_name, label, flow, reset, max_attempts=3): @@ -167,23 +168,14 @@ def yunda_expected_download_impl(page): ws_frame.locator("#startTime").wait_for(state="attached", timeout=15000) print("✅ 进站交接单查询页面就绪") - # 2. 从配置文件中解析并计算绝对日期跨度 - query_days = 1 - try: - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - query_days = int(config.get("yunda", {}).get("query_days", 1)) - except Exception as e: - print(f" ⚠️ 读取 config.yaml 失败,默认查询 1 天: {e}") - + # 2. 读取服务端日期偏移(0=今天,1=昨天…),单日范围:起止同日 + offset = state_store.get_offset("韵达") today = datetime.now() - start_date = today - timedelta(days=(query_days - 1)) - - today_ymd = f"{today.year}-{today.month}-{today.day}" - start_date_ymd = f"{start_date.year}-{start_date.month}-{start_date.day}" - - print(f">> 设置查询时间范围: [{start_date_ymd}] 至 [{today_ymd}]") + target = today - timedelta(days=offset) + target_ymd = f"{target.year}-{target.month}-{target.day}" + start_date_ymd = target_ymd + today_ymd = target_ymd + print(f">> 设置查询日期: [{target_ymd}](偏移 {offset},0=今天)") # 设定起始时间 print(" >> 设置起始时间...") @@ -422,19 +414,15 @@ def yunda_actual_download_impl(page): ).first.wait_for(state="visible", timeout=15000) print("✅ 扫描记录查询页面已初始化") - query_days = 1 - try: - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - query_days = int(config.get("yunda", {}).get("query_days", 1)) - except Exception: - pass - + offset = state_store.get_offset("韵达") today = datetime.now() - start_date = today - timedelta(days=(query_days - 1)) - - print(f">> 设置实到查询时间范围: [近 {query_days} 天]") + target = today - timedelta(days=offset) + start_date = target # 单日范围:起止同日 + today = target # 让下方"截止时间"选择器也指向 target + print( + f">> 设置实到查询日期: [{target.year}-{target.month}-{target.day}]" + f"(偏移 {offset},0=今天)" + ) print(" >> 正在设定起始时间...") ws_frame.locator("#startDate").click() diff --git a/site_zto.py b/site_zto.py index f050f47..11dfd87 100644 --- a/site_zto.py +++ b/site_zto.py @@ -8,6 +8,7 @@ from datetime import datetime import pandas as pd from paths import DOWNLOAD_DIR, CONFIG_PATH +import state_store def with_retry(site_name, label, flow, reset, max_attempts=3): @@ -141,17 +142,10 @@ def zto_expected_download_impl(page): print(">> 正在检测表格统计面板 (#inEwbCount)...") ewb_frame.locator("#inEwbCount").wait_for(state="attached", timeout=15000) - # 读取 YAML 配置设定天数 - query_days = 1 - try: - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - query_days = int(config.get("zto", {}).get("query_days", 1)) - except Exception as e: - print(f" ⚠️ 读取 config.yaml 失败,默认查询 1 天: {e}") + # 读取服务端日期偏移(0=今天,1=昨天…),单日:起止同日 + offset = state_store.get_offset("中通") - print(f">> 正在设定查询时间范围为近【{query_days}】天...") + print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...") ewb_frame.locator("#beginDate").click() page.wait_for_timeout(500) @@ -161,15 +155,15 @@ def zto_expected_download_impl(page): today_time_str = today_cell.get_attribute("time") if today_time_str: today_time = int(today_time_str) - start_time = today_time - (query_days - 1) * 86400000 - start_cell = ewb_frame.locator(f"td div.day[time='{start_time}']").first + target_time = today_time - offset * 86400000 + target_cell = ewb_frame.locator(f"td div.day[time='{target_time}']").first - if start_cell.is_visible(): - start_cell.click() + if target_cell.is_visible(): + target_cell.click() page.wait_for_timeout(300) - today_cell.click() + target_cell.click() else: - print(" ⚠️ 设定的天数过大,不在当前日历视窗内,自动降级为查询当天。") + print(" ⚠️ 偏移日期不在当前日历视窗内,自动降级为查询当天。") today_cell.click() page.wait_for_timeout(300) today_cell.click() @@ -369,17 +363,10 @@ def zto_actual_download_impl(page): print(">> 正在检测主页面 (#daterange)...") arr_frame.locator("#daterange").wait_for(state="attached", timeout=15000) - # 2. 读取 YAML 并设定时间范围 - query_days = 1 - try: - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - query_days = int(config.get("zto", {}).get("query_days", 1)) - except Exception: - pass + # 2. 读取服务端日期偏移(0=今天,1=昨天…),单日:起止同日 + offset = state_store.get_offset("中通") - print(f">> 正在设定查询时间范围为近【{query_days}】天...") + print(f">> 正在设定查询日期: 偏移 {offset}(0=今天)...") arr_frame.locator("#daterange").click() page.wait_for_timeout(500) @@ -389,15 +376,15 @@ def zto_actual_download_impl(page): today_time_str = today_cell.get_attribute("time") if today_time_str: today_time = int(today_time_str) - start_time = today_time - (query_days - 1) * 86400000 - start_cell = arr_frame.locator(f"td div.day[time='{start_time}']").first + target_time = today_time - offset * 86400000 + target_cell = arr_frame.locator(f"td div.day[time='{target_time}']").first # 日期格子用 _dom_click 直接派发事件:Playwright 的 .click() 会先 hover 格子, # 触发“范围长度”提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。 - if start_cell.is_visible(): - _dom_click(start_cell) + if target_cell.is_visible(): + _dom_click(target_cell) page.wait_for_timeout(300) - _dom_click(today_cell) + _dom_click(target_cell) else: _dom_click(today_cell) page.wait_for_timeout(300) diff --git a/state_store.py b/state_store.py index 8d00cdb..ff3aba0 100644 --- a/state_store.py +++ b/state_store.py @@ -56,6 +56,13 @@ def init_db(): error TEXT ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS site_config ( + site TEXT PRIMARY KEY, + date_offset INTEGER NOT NULL DEFAULT 0, + updated_at TEXT + ) + """) conn.commit() @@ -120,6 +127,14 @@ def set_login_state(site, logged_in): _upsert(conn, site, login_state=state, login_checked_at=_now()) +def reset_login_states(sites): + """启动时把给定站点的登录态重置为 unknown(避免显示上一会话的陈旧登录态)。 + 登录态是会话级的;数据态(文件就绪)会话无关、保留不动,心跳就绪后会重新探测。""" + with sqlite3.connect(STATE_DB_PATH) as conn: + for site in sites: + _upsert(conn, site, login_state=LOGIN_UNKNOWN, login_checked_at="") + + def set_data_state(site, kind, ready, generated_at): """更新单站数据态。kind: 'expected'/'actual';ready: bool;generated_at: str 或 ''。""" key_ready = "expected_ready" if kind == "expected" else "actual_ready" @@ -154,6 +169,45 @@ def get_all_status(): } +# ============================ 下载日期偏移(site_config)============================ + +MAX_DATE_OFFSET = 30 # 0=今天,最大回溯 30 天 + + +def get_offset(site): + """读取单站下载日期偏移(0=今天,1=昨天…);未配置返回 0。""" + if not os.path.exists(STATE_DB_PATH): + return 0 + with sqlite3.connect(STATE_DB_PATH) as conn: + row = conn.execute( + "SELECT date_offset FROM site_config WHERE site=?", (site,) + ).fetchone() + return int(row[0]) if row else 0 + + +def set_offset(site, offset): + """设置单站下载日期偏移,钳制到 [0, MAX_DATE_OFFSET],返回实际写入值。""" + offset = max(0, min(MAX_DATE_OFFSET, int(offset))) + with sqlite3.connect(STATE_DB_PATH) as conn: + conn.execute( + "INSERT INTO site_config (site, date_offset, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(site) DO UPDATE SET " + "date_offset=excluded.date_offset, updated_at=excluded.updated_at", + (site, offset, _now()), + ) + conn.commit() + return offset + + +def get_all_offsets(): + """返回 {site: date_offset};未配置站点不在结果中(调用方按 0 兜底)。""" + if not os.path.exists(STATE_DB_PATH): + return {} + with sqlite3.connect(STATE_DB_PATH) as conn: + rows = conn.execute("SELECT site, date_offset FROM site_config").fetchall() + return {r[0]: int(r[1]) for r in rows} + + # ============================ 任务历史 ============================