1588 lines
85 KiB
Python
1588 lines
85 KiB
Python
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import gzip
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
import zlib
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from core.device_cookie import apply_device_profile_to_cookie, cookie_to_string
|
||
from core.device_profile import DeviceProfile, DeviceProfileGenerator, load_device_profile
|
||
from core.enc_data import (
|
||
KWSG_10400_DEFAULT_CFG9,
|
||
build_inner_zt_header,
|
||
kwsg_10400_ecb_encrypt,
|
||
kwsg_10400_nonce9,
|
||
load_kwsg_10400_tables,
|
||
zt_outer_wrap,
|
||
)
|
||
from core.h5_sig3 import h5_sig3_crc32_from_sign_input, h5_sig3_from_fields
|
||
from core.h5_jsbridge import H5JsBridgeEncoder, build_h5_sign_input, h5_sig3_for_request
|
||
from core.h5_kww import H5KwwGenerator, PureH5KwwGenerator
|
||
from core.h5_kws import KWS_PRODUCT_NAME, build_h5_kws_default_ticket, build_h5_kws_script_ticket
|
||
from core.reward_request import build_reward_body, build_reward_str_e
|
||
from core.sig import build_sig_plaintext, sig as calc_sig
|
||
from core.sig3 import Kwsg10418State, load_kwsg_10418_tables
|
||
from core.tokensig import tokensig as calc_tokensig
|
||
from core.xfalcon import xfalcon_value_from_input_bytes
|
||
from tools.new_device import run_online_bootstrap
|
||
|
||
DEFAULT_OUT_DIR = Path("out")
|
||
NEBULA_HOST = "nebula.kuaishou.com"
|
||
API_HOST = "api.e.kuaishou.com"
|
||
NEBULA_BASE = "https://nebula.kuaishou.com"
|
||
API_BASE = "https://api.e.kuaishou.com"
|
||
SOURCE = "bottom_guide_first"
|
||
REPORT_ENC_HEAD8 = bytes.fromhex("5a54eecdeb3d358c")
|
||
REPORT_ENC_XOR_KEY = b"ErC76yvDSrCzakT3"
|
||
SENSITIVE_COOKIE_KEYS = {"kuaishou.api_st", "token", "kuaishou.h5_st", "kwssectoken", "kwscode", "kwfv1"}
|
||
SENSITIVE_QUERY_KEYS = {"__NStokensig", "__NS_sig3", "__NS_xfalcon", "kuaishou.api_st", "token"}
|
||
SIGNATURE_QUERY_KEYS = {"sig", "sig2", "__NS_sig3", "__NStokensig", "__NS_xfalcon"}
|
||
|
||
# 这些是从 2026-07-10 HAR 固化出的稳定协议字段,不再作为请求列表驱动主流程。
|
||
# 主流程见 KsNebulaClient.run_all_once(),每个接口均有独立方法。
|
||
H5_HEADERS = {'login': {'Host': 'nebula.kuaishou.com', 'sec-ch-ua-platform': '"Android"', 'User-Agent': 'Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 Yoda/3.2.20 ksNebula/14.5.50.11631 OS_PRO_BIT/64 MAX_PHY_MEM/15107 KDT/PHONE AZPREFIX/az3 ICFO/0 StatusHT/40 TitleHT/252 NetType/NR ISLP/0 ISDM/0 ISLB/0 locale/zh-cn SHP/2376 SWP/1080 SD/3.0 CT/0 ISLM/0', 'sec-ch-ua': '"Android WebView";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'sec-ch-ua-mobile': '?1', 'Accept': '*/*', 'X-Requested-With': 'com.kuaishou.nebula', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': 'https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first', 'Accept-Encoding': 'gzip', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}, 'task_list': {'Host': 'nebula.kuaishou.com', 'Pragma': 'no-cache', 'Cache-Control': 'no-cache', 'sec-ch-ua-platform': '"Android"', 'User-Agent': 'Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 Yoda/3.2.20 ksNebula/14.5.50.11631 OS_PRO_BIT/64 MAX_PHY_MEM/15107 KDT/PHONE AZPREFIX/az3 ICFO/0 StatusHT/40 TitleHT/252 NetType/NR ISLP/0 ISDM/0 ISLB/0 locale/zh-cn SHP/2376 SWP/1080 SD/3.0 CT/0 ISLM/0', 'sec-ch-ua': '"Android WebView";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'kww': 'KQKRjWEINlzEI07iMa8YrvXGWc4qQ2YMsjv4wGL2oLDzOX3k0vIz1mXqAtSlzbvkjuY8emvk2CTvxX4OajiTLTXaV5saHH2zG1eNE7Lh7q/izIB39vwqovlepXQ9wwG11DVzphkMKOLcXjHnGkOkvxg7k0mZIGMUoSxgoWaQeXtYEO+WRYQlSt92TO+KI7PKTQe4he/yhs/GHqBqUk0czIsgF==', 'sec-ch-ua-mobile': '?1', 'Accept': '*/*', 'X-Requested-With': 'com.kuaishou.nebula', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': 'https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first', 'Accept-Encoding': 'gzip', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}, 'sign_in_resource': {'Host': 'nebula.kuaishou.com', 'Pragma': 'no-cache', 'Cache-Control': 'no-cache', 'sec-ch-ua-platform': '"Android"', 'User-Agent': 'Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 Yoda/3.2.20 ksNebula/14.5.50.11631 OS_PRO_BIT/64 MAX_PHY_MEM/15107 KDT/PHONE AZPREFIX/az3 ICFO/0 StatusHT/40 TitleHT/252 NetType/NR ISLP/0 ISDM/0 ISLB/0 locale/zh-cn SHP/2376 SWP/1080 SD/3.0 CT/0 ISLM/0', 'sec-ch-ua': '"Android WebView";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'kww': 'KQKRjWEINlzEI07iMa8YrvXGWc4qQ2YMsjv4wGL2oLDzOX3k0vIz1mXqAtSlzbvkjuY8emvk2CTvxX4OajiTLTXaV5saHH2zG1eNE7Lh7q/izIB39vwqovlepXQ9wwG11DVzphkMKOLcXjHnGkOkvxg7k0mZIGMUoSxgoWaQeXtYEO+WRYQlSt92TO+KI7PKTQe4he/yhs/GHqBqUk0czIsgF==', 'sec-ch-ua-mobile': '?1', 'Accept': '*/*', 'X-Requested-With': 'com.kuaishou.nebula', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': 'https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first', 'Accept-Encoding': 'gzip', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}, 'sign_in_report': {'Host': 'nebula.kuaishou.com', 'Pragma': 'no-cache', 'Cache-Control': 'no-cache', 'sec-ch-ua-platform': '"Android"', 'User-Agent': 'Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 Yoda/3.2.20 ksNebula/14.5.50.11631 OS_PRO_BIT/64 MAX_PHY_MEM/15107 KDT/PHONE AZPREFIX/az3 ICFO/0 StatusHT/40 TitleHT/252 NetType/NR ISLP/0 ISDM/0 ISLB/0 locale/zh-cn SHP/2376 SWP/1080 SD/3.0 CT/0 ISLM/0', 'sec-ch-ua': '"Android WebView";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'kww': 'PnGU+9+Y8008S+nH0U+0mjPf8fP08f+98f+nLlwnrIP9+0G0G7+/z08/8jP/ZEw/80Gf+D8/PUPeHFPfGhPfLE+fbYGnHEwBLEGnr7+BP7Per9w/Sf+AD7GfLIG/rE+ncl8Br7Gnb0+B8S+/rIP98D+nzD8BLh80rIP/pSwnpYPBL=', 'sec-ch-ua-mobile': '?1', 'Accept': '*/*', 'X-Requested-With': 'com.kuaishou.nebula', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': 'https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first', 'Accept-Encoding': 'gzip', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}, 'treasure_info': {'Host': 'nebula.kuaishou.com', 'Pragma': 'no-cache', 'Cache-Control': 'no-cache', 'sec-ch-ua-platform': '"Android"', 'User-Agent': 'Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 Yoda/3.2.20 ksNebula/14.5.50.11631 OS_PRO_BIT/64 MAX_PHY_MEM/15107 KDT/PHONE AZPREFIX/az3 ICFO/0 StatusHT/40 TitleHT/252 NetType/NR ISLP/0 ISDM/0 ISLB/0 locale/zh-cn SHP/2376 SWP/1080 SD/3.0 CT/0 ISLM/0', 'sec-ch-ua': '"Android WebView";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'kww': 'KQKRjWEINlzEI07iMa8YrvXGWc4qQ2YMsjv4wGL2oLDzOX3k0vIz1mXqAtSlzbvkjuY8emvk2CTvxX4OajiTLTXaV5saHH2zG1eNE7Lh7q/izIB39vwqovlepXQ9wwG11DVzphkMKOLcXjHnGkOkvxg7k0mZIGMUoSxgoWaQeXtYEO+WRYQlSt92TO+KI7PKTQe4he/yhs/GHqBqUk0czIsgF==', 'sec-ch-ua-mobile': '?1', 'Accept': '*/*', 'X-Requested-With': 'com.kuaishou.nebula', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': 'https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first', 'Accept-Encoding': 'gzip', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}, 'treasure_open': {'Host': 'nebula.kuaishou.com', 'Pragma': 'no-cache', 'Cache-Control': 'no-cache', 'sec-ch-ua-platform': '"Android"', 'User-Agent': 'Mozilla/5.0 (Linux; Android 16; PJZ110 Build/BP2A.250605.015; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/149.0.7827.164 Mobile Safari/537.36 Yoda/3.2.20 ksNebula/14.5.50.11631 OS_PRO_BIT/64 MAX_PHY_MEM/15107 KDT/PHONE AZPREFIX/az3 ICFO/0 StatusHT/40 TitleHT/252 NetType/NR ISLP/0 ISDM/0 ISLB/0 locale/zh-cn SHP/2376 SWP/1080 SD/3.0 CT/0 ISLM/0', 'sec-ch-ua': '"Android WebView";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'content-type': 'application/json', 'kww': 'PnGU+9+Y8008S+nH0U+0mjPf8fP08f+98f+nLlwnrIP9+0G0G7+/z08/8jP/ZEw/80Gf+D8/PUPeHFPfGhPfLE+fbYGnHEwBLEGnr7+BP7Per9w/Sf+AD7GfLIG/rE+ncl8Br7Gnb0+B8S+/rIP98D+nzD8BLh80rIP/pSwnpYPBL=', 'sec-ch-ua-mobile': '?1', 'Accept': '*/*', 'Origin': 'https://nebula.kuaishou.com', 'X-Requested-With': 'com.kuaishou.nebula', 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty', 'Referer': 'https://nebula.kuaishou.com/nebula/task/earning?&layoutType=4&source=bottom_guide_first', 'Accept-Encoding': 'gzip', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7'}}
|
||
TASK_LIST_PARAMS = {'searchWidgetStatus': 'false', 'rankWidgetStatus': 'false', 'encourageDesktopWidgetStatus': 'false', 'pushNotificationStatus': 'true', 'encourageDesktopWidgetNewStatus': '0', 'cs': 'false', 'source': 'bottom_guide_first', 'bizParams': '{"ignoringBatteryOptimizationsStatus":false,"source":"bottom_guide_first","initRequest":true,"refreshType":"init","contactsPermissionStatus":false}', 'imei': '', 'idfa': '', 'oaid': '700ED68650EF49ECB686B2AAA9994C1Dca31c779ee5acb7d4c3b03337599a591'}
|
||
SIGN_IN_EVENT_TRACKING = '{"deliverOrderId":"5118","eventTrackingTaskId":20022,"resourceId":"externalFeed_externalSignPopup","extParams":{"signInThresholdTaskType":0,"autoSignInSuccess":false,"source":"bottom_guide_first"},"resourceFirstLevelName":"EXTERNAL_FEED","resourceSecondLevelName":"EXTERNAL_SIGN_POPUP"}'
|
||
TREASURE_OPEN_BODY = '{"imei":"","idfa":"","oaid":"700ED68650EF49ECB686B2AAA9994C1Dca31c779ee5acb7d4c3b03337599a591","eventTrackingLogInfo":"{\\"eventTrackingTaskId\\":20035,\\"resourceId\\":\\"externalFeed_externalTreasureChestWidget\\",\\"extParams\\":{\\"isServerRecordClickAction\\":true,\\"source\\":\\"bottom_guide_first\\"},\\"deliverOrderId\\":\\"5128\\",\\"resourceFirstLevelName\\":\\"EXTERNAL_FEED\\",\\"resourceSecondLevelName\\":\\"EXTERNAL_TREASURE_CHEST_WIDGET\\"}"}'
|
||
DEFAULT_OAID = TASK_LIST_PARAMS["oaid"]
|
||
TREASURE_OPEN_EVENT_TRACKING = {
|
||
"eventTrackingTaskId": 20035,
|
||
"resourceId": "externalFeed_externalTreasureChestWidget",
|
||
"extParams": {"isServerRecordClickAction": True, "source": SOURCE},
|
||
"deliverOrderId": "5128",
|
||
"resourceFirstLevelName": "EXTERNAL_FEED",
|
||
"resourceSecondLevelName": "EXTERNAL_TREASURE_CHEST_WIDGET",
|
||
}
|
||
H5_SIG3_CRC32 = {
|
||
"sign_in_resource": 0x2C60DFFD,
|
||
"sign_in_report": 0xCCAE724F,
|
||
"treasure_info": 0xA6E9C286,
|
||
"treasure_open": 0x02F88937,
|
||
}
|
||
API_HEADERS = {'Host': 'api.e.kuaishou.com', 'User-Agent': 'kwai-android aegon/4.50.1', 'Accept-Language': 'zh-cn', 'page-code': 'NEW_TASK_CENTER', 'kaw': 'MDJ3ll0yLjVpYO7iiytguFDgh1dM9kzX1QW7XXr0RScGn+UbPnUe/UT8OM+5ods5QMOzXRclcl/CzL9jxwWWfBFEYYSzM7RbUPpceM0luB7tOTcbSVlRfDDvYPWVAYRZ5ETRRvOM5sUIG7mB25kTIy2Q90Q/XaCQdkDCy0ikkyN0zL30NUoEBAt5t6tg8lXHTgMhuGIHxxmLccoTvk6dC0ZJhKgGdMmK3lMwGnbxyb3vjKl5tSXFx7nC2Z0ES1PnoYBhwpsA', 'kas': '0013d223e281e58ae60aea1b9948d88f72', 'Content-Type': 'application/x-www-form-urlencoded', 'Accept-Encoding': 'gzip', 'X-Client-Info': 'model=PJZ110;os=Android;nqe-score=-1;network=OTHER;'}
|
||
API_BASE_PARAMS = {'earphoneMode': '1', 'mod': 'OnePlus(PJZ110)', 'appver': '14.5.50.11631', 'isp': 'CUCC', 'language': 'zh-cn', 'ud': '1579452490', 'did_tag': '0', 'egid': 'DFPE1EAE3D15BCBB5132EF6598C43DC060E5166B02182E3800A82577C54EE4E1', 'thermal': '10000', 'net': '5G', 'kcv': '1627', 'app': '0', 'kpf': 'ANDROID_PHONE', 'bottom_navigation': 'true', 'ver': '14.5', 'android_os': '0', 'oDid': 'ANDROID_46a032e0a2af8184', 'boardPlatform': 'sun', 'kpn': 'NEBULA', 'newOc': 'OPPO', 'androidApiLevel': '36', 'slh': '0', 'country_code': 'cn', 'nbh': '0', 'hotfix_ver': '', 'did_gt': '1783674614984', 'keyconfig_state': '2', 'cdid_tag': '2', 'sys': 'ANDROID_16', 'max_memory': '256', 'cold_launch_time_ms': '1783674610031', 'oc': 'OPPO', 'sh': '2376', 'deviceBit': '4', 'browseType': '3', 'ddpi': '480', 'socName': 'Qualcomm Snapdragon 8750', 'is_background': '0', 'c': 'OPPO', 'sw': '1080', 'ftt': '', 'apptype': '22', 'abi': 'arm64', 'userRecoBit': '0', 'device_abi': 'arm64', 'icaver': '1', 'totalMemory': '15107', 'grant_browse_type': 'AUTHORIZED', 'iuid': '', 'rdid': 'ANDROID_741de4351c44850d', 'sbh': '120', 'darkMode': 'false', 'did': 'ANDROID_e8dfd2f16b618053'}
|
||
API_COMMON_BODY = {'cs': 'false', 'client_key': '2ac2a76d', 'videoModelCrowdTag': '1_100', 'os': 'android', 'uQaTag': '7#33333333339999999999#DP:3hX9ONf4GgQVINru4wfjCg==#ecBl:33#ecPp:-9#cmNt:-1#cmHs:-5#cmMnsl:-0#cmAu:-3'}
|
||
API_SIG3_SEED = {'sign_in_fetch': 'a6b7c2e4c8969fbd4feeedecfe49ab42d341a59ef3fff1e7', 'treasure_fetch': 'e8f98caa86d8d1f31ca0a3a2ed9f44b9dd0febd0bdb1bfa9', 'normal_fetch': 'dacbbe98b4eae3c18093919051c3bf2a163dd9e28f838d9b', 'sign_in_report': 'c1d0a583aff1f8da32898a8b3119641ed926c2f994989680', 'treasure_report': '3c2d587e520c0527967477767f4f25c8ffdb3f0469656b7d', 'normal_report': 'aebfcaecc09e97b5cbe7e5e4bbaf01124349ad96fbf7f9ef'}
|
||
AD_FETCH_PAYLOADS = {'sign_in': {'encData': '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', 'sign': '5a54eecde4d4ea61056e396d684a1637d6584231c3c5598e70987b175323786d'}, 'treasure': {'encData': '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', 'sign': '5a54eecde4d4ea61e78cdb8f8aa8f4d517baa0d314abe099d27a99f5b1c19a8f'}, 'normal': {'encData': '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', 'sign': '5a54eecde4d4ea61c6adfaaeab89d5f4989a81f29c36b66b0a5bb8d490e0bbae'}}
|
||
AD_FETCH_FALLBACK_BUSINESS = {"sign_in": 605, "treasure": 606, "normal": 672}
|
||
AD_FETCH_FALLBACK_SCENE = {"sign_in": (11101, 100024058, 20342), "treasure": (11101, 100024064, 20346), "normal": (11101, 100026367, 24067)}
|
||
|
||
|
||
def describe_local_algorithm_stack(
|
||
*,
|
||
h5_sig3_mode: str | None = None,
|
||
h5_kww_mode: str | None = None,
|
||
h5_kws_mode: str | None = None,
|
||
report_body_mode: str | None = None,
|
||
) -> list[str]:
|
||
"""返回当前任务使用的本地算法栈说明。
|
||
|
||
这里只列本地 Python 模块和本地 Node runner,避免把参考脚本里的
|
||
远端签名服务混入执行路径。
|
||
"""
|
||
|
||
sig3_mode = (h5_sig3_mode or os.environ.get("KS_H5_SIG3_MODE", "pure")).strip().lower() or "pure"
|
||
kww_mode = (h5_kww_mode or os.environ.get("KS_H5_KWW_MODE", "pure")).strip().lower() or "pure"
|
||
kws_mode = (h5_kws_mode or os.environ.get("KS_H5_KWS_MODE", "default")).strip().lower() or "default"
|
||
body_mode = (report_body_mode or os.environ.get("KS_REPORT_BODY_MODE", "encData")).strip() or "encData"
|
||
h5_sig3_impl = "core.h5_sig3"
|
||
if sig3_mode in {"jsbridge", "node", "vm", "jsbridge_strict", "strict"}:
|
||
h5_sig3_impl = "core.h5_jsbridge + local node runner"
|
||
h5_kww_impl = "core.h5_kww"
|
||
if kww_mode not in {"pure", "python", "py", "0", "off", "static", "disabled"}:
|
||
h5_kww_impl = "core.h5_kww + local node runner"
|
||
h5_kws_impl = "core.h5_kws default ticket"
|
||
if kws_mode in {"script", "sign", "server", "config"}:
|
||
h5_kws_impl = "core.h5_kws local signUrl runner / known-code parser"
|
||
return [
|
||
"api_query: sig=core.sig; api_sig3=core.sig3; xfalcon=core.xfalcon; tokensig=core.tokensig",
|
||
"reward_fetch: reward_fetch=core.reward_request; encData=core.enc_data; body_sign=core.reward_sign",
|
||
f"reward_report: body_mode={body_mode}; report_enc=core.enc_data; url_sign=core.sig/core.sig3/core.xfalcon",
|
||
f"h5_state: h5_sig3={h5_sig3_impl}; sign_input=core.h5_jsbridge.build_h5_sign_input",
|
||
f"h5_webweapon: kws={h5_kws_impl}; kww={h5_kww_impl}",
|
||
]
|
||
|
||
|
||
def print_local_algorithm_stack() -> None:
|
||
print("[算法] 本地纯算链路启用")
|
||
for line in describe_local_algorithm_stack():
|
||
print(f"[算法] {line}")
|
||
|
||
_SIG3_TABLES: tuple[bytes, bytes] | None = None
|
||
_ENC_TABLES: tuple[bytes, bytes] | None = None
|
||
|
||
|
||
def _compact_json(value: Any) -> str:
|
||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||
|
||
|
||
def event_tracking_to_json(value: Any) -> str:
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
if not text:
|
||
return ""
|
||
try:
|
||
value = json.loads(text)
|
||
except json.JSONDecodeError:
|
||
return text
|
||
if isinstance(value, dict):
|
||
return _compact_json(value)
|
||
return ""
|
||
|
||
|
||
def event_tracking_with_source(value: Any) -> str:
|
||
"""把 H5 状态上报事件归一为 HAR 成功样本使用的 source 上下文。"""
|
||
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
if not text:
|
||
return ""
|
||
try:
|
||
value = json.loads(text)
|
||
except json.JSONDecodeError:
|
||
return text
|
||
if not isinstance(value, dict):
|
||
return ""
|
||
event = json.loads(_compact_json(value))
|
||
ext_params = event.get("extParams")
|
||
if not isinstance(ext_params, dict):
|
||
ext_params = {}
|
||
ext_params.setdefault("source", SOURCE)
|
||
event["extParams"] = ext_params
|
||
return _compact_json(event)
|
||
|
||
|
||
_H5_COOKIE_WIRE_ENCODE_KEYS = {"mod", "deviceName", "socName"}
|
||
|
||
|
||
def normalize_h5_cookie_wire_values(cookie: dict[str, str]) -> dict[str, str]:
|
||
"""把 `.env` 中易读设备字段恢复成 APP Cookie 的 wire 形态。
|
||
|
||
浏览器/HAR 中 `mod`、`socName` 等字段保留 URL 编码形态:
|
||
`OnePlus%28PJZ110%29`、`Qualcomm+Snapdragon+8750`。
|
||
`.env` 为了可读通常写成 `OnePlus(PJZ110)` 和空格形式;H5
|
||
`__NS_sig3` 的 sign_input 又直接读取 cookie 值,因此这里在 H5
|
||
专用 cookie 中归一,API query 仍走原始字段后由 urlencode 编码。
|
||
"""
|
||
|
||
out = dict(cookie)
|
||
for key in _H5_COOKIE_WIRE_ENCODE_KEYS:
|
||
value = out.get(key)
|
||
if value is None:
|
||
continue
|
||
decoded = urllib.parse.unquote_plus(str(value))
|
||
out[key] = urllib.parse.quote_plus(decoded, safe="")
|
||
return out
|
||
|
||
|
||
def treasure_open_body_for_oaid(oaid: str, event_tracking: Any = None) -> str:
|
||
event_tracking_json = event_tracking_to_json(event_tracking)
|
||
if not event_tracking_json:
|
||
event_tracking_json = _compact_json(TREASURE_OPEN_EVENT_TRACKING)
|
||
return _compact_json(
|
||
{
|
||
"imei": "",
|
||
"idfa": "",
|
||
"oaid": oaid,
|
||
"eventTrackingLogInfo": event_tracking_json,
|
||
}
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AdMaterial:
|
||
kind: str
|
||
llsid: str
|
||
creative_id: str
|
||
business_id: int
|
||
page_id: int
|
||
sub_page_id: int
|
||
pos_id: int
|
||
material_time: int
|
||
request_scene_type: int
|
||
neo_params: str = ""
|
||
neo_ext_params: str = ""
|
||
ad_ext_info: str = ""
|
||
callback_param: str = ""
|
||
feed_id: str = ""
|
||
session_id: str = ""
|
||
|
||
|
||
@dataclass
|
||
class ResponseRecord:
|
||
label: str
|
||
title: str
|
||
request_id: int
|
||
status: int | None
|
||
ok: bool
|
||
url: str
|
||
response_json: Any
|
||
response_text: str
|
||
error: str = ""
|
||
|
||
def summary(self) -> str:
|
||
if isinstance(self.response_json, dict) and self.response_json.get("dryRun") is True:
|
||
return (
|
||
f"dry-run method={self.response_json.get('method')} "
|
||
f"host={self.response_json.get('host')} "
|
||
f"path={self.response_json.get('path')} "
|
||
f"bodySize={self.response_json.get('bodySize')}"
|
||
)
|
||
if isinstance(self.response_json, dict) and self.response_json.get("skipped") is True:
|
||
parts = ["skipped"]
|
||
reason = self.response_json.get("reason")
|
||
expected_reward = self.response_json.get("expectedReward")
|
||
countdown_ms = self.response_json.get("countdownMs")
|
||
if reason:
|
||
parts.append(f"reason={reason}")
|
||
if expected_reward:
|
||
parts.append(f"expected={expected_reward}金币")
|
||
if countdown_ms:
|
||
parts.append(f"countdown={float(countdown_ms) / 1000:.1f}s")
|
||
return " ".join(parts)
|
||
if self.error:
|
||
return f"HTTP={self.status or '-'} error={self.error}"
|
||
result = self.response_json.get("result") if isinstance(self.response_json, dict) else None
|
||
msg = extract_message(self.response_json)
|
||
awards = extract_awards(self.response_json)
|
||
parts = [f"HTTP={self.status}", f"result={result}"]
|
||
if msg:
|
||
parts.append(f"msg={msg}")
|
||
expected_reward = extract_fetch_expected_reward(self.response_json)
|
||
if expected_reward:
|
||
parts.append(f"expected={expected_reward}金币")
|
||
material_time = extract_fetch_material_time(self.response_json)
|
||
if material_time:
|
||
parts.append(f"material={material_time / 1000:.1f}s")
|
||
if awards:
|
||
parts.append("award=" + ", ".join(awards[:4]))
|
||
return " ".join(parts)
|
||
|
||
def to_json(self) -> dict[str, Any]:
|
||
return {
|
||
"label": self.label,
|
||
"title": self.title,
|
||
"requestId": self.request_id,
|
||
"status": self.status,
|
||
"ok": self.ok,
|
||
"url": redact_url(self.url),
|
||
"error": self.error,
|
||
"summary": self.summary(),
|
||
"responseJson": redact_json(self.response_json),
|
||
"responseText": "" if self.response_json is not None else self.response_text[:2000],
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeviceRuntimeOptions:
|
||
memory_device: bool
|
||
device_online: bool
|
||
rotate_h5_device: bool = False
|
||
strict_device_online: bool = False
|
||
|
||
|
||
def load_dotenv(path: Path = Path(".env")) -> None:
|
||
if not path.exists():
|
||
return
|
||
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||
line = raw.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, value = line.split("=", 1)
|
||
value = value.strip()
|
||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||
value = value[1:-1]
|
||
os.environ.setdefault(key.strip(), value)
|
||
|
||
|
||
def parse_account(value: str) -> tuple[str, str]:
|
||
parts = value.split("#")
|
||
if len(parts) >= 3:
|
||
return parts[1].strip(), parts[2].strip()
|
||
return "", ""
|
||
|
||
|
||
def parse_first_ksck(value: str) -> tuple[str, str]:
|
||
"""解析单变量 `ksck`,多个账号按 `\\n` / `\\r` 分隔,默认取第一个。"""
|
||
|
||
text = (value or "").replace("\\r", "\r").replace("\\n", "\n")
|
||
for raw in re.split(r"[\r\n]+", text):
|
||
item = raw.strip()
|
||
if not item:
|
||
continue
|
||
cookie, salt = parse_account(item)
|
||
if cookie:
|
||
return cookie, salt
|
||
return item, ""
|
||
return "", ""
|
||
|
||
|
||
def parse_cookie(cookie: str) -> dict[str, str]:
|
||
out: dict[str, str] = {}
|
||
for item in cookie.split(";"):
|
||
item = item.strip()
|
||
if "=" in item:
|
||
key, value = item.split("=", 1)
|
||
out[key.strip()] = value.strip()
|
||
return out
|
||
|
||
|
||
def cookie_from_env(device_profile_path: str | Path | None = None) -> tuple[str, str, dict[str, str]]:
|
||
ksck_cookie, ksck_salt = parse_first_ksck(os.environ.get("ksck") or os.environ.get("KSCK", ""))
|
||
account_cookie, account_salt = parse_account(os.environ.get("KS_ACCOUNT", ""))
|
||
if ksck_cookie:
|
||
cookie = ksck_cookie
|
||
client_salt = ksck_salt or os.environ.get("KS_CLIENT_SALT") or account_salt
|
||
else:
|
||
cookie = os.environ.get("KS_COOKIE") or account_cookie
|
||
client_salt = os.environ.get("KS_CLIENT_SALT") or account_salt
|
||
if not cookie:
|
||
raise SystemExit("缺少 ksck / KS_COOKIE / KS_ACCOUNT,请先配置 .env")
|
||
cookie_dict = parse_cookie(cookie)
|
||
profile_path = str(device_profile_path or os.environ.get("KS_DEVICE_PROFILE", "")).strip()
|
||
if profile_path:
|
||
profile = load_device_profile(profile_path)
|
||
cookie_dict = apply_device_profile_to_cookie(cookie_dict, profile)
|
||
cookie = cookie_to_string(cookie_dict)
|
||
return cookie, client_salt, cookie_dict
|
||
|
||
|
||
def args_to_device_options(args: argparse.Namespace) -> DeviceRuntimeOptions:
|
||
server_device = bool(getattr(args, "server_device", False))
|
||
no_device_online = bool(getattr(args, "no_device_online", False))
|
||
if server_device and no_device_online:
|
||
raise SystemExit("--server-device conflicts with --no-device-online")
|
||
return DeviceRuntimeOptions(
|
||
memory_device=bool(getattr(args, "memory_device", False)) or server_device,
|
||
device_online=not no_device_online,
|
||
rotate_h5_device=bool(getattr(args, "rotate_h5_device", False)),
|
||
strict_device_online=bool(getattr(args, "strict_device_online", False)),
|
||
)
|
||
|
||
|
||
def safe_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
if value in (None, ""):
|
||
return default
|
||
text = str(value).strip()
|
||
try:
|
||
return int(text)
|
||
except ValueError:
|
||
return int(float(text))
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def generate_request_id() -> str:
|
||
return f"{int(time.time() * 1000)}{os.getpid() % 100000:05d}"
|
||
|
||
|
||
def sig3_tables() -> tuple[bytes, bytes]:
|
||
global _SIG3_TABLES
|
||
if _SIG3_TABLES is None:
|
||
_SIG3_TABLES = load_kwsg_10418_tables("bin/kwsg_10418_B_T1.bin", "bin/kwsg_10418_B_T2.bin")
|
||
return _SIG3_TABLES
|
||
|
||
|
||
def enc_tables() -> tuple[bytes, bytes]:
|
||
global _ENC_TABLES
|
||
if _ENC_TABLES is None:
|
||
_ENC_TABLES = load_kwsg_10400_tables("bin/kwsg_10400_T1.bin", "bin/kwsg_10400_T2.bin")
|
||
return _ENC_TABLES
|
||
|
||
|
||
def build_report_enc_data(payload: bytes) -> str:
|
||
t1, t2 = enc_tables()
|
||
encrypted = kwsg_10400_ecb_encrypt(payload, t1, t2)
|
||
inner = build_inner_zt_header(kwsg_10400_nonce9(), KWSG_10400_DEFAULT_CFG9, encrypted) + encrypted
|
||
raw = zt_outer_wrap(inner, REPORT_ENC_HEAD8, REPORT_ENC_XOR_KEY)
|
||
return base64.b64encode(raw).decode("ascii")
|
||
|
||
|
||
def replace_pair_values(pairs: list[tuple[str, str]], updates: dict[str, str]) -> list[tuple[str, str]]:
|
||
seen: set[str] = set()
|
||
result: list[tuple[str, str]] = []
|
||
for key, value in pairs:
|
||
if key in updates:
|
||
result.append((key, updates[key]))
|
||
seen.add(key)
|
||
else:
|
||
result.append((key, value))
|
||
for key, value in updates.items():
|
||
if key not in seen:
|
||
result.append((key, value))
|
||
return result
|
||
|
||
|
||
def signed_api_url(
|
||
path: str,
|
||
params: dict[str, str],
|
||
body_pairs: list[tuple[str, str]],
|
||
client_salt: str,
|
||
sig3_seed: str,
|
||
*,
|
||
state: Kwsg10418State | None = None,
|
||
) -> str:
|
||
signing_params = {k: v for k, v in params.items() if k not in SIGNATURE_QUERY_KEYS and not k.startswith("__NS")}
|
||
signing_params.update(dict(body_pairs))
|
||
sig_value = calc_sig(signing_params)
|
||
if state is None:
|
||
try:
|
||
state = Kwsg10418State.from_digest(sig3_seed)
|
||
except Exception:
|
||
state = Kwsg10418State()
|
||
t1, t2 = sig3_tables()
|
||
sig3_value = state.sig3_hex(path + sig_value, t1=t1, t2=t2)
|
||
token_sig = calc_tokensig(sig_value, client_salt) if client_salt else ""
|
||
xfalcon = xfalcon_value_from_input_bytes(path.encode("utf-8") + build_sig_plaintext(signing_params))
|
||
signed = dict(params)
|
||
signed.update({"sig": sig_value, "__NS_sig3": sig3_value, "__NStokensig": token_sig, "__NS_xfalcon": xfalcon})
|
||
return f"{API_BASE}{path}?{urllib.parse.urlencode(signed)}"
|
||
|
||
|
||
def decode_response(raw: bytes, encoding: str) -> str:
|
||
encoding = encoding.lower()
|
||
if "gzip" in encoding:
|
||
raw = gzip.decompress(raw)
|
||
elif "deflate" in encoding:
|
||
raw = zlib.decompress(raw)
|
||
return raw.decode("utf-8", "replace")
|
||
|
||
|
||
def parse_json(text: str) -> Any:
|
||
try:
|
||
return json.loads(text)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def is_business_ok(status: int | None, data: Any) -> bool:
|
||
if status is None or not (200 <= status < 300):
|
||
return False
|
||
if isinstance(data, dict):
|
||
result = data.get("result")
|
||
message = extract_message(data)
|
||
if result in (50, 500, 6001) or data.get("code") == 400010:
|
||
return False
|
||
if message in {"ANTISPAM_REQUEST", "INVALID_REQUEST"}:
|
||
return False
|
||
return True
|
||
|
||
|
||
def is_rate_limited(status: int | None, data: Any) -> bool:
|
||
if status == 429:
|
||
return True
|
||
if not isinstance(data, dict):
|
||
return False
|
||
message = extract_message(data)
|
||
return data.get("code") == 400010 or "访问太快" in message
|
||
|
||
|
||
def extract_message(data: Any) -> str:
|
||
if not isinstance(data, dict):
|
||
return ""
|
||
for key in ("message", "errorMsg", "error_msg", "msg"):
|
||
if data.get(key):
|
||
return str(data[key])
|
||
node = data.get("data")
|
||
if isinstance(node, dict):
|
||
for key in ("toast", "errorMessage", "antispamToast"):
|
||
if node.get(key):
|
||
return str(node[key])
|
||
return ""
|
||
|
||
|
||
def extract_fetch_material_time(data: Any) -> int:
|
||
if not isinstance(data, dict):
|
||
return 0
|
||
feeds = data.get("feeds")
|
||
if not isinstance(feeds, list) or not feeds:
|
||
return 0
|
||
feed = feeds[0] if isinstance(feeds[0], dict) else {}
|
||
return safe_int((feed.get("ext_params") or {}).get("video"), 0)
|
||
|
||
|
||
def extract_fetch_expected_reward(data: Any) -> int:
|
||
if not isinstance(data, dict):
|
||
return 0
|
||
feeds = data.get("feeds")
|
||
if not isinstance(feeds, list) or not feeds:
|
||
return 0
|
||
feed = feeds[0] if isinstance(feeds[0], dict) else {}
|
||
ad = feed.get("ad") if isinstance(feed.get("ad"), dict) else {}
|
||
ad_v2 = ad.get("adDataV2") if isinstance(ad.get("adDataV2"), dict) else {}
|
||
for item in ad_v2.get("templateDatas") or []:
|
||
if not isinstance(item, dict) or not isinstance(item.get("data"), str):
|
||
continue
|
||
try:
|
||
template_data = json.loads(item["data"])
|
||
except Exception:
|
||
continue
|
||
neo_count = safe_int(template_data.get("neoCount"), 0)
|
||
if neo_count > 0:
|
||
return neo_count
|
||
return 0
|
||
|
||
|
||
def extract_awards(data: Any) -> list[str]:
|
||
awards: list[str] = []
|
||
|
||
def add(name: str, value: Any, unit: str = "") -> None:
|
||
if value in (None, "", []):
|
||
return
|
||
text = f"{name}:{value}{unit}"
|
||
if text not in awards:
|
||
awards.append(text)
|
||
|
||
def walk(node: Any) -> None:
|
||
if isinstance(node, dict):
|
||
if "neoAmount" in node:
|
||
add("neoAmount", node.get("neoAmount"), "金币")
|
||
if "sessionTotalAmount" in node:
|
||
add("sessionTotal", node.get("sessionTotalAmount"), "金币")
|
||
if "totalCoin" in node:
|
||
add("totalCoin", node.get("totalCoin"), "金币")
|
||
if "allCash" in node:
|
||
add("cash", node.get("allCash"), "元")
|
||
if "rewardCount" in node:
|
||
add("reward", node.get("rewardCount"), str(node.get("rewardUnit") or ""))
|
||
if "amount" in node and any(k in node for k in ("awardType", "bizType")):
|
||
add(str(node.get("bizType") or "amount"), node.get("amount"), "金币")
|
||
for value in node.values():
|
||
if isinstance(value, (dict, list)):
|
||
walk(value)
|
||
elif isinstance(node, list):
|
||
for item in node:
|
||
walk(item)
|
||
|
||
walk(data)
|
||
return awards
|
||
|
||
|
||
def extract_neo_amount(data: Any) -> int | None:
|
||
if isinstance(data, dict):
|
||
if "neoAmount" in data:
|
||
return safe_int(data.get("neoAmount"), 0)
|
||
for value in data.values():
|
||
amount = extract_neo_amount(value)
|
||
if amount is not None:
|
||
return amount
|
||
elif isinstance(data, list):
|
||
for item in data:
|
||
amount = extract_neo_amount(item)
|
||
if amount is not None:
|
||
return amount
|
||
return None
|
||
|
||
|
||
def redact_url(url: str) -> str:
|
||
split = urllib.parse.urlsplit(url)
|
||
pairs = urllib.parse.parse_qsl(split.query, keep_blank_values=True)
|
||
pairs = [(k, "<redacted>" if k in SENSITIVE_QUERY_KEYS else v) for k, v in pairs]
|
||
return urllib.parse.urlunsplit((split.scheme, split.netloc, split.path, urllib.parse.urlencode(pairs), split.fragment))
|
||
|
||
|
||
def redact_json(value: Any) -> Any:
|
||
if isinstance(value, dict):
|
||
out = {}
|
||
for key, item in value.items():
|
||
key_s = str(key)
|
||
if key_s in SENSITIVE_COOKIE_KEYS or "token" in key_s.lower() or key_s.endswith("_st"):
|
||
out[key] = "<redacted>"
|
||
else:
|
||
out[key] = redact_json(item)
|
||
return out
|
||
if isinstance(value, list):
|
||
return [redact_json(item) for item in value]
|
||
return value
|
||
|
||
|
||
def decode_neo_params(value: str) -> dict[str, Any]:
|
||
if not value:
|
||
return {}
|
||
try:
|
||
padded = value + "=" * (-len(value) % 4)
|
||
return json.loads(base64.b64decode(padded).decode("utf-8"))
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def extract_ad_material(kind: str, data: Any) -> AdMaterial | None:
|
||
if not isinstance(data, dict):
|
||
return None
|
||
feeds = data.get("feeds") or []
|
||
if not feeds:
|
||
return None
|
||
feed = feeds[0] if isinstance(feeds[0], dict) else {}
|
||
ad = feed.get("ad") if isinstance(feed.get("ad"), dict) else {}
|
||
ad_v2 = ad.get("adDataV2") if isinstance(ad.get("adDataV2"), dict) else {}
|
||
inspire = ad_v2.get("inspireAdInfo") if isinstance(ad_v2.get("inspireAdInfo"), dict) else {}
|
||
ext_data: dict[str, Any] = {}
|
||
raw_ext = ad.get("extData")
|
||
if isinstance(raw_ext, str) and raw_ext:
|
||
try:
|
||
ext_data = json.loads(raw_ext)
|
||
except Exception:
|
||
ext_data = {}
|
||
elif isinstance(raw_ext, dict):
|
||
ext_data = raw_ext
|
||
|
||
neo_params = str(inspire.get("neoParams") or "")
|
||
neo_decoded = decode_neo_params(neo_params)
|
||
business_id = safe_int(
|
||
neo_decoded.get("businessId") or ext_data.get("inspireTaskId") or AD_FETCH_FALLBACK_BUSINESS.get(kind),
|
||
AD_FETCH_FALLBACK_BUSINESS.get(kind, 0),
|
||
)
|
||
material_time = safe_int((feed.get("ext_params") or {}).get("video"), 0)
|
||
if material_time <= 0:
|
||
stream = feed.get("streamManifest") if isinstance(feed.get("streamManifest"), dict) else {}
|
||
sets = stream.get("adaptationSet") if isinstance(stream.get("adaptationSet"), list) else []
|
||
material_time = safe_int((sets[0] if sets else {}).get("duration"), 30000)
|
||
|
||
return AdMaterial(
|
||
kind=kind,
|
||
llsid=str(ad.get("llsid") or data.get("llsid") or ""),
|
||
creative_id=str(ad.get("creativeId") or ""),
|
||
business_id=business_id,
|
||
page_id=safe_int(neo_decoded.get("pageId") or ad.get("pageId"), 0),
|
||
sub_page_id=safe_int(neo_decoded.get("subPageId") or ad.get("subPageId"), 0),
|
||
pos_id=safe_int(ad_v2.get("posId"), 0),
|
||
material_time=material_time,
|
||
request_scene_type=safe_int(ext_data.get("requestSceneType"), 1),
|
||
neo_params=neo_params,
|
||
neo_ext_params=str(neo_decoded.get("extParams") or ""),
|
||
ad_ext_info=str(inspire.get("adExtInfo") or ""),
|
||
callback_param=str(ad.get("callbackParam") or ""),
|
||
feed_id=str(ext_data.get("liveStreamId") or feed.get("liveStreamId") or ""),
|
||
session_id=str(ext_data.get("inspireSessionId") or ""),
|
||
)
|
||
|
||
|
||
def fallback_ad_material(kind: str) -> AdMaterial:
|
||
page_id, sub_page_id, pos_id = AD_FETCH_FALLBACK_SCENE.get(kind, (11101, 0, 0))
|
||
return AdMaterial(
|
||
kind=kind,
|
||
llsid="0",
|
||
creative_id="0",
|
||
business_id=AD_FETCH_FALLBACK_BUSINESS.get(kind, 0),
|
||
page_id=page_id,
|
||
sub_page_id=sub_page_id,
|
||
pos_id=pos_id,
|
||
material_time=30000,
|
||
request_scene_type=1,
|
||
session_id=f"dryRun-{kind}-{int(time.time() * 1000)}",
|
||
)
|
||
|
||
|
||
def build_ad_report_biz(material: AdMaterial, cookie_dict: dict[str, str]) -> dict[str, Any]:
|
||
if material.neo_params:
|
||
neo_info = {
|
||
"llsid": safe_int(material.llsid),
|
||
"feedId": material.feed_id,
|
||
"creativeId": safe_int(material.creative_id),
|
||
"taskType": 1,
|
||
"extInfo": material.neo_params,
|
||
"requestSceneType": material.request_scene_type or 1,
|
||
"watchStage": 0,
|
||
"watchExpId": "",
|
||
"adExtInfo": material.ad_ext_info,
|
||
"watchAdTime": 0,
|
||
"materialTime": 0,
|
||
"inflow": False,
|
||
}
|
||
if material.callback_param:
|
||
neo_info["callbackParam"] = material.callback_param
|
||
return {
|
||
"pageId": material.page_id,
|
||
"subPageId": material.sub_page_id,
|
||
"posId": material.pos_id,
|
||
"businessId": material.business_id,
|
||
"mediaScene": "live",
|
||
"extParams": material.neo_ext_params,
|
||
"startTime": 0,
|
||
"endTime": 0,
|
||
"neoInfos": [neo_info],
|
||
"reportType": 0,
|
||
"sessionId": "",
|
||
}
|
||
|
||
end_time = int(time.time() * 1000)
|
||
watch_time = max(material.material_time, 5000)
|
||
start_time = end_time - watch_time
|
||
task_type = 2 if material.business_id == 606 else 1
|
||
request_scene_type = 7 if material.business_id == 606 else (material.request_scene_type or 1)
|
||
user_id = cookie_dict.get("userId") or cookie_dict.get("ud") or ""
|
||
session_id = material.session_id or (f"adNeo-{user_id}-{material.sub_page_id}-{end_time}" if material.business_id == 672 else "")
|
||
neo_info = {
|
||
"creativeId": safe_int(material.creative_id),
|
||
"extInfo": "",
|
||
"llsid": safe_int(material.llsid),
|
||
"materialTime": material.material_time,
|
||
"watchAdTime": watch_time,
|
||
"requestSceneType": request_scene_type,
|
||
"taskType": task_type,
|
||
"watchExpId": "",
|
||
"watchStage": 0,
|
||
}
|
||
if material.ad_ext_info:
|
||
neo_info["adExtInfo"] = material.ad_ext_info
|
||
if material.callback_param:
|
||
neo_info["callbackParam"] = material.callback_param
|
||
return {
|
||
"businessId": material.business_id,
|
||
"endTime": end_time,
|
||
"extParams": "",
|
||
"mediaScene": "video",
|
||
"neoInfos": [neo_info],
|
||
"pageId": material.page_id,
|
||
"posId": material.pos_id,
|
||
"reportType": 0,
|
||
"sessionId": session_id,
|
||
"startTime": start_time,
|
||
"subPageId": material.sub_page_id,
|
||
}
|
||
|
||
|
||
class KsNebulaClient:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
full_cookie: str,
|
||
client_salt: str,
|
||
cookie_dict: dict[str, str],
|
||
timeout: int,
|
||
dry_run: bool,
|
||
memory_device: bool = False,
|
||
device_online: bool = True,
|
||
device_seed: int | None = None,
|
||
device_low_coin_threshold: int = 0,
|
||
device_max_switches: int = 0,
|
||
rotate_h5_device: bool = False,
|
||
strict_device_online: bool = False,
|
||
) -> None:
|
||
self.full_cookie = full_cookie
|
||
self.client_salt = client_salt
|
||
self.cookie_dict = cookie_dict
|
||
self.h5_cookie_dict = normalize_h5_cookie_wire_values(cookie_dict)
|
||
self.h5_full_cookie = cookie_to_string(self.h5_cookie_dict)
|
||
self.timeout = timeout
|
||
self.dry_run = dry_run
|
||
self.ad_materials: dict[str, AdMaterial] = {}
|
||
self.ad_fetch_neo_params: dict[str, str] = {}
|
||
self.ad_fetch_scenes: dict[str, tuple[int, int, int]] = {}
|
||
self.ad_fetch_business: dict[str, int] = {}
|
||
self.ad_task_countdown_ms: dict[str, int] = {}
|
||
self.ad_task_countdown_toast: dict[str, str] = {}
|
||
self.memory_device = memory_device
|
||
self.device_online = device_online
|
||
self.device_generator = DeviceProfileGenerator(seed=device_seed)
|
||
self.device_low_coin_threshold = device_low_coin_threshold
|
||
self.device_max_switches = device_max_switches
|
||
self.rotate_h5_device = rotate_h5_device
|
||
self.strict_device_online = strict_device_online
|
||
self.device_switch_count = 0
|
||
self.device_profile: DeviceProfile | None = None
|
||
self.h5_sig3_counter = ((int(time.time() * 1000) >> 3) & 0xFF) or 160
|
||
self.h5_sig3_mode = os.environ.get("KS_H5_SIG3_MODE", "pure").strip().lower()
|
||
self.h5_sig3_runner = Path(os.environ.get("KS_H5_SIG3_RUNNER", "core/h5_vendor_encode.mjs"))
|
||
self.h5_sig3_server = Path(os.environ.get("KS_H5_SIG3_SERVER", "core/h5_vendor_encode_server.mjs"))
|
||
self.h5_sig3_node = os.environ.get("NODE_BIN") or None
|
||
self.h5_encoder: H5JsBridgeEncoder | None = None
|
||
self._h5_sig3_warned = False
|
||
self.h5_kww_mode = os.environ.get("KS_H5_KWW_MODE", "pure").strip().lower()
|
||
self.h5_kww_server = Path(os.environ.get("KS_H5_KWW_SERVER", "core/h5_kww_server.mjs"))
|
||
self.h5_kww_node = os.environ.get("NODE_BIN") or None
|
||
self.h5_kww_generator: H5KwwGenerator | PureH5KwwGenerator | None = None
|
||
self._h5_kww_warned = False
|
||
self.sign_in_event_tracking = SIGN_IN_EVENT_TRACKING
|
||
self.treasure_open_event_tracking = _compact_json(TREASURE_OPEN_EVENT_TRACKING)
|
||
if self.memory_device:
|
||
self.register_memory_device("startup")
|
||
|
||
@classmethod
|
||
def from_env(
|
||
cls,
|
||
*,
|
||
timeout: int,
|
||
dry_run: bool,
|
||
device_profile_path: str | Path | None = None,
|
||
memory_device: bool = False,
|
||
device_online: bool = True,
|
||
device_seed: int | None = None,
|
||
device_low_coin_threshold: int = 0,
|
||
device_max_switches: int = 0,
|
||
rotate_h5_device: bool = False,
|
||
strict_device_online: bool = False,
|
||
) -> "KsNebulaClient":
|
||
full_cookie, client_salt, cookie_dict = cookie_from_env(device_profile_path=device_profile_path)
|
||
return cls(
|
||
full_cookie=full_cookie,
|
||
client_salt=client_salt,
|
||
cookie_dict=cookie_dict,
|
||
timeout=timeout,
|
||
dry_run=dry_run,
|
||
memory_device=memory_device,
|
||
device_online=device_online,
|
||
device_seed=device_seed,
|
||
device_low_coin_threshold=device_low_coin_threshold,
|
||
device_max_switches=device_max_switches,
|
||
rotate_h5_device=rotate_h5_device,
|
||
strict_device_online=strict_device_online,
|
||
)
|
||
|
||
def _apply_device_profile(self, profile: DeviceProfile) -> None:
|
||
self.cookie_dict = apply_device_profile_to_cookie(self.cookie_dict, profile)
|
||
self.full_cookie = cookie_to_string(self.cookie_dict)
|
||
if self.rotate_h5_device:
|
||
self.h5_cookie_dict = normalize_h5_cookie_wire_values(apply_device_profile_to_cookie(self.h5_cookie_dict, profile))
|
||
self.h5_full_cookie = cookie_to_string(self.h5_cookie_dict)
|
||
|
||
def register_memory_device(self, reason: str, *, hard_fail: bool = True) -> DeviceProfile | None:
|
||
profile = self.device_generator.new_profile()
|
||
local_profile = DeviceProfile.from_dict(profile.to_dict())
|
||
online_accepted = False
|
||
if self.device_online and not self.dry_run:
|
||
try:
|
||
result = run_online_bootstrap(profile, timeout=self.timeout)
|
||
identity = result.get("identity", {}) if isinstance(result, dict) else {}
|
||
if not identity.get("egid"):
|
||
raise RuntimeError("DFP 注册未返回 EGID")
|
||
online_accepted = True
|
||
except Exception as exc:
|
||
if self.strict_device_online:
|
||
if hard_fail:
|
||
raise SystemExit(
|
||
f"[设备] DFP 在线注册失败,拒绝 fallback 假 EGID reason={reason} error={exc}"
|
||
"(如需离线本地画像,请加 --no-device-online)"
|
||
)
|
||
print(f"[设备] DFP 在线注册失败,保留当前设备继续 reason={reason} error={exc}")
|
||
return self.device_profile
|
||
profile = local_profile
|
||
print(f"[设备] DFP 注册失败,继续使用本地画像 reason={reason} error={exc}")
|
||
self._apply_device_profile(profile)
|
||
self.device_profile = profile
|
||
mode = "服务端设备" if online_accepted else "内存设备"
|
||
print(f"[设备] 使用{mode} reason={reason} did={profile.did} egid={profile.egid or '-'}")
|
||
return profile
|
||
|
||
def maybe_rotate_device_for_record(self, record: ResponseRecord) -> bool:
|
||
if not self.memory_device:
|
||
return False
|
||
if self.device_low_coin_threshold <= 0:
|
||
return False
|
||
if self.device_max_switches <= 0 or self.device_switch_count >= self.device_max_switches:
|
||
return False
|
||
if not record.ok:
|
||
return False
|
||
reason = "low_coin"
|
||
if record.label.endswith("_ad_report"):
|
||
amount = extract_neo_amount(record.response_json)
|
||
elif record.label.endswith("_ad_skip") and isinstance(record.response_json, dict):
|
||
if record.response_json.get("reason") != "low_expected_reward":
|
||
return False
|
||
try:
|
||
amount = int(record.response_json.get("expectedReward") or 0)
|
||
except (TypeError, ValueError):
|
||
amount = None
|
||
reason = "low_expected_reward"
|
||
else:
|
||
return False
|
||
if amount is None or amount > self.device_low_coin_threshold:
|
||
return False
|
||
self.device_switch_count += 1
|
||
self.register_memory_device(f"{reason}:{amount}", hard_fail=False)
|
||
return True
|
||
|
||
def _cookie_header(self) -> str:
|
||
return self.full_cookie
|
||
|
||
def _h5_cookie_header(self) -> str:
|
||
return self.h5_full_cookie
|
||
|
||
def _ensure_h5_kws_cookie_fields(self, *, url: str = "") -> None:
|
||
"""补齐 WebWeapon/KWS 票据字段。"""
|
||
|
||
product_name = self.h5_cookie_dict.get("kwpsecproductname") or KWS_PRODUCT_NAME
|
||
self.h5_cookie_dict["kwpsecproductname"] = product_name
|
||
if not (self.h5_cookie_dict.get("kwssectoken") and self.h5_cookie_dict.get("kwscode")):
|
||
# kwssectoken 与 kwscode 是成对生成的;任一缺失时整对重算,
|
||
# 避免旧 token 搭配新 code 造成服务端校验上下文不一致。
|
||
kws_mode = os.environ.get("KS_H5_KWS_MODE", "").strip().lower()
|
||
server_sec_token = (
|
||
os.environ.get("KS_H5_KWS_SEC_TOKEN")
|
||
or os.environ.get("KWS_SEC_TOKEN")
|
||
or ""
|
||
).strip()
|
||
if server_sec_token and kws_mode in {"script", "sign", "server", "config"}:
|
||
self.h5_cookie_dict.update(
|
||
build_h5_kws_script_ticket(
|
||
sec_token=server_sec_token,
|
||
product_name=product_name,
|
||
)
|
||
)
|
||
else:
|
||
ticket = build_h5_kws_default_ticket(
|
||
url=url,
|
||
did=self.h5_cookie_dict.get("did") or self.cookie_dict.get("did", ""),
|
||
product_name=product_name,
|
||
)
|
||
self.h5_cookie_dict["kwssectoken"] = ticket.kwssectoken
|
||
self.h5_cookie_dict["kwscode"] = ticket.kwscode
|
||
if not self.h5_cookie_dict.get("kwfv1"):
|
||
self.h5_cookie_dict["kwfv1"] = ticket.kwfv1
|
||
self.h5_full_cookie = cookie_to_string(self.h5_cookie_dict)
|
||
|
||
def _api_cookie_header(self) -> str:
|
||
api_st = self.cookie_dict.get("kuaishou.api_st", "")
|
||
return f"kuaishou.api_st={api_st}" if api_st else self.full_cookie
|
||
|
||
def _device_oaid(self) -> str:
|
||
return (
|
||
os.environ.get("KS_OAID")
|
||
or self.cookie_dict.get("oaid", "")
|
||
or (self.device_profile.runtime_hints.oaid if self.device_profile else "")
|
||
or DEFAULT_OAID
|
||
)
|
||
|
||
def _h5_device_oaid(self) -> str:
|
||
return os.environ.get("KS_H5_OAID") or self.h5_cookie_dict.get("oaid", "") or DEFAULT_OAID
|
||
|
||
def _task_list_params(self) -> dict[str, str]:
|
||
params = dict(TASK_LIST_PARAMS)
|
||
params["oaid"] = self._h5_device_oaid()
|
||
return params
|
||
|
||
def _treasure_open_body(self) -> str:
|
||
return treasure_open_body_for_oaid(self._h5_device_oaid(), self.treasure_open_event_tracking)
|
||
|
||
def _api_params(self) -> dict[str, str]:
|
||
params = dict(API_BASE_PARAMS)
|
||
cookie_map = {
|
||
"ud": self.cookie_dict.get("ud") or self.cookie_dict.get("userId") or "",
|
||
"did": self.cookie_dict.get("did", ""),
|
||
"did_tag": self.cookie_dict.get("did_tag", ""),
|
||
"cdid_tag": self.cookie_dict.get("cdid_tag", ""),
|
||
"egid": self.cookie_dict.get("egid", ""),
|
||
"oDid": self.cookie_dict.get("oDid", ""),
|
||
"rdid": self.cookie_dict.get("rdid", ""),
|
||
"mod": self.cookie_dict.get("mod", params.get("mod", "")),
|
||
"deviceName": self.cookie_dict.get("deviceName", params.get("deviceName", "")),
|
||
"appver": self.cookie_dict.get("appver", params.get("appver", "")),
|
||
"kpn": self.cookie_dict.get("kpn", "NEBULA"),
|
||
"kpf": self.cookie_dict.get("kpf", "ANDROID_PHONE"),
|
||
"c": self.cookie_dict.get("c", params.get("c", "")),
|
||
"oc": self.cookie_dict.get("oc", params.get("oc", "")),
|
||
"newOc": self.cookie_dict.get("newOc", params.get("newOc", "")),
|
||
"sys": self.cookie_dict.get("sys", params.get("sys", "")),
|
||
"net": self.cookie_dict.get("net", params.get("net", "")),
|
||
"isp": self.cookie_dict.get("isp", params.get("isp", "")),
|
||
"ver": self.cookie_dict.get("ver", params.get("ver", "")),
|
||
"did_gt": self.cookie_dict.get("did_gt", params.get("did_gt", "")),
|
||
"boardPlatform": self.cookie_dict.get("boardPlatform", params.get("boardPlatform", "")),
|
||
"socName": self.cookie_dict.get("socName", params.get("socName", "")),
|
||
"max_memory": self.cookie_dict.get("max_memory", params.get("max_memory", "")),
|
||
"deviceBit": self.cookie_dict.get("deviceBit", params.get("deviceBit", "")),
|
||
"sw": self.cookie_dict.get("sw", params.get("sw", "")),
|
||
"sh": self.cookie_dict.get("sh", params.get("sh", "")),
|
||
"sbh": self.cookie_dict.get("sbh", params.get("sbh", "")),
|
||
"ddpi": self.cookie_dict.get("ddpi", params.get("ddpi", "")),
|
||
"totalMemory": self.cookie_dict.get("totalMemory", params.get("totalMemory", "")),
|
||
"abi": self.cookie_dict.get("abi", params.get("abi", "")),
|
||
"device_abi": self.cookie_dict.get("device_abi", params.get("device_abi", "")),
|
||
"cold_launch_time_ms": self.cookie_dict.get("cold_launch_time_ms", params.get("cold_launch_time_ms", "")),
|
||
"sid": self.cookie_dict.get("sid", params.get("sid", "")),
|
||
"country_code": self.cookie_dict.get("country_code", params.get("country_code", "")),
|
||
"androidApiLevel": self.cookie_dict.get("androidApiLevel", params.get("androidApiLevel", "")),
|
||
}
|
||
for key, value in cookie_map.items():
|
||
params[key] = value
|
||
return params
|
||
|
||
def _api_body_common(self) -> list[tuple[str, str]]:
|
||
api_st = self.cookie_dict.get("kuaishou.api_st", "")
|
||
token = self.cookie_dict.get("token") or api_st
|
||
pairs = list(API_COMMON_BODY.items())
|
||
pairs.append(("kuaishou.api_st", api_st))
|
||
pairs.append(("token", token))
|
||
return pairs
|
||
|
||
def _update_ad_fetch_contexts(self, data: Any) -> None:
|
||
if not isinstance(data, dict):
|
||
return
|
||
root = data.get("data") if isinstance(data.get("data"), dict) else data
|
||
if not isinstance(root, dict):
|
||
return
|
||
tasks: list[Any] = []
|
||
for key in ("dailyTasks", "flagTaskList", "finishedTaskList"):
|
||
value = root.get(key)
|
||
if isinstance(value, list):
|
||
tasks.extend(value)
|
||
|
||
kind_by_business = {605: "sign_in", 606: "treasure", 672: "normal"}
|
||
for task in tasks:
|
||
if not isinstance(task, dict):
|
||
continue
|
||
link_url = str(task.get("linkUrl") or "")
|
||
decoded = decode_neo_params(link_url)
|
||
business_id = safe_int(decoded.get("businessId"), 0)
|
||
kind = kind_by_business.get(business_id)
|
||
if not kind:
|
||
continue
|
||
fallback_scene = AD_FETCH_FALLBACK_SCENE[kind]
|
||
self.ad_fetch_neo_params[kind] = link_url
|
||
self.ad_fetch_scenes[kind] = (
|
||
safe_int(decoded.get("pageId"), fallback_scene[0]),
|
||
safe_int(decoded.get("subPageId"), fallback_scene[1]),
|
||
safe_int(decoded.get("posId"), fallback_scene[2]),
|
||
)
|
||
self.ad_fetch_business[kind] = business_id
|
||
ext_param = task.get("extParam")
|
||
countdown_ms = 0
|
||
toast = ""
|
||
if isinstance(ext_param, dict):
|
||
countdown_ms = safe_int(ext_param.get("descCountDown"), 0)
|
||
toast = str(ext_param.get("buttonCountDownToast") or "")
|
||
self.ad_task_countdown_ms[kind] = max(0, countdown_ms)
|
||
self.ad_task_countdown_toast[kind] = toast
|
||
|
||
def _update_h5_state_contexts(self, data: Any) -> None:
|
||
if not isinstance(data, dict):
|
||
return
|
||
root = data.get("data") if isinstance(data.get("data"), dict) else data
|
||
if not isinstance(root, dict):
|
||
return
|
||
|
||
treasure_event = event_tracking_with_source(root.get("eventTrackingLogInfo"))
|
||
if treasure_event:
|
||
self.treasure_open_event_tracking = treasure_event
|
||
|
||
tasks: list[Any] = []
|
||
for key in ("dailyTasks", "flagTaskList", "finishedTaskList"):
|
||
value = root.get(key)
|
||
if isinstance(value, list):
|
||
tasks.extend(value)
|
||
|
||
for task in tasks:
|
||
if not isinstance(task, dict):
|
||
continue
|
||
link_url = str(task.get("linkUrl") or "")
|
||
event = task.get("eventTrackingLogInfo")
|
||
event_task_id = safe_int(task.get("id") or task.get("taskId"), 0)
|
||
if isinstance(event, dict):
|
||
event_task_id = safe_int(event.get("eventTrackingTaskId"), event_task_id)
|
||
is_sign_in_report = (
|
||
"/rest/wd/encourage/unionTask/signIn/report" in link_url
|
||
or event_task_id == 20022
|
||
)
|
||
if not is_sign_in_report:
|
||
continue
|
||
sign_event = ""
|
||
if isinstance(event, dict) and event.get("resourceId") == "externalFeed_externalSignPopup":
|
||
sign_event = event_tracking_with_source(event)
|
||
if not sign_event:
|
||
ext_param = task.get("extParam")
|
||
if isinstance(ext_param, dict):
|
||
ext_event = ext_param.get("eventTrackingLogInfo")
|
||
if isinstance(ext_event, dict) and ext_event.get("resourceId") == "externalFeed_externalSignPopup":
|
||
sign_event = event_tracking_with_source(ext_event)
|
||
if sign_event:
|
||
self.sign_in_event_tracking = sign_event
|
||
return
|
||
|
||
def _request(self, label: str, title: str, request_id: int, method: str, url: str, headers: dict[str, str], body: bytes | None = None) -> ResponseRecord:
|
||
headers = dict(headers)
|
||
headers.setdefault("Accept-Encoding", "gzip")
|
||
if "Cookie" not in headers:
|
||
headers["Cookie"] = self._cookie_header()
|
||
if self.dry_run:
|
||
return ResponseRecord(label, title, request_id, None, True, url, {
|
||
"dryRun": True,
|
||
"method": method,
|
||
"host": urllib.parse.urlsplit(url).netloc,
|
||
"path": urllib.parse.urlsplit(url).path,
|
||
"bodySize": len(body or b""),
|
||
}, "")
|
||
attempts = max(1, int(os.environ.get("KS_HTTP_RETRIES", "3")))
|
||
backoff = max(0.0, float(os.environ.get("KS_RATE_LIMIT_BACKOFF", "8")))
|
||
for attempt in range(attempts):
|
||
attempt_headers = dict(headers)
|
||
if urllib.parse.urlsplit(url).netloc == API_HOST:
|
||
attempt_headers["X-REQUESTID"] = generate_request_id()
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=body if method not in {"GET", "HEAD"} else None,
|
||
headers=attempt_headers,
|
||
method=method,
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||
text = decode_response(resp.read(), resp.headers.get("Content-Encoding", ""))
|
||
data = parse_json(text)
|
||
record = ResponseRecord(label, title, request_id, resp.status, is_business_ok(resp.status, data), url, data, text)
|
||
except urllib.error.HTTPError as exc:
|
||
text = decode_response(exc.read(), exc.headers.get("Content-Encoding", ""))
|
||
data = parse_json(text)
|
||
record = ResponseRecord(label, title, request_id, exc.code, False, url, data, text, f"http_status_{exc.code}")
|
||
except Exception as exc:
|
||
record = ResponseRecord(label, title, request_id, None, False, url, None, "", str(exc))
|
||
if attempt < attempts - 1 and is_rate_limited(record.status, record.response_json):
|
||
delay = backoff * (attempt + 1)
|
||
if delay > 0:
|
||
print(f"{title} 触发限速,等待 {delay:.1f}s 后重试 ...")
|
||
time.sleep(delay)
|
||
continue
|
||
return record
|
||
return record
|
||
|
||
def _h5_get(self, label: str, title: str, request_id: int, path: str, params: dict[str, str], header_key: str) -> ResponseRecord:
|
||
url = f"{NEBULA_BASE}{path}?{urllib.parse.urlencode(params)}"
|
||
return self._request(label, title, request_id, "GET", url, self._h5_headers(header_key, url=url, method="GET"))
|
||
|
||
def _h5_headers(self, header_key: str, *, url: str = "", method: str = "GET", body: str = "") -> dict[str, str]:
|
||
headers = dict(H5_HEADERS[header_key])
|
||
headers["Cookie"] = self._h5_cookie_header()
|
||
if "kww" in headers:
|
||
self._ensure_h5_kws_cookie_fields(url=url or headers.get("Referer", ""))
|
||
headers["Cookie"] = self._h5_cookie_header()
|
||
if "kww" in headers and self.h5_kww_mode not in {"0", "off", "static", "disabled"}:
|
||
try:
|
||
# KWF WebView 会同时维护 header.kww 与 localStorage/cookie
|
||
# 里的 kwfv1/kwfcv1。这里让纯算链路保持同样状态,避免
|
||
# ksck 只带基础登录态时 H5 Cookie 缺少动态风控字段。
|
||
if self.h5_kww_generator is None:
|
||
if self.h5_kww_mode in {"pure", "python", "py"}:
|
||
self.h5_kww_generator = PureH5KwwGenerator()
|
||
else:
|
||
self.h5_kww_generator = H5KwwGenerator(
|
||
server=self.h5_kww_server,
|
||
node_bin=self.h5_kww_node,
|
||
timeout=self.timeout,
|
||
)
|
||
result = self.h5_kww_generator.get(
|
||
url=url,
|
||
method=method,
|
||
headers={key: value for key, value in headers.items() if key.lower() != "cookie"},
|
||
body=body,
|
||
cookie=self._h5_cookie_header(),
|
||
)
|
||
headers["kww"] = result.kww
|
||
if result.kwfv1:
|
||
self.h5_cookie_dict["kwfv1"] = result.kwfv1
|
||
if result.kwfcv1:
|
||
self.h5_cookie_dict["kwfcv1"] = result.kwfcv1
|
||
self.h5_full_cookie = cookie_to_string(self.h5_cookie_dict)
|
||
headers["Cookie"] = self._h5_cookie_header()
|
||
except Exception as exc:
|
||
strict = self.h5_kww_mode in {"strict", "dynamic_strict"} or os.environ.get("KS_H5_KWW_STRICT") == "1"
|
||
if strict:
|
||
raise
|
||
if self.h5_kww_generator is not None:
|
||
self.h5_kww_generator.close()
|
||
self.h5_kww_generator = None
|
||
if not self._h5_kww_warned:
|
||
print(f"[H5] 动态 kww 不可用,回退静态 header: {exc}")
|
||
self._h5_kww_warned = True
|
||
return headers
|
||
|
||
def _next_h5_sig3(
|
||
self,
|
||
kind: str,
|
||
query: dict[str, Any] | None = None,
|
||
body: str | dict[str, Any] | list[Any] | None = None,
|
||
method: str = "GET",
|
||
request_type: str = "json",
|
||
) -> str:
|
||
if self.h5_sig3_mode in {"jsbridge", "node", "vm", "jsbridge_strict", "strict"}:
|
||
try:
|
||
if self.h5_encoder is None:
|
||
self.h5_encoder = H5JsBridgeEncoder(
|
||
server=self.h5_sig3_server,
|
||
node_bin=self.h5_sig3_node,
|
||
timeout=self.timeout,
|
||
)
|
||
result = h5_sig3_for_request(
|
||
self.h5_cookie_dict,
|
||
query=query,
|
||
body=body,
|
||
method=method,
|
||
request_type=request_type,
|
||
runner=self.h5_sig3_runner,
|
||
node_bin=self.h5_sig3_node,
|
||
timeout=self.timeout,
|
||
encoder=self.h5_encoder,
|
||
)
|
||
return result.sig3
|
||
except Exception as exc:
|
||
if self.h5_encoder is not None:
|
||
self.h5_encoder.close()
|
||
self.h5_encoder = None
|
||
strict = self.h5_sig3_mode in {"jsbridge_strict", "strict"} or os.environ.get("KS_H5_SIG3_STRICT") == "1"
|
||
if strict:
|
||
raise
|
||
if not self._h5_sig3_warned:
|
||
print(f"[H5] JSBridge sig3 不可用,回退字段生成: {exc}")
|
||
self._h5_sig3_warned = True
|
||
|
||
if self.h5_sig3_mode in {"fields", "static"}:
|
||
crc32_value = H5_SIG3_CRC32[kind]
|
||
else:
|
||
sign_input = build_h5_sign_input(
|
||
self.h5_cookie_dict,
|
||
query=query,
|
||
body=body,
|
||
method=method,
|
||
request_type=request_type,
|
||
)
|
||
crc32_value = h5_sig3_crc32_from_sign_input(sign_input)
|
||
if self.h5_sig3_counter <= 1:
|
||
self.h5_sig3_counter = 160
|
||
self.h5_sig3_counter -= 1
|
||
elapsed_ms = 0x4B4B0000 | (int(time.monotonic() * 1000) & 0xFFFF)
|
||
state_seed = (
|
||
f"{kind}:{self.h5_sig3_counter}:{elapsed_ms}:{time.time_ns()}:"
|
||
f"{self.h5_cookie_dict.get('userId', '')}:{self.h5_cookie_dict.get('did', '')}"
|
||
).encode("utf-8")
|
||
state_value = 0x10000 | int.from_bytes(hashlib.sha256(state_seed).digest()[:2], "little")
|
||
return h5_sig3_from_fields(
|
||
crc32_value=crc32_value,
|
||
counter=self.h5_sig3_counter,
|
||
elapsed_ms=elapsed_ms,
|
||
state_value=state_value,
|
||
)
|
||
|
||
def query_balance(self) -> ResponseRecord:
|
||
return self._h5_get("login_check", "登录态/余额检查", 1286, "/rest/n/nebula/activity/earn/overview/basicInfo", {"source": SOURCE}, "login")
|
||
|
||
def final_balance(self) -> ResponseRecord:
|
||
return self._h5_get("final_balance", "最终余额", 2709, "/rest/n/nebula/activity/earn/overview/basicInfo", {"source": SOURCE}, "login")
|
||
|
||
def task_list(self) -> ResponseRecord:
|
||
record = self._h5_get("task_list", "任务列表", 1383, "/rest/n/nebula/activity/earn/overview/tasks", self._task_list_params(), "task_list")
|
||
self._update_ad_fetch_contexts(record.response_json)
|
||
self._update_h5_state_contexts(record.response_json)
|
||
return record
|
||
|
||
def sign_in_resource(self) -> ResponseRecord:
|
||
return self._h5_get(
|
||
"sign_in_resource",
|
||
"签到资源",
|
||
1476,
|
||
"/rest/wd/encourage/unionTask/signIn/resource",
|
||
{"__NS_sig3": self._next_h5_sig3("sign_in_resource"), "sigCatVer": "1"},
|
||
"sign_in_resource",
|
||
)
|
||
|
||
def sign_in(self) -> ResponseRecord:
|
||
if os.environ.get("KS_SIGNED_STATE_TASKS", "0") == "1":
|
||
return self.sign_in_api()
|
||
query = {"eventTrackingLogInfo": self.sign_in_event_tracking}
|
||
record = self._h5_get(
|
||
"sign_in_report",
|
||
"签到上报",
|
||
1633,
|
||
"/rest/wd/encourage/unionTask/signIn/report",
|
||
{"__NS_sig3": self._next_h5_sig3("sign_in_report", query=query), "sigCatVer": "1", **query},
|
||
"sign_in_report",
|
||
)
|
||
return record
|
||
|
||
def sign_in_api(self) -> ResponseRecord:
|
||
path = "/rest/n/encourage/unionTask/signIn/report"
|
||
body_pairs = [("eventTrackingLogInfo", "")] + self._api_body_common()
|
||
url = signed_api_url(path, self._api_params(), body_pairs, self.client_salt, API_SIG3_SEED["sign_in_report"])
|
||
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
|
||
headers = dict(API_HEADERS)
|
||
headers["Cookie"] = self._api_cookie_header()
|
||
return self._request("sign_in_report", "签到上报(API)", 1633, "POST", url, headers, body)
|
||
|
||
def treasure_box_info(self) -> ResponseRecord:
|
||
query = {"source": SOURCE}
|
||
record = self._h5_get(
|
||
"treasure_info",
|
||
"宝箱信息",
|
||
1376,
|
||
"/rest/wd/encourage/unionTask/treasureBox/info",
|
||
{"__NS_sig3": self._next_h5_sig3("treasure_info", query=query), "sigCatVer": "1", **query},
|
||
"treasure_info",
|
||
)
|
||
self._update_h5_state_contexts(record.response_json)
|
||
return record
|
||
|
||
def open_treasure_box(self) -> ResponseRecord:
|
||
if os.environ.get("KS_SIGNED_STATE_TASKS", "0") == "1":
|
||
return self.open_treasure_box_api()
|
||
body_text = self._treasure_open_body()
|
||
sig3 = self._next_h5_sig3("treasure_open", body=body_text, method="POST", request_type="json")
|
||
url = f"{NEBULA_BASE}/rest/wd/encourage/unionTask/treasureBox/report?{urllib.parse.urlencode({"__NS_sig3": sig3, "sigCatVer": "1"})}"
|
||
body = body_text.encode("utf-8")
|
||
record = self._request(
|
||
"treasure_open",
|
||
"开宝箱",
|
||
2105,
|
||
"POST",
|
||
url,
|
||
self._h5_headers("treasure_open", url=url, method="POST", body=body_text),
|
||
body,
|
||
)
|
||
return record
|
||
|
||
def open_treasure_box_api(self) -> ResponseRecord:
|
||
path = "/rest/n/encourage/unionTask/treasureBox/report"
|
||
body_pairs = [("layoutPage", "NEBULA_ACTIVITY"), ("eventTrackingLogInfo", "")] + self._api_body_common()
|
||
url = signed_api_url(path, self._api_params(), body_pairs, self.client_salt, API_SIG3_SEED["treasure_report"])
|
||
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
|
||
headers = dict(API_HEADERS)
|
||
headers["Cookie"] = self._api_cookie_header()
|
||
return self._request("treasure_open", "开宝箱(API)", 2105, "POST", url, headers, body)
|
||
|
||
def fetch_ad(self, kind: str) -> ResponseRecord:
|
||
path = "/rest/e/reward/mixed/ad"
|
||
api_params = self._api_params()
|
||
scene = self.ad_fetch_scenes.get(kind, AD_FETCH_FALLBACK_SCENE[kind])
|
||
business_id = self.ad_fetch_business.get(kind, AD_FETCH_FALLBACK_BUSINESS[kind])
|
||
str_e = build_reward_str_e(
|
||
kind=kind,
|
||
cookie_dict=self.cookie_dict,
|
||
api_params=api_params,
|
||
oaid=self._device_oaid(),
|
||
scene=scene,
|
||
business_id=business_id,
|
||
neo_params=self.ad_fetch_neo_params.get(kind, ""),
|
||
now_ms=int(time.time() * 1000),
|
||
network_ip=os.environ.get("KS_AD_NETWORK_IP", "172.31.230.147"),
|
||
ipdx_ip=os.environ.get("KS_AD_IPDX_IP", "106.178.190.155"),
|
||
)
|
||
state = Kwsg10418State.from_digest(API_SIG3_SEED[f"{kind}_fetch"])
|
||
t1, t2 = enc_tables()
|
||
reward_body = build_reward_body(str_e, state, t1=t1, t2=t2)
|
||
body_pairs = [("encData", reward_body.enc_data), ("sign", reward_body.sign)] + self._api_body_common()
|
||
url = signed_api_url(path, api_params, body_pairs, self.client_salt, API_SIG3_SEED[f"{kind}_fetch"], state=state)
|
||
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
|
||
headers = dict(API_HEADERS)
|
||
headers["Cookie"] = self._api_cookie_header()
|
||
record = self._request(f"{kind}_ad_fetch", {"sign_in": "签到广告拉取", "treasure": "宝箱广告拉取", "normal": "看广告得金币拉取"}[kind], {"sign_in": 1688, "treasure": 2119, "normal": 2637}[kind], "POST", url, headers, body)
|
||
material = extract_ad_material(kind, record.response_json)
|
||
if material is not None:
|
||
self.ad_materials[kind] = material
|
||
return record
|
||
|
||
def report_ad(self, kind: str) -> ResponseRecord:
|
||
material = self.ad_materials.get(kind)
|
||
if material is None:
|
||
material = fallback_ad_material(kind) if self.dry_run else None
|
||
label = f"{kind}_ad_report"
|
||
title = {"sign_in": "签到广告上报", "treasure": "宝箱广告上报", "normal": "看广告得金币上报"}[kind]
|
||
request_id = {"sign_in": 2056, "treasure": 2418, "normal": 2700}[kind]
|
||
if material is None:
|
||
return ResponseRecord(label, title, request_id, None, False, "", None, "", "missing_ad_material")
|
||
biz = build_ad_report_biz(material, self.cookie_dict)
|
||
biz_str = json.dumps(biz, ensure_ascii=False, separators=(",", ":"))
|
||
mode = os.environ.get("KS_REPORT_BODY_MODE", "encData").lower()
|
||
first_pair = ("bizStr", biz_str) if mode == "bizstr" else ("encData", build_report_enc_data(biz_str.encode("utf-8")))
|
||
body_pairs = [first_pair] + self._api_body_common()
|
||
path = "/rest/r/ad/task/report"
|
||
url = signed_api_url(path, self._api_params(), body_pairs, self.client_salt, API_SIG3_SEED[f"{kind}_report"])
|
||
body = urllib.parse.urlencode(body_pairs).encode("utf-8")
|
||
headers = dict(API_HEADERS)
|
||
headers["Cookie"] = self._api_cookie_header()
|
||
return self._request(label, title, request_id, "POST", url, headers, body)
|
||
|
||
def _wait_then_report(self, kind: str, ad_wait: float) -> ResponseRecord:
|
||
if not self.dry_run:
|
||
wait_seconds = ad_wait
|
||
if ad_wait < 0:
|
||
material = self.ad_materials.get(kind)
|
||
wait_seconds = (material.material_time / 1000.0) if material else 0
|
||
if wait_seconds > 0:
|
||
print(f"等待广告模拟观看 {wait_seconds:.1f}s ...")
|
||
time.sleep(wait_seconds)
|
||
return self.report_ad(kind)
|
||
|
||
def _ad_skip_record(self, kind: str, reason: str, payload: dict[str, Any]) -> ResponseRecord:
|
||
title = {"sign_in": "签到广告跳过", "treasure": "宝箱广告跳过", "normal": "看广告得金币跳过"}[kind]
|
||
request_id = {"sign_in": 1688, "treasure": 2119, "normal": 2637}[kind]
|
||
data = {"skipped": True, "reason": reason}
|
||
data.update(payload)
|
||
return ResponseRecord(f"{kind}_ad_skip", title, request_id, None, True, "", data, "")
|
||
|
||
def run_ad_sequence(self, kind: str, ad_wait: float, skip_reward_leq: int = 0) -> list[ResponseRecord]:
|
||
countdown_ms = self.ad_task_countdown_ms.get(kind, 0)
|
||
if kind == "normal" and countdown_ms > 0:
|
||
return [
|
||
self._ad_skip_record(
|
||
kind,
|
||
"task_countdown",
|
||
{
|
||
"countdownMs": countdown_ms,
|
||
"toast": self.ad_task_countdown_toast.get(kind, ""),
|
||
},
|
||
)
|
||
]
|
||
fetch_record = self.fetch_ad(kind)
|
||
records = [fetch_record]
|
||
if not fetch_record.ok:
|
||
return records
|
||
if kind not in self.ad_materials and not self.dry_run:
|
||
return records
|
||
expected_reward = extract_fetch_expected_reward(fetch_record.response_json)
|
||
if skip_reward_leq > 0 and 0 < expected_reward <= skip_reward_leq:
|
||
records.append(self._ad_skip_record(kind, "low_expected_reward", {"expectedReward": expected_reward}))
|
||
return records
|
||
records.append(self._wait_then_report(kind, ad_wait))
|
||
return records
|
||
|
||
def run_all_once(
|
||
self,
|
||
*,
|
||
ad_wait: float,
|
||
out_dir: Path,
|
||
normal_ad_count: int = 1,
|
||
skip_ad_reward_leq: int = 0,
|
||
) -> int:
|
||
if normal_ad_count < 0:
|
||
raise SystemExit("--normal-ad-count must be >= 0")
|
||
if skip_ad_reward_leq < 0:
|
||
raise SystemExit("--skip-ad-reward-leq must be >= 0")
|
||
out_dir.mkdir(parents=True, exist_ok=True)
|
||
record_path = out_dir / f"request_replay_{time.strftime('%Y%m%d_%H%M%S')}.jsonl"
|
||
|
||
def run_ad_action(kind: str) -> list[ResponseRecord]:
|
||
if skip_ad_reward_leq:
|
||
return self.run_ad_sequence(kind, ad_wait, skip_reward_leq=skip_ad_reward_leq)
|
||
return self.run_ad_sequence(kind, ad_wait)
|
||
|
||
actions = [
|
||
self.query_balance,
|
||
self.task_list,
|
||
self.sign_in_resource,
|
||
self.sign_in,
|
||
lambda: run_ad_action("sign_in"),
|
||
self.treasure_box_info,
|
||
self.open_treasure_box,
|
||
lambda: run_ad_action("treasure"),
|
||
*[lambda: run_ad_action("normal") for _ in range(normal_ad_count)],
|
||
self.final_balance,
|
||
]
|
||
failures = 0
|
||
request_count = 0
|
||
for action in actions:
|
||
result = action()
|
||
records = result if isinstance(result, list) else [result]
|
||
for record in records:
|
||
request_count += 1
|
||
with record_path.open("a", encoding="utf-8") as fp:
|
||
fp.write(json.dumps(record.to_json(), ensure_ascii=False, separators=(",", ":")) + "\n")
|
||
status = "OK" if record.ok else "FAIL"
|
||
print(f"[{status}] {record.title} #{record.request_id} {record.summary()}")
|
||
if not record.ok:
|
||
failures += 1
|
||
self.maybe_rotate_device_for_record(record)
|
||
if self.h5_encoder is not None:
|
||
self.h5_encoder.close()
|
||
self.h5_encoder = None
|
||
if self.h5_kww_generator is not None:
|
||
self.h5_kww_generator.close()
|
||
self.h5_kww_generator = None
|
||
print("")
|
||
print("任务执行完成")
|
||
print(f"请求数量: {request_count}")
|
||
print(f"失败数量: {failures}")
|
||
print(f"记录文件: {record_path}")
|
||
return 1 if failures else 0
|
||
|
||
|
||
def run_all(
|
||
*,
|
||
dry_run: bool,
|
||
timeout: int,
|
||
ad_wait: float,
|
||
out_dir: Path,
|
||
device_profile_path: str | Path | None = None,
|
||
memory_device: bool = False,
|
||
device_online: bool = True,
|
||
device_seed: int | None = None,
|
||
device_low_coin_threshold: int = 0,
|
||
device_max_switches: int = 0,
|
||
normal_ad_count: int = 1,
|
||
skip_ad_reward_leq: int = 0,
|
||
rotate_h5_device: bool = False,
|
||
strict_device_online: bool = False,
|
||
) -> int:
|
||
return KsNebulaClient.from_env(
|
||
timeout=timeout,
|
||
dry_run=dry_run,
|
||
device_profile_path=device_profile_path,
|
||
memory_device=memory_device,
|
||
device_online=device_online,
|
||
device_seed=device_seed,
|
||
device_low_coin_threshold=device_low_coin_threshold,
|
||
device_max_switches=device_max_switches,
|
||
rotate_h5_device=rotate_h5_device,
|
||
strict_device_online=strict_device_online,
|
||
).run_all_once(
|
||
ad_wait=ad_wait,
|
||
out_dir=out_dir,
|
||
normal_ad_count=normal_ad_count,
|
||
skip_ad_reward_leq=skip_ad_reward_leq,
|
||
)
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description="快手极速版任务脚本(方法化接口,Cookie 从 .env 读取一次)")
|
||
parser.add_argument("--dry-run", action="store_true", default=os.environ.get("KS_DRY_RUN", "0") == "1")
|
||
parser.add_argument("--timeout", type=int, default=int(os.environ.get("KS_TIMEOUT", "30")))
|
||
parser.add_argument("--ad-wait", type=float, default=float(os.environ.get("KS_AD_WAIT_SECONDS", "-1")))
|
||
parser.add_argument("--out-dir", default=os.environ.get("KS_OUT_DIR", "out"))
|
||
parser.add_argument("--device-profile", default=os.environ.get("KS_DEVICE_PROFILE", ""))
|
||
parser.add_argument("--memory-device", action="store_true", default=os.environ.get("KS_MEMORY_DEVICE", "0") == "1")
|
||
parser.add_argument("--server-device", action="store_true", default=os.environ.get("KS_SERVER_DEVICE", "0") == "1")
|
||
parser.add_argument("--no-device-online", action="store_true", default=os.environ.get("KS_DEVICE_ONLINE", "1") == "0")
|
||
parser.add_argument("--device-seed", type=int, default=int(os.environ["KS_DEVICE_SEED"]) if os.environ.get("KS_DEVICE_SEED") else None)
|
||
parser.add_argument("--device-low-coin-threshold", type=int, default=int(os.environ.get("KS_DEVICE_LOW_COIN_THRESHOLD", "0")))
|
||
parser.add_argument("--device-max-switches", type=int, default=int(os.environ.get("KS_DEVICE_MAX_SWITCHES", "3")))
|
||
parser.add_argument("--normal-ad-count", type=int, default=int(os.environ.get("KS_NORMAL_AD_COUNT", "1")))
|
||
parser.add_argument("--skip-ad-reward-leq", type=int, default=int(os.environ.get("KS_SKIP_AD_REWARD_LEQ", "0")))
|
||
parser.add_argument("--rotate-h5-device", action="store_true", default=os.environ.get("KS_ROTATE_H5_DEVICE", "0") == "1")
|
||
parser.add_argument("--strict-device-online", action="store_true", default=os.environ.get("KS_STRICT_DEVICE_ONLINE", "0") == "1")
|
||
parser.add_argument("--print-algorithms", action="store_true", default=os.environ.get("KS_PRINT_ALGORITHMS", "0") == "1")
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
load_dotenv()
|
||
args = build_parser().parse_args()
|
||
if args.print_algorithms:
|
||
print_local_algorithm_stack()
|
||
device_options = args_to_device_options(args)
|
||
return run_all(
|
||
dry_run=args.dry_run,
|
||
timeout=args.timeout,
|
||
ad_wait=args.ad_wait,
|
||
out_dir=Path(args.out_dir),
|
||
device_profile_path=args.device_profile,
|
||
memory_device=device_options.memory_device,
|
||
device_online=device_options.device_online,
|
||
device_seed=args.device_seed,
|
||
device_low_coin_threshold=args.device_low_coin_threshold,
|
||
device_max_switches=args.device_max_switches,
|
||
normal_ad_count=args.normal_ad_count,
|
||
skip_ad_reward_leq=args.skip_ad_reward_leq,
|
||
rotate_h5_device=device_options.rotate_h5_device,
|
||
strict_device_online=device_options.strict_device_online,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|