xdfetch 0.0.1__tar.gz → 0.2.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.
Files changed (92) hide show
  1. xdfetch-0.2.0/.gitattributes +2 -0
  2. {xdfetch-0.0.1 → xdfetch-0.2.0}/.github/workflows/release.yml +1 -1
  3. xdfetch-0.2.0/.github/workflows/test.yml +32 -0
  4. xdfetch-0.2.0/PKG-INFO +287 -0
  5. xdfetch-0.2.0/README.md +262 -0
  6. {xdfetch-0.0.1 → xdfetch-0.2.0}/pyproject.toml +24 -7
  7. xdfetch-0.2.0/tests/compat/store/downloads/2026/20261029-120000-www.zip +0 -0
  8. xdfetch-0.2.0/tests/compat/store/downloads/2026/20261029-120000-www.zip.register.tsv +2 -0
  9. xdfetch-0.2.0/tests/compat/store/downloads/2026/20261030-130000-www.zip +0 -0
  10. xdfetch-0.2.0/tests/compat/store/downloads/2026/20261030-130000-www.zip.register.tsv +2 -0
  11. xdfetch-0.2.0/tests/compat/store/ledger.tsv +10 -0
  12. xdfetch-0.2.0/tests/compat/store/reports/2026/20261029-120000-report.md +17 -0
  13. xdfetch-0.2.0/tests/compat/store/reports/2026/20261030-130000-report.md +18 -0
  14. xdfetch-0.2.0/tests/conftest.py +136 -0
  15. xdfetch-0.2.0/tests/synthetic.py +594 -0
  16. xdfetch-0.2.0/tests/test_adapters.py +126 -0
  17. xdfetch-0.2.0/tests/test_amuse.py +478 -0
  18. xdfetch-0.2.0/tests/test_archive.py +108 -0
  19. xdfetch-0.2.0/tests/test_availability.py +86 -0
  20. xdfetch-0.2.0/tests/test_ccxml_puz.py +219 -0
  21. xdfetch-0.2.0/tests/test_cli.py +412 -0
  22. xdfetch-0.2.0/tests/test_compat.py +238 -0
  23. xdfetch-0.2.0/tests/test_conde.py +77 -0
  24. xdfetch-0.2.0/tests/test_conde_adapter.py +59 -0
  25. xdfetch-0.2.0/tests/test_convert.py +245 -0
  26. xdfetch-0.2.0/tests/test_fetch.py +233 -0
  27. xdfetch-0.2.0/tests/test_gamedist.py +307 -0
  28. xdfetch-0.2.0/tests/test_http.py +191 -0
  29. xdfetch-0.2.0/tests/test_ipuz.py +93 -0
  30. xdfetch-0.2.0/tests/test_ledger.py +61 -0
  31. xdfetch-0.2.0/tests/test_nyt.py +115 -0
  32. xdfetch-0.2.0/tests/test_nyt_adapter.py +88 -0
  33. xdfetch-0.2.0/tests/test_page.py +53 -0
  34. xdfetch-0.2.0/tests/test_plugins.py +180 -0
  35. xdfetch-0.2.0/tests/test_princetonian.py +80 -0
  36. xdfetch-0.2.0/tests/test_princetonian_adapter.py +85 -0
  37. xdfetch-0.2.0/tests/test_puzzlr.py +256 -0
  38. xdfetch-0.2.0/tests/test_puzzmo.py +602 -0
  39. xdfetch-0.2.0/tests/test_registry.py +225 -0
  40. xdfetch-0.2.0/tests/test_store.py +102 -0
  41. xdfetch-0.2.0/tests/test_wapo.py +87 -0
  42. xdfetch-0.2.0/uv.lock +622 -0
  43. {xdfetch-0.0.1 → xdfetch-0.2.0}/xdfetch/__init__.py +1 -1
  44. xdfetch-0.2.0/xdfetch/adapters/__init__.py +81 -0
  45. xdfetch-0.2.0/xdfetch/adapters/amuse.py +329 -0
  46. xdfetch-0.2.0/xdfetch/adapters/base.py +99 -0
  47. xdfetch-0.2.0/xdfetch/adapters/conde.py +63 -0
  48. xdfetch-0.2.0/xdfetch/adapters/gamedist.py +73 -0
  49. xdfetch-0.2.0/xdfetch/adapters/nyt.py +79 -0
  50. xdfetch-0.2.0/xdfetch/adapters/page.py +53 -0
  51. xdfetch-0.2.0/xdfetch/adapters/princetonian.py +98 -0
  52. xdfetch-0.2.0/xdfetch/adapters/puzzlr.py +78 -0
  53. xdfetch-0.2.0/xdfetch/adapters/puzzmo.py +187 -0
  54. xdfetch-0.2.0/xdfetch/adapters/url.py +37 -0
  55. xdfetch-0.2.0/xdfetch/archive.py +204 -0
  56. xdfetch-0.2.0/xdfetch/availability.py +111 -0
  57. xdfetch-0.2.0/xdfetch/cli.py +492 -0
  58. xdfetch-0.2.0/xdfetch/convert/__init__.py +177 -0
  59. xdfetch-0.2.0/xdfetch/convert/amuse.py +230 -0
  60. xdfetch-0.2.0/xdfetch/convert/ccjson.py +87 -0
  61. xdfetch-0.2.0/xdfetch/convert/ccxml.py +298 -0
  62. xdfetch-0.2.0/xdfetch/convert/conde.py +195 -0
  63. xdfetch-0.2.0/xdfetch/convert/ipuz.py +177 -0
  64. xdfetch-0.2.0/xdfetch/convert/nytjson.py +265 -0
  65. xdfetch-0.2.0/xdfetch/convert/princetonian.py +110 -0
  66. xdfetch-0.2.0/xdfetch/convert/puz.py +125 -0
  67. xdfetch-0.2.0/xdfetch/convert/puzzlr.py +164 -0
  68. xdfetch-0.2.0/xdfetch/convert/puzzmo.py +336 -0
  69. xdfetch-0.2.0/xdfetch/convert/uclick.py +108 -0
  70. xdfetch-0.2.0/xdfetch/convert/wapo.py +107 -0
  71. xdfetch-0.2.0/xdfetch/fetch.py +309 -0
  72. xdfetch-0.2.0/xdfetch/http.py +229 -0
  73. xdfetch-0.2.0/xdfetch/ledger.py +148 -0
  74. xdfetch-0.2.0/xdfetch/outlets.tsv +35 -0
  75. xdfetch-0.2.0/xdfetch/registry.py +308 -0
  76. xdfetch-0.2.0/xdfetch/store.py +117 -0
  77. xdfetch-0.2.0/xdfetch/tsv.py +41 -0
  78. xdfetch-0.2.0/xdfetch/xd.py +306 -0
  79. xdfetch-0.0.1/.github/workflows/test.yml +0 -18
  80. xdfetch-0.0.1/DESIGN.md +0 -74
  81. xdfetch-0.0.1/PKG-INFO +0 -56
  82. xdfetch-0.0.1/README.md +0 -36
  83. xdfetch-0.0.1/tests/test_cli.py +0 -24
  84. xdfetch-0.0.1/uv.lock +0 -202
  85. xdfetch-0.0.1/xdfetch/adapters/__init__.py +0 -5
  86. xdfetch-0.0.1/xdfetch/cli.py +0 -51
  87. xdfetch-0.0.1/xdfetch/convert/__init__.py +0 -5
  88. xdfetch-0.0.1/xdfetch/outlets.tsv +0 -1
  89. xdfetch-0.0.1/xdfetch/registry.py +0 -41
  90. {xdfetch-0.0.1 → xdfetch-0.2.0}/.gitignore +0 -0
  91. {xdfetch-0.0.1 → xdfetch-0.2.0}/LICENSE +0 -0
  92. {xdfetch-0.0.1 → xdfetch-0.2.0}/xdfetch/__main__.py +0 -0
