player.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /**
  2. * player.js — 「看视频」这件事:播放存活探测 + 有界观看
  3. *
  4. * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。
  5. * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒,
  6. * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。
  7. * 网络一抖,sleep 出来的"观看"就是假的。
  8. *
  9. * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**——
  10. * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始);
  11. * 2. 观看期间持续做两件事:
  12. * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报);
  13. * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒;
  14. * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。
  15. *
  16. * 播放存活证据(按可用性排序,可配置):
  17. * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用
  18. * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权
  19. * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里
  20. * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。
  21. *
  22. * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui
  23. */
  24. TikTokAuto.player = (function () {
  25. 'use strict';
  26. var PIXEL_SAMPLE_POINTS = [
  27. [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45]
  28. ];
  29. // ------------------------------------------------------------------ 证据源
  30. function createAudioProbe() {
  31. var manager = null;
  32. try {
  33. if (typeof context !== 'undefined' && context) {
  34. manager = context.getSystemService('audio');
  35. }
  36. } catch (error) {
  37. manager = null;
  38. }
  39. if (!manager) {
  40. return null;
  41. }
  42. return {
  43. name: 'audio',
  44. read: function () {
  45. try {
  46. return manager.isMusicActive() === true;
  47. } catch (error) {
  48. return false;
  49. }
  50. }
  51. };
  52. }
  53. function createPixelProbe() {
  54. var first = null;
  55. function grab() {
  56. var image = captureScreen();
  57. if (!image) {
  58. return null;
  59. }
  60. var width = image.getWidth();
  61. var height = image.getHeight();
  62. var samples = [];
  63. for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) {
  64. var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]);
  65. var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]);
  66. samples.push(image.pixel(x, y));
  67. }
  68. return samples.join(',');
  69. }
  70. try {
  71. first = grab();
  72. } catch (error) {
  73. return null; // 没有 MediaProjection 授权,直接放弃这条证据源
  74. }
  75. if (first === null) {
  76. return null;
  77. }
  78. return {
  79. name: 'pixels',
  80. read: function () {
  81. try {
  82. var now = grab();
  83. return now !== null && now !== first;
  84. } catch (error) {
  85. return false;
  86. }
  87. }
  88. };
  89. }
  90. /**
  91. * 组装可用的探测源。
  92. * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。
  93. */
  94. function createProbe(config) {
  95. var sources = [];
  96. if (config.probe.useAudio) {
  97. var audio = createAudioProbe();
  98. if (audio) {
  99. sources.push(audio);
  100. }
  101. }
  102. if (config.probe.usePixels) {
  103. var pixels = createPixelProbe();
  104. if (pixels) {
  105. sources.push(pixels);
  106. }
  107. }
  108. return {
  109. mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'),
  110. degraded: sources.length === 0,
  111. /** 任一证据源报"在动"就算活着 */
  112. alive: function () {
  113. for (var i = 0; i < sources.length; i += 1) {
  114. if (sources[i].read()) {
  115. return true;
  116. }
  117. }
  118. return false;
  119. }
  120. };
  121. }
  122. // ------------------------------------------------------------------ 等待播放
  123. /** 等播放真正开始(而不是假定点开就在播) */
  124. function waitPlaybackStart(config, step) {
  125. var logger = TikTokAuto.logger;
  126. var probe = createProbe(config);
  127. if (probe.degraded) {
  128. logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式');
  129. return { started: false, probe: probe };
  130. }
  131. var started = TikTokAuto.wait.until(function () {
  132. return probe.alive() ? true : null;
  133. }, config.timeouts.playbackStartMs, config.probe.pollTickMs);
  134. if (started) {
  135. logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')');
  136. } else {
  137. logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)');
  138. }
  139. return { started: started === true, probe: probe };
  140. }
  141. // ------------------------------------------------------------------ 观看
  142. function randomInt(min, max) {
  143. return Math.floor(Math.random() * (max - min + 1)) + min;
  144. }
  145. /**
  146. * 观看视频。
  147. *
  148. * @param config 生效配置
  149. * @param options { step, onInterrupt }
  150. * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何")
  151. */
  152. function watch(config, options) {
  153. var settings = options || {};
  154. var step = settings.step || 'watch';
  155. var logger = TikTokAuto.logger;
  156. var tiktok = TikTokAuto.tiktok;
  157. var targetMs = randomInt(config.watch.minMs, config.watch.maxMs);
  158. var start = waitPlaybackStart(config, step);
  159. var probe = start.probe;
  160. var beganAt = Date.now();
  161. var deadline = beganAt + targetMs;
  162. var tickMs = config.probe.pollTickMs;
  163. var nextLivenessAt = beganAt + config.probe.livenessIntervalMs;
  164. var stalls = 0;
  165. var offPage = 0;
  166. var maxOffPage = 3;
  167. var nudges = 0;
  168. var aborted = null;
  169. logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' +
  170. config.watch.minMs + '-' + config.watch.maxMs + 'ms),证据源=' + probe.mode);
  171. while (Date.now() < deadline) {
  172. // (a) 页面身份守卫:不在播放页就不是"在看视频"
  173. if (!tiktok.onPlayerPage()) {
  174. offPage += 1;
  175. logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)');
  176. if (offPage >= maxOffPage) {
  177. aborted = 'left-player-page';
  178. break;
  179. }
  180. } else {
  181. offPage = 0;
  182. }
  183. // (b) 播放存活采样
  184. if (Date.now() >= nextLivenessAt) {
  185. nextLivenessAt = Date.now() + config.probe.livenessIntervalMs;
  186. if (!probe.degraded) {
  187. if (probe.alive()) {
  188. stalls = 0;
  189. } else {
  190. stalls += 1;
  191. logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')');
  192. if (stalls >= config.probe.stallTolerance) {
  193. // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西
  194. if (nudges < 1) {
  195. nudge(step);
  196. nudges += 1;
  197. stalls = 0;
  198. } else {
  199. logger.warn(step, '判定卡顿且唤醒无效,提前结束观看');
  200. aborted = 'stalled';
  201. break;
  202. }
  203. }
  204. }
  205. }
  206. }
  207. TikTokAuto.wait.tick(tickMs);
  208. }
  209. var actualMs = Date.now() - beganAt;
  210. var report = {
  211. requestedMs: targetMs,
  212. actualMs: actualMs,
  213. probeMode: probe.mode,
  214. degraded: probe.degraded,
  215. nudges: nudges,
  216. aborted: aborted,
  217. completed: aborted === null && actualMs >= targetMs - tickMs
  218. };
  219. if (report.completed) {
  220. logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms');
  221. } else {
  222. logger.warn(step, '观看未按计划完成:' + JSON.stringify(report));
  223. }
  224. return report;
  225. }
  226. /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */
  227. function nudge(step) {
  228. var layout = TikTokAuto.layout;
  229. var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) };
  230. TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')');
  231. TikTokAuto.ui.tapPoint(point.x, point.y);
  232. TikTokAuto.wait.tick(300);
  233. }
  234. /** 等播放页就绪(点开卡片之后、开始计时之前) */
  235. function waitPlayerReady(config, step) {
  236. var logger = TikTokAuto.logger;
  237. var tiktok = TikTokAuto.tiktok;
  238. /* 先花最多 4 秒分辨"打开的是不是视频"。
  239. *
  240. * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**,
  241. * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。
  242. * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。
  243. *
  244. * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。
  245. */
  246. var kind = TikTokAuto.wait.waitFor(step + ':kind', function () {
  247. if (tiktok.onPlayerPage()) {
  248. return 'player';
  249. }
  250. if (tiktok.photoPost()) {
  251. return 'photo';
  252. }
  253. return null;
  254. }, 4000, config.probe.pollTickMs);
  255. if (kind === 'photo') {
  256. logger.info(step, '打开的是照片帖(没有播放页),跳过');
  257. return false;
  258. }
  259. var ready = TikTokAuto.wait.waitFor(step, function () {
  260. return tiktok.onPlayerPage() ? true : null;
  261. }, config.timeouts.playerReadyMs, config.probe.pollTickMs);
  262. if (ready) {
  263. TikTokAuto.wait.waitQuiet(function () {
  264. return TikTokAuto.layout.signature();
  265. }, { maxMs: 3000, tickMs: 250, stableTimes: 3 });
  266. }
  267. return ready === true;
  268. }
  269. return {
  270. createProbe: createProbe,
  271. waitPlaybackStart: waitPlaybackStart,
  272. waitPlayerReady: waitPlayerReady,
  273. watch: watch,
  274. randomInt: randomInt
  275. };
  276. }());