Hearthstone deck tracker for Linux, first public version

Reads the game's own Power.log: no screen capture, no image
recognition, no memory reading.

- deck tracking (remaining cards, draw chance, played cards fade in place)
- opponent panel (revealed cards, hand and deck counts)
- decks read from the game's offline cache, matched to the match
- card art in rows, full card on hover
- overlay and window modes, adjustable transparency, tray icon
- English and Turkish interface
- consistency tests over a real log corpus, cross-checked against Zone.log
This commit is contained in:
yusufipk
2026-07-27 22:31:13 +07:00
commit c2a5a2d4d2
41 changed files with 4521 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Kart verisi: HearthstoneJSON'dan indirir, diske cache'ler.
Not: api.hearthstonejson.com varsayılan urllib User-Agent'ına 403 dönüyor,
bu yüzden kendi UA'mızı göndermek zorunlu.
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
CARDS_URL = "https://api.hearthstonejson.com/v1/latest/enUS/cards.json"
USER_AGENT = "deste/0.1 (kisisel hearthstone tracker; +local)"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "deste"
CARDS_PATH = CACHE_DIR / "cards.json"
MAX_AGE_SECONDS = 7 * 24 * 3600
def _download(url: str, timeout: int = 60) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def cache_is_stale(path: Path = CARDS_PATH, max_age: int = MAX_AGE_SECONDS) -> bool:
if not path.exists():
return True
return (time.time() - path.stat().st_mtime) > max_age
def update_cache(force: bool = False) -> bool:
"""Kart verisini indirir. Ağ yoksa mevcut cache korunur."""
if not force and not cache_is_stale():
return True
CACHE_DIR.mkdir(parents=True, exist_ok=True)
try:
payload = _download(CARDS_URL)
except (urllib.error.URLError, TimeoutError, OSError):
return CARDS_PATH.exists()
tmp = CARDS_PATH.with_suffix(".tmp")
tmp.write_bytes(payload)
tmp.replace(CARDS_PATH)
return True
class CardDB:
"""Kart kimliğinden isim, mana, sınıf gibi alanlara erişim."""
def __init__(self, cards: list[dict]):
self.by_id: dict[str, dict] = {c["id"]: c for c in cards if "id" in c}
self.by_dbf: dict[int, dict] = {c["dbfId"]: c for c in cards if "dbfId" in c}
@classmethod
def load(cls, path: Path = CARDS_PATH, auto_update: bool = True) -> "CardDB":
if auto_update:
update_cache()
if not path.exists():
raise FileNotFoundError(
f"Kart verisi yok: {path}. İnternete bağlanıp tekrar deneyin."
)
return cls(json.loads(path.read_text(encoding="utf-8")))
def get(self, card_id: str) -> dict:
return self.by_id.get(card_id, {})
def name(self, card_id: str) -> str:
return self.get(card_id).get("name", card_id or "?")
def cost(self, card_id: str) -> int:
return self.get(card_id).get("cost", 0)
def card_class(self, card_id: str) -> str:
card = self.get(card_id)
classes = card.get("classes")
if classes:
return classes[0]
return card.get("cardClass", "")
def rarity(self, card_id: str) -> str:
return self.get(card_id).get("rarity", "")
def card_type(self, card_id: str) -> str:
return self.get(card_id).get("type", "")
def dbf_id(self, card_id: str) -> int | None:
return self.get(card_id).get("dbfId")
def id_from_dbf(self, dbf_id: int) -> str:
return self.by_dbf.get(dbf_id, {}).get("id", "")
def collectible_by_dbf(self, dbf_id: int) -> dict:
return self.by_dbf.get(dbf_id, {})