xkcdkit 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xkcdkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations.
5
5
  Keywords: xkcd,comics,cli,tui,textual
6
6
  Author: Eivind Teig
@@ -113,8 +113,8 @@ xkcd random # a random comic
113
113
  xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
114
114
  xkcd explain 614 # just the explanation, as plain text
115
115
  xkcd sync # refresh the archive index and the latest comic
116
- xkcd sync --full # download every comic and explanation (under an hour, see below)
117
- xkcd sync --full --no-explain # comics only, a minute or two; explanations come later
116
+ xkcd sync --full # download every comic and explanation (a couple of minutes)
117
+ xkcd sync --full --no-explain # comics only, under a minute; explanations come later
118
118
  xkcd resync # re-fetch comics older than 30 days or still missing an explanation
119
119
  xkcd tui # open the browser
120
120
  ```
@@ -272,19 +272,18 @@ async def xkcd(ctx, num: int):
272
272
  ### Being polite to explainxkcd
273
273
 
274
274
  explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
275
- per second, so a full sync sends at most one explainxkcd request per second
276
- and a User-Agent that names this project and its repository. xkcd.com has no
277
- such limit and is fetched by several workers at once, so explanations are the
278
- pacing item: a complete first `sync --full` takes a little under an hour for
279
- the whole archive.
280
-
281
- Two ways to shorten the wait:
282
-
283
- - `xkcd sync --full --no-explain` fetches only the comics, which takes a
284
- minute or two. Explanations are then filled in on demand when you open a
285
- comic, or in bulk with `xkcd resync`, which is paced the same way.
286
- - Ctrl-C at any point is safe. Every comic is committed to the database the
287
- moment it is fetched, and the next `sync --full` skips everything already
275
+ per second, so bulk syncs send at most one explainxkcd request per second and
276
+ a User-Agent that names this project and its repository. To stay fast anyway,
277
+ each request asks the MediaWiki API for fifty pages at once, the maximum it
278
+ allows, so the whole archive's explanations arrive in about seventy requests.
279
+ xkcd.com has no such limit and is fetched by several workers at once. A
280
+ complete first `sync --full` takes a couple of minutes.
281
+
282
+ - `xkcd sync --full --no-explain` fetches only the comics, in under a minute.
283
+ Explanations are then filled in on demand when you open a comic, or in bulk
284
+ with `xkcd resync`, paced the same way.
285
+ - Ctrl-C at any point is safe. Each batch of fifty is committed as soon as it
286
+ is fetched, and the next `sync --full` or `resync` skips everything already
288
287
  cached, so you can sync in several sittings.
289
288
 
290
289
  The sync walks every number from 1 to the latest comic, so it does not depend
@@ -81,8 +81,8 @@ xkcd random # a random comic
81
81
  xkcd search "woodpecker" # fuzzy search over cached titles, alt text, transcripts and explanations
82
82
  xkcd explain 614 # just the explanation, as plain text
83
83
  xkcd sync # refresh the archive index and the latest comic
84
- xkcd sync --full # download every comic and explanation (under an hour, see below)
85
- xkcd sync --full --no-explain # comics only, a minute or two; explanations come later
84
+ xkcd sync --full # download every comic and explanation (a couple of minutes)
85
+ xkcd sync --full --no-explain # comics only, under a minute; explanations come later
86
86
  xkcd resync # re-fetch comics older than 30 days or still missing an explanation
87
87
  xkcd tui # open the browser
88
88
  ```
@@ -240,19 +240,18 @@ async def xkcd(ctx, num: int):
240
240
  ### Being polite to explainxkcd
241
241
 
242
242
  explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
