瀏覽代碼

feat(core): 优化 Frida RPC 调用并增强超时重连能力

- 重构 JhsRawCodecClient,新增基于线程池的 RPC 调用超时机制
- 实现重连逻辑,针对可重试的 Frida 连接错误自动恢复会话
- 增加 CLI 调用的非阻塞 stdout/stderr 读取,避免死锁和卡死
- 异常时强制结束子进程,清理资源防止僵尸进程残留
- jhs_rpc_spider 中新增 TokenExpiredError,用于标识 token 过期状态
- 市场数据抓取支持多种异常类型重试,同时连续失败时触发 Frida 会话重连
- 更新 README,添加基于 ZygiskFrida 的使用说明与弹窗自动清除脚本介绍
- 提供 PowerShell 脚本用于启动/停止自动关闭升级弹窗的后台任务
charley 1 周之前
父節點
當前提交
7f9210b44e

+ 118 - 22
jhs_rpc_spider/README.md

@@ -1,4 +1,4 @@
-# raw_codec_rpc
+# raw_codec_rpc
 
 基于 Frida RPC 的 `raw_data` 编解码调用封装,核心依赖 App 运行时中的 Java 层逻辑(`gc.b.b` / `gc.a.intercept`)。
 
@@ -6,24 +6,32 @@
 
 - `jhs_raw_codec_rpc.js`:Frida 侧 RPC 脚本(实现 enc/dec)
 - `jhs_raw_codec_client.py`:Python 客户端封装(设备连接、attach、RPC 调用、CLI 兜底)
+- `jhs_rpc_spider.py`:主爬虫脚本(定时采集 + 入库)
 - `demo.py`:单页请求封装示例(可直接改为多页循环)
+- `auto_dismiss_update.js`:常驻 Frida 脚本(自动 dismiss App 启动时的"立即更新"弹窗)
+- `start_auto_dismiss.ps1` / `stop_auto_dismiss.ps1`:弹窗清除脚本的后台启动 / 停止
+- `start_jhs_zygiskfrida_bg.ps1` / `stop_jhs_zygiskfrida_bg.ps1`:旧版后台 hook 启停脚本(当前未使用,保留备查)
+- `application.yml`:配置文件(数据库等)
 - `requirements.txt`:Python 依赖清单
 
 ## 1. 环境要求
 
 ### 1.1 PC 端(Windows)
 
-- Python:3.10+(建议和你当前一致,3.10.8)
+- Python:3.12.10+
 - ADB:可用(`adb version` 正常)
 - Frida CLI:可用(`frida --version` 正常)
 - Python 包:`frida`、`frida-tools`、`requests`
 
-### 1.2 模拟器端(Android)
+### 1.2 设备端(Android)
 
