jiwu_core.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/8/18 19:19
  5. """集物星球爬虫核心库:签名、脚本自登录拿 sessionId、通用请求(curl_cffi 过 TLS 风控)。
  6. 三大关键点:
  7. 1. 请求头签名 X_SIG_1 = base64(HMAC_SHA256(key, msg)),msg 拼接顺序见 _sign。
  8. 2. 必须用 curl_cffi(impersonate=chrome)发请求——服务端按 TLS/JA3 指纹识别客户端,
  9. python requests 的指纹会被判为非真机,接口静默返回 code:200 空壳(data.result=[]);
  10. curl_cffi 才能拿到真正的 code:1 数据。
  11. 3. sessionId 由客户端自造、regLogin 上报绑定,服务端沿用该值;请求头 X_SID、body.sessionId、
  12. 签名串里的 sessionId 三处必须同值。业务接口成功码是 code==1。
  13. """
  14. import sys
  15. import time
  16. import json
  17. import hmac
  18. import base64
  19. import random
  20. import hashlib
  21. from curl_cffi import requests as creq
  22. from loguru import logger
  23. from tenacity import retry, stop_after_attempt, wait_fixed
  24. BASE_URL = "https://api.jiwuplanet.com"
  25. API_SIGN_SECRET_KEY = "EahycFpDpewggO2rksvQwTlnhCbTHFx6" # 生产环境签名密钥(逆向所得)
  26. X_VERSION = "2.12.2" # App 版本号
  27. X_PLATFORM = "150" # 平台常量
  28. IMPERSONATE = "chrome" # curl_cffi TLS 指纹,过服务端风控的关键
  29. NO_PROXY = {"http": None, "https": None} # 显式直连,忽略系统 HTTP(S)_PROXY(127.0.0.1:7890)
  30. BIZ_OK_CODE = 1 # 业务接口成功码(登录/查询成功均为 1)
  31. PRICE_DIVISOR = 1000000 # 金额原始值 ÷ 此 = 元(实测 amount=99000000 → App 显示 ¥99.00)
  32. # 脚本自登录账号(无人值守续期用;账密为运行必需,如需可挪到 application.yml)
  33. LOGIN_ACCOUNT = {
  34. "appVersions": X_VERSION, "deviceNumber": "40b03f4bec96675f",
  35. "identificationNumber": "19521500850", "ip": "10.1.10.1", "language": "ZH",
  36. "loginPassword": "pass2022", "loginType": "ACCOUNT", "mobile": "19521500850",
  37. "mobileCode": "86", "platformInfo": "Google Pixel 5", "platformType": "ANDROID_USER",
  38. "systemBusinessType": 6, "terminalVersions": "11",
  39. }
  40. SESSION_TTL = 1800 # sessionId 复用时长(秒),超时重新登录
  41. _session = {"sid": None, "ts": 0.0} # 登录态 sessionId 内存缓存
  42. _anon = {"sid": None} # 免登录接口用的匿名 sid(随机自造、不经 regLogin,进程内复用;被风控则轮换)
  43. def after_log(retry_state):
  44. """tenacity 重试回调,记录每次尝试的结果。
  45. Args:
  46. retry_state: tenacity 传入的 RetryCallState,args[0] 约定为 log。
  47. """
  48. log = retry_state.args[0] if retry_state.args else logger
  49. if retry_state.outcome.failed:
  50. log.warning(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} Times")
  51. else:
  52. log.info(f"Function '{retry_state.fn.__name__}', Attempt {retry_state.attempt_number} succeeded")
  53. def canonical_body(body_obj: dict) -> str:
  54. """把请求体规范化为被签名/被发送的紧凑串(等价 App 端 Gson 处理)。
  55. Args:
  56. body_obj (dict): 原始请求体字典。
  57. Returns:
  58. str: 递归按 key 排序、紧凑、非 ASCII 不转义的 JSON 串;既用于签名也用于实际发送。
  59. """
  60. return json.dumps(body_obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
  61. def to_yuan(raw) -> float | None:
  62. """把接口原始金额换算成元(÷PRICE_DIVISOR,保留 2 位),供入库前统一转换。
  63. Args:
  64. raw (int | float | None): 接口原始金额。
  65. Returns:
  66. float | None: 元(保留 2 位);raw 为空返回 None。
  67. """
  68. return round(raw / PRICE_DIVISOR, 2) if isinstance(raw, (int, float)) else None
  69. def gen_session_id() -> str:
  70. """自造一个 32 位小写 hex 会话标识(服务端沿用此值作会话)。
  71. Returns:
  72. str: 32 位十六进制字符串。
  73. """
  74. return hashlib.md5(f"{time.time()}-{random.random()}".encode()).hexdigest()
  75. def _sign(path: str, body_str: str, session_id: str) -> dict:
  76. """按签名算法生成带 X_SIG_1 的完整请求头。
  77. Args:
  78. path (str): URL 路径(encodedPath,不含 host/query)。
  79. body_str (str): 规范化后的请求体字符串。
  80. session_id (str): 会话标识,与 body 内 sessionId 一致。
  81. Returns:
  82. dict: 含 6 个 X_* 头及基础头的请求头字典。
  83. """
  84. x_time = str(int(time.time())) # 秒级时间戳
  85. x_rand = str(random.randint(100000, 999999)) # 6 位随机数
  86. msg = path + X_VERSION + X_PLATFORM + session_id + x_time + x_rand + body_str
  87. sig = base64.b64encode(
  88. hmac.new(API_SIGN_SECRET_KEY.encode(), msg.encode(), hashlib.sha256).digest()
  89. ).decode()
  90. return {
  91. "X_VERSION": X_VERSION, "X_TIME": x_time, "X_SID": session_id,
  92. "X_RAND": x_rand, "X_PLATFORM": X_PLATFORM, "X_SIG_1": sig,
  93. "Content-Type": "application/json;charset=UTF-8",
  94. "User-Agent": "okhttp/3.12.10", "Host": "api.jiwuplanet.com",
  95. }
  96. @retry(stop=stop_after_attempt(3), wait=wait_fixed(2), after=after_log)
  97. def _post(log, path: str, body_obj: dict, session_id: str) -> dict:
  98. """用 curl_cffi 发一次带签名的 POST 请求(带重试)。
  99. Args:
  100. log: 日志对象(首参,供 after_log 取用)。
  101. path (str): 接口路径。
  102. body_obj (dict): 请求体(须已含 sessionId)。
  103. session_id (str): 会话标识。
  104. Returns:
  105. dict: 响应 JSON。
  106. Raises:
  107. RuntimeError: HTTP 状态非 200 时抛出以触发重试。
  108. """
  109. body_str = canonical_body(body_obj)
  110. headers = _sign(path, body_str, session_id)
  111. r = creq.post(BASE_URL + path, headers=headers, data=body_str.encode("utf-8"),
  112. timeout=20, impersonate=IMPERSONATE, proxies=NO_PROXY)
  113. if r.status_code != 200:
  114. log.error(f"请求失败 HTTP {r.status_code}: {path}")
  115. raise RuntimeError(f"HTTP {r.status_code}")
  116. return r.json()
  117. def login(log) -> str:
  118. """脚本自登录:自造 sessionId 调 regLogin 绑定,返回可用会话标识并写入缓存。
  119. Args:
  120. log: 日志对象。
  121. Returns:
  122. str: 登录成功后可用的 sessionId;失败返回空串。
  123. """
  124. sid = gen_session_id()
  125. body = dict(LOGIN_ACCOUNT)
  126. body["sessionId"] = sid
  127. try:
  128. j = _post(log, "/acct/user/regLogin", body, sid)
  129. except Exception as e:
  130. log.error(f"登录请求异常: {e}")
  131. return ""
  132. if j.get("code") != BIZ_OK_CODE:
  133. log.error(f"登录失败: code={j.get('code')} msg={j.get('message')}")
  134. return ""
  135. data = j.get("data") or {}
  136. ret_sid = data.get("sessionId") or sid
  137. _session["sid"] = ret_sid
  138. _session["ts"] = time.time()
  139. log.success(f"登录成功 nickName={data.get('nickName')} userId={data.get('userId')} sid={ret_sid}")
  140. return ret_sid
  141. def ensure_session(log) -> str:
  142. """取可用 sessionId:内存缓存未过期直接用,否则重新登录。
  143. Args:
  144. log: 日志对象。
  145. Returns:
  146. str: 可用的 sessionId;登录失败返回空串。
  147. """
  148. if _session["sid"] and (time.time() - _session["ts"] < SESSION_TTL):
  149. return _session["sid"]
  150. return login(log)
  151. def get_anon_sid() -> str:
  152. """取免登录接口用的匿名 sessionId:随机自造、不经 regLogin,进程内复用。
  153. Returns:
  154. str: 32 位十六进制匿名会话标识。
  155. """
  156. if not _anon["sid"]:
  157. _anon["sid"] = gen_session_id()
  158. return _anon["sid"]
  159. def do_request(log, path: str, body: dict, need_auth: bool = False) -> dict | None:
  160. """通用业务请求:自动带 sessionId + 签名。
  161. 登录态最小化:**默认走免登录**(need_auth=False,用随机未绑定的匿名 sid 签名,不调 regLogin);
  162. 仅服务端强制要求登录的接口(商家列表 hotRecommend、购买记录 publicity/user/group/pager、拆卡报告 gift/report)才传 need_auth=True。
  163. Args:
  164. log: 日志对象。
  165. path (str): 接口路径。
  166. body (dict): 业务请求参数(不含 sessionId,内部自动补)。
  167. need_auth (bool, optional): 是否需要真实登录会话。False=匿名免登录;True=regLogin 会话。Defaults to False。
  168. Returns:
  169. dict | None: 成功返回响应 JSON(code==1);失败返回 None。
  170. """
  171. if not need_auth: # 免登录:匿名 sid,被风控则轮换匿名 sid 下轮再来(始终不登录)
  172. sid = get_anon_sid()
  173. b = dict(body)
  174. b["sessionId"] = sid
  175. try:
  176. j = _post(log, path, b, sid)
  177. except Exception as e:
  178. log.warning(f"请求异常 {path}: {e}")
  179. return None
  180. if j.get("code") == BIZ_OK_CODE:
  181. return j
  182. log.warning(f"{path} 匿名请求返回 code={j.get('code')},轮换匿名 sid")
  183. _anon["sid"] = None
  184. return None
  185. for attempt in range(2): # 需登录:最多两次(首次 + 会话失效重登后再试)
  186. sid = ensure_session(log)
  187. if not sid:
  188. return None
  189. b = dict(body)
  190. b["sessionId"] = sid
  191. try:
  192. j = _post(log, path, b, sid)
  193. except Exception as e:
  194. log.warning(f"请求异常 {path}: {e}")
  195. return None
  196. if j.get("code") == BIZ_OK_CODE:
  197. return j
  198. log.warning(f"{path} 返回 code={j.get('code')},判为会话失效,第 {attempt + 1} 次,清缓存重登")
  199. _session["sid"] = None
  200. return None
  201. if __name__ == "__main__":
  202. # 自测:登录 + 在售 + 购买记录
  203. logger.remove()
  204. logger.add(sys.stderr, level="INFO", format="[{time:HH:mm:ss}] {level} {message}")
  205. _log = logger
  206. _log.info("===== 自测1:脚本自登录 =====")
  207. _sid = ensure_session(_log)
  208. _log.info(f"拿到 sessionId: {_sid}")
  209. _log.info("===== 自测2:在售列表 index/top =====")
  210. _j = do_request(_log, "/search/app/index/top",
  211. {"currentPage": "1", "limit": "10", "productType": "3", "systemBusinessType": 5})
  212. if _j:
  213. _d = _j["data"]
  214. _log.info(f"code={_j['code']} total={_d.get('totalCount')} 本页={len(_d.get('records', []))}")
  215. if _d.get("records"):
  216. _r = _d["records"][0]
  217. _log.info(
  218. f"首条: {_r.get('goodsName')} | {_r.get('corpInfoName')}({_r.get('corpInfoId')}) | 价={_r.get('highestPrice')}")
  219. _log.info("===== 自测3:购买记录 玩家维度(需登录)=====")
  220. _j = do_request(_log, "/order/merchant/app/query/gift/publicity/user/group/pager",
  221. {"currentPage": "1", "giftBusinessName": "", "goodsId": "1660823", "limit": "5"},
  222. need_auth=True)
  223. if _j:
  224. _d = _j["data"]
  225. _log.info(f"code={_j['code']} total={_d.get('totalCount')} 本页={len(_d.get('records', []))}")
  226. for _rr in _d.get("records", [])[:3]:
  227. _log.info(f"买家={_rr.get('userNick')} 份数={_rr.get('count')} uid={_rr.get('userId')}")