mirror of
https://github.com/rachelos/we-mp-rss.git
synced 2026-08-30 18:01:55 +08:00
8628d91496
- 新增 scripts/host_weread_refresh_agent.py:宿主机常驻刷新代理(verify-first, Cookie 有效时秒回不启动浏览器;失效时无头刷新;登录态过期才需扫码) - 新增 launchd plist(KeepAlive)常驻代理 - jobs/mps.add_job:weread_mp 模式下,执行/定时同步前自动请代理刷新, 刷新失败(需扫码)则中止任务并明确报错,不再静默同步 0 篇 - 修复冷却期 bug:冷却期内不再无条件跳过,Cookie 失效时强制刷新 - 修复扫码等待失效 bug:扫码模式不注入过期种子 Cookie 且清空上下文, 判定改为必须通过真实接口校验,否则窗口保持等待(5 分钟 -> 10 分钟)
157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
||
"""宿主机微信读书 Cookie 刷新代理(常驻,由 launchd 启动)。
|
||
|
||
为什么需要它
|
||
------------
|
||
- 微信读书的登录态 profile 由 macOS 钥匙串加密,容器内 Linux Chromium **解密不了**,
|
||
因此浏览器刷新动作必须在宿主机用本机 Chrome 完成。
|
||
- 但用户希望「点击执行 / 定时任务」时,若 Cookie 过期就**自动刷新**再同步文章,
|
||
而不是手动跑到宿主机敲命令。
|
||
- 解法:宿主机常驻一个轻量 HTTP 代理。容器在同步前调用其 ``/refresh`` 端点,
|
||
代理用本机 Chrome 做无头刷新(登录态失效时再弹窗扫码),结果写回数据卷
|
||
``wx.lic``;容器随后读到最新 Cookie 去同步文章。
|
||
|
||
刷新策略(verify-first,尽量不启动浏览器)
|
||
----------------------------------------
|
||
1. 先验证当前 wx.lic 里的 Cookie 是否仍能拉到数据;有效则直接返回(秒级,不启动浏览器)。
|
||
2. 无效则做**无头刷新**(复用持久化 profile,通常秒级~数十秒)。
|
||
3. 无头仍失败(登录态过期)→ 后台开一个可见 Chrome 窗口让用户扫码,HTTP 立即返回
|
||
``needs_scan``,扫码完成后 Cookie 自动写回,用户重新点一次执行即可。
|
||
|
||
容器侧通过环境变量 ``WEREAD_REFRESH_AGENT_URL``(默认
|
||
``http://host.docker.internal:9876/refresh``)找到本代理。
|
||
|
||
注意:本文件运行在**宿主机**(macOS),使用宿主机 Python venv + Playwright + 本机 Chrome,
|
||
不是容器内的 Linux Chromium。
|
||
"""
|
||
import os
|
||
import sys
|
||
import json
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
|
||
# 日志实时落盘(launchd 场景 stdout 重定向为文件时避免全缓冲滞后)
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
sys.stdout.reconfigure(line_buffering=True)
|
||
|
||
# 让脚本能 import 项目的 core 包
|
||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
if ROOT not in sys.path:
|
||
sys.path.insert(0, ROOT)
|
||
|
||
# 指向宿主机上容器挂载的数据卷 wx.lic / profile(与容器 /app/data 同一物理目录)
|
||
os.environ.setdefault("WEREAD_LIC_PATH", "/Users/yangqing/wechat-rss-data/wx.lic")
|
||
os.environ.setdefault(
|
||
"WEREAD_PROFILE_DIR",
|
||
"/Users/yangqing/wechat-rss-data/weread-chrome-profile",
|
||
)
|
||
|
||
from core.weread_cookie_refresh import ( # noqa: E402
|
||
refresh_weread_cookie,
|
||
_verify_cookie,
|
||
_load_weread_data,
|
||
)
|
||
|
||
HOST = os.environ.get("WEREAD_AGENT_HOST", "0.0.0.0")
|
||
PORT = int(os.environ.get("WEREAD_AGENT_PORT", "9876"))
|
||
|
||
# 串行化刷新,避免并发(如 07:55 定时 + 手动执行同时触发)争抢同一 profile
|
||
_refresh_lock = threading.Lock()
|
||
|
||
|
||
def do_refresh() -> dict:
|
||
"""执行一次「确保 Cookie 新鲜」的逻辑。
|
||
|
||
返回: ``{"ok": bool, "refreshed": bool, "needs_scan": bool, "message": str}``
|
||
"""
|
||
# 1) 当前 Cookie 仍有效 → 无需启动浏览器,直接返回
|
||
_, data = _load_weread_data()
|
||
cookie = (data.get("cookie") or "").strip()
|
||
if cookie and _verify_cookie(cookie):
|
||
return {
|
||
"ok": True,
|
||
"refreshed": False,
|
||
"needs_scan": False,
|
||
"message": "Cookie 仍有效,无需刷新",
|
||
}
|
||
|
||
# 2) 需要刷新:无头优先(cooldown_hours=0 强制真刷新,绕过 6h 冷却误判)
|
||
try:
|
||
ok = refresh_weread_cookie(verbose=True, headless_only=True, cooldown_hours=0)
|
||
except Exception as e:
|
||
ok = False
|
||
print(f"[agent] 无头刷新异常: {e}")
|
||
|
||
if ok:
|
||
return {
|
||
"ok": True,
|
||
"refreshed": True,
|
||
"needs_scan": False,
|
||
"message": "Cookie 已自动刷新",
|
||
}
|
||
|
||
# 3) 无头失败:登录态可能已过期,后台开可见窗口让用户扫码(不阻塞 HTTP 请求)
|
||
def _bg_scan():
|
||
try:
|
||
# 不带 headless_only → 无头失败会弹窗等待扫码(最长 5 分钟)
|
||
refresh_weread_cookie(verbose=True, cooldown_hours=0)
|
||
except Exception as e:
|
||
print(f"[agent] 后台扫码刷新异常: {e}")
|
||
|
||
threading.Thread(target=_bg_scan, daemon=True).start()
|
||
return {
|
||
"ok": False,
|
||
"refreshed": False,
|
||
"needs_scan": True,
|
||
"message": "Cookie 已过期,请在弹出的浏览器窗口中扫码登录微信读书",
|
||
}
|
||
|
||
|
||
class _Handler(BaseHTTPRequestHandler):
|
||
def _send(self, code: int, payload: dict):
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
self.send_response(code)
|
||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
def _refresh(self):
|
||
with _refresh_lock:
|
||
result = do_refresh()
|
||
self._send(200, result)
|
||
|
||
def do_GET(self):
|
||
path = self.path.split("?")[0].rstrip("/")
|
||
if path in ("/health", ""):
|
||
self._send(200, {"ok": True, "message": "agent alive"})
|
||
elif path == "/refresh":
|
||
self._refresh()
|
||
else:
|
||
self._send(404, {"ok": False, "message": "not found"})
|
||
|
||
def do_POST(self):
|
||
# /refresh 同时支持 POST(容器 urllib 默认 POST)
|
||
self._refresh()
|
||
|
||
def log_message(self, *args):
|
||
# 避免把每个请求打到 stderr(已在 do_refresh 内打印关键日志)
|
||
pass
|
||
|
||
|
||
def main():
|
||
os.makedirs(os.path.dirname(os.environ.get("WEREAD_LIC_PATH", "")) or ".", exist_ok=True)
|
||
server = ThreadingHTTPServer((HOST, PORT), _Handler)
|
||
print(f"[agent] 微信读书 Cookie 刷新代理已启动: http://{HOST}:{PORT}/refresh")
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
server.server_close()
|
||
print("[agent] 代理已停止")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|