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': 'WlTuzeTU6mGT9525bjJUVHBodAMFTXBjgjAw+tDz1mZdBpMBdHJJY0pKd7s5mx8+XMRRi7MCE3BSy1NNxI6bywhQueJl4YV8QDNsCONuMI7m59XX7rycrXz5ZPbObMBFLp23dqIF86Do53AbF3u4Dd2Vsk+1BzkhUIjucpA2gZ6g837f5Wz19c+9YTMglmmSZhLJT+kYSwpVpxB+tGv/njno3gVLDDdPD0Mu8upetDzDc8BiGuhkhY92NV/tVAA2lRjCH5K806zuAzx34YdTxIXMI4xEvi960krHjCaRmYEh7gUI3U9+6Cy259BZ8/oIYxK8QJSvKojDLVI9xQ3874SRbUpTftJnnK5ZBMHWzpxaQ3TcmxcjFSjYMiZimi1zdfbBe2MsBofnj+6W4tQase4/3OO+FtrKdcCQ8Ts7hDPzrEF7pV4BKvLB9NfLYUOE4fyBmeet4313zTjsnF6zau7zh4Eo0t9PkAnptNRJkBQOO8okmT38sOy4nQj6Pt4EaqsuFBuoE0f9p+ztWXGj0MBYEJh3zm8W6Qyc/dyaHwtBwV+cVa5pQkV54Y/7Ux6mUunHd4JPJROC+4tYGMGRh7u5ov+EeVaKrvos8CWYkrbYuhLORs2HTaNuodhQSJgHOj4lwOiVx+/LAKgaUBguKhjj9XcjYBZgkvzeS6NMl7j0hOAsXsZhVZDm3SjkgV7UB7DFpxcow0ix/1/23IZlfME8QkxW5k506UyNOBCopKqkl0NVL8f+v6HoEhmlreUWriZ+Mu53yZor50yGr3wUkZOnUI75sn56YPfgiIFEEapY6yuzKfx873GWbVwzq7fwf2ms8+X8pIFLRZaazv2Kqi/x56St+GJNzOTE3axmgGkyG/VQ7y01wso48x99p2V7vilKwdrjpRLibhhC3kdgxlWvRJ+RaIAZexYA9gzdj19vk/CjCVdlT4wC8a0dnBkzUknqysa2y1gZZ0WDXggncVtfDyL6HhtwIQrqB0UEDYEcF7B1RS7wKLSA7w+wve0xkgLzriakCnVIYVmYkbK4NKb0mjPI9oRmeQCTZfHisGiFwW0FHiTV12rCcuSQudOEGD492eyWTH0cykKGqBSEK7opsrRT9M4SRpkrlpqs1WyKbpzApMm8C8DS43FtEQNBAKAA7I189qv8T1sw0AJFpAdNqNzQt6yub+F7Ad34GlZ0rL2KCRNgujC3+dMHIQLlxeNZ0FAK49XvIrKjYXeydgnTfYGh7SizcSbANpUs7rlUnAWd1EVVuxlvYqvW2lGSQ8H6vNleJEv0FfwIK+uIXTh5rdCfJUOKANCFTKaVSlnfKn3lw0/WE59Z2EtxaQ2zpNZQtGVjGcCCTTyMkSCknkSlkvBj8qkIy/PkRicqFRhMIL3LVXSID29LujlGZkvusD0X90yJWV0E8Y8P0LpqfX6OPRz/ke7+ij1NrbMipQjrol7n1xQa2moInmE8KG+GuC1eYJgLC/Iq0UQdLEjK5dnVRtEm5N3947SuZIENOOqufU06citxQVlMfGnBXUqCzOhwgfwxF0E/J/7+9ZakqqsAGpSXG06f3x6zu7h+CT1RTtmf+SpbIw6MP5atkYd2F7CLwCMgJ9Nk56KBP49M0VhdBsnSxAdfiGCD9itFu0vKHE0UlDiyyy0XjKDqmdCKGg0L2H8St0ewRWTYLPWfYu6KJNKyA9sDFTigDLQpRYLqHBD6rlxEnnmFtMOHBj0UZjazdGJHZAzsTH23QNzC94IJN2IumQ+swbqsOmHebI+06C9pa2X/nIiq5x92D74Svkqn47U3bQ1Os4sPIAijiE78K+F2LYK6J7OnHaPWEIEYjO895EVHmd19KkLUuW/jctN/qcK3U+39MOA4M+/RpDNq85Pos5B6awk5SpBFBIYZ0SlHkfjB0+XG6cast9tLmYsIlaKVx4m7qlmRZC/EMOlY+sNjQZx/sNax8uG5dyxXWPVyoD4PkuMkwJT/25K5fvTT9JQRyfKH0wKhUv6BFZ/1syEEuFJVzAOAuVPZx1EsFAzorDfdYKbQv6A2OjRorwN3vjb3YHzlQeHFTtl7ssfNsEaf9Hvx6CxgQ/8zbZ+3aoeTeQuFjZ4W2Fh7GPi12xBdfJvnKYsM7Sk7OvbKdXphFmMOeKmLaFF39kh6pMXJHtQM5MBC5r79+x6pd/uEuBXrt+qf5WCFPPtGw98/Cn5t5JCOf3xG27CMSYTIrYjQVAbHSlpiG4ildUPX1Hm1yYwr0SHlCUHj+RBiTkIizzPTjuCq4hj+rhnN9H5R1EAmB8kP+p1GPSIW0WVkxYcuRlH3XhPFjpI9YPYDX8lKm7OOIMjewpEVYMO6MiGVItYkp5ubBqkQzXjS7Kr9p2D457mdiqGFhoWyRmcWBf4zgaNALddGqEwFlgbMa+FoY7Ue8YapnU+OUQG7V/CFNahQXi5Z41kiS3CJDodL6JxsOlAVDUejUH4eY7HavxT03hDXKcEyXTICjmSdRFL/qtHe/GT29QAHiNomHrIecuzlU6hhwErZWpWv', '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, "" 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] = "" 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())