xdfetch 0.2.0__tar.gz → 0.2.1__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.
- {xdfetch-0.2.0 → xdfetch-0.2.1}/PKG-INFO +3 -4
- {xdfetch-0.2.0 → xdfetch-0.2.1}/README.md +2 -3
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_puzzmo.py +1 -70
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_registry.py +0 -3
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/__init__.py +1 -1
- xdfetch-0.2.1/xdfetch/adapters/puzzmo.py +103 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/outlets.tsv +0 -3
- xdfetch-0.2.0/xdfetch/adapters/puzzmo.py +0 -187
- {xdfetch-0.2.0 → xdfetch-0.2.1}/.gitattributes +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/.github/workflows/release.yml +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/.github/workflows/test.yml +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/.gitignore +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/LICENSE +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/pyproject.toml +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261029-120000-www.zip +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261029-120000-www.zip.register.tsv +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261030-130000-www.zip +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261030-130000-www.zip.register.tsv +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/ledger.tsv +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/reports/2026/20261029-120000-report.md +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/reports/2026/20261030-130000-report.md +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/conftest.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/synthetic.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_adapters.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_amuse.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_archive.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_availability.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_ccxml_puz.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_cli.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_compat.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_conde.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_conde_adapter.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_convert.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_fetch.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_gamedist.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_http.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_ipuz.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_ledger.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_nyt.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_nyt_adapter.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_page.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_plugins.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_princetonian.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_princetonian_adapter.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_puzzlr.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_store.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_wapo.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/uv.lock +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/__main__.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/__init__.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/amuse.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/base.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/conde.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/gamedist.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/nyt.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/page.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/princetonian.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/puzzlr.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/url.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/archive.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/availability.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/cli.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/__init__.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/amuse.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/ccjson.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/ccxml.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/conde.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/ipuz.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/nytjson.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/princetonian.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/puz.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/puzzlr.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/puzzmo.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/uclick.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/wapo.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/fetch.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/http.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/ledger.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/registry.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/store.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/tsv.py +0 -0
- {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/xd.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: xdfetch
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Download crossword puzzles and convert them to .xd
|
|
5
5
|
Project-URL: Homepage, https://github.com/century-arcade/xdfetch
|
|
6
6
|
Project-URL: Repository, https://github.com/century-arcade/xdfetch
|
|
@@ -189,7 +189,7 @@ The series xdfetch knows how to fetch are rows of
|
|
|
189
189
|
`crosswordclub.daily`, `crosswordclub.themed`, `vox.daily`, `vulture.daily`,
|
|
190
190
|
`walrus.weekly`, `defector.weekly`, `kingfeatures.thomas-joseph`,
|
|
191
191
|
`kingfeatures.eugene-sheffer`, `kingfeatures.premier-sunday`, `wsj.daily`,
|
|
192
|
-
`bostonglobe.sunday`, `bostonglobe.mini
|
|
192
|
+
`bostonglobe.sunday`, `bostonglobe.mini`.
|
|
193
193
|
`xdfetch list` prints them all.
|
|
194
194
|
|
|
195
195
|
A row's columns, of which only `id` and `adapter` are required:
|
|
@@ -233,8 +233,7 @@ Three more fetch from platforms that also carry several outlets' puzzles:
|
|
|
233
233
|
- `puzzlr`, PZLR: `tenant` is the publisher's slug, and `offset` the days added to the
|
|
234
234
|
date asked for to get the date PZLR files the puzzle under. `copyright` is written when
|
|
235
235
|
the puzzle carries none.
|
|
236
|
-
- `puzzmo`, Puzzmo: `team` and `queue` name a publisher's queue
|
|
237
|
-
Puzzmo's own series (`crossword`, `crossword/mini`, `crossword/big`).
|
|
236
|
+
- `puzzmo`, Puzzmo: `team` and `queue` name a publisher's queue.
|
|
238
237
|
|
|
239
238
|
Adapters return each file as the outlet served it; converting is a separate step.
|
|
240
239
|
|
|
@@ -164,7 +164,7 @@ The series xdfetch knows how to fetch are rows of
|
|
|
164
164
|
`crosswordclub.daily`, `crosswordclub.themed`, `vox.daily`, `vulture.daily`,
|
|
165
165
|
`walrus.weekly`, `defector.weekly`, `kingfeatures.thomas-joseph`,
|
|
166
166
|
`kingfeatures.eugene-sheffer`, `kingfeatures.premier-sunday`, `wsj.daily`,
|
|
167
|
-
`bostonglobe.sunday`, `bostonglobe.mini
|
|
167
|
+
`bostonglobe.sunday`, `bostonglobe.mini`.
|
|
168
168
|
`xdfetch list` prints them all.
|
|
169
169
|
|
|
170
170
|
A row's columns, of which only `id` and `adapter` are required:
|
|
@@ -208,8 +208,7 @@ Three more fetch from platforms that also carry several outlets' puzzles:
|
|
|
208
208
|
- `puzzlr`, PZLR: `tenant` is the publisher's slug, and `offset` the days added to the
|
|
209
209
|
date asked for to get the date PZLR files the puzzle under. `copyright` is written when
|
|
210
210
|
the puzzle carries none.
|
|
211
|
-
- `puzzmo`, Puzzmo: `team` and `queue` name a publisher's queue
|
|
212
|
-
Puzzmo's own series (`crossword`, `crossword/mini`, `crossword/big`).
|
|
211
|
+
- `puzzmo`, Puzzmo: `team` and `queue` name a publisher's queue.
|
|
213
212
|
|
|
214
213
|
Adapters return each file as the outlet served it; converting is a separate step.
|
|
215
214
|
|
|
@@ -287,7 +287,7 @@ def test_date_not_in_queue_is_a_miss(registry, http):
|
|
|
287
287
|
|
|
288
288
|
@responses.activate
|
|
289
289
|
def test_queue_and_graphql_errors(registry, http):
|
|
290
|
-
responses.add(responses.GET, LIST, json={"errors": ["
|
|
290
|
+
responses.add(responses.GET, LIST, json={"errors": ["no"]})
|
|
291
291
|
a = adapter.PuzzmoAdapter(registry["bostonglobe.sunday"], http)
|
|
292
292
|
with pytest.raises(FetchError, match="queue list is not as expected"):
|
|
293
293
|
a.targets(SUNDAY)
|
|
@@ -320,75 +320,6 @@ def test_team_registry_rows(registry):
|
|
|
320
320
|
assert mini.since == dt.date(2026, 6, 1)
|
|
321
321
|
|
|
322
322
|
|
|
323
|
-
# --- Puzzmo's own series, by date ---
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def test_own_series_registry_rows(registry):
|
|
327
|
-
assert registry["puzzmo.daily"].params == {"finder": "crossword"}
|
|
328
|
-
assert registry["puzzmo.daily"].since == dt.date(2023, 10, 16)
|
|
329
|
-
assert registry["puzzmo.mini"].params == {"finder": "crossword/mini"}
|
|
330
|
-
assert registry["puzzmo.mini"].since == dt.date(2025, 10, 1)
|
|
331
|
-
assert registry["puzzmo.big"].params == {"finder": "crossword/big"}
|
|
332
|
-
assert registry["puzzmo.big"].days == frozenset({0})
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
@responses.activate
|
|
336
|
-
def test_gameplay_by_date(registry, http):
|
|
337
|
-
responses.add(responses.POST, adapter.PLAY, body=DAILY)
|
|
338
|
-
a = adapter.PuzzmoAdapter(registry["puzzmo.mini"], http)
|
|
339
|
-
(t,) = a.targets(NOV9)
|
|
340
|
-
assert t.filename == "puzzmo.mini-2026-11-09.json"
|
|
341
|
-
assert t.url == adapter.PLAY
|
|
342
|
-
assert a.fetch(t).data == DAILY
|
|
343
|
-
req = responses.calls[0].request
|
|
344
|
-
sent = posted(req)
|
|
345
|
-
assert sent["variables"]["finderKey"] == "today:/2026-11-09/crossword/mini"
|
|
346
|
-
assert "startOrFindGameplay" in sent["query"]
|
|
347
|
-
assert req.headers["Puzzmo-Gameplay-Id"]
|
|
348
|
-
assert "startOrFindGameplay" in a.describe()
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
@responses.activate
|
|
352
|
-
def test_gameplay_id_is_one_per_run(registry, http):
|
|
353
|
-
responses.add(responses.POST, adapter.PLAY, body=DAILY)
|
|
354
|
-
a = adapter.PuzzmoAdapter(registry["puzzmo.daily"], http)
|
|
355
|
-
for day in (NOV9, NOV9 - dt.timedelta(days=1)):
|
|
356
|
-
a.fetch(a.targets(day)[0])
|
|
357
|
-
ids = {c.request.headers["Puzzmo-Gameplay-Id"] for c in responses.calls}
|
|
358
|
-
assert len(ids) == 1
|
|
359
|
-
assert adapter.PuzzmoAdapter(registry["puzzmo.daily"], http)._gameplay_id not in ids
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
@responses.activate
|
|
363
|
-
def test_gameplay_no_puzzle_is_a_miss(registry, http):
|
|
364
|
-
answer = {"data": {"startOrFindGameplay": {"__typename": "ErrorableResponse", "message": "No puzzle found"}}}
|
|
365
|
-
responses.add(responses.POST, adapter.PLAY, json=answer)
|
|
366
|
-
a = adapter.PuzzmoAdapter(registry["puzzmo.big"], http)
|
|
367
|
-
(t,) = a.targets(NOV9)
|
|
368
|
-
with pytest.raises(NotFound, match="today:/2026-11-09/crossword/big"):
|
|
369
|
-
a.fetch(t)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
@responses.activate
|
|
373
|
-
def test_gameplay_other_errors(registry, http):
|
|
374
|
-
a = adapter.PuzzmoAdapter(registry["puzzmo.daily"], http)
|
|
375
|
-
(t,) = a.targets(NOV9)
|
|
376
|
-
answer = {"data": {"startOrFindGameplay": {"__typename": "ErrorableResponse", "message": "Slow down"}}}
|
|
377
|
-
responses.add(responses.POST, adapter.PLAY, json=answer)
|
|
378
|
-
with pytest.raises(FetchError, match="Slow down"):
|
|
379
|
-
a.fetch(t)
|
|
380
|
-
responses.replace(responses.POST, adapter.PLAY, json={"errors": [{"message": "bad"}], "data": None})
|
|
381
|
-
with pytest.raises(FetchError, match="bad"):
|
|
382
|
-
a.fetch(t)
|
|
383
|
-
responses.replace(responses.POST, adapter.PLAY, body="<html>")
|
|
384
|
-
with pytest.raises(FetchError, match="not JSON"):
|
|
385
|
-
a.fetch(t)
|
|
386
|
-
empty = {"data": {"startOrFindGameplay": {"__typename": "HasGamePlayed", "gamePlayed": {"puzzle": {}}}}}
|
|
387
|
-
responses.replace(responses.POST, adapter.PLAY, json=empty)
|
|
388
|
-
with pytest.raises(FetchError, match="no puzzle text"):
|
|
389
|
-
a.fetch(t)
|
|
390
|
-
|
|
391
|
-
|
|
392
323
|
# --- the converter: a team queue's answer ---
|
|
393
324
|
|
|
394
325
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The `puzzmo` adapter: a publisher's team queues on Puzzmo (the Boston Globe's crosswords).
|
|
2
|
+
|
|
3
|
+
params:
|
|
4
|
+
team the team slug on Puzzmo, e.g. boston-globe-amlaj
|
|
5
|
+
queue the queue, default crossword
|
|
6
|
+
days how many days back one list request covers, default 10 (Puzzmo serves up to 50)
|
|
7
|
+
|
|
8
|
+
- GET https://api.puzzmo.com/team/v1/<team>/queues/<queue>/puzzles?startDate=<date>&days=<n>
|
|
9
|
+
lists the queue's puzzles from that date back (`days` must be positive; up to 50 are
|
|
10
|
+
returned): id, name, publishDate, authors. The list is read once per run for the dates it
|
|
11
|
+
covers. A queue can hold more than one series (the Globe's `crossword` queue holds its
|
|
12
|
+
Sunday crossword and the Universal crossword on the other days); the row's calendar says
|
|
13
|
+
which days to ask for.
|
|
14
|
+
- POST https://api.puzzmo.com/graphql with { puzzle(id: "<id>") { id name puzzle } } returns the
|
|
15
|
+
puzzle; its `puzzle` field is the puzzle as xd text in Puzzmo's dialect. The response is
|
|
16
|
+
saved as served, named <id>-YYYY-MM-DD.json by the list's publishDate.
|
|
17
|
+
|
|
18
|
+
A publisher's row needs the team slug and queue name, which its player page uses.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import json
|
|
25
|
+
|
|
26
|
+
from ..http import FetchError, NotFound
|
|
27
|
+
from .base import Adapter, Fetched, Target
|
|
28
|
+
|
|
29
|
+
TEAM = "https://api.puzzmo.com/team/v1/{team}/queues/{queue}/puzzles?startDate={date}&days={days}"
|
|
30
|
+
GRAPHQL = "https://api.puzzmo.com/graphql"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PuzzmoAdapter(Adapter):
|
|
34
|
+
name = "puzzmo"
|
|
35
|
+
interval = 3.0
|
|
36
|
+
|
|
37
|
+
def __init__(self, *args, **kwargs):
|
|
38
|
+
super().__init__(*args, **kwargs)
|
|
39
|
+
self._listed: dict[dt.date, dict] = {}
|
|
40
|
+
self._asked: set[dt.date] = set()
|
|
41
|
+
|
|
42
|
+
def list_url(self, date: dt.date) -> str:
|
|
43
|
+
return TEAM.format(
|
|
44
|
+
team=self.outlet.param("team"),
|
|
45
|
+
queue=self.outlet.params.get("queue", "crossword"),
|
|
46
|
+
date=date.isoformat(),
|
|
47
|
+
days=self.outlet.params.get("days", "10"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def listed(self, date: dt.date) -> dict | None:
|
|
51
|
+
"""The queue's record for the date, listing from that date back once per run."""
|
|
52
|
+
if date not in self._listed and date not in self._asked:
|
|
53
|
+
self._asked.add(date)
|
|
54
|
+
url = self.list_url(date)
|
|
55
|
+
r = self.http.get(url, interval=self.interval)
|
|
56
|
+
try:
|
|
57
|
+
puzzles = r.json()["puzzles"]
|
|
58
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
59
|
+
raise FetchError(f"queue list is not as expected: {e}", url=url) from e
|
|
60
|
+
for p in puzzles:
|
|
61
|
+
try:
|
|
62
|
+
day = dt.date.fromisoformat(p["publishDate"])
|
|
63
|
+
except (KeyError, ValueError, TypeError):
|
|
64
|
+
continue
|
|
65
|
+
self._listed.setdefault(day, p)
|
|
66
|
+
return self._listed.get(date)
|
|
67
|
+
|
|
68
|
+
def targets(self, date: dt.date) -> list[Target]:
|
|
69
|
+
filename = f"{self.outlet.id}-{date.isoformat()}.json"
|
|
70
|
+
rec = self.listed(date)
|
|
71
|
+
if rec is None:
|
|
72
|
+
raise NotFound(f"no puzzle dated {date} in the {self.outlet.param('team')} queue", url=self.list_url(date))
|
|
73
|
+
note = f"{date}: {rec.get('name', '')!r} by {', '.join(a.get('name', '') for a in rec.get('authors') or [])}"
|
|
74
|
+
return [Target(url=GRAPHQL, filename=filename, note=note, headers={"X-Puzzle-Id": rec["id"]})]
|
|
75
|
+
|
|
76
|
+
def fetch(self, target: Target) -> Fetched:
|
|
77
|
+
puzzle_id = target.headers.get("X-Puzzle-Id", "")
|
|
78
|
+
clean = puzzle_id.replace('"', "")
|
|
79
|
+
query = f'{{ puzzle(id: "{clean}") {{ id name puzzle }} }}'
|
|
80
|
+
r = self.http.post(target.url, json={"query": query}, interval=self.interval)
|
|
81
|
+
try:
|
|
82
|
+
body = json.loads(r.content)
|
|
83
|
+
puzzle = body["data"]["puzzle"]
|
|
84
|
+
except (ValueError, KeyError, TypeError) as e:
|
|
85
|
+
raise FetchError(f"GraphQL answer is not a puzzle: {e}", url=target.url) from e
|
|
86
|
+
if not puzzle or not puzzle.get("puzzle"):
|
|
87
|
+
raise NotFound(f"no puzzle text for {puzzle_id}", url=target.url)
|
|
88
|
+
return Fetched(data=r.content, url=target.url, content_type="application/json", filename=target.filename)
|
|
89
|
+
|
|
90
|
+
def describe(self) -> str:
|
|
91
|
+
team = self.outlet.param("team")
|
|
92
|
+
queue = self.outlet.params.get("queue", "crossword")
|
|
93
|
+
text = (
|
|
94
|
+
f"The outlet plays its puzzles from Puzzmo. Its `{queue}` queue on Puzzmo's team API "
|
|
95
|
+
f"(`https://api.puzzmo.com/team/v1/{team}/queues/{queue}/puzzles?startDate=<date>&days=<n>`) lists "
|
|
96
|
+
f"the puzzles by date. Each file is the answer to the GraphQL query "
|
|
97
|
+
f'`{{ puzzle(id: "<id>") {{ id name puzzle }} }}` posted to `{GRAPHQL}`, saved as served; its '
|
|
98
|
+
f"`puzzle` field is the puzzle as xd text in Puzzmo's dialect. Files are named by the queue's "
|
|
99
|
+
f"publishDate."
|
|
100
|
+
)
|
|
101
|
+
if self.outlet.notes:
|
|
102
|
+
text += " " + self.outlet.notes
|
|
103
|
+
return text
|
|
@@ -30,6 +30,3 @@ kingfeatures.premier-sunday gamedist feed=hearst_premier-sunday-crossword dash=-
|
|
|
30
30
|
wsj.daily puzzlr tenant=wsj offset=-1 'copyright=© The Wall Street Journal' mon-sat America/New_York -18 1,2,3,4 https://www.wsj.com/news/puzzle yes The Wall Street Journal's crossword, Monday to Saturday, from PZLR, the platform the paper's player runs on; each file is the answer to the player's own request for the date. Files are named by the print date; PZLR dates a puzzle by its online release, the day before (offset=-1), and has it up by the morning of that day. Friday's contest puzzle is served without its answers until the contest closes on Sunday night: it is written with every white square an X until a re-fetch (through day 4) has the answers. Payloads from before PZLR's 2026 format change carry no circles or rebus squares.
|
|
31
31
|
bostonglobe.sunday puzzmo team=boston-globe-amlaj queue=crossword sun 2024-02-25 America/New_York 3 1,2 https://www.bostonglobe.com/games/crossword/ yes The Boston Globe Magazine's Sunday crossword, by Brendan Emmett Quigley and Joon Pahk in alternate weeks, from the Globe's crossword queue on Puzzmo, where the Globe's player gets its puzzles. The queue also holds the Universal crossword Monday to Saturday, which is not fetched here (see universal.daily). Files are named by the queue's publishDate.
|
|
32
32
|
bostonglobe.mini puzzmo team=boston-globe-amlaj queue=crossword-mini daily 2026-06-01 America/New_York 3 1,2 https://www.bostonglobe.com/games/mini-crossword/ yes The Globe's daily mini, a 5x5, from the team's crossword-mini queue on Puzzmo. Puzzmo names each one by its date (Tue, Nov 3, 2026); the title is that date written out (Tuesday, November 3, 2026).
|
|
33
|
-
puzzmo.daily puzzmo finder=crossword daily 2023-10-16 America/New_York 3 1,2 https://www.puzzmo.com/ yes Puzzmo's own daily crossword, edited by Brian Callahan; sizes vary (11x11, 12x10). Each clue has a hint (A1 ^hint: ...) in the file as served; the .xd doesn't carry them. Fetched the way xword-dl fetches Puzzmo.
|
|
34
|
-
puzzmo.mini puzzmo finder=crossword/mini daily 2025-10-01 America/New_York 3 1,2 https://www.puzzmo.com/ yes Puzzmo's own daily mini, from Puzzmo Mini Crossword #1 on 2025-10-01; not always 5x5 (9x5 on 2026-11-01). The file as served also has a Line Breaks section (where the player may wrap each clue), which the .xd doesn't need. Some days have Schrödinger squares, written as rebus squares holding both letters (1=E|K), with the clue once per reading. Fetched the way xword-dl fetches Puzzmo.
|
|
35
|
-
puzzmo.big puzzmo finder=crossword/big mon 2025-01-13 America/New_York 3 1,2 https://www.puzzmo.com/ yes Puzzmo's Big crossword, a 15x15 played over three days from a Monday (its dailyTitle says "Nov 2-4, 2026"), filed under that Monday: every other Monday from 2025-01-13, the first Monday of each month from 2025-07-07, and every other Monday again from 2026-01-05. The other Mondays are misses. Fetched the way xword-dl fetches Puzzmo.
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
"""The `puzzmo` adapter: a publisher's team queues on Puzzmo (the Boston Globe's crosswords),
|
|
2
|
-
and Puzzmo's own crosswords by date.
|
|
3
|
-
|
|
4
|
-
params:
|
|
5
|
-
team the team slug on Puzzmo, e.g. boston-globe-amlaj
|
|
6
|
-
queue the queue, default crossword
|
|
7
|
-
days how many days back one list request covers, default 10 (Puzzmo serves up to 50)
|
|
8
|
-
finder instead of team: one of Puzzmo's own series, as the part of its play URL after the
|
|
9
|
-
date: `crossword` (the daily), `crossword/mini`, `crossword/big`
|
|
10
|
-
|
|
11
|
-
Team queues:
|
|
12
|
-
|
|
13
|
-
- GET https://api.puzzmo.com/team/v1/<team>/queues/<queue>/puzzles?startDate=<date>&days=<n>
|
|
14
|
-
lists the queue's puzzles from that date back (`days` must be positive; up to 50 are
|
|
15
|
-
returned): id, name, publishDate, authors. The list is read once per run for the dates it
|
|
16
|
-
covers. A queue can hold more than one series (the Globe's `crossword` queue holds its
|
|
17
|
-
Sunday crossword and the Universal crossword on the other days); the row's calendar says
|
|
18
|
-
which days to ask for.
|
|
19
|
-
- POST https://api.puzzmo.com/graphql with { puzzle(id: "<id>") { id name puzzle } } returns the
|
|
20
|
-
puzzle; its `puzzle` field is the puzzle as xd text in Puzzmo's dialect. The response is
|
|
21
|
-
saved as served, named <id>-YYYY-MM-DD.json by the list's publishDate.
|
|
22
|
-
|
|
23
|
-
A publisher's row needs the team slug and queue name, which its player page uses.
|
|
24
|
-
|
|
25
|
-
Puzzmo's own series (`finder=`), the way xword-dl gets them: the play screen's query,
|
|
26
|
-
`startOrFindGameplay(finderKey: "today:/<date>/<finder>")`, posted to
|
|
27
|
-
https://www.puzzmo.com/_api/prod/graphql with a `Puzzmo-Gameplay-Id` header. That id is
|
|
28
|
-
made up, a random one per run, as the play screen makes one for an anonymous player. A date
|
|
29
|
-
with no puzzle answers `ErrorableResponse` "No puzzle found": a miss. The response is saved
|
|
30
|
-
as served, named <id>-YYYY-MM-DD.json by the date asked for.
|
|
31
|
-
"""
|
|
32
|
-
|
|
33
|
-
from __future__ import annotations
|
|
34
|
-
|
|
35
|
-
import datetime as dt
|
|
36
|
-
import json
|
|
37
|
-
import secrets
|
|
38
|
-
|
|
39
|
-
from ..http import FetchError, NotFound
|
|
40
|
-
from .base import Adapter, Fetched, Target
|
|
41
|
-
|
|
42
|
-
TEAM = "https://api.puzzmo.com/team/v1/{team}/queues/{queue}/puzzles?startDate={date}&days={days}"
|
|
43
|
-
GRAPHQL = "https://api.puzzmo.com/graphql"
|
|
44
|
-
PLAY = "https://www.puzzmo.com/_api/prod/graphql?PlayGameScreenQuery"
|
|
45
|
-
# The play screen's query (PlayGameScreenQuery), asking for every field of the puzzle that the
|
|
46
|
-
# site's own query asks for, less what is about the viewer or the session (their gameplay,
|
|
47
|
-
# tutorial state, leaderboards) and the game's asset paths, plus shortDescription. Kept in full
|
|
48
|
-
# in the saved file so a field the converter doesn't use yet is there later. The byline and
|
|
49
|
-
# editor the site shows are publishingName, else name.
|
|
50
|
-
USER = "{ id name username usernameID publishingName }"
|
|
51
|
-
GAMEPLAY = (
|
|
52
|
-
"query PlayGameScreenQuery($finderKey: String!, $gameContext: StartGameContext!) { "
|
|
53
|
-
"startOrFindGameplay(finderKey: $finderKey, context: $gameContext) { __typename "
|
|
54
|
-
"... on ErrorableResponse { message failed success } "
|
|
55
|
-
"... on HasGamePlayed { gamePlayed { puzzle { "
|
|
56
|
-
"id name emoji puzzle slug gameID variantID subvariantID dailyTitle shortDescription "
|
|
57
|
-
"difficulty difficultyPointsCap seriesNumber gameNameOverride accessOverride forceSettings "
|
|
58
|
-
"thumbnailWidth thumbnailHeight "
|
|
59
|
-
f"author authors {USER} editor editors {USER} puzmoEditor {USER} "
|
|
60
|
-
"source { id name displayName url formatString } "
|
|
61
|
-
"game { id slug displayName gameCreatorName } "
|
|
62
|
-
"featuredInDaily { id daily { id day } } "
|
|
63
|
-
"submissionVersion { id submission { id status slug queue { id slug } } } "
|
|
64
|
-
"} } } } }"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
class PuzzmoAdapter(Adapter):
|
|
69
|
-
name = "puzzmo"
|
|
70
|
-
interval = 3.0
|
|
71
|
-
|
|
72
|
-
def __init__(self, *args, **kwargs):
|
|
73
|
-
super().__init__(*args, **kwargs)
|
|
74
|
-
self._listed: dict[dt.date, dict] = {}
|
|
75
|
-
self._asked: set[dt.date] = set()
|
|
76
|
-
self._gameplay_id = secrets.token_urlsafe(21)
|
|
77
|
-
|
|
78
|
-
@property
|
|
79
|
-
def finder(self) -> str:
|
|
80
|
-
return self.outlet.params.get("finder", "").strip("/")
|
|
81
|
-
|
|
82
|
-
def list_url(self, date: dt.date) -> str:
|
|
83
|
-
return TEAM.format(
|
|
84
|
-
team=self.outlet.param("team"),
|
|
85
|
-
queue=self.outlet.params.get("queue", "crossword"),
|
|
86
|
-
date=date.isoformat(),
|
|
87
|
-
days=self.outlet.params.get("days", "10"),
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
def listed(self, date: dt.date) -> dict | None:
|
|
91
|
-
"""The queue's record for the date, listing from that date back once per run."""
|
|
92
|
-
if date not in self._listed and date not in self._asked:
|
|
93
|
-
self._asked.add(date)
|
|
94
|
-
url = self.list_url(date)
|
|
95
|
-
r = self.http.get(url, interval=self.interval)
|
|
96
|
-
try:
|
|
97
|
-
puzzles = r.json()["puzzles"]
|
|
98
|
-
except (ValueError, KeyError, TypeError) as e:
|
|
99
|
-
raise FetchError(f"queue list is not as expected: {e}", url=url) from e
|
|
100
|
-
for p in puzzles:
|
|
101
|
-
try:
|
|
102
|
-
day = dt.date.fromisoformat(p["publishDate"])
|
|
103
|
-
except (KeyError, ValueError, TypeError):
|
|
104
|
-
continue
|
|
105
|
-
self._listed.setdefault(day, p)
|
|
106
|
-
return self._listed.get(date)
|
|
107
|
-
|
|
108
|
-
def targets(self, date: dt.date) -> list[Target]:
|
|
109
|
-
filename = f"{self.outlet.id}-{date.isoformat()}.json"
|
|
110
|
-
if self.finder:
|
|
111
|
-
key = f"today:/{date.isoformat()}/{self.finder}"
|
|
112
|
-
return [Target(url=PLAY, filename=filename, note=key, headers={"X-Finder-Key": key})]
|
|
113
|
-
rec = self.listed(date)
|
|
114
|
-
if rec is None:
|
|
115
|
-
raise NotFound(f"no puzzle dated {date} in the {self.outlet.param('team')} queue", url=self.list_url(date))
|
|
116
|
-
note = f"{date}: {rec.get('name', '')!r} by {', '.join(a.get('name', '') for a in rec.get('authors') or [])}"
|
|
117
|
-
return [Target(url=GRAPHQL, filename=filename, note=note, headers={"X-Puzzle-Id": rec["id"]})]
|
|
118
|
-
|
|
119
|
-
def fetch(self, target: Target) -> Fetched:
|
|
120
|
-
if "X-Finder-Key" in target.headers:
|
|
121
|
-
return self.fetch_gameplay(target)
|
|
122
|
-
puzzle_id = target.headers.get("X-Puzzle-Id", "")
|
|
123
|
-
clean = puzzle_id.replace('"', "")
|
|
124
|
-
query = f'{{ puzzle(id: "{clean}") {{ id name puzzle }} }}'
|
|
125
|
-
r = self.http.post(target.url, json={"query": query}, interval=self.interval)
|
|
126
|
-
try:
|
|
127
|
-
body = json.loads(r.content)
|
|
128
|
-
puzzle = body["data"]["puzzle"]
|
|
129
|
-
except (ValueError, KeyError, TypeError) as e:
|
|
130
|
-
raise FetchError(f"GraphQL answer is not a puzzle: {e}", url=target.url) from e
|
|
131
|
-
if not puzzle or not puzzle.get("puzzle"):
|
|
132
|
-
raise NotFound(f"no puzzle text for {puzzle_id}", url=target.url)
|
|
133
|
-
return Fetched(data=r.content, url=target.url, content_type="application/json", filename=target.filename)
|
|
134
|
-
|
|
135
|
-
def fetch_gameplay(self, target: Target) -> Fetched:
|
|
136
|
-
key = target.headers["X-Finder-Key"]
|
|
137
|
-
payload = {
|
|
138
|
-
"operationName": "PlayGameScreenQuery",
|
|
139
|
-
"query": GAMEPLAY,
|
|
140
|
-
"variables": {"finderKey": key, "gameContext": {"partnerSlug": None, "pingOwnerForMultiplayer": True}},
|
|
141
|
-
}
|
|
142
|
-
r = self.http.post(
|
|
143
|
-
target.url, json=payload, headers={"Puzzmo-Gameplay-Id": self._gameplay_id}, interval=self.interval
|
|
144
|
-
)
|
|
145
|
-
try:
|
|
146
|
-
body = json.loads(r.content)
|
|
147
|
-
except ValueError as e:
|
|
148
|
-
raise FetchError(f"gameplay answer is not JSON: {e}", url=target.url) from e
|
|
149
|
-
try:
|
|
150
|
-
answer = body["data"]["startOrFindGameplay"]
|
|
151
|
-
kind = answer["__typename"]
|
|
152
|
-
except (KeyError, TypeError) as e:
|
|
153
|
-
errors = body.get("errors") if isinstance(body, dict) else None
|
|
154
|
-
raise FetchError(f"gameplay answer is not as expected: {errors or e}", url=target.url) from e
|
|
155
|
-
if kind == "ErrorableResponse":
|
|
156
|
-
message = answer.get("message") or ""
|
|
157
|
-
if "no puzzle found" in message.lower():
|
|
158
|
-
raise NotFound(f"Puzzmo has no {key}", url=target.url)
|
|
159
|
-
raise FetchError(f"Puzzmo answered {message!r} for {key}", url=target.url)
|
|
160
|
-
puzzle = (answer.get("gamePlayed") or {}).get("puzzle") or {}
|
|
161
|
-
if not puzzle.get("puzzle"):
|
|
162
|
-
raise FetchError(f"no puzzle text for {key}", url=target.url)
|
|
163
|
-
return Fetched(data=r.content, url=target.url, content_type="application/json", filename=target.filename)
|
|
164
|
-
|
|
165
|
-
def describe(self) -> str:
|
|
166
|
-
if self.finder:
|
|
167
|
-
text = (
|
|
168
|
-
f"Puzzmo's own puzzle, the one played at `https://www.puzzmo.com/puzzle/<date>/{self.finder}`. "
|
|
169
|
-
f"Each file is the answer to the play screen's GraphQL query, `startOrFindGameplay(finderKey: "
|
|
170
|
-
f'"today:/<date>/{self.finder}")`, posted to `{PLAY}` (the way xword-dl fetches Puzzmo), '
|
|
171
|
-
f"saved as served; its `puzzle` field is the puzzle as xd text in Puzzmo's dialect. Files are "
|
|
172
|
-
f"named by the date asked for."
|
|
173
|
-
)
|
|
174
|
-
else:
|
|
175
|
-
team = self.outlet.param("team")
|
|
176
|
-
queue = self.outlet.params.get("queue", "crossword")
|
|
177
|
-
text = (
|
|
178
|
-
f"The outlet plays its puzzles from Puzzmo. Its `{queue}` queue on Puzzmo's team API "
|
|
179
|
-
f"(`https://api.puzzmo.com/team/v1/{team}/queues/{queue}/puzzles?startDate=<date>&days=<n>`) lists "
|
|
180
|
-
f"the puzzles by date. Each file is the answer to the GraphQL query "
|
|
181
|
-
f'`{{ puzzle(id: "<id>") {{ id name puzzle }} }}` posted to `{GRAPHQL}`, saved as served; its '
|
|
182
|
-
f"`puzzle` field is the puzzle as xd text in Puzzmo's dialect. Files are named by the queue's "
|
|
183
|
-
f"publishDate."
|
|
184
|
-
)
|
|
185
|
-
if self.outlet.notes:
|
|
186
|
-
text += " " + self.outlet.notes
|
|
187
|
-
return text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|