Преглед на файлове

修好「排除无头像」:主动申请截屏授权 + 判无头像时二次采样

两个问题叠在一起,表现都是"勾了排除无头像但什么都没发生"。

① 从来没申请过截屏授权
captureScreen() 在 AutoX 上要先 requestScreenCapture(),不申请就抛
SecurityException: No screen capture permission,于是每次判定都失败、
降级为"放行 + 告知",而且**每个候选都白试一次、白记一条错误**。

真机实测:requestScreenCapture(false) 是**静默的** —— AutoX 的无障碍服务带
CAPABILITY_CAN_TAKE_SCREENSHOT,申请当场返回 true、不弹 MediaProjection 确认框、
1 秒内完成,随后 captureScreen() 拿到 1080x1920。
现在第一次判定前申请一次,失败就锁定并只告知一次(不再刷屏)。

② 头像图没加载完就判定 → 误判成"无头像"
修好截屏后第一次跑,日志第一条是"头像像素判定 0.000",而它出现在 openRow 之前 ——
那是 openBySearch 打开的 anchor 账号,头像图片还在下载,截到的是灰底。
waitProfile 只等到 @username 出现(约 585ms),图片往往还没到,所以候选账号
如果加载慢就会被**错误跳过**。

改成:判为"无头像"时必须再采一次(间隔 400ms)才采信;有色的不重复采
(灰底不会自己变彩色),额外开销只落在疑似无头像的账号上。
同一个账号修前 0.000、修后 0.880。

阈值复核(引擎同一套 5x5 采样)
  默认剪影头像 1/25 = 0.040 → 判为无头像 ✓
  真头像       15/25 = 0.600 → 有头像 ✓
  阈值 0.12 落在两者中间;真机 6 个候选实测 0.320~1.000,全部正确收录。
梦境 преди 1 седмица
родител
ревизия
f95b7400bf
променени са 2 файла, в които са добавени 153 реда и са изтрити 5 реда
  1. 77 3
      tiktok-auto/dist/tiktok-auto.js
  2. 76 2
      tiktok-auto/src/profile.js

+ 77 - 3
tiktok-auto/dist/tiktok-auto.js

@@ -1,5 +1,5 @@
 /*! tiktok-auto v1.0.0
- *  built 2026-10-20T08:08:36.158Z
+ *  built 2026-10-20T08:37:51.597Z
  *  modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, flow, parse, uidset, filters, profile, idsource, idflow, main
  *  由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
  */