@@ -0,0 +1,2 @@
1
+ # byte-for-byte expected output: never convert line endings
2
+ tests/compat/** binary
@@ -50,7 +50,7 @@ jobs:
50
50
  - run: twine check --strict dist/*
51
51
  - name: run the tests against the built wheel
52
52
  run: |
53
- pip install "$(ls dist/*.whl)" pytest
53
+ pip install "$(ls dist/*.whl)" pytest responses
54
54
  # run from outside the checkout so the tests import the installed wheel
55
55
  cd "$RUNNER_TEMP"
56
56
  pytest "$GITHUB_WORKSPACE/tests"
@@ -0,0 +1,32 @@
1
+ name: test
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ lint:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v7
10
+ - uses: astral-sh/setup-uv@v7
11
+ - run: uv sync --locked
12
+ - run: uv run ruff check
13
+ - run: uv run ruff format --check
14
+ - run: uv run ty check
15
+
16
+ pytest:
17
+ runs-on: ${{ matrix.os }}
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ os: [ubuntu-latest]
22
+ python-version: ["3.11", "3.14"]
23
+ include:
24
+ - os: windows-latest
25
+ python-version: "3.14"
26
+ steps:
27
+ - uses: actions/checkout@v7
28
+ - uses: astral-sh/setup-uv@v7
29
+ with:
30
+ python-version: ${{ matrix.python-version }}
31
+ - run: uv sync --locked
32
+ - run: uv run pytest
xdfetch-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,287 @@
1
+ Metadata-Version: 2.5
2
+ Name: xdfetch
3
+ Version: 0.2.0
4
+ Summary: Download crossword puzzles and convert them to .xd
5
+ Project-URL: Homepage, https://github.com/century-arcade/xdfetch
6
+ Project-URL: Repository, https://github.com/century-arcade/xdfetch
7
+ Project-URL: Issues, https://github.com/century-arcade/xdfetch/issues
8
+ Author: Alex DeJarnatt
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: crossword,downloader,puzzles,xd
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Games/Entertainment :: Puzzle Games
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: lxml>=5.0
20
+ Requires-Dist: requests>=2.32
21
+ Requires-Dist: tzdata>=2024.1; sys_platform == 'win32'
22
+ Provides-Extra: s3
23
+ Requires-Dist: boto3>=1.34; extra == 's3'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # xdfetch
27
+
28
+ Downloads crossword puzzles and converts them to
29
+ [.xd](https://github.com/century-arcade/xdformat), a plain-text crossword format.
30
+
31
+ **Early days.** It fetches puzzles from the outlets it knows (see Outlets below) or from a
32
+ URL, converts puzzle files (see Formats), and can keep an archive of every file it fetches
33
+ (see Keeping an archive).
34
+
35
+ ## Use
36
+
37
+ pip install xdfetch # or: uv tool install xdfetch
38
+
39
+ xdfetch list # the outlets: id, days, homepage
40
+ xdfetch get nytimes.mini # the latest -> nytimes.mini-2026-11-08.xd
41
+ xdfetch get universal.daily --date 2026-11-02 # -> universal.daily-2026-11-02.xd
42
+ xdfetch get https://example.com/puzzle.puz # -> puzzle.xd
43
+ xdfetch get https://example.com/puzzle.puz --format both # also keep puzzle.puz as served
44
+ xdfetch get https://example.com/puzzle.puz --format raw # only the file as served
45
+ xdfetch convert *.puz *.ipuz # -> one .xd each
46
+ xdfetch convert old.puz -o - # to stdout
47
+
48
+ Output goes in the current directory unless `-o` names a file or a directory. An
49
+ existing file is left alone unless you add `--force`. `get` exits with 3 when the outlet
50
+ has no puzzle for the date. An outlet whose row names an `auth` variable reads its secret
51
+ from that environment variable.
52
+
53
+ The format is picked by looking at the file, not its extension. Text is written as UTF-8,
54
+ with curly quotes and ellipses made plain ASCII and HTML in clues turned into xd's markup.
55
+
56
+ ## Keeping an archive
57
+
58
+ `get` fetches one puzzle. `run` is for fetching many, again and again: run it on a
59
+ schedule and it keeps a growing archive of every outlet's files, exactly as served, and a
60
+ ledger of what it has fetched so the next run doesn't repeat it.
61
+
62
+ xdfetch run --store ./archive # yesterday's puzzles from every enabled outlet
63
+ xdfetch run --store ./archive --start 2026-10-01 --id universal.daily usatoday.daily
64
+ xdfetch plan --store ./archive --date today # what run would fetch
65
+ xdfetch status --store ./archive # per outlet: last file, misses, errors
66
+ xdfetch availability --store ./archive # when each outlet's file is really up
67
+ xdfetch verify ./archive/downloads/2026/*.zip # check archives against their manifests
68
+
69
+ ### The store
70
+
71
+ `--store` names a directory or `s3://bucket/prefix` (`pip install 'xdfetch[s3]'`); it can
72
+ also come from the `XDFETCH_STORE` environment variable. A run writes three things there:
73
+
74
+ ledger.tsv one row per fetch, added to by every run
75
+ downloads/2026/20261109-070000-www.zip the files this run fetched (none fetched, no zip)
76
+ reports/2026/20261109-070000-report.md this run's report
77
+
78
+ The archive holds files as the outlet served them, not `.xd`. Unzip one and
79
+ `xdfetch convert` its files when you want `.xd`.
80
+
81
+ ### Which outlets
82
+
83
+ A run fetches every enabled outlet that `xdfetch list` shows, or only the ones named
84
+ after `--id`. `--registry FILE` (repeatable, on every command but `convert` and `verify`)
85
+ lays a file of your own rows over xdfetch's. It adds outlets, or replaces a built-in row
86
+ that has the same `id`; it does not narrow the run to the outlets in the file. A
87
+ tab-separated file with a header line, with only the columns you need (the columns are
88
+ listed under Outlets below):
89
+
90
+ id adapter params days homepage
91
+ example.weekly url url=https://example.com/puzzles/%Y-%m-%d.puz sat https://example.com/
92
+
93
+ To leave a built-in outlet out of every run, replace its row with a disabled one:
94
+
95
+ id adapter enabled
96
+ usatoday.daily url no
97
+
98
+ ### Which dates
99
+
100
+ | Options | Dates |
101
+ |---|---|
102
+ | none | yesterday |
103
+ | `--date D` | that one date |
104
+ | `--start D` | from D to today |
105
+ | `--start D --end E` | from D to E |
106
+
107
+ A date is `YYYY-MM-DD`, `today` or `yesterday` (UTC). Within the range, an outlet is asked
108
+ only for the days it publishes (`days`, `since`) and only once the day's file should be up
109
+ (`available_after`).
110
+
111
+ A run looks only at the dates in its range. It does not work out what it missed since the
112
+ last run, so a job that skips a night, or that should retry misses and do re-fetches,
113
+ needs a range that reaches back. For example, with GNU `date`:
114
+
115
+ xdfetch run --store ./archive --start "$(date -d '8 days ago' +%F)"
116
+
117
+ Dates the ledger already has are skipped, so a wide range costs nothing extra.
118
+
119
+ ### What is fetched
120
+
121
+ For each outlet and date in the range, the ledger decides (`plan` prints the reason):
122
+
123
+ | Reason | When |
124
+ |---|---|
125
+ | `new` | the ledger has no row for it |
126
+ | `retry` | the last try was `missing` or `error`, and the date is at most 3 days old |
127
+ | `refetch` | the outlet's `refetch` column names days after publication to look again (`1,2,7`), and one is due |
128
+ | `forced` | `--force`: fetch it whatever the ledger says |
129
+
130
+ `--no-refetch` turns re-fetches off. Each try ends as one of:
131
+
132
+ | Status | Meaning |
133
+ |---|---|
134
+ | `ok` | first copy of the file, archived |
135
+ | `same` | a re-fetch got identical bytes; nothing new is kept |
136
+ | `changed` | a re-fetch got different bytes; the new file is kept in this run's zip as `name.v2.ext`, and the first copy stays where it was |
137
+ | `missing` | the outlet has nothing for that date (a 404 or an empty page) |
138
+ | `error` | anything else; an outlet that errors 3 times in a row is skipped for the rest of the run |
139
+
140
+ An archive is never rewritten once stored. Each zip holds the fetched files under their
141
+ outlet's id, a `MANIFEST.tsv` (file name, sha256, size, URL and fetch time of each) and a
142
+ `NOTES.md` saying where each outlet's files came from.
143
+
144
+ Other options of `run`: `--dry-run` plans without fetching, `--interval SECONDS` sets the
145
+ pause between requests to one host (default 2), and `--summary FILE` writes the report to
146
+ a second place, such as a CI job summary. The report is also printed. `run` exits with 1
147
+ if any fetch ended in `error`; a `missing` is not an error.
148
+
149
+ ### Checking on it
150
+
151
+ - `status` prints a line per outlet: the date of its last file, how many files, misses
152
+ and errors the ledger holds, and how many of its most recent dates in a row ended in a
153
+ miss or an error.
154
+ - `availability` compares each outlet's `available_after` with what the ledger saw (when
155
+ a file was first found, when it was still missing, and the server's Last-Modified), and
156
+ says whether the number should be raised or could be lower.
157
+ - `verify ZIP...` checks each zip against its own manifest: every listed file is there
158
+ with the same sha256 and size, and no file under an outlet's folder is unlisted. It
159
+ reads only the zips, and exits with 1 if any has a problem.
160
+
161
+ ## Formats
162
+
163
+ | Format | Served by, for example |
164
+ |---|---|
165
+ | Across Lite `.puz` | many outlets and archives |
166
+ | ipuz (`.ipuz`, also as JSONP) | Crossword Compiler and other tools |
167
+ | Crossword Compiler XML (`.jpz`) | Arkadium's feeds, many others |
168
+ | uclick XML | Andrews McMeel (Universal, USA Today) |
169
+ | NYT puzzle JSON (v6) | The New York Times |
170
+ | Condé Nast puzzle record | The New Yorker |
171
+ | Washington Post games JSON | The Washington Post |
172
+ | Daily Princetonian crossword API | The Daily Princetonian |
173
+ | Amuse Labs (PuzzleMe) puzzle JSON, unscrambled from the player page | the LA Times, the Atlantic, Newsday, Vox, many others |
174
+ | Crossword Compiler as JSON (xml2js) | King Features, on GameDistribution's feeds |
175
+ | PZLR crossword level | The Wall Street Journal |
176
+ | Puzzmo's xd dialect, in its GraphQL answers | Puzzmo, the Boston Globe |
177
+
178
+ Other packages can add formats; see "Adding to xdfetch from another package" below.
179
+
180
+ ## Outlets
181
+
182
+ The series xdfetch knows how to fetch are rows of
183
+ [`xdfetch/outlets.tsv`](xdfetch/outlets.tsv), each with an `id` of the form
184
+ `publisher.series`: `nytimes.daily`, `nytimes.mini`, `nytimes.midi`, `newyorker.daily`,
185
+ `newyorker.mini`, `universal.daily`, `universal.sunday`, `usatoday.daily`,
186
+ `washingtonpost.sunday`, `arkadium.mini`, `arkadium.two-minute-mini`,
187
+ `brendanemmettquigley.blog`, `dailyprincetonian.daily`, `dailyprincetonian.mini`,
188
+ `latimes.daily`, `latimes.midi`, `atlantic.daily`, `newsday.daily`, `dailybeast.daily`,
189
+ `crosswordclub.daily`, `crosswordclub.themed`, `vox.daily`, `vulture.daily`,
190
+ `walrus.weekly`, `defector.weekly`, `kingfeatures.thomas-joseph`,
191
+ `kingfeatures.eugene-sheffer`, `kingfeatures.premier-sunday`, `wsj.daily`,
192
+ `bostonglobe.sunday`, `bostonglobe.mini`, `puzzmo.daily`, `puzzmo.mini`, `puzzmo.big`.
193
+ `xdfetch list` prints them all.
194
+
195
+ A row's columns, of which only `id` and `adapter` are required:
196
+
197
+ | Column | Meaning | Blank means |
198
+ |---|---|---|
199
+ | `id` | the series' id | |
200
+ | `adapter` | which adapter fetches it: `url`, `page`, `nyt`, `conde`, `princetonian`, `amuse`, `gamedist`, `puzzlr`, `puzzmo`, or one a package adds | |
201
+ | `params` | the adapter's parameters, as `key=value` pairs separated by spaces (quote a value that holds spaces) | none |
202
+ | `days` | the days a puzzle runs: `daily`, `mon-sat`, `sun`, `mon,thu` | daily |
203
+ | `since` | the first date to expect a puzzle, `YYYY-MM-DD` | any date |
204
+ | `tz` | the outlet's timezone, an IANA name | `America/New_York` |
205
+ | `available_after` | hours after midnight in `tz` before the day's file is reliably up; negative for the evening before | 0 |
206
+ | `refetch` | days after publication to fetch again and compare, such as `1,2,7` | never |
207
+ | `homepage` | the outlet's own page for the series | |
208
+ | `auth` | the environment variable holding the secret the adapter needs | no secret |
209
+ | `enabled` | `yes` or `no` | yes |
210
+ | `notes` | what the file is, how it's named, quirks of the series | |
211
+
212
+ Two adapters need no code for a new outlet. `url` fetches a file at a predictable address:
213
+ its `url` param is the address with strftime fields for the date (`%Y-%m-%d`), and an
214
+ optional `filename` param names the file to keep it under. `page` fetches a dated page
215
+ (`page`, also with strftime fields) and then every file it links whose address matches
216
+ the `link` regex.
217
+
218
+ The `amuse` adapter fetches from an Amuse Labs (PuzzleMe) player: `host` is the player's
219
+ base address and `set` the series on it. The puzzle id for a date comes from an `id`
220
+ strftime template when the id encodes the date; or, with `page`, from the player embedded
221
+ in the outlet's own page for the date (`fallback` names an undated page to try when that
222
+ one is missing); else from the player's date picker, which lists only recent puzzles.
223
+ Its converter reads `editor`, `title_drop`, `copyright=year`, `publish_offset` and
224
+ `date_title=weekday` from the
225
+ row too; `xdfetch/adapters/amuse.py` and `xdfetch/convert/amuse.py` describe them all.
226
+
227
+ Three more fetch from platforms that also carry several outlets' puzzles:
228
+
229
+ - `gamedist`, GameDistribution's crossword feeds (King Features' puzzles): `feed` is the
230
+ feed's slug. The rows also set how the puzzle is written: `author` (a fixed byline),
231
+ `dash` and `endash` (what to write for an em or en dash in a clue) and `quotes=repair`
232
+ (a space after a clue's opening quote mark removed, unbalanced quote marks noted).
233
+ - `puzzlr`, PZLR: `tenant` is the publisher's slug, and `offset` the days added to the
234
+ date asked for to get the date PZLR files the puzzle under. `copyright` is written when
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`).
238
+
239
+ Adapters return each file as the outlet served it; converting is a separate step.
240
+
241
+ ## Adding to xdfetch from another package
242
+
243
+ Another package can add adapters, outlet rows and formats without changing xdfetch,
244
+ through three entry-point groups in its `pyproject.toml`:
245
+
246
+ [project]
247
+ dependencies = ["xdfetch"]
248
+
249
+ # an adapter: the entry point's name is the adapter's name, its object an
250
+ # xdfetch.adapters.Adapter subclass
251
+ [project.entry-points."xdfetch.adapters"]
252
+ mysite = "mypackage.adapters:MySiteAdapter"
253
+
254
+ # outlet rows: a path to a TSV with the same columns as outlets.tsv (a str,
255
+ # os.PathLike or importlib.resources Traversable), or a function returning one
256
+ [project.entry-points."xdfetch.registries"]
257
+ mypackage = "mypackage:outlets_tsv"
258
+
259
+ # a converter: a (name, sniff, convert) tuple, or an object such as a module
260
+ # with sniff and convert functions
261
+ [project.entry-points."xdfetch.converters"]
262
+ myformat = "mypackage.myformat"
263
+
264
+ Converter plugins are sniffed before the built-in converters and may hand data back by
265
+ returning None; the docstring of `xdfetch.convert` gives the full shape.
266
+
267
+ `xdfetch.registry.effective()` reads xdfetch's own rows, then each package's TSV (in
268
+ order of entry-point name), then any files passed to it. A later row with the same
269
+ `id` replaces the earlier one whole, so a package can change a built-in outlet's row as
270
+ well as add its own. A program that keeps a registry of its own can load just its files
271
+ with `xdfetch.registry.from_files(paths)`; ids outside xdfetch's own rows can be any
272
+ string and are used as given. `xdfetch.adapters.get(name)` finds built-in adapters first,
273
+ then the packages'; a missing or broken one is an error that names it.
274
+
275
+ ## Development
276
+
277
+ uv sync
278
+ uv run pytest
279
+ uv run ruff check && uv run ruff format --check
280
+ uv run ty check
281
+
282
+ Tests use tiny puzzles made up for the purpose (`tests/conftest.py`, `tests/synthetic.py`).
283
+ Please don't add real, published puzzles to the repo.
284
+
285
+ ## License
286
+
287
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,262 @@
1
+ # xdfetch
2
+
3
+ Downloads crossword puzzles and converts them to
4
+ [.xd](https://github.com/century-arcade/xdformat), a plain-text crossword format.
5
+
6
+ **Early days.** It fetches puzzles from the outlets it knows (see Outlets below) or from a
7
+ URL, converts puzzle files (see Formats), and can keep an archive of every file it fetches
8
+ (see Keeping an archive).
9
+
10
+ ## Use
11
+
12
+ pip install xdfetch # or: uv tool install xdfetch
13
+
14
+ xdfetch list # the outlets: id, days, homepage
15
+ xdfetch get nytimes.mini # the latest -> nytimes.mini-2026-11-08.xd
16
+ xdfetch get universal.daily --date 2026-11-02 # -> universal.daily-2026-11-02.xd
17
+ xdfetch get https://example.com/puzzle.puz # -> puzzle.xd
18
+ xdfetch get https://example.com/puzzle.puz --format both # also keep puzzle.puz as served
19
+ xdfetch get https://example.com/puzzle.puz --format raw # only the file as served
20
+ xdfetch convert *.puz *.ipuz # -> one .xd each
21
+ xdfetch convert old.puz -o - # to stdout
22
+
23
+ Output goes in the current directory unless `-o` names a file or a directory. An
24
+ existing file is left alone unless you add `--force`. `get` exits with 3 when the outlet
25
+ has no puzzle for the date. An outlet whose row names an `auth` variable reads its secret
26
+ from that environment variable.
27
+
28
+ The format is picked by looking at the file, not its extension. Text is written as UTF-8,
29
+ with curly quotes and ellipses made plain ASCII and HTML in clues turned into xd's markup.
30
+
31
+ ## Keeping an archive
32
+
33
+ `get` fetches one puzzle. `run` is for fetching many, again and again: run it on a
34
+ schedule and it keeps a growing archive of every outlet's files, exactly as served, and a
35
+ ledger of what it has fetched so the next run doesn't repeat it.
36
+
37
+ xdfetch run --store ./archive # yesterday's puzzles from every enabled outlet
38
+ xdfetch run --store ./archive --start 2026-10-01 --id universal.daily usatoday.daily
39
+ xdfetch plan --store ./archive --date today # what run would fetch
40
+ xdfetch status --store ./archive # per outlet: last file, misses, errors
41
+ xdfetch availability --store ./archive # when each outlet's file is really up
42
+ xdfetch verify ./archive/downloads/2026/*.zip # check archives against their manifests
43
+
44
+ ### The store
45
+
46
+ `--store` names a directory or `s3://bucket/prefix` (`pip install 'xdfetch[s3]'`); it can
47
+ also come from the `XDFETCH_STORE` environment variable. A run writes three things there:
48
+
49
+ ledger.tsv one row per fetch, added to by every run
50
+ downloads/2026/20261109-070000-www.zip the files this run fetched (none fetched, no zip)
51
+ reports/2026/20261109-070000-report.md this run's report
52
+
53
+ The archive holds files as the outlet served them, not `.xd`. Unzip one and
54
+ `xdfetch convert` its files when you want `.xd`.
55
+
56
+ ### Which outlets
57
+
58
+ A run fetches every enabled outlet that `xdfetch list` shows, or only the ones named
59
+ after `--id`. `--registry FILE` (repeatable, on every command but `convert` and `verify`)
60
+ lays a file of your own rows over xdfetch's. It adds outlets, or replaces a built-in row
61
+ that has the same `id`; it does not narrow the run to the outlets in the file. A
62
+ tab-separated file with a header line, with only the columns you need (the columns are
63
+ listed under Outlets below):
64
+
65
+ id adapter params days homepage
66
+ example.weekly url url=https://example.com/puzzles/%Y-%m-%d.puz sat https://example.com/
67
+
68
+ To leave a built-in outlet out of every run, replace its row with a disabled one:
69
+
70
+ id adapter enabled
71
+ usatoday.daily url no
72
+
73
+ ### Which dates
74
+
75
+ | Options | Dates |
76
+ |---|---|
77
+ | none | yesterday |
78
+ | `--date D` | that one date |
79
+ | `--start D` | from D to today |
80
+ | `--start D --end E` | from D to E |
81
+
82
+ A date is `YYYY-MM-DD`, `today` or `yesterday` (UTC). Within the range, an outlet is asked
83
+ only for the days it publishes (`days`, `since`) and only once the day's file should be up
84
+ (`available_after`).
85
+
86
+ A run looks only at the dates in its range. It does not work out what it missed since the
87
+ last run, so a job that skips a night, or that should retry misses and do re-fetches,
88
+ needs a range that reaches back. For example, with GNU `date`:
89
+
90
+ xdfetch run --store ./archive --start "$(date -d '8 days ago' +%F)"
91
+
92
+ Dates the ledger already has are skipped, so a wide range costs nothing extra.
93
+
94
+ ### What is fetched
95
+
96
+ For each outlet and date in the range, the ledger decides (`plan` prints the reason):
97
+
98
+ | Reason | When |
99
+ |---|---|
100
+ | `new` | the ledger has no row for it |
101
+ | `retry` | the last try was `missing` or `error`, and the date is at most 3 days old |
102
+ | `refetch` | the outlet's `refetch` column names days after publication to look again (`1,2,7`), and one is due |
103
+ | `forced` | `--force`: fetch it whatever the ledger says |
104
+
105
+ `--no-refetch` turns re-fetches off. Each try ends as one of:
106
+
107
+ | Status | Meaning |
108
+ |---|---|
109
+ | `ok` | first copy of the file, archived |
110
+ | `same` | a re-fetch got identical bytes; nothing new is kept |
111
+ | `changed` | a re-fetch got different bytes; the new file is kept in this run's zip as `name.v2.ext`, and the first copy stays where it was |
112
+ | `missing` | the outlet has nothing for that date (a 404 or an empty page) |
113
+ | `error` | anything else; an outlet that errors 3 times in a row is skipped for the rest of the run |
114
+
115
+ An archive is never rewritten once stored. Each zip holds the fetched files under their
116
+ outlet's id, a `MANIFEST.tsv` (file name, sha256, size, URL and fetch time of each) and a
117
+ `NOTES.md` saying where each outlet's files came from.
118
+
119
+ Other options of `run`: `--dry-run` plans without fetching, `--interval SECONDS` sets the
120
+ pause between requests to one host (default 2), and `--summary FILE` writes the report to
121
+ a second place, such as a CI job summary. The report is also printed. `run` exits with 1
122
+ if any fetch ended in `error`; a `missing` is not an error.
123
+
124
+ ### Checking on it
125
+
126
+ - `status` prints a line per outlet: the date of its last file, how many files, misses
127
+ and errors the ledger holds, and how many of its most recent dates in a row ended in a
128
+ miss or an error.
129
+ - `availability` compares each outlet's `available_after` with what the ledger saw (when
130
+ a file was first found, when it was still missing, and the server's Last-Modified), and
131
+ says whether the number should be raised or could be lower.
132
+ - `verify ZIP...` checks each zip against its own manifest: every listed file is there
133
+ with the same sha256 and size, and no file under an outlet's folder is unlisted. It
134
+ reads only the zips, and exits with 1 if any has a problem.
135
+
136
+ ## Formats
137
+
138
+ | Format | Served by, for example |
139
+ |---|---|
140
+ | Across Lite `.puz` | many outlets and archives |
141
+ | ipuz (`.ipuz`, also as JSONP) | Crossword Compiler and other tools |
142
+ | Crossword Compiler XML (`.jpz`) | Arkadium's feeds, many others |
143
+ | uclick XML | Andrews McMeel (Universal, USA Today) |
144
+ | NYT puzzle JSON (v6) | The New York Times |
145
+ | Condé Nast puzzle record | The New Yorker |
146
+ | Washington Post games JSON | The Washington Post |
147
+ | Daily Princetonian crossword API | The Daily Princetonian |
148
+ | Amuse Labs (PuzzleMe) puzzle JSON, unscrambled from the player page | the LA Times, the Atlantic, Newsday, Vox, many others |
149
+ | Crossword Compiler as JSON (xml2js) | King Features, on GameDistribution's feeds |
150
+ | PZLR crossword level | The Wall Street Journal |
151
+ | Puzzmo's xd dialect, in its GraphQL answers | Puzzmo, the Boston Globe |
152
+
153
+ Other packages can add formats; see "Adding to xdfetch from another package" below.
154
+
155
+ ## Outlets
156
+
157
+ The series xdfetch knows how to fetch are rows of
158
+ [`xdfetch/outlets.tsv`](xdfetch/outlets.tsv), each with an `id` of the form
159
+ `publisher.series`: `nytimes.daily`, `nytimes.mini`, `nytimes.midi`, `newyorker.daily`,
160
+ `newyorker.mini`, `universal.daily`, `universal.sunday`, `usatoday.daily`,
161
+ `washingtonpost.sunday`, `arkadium.mini`, `arkadium.two-minute-mini`,
162
+ `brendanemmettquigley.blog`, `dailyprincetonian.daily`, `dailyprincetonian.mini`,
163
+ `latimes.daily`, `latimes.midi`, `atlantic.daily`, `newsday.daily`, `dailybeast.daily`,
164
+ `crosswordclub.daily`, `crosswordclub.themed`, `vox.daily`, `vulture.daily`,
165
+ `walrus.weekly`, `defector.weekly`, `kingfeatures.thomas-joseph`,
166
+ `kingfeatures.eugene-sheffer`, `kingfeatures.premier-sunday`, `wsj.daily`,
167
+ `bostonglobe.sunday`, `bostonglobe.mini`, `puzzmo.daily`, `puzzmo.mini`, `puzzmo.big`.
168
+ `xdfetch list` prints them all.
169
+
170
+ A row's columns, of which only `id` and `adapter` are required:
171
+
172
+ | Column | Meaning | Blank means |
173
+ |---|---|---|
174
+ | `id` | the series' id | |
175
+ | `adapter` | which adapter fetches it: `url`, `page`, `nyt`, `conde`, `princetonian`, `amuse`, `gamedist`, `puzzlr`, `puzzmo`, or one a package adds | |
176
+ | `params` | the adapter's parameters, as `key=value` pairs separated by spaces (quote a value that holds spaces) | none |
177
+ | `days` | the days a puzzle runs: `daily`, `mon-sat`, `sun`, `mon,thu` | daily |
178
+ | `since` | the first date to expect a puzzle, `YYYY-MM-DD` | any date |
179
+ | `tz` | the outlet's timezone, an IANA name | `America/New_York` |
180
+ | `available_after` | hours after midnight in `tz` before the day's file is reliably up; negative for the evening before | 0 |
181
+ | `refetch` | days after publication to fetch again and compare, such as `1,2,7` | never |
182
+ | `homepage` | the outlet's own page for the series | |
183
+ | `auth` | the environment variable holding the secret the adapter needs | no secret |
184
+ | `enabled` | `yes` or `no` | yes |
185
+ | `notes` | what the file is, how it's named, quirks of the series | |
186
+
187
+ Two adapters need no code for a new outlet. `url` fetches a file at a predictable address:
188
+ its `url` param is the address with strftime fields for the date (`%Y-%m-%d`), and an
189
+ optional `filename` param names the file to keep it under. `page` fetches a dated page
190
+ (`page`, also with strftime fields) and then every file it links whose address matches
191
+ the `link` regex.
192
+
193
+ The `amuse` adapter fetches from an Amuse Labs (PuzzleMe) player: `host` is the player's
194
+ base address and `set` the series on it. The puzzle id for a date comes from an `id`
195
+ strftime template when the id encodes the date; or, with `page`, from the player embedded
196
+ in the outlet's own page for the date (`fallback` names an undated page to try when that
197
+ one is missing); else from the player's date picker, which lists only recent puzzles.
198
+ Its converter reads `editor`, `title_drop`, `copyright=year`, `publish_offset` and
199
+ `date_title=weekday` from the
200
+ row too; `xdfetch/adapters/amuse.py` and `xdfetch/convert/amuse.py` describe them all.
201
+
202
+ Three more fetch from platforms that also carry several outlets' puzzles:
203
+
204
+ - `gamedist`, GameDistribution's crossword feeds (King Features' puzzles): `feed` is the
205
+ feed's slug. The rows also set how the puzzle is written: `author` (a fixed byline),
206
+ `dash` and `endash` (what to write for an em or en dash in a clue) and `quotes=repair`
207
+ (a space after a clue's opening quote mark removed, unbalanced quote marks noted).
208
+ - `puzzlr`, PZLR: `tenant` is the publisher's slug, and `offset` the days added to the
209
+ date asked for to get the date PZLR files the puzzle under. `copyright` is written when
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`).
213
+
214
+ Adapters return each file as the outlet served it; converting is a separate step.
215
+
216
+ ## Adding to xdfetch from another package
217
+
218
+ Another package can add adapters, outlet rows and formats without changing xdfetch,
219
+ through three entry-point groups in its `pyproject.toml`:
220
+
221
+ [project]
222
+ dependencies = ["xdfetch"]
223
+
224
+ # an adapter: the entry point's name is the adapter's name, its object an
225
+ # xdfetch.adapters.Adapter subclass
226
+ [project.entry-points."xdfetch.adapters"]
227
+ mysite = "mypackage.adapters:MySiteAdapter"
228
+
229
+ # outlet rows: a path to a TSV with the same columns as outlets.tsv (a str,
230
+ # os.PathLike or importlib.resources Traversable), or a function returning one
231
+ [project.entry-points."xdfetch.registries"]
232
+ mypackage = "mypackage:outlets_tsv"
233
+
234
+ # a converter: a (name, sniff, convert) tuple, or an object such as a module
235
+ # with sniff and convert functions
236
+ [project.entry-points."xdfetch.converters"]
237
+ myformat = "mypackage.myformat"
238
+
239
+ Converter plugins are sniffed before the built-in converters and may hand data back by
240
+ returning None; the docstring of `xdfetch.convert` gives the full shape.
241
+
242
+ `xdfetch.registry.effective()` reads xdfetch's own rows, then each package's TSV (in
243
+ order of entry-point name), then any files passed to it. A later row with the same
244
+ `id` replaces the earlier one whole, so a package can change a built-in outlet's row as
245
+ well as add its own. A program that keeps a registry of its own can load just its files
246
+ with `xdfetch.registry.from_files(paths)`; ids outside xdfetch's own rows can be any
247
+ string and are used as given. `xdfetch.adapters.get(name)` finds built-in adapters first,
248
+ then the packages'; a missing or broken one is an error that names it.
249
+
250
+ ## Development
251
+
252
+ uv sync
253
+ uv run pytest
254
+ uv run ruff check && uv run ruff format --check
255
+ uv run ty check
256
+
257
+ Tests use tiny puzzles made up for the purpose (`tests/conftest.py`, `tests/synthetic.py`).
258
+ Please don't add real, published puzzles to the repo.
259
+
260
+ ## License
261
+
262
+ MIT. See [LICENSE](LICENSE).
@@ -5,22 +5,29 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "xdfetch"
7
7
  dynamic = ["version"]
8
- description = "Fetch crossword puzzles from online outlets and convert them to .xd"
8
+ description = "Download crossword puzzles and convert them to .xd"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
- requires-python = ">=3.10"
12
+ requires-python = ">=3.11"
13
13
  authors = [{ name = "Alex DeJarnatt" }]
14
14
  keywords = ["crossword", "puzzles", "xd", "downloader"]
15
15
  classifiers = [
16
- "Development Status :: 1 - Planning",
16
+ "Development Status :: 2 - Pre-Alpha",
17
17
  "Environment :: Console",
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
20
20
  "Programming Language :: Python :: 3 :: Only",
21
21
  "Topic :: Games/Entertainment :: Puzzle Games",
22
22
  ]
23
- dependencies = []
23
+ dependencies = [
24
+ "lxml>=5.0",
25
+ "requests>=2.32",
26
+ "tzdata>=2024.1; sys_platform == 'win32'",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ s3 = ["boto3>=1.34"] # s3:// stores
24
31
 
25
32
  [project.urls]
26
33
  Homepage = "https://github.com/century-arcade/xdfetch"
@@ -31,7 +38,12 @@ Issues = "https://github.com/century-arcade/xdfetch/issues"
31
38
  xdfetch = "xdfetch.cli:main"
32
39
 
33
40
  [dependency-groups]
34
- dev = ["pytest>=8", "ruff>=0.8"]
41
+ dev = [
42
+ "pytest>=8",
43
+ "responses>=0.26.3",
44
+ "ruff>=0.8",
45
+ "ty>=0.0.100",
46
+ ]
35
47
 
36
48
  [tool.hatch.version]
37
49
  path = "xdfetch/__init__.py"
@@ -41,10 +53,15 @@ packages = ["xdfetch"]
41
53
 
42
54
  [tool.ruff]
43
55
  line-length = 120
44
- target-version = "py310"
56
+ target-version = "py311"
45
57
 
46
58
  [tool.ruff.lint]
47
- select = ["E", "F", "I", "UP", "B", "W"]
59
+ select = ["E", "W", "F", "I", "UP", "B", "SIM", "RUF", "PT"]
60
+ ignore = ["RUF001", "RUF003"] # puzzle text is full of curly quotes, dashes and the like
61
+
62
+ [tool.ty.environment]
63
+ python-version = "3.11"
48
64
 
49
65
  [tool.pytest.ini_options]
50
66
  testpaths = ["tests"]
67
+ addopts = ["--strict-markers"]
@@ -0,0 +1,2 @@
1
+ name sha256 bytes producer kind received fixity_recorded notes
2
+ 20261029-120000-www.zip f987b48f3cfb4f9efa89147dafb51719d376bdf2410e09db636b3ee13a818ce9 1376 othertool session 2026-10-29 at receipt
@@ -0,0 +1,2 @@
1
+ name sha256 bytes producer kind received fixity_recorded notes
2
+ 20261030-130000-www.zip 059b31e9a1932cc5797949321224376665cab6761e1222c125c21998283795fc 1176 othertool session 2026-10-30 at receipt