fingerprint.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * fingerprint.js — 配置指纹(界面侧)
  3. *
  4. * ★ **逐字节同算法的一份拷贝**,源头在 `tiktok-auto/src/fingerprint.js`。
  5. *
  6. * 为什么必须有两份:界面要在"点继续之前"就判断断点还有没有效(不折腾用户手机),
  7. * 引擎要在"启动 App 之前"再判断一次(用户也可能直接改配置手起引擎)。
  8. * 项目没有共享模块机制,只能各放一份。
  9. *
  10. * **改算法必须两边一起改**:两侧算出的指纹不同 = 界面说"能续跑"、引擎说"配置变了"。
  11. * `tiktok-auto/tools/pc-selftest-id.js` 有一组断言专门比对两份实现的结果。
  12. */
  13. var Fingerprint = (function () {
  14. 'use strict';
  15. var VERSION = 1;
  16. /**
  17. * 是不是数组。
  18. *
  19. * 不用 `v instanceof Array`:跨 realm(PC 自检把 Node 造的对象喂进 vm 沙箱)时
  20. * `instanceof` 会返回 false,那会让"数组"被当成普通对象序列化,两边算出的指纹就不一样了。
  21. *
  22. * 也不用 `toString` 配 `Function.prototype.call` 的写法:那个关键字加左括号
  23. * 命中 MCP 危险调用黑名单(`tools/build.js` 的 `DANGEROUS_RULES`),会让每次下发都要人工确认。
  24. * 优先 `Array.isArray`(ES5,AutoX 的 Rhino 支持),没有时退回 `.apply`。
  25. */
  26. function isArray(v) {
  27. if (typeof Array.isArray === 'function') {
  28. return Array.isArray(v);
  29. }
  30. return Object.prototype.toString.apply(v, []) === '[object Array]';
  31. }
  32. /** 自有键,排序后返回(键序无关 → 同一份配置换个键序不会让断点失效) */
  33. function ownKeys(v) {
  34. var keys = [];
  35. for (var k in v) {
  36. if (Object.prototype.hasOwnProperty.apply(v, [k]) === true) {
  37. keys.push(k);
  38. }
  39. }
  40. keys.sort();
  41. return keys;
  42. }
  43. /**
  44. * 稳定序列化:对象按键名排序、数组保持顺序、每个值带类型前缀。
  45. *
  46. * 类型前缀是必须的 —— 否则 `"1"`(字符串)和 `1`(数字)会序列化成同一个串,
  47. * 而界面上"粉丝下限"这类字段确实可能一个是空串一个是 0。
  48. */
  49. function canon(v) {
  50. if (v === null || v === undefined) {
  51. return 'n';
  52. }
  53. if (isArray(v)) {
  54. var parts = [];
  55. for (var i = 0; i < v.length; i += 1) {
  56. parts.push(canon(v[i]));
  57. }
  58. return '[' + parts.join(',') + ']';
  59. }
  60. if (typeof v === 'object') {
  61. var keys = ownKeys(v);
  62. var out = [];
  63. for (var j = 0; j < keys.length; j += 1) {
  64. out.push(keys[j] + ':' + canon(v[keys[j]]));
  65. }
  66. return '{' + out.join(',') + '}';
  67. }
  68. if (typeof v === 'number') {
  69. return 'd' + String(v);
  70. }
  71. if (typeof v === 'boolean') {
  72. return v ? 'T' : 'F';
  73. }
  74. return 's' + JSON.stringify(String(v));
  75. }
  76. /**
  77. * djb2-xor 摘要。
  78. *
  79. * 末尾拼上原文长度:32 位散列碰撞概率本来就不高,加个长度等于多一维区分,
  80. * 而且是"免费"的(长度本来就要算)。输出是 base36,比十六进制短。
  81. */
  82. function digest(text) {
  83. var s = String(text);
  84. var h = 5381;
  85. for (var i = 0; i < s.length; i += 1) {
  86. h = (((h << 5) + h) ^ s.charCodeAt(i)) | 0; /* |0 截成 32 位,避免精度丢失 */
  87. }
  88. return (h >>> 0).toString(36) + '-' + s.length.toString(36);
  89. }
  90. /** 把一条取材归一化成"只含参与指纹的字段",缺项补默认 */
  91. function ofSource(one, index) {
  92. var s = one || {};
  93. return {
  94. key: s.key || ('i' + index + ':' + (s.kind || '')),
  95. kind: s.kind || '',
  96. anchors: s.anchors || '',
  97. lists: isArray(s.lists) ? s.lists : [],
  98. searchType: s.searchType || '',
  99. keyword: s.keyword || '',
  100. videoSource: s.videoSource || '',
  101. videoKeyword: s.videoKeyword || '',
  102. path: s.path || '',
  103. expand: s.expand || ''
  104. };
  105. }
  106. /**
  107. * 算 `id` 段的指纹。
  108. *
  109. * **分项存**(sources / filter / output 各一份)而不只存一个总串:
  110. * 界面要能说清"是**取材**改过了"而不是含糊的"配置变了",用户才知道该改回去什么。
  111. *
  112. * @param id `config.id` 整段(界面传 `Engine.buildIdConfig().config.id`,
  113. * 引擎传解析后的 `config.id` —— 两者是同一份数据)
  114. */
  115. function ofIdSection(id) {
  116. var section = id || {};
  117. var list = isArray(section.sources) ? section.sources : [];
  118. var sources = [];
  119. for (var i = 0; i < list.length; i += 1) {
  120. sources.push(ofSource(list[i], i));
  121. }
  122. var f = section.filter || {};
  123. var o = section.output || {};
  124. var sourcesHash = digest(canon(sources));
  125. var filterHash = digest(canon({
  126. private: f.private === true,
  127. noAvatar: f.noAvatar === true,
  128. langRegion: f.langRegion || 'any',
  129. nicknameCheck: f.nicknameCheck || 'any',
  130. follow: f.follow || {},
  131. fans: f.fans || {},
  132. likes: f.likes || {}
  133. }));
  134. var outputHash = digest(canon({
  135. dir: o.dir || '',
  136. historyPath: o.historyPath || '',
  137. dedupeHistory: o.dedupeHistory !== false
  138. }));
  139. return {
  140. v: VERSION,
  141. sources: sourcesHash,
  142. filter: filterHash,
  143. output: outputHash,
  144. all: digest(sourcesHash + filterHash + outputHash)
  145. };
  146. }
  147. /**
  148. * 比两份指纹。
  149. * @returns { same, changed: ['sources'|'filter'|'output'] }
  150. */
  151. function compare(a, b) {
  152. if (!a || !b || a.v !== VERSION || b.v !== VERSION) {
  153. return { same: false, changed: ['sources', 'filter', 'output'] };
  154. }
  155. var changed = [];
  156. if (a.sources !== b.sources) {
  157. changed.push('sources');
  158. }
  159. if (a.filter !== b.filter) {
  160. changed.push('filter');
  161. }
  162. if (a.output !== b.output) {
  163. changed.push('output');
  164. }
  165. return { same: changed.length === 0, changed: changed };
  166. }
  167. return {
  168. VERSION: VERSION,
  169. canon: canon,
  170. digest: digest,
  171. ofIdSection: ofIdSection,
  172. compare: compare
  173. };
  174. }());