parse.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /**
  2. * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析
  3. *
  4. * ## 为什么单独一个模块
  5. * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链:
  6. * · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较;
  7. * · 语言区域要靠昵称/个签里的字符构成来判;
  8. * · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。
  9. * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里
  10. * **唯一一处可以用断言证明"逻辑对了"** 的部分。
  11. *
  12. * ## 关于数字缩写(真机实测)
  13. * TikTok 的计数一律是缩写形态,实测到的写法:
  14. * "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K"
  15. * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。
  16. *
  17. * 依赖:无(纯函数,不取任何模块引用)
  18. */
  19. TikTokAuto.parse = (function () {
  20. 'use strict';
  21. /* ---------------------------------------------------------- 数字 */
  22. /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */
  23. var SUFFIX = {
  24. '': 1,
  25. 'k': 1000,
  26. 'm': 1000000,
  27. 'b': 1000000000,
  28. 'w': 10000, /* 万:中文界面 "1.2万" */
  29. '万': 10000,
  30. '萬': 10000,
  31. '亿': 100000000,
  32. '億': 100000000
  33. };
  34. /**
  35. * 把一个计数字符串解析成整数。
  36. *
  37. * 返回 **null** 表示"读不到",与 **0** 严格区分:
  38. * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 ——
  39. * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。
  40. *
  41. * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万"
  42. * @returns {number|null}
  43. */
  44. function count(raw) {
  45. if (raw === null || raw === undefined) {
  46. return null;
  47. }
  48. /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */
  49. var text = String(raw)
  50. .replace(/[0-9]/g, function (ch) {
  51. return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0);
  52. })
  53. .replace(/[,]/g, ',');
  54. /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。
  55. * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */
  56. var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/);
  57. if (!matched) {
  58. return null;
  59. }
  60. var number = Number(String(matched[1]).replace(/,/g, ''));
  61. if (isNaN(number)) {
  62. return null;
  63. }
  64. var suffix = String(matched[2] || '').toLowerCase();
  65. var factor = SUFFIX[suffix];
  66. if (factor === undefined) {
  67. /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */
  68. factor = 1;
  69. }
  70. return Math.round(number * factor);
  71. }
  72. /* ---------------------------------------------------------- 语言 */
  73. /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */
  74. var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/;
  75. var LATIN = /[A-Za-z\u00c0-\u024f]/; /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */
  76. /**
  77. * 估算一段文本的语种。只返回三类,够用且不猜。
  78. *
  79. * 判定规则(不是拍脑袋,理由在下面):
  80. * · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高,
  81. * 算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。
  82. * · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"),
  83. * 门槛设成 50% 会把这类人漏掉。
  84. * · 英文门槛 **70%**:反过来,英文用户极少混中文,
  85. * 所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。
  86. *
  87. * @returns {string} "zh" | "en" | "mixed" | "none"
  88. */
  89. function language(raw) {
  90. var text = String(raw === null || raw === undefined ? '' : raw);
  91. if (text === '') {
  92. return 'none';
  93. }
  94. var cjk = 0;
  95. var latin = 0;
  96. for (var i = 0; i < text.length; i += 1) {
  97. var ch = text.charAt(i);
  98. if (CJK.test(ch)) {
  99. cjk += 1;
  100. } else if (LATIN.test(ch)) {
  101. latin += 1;
  102. }
  103. }
  104. var total = cjk + latin;
  105. if (total === 0) {
  106. return 'none'; /* 纯 emoji / 纯数字 / 阿拉伯文等 */
  107. }
  108. var zhRatio = cjk / total;
  109. var enRatio = latin / total;
  110. if (zhRatio >= 0.3) {
  111. return 'zh';
  112. }
  113. if (enRatio >= 0.7) {
  114. return 'en';
  115. }
  116. return 'mixed';
  117. }
  118. /**
  119. * 语言是否匹配"仅中文 / 仅英文 / 通杀"。
  120. *
  121. * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。
  122. * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。
  123. */
  124. function languageMatches(want, actual) {
  125. if (!want || want === 'any') {
  126. return true;
  127. }
  128. if (want === 'zh') {
  129. return actual === 'zh';
  130. }
  131. if (want === 'en') {
  132. return actual === 'en';
  133. }
  134. return true;
  135. }
  136. /* ---------------------------------------------------------- 区间 */
  137. /**
  138. * 区间判定。
  139. *
  140. * 三种状态必须分开表达,这是这个函数存在的理由:
  141. * · **不限**(min/max 都是 0)→ 永远通过,连值都不用读;
  142. * · **有值** → 落在 [min, max] 内(0 表示该侧不设限);
  143. * · **读不到**(value === null)→ **不通过**。
  144. *
  145. * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件;
  146. * 读不到就无法证明符合,宁可少收并在日志里说清楚 ——
  147. * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。
  148. *
  149. * @returns {boolean}
  150. */
  151. function inRange(value, min, max) {
  152. var low = Number(min) || 0;
  153. var high = Number(max) || 0;
  154. if (low <= 0 && high <= 0) {
  155. return true;
  156. }
  157. if (value === null || value === undefined) {
  158. return false;
  159. }
  160. var number = Number(value);
  161. if (isNaN(number)) {
  162. return false;
  163. }
  164. if (low > 0 && number < low) {
  165. return false;
  166. }
  167. if (high > 0 && number > high) {
  168. return false;
  169. }
  170. return true;
  171. }
  172. /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */
  173. function isRangeOpen(min, max) {
  174. return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0;
  175. }
  176. /* ---------------------------------------------------------- 用户名 */
  177. /**
  178. * 把 "@username" 归一成 "username"。
  179. *
  180. * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069):
  181. * 显示为 `⁨ajman_fashion_week⁩`,肉眼完全看不出,但直接当文件名/比对键就会出错。
  182. * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符,
  183. * 是双向文本隔离符),所以这里单独剥一次。
  184. *
  185. * 同时剥掉全角 @ 与首尾空白。
  186. */
  187. function username(raw) {
  188. if (raw === null || raw === undefined) {
  189. return '';
  190. }
  191. return String(raw)
  192. .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
  193. .replace(/\s+/g, '')
  194. .replace(/^[@@]+/, '');
  195. }
  196. /**
  197. * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。
  198. *
  199. * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途),
  200. * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。
  201. * 判定必须**保留原始空白**:真正的用户名里不会有空格。
  202. */
  203. function looksLikeUsername(raw) {
  204. if (raw === null || raw === undefined) {
  205. return false;
  206. }
  207. var name = String(raw)
  208. .replace(/[\u2066-\u2069\u202a-\u202e]/g, '')
  209. .replace(/^[\s@@]+|[\s]+$/g, '');
  210. if (name.length < 2 || name.length > 30) {
  211. return false;
  212. }
  213. if (/\s/.test(name)) {
  214. return false; /* 中间有空白 → 是文案,不是用户名 */
  215. }
  216. return /^[A-Za-z0-9_.]+$/.test(name);
  217. }
  218. /* ---------------------------------------------------------- 文件名 */
  219. /** 两位补零 */
  220. function pad2(value) {
  221. return value < 10 ? '0' + value : String(value);
  222. }
  223. /**
  224. * 结果文件名:`前缀_YYYYMMDD_HHmm.txt`
  225. *
  226. * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`),
  227. * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。
  228. */
  229. function timestampName(prefix, date) {
  230. var d = date || new Date();
  231. return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) +
  232. '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt';
  233. }
  234. return {
  235. count: count,
  236. language: language,
  237. languageMatches: languageMatches,
  238. inRange: inRange,
  239. isRangeOpen: isRangeOpen,
  240. username: username,
  241. looksLikeUsername: looksLikeUsername,
  242. timestampName: timestampName
  243. };
  244. }());