1pub mod cookie_transport;
10pub mod session;
11
12use std::path::{Path, PathBuf};
13
14use crate::error::XApiError;
15use crate::x_api::retry::{retry_with_backoff, RetryConfig};
16use crate::x_api::scraper_health::{new_scraper_health, ScraperHealth};
17use crate::x_api::types::{
18 MediaId, MediaType, MentionResponse, PostedTweet, RawApiResponse, SearchResponse, Tweet, User,
19 UsersResponse,
20};
21use crate::x_api::XApiClient;
22
23use cookie_transport::CookieTransport;
24use session::ScraperSession;
25
26const SCRAPER_RETRY: RetryConfig = RetryConfig {
28 max_attempts: 3,
29 base_delay: std::time::Duration::from_millis(500),
30 max_delay: std::time::Duration::from_secs(8),
31};
32
33pub struct LocalModeXClient {
43 allow_mutations: bool,
44 cookie_transport: Option<CookieTransport>,
45 health: ScraperHealth,
47}
48
49impl LocalModeXClient {
50 pub fn new(allow_mutations: bool) -> Self {
55 Self {
56 allow_mutations,
57 cookie_transport: None,
58 health: new_scraper_health(),
59 }
60 }
61
62 pub async fn with_session(allow_mutations: bool, data_dir: &Path) -> Self {
69 Self::with_session_and_health(allow_mutations, data_dir, new_scraper_health()).await
70 }
71
72 pub async fn with_session_and_health(
79 allow_mutations: bool,
80 data_dir: &Path,
81 health: ScraperHealth,
82 ) -> Self {
83 let session_path = data_dir.join("scraper_session.json");
84 let session = ScraperSession::load(&session_path).ok().flatten();
85
86 let cookie_transport = if let Some(session) = session {
87 let resolved = cookie_transport::resolve_transport().await;
88 tracing::info!("Cookie-auth transport loaded from scraper_session.json");
89 Some(CookieTransport::with_resolved_transport(
90 session,
91 resolved.query_ids,
92 resolved.transaction,
93 ))
94 } else {
95 None
96 };
97
98 Self {
99 allow_mutations,
100 cookie_transport,
101 health,
102 }
103 }
104
105 pub fn health(&self) -> ScraperHealth {
110 self.health.clone()
111 }
112
113 async fn with_retry_and_health<F, Fut, T>(&self, op: F) -> Result<T, XApiError>
118 where
119 F: FnMut() -> Fut,
120 Fut: std::future::Future<Output = Result<T, XApiError>>,
121 {
122 match retry_with_backoff(SCRAPER_RETRY, op).await {
123 Ok(v) => {
124 self.health.lock().await.record_success();
125 Ok(v)
126 }
127 Err(e) => {
128 self.health.lock().await.record_failure(&e.to_string());
129 Err(e)
130 }
131 }
132 }
133
134 pub fn session_path(data_dir: &Path) -> PathBuf {
136 data_dir.join("scraper_session.json")
137 }
138
139 fn check_mutation(&self, method: &str) -> Result<(), XApiError> {
141 if !self.allow_mutations {
142 return Err(XApiError::ScraperMutationBlocked {
143 message: method.to_string(),
144 });
145 }
146 if self.cookie_transport.is_none() {
147 return Err(XApiError::ScraperTransportUnavailable {
148 message: format!(
149 "{method}: no browser session imported. \
150 Import cookies via Settings → X API → Import Browser Session."
151 ),
152 });
153 }
154 Ok(())
155 }
156
157 fn transport(&self) -> Result<&CookieTransport, XApiError> {
163 self.cookie_transport
164 .as_ref()
165 .ok_or_else(|| XApiError::ScraperTransportUnavailable {
166 message: "no browser session imported. \
167 Import cookies via Settings → X API → Import Browser Session."
168 .to_string(),
169 })
170 }
171
172 fn read_stub(method: &str) -> XApiError {
174 XApiError::ScraperTransportUnavailable {
175 message: format!("{method}: scraper transport not yet implemented"),
176 }
177 }
178
179 fn auth_required(method: &str) -> XApiError {
181 XApiError::FeatureRequiresAuth {
182 message: format!("{method} requires authenticated API access"),
183 }
184 }
185}
186
187#[async_trait::async_trait]
188impl XApiClient for LocalModeXClient {
189 async fn get_me(&self) -> Result<User, XApiError> {
192 if let Some(ref transport) = self.cookie_transport {
193 return transport.fetch_viewer().await;
194 }
195 Err(Self::auth_required("get_me"))
196 }
197
198 async fn get_mentions(
199 &self,
200 _user_id: &str,
201 _since_id: Option<&str>,
202 _pagination_token: Option<&str>,
203 ) -> Result<MentionResponse, XApiError> {
204 Err(Self::auth_required("get_mentions"))
206 }
207
208 async fn get_home_timeline(
209 &self,
210 _user_id: &str,
211 max_results: u32,
212 pagination_token: Option<&str>,
213 ) -> Result<SearchResponse, XApiError> {
214 if let Some(ref transport) = self.cookie_transport {
215 return transport
216 .get_home_timeline(max_results, pagination_token)
217 .await;
218 }
219 Err(Self::auth_required("get_home_timeline"))
220 }
221
222 async fn get_bookmarks(
223 &self,
224 _user_id: &str,
225 max_results: u32,
226 pagination_token: Option<&str>,
227 ) -> Result<SearchResponse, XApiError> {
228 if let Some(ref transport) = self.cookie_transport {
229 return transport.get_bookmarks(max_results, pagination_token).await;
230 }
231 Err(Self::auth_required("get_bookmarks"))
232 }
233
234 async fn bookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
235 self.check_mutation("bookmark_tweet")?;
236 self.transport()?.create_bookmark(tweet_id).await
237 }
238
239 async fn unbookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
240 self.check_mutation("unbookmark_tweet")?;
241 self.transport()?.delete_bookmark(tweet_id).await
242 }
243
244 async fn search_tweets(
247 &self,
248 query: &str,
249 max_results: u32,
250 _since_id: Option<&str>,
251 pagination_token: Option<&str>,
252 ) -> Result<SearchResponse, XApiError> {
253 if let Some(ref transport) = self.cookie_transport {
254 return transport
255 .search_timeline(query, max_results, pagination_token)
256 .await;
257 }
258 Err(Self::read_stub("search_tweets"))
259 }
260
261 async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError> {
262 if let Some(ref transport) = self.cookie_transport {
263 return transport.get_tweet_by_id(tweet_id).await;
264 }
265 Err(Self::read_stub("get_tweet"))
266 }
267
268 async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError> {
269 if let Some(ref transport) = self.cookie_transport {
270 return transport.get_user_by_screen_name(username).await;
271 }
272 Err(Self::read_stub("get_user_by_username"))
273 }
274
275 async fn get_user_tweets(
276 &self,
277 user_id: &str,
278 max_results: u32,
279 pagination_token: Option<&str>,
280 ) -> Result<SearchResponse, XApiError> {
281 if let Some(ref transport) = self.cookie_transport {
282 return transport
283 .get_user_tweets(user_id, max_results, pagination_token)
284 .await;
285 }
286 Err(Self::read_stub("get_user_tweets"))
287 }
288
289 async fn get_user_by_id(&self, user_id: &str) -> Result<User, XApiError> {
290 if let Some(ref transport) = self.cookie_transport {
291 return transport.get_user_by_rest_id(user_id).await;
292 }
293 Err(Self::read_stub("get_user_by_id"))
294 }
295
296 async fn get_followers(
297 &self,
298 user_id: &str,
299 max_results: u32,
300 pagination_token: Option<&str>,
301 ) -> Result<UsersResponse, XApiError> {
302 if let Some(ref transport) = self.cookie_transport {
303 return transport
304 .get_followers(user_id, max_results, pagination_token)
305 .await;
306 }
307 Err(Self::read_stub("get_followers"))
308 }
309
310 async fn get_following(
311 &self,
312 user_id: &str,
313 max_results: u32,
314 pagination_token: Option<&str>,
315 ) -> Result<UsersResponse, XApiError> {
316 if let Some(ref transport) = self.cookie_transport {
317 return transport
318 .get_following(user_id, max_results, pagination_token)
319 .await;
320 }
321 Err(Self::read_stub("get_following"))
322 }
323
324 async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
325 Err(Self::read_stub("get_users_by_ids"))
327 }
328
329 async fn get_liked_tweets(
330 &self,
331 user_id: &str,
332 max_results: u32,
333 pagination_token: Option<&str>,
334 ) -> Result<SearchResponse, XApiError> {
335 if let Some(ref transport) = self.cookie_transport {
336 return transport
337 .get_liked_tweets(user_id, max_results, pagination_token)
338 .await;
339 }
340 Err(Self::read_stub("get_liked_tweets"))
341 }
342
343 async fn get_tweet_liking_users(
344 &self,
345 _tweet_id: &str,
346 _max_results: u32,
347 _pagination_token: Option<&str>,
348 ) -> Result<UsersResponse, XApiError> {
349 Err(Self::read_stub("get_tweet_liking_users"))
351 }
352
353 async fn raw_request(
354 &self,
355 _method: &str,
356 _url: &str,
357 _query: Option<&[(String, String)]>,
358 _body: Option<&str>,
359 _headers: Option<&[(String, String)]>,
360 ) -> Result<RawApiResponse, XApiError> {
361 Err(Self::read_stub("raw_request"))
362 }
363
364 async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
367 self.check_mutation("post_tweet")?;
368 let transport = self.transport()?;
369 let text = text.to_string();
370 self.with_retry_and_health(|| {
371 let t = text.clone();
372 async move { transport.post_tweet(&t).await }
373 })
374 .await
375 }
376
377 async fn reply_to_tweet(
378 &self,
379 text: &str,
380 in_reply_to_id: &str,
381 ) -> Result<PostedTweet, XApiError> {
382 self.check_mutation("reply_to_tweet")?;
383 let transport = self.transport()?;
384 let text = text.to_string();
385 let reply_id = in_reply_to_id.to_string();
386 self.with_retry_and_health(|| {
387 let t = text.clone();
388 let r = reply_id.clone();
389 async move { transport.reply_to_tweet(&t, &r).await }
390 })
391 .await
392 }
393
394 async fn post_tweet_with_media(
395 &self,
396 text: &str,
397 _media_ids: &[String],
398 ) -> Result<PostedTweet, XApiError> {
399 self.check_mutation("post_tweet_with_media")?;
400 let transport = self.transport()?;
402 let text = text.to_string();
403 self.with_retry_and_health(|| {
404 let t = text.clone();
405 async move { transport.post_tweet(&t).await }
406 })
407 .await
408 }
409
410 async fn reply_to_tweet_with_media(
411 &self,
412 text: &str,
413 in_reply_to_id: &str,
414 _media_ids: &[String],
415 ) -> Result<PostedTweet, XApiError> {
416 self.check_mutation("reply_to_tweet_with_media")?;
417 let transport = self.transport()?;
419 let text = text.to_string();
420 let reply_id = in_reply_to_id.to_string();
421 self.with_retry_and_health(|| {
422 let t = text.clone();
423 let r = reply_id.clone();
424 async move { transport.reply_to_tweet(&t, &r).await }
425 })
426 .await
427 }
428
429 async fn quote_tweet(
430 &self,
431 _text: &str,
432 _quoted_tweet_id: &str,
433 ) -> Result<PostedTweet, XApiError> {
434 self.check_mutation("quote_tweet")?;
435 Err(XApiError::ScraperTransportUnavailable {
436 message: "quote_tweet not yet supported via cookie transport".to_string(),
437 })
438 }
439
440 async fn like_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
441 self.check_mutation("like_tweet")?;
442 self.transport()?.favorite_tweet(tweet_id).await
443 }
444
445 async fn unlike_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
446 self.check_mutation("unlike_tweet")?;
447 self.transport()?.unfavorite_tweet(tweet_id).await
448 }
449
450 async fn follow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
451 self.check_mutation("follow_user")?;
452 self.transport()?.follow_user(target_user_id).await
453 }
454
455 async fn unfollow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
456 self.check_mutation("unfollow_user")?;
457 self.transport()?.unfollow_user(target_user_id).await
458 }
459
460 async fn retweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
461 self.check_mutation("retweet")?;
462 self.transport()?.create_retweet(tweet_id).await
463 }
464
465 async fn unretweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
466 self.check_mutation("unretweet")?;
467 self.transport()?.delete_retweet(tweet_id).await
468 }
469
470 async fn delete_tweet(&self, tweet_id: &str) -> Result<bool, XApiError> {
471 self.check_mutation("delete_tweet")?;
472 self.transport()?.delete_tweet(tweet_id).await
473 }
474
475 async fn upload_media(
478 &self,
479 _data: &[u8],
480 _media_type: MediaType,
481 ) -> Result<MediaId, XApiError> {
482 Err(XApiError::MediaUploadError {
483 message: "media upload unavailable in scraper mode".to_string(),
484 })
485 }
486}
487
488#[cfg(test)]
489mod tests;