specific-ai-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +177 -0
- package/README.ja.md +177 -0
- package/README.ko.md +177 -0
- package/README.md +174 -2
- package/README.zh-TW.md +177 -0
- package/assets/banner.txt +3 -0
- package/dist/cli/argv.js +98 -0
- package/dist/cli/commands/auth.js +11 -0
- package/dist/cli/commands/config.js +23 -0
- package/dist/cli/commands/doctor.js +134 -0
- package/dist/cli/commands/platforms.js +24 -0
- package/dist/cli/commands/version.js +4 -0
- package/dist/cli/confirm.js +22 -0
- package/dist/cli/dispatch.js +440 -0
- package/dist/cli/help.js +223 -0
- package/dist/cli/hints.js +60 -0
- package/dist/cli/main.js +18 -0
- package/dist/cli/output.js +66 -0
- package/dist/core/auth-handlers.js +41 -0
- package/dist/core/auth-store.js +119 -0
- package/dist/core/capabilities.js +163 -0
- package/dist/core/config.js +121 -0
- package/dist/core/cookies.js +164 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/files.js +89 -0
- package/dist/core/fsutil.js +52 -0
- package/dist/core/hash.js +4 -0
- package/dist/core/http.js +331 -0
- package/dist/core/image.js +280 -0
- package/dist/core/log.js +40 -0
- package/dist/core/login.js +191 -0
- package/dist/core/mp4.js +130 -0
- package/dist/core/normalize.js +207 -0
- package/dist/core/options.js +110 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/pb.js +59 -0
- package/dist/core/py.js +181 -0
- package/dist/core/rand.js +131 -0
- package/dist/core/registry.js +129 -0
- package/dist/core/schemas.js +41 -0
- package/dist/core/stream.js +104 -0
- package/dist/core/toolkit.js +35 -0
- package/dist/core/vm.js +73 -0
- package/dist/core/vocab.js +157 -0
- package/dist/platforms/_shared/impaas.js +410 -0
- package/dist/platforms/bilibili/index.js +216 -0
- package/dist/platforms/bilibili/web/api.js +806 -0
- package/dist/platforms/bilibili/web/client.js +311 -0
- package/dist/platforms/bilibili/web/commands.js +783 -0
- package/dist/platforms/bilibili/web/ddddocr.js +342 -0
- package/dist/platforms/bilibili/web/gaia.js +130 -0
- package/dist/platforms/bilibili/web/geetest-manual.js +154 -0
- package/dist/platforms/bilibili/web/geetest-vision.js +232 -0
- package/dist/platforms/bilibili/web/geetest.js +450 -0
- package/dist/platforms/bilibili/web/normalize.js +234 -0
- package/dist/platforms/bilibili/web/profile.js +135 -0
- package/dist/platforms/bilibili/web/resolve.js +112 -0
- package/dist/platforms/bilibili/web/sign.js +223 -0
- package/dist/platforms/bilibili/web/upos.js +102 -0
- package/dist/platforms/douyin/index.js +142 -0
- package/dist/platforms/douyin/web/api.js +863 -0
- package/dist/platforms/douyin/web/client.js +413 -0
- package/dist/platforms/douyin/web/commands.js +712 -0
- package/dist/platforms/douyin/web/creator.js +601 -0
- package/dist/platforms/douyin/web/crypto.js +247 -0
- package/dist/platforms/douyin/web/dtrait.js +190 -0
- package/dist/platforms/douyin/web/im.js +502 -0
- package/dist/platforms/douyin/web/image.js +5 -0
- package/dist/platforms/douyin/web/live.js +225 -0
- package/dist/platforms/douyin/web/mssdk.js +148 -0
- package/dist/platforms/douyin/web/normalize.js +326 -0
- package/dist/platforms/douyin/web/passport.js +1247 -0
- package/dist/platforms/douyin/web/profile.js +221 -0
- package/dist/platforms/douyin/web/proto.js +26 -0
- package/dist/platforms/douyin/web/resolve.js +88 -0
- package/dist/platforms/douyin/web/sign.js +436 -0
- package/dist/platforms/douyin/web/tos.js +78 -0
- package/dist/platforms/index.js +17 -0
- package/dist/platforms/jd/index.js +67 -0
- package/dist/platforms/jd/web/aks.js +115 -0
- package/dist/platforms/jd/web/api.js +549 -0
- package/dist/platforms/jd/web/chat.js +132 -0
- package/dist/platforms/jd/web/client.js +470 -0
- package/dist/platforms/jd/web/commands.js +557 -0
- package/dist/platforms/jd/web/jcap/helper.js +90 -0
- package/dist/platforms/jd/web/jcap/host.js +107 -0
- package/dist/platforms/jd/web/jcap/lsd.js +300 -0
- package/dist/platforms/jd/web/jcap/solver.js +995 -0
- package/dist/platforms/jd/web/jcap.js +150 -0
- package/dist/platforms/jd/web/js.js +420 -0
- package/dist/platforms/jd/web/login.js +775 -0
- package/dist/platforms/jd/web/normalize.js +297 -0
- package/dist/platforms/jd/web/profile.js +256 -0
- package/dist/platforms/jd/web/util.js +175 -0
- package/dist/platforms/jd/web/webm.js +133 -0
- package/dist/platforms/kuaishou/index.js +98 -0
- package/dist/platforms/kuaishou/web/api.js +694 -0
- package/dist/platforms/kuaishou/web/captcha-crypto.js +134 -0
- package/dist/platforms/kuaishou/web/captcha-fp.js +180 -0
- package/dist/platforms/kuaishou/web/captcha.js +273 -0
- package/dist/platforms/kuaishou/web/client.js +487 -0
- package/dist/platforms/kuaishou/web/commands.js +863 -0
- package/dist/platforms/kuaishou/web/gap.js +144 -0
- package/dist/platforms/kuaishou/web/gdfp.js +376 -0
- package/dist/platforms/kuaishou/web/gql.js +11 -0
- package/dist/platforms/kuaishou/web/live.js +125 -0
- package/dist/platforms/kuaishou/web/normalize.js +241 -0
- package/dist/platforms/kuaishou/web/oracle.js +329 -0
- package/dist/platforms/kuaishou/web/profile.js +259 -0
- package/dist/platforms/kuaishou/web/resolve.js +84 -0
- package/dist/platforms/kuaishou/web/session.js +592 -0
- package/dist/platforms/kuaishou/web/sign.js +568 -0
- package/dist/platforms/taobao/index.js +56 -0
- package/dist/platforms/taobao/web/api.js +96 -0
- package/dist/platforms/taobao/web/client.js +102 -0
- package/dist/platforms/taobao/web/commands.js +172 -0
- package/dist/platforms/taobao/web/im.js +68 -0
- package/dist/platforms/taobao/web/normalize.js +103 -0
- package/dist/platforms/taobao/web/profile.js +85 -0
- package/dist/platforms/taobao/web/resolve.js +51 -0
- package/dist/platforms/taobao/web/sign.js +35 -0
- package/dist/platforms/tiktok/index.js +120 -0
- package/dist/platforms/tiktok/web/api.js +1234 -0
- package/dist/platforms/tiktok/web/client.js +362 -0
- package/dist/platforms/tiktok/web/commands.js +821 -0
- package/dist/platforms/tiktok/web/im.js +133 -0
- package/dist/platforms/tiktok/web/jsrun.js +126 -0
- package/dist/platforms/tiktok/web/normalize.js +286 -0
- package/dist/platforms/tiktok/web/profile.js +169 -0
- package/dist/platforms/tiktok/web/resolve.js +156 -0
- package/dist/platforms/tiktok/web/sign.js +585 -0
- package/dist/platforms/tiktok/web/upload.js +805 -0
- package/dist/platforms/tiktok/web/wire.js +490 -0
- package/dist/platforms/weibo/index.js +91 -0
- package/dist/platforms/weibo/web/api.js +206 -0
- package/dist/platforms/weibo/web/client.js +155 -0
- package/dist/platforms/weibo/web/commands.js +202 -0
- package/dist/platforms/weibo/web/normalize.js +137 -0
- package/dist/platforms/weibo/web/profile.js +246 -0
- package/dist/platforms/weibo/web/resolve.js +60 -0
- package/dist/platforms/weibo/web/sign.js +85 -0
- package/dist/platforms/x/index.js +107 -0
- package/dist/platforms/x/web/api.js +443 -0
- package/dist/platforms/x/web/article.js +212 -0
- package/dist/platforms/x/web/client.js +202 -0
- package/dist/platforms/x/web/commands.js +363 -0
- package/dist/platforms/x/web/graphql.js +108 -0
- package/dist/platforms/x/web/normalize.js +235 -0
- package/dist/platforms/x/web/profile.js +99 -0
- package/dist/platforms/x/web/resolve.js +44 -0
- package/dist/platforms/x/web/transaction.js +171 -0
- package/dist/platforms/xhs/index.js +101 -0
- package/dist/platforms/xhs/web/api.js +278 -0
- package/dist/platforms/xhs/web/client.js +540 -0
- package/dist/platforms/xhs/web/commands.js +1046 -0
- package/dist/platforms/xhs/web/creator-api.js +609 -0
- package/dist/platforms/xhs/web/creator.js +203 -0
- package/dist/platforms/xhs/web/js.js +194 -0
- package/dist/platforms/xhs/web/login.js +230 -0
- package/dist/platforms/xhs/web/normalize.js +166 -0
- package/dist/platforms/xhs/web/profile.js +127 -0
- package/dist/platforms/xhs/web/push.js +361 -0
- package/dist/platforms/xhs/web/resolve.js +69 -0
- package/dist/platforms/xhs/web/state.js +368 -0
- package/dist/platforms/xianyu/index.js +59 -0
- package/dist/platforms/xianyu/web/api.js +312 -0
- package/dist/platforms/xianyu/web/client.js +134 -0
- package/dist/platforms/xianyu/web/commands.js +305 -0
- package/dist/platforms/xianyu/web/im.js +73 -0
- package/dist/platforms/xianyu/web/normalize.js +123 -0
- package/dist/platforms/xianyu/web/profile.js +146 -0
- package/dist/platforms/xianyu/web/resolve.js +48 -0
- package/dist/platforms/xianyu/web/sign.js +79 -0
- package/package.json +77 -4
- package/static/douyin/Live.proto +194 -0
- package/static/douyin/Request.proto +89 -0
- package/static/douyin/Response.proto +46 -0
- package/static/douyin/acrawler_runtime/ac_vm.js +2 -0
- package/static/douyin/acrawler_runtime/browser_window_shape.json +1 -0
- package/static/douyin/acrawler_runtime/canvas_actual_exact.json +1 -0
- package/static/douyin/acrawler_runtime/run_ac_node.js +1005 -0
- package/static/douyin/acrawler_runtime/runner_window_keys_baseline.txt +240 -0
- package/static/douyin/challenge_profile.json +801 -0
- package/static/douyin/challenge_template_runner.js +217 -0
- package/static/douyin/dtrait_profile.json +66 -0
- package/static/douyin/mstoken_common_profile.json +1 -0
- package/static/douyin/mstoken_profile.json +214 -0
- package/static/jd/h5st5_env.js +750 -0
- package/static/jd/h5st5_lib.js +5 -0
- package/static/jd/h5st5_server.js +49 -0
- package/static/jd/jcap/env/env_core.js +512 -0
- package/static/jd/jcap/env/run.js +1307 -0
- package/static/jd/jcap/run/jcap_ujb96b.js +39 -0
- package/static/jd/package.json +4 -0
- package/static/jd/pc_tk_lib.js +1 -0
- package/static/jd/pc_tk_server.js +63 -0
- package/static/jd/summer_cryptico_runner.js +50 -0
- package/static/jd/webm/env/run.js +453 -0
- package/static/jd/webm/run/jdwebm-riskhandle.js +49 -0
- package/static/kuaishou/bundles/weapon/kwf-0.0.2.2cee19b4b7dec496.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-0-0.0.1-obfuscated.4b7f3de1ab0ddcc2.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-1-0.0.1-obfuscated.346ebdb1c78df316.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-10-0.0.1-obfuscated.a33db41897b983af.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-12-0.0.1-obfuscated.20ea29345706ec8c.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-13-0.0.1-obfuscated.6b74e9640ff18648.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-14-0.0.1-obfuscated.551e4429d156bf66.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-15-0.0.1-obfuscated.8625b087aaba4a60.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-16-0.0.1-obfuscated.d5ffa4020f3be99a.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-17-0.0.1-obfuscated.6d13e19b22f50a80.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-18-0.0.1-obfuscated.6d31405044166289.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-19-0.0.1-obfuscated.a9a8d925942f19e4.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-2-0.0.1-obfuscated.b849a136ae4a6b3e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-3-0.0.1-obfuscated.f9e77ebf6186e2ac.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-4-0.0.1-obfuscated.2c3c155aa52ceb0e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-5-0.0.1-obfuscated.4e553d17691b3c5a.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-6-0.0.1-obfuscated.f73ddd3ff13e1217.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-7-0.0.1-obfuscated.1628b8729beaab7e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-8-0.0.1-obfuscated.6da6baef7aa81fff.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-9-0.0.1-obfuscated.8645d55e5323b743.js +1 -0
- package/static/kuaishou/fixtures/current_follow_script_813.network-response +2 -0
- package/static/kuaishou/fixtures/live_ws_maps.json +61 -0
- package/static/kuaishou/fixtures/live_ws_proto.json +1342 -0
- package/static/kuaishou/js/cp-kwf.js +1 -0
- package/static/kuaishou/tools/kwf_oracle.js +799 -0
- package/static/kuaishou/tools/kws_oracle.js +885 -0
- package/static/kuaishou/tools/like_token_oracle.js +70 -0
- package/static/taobao/taobao_js_20260407.js +570 -0
- package/static/tiktok/Tiktok_Request.json +597 -0
- package/static/tiktok/package.json +3 -0
- package/static/tiktok/shop_bsid/core.js +1 -0
- package/static/tiktok/shop_bsid/env_core.js +512 -0
- package/static/tiktok/shop_bsid/loader.js +1 -0
- package/static/tiktok/shop_bsid/run.js +753 -0
- package/static/tiktok/shop_bsid/sign.js +100 -0
- package/static/tiktok/shop_bsid/webmssdk.js +1 -0
- package/static/tiktok/shop_bsid/webmssdk_ex.js +1 -0
- package/static/tiktok/signing/env/env_core.js +512 -0
- package/static/tiktok/signing/env/sign.js +455 -0
- package/static/tiktok/signing/sdk_config.json +230 -0
- package/static/tiktok/signing/source/webmssdk.js +1 -0
- package/static/tiktok/signing/source/webmssdk_ex.js +1 -0
- package/static/tiktok/signing/source/webmssdk_legacy.js +1 -0
- package/static/x/graphql.json +7777 -0
- package/static/x/transaction_l1.json +12 -0
- package/static/xhs/package.json +3 -0
- package/static/xhs/xhs_core/js/b1.js +210 -0
- package/static/xhs/xhs_core/js/mns.js +340 -0
- package/static/xhs/xhs_core/js/mns_0101_keystream.json +5 -0
- package/static/xhs/xhs_core/js/mns_keystreams.json +1 -0
- package/static/xhs/xhs_core/js/sign.js +225 -0
- package/static/xhs/xhs_core/js/websectiga_cli.js +220 -0
- package/static/xhs/xhs_core/js/websectiga_env.js +512 -0
- package/static/xhs/xhs_core/js/xs_common.js +79 -0
- package/static/xhs/xhs_creator/js/profile.js +115 -0
- package/static/xhs/xhs_creator/js/rap_fingerprint_creator.json +6 -0
- package/static/xhs/xhs_creator/js/reference_profile.json +282 -0
- package/static/xhs/xhs_creator/js/xhs_creator_sign.js +29 -0
- package/static/xhs/xhs_creator/js/xhs_creator_signature.js +23 -0
- package/static/xhs/xhs_pc/js/aes_decrypt.js +22 -0
- package/static/xhs/xhs_pc/js/aes_encrypt.js +30 -0
- package/static/xhs/xhs_pc/js/b1.js +9 -0
- package/static/xhs/xhs_pc/js/deflate.js +320 -0
- package/static/xhs/xhs_pc/js/mns.js +4 -0
- package/static/xhs/xhs_pc/js/profile.js +141 -0
- package/static/xhs/xhs_pc/js/rap.js +173 -0
- package/static/xhs/xhs_pc/js/rap_cli.js +30 -0
- package/static/xhs/xhs_pc/js/rap_crypto.js +73 -0
- package/static/xhs/xhs_pc/js/rap_fingerprint_template.json +8 -0
- package/static/xhs/xhs_pc/js/reference_profile.json +262 -0
- package/static/xhs/xhs_pc/js/sign.js +6 -0
- package/static/xhs/xhs_pc/js/web_ssk.js +82 -0
- package/static/xhs/xhs_pc/js/xs_common.js +4 -0
- package/static/xianyu/et_f.js +2 -0
- package/static/xianyu/gen_tfstk.js +410 -0
- package/static/xianyu/goofish_js_version_2.js +568 -0
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createCipheriv, createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { SpecificAIError } from '../../../core/errors.js';
|
|
8
|
+
import { staticFile } from '../../../core/paths.js';
|
|
9
|
+
import { compactJson, jsonDumps, jsonLoads, parseQsl, quote, urlencode } from '../../../core/py.js';
|
|
10
|
+
import * as rand from '../../../core/rand.js';
|
|
11
|
+
import * as api from './api.js';
|
|
12
|
+
import { clientDataCookie, clientDataV2Cookie, generateEcKey } from './crypto.js';
|
|
13
|
+
import { DTRAIT_BROKEN } from './dtrait.js';
|
|
14
|
+
import { commonBehavior, commonReport } from './mssdk.js';
|
|
15
|
+
import { headers as baseHeaders, LOGIN, Params, PROFILE, WWW } from './profile.js';
|
|
16
|
+
import { aidSign, md5Hex, passportEncrypt, passportSign, sdkTs, spliceUrl, svWebId, uaAesUrlsafe } from './sign.js';
|
|
17
|
+
/**
|
|
18
|
+
* 登录(上游 dy_apis/login_api.py 的 DYLoginApi,默认的 chrome_current 画像):
|
|
19
|
+
* 页面 bootstrap → challenge → 扫码轮询 / 短信验证码 → 跟随重定向落 cookie。
|
|
20
|
+
* 登录响应的 bd-ticket-guard-server-data 下发 ticket / ts_sign / client_cert,签给登录前自己生成的 P-256 公钥。
|
|
21
|
+
*/
|
|
22
|
+
// ---------------------------------------------------------------- cookie 视图(上游 scoped_cookies)
|
|
23
|
+
const WWW_ONLY = ['s_v_web_id', '__ac_nonce', '__ac_signature', 'x-web-secsdk-uid', 'dy_swidth', 'dy_sheight', 'device_web_cpu_core', 'device_web_memory_size', 'architecture', 'fpk1', 'fpk2'];
|
|
24
|
+
const LOGIN_ORDER = [
|
|
25
|
+
'enter_pc_once', 'UIFID_TEMP', 'is_support_rtm_web_ts', 'hevc_supported', 'IsDouyinActive', 'home_can_add_dy_2_desktop',
|
|
26
|
+
'stream_recommend_feed_params', 'odin_tt', 'strategyABtestKey', 'passport_csrf_token', 'passport_csrf_token_default', 'ttwid',
|
|
27
|
+
'__security_mc_1_s_sdk_crypt_sdk', 'bd_ticket_guard_regenerate_keys_time', 'bd_ticket_guard_client_data', 'bd_ticket_guard_client_web_domain',
|
|
28
|
+
'bd_ticket_guard_client_data_v2', 'biz_trace_id', 'sdk_source_info', 'bit_env', 'gulu_source_res', 'passport_auth_mix_state',
|
|
29
|
+
];
|
|
30
|
+
const SMS_ORDER = [
|
|
31
|
+
'enter_pc_once', 'UIFID_TEMP', 'odin_tt', 'is_support_rtm_web_ts', 'hevc_supported', 'IsDouyinActive', 'home_can_add_dy_2_desktop',
|
|
32
|
+
'stream_recommend_feed_params', 'strategyABtestKey', 'is_dash_user', 'passport_csrf_token', 'passport_csrf_token_default', 'ttwid',
|
|
33
|
+
'biz_trace_id', '__security_mc_1_s_sdk_crypt_sdk', 'bd_ticket_guard_regenerate_keys_time', 'bd_ticket_guard_client_data',
|
|
34
|
+
'bd_ticket_guard_client_web_domain', 'bd_ticket_guard_client_data_v2', 'sdk_source_info', 'bit_env', 'gulu_source_res', 'passport_auth_mix_state',
|
|
35
|
+
];
|
|
36
|
+
const SMS_HISTORICAL_ONLY = ['MONITOR_WEB_ID', 'UIFID', 'download_guide'];
|
|
37
|
+
const CHALLENGE_ORDER = [
|
|
38
|
+
'enter_pc_once', 'UIFID_TEMP', 'odin_tt', 'is_support_rtm_web_ts', 'hevc_supported', 'IsDouyinActive', 'home_can_add_dy_2_desktop',
|
|
39
|
+
'stream_recommend_feed_params', 'strategyABtestKey', 'is_dash_user', 'passport_csrf_token', 'passport_csrf_token_default', 'ttwid', 'biz_trace_id',
|
|
40
|
+
'__security_mc_1_s_sdk_crypt_sdk', 'bd_ticket_guard_regenerate_keys_time', 'bd_ticket_guard_client_data', 'bd_ticket_guard_client_web_domain',
|
|
41
|
+
];
|
|
42
|
+
const QR_ORDER = [...CHALLENGE_ORDER, 'bd_ticket_guard_client_data_v2', 'sdk_source_info', 'bit_env', 'gulu_source_res', 'passport_auth_mix_state'];
|
|
43
|
+
const QR_REFRESH_ORDER = [
|
|
44
|
+
...CHALLENGE_ORDER, 'bd_ticket_guard_client_data_v2', 'gulu_source_res', 'download_guide', 'sdk_source_info', 'bit_env', 'passport_auth_mix_state',
|
|
45
|
+
];
|
|
46
|
+
/** login.douyin.com 手机号 SSO 页的 cookie(上游 PHONE_SSO_COOKIE_ORDER)。 */
|
|
47
|
+
const PHONE_SSO_ORDER = ['passport_csrf_token', 'passport_csrf_token_default', 'MONITOR_WEB_ID', 'biz_trace_id'];
|
|
48
|
+
const PHONE_SSO_SDK = [
|
|
49
|
+
['passport_jssdk_version', '3.0.29'],
|
|
50
|
+
['passport_jssdk_type', 'normal'],
|
|
51
|
+
['aid', '24'],
|
|
52
|
+
['language', 'zh'],
|
|
53
|
+
['account_sdk_source', 'sso'],
|
|
54
|
+
];
|
|
55
|
+
const TTWID_ORDER = [
|
|
56
|
+
'enter_pc_once', 'UIFID_TEMP', 'odin_tt', 'is_support_rtm_web_ts', 'hevc_supported', 'IsDouyinActive', 'home_can_add_dy_2_desktop',
|
|
57
|
+
'stream_recommend_feed_params', 'strategyABtestKey', 'ttwid', 'is_dash_user', 'biz_trace_id', 'passport_csrf_token', 'passport_csrf_token_default',
|
|
58
|
+
];
|
|
59
|
+
const WWW_PAGE = [
|
|
60
|
+
'__ac_nonce', '__ac_signature', 'enter_pc_once', 'UIFID_TEMP', 'x-web-secsdk-uid', 's_v_web_id', 'odin_tt', 'douyin.com', 'device_web_cpu_core',
|
|
61
|
+
'device_web_memory_size', 'architecture', 'is_support_rtm_web_ts', 'hevc_supported', 'IsDouyinActive', 'home_can_add_dy_2_desktop', 'dy_swidth',
|
|
62
|
+
'dy_sheight', 'stream_recommend_feed_params', 'strategyABtestKey',
|
|
63
|
+
];
|
|
64
|
+
const WWW_BOOTSTRAP_ORDER = [...WWW_PAGE, 'ttwid', 'is_dash_user', 'biz_trace_id', 'passport_csrf_token', 'passport_csrf_token_default'];
|
|
65
|
+
const WWW_TTWID_ORDER = ['__ac_nonce', '__ac_signature', 'ttwid', ...WWW_PAGE.slice(2)];
|
|
66
|
+
const WWW_GUIDING_ORDER = [...WWW_PAGE, 'ttwid', 'is_dash_user', 'biz_trace_id'];
|
|
67
|
+
const TICKET_GUARD_ORDER = [
|
|
68
|
+
...WWW_PAGE.slice(0, 19), 'is_dash_user', 'passport_csrf_token', 'passport_csrf_token_default', 'fpk1', 'fpk2', 'ttwid', 'biz_trace_id',
|
|
69
|
+
'__security_mc_1_s_sdk_crypt_sdk', 'bd_ticket_guard_regenerate_keys_time',
|
|
70
|
+
];
|
|
71
|
+
const HEADER_ORDER = [
|
|
72
|
+
'web-sdk-version', 'x-tt-session-dtrait', 'referer', 'x-tt-passport-aid-sign', 'x-tt-passport-csrf-token', 'x-tt-passport-trace-id', 'user-agent', 'accept',
|
|
73
|
+
'content-type', 'x-tt-passport-verify-portrait', 'accept-encoding', 'accept-language', 'content-length', 'origin', 'priority', 'sec-fetch-dest',
|
|
74
|
+
'sec-fetch-mode', 'sec-fetch-site',
|
|
75
|
+
];
|
|
76
|
+
const HEADER_ORDER_QR = [
|
|
77
|
+
'web-sdk-version', 'sec-ch-ua-platform', 'x-tt-session-dtrait', 'referer', 'sec-ch-ua', 'x-tt-passport-aid-sign', 'sec-ch-ua-mobile',
|
|
78
|
+
'x-tt-passport-csrf-token', 'x-tt-passport-trace-id', 'user-agent', 'accept', 'content-type', 'x-tt-passport-verify-portrait', 'accept-language',
|
|
79
|
+
'origin', 'priority', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site',
|
|
80
|
+
];
|
|
81
|
+
const PASSPORT_SDK = [
|
|
82
|
+
['passport_jssdk_version', '3.4.4'],
|
|
83
|
+
['passport_jssdk_type', 'normal'],
|
|
84
|
+
['is_from_ttaccountsdk', '1'],
|
|
85
|
+
];
|
|
86
|
+
const SDK_TAIL = { p_ui: '2.4.4', p_ca: '4.0.26', p_ca_real: '1.0.0.892', account_sdk_source: 'web', p_js_v: '3.4.4', p_js_t: 'pro', p_zt: '3.3.17', p_ver: '1.1.3', p_ver_real: '0', p_bd: '1.0.1.19-fix.01' };
|
|
87
|
+
const MIX_ALPHABET = '1234567890qwertyuiopasdfghjklzxcvbnm';
|
|
88
|
+
const POLL_INTERVAL = 5200;
|
|
89
|
+
const QR_TTL = 55_000;
|
|
90
|
+
const THROTTLE_GIVEUP = 120_000;
|
|
91
|
+
const MS_COMMON_INTERVAL = 300_000;
|
|
92
|
+
/** 登录过程中的会话状态(上游挂在 DouyinAuth 上的那些字段)。 */
|
|
93
|
+
export class Passport {
|
|
94
|
+
d;
|
|
95
|
+
strict = false;
|
|
96
|
+
verifyPortrait = '';
|
|
97
|
+
secTs = '';
|
|
98
|
+
tSuffix = '';
|
|
99
|
+
tCookie = '';
|
|
100
|
+
tQuery = '';
|
|
101
|
+
sourceInfo = '';
|
|
102
|
+
qrRefreshReady = false;
|
|
103
|
+
smsSentAt = null;
|
|
104
|
+
/** 短信走 login.douyin.com 的 SSO 链(上游 phone_login_profile = "sso")。 */
|
|
105
|
+
sso = false;
|
|
106
|
+
/** SSO 页打开的时刻,account_sdk_source_info 的 timeOrigin(上游 _phone_page_started_ms)。 */
|
|
107
|
+
ssoPageStartedMs = 0;
|
|
108
|
+
constructor(d) {
|
|
109
|
+
this.d = d;
|
|
110
|
+
}
|
|
111
|
+
/** 非 TTY 下短信分两步:发码之后把整个页面会话存下来,填验证码时原样恢复(上游要求同一个 auth)。 */
|
|
112
|
+
snapshot() {
|
|
113
|
+
const { strict, verifyPortrait, secTs, tSuffix, tCookie, tQuery, sourceInfo, qrRefreshReady, smsSentAt, sso, ssoPageStartedMs } = this;
|
|
114
|
+
return { strict, verifyPortrait, secTs, tSuffix, tCookie, tQuery, sourceInfo, qrRefreshReady, smsSentAt, sso, ssoPageStartedMs, credential: structuredClone(this.d.credential) };
|
|
115
|
+
}
|
|
116
|
+
restore(state) {
|
|
117
|
+
const { credential, ...fields } = state;
|
|
118
|
+
Object.assign(this.d.credential, structuredClone(credential));
|
|
119
|
+
Object.assign(this, fields);
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------- cookie
|
|
122
|
+
get(name) {
|
|
123
|
+
return this.d.cookie(name);
|
|
124
|
+
}
|
|
125
|
+
/** Python 的 dict.setdefault:默认值总会先求值(随机数照样消耗)。 */
|
|
126
|
+
setdefault(name, value) {
|
|
127
|
+
if (this.get(name) === undefined)
|
|
128
|
+
this.d.setCookie(name, value);
|
|
129
|
+
}
|
|
130
|
+
view(order) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const name of order) {
|
|
133
|
+
const v = this.get(name);
|
|
134
|
+
if (v != null && v !== '')
|
|
135
|
+
out.push([name, v]);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** 按域、按实录顺序挑 cookie(上游 scoped_cookies)。 */
|
|
140
|
+
scoped(host) {
|
|
141
|
+
if (host === 'challenge') {
|
|
142
|
+
const out = this.view(this.qrRefreshReady ? QR_REFRESH_ORDER : CHALLENGE_ORDER);
|
|
143
|
+
const dg = this.get('download_guide');
|
|
144
|
+
if (dg && !out.some(([k]) => k === 'download_guide'))
|
|
145
|
+
out.push(['download_guide', dg]);
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
if (host === 'qr')
|
|
149
|
+
return this.view(this.qrRefreshReady ? QR_REFRESH_ORDER : QR_ORDER);
|
|
150
|
+
if (host === 'ticket_guard')
|
|
151
|
+
return this.view(TICKET_GUARD_ORDER);
|
|
152
|
+
if (host === 'ttwid')
|
|
153
|
+
return this.view(TTWID_ORDER);
|
|
154
|
+
if (host === 'www_ttwid')
|
|
155
|
+
return this.view(WWW_TTWID_ORDER);
|
|
156
|
+
if (host === 'www_guiding')
|
|
157
|
+
return this.view(WWW_GUIDING_ORDER);
|
|
158
|
+
if (host === 'www_bootstrap')
|
|
159
|
+
return this.view(WWW_BOOTSTRAP_ORDER);
|
|
160
|
+
const order = host === 'sms' || host === 'sms_login' ? SMS_ORDER : host === 'phone_sso' ? PHONE_SSO_ORDER : LOGIN_ORDER;
|
|
161
|
+
const out = this.view(order);
|
|
162
|
+
for (const [k, v] of this.d.cookies()) {
|
|
163
|
+
if (out.some(([n]) => n === k) || v === '')
|
|
164
|
+
continue;
|
|
165
|
+
if ((host === 'sms' || host === 'sms_login') && SMS_HISTORICAL_ONLY.includes(k))
|
|
166
|
+
continue;
|
|
167
|
+
if (WWW_ONLY.includes(k) || k === 'my_rd')
|
|
168
|
+
continue;
|
|
169
|
+
out.push([k, v]);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
static cookieHeader(pairs) {
|
|
174
|
+
return pairs.map(([k, v]) => `${k}=${v}`).join('; ');
|
|
175
|
+
}
|
|
176
|
+
// ---------------------------------------------------------------- 页面 bootstrap
|
|
177
|
+
/** 取不透明的页面随机量:t 的 9 位会话后缀共用,4 位前缀按 cookie / query 分开(上游 _browser_t)。 */
|
|
178
|
+
browserT(role) {
|
|
179
|
+
if (!this.tSuffix)
|
|
180
|
+
this.tSuffix = String(Math.floor(rand.random() * 1e9)).padStart(9, '0');
|
|
181
|
+
const make = () => String(Math.floor(rand.random() * 1e4)).padStart(4, '0') + this.tSuffix;
|
|
182
|
+
if (role === 'cookie')
|
|
183
|
+
return (this.tCookie ||= make());
|
|
184
|
+
return (this.tQuery ||= make());
|
|
185
|
+
}
|
|
186
|
+
/** 页面 JS 自己写的那批 cookie(上游 _add_page_cookies,chrome_current 画像)。 */
|
|
187
|
+
addPageCookies() {
|
|
188
|
+
this.setdefault('enter_pc_once', '1');
|
|
189
|
+
this.setdefault('is_support_rtm_web_ts', '1');
|
|
190
|
+
this.setdefault('hevc_supported', 'true');
|
|
191
|
+
this.setdefault('IsDouyinActive', 'true');
|
|
192
|
+
this.setdefault('is_dash_user', '1');
|
|
193
|
+
this.setdefault('my_rd', '2');
|
|
194
|
+
this.setdefault('home_can_add_dy_2_desktop', '%220%22');
|
|
195
|
+
this.setdefault('strategyABtestKey', quote(jsonDumps((rand.now() / 1000).toFixed(3))));
|
|
196
|
+
this.setdefault('dy_swidth', PROFILE.screenWidth);
|
|
197
|
+
this.setdefault('dy_sheight', PROFILE.screenHeight);
|
|
198
|
+
this.setdefault('device_web_cpu_core', PROFILE.cpuCoreNum);
|
|
199
|
+
this.setdefault('device_web_memory_size', PROFILE.deviceMemory);
|
|
200
|
+
this.setdefault('architecture', 'amd64');
|
|
201
|
+
this.setdefault('x-web-secsdk-uid', rand.uuid4());
|
|
202
|
+
const feed = { cookie_enabled: true, screen_width: 2560, screen_height: 1440, browser_online: true, cpu_core_num: 20, device_memory: 32, downlink: 10, effective_type: '4g', round_trip_time: 0 };
|
|
203
|
+
this.setdefault('stream_recommend_feed_params', quote(jsonDumps(jsonDumps(feed, { separators: [',', ':'] }), { ensureAscii: false }), ''));
|
|
204
|
+
this.setdefault('__security_mc_1_s_sdk_crypt_sdk', `${rand.hex(4)}-${rand.hex(2)}-${rand.hex(2)}`);
|
|
205
|
+
this.setdefault('bd_ticket_guard_regenerate_keys_time', shanghai(rand.now(), 'keys'));
|
|
206
|
+
if (!this.get('fpk2'))
|
|
207
|
+
this.d.setCookie('fpk2', md5Hex(PROFILE.ua));
|
|
208
|
+
if (!this.get('fpk1'))
|
|
209
|
+
this.d.setCookie('fpk1', fpk1());
|
|
210
|
+
if (this.get('passport_auth_mix_state') === undefined)
|
|
211
|
+
this.d.setCookie('passport_auth_mix_state', mixState(48));
|
|
212
|
+
this.setdefault('gulu_source_res', Buffer.from(compactJson({ p_in: rand.hex(32) })).toString('base64'));
|
|
213
|
+
this.setdefault('sdk_source_info', passportEncrypt(compactJson(probe(this.browserT('cookie')))));
|
|
214
|
+
}
|
|
215
|
+
/** 页面 acrawler 生成 __ac_signature(上游 _apply_ac_signature,在子进程里跑上游的 run_ac_node.js)。 */
|
|
216
|
+
async applyAcSignature() {
|
|
217
|
+
const nonce = this.get('__ac_nonce');
|
|
218
|
+
if (!nonce) {
|
|
219
|
+
if (this.strict)
|
|
220
|
+
throw new SpecificAIError('RISK_CONTROL', '登录页没有下发 __ac_nonce', { detail: { kind: 'blocked' } });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (this.get('__ac_signature'))
|
|
224
|
+
return;
|
|
225
|
+
try {
|
|
226
|
+
const out = await runNode(staticFile('douyin', 'acrawler_runtime/run_ac_node.js'), [], {
|
|
227
|
+
AC_NONCE: nonce,
|
|
228
|
+
AC_SIGN_NONCE: nonce,
|
|
229
|
+
AC_COOKIE_ONLY: this.d.cookieStr,
|
|
230
|
+
AC_HREF: `${WWW}/jingxuan`,
|
|
231
|
+
AC_REFERRER: `${WWW}/jingxuan`,
|
|
232
|
+
AC_VARIANT: 'chrome-doc-native-proto',
|
|
233
|
+
AC_UA: PROFILE.ua,
|
|
234
|
+
AC_NOW: String(rand.now()),
|
|
235
|
+
AC_CANVAS_DATA_URL: canvasDataUrl(),
|
|
236
|
+
});
|
|
237
|
+
const payload = lastJson(out);
|
|
238
|
+
const sig = String(payload?.sig ?? '');
|
|
239
|
+
if (!sig.startsWith('_'))
|
|
240
|
+
throw new Error('acrawler 没有产出签名');
|
|
241
|
+
const before = new Map(this.d.cookies());
|
|
242
|
+
this.d.setCookie('__ac_signature', sig);
|
|
243
|
+
for (const [k, v] of parseCookieHeader(String(payload.cookie ?? '')))
|
|
244
|
+
if (k !== '__ac_signature' && before.get(k) !== v)
|
|
245
|
+
this.d.setCookie(k, v);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
if (this.strict)
|
|
249
|
+
throw new SpecificAIError('RISK_CONTROL', `无法执行页面 acrawler 生成 __ac_signature:${err.message}`, { detail: { kind: 'blocked' } });
|
|
250
|
+
this.d.ctx.log.debug(`执行 page acrawler 失败,暂不写入 __ac_signature:${err.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ---------------------------------------------------------------- 公共参数与请求头
|
|
254
|
+
/** account_sdk_source_info:页面会话内固定(上游 _sdk_source_info,chrome_current 画像)。 */
|
|
255
|
+
sdkSourceInfo() {
|
|
256
|
+
if (this.sourceInfo)
|
|
257
|
+
return this.sourceInfo;
|
|
258
|
+
const g = PROFILE.geo;
|
|
259
|
+
const timeOrigin = pyFloat(Number((rand.now() - rand.uniform(3000, 60000)).toFixed(1)));
|
|
260
|
+
const info = {
|
|
261
|
+
hardwareConcurrency: 20,
|
|
262
|
+
webdriver: false,
|
|
263
|
+
chromedriver: false,
|
|
264
|
+
shelldriver: false,
|
|
265
|
+
plugins: 5,
|
|
266
|
+
innerHeight: g[1],
|
|
267
|
+
innerWidth: g[0],
|
|
268
|
+
outerHeight: g[3],
|
|
269
|
+
outerWidth: g[2],
|
|
270
|
+
webgl: { vendor: PROFILE.webglVendor, renderer: PROFILE.webglRenderer },
|
|
271
|
+
automation: { s: '00000000', c: '0000', p: '0000000', s1: '00000000', c1: '0000', p1: '0' },
|
|
272
|
+
performance: {
|
|
273
|
+
timeOrigin: '__TIME_ORIGIN__',
|
|
274
|
+
usedJSHeapSize: 472537551,
|
|
275
|
+
navigationTiming: {
|
|
276
|
+
decodedBodySize: 968454,
|
|
277
|
+
entryType: 'navigation',
|
|
278
|
+
initiatorType: 'navigation',
|
|
279
|
+
name: `${WWW}/?recommend=1`,
|
|
280
|
+
renderBlockingStatus: 'non-blocking',
|
|
281
|
+
serverTiming: 'cdn-cache,edge,origin,inner,tt_agw',
|
|
282
|
+
guleStart: 610.8999999761581,
|
|
283
|
+
guleDuration: 'none',
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
browser: { t: this.browserT('query'), bit_protocol: 'false', bit_helper: false },
|
|
287
|
+
};
|
|
288
|
+
this.sourceInfo = passportEncrypt(compactJson(info).replace('"__TIME_ORIGIN__"', String(timeOrigin)));
|
|
289
|
+
return this.sourceInfo;
|
|
290
|
+
}
|
|
291
|
+
/** 登录接口的公共 query,最后是 sign / qs / msToken(上游 _sdk_params)。 */
|
|
292
|
+
async sdkParams(extra = [], options = {}) {
|
|
293
|
+
const { data, deviceFp = false, pUi = true, requestHost = true, msToken = true } = options;
|
|
294
|
+
const p = new Params().update(PASSPORT_SDK).add('aid', '6383').add('language', 'zh').add('account_app_language', 'zh-CN').add('ts', sdkTs());
|
|
295
|
+
p.update(extra);
|
|
296
|
+
if (pUi)
|
|
297
|
+
p.add('p_ui', SDK_TAIL.p_ui);
|
|
298
|
+
if (deviceFp) {
|
|
299
|
+
const fp = this.get('s_v_web_id') || svWebId();
|
|
300
|
+
this.setdefault('s_v_web_id', fp);
|
|
301
|
+
p.add('p_ca', SDK_TAIL.p_ca).add('p_ca_real', SDK_TAIL.p_ca_real).add('fp', fp).add('verifyFp', fp);
|
|
302
|
+
}
|
|
303
|
+
p.add('account_sdk_source', SDK_TAIL.account_sdk_source).add('account_sdk_source_info', this.sdkSourceInfo());
|
|
304
|
+
for (const k of ['p_js_v', 'p_js_t', 'p_zt', 'p_ver', 'p_ver_real'])
|
|
305
|
+
p.add(k, SDK_TAIL[k]);
|
|
306
|
+
if (requestHost)
|
|
307
|
+
p.add('request_host', quote(WWW, ''));
|
|
308
|
+
p.add('p_bd', SDK_TAIL.p_bd).add('p_ts', String(rand.now())).add('p_no', rand.hex(32));
|
|
309
|
+
p.add('biz_trace_id', this.get('biz_trace_id') || rand.hex(4)).add('device_platform', 'web_app');
|
|
310
|
+
const { sign, qs } = passportSign(p.pairs(), data);
|
|
311
|
+
p.add('sign', sign).add('qs', qs);
|
|
312
|
+
if (msToken)
|
|
313
|
+
p.add('msToken', await this.d.msToken());
|
|
314
|
+
return p;
|
|
315
|
+
}
|
|
316
|
+
/** passport 请求头(上游 _passport_headers,chrome_current 画像)。 */
|
|
317
|
+
headers(options = {}) {
|
|
318
|
+
const { form = false, api = '', strictDtrait = false, bodyLength, wireAcceptEncoding = false } = options;
|
|
319
|
+
const h = new Map();
|
|
320
|
+
h.set('web-sdk-version', '1');
|
|
321
|
+
const qr = ['/passport/web/challenge/', '/passport/web/get_qrcode/', '/passport/web/check_qrconnect/'].some((p) => api.endsWith(p));
|
|
322
|
+
if (qr)
|
|
323
|
+
h.set('sec-ch-ua-platform', PROFILE.secChUaPlatform);
|
|
324
|
+
const dtrait = api ? this.d.dtraitHeader(api, { aid: 6383, origin: WWW, strict: strictDtrait }) : null;
|
|
325
|
+
if (strictDtrait && (dtrait ?? '').length !== 820) {
|
|
326
|
+
throw new SpecificAIError('RISK_CONTROL', `x-tt-session-dtrait 未达到 Chrome 短信实录的 820 字节(当前 ${(dtrait ?? '').length}),已拒绝发送`, { detail: { kind: 'blocked' } });
|
|
327
|
+
}
|
|
328
|
+
if (dtrait)
|
|
329
|
+
h.set('x-tt-session-dtrait', dtrait);
|
|
330
|
+
h.set('referer', `${WWW}/`);
|
|
331
|
+
if (qr)
|
|
332
|
+
h.set('sec-ch-ua', PROFILE.secChUa);
|
|
333
|
+
if (api)
|
|
334
|
+
h.set('x-tt-passport-aid-sign', aidSign(api, sdkTs()));
|
|
335
|
+
if (qr)
|
|
336
|
+
h.set('sec-ch-ua-mobile', '?0');
|
|
337
|
+
h.set('x-tt-passport-csrf-token', this.get('passport_csrf_token') || this.get('passport_csrf_token_default') || '');
|
|
338
|
+
const trace = this.get('biz_trace_id');
|
|
339
|
+
if (trace)
|
|
340
|
+
h.set('x-tt-passport-trace-id', trace);
|
|
341
|
+
h.set('user-agent', PROFILE.ua);
|
|
342
|
+
h.set('accept', 'application/json, text/javascript');
|
|
343
|
+
if (form)
|
|
344
|
+
h.set('content-type', 'application/x-www-form-urlencoded');
|
|
345
|
+
if (this.verifyPortrait)
|
|
346
|
+
h.set('x-tt-passport-verify-portrait', this.verifyPortrait);
|
|
347
|
+
if (wireAcceptEncoding)
|
|
348
|
+
h.set('accept-encoding', 'gzip, deflate, br, zstd');
|
|
349
|
+
h.set('accept-language', 'zh-CN,zh;q=0.9');
|
|
350
|
+
if (bodyLength != null)
|
|
351
|
+
h.set('content-length', String(bodyLength));
|
|
352
|
+
h.set('origin', WWW).set('priority', 'u=1, i').set('sec-fetch-dest', 'empty').set('sec-fetch-mode', 'cors').set('sec-fetch-site', 'same-site');
|
|
353
|
+
return (qr ? HEADER_ORDER_QR : HEADER_ORDER).filter((k) => h.has(k)).map((k) => [k, h.get(k)]);
|
|
354
|
+
}
|
|
355
|
+
/** 响应的 Set-Cookie 合并回来,收 bd-ticket-guard-server-data 与 sec_ts。 */
|
|
356
|
+
async absorb(res, options = {}) {
|
|
357
|
+
this.d.mergeSetCookies(res.url || `${LOGIN}/`, res);
|
|
358
|
+
if (options.ticket)
|
|
359
|
+
applyTicketGuard(this.d, res);
|
|
360
|
+
if (options.secTs)
|
|
361
|
+
await this.harvestSecTs(res, options.refreshV2 ?? true);
|
|
362
|
+
}
|
|
363
|
+
async harvestSecTs(res, refreshV2) {
|
|
364
|
+
const v = res.headers.get('bd-ticket-guard-sec-ts');
|
|
365
|
+
if (!v || v === this.secTs)
|
|
366
|
+
return;
|
|
367
|
+
this.secTs = v;
|
|
368
|
+
if (refreshV2)
|
|
369
|
+
await this.addClientDataV2();
|
|
370
|
+
}
|
|
371
|
+
// ---------------------------------------------------------------- bootstrap 各步
|
|
372
|
+
/** 加载落地页,吸收 __ac_nonce / UIFID_TEMP 等 Set-Cookie(上游 fetch_www_bootstrap)。 */
|
|
373
|
+
async fetchWwwBootstrap() {
|
|
374
|
+
const res = await this.d.request({
|
|
375
|
+
url: `${WWW}/?recommend=1`,
|
|
376
|
+
headers: [
|
|
377
|
+
['upgrade-insecure-requests', '1'],
|
|
378
|
+
['user-agent', PROFILE.ua],
|
|
379
|
+
['sec-ch-ua', PROFILE.secChUa],
|
|
380
|
+
['sec-ch-ua-mobile', '?0'],
|
|
381
|
+
['sec-ch-ua-platform', PROFILE.secChUaPlatform],
|
|
382
|
+
['accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'],
|
|
383
|
+
['accept-language', 'zh-CN,zh;q=0.9'],
|
|
384
|
+
['priority', 'u=0, i'],
|
|
385
|
+
['sec-fetch-dest', 'document'],
|
|
386
|
+
['sec-fetch-mode', 'navigate'],
|
|
387
|
+
['sec-fetch-site', 'none'],
|
|
388
|
+
['sec-fetch-user', '?1'],
|
|
389
|
+
['referer', `${WWW}/?recommend=1`],
|
|
390
|
+
],
|
|
391
|
+
timeout: 20,
|
|
392
|
+
});
|
|
393
|
+
await res.arrayBuffer().catch(() => { });
|
|
394
|
+
}
|
|
395
|
+
async ttwidCheck(url, referer, site, scope, extra = []) {
|
|
396
|
+
const h = [
|
|
397
|
+
['referer', referer],
|
|
398
|
+
['user-agent', PROFILE.ua],
|
|
399
|
+
['accept', 'application/json, text/plain, */*'],
|
|
400
|
+
...extra,
|
|
401
|
+
['content-type', 'application/json'],
|
|
402
|
+
['cookie', Passport.cookieHeader(this.scoped(scope))],
|
|
403
|
+
['accept-language', 'zh-CN,zh;q=0.9'],
|
|
404
|
+
['origin', WWW],
|
|
405
|
+
['priority', 'u=1, i'],
|
|
406
|
+
['sec-fetch-dest', 'empty'],
|
|
407
|
+
['sec-fetch-mode', 'cors'],
|
|
408
|
+
['sec-fetch-site', site],
|
|
409
|
+
];
|
|
410
|
+
const res = await this.d.request({ method: 'POST', url, headers: h, body: '{"aid":6383,"service":"www.douyin.com"}', timeout: 20 }, { merge: false });
|
|
411
|
+
const got = res.headers.getSetCookie().some((c) => c.startsWith('ttwid='));
|
|
412
|
+
await this.absorb(res);
|
|
413
|
+
return got;
|
|
414
|
+
}
|
|
415
|
+
/** www.douyin.com/ttwid/check(上游 check_ttwid_www)。 */
|
|
416
|
+
checkTtwidWww() {
|
|
417
|
+
return this.ttwidCheck(`${WWW}/ttwid/check/`, `${WWW}/jingxuan`, 'same-origin', 'www_ttwid', [['x-secsdk-csrf-token', 'DOWNGRADE']]);
|
|
418
|
+
}
|
|
419
|
+
/** login.douyin.com/ttwid/check(上游 check_ttwid)。 */
|
|
420
|
+
checkTtwid() {
|
|
421
|
+
return this.ttwidCheck(`${LOGIN}/ttwid/check/`, `${WWW}/`, 'same-site', 'ttwid');
|
|
422
|
+
}
|
|
423
|
+
/** 登录引导策略,响应写 passport_csrf_token(上游 login_guiding_strategy)。 */
|
|
424
|
+
async loginGuidingStrategy() {
|
|
425
|
+
const path = '/passport/general/login_guiding_strategy/';
|
|
426
|
+
const p = await this.sdkParams([], { pUi: false, requestHost: true });
|
|
427
|
+
p.add('a_bogus', this.d.ab.sign(spliceUrl(p.pairs()), '', 'www.douyin.com'));
|
|
428
|
+
const h = [
|
|
429
|
+
['x-tt-passport-csrf-token', this.get('passport_csrf_token') || this.get('passport_csrf_token_default') || ''],
|
|
430
|
+
['referer', `${WWW}/jingxuan`],
|
|
431
|
+
['user-agent', PROFILE.ua],
|
|
432
|
+
['accept', 'application/json, text/javascript'],
|
|
433
|
+
['x-tt-passport-aid-sign', aidSign(path, sdkTs())],
|
|
434
|
+
['x-tt-passport-trace-id', this.get('biz_trace_id') ?? ''],
|
|
435
|
+
['accept-language', 'zh-CN,zh;q=0.9'],
|
|
436
|
+
['origin', WWW],
|
|
437
|
+
['priority', 'u=1, i'],
|
|
438
|
+
['sec-fetch-dest', 'empty'],
|
|
439
|
+
['sec-fetch-mode', 'cors'],
|
|
440
|
+
['sec-fetch-site', 'same-origin'],
|
|
441
|
+
['cookie', Passport.cookieHeader(this.scoped('www_guiding'))],
|
|
442
|
+
];
|
|
443
|
+
const res = await this.d.request({ url: WWW + path, headers: h, query: p.pairs(), timeout: 20 }, { merge: false });
|
|
444
|
+
await this.absorb(res);
|
|
445
|
+
const body = await jsonOrRaw(res);
|
|
446
|
+
raiseIfBlocked('login_guiding_strategy/', body);
|
|
447
|
+
return body;
|
|
448
|
+
}
|
|
449
|
+
/** get_sec_ts:sec_ts 在响应头里(上游 get_sec_ts)。 */
|
|
450
|
+
async getSecTs() {
|
|
451
|
+
const query = [
|
|
452
|
+
['aid', '6383'],
|
|
453
|
+
['is_from_ttaccountsdk', '1'],
|
|
454
|
+
['msToken', await this.d.msToken()],
|
|
455
|
+
];
|
|
456
|
+
query.push(['a_bogus', this.d.ab.sign(urlencode(query), '', 'www.douyin.com')]);
|
|
457
|
+
const h = [
|
|
458
|
+
['referer', `${WWW}/user/self`],
|
|
459
|
+
['user-agent', PROFILE.ua],
|
|
460
|
+
['accept', 'application/json'],
|
|
461
|
+
['x-secsdk-csrf-token', 'DOWNGRADE'],
|
|
462
|
+
['content-type', 'application/x-www-form-urlencoded'],
|
|
463
|
+
['cookie', Passport.cookieHeader(this.scoped('www_bootstrap'))],
|
|
464
|
+
['accept-language', 'zh-CN,zh;q=0.9'],
|
|
465
|
+
['origin', WWW],
|
|
466
|
+
['priority', 'u=1, i'],
|
|
467
|
+
['sec-fetch-dest', 'empty'],
|
|
468
|
+
['sec-fetch-mode', 'cors'],
|
|
469
|
+
['sec-fetch-site', 'same-origin'],
|
|
470
|
+
];
|
|
471
|
+
const res = await this.d.request({ method: 'POST', url: `${WWW}/passport/user_info/get_sec_ts/`, headers: h, query, body: 'aid=6383&is_from_ttaccountsdk=1', timeout: 20 }, { merge: false });
|
|
472
|
+
await this.absorb(res, { secTs: true, refreshV2: false });
|
|
473
|
+
await res.arrayBuffer().catch(() => { });
|
|
474
|
+
}
|
|
475
|
+
/** bd_ticket_guard_client_data_v2(上游 _add_client_data_v2)。 */
|
|
476
|
+
async addClientDataV2() {
|
|
477
|
+
if (!this.secTs)
|
|
478
|
+
return false;
|
|
479
|
+
try {
|
|
480
|
+
const path = '/passport/ticket_guard/get_client_cert/';
|
|
481
|
+
const dtrait = this.d.dtraitHeader(path, { aid: 6383, origin: WWW }) ?? undefined;
|
|
482
|
+
const cert = await api.serverCert(this.d, 6383, Passport.cookieHeader(this.scoped('ticket_guard')), WWW, {
|
|
483
|
+
dtrait,
|
|
484
|
+
csrf: 'DOWNGRADE',
|
|
485
|
+
msToken: (await this.d.msToken()) || undefined,
|
|
486
|
+
referer: `${WWW}/`,
|
|
487
|
+
});
|
|
488
|
+
this.d.setCookie('bd_ticket_guard_client_data_v2', clientDataV2Cookie(this.d.privateKey, this.secTs, cert, String(this.d.tokens.ts_sign ?? '')));
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
this.d.ctx.log.debug(`构造 bd_ticket_guard_client_data_v2 失败,跳过:${err.message}`);
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** 设备认证 challenge:响应带 passportiv 与需要执行的 template(上游 challenge)。 */
|
|
497
|
+
async challenge() {
|
|
498
|
+
const body = challengeBody();
|
|
499
|
+
const data = parseQsl(body);
|
|
500
|
+
const p = await this.sdkParams([
|
|
501
|
+
['request_host', quote(WWW, '')],
|
|
502
|
+
['skip_c', '1'],
|
|
503
|
+
], { data, deviceFp: true, pUi: false, requestHost: false });
|
|
504
|
+
p.add('a_bogus', this.d.ab.sign(spliceUrl(p.pairs()), spliceUrl(data), 'login.douyin.com'));
|
|
505
|
+
const h = this.headers({ form: true, api: '/passport/web/challenge/', strictDtrait: this.strict });
|
|
506
|
+
h.push(['cookie', Passport.cookieHeader(this.scoped('challenge'))]);
|
|
507
|
+
const res = await this.d.request({ method: 'POST', url: `${LOGIN}/passport/web/challenge/`, headers: h, query: p.pairs(), body, timeout: 25 }, { merge: false });
|
|
508
|
+
await this.absorb(res, { secTs: true });
|
|
509
|
+
const out = await jsonOrRaw(res);
|
|
510
|
+
await this.applyChallengeTemplate(out);
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
/** 执行 challenge 下发的 template,写回 gulu_source_res / sdk_source_info;passportiv 派生 bit_env(上游 _apply_challenge_template)。 */
|
|
514
|
+
async applyChallengeTemplate(res) {
|
|
515
|
+
const data = res?.data ?? {};
|
|
516
|
+
if (data.passportiv)
|
|
517
|
+
this.d.setCookie('bit_env', uaAesUrlsafe(Buffer.from(String(data.passportiv), 'utf8')));
|
|
518
|
+
const tpl = data.template;
|
|
519
|
+
if (!tpl) {
|
|
520
|
+
if (this.strict)
|
|
521
|
+
throw new SpecificAIError('RISK_CONTROL', 'challenge 响应缺少 data.template', { detail: { kind: 'blocked' } });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const out = await runTemplate(String(tpl)).catch(() => null);
|
|
525
|
+
if (!out?.p_in) {
|
|
526
|
+
if (this.strict)
|
|
527
|
+
throw new SpecificAIError('RISK_CONTROL', 'challenge template 执行失败或没有产出 p_in', { detail: { kind: 'blocked' } });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const eIn = out.e_in && typeof out.e_in === 'object' ? out.e_in : {};
|
|
531
|
+
if (this.strict) {
|
|
532
|
+
if (!/^[0-9a-fA-F]{64}$/.test(String(out.p_in)))
|
|
533
|
+
throw new SpecificAIError('RISK_CONTROL', 'challenge template 的 p_in 不是 64 位 sha256', { detail: { kind: 'blocked' } });
|
|
534
|
+
if (!Object.keys(eIn).length)
|
|
535
|
+
throw new SpecificAIError('RISK_CONTROL', 'challenge template 没有产出 e_in 探针', { detail: { kind: 'blocked' } });
|
|
536
|
+
}
|
|
537
|
+
this.d.setCookie('gulu_source_res', Buffer.from(compactJson({ p_in: out.p_in })).toString('base64'));
|
|
538
|
+
const merged = { ...probe(this.browserT('cookie')), ...eIn, global_variables: '[]' };
|
|
539
|
+
delete merged.console_liad;
|
|
540
|
+
merged.console_lied = 'false';
|
|
541
|
+
this.d.setCookie('sdk_source_info', passportEncrypt(compactJson(merged)));
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* 登录前的完整 bootstrap(上游 bootstrap_auth)。strict 只用于短信登录:任何一步缺失就停止,不带着伪造字段继续。
|
|
545
|
+
*/
|
|
546
|
+
async bootstrap(strict) {
|
|
547
|
+
this.strict = strict;
|
|
548
|
+
const d = this.d;
|
|
549
|
+
d.setDefault('s_v_web_id', svWebId);
|
|
550
|
+
const prv = generateEcKey();
|
|
551
|
+
d.device.private_key = prv;
|
|
552
|
+
d.setCookie('bd_ticket_guard_client_data', clientDataCookie(prv));
|
|
553
|
+
d.setCookie('bd_ticket_guard_client_web_domain', '2');
|
|
554
|
+
try {
|
|
555
|
+
await this.fetchWwwBootstrap();
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
if (strict && !this.get('__ac_nonce'))
|
|
559
|
+
throw err;
|
|
560
|
+
d.ctx.log.debug(`加载落地页失败:${err.message}`);
|
|
561
|
+
}
|
|
562
|
+
if (!this.get('ttwid')) {
|
|
563
|
+
const ttwid = await api.registerTtwid(d).catch(() => '');
|
|
564
|
+
if (ttwid)
|
|
565
|
+
d.setCookie('ttwid', ttwid);
|
|
566
|
+
else if (strict)
|
|
567
|
+
throw new SpecificAIError('RISK_CONTROL', '注册 ttwid 失败', { detail: { kind: 'blocked' } });
|
|
568
|
+
}
|
|
569
|
+
d.deleteCookie('msToken');
|
|
570
|
+
this.setdefault('s_v_web_id', svWebId());
|
|
571
|
+
this.setdefault('biz_trace_id', rand.hex(4));
|
|
572
|
+
this.addPageCookies();
|
|
573
|
+
await this.applyAcSignature();
|
|
574
|
+
if (strict) {
|
|
575
|
+
for (const name of ['UIFID_TEMP', 'odin_tt']) {
|
|
576
|
+
if (!this.get(name))
|
|
577
|
+
throw new SpecificAIError('RISK_CONTROL', `短信登录要求登录页下发 ${name}`, { detail: { kind: 'blocked' } });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
this.verifyPortrait = `${rand.uuid4()}.login`;
|
|
581
|
+
// dtrait blob 是设备绑定的:优先用导入的素材,没有时按设备档案现算(上游 bootstrap_auth)
|
|
582
|
+
if (!d.dtraitBlob() && !d.device.session_dtrait) {
|
|
583
|
+
if (strict)
|
|
584
|
+
throw new SpecificAIError('AUTH_REQUIRED', `短信登录需要 dtrait 设备素材(${DTRAIT_BROKEN})`, { hint: '重新导入有效的 dtrait_profile / dtrait_blob(specific-ai douyin auth login --method cookie),或改用扫码登录' });
|
|
585
|
+
d.ctx.log.debug('没有 dtrait 素材,passport 请求会缺 x-tt-session-dtrait');
|
|
586
|
+
}
|
|
587
|
+
if (strict && (!this.get('__ac_nonce') || !this.get('__ac_signature'))) {
|
|
588
|
+
throw new SpecificAIError('RISK_CONTROL', '短信登录缺少 www 页面的 __ac_nonce / __ac_signature', { detail: { kind: 'blocked' } });
|
|
589
|
+
}
|
|
590
|
+
await this.step(strict, 'www/ttwid/check', () => this.checkTtwidWww());
|
|
591
|
+
await this.step(strict, 'login_guiding_strategy', async () => (await this.loginGuidingStrategy())?.message === 'success');
|
|
592
|
+
await this.step(strict, 'get_sec_ts', async () => (await this.getSecTs(), true));
|
|
593
|
+
await this.step(strict, 'ttwid/check', () => this.checkTtwid());
|
|
594
|
+
d.deleteCookie('bd_ticket_guard_client_data_v2');
|
|
595
|
+
await this.step(strict, 'challenge', async () => (await this.challenge())?.message === 'success');
|
|
596
|
+
if (strict && !this.get('bit_env'))
|
|
597
|
+
throw new SpecificAIError('RISK_CONTROL', 'challenge 没有下发 passportiv,无法生成 bit_env', { detail: { kind: 'blocked' } });
|
|
598
|
+
await this.step(strict, 'client_data_v2', () => this.addClientDataV2());
|
|
599
|
+
}
|
|
600
|
+
async step(strict, name, fn) {
|
|
601
|
+
let ok = false;
|
|
602
|
+
try {
|
|
603
|
+
ok = await fn();
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
if (strict)
|
|
607
|
+
throw err instanceof SpecificAIError ? err : new SpecificAIError('RISK_CONTROL', `登录 bootstrap 的 ${name} 失败:${err.message}`, { detail: { kind: 'blocked' } });
|
|
608
|
+
this.d.ctx.log.debug(`${name} 失败:${err.message}`);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (!ok) {
|
|
612
|
+
if (strict)
|
|
613
|
+
throw new SpecificAIError('RISK_CONTROL', `登录 bootstrap 的 ${name} 没有成功`, { detail: { kind: 'blocked' } });
|
|
614
|
+
this.d.ctx.log.debug(`${name} 没有成功`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// ---------------------------------------------------------------- 扫码
|
|
618
|
+
async getQrcode() {
|
|
619
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
620
|
+
const p = await this.sdkParams([
|
|
621
|
+
['next', WWW],
|
|
622
|
+
['need_short_url', 'true'],
|
|
623
|
+
['need_logo', 'false'],
|
|
624
|
+
['is_new_login', '1'],
|
|
625
|
+
['is_from_iesaccountsaas', '1'],
|
|
626
|
+
], { deviceFp: this.qrRefreshReady });
|
|
627
|
+
p.add('a_bogus', this.d.ab.sign(spliceUrl(p.pairs()), '', 'login.douyin.com'));
|
|
628
|
+
const h = this.headers({ api: '/passport/web/get_qrcode/' });
|
|
629
|
+
h.push(['cookie', Passport.cookieHeader(this.scoped('qr'))]);
|
|
630
|
+
const res = await this.d.request({ url: `${LOGIN}/passport/web/get_qrcode/`, headers: h, query: p.pairs(), timeout: 20 }, { merge: false });
|
|
631
|
+
await this.absorb(res, { ticket: true, secTs: true });
|
|
632
|
+
const text = await res.text();
|
|
633
|
+
if (text.trimStart().startsWith('{')) {
|
|
634
|
+
const body = jsonLoads(text);
|
|
635
|
+
raiseIfBlocked('get_qrcode/', body);
|
|
636
|
+
return body;
|
|
637
|
+
}
|
|
638
|
+
if (attempt === 0 && this.solveGfkadpd(text))
|
|
639
|
+
continue;
|
|
640
|
+
throw new SpecificAIError('RISK_CONTROL', `get_qrcode 返回的不是 JSON(HTTP ${res.status})`, { detail: { kind: 'blocked', status: res.status } });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
solveGfkadpd(text) {
|
|
644
|
+
if (!text.includes('gfkadpd'))
|
|
645
|
+
return false;
|
|
646
|
+
const m = /var\s+e\s*=\s*"(\d+)"\s*,\s*t\s*=\s*"(\d+)"/.exec(text);
|
|
647
|
+
if (!m)
|
|
648
|
+
return false;
|
|
649
|
+
this.d.setCookie('gfkadpd', `${m[1]},${m[2]}`);
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
/** 轮询二维码状态:new / scanned / confirmed / expired(上游 check_qrcode)。 */
|
|
653
|
+
async checkQrcode(token) {
|
|
654
|
+
const data = [
|
|
655
|
+
['need_logo', 'false'],
|
|
656
|
+
['is_frontier', 'true'],
|
|
657
|
+
['token', token],
|
|
658
|
+
['is_new_login', '1'],
|
|
659
|
+
['next', WWW],
|
|
660
|
+
['need_short_url', 'true'],
|
|
661
|
+
];
|
|
662
|
+
const p = await this.sdkParams([['is_from_iesaccountsaas', '1']], { data, deviceFp: true });
|
|
663
|
+
p.add('a_bogus', this.d.ab.sign(spliceUrl(p.pairs()), spliceUrl(data), 'login.douyin.com'));
|
|
664
|
+
const h = this.headers({ form: true, api: '/passport/web/check_qrconnect/' });
|
|
665
|
+
h.push(['cookie', Passport.cookieHeader(this.scoped('qr'))]);
|
|
666
|
+
const res = await this.d.request({ method: 'POST', url: `${LOGIN}/passport/web/check_qrconnect/`, headers: h, query: p.pairs(), form: data, timeout: 20 }, { merge: false });
|
|
667
|
+
await this.absorb(res, { ticket: true, secTs: true });
|
|
668
|
+
const raw = (await res.text()).trim();
|
|
669
|
+
if (!raw)
|
|
670
|
+
return { data: { error_code: 7, description: 'empty response from QR poll edge' }, message: 'retry' };
|
|
671
|
+
if (!raw.startsWith('{'))
|
|
672
|
+
throw new SpecificAIError('RISK_CONTROL', `check_qrconnect 返回的不是 JSON(HTTP ${res.status}),可能命中登录风控页`, { detail: { kind: 'blocked' } });
|
|
673
|
+
const body = jsonLoads(raw);
|
|
674
|
+
if (body.data?.status === 'expired') {
|
|
675
|
+
this.qrRefreshReady = true;
|
|
676
|
+
this.setdefault('download_guide', downloadGuide('1'));
|
|
677
|
+
const mix = this.get('passport_auth_mix_state');
|
|
678
|
+
if (mix && mix.length !== 32)
|
|
679
|
+
this.d.setCookie('passport_auth_mix_state', mixState(32));
|
|
680
|
+
}
|
|
681
|
+
if (body.data?.error_code !== 7)
|
|
682
|
+
raiseIfBlocked('check_qrconnect/', body);
|
|
683
|
+
return body;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* 完整扫码流程(上游 qrcode_login):二维码 55 秒一换(只在读到 new 时换),error_code=7 指数退避,
|
|
687
|
+
* 首次读到状态后发一次 /web/common 完整上报,之后每 5 分钟一次行为心跳。
|
|
688
|
+
*/
|
|
689
|
+
async qrcodeLogin(show, options = {}) {
|
|
690
|
+
const d = this.d;
|
|
691
|
+
const started = rand.now();
|
|
692
|
+
await this.bootstrap(false);
|
|
693
|
+
d.msPinned = true;
|
|
694
|
+
const deadline = rand.now() + (options.timeout ?? 300_000);
|
|
695
|
+
let token = null;
|
|
696
|
+
let born = 0;
|
|
697
|
+
let wait = POLL_INTERVAL;
|
|
698
|
+
let throttledSince = null;
|
|
699
|
+
let scanned = false;
|
|
700
|
+
let firstStatusAt = null;
|
|
701
|
+
let commonDone = false;
|
|
702
|
+
let nextBehavior = started + MS_COMMON_INTERVAL;
|
|
703
|
+
let fresh = false;
|
|
704
|
+
try {
|
|
705
|
+
while (rand.now() < deadline && !d.ctx.signal.aborted) {
|
|
706
|
+
const now = rand.now();
|
|
707
|
+
if (token && firstStatusAt != null && !commonDone && now - firstStatusAt >= POLL_INTERVAL) {
|
|
708
|
+
commonDone = true;
|
|
709
|
+
await this.refreshCommon(false);
|
|
710
|
+
}
|
|
711
|
+
else if (token && commonDone && now >= nextBehavior) {
|
|
712
|
+
await this.refreshCommon(true);
|
|
713
|
+
while (nextBehavior <= now)
|
|
714
|
+
nextBehavior += MS_COMMON_INTERVAL;
|
|
715
|
+
}
|
|
716
|
+
let pending = null;
|
|
717
|
+
if (token && fresh && !scanned && rand.now() - born > QR_TTL) {
|
|
718
|
+
const last = (await this.checkQrcode(token)).data ?? {};
|
|
719
|
+
if (last.status === 'confirmed')
|
|
720
|
+
return this.finish(last.redirect_url);
|
|
721
|
+
if (last.status === 'scanned') {
|
|
722
|
+
scanned = true;
|
|
723
|
+
d.ctx.log.info('换码前发现已扫码,请在手机上确认');
|
|
724
|
+
pending = last;
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
d.ctx.log.info('二维码到期,换一张');
|
|
728
|
+
token = null;
|
|
729
|
+
fresh = false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (!token) {
|
|
733
|
+
const data = (await this.getQrcode()).data ?? {};
|
|
734
|
+
token = data.token;
|
|
735
|
+
if (!token)
|
|
736
|
+
throw new SpecificAIError('UPSTREAM', '获取二维码失败', { detail: { error_code: data.error_code ?? null } });
|
|
737
|
+
born = rand.now();
|
|
738
|
+
await show(data.qrcode_index_url);
|
|
739
|
+
}
|
|
740
|
+
const info = pending ?? (await this.checkQrcode(token)).data ?? {};
|
|
741
|
+
if (info.error_code === 7) {
|
|
742
|
+
fresh = false;
|
|
743
|
+
throttledSince ??= rand.now();
|
|
744
|
+
if (rand.now() - throttledSince > THROTTLE_GIVEUP) {
|
|
745
|
+
throw new SpecificAIError('RISK_CONTROL', 'check_qrconnect 持续返回 error_code=7(访问太频繁),请等几分钟再试', { detail: { kind: 'rate_limit' } });
|
|
746
|
+
}
|
|
747
|
+
wait = Math.min(wait * 2, 60_000);
|
|
748
|
+
await rand.sleep(wait, d.ctx.signal);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
throttledSince = null;
|
|
752
|
+
wait = POLL_INTERVAL;
|
|
753
|
+
const status = info.status;
|
|
754
|
+
fresh = Boolean(status);
|
|
755
|
+
if (fresh && firstStatusAt == null)
|
|
756
|
+
firstStatusAt = rand.now();
|
|
757
|
+
if (status === 'confirmed')
|
|
758
|
+
return this.finish(info.redirect_url);
|
|
759
|
+
if (status === 'expired') {
|
|
760
|
+
d.ctx.log.info('二维码已过期,换一张继续等');
|
|
761
|
+
token = null;
|
|
762
|
+
scanned = false;
|
|
763
|
+
fresh = false;
|
|
764
|
+
if (!commonDone)
|
|
765
|
+
firstStatusAt = null;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (status === 'scanned' && !scanned) {
|
|
769
|
+
scanned = true;
|
|
770
|
+
d.ctx.log.info('已扫码,请在手机上确认');
|
|
771
|
+
}
|
|
772
|
+
await rand.sleep(wait, d.ctx.signal);
|
|
773
|
+
}
|
|
774
|
+
throw new SpecificAIError('AUTH_REQUIRED', '扫码登录超时', { hint: '重新执行 specific-ai douyin auth login' });
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
d.msPinned = false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* mssdk /web/common 轮换 msToken(上游 refresh_mstoken(common=True)):失败时保留上一个;
|
|
782
|
+
* 既没有上一个、也不在扫码轮询期间,才退回 /web/r/token。
|
|
783
|
+
*/
|
|
784
|
+
async refreshCommon(behavior, sms = false) {
|
|
785
|
+
const cached = this.d.tokens.msToken;
|
|
786
|
+
const previous = cached?.value ?? '';
|
|
787
|
+
try {
|
|
788
|
+
const token = await api.commonMstoken(this.d, previous, behavior ? commonBehavior() : commonReport(6383, 6241, sms));
|
|
789
|
+
if (token) {
|
|
790
|
+
this.d.setMsToken(token);
|
|
791
|
+
return token;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
catch { }
|
|
795
|
+
if (previous)
|
|
796
|
+
return previous;
|
|
797
|
+
if (this.d.msPinned)
|
|
798
|
+
return '';
|
|
799
|
+
delete this.d.tokens.msToken;
|
|
800
|
+
return this.d.msToken();
|
|
801
|
+
}
|
|
802
|
+
// ---------------------------------------------------------------- 短信
|
|
803
|
+
/**
|
|
804
|
+
* 严格短信登录的 cookie 形状检查:名字、顺序、每个值的长度都要与 Chrome 实录(current_23)一致,
|
|
805
|
+
* 否则不发送(上游 _assert_sms_cookie_shape)。
|
|
806
|
+
*/
|
|
807
|
+
assertSmsCookieShape() {
|
|
808
|
+
if (!this.strict)
|
|
809
|
+
return;
|
|
810
|
+
const expected = {
|
|
811
|
+
enter_pc_once: [1], UIFID_TEMP: [160, 224], odin_tt: [96, 128, 160], is_support_rtm_web_ts: [1], hevc_supported: [4],
|
|
812
|
+
IsDouyinActive: [4], home_can_add_dy_2_desktop: [7], stream_recommend_feed_params: [323, 324], strategyABtestKey: [20],
|
|
813
|
+
is_dash_user: [1], passport_csrf_token: [32], passport_csrf_token_default: [32], ttwid: [127], biz_trace_id: [8],
|
|
814
|
+
__security_mc_1_s_sdk_crypt_sdk: [18], bd_ticket_guard_regenerate_keys_time: [19], bd_ticket_guard_client_data: [304],
|
|
815
|
+
bd_ticket_guard_client_web_domain: [1], bd_ticket_guard_client_data_v2: [354, 552], sdk_source_info: [472], bit_env: [512],
|
|
816
|
+
gulu_source_res: [100], passport_auth_mix_state: [32, 48],
|
|
817
|
+
};
|
|
818
|
+
const missing = SMS_ORDER.filter((n) => !this.get(n));
|
|
819
|
+
if (missing.length)
|
|
820
|
+
throw new SpecificAIError('RISK_CONTROL', `短信登录缺少 Chrome cookie:${missing.join(', ')}`, { detail: { kind: 'blocked' } });
|
|
821
|
+
const wrong = Object.entries(expected)
|
|
822
|
+
.filter(([n, sizes]) => !sizes.includes((this.get(n) ?? '').length))
|
|
823
|
+
.map(([n, sizes]) => `${n}=${(this.get(n) ?? '').length}(应为 ${sizes.join('/')})`);
|
|
824
|
+
if (wrong.length)
|
|
825
|
+
throw new SpecificAIError('RISK_CONTROL', `短信登录 cookie 长度与 Chrome 实录不一致:${wrong.join(', ')}`, { detail: { kind: 'blocked' } });
|
|
826
|
+
}
|
|
827
|
+
/** 短信页要求 /web/common 轮换后的 msToken(解码后至少 170 字节,上游 _ensure_sms_ms_token)。 */
|
|
828
|
+
async ensureSmsMsToken() {
|
|
829
|
+
const len = (v) => decodeURIComponent(v).length;
|
|
830
|
+
const cached = this.d.tokens.msToken?.value ?? '';
|
|
831
|
+
if (len(cached) >= 170)
|
|
832
|
+
return;
|
|
833
|
+
if (len(await this.d.msToken()) >= 170)
|
|
834
|
+
return;
|
|
835
|
+
const rotated = await this.refreshCommon(false, true);
|
|
836
|
+
if (len(rotated) < 170)
|
|
837
|
+
throw new SpecificAIError('RISK_CONTROL', `短信请求要求 /web/common 轮换后的 msToken(至少 170 字节,当前 ${len(rotated)})`, { detail: { kind: 'blocked' } });
|
|
838
|
+
}
|
|
839
|
+
async smsPost(path, data, scope) {
|
|
840
|
+
this.assertSmsCookieShape();
|
|
841
|
+
await this.ensureSmsMsToken();
|
|
842
|
+
const p = await this.sdkParams([['is_from_iesaccountsaas', '1']], { data, deviceFp: true });
|
|
843
|
+
p.add('a_bogus', this.d.ab.sign(spliceUrl(p.pairs()), spliceUrl(data), 'login.douyin.com'));
|
|
844
|
+
const body = urlencode(data);
|
|
845
|
+
const h = this.headers({ form: true, api: path, strictDtrait: true, bodyLength: Buffer.byteLength(body), wireAcceptEncoding: true });
|
|
846
|
+
h.push(['cookie', Passport.cookieHeader(this.scoped(scope))]);
|
|
847
|
+
return this.d.request({ method: 'POST', url: LOGIN + path, headers: h, query: p.pairs(), body, timeout: 20 }, { merge: false });
|
|
848
|
+
}
|
|
849
|
+
/** 发短信验证码(上游 send_sms_code,passport_web 画像)。 */
|
|
850
|
+
async sendSmsCode(phone) {
|
|
851
|
+
const res = await this.smsPost('/passport/web/send_code/', [
|
|
852
|
+
['is6Digits', '1'],
|
|
853
|
+
['mix_mode', '1'],
|
|
854
|
+
['mobile', passportEncrypt(formatPhone(phone))],
|
|
855
|
+
['type', '3731'],
|
|
856
|
+
['fixed_mix_mode', '1'],
|
|
857
|
+
], 'sms');
|
|
858
|
+
await this.absorb(res);
|
|
859
|
+
const body = jsonLoads(await res.text());
|
|
860
|
+
this.smsSentAt = rand.now();
|
|
861
|
+
if (body.error_code && body.error_code !== 0)
|
|
862
|
+
throw smsError(body);
|
|
863
|
+
if (body.data?.error_code)
|
|
864
|
+
throw smsError(body.data);
|
|
865
|
+
return body;
|
|
866
|
+
}
|
|
867
|
+
/** 用验证码登录(上游 phone_login)。 */
|
|
868
|
+
async phoneLogin(phone, code) {
|
|
869
|
+
if (!/^\d{6}$/.test(code.trim()))
|
|
870
|
+
throw new SpecificAIError('USAGE', '验证码必须是 6 位数字');
|
|
871
|
+
const res = await this.smsPost('/passport/web/sms_login/', [
|
|
872
|
+
['service', WWW],
|
|
873
|
+
['mix_mode', '1'],
|
|
874
|
+
['mobile', passportEncrypt(formatPhone(phone))],
|
|
875
|
+
['code', passportEncrypt(code.trim())],
|
|
876
|
+
['fixed_mix_mode', '1'],
|
|
877
|
+
], 'sms_login');
|
|
878
|
+
await this.absorb(res, { ticket: true, secTs: true });
|
|
879
|
+
const body = jsonLoads(await res.text());
|
|
880
|
+
const err = body.data?.error_code ?? body.error_code;
|
|
881
|
+
if (err)
|
|
882
|
+
throw smsError(body.data ?? body);
|
|
883
|
+
await this.finish(body.data?.redirect_url ?? body.redirect_url);
|
|
884
|
+
}
|
|
885
|
+
// ---------------------------------------------------------------- 短信:login.douyin.com 的 SSO 链
|
|
886
|
+
/**
|
|
887
|
+
* 打开 login.douyin.com 手机号页(上游 bootstrap_phone_auth):文档响应下发两个 passport_csrf_token,
|
|
888
|
+
* 页面 SDK 再写 MONITOR_WEB_ID;不走扫码 / passport-web 的 bootstrap。上游只在 DY_PHONE_LOGIN_PROFILE=sso 时用它。
|
|
889
|
+
*/
|
|
890
|
+
async bootstrapSso() {
|
|
891
|
+
this.sso = true;
|
|
892
|
+
this.ssoPageStartedMs = rand.now();
|
|
893
|
+
const res = await this.d.request({
|
|
894
|
+
url: `${LOGIN}/`,
|
|
895
|
+
headers: [
|
|
896
|
+
['upgrade-insecure-requests', '1'],
|
|
897
|
+
['user-agent', PROFILE.ua],
|
|
898
|
+
['accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'],
|
|
899
|
+
['accept-language', 'zh-CN,zh;q=0.9'],
|
|
900
|
+
['priority', 'u=0, i'],
|
|
901
|
+
['sec-fetch-dest', 'document'],
|
|
902
|
+
['sec-fetch-mode', 'navigate'],
|
|
903
|
+
['sec-fetch-site', 'none'],
|
|
904
|
+
['sec-fetch-user', '?1'],
|
|
905
|
+
],
|
|
906
|
+
timeout: 20,
|
|
907
|
+
});
|
|
908
|
+
await res.text();
|
|
909
|
+
if (!this.get('passport_csrf_token'))
|
|
910
|
+
throw new SpecificAIError('UPSTREAM', '手机号登录页没有下发 passport_csrf_token');
|
|
911
|
+
this.setdefault('MONITOR_WEB_ID', rand.uuid4());
|
|
912
|
+
this.d.device.private_key = generateEcKey();
|
|
913
|
+
}
|
|
914
|
+
/** SSO 页的 account_sdk_source_info(上游 _phone_sso_source_info:3.0.29 的探针,键名拼写照实录,含 stoargeStatus)。 */
|
|
915
|
+
ssoSourceInfo() {
|
|
916
|
+
const g = PROFILE.geo;
|
|
917
|
+
const info = {
|
|
918
|
+
hardwareConcurrency: Number(PROFILE.cpuCoreNum),
|
|
919
|
+
webdriver: false,
|
|
920
|
+
chromedriver: false,
|
|
921
|
+
shelldriver: false,
|
|
922
|
+
plugins: 5,
|
|
923
|
+
permissions: [{ name: 'notifications', state: 'prompt' }],
|
|
924
|
+
innerHeight: g[1],
|
|
925
|
+
innerWidth: g[0],
|
|
926
|
+
outerHeight: g[3],
|
|
927
|
+
outerWidth: g[2],
|
|
928
|
+
stoargeStatus: {
|
|
929
|
+
indexedDB: { idb: 'object', open: 'function', indexedDB: 'object', IDBKeyRange: 'function', openDatabase: 'undefined', isSafari: false, hasFetch: false },
|
|
930
|
+
localStorage: { isSupportLStorage: true, size: 590, write: true },
|
|
931
|
+
storageQuotaStatus: { usage: 0, quota: 6442450944, isPrivate: false },
|
|
932
|
+
},
|
|
933
|
+
webgl: { vendor: PROFILE.webglVendor, renderer: PROFILE.webglRenderer },
|
|
934
|
+
notificationPermission: 'default',
|
|
935
|
+
performance: {
|
|
936
|
+
timeOrigin: '__TIME_ORIGIN__',
|
|
937
|
+
usedJSHeapSize: 15073598,
|
|
938
|
+
navigationTiming: {
|
|
939
|
+
decodedBodySize: 3745,
|
|
940
|
+
entryType: 'navigation',
|
|
941
|
+
initiatorType: 'navigation',
|
|
942
|
+
name: `${LOGIN}/`,
|
|
943
|
+
renderBlockingStatus: 'non-blocking',
|
|
944
|
+
serverTiming: 'inner,cdn-cache,edge,origin',
|
|
945
|
+
guleStart: 'none',
|
|
946
|
+
guleDuration: 'none',
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
request_host: 'login.douyin.com',
|
|
950
|
+
request_pathname: '/',
|
|
951
|
+
browser: {},
|
|
952
|
+
};
|
|
953
|
+
const origin = pyFloat(Number((this.ssoPageStartedMs || rand.now()).toFixed(1)));
|
|
954
|
+
return passportEncrypt(compactJson(info).replace('"__TIME_ORIGIN__"', origin));
|
|
955
|
+
}
|
|
956
|
+
/** SSO 请求:7 个 query、12 个头,cookie 紧跟 content-type;遇到 gfkadpd 拦截页补 cookie 重试一次(上游 _phone_sso_post)。 */
|
|
957
|
+
async ssoPost(url, data) {
|
|
958
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
959
|
+
if (!this.get('biz_trace_id'))
|
|
960
|
+
this.d.setCookie('biz_trace_id', rand.hex(4));
|
|
961
|
+
const p = new Params().update(PHONE_SSO_SDK).add('account_sdk_source_info', this.ssoSourceInfo()).add('biz_trace_id', this.get('biz_trace_id'));
|
|
962
|
+
const h = [
|
|
963
|
+
['accept', 'application/json, text/javascript'],
|
|
964
|
+
['content-type', 'application/x-www-form-urlencoded'],
|
|
965
|
+
['cookie', Passport.cookieHeader(this.scoped('phone_sso'))],
|
|
966
|
+
['x-tt-passport-csrf-token', this.get('passport_csrf_token') || this.get('passport_csrf_token_default') || ''],
|
|
967
|
+
['x-tt-passport-trace-id', this.get('biz_trace_id') || ''],
|
|
968
|
+
['referer', `${LOGIN}/`],
|
|
969
|
+
['user-agent', PROFILE.ua],
|
|
970
|
+
['accept-language', 'zh-CN,zh;q=0.9'],
|
|
971
|
+
['origin', LOGIN],
|
|
972
|
+
['priority', 'u=1, i'],
|
|
973
|
+
['sec-fetch-dest', 'empty'],
|
|
974
|
+
['sec-fetch-mode', 'cors'],
|
|
975
|
+
['sec-fetch-site', 'same-origin'],
|
|
976
|
+
];
|
|
977
|
+
const res = await this.d.request({ method: 'POST', url, headers: h, query: p.pairs(), body: urlencode(data), timeout: 20 }, { merge: false });
|
|
978
|
+
await this.absorb(res, { ticket: true });
|
|
979
|
+
const text = await res.text();
|
|
980
|
+
if (text.trimStart().startsWith('{')) {
|
|
981
|
+
try {
|
|
982
|
+
return jsonLoads(text);
|
|
983
|
+
}
|
|
984
|
+
catch { }
|
|
985
|
+
}
|
|
986
|
+
if (attempt === 0 && this.solveGfkadpd(text))
|
|
987
|
+
continue;
|
|
988
|
+
throw new SpecificAIError('RISK_CONTROL', `手机号 SSO 接口返回的不是 JSON(多为 gfkadpd 拦截页或页面版本切换,HTTP ${res.status})`, { detail: { kind: 'blocked', status: res.status } });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/** SSO 发验证码:/send_activation_code/v2/(上游 _send_sms_code_sso)。 */
|
|
992
|
+
async sendSmsCodeSso(phone) {
|
|
993
|
+
this.sso = true;
|
|
994
|
+
const body = await this.ssoPost(`${LOGIN}/send_activation_code/v2/`, [
|
|
995
|
+
['mix_mode', '1'],
|
|
996
|
+
['mobile', passportEncrypt(formatPhone(phone))],
|
|
997
|
+
['type', '3731'],
|
|
998
|
+
['is6Digits', '1'],
|
|
999
|
+
['fixed_mix_mode', '1'],
|
|
1000
|
+
]);
|
|
1001
|
+
this.smsSentAt = rand.now();
|
|
1002
|
+
if (body?.error_code)
|
|
1003
|
+
throw smsError(body);
|
|
1004
|
+
if (body?.data?.error_code)
|
|
1005
|
+
throw smsError(body.data);
|
|
1006
|
+
return body;
|
|
1007
|
+
}
|
|
1008
|
+
/** SSO 验证码登录:/quick_login/v2/,再跟随重定向(上游 _phone_login_sso)。 */
|
|
1009
|
+
async phoneLoginSso(phone, code) {
|
|
1010
|
+
if (!/^\d{6}$/.test(code.trim()))
|
|
1011
|
+
throw new SpecificAIError('USAGE', '验证码必须是 6 位数字');
|
|
1012
|
+
if (this.smsSentAt == null)
|
|
1013
|
+
throw new SpecificAIError('USAGE', 'quick_login 必须复用发验证码时的会话,请先发验证码');
|
|
1014
|
+
const body = await this.ssoPost(`${LOGIN}/quick_login/v2/`, [
|
|
1015
|
+
['mix_mode', '1'],
|
|
1016
|
+
['mobile', passportEncrypt(formatPhone(phone))],
|
|
1017
|
+
['code', passportEncrypt(code.trim())],
|
|
1018
|
+
['service', 'https://www.toutiao.com'],
|
|
1019
|
+
['fixed_mix_mode', '1'],
|
|
1020
|
+
]);
|
|
1021
|
+
const err = body?.data?.error_code ?? body?.error_code;
|
|
1022
|
+
if (err)
|
|
1023
|
+
throw smsError(body.data ?? body);
|
|
1024
|
+
const redirect = body?.data?.redirect_url ?? body?.redirect_url;
|
|
1025
|
+
if (redirect)
|
|
1026
|
+
await this.finish(redirect);
|
|
1027
|
+
if (!this.d.tokens.ticket)
|
|
1028
|
+
this.d.ctx.log.debug('quick_login 没有下发 ticket,写操作需要的 bd-ticket-guard 凭证缺失');
|
|
1029
|
+
}
|
|
1030
|
+
// ---------------------------------------------------------------- 收尾
|
|
1031
|
+
/** 跟随登录重定向,收下各跳的 Set-Cookie 与 ticket(上游 _follow_login_redirect)。 */
|
|
1032
|
+
async finish(redirectUrl) {
|
|
1033
|
+
let url = redirectUrl;
|
|
1034
|
+
for (let hop = 0; url && hop < 5; hop++) {
|
|
1035
|
+
const h = baseHeaders('DOC').set('cookie', Passport.cookieHeader(this.scoped('login'))).list();
|
|
1036
|
+
const res = await this.d.request({ url, headers: h, redirect: 'manual', timeout: 20 }, { merge: false });
|
|
1037
|
+
await this.absorb(res, { ticket: true });
|
|
1038
|
+
if (![301, 302, 303, 307, 308].includes(res.status))
|
|
1039
|
+
break;
|
|
1040
|
+
url = res.headers.get('location') ?? undefined;
|
|
1041
|
+
}
|
|
1042
|
+
this.d.deleteCookie('msToken');
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// ---------------------------------------------------------------- 纯函数
|
|
1046
|
+
/** 反自动化探针的默认值(sdk_source_info 明文,键名与拼写照实录)。 */
|
|
1047
|
+
function probe(t) {
|
|
1048
|
+
return {
|
|
1049
|
+
automa_ele: 'false',
|
|
1050
|
+
bit_helper: 'false',
|
|
1051
|
+
chrome_extension_script: '[]',
|
|
1052
|
+
console_lied: 'false',
|
|
1053
|
+
global_variables: '[]',
|
|
1054
|
+
swt_alt: 'false',
|
|
1055
|
+
zn_cap: 'false',
|
|
1056
|
+
hok_noti: 'false',
|
|
1057
|
+
inj_zfb: 'false',
|
|
1058
|
+
t,
|
|
1059
|
+
bit_protocol: 'false',
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
/** passport_auth_mix_state:36 字符表里取 n 个(上游 generate_passport_auth_mix_state)。 */
|
|
1063
|
+
function mixState(length) {
|
|
1064
|
+
let s = '';
|
|
1065
|
+
for (let i = 0; i < length; i++)
|
|
1066
|
+
s += MIX_ALPHABET[Math.floor(rand.random() * MIX_ALPHABET.length)];
|
|
1067
|
+
return s;
|
|
1068
|
+
}
|
|
1069
|
+
/** 北京时间的日期串。上游用本机时区;这里固定 Asia/Shanghai,与抖音页面一致,也让对拍与机器时区无关。 */
|
|
1070
|
+
function shanghai(ms, kind) {
|
|
1071
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
|
|
1072
|
+
.formatToParts(new Date(ms))
|
|
1073
|
+
.map((p) => [p.type, p.value]));
|
|
1074
|
+
return kind === 'keys' ? `${parts.year}-${parts.month}-${parts.day}/${parts.hour}:${parts.minute}:${parts.second}` : `${parts.year}${parts.month}${parts.day}`;
|
|
1075
|
+
}
|
|
1076
|
+
function downloadGuide(stage) {
|
|
1077
|
+
return quote(jsonDumps(`${stage}/${shanghai(rand.now(), 'date')}/0`), '');
|
|
1078
|
+
}
|
|
1079
|
+
/** Python `json.dumps(float)`:整数值的浮点数带 `.0`。 */
|
|
1080
|
+
function pyFloat(x) {
|
|
1081
|
+
return Number.isInteger(x) ? `${x}.0` : String(x);
|
|
1082
|
+
}
|
|
1083
|
+
let challengeProfile;
|
|
1084
|
+
function profile() {
|
|
1085
|
+
return (challengeProfile ??= JSON.parse(readFileSync(staticFile('douyin', 'challenge_profile.json'), 'utf8')));
|
|
1086
|
+
}
|
|
1087
|
+
/** fpk1:FingerprintJS 摘要经 CryptoJS 口令 AES(OpenSSL `Salted__` 格式,上游 build_fpk1)。 */
|
|
1088
|
+
function fpk1() {
|
|
1089
|
+
const digest = String(profile().fingerprint_digest).toLowerCase();
|
|
1090
|
+
const salt = Buffer.from(rand.bytes(8));
|
|
1091
|
+
let derived = Buffer.alloc(0);
|
|
1092
|
+
let prev = Buffer.alloc(0);
|
|
1093
|
+
while (derived.length < 48) {
|
|
1094
|
+
prev = createHash('md5').update(Buffer.concat([prev, Buffer.from('byte_fingerprint'), salt])).digest();
|
|
1095
|
+
derived = Buffer.concat([derived, prev]);
|
|
1096
|
+
}
|
|
1097
|
+
const cipher = createCipheriv('aes-256-cbc', derived.subarray(0, 32), derived.subarray(32, 48));
|
|
1098
|
+
const ct = Buffer.concat([cipher.update(digest, 'ascii'), cipher.final()]);
|
|
1099
|
+
return Buffer.concat([Buffer.from('Salted__'), salt, ct]).toString('base64');
|
|
1100
|
+
}
|
|
1101
|
+
/** challenge 的 body:sign = AES(btoa(指纹 JSON)),sk = XOR5(encodeURIComponent(调用栈))(上游 build_challenge_body)。 */
|
|
1102
|
+
function challengeBody() {
|
|
1103
|
+
const p = profile();
|
|
1104
|
+
const sign = uaAesUrlsafe(Buffer.from(Buffer.from(JSON.stringify(p.fingerprint), 'latin1').toString('base64'), 'ascii'));
|
|
1105
|
+
const stack = String(p.stack_current ?? p.stack);
|
|
1106
|
+
const sk = [...Buffer.from(encodeURIComponent(stack), 'utf8')].map((b) => (b ^ 5).toString(16)).join('');
|
|
1107
|
+
return urlencode([
|
|
1108
|
+
['sign', sign],
|
|
1109
|
+
['sk', sk],
|
|
1110
|
+
]);
|
|
1111
|
+
}
|
|
1112
|
+
function canvasDataUrl() {
|
|
1113
|
+
try {
|
|
1114
|
+
return JSON.parse(readFileSync(staticFile('douyin', 'acrawler_runtime/canvas_actual_exact.json'), 'utf8'));
|
|
1115
|
+
}
|
|
1116
|
+
catch {
|
|
1117
|
+
return '';
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
function formatPhone(phone) {
|
|
1121
|
+
const raw = phone.trim();
|
|
1122
|
+
let m = /^(?:\+86\s?)?(\d{11})$/.exec(raw);
|
|
1123
|
+
if (m)
|
|
1124
|
+
return `+86 ${m[1]}`;
|
|
1125
|
+
m = /^\+86(\d{11})$/.exec(raw);
|
|
1126
|
+
if (m)
|
|
1127
|
+
return `+86 ${m[1]}`;
|
|
1128
|
+
throw new SpecificAIError('USAGE', '手机号必须是 11 位大陆号码,或 +86 <11 位>');
|
|
1129
|
+
}
|
|
1130
|
+
function parseCookieHeader(header) {
|
|
1131
|
+
return header
|
|
1132
|
+
.split(';')
|
|
1133
|
+
.map((t) => t.trim())
|
|
1134
|
+
.filter((t) => t.includes('='))
|
|
1135
|
+
.map((t) => [t.slice(0, t.indexOf('=')), t.slice(t.indexOf('=') + 1)]);
|
|
1136
|
+
}
|
|
1137
|
+
function lastJson(text) {
|
|
1138
|
+
for (const line of text.split(/\r?\n/).reverse()) {
|
|
1139
|
+
const s = line.trim();
|
|
1140
|
+
if (!s)
|
|
1141
|
+
continue;
|
|
1142
|
+
try {
|
|
1143
|
+
return jsonLoads(s);
|
|
1144
|
+
}
|
|
1145
|
+
catch { }
|
|
1146
|
+
}
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
async function jsonOrRaw(res) {
|
|
1150
|
+
const text = await res.text();
|
|
1151
|
+
try {
|
|
1152
|
+
return jsonLoads(text);
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
return { raw: text.slice(0, 200), status: res.status };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
/** passport 的风控错误码(上游 _raise_if_blocked)。 */
|
|
1159
|
+
function raiseIfBlocked(api, res) {
|
|
1160
|
+
const code = res?.data?.error_code;
|
|
1161
|
+
if (code === 0 || code == null)
|
|
1162
|
+
return;
|
|
1163
|
+
const desc = res.data?.description ?? res.message ?? '';
|
|
1164
|
+
if (code === 4031)
|
|
1165
|
+
throw new SpecificAIError('RISK_CONTROL', `${api} 返回 4031「网站存在安全风险」:${desc}`, { detail: { kind: 'blocked', code } });
|
|
1166
|
+
if (code === 7)
|
|
1167
|
+
throw new SpecificAIError('RISK_CONTROL', `${api} 访问太频繁:${desc}`, { detail: { kind: 'rate_limit', code } });
|
|
1168
|
+
throw new SpecificAIError('UPSTREAM', `${api} 失败 error_code=${code} ${desc}`, { detail: { code, description: desc } });
|
|
1169
|
+
}
|
|
1170
|
+
function smsError(data) {
|
|
1171
|
+
const code = data.error_code;
|
|
1172
|
+
const desc = data.description ?? data.message ?? '';
|
|
1173
|
+
if ([1105, 1104, 1107, 2046].includes(code) || /验证|captcha/i.test(desc))
|
|
1174
|
+
return new SpecificAIError('RISK_CONTROL', `需要人机验证:${desc || code}`, { detail: { kind: 'captcha', code } });
|
|
1175
|
+
return new SpecificAIError('UPSTREAM', desc || `短信登录失败 error_code=${code}`, { detail: { code, description: desc } });
|
|
1176
|
+
}
|
|
1177
|
+
/** 登录响应下发的 ticket 信息(上游 apply_ticket_guard)。 */
|
|
1178
|
+
export function applyTicketGuard(d, res) {
|
|
1179
|
+
let raw = res.headers.get('bd-ticket-guard-server-data');
|
|
1180
|
+
if (!raw)
|
|
1181
|
+
raw = /(?:^|\s)bd_ticket_guard_server_data=([^;]*)/.exec(res.headers.getSetCookie().join('\n'))?.[1] ?? null;
|
|
1182
|
+
if (!raw)
|
|
1183
|
+
return false;
|
|
1184
|
+
try {
|
|
1185
|
+
const info = jsonLoads(Buffer.from(decodeURIComponent(raw), 'base64').toString('utf8'));
|
|
1186
|
+
if (!info.ticket)
|
|
1187
|
+
return false;
|
|
1188
|
+
Object.assign(d.tokens, { ticket: info.ticket, ts_sign: info.ts_sign ?? '', client_cert: info.client_cert ?? '' });
|
|
1189
|
+
return true;
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
/** 在子进程里跑上游的 Node 脚本(它们改进程级的全局状态,vm 里跑不了,AGENTS 7.4)。确定性模式下注入与对拍相同的 Math.random / Date。 */
|
|
1196
|
+
function runNode(script, args, env) {
|
|
1197
|
+
const preload = rand.isDeterministic()
|
|
1198
|
+
? `(()=>{let a=${rand.DEFAULT_SEED}>>>0;Math.random=()=>{a=(a+0x6d2b79f5)>>>0;let t=Math.imul(a^(a>>>15),a|1);t=(t+Math.imul(t^(t>>>7),t|61))^t;return((t^(t>>>14))>>>0)/4294967296};const D=Date,n=${rand.now()};globalThis.Date=class extends D{constructor(...x){super(...(x.length?x:[n]))}static now(){return n}}})();`
|
|
1199
|
+
: '';
|
|
1200
|
+
// specific-ai 的 package.json 是 "type": "module",上游脚本是 CommonJS:按 CJS 编译执行,不走模块解析
|
|
1201
|
+
const load = `const M=require('module'),f=${JSON.stringify(script)},m=new M(f);m.filename=f;m.paths=M._nodeModulePaths(require('path').dirname(f));m._compile(require('fs').readFileSync(f,'utf8'),f)`;
|
|
1202
|
+
const code = preload + load;
|
|
1203
|
+
return new Promise((resolve, reject) => {
|
|
1204
|
+
const child = spawn(process.execPath, ['-e', code, script, ...args], { env: { ...process.env, ...env, NODE_OPTIONS: '' }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
1205
|
+
let out = '';
|
|
1206
|
+
child.stdout.on('data', (b) => (out += b));
|
|
1207
|
+
child.stderr.on('data', () => { });
|
|
1208
|
+
const timer = setTimeout(() => child.kill(), 60_000);
|
|
1209
|
+
child.on('error', reject);
|
|
1210
|
+
child.on('close', (code) => {
|
|
1211
|
+
clearTimeout(timer);
|
|
1212
|
+
code === 0 ? resolve(out) : reject(new Error(`子进程退出码 ${code}`));
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
/** 执行 challenge template(上游 challenge_template.run_template,runner 原样复制在 static/douyin/)。 */
|
|
1217
|
+
async function runTemplate(template) {
|
|
1218
|
+
const dir = await mkdtemp(join(tmpdir(), 'dych_'));
|
|
1219
|
+
try {
|
|
1220
|
+
const tpl = join(dir, 't.js');
|
|
1221
|
+
const prof = join(dir, 'p.json');
|
|
1222
|
+
const g = PROFILE.geo;
|
|
1223
|
+
await writeFile(tpl, template, 'utf8');
|
|
1224
|
+
await writeFile(prof, JSON.stringify({
|
|
1225
|
+
ua: PROFILE.ua,
|
|
1226
|
+
browser_major: 151,
|
|
1227
|
+
cpu_core_num: 20,
|
|
1228
|
+
device_memory: 32,
|
|
1229
|
+
screen_width: 2560,
|
|
1230
|
+
screen_height: 1440,
|
|
1231
|
+
avail_width: g[4],
|
|
1232
|
+
avail_height: g[5],
|
|
1233
|
+
inner_width: g[0],
|
|
1234
|
+
inner_height: g[1],
|
|
1235
|
+
outer_width: g[2],
|
|
1236
|
+
outer_height: g[3],
|
|
1237
|
+
webgl_vendor: PROFILE.webglVendor,
|
|
1238
|
+
webgl_renderer: PROFILE.webglRenderer,
|
|
1239
|
+
languages: ['zh-CN', 'zh', 'en', 'zh-TW', 'ja'],
|
|
1240
|
+
}), 'utf8');
|
|
1241
|
+
const out = lastJson(await runNode(staticFile('douyin', 'challenge_template_runner.js'), [tpl, prof], {}));
|
|
1242
|
+
return out?.ok ? out.result : null;
|
|
1243
|
+
}
|
|
1244
|
+
finally {
|
|
1245
|
+
await rm(dir, { recursive: true, force: true });
|
|
1246
|
+
}
|
|
1247
|
+
}
|