🔍 核心机关与修改指南
🌐 Chromium 全家桶引擎 Chrome / Edge / Brave
自动定位 Local Storage/leveldb,先拷贝到临时目录防浏览器锁文件,再从二进制流中精准捞出最新鲜的 skillhub_token。
🦊 Firefox 零锁库快照 SQLite Backup + Snappy
使用 SQLite 在线 backup() API 连还在 -wal 日志里的热乎登录态一起读出,再用纯标准库解开 Snappy 压缩。
🔒 0600 保险箱权限 绝不泄露明文
以 0o600 权限安全写入 .local/ph_token.txt,终端只打印字符数和来源,绝不把 JWT 明文印在屏幕上!
💻 完整源码与配置内容(共 354 行 · 支持行号定位与一键复制)
| 1 | #!/usr/bin/env python3 |
| 2 | """extract_token.py —— 从本机任意主流浏览器或环境变量/参数提取 PrimeHub 登录 token |
| 3 | |
| 4 | 支持以下 4 种提取来源(不再局限于 Firefox!): |
| 5 | 1. 【Chrome / Edge / Brave / Chromium】自动扫描 Local Storage/leveldb(复制快照防锁库,兼容 UTF-8 / UTF-16LE 与 Snappy 块) |
| 6 | 2. 【Firefox(含 Windows MSIX / macOS / Linux)】自动扫描 storage/default/https+++primehub.primebot.com/ls/data.sqlite(含 WAL 快照与 Snappy 解压) |
| 7 | 3. 【环境变量 PRIMEHUB_TOKEN】若设置了环境变量 PRIMEHUB_TOKEN,直接使用 |
| 8 | 4. 【命令行参数 --token】支持在任意浏览器按 F12 复制 skillhub_token 后通过 `--token "eyJ..."` 直接写入 |
| 9 | |
| 10 | 用法: |
| 11 | python3 extract_token.py [输出文件(默认 workspace/.local/ph_token.txt)] |
| 12 | python3 extract_token.py --browser auto|chrome|edge|firefox|brave|chromium [输出文件] |
| 13 | python3 extract_token.py --token "eyJhbG..." [输出文件] |
| 14 | """ |
| 15 | import argparse |
| 16 | import base64 |
| 17 | import glob |
| 18 | import json |
| 19 | import os |
| 20 | import re |
| 21 | import shutil |
| 22 | import sqlite3 |
| 23 | import sys |
| 24 | import tempfile |
| 25 | from pathlib import Path |
| 26 | |
| 27 | DEFAULT_TOKEN_FILE = os.environ.get( |
| 28 | "PRIMEHUB_TOKEN_FILE", str(Path(__file__).resolve().parents[2] / ".local/ph_token.txt") |
| 29 | ) |
| 30 | |
| 31 | JWT_ASCII_RE = re.compile(rb"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}") |
| 32 | |
| 33 | |
| 34 | def clean_token(raw: str) -> str: |
| 35 | """剥离首尾空白、外层单双引号及可能的 Bearer 前缀。""" |
| 36 | tok = raw.strip().strip('"').strip("'").strip() |
| 37 | if tok.lower().startswith("bearer "): |
| 38 | tok = tok[7:].strip() |
| 39 | return tok |
| 40 | |
| 41 | |
| 42 | def jwt_timestamp_score(token: str) -> int: |
| 43 | """解析 JWT payload 中的 exp 或 iat 时间戳用于新旧排序;无法解析时返回 0。""" |
| 44 | parts = token.split(".") |
| 45 | if len(parts) != 3: |
| 46 | return 0 |
| 47 | try: |
| 48 | payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4) |
| 49 | payload = json.loads(base64.urlsafe_b64decode(payload_b64.encode("ascii")).decode("utf-8")) |
| 50 | if isinstance(payload, dict): |
| 51 | return int(payload.get("exp") or payload.get("iat") or 0) |
| 52 | except Exception: |
| 53 | pass |
| 54 | return 0 |
| 55 | |
| 56 | |
| 57 | def save_token_file(out_file: Path, token: str, source_label: str = "") -> None: |
| 58 | """以 0600 权限安全写入 token 文件,仅打印路径、字符数与来源,绝不打印 token 明文。""" |
| 59 | tok = clean_token(token) |
| 60 | out_file.parent.mkdir(parents=True, exist_ok=True) |
| 61 | descriptor = os.open(out_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) |
| 62 | with os.fdopen(descriptor, "w", encoding="utf-8") as output: |
| 63 | output.write(tok) |
| 64 | suffix = (",来源: " + source_label) if source_label else "" |
| 65 | print("token → %s(%d 字符%s;有效期以服务端为准)" % (out_file, len(tok), suffix)) |
| 66 | |
| 67 | |
| 68 | # ============================================================================== |
| 69 | # 来源 1:Firefox (SQLite + WAL + Snappy) |
| 70 | # ============================================================================== |
| 71 | |
| 72 | def firefox_databases() -> list[Path]: |
| 73 | """支持 Windows、MSIX、macOS、Linux 及显式指定的 Firefox profile。""" |
| 74 | profile = os.environ.get("PRIMEHUB_FIREFOX_PROFILE") |
| 75 | roots = [Path(profile)] if profile else [ |
| 76 | Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) |
| 77 | / "Mozilla/Firefox/Profiles", |
| 78 | Path.home() / "Library/Application Support/Firefox/Profiles", |
| 79 | Path.home() / ".mozilla/firefox", |
| 80 | Path.home() / "snap/firefox/common/.mozilla/firefox", |
| 81 | ] |
| 82 | if not profile and os.environ.get("LOCALAPPDATA"): |
| 83 | packages = Path(os.environ["LOCALAPPDATA"]) / "Packages" |
| 84 | roots.extend(packages.glob( |
| 85 | "Mozilla.MozillaFirefox_*/LocalCache/Roaming/Mozilla/Firefox/Profiles")) |
| 86 | suffix = "storage/default/https+++primehub.primebot.com/ls/data.sqlite" |
| 87 | databases = set() |
| 88 | for root in roots: |
| 89 | for pattern in (suffix, "*/" + suffix): |
| 90 | databases.update(Path(p) for p in glob.glob(glob.escape(str(root)) + "/" + pattern)) |
| 91 | return sorted(databases, key=lambda p: p.stat().st_mtime, reverse=True) |
| 92 | |
| 93 | |
| 94 | def read_storage(database: Path) -> dict: |
| 95 | """用 SQLite backup 读取一致快照,包含仍在 WAL 中的登录数据。""" |
| 96 | source = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True) |
| 97 | snapshot = sqlite3.connect(":memory:") |
| 98 | try: |
| 99 | source.backup(snapshot) |
| 100 | return dict(snapshot.execute("SELECT key, value FROM data").fetchall()) |
| 101 | finally: |
| 102 | snapshot.close() |
| 103 | source.close() |
| 104 | |
| 105 | |
| 106 | def snappy_decompress(data: bytes) -> bytes: |
| 107 | """纯 stdlib snappy 解压:uvarint 声明长度 + literal/copy tag 流。""" |
| 108 | def uvarint(d, i): |
| 109 | v = s = 0 |
| 110 | while True: |
| 111 | b = d[i]; i += 1 |
| 112 | v |= (b & 0x7F) << s; s += 7 |
| 113 | if not (b & 0x80): |
| 114 | break |
| 115 | return v, i |
| 116 | |
| 117 | declared, i = uvarint(data, 0) |
| 118 | out = bytearray(); n = len(data) |
| 119 | while i < n: |
| 120 | tag = data[i]; i += 1 |
| 121 | t = tag & 0x03 |
| 122 | if t == 0: # literal |
| 123 | ln = tag >> 2 |
| 124 | if ln < 60: |
| 125 | ln += 1 |
| 126 | else: |
| 127 | extra = ln - 59 |
| 128 | ln = int.from_bytes(data[i:i + extra], "little") + 1 |
| 129 | i += extra |
| 130 | out += data[i:i + ln]; i += ln |
| 131 | else: # copy |
| 132 | if t == 1: |
| 133 | ln = ((tag >> 2) & 0x07) + 4 |
| 134 | off = ((tag >> 5) << 8) | data[i]; i += 1 |
| 135 | elif t == 2: |
| 136 | ln = (tag >> 2) + 1 |
| 137 | off = int.from_bytes(data[i:i + 2], "little"); i += 2 |
| 138 | else: |
| 139 | ln = (tag >> 2) + 1 |
| 140 | off = int.from_bytes(data[i:i + 4], "little"); i += 4 |
| 141 | for _ in range(ln): |
| 142 | out.append(out[-off]) |
| 143 | assert len(out) == declared, (len(out), declared) |
| 144 | return bytes(out) |
| 145 | |
| 146 | |
| 147 | def extract_from_firefox() -> str | None: |
| 148 | """从 Firefox 数据库列表提取 skillhub_token。""" |
| 149 | for db in firefox_databases(): |
| 150 | try: |
| 151 | rows = read_storage(db) |
| 152 | if "skillhub_token" in rows: |
| 153 | return clean_token(snappy_decompress(rows["skillhub_token"]).decode("utf-8")) |
| 154 | except Exception: |
| 155 | continue |
| 156 | return None |
| 157 | |
| 158 | |
| 159 | # ============================================================================== |
| 160 | # 来源 2:Chrome / Edge / Brave / Chromium (LevelDB .log / .ldb) |
| 161 | # ============================================================================== |
| 162 | |
| 163 | def chromium_user_data_roots(browser: str = "auto") -> list[tuple[str, Path]]: |
| 164 | """返回指定或全部 Chromium 内核浏览器的 User Data 或 Profile 根目录列表。""" |
| 165 | custom = os.environ.get("PRIMEHUB_CHROME_PROFILE") or os.environ.get("PRIMEHUB_BROWSER_PATH") |
| 166 | if custom: |
| 167 | return [("custom", Path(custom))] |
| 168 | |
| 169 | local_app = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) |
| 170 | home = Path.home() |
| 171 | catalog = { |
| 172 | "chrome": [ |
| 173 | local_app / "Google/Chrome/User Data", |
| 174 | home / "Library/Application Support/Google/Chrome", |
| 175 | home / ".config/google-chrome", |
| 176 | ], |
| 177 | "edge": [ |
| 178 | local_app / "Microsoft/Edge/User Data", |
| 179 | home / "Library/Application Support/Microsoft Edge", |
| 180 | home / ".config/microsoft-edge", |
| 181 | ], |
| 182 | "brave": [ |
| 183 | local_app / "BraveSoftware/Brave-Browser/User Data", |
| 184 | home / "Library/Application Support/BraveSoftware/Brave-Browser", |
| 185 | home / ".config/BraveSoftware/Brave-Browser", |
| 186 | ], |
| 187 | "chromium": [ |
| 188 | local_app / "Chromium/User Data", |
| 189 | home / "Library/Application Support/Chromium", |
| 190 | home / ".config/chromium", |
| 191 | home / "snap/chromium/common/chromium", |
| 192 | ], |
| 193 | } |
| 194 | selected = catalog.keys() if browser == "auto" else [browser] |
| 195 | results = [] |
| 196 | for name in selected: |
| 197 | for path in catalog.get(name, []): |
| 198 | results.append((name, path)) |
| 199 | return results |
| 200 | |
| 201 | |
| 202 | def chromium_leveldb_dirs(browser: str = "auto") -> list[tuple[str, Path]]: |
| 203 | """查找 Chromium 系浏览器所有 Profile 下的 Local Storage/leveldb 目录。""" |
| 204 | found = [] |
| 205 | seen = set() |
| 206 | for name, root in chromium_user_data_roots(browser): |
| 207 | for pattern in ("Local Storage/leveldb", "Default/Local Storage/leveldb", "Profile */Local Storage/leveldb"): |
| 208 | for match in glob.glob(glob.escape(str(root)) + "/" + pattern): |
| 209 | p = Path(match) |
| 210 | if p.is_dir() and p not in seen: |
| 211 | seen.add(p) |
| 212 | found.append((name, p)) |
| 213 | return found |
| 214 | |
| 215 | |
| 216 | def _scan_bytes_for_skillhub_jwt(raw: bytes) -> list[str]: |
| 217 | """在字节流(支持 UTF-8/Latin1 与 UTF-16LE)中检索紧跟 skillhub_token 后的 JWT。""" |
| 218 | tokens = [] |
| 219 | # 1. UTF-8 / Latin1 编码的 key + value |
| 220 | key_utf8 = b"skillhub_token" |
| 221 | pos = 0 |
| 222 | while True: |
| 223 | idx = raw.find(key_utf8, pos) |
| 224 | if idx == -1: |
| 225 | break |
| 226 | window = raw[idx + len(key_utf8): idx + len(key_utf8) + 4096] |
| 227 | match = JWT_ASCII_RE.search(window) |
| 228 | if match and match.start() < 128: |
| 229 | tokens.append(match.group(0).decode("ascii")) |
| 230 | pos = idx + len(key_utf8) |
| 231 | |
| 232 | # 2. UTF-16LE 编码的 key 或 value |
| 233 | key_utf16 = "skillhub_token".encode("utf-16le") |
| 234 | pos = 0 |
| 235 | while True: |
| 236 | idx = raw.find(key_utf16, pos) |
| 237 | if idx == -1: |
| 238 | break |
| 239 | window = raw[idx + len(key_utf16): idx + len(key_utf16) + 8192] |
| 240 | # 压缩奇数位 0x00 还原 ASCII 视图 |
| 241 | collapsed = window[0::2] |
| 242 | match = JWT_ASCII_RE.search(collapsed) |
| 243 | if match and match.start() < 128: |
| 244 | tokens.append(match.group(0).decode("ascii")) |
| 245 | pos = idx + len(key_utf16) |
| 246 | return tokens |
| 247 | |
| 248 | |
| 249 | def read_chromium_leveldb_token(leveldb_dir: Path) -> str | None: |
| 250 | """拷贝 LevelDB 快照并提取最新的 skillhub_token。""" |
| 251 | candidates = [] |
| 252 | with tempfile.TemporaryDirectory(prefix="primehub_ldb_") as tmp_dir: |
| 253 | tmp_path = Path(tmp_dir) |
| 254 | files = sorted( |
| 255 | [p for p in leveldb_dir.iterdir() if p.suffix in (".log", ".ldb", ".sst")], |
| 256 | key=lambda p: p.stat().st_mtime, |
| 257 | ) |
| 258 | for src_file in files: |
| 259 | try: |
| 260 | dst_file = tmp_path / src_file.name |
| 261 | shutil.copy2(src_file, dst_file) |
| 262 | data = dst_file.read_bytes() |
| 263 | except Exception: |
| 264 | continue |
| 265 | mtime = int(src_file.stat().st_mtime) |
| 266 | for tok in _scan_bytes_for_skillhub_jwt(data): |
| 267 | candidates.append((jwt_timestamp_score(tok), mtime, tok)) |
| 268 | # 若整个文件/块为纯 Snappy 流,也尝试解压后扫描 |
| 269 | try: |
| 270 | decompressed = snappy_decompress(data) |
| 271 | for tok in _scan_bytes_for_skillhub_jwt(decompressed): |
| 272 | candidates.append((jwt_timestamp_score(tok), mtime, tok)) |
| 273 | except Exception: |
| 274 | pass |
| 275 | if not candidates: |
| 276 | return None |
| 277 | # 按 (JWT过期时间/签发时间, 文件修改时间) 升序排,取最新一条 |
| 278 | candidates.sort(key=lambda item: (item[0], item[1])) |
| 279 | return clean_token(candidates[-1][2]) |
| 280 | |
| 281 | |
| 282 | def extract_from_chromium(browser: str = "auto") -> tuple[str, str] | None: |
| 283 | """从 Chrome / Edge / Brave / Chromium 提取最新 token,返回 (browser_name, token)。""" |
| 284 | best = None # (jwt_score, dir_mtime, browser_name, token) |
| 285 | for name, ldb_dir in chromium_leveldb_dirs(browser): |
| 286 | tok = read_chromium_leveldb_token(ldb_dir) |
| 287 | if tok: |
| 288 | score = (jwt_timestamp_score(tok), int(ldb_dir.stat().st_mtime), name, tok) |
| 289 | if best is None or score[:2] >= best[:2]: |
| 290 | best = score |
| 291 | if best: |
| 292 | return best[2], best[3] |
| 293 | return None |
| 294 | |
| 295 | |
| 296 | def main(): |
| 297 | parser = argparse.ArgumentParser( |
| 298 | description="从 Firefox / Chrome / Edge / Brave / Chromium 或命令行提取 PrimeHub 登录 token" |
| 299 | ) |
| 300 | parser.add_argument("out_file", nargs="?", default=DEFAULT_TOKEN_FILE, help="输出 token 文件路径") |
| 301 | parser.add_argument( |
| 302 | "--browser", |
| 303 | choices=("auto", "firefox", "chrome", "edge", "brave", "chromium"), |
| 304 | default="auto", |
| 305 | help="指定浏览器来源(默认 auto:自动扫描 Firefox 与 Chrome/Edge 等)", |
| 306 | ) |
| 307 | parser.add_argument("--token", help="直接传入从浏览器 DevTools 复制的 skillhub_token 字符串") |
| 308 | args = parser.parse_args() |
| 309 | out_file = Path(args.out_file) |
| 310 | |
| 311 | # 1. 优先支持显式 --token 参数或 PRIMEHUB_TOKEN 环境变量 |
| 312 | direct_token = args.token or os.environ.get("PRIMEHUB_TOKEN") |
| 313 | if direct_token and clean_token(direct_token): |
| 314 | save_token_file(out_file, direct_token, "direct") |
| 315 | return |
| 316 | |
| 317 | # 2. Firefox 提取路径 |
| 318 | if args.browser in ("auto", "firefox"): |
| 319 | dbs = firefox_databases() |
| 320 | for db in dbs: |
| 321 | try: |
| 322 | rows = read_storage(db) |
| 323 | except Exception: |
| 324 | continue |
| 325 | if "skillhub_token" in rows: |
| 326 | tok = snappy_decompress(rows["skillhub_token"]).decode("utf-8").strip('"') |
| 327 | save_token_file(out_file, tok, "firefox") |
| 328 | return |
| 329 | if args.browser == "firefox": |
| 330 | if not dbs: |
| 331 | raise SystemExit("没找到 Firefox 的 primehub localStorage(先在 Firefox 登录一次 PrimeHub)") |
| 332 | print("没找到 skillhub_token——Firefox 登录态过期,重新登录一次", file=sys.stderr) |
| 333 | sys.exit(1) |
| 334 | |
| 335 | # 3. Chrome / Edge / Brave / Chromium 提取路径 |
| 336 | if args.browser in ("auto", "chrome", "edge", "brave", "chromium"): |
| 337 | chromium_hit = extract_from_chromium(args.browser) |
| 338 | if chromium_hit: |
| 339 | browser_name, tok = chromium_hit |
| 340 | save_token_file(out_file, tok, browser_name) |
| 341 | return |
| 342 | |
| 343 | # 4. 全部未命中时给出清晰的可操作提示(保留 Firefox 关键字以兼容既有自动化断言) |
| 344 | if args.browser == "auto" and firefox_databases(): |
| 345 | print("没找到 skillhub_token——Firefox / Chrome / Edge 登录态过期,请重新登录一次", file=sys.stderr) |
| 346 | sys.exit(1) |
| 347 | raise SystemExit( |
| 348 | "没找到 Firefox / Chrome / Edge 的 primehub localStorage" |
| 349 | "(请先在任意主流浏览器登录一次 https://primehub.primebot.com,或使用 --token 传入)" |
| 350 | ) |
| 351 | |
| 352 | |
| 353 | if __name__ == "__main__": |
| 354 | main() |