243
- per second, so a full sync sends at most one explainxkcd request per second
244
- and a User-Agent that names this project and its repository. xkcd.com has no
245
- such limit and is fetched by several workers at once, so explanations are the
246
- pacing item: a complete first `sync --full` takes a little under an hour for
247
- the whole archive.
248
-
249
- Two ways to shorten the wait:
250
-
251
- - `xkcd sync --full --no-explain` fetches only the comics, which takes a
252
- minute or two. Explanations are then filled in on demand when you open a
253
- comic, or in bulk with `xkcd resync`, which is paced the same way.
254
- - Ctrl-C at any point is safe. Every comic is committed to the database the
255
- moment it is fetched, and the next `sync --full` skips everything already
243
+ per second, so bulk syncs send at most one explainxkcd request per second and
244
+ a User-Agent that names this project and its repository. To stay fast anyway,
245
+ each request asks the MediaWiki API for fifty pages at once, the maximum it
246
+ allows, so the whole archive's explanations arrive in about seventy requests.
247
+ xkcd.com has no such limit and is fetched by several workers at once. A
248
+ complete first `sync --full` takes a couple of minutes.
249
+
250
+ - `xkcd sync --full --no-explain` fetches only the comics, in under a minute.
251
+ Explanations are then filled in on demand when you open a comic, or in bulk
252
+ with `xkcd resync`, paced the same way.
253
+ - Ctrl-C at any point is safe. Each batch of fifty is committed as soon as it
254
+ is fetched, and the next `sync --full` or `resync` skips everything already
256
255
  cached, so you can sync in several sittings.
257
256
 
258
257
  The sync walks every number from 1 to the latest comic, so it does not depend
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xkcdkit"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "xkcd comics as a Python library, a CLI and a TUI, with a local cache and explainxkcd explanations."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -13,7 +13,14 @@ import httpx2
13
13
 
14
14
  from xkcdkit import ComicNotFound, XKCDError
15
15
  from xkcdkit.models import ArchiveEntry, Comic
16
- from xkcdkit.sources import fetch_archive, fetch_comic, fetch_explanation, user_agent
16
+ from xkcdkit.sources import (
17
+ EXPLAIN_BATCH,
18
+ fetch_archive,
19
+ fetch_comic,
20
+ fetch_explanation,
21
+ fetch_explanations,
22
+ user_agent,
23
+ )
17
24
  from xkcdkit.store import Store, utc_now
18
25
 
19
26
  Progress = Callable[[int, int], None]
@@ -72,6 +79,10 @@ class AsyncXKCD:
72
79
  """Attach the explainxkcd page when it exists and stamp the sync time."""
73
80
  found = await fetch_explanation(self.http, comic.num)
74
81
  self.last_explain = time.monotonic()
82
+ return self.attach(comic, found)
83
+
84
+ def attach(self, comic: Comic, found: tuple[str, int] | None) -> Comic:
85
+ """Copy a comic with explanation data (or a note of its absence) and a fresh sync time."""
75
86
  wikitext, revid = found if found else (None, None)
