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.
@@ -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 });