+> **本项目使用 ZygiskFrida(Zygisk 模块)而非传统 frida-server。**
+> ZygiskFrida 会在 App 启动时自动将 frida-gadget 注入目标进程,无需手动推送或运行 frida-server。
+
+- 已 root(Magisk / KernelSU)并启用 Zygisk
+- 已安装 [ZygiskFrida](https://github.com/lico-n/ZygiskFrida) 模块,并在模块配置中指定目标包名 `com.jihuanshe`
 - 已安装并可启动目标 App:`com.jihuanshe`
-- 模拟器可 ADB 连接
-- `frida-server` 已推送到设备并可执行
-- `frida-server` 版本需与 PC 侧 `frida` 主版本一致
+- 设备可 ADB 连接
 
 ## 2. 安装步骤
 
@@ -45,35 +53,101 @@ frida --version
 adb version
 ```
 
-### 2.2 模拟器端安装 frida-server
+### 2.2 设备端安装 ZygiskFrida
 
-1. 下载与你 PC 侧 frida 主版本匹配的 `frida-server-*-android-*.xz`
-2. 解压得到 `frida-server`
-3. 推送并授权:
+> 本项目**不使用**传统的 `frida-server`,而是通过 ZygiskFrida 模块在 App 启动时自动注入 frida-gadget。
+
+1. 确保设备已 root 且 Magisk/KernelSU 中 **Zygisk 已开启**
+2. 下载 [ZygiskFrida](https://github.com/lico-n/ZygiskFrida/releases) 模块 zip,在 Magisk/KernelSU 中刷入
+3. 在 `/data/local/tmp/re.zyg.fri/target_packages.txt` 中添加目标包名:
 
-```bash
-adb -s <device_id> push frida-server /data/local/tmp/fs
-adb -s <device_id> shell "chmod 755 /data/local/tmp/fs"
+```
+com.jihuanshe
 ```
 
-4. 启动 frida-server:
+4. 重启设备使模块生效
+5. 验证:启动目标 App 后,在 PC 端执行:
 
 ```bash
-adb -s <device_id> shell "/data/local/tmp/fs &"
+frida -U -N com.jihuanshe
+```
+
+如果能正常 attach 说明 gadget 注入成功。
+
+### 2.3 弹窗清除脚本(按需启动)
+
+老版本 App 启动会弹"立即更新"提示,挡住登录等人工操作。提供常驻 Frida 脚本自动 dismiss:
+
+**启动**(重启 App → 等待 gadget 注入 → 挂载 dismiss 脚本):
+
+```powershell
+.\start_auto_dismiss.ps1               # 后台运行
+.\start_auto_dismiss.ps1 -Foreground   # 前台跑,看实时日志(推荐调试时用)
+.\start_auto_dismiss.ps1 -NoRestart    # App 已经在跑,跳过重启直接 attach
 ```
 
-5. 验证连接:
+可选参数:
+
+| 参数 | 默认值 | 说明 |
+|------|--------|------|
+| `-Package` | `com.jihuanshe` | 目标包名 |
+| `-DelayMs` | `12000` | 等待 ZygiskFrida 注入毫秒数 |
+| `-Adb` | `D:\platform-tools\adb.exe` | adb 路径 |
+| `-Frida` | `C:\Python\Python312\Scripts\frida.exe` | frida CLI 路径 |
+| `-Hook` | `auto_dismiss_update.js` | hook 脚本路径 |
+| `-NoRestart` | 关 | 加上则跳过 App 重启,直接 attach |
+| `-Foreground` | 关 | 前台运行(Ctrl+C 停止);不加则后台运行写日志文件 |
+
+**停止**:
+
+```powershell
+.\stop_auto_dismiss.ps1
+```
+
+**说明**:弹窗只挡人工操作,不影响爬虫 RPC(爬虫不碰 UI),所以该脚本只在「需要登录刷 token」时跑一次即可,**日常爬取不需要**。
+
+## 3. 日常使用流程
+
+### 3.1 场景 A:日常跑爬虫(token 还有效)
+
+只要 App 在手机上正常运行(ZygiskFrida 会在 App 启动时自动注入 gadget),直接:
 
 ```bash
-frida-ls-devices
-frida-ps -D <device_id>
+python .\jhs_rpc_spider.py
 ```
 
-## 3. 程序运行时的模拟器状态要求
+爬虫的 `JhsRawCodecClient` 会自己 attach 到 App 调 RPC,不需要任何其他守护进程。弹窗在不在都无所谓。
 
-- 模拟器保持开机,不要休眠
+### 3.2 场景 B:token 过期需要重新登录
+
+`jhs_token` 表里的 token 是 JWT,过期后接口会返回 `401 MARKET_UNAUTHORIZED`,爬虫报 `KeyError: 'raw_data'`。流程:
+
+```powershell
+# 1. 启动弹窗清除(推荐 -Foreground 看日志)
+#    App 还没开 → 用这条(会自动重启 App 并等待注入)
+.\start_auto_dismiss.ps1 -Foreground
+#    App 已经在跑 → 用这条(跳过重启,直接 attach,更快)
+.\start_auto_dismiss.ps1 -NoRestart -Foreground
+#    看到 [dismiss] all hooks installed 即可,弹窗一出来就会被自动关掉
+
+# 2. 在手机里登录账号;用抓包工具拿新 token
+#    (token 在登录接口返回里,或后续请求的 token 参数)
+
+# 3. 手动 UPDATE 数据库
+#    UPDATE jhs_token SET token = '<新token>' WHERE id = 1;
+#    (写库操作必须人工执行)
+
+# 4. Ctrl+C 停掉 dismiss 脚本(或者 .\stop_auto_dismiss.ps1)
+
+# 5. 跑爬虫
+python .\jhs_rpc_spider.py
+```
+
+### 3.3 程序运行时的设备状态要求
+
+- 设备保持开机,不要休眠
 - 目标 App 已启动,并停留在前台页面(至少已完成初始化)
-- `frida-server` 保持运行(不要被系统回收)
+- ZygiskFrida 模块已生效(App 启动时 gadget 会自动注入,无需额外守护进程
 - 跑批量分页时,不要频繁切换 App 到后台
 
 ## 4. 关键参数说明
@@ -125,7 +199,8 @@ with JhsRawCodecClient(device_id="25051FDD4S018P", cli_target_sec=2) as client:
 
 - 确认 App 正在运行
 - 确认 `device_id` 传对
-- 确认 `frida-server` 正在设备里运行
+- 确认 ZygiskFrida 模块已启用,且 `target_packages.txt` 包含目标包名
+- 尝试重启 App(或用 `start_jhs_zygiskfrida_bg.ps1` 重新走完流程)
 
 ### 7.2 `Java is not defined`
 
@@ -137,6 +212,27 @@ with JhsRawCodecClient(device_id="25051FDD4S018P", cli_target_sec=2) as client:
 - 显式传 `device_id`
 - 用 `adb devices` / `frida-ls-devices` 核对 ID
 
+### 7.4 "立即更新"弹窗去不掉
+
+老版本 App 启动会弹"有新版本 / 立即更新",挡住登录操作。
+
+**先用算法助手**(LSPosed 模块)的「拦截关键词弹窗」开关,多数 App 这样就够了。
+
+**若算法助手失效**(集换社这个弹窗就是):它不是标准 `Dialog`,而是 App 自己用 `WindowManager.addView()` 手撸的自定义弹窗,算法助手只盯标准 Dialog,盯不到这层。解决:
+
+```powershell
+# App 已经在跑、弹窗正显示时,直接 attach 我们的脚本即可
+.\start_auto_dismiss.ps1 -NoRestart -Foreground
+```
+
+`auto_dismiss_update.js` 在最底层的 `WindowManager.addView()` 也下了 hook,能兜住任意形态的弹窗。原理详见 `docs/优化记录_jhs_rpc_spider_20260609.md` 的 §5、§6。
+
+排查口诀:
+
+1. 确认算法助手「拦截关键词弹窗」已开、关键词文案对得上
+2. 仍不掉 → 大概率是自定义 `WindowManager.addView` 实现
+3. 用我们的 `start_auto_dismiss.ps1` 兜底
+
 ## 8. 安全说明
 
 - `TOKEN` 建议不要硬编码在仓库,改为环境变量或外部配置

+ 253 - 55
jhs_rpc_spider/jhs_raw_codec_client.py

@@ -24,8 +24,12 @@ from typing import Any, Dict, Optional
 import time
 import json
 import os
+import queue
 import subprocess
 import tempfile
+import threading
+import concurrent.futures
+from urllib.parse import unquote
 
 import frida
 
@@ -35,9 +39,22 @@ SCRIPT_PATH = Path(__file__).with_name("jhs_raw_codec_rpc.js")
 ENV_DEVICE_ID = "FRIDA_DEVICE_ID"
 ENV_DEBUG = "JHS_CODEC_DEBUG"
 ENV_CLI_TARGET_SEC = "FRIDA_CLI_TARGET_SEC"
+ENV_RPC_TIMEOUT_SEC = "FRIDA_RPC_TIMEOUT_SEC"
 
 
 class JhsRawCodecClient:
+    RETRYABLE_FRIDA_ERROR_MARKERS = (
+        "unable to connect to remote frida-server: closed",
+        "server is not running",
+        "the connection is closed",
+        "connection is closed",
+        "connection terminated",
+        "session is detached",
+        "script is destroyed",
+        "transport error",
+        "device lost",
+    )
+
     def _on_script_message(self, message, data) -> None:
         msg_type = message.get("type")
         if msg_type == "log":
@@ -74,6 +91,10 @@ class JhsRawCodecClient:
         if self.debug:
             print(f"[JhsRawCodecClient] {msg}")
 
+    def _is_retryable_frida_error(self, exc: Exception) -> bool:
+        msg = str(exc).strip().lower()
+        return any(marker in msg for marker in self.RETRYABLE_FRIDA_ERROR_MARKERS)
+
     def _find_pid_by_identifier(self) -> int:
         """
         通过应用标识符(package name)查找目标应用的进程 PID。
@@ -105,11 +126,49 @@ class JhsRawCodecClient:
             pass
         return 0
 
+    def _attach_session(self) -> None:
+        last_err = None
+        self.device = self._resolve_device(self.device_id)
+        for _ in range(6):
+            try:
+                pid = self._find_pid_by_identifier() or self._find_pid_by_process()
+                if pid:
+                    self.session = self.device.attach(pid)
+                    return
+                last_err = frida.ProcessNotFoundError(
+                    f"unable to find running app/process for '{self.package}'"
+                )
+                time.sleep(1.0)
+            except frida.ProcessNotFoundError as e:
+                last_err = e
+                time.sleep(1.0)
+        raise RuntimeError(
+            f"unable to attach '{self.package}', please open app and keep it running"
+        ) from last_err
+
+    def _load_script(self) -> None:
+        code = SCRIPT_PATH.read_text(encoding="utf-8")
+        self.script = self.session.create_script(code)
+        self.script.on("message", self._on_script_message)
+        self.script.load()
+
+    def _connect(self) -> None:
+        self.session = None
+        self.script = None
+        self._attach_session()
+        self._load_script()
+
+    def reconnect(self) -> None:
+        self._log("reconnecting frida session")
+        self.close()
+        self._connect()
+
     def __init__(
         self,
         package: str = PKG,
         device_id: Optional[str] = None,
         cli_target_sec: Optional[int] = None,
+        rpc_timeout_sec: Optional[int] = None,
     ):
         """
         初始化客户端并附加到目标 App 进程,随后加载 RPC 脚本。
@@ -120,6 +179,9 @@ class JhsRawCodecClient:
                 若仍为空则使用默认 USB 设备选择逻辑。
             cli_target_sec: CLI 兜底模式的 frida `-t` 秒数。未传时读取
                 环境变量 FRIDA_CLI_TARGET_SEC,默认 3 秒。
+            rpc_timeout_sec: Python RPC 同步调用的超时秒数(frida-python 的
+                `exports_sync` 本身无超时,靠外层 future 兜底)。未传时读取
+                环境变量 FRIDA_RPC_TIMEOUT_SEC,默认 20 秒;最小 3 秒。
 
         Raises:
             RuntimeError: 多次重试后仍无法附加到目标进程。
@@ -131,31 +193,19 @@ class JhsRawCodecClient:
         if target_sec is None:
             target_sec = int(os.getenv(ENV_CLI_TARGET_SEC, "3"))
         self.cli_target_sec = max(1, int(target_sec))
-        self.device = self._resolve_device(self.device_id)
+        rpc_ts = rpc_timeout_sec
+        if rpc_ts is None:
+            rpc_ts = int(os.getenv(ENV_RPC_TIMEOUT_SEC, "20"))
+        self.rpc_timeout_sec = max(3, int(rpc_ts))
+        self.device = None
         self.session = None
+        self.script = None
         self._prefer_cli = False
-        last_err = None
-        for _ in range(6):
-            try:
-                pid = self._find_pid_by_identifier() or self._find_pid_by_process()
-                if pid:
-                    self.session = self.device.attach(pid)
-                    break
-                last_err = frida.ProcessNotFoundError(
-                    f"unable to find running app/process for '{self.package}'"
-                )
-                time.sleep(1.0)
-            except frida.ProcessNotFoundError as e:
-                last_err = e
-                time.sleep(1.0)
-        if self.session is None:
-            raise RuntimeError(
-                f"unable to attach '{self.package}', please open app and keep it running"
-            ) from last_err
-        code = SCRIPT_PATH.read_text(encoding="utf-8")
-        self.script = self.session.create_script(code)
-        self.script.on("message", self._on_script_message)
-        self.script.load()
+        # 单线程 executor 专门用来给同步 RPC 加超时;到点若卡死则丢弃换新的
+        self._executor = concurrent.futures.ThreadPoolExecutor(
+            max_workers=1, thread_name_prefix="jhs-rpc"
+        )
+        self._connect()
 
     def __enter__(self):
         """上下文管理器入口,返回当前客户端实例。"""
@@ -167,16 +217,66 @@ class JhsRawCodecClient:
         return False
 
     def close(self) -> None:
-        """关闭并清理资源:卸载脚本、断开会话连接。"""
+        """关闭并清理资源:卸载脚本、断开会话连接、关闭 executor。"""
+        try:
+            if self.script is not None:
+                self.script.unload()
+        except Exception:
+            pass
         try:
-            self.script.unload()
+            if self.session is not None:
+                self.session.detach()
         except Exception:
             pass
+        self.script = None
+        self.session = None
+        # executor 里可能残留卡死的调用线程,不 join,让守护线程随进程结束
         try:
-            self.session.detach()
+            self._executor.shutdown(wait=False, cancel_futures=True)
         except Exception:
             pass
 
+    def _sync_rpc_call(self, params: Dict[str, Any]) -> Dict[str, Any]:
+        """
+        为 `script.exports_sync.call` 增加超时保护的包装。
+
+        frida-python 的 `exports_sync` 是纯阻塞调用、没有 timeout 参数;
+        目标 app 冷启动时相关 Java 类可能还未加载,脚本 hook 排队后不返回,
+        Python 端会永远阻塞。这里通过后台 future + `result(timeout=...)` 兜底。
+
+        Args:
+            params (Dict[str, Any]): RPC 参数字典。
+
+        Returns:
+            Dict[str, Any]: JS 侧返回的结果。
+
+        Raises:
+            TimeoutError: 超过 `self.rpc_timeout_sec` 仍未返回;抛出前会尝试
+                detach session 让底层阻塞线程报错退出,并重建 executor。
+        """
+        fut = self._executor.submit(self.script.exports_sync.call, params)
+        try:
+            return fut.result(timeout=self.rpc_timeout_sec)
+        except concurrent.futures.TimeoutError:
+            self._log(f"python rpc timeout after {self.rpc_timeout_sec}s, detaching session")
+            # 强断会话,通常会让阻塞的 frida 线程抛异常退出(否则它就常驻卡着)
+            try:
+                if self.session is not None:
+                    self.session.detach()
+            except Exception:
+                pass
+            # 旧 executor 内的线程可能还在阻塞,直接丢弃、开新的
+            try:
+                self._executor.shutdown(wait=False, cancel_futures=True)
+            except Exception:
+                pass
+            self._executor = concurrent.futures.ThreadPoolExecutor(
+                max_workers=1, thread_name_prefix="jhs-rpc"
+            )
+            raise TimeoutError(
+                f"frida python rpc call timeout after {self.rpc_timeout_sec}s"
+            )
+
     def encrypt(self, url: str) -> Dict[str, Any]:
         """
         调用 JS RPC 的 encrypt 方法,对请求 URL 进行加密处理。
@@ -220,7 +320,15 @@ class JhsRawCodecClient:
             return self._call_via_cli(params)
         try:
             self._log("call path: python rpc")
-            return self.script.exports_sync.call(params)
+            return self._sync_rpc_call(params)
+        except TimeoutError as e:
+            # 已经在 _sync_rpc_call 内 detach 过;这里直接重连,让上层重试
+            self._log(f"python rpc timeout, reconnecting session: {e}")
+            try:
+                self.reconnect()
+            except Exception as re:
+                self._log(f"reconnect after timeout failed: {re}")
+            raise
         except Exception as e:
             msg = str(e)
             # In some embedded Gadget setups, Python session scripts miss Java bridge.
@@ -229,6 +337,10 @@ class JhsRawCodecClient:
                 self._prefer_cli = True
                 self._log("python rpc missing Java bridge, switch to cli fallback")
                 return self._call_via_cli(params)
+            if self._is_retryable_frida_error(e):
+                self._log(f"python rpc failed with retryable frida error: {e}")
+                self.reconnect()
+                return self._sync_rpc_call(params)
             raise
 
     def _call_via_cli(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -256,7 +368,7 @@ class JhsRawCodecClient:
             + js_src
             + "\nsetImmediate(function(){\n"
               "  rpc.exports.call(__PARAMS)\n"
-              "    .then(function(r){ console.log('[CODEC-RESULT]' + JSON.stringify(r)); })\n"
+              "    .then(function(r){ console.log('[CODEC-RESULT-URI]' + encodeURIComponent(JSON.stringify(r))); })\n"
               "    .catch(function(e){ console.log('[CODEC-ERROR]' + e); });\n"
               "});\n"
         )
@@ -280,52 +392,138 @@ class JhsRawCodecClient:
                 encoding="utf-8",
                 errors="replace",
             )
-            deadline = time.time() + max(10, self.cli_target_sec + 8)
+            deadline_total = max(10, self.cli_target_sec + 8)
+            deadline = time.time() + deadline_total
             out_lines = []
             err_lines = []
 
-            while time.time() < deadline:
-                if proc.stdout is None:
+            # 后台线程把 stdout / stderr 读到 queue 里,主循环用 queue.get(timeout=...)
+            # 拿数据,从根本上避免 readline() 阻塞导致 deadline 失效
+            io_q: "queue.Queue" = queue.Queue()
+
+            def _pump(stream, tag):
+                try:
+                    for line in iter(stream.readline, ""):
+                        io_q.put((tag, line))
+                finally:
+                    try:
+                        stream.close()
+                    except Exception:
+                        pass
+                    io_q.put((tag, None))  # EOF 标记
+
+            t_out = threading.Thread(target=_pump, args=(proc.stdout, "out"), daemon=True)
+            t_err = threading.Thread(target=_pump, args=(proc.stderr, "err"), daemon=True)
+            t_out.start()
+            t_err.start()
+
+            out_eof = False
+            err_eof = False
+
+            while True:
+                remaining = deadline - time.time()
+                if remaining <= 0:
                     break
-                line = proc.stdout.readline()
-                if line:
-                    line = line.rstrip("\r\n")
-                    out_lines.append(line)
-                    if line.startswith("[CODEC-RESULT]"):
-                        result = json.loads(line[len("[CODEC-RESULT]"):])
-                        if proc.poll() is None:
-                            proc.terminate()
-                        return result
-                    if line.startswith("[CODEC-ERROR]"):
-                        if proc.poll() is None:
-                            proc.terminate()
-                        raise RuntimeError(line)
+                try:
+                    tag, line = io_q.get(timeout=min(0.5, remaining))
+                except queue.Empty:
+                    # 进程已结束且两路输出都到 EOF 就退出,否则继续等
+                    if proc.poll() is not None and out_eof and err_eof:
+                        break
                     continue
 
-                if proc.poll() is not None:
-                    break
+                if line is None:
+                    if tag == "out":
+                        out_eof = True
+                    else:
+                        err_eof = True
+                    if out_eof and err_eof and proc.poll() is not None:
+                        break
+                    continue
 
-            if proc.stderr is not None:
-                err_lines.extend(proc.stderr.read().splitlines())
+                line = line.rstrip("\r\n")
+                if tag == "err":
+                    err_lines.append(line)
+                    continue
 
+                out_lines.append(line)
+                if line.startswith("[CODEC-RESULT-URI]"):
+                    payload = unquote(line[len("[CODEC-RESULT-URI]"):])
+                    result = json.loads(payload)
+                    self._terminate_proc(proc)
+                    return result
+                if line.startswith("[CODEC-RESULT]"):
+                    result = json.loads(line[len("[CODEC-RESULT]"):])
+                    self._terminate_proc(proc)
+                    return result
+                if line.startswith("[CODEC-ERROR]"):
+                    self._terminate_proc(proc)
+                    raise RuntimeError(line)
+
+            # 到点未拿到结果:强杀子进程,抛 TimeoutError 让上层重试
+            self._terminate_proc(proc)
+            # 抓一下 err 里剩下的(可能已经通过 queue 收了大部分,这里兜底)
+            drained_out, drained_err = self._drain_queue(io_q)
+            out_lines.extend(drained_out)
+            err_lines.extend(drained_err)
             out = "\n".join(out_lines)
             err = "\n".join(err_lines)
-            raise RuntimeError(
-                "cli codec call failed: no result line\n"
+            raise TimeoutError(
+                f"cli codec call timeout after {deadline_total}s, no result line\n"
                 + "stdout:\n" + out + "\n"
                 + "stderr:\n" + err
             )
         finally:
-            if proc is not None and proc.poll() is None:
-                try:
-                    proc.terminate()
-                except Exception:
-                    pass
+            self._terminate_proc(proc)
             try:
                 os.remove(tmp_path)
             except Exception:
                 pass
 
+    @staticmethod
+    def _drain_queue(io_q: "queue.Queue"):
+        """把 queue 里现存的数据一次性抽干,用于超时后收拾残余输出。
+
+        Args:
+            io_q (queue.Queue): _call_via_cli 中用来收集 stdout/stderr 的队列。
+
+        Returns:
+            tuple[list[str], list[str]]: (stdout 行列表, stderr 行列表)。
+        """
+        outs, errs = [], []
+        while True:
+            try:
+                tag, line = io_q.get_nowait()
+            except queue.Empty:
+                break
+            if line is None:
+                continue
+            (outs if tag == "out" else errs).append(line.rstrip("\r\n"))
+        return outs, errs
+
+    @staticmethod
+    def _terminate_proc(proc) -> None:
+        """尽力结束 frida CLI 子进程,先 terminate 再 kill。
+
+        Args:
+            proc: `subprocess.Popen` 对象,允许为 None。
+        """
+        if proc is None:
+            return
+        if proc.poll() is not None:
+            return
+        try:
+            proc.terminate()
+        except Exception:
+            pass
+        try:
+            proc.wait(timeout=2)
+        except Exception:
+            try:
+                proc.kill()
+            except Exception:
+                pass
+
 
 def encrypt_url(url: str, package: str = PKG, device_id: Optional[str] = None) -> Dict[str, Any]:
     """

+ 71 - 5
jhs_rpc_spider/jhs_rpc_spider.py

@@ -14,9 +14,24 @@ from mysql_pool import MySQLConnectionPool
 from jhs_raw_codec_client import JhsRawCodecClient
 from tenacity import retry, stop_after_attempt, wait_fixed, retry_if_exception_type
 
+"""
+此项目基于 集换社3.36.2 版本 其他版本报错
+    [2026-05-22 19:29:05.043] ERROR Error fetching page 9: [CODEC-ERROR]call failed: Error: unable to resolve instance for gc.b
+    [2026-05-22 19:38:41.595] ERROR Error fetching page 4: [CODEC-ERROR]call failed: TypeError: not a function
+"""
+
+
+class TokenExpiredError(Exception):
+    """token 过期 / 未授权时抛出。
+
+    服务端会返回 HTTP 200,但 body 为 {"code":401,"error":"MARKET_UNAUTHORIZED",...},
+    因此 raise_for_status 无法拦截,需在解析 body 时主动识别。该异常故意不加入
+    fetch_market_page 的 @retry 可重试类型——token 过期重试无意义,应立即中止整轮。
+    """
+
 # TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbnYiOiJwcm9kdWN0aW9uIiwic3ViIjoyODI3NDU4LCJpc3MiOiJodHRwOi8vYXBpLmppaHVhbnNoZS5jb20vYXBpL21hcmtldC9hdXRoL2xvZ2luLW9yLXNpZ251cCIsImlhdCI6MTc3NTYzNzQzNSwiZXhwIjoxNzgwODIxNDM1LCJuYmYiOjE3NzU2Mzc0MzUsImp0aSI6InhiT3NsdUJRTzVWeHRabHQifQ.uHz7M-U0ewPgi5Qzr5P4eJbSdIUO_i_hmVE-0jsaG2Y"
 # DEVICE_ID = "127.0.0.1:5557" # adb connect 127.0.0.1:5557
-DEVICE_ID = "25051FDD4S018P" # adb connect 127.0.0.1:5557
+DEVICE_ID = "25051FDD4S018P"  # adb connect 127.0.0.1:5557
 
 CLI_TARGET_SEC = 2
 TIMEOUT_SEC = 15
@@ -73,7 +88,16 @@ def get_proxys(log):
         raise e
 
 
-@retry(stop=stop_after_attempt(3), wait=wait_fixed(2), retry=retry_if_exception_type(json.JSONDecodeError), after=after_log)
+# 覆盖到所有可重试异常:
+#   - json.JSONDecodeError: 响应体损坏
+#   - TimeoutError: frida RPC 或 CLI 兜底超时(新增的超时保护路径)
+#   - RuntimeError: [CODEC-ERROR] 等 JS 侧抛出的错误
+#   - requests.RequestException: HTTP 层网络异常(连接超时 / 读超时 / 5xx 抛出等)
+@retry(stop=stop_after_attempt(3), wait=wait_fixed(2),
+       retry=retry_if_exception_type(
+           (json.JSONDecodeError, TimeoutError, RuntimeError, requests.RequestException)
+       ),
+       after=after_log)
 def fetch_market_page(
         log,
         page: int,
@@ -103,6 +127,18 @@ def fetch_market_page(
     )
     resp.raise_for_status()
     body = resp.json()
+
+    # 服务端 token 过期时返回 HTTP 200 但 body 无 raw_data({"code":401,"error":"MARKET_UNAUTHORIZED"}),
+    # 需主动识别,否则会退化成没头没脑的 KeyError: 'raw_data' 并空跑满全部页
+    if "raw_data" not in body:
+        code = body.get("code")
+        err = body.get("error")
+        msg = body.get("msg")
+        if code == 401 or err == "MARKET_UNAUTHORIZED":
+            raise TokenExpiredError(f"token 已过期/未授权: code={code} error={err} msg={msg}")
+        # 其他缺字段情况:抛 RuntimeError 触发 @retry(可能是偶发脏响应)
+        raise RuntimeError(f"响应缺少 raw_data 字段: {json.dumps(body, ensure_ascii=False)[:200]}")
+
     response_raw_data = body["raw_data"]
 
     request_url_for_dec = f"{BASE_URL}?raw_data={raw_data}&token={token}"
@@ -138,7 +174,7 @@ def parse_data(resp_data, sql_pool):
     :param resp_data: 响应数据
     :param sql_pool: 数据库连接池
     """
-    data_list = resp_data.get("raw_data",{}).get("data", [])
+    data_list = resp_data.get("raw_data", {}).get("data", [])
 
     info_list = []
     for data in data_list:
@@ -194,8 +230,23 @@ def parse_data(resp_data, sql_pool):
 
 
 def get_market_list(log, token: str, sql_pool):
+    """
+    分页抓取市场列表并入库。
+
+    支持连续失败自动重连 frida 会话——手机端 app 冷启动 / gadget 假死时,
+    避免整轮任务卡死或全页失败。
+
+    Args:
+        log: 日志对象。
+        token (str): 用户登录 token。
+        sql_pool: 数据库连接池实例。
+    """
     page = 1
-    max_page = 800
+    max_page = 200
+
+    # 连续失败达到阈值就重连 frida 会话(覆盖 app 冷启动类未加载 / gadget 掉线场景)
+    consecutive_fails = 0
+    reconnect_threshold = 3
 
     with JhsRawCodecClient(device_id=DEVICE_ID, cli_target_sec=CLI_TARGET_SEC) as codec_client:
         with requests.Session() as http_sess:
@@ -216,8 +267,24 @@ def get_market_list(log, token: str, sql_pool):
                     except Exception as e:
                         log.error(f"Error parsing page {page}: {e}")
 
+                    consecutive_fails = 0
+
+                except TokenExpiredError as e:
+                    # token 过期:重连 frida / 翻页都没用,立即中止整轮,等更新 token 后重跑
+                    log.error(f"token 已过期,请更新 jhs_token 表(id=1)后重跑,本轮中止: {e}")
+                    break
+
                 except Exception as e:
                     log.error(f"Error fetching page {page}: {e}")
+                    consecutive_fails += 1
+                    if consecutive_fails >= reconnect_threshold:
+                        log.warning(
+                            f"连续 {consecutive_fails} 页失败,尝试 reconnect frida 会话")
+                        try:
+                            codec_client.reconnect()
+                            consecutive_fails = 0
+                        except Exception as re:
+                            log.error(f"reconnect failed: {re}")
 
                 page += 1
 
@@ -246,7 +313,6 @@ def jhs_rpc_main(log):
         log.info(f'爬虫程序 {inspect.currentframe().f_code.co_name} 运行结束,等待下一轮的采集任务............')
 
 
-
 def schedule_task():
     """
     设置定时任务

+ 3 - 0
jhs_rpc_spider/requirements.txt

@@ -0,0 +1,3 @@
+frida>=16.0.0
+frida-tools>=13.0.0
+requests>=2.31.0

+ 111 - 0
jhs_rpc_spider/start_auto_dismiss.ps1

@@ -0,0 +1,111 @@
+param(
+    [string]$Package = "com.jihuanshe",
+    [int]$DelayMs = 12000,
+    [string]$Adb = "D:\platform-tools\adb.exe",
+    [string]$Frida = "C:\Python\Python312\Scripts\frida.exe",
+    [string]$Hook = "",
+    [switch]$NoRestart,
+    [switch]$Foreground
+)
+
+# Usage:
+#   .\start_auto_dismiss.ps1               # restart App -> wait gadget -> attach dismiss hook (background)
+#   .\start_auto_dismiss.ps1 -NoRestart    # App already running, attach directly
+#   .\start_auto_dismiss.ps1 -Foreground   # foreground mode, show live log
+#
+# Stop: .\stop_auto_dismiss.ps1
+
+$ErrorActionPreference = "Stop"
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$StateFile = Join-Path $ScriptDir "auto_dismiss_bg_state.json"
+$StdoutLog = Join-Path $ScriptDir "auto_dismiss_stdout.log"
+$StderrLog = Join-Path $ScriptDir "auto_dismiss_stderr.log"
+
+if ([string]::IsNullOrWhiteSpace($Hook)) {
+    $Hook = Join-Path $ScriptDir "auto_dismiss_update.js"
+}
+
+if (-not (Test-Path $Adb))   { throw "adb not found: $Adb" }
+if (-not (Test-Path $Frida)) { throw "frida not found: $Frida" }
+if (-not (Test-Path $Hook))  { throw "hook script not found: $Hook" }
+
+# kill previous background frida if any
+if (Test-Path $StateFile) {
+    try {
+        $oldState = Get-Content $StateFile -Raw | ConvertFrom-Json
+        if ($oldState.pid) {
+            $oldProc = Get-Process -Id ([int]$oldState.pid) -ErrorAction SilentlyContinue
+            if ($oldProc) {
+                Write-Host "[cleanup] stopping previous frida pid=$($oldState.pid)"
+                Stop-Process -Id ([int]$oldState.pid) -Force
+                Start-Sleep -Milliseconds 500
+            }
+        }
+    } catch {
+        Write-Host "[cleanup] ignore invalid state file"
+    }
+}
+
+if (-not $NoRestart) {
+    Write-Host "[1/4] force-stop $Package"
+    & $Adb shell "su -c 'am force-stop $Package'" | Out-Host
+    Start-Sleep -Milliseconds 800
+
+    Write-Host "[2/4] launch $Package"
+    & $Adb shell "su -c 'monkey -p $Package -c android.intent.category.LAUNCHER 1'" | Out-Host
+
+    Write-Host "[3/4] wait $DelayMs ms for ZygiskFrida gadget injection"
+    Start-Sleep -Milliseconds $DelayMs
+} else {
+    Write-Host "[1/4] skip restart"
+    Write-Host "[2/4] skip launch"
+    Write-Host "[3/4] skip wait"
+}
+
+if (Test-Path $StdoutLog) { Remove-Item $StdoutLog -Force }
+if (Test-Path $StderrLog) { Remove-Item $StderrLog -Force }
+
+$argList = @(
+    "-U",
+    "-N", $Package,
+    "-l", $Hook,
+    "-q"
+)
+
+if ($Foreground) {
+    Write-Host "[4/4] start frida in FOREGROUND (Ctrl+C to stop)"
+    & $Frida @argList
+    return
+}
+
+Write-Host "[4/4] start frida in background"
+$proc = Start-Process -FilePath $Frida `
+    -ArgumentList $argList `
+    -RedirectStandardOutput $StdoutLog `
+    -RedirectStandardError $StderrLog `
+    -PassThru `
+    -WindowStyle Hidden
+
+$state = [ordered]@{
+    package    = $Package
+    pid        = $proc.Id
+    delay_ms   = $DelayMs
+    hook       = $Hook
+    started_at = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
+    stdout_log = $StdoutLog
+    stderr_log = $StderrLog
+    no_restart = [bool]$NoRestart
+}
+$state | ConvertTo-Json | Set-Content -Path $StateFile -Encoding UTF8
+
+Write-Host ""
+Write-Host "auto-dismiss background task started"
+Write-Host "pid        : $($proc.Id)"
+Write-Host "hook       : $Hook"
+Write-Host "stdout log : $StdoutLog"
+Write-Host "stderr log : $StderrLog"
+Write-Host "state file : $StateFile"
+Write-Host ""
+Write-Host "tip: tail stdout to watch dismiss events:"
+Write-Host "     Get-Content -Path $StdoutLog -Wait -Tail 20"

+ 1 - 0
jhs_rpc_spider/去除升级弹窗.txt

@@ -0,0 +1 @@
+.\start_auto_dismiss.ps1 -NoRestart -Foreground