|
|
@@ -0,0 +1,625 @@
|
|
|
+/**
|
|
|
+ * profile.js — 在 TikTok 里"走到某个账号的主页并读出来"
|
|
|
+ *
|
|
|
+ * ## 这一层管什么
|
|
|
+ * 只管**导航**与**读取**:打开搜索、进用户主页、点开粉丝列表、翻一屏、退回去。
|
|
|
+ * 它不判断"要不要这个人"(那是 filters.js),也不管"从哪来"(idsource.js)。
|
|
|
+ *
|
|
|
+ * ## 真机实测出来的三条导航事实(2026-10-20 / TikTok 47.1.4)
|
|
|
+ * 1. **用户主页 → 粉丝列表是一张覆盖页**,顶部有三个页签(Following / Followers / Suggested),
|
|
|
+ * 可以直接在页签间切换 —— 所以"先收粉丝再收关注"不必退回主页再进。
|
|
|
+ * 2. **从列表进用户页再返回,列表位置会保持**(实测首行与坐标都不变)。
|
|
|
+ * 这条决定了翻页策略:可以"读一屏 → 逐个进 → 退回来接着读下一屏",
|
|
|
+ * 不必先把整屏 uid 存起来再统一处理。
|
|
|
+ * 3. **列表行里就有 uid**(`txt_desc`),不必为了拿 uid 去开用户页。
|
|
|
+ * 用户页只在**需要更多资料**(数量、语言、私密、头像)时才开 —— 差一个数量级的时间。
|
|
|
+ *
|
|
|
+ * ## 头像为什么要截屏
|
|
|
+ * 头像是 ImageView,**没有 text、没有 desc、不可点**,无障碍树里读不到任何线索。
|
|
|
+ * 实测对比:默认头像(灰色人形剪影)高饱和像素占比 0.035,真头像 0.472 ——
|
|
|
+ * 分离度足够,所以用像素法。**已知局限:纯黑白照片的头像会被误判成"无头像"**,
|
|
|
+ * 这一点会如实写进 README 与界面说明,不假装没有。
|
|
|
+ *
|
|
|
+ * 依赖:TikTokAuto.layout / tiktok / ui / wait / logger / parse
|
|
|
+ */
|
|
|
+TikTokAuto.profile = (function () {
|
|
|
+ 'use strict';
|
|
|
+
|
|
|
+ /** 头像采样网格:5x5。25 个点足够区分剪影与照片,又不会让截图开销变成瓶颈。 */
|
|
|
+ var AVATAR_GRID = 5;
|
|
|
+
|
|
|
+ /** 高饱和像素占比超过它就认为"有头像"。实测默认头像 0.035 / 真头像 0.472。 */
|
|
|
+ var AVATAR_SATURATED_RATIO = 0.12;
|
|
|
+
|
|
|
+ function logger() { return TikTokAuto.logger; }
|
|
|
+
|
|
|
+ /** 等页面安静下来(签名连续几次不变),比固定 sleep 可靠 */
|
|
|
+ function settle(maxMs) {
|
|
|
+ TikTokAuto.wait.waitQuiet(function () {
|
|
|
+ return TikTokAuto.layout.signature();
|
|
|
+ }, { maxMs: maxMs || 2500, tickMs: 200, stableTimes: 2 });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 读取主页
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 把当前用户主页读成一份资料。
|
|
|
+ *
|
|
|
+ * 读不到的字段一律给 **null**(而不是 0 或空串):
|
|
|
+ * "读不到"与"就是 0"在过滤判定里含义完全不同(见 filters.js 文件头)。
|
|
|
+ *
|
|
|
+ * @returns { uid, nickname, signature, follow, fans, likes, isPrivate, hasAvatar, gridCount }
|
|
|
+ */
|
|
|
+ function readProfile() {
|
|
|
+ var tiktok = TikTokAuto.tiktok;
|
|
|
+ var parse = TikTokAuto.parse;
|
|
|
+
|
|
|
+ var userNode = tiktok.profileUsernameNode();
|
|
|
+ var uid = userNode ? parse.username(TikTokAuto.layout.text(userNode)) : '';
|
|
|
+
|
|
|
+ var nicknameNode = tiktok.profileNicknameNode();
|
|
|
+ var signatureNode = tiktok.profileSignatureNode();
|
|
|
+ var stats = readStatsStable();
|
|
|
+ var hint = tiktok.privateHint();
|
|
|
+ var grid = tiktok.videoGridCount();
|
|
|
+
|
|
|
+ return {
|
|
|
+ uid: uid,
|
|
|
+ nickname: nicknameNode ? TikTokAuto.layout.text(nicknameNode) : '',
|
|
|
+ signature: signatureNode ? TikTokAuto.layout.text(signatureNode) : '',
|
|
|
+ follow: stats.follow,
|
|
|
+ fans: stats.fans,
|
|
|
+ likes: stats.likes,
|
|
|
+ /* 私密:读到了提示文案才算私密;读不到就是"看起来是公开的"。
|
|
|
+ * 反过来判("没读到就是私密")会把所有正常账号都排除掉。 */
|
|
|
+ isPrivate: hint !== '' && grid < 3,
|
|
|
+ privateHint: hint,
|
|
|
+ /* 头像:null = 判定不可用(截屏没授权 / 找不到头像节点) */
|
|
|
+ hasAvatar: readAvatarState(),
|
|
|
+ gridCount: grid
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 读三组数字,直到**连续两次一致**才采信。
|
|
|
+ *
|
|
|
+ * ★ 这是实测踩出来的:点开用户主页后,页面会先渲染骨架、再把数字填进去,
|
|
|
+ * 中间那一小段时间读到的数字是**占位的 0**。
|
|
|
+ * 同一个账号(@user3670597276145)两次运行分别读到 78 与 0,
|
|
|
+ * 而 78 才是真的 —— 如果拿 0 去比"粉丝量 ≥ 1000",它就被冤枉地跳过了。
|
|
|
+ * 页面"安静"不代表数字已经就位,只有"两次读到一样"才是可靠的信号。
|
|
|
+ */
|
|
|
+ function readStatsStable() {
|
|
|
+ var tiktok = TikTokAuto.tiktok;
|
|
|
+ TikTokAuto.wait.tick(350);
|
|
|
+ var previous = tiktok.statNumbers();
|
|
|
+
|
|
|
+ for (var i = 0; i < 3; i += 1) {
|
|
|
+ TikTokAuto.wait.tick(350);
|
|
|
+ var current = tiktok.statNumbers();
|
|
|
+ if (current.follow === previous.follow && current.fans === previous.fans &&
|
|
|
+ current.likes === previous.likes) {
|
|
|
+ return current;
|
|
|
+ }
|
|
|
+ previous = current;
|
|
|
+ }
|
|
|
+ logger().warn('profile', '三组数字连续三次都没稳定,采用最后一次的值(可能是中间态)');
|
|
|
+ return previous;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 头像是默认剪影吗?
|
|
|
+ *
|
|
|
+ * @returns true(有头像)/ false(默认剪影)/ null(判不了)
|
|
|
+ */
|
|
|
+ function readAvatarState() {
|
|
|
+ var node = avatarNode();
|
|
|
+ if (!node) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ var box = node.bounds();
|
|
|
+ var saturated = 0;
|
|
|
+ var total = 0;
|
|
|
+
|
|
|
+ var image = null;
|
|
|
+ try {
|
|
|
+ image = captureScreen();
|
|
|
+ } catch (error) {
|
|
|
+ /* Android 14 上截屏要 MediaProjection 授权。拿不到不是"没头像",
|
|
|
+ * 是"这次判不了" —— 必须返回 null,由上层如实告知。 */
|
|
|
+ logger().info('profile', '头像判定不可用(无法截屏):' + error);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (!image) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ var stepX = (box.right - box.left) / (AVATAR_GRID + 1);
|
|
|
+ var stepY = (box.bottom - box.top) / (AVATAR_GRID + 1);
|
|
|
+ for (var row = 1; row <= AVATAR_GRID; row += 1) {
|
|
|
+ for (var col = 1; col <= AVATAR_GRID; col += 1) {
|
|
|
+ var x = Math.round(box.left + stepX * col);
|
|
|
+ var y = Math.round(box.top + stepY * row);
|
|
|
+ var pixel = 0;
|
|
|
+ try {
|
|
|
+ pixel = image.pixel(x, y);
|
|
|
+ } catch (error2) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ total += 1;
|
|
|
+ if (isSaturated(pixel)) {
|
|
|
+ saturated += 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (total === 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ var ratio = saturated / total;
|
|
|
+ logger().info('profile', '头像像素判定:高饱和 ' + saturated + '/' + total +
|
|
|
+ ' = ' + ratio.toFixed(3) + '(阈值 ' + AVATAR_SATURATED_RATIO + ')');
|
|
|
+ return ratio >= AVATAR_SATURATED_RATIO;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** ARGB int → 饱和度是否够高。s = (max-min)/max,避开明亮的白色背景。 */
|
|
|
+ function isSaturated(pixel) {
|
|
|
+ var r = (pixel >> 16) & 0xff;
|
|
|
+ var g = (pixel >> 8) & 0xff;
|
|
|
+ var b = pixel & 0xff;
|
|
|
+ var max = Math.max(r, Math.max(g, b));
|
|
|
+ var min = Math.min(r, Math.min(g, b));
|
|
|
+ if (max < 40) {
|
|
|
+ return false; /* 太暗:黑边/阴影,不算色彩 */
|
|
|
+ }
|
|
|
+ return (max - min) / max >= 0.25;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 用户主页的头像节点。
|
|
|
+ * 判据:可见 ImageView、尺寸 140~280px、位于页面上部 1/3。
|
|
|
+ * 实测新版布局在右上角([856,168][1048,360]),老布局可能在左上,所以两侧都认。
|
|
|
+ */
|
|
|
+ function avatarNode() {
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
+ var best = null;
|
|
|
+ var bestArea = 0;
|
|
|
+ var collection = className('android.widget.ImageView').find();
|
|
|
+ for (var i = 0; i < collection.size(); i += 1) {
|
|
|
+ var node = collection.get(i);
|
|
|
+ if (!layout.isVisible(node)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var box = node.bounds();
|
|
|
+ var width = box.right - box.left;
|
|
|
+ var height = box.bottom - box.top;
|
|
|
+ if (width < 140 || width > 280 || height < 140 || height > 280) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (box.top > device.height * 0.33) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ /* 取面积最大的那个:页面上部可能还有别的图标,头像一定是最大的 */
|
|
|
+ if (width * height > bestArea) {
|
|
|
+ bestArea = width * height;
|
|
|
+ best = node;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return best;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ------------------------------------------------------------ 导航
|
|
|
+
|
|
|
+ /** 当前在哪个页面(ID 提取这条链路自己的判据) */
|
|
|
+ function where() {
|
|
|
+ return TikTokAuto.tiktok.idPageState();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 打开一个账号的主页(走搜索)。
|
|
|
+ *
|
|
|
+ * 为什么不直接拼 URL 打开:TikTok 的 deeplink 需要签名参数,
|
|
|
+ * 而且"用户在用的这个 TikTok 版本"才是唯一可靠的入口。
|
|
|
+ * 走搜索虽然慢(约 8 秒),但实测稳定。
|
|
|
+ *
|
|
|
+ * @returns 资料对象;失败返回 null
|
|
|
+ */
|
|
|
+ function openBySearch(uid, config) {
|
|
|
+ var step = 'openProfile';
|
|
|
+ var tiktok = TikTokAuto.tiktok;
|
|
|
+ var log = logger();
|
|
|
+ var parse = TikTokAuto.parse;
|
|
|
+ var wanted = parse.username(uid);
|
|
|
+
|
|
|
+ if (wanted === '') {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ log.step(step, '搜索并打开 @' + wanted);
|
|
|
+ if (!ensureFeed(config)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (!openSearch(config)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (!submit(wanted, config)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 切到 Users 页签:不切的话看到的是视频,找不到人 */
|
|
|
+ var usersTab = tiktok.resultTab('Users');
|
|
|
+ if (usersTab) {
|
|
|
+ TikTokAuto.ui.clickNode(usersTab, step + ':usersTab');
|
|
|
+ /* ★ 等**结果行真的出现**,不能只等"页面安静":
|
|
|
+ * 页签条一渲染出来签名就稳定了,而结果列表还要联网加载 1~2 秒。
|
|
|
+ * 这里如果不等,就会稳定地误报"搜索结果里找不到该账号"(实测踩到)。 */
|
|
|
+ TikTokAuto.wait.waitFor(step + ':rows', function () {
|
|
|
+ return tiktok.usersResultRows().length > 0 ? true : null;
|
|
|
+ }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
|
|
|
+ }
|
|
|
+
|
|
|
+ var row = findRowByUid(wanted);
|
|
|
+ if (!row) {
|
|
|
+ log.warn(step, '搜索结果里找不到 @' + wanted +
|
|
|
+ '(当前可见 ' + tiktok.usersResultRows().length + ' 行)');
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ tapRow(row, step + ':row');
|
|
|
+ var arrived = waitProfile(step);
|
|
|
+
|
|
|
+ if (!arrived) {
|
|
|
+ /* 实测:`node.click()` 打在整行容器上会落在**中心**,
|
|
|
+ * 而搜索用户行的中心那块是空白(有时还是 Follow 按钮的邻域)——
|
|
|
+ * 点了返回 true 却没进主页。左侧 30% 处(用户名文字上)才稳定。
|
|
|
+ * 所以这里再补一次"按坐标点",只重试一次,不做无限重试。 */
|
|
|
+ log.info(step, '第一次点击没进主页,改用行内坐标重试');
|
|
|
+ tapRow(row, step + ':row-retry');
|
|
|
+ arrived = waitProfile(step + ':retry');
|
|
|
+ }
|
|
|
+ if (!arrived) {
|
|
|
+ log.warn(step, '点了搜索结果但没有进入用户主页(当前 ' + where() + ')');
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ /* 主页渲染完成后,数字区可能还差一帧。读之前再确认一次"三组数字里至少有一个读到了",
|
|
|
+ * 否则会把"还没渲染"当成"这个账号没有数据"。最多再等 3 秒。 */
|
|
|
+ TikTokAuto.wait.waitFor(step + ':stats', function () {
|
|
|
+ var stats = tiktok.statNumbers();
|
|
|
+ return (stats.follow !== null || stats.fans !== null || stats.likes !== null) ? true : null;
|
|
|
+ }, 3000, 200);
|
|
|
+ return readProfile();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 搜索页 → 输入 → 提交。复用 flow 里已经验证过的锚点,但关键词由这里给。 */
|
|
|
+ function openSearch(config) {
|
|
|
+ var step = 'openSearch';
|
|
|
+ var entry = TikTokAuto.tiktok.searchEntry();
|
|
|
+ if (!entry) {
|
|
|
+ logger().error(step, '首页找不到搜索入口');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ TikTokAuto.ui.clickNode(entry, step);
|
|
|
+ return TikTokAuto.wait.waitFor(step, function () {
|
|
|
+ var state = TikTokAuto.tiktok.idPageState();
|
|
|
+ return (state === 'search' || state === 'users-results') ? true : null;
|
|
|
+ }, config.timeouts.pageReadyMs, config.probe.pollTickMs) === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function submit(keyword, config) {
|
|
|
+ var step = 'submitKeyword';
|
|
|
+ var tiktok = TikTokAuto.tiktok;
|
|
|
+
|
|
|
+ var input = tiktok.searchInput();
|
|
|
+ if (!input) {
|
|
|
+ logger().error(step, '找不到搜索输入框');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ TikTokAuto.ui.clickNode(input, step);
|
|
|
+ TikTokAuto.wait.tick(250);
|
|
|
+
|
|
|
+ var typed = TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, step);
|
|
|
+ if (!typed.ok) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ var button = tiktok.searchSubmit();
|
|
|
+ if (!button) {
|
|
|
+ logger().error(step, '找不到搜索提交按钮');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ TikTokAuto.ui.clickNode(button, step);
|
|
|
+
|
|
|
+ return TikTokAuto.wait.waitFor(step, function () {
|
|
|
+ var state = tiktok.idPageState();
|
|
|
+ return (state === 'users-results' || TikTokAuto.tiktok.resultTabStripPresent()) ? true : null;
|
|
|
+ }, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 在当前搜索结果里按 uid 找行(找不到返回 null,不猜第一行) */
|
|
|
+ function findRowByUid(uid) {
|
|
|
+ var rows = TikTokAuto.tiktok.usersResultRows();
|
|
|
+ for (var i = 0; i < rows.length; i += 1) {
|
|
|
+ if (rows[i].uid === uid) {
|
|
|
+ return rows[i];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 保证停在信息流首页(复用 flow 的成熟实现,避免两套退出逻辑) */
|
|
|
+ function ensureFeed(config) {
|
|
|
+ return TikTokAuto.flow.ensureFeed(config) === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在用户主页上打开「粉丝/关注」列表。
|
|
|
+ *
|
|
|
+ * ★ 这里**不能**用 `settle()` 判就绪:点完之后列表要联网加载,
|
|
|
+ * 而 `waitQuiet` 的判据是"可见文案连续两次不变" —— 加载中的列表和
|
|
|
+ * 打开前的页面**签名是一样的**,于是它会立刻就返回(实测 872ms 就回来了),
|
|
|
+ * 然后被判成"打不开列表"。正确做法是等**列表真的有行**。
|
|
|
+ *
|
|
|
+ * @param which 'fans' | 'following'
|
|
|
+ */
|
|
|
+ function openList(which, config) {
|
|
|
+ var step = 'openList';
|
|
|
+ var tiktok = TikTokAuto.tiktok;
|
|
|
+ var entry = tiktok.statEntry(which === 'following' ? 'following' : 'fans');
|
|
|
+ if (!entry) {
|
|
|
+ logger().warn(step, '用户主页上找不到 ' + which + ' 入口');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ logger().step(step, '打开列表:' + which);
|
|
|
+ TikTokAuto.ui.clickNode(entry, step);
|
|
|
+
|
|
|
+ var ready = TikTokAuto.wait.waitFor(step, function () {
|
|
|
+ return where() === 'user-list' ? true : null;
|
|
|
+ }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
|
|
|
+
|
|
|
+ if (ready === true) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 与粉丝列表相关的一个真机实况:首次打开会弹「给 TikTok 访问你的 Facebook 好友列表」
|
|
|
+ * 的授权窗,盖住整个列表。它不是错误页,但会让"列表就绪"永远等不到。
|
|
|
+ * 这里点掉它再等一次 —— 只做一轮,不无限重试。 */
|
|
|
+ if (dismissFriendPrompt()) {
|
|
|
+ logger().info(step, '点掉了好友列表授权窗,再等一次');
|
|
|
+ ready = TikTokAuto.wait.waitFor(step + ':retry', function () {
|
|
|
+ return where() === 'user-list' ? true : null;
|
|
|
+ }, config.timeouts.pageReadyMs, config.probe.pollTickMs);
|
|
|
+ }
|
|
|
+ return ready === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 点掉「Facebook 好友列表」授权窗的"不允许"。
|
|
|
+ *
|
|
|
+ * 实测这个弹窗的文案是英文 `Don’t allow`(撇号是 U+2019,不是 ASCII 单引号,
|
|
|
+ * 直接比对 'Don\'t allow' 是匹配不上的 —— 这里按**包含**比对并同时认几种写法)。
|
|
|
+ * 找不到就当没有弹窗,返回 false,由上层决定下一步。
|
|
|
+ */
|
|
|
+ function dismissFriendPrompt() {
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
+ var keywords = ['Facebook', '好友列表', 'friends list'];
|
|
|
+ var buttons = className('android.widget.Button').find();
|
|
|
+ var hint = false;
|
|
|
+
|
|
|
+ for (var i = 0; i < buttons.size(); i += 1) {
|
|
|
+ var text = layout.text(buttons.get(i));
|
|
|
+ if (text === '') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (text.indexOf('allow') >= 0 || text.indexOf('允许') >= 0 || text.indexOf('允許') >= 0) {
|
|
|
+ hint = true;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ for (var k = 0; k < keywords.length; k += 1) {
|
|
|
+ if (text.indexOf(keywords[k]) >= 0) {
|
|
|
+ hint = true;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!hint) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 有授权窗:找那个"不允许 / Don't allow",它一定比"OK"靠左(实测 [260,1114][539,1209] vs [540,1114][819,1209]) */
|
|
|
+ var target = null;
|
|
|
+ for (var j = 0; j < buttons.size(); j += 1) {
|
|
|
+ var label = layout.text(buttons.get(j));
|
|
|
+ if (/don.t allow|不允许|不允許|稍后|稍後|not now|no thanks/i.test(label)) {
|
|
|
+ if (target === null || buttons.get(j).bounds().left < target.bounds().left) {
|
|
|
+ target = buttons.get(j);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!target) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ logger().info('profile', '点掉授权窗:' + JSON.stringify(TikTokAuto.layout.text(target)));
|
|
|
+ TikTokAuto.ui.clickNode(target, 'dismissFriendPrompt');
|
|
|
+ settle(2000);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在列表页切页签。已在目标页签上时直接返回 true(不重复点)。
|
|
|
+ * @param which 'fans' | 'following'
|
|
|
+ */
|
|
|
+ function switchListTab(which) {
|
|
|
+ var step = 'switchTab';
|
|
|
+ var tabs = TikTokAuto.tiktok.userListTabs();
|
|
|
+ var wanted = which === 'following' ? 'following' : 'fans';
|
|
|
+
|
|
|
+ for (var i = 0; i < tabs.length; i += 1) {
|
|
|
+ if (tabs[i].key !== wanted) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (TikTokAuto.tiktok.selectedListTab() === wanted) {
|
|
|
+ logger().info(step, '已在 ' + wanted + ' 页签上');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ logger().info(step, '切到 ' + wanted + ' 页签(' + tabs[i].label + ')');
|
|
|
+ TikTokAuto.ui.clickNode(tabs[i].node, step);
|
|
|
+ settle(3000);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ logger().warn(step, '找不到 ' + wanted + ' 页签');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 列表当前可见的每一行(已按屏幕从上到下排序) */
|
|
|
+ function listRows() {
|
|
|
+ return TikTokAuto.tiktok.userListRows();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 列表向下翻一屏。
|
|
|
+ *
|
|
|
+ * 起点刻意选在屏幕中部偏下、终点在中部偏上:太靠下会落到 Follow 按钮上
|
|
|
+ * (按钮吃掉手势,页面纹丝不动 —— 这个坑在界面上踩过一次,这里避开)。
|
|
|
+ *
|
|
|
+ * @returns 翻之前那一屏的 uid 签名,供调用方判断"翻完有没有新东西"
|
|
|
+ */
|
|
|
+ function scrollList() {
|
|
|
+ var layout = TikTokAuto.layout;
|
|
|
+ var screen = layout.screen();
|
|
|
+ TikTokAuto.ui.dragScreen(
|
|
|
+ Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.78),
|
|
|
+ Math.floor(screen.width * 0.3), Math.floor(screen.height * 0.3),
|
|
|
+ 420
|
|
|
+ );
|
|
|
+ settle(2500);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 点列表里的一行,进入该用户主页 */
|
|
|
+ function openRow(row) {
|
|
|
+ var step = 'openRow';
|
|
|
+ if (!row) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ logger().info(step, '点开 @' + row.uid);
|
|
|
+ tapRow(row, step);
|
|
|
+ return waitProfile(step);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 等用户主页**真的渲染出来**。
|
|
|
+ *
|
|
|
+ * ★ 不能只等 `settle()`(页面安静):点开主页后骨架先出现、`@username`
|
|
|
+ * 那几个字后出现,中间页面是"安静的" —— 于是 settle 会提前返回,
|
|
|
+ * 紧接着 readProfile() 读到一份**空资料**。
|
|
|
+ * 实测后果:一个粉丝数为 0 的账号被当成"读不到资料",或被误判过滤。
|
|
|
+ * 判据必须是"@username 节点已经存在"。
|
|
|
+ */
|
|
|
+ function waitProfile(step) {
|
|
|
+ return TikTokAuto.wait.waitFor(step + ':profile', function () {
|
|
|
+ return TikTokAuto.tiktok.profileUsernameNode() !== null ? true : null;
|
|
|
+ }, 10000, 300) === true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 点一行用户条目。
|
|
|
+ *
|
|
|
+ * 用**坐标**而不是 `node.click()`:整行容器的 click() 在真机上常常落到
|
|
|
+ * "行内第一个可点元素"(也就是右侧那个 Follow 按钮),会把用户关注掉 ——
|
|
|
+ * 这是不能接受的副作用。所以点在行左侧 30% 处(用户名文字上),远离按钮。
|
|
|
+ * 实测这一处既能进主页,也不会误触关注。
|
|
|
+ */
|
|
|
+ function tapRow(row, step) {
|
|
|
+ if (row.x === undefined || row.y === undefined) {
|
|
|
+ TikTokAuto.ui.clickNode(row.node, step || 'tapRow');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ TikTokAuto.ui.tapPoint(row.x, row.y);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从用户主页退回列表(列表位置会保持,实测) */
|
|
|
+ function backToList() {
|
|
|
+ var step = 'backToList';
|
|
|
+ var deadline = Date.now() + 8000;
|
|
|
+ while (Date.now() < deadline) {
|
|
|
+ var state = where();
|
|
|
+ if (state === 'user-list') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (state === 'profile' || state === 'comment-panel' || state === 'users-results') {
|
|
|
+ TikTokAuto.ui.goBack(step);
|
|
|
+ settle(2000);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ /* 其它页面(搜索/结果/信息流):已经退过头了 */
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return where() === 'user-list';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从列表退回用户主页 */
|
|
|
+ function backToProfile() {
|
|
|
+ var step = 'backToProfile';
|
|
|
+ var deadline = Date.now() + 8000;
|
|
|
+ while (Date.now() < deadline) {
|
|
|
+ var state = where();
|
|
|
+ if (state === 'profile') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ TikTokAuto.ui.goBack(step);
|
|
|
+ settle(2000);
|
|
|
+ }
|
|
|
+ return where() === 'profile';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从用户主页退回评论区。
|
|
|
+ *
|
|
|
+ * 与 backToProfile/backToList 是一组:评论区取材时每个评论者都要
|
|
|
+ * "点头像 → 读 → 退回来",退错一层(退到播放页而不是评论区)就会
|
|
|
+ * 让下一轮找不到评论者。所以这里按状态确认,不靠数次数。
|
|
|
+ */
|
|
|
+ function backToCommentPanel() {
|
|
|
+ var step = 'backToComment';
|
|
|
+ var deadline = Date.now() + 10000;
|
|
|
+ while (Date.now() < deadline) {
|
|
|
+ var state = where();
|
|
|
+ if (state === 'comment-panel') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ if (state === 'player') {
|
|
|
+ /* 退到了播放页:把评论面板重新点开,而不是继续退 */
|
|
|
+ var button = TikTokAuto.tiktok.commentButton();
|
|
|
+ if (button) {
|
|
|
+ TikTokAuto.ui.clickNode(button, step + ':reopen');
|
|
|
+ settle(2500);
|
|
|
+ if (where() === 'comment-panel') {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ TikTokAuto.ui.goBack(step);
|
|
|
+ settle(2000);
|
|
|
+ }
|
|
|
+ return where() === 'comment-panel';
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ readProfile: readProfile,
|
|
|
+ readAvatarState: readAvatarState,
|
|
|
+ avatarNode: avatarNode,
|
|
|
+ where: where,
|
|
|
+ ensureFeed: ensureFeed,
|
|
|
+ openBySearch: openBySearch,
|
|
|
+ findRowByUid: findRowByUid,
|
|
|
+ openList: openList,
|
|
|
+ switchListTab: switchListTab,
|
|
|
+ listRows: listRows,
|
|
|
+ scrollList: scrollList,
|
|
|
+ openRow: openRow,
|
|
|
+ backToList: backToList,
|
|
|
+ backToProfile: backToProfile,
|
|
|
+ backToCommentPanel: backToCommentPanel,
|
|
|
+ settle: settle,
|
|
|
+ dismissFriendPrompt: dismissFriendPrompt,
|
|
|
+ AVATAR_SATURATED_RATIO: AVATAR_SATURATED_RATIO
|
|
|
+ };
|
|
|
+}());
|