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.
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/PKG-INFO +15 -16
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/README.md +14 -15
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/pyproject.toml +1 -1
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/pyproject.toml.orig +1 -1
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/client.py +39 -24
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/sources.py +30 -8
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/__init__.py +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/cli.py +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/config.py +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/models.py +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/py.typed +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/render.py +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/store.py +0 -0
- {xkcdkit-0.3.0 → xkcdkit-0.4.0}/src/xkcdkit/tui.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xkcdkit
|
|
3
|
-
Version: 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 (
|
|
117
|
-
xkcd sync --full --no-explain # comics only, a minute
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 (
|
|
85
|
-
xkcd sync --full --no-explain # comics only, a minute
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
169
|
+
"""Fetch and store comics in batches of ``EXPLAIN_BATCH``.
|
|
159
170
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
while pending:
|
|
168
|
-
num = pending.pop()
|
|
193
|
+
async def one(num: int) -> Comic | None:
|
|
194
|
+
async with limit:
|
|
169
195
|
try:
|
|
170
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
63
|
-
|
|
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="
|
|
68
|
-
|
|
74
|
+
action="query",
|
|
75
|
+
titles="|".join(str(num) for num in nums),
|
|
69
76
|
redirects=1,
|
|
70
|
-
prop="
|
|
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
|
-
|
|
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
|