scraper-for-x 0.1.0__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.
- scraper_for_x/__init__.py +327 -0
- scraper_for_x/_stealth_init.js +8 -0
- scraper_for_x/auth.py +432 -0
- scraper_for_x/cli.py +484 -0
- scraper_for_x/client.py +124 -0
- scraper_for_x/config.py +78 -0
- scraper_for_x/errors.py +91 -0
- scraper_for_x/gql.py +178 -0
- scraper_for_x/model.py +341 -0
- scraper_for_x/parse.py +128 -0
- scraper_for_x/queryids.py +147 -0
- scraper_for_x/redact.py +182 -0
- scraper_for_x/retrieve.py +462 -0
- scraper_for_x/session.py +275 -0
- scraper_for_x-0.1.0.dist-info/METADATA +118 -0
- scraper_for_x-0.1.0.dist-info/RECORD +19 -0
- scraper_for_x-0.1.0.dist-info/WHEEL +4 -0
- scraper_for_x-0.1.0.dist-info/entry_points.txt +2 -0
- scraper_for_x-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Public Python API (plan §5). See README.md for the full picture.
|
|
2
|
+
|
|
3
|
+
MUST NOT eagerly import scrapling (plan §14 G-lazy-import) -- everything
|
|
4
|
+
imported here (``session``, ``auth``, ``client``, ``retrieve``, ``model``,
|
|
5
|
+
``errors``) is base-install-safe; the scrapling-touching code lives inside
|
|
6
|
+
``session.run_login``/``run_setup`` and is only reached when those are
|
|
7
|
+
actually called.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from datetime import UTC, date, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import auth
|
|
19
|
+
from . import client as client_module
|
|
20
|
+
from . import retrieve as retrieve_module
|
|
21
|
+
from . import session as session_module
|
|
22
|
+
from .config import DEFAULT_PROFILE_NAME
|
|
23
|
+
from .errors import (
|
|
24
|
+
FeatureNotImplementedError,
|
|
25
|
+
InvalidCookieError,
|
|
26
|
+
InvalidIdentifierError,
|
|
27
|
+
LoginRequiredError,
|
|
28
|
+
NotEnteredError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
ProfileUnavailableError,
|
|
31
|
+
RateLimitedError,
|
|
32
|
+
ScraperForXError,
|
|
33
|
+
SessionClosedError,
|
|
34
|
+
SessionExpiredError,
|
|
35
|
+
)
|
|
36
|
+
from .model import Media, Tweet, User
|
|
37
|
+
from .parse import EnvelopeParseError
|
|
38
|
+
from .retrieve import RetrieveResult
|
|
39
|
+
from .session import Status
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"XScraper",
|
|
43
|
+
"Tweet",
|
|
44
|
+
"User",
|
|
45
|
+
"Media",
|
|
46
|
+
"Status",
|
|
47
|
+
"RetrieveResult",
|
|
48
|
+
"ScraperForXError",
|
|
49
|
+
"LoginRequiredError",
|
|
50
|
+
"SessionExpiredError",
|
|
51
|
+
"RateLimitedError",
|
|
52
|
+
"ProfileUnavailableError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"InvalidCookieError",
|
|
55
|
+
"InvalidIdentifierError",
|
|
56
|
+
"NotEnteredError",
|
|
57
|
+
"SessionClosedError",
|
|
58
|
+
"EnvelopeParseError",
|
|
59
|
+
"FeatureNotImplementedError",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _to_date(value: str | date) -> date:
|
|
64
|
+
return value if isinstance(value, date) else date.fromisoformat(value) # strict YYYY-MM-DD
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _parse_since(value: str | date | None) -> datetime | None:
|
|
68
|
+
"""--since D stops once a tweet is from *before* D (plan §11) -- D itself
|
|
69
|
+
is the inclusive boundary, so compare against the START of day D."""
|
|
70
|
+
if value is None:
|
|
71
|
+
return None
|
|
72
|
+
day = _to_date(value)
|
|
73
|
+
return datetime(day.year, day.month, day.day, tzinfo=UTC)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_until(value: str | date | None) -> datetime | None:
|
|
77
|
+
"""--until D skips tweets *newer than* D (plan §11) -- D itself is
|
|
78
|
+
included, so compare against the END of day D, not its start (otherwise
|
|
79
|
+
every tweet from D itself would be wrongly skipped as "newer than D")."""
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
day = _to_date(value)
|
|
83
|
+
return datetime(day.year, day.month, day.day, 23, 59, 59, 999999, tzinfo=UTC)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class XScraper:
|
|
87
|
+
"""Read-only X/Twitter client: harvest-then-replay hybrid (plan §3).
|
|
88
|
+
|
|
89
|
+
A stealth-browser login (or cookie import) harvests a session once;
|
|
90
|
+
all reads afterward go over `httpx` with no `x-client-transaction-id`
|
|
91
|
+
(plan §0, §8). See DISCLAIMER.md first.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
profile: str = DEFAULT_PROFILE_NAME,
|
|
97
|
+
*,
|
|
98
|
+
profile_dir: str | Path | None = None,
|
|
99
|
+
min_request_pause: float | None = None,
|
|
100
|
+
max_requests: int | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.profile = profile
|
|
103
|
+
self._profile_dir_override = str(profile_dir) if profile_dir is not None else None
|
|
104
|
+
self._min_request_pause = min_request_pause
|
|
105
|
+
self._max_requests = max_requests
|
|
106
|
+
self.last_result: RetrieveResult | None = None
|
|
107
|
+
|
|
108
|
+
self._entered = False
|
|
109
|
+
self._closed = False
|
|
110
|
+
self._read_client: client_module.ReadClient | None = None
|
|
111
|
+
self._query_ids: dict | None = None
|
|
112
|
+
self._features: dict | None = None
|
|
113
|
+
|
|
114
|
+
def __enter__(self) -> XScraper:
|
|
115
|
+
credential = auth.load_session(
|
|
116
|
+
self.profile, profile_dir_override=self._profile_dir_override
|
|
117
|
+
)
|
|
118
|
+
self._query_ids, self._features = session_module.query_ids_for(credential)
|
|
119
|
+
self._read_client = client_module.ReadClient(
|
|
120
|
+
credential.auth_token,
|
|
121
|
+
credential.ct0,
|
|
122
|
+
credential.user_agent,
|
|
123
|
+
min_pause=self._min_request_pause,
|
|
124
|
+
max_requests=self._max_requests,
|
|
125
|
+
)
|
|
126
|
+
self._entered = True
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
130
|
+
if self._read_client is not None:
|
|
131
|
+
self._read_client.close()
|
|
132
|
+
self._closed = True
|
|
133
|
+
|
|
134
|
+
def _require_entered(self) -> None:
|
|
135
|
+
if self._closed:
|
|
136
|
+
raise SessionClosedError("XScraper is closed; use it inside a fresh `with` block")
|
|
137
|
+
if not self._entered:
|
|
138
|
+
raise NotEnteredError("use `with XScraper(...) as x:` for reads")
|
|
139
|
+
|
|
140
|
+
# --- session setup: side-effecting persisters (plan §5) -----------------
|
|
141
|
+
|
|
142
|
+
def login(self) -> bool:
|
|
143
|
+
"""Headed stealth-browser login (requires the `[browser]` extra)."""
|
|
144
|
+
return session_module.run_login(
|
|
145
|
+
self.profile, profile_dir_override=self._profile_dir_override
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_cookies(
|
|
150
|
+
cls,
|
|
151
|
+
*,
|
|
152
|
+
auth_token: str,
|
|
153
|
+
ct0: str,
|
|
154
|
+
profile: str = DEFAULT_PROFILE_NAME,
|
|
155
|
+
profile_dir: str | Path | None = None,
|
|
156
|
+
) -> XScraper:
|
|
157
|
+
"""Import an already-established session's cookies directly (plan §7).
|
|
158
|
+
No browser required."""
|
|
159
|
+
auth.validate_token_shapes(auth_token, ct0)
|
|
160
|
+
credential = auth.SessionCredential(
|
|
161
|
+
auth_token=auth_token, ct0=ct0, user_agent=auth.DEFAULT_USER_AGENT
|
|
162
|
+
)
|
|
163
|
+
auth.save_session(
|
|
164
|
+
profile, credential, profile_dir_override=str(profile_dir) if profile_dir else None
|
|
165
|
+
)
|
|
166
|
+
return cls(profile=profile, profile_dir=profile_dir)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_cookie_file(
|
|
170
|
+
cls,
|
|
171
|
+
path: str | Path,
|
|
172
|
+
profile: str = DEFAULT_PROFILE_NAME,
|
|
173
|
+
*,
|
|
174
|
+
profile_dir: str | Path | None = None,
|
|
175
|
+
) -> XScraper:
|
|
176
|
+
"""Import a Netscape/JSON/cURL cookie export (plan §7). No browser required."""
|
|
177
|
+
auth.from_cookie_file(
|
|
178
|
+
Path(path),
|
|
179
|
+
profile,
|
|
180
|
+
profile_dir_override=str(profile_dir) if profile_dir else None,
|
|
181
|
+
)
|
|
182
|
+
return cls(profile=profile, profile_dir=profile_dir)
|
|
183
|
+
|
|
184
|
+
def status(self) -> Status:
|
|
185
|
+
return session_module.run_status(
|
|
186
|
+
self.profile, profile_dir_override=self._profile_dir_override
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# --- reads: require an entered context (plan §5) ------------------------
|
|
190
|
+
|
|
191
|
+
def fetch_user_tweets(
|
|
192
|
+
self,
|
|
193
|
+
identifier: str,
|
|
194
|
+
*,
|
|
195
|
+
replies: bool = False,
|
|
196
|
+
limit: int | None = None,
|
|
197
|
+
since: str | date | None = None,
|
|
198
|
+
until: str | date | None = None,
|
|
199
|
+
by: str | None = None,
|
|
200
|
+
wait_on_limit: bool = False,
|
|
201
|
+
max_wait: float | None = None,
|
|
202
|
+
raw: bool = False,
|
|
203
|
+
) -> list[Tweet]:
|
|
204
|
+
"""A profile's tweets/replies/media (plan §1)."""
|
|
205
|
+
self._require_entered()
|
|
206
|
+
kind, value = auth.normalize_identifier(identifier, by=by)
|
|
207
|
+
result = retrieve_module.fetch_user_tweets(
|
|
208
|
+
self._read_client,
|
|
209
|
+
self._query_ids,
|
|
210
|
+
self._features,
|
|
211
|
+
kind,
|
|
212
|
+
value,
|
|
213
|
+
replies=replies,
|
|
214
|
+
limit=limit,
|
|
215
|
+
since=_parse_since(since),
|
|
216
|
+
until=_parse_until(until),
|
|
217
|
+
wait_on_limit=wait_on_limit,
|
|
218
|
+
max_wait=max_wait,
|
|
219
|
+
raw=raw,
|
|
220
|
+
)
|
|
221
|
+
self.last_result = result
|
|
222
|
+
return result.tweets
|
|
223
|
+
|
|
224
|
+
def iter_user_tweets(
|
|
225
|
+
self,
|
|
226
|
+
identifier: str,
|
|
227
|
+
*,
|
|
228
|
+
replies: bool = False,
|
|
229
|
+
limit: int | None = None,
|
|
230
|
+
since: str | date | None = None,
|
|
231
|
+
until: str | date | None = None,
|
|
232
|
+
by: str | None = None,
|
|
233
|
+
wait_on_limit: bool = False,
|
|
234
|
+
max_wait: float | None = None,
|
|
235
|
+
raw: bool = False,
|
|
236
|
+
) -> Iterator[Tweet]:
|
|
237
|
+
"""Streaming form of `fetch_user_tweets` (plan §5) -- yields incrementally.
|
|
238
|
+
|
|
239
|
+
Must be consumed inside the owning `with` block; advancing it after
|
|
240
|
+
the block exited raises `SessionClosedError`. Since this is a
|
|
241
|
+
generator, that check can't run at call time -- only advancing it does
|
|
242
|
+
(calling `iter_user_tweets()` on an already-closed instance never
|
|
243
|
+
raises by itself; the first `next()` does).
|
|
244
|
+
"""
|
|
245
|
+
if self._closed:
|
|
246
|
+
raise SessionClosedError(
|
|
247
|
+
"iter_user_tweets() was advanced after its `with` block exited"
|
|
248
|
+
)
|
|
249
|
+
self._require_entered()
|
|
250
|
+
kind, value = auth.normalize_identifier(identifier, by=by)
|
|
251
|
+
state = retrieve_module.RunState()
|
|
252
|
+
stream = retrieve_module.iter_user_tweets(
|
|
253
|
+
self._read_client,
|
|
254
|
+
self._query_ids,
|
|
255
|
+
self._features,
|
|
256
|
+
kind,
|
|
257
|
+
value,
|
|
258
|
+
replies=replies,
|
|
259
|
+
limit=limit,
|
|
260
|
+
since=_parse_since(since),
|
|
261
|
+
until=_parse_until(until),
|
|
262
|
+
wait_on_limit=wait_on_limit,
|
|
263
|
+
max_wait=max_wait,
|
|
264
|
+
raw=raw,
|
|
265
|
+
state=state,
|
|
266
|
+
)
|
|
267
|
+
for tweet in stream:
|
|
268
|
+
if self._closed:
|
|
269
|
+
raise SessionClosedError(
|
|
270
|
+
"iter_user_tweets() was advanced after its `with` block exited"
|
|
271
|
+
)
|
|
272
|
+
yield tweet
|
|
273
|
+
|
|
274
|
+
def search(
|
|
275
|
+
self,
|
|
276
|
+
query: str,
|
|
277
|
+
*,
|
|
278
|
+
product: str = "Latest",
|
|
279
|
+
limit: int | None = None,
|
|
280
|
+
since: str | date | None = None,
|
|
281
|
+
until: str | date | None = None,
|
|
282
|
+
wait_on_limit: bool = False,
|
|
283
|
+
max_wait: float | None = None,
|
|
284
|
+
raw: bool = False,
|
|
285
|
+
) -> list[Tweet]:
|
|
286
|
+
"""Tweets matching a query / advanced operators (plan §1)."""
|
|
287
|
+
self._require_entered()
|
|
288
|
+
result = retrieve_module.search(
|
|
289
|
+
self._read_client,
|
|
290
|
+
self._query_ids,
|
|
291
|
+
self._features,
|
|
292
|
+
query,
|
|
293
|
+
product=product,
|
|
294
|
+
limit=limit,
|
|
295
|
+
since=_parse_since(since),
|
|
296
|
+
until=_parse_until(until),
|
|
297
|
+
wait_on_limit=wait_on_limit,
|
|
298
|
+
max_wait=max_wait,
|
|
299
|
+
raw=raw,
|
|
300
|
+
)
|
|
301
|
+
self.last_result = result
|
|
302
|
+
return result.tweets
|
|
303
|
+
|
|
304
|
+
def fetch_tweet(
|
|
305
|
+
self,
|
|
306
|
+
identifier: str,
|
|
307
|
+
*,
|
|
308
|
+
replies: bool = False,
|
|
309
|
+
wait_on_limit: bool = False,
|
|
310
|
+
max_wait: float | None = None,
|
|
311
|
+
raw: bool = False,
|
|
312
|
+
) -> list[Tweet]:
|
|
313
|
+
"""One tweet plus its reply/conversation thread (plan §1)."""
|
|
314
|
+
self._require_entered()
|
|
315
|
+
value = auth.normalize_tweet_identifier(identifier)
|
|
316
|
+
result = retrieve_module.fetch_tweet(
|
|
317
|
+
self._read_client,
|
|
318
|
+
self._query_ids,
|
|
319
|
+
self._features,
|
|
320
|
+
value,
|
|
321
|
+
replies=replies,
|
|
322
|
+
wait_on_limit=wait_on_limit,
|
|
323
|
+
max_wait=max_wait,
|
|
324
|
+
raw=raw,
|
|
325
|
+
)
|
|
326
|
+
self.last_result = result
|
|
327
|
+
return result.tweets
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Overrides navigator.webdriver so X's login flow doesn't block automated
|
|
2
|
+
// browsers on sight (plan §17 G-webdriver-login, proven live 2026-07-05:
|
|
3
|
+
// X refused to log in at all while this read `true`). Combined with
|
|
4
|
+
// StealthySession's own patchright backend + `--disable-blink-features=
|
|
5
|
+
// AutomationControlled` + ignoring `--enable-automation` (scrapling's
|
|
6
|
+
// defaults), this reproduces the exact config validated against a real
|
|
7
|
+
// X login.
|
|
8
|
+
Object.defineProperty(navigator, "webdriver", { get: () => undefined });
|