Last active
November 12, 2025 04:48
-
-
Save tongoclinh/9106b9b4a0023818e3bf356560e76230 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| get_genshin_wishes.py | |
| A Python rewrite of get_genshin_wishes.sh that extracts the Genshin Impact wish‑history URL from | |
| the recent WebKit network cache and copies it to the macOS clipboard. | |
| Author: <your name> | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from typing import Iterable, List, Optional | |
| import sqlite3 | |
| class WishExtractor: | |
| HOYO_PATTERN = r"https://[^ ]+gacha[^ ]+authkey[^ ]+game_biz[^ ]+" | |
| """ | |
| Extracts the wish‑history URL from a recent WebKit cache directory. | |
| Parameters | |
| ---------- | |
| game : str | |
| Either ``"genshin"`` or ``"honkai"`` – the bundle id is looked up from this. | |
| home : Path, optional | |
| The user's home directory. Defaults to ``Path.home()``. | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| CONFIGs = { | |
| "genshin": { | |
| "bundle": "com.miHoYo.GenshinImpact", | |
| "import_instructions": ( | |
| "Import the URL on https://paimon.moe/wish/import to view your wishes history." | |
| ), | |
| }, | |
| "hsr": { | |
| "bundle": "com.HoYoverse.hkrpgoversea", | |
| "import_instructions": ( | |
| 'Import the URL on https://starrailstation.com/en/warp#import to view your wishes history. Uncheck "Upload to global stats" if the website is stuck forever.' | |
| ), | |
| }, | |
| "wutheringwaves": { | |
| "bundle": "com.kurogame.wutheringwaves.global", | |
| "import_instructions": ( | |
| "Import the URL on https://wuwatracker.com to view your wishes history." | |
| ), | |
| }, | |
| "zenless": { | |
| "bundle": "com.HoYoverse.Nap", | |
| "import_instructions": ( | |
| "Import the URL on https://zzz.rng.moe/en/tracker/import to view your wishes history." | |
| ), | |
| }, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Construction | |
| # --------------------------------------------------------------------------- | |
| def __init__(self, game: str = "genshin", home: Optional[Path] = None) -> None: | |
| if game not in self.CONFIGs: | |
| raise ValueError( | |
| f"Unsupported game {game!r}. Must be one of {list(self.CONFIGs)}" | |
| ) | |
| self.game = game | |
| self.bundle_id = self.CONFIGs[game]["bundle"] | |
| self.home = Path(home) if home else Path.home() | |
| # --------------------------------------------------------------------------- | |
| # Public API – orchestrates the whole workflow | |
| # --------------------------------------------------------------------------- | |
| def run(self) -> None: | |
| if self.game == "wutheringwaves": | |
| url, matched_file = self.ww_run() | |
| elif self.game == "zenless": | |
| url, matched_file = self.zz_run() | |
| else: | |
| url, matched_file = self.hoyo_run() | |
| if not url: | |
| self._error( | |
| "URL not found!\n" | |
| "Are you sure you opened the wishing history screen?\n" | |
| "If so, the script may need to be updated." | |
| ) | |
| print(f"Found Wish History URL in file:\n{matched_file}\n") | |
| print(url) | |
| print("\nURL copied to clipboard!\n") | |
| self.copy_to_clipboard(url) | |
| print( | |
| self.CONFIGs[self.game]["import_instructions"] | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Wuthering Waves specific extraction | |
| # --------------------------------------------------------------------------- | |
| def ww_run(self) -> None: | |
| cache_file = ( | |
| self.home | |
| / "Library" | |
| / "Containers" | |
| / "com.kurogame.wutheringwaves.global" | |
| / "Data" | |
| / "Library" | |
| / "Logs" | |
| / "Client" | |
| / "Client.log" | |
| ) | |
| if not cache_file.is_file(): | |
| self._error( | |
| f"Couldn't find Wuthering Waves log file in\n{cache_file}", | |
| exit_code=1, | |
| ) | |
| URL_RE = re.compile( | |
| r"https://aki-gm-resources(-oversea)?\.aki-game\.(net|com)[^\"]*" | |
| ) | |
| def find_last_url(path: Path) -> str | None: | |
| try: | |
| with path.open("r", encoding="utf-8") as fh: | |
| # Đọc toàn bộ dòng, để có thể lấy “đầu cuối”. | |
| lines = fh.readlines() | |
| except FileNotFoundError: | |
| self._error(f"File not found: {path}", exit_code=1) | |
| return None | |
| urls = [ | |
| m.group(0) for line in reversed(lines) if (m := URL_RE.search(line)) | |
| ] | |
| return urls[0] if urls else None | |
| url = find_last_url(cache_file) | |
| if not url: | |
| self._error( | |
| "URL not found!\n" | |
| "Are you sure you opened the wishing history screen?\n" | |
| "If so, the script may need to be updated." | |
| ) | |
| return (url, cache_file) | |
| # --------------------------------------------------------------------------- | |
| # Zenless Zone specific extraction | |
| # --------------------------------------------------------------------------- | |
| def zz_run(self) -> None: | |
| def connect_db(db_path: str) -> sqlite3.Connection: | |
| """Return a SQLite connection to the given database file.""" | |
| if not Path(db_path).is_file(): | |
| self._error( | |
| f"Database for Zenless Zone Zero not found: {db_path}", exit_code=1 | |
| ) | |
| raise FileNotFoundError(f"Database file not found: {db_path}") | |
| return sqlite3.connect(db_path) | |
| def find_latest_request(conn: sqlite3.Connection, pattern: str) -> str | None: | |
| """ | |
| Return the newest `request_key` that matches the regular expression. | |
| Parameters | |
| ---------- | |
| conn : sqlite3.Connection | |
| Active connection to the database. | |
| pattern : str | |
| Regular expression used to filter request keys. | |
| Returns | |
| ------- | |
| str or None | |
| The matched key, or None if nothing matches. | |
| """ | |
| # We let SQLite sort by the timestamp column (text type, ISO‑8601 format) | |
| sql = """ | |
| SELECT request_key | |
| FROM cfurl_cache_response | |
| WHERE request_key REGEXP ? | |
| ORDER BY time_stamp DESC | |
| LIMIT 1; | |
| """ | |
| # Register a custom REGEXP function so that SQLite can use Python's regex. | |
| conn.create_function( | |
| "REGEXP", 2, lambda expr, val: re.search(expr, val) is not None | |
| ) | |
| cur = conn.execute(sql, (pattern,)) | |
| row = cur.fetchone() | |
| return row[0] if row else None | |
| cached_db = ( | |
| self.home | |
| / "Library" | |
| / "Containers" | |
| / self.bundle_id | |
| / "Data" | |
| / "Library" | |
| / "Caches" | |
| / self.bundle_id | |
| / "Cache.db" | |
| ) | |
| conn = connect_db(cached_db) | |
| url = find_latest_request(conn, self.HOYO_PATTERN) | |
| if url is None: | |
| self._error( | |
| "URL not found!\n" | |
| "Are you sure you opened the wishing history screen?\n" | |
| "If so, the script may need to be updated." | |
| ) | |
| return url, cached_db | |
| # --------------------------------------------------------------------------- | |
| # Genshin Impact / Honkai Star Rail extraction | |
| # --------------------------------------------------------------------------- | |
| def hoyo_run(self) -> None: | |
| CACHE_VERSIONS = ("Version 17", "Version 16") # order matters – try newer first | |
| URL_PATTERN = re.compile( | |
| self.HOYO_PATTERN, | |
| re.IGNORECASE, | |
| ) | |
| NON_URL_CHARS_RE = re.compile(r"[^A-Za-z0-9._~:/?#$@!&'()*+,;=%\-]") | |
| # --------------------------------------------------------------------------- | |
| # Individual steps – can be unit‑tested separately | |
| # --------------------------------------------------------------------------- | |
| def find_cache_dir() -> Path: | |
| """ | |
| Return the first existing cache directory for the selected bundle id. | |
| Raises an error if none of the expected directories exist. | |
| """ | |
| base = ( | |
| self.home | |
| / "Library" | |
| / "Containers" | |
| / self.bundle_id | |
| / "Data" | |
| / "Library" | |
| / "Caches" | |
| / "WebKit" | |
| / "NetworkCache" | |
| ) | |
| for version in CACHE_VERSIONS: | |
| candidate = base / version / "Records" | |
| if candidate.is_dir(): | |
| return candidate | |
| self._error( | |
| f"Couldn't find Genshin Impact/PlayCover cache directory in\n{self.home / 'Library' / 'Containers'}", | |
| exit_code=1, | |
| ) | |
| # pragma: no cover – the error will raise and never reach this point | |
| raise RuntimeError("Unreachable") | |
| def candidate_files(cache_dir: Path) -> Iterable[Path]: | |
| """ | |
| Yield all files under ``cache_dir`` that were modified in the last hour. | |
| The generator yields them sorted by modification time (most recent first). | |
| """ | |
| one_hour_ago = datetime.now() - timedelta(hours=1) | |
| candidates = ( | |
| p | |
| for p in cache_dir.rglob("*") | |
| if p.is_file() | |
| and p.name.lower() != ".ds_store" | |
| and datetime.fromtimestamp(p.stat().st_mtime) > one_hour_ago | |
| ) | |
| # Sort by mtime descending so the newest files are examined first | |
| sorted_candidates = sorted( | |
| candidates, | |
| key=lambda p: p.stat().st_mtime, | |
| reverse=True, | |
| ) | |
| yield from sorted_candidates | |
| def extract_url(files: List[Path]) -> tuple[Optional[str], Optional[Path]]: | |
| """ | |
| Search the given list of files for a wish‑history URL. | |
| Returns ``(url, file)`` if found, otherwise ``(None, None)``. | |
| """ | |
| for f in files: | |
| try: | |
| content_bytes = f.read_bytes() | |
| except OSError as e: | |
| print(f"WARNING: Couldn't read {f}: {e}", file=sys.stderr) | |
| continue | |
| # 1️⃣ Decode the bytes – errors become � so we can safely strip them later | |
| decoded = content_bytes.decode("utf-8", errors="replace") | |
| # 2️⃣ Remove any characters that are not allowed in a URL | |
| cleaned = NON_URL_CHARS_RE.sub(" ", decoded) | |
| match = URL_PATTERN.search(cleaned) | |
| if match: | |
| return match.group(0), f | |
| return None, None | |
| """Run the extraction pipeline and copy the URL to the clipboard.""" | |
| if self.game == "zenless": | |
| candidate_files = [] | |
| cache_dir = find_cache_dir() | |
| candidate_files = list(candidate_files(cache_dir)) | |
| if not candidate_files: | |
| self._error( | |
| "Couldn't find any Genshin network cache files!\n" | |
| "Are you sure you opened the wishing history screen recently?" | |
| ) | |
| print(f"# candidate files: {len(candidate_files)}\n") | |
| url, matched_file = extract_url(candidate_files) | |
| return url, matched_file | |
| def copy_to_clipboard(self, url: str) -> None: | |
| """Copy the given URL to macOS clipboard via `pbcopy`.""" | |
| try: | |
| subprocess.run( | |
| ["pbcopy"], | |
| input=url.encode("utf-8"), | |
| check=True, | |
| ) | |
| except Exception as e: | |
| print(f"WARNING: Failed to copy URL to clipboard: {e}", file=sys.stderr) | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| @staticmethod | |
| def _error(message: str, exit_code: int = 1) -> None: | |
| """Print an error message and exit.""" | |
| print(f"ERROR: {message}\n", file=sys.stderr) | |
| sys.exit(exit_code) | |
| # ------------------------------------------------------------------------------- | |
| # CLI handling – keeps the script runnable from the command line | |
| # ------------------------------------------------------------------------------- | |
| def build_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| description="Extract wish‑history URL from WebKit cache." | |
| ) | |
| parser.add_argument( | |
| "--game", | |
| choices=["genshin", "hsr", "wutheringwaves", "zenless"], | |
| # default="genshin", | |
| help="genshin (Genshin Impact)/hsr (Honkai Star Rail)/wutheringwaves (Wuthering Waves -- macOS version)/zenless (Zenless Zone Zero)", | |
| ) | |
| return parser | |
| def main() -> None: | |
| args = build_parser().parse_args() | |
| extractor = WishExtractor(game=args.game) | |
| extractor.run() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment