| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- # -*- coding: utf-8 -*-
- # Author : Charley
- # Python : 3.12.10
- # Date : 2026/8/18 19:19
- """集物星球爬虫核心库:签名、脚本自登录拿 sessionId、通用请求(curl_cffi 过 TLS 风控)。
- 三大关键点:
- 1. 请求头签名 X_SIG_1 = base64(HMAC_SHA256(key, msg)),msg 拼接顺序见 _sign。
- 2. 必须用 curl_cffi(impersonate=chrome)发请求——服务端按 TLS/JA3 指纹识别客户端,
- python requests 的指纹会被判为非真机,接口静默返回 code:200 空壳(data.result=[]);
- curl_cffi 才能拿到真正的 code:1 数据。
- 3. sessionId 由客户端自造、regLogin 上报绑定,服务端沿用该值;请求头 X_SID、body.sessionId、
- 签名串里的 sessionId 三处必须同值。业务接口成功码是 code==1。
- """
- import sys
- import time
- import json
- import hmac
- import base64
- import random
- import hashlib
- from curl_cffi import requests as creq
- from loguru import logger
- from tenacity import retry, stop_after_attempt, wait_fixed
- BASE_URL = "https://api.jiwuplanet.com"
- API_SIGN_SECRET_KEY = "EahycFpDpewggO2rksvQwTlnhCbTHFx6" # 生产环境签名密钥(逆向所得)
- X_VERSION = "2.12.2" # App 版本号
- X_PLATFORM = "150" # 平台常量
- IMPERSONATE = "chrome" # curl_cffi TLS 指纹,过服务端风控的关键
- NO_PROXY = {"http": None, "https": None} # 显式直连,忽略系统 HTTP(S)_PROXY(127.0.0.1:7890)
- BIZ_OK_CODE = 1 # 业务接口成功码(登录/查询成功均为 1)
- PRICE_DIVISOR = 1000000 # 金额原始值 ÷ 此 = 元(实测 amount=99000000 → App 显示 ¥99.00)
- # 脚本自登录账号(无人值守续期用;账密为运行必需,如需可挪到 application.yml)
- LOGIN_ACCOUNT = {
- "appVersions": X_VERSION, "deviceNumber": "40b03f4bec96675f",
- "identificationNumber": "19521500850", "ip": "10.1.10.1", "language": "ZH",
- "loginPassword": "pass2022", "loginType": "ACCOUNT", "mobile": "19521500850",
- "mobileCode": "86", "platformInfo": "Google Pixel 5", "platformType": "ANDROID_USER",
- "systemBusinessType": 6, "terminalVersions": "11",
- }
- SESSION_TTL = 1800 # sessionId 复用时长(秒),超时重新登录
- _session = {"sid": None, "ts": 0.0} # 登录态 sessionId 内存缓存
- _anon = {"sid": None} # 免登录接口用的匿名 sid(随机自造、不经 regLogin,进程内复用;被风控则轮换)
- def after_log(retry_state):
- """tenacity 重试回调,记录每次尝试的结果。
- Args:
- retry_state: tenacity 传入的 RetryCallState,args[0] 约定为 log。
- """
- log = retry_state.args[0] if retry_state.args else logger
- if retry_state.outcome.failed:
- log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
- else:
- log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
- def canonical_body(body_obj: dict) -> str:
- """把请求体规范化为被签名/被发送的紧凑串(等价 App 端 Gson 处理)。
- Args:
- body_obj (dict): 原始请求体字典。
- Returns:
- str: 递归按 key 排序、紧凑、非 ASCII 不转义的 JSON 串;既用于签名也用于实际发送。
- """
- return json.dumps(body_obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
- def to_yuan(raw) -> float | None:
- """把接口原始金额换算成元(÷PRICE_DIVISOR,保留 2 位),供入库前统一转换。
- Args:
- raw (int | float | None): 接口原始金额。
- Returns:
- float | None: 元(保留 2 位);raw 为空返回 None。
- """
- return round(raw / PRICE_DIVISOR, 2) if isinstance(raw, (int, float)) else None
- def gen_session_id() -> str:
- """自造一个 32 位小写 hex 会话标识(服务端沿用此值作会话)。
- Returns:
- str: 32 位十六进制字符串。
- """
- return hashlib.md5(f"{time.time()}-{random.random()}".encode()).hexdigest()
- def _sign(path: str, body_str: str, session_id: str) -> dict:
- """按签名算法生成带 X_SIG_1 的完整请求头。
- Args:
- path (str): URL 路径(encodedPath,不含 host/query)。
- body_str (str): 规范化后的请求体字符串。
- session_id (str): 会话标识,与 body 内 sessionId 一致。
- Returns:
- dict: 含 6 个 X_* 头及基础头的请求头字典。
- """
- x_time = str(int(time.time())) # 秒级时间戳
- x_rand = str(random.randint(100000, 999999)) # 6 位随机数
- msg = path + X_VERSION + X_PLATFORM + session_id + x_time + x_rand + body_str
- sig = base64.b64encode(
- hmac.new(API_SIGN_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).digest()
- ).decode()
- return {
- "X_VERSION": X_VERSION, "X_TIME": x_time, "X_SID": session_id,
- "X_RAND": x_rand, "X_PLATFORM": X_PLATFORM, "X_SIG_1": sig,
- "Content-Type": "application/json;charset=UTF-8",
- "User-Agent": "okhttp/3.12.10", "Host": "api.jiwuplanet.com",
- }
- @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
- def _post(log, path: str, body_obj: dict, session_id: str) -> dict:
- """用 curl_cffi 发一次带签名的 POST 请求(带重试)。
- Args:
- log: 日志对象(首参,供 after_log 取用)。
- path (str): 接口路径。
- body_obj (dict): 请求体(须已含 sessionId)。
- session_id (str): 会话标识。
- Returns:
- dict: 响应 JSON。
- Raises:
- RuntimeError: HTTP 状态非 200 时抛出以触发重试。
- """
- body_str = canonical_body(body_obj)
- headers = _sign(path, body_str, session_id)
- r = creq.post(BASE_URL + path, headers=headers, data=body_str.encode("utf-8"),
- timeout=20, impersonate=IMPERSONATE, proxies=NO_PROXY)
- if r.status_code != 200:
- log.error(f"请求失败 HTTP {r.status_code}: {path}")
- raise RuntimeError(f"HTTP {r.status_code}")
- return r.json()
- def login(log) -> str:
- """脚本自登录:自造 sessionId 调 regLogin 绑定,返回可用会话标识并写入缓存。
- Args:
- log: 日志对象。
- Returns:
- str: 登录成功后可用的 sessionId;失败返回空串。
- """
- sid = gen_session_id()
- body = dict(LOGIN_ACCOUNT)
- body["sessionId"] = sid
- try:
- j = _post(log, "/acct/user/regLogin", body, sid)
- except Exception as e:
- log.error(f"登录请求异常: {e}")
- return ""
- if j.get("code") != BIZ_OK_CODE:
- log.error(f"登录失败: code={j.get('code')} msg={j.get('message')}")
- return ""
- data = j.get("data") or {}
- ret_sid = data.get("sessionId") or sid
- _session["sid"] = ret_sid
- _session["ts"] = time.time()
- log.success(f"登录成功 nickName={data.get('nickName')} userId={data.get('userId')} sid={ret_sid}")
- return ret_sid
- def ensure_session(log) -> str:
- """取可用 sessionId:内存缓存未过期直接用,否则重新登录。
- Args:
- log: 日志对象。
- Returns:
- str: 可用的 sessionId;登录失败返回空串。
- """
- if _session["sid"] and (time.time() - _session["ts"] < SESSION_TTL):
- return _session["sid"]
- return login(log)
- def get_anon_sid() -> str:
- """取免登录接口用的匿名 sessionId:随机自造、不经 regLogin,进程内复用。
- Returns:
- str: 32 位十六进制匿名会话标识。
- """
- if not _anon["sid"]:
- _anon["sid"] = gen_session_id()
- return _anon["sid"]
- def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
- """通用业务请求:自动带 sessionId + 签名。
- 登录态最小化:**默认走免登录**(need_auth=False,用随机未绑定的匿名 sid 签名,不调 regLogin);
- 仅服务端强制要求登录的接口(商家列表 hotRecommend、购买记录 publicity/user/group/pager、拆卡报告 gift/report)才传 need_auth=True。
- Args:
- log: 日志对象。
- path (str): 接口路径。
- body (dict): 业务请求参数(不含 sessionId,内部自动补)。
- need_auth (bool, optional): 是否需要真实登录会话。False=匿名免登录;True=regLogin 会话。Defaults to False。
- Returns:
- dict | None: 成功返回响应 JSON(code==1);失败返回 None。
- """
- if not need_auth: # 免登录:匿名 sid,被风控则轮换匿名 sid 下轮再来(始终不登录)
- sid = get_anon_sid()
- b = dict(body)
- b["sessionId"] = sid
- try:
- j = _post(log, path, b, sid)
- except Exception as e:
- log.warning(f"请求异常 {path}: {e}")
- return None
- if j.get("code") == BIZ_OK_CODE:
- return j
- log.warning(f"{path} 匿名请求返回 code={j.get('code')},轮换匿名 sid")
- _anon["sid"] = None
- return None
- for attempt in range(2): # 需登录:最多两次(首次 + 会话失效重登后再试)
- sid = ensure_session(log)
- if not sid:
- return None
- b = dict(body)
- b["sessionId"] = sid
- try:
- j = _post(log, path, b, sid)
- except Exception as e:
- log.warning(f"请求异常 {path}: {e}")
- return None
- if j.get("code") == BIZ_OK_CODE:
- return j
- log.warning(f"{path} 返回 code={j.get('code')},判为会话失效,第 {attempt + 1} 次,清缓存重登")
- _session["sid"] = None
- return None
- if __name__ == "__main__":
- # 自测:登录 + 在售 + 购买记录
- logger.remove()
- logger.add(sys.stderr, level="INFO", format="[{time:HH:mm:ss}] {level} {message}")
- _log = logger
- _log.info("===== 自测1:脚本自登录 =====")
- _sid = ensure_session(_log)
- _log.info(f"拿到 sessionId: {_sid}")
- _log.info("===== 自测2:在售列表 index/top =====")
- _j = do_request(_log, "/search/app/index/top",
- {"currentPage": "1", "limit": "10", "productType": "3", "systemBusinessType": 5})
- if _j:
- _d = _j["data"]
- _log.info(f"code={_j['code']} total={_d.get('totalCount')} 本页={len(_d.get('records', []))}")
- if _d.get("records"):
- _r = _d["records"][0]
- _log.info(
- f"首条: {_r.get('goodsName')} | {_r.get('corpInfoName')}({_r.get('corpInfoId')}) | 价={_r.get('highestPrice')}")
- _log.info("===== 自测3:购买记录 玩家维度(需登录)=====")
- _j = do_request(_log, "/order/merchant/app/query/gift/publicity/user/group/pager",
- {"currentPage": "1", "giftBusinessName": "", "goodsId": "1660823", "limit": "5"},
- need_auth=True)
- if _j:
- _d = _j["data"]
- _log.info(f"code={_j['code']} total={_d.get('totalCount')} 本页={len(_d.get('records', []))}")
- for _rr in _d.get("records", [])[:3]:
- _log.info(f"买家={_rr.get('userNick')} 份数={_rr.get('count')} uid={_rr.get('userId')}")
|