| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- /**
- * 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
- };
- }());
|