| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- /**
- * filters.js — 「只要这样的账号」的判定
- *
- * 这一层是**纯函数**:给它一份采集到的资料 + 一份过滤设置,它只回答"要 / 不要 / 为什么"。
- * 不点屏幕、不读写文件、不知道 TikTok 长什么样。所以它能被 100% 离线自检,
- * 而"界面上的 21 项设置真的会拦人"这件事也就有了证据。
- *
- * ## 一条重要的区分:**数据缺失**不等于**不符合**
- * 三种"没有值"必须分开处理,否则过滤要么形同虚设,要么乱杀:
- *
- * ① 用户没设这个门槛 → 放行(设置里是 0,语义就是"不限")
- * ② 设了,但平台根本不提供 → **放行 + 记一条 warn**(例如"视频量":真机实测用户页
- * 顶部只有 关注/粉丝/获赞 三组数字,没有视频数)
- * ③ 设了,本应能读到却读不到 → **拦下**(读不到就无法证明符合,宁可少收)
- *
- * ②和③看起来像,但责任不同:②是我们做不到,不该惩罚用户抓到的人;③是数据异常,
- * 放行会让过滤失去意义而用户以为它生效了。
- *
- * 依赖:TikTokAuto.parse、TikTokAuto.logger(调用时取)
- */
- TikTokAuto.filters = (function () {
- 'use strict';
- /**
- * 这份过滤设置是否需要打开用户页才能判。
- *
- * 用途是**快路径**:所有过滤都关着时(最常见的第一种用法),
- * 直接从列表页把 uid 抄下来就行 —— 不必为每个人开一次用户页。
- * 实测一次用户页打开约 3~5 秒,100 个人就是 5~8 分钟,差一个数量级。
- *
- * 注意 `videos` 不参与这个判断:它读不到(见文件头 ②),
- * 为此打开用户页也只是白花时间。
- */
- function needsProfile(filter) {
- var f = filter || {};
- if (f.private === true || f.noAvatar === true) {
- return true;
- }
- if (f.langRegion && f.langRegion !== 'any') {
- return true;
- }
- var groups = ['follow', 'fans', 'likes'];
- for (var i = 0; i < groups.length; i += 1) {
- var range = f[groups[i]] || {};
- if (!TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
- return true;
- }
- }
- return false;
- }
- /** 取该判定的取样文本:昵称、个签,或两者(昵称检测下拉的三个取值)。 */
- function sampleText(profile, mode) {
- var nick = profile.nickname || '';
- var sign = profile.signature || '';
- if (mode === 'nick') {
- return nick;
- }
- if (mode === 'sign') {
- return sign;
- }
- /* "通杀":两段合起来判 —— 分开判会让"昵称中文、个签全英文"的人两边都不算 */
- return nick + ' ' + sign;
- }
- /**
- * 单条判定的结果。
- * `soft` 表示"我们做不到,放行但要告知"(对应文件头的 ②)。
- */
- function pass() {
- return { ok: true, reason: '', soft: '' };
- }
- function reject(reason) {
- return { ok: false, reason: reason, soft: '' };
- }
- function softPass(reason) {
- return { ok: true, reason: '', soft: reason };
- }
- /**
- * 判定一个账号要不要。
- *
- * @param profile 采集到的资料;`null` 表示"这次没有打开用户页"(快路径)
- * —— 此时只有不看资料就能判的项目生效,其余全部放行
- * @param filter 过滤设置
- * @returns { ok, reason, soft }
- * reason 是"为什么不要"(人话,会进日志)
- * soft 是"放行了但你应该知道"(进 summary 的 warnings,只报一次)
- */
- function judge(profile, filter) {
- var f = filter || {};
- var parse = TikTokAuto.parse;
- if (!profile) {
- /* 没有资料,两种情况必须分开:
- * · 这份设置**本来就不需要**资料(快路径)→ 放行;
- * · 需要资料却拿不到(用户页打不开、读不出)→ **拦下**。
- * 第二种如果放行,等于"打不开的人全都通过" —— 过滤看着在跑,
- * 实际放进来一批根本没检查过的账号,比不过滤更糟(用户以为筛过了)。 */
- if (needsProfile(f)) {
- return reject('打不开用户页,读不到任何资料');
- }
- return pass();
- }
- /* ① 私密账户。列表页读不到,只能打开用户页看提示文案。 */
- if (f.private === true && profile.isPrivate === true) {
- return reject('私密账号');
- }
- /* ② 无头像。真机实测头像节点不可读(无文案、不可点),
- * 只能靠像素法 —— 见 profile.js 里的说明。读不到时放行并告知。 */
- if (f.noAvatar === true) {
- if (profile.hasAvatar === false) {
- return reject('无头像');
- }
- if (profile.hasAvatar === null) {
- return {
- ok: true,
- reason: '',
- soft: '无头像检测:头像读取不可用,本次未生效'
- };
- }
- }
- /* ③ 语言区域:按昵称/个签的字符构成判(见 parse.language 的规则说明) */
- if (f.langRegion && f.langRegion !== 'any') {
- var text = sampleText(profile, f.nicknameCheck || 'any');
- var lang = parse.language(text);
- if (lang === 'none') {
- /* 纯 emoji / 纯数字昵称:没有语言线索。用户明确要筛语言,就不该混进来。 */
- return reject('看不出语言(昵称与个签都没有文字)');
- }
- if (!parse.languageMatches(f.langRegion, lang)) {
- return reject('语言不符:' + lang + ' ≠ ' + f.langRegion);
- }
- }
- /* ④ 三组数量门槛。真机实测用户页顶部是 关注 / 粉丝 / 获赞,
- * 数字形态是缩写("1.6M"),parse.count 负责解析。 */
- var checks = [
- { key: 'follow', label: '关注量' },
- { key: 'fans', label: '粉丝量' },
- { key: 'likes', label: '获赞量' }
- ];
- for (var i = 0; i < checks.length; i += 1) {
- var one = checks[i];
- var range = f[one.key] || {};
- if (parse.isRangeOpen(range.min, range.max)) {
- continue;
- }
- if (!parse.inRange(profile[one.key], range.min, range.max)) {
- var actual = profile[one.key];
- return reject(one.label + '不在区间内(' +
- (actual === null || actual === undefined ? '读不到' : actual) + ')');
- }
- }
- /* ⑤ 视频量:平台不提供(实测),放行 + 告知。见文件头 ②。 */
- var videoRange = f.videos || {};
- if (!parse.isRangeOpen(videoRange.min, videoRange.max)) {
- return softPass('视频量:用户页不显示该数字,本次未参与过滤');
- }
- return pass();
- }
- /** 给人看的一句话:这次到底在筛什么(进日志与界面状态条) */
- function describe(filter) {
- var f = filter || {};
- var parts = [];
- if (f.private === true) { parts.push('排除私密'); }
- if (f.noAvatar === true) { parts.push('排除无头像'); }
- if (f.langRegion && f.langRegion !== 'any') {
- parts.push(f.langRegion === 'zh' ? '仅中文' : '仅英文');
- }
- var labels = { follow: '关注', fans: '粉丝', likes: '获赞' };
- var keys = ['follow', 'fans', 'likes'];
- for (var i = 0; i < keys.length; i += 1) {
- var range = f[keys[i]] || {};
- if (TikTokAuto.parse.isRangeOpen(range.min, range.max)) {
- continue;
- }
- parts.push(labels[keys[i]] + ' ' + (range.min || 0) + '~' + (range.max || '∞'));
- }
- return parts.length === 0 ? '不过滤(拿到就收)' : parts.join(',');
- }
- return {
- needsProfile: needsProfile,
- judge: judge,
- describe: describe
- };
- }());
|