/** * parse.js — 文本 → 数值 / 语言 / 区间的**纯函数**解析 * * ## 为什么单独一个模块 * 这三件事都是「界面上的设置真的会拦人」这一主张的证据链: * · 数量门槛要先把 "1.6M" 变成 1600000 才谈得上比较; * · 语言区域要靠昵称/个签里的字符构成来判; * · 区间判定要能表达"不限"和"读不到"这两种不同的"没有值"。 * 它们全部与界面无关,所以能 100% 离线自检 —— 这是整个 ID 提取里 * **唯一一处可以用断言证明"逻辑对了"** 的部分。 * * ## 关于数字缩写(真机实测) * TikTok 的计数一律是缩写形态,实测到的写法: * "594"、"2,491"、"1.6M"、"253.9M"、"245K"、"371.7K" * 中文界面下会是 "1.2万" / "1.5亿"。所以后缀要同时认 K/M/B 与 万/亿。 * * 依赖:无(纯函数,不取任何模块引用) */ TikTokAuto.parse = (function () { 'use strict'; /* ---------------------------------------------------------- 数字 */ /** 后缀倍数。键统一小写;`二`的两种写法(万/萬)都认。 */ var SUFFIX = { '': 1, 'k': 1000, 'm': 1000000, 'b': 1000000000, 'w': 10000, /* 万:中文界面 "1.2万" */ '万': 10000, '萬': 10000, '亿': 100000000, '億': 100000000 }; /** * 把一个计数字符串解析成整数。 * * 返回 **null** 表示"读不到",与 **0** 严格区分: * "0 followers" 是真实的值(一个粉丝都没有),"读不到"是另一回事 —— * 数量门槛的判定要靠这个区别决定是"拦下"还是"放行"。 * * @param raw 原始文本,如 "1.6M followers" / "2,491" / "1.2万" * @returns {number|null} */ function count(raw) { if (raw === null || raw === undefined) { return null; } /* 统一全角数字与全角逗号:某些语言/输入法会给出全角 */ var text = String(raw) .replace(/[0-9]/g, function (ch) { return String.fromCharCode(ch.charCodeAt(0) - 0xFEE0); }) .replace(/[,]/g, ','); /* 抓「数字 + 可选小数 + 可选后缀」。前面的自由文本("1.6M followers")不影响。 * 后缀只认白名单里的字符,避免把 "594 followers" 的 f 当成后缀。 */ var matched = text.match(/(\d[\d,]*(?:\.\d+)?)\s*([kKmMbBwW万萬亿億]?)/); if (!matched) { return null; } var number = Number(String(matched[1]).replace(/,/g, '')); if (isNaN(number)) { return null; } var suffix = String(matched[2] || '').toLowerCase(); var factor = SUFFIX[suffix]; if (factor === undefined) { /* 后缀不在白名单(例如 "594 followers" 的 f 已不会被捕获)→ 按无后缀处理 */ factor = 1; } return Math.round(number * factor); } /* ---------------------------------------------------------- 语言 */ /** CJK 汉字(含扩展 A 与兼容区)与假名 —— 判定"中文内容"用 */ var CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff]/; var LATIN = /[A-Za-z\u00c0-\u024f]/; /* 含带音标的拉丁字母(西/法/德用户昵称很常见) */ /** * 估算一段文本的语种。只返回三类,够用且不猜。 * * 判定规则(不是拍脑袋,理由在下面): * · **只统计字母类字符**,忽略数字/标点/emoji —— 昵称里 emoji 占比极高, * 算进分母会把"中文昵称 + 三个 emoji"判成"非中文"。 * · 中文门槛 **30%**:中文用户常混英文单词("小美的Fashion"), * 门槛设成 50% 会把这类人漏掉。 * · 英文门槛 **70%**:反过来,英文用户极少混中文, * 所以"仅英文"要更严格 —— 否则一个 "Fashion 小美" 会同时进两个桶。 * * @returns {string} "zh" | "en" | "mixed" | "none" */ function language(raw) { var text = String(raw === null || raw === undefined ? '' : raw); if (text === '') { return 'none'; } var cjk = 0; var latin = 0; for (var i = 0; i < text.length; i += 1) { var ch = text.charAt(i); if (CJK.test(ch)) { cjk += 1; } else if (LATIN.test(ch)) { latin += 1; } } var total = cjk + latin; if (total === 0) { return 'none'; /* 纯 emoji / 纯数字 / 阿拉伯文等 */ } var zhRatio = cjk / total; var enRatio = latin / total; if (zhRatio >= 0.3) { return 'zh'; } if (enRatio >= 0.7) { return 'en'; } return 'mixed'; } /** * 语言是否匹配"仅中文 / 仅英文 / 通杀"。 * * `mixed` 在两边都不算匹配 —— 说不清是哪一边的,就不该被任何一边选中。 * 但 `none`(纯 emoji 昵称)在"通杀"下通过:通杀本来就不筛。 */ function languageMatches(want, actual) { if (!want || want === 'any') { return true; } if (want === 'zh') { return actual === 'zh'; } if (want === 'en') { return actual === 'en'; } return true; } /* ---------------------------------------------------------- 区间 */ /** * 区间判定。 * * 三种状态必须分开表达,这是这个函数存在的理由: * · **不限**(min/max 都是 0)→ 永远通过,连值都不用读; * · **有值** → 落在 [min, max] 内(0 表示该侧不设限); * · **读不到**(value === null)→ **不通过**。 * * 为什么读不到要拦:用户明确设了门槛,说明他在意这个条件; * 读不到就无法证明符合,宁可少收并在日志里说清楚 —— * 若这里放行,"过滤"就是形同虚设,而用户以为它生效了。 * * @returns {boolean} */ function inRange(value, min, max) { var low = Number(min) || 0; var high = Number(max) || 0; if (low <= 0 && high <= 0) { return true; } if (value === null || value === undefined) { return false; } var number = Number(value); if (isNaN(number)) { return false; } if (low > 0 && number < low) { return false; } if (high > 0 && number > high) { return false; } return true; } /** 该区间是否"什么都没设"(用来判断要不要为此打开用户页) */ function isRangeOpen(min, max) { return (Number(min) || 0) <= 0 && (Number(max) || 0) <= 0; } /* ---------------------------------------------------------- 用户名 */ /** * 把 "@username" 归一成 "username"。 * * 实测发现用户名文本被 **FID 隔离符** 包裹(U+2068 / U+2069): * 显示为 `⁨ajman_fashion_week⁩`,肉眼完全看不出,但直接当文件名/比对键就会出错。 * `layout.sanitize` 处理了 U+200b-200f 等,**没有**处理 2068/2069(它们不是零宽字符, * 是双向文本隔离符),所以这里单独剥一次。 * * 同时剥掉全角 @ 与首尾空白。 */ function username(raw) { if (raw === null || raw === undefined) { return ''; } return String(raw) .replace(/[\u2066-\u2069\u202a-\u202e]/g, '') .replace(/\s+/g, '') .replace(/^[@@]+/, ''); } /** * 一个字符串是否像 TikTok 用户名(字母数字下划线点,长度 2~30)。 * * 注意**不能**先走 username() —— 那个函数会去掉所有空白(清洗用途), * 于是 "This is a caption" 会被压成 "Thisisacaption" 而"看起来像用户名"。 * 判定必须**保留原始空白**:真正的用户名里不会有空格。 */ function looksLikeUsername(raw) { if (raw === null || raw === undefined) { return false; } var name = String(raw) .replace(/[\u2066-\u2069\u202a-\u202e]/g, '') .replace(/^[\s@@]+|[\s]+$/g, ''); if (name.length < 2 || name.length > 30) { return false; } if (/\s/.test(name)) { return false; /* 中间有空白 → 是文案,不是用户名 */ } return /^[A-Za-z0-9_.]+$/.test(name); } /* ---------------------------------------------------------- 文件名 */ /** 两位补零 */ function pad2(value) { return value < 10 ? '0' + value : String(value); } /** * 结果文件名:`前缀_YYYYMMDD_HHmm.txt` * * 方案 §6.1 是对规格书的有意识偏离:规格书只有日期(`uid_20261013.txt`), * 同一天跑两次会互相覆盖 —— 而"同一天调两次参数"恰恰是最常见的用法。 */ function timestampName(prefix, date) { var d = date || new Date(); return prefix + '_' + d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + '_' + pad2(d.getHours()) + pad2(d.getMinutes()) + '.txt'; } return { count: count, language: language, languageMatches: languageMatches, inRange: inRange, isRangeOpen: isRangeOpen, username: username, looksLikeUsername: looksLikeUsername, timestampName: timestampName }; }());