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.
Files changed (82) hide show
  1. {xdfetch-0.2.0 → xdfetch-0.2.1}/PKG-INFO +3 -4
  2. {xdfetch-0.2.0 → xdfetch-0.2.1}/README.md +2 -3
  3. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_puzzmo.py +1 -70
  4. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_registry.py +0 -3
  5. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/__init__.py +1 -1
  6. xdfetch-0.2.1/xdfetch/adapters/puzzmo.py +103 -0
  7. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/outlets.tsv +0 -3
  8. xdfetch-0.2.0/xdfetch/adapters/puzzmo.py +0 -187
  9. {xdfetch-0.2.0 → xdfetch-0.2.1}/.gitattributes +0 -0
  10. {xdfetch-0.2.0 → xdfetch-0.2.1}/.github/workflows/release.yml +0 -0
  11. {xdfetch-0.2.0 → xdfetch-0.2.1}/.github/workflows/test.yml +0 -0
  12. {xdfetch-0.2.0 → xdfetch-0.2.1}/.gitignore +0 -0
  13. {xdfetch-0.2.0 → xdfetch-0.2.1}/LICENSE +0 -0
  14. {xdfetch-0.2.0 → xdfetch-0.2.1}/pyproject.toml +0 -0
  15. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261029-120000-www.zip +0 -0
  16. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261029-120000-www.zip.register.tsv +0 -0
  17. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261030-130000-www.zip +0 -0
  18. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/downloads/2026/20261030-130000-www.zip.register.tsv +0 -0
  19. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/ledger.tsv +0 -0
  20. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/reports/2026/20261029-120000-report.md +0 -0
  21. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/compat/store/reports/2026/20261030-130000-report.md +0 -0
  22. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/conftest.py +0 -0
  23. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/synthetic.py +0 -0
  24. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_adapters.py +0 -0
  25. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_amuse.py +0 -0
  26. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_archive.py +0 -0
  27. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_availability.py +0 -0
  28. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_ccxml_puz.py +0 -0
  29. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_cli.py +0 -0
  30. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_compat.py +0 -0
  31. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_conde.py +0 -0
  32. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_conde_adapter.py +0 -0
  33. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_convert.py +0 -0
  34. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_fetch.py +0 -0
  35. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_gamedist.py +0 -0
  36. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_http.py +0 -0
  37. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_ipuz.py +0 -0
  38. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_ledger.py +0 -0
  39. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_nyt.py +0 -0
  40. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_nyt_adapter.py +0 -0
  41. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_page.py +0 -0
  42. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_plugins.py +0 -0
  43. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_princetonian.py +0 -0
  44. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_princetonian_adapter.py +0 -0
  45. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_puzzlr.py +0 -0
  46. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_store.py +0 -0
  47. {xdfetch-0.2.0 → xdfetch-0.2.1}/tests/test_wapo.py +0 -0
  48. {xdfetch-0.2.0 → xdfetch-0.2.1}/uv.lock +0 -0
  49. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/__main__.py +0 -0
  50. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/__init__.py +0 -0
  51. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/amuse.py +0 -0
  52. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/base.py +0 -0
  53. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/conde.py +0 -0
  54. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/gamedist.py +0 -0
  55. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/nyt.py +0 -0
  56. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/page.py +0 -0
  57. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/princetonian.py +0 -0
  58. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/puzzlr.py +0 -0
  59. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/adapters/url.py +0 -0
  60. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/archive.py +0 -0
  61. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/availability.py +0 -0
  62. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/cli.py +0 -0
  63. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/__init__.py +0 -0
  64. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/amuse.py +0 -0
  65. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/ccjson.py +0 -0
  66. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/ccxml.py +0 -0
  67. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/conde.py +0 -0
  68. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/ipuz.py +0 -0
  69. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/nytjson.py +0 -0
  70. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/princetonian.py +0 -0
  71. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/puz.py +0 -0
  72. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/puzzlr.py +0 -0
  73. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/puzzmo.py +0 -0
  74. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/uclick.py +0 -0
  75. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/convert/wapo.py +0 -0
  76. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/fetch.py +0 -0
  77. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/http.py +0 -0
  78. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/ledger.py +0 -0
  79. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/registry.py +0 -0
  80. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/store.py +0 -0
  81. {xdfetch-0.2.0 → xdfetch-0.2.1}/xdfetch/tsv.py +0 -0
  82. {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.0
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`, `puzzmo.daily`, `puzzmo.mini`, `puzzmo.big`.
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, or `finder` names one of
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`, `puzzmo.daily`, `puzzmo.mini`, `puzzmo.big`.
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, or `finder` names one of
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": ["FORBIDDEN_TEAM"]})
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
 
@@ -84,9 +84,6 @@ PUBLIC_IDS = [
84
84
  "wsj.daily",
85
85
  "bostonglobe.sunday",
86
86
  "bostonglobe.mini",
87
- "puzzmo.daily",
88
- "puzzmo.mini",
89
- "puzzmo.big",
90
87
  ]
91
88
 
92
89
 
@@ -1,5 +1,5 @@
1
1
  """xdfetch: fetch crossword puzzles as their outlets serve them, and convert them to .xd."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.1"
4
4
 
5
5
  USER_AGENT = f"xdfetch/{__version__} (+https://github.com/century-arcade/xdfetch)"
@@ -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