按站点指定下载日期偏移(0=今天…30)+ 修复启动陈旧登录态

偏移量功能(前端/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 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-07-17 21:27:52 +08:00
parent 66bd8af421
commit 26a94999dc
8 changed files with 151 additions and 106 deletions

View File

@@ -24,7 +24,7 @@ debug:
# 顺心捷达 (https://sxne.sxjdfreight.com) # 顺心捷达 (https://sxne.sxjdfreight.com)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
shunxin: shunxin:
# 应到 / 实到数据的查询时间范围(单位:天,向前回溯 N 天至今天) # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移0=今天1=昨天…,存 state.db此项不再生效
query_days: 1 query_days: 1
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -39,8 +39,7 @@ baishi:
# 中通快运 (https://ws.zto56.com/) # 中通快运 (https://ws.zto56.com/)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
zto: zto:
# 应到货物数据的查询时间范围(单位:天,向前回溯 N 天至今天) # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移0=今天1=昨天…,存 state.db此项不再生效
# 建议保持 1。设置过大且超出日历视窗时会自动降级为仅查询当天。
query_days: 1 query_days: 1
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -53,7 +52,7 @@ yunda:
username: "YOUR_USERNAME_HERE" username: "YOUR_USERNAME_HERE"
password: "YOUR_PASSWORD_HERE" password: "YOUR_PASSWORD_HERE"
# 应到 / 实到数据的查询时间范围(单位:天,向前回溯 N 天至今天) # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移0=今天1=昨天…,存 state.db此项不再生效
query_days: 1 query_days: 1
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
@@ -63,7 +62,7 @@ yunda:
# Electron 可执行文件(自动选取一个空闲端口作为 --remote-debugging-port避免端口冲突 # Electron 可执行文件(自动选取一个空闲端口作为 --remote-debugging-port避免端口冲突
# 启动后请在应用内手动登录main_router 会自动轮询判断是否进入主页。 # 启动后请在应用内手动登录main_router 会自动轮询判断是否进入主页。
anneng: anneng:
# 应到(运单信息)数据的查询时间范围(单位:天,向前回溯 N 天至今天) # 【已废弃】下载日期改由 Web 前端/API 按站点设偏移0=今天1=昨天…,存 state.db此项不再生效
query_days: 1 query_days: 1
# 安能 Electron 应用的可执行文件路径。 # 安能 Electron 应用的可执行文件路径。

View File

@@ -227,6 +227,13 @@ def launch_and_prepare(debug_mode=False, debug_target=""):
print("⚠️ 已启用安能但 config.yaml 未配置 anneng.app_path将跳过安能。") print("⚠️ 已启用安能但 config.yaml 未配置 anneng.app_path将跳过安能。")
anneng_active = False 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() 收尾) # 3. 启动 Playwright不用 with改 .start(),由 RuntimeContext.stop() 收尾)
pw = sync_playwright().start() pw = sync_playwright().start()
browser = pw.chromium.launch(headless=False) browser = pw.chromium.launch(headless=False)

View File

@@ -33,6 +33,10 @@ from runtime import (
run_heartbeat, run_heartbeat,
) )
# 全部站点;百世固定下载当天,不可配置偏移
ALL_SITES = ["顺心", "百世", "中通", "韵达", "安能"]
CONFIGURABLE_SITES = {"顺心", "中通", "韵达", "安能"}
# 任务队列:元素 (task_id, task_spec)。主线程投递worker 消费。 # 任务队列:元素 (task_id, task_spec)。主线程投递worker 消费。
task_queue: "queue.Queue" = queue.Queue() task_queue: "queue.Queue" = queue.Queue()
# worker 运行状态主线程只读worker 写) # 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}") @app.get("/data/{filename}")
def download_data(filename: str): def download_data(filename: str):
"""下载 downloads/ 下的数据文件(防路径穿越)。""" """下载 downloads/ 下的数据文件(防路径穿越)。"""

View File

