twidr 0.1__py3-none-any.whl

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.
twidr/__init__.py ADDED
File without changes
twidr/lib/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """Constructs for command-line argument parsing."""
2
+
3
+ import re
4
+ from argparse import ArgumentParser, ArgumentTypeError
5
+ from pathlib import Path
6
+ from typing import Final
7
+
8
+ from . import defaults, meta
9
+
10
+ HANDLE_RE: Final = re.compile(r"[A-Za-z0-9_]{1,15}")
11
+ DOMAIN_RE: Final = re.compile(r"[a-z0-9\-]+(?:\.[a-z0-9\-]+)+")
12
+
13
+
14
+ def handle(value: str) -> str:
15
+ h = value.lstrip("@")
16
+
17
+ if not HANDLE_RE.fullmatch(h):
18
+ raise ArgumentTypeError(
19
+ f"{value!r} is not a valid handle; must be 1–15 letters, digits, or underscores"
20
+ )
21
+
22
+ return h
23
+
24
+
25
+ def domain(value: str) -> str:
26
+ d = value.lower()
27
+
28
+ if not DOMAIN_RE.fullmatch(d):
29
+ raise ArgumentTypeError(
30
+ f"{value!r} is not a valid domain (expected something like twitter.com)"
31
+ )
32
+
33
+ return d
34
+
35
+
36
+ def positive_float(value: str) -> float:
37
+ f = float(value)
38
+
39
+ if f <= 0:
40
+ raise ArgumentTypeError("Must be > 0")
41
+
42
+ return f
43
+
44
+
45
+ def positive_int(value: str) -> int:
46
+ i = int(value)
47
+
48
+ if i < 1:
49
+ raise ArgumentTypeError("Must be >= 1")
50
+
51
+ return i
52
+
53
+
54
+ def parser() -> ArgumentParser:
55
+ """Get an ArgumentParser instance preconfigured for this program."""
56
+
57
+ p = ArgumentParser(description=meta.APP_DESCRIPTION)
58
+
59
+ p.add_argument(
60
+ "-v",
61
+ "--version",
62
+ action="version",
63
+ version=f"🐦️ {meta.APP_NAME}, {meta.PROJECT_DESCRIPTION} ({meta.ORG_DOMAIN}) {meta.VERSION}",
64
+ )
65
+
66
+ p.add_argument("username", type=handle, help="account handle, with or without @")
67
+
68
+ p.add_argument(
69
+ "-o",
70
+ "--output",
71
+ type=Path,
72
+ default=None,
73
+ help="directory for media and state files (default: a directory named after the handle)",
74
+ )
75
+
76
+ p.add_argument(
77
+ "--domain",
78
+ type=domain,
79
+ action="append",
80
+ default=None,
81
+ help=(
82
+ "archived domain to search (repeat the argument for multiple; "
83
+ f"domains tried in order; default {", ".join(defaults.DOMAINS)})"
84
+ ),
85
+ )
86
+
87
+ p.add_argument(
88
+ "--max-attempts",
89
+ type=positive_int,
90
+ default=defaults.MAX_ATTEMPTS,
91
+ help=f"attempts per request before giving up (default {defaults.MAX_ATTEMPTS})",
92
+ )
93
+
94
+ p.add_argument(
95
+ "--refresh-index",
96
+ action="store_true",
97
+ help="requery the Wayback Machine's CDX index for the account",
98
+ )
99
+
100
+ p.add_argument(
101
+ "--retry-failed",
102
+ action="store_true",
103
+ help="retry tweets or images that failed or yielded no media in previous runs",
104
+ )
105
+
106
+ p.add_argument(
107
+ "-t",
108
+ "--threads",
109
+ type=positive_int,
110
+ default=defaults.NUM_WORKERS,
111
+ help=f"number of parallel worker threads (default {defaults.NUM_WORKERS})",
112
+ )
113
+
114
+ p.add_argument(
115
+ "--rate",
116
+ type=positive_float,
117
+ default=defaults.RATE,
118
+ help=f"max total requests per second across all workers (default {defaults.RATE})",
119
+ )
120
+
121
+ return p
twidr/lib/client.py ADDED
@@ -0,0 +1,91 @@
1
+ """Machinery for session-bound, threaded, and rate-limited HTTP GET requests."""
2
+
3
+ import threading
4
+ import time
5
+ from threading import Event, Lock
6
+ from typing import Any, Final
7
+
8
+ from requests import RequestException, Response, Session
9
+
10
+ from . import meta
11
+
12
+ USER_AGENT: Final = f"{meta.APP_NAME}/{meta.VERSION} ({meta.ORG_DOMAIN})"
13
+
14
+
15
+ class RateLimiter:
16
+ """A self-contained construct for rate-limiting an HTTP client atomically across threads."""
17
+
18
+ def __init__(self, per_second: float, stop: Event) -> None:
19
+ """Create a new RateLimiter with a per-second maximum rate and an Event to trigger pauses."""
20
+
21
+ self._lock = Lock()
22
+ self._interval = 1 / per_second
23
+ self._next = 0.0
24
+ self._stop = stop
25
+
26
+ def wait(self) -> None:
27
+ """Atomically trigger a pause."""
28
+
29
+ with self._lock:
30
+ now = time.monotonic()
31
+ slot = max(now, self._next)
32
+ self._next = slot + self._interval
33
+
34
+ delay = slot - now
35
+
36
+ if delay > 0:
37
+ self._stop.wait(delay) # returns early on Ctrl-C
38
+
39
+ def penalize(self, seconds: float) -> None:
40
+ """Atomically add to the current pause delay."""
41
+
42
+ with self._lock:
43
+ self._next = max(self._next, time.monotonic() + seconds)
44
+
45
+
46
+ class Client:
47
+ """A generic, rate-limited HTTP client implementing GET."""
48
+
49
+ def __init__(self, rate: float, max_attempts: int) -> None:
50
+ """Create a new Client with a rate limit and maximum number of attempts per request."""
51
+
52
+ self.stop = Event()
53
+ self._limiter = RateLimiter(rate, self.stop)
54
+ self._max_attempts = max_attempts
55
+ self._local = threading.local()
56
+
57
+ def _session(self) -> Session:
58
+ session: Session | None = getattr(self._local, "session", None)
59
+
60
+ if session is None:
61
+ session = Session()
62
+ session.headers["User-Agent"] = USER_AGENT
63
+
64
+ self._local.session = session
65
+
66
+ return session
67
+
68
+ def get(self, url: str, **kw: Any) -> Response | None:
69
+ """Send an HTTP GET request with arbitrary parameters."""
70
+
71
+ for attempt in range(self._max_attempts):
72
+ self._limiter.wait()
73
+
74
+ if self.stop.is_set():
75
+ return None
76
+
77
+ try:
78
+ response = self._session().get(url, timeout=30, **kw)
79
+ except RequestException:
80
+ self._limiter.penalize(2**attempt)
81
+
82
+ continue
83
+
84
+ if response.status_code == 429 or response.status_code >= 500:
85
+ self._limiter.penalize(5 * (attempt + 1))
86
+
87
+ continue
88
+
89
+ return response
90
+
91
+ return None
twidr/lib/defaults.py ADDED
@@ -0,0 +1,8 @@
1
+ """Default program config values."""
2
+
3
+ from typing import Final
4
+
5
+ DOMAINS: Final = ("twitter.com", "x.com")
6
+ MAX_ATTEMPTS: Final = 4
7
+ NUM_WORKERS: Final = 4
8
+ RATE: Final = 3.0
twidr/lib/meta.py ADDED
@@ -0,0 +1,15 @@
1
+ """Configurable and pyproject.toml-derived information about this program."""
2
+
3
+ from importlib.metadata import metadata
4
+ from typing import Final
5
+
6
+ APP_NAME: Final = "twidr"
7
+ APP_DESCRIPTION: Final = (
8
+ "download images from the Wayback Machine's snapshots of a Twitter (X) account"
9
+ )
10
+ ORG_DOMAIN: Final = "dandelion.computer"
11
+
12
+ _meta = metadata(APP_NAME)
13
+
14
+ VERSION: Final = _meta["Version"]
15
+ PROJECT_DESCRIPTION: Final = _meta["Summary"]
twidr/lib/wayback.py ADDED
@@ -0,0 +1,123 @@
1
+ """A specialized, HTTP-based client for Twitter media stored in the Wayback Machine."""
2
+
3
+ from threading import Event
4
+ from typing import Final, Literal, NamedTuple
5
+
6
+ from requests import Response
7
+
8
+ from .client import Client
9
+
10
+
11
+ class Capture(NamedTuple):
12
+ """An archived copy of a URL on the Wayback Machine."""
13
+
14
+ timestamp: str
15
+ original: str
16
+
17
+
18
+ def resolution_rank(capture: Capture) -> tuple[int, str]:
19
+ """Rank captures of an image by resolution, then by timestamp."""
20
+
21
+ url = capture.original
22
+
23
+ ordinal = (
24
+ 0 if "orig" in url else 1 if "large" in url else 2 if "medium" in url else 3
25
+ )
26
+
27
+ return (ordinal, capture.timestamp)
28
+
29
+
30
+ class Wayback:
31
+ """A specialized, rate-limited client for retrieving Twitter media from the Wayback Machine."""
32
+
33
+ CDX_URL: Final = "https://web.archive.org/cdx/search/cdx"
34
+ WEB_URL: Final = "https://web.archive.org/web"
35
+
36
+ def __init__(self, client: Client) -> None:
37
+ """Create a new Wayback client, wrapping a Client providing generic, rate-limited HTTP."""
38
+
39
+ self._client = client
40
+
41
+ @property
42
+ def stop(self) -> Event:
43
+ return self._client.stop
44
+
45
+ def _cdx(self, **params: str) -> list[list[str]] | None:
46
+ """Get raw CDX rows (header skipped) or return None on request failure."""
47
+
48
+ response = self._client.get(self.CDX_URL, params={"output": "json", **params})
49
+
50
+ # failure at request time
51
+ if response is None or not response.ok:
52
+ return None
53
+
54
+ # empty
55
+ if not response.text.strip():
56
+ return []
57
+
58
+ # success
59
+ try:
60
+ rows: list[list[str]] = response.json()
61
+
62
+ # failure at parse time
63
+ except ValueError:
64
+ return None
65
+
66
+ return rows[1:]
67
+
68
+ def _replay(
69
+ self, capture: Capture, modifier: Literal["id", "im"]
70
+ ) -> Response | None:
71
+ """Fetch a capture; "id" gets a raw capture and "im" gets an image."""
72
+
73
+ return self._client.get(
74
+ f"{self.WEB_URL}/{capture.timestamp}{modifier}_/{capture.original}"
75
+ )
76
+
77
+ def captures(self, user: str, domain: str) -> list[Capture] | None:
78
+ """Get every capture of a user's tweet pages on a domain."""
79
+
80
+ rows = self._cdx(
81
+ url=f"{domain}/{user}/status/*",
82
+ fl="timestamp,original",
83
+ filter="!statuscode:404",
84
+ )
85
+
86
+ return None if rows is None else [Capture(row[0], row[1]) for row in rows]
87
+
88
+ def variants(self, media_id: str) -> list[Capture]:
89
+ """Get captures of a piece of media, best resolution first."""
90
+
91
+ rows = self._cdx(
92
+ url=f"pbs.twimg.com/media/{media_id}*",
93
+ fl="timestamp,original,mimetype",
94
+ filter="statuscode:200",
95
+ )
96
+
97
+ if rows is None:
98
+ return []
99
+
100
+ return sorted(
101
+ (Capture(row[0], row[1]) for row in rows if not row[2].startswith("text/")),
102
+ key=resolution_rank,
103
+ )
104
+
105
+ def page(self, capture: Capture) -> str | None:
106
+ """Get a captured document as HTML."""
107
+
108
+ response = self._replay(capture, "id")
109
+
110
+ if response is None or response.status_code != 200:
111
+ return None
112
+
113
+ return response.text
114
+
115
+ def image(self, capture: Capture) -> Response | None:
116
+ """Get a captured image as a Response."""
117
+
118
+ response = self._replay(capture, "im")
119
+
120
+ if response is None or not response.ok:
121
+ return None
122
+
123
+ return response