get_token.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. # -*- coding: utf-8 -*-
  2. # Author : Charley
  3. # Python : 3.12.10
  4. # Date : 2026/08/12
  5. """得卡 DECA App token 抓取脚本(root 直读本地存储,替代 mitmproxy 代理抓包)。
  6. 设备已 root(Magisk)且 得卡 App 已登录时,直接从 App 私有目录的
  7. Jetpack DataStore 文件读出当前登录态的 accessToken / refreshToken,
  8. 算好过期时间,覆盖写入项目根目录 token.json,供 deca_sold_core.py 等复用。
  9. 与千岛版差异(都是 root 直读,只是存储格式不同):
  10. 千岛 token 明文存 SharedPreferences xml,一条 grep 即可;
  11. 得卡 token 存 DataStore 的 protobuf 文件 deka_settings.preferences_pb 里,
  12. 藏在顶层 key `switch_accounts` 的 JSON 数组内(每个账号一条 userInfo),
  13. 需按 protobuf 解出该 string,再解 JSON 取 accessToken 等字段。
  14. App 登录 / 切号后该 pb 会实时更新,App 后台也会用 refreshToken 自动续期并回写,
  15. 所以本脚本读到的必然是「此刻有效」的 token。token.json 只需种一次,之后
  16. deca_sold_core.ensure_token 会靠 refreshToken 自续期,refreshToken 失效时再跑本脚本。
  17. 用法:
  18. python get_token.py # 读设备 → 覆盖写 token.json → 打印脱敏摘要
  19. """
  20. import os
  21. import sys
  22. import json
  23. import time
  24. import base64
  25. import subprocess
  26. # 得卡 App 包名(pm list packages 实测:有好卡 · 得卡)
  27. PACKAGE_NAME = "com.youhaoka.deka"
  28. # 登录态所在的 DataStore(Preferences,protobuf 二进制)文件
  29. PB_PATH = f"/data/data/{PACKAGE_NAME}/files/datastore/deka_settings.preferences_pb"
  30. # token 藏身的顶层 DataStore key,value 是一段 JSON 数组(多账号切换列表)
  31. ACCOUNTS_KEY = "switch_accounts"
  32. # token.json 与本脚本同目录(deca_sold_core 从运行目录读它)
  33. TOKEN_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "token.json")
  34. def _read_varint(buf: bytes, i: int) -> tuple[int, int]:
  35. """从字节流指定位置解一个 protobuf varint。
  36. Args:
  37. buf (bytes): 原始字节流。
  38. i (int): 起始下标。
  39. Returns:
  40. tuple[int, int]: (解出的整数值, 解析后的新下标)。
  41. """
  42. shift = 0
  43. result = 0
  44. while True:
  45. b = buf[i]
  46. i += 1
  47. result |= (b & 0x7F) << shift
  48. if not b & 0x80: # 最高位为 0 表示 varint 结束
  49. return result, i
  50. shift += 7
  51. def _get_pref_string(raw: bytes, key: str) -> str | None:
  52. """从 DataStore Preferences protobuf 里取指定 key 的 string value。
  53. DataStore 结构:顶层 map<string, Value>(field 1,每条是一个 entry message),
  54. entry 内 field 1=key(string)、field 2=Value message;Value 里 field 5=string 值。
  55. Args:
  56. raw (bytes): deka_settings.preferences_pb 的完整字节。
  57. key (str): 要取的顶层 key 名,如 "switch_accounts"。
  58. Returns:
  59. str | None: 该 key 的字符串值;未找到返回 None。
  60. """
  61. i = 0
  62. while i < len(raw):
  63. tag, i = _read_varint(raw, i)
  64. field, wire = tag >> 3, tag & 7
  65. if field == 1 and wire == 2: # 顶层 map entry(length-delimited)
  66. ln, i = _read_varint(raw, i)
  67. entry = raw[i:i + ln]
  68. i += ln
  69. j = 0
  70. k = v = None
  71. while j < len(entry):
  72. t2, j = _read_varint(entry, j)
  73. f2, w2 = t2 >> 3, t2 & 7
  74. if f2 == 1 and w2 == 2: # entry.key
  75. kl, j = _read_varint(entry, j)
  76. k = entry[j:j + kl].decode("utf-8", "replace")
  77. j += kl
  78. elif f2 == 2 and w2 == 2: # entry.value(Value message)
  79. vl, j = _read_varint(entry, j)
  80. vb = entry[j:j + vl]
  81. j += vl
  82. m = 0
  83. while m < len(vb): # 在 Value 里找 field 5=string
  84. t3, m = _read_varint(vb, m)
  85. f3, w3 = t3 >> 3, t3 & 7
  86. if f3 == 5 and w3 == 2:
  87. sl, m = _read_varint(vb, m)
  88. v = vb[m:m + sl].decode("utf-8", "replace")
  89. m += sl
  90. elif w3 == 0: # 跳过其它类型字段(bool/int 等)
  91. _, m = _read_varint(vb, m)
  92. elif w3 == 2:
  93. xl, m = _read_varint(vb, m)
  94. m += xl
  95. else:
  96. break
  97. else:
  98. break
  99. if k == key:
  100. return v
  101. elif wire == 0: # 跳过非 map 的 varint 字段
  102. _, i = _read_varint(raw, i)
  103. elif wire == 2: # 跳过非 map 的 length-delimited 字段
  104. ln, i = _read_varint(raw, i)
  105. i += ln
  106. else:
  107. break
  108. return None
  109. def _decode_jwt_exp(access: str) -> int | None:
  110. """从 JWT accessToken 的 payload 里解出 exp(过期 unix 秒)。
  111. Args:
  112. access (str): JWT 字符串(header.payload.signature)。
  113. Returns:
  114. int | None: exp 时间戳;解析失败返回 None。
  115. """
  116. try:
  117. payload = access.split(".")[1]
  118. payload += "=" * (-len(payload) % 4) # 补齐 base64url 填充
  119. data = json.loads(base64.urlsafe_b64decode(payload))
  120. return int(data["exp"]) if "exp" in data else None
  121. except Exception:
  122. return None
  123. def read_device_token() -> dict:
  124. """从已登录的 得卡 App 读取当前账号的 token 三元组。
  125. 通过 `adb exec-out su -c cat` 以 root 读 DataStore pb 文件(exec-out 保留二进制、
  126. 不做 CRLF 转换),解出 switch_accounts;多账号时取 tokenObtainedAtMs 最大者
  127. (最近获取 = 当前登录账号);exp 优先用 JWT 解,兜底 tokenObtainedAtMs+expiresIn。
  128. Returns:
  129. dict: {"access": str, "refresh": str, "exp": int, "userId": ..., "phone": str}。
  130. Raises:
  131. RuntimeError: adb 缺失 / 超时 / 未读到 pb / 未登录(无账号)时抛出。
  132. """
  133. cmd = ["adb", "exec-out", "su", "-c", f"cat {PB_PATH}"]
  134. try:
  135. result = subprocess.run(cmd, capture_output=True, timeout=20)
  136. except FileNotFoundError as e:
  137. raise RuntimeError(f"[{sys._getframe().f_code.co_name}] 未找到 adb,请确认已配置环境变量") from e
  138. except subprocess.TimeoutExpired as e:
  139. raise RuntimeError(f"[{sys._getframe().f_code.co_name}] adb 命令超时,请检查设备连接") from e
  140. raw = result.stdout or b""
  141. if not raw:
  142. err = (result.stderr or b"").decode("utf-8", "replace").strip() or "未知错误"
  143. raise RuntimeError(
  144. f"[{sys._getframe().f_code.co_name}] 读取 pb 失败:{err}\n"
  145. f"排查:1) adb devices 是否有设备 2) 设备是否已 root 3) 得卡 App 是否已登录"
  146. )
  147. accounts_json = _get_pref_string(raw, ACCOUNTS_KEY)
  148. if not accounts_json:
  149. raise RuntimeError(
  150. f"[{sys._getframe().f_code.co_name}] pb 里没有 {ACCOUNTS_KEY},请确认 得卡 App 已登录"
  151. )
  152. try:
  153. accounts = json.loads(accounts_json)
  154. except Exception as e:
  155. raise RuntimeError(f"[{sys._getframe().f_code.co_name}] {ACCOUNTS_KEY} 不是合法 JSON:{e}") from e
  156. if not accounts:
  157. raise RuntimeError(f"[{sys._getframe().f_code.co_name}] 账号列表为空,请在 得卡 App 登录")
  158. # 多账号时取 tokenObtainedAtMs 最大(最近登录/续期)的那个作为当前账号
  159. def _obtained(acc: dict) -> int:
  160. """取账号 userInfo 里的 tokenObtainedAtMs,缺失按 0。"""
  161. return int((acc.get("userInfo") or {}).get("tokenObtainedAtMs") or 0)
  162. cur = max(accounts, key=_obtained)
  163. info = cur.get("userInfo") or {}
  164. access = info.get("accessToken")
  165. refresh = info.get("refreshToken")
  166. if not access or not refresh:
  167. raise RuntimeError(f"[{sys._getframe().f_code.co_name}] 当前账号缺 accessToken/refreshToken,请重登")
  168. exp = _decode_jwt_exp(access)
  169. if exp is None: # JWT 解不出 → 用获取时刻 + expiresIn
  170. obtained_s = _obtained(cur) // 1000 or int(time.time())
  171. exp = obtained_s + int(info.get("expiresIn") or 900)
  172. return {"access": access, "refresh": refresh, "exp": exp,
  173. "userId": info.get("userId"), "phone": info.get("phone")}
  174. def write_token_json(tk: dict) -> None:
  175. """把 token 三元组覆盖写入 token.json(格式与旧抓包脚本一致,消费方零改动)。
  176. Args:
  177. tk (dict): read_device_token 的返回值,至少含 access/refresh/exp。
  178. """
  179. payload = {"access": tk["access"], "refresh": tk["refresh"], "exp": tk["exp"]}
  180. with open(TOKEN_FILE, "w", encoding="utf-8") as f:
  181. json.dump(payload, f, ensure_ascii=False)
  182. def main() -> None:
  183. """读设备 token → 覆盖写 token.json → 打印脱敏摘要。"""
  184. tk = read_device_token()
  185. write_token_json(tk)
  186. acc, exp, now = tk["access"], tk["exp"], int(time.time())
  187. left = exp - now
  188. print(f"[OK] token.json 已更新 -> {TOKEN_FILE}")
  189. print(f" userId : {tk.get('userId')} phone : {tk.get('phone')}")
  190. print(f" access : JWT 长{len(acc)} 头[{acc[:10]}] 尾[{acc[-6:]}]")
  191. print(f" exp : {exp} ({time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(exp))})"
  192. f" 剩余 {left} 秒 {'有效' if left > 0 else '已过期(App 侧会自动续期,稍后重跑)'}")
  193. if __name__ == "__main__":
  194. try:
  195. main()
  196. except RuntimeError as exc:
  197. print(exc)
  198. sys.exit(1)