76
87
  return comic.model_copy(
77
88
  update={
@@ -155,34 +166,38 @@ class AsyncXKCD:
155
166
  return await self.fetch_many(self.store.stale(max_age), progress)
156
167
 
157
168
  async def fetch_many(self, nums: list[int], progress: Progress | None, explain: bool = True) -> int:
158
- """Fetch and store comics with several workers; explainxkcd is paced to one request at a time.
169
+ """Fetch and store comics in batches of ``EXPLAIN_BATCH``.
159
170
 
160
- xkcd.com requests overlap freely. Each stored comic is committed at once, so an
161
- interrupted run keeps everything finished so far. Numbers xkcd lacks are skipped.
171
+ Within a batch xkcd.com is fetched concurrently and explainxkcd answers for the
172
+ whole batch in one paced request. Each batch is committed as a unit, so an
173
+ interrupted run keeps every finished batch. Numbers xkcd lacks are skipped.
162
174
  """
163
- pending, done, fetched = list(reversed(nums)), 0, 0
175
+ fetched = 0
176
+ for start in range(0, len(nums), EXPLAIN_BATCH):
177
+ comics = await self.fetch_comics(nums[start : start + EXPLAIN_BATCH])
178
+ if explain and comics:
179
+ await self.pace()
180
+ found = await fetch_explanations(self.http, [comic.num for comic in comics])
181
+ comics = [self.attach(comic, found[comic.num]) for comic in comics]
182
+ for comic in comics:
183
+ self.store.replace(comic)
184
+ fetched += len(comics)
185
+ if progress:
186
+ progress(min(start + EXPLAIN_BATCH, len(nums)), len(nums))
187
+ return fetched
188
+
189
+ async def fetch_comics(self, nums: list[int]) -> list[Comic]:
190
+ """Fetch several comics from xkcd.com, a few at a time, skipping numbers it lacks."""
191
+ limit = asyncio.Semaphore(WORKERS)
164
192
 
165
- async def worker() -> None:
166
- nonlocal done, fetched
167
- while pending:
168
- num = pending.pop()
193
+ async def one(num: int) -> Comic | None:
194
+ async with limit:
169
195
  try:
170
- comic = await fetch_comic(self.http, num)
171
- if explain:
172
- await self.pace()
173
- comic = await self.complete(comic)
174
- self.store.replace(comic)
175
- fetched += 1
196
+ return await fetch_comic(self.http, num)
176
197
  except ComicNotFound:
177
- pass
178
- done += 1
179
- if progress:
180
- progress(done, len(nums))
181
-
182
- async with asyncio.TaskGroup() as group:
183
- for _ in range(min(WORKERS, len(nums))):
184
- group.create_task(worker())
185
- return fetched
198
+ return None
199
+
200
+ return [comic for comic in await asyncio.gather(*(one(num) for num in nums)) if comic]
186
201
 
187
202
  async def pace(self) -> None:
188
203
  """Wait until ``explain_delay`` has passed since the previous explainxkcd request."""
@@ -15,6 +15,7 @@ from xkcdkit.models import ArchiveEntry, Comic
15
15
 
16
16
  XKCD = "https://xkcd.com"
17
17
  EXPLAIN_API = "https://www.explainxkcd.com/wiki/api.php"
18
+ EXPLAIN_BATCH = 50 # pages per API request allowed for ordinary users
18
19
  ARCHIVE_LINK = re.compile(r'<a href="/(\d+)/" title="([^"]+)">(.*?)</a>', re.DOTALL)
19
20
  HTML_TAG = re.compile(r"</?[a-z][a-z0-9]*(?:\s[^>]*)?>")
20
21
 
@@ -59,15 +60,23 @@ async def fetch_archive(client: httpx2.AsyncClient) -> list[ArchiveEntry]:
59
60
  return parse_archive(response.text)
60
61
 
61
62
 
62
- async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str, int] | None:
63
- """Fetch the explainxkcd page for ``num`` as ``(wikitext, revid)``, or None if absent."""
63
+ async def fetch_explanations(
64
+ client: httpx2.AsyncClient, nums: list[int]
65
+ ) -> dict[int, tuple[str, int] | None]:
66
+ """Fetch up to ``EXPLAIN_BATCH`` explainxkcd pages in one request, keyed by comic number.
67
+
68
+ The wiki pages are named ``614: Woodpecker``; the bare number redirects there, and
69
+ the API resolves that for us. Numbers without a page map to None.
70
+ """
64
71
  response = await request(
65
72
  client,
66
73
  EXPLAIN_API,
67
- action="parse",
68
- page=str(num),
74
+ action="query",
75
+ titles="|".join(str(num) for num in nums),
69
76
  redirects=1,
70
- prop="wikitext|revid",
77
+ prop="revisions",
78
+ rvprop="ids|content",
79
+ rvslots="main",
71
80
  format="json",
72
81
  formatversion=2,
73
82
  )
@@ -75,10 +84,23 @@ async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str,
75
84
  raise XKCDError(f"explainxkcd: HTTP {response.status_code}")
76
85
  payload = response.json()
77
86
  if "error" in payload:
78
- if payload["error"].get("code") == "missingtitle":
79
- return None
80
87
  raise XKCDError(f"explainxkcd: {payload['error'].get('info', 'unknown error')}")
81
- return payload["parse"]["wikitext"], int(payload["parse"]["revid"])
88
+ target = {r["from"]: r["to"] for r in payload["query"].get("redirects", [])}
89
+ pages = {page["title"]: page for page in payload["query"]["pages"]}
90
+ return {num: page_content(pages.get(target.get(str(num), str(num)))) for num in nums}
91
+
92
+
93
+ def page_content(page: dict[str, Any] | None) -> tuple[str, int] | None:
94
+ """The latest revision's wikitext and id, or None for an absent page."""
95
+ if not page or page.get("missing"):
96
+ return None
97
+ revision = page["revisions"][0]
98
+ return revision["slots"]["main"]["content"], int(revision["revid"])
99
+
100
+
101
+ async def fetch_explanation(client: httpx2.AsyncClient, num: int) -> tuple[str, int] | None:
102
+ """Fetch the explainxkcd page for ``num`` as ``(wikitext, revid)``, or None if absent."""
103
+ return (await fetch_explanations(client, [num]))[num]
82
104
 
83
105
 
84
106
  async def fetch_image(client: httpx2.AsyncClient, comic: Comic) -> bytes:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes