xkcdkit 0.2.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.2.0 → xkcdkit-0.4.0}/PKG-INFO +19 -6
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/README.md +18 -5
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/pyproject.toml +1 -1
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/pyproject.toml.orig +1 -1
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/cli.py +13 -1
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/client.py +67 -20
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/sources.py +30 -8
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/store.py +2 -2
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/__init__.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/config.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/models.py +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/py.typed +0 -0
- {xkcdkit-0.2.0 → xkcdkit-0.4.0}/src/xkcdkit/render.py +0 -0
- {xkcdkit-0.2.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,7 +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 (
|
|
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
|
|
117
118
|
xkcd resync # re-fetch comics older than 30 days or still missing an explanation
|
|
118
119
|
xkcd tui # open the browser
|
|
119
120
|
```
|
|
@@ -271,10 +272,22 @@ async def xkcd(ctx, num: int):
|
|
|
271
272
|
### Being polite to explainxkcd
|
|
272
273
|
|
|
273
274
|
explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
|
|
274
|
-
per second, so
|
|
275
|
-
User-Agent that names this project and its repository.
|
|
276
|
-
|
|
277
|
-
|
|
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
|
|
287
|
+
cached, so you can sync in several sittings.
|
|
288
|
+
|
|
289
|
+
The sync walks every number from 1 to the latest comic, so it does not depend
|
|
290
|
+
on the archive page. On-demand lookups are never delayed.
|
|
278
291
|
|
|
279
292
|
## Terminal image support
|
|
280
293
|
|
|
@@ -81,7 +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 (
|
|
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
|
|
85
86
|
xkcd resync # re-fetch comics older than 30 days or still missing an explanation
|
|
86
87
|
xkcd tui # open the browser
|
|
87
88
|
```
|
|
@@ -239,10 +240,22 @@ async def xkcd(ctx, num: int):
|
|
|
239
240
|
### Being polite to explainxkcd
|
|
240
241
|
|
|
241
242
|
explainxkcd is a volunteer-run wiki. Its robots policy asks for one request
|
|
242
|
-
per second, so
|
|
243
|
-
User-Agent that names this project and its repository.
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
255
|
+
cached, so you can sync in several sittings.
|
|
256
|
+
|
|
257
|
+
The sync walks every number from 1 to the latest comic, so it does not depend
|
|
258
|
+
on the archive page. On-demand lookups are never delayed.
|
|
246
259
|
|
|
247
260
|
## Terminal image support
|
|
248
261
|
|
|
@@ -69,6 +69,12 @@ def run(ctx: typer.Context, action: Callable[[AsyncXKCD], Awaitable[Any]]) -> An
|
|
|
69
69
|
except XKCDError as exc:
|
|
70
70
|
errors.print(f"error: {exc}", markup=False, highlight=False)
|
|
71
71
|
raise typer.Exit(1) from exc
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
errors.print(
|
|
74
|
+
"Interrupted. Everything fetched so far is saved; run the command again to continue.",
|
|
75
|
+
soft_wrap=True,
|
|
76
|
+
)
|
|
77
|
+
raise typer.Exit(130) from None
|
|
72
78
|
|
|
73
79
|
|
|
74
80
|
@dataclass
|
|
@@ -173,11 +179,17 @@ def explain(ctx: typer.Context, num: Num, json: Json = False) -> None:
|
|
|
173
179
|
def sync(
|
|
174
180
|
ctx: typer.Context,
|
|
175
181
|
full: Annotated[bool, typer.Option("--full", help="Fetch every comic and explanation.")] = False,
|
|
182
|
+
no_explain: Annotated[
|
|
183
|
+
bool, typer.Option("--no-explain", help="Skip explainxkcd for now (much faster); resync fills it in.")
|
|
184
|
+
] = False,
|
|
176
185
|
) -> None:
|
|
177
186
|
"""Refresh the archive index and latest comic; with --full, download everything."""
|
|
178
187
|
with console.status("Syncing") as status:
|
|
179
188
|
added: int = run(
|
|
180
|
-
ctx,
|
|
189
|
+
ctx,
|
|
190
|
+
lambda xk: xk.sync(
|
|
191
|
+
full, lambda done, total: status.update(f"Syncing {done}/{total}"), explain=not no_explain
|
|
192
|
+
),
|
|
181
193
|
)
|
|
182
194
|
console.print(f"{added} new comic{'s' if added != 1 else ''} cached.")
|
|
183
195
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import random
|
|
5
|
+
import time
|
|
5
6
|
from collections.abc import Callable
|
|
6
7
|
from datetime import timedelta
|
|
7
8
|
from pathlib import Path
|
|
@@ -12,11 +13,19 @@ import httpx2
|
|
|
12
13
|
|
|
13
14
|
from xkcdkit import ComicNotFound, XKCDError
|
|
14
15
|
from xkcdkit.models import ArchiveEntry, Comic
|
|
15
|
-
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
|
+
)
|
|
16
24
|
from xkcdkit.store import Store, utc_now
|
|
17
25
|
|
|
18
26
|
Progress = Callable[[int, int], None]
|
|
19
27
|
RETRY_MISSING_AFTER = timedelta(days=1)
|
|
28
|
+
WORKERS = 8
|
|
20
29
|
|
|
21
30
|
|
|
22
31
|
class AsyncXKCD:
|
|
@@ -32,7 +41,7 @@ class AsyncXKCD:
|
|
|
32
41
|
db: Database path. Defaults to the platform user data directory or ``$XKCDKIT_DB``.
|
|
33
42
|
client: An ``httpx2.AsyncClient`` to use instead of the built-in one.
|
|
34
43
|
timeout: Request timeout in seconds for the built-in client.
|
|
35
|
-
explain_delay:
|
|
44
|
+
explain_delay: Minimum seconds between explainxkcd requests during bulk syncs.
|
|
36
45
|
"""
|
|
37
46
|
|
|
38
47
|
def __init__(
|
|
@@ -48,6 +57,8 @@ class AsyncXKCD:
|
|
|
48
57
|
timeout=timeout, headers={"User-Agent": user_agent()}, follow_redirects=True
|
|
49
58
|
)
|
|
50
59
|
self.explain_delay = explain_delay
|
|
60
|
+
self.explain_lock = asyncio.Lock()
|
|
61
|
+
self.last_explain = 0.0
|
|
51
62
|
|
|
52
63
|
async def __aenter__(self) -> Self:
|
|
53
64
|
"""Enter the async context manager."""
|
|
@@ -67,6 +78,11 @@ class AsyncXKCD:
|
|
|
67
78
|
async def complete(self, comic: Comic) -> Comic:
|
|
68
79
|
"""Attach the explainxkcd page when it exists and stamp the sync time."""
|
|
69
80
|
found = await fetch_explanation(self.http, comic.num)
|
|
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."""
|
|
70
86
|
wikitext, revid = found if found else (None, None)
|
|
71
87
|
return comic.model_copy(
|
|
72
88
|
update={
|
|
@@ -126,39 +142,70 @@ class AsyncXKCD:
|
|
|
126
142
|
self.store.put_archive(entries)
|
|
127
143
|
return entries
|
|
128
144
|
|
|
129
|
-
async def sync(self, full: bool = False, progress: Progress | None = None) -> int:
|
|
145
|
+
async def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
|
|
130
146
|
"""Refresh the archive index and the latest comic; with ``full`` fetch every comic.
|
|
131
147
|
|
|
132
148
|
A full sync walks every number from 1 to the latest, so it does not depend
|
|
133
|
-
on the archive page.
|
|
149
|
+
on the archive page. With ``explain=False`` it skips explainxkcd, which is
|
|
150
|
+
much faster; ``resync`` or a later ``get`` fills the explanations in.
|
|
151
|
+
Returns the number of comics newly added to the cache.
|
|
134
152
|
"""
|
|
135
153
|
before = self.store.count()
|
|
136
154
|
await self.refresh_archive()
|
|
137
155
|
latest = await self.latest()
|
|
138
156
|
if full:
|
|
139
157
|
cached = set(self.store.nums())
|
|
140
|
-
|
|
158
|
+
nums = [n for n in range(1, latest.num + 1) if n not in cached]
|
|
159
|
+
await self.fetch_many(nums, progress, explain)
|
|
141
160
|
self.store.set_meta("last_full_sync_at", utc_now().isoformat())
|
|
142
161
|
self.store.set_meta("last_sync_at", utc_now().isoformat())
|
|
143
162
|
return self.store.count() - before
|
|
144
163
|
|
|
145
164
|
async def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
|
|
146
|
-
"""Re-fetch comics older than ``max_age`` or
|
|
165
|
+
"""Re-fetch comics older than ``max_age`` or without explanation data. Returns the count."""
|
|
147
166
|
return await self.fetch_many(self.store.stale(max_age), progress)
|
|
148
167
|
|
|
149
|
-
async def fetch_many(self, nums: list[int], progress: Progress | None) -> int:
|
|
150
|
-
"""Fetch and store
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
168
|
+
async def fetch_many(self, nums: list[int], progress: Progress | None, explain: bool = True) -> int:
|
|
169
|
+
"""Fetch and store comics in batches of ``EXPLAIN_BATCH``.
|
|
170
|
+
|
|
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.
|
|
174
|
+
"""
|
|
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)
|
|
158
185
|
if progress:
|
|
159
|
-
progress(
|
|
160
|
-
|
|
161
|
-
|
|
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)
|
|
192
|
+
|
|
193
|
+
async def one(num: int) -> Comic | None:
|
|
194
|
+
async with limit:
|
|
195
|
+
try:
|
|
196
|
+
return await fetch_comic(self.http, num)
|
|
197
|
+
except ComicNotFound:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
return [comic for comic in await asyncio.gather(*(one(num) for num in nums)) if comic]
|
|
201
|
+
|
|
202
|
+
async def pace(self) -> None:
|
|
203
|
+
"""Wait until ``explain_delay`` has passed since the previous explainxkcd request."""
|
|
204
|
+
async with self.explain_lock:
|
|
205
|
+
wait = self.last_explain + self.explain_delay - time.monotonic()
|
|
206
|
+
if wait > 0:
|
|
207
|
+
await asyncio.sleep(wait)
|
|
208
|
+
self.last_explain = time.monotonic()
|
|
162
209
|
|
|
163
210
|
|
|
164
211
|
class XKCD:
|
|
@@ -224,9 +271,9 @@ class XKCD:
|
|
|
224
271
|
"""See ``AsyncXKCD.explain``."""
|
|
225
272
|
return self.run("explain", num) # type: ignore[no-any-return]
|
|
226
273
|
|
|
227
|
-
def sync(self, full: bool = False, progress: Progress | None = None) -> int:
|
|
274
|
+
def sync(self, full: bool = False, progress: Progress | None = None, explain: bool = True) -> int:
|
|
228
275
|
"""See ``AsyncXKCD.sync``."""
|
|
229
|
-
return self.run("sync", full, progress) # type: ignore[no-any-return]
|
|
276
|
+
return self.run("sync", full, progress, explain) # type: ignore[no-any-return]
|
|
230
277
|
|
|
231
278
|
def resync(self, max_age: timedelta = timedelta(days=30), progress: Progress | None = None) -> int:
|
|
232
279
|
"""See ``AsyncXKCD.resync``."""
|
|
@@ -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:
|
|
@@ -129,10 +129,10 @@ class Store:
|
|
|
129
129
|
return str(row[0]) if row else None
|
|
130
130
|
|
|
131
131
|
def stale(self, max_age: timedelta) -> list[int]:
|
|
132
|
-
"""Numbers of comics older than ``max_age`` or
|
|
132
|
+
"""Numbers of comics older than ``max_age`` or without explanation data yet."""
|
|
133
133
|
cutoff = (utc_now() - max_age).isoformat()
|
|
134
134
|
rows = self.db.execute(
|
|
135
|
-
"SELECT num FROM comics WHERE synced_at < ? OR
|
|
135
|
+
"SELECT num FROM comics WHERE synced_at < ? OR explanation IS NULL ORDER BY num",
|
|
136
136
|
(cutoff,),
|
|
137
137
|
)
|
|
138
138
|
return [row[0] for row in rows]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|