@@ -40,6 +40,7 @@ import yaml
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
from paths import DOWNLOAD_DIR, CONFIG_PATH from paths import DOWNLOAD_DIR, CONFIG_PATH
import state_store
def with_retry(site_name, label, flow, reset, max_attempts=3): 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)) _remove_if_exists(os.path.join(download_dir, FINAL_FILENAME))
query_days = _load_query_days() offset = state_store.get_offset("安能")
today = datetime.now() today = datetime.now()
start = today - timedelta(days=(query_days - 1)) target = today - timedelta(days=offset)
start_str = f"{start.year}-{start.month:02d}-{start.day:02d}" target_str = f"{target.year}-{target.month:02d}-{target.day:02d}"
today_str = f"{today.year}-{today.month:02d}-{today.day:02d}" start_str = target_str
print(f">> 查询时间范围: [{start_str}] 至 [{today_str}](近 {query_days} 天)") today_str = target_str
print(f">> 查询日期: [{target_str}](偏移 {offset}0=今天)")
main_cdp = find_main_page_cdp() main_cdp = find_main_page_cdp()
export_times = [] export_times = []
@@ -1171,12 +1173,12 @@ def anneng_actual_download_impl():
# 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对 # 清理上次的最终文件,避免本次无数据/失败时残留旧数据误导比对
_remove_if_exists(os.path.join(download_dir, ACTUAL_FINAL_FILENAME)) _remove_if_exists(os.path.join(download_dir, ACTUAL_FINAL_FILENAME))
days = _load_query_days() offset = state_store.get_offset("安能")
today = datetime.now() today = datetime.now()
start = today - timedelta(days=days - 1) target = today - timedelta(days=offset)
start_str = f"{start.year}/{start.month:02d}/{start.day:02d} 00:00:00" start_str = f"{target.year}/{target.month:02d}/{target.day:02d} 00:00:00"
end_str = f"{today.year}/{today.month:02d}/{today.day:02d} 23:59:59" end_str = f"{target.year}/{target.month:02d}/{target.day:02d} 23:59:59"
print(f">> 扫描时间范围: [{start_str}][{end_str}] {days} 天)") print(f">> 扫描日期: [{start_str}{end_str}]偏移 {offset}0=今天)")
main_cdp = find_main_page_cdp() main_cdp = find_main_page_cdp()
try: try:

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
import pandas as pd import pandas as pd
from paths import DOWNLOAD_DIR, CONFIG_PATH from paths import DOWNLOAD_DIR, CONFIG_PATH
import state_store
def with_retry(site_name, label, flow, reset, max_attempts=3): 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") page.get_by_role("button", name="强卸").wait_for(state="visible")
print("✅ 车辆点到界面加载完毕") print("✅ 车辆点到界面加载完毕")
# 2. 从配置读取时间偏移量并动态设置日期选择器 # 2. 读取服务端日期偏移0=今天1=昨天…),单日范围:起止同日
query_days = 1 offset = state_store.get_offset("顺心")
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
today = datetime.now() today = datetime.now()
start_date = today - timedelta(days=(query_days - 1)) target = today - timedelta(days=offset)
target_str = target.strftime("%Y-%m-%d")
today_str = today.strftime("%Y-%m-%d") start_date_str = target_str
start_date_str = start_date.strftime("%Y-%m-%d") today_str = target_str
print(f">> 正在设置查询日期: [{target_str}](偏移 {offset}0=今天)...")
print(f">> 正在设置查询时间范围: [{start_date_str}] 至 [{today_str}]...")
# 分两步精准呼出和点击时间控件 # 分两步精准呼出和点击时间控件
print(" >> 设置起始时间...") print(" >> 设置起始时间...")
@@ -541,23 +533,14 @@ def shunxin_actual_download_impl(page, out_tag=""):
page.get_by_role("radio", name="1天").wait_for(state="visible") page.get_by_role("radio", name="1天").wait_for(state="visible")
print("✅ 卸车扫描记录界面加载完毕") print("✅ 卸车扫描记录界面加载完毕")
# 2. 从配置读取时间并设置日期选择器 # 2. 读取服务端日期偏移0=今天1=昨天…),单日范围:起止同日
query_days = 1 offset = state_store.get_offset("顺心")
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
today = datetime.now() today = datetime.now()
start_date = today - timedelta(days=(query_days - 1)) target = today - timedelta(days=offset)
target_str = target.strftime("%Y-%m-%d")
today_str = today.strftime("%Y-%m-%d") start_date_str = target_str
start_date_str = start_date.strftime("%Y-%m-%d") today_str = target_str
print(f">> 正在设置查询日期: [{target_str}](偏移 {offset}0=今天)...")
print(f">> 正在设置查询时间范围: [{start_date_str}] 至 [{today_str}]...")
# 分两步精准呼出和点击时间控件 # 分两步精准呼出和点击时间控件
print(" >> 设置起始时间...") print(" >> 设置起始时间...")

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
import pandas as pd import pandas as pd
from paths import DOWNLOAD_DIR, CONFIG_PATH from paths import DOWNLOAD_DIR, CONFIG_PATH
import state_store
def with_retry(site_name, label, flow, reset, max_attempts=3): 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) ws_frame.locator("#startTime").wait_for(state="attached", timeout=15000)
print("✅ 进站交接单查询页面就绪") print("✅ 进站交接单查询页面就绪")
# 2. 从配置文件中解析并计算绝对日期跨度 # 2. 读取服务端日期偏移0=今天1=昨天…),单日范围:起止同日
query_days = 1 offset = state_store.get_offset("韵达")
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}")
today = datetime.now() today = datetime.now()
start_date = today - timedelta(days=(query_days - 1)) target = today - timedelta(days=offset)
target_ymd = f"{target.year}-{target.month}-{target.day}"
today_ymd = f"{today.year}-{today.month}-{today.day}" start_date_ymd = target_ymd
start_date_ymd = f"{start_date.year}-{start_date.month}-{start_date.day}" today_ymd = target_ymd
print(f">> 设置查询日期: [{target_ymd}](偏移 {offset}0=今天)")
print(f">> 设置查询时间范围: [{start_date_ymd}] 至 [{today_ymd}]")
# 设定起始时间 # 设定起始时间
print(" >> 设置起始时间...") print(" >> 设置起始时间...")
@@ -422,19 +414,15 @@ def yunda_actual_download_impl(page):
).first.wait_for(state="visible", timeout=15000) ).first.wait_for(state="visible", timeout=15000)
print("✅ 扫描记录查询页面已初始化") print("✅ 扫描记录查询页面已初始化")
query_days = 1 offset = state_store.get_offset("韵达")
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
today = datetime.now() today = datetime.now()
start_date = today - timedelta(days=(query_days - 1)) target = today - timedelta(days=offset)
start_date = target # 单日范围:起止同日
print(f">> 设置实到查询时间范围: [近 {query_days} 天]") today = target # 让下方"截止时间"选择器也指向 target
print(
f">> 设置实到查询日期: [{target.year}-{target.month}-{target.day}]"
f"(偏移 {offset}0=今天)"
)
print(" >> 正在设定起始时间...") print(" >> 正在设定起始时间...")
ws_frame.locator("#startDate").click() ws_frame.locator("#startDate").click()

