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 read_stub(method: &str) -> XApiError {
159 XApiError::ScraperTransportUnavailable {
160 message: format!("{method}: scraper transport not yet implemented"),
161 }
162 }
163
164 fn auth_required(method: &str) -> XApiError {
166 XApiError::FeatureRequiresAuth {
167 message: format!("{method} requires authenticated API access"),
168 }
169 }
170}
171
172#[async_trait::async_trait]
173impl XApiClient for LocalModeXClient {
174 async fn get_me(&self) -> Result<User, XApiError> {
177 if let Some(ref transport) = self.cookie_transport {
178 return transport.fetch_viewer().await;
179 }
180 Err(Self::auth_required("get_me"))
181 }
182
183 async fn get_mentions(
184 &self,
185 _user_id: &str,
186 _since_id: Option<&str>,
187 _pagination_token: Option<&str>,
188 ) -> Result<MentionResponse, XApiError> {
189 Err(Self::auth_required("get_mentions"))
191 }
192
193 async fn get_home_timeline(
194 &self,
195 _user_id: &str,
196 max_results: u32,
197 pagination_token: Option<&str>,
198 ) -> Result<SearchResponse, XApiError> {
199 if let Some(ref transport) = self.cookie_transport {
200 return transport
201 .get_home_timeline(max_results, pagination_token)
202 .await;
203 }
204 Err(Self::auth_required("get_home_timeline"))
205 }
206
207 async fn get_bookmarks(
208 &self,
209 _user_id: &str,
210 max_results: u32,
211 pagination_token: Option<&str>,
212 ) -> Result<SearchResponse, XApiError> {
213 if let Some(ref transport) = self.cookie_transport {
214 return transport.get_bookmarks(max_results, pagination_token).await;
215 }
216 Err(Self::auth_required("get_bookmarks"))
217 }
218
219 async fn bookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
220 self.check_mutation("bookmark_tweet")?;
221 self.cookie_transport
222 .as_ref()
223 .unwrap()
224 .create_bookmark(tweet_id)
225 .await
226 }
227
228 async fn unbookmark_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
229 self.check_mutation("unbookmark_tweet")?;
230 self.cookie_transport
231 .as_ref()
232 .unwrap()
233 .delete_bookmark(tweet_id)
234 .await
235 }
236
237 async fn search_tweets(
240 &self,
241 query: &str,
242 max_results: u32,
243 _since_id: Option<&str>,
244 pagination_token: Option<&str>,
245 ) -> Result<SearchResponse, XApiError> {
246 if let Some(ref transport) = self.cookie_transport {
247 return transport
248 .search_timeline(query, max_results, pagination_token)
249 .await;
250 }
251 Err(Self::read_stub("search_tweets"))
252 }
253
254 async fn get_tweet(&self, tweet_id: &str) -> Result<Tweet, XApiError> {
255 if let Some(ref transport) = self.cookie_transport {
256 return transport.get_tweet_by_id(tweet_id).await;
257 }
258 Err(Self::read_stub("get_tweet"))
259 }
260
261 async fn get_user_by_username(&self, username: &str) -> Result<User, XApiError> {
262 if let Some(ref transport) = self.cookie_transport {
263 return transport.get_user_by_screen_name(username).await;
264 }
265 Err(Self::read_stub("get_user_by_username"))
266 }
267
268 async fn get_user_tweets(
269 &self,
270 user_id: &str,
271 max_results: u32,
272 pagination_token: Option<&str>,
273 ) -> Result<SearchResponse, XApiError> {
274 if let Some(ref transport) = self.cookie_transport {
275 return transport
276 .get_user_tweets(user_id, max_results, pagination_token)
277 .await;
278 }
279 Err(Self::read_stub("get_user_tweets"))
280 }
281
282 async fn get_user_by_id(&self, user_id: &str) -> Result<User, XApiError> {
283 if let Some(ref transport) = self.cookie_transport {
284 return transport.get_user_by_rest_id(user_id).await;
285 }
286 Err(Self::read_stub("get_user_by_id"))
287 }
288
289 async fn get_followers(
290 &self,
291 user_id: &str,
292 max_results: u32,
293 pagination_token: Option<&str>,
294 ) -> Result<UsersResponse, XApiError> {
295 if let Some(ref transport) = self.cookie_transport {
296 return transport
297 .get_followers(user_id, max_results, pagination_token)
298 .await;
299 }
300 Err(Self::read_stub("get_followers"))
301 }
302
303 async fn get_following(
304 &self,
305 user_id: &str,
306 max_results: u32,
307 pagination_token: Option<&str>,
308 ) -> Result<UsersResponse, XApiError> {
309 if let Some(ref transport) = self.cookie_transport {
310 return transport
311 .get_following(user_id, max_results, pagination_token)
312 .await;
313 }
314 Err(Self::read_stub("get_following"))
315 }
316
317 async fn get_users_by_ids(&self, _user_ids: &[&str]) -> Result<UsersResponse, XApiError> {
318 Err(Self::read_stub("get_users_by_ids"))
320 }
321
322 async fn get_liked_tweets(
323 &self,
324 user_id: &str,
325 max_results: u32,
326 pagination_token: Option<&str>,
327 ) -> Result<SearchResponse, XApiError> {
328 if let Some(ref transport) = self.cookie_transport {
329 return transport
330 .get_liked_tweets(user_id, max_results, pagination_token)
331 .await;
332 }
333 Err(Self::read_stub("get_liked_tweets"))
334 }
335
336 async fn get_tweet_liking_users(
337 &self,
338 _tweet_id: &str,
339 _max_results: u32,
340 _pagination_token: Option<&str>,
341 ) -> Result<UsersResponse, XApiError> {
342 Err(Self::read_stub("get_tweet_liking_users"))
344 }
345
346 async fn raw_request(
347 &self,
348 _method: &str,
349 _url: &str,
350 _query: Option<&[(String, String)]>,
351 _body: Option<&str>,
352 _headers: Option<&[(String, String)]>,
353 ) -> Result<RawApiResponse, XApiError> {
354 Err(Self::read_stub("raw_request"))
355 }
356
357 async fn post_tweet(&self, text: &str) -> Result<PostedTweet, XApiError> {
360 self.check_mutation("post_tweet")?;
361 let transport = self.cookie_transport.as_ref().unwrap();
362 let text = text.to_string();
363 self.with_retry_and_health(|| {
364 let t = text.clone();
365 async move { transport.post_tweet(&t).await }
366 })
367 .await
368 }
369
370 async fn reply_to_tweet(
371 &self,
372 text: &str,
373 in_reply_to_id: &str,
374 ) -> Result<PostedTweet, XApiError> {
375 self.check_mutation("reply_to_tweet")?;
376 let transport = self.cookie_transport.as_ref().unwrap();
377 let text = text.to_string();
378 let reply_id = in_reply_to_id.to_string();
379 self.with_retry_and_health(|| {
380 let t = text.clone();
381 let r = reply_id.clone();
382 async move { transport.reply_to_tweet(&t, &r).await }
383 })
384 .await
385 }
386
387 async fn post_tweet_with_media(
388 &self,
389 text: &str,
390 _media_ids: &[String],
391 ) -> Result<PostedTweet, XApiError> {
392 self.check_mutation("post_tweet_with_media")?;
393 let transport = self.cookie_transport.as_ref().unwrap();
395 let text = text.to_string();
396 self.with_retry_and_health(|| {
397 let t = text.clone();
398 async move { transport.post_tweet(&t).await }
399 })
400 .await
401 }
402
403 async fn reply_to_tweet_with_media(
404 &self,
405 text: &str,
406 in_reply_to_id: &str,
407 _media_ids: &[String],
408 ) -> Result<PostedTweet, XApiError> {
409 self.check_mutation("reply_to_tweet_with_media")?;
410 let transport = self.cookie_transport.as_ref().unwrap();
412 let text = text.to_string();
413 let reply_id = in_reply_to_id.to_string();
414 self.with_retry_and_health(|| {
415 let t = text.clone();
416 let r = reply_id.clone();
417 async move { transport.reply_to_tweet(&t, &r).await }
418 })
419 .await
420 }
421
422 async fn quote_tweet(
423 &self,
424 _text: &str,
425 _quoted_tweet_id: &str,
426 ) -> Result<PostedTweet, XApiError> {
427 self.check_mutation("quote_tweet")?;
428 Err(XApiError::ScraperTransportUnavailable {
429 message: "quote_tweet not yet supported via cookie transport".to_string(),
430 })
431 }
432
433 async fn like_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
434 self.check_mutation("like_tweet")?;
435 self.cookie_transport
436 .as_ref()
437 .unwrap()
438 .favorite_tweet(tweet_id)
439 .await
440 }
441
442 async fn unlike_tweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
443 self.check_mutation("unlike_tweet")?;
444 self.cookie_transport
445 .as_ref()
446 .unwrap()
447 .unfavorite_tweet(tweet_id)
448 .await
449 }
450
451 async fn follow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
452 self.check_mutation("follow_user")?;
453 self.cookie_transport
454 .as_ref()
455 .unwrap()
456 .follow_user(target_user_id)
457 .await
458 }
459
460 async fn unfollow_user(&self, _user_id: &str, target_user_id: &str) -> Result<bool, XApiError> {
461 self.check_mutation("unfollow_user")?;
462 self.cookie_transport
463 .as_ref()
464 .unwrap()
465 .unfollow_user(target_user_id)
466 .await
467 }
468
469 async fn retweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
470 self.check_mutation("retweet")?;
471 self.cookie_transport
472 .as_ref()
473 .unwrap()
474 .create_retweet(tweet_id)
475 .await
476 }
477
478 async fn unretweet(&self, _user_id: &str, tweet_id: &str) -> Result<bool, XApiError> {
479 self.check_mutation("unretweet")?;
480 self.cookie_transport
481 .as_ref()
482 .unwrap()
483 .delete_retweet(tweet_id)
484 .await
485 }
486
487 async fn delete_tweet(&self, tweet_id: &str) -> Result<bool, XApiError> {
488 self.check_mutation("delete_tweet")?;
489 self.cookie_transport
490 .as_ref()
491 .unwrap()
492 .delete_tweet(tweet_id)
493 .await
494 }
495
496 async fn upload_media(
499 &self,
500 _data: &[u8],
501 _media_type: MediaType,
502 ) -> Result<MediaId, XApiError> {
503 Err(XApiError::MediaUploadError {
504 message: "media upload unavailable in scraper mode".to_string(),
505 })
506 }
507}
508
509#[cfg(test)]
510mod tests;