doubletap 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- doubletap/__init__.py +1 -0
- doubletap/analysis.py +222 -0
- doubletap/archidekt.py +330 -0
- doubletap/cli.py +1089 -0
- doubletap/db.py +67 -0
- doubletap/decks.py +281 -0
- doubletap/formats.py +433 -0
- doubletap/ml/__init__.py +0 -0
- doubletap/ml/data.py +259 -0
- doubletap/ml/eval.py +9 -0
- doubletap/ml/infer_np.py +84 -0
- doubletap/ml/model.py +143 -0
- doubletap/ml/neighbors.py +60 -0
- doubletap/ml/policy.py +126 -0
- doubletap/ml/reward.py +129 -0
- doubletap/ml/train_bc.py +93 -0
- doubletap/ml/train_cql.py +141 -0
- doubletap/names.py +58 -0
- doubletap/ocr.py +40 -0
- doubletap/scryfall.py +112 -0
- doubletap/static/index.html +1014 -0
- doubletap/web.py +311 -0
- doubletap-0.1.0.dist-info/METADATA +540 -0
- doubletap-0.1.0.dist-info/RECORD +27 -0
- doubletap-0.1.0.dist-info/WHEEL +4 -0
- doubletap-0.1.0.dist-info/entry_points.txt +2 -0
- doubletap-0.1.0.dist-info/licenses/LICENSE +21 -0
doubletap/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
doubletap/analysis.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Deck-level analysis: functional card roles (the "how does this deck win"
|
|
2
|
+
breakdown), mana curve and color balance, and market prices (budget
|
|
3
|
+
constraints). Heuristics run on Scryfall oracle text — approximate by design,
|
|
4
|
+
good enough to spot structural gaps. The gap list this implements is
|
|
5
|
+
documented in docs/gameplay-blindspots.md."""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
# --- Market prices -----------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def card_price(card: dict) -> float | None:
|
|
17
|
+
"""Cheapest available USD finish from Scryfall, None when unpriced
|
|
18
|
+
(digital-only or brand-new cards)."""
|
|
19
|
+
prices = card.get("prices") or {}
|
|
20
|
+
values = [
|
|
21
|
+
float(p)
|
|
22
|
+
for p in (prices.get("usd"), prices.get("usd_foil"), prices.get("usd_etched"))
|
|
23
|
+
if p
|
|
24
|
+
]
|
|
25
|
+
return min(values) if values else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- Functional roles --------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# Each pattern runs case-insensitively against the card's combined oracle text.
|
|
31
|
+
_ROLE_PATTERNS = {
|
|
32
|
+
"ramp": re.compile(
|
|
33
|
+
r"add \{|search your library for (?:a|up to \w+) (?:basic )?land", re.I
|
|
34
|
+
),
|
|
35
|
+
"draw": re.compile(r"draws? (?:a|two|three|four|x) cards?", re.I),
|
|
36
|
+
"removal": re.compile(
|
|
37
|
+
r"destroy target|exile target|counter target"
|
|
38
|
+
r"|deals? \d+ damage to (?:any target|target creature|target planeswalker)"
|
|
39
|
+
r"|target creature gets? [-—]\d+/[-—]\d+",
|
|
40
|
+
re.I,
|
|
41
|
+
),
|
|
42
|
+
"board_wipe": re.compile(
|
|
43
|
+
r"destroy all|exile all|deals? \d+ damage to each creature"
|
|
44
|
+
r"|all creatures get [-—]\d+/[-—]\d+",
|
|
45
|
+
re.I,
|
|
46
|
+
),
|
|
47
|
+
"wincon": re.compile(r"you win the game|each opponent loses the game", re.I),
|
|
48
|
+
"mill": re.compile(
|
|
49
|
+
r"\bmills?\b"
|
|
50
|
+
r"|puts? the top .{0,30} cards? of (?:their|that player's|each player's)"
|
|
51
|
+
r" library into (?:their|its owner's) graveyard",
|
|
52
|
+
re.I,
|
|
53
|
+
),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# "search your library for <what>" is a tutor unless <what> is a land (that's
|
|
57
|
+
# ramp / mana fixing, already counted)
|
|
58
|
+
_TUTOR_RE = re.compile(r"search your library for ([^.\n]*)", re.I)
|
|
59
|
+
|
|
60
|
+
EVASION_KEYWORDS = frozenset(
|
|
61
|
+
{"Flying", "Trample", "Menace", "Fear", "Intimidate", "Shadow", "Skulk"}
|
|
62
|
+
)
|
|
63
|
+
POISON_KEYWORDS = frozenset({"Infect", "Toxic"})
|
|
64
|
+
|
|
65
|
+
BIG_THREAT_POWER = 5
|
|
66
|
+
|
|
67
|
+
# Community consensus quotas for a functional Commander deck. Not rules —
|
|
68
|
+
# a starting point for spotting gaps.
|
|
69
|
+
COMMANDER_TARGETS = {
|
|
70
|
+
"lands": 36,
|
|
71
|
+
"ramp": 10,
|
|
72
|
+
"draw": 10,
|
|
73
|
+
"removal": 10,
|
|
74
|
+
"board_wipe": 3,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _oracle_text(card: dict) -> str:
|
|
79
|
+
if "oracle_text" in card:
|
|
80
|
+
return card["oracle_text"]
|
|
81
|
+
return "\n".join(f.get("oracle_text", "") for f in card.get("card_faces", []))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _mana_cost(card: dict) -> str:
|
|
85
|
+
if card.get("mana_cost"):
|
|
86
|
+
return card["mana_cost"]
|
|
87
|
+
faces = card.get("card_faces") or []
|
|
88
|
+
return faces[0].get("mana_cost", "") if faces else ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _power(card: dict) -> int:
|
|
92
|
+
for source in (card, *card.get("card_faces", [])):
|
|
93
|
+
raw = source.get("power")
|
|
94
|
+
if raw and raw.isdigit():
|
|
95
|
+
return int(raw)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_land(card: dict) -> bool:
|
|
100
|
+
return "Land" in card["type_line"].split(" // ")[0]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def is_instant_speed(card: dict) -> bool:
|
|
104
|
+
"""Castable on other players' turns: an instant, or anything with flash."""
|
|
105
|
+
return "Instant" in card["type_line"].split("//")[0] or "Flash" in card.get(
|
|
106
|
+
"keywords", []
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def classify(card: dict) -> set[str]:
|
|
111
|
+
"""Which functional roles a card fills. A card can fill several
|
|
112
|
+
(e.g. a creature that ramps); lands are counted separately."""
|
|
113
|
+
if _is_land(card):
|
|
114
|
+
return {"land"}
|
|
115
|
+
text = _oracle_text(card)
|
|
116
|
+
keywords = set(card.get("keywords", []))
|
|
117
|
+
roles = {role for role, pat in _ROLE_PATTERNS.items() if pat.search(text)}
|
|
118
|
+
|
|
119
|
+
tutor_match = _TUTOR_RE.search(text)
|
|
120
|
+
if tutor_match and "land" not in tutor_match.group(1).casefold():
|
|
121
|
+
roles.add("tutor")
|
|
122
|
+
|
|
123
|
+
is_creature = "Creature" in card["type_line"]
|
|
124
|
+
# big creatures are a path to winning through combat even without
|
|
125
|
+
# explicit wincon text
|
|
126
|
+
if is_creature and _power(card) >= BIG_THREAT_POWER:
|
|
127
|
+
roles.add("threat")
|
|
128
|
+
if is_creature and (
|
|
129
|
+
keywords & EVASION_KEYWORDS or "can't be blocked" in text.casefold()
|
|
130
|
+
):
|
|
131
|
+
roles.add("evasive")
|
|
132
|
+
if keywords & POISON_KEYWORDS:
|
|
133
|
+
roles.add("poison")
|
|
134
|
+
# removal you can cast on opponents' turns is worth more than sorceries
|
|
135
|
+
if "removal" in roles and is_instant_speed(card):
|
|
136
|
+
roles.add("removal_instant")
|
|
137
|
+
return roles
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- Curve and color balance ---------------------------------------------------
|
|
141
|
+
|
|
142
|
+
CURVE_TOP_BUCKET = 7 # mana values 7+ share one histogram bucket
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class DeckReport:
|
|
147
|
+
by_role: dict[str, list[tuple[str, int]]] = field(default_factory=dict)
|
|
148
|
+
total_price: float = 0.0
|
|
149
|
+
unpriced: int = 0
|
|
150
|
+
curve: Counter = field(default_factory=Counter) # mv bucket -> nonland count
|
|
151
|
+
avg_mv: float = 0.0
|
|
152
|
+
early_plays: int = 0 # nonland cards with mv <= 2
|
|
153
|
+
pips: Counter = field(default_factory=Counter) # color -> symbols in costs
|
|
154
|
+
sources: Counter = field(default_factory=Counter) # color -> lands making it
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def deck_report(conn: sqlite3.Connection, entries: dict[str, int]) -> DeckReport:
|
|
158
|
+
"""Full structural report for a deck (oracle_id -> qty): roles, price,
|
|
159
|
+
mana curve, and colored-cost vs land-color balance."""
|
|
160
|
+
report = DeckReport()
|
|
161
|
+
total_nonland = 0
|
|
162
|
+
total_mv = 0.0
|
|
163
|
+
for oid, qty in entries.items():
|
|
164
|
+
row = conn.execute(
|
|
165
|
+
"SELECT name, json FROM cards WHERE oracle_id = ?", (oid,)
|
|
166
|
+
).fetchone()
|
|
167
|
+
if row is None:
|
|
168
|
+
continue
|
|
169
|
+
name, raw = row
|
|
170
|
+
card = json.loads(raw)
|
|
171
|
+
for role in classify(card):
|
|
172
|
+
report.by_role.setdefault(role, []).append((name, qty))
|
|
173
|
+
|
|
174
|
+
price = card_price(card)
|
|
175
|
+
if price is None:
|
|
176
|
+
report.unpriced += qty
|
|
177
|
+
else:
|
|
178
|
+
report.total_price += price * qty
|
|
179
|
+
|
|
180
|
+
if _is_land(card):
|
|
181
|
+
for color in card.get("produced_mana") or []:
|
|
182
|
+
if color in "WUBRG":
|
|
183
|
+
report.sources[color] += qty
|
|
184
|
+
else:
|
|
185
|
+
mv = card.get("cmc") or 0
|
|
186
|
+
report.curve[min(int(mv), CURVE_TOP_BUCKET)] += qty
|
|
187
|
+
total_nonland += qty
|
|
188
|
+
total_mv += mv * qty
|
|
189
|
+
if mv <= 2:
|
|
190
|
+
report.early_plays += qty
|
|
191
|
+
for symbol in re.findall(r"\{([^}]+)\}", _mana_cost(card)):
|
|
192
|
+
for color in "WUBRG":
|
|
193
|
+
if color in symbol:
|
|
194
|
+
report.pips[color] += qty
|
|
195
|
+
if total_nonland:
|
|
196
|
+
report.avg_mv = total_mv / total_nonland
|
|
197
|
+
return report
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def short_colors(report: DeckReport) -> list[str]:
|
|
201
|
+
"""Colors whose share of land sources falls well below their share of
|
|
202
|
+
mana symbols — hands with those spells won't be castable on time."""
|
|
203
|
+
pip_total = sum(report.pips.values())
|
|
204
|
+
source_total = sum(report.sources.values())
|
|
205
|
+
if not pip_total or not source_total:
|
|
206
|
+
return []
|
|
207
|
+
short = []
|
|
208
|
+
for color, n in report.pips.items():
|
|
209
|
+
need = n / pip_total
|
|
210
|
+
have = report.sources.get(color, 0) / source_total
|
|
211
|
+
if need > 0.1 and have < need * 0.6:
|
|
212
|
+
short.append(color)
|
|
213
|
+
return short
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def analyze_deck(
|
|
217
|
+
conn: sqlite3.Connection, entries: dict[str, int]
|
|
218
|
+
) -> tuple[dict[str, list[tuple[str, int]]], float, int]:
|
|
219
|
+
"""Classify every card in a deck (oracle_id -> qty). Returns
|
|
220
|
+
(role -> [(name, qty)], total_price_usd, n_unpriced)."""
|
|
221
|
+
report = deck_report(conn, entries)
|
|
222
|
+
return report.by_role, report.total_price, report.unpriced
|
doubletap/archidekt.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import gzip
|
|
2
|
+
import json
|
|
3
|
+
import random
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .db import data_home
|
|
13
|
+
from .decks import Deck
|
|
14
|
+
from .formats import get_format, validate
|
|
15
|
+
|
|
16
|
+
SEARCH_URL = "https://archidekt.com/api/decks/v3/"
|
|
17
|
+
DECK_URL = "https://archidekt.com/api/decks/{deck_id}/"
|
|
18
|
+
USER_AGENT = "DoubleTap/0.1 (joshua.magana@gmail.com)"
|
|
19
|
+
|
|
20
|
+
# verified empirically 2026-07-04: deckFormat=2 returns Modern decks,
|
|
21
|
+
# deckFormat=3 returns Commander decks (7 is Custom — do not trust folklore ids)
|
|
22
|
+
FORMAT_IDS = {"commander": 3, "modern": 2}
|
|
23
|
+
|
|
24
|
+
_DROPPED_CATEGORIES = {
|
|
25
|
+
"sideboard",
|
|
26
|
+
"maybeboard",
|
|
27
|
+
"considering",
|
|
28
|
+
"wishlist",
|
|
29
|
+
"tokens",
|
|
30
|
+
# the companion sits outside the starting deck; v1 corpus ignores it
|
|
31
|
+
"companion",
|
|
32
|
+
}
|
|
33
|
+
MAX_UNRESOLVED_FRACTION = 0.02
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimiter:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
interval: float = 1.0,
|
|
40
|
+
jitter: float = 0.3,
|
|
41
|
+
sleep=time.sleep,
|
|
42
|
+
clock=time.monotonic,
|
|
43
|
+
):
|
|
44
|
+
self.interval = interval
|
|
45
|
+
self.jitter = jitter
|
|
46
|
+
self.sleep = sleep
|
|
47
|
+
self.clock = clock
|
|
48
|
+
self._last: float | None = None
|
|
49
|
+
|
|
50
|
+
def wait(self) -> None:
|
|
51
|
+
if self._last is not None:
|
|
52
|
+
delay = (
|
|
53
|
+
self.interval
|
|
54
|
+
+ random.uniform(0, self.jitter)
|
|
55
|
+
- (self.clock() - self._last)
|
|
56
|
+
)
|
|
57
|
+
if delay > 0:
|
|
58
|
+
self.sleep(delay)
|
|
59
|
+
self._last = self.clock()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_client() -> httpx.Client:
|
|
63
|
+
return httpx.Client(
|
|
64
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
65
|
+
timeout=60,
|
|
66
|
+
follow_redirects=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_json(
|
|
71
|
+
client: httpx.Client,
|
|
72
|
+
limiter: RateLimiter,
|
|
73
|
+
url: str,
|
|
74
|
+
params: dict | None = None,
|
|
75
|
+
max_retries: int = 5,
|
|
76
|
+
):
|
|
77
|
+
"""GET with rate limiting and exponential backoff on 429/5xx and transport
|
|
78
|
+
failures (dropped connections, timeouts); hard stop after retries."""
|
|
79
|
+
for attempt in range(max_retries):
|
|
80
|
+
limiter.wait()
|
|
81
|
+
try:
|
|
82
|
+
resp = client.get(url, params=params)
|
|
83
|
+
except httpx.TransportError:
|
|
84
|
+
limiter.sleep(2**attempt)
|
|
85
|
+
continue
|
|
86
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
87
|
+
limiter.sleep(2**attempt)
|
|
88
|
+
continue
|
|
89
|
+
resp.raise_for_status()
|
|
90
|
+
return resp.json()
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
f"giving up on {url} after {max_retries} attempts (rate limited?)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def discover(
|
|
97
|
+
conn: sqlite3.Connection,
|
|
98
|
+
client: httpx.Client,
|
|
99
|
+
limiter: RateLimiter,
|
|
100
|
+
format_name: str,
|
|
101
|
+
max_decks: int,
|
|
102
|
+
order_by: str = "-viewCount",
|
|
103
|
+
) -> int:
|
|
104
|
+
"""Queue deck ids from the search API. Idempotent.
|
|
105
|
+
|
|
106
|
+
Pages are requested explicitly: the API's `next` link goes null around
|
|
107
|
+
page 100 (~6k decks) but direct page access keeps returning results far
|
|
108
|
+
deeper (verified to page 700), so `next` cannot be trusted for large
|
|
109
|
+
crawls. Stops on the first empty page.
|
|
110
|
+
|
|
111
|
+
The max_decks bound counts entries *seen*, not newly inserted — a resumed
|
|
112
|
+
crawl re-walks the same pages, and counting inserts would keep it paging
|
|
113
|
+
until it found max_decks decks it had never seen. max_decks=0 skips
|
|
114
|
+
discovery entirely (fetch the existing queue only)."""
|
|
115
|
+
fmt_id = FORMAT_IDS[format_name]
|
|
116
|
+
queued = 0
|
|
117
|
+
seen = 0
|
|
118
|
+
page = 1
|
|
119
|
+
while seen < max_decks:
|
|
120
|
+
data = get_json(
|
|
121
|
+
client,
|
|
122
|
+
limiter,
|
|
123
|
+
SEARCH_URL,
|
|
124
|
+
params={"deckFormat": fmt_id, "orderBy": order_by, "page": page},
|
|
125
|
+
)
|
|
126
|
+
results = data.get("results", [])
|
|
127
|
+
if not results:
|
|
128
|
+
break
|
|
129
|
+
for entry in results:
|
|
130
|
+
cur = conn.execute(
|
|
131
|
+
"INSERT OR IGNORE INTO decks (deck_id, source, format, url, status) VALUES (?, 'archidekt', ?, ?, 'queued')",
|
|
132
|
+
(
|
|
133
|
+
entry["id"],
|
|
134
|
+
format_name,
|
|
135
|
+
f"https://archidekt.com/decks/{entry['id']}/",
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
queued += cur.rowcount
|
|
139
|
+
seen += 1
|
|
140
|
+
if seen >= max_decks:
|
|
141
|
+
break
|
|
142
|
+
page += 1
|
|
143
|
+
conn.commit()
|
|
144
|
+
return queued
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def trim_deck(raw: dict, format_name: str) -> dict:
|
|
148
|
+
"""Reduce a deck API response to the fields the corpus needs."""
|
|
149
|
+
cards = []
|
|
150
|
+
for entry in raw.get("cards", []):
|
|
151
|
+
oracle = entry["card"].get("oracleCard") or {}
|
|
152
|
+
cards.append(
|
|
153
|
+
{
|
|
154
|
+
"qty": entry.get("quantity", 1),
|
|
155
|
+
"oracle_id": oracle.get("uid"),
|
|
156
|
+
"name": oracle.get("name"),
|
|
157
|
+
"categories": entry.get("categories") or [],
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
return {
|
|
161
|
+
"id": raw["id"],
|
|
162
|
+
"name": raw.get("name"),
|
|
163
|
+
"format": format_name,
|
|
164
|
+
"cards": cards,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def shard_path(format_name: str) -> Path:
|
|
169
|
+
path = data_home() / "corpus" / "raw" / f"{format_name}.jsonl.gz"
|
|
170
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
return path
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def fetch_queued(
|
|
175
|
+
conn: sqlite3.Connection,
|
|
176
|
+
client: httpx.Client,
|
|
177
|
+
limiter: RateLimiter,
|
|
178
|
+
format_name: str,
|
|
179
|
+
progress=None,
|
|
180
|
+
) -> int:
|
|
181
|
+
"""Fetch queued decks, appending trimmed records to the raw shard. Resumable:
|
|
182
|
+
already-fetched decks are never requested again. Decks the API refuses with
|
|
183
|
+
a 4xx (deleted/private) are marked 'gone' and skipped — one dead deck must
|
|
184
|
+
not kill a large crawl."""
|
|
185
|
+
rows = conn.execute(
|
|
186
|
+
"SELECT deck_id FROM decks WHERE source = 'archidekt' AND format = ? AND status = 'queued'",
|
|
187
|
+
(format_name,),
|
|
188
|
+
).fetchall()
|
|
189
|
+
fetched = 0
|
|
190
|
+
path = shard_path(format_name)
|
|
191
|
+
for (deck_id,) in rows:
|
|
192
|
+
try:
|
|
193
|
+
raw = get_json(client, limiter, DECK_URL.format(deck_id=deck_id))
|
|
194
|
+
except httpx.HTTPStatusError as e:
|
|
195
|
+
if 400 <= e.response.status_code < 500:
|
|
196
|
+
conn.execute(
|
|
197
|
+
"UPDATE decks SET status = 'gone', fetched_at = datetime('now') WHERE deck_id = ?",
|
|
198
|
+
(deck_id,),
|
|
199
|
+
)
|
|
200
|
+
conn.commit()
|
|
201
|
+
continue
|
|
202
|
+
raise
|
|
203
|
+
with gzip.open(path, "at", encoding="utf-8") as f:
|
|
204
|
+
f.write(json.dumps(trim_deck(raw, format_name), ensure_ascii=False) + "\n")
|
|
205
|
+
conn.execute(
|
|
206
|
+
"UPDATE decks SET status = 'fetched', fetched_at = datetime('now') WHERE deck_id = ?",
|
|
207
|
+
(deck_id,),
|
|
208
|
+
)
|
|
209
|
+
conn.commit()
|
|
210
|
+
fetched += 1
|
|
211
|
+
if progress:
|
|
212
|
+
progress(fetched, len(rows))
|
|
213
|
+
return fetched
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_trimmed(conn: sqlite3.Connection, trimmed: dict) -> tuple[Deck | None, str]:
|
|
217
|
+
"""Build a validated Deck from a trimmed record; (None, reason) on rejection."""
|
|
218
|
+
fmt = get_format(trimmed["format"])
|
|
219
|
+
deck = Deck(format=fmt.name)
|
|
220
|
+
commanders = []
|
|
221
|
+
unresolved = 0
|
|
222
|
+
total = 0
|
|
223
|
+
known = set()
|
|
224
|
+
for card in trimmed["cards"]:
|
|
225
|
+
categories = {c.casefold() for c in card["categories"]}
|
|
226
|
+
if categories & _DROPPED_CATEGORIES:
|
|
227
|
+
continue
|
|
228
|
+
total += card["qty"]
|
|
229
|
+
oid = card["oracle_id"]
|
|
230
|
+
if oid not in known:
|
|
231
|
+
exists = conn.execute(
|
|
232
|
+
"SELECT 1 FROM cards WHERE oracle_id = ?", (oid,)
|
|
233
|
+
).fetchone()
|
|
234
|
+
if not exists:
|
|
235
|
+
unresolved += card["qty"]
|
|
236
|
+
continue
|
|
237
|
+
known.add(oid)
|
|
238
|
+
if "commander" in categories:
|
|
239
|
+
commanders.append(oid)
|
|
240
|
+
else:
|
|
241
|
+
deck.entries[oid] += card["qty"]
|
|
242
|
+
if total == 0:
|
|
243
|
+
return None, "empty"
|
|
244
|
+
if unresolved / total > MAX_UNRESOLVED_FRACTION:
|
|
245
|
+
return None, "unresolved_cards"
|
|
246
|
+
if fmt.requires_commander:
|
|
247
|
+
if len(commanders) == 1:
|
|
248
|
+
deck.commander = commanders[0]
|
|
249
|
+
elif len(commanders) == 2:
|
|
250
|
+
# Accept partner commanders: both must carry a Partner keyword
|
|
251
|
+
def _has_partner(oid):
|
|
252
|
+
row = conn.execute(
|
|
253
|
+
"SELECT json FROM cards WHERE oracle_id = ?", (oid,)
|
|
254
|
+
).fetchone()
|
|
255
|
+
if not row:
|
|
256
|
+
return False
|
|
257
|
+
card = json.loads(row[0])
|
|
258
|
+
return any(k.startswith("Partner") for k in card.get("keywords", []))
|
|
259
|
+
|
|
260
|
+
if _has_partner(commanders[0]) and _has_partner(commanders[1]):
|
|
261
|
+
deck.commander = commanders[0]
|
|
262
|
+
deck.partner = commanders[1]
|
|
263
|
+
else:
|
|
264
|
+
return None, "commander_count"
|
|
265
|
+
else:
|
|
266
|
+
return None, "commander_count"
|
|
267
|
+
elif commanders:
|
|
268
|
+
return None, "unexpected_commander"
|
|
269
|
+
if validate(conn, deck):
|
|
270
|
+
return None, "invalid"
|
|
271
|
+
return deck, "ok"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def parse_shards(conn: sqlite3.Connection, format_name: str) -> Counter:
|
|
275
|
+
"""(Re)parse the raw shard into decks/deck_cards. Only fetched/parsed/rejected
|
|
276
|
+
rows are touched, so this can rebuild tables without re-crawling."""
|
|
277
|
+
outcomes = Counter()
|
|
278
|
+
path = shard_path(format_name)
|
|
279
|
+
if not path.exists():
|
|
280
|
+
return outcomes
|
|
281
|
+
with gzip.open(path, "rt", encoding="utf-8") as f:
|
|
282
|
+
for line in f:
|
|
283
|
+
trimmed = json.loads(line)
|
|
284
|
+
deck, reason = parse_trimmed(conn, trimmed)
|
|
285
|
+
outcomes[reason] += 1
|
|
286
|
+
deck_id = trimmed["id"]
|
|
287
|
+
conn.execute("DELETE FROM deck_cards WHERE deck_id = ?", (deck_id,))
|
|
288
|
+
if deck is None:
|
|
289
|
+
conn.execute(
|
|
290
|
+
"UPDATE decks SET status = 'rejected' WHERE deck_id = ?", (deck_id,)
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
conn.execute(
|
|
294
|
+
"UPDATE decks SET status = 'parsed', commander_oracle_id = ?, partner_oracle_id = ? WHERE deck_id = ?",
|
|
295
|
+
(deck.commander, deck.partner, deck_id),
|
|
296
|
+
)
|
|
297
|
+
conn.executemany(
|
|
298
|
+
"INSERT OR REPLACE INTO deck_cards (deck_id, oracle_id, qty) VALUES (?, ?, ?)",
|
|
299
|
+
[(deck_id, oid, qty) for oid, qty in deck.entries.items()],
|
|
300
|
+
)
|
|
301
|
+
for cmd_oid in (deck.commander, deck.partner):
|
|
302
|
+
if cmd_oid:
|
|
303
|
+
conn.execute(
|
|
304
|
+
"INSERT OR REPLACE INTO deck_cards (deck_id, oracle_id, qty) VALUES (?, ?, 1)",
|
|
305
|
+
(deck_id, cmd_oid),
|
|
306
|
+
)
|
|
307
|
+
conn.commit()
|
|
308
|
+
return outcomes
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def crawl(
|
|
312
|
+
conn: sqlite3.Connection,
|
|
313
|
+
format_name: str,
|
|
314
|
+
max_decks: int,
|
|
315
|
+
client: httpx.Client | None = None,
|
|
316
|
+
limiter: RateLimiter | None = None,
|
|
317
|
+
progress=None,
|
|
318
|
+
order_by: str = "-viewCount",
|
|
319
|
+
) -> Counter:
|
|
320
|
+
get_format(format_name) # fail fast on unknown formats
|
|
321
|
+
own_client = client is None
|
|
322
|
+
client = client or make_client()
|
|
323
|
+
limiter = limiter or RateLimiter()
|
|
324
|
+
try:
|
|
325
|
+
discover(conn, client, limiter, format_name, max_decks, order_by=order_by)
|
|
326
|
+
fetch_queued(conn, client, limiter, format_name, progress=progress)
|
|
327
|
+
return parse_shards(conn, format_name)
|
|
328
|
+
finally:
|
|
329
|
+
if own_client:
|
|
330
|
+
client.close()
|