View File

@@ -8,6 +8,7 @@ from datetime import datetime
import pandas as pd import pandas as pd
from paths import DOWNLOAD_DIR, CONFIG_PATH from paths import DOWNLOAD_DIR, CONFIG_PATH
import state_store
def with_retry(site_name, label, flow, reset, max_attempts=3): def with_retry(site_name, label, flow, reset, max_attempts=3):
@@ -141,17 +142,10 @@ def zto_expected_download_impl(page):
print(">> 正在检测表格统计面板 (#inEwbCount)...") print(">> 正在检测表格统计面板 (#inEwbCount)...")
ewb_frame.locator("#inEwbCount").wait_for(state="attached", timeout=15000) ewb_frame.locator("#inEwbCount").wait_for(state="attached", timeout=15000)
# 读取 YAML 配置设定天数 # 读取服务端日期偏移0=今天1=昨天…),单日:起止同日
query_days = 1 offset = state_store.get_offset("中通")
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}")
print(f">> 正在设定查询时间范围为近【{query_days}】天...") print(f">> 正在设定查询日期: 偏移 {offset}0=今天)...")
ewb_frame.locator("#beginDate").click() ewb_frame.locator("#beginDate").click()
page.wait_for_timeout(500) page.wait_for_timeout(500)
@@ -161,15 +155,15 @@ def zto_expected_download_impl(page):
today_time_str = today_cell.get_attribute("time") today_time_str = today_cell.get_attribute("time")
if today_time_str: if today_time_str:
today_time = int(today_time_str) today_time = int(today_time_str)
start_time = today_time - (query_days - 1) * 86400000 target_time = today_time - offset * 86400000
start_cell = ewb_frame.locator(f"td div.day[time='{start_time}']").first target_cell = ewb_frame.locator(f"td div.day[time='{target_time}']").first
if start_cell.is_visible(): if target_cell.is_visible():
start_cell.click() target_cell.click()
page.wait_for_timeout(300) page.wait_for_timeout(300)
today_cell.click() target_cell.click()
else: else:
print(" ⚠️ 设定的天数过大,不在当前日历视窗内,自动降级为查询当天。") print(" ⚠️ 偏移日期不在当前日历视窗内,自动降级为查询当天。")
today_cell.click() today_cell.click()
page.wait_for_timeout(300) page.wait_for_timeout(300)
today_cell.click() today_cell.click()
@@ -369,17 +363,10 @@ def zto_actual_download_impl(page):
print(">> 正在检测主页面 (#daterange)...") print(">> 正在检测主页面 (#daterange)...")
arr_frame.locator("#daterange").wait_for(state="attached", timeout=15000) arr_frame.locator("#daterange").wait_for(state="attached", timeout=15000)
# 2. 读取 YAML 并设定时间范围 # 2. 读取服务端日期偏移0=今天1=昨天…),单日:起止同日
query_days = 1 offset = state_store.get_offset("中通")
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
print(f">> 正在设定查询时间范围为近【{query_days}】天...") print(f">> 正在设定查询日期: 偏移 {offset}0=今天)...")
arr_frame.locator("#daterange").click() arr_frame.locator("#daterange").click()
page.wait_for_timeout(500) page.wait_for_timeout(500)
@@ -389,15 +376,15 @@ def zto_actual_download_impl(page):
today_time_str = today_cell.get_attribute("time") today_time_str = today_cell.get_attribute("time")
if today_time_str: if today_time_str:
today_time = int(today_time_str) today_time = int(today_time_str)
start_time = today_time - (query_days - 1) * 86400000 target_time = today_time - offset * 86400000
start_cell = arr_frame.locator(f"td div.day[time='{start_time}']").first target_cell = arr_frame.locator(f"td div.day[time='{target_time}']").first
# 日期格子用 _dom_click 直接派发事件Playwright 的 .click() 会先 hover 格子, # 日期格子用 _dom_click 直接派发事件Playwright 的 .click() 会先 hover 格子,
# 触发“范围长度”提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。 # 触发“范围长度”提示气泡(.date-range-length-tip)盖住格子,导致点击被判遮挡而超时。
if start_cell.is_visible(): if target_cell.is_visible():
_dom_click(start_cell) _dom_click(target_cell)
page.wait_for_timeout(300) page.wait_for_timeout(300)
_dom_click(today_cell) _dom_click(target_cell)
else: else:
_dom_click(today_cell) _dom_click(today_cell)
page.wait_for_timeout(300) page.wait_for_timeout(300)

View File

@@ -56,6 +56,13 @@ def init_db():
error TEXT 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() conn.commit()
@@ -120,6 +127,14 @@ def set_login_state(site, logged_in):
_upsert(conn, site, login_state=state, login_checked_at=_now()) _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): def set_data_state(site, kind, ready, generated_at):
"""更新单站数据态。kind: 'expected'/'actual'ready: boolgenerated_at: str 或 ''""" """更新单站数据态。kind: 'expected'/'actual'ready: boolgenerated_at: str 或 ''"""
key_ready = "expected_ready" if kind == "expected" else "actual_ready" 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}
# ============================ 任务历史 ============================ # ============================ 任务历史 ============================