@@ -4348,16 +4348,88 @@ TikTokAuto.profile = (function () {
         return previous;
     }
 
+    /**
+     * 截屏授权状态:null = 还没试过,true = 已就绪,false = 不可用。
+     *
+     * ★ 真机实测(2026-10-20):**`requestScreenCapture(false)` 是静默的** ——
+     *   AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 true,
+     *   不弹 MediaProjection 确认框,1 秒内完成,随后 `captureScreen()` 直接可用。
+     *   而**不主动申请**时 `captureScreen()` 会抛 `No screen capture permission` ——
+     *   这就是早先"排除无头像一直不生效、每个账号还刷一条错误"的全部原因。
+     *
+     * 代价与风险:某些设备/ROM 上申请会弹一次系统授权框并阻塞。
+     * 所以申请失败就锁定"不可用"并**只告知一次**,不让它把整轮拖住或刷屏。
+     */
+    var screenCaptureReady = null;
+
+    function ensureScreenCapture() {
+        if (screenCaptureReady !== null) {
+            return screenCaptureReady;
+        }
+        if (typeof requestScreenCapture !== "function") {
+            screenCaptureReady = false;
+            logger().warn('profile', '这个运行时不支持截屏申请,本次运行的头像判定会跳过');
+            return false;
+        }
+        try {
+            screenCaptureReady = requestScreenCapture(false) === true;
+        } catch (error) {
+            screenCaptureReady = false;
+            logger().warn('profile', '申请截屏授权失败,本次运行的头像判定会跳过:' + error);
+            return false;
+        }
+        if (screenCaptureReady) {
+            logger().info('profile', '截屏授权已就绪,头像判定可用');
+        } else {
+            logger().warn('profile', '截屏授权未获批准,本次运行的头像判定会跳过');
+        }
+        return screenCaptureReady;
+    }
+
     /**
      * 头像是默认剪影吗?
      *
+     * ★ "判为无头像"必须**再采一次**才采信。
+     *   实测发现:刚进用户页时(`waitProfile` 只等到 `@username` 出现,约 585ms),
+     *   头像图片往往还在下载,截到的是**灰色占位块** → 高饱和 0.000 → 误判"无头像"
+     *   → 一个明明有头像的账号被错误跳过。
+     *   有色的情况不必二次确认(灰底不会自己变彩色),所以这个额外开销只落在
+     *   "疑似无头像"的那部分账号上,约多花 400ms。
+     *
      * @returns true(有头像)/ false(默认剪影)/ null(判不了)
      */
     function readAvatarState() {
+        if (!ensureScreenCapture()) {
+            return null;
+        }
         var node = avatarNode();
         if (!node) {
             return null;
         }
+
+        var first = sampleAvatar(node);
+        if (first === null || first === true) {
+            return first;
+        }
+
+        /* 第一次判为"无色":等图片加载完再确认一次 */
+        TikTokAuto.wait.tick(400);
+        var second = sampleAvatar(node);
+        if (second === null) {
+            return first;
+        }
+        if (second !== first) {
+            logger().info('profile', '头像两次采样不一致(' + first + ' → ' + second +
+                '),采用第二次 —— 第一次多半是图片还没加载');
+        }
+        return second;
+    }
+
+    /**
+     * 对头像区域采样一次,返回"是否有头像"。
+     * @returns true / false / null(拿不到节点或截屏失败)
+     */
+    function sampleAvatar(node) {
         var box = node.bounds();
         var saturated = 0;
         var total = 0;
@@ -4366,12 +4438,14 @@ TikTokAuto.profile = (function () {
         try {
             image = captureScreen();
         } catch (error) {
-            /* Android 14 上截屏要 MediaProjection 授权。拿不到不是"没头像",
+            /* 申请过了却还是拿不到(被系统撤销 / ROM 限制):不是"没头像",
              * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
-            logger().info('profile', '头像判定不可用(无法截屏):' + error);
+            screenCaptureReady = false;
+            logger().warn('profile', '截屏不可用,后续账号不再尝试头像判定:' + error);
             return null;
         }
         if (!image) {
+            screenCaptureReady = false;
             return null;
         }
 

+ 76 - 2
tiktok-auto/src/profile.js

@@ -107,16 +107,88 @@ TikTokAuto.profile = (function () {
         return previous;
     }
 
+    /**
+     * 截屏授权状态:null = 还没试过,true = 已就绪,false = 不可用。
+     *
+     * ★ 真机实测(2026-10-20):**`requestScreenCapture(false)` 是静默的** ——
+     *   AutoX 的无障碍服务带 `CAPABILITY_CAN_TAKE_SCREENSHOT`,申请当场返回 true,
+     *   不弹 MediaProjection 确认框,1 秒内完成,随后 `captureScreen()` 直接可用。
+     *   而**不主动申请**时 `captureScreen()` 会抛 `No screen capture permission` ——
+     *   这就是早先"排除无头像一直不生效、每个账号还刷一条错误"的全部原因。
+     *
+     * 代价与风险:某些设备/ROM 上申请会弹一次系统授权框并阻塞。
+     * 所以申请失败就锁定"不可用"并**只告知一次**,不让它把整轮拖住或刷屏。
+     */
+    var screenCaptureReady = null;
+
+    function ensureScreenCapture() {
+        if (screenCaptureReady !== null) {
+            return screenCaptureReady;
+        }
+        if (typeof requestScreenCapture !== "function") {
+            screenCaptureReady = false;
+            logger().warn('profile', '这个运行时不支持截屏申请,本次运行的头像判定会跳过');
+            return false;
+        }
+        try {
+            screenCaptureReady = requestScreenCapture(false) === true;
+        } catch (error) {
+            screenCaptureReady = false;
+            logger().warn('profile', '申请截屏授权失败,本次运行的头像判定会跳过:' + error);
+            return false;
+        }
+        if (screenCaptureReady) {
+            logger().info('profile', '截屏授权已就绪,头像判定可用');
+        } else {
+            logger().warn('profile', '截屏授权未获批准,本次运行的头像判定会跳过');
+        }
+        return screenCaptureReady;
+    }
+
     /**
      * 头像是默认剪影吗?
      *
+     * ★ "判为无头像"必须**再采一次**才采信。
+     *   实测发现:刚进用户页时(`waitProfile` 只等到 `@username` 出现,约 585ms),
+     *   头像图片往往还在下载,截到的是**灰色占位块** → 高饱和 0.000 → 误判"无头像"
+     *   → 一个明明有头像的账号被错误跳过。
+     *   有色的情况不必二次确认(灰底不会自己变彩色),所以这个额外开销只落在
+     *   "疑似无头像"的那部分账号上,约多花 400ms。
+     *
      * @returns true(有头像)/ false(默认剪影)/ null(判不了)
      */
     function readAvatarState() {
+        if (!ensureScreenCapture()) {
+            return null;
+        }
         var node = avatarNode();
         if (!node) {
             return null;
         }
+
+        var first = sampleAvatar(node);
+        if (first === null || first === true) {
+            return first;
+        }
+
+        /* 第一次判为"无色":等图片加载完再确认一次 */
+        TikTokAuto.wait.tick(400);
+        var second = sampleAvatar(node);
+        if (second === null) {
+            return first;
+        }
+        if (second !== first) {
+            logger().info('profile', '头像两次采样不一致(' + first + ' → ' + second +
+                '),采用第二次 —— 第一次多半是图片还没加载');
+        }
+        return second;
+    }
+
+    /**
+     * 对头像区域采样一次,返回"是否有头像"。
+     * @returns true / false / null(拿不到节点或截屏失败)
+     */
+    function sampleAvatar(node) {
         var box = node.bounds();
         var saturated = 0;
         var total = 0;
@@ -125,12 +197,14 @@ TikTokAuto.profile = (function () {
         try {
             image = captureScreen();
         } catch (error) {
-            /* Android 14 上截屏要 MediaProjection 授权。拿不到不是"没头像",
+            /* 申请过了却还是拿不到(被系统撤销 / ROM 限制):不是"没头像",
              * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
-            logger().info('profile', '头像判定不可用(无法截屏):' + error);
+            screenCaptureReady = false;
+            logger().warn('profile', '截屏不可用,后续账号不再尝试头像判定:' + error);
             return null;
         }
         if (!image) {
+            screenCaptureReady = false;
             return null;
         }