archivedive 0.1.0__tar.gz

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.
@@ -0,0 +1,40 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebSearch",
5
+ "WebFetch(domain:api-docs.gatcg.com)",
6
+ "WebFetch(domain:scryfall.com)",
7
+ "Bash(uv --version)",
8
+ "Bash(uv sync *)",
9
+ "Bash(git init *)",
10
+ "Skill(git-commit)",
11
+ "Bash(git add *)",
12
+ "Bash(git commit *)",
13
+ "Bash(uv run *)",
14
+ "Bash(git restore *)",
15
+ "Bash(uv add *)",
16
+ "Bash(uv remove *)",
17
+ "Bash(uv pip *)",
18
+ "WebFetch(domain:index.gatcg.com)",
19
+ "WebFetch(domain:rules.gatcg.com)",
20
+ "WebFetch(domain:www.gatcg.com)",
21
+ "Read(//home/celeryjro/Documents/Projects/ga-archivedive/ga_archivedive/**)",
22
+ "Read(//home/celeryjro/Documents/Projects/ga-archivedive/**)",
23
+ "Bash(python3 *)",
24
+ "Bash(.venv/bin/pytest *)",
25
+ "Bash(.venv/bin/python *)",
26
+ "Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=CLERIC&per_page=3\")",
27
+ "Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=MAGE&per_page=3\")",
28
+ "Bash(curl -s \"https://api.gatcg.com/cards/search?per_page=1\")",
29
+ "Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=CLERIC&per_page=1\")",
30
+ "Bash(curl -s \"https://api.gatcg.com/cards/search?subtype=MAGE&per_page=1\")",
31
+ "Bash(python *)",
32
+ "WebFetch(domain:github.com)",
33
+ "WebFetch(domain:textual.textualize.io)",
34
+ "WebFetch(domain:raw.githubusercontent.com)",
35
+ "WebFetch(domain:darren.codes)",
36
+ "Bash(gh issue *)",
37
+ "WebFetch(domain:gitlab.gnome.org)"
38
+ ]
39
+ }
40
+ }
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .pytest_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .DS_Store
9
+ REVIEW.md
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: archivedive
3
+ Version: 0.1.0
4
+ Summary: A Scryfall-like TUI card browser for Grand Archive TCG
5
+ Author-email: Thi Dinh <vietthidinh2001@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: platformdirs>=4.0
10
+ Requires-Dist: pydantic>=2.0
11
+ Requires-Dist: textual>=8.2.7
12
+ Description-Content-Type: text/markdown
13
+
14
+ # ArchiveDive
15
+
16
+ A terminal card browser for [Grand Archive TCG](https://www.gatcg.com/), inspired by [Scryfall](https://scryfall.com).
17
+
18
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue)
19
+
20
+ ## Install
21
+
22
+ ```
23
+ pip install archivedive
24
+ ```
25
+
26
+ or with uv (no install needed):
27
+
28
+ ```
29
+ uvx archivedive
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```
35
+ archivedive
36
+ ```
37
+
38
+ ### Key bindings
39
+
40
+ | Key | Action |
41
+ | -------- | --------------------------- |
42
+ | `s` | Focus search bar |
43
+ | `F1` | Search syntax help |
44
+ | `c` | Copy card text to clipboard |
45
+ | `o` | Open card image in browser |
46
+ | `ctrl+o` | Select art edition |
47
+ | `r` | Show related cards |
48
+ | `ctrl+<` | Previous page |
49
+ | `ctrl+>` | Next page |
50
+ | `ctrl+c` | Copy search bar text |
51
+ | `ctrl+q` | Quit |
52
+
53
+ Clipboard copy requires `wl-clipboard` (Wayland), `xclip`, or `xsel` on Linux.
54
+
55
+ ## Search syntax
56
+
57
+ Filters use `key:value` format and combine with `and`. Element filters use `or`.
58
+
59
+ ```
60
+ silvie search by name
61
+ t:ally e:fire cost:2 fire ally costing 2
62
+ class:mage o:banish -r:common mage with banish, not common
63
+ e:fire or e:water fire or water element
64
+ t:champion legal:standard standard-legal champions
65
+ sort:rarity order:desc sort by rarity descending
66
+ ```
67
+
68
+ See [SEARCH_SYNTAX.md](SEARCH_SYNTAX.md) for the full reference.
@@ -0,0 +1,55 @@
1
+ # ArchiveDive
2
+
3
+ A terminal card browser for [Grand Archive TCG](https://www.gatcg.com/), inspired by [Scryfall](https://scryfall.com).
4
+
5
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue)
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install archivedive
11
+ ```
12
+
13
+ or with uv (no install needed):
14
+
15
+ ```
16
+ uvx archivedive
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```
22
+ archivedive
23
+ ```
24
+
25
+ ### Key bindings
26
+
27
+ | Key | Action |
28
+ | -------- | --------------------------- |
29
+ | `s` | Focus search bar |
30
+ | `F1` | Search syntax help |
31
+ | `c` | Copy card text to clipboard |
32
+ | `o` | Open card image in browser |
33
+ | `ctrl+o` | Select art edition |
34
+ | `r` | Show related cards |
35
+ | `ctrl+<` | Previous page |
36
+ | `ctrl+>` | Next page |
37
+ | `ctrl+c` | Copy search bar text |
38
+ | `ctrl+q` | Quit |
39
+
40
+ Clipboard copy requires `wl-clipboard` (Wayland), `xclip`, or `xsel` on Linux.
41
+
42
+ ## Search syntax
43
+
44
+ Filters use `key:value` format and combine with `and`. Element filters use `or`.
45
+
46
+ ```
47
+ silvie search by name
48
+ t:ally e:fire cost:2 fire ally costing 2
49
+ class:mage o:banish -r:common mage with banish, not common
50
+ e:fire or e:water fire or water element
51
+ t:champion legal:standard standard-legal champions
52
+ sort:rarity order:desc sort by rarity descending
53
+ ```
54
+
55
+ See [SEARCH_SYNTAX.md](SEARCH_SYNTAX.md) for the full reference.
@@ -0,0 +1,201 @@
1
+ # ArchiveDive Search Syntax
2
+
3
+ ## Basic search
4
+
5
+ Plain text searches by card name (fuzzy match).
6
+
7
+ silvie
8
+ dungeon guide
9
+
10
+ ---
11
+
12
+ ## Keyword filters
13
+
14
+ Filters use the format `key:value`. Multiple filters are combined with `and`.
15
+
16
+ | Key | Aliases | Description | Example |
17
+ | --------- | ------------------------- | ------------------------------- | ------------------ |
18
+ | `name:` | (plain text) | Card name | `silvie` |
19
+ | `o:` | `effect:` | Effect text (any edition) | `o:banish` |
20
+ | `oc:` | `oracle:` | Effect text (canonical only) | `oc:banish` |
21
+ | `kw:` | `keyword:` | Keyword ability (exact match) | `kw:stealth` |
22
+ | `rule:` | | Rule text (title or body) | `rule:graveyard` |
23
+ | `flavor:` | | Flavor text | `flavor:silvie` |
24
+ | `ill:` | `illustrator:` | Illustrator name (fuzzy) | `ill:dragonart` |
25
+ | `t:` | `type:` `sub:` `subtype:` | Type or subtype (searches both) | `t:ally` `t:human` |
26
+ | `class:` | `cl:` | Class | `class:mage` |
27
+ | `e:` | `element:` | Element (see below) | `e:fire` |
28
+ | `r:` | `rarity:` | Rarity (see below) | `r:rare` |
29
+ | `set:` | `s:` | Set prefix code | `set:DOA` |
30
+ | `cost:` | `c:` | Memory or reserve cost (either) | `cost:3` |
31
+ | `m:` | `memory:` | Memory cost only | `m:3` |
32
+ | `res:` | `reserve:` | Reserve cost only | `res:2` |
33
+ | `legal:` | | Legal in format | `legal:standard` |
34
+ | `banned:` | | Banned in format | `banned:standard` |
35
+ | `speed:` | | Speed: fast or slow | `speed:fast` |
36
+ | `is:` | | Flags (see below) | `is:material` |
37
+ | `pow:` | `power:` | Power | `pow:3` |
38
+ | `life:` | | Life | `life:4` |
39
+ | `dur:` | `durability:` | Durability | `dur:2` |
40
+ | `lvl:` | `level:` | Level | `lvl:2` |
41
+
42
+ ---
43
+
44
+ ## Flags (is:)
45
+
46
+ | Flag | Description | Expands to |
47
+ | -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
48
+ | `is:material` | Material deck cards | `t:champion or t:regalia` |
49
+ | `is:permanent` | Cards that stay on the field | `t:ally or t:champion or t:item or t:weapon or t:token or t:status or t:regalia or t:phantasia or t:mastery` |
50
+
51
+ ---
52
+
53
+ ## Sorting
54
+
55
+ Use `sort:` and `order:` to control result ordering.
56
+
57
+ | Key | Default | Values |
58
+ | -------- | ------- | ------------------------------------------------------------ |
59
+ | `sort:` | `name` | `name` `cost` `rarity` `power` `life` `level` `dur` `number` |
60
+ | `order:` | `asc` | `asc` `a` `desc` `d` |
61
+
62
+ Examples:
63
+
64
+ sort:rarity order:desc highest rarity first
65
+ sort:cost cheapest cards first
66
+ e:fire sort:power order:desc fire cards by power descending
67
+ t:champion sort:level champions sorted by level
68
+
69
+ ---
70
+
71
+ ## Quoting phrases
72
+
73
+ Wrap multi-word values in double quotes to search for an exact phrase.
74
+ Without quotes, spaces end the value and the rest is parsed as new tokens.
75
+
76
+ o:"on enter" effect contains the phrase "on enter"
77
+ o:"banish from memory" exact phrase in effect text
78
+ name:"silvie" works but unnecessary for single words
79
+ ill:dragonart illustrator name
80
+
81
+ ---
82
+
83
+ ## Operators (numeric fields only)
84
+
85
+ Supported: `=` (default), `>`, `<`, `>=`, `<=`
86
+
87
+ m>=3 memory cost 3 or more
88
+ pow<4 power less than 4
89
+ life=5 exactly 5 life
90
+ lvl<=2 level 2 or lower
91
+
92
+ ---
93
+
94
+ ## Negation
95
+
96
+ Prefix a filter with `-` to exclude it. Handled client-side — pagination
97
+ counts may differ slightly as results are filtered after fetching.
98
+
99
+ -e:fire not fire element
100
+ -t:champion not a champion
101
+ -r:common not common rarity
102
+
103
+ ---
104
+
105
+ ## OR logic
106
+
107
+ Use `or` or `OR` between filters. Handled client-side via two API calls
108
+ merged and deduplicated.
109
+
110
+ e:fire or e:water fire or water
111
+ e:fire or e:water same thing
112
+ t:ally or t:champion ally or champion
113
+ o:banish or o:memory effect mentions banish or memory
114
+
115
+ `or` binds more loosely than adjacent filters:
116
+
117
+ t:ally e:fire or t:champion (ally AND fire) or (champion)
118
+
119
+ Use parentheses to make explicit grouping:
120
+
121
+ (t:ally e:fire) or (t:champion e:water) explicit grouping
122
+ (t:ally or t:champion) e:fire fire ally or fire champion
123
+
124
+ ---
125
+
126
+ ## Combining filters
127
+
128
+ t:ally e:fire cost:2 fire ally costing 2 (memory or reserve)
129
+ t:human class:mage o:banish human mage with banish in effect
130
+ t:champion -e:norm champion with any element
131
+ e:fire or e:water -r:common fire or water, excluding commons
132
+ set:DOA legal:standard Dawn of Ashes cards legal in standard
133
+
134
+ ---
135
+
136
+ ## Elements
137
+
138
+ fire water wind crux norm
139
+ arcane astra tera umbra luxem
140
+ neos exia exalted
141
+
142
+ Aliases: fi=fire wa=water wi=wind cr=crux no=norm
143
+
144
+ ---
145
+
146
+ ## Rarities
147
+
148
+ common (c) uncommon (u) rare (r)
149
+ superrare (sr) ultrarare (ur) promo (pr)
150
+ collectorsuper (csr) collectorultra (cur) collectorpromo (cpr)
151
+
152
+ ---
153
+
154
+ ## Legality formats
155
+
156
+ Used with `legal:` and `banned:`:
157
+
158
+ standard (s) pantheon (p) draft (d)
159
+
160
+ legal:standard legal:s
161
+ banned:pantheon banned:p
162
+
163
+ ---
164
+
165
+ ## Keywords (kw:)
166
+
167
+ Common keywords (59 total — see rules.gatcg.com/glossary/keywords-and-abilities):
168
+
169
+ stealth taunt steadfast unblockable true sight
170
+ intercept cleave agility ambush on enter
171
+ on death on hit on attack on kill floating memory
172
+ vigor ranged empower bulwark immortality
173
+
174
+ `kw:` matches only cards that have the keyword — not cards that merely mention
175
+ it in their text. Handled client-side: fetches by effect text, then filters
176
+ to cards where the keyword appears as a standalone ability.
177
+
178
+ ---
179
+
180
+ ## Examples
181
+
182
+ silvie
183
+ t:ally e:fire cost:2
184
+ class:mage o:banish -r:common
185
+ t:champion legal:p lvl<=3
186
+ pow>=3 life>=3 t:human
187
+ o:memory speed:fast
188
+ set:DOA t:champion
189
+ banned:standard
190
+ legal:pantheon t:champion
191
+ is:material e:fire
192
+ is:permanent -t:champion
193
+ e:fire or e:water -r:common -r:uncommon
194
+ o:"on enter" t:ally class:mage
195
+ oc:"banish from memory"
196
+ kw:stealth t:ally
197
+ kw:taunt or kw:intercept
198
+ rule:graveyard
199
+ flavor:"courage"
200
+ ill:dragonart r:csr
201
+ is:material legal:s -r:common
File without changes
@@ -0,0 +1,301 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from platformdirs import user_cache_dir
11
+
12
+ from .models import Card, SearchResponse
13
+
14
+ BASE_URL = "https://api.gatcg.com"
15
+ CACHE_TTL = 3600 # 1 hour
16
+
17
+
18
+ def _cache_path() -> Path:
19
+ path = Path(user_cache_dir("ga-archivedive")) / "cache.db"
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ return path
22
+
23
+
24
+ class _Cache:
25
+ def __init__(self) -> None:
26
+ self._db = sqlite3.connect(_cache_path())
27
+ self._db.execute(
28
+ "CREATE TABLE IF NOT EXISTS cache "
29
+ "(key TEXT PRIMARY KEY, value TEXT, expires_at REAL)"
30
+ )
31
+ self._db.commit()
32
+
33
+ def get(self, key: str) -> Any | None:
34
+ row = self._db.execute(
35
+ "SELECT value, expires_at FROM cache WHERE key = ?", (key,)
36
+ ).fetchone()
37
+ if row is None:
38
+ return None
39
+ value, expires_at = row
40
+ if time.time() > expires_at:
41
+ self._db.execute("DELETE FROM cache WHERE key = ?", (key,))
42
+ self._db.commit()
43
+ return None
44
+ try:
45
+ return json.loads(value)
46
+ except json.JSONDecodeError:
47
+ self._db.execute("DELETE FROM cache WHERE key = ?", (key,))
48
+ self._db.commit()
49
+ return None
50
+
51
+ def set(self, key: str, value: Any, ttl: int = CACHE_TTL) -> None:
52
+ self._db.execute(
53
+ "INSERT OR REPLACE INTO cache (key, value, expires_at) VALUES (?, ?, ?)",
54
+ (key, json.dumps(value), time.time() + ttl),
55
+ )
56
+ self._db.commit()
57
+
58
+ def clear(self) -> None:
59
+ self._db.execute("DELETE FROM cache")
60
+ self._db.commit()
61
+
62
+
63
+ def _or_sort_key(card: Card, sort: str) -> tuple:
64
+ name = (card.name or "").lower()
65
+ if sort == "name":
66
+ return (name,)
67
+ if sort == "power":
68
+ return (card.power or 0, name)
69
+ if sort == "life":
70
+ return (card.life or 0, name)
71
+ if sort == "level":
72
+ return (card.level or 0, name)
73
+ if sort == "durability":
74
+ return (card.durability or 0, name)
75
+ if sort in ("cost_memory", "cost_reserve"):
76
+ try:
77
+ v = int((card.cost and card.cost.value) or 0)
78
+ except (ValueError, TypeError):
79
+ v = 0
80
+ return (v, name)
81
+ if sort == "rarity":
82
+ eds = card.result_editions or card.editions
83
+ try:
84
+ r = int(eds[0].rarity) if eds and eds[0].rarity else 0
85
+ except (ValueError, TypeError):
86
+ r = 0
87
+ return (r, name)
88
+ return (name,)
89
+
90
+
91
+ class GAClient:
92
+ def __init__(self) -> None:
93
+ self._http = httpx.AsyncClient(
94
+ base_url=BASE_URL,
95
+ timeout=10.0,
96
+ headers={"Accept": "application/json"},
97
+ )
98
+ self._cache = _Cache()
99
+
100
+ async def close(self) -> None:
101
+ await self._http.aclose()
102
+
103
+ async def search(
104
+ self,
105
+ *,
106
+ name: str | None = None,
107
+ element: list[str] | None = None,
108
+ type: list[str] | None = None,
109
+ subtype: list[str] | None = None,
110
+ cls: list[str] | None = None,
111
+ rarity: list[str] | None = None,
112
+ cost_memory: int | None = None,
113
+ cost_reserve: int | None = None,
114
+ effect: str | None = None,
115
+ legality_format: str | None = None,
116
+ sort: str = "name",
117
+ order: str = "ASC",
118
+ page: int = 1,
119
+ page_size: int = 50,
120
+ ) -> SearchResponse:
121
+ params: dict[str, Any] = {
122
+ "sort": sort,
123
+ "order": order,
124
+ "page": page,
125
+ "page_size": page_size,
126
+ }
127
+ if name:
128
+ params["name"] = name
129
+ if element:
130
+ params["element[]"] = element
131
+ if type:
132
+ params["type[]"] = type
133
+ if subtype:
134
+ params["subtype[]"] = subtype
135
+ if cls:
136
+ params["class[]"] = cls
137
+ if rarity:
138
+ params["rarity[]"] = rarity
139
+ if cost_memory is not None:
140
+ params["cost_memory"] = cost_memory
141
+ if cost_reserve is not None:
142
+ params["cost_reserve"] = cost_reserve
143
+ if effect:
144
+ params["effect"] = effect
145
+ if legality_format:
146
+ params["legality_format"] = legality_format
147
+
148
+ cache_key = f"search:{json.dumps(params, sort_keys=True)}"
149
+ if cached := self._cache.get(cache_key):
150
+ return SearchResponse.model_validate(cached)
151
+
152
+ response = await self._http.get("/cards/search", params=params)
153
+ response.raise_for_status()
154
+ data = response.json()
155
+ self._cache.set(cache_key, data)
156
+ return SearchResponse.model_validate(data)
157
+
158
+ async def get_card(self, slug: str) -> Card:
159
+ cache_key = f"card:{slug}"
160
+ if cached := self._cache.get(cache_key):
161
+ return Card.model_validate(cached)
162
+
163
+ response = await self._http.get(f"/cards/{slug}")
164
+ response.raise_for_status()
165
+ data = response.json()
166
+ self._cache.set(cache_key, data)
167
+ return Card.model_validate(data)
168
+
169
+ async def autocomplete(self, name: str) -> list[Card]:
170
+ cache_key = f"autocomplete:{name}"
171
+ if cached := self._cache.get(cache_key):
172
+ return [Card.model_validate(c) for c in cached]
173
+
174
+ response = await self._http.get("/cards/autocomplete", params={"name": name})
175
+ response.raise_for_status()
176
+ data = response.json()
177
+ results = data.get("data", data) if isinstance(data, dict) else data
178
+ self._cache.set(cache_key, results, ttl=300)
179
+ return [Card.model_validate(c) for c in results]
180
+
181
+ async def random(self, count: int = 8) -> list[Card]:
182
+ response = await self._http.get("/cards/random", params={"count": count})
183
+ response.raise_for_status()
184
+ data = response.json()
185
+ cards = data.get("data", data) if isinstance(data, dict) else data
186
+ return [Card.model_validate(c) for c in cards]
187
+
188
+ async def search_query(
189
+ self,
190
+ query: str,
191
+ page: int = 1,
192
+ page_size: int = 50,
193
+ ) -> SearchResponse:
194
+ from .query import parse, to_api_params, apply_client_filters
195
+
196
+ parsed = parse(query)
197
+
198
+ if not parsed.groups or all(not g for g in parsed.groups):
199
+ if parsed.warnings:
200
+ return SearchResponse(
201
+ data=[], total_cards=0, total_pages=1, has_more=False,
202
+ paginated_cards_count=0, page=page, page_size=page_size,
203
+ )
204
+ return await self.search(page=page, page_size=page_size)
205
+
206
+ if len(parsed.groups) == 1:
207
+ return await self._fetch_group(
208
+ parsed.groups[0], page, page_size,
209
+ sort=parsed.sort, order=parsed.order,
210
+ )
211
+
212
+ # OR: fetch each group and merge, deduplicated by slug
213
+ seen: set[str] = set()
214
+ merged: list[Card] = []
215
+ for group in parsed.groups:
216
+ result = await self._fetch_group(
217
+ group, page=1, page_size=page_size,
218
+ sort=parsed.sort, order=parsed.order,
219
+ )
220
+ for card in result.data:
221
+ if card.slug not in seen:
222
+ seen.add(card.slug)
223
+ merged.append(card)
224
+
225
+ merged.sort(
226
+ key=lambda c: _or_sort_key(c, parsed.sort),
227
+ reverse=(parsed.order.upper() == "DESC"),
228
+ )
229
+
230
+ resp = SearchResponse(
231
+ data=merged,
232
+ total_cards=len(merged),
233
+ total_pages=1,
234
+ has_more=False,
235
+ paginated_cards_count=len(merged),
236
+ page=1,
237
+ page_size=page_size,
238
+ )
239
+ return resp
240
+
241
+ async def _fetch_group(
242
+ self,
243
+ filters: list,
244
+ page: int,
245
+ page_size: int,
246
+ sort: str = "name",
247
+ order: str = "ASC",
248
+ ) -> SearchResponse:
249
+ from .query import to_api_params, apply_client_filters
250
+
251
+ params = to_api_params(filters)
252
+ params["sort"] = sort
253
+ params["order"] = order
254
+ params["page"] = page
255
+ params["page_size"] = page_size
256
+
257
+ cache_key = f"query:{json.dumps(params, sort_keys=True, default=str)}"
258
+ if cached := self._cache.get(cache_key):
259
+ result = SearchResponse.model_validate(cached)
260
+ else:
261
+ # Build httpx-compatible param list (multi-value support)
262
+ param_list: list[tuple[str, Any]] = []
263
+ for k, v in params.items():
264
+ if isinstance(v, list):
265
+ for item in v:
266
+ param_list.append((k, item))
267
+ else:
268
+ param_list.append((k, v))
269
+
270
+ response = await self._http.get("/cards/search", params=param_list)
271
+ response.raise_for_status()
272
+ data = response.json()
273
+ self._cache.set(cache_key, data)
274
+ result = SearchResponse.model_validate(data)
275
+
276
+ result.data = apply_client_filters(result.data, filters)
277
+ return result
278
+
279
+ async def fetch_known_types(self) -> set[str]:
280
+ """Fetch all valid card types from the API definitions endpoint."""
281
+ cache_key = "definitions:types"
282
+ if cached := self._cache.get(cache_key):
283
+ return set(cached)
284
+ try:
285
+ response = await self._http.get("/option/search")
286
+ response.raise_for_status()
287
+ types = {entry["value"] for entry in response.json().get("type", [])}
288
+ self._cache.set(cache_key, list(types), ttl=86400) # cache 24h
289
+ return types
290
+ except Exception:
291
+ return set()
292
+
293
+ def image_url(self, filename: str) -> str:
294
+ return f"{BASE_URL}/cards/images/{filename}"
295
+
296
+ async def fetch_image(self, filename: str) -> bytes:
297
+ if filename.startswith("/cards/images/"):
298
+ filename = filename[len("/cards/images/"):]
299
+ response = await self._http.get(f"/cards/images/{filename}")
300
+ response.raise_for_status()
301
+ return response.content