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 +0 -0
- twidr/lib/cli.py +121 -0
- twidr/lib/client.py +91 -0
- twidr/lib/defaults.py +8 -0
- twidr/lib/meta.py +15 -0
- twidr/lib/wayback.py +123 -0
- twidr/main.py +527 -0
- twidr-0.1.dist-info/METADATA +11 -0
- twidr-0.1.dist-info/RECORD +12 -0
- twidr-0.1.dist-info/WHEEL +4 -0
- twidr-0.1.dist-info/entry_points.txt +3 -0
- twidr-0.1.dist-info/licenses/LICENSE.md +675 -0
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
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
|