insta_rs/
client.rs

1use reqwest::{
2    Client as HttpClient,
3    header::{ACCEPT, ACCEPT_LANGUAGE, HeaderMap, HeaderValue},
4};
5use serde_json::{Value, json};
6use std::sync::Arc;
7use tokio::sync::RwLock;
8
9use crate::auth::{Auth, SessionData};
10use crate::endpoints::{self, graphql};
11use crate::error::{Error, Result};
12use crate::types::*;
13
14const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
15const IG_BASE: &str = "https://www.instagram.com";
16
17pub struct Instagram {
18    http: HttpClient,
19    auth: Auth,
20    csrf: Arc<RwLock<Option<String>>>,
21}
22
23pub struct InstagramBuilder {
24    auth: Auth,
25    user_agent: Option<String>,
26}
27
28impl Instagram {
29    pub fn builder() -> InstagramBuilder {
30        InstagramBuilder {
31            auth: Auth::None,
32            user_agent: None,
33        }
34    }
35
36    fn headers() -> HeaderMap {
37        let mut h = HeaderMap::new();
38        h.insert(ACCEPT, HeaderValue::from_static("*/*"));
39        h.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9"));
40        h.insert("x-ig-app-id", HeaderValue::from_static(endpoints::APP_ID));
41        h.insert(
42            "x-requested-with",
43            HeaderValue::from_static("XMLHttpRequest"),
44        );
45        h
46    }
47
48    async fn fetch_csrf(&self) -> Result<String> {
49        {
50            let cached = self.csrf.read().await;
51            if let Some(ref token) = *cached {
52                return Ok(token.clone());
53            }
54        }
55
56        let resp = self
57            .http
58            .get(IG_BASE)
59            .header("cookie", "ig_cb=1")
60            .send()
61            .await?;
62
63        let body = resp.text().await?;
64
65        let token = body
66            .find("\"csrf_token\":\"")
67            .and_then(|start| {
68                let rest = &body[start + 14..];
69                rest.find('"').map(|end| rest[..end].to_string())
70            })
71            .ok_or_else(|| Error::Api("csrf token not found".into()))?;
72
73        {
74            let mut cached = self.csrf.write().await;
75            *cached = Some(token.clone());
76        }
77
78        Ok(token)
79    }
80
81    pub async fn login(&self) -> Result<()> {
82        let Auth::Credentials {
83            ref id,
84            ref password,
85        } = self.auth
86        else {
87            return Err(Error::InvalidCredentials);
88        };
89
90        let csrf = self.fetch_csrf().await?;
91
92        let body = format!(
93            "username={}&enc_password=#PWD_INSTAGRAM_BROWSER:0:{}:{}&queryParams={{}}&optIntoOneTap=false",
94            urlencoding::encode(id),
95            chrono::Utc::now().timestamp(),
96            urlencoding::encode(password)
97        );
98
99        let resp = self
100            .http
101            .post(endpoints::rest::LOGIN)
102            .header("content-type", "application/x-www-form-urlencoded")
103            .header("x-csrftoken", &csrf)
104            .body(body)
105            .send()
106            .await?;
107
108        if resp.status() == 400 {
109            return Err(Error::InvalidCredentials);
110        }
111        println!("{:?}", resp.status());
112
113        let json: Value = resp.json().await?;
114        println!("{:?}", &json);
115
116        if json.get("authenticated").and_then(|v| v.as_bool()) != Some(true) {
117            let msg = json
118                .get("message")
119                .and_then(|m| m.as_str())
120                .unwrap_or("login failed");
121            return Err(Error::Api(msg.into()));
122        }
123
124        Ok(())
125    }
126
127    async fn graphql_query(&self, doc_id: &str, variables: Value) -> Result<Value> {
128        let csrf = self.fetch_csrf().await?;
129
130        let body = format!(
131            "variables={}&doc_id={}",
132            urlencoding::encode(&variables.to_string()),
133            doc_id
134        );
135
136        let resp = self
137            .http
138            .post(graphql::BASE)
139            .header("content-type", "application/x-www-form-urlencoded")
140            .header("x-csrftoken", &csrf)
141            .body(body)
142            .send()
143            .await?;
144
145        if resp.status() == 429 {
146            let retry = resp
147                .headers()
148                .get("retry-after")
149                .and_then(|v| v.to_str().ok())
150                .and_then(|s| s.parse().ok());
151            return Err(Error::RateLimited(retry));
152        }
153
154        if resp.status() == 401 || resp.status() == 403 {
155            return Err(Error::AuthRequired);
156        }
157
158        let json: Value = resp.json().await?;
159        Ok(json)
160    }
161
162    async fn get_json(&self, url: &str) -> Result<Value> {
163        let resp = self.http.get(url).send().await?;
164
165        if resp.status() == 429 {
166            return Err(Error::RateLimited(None));
167        }
168
169        if resp.status() == 404 {
170            return Err(Error::Api("not found".into()));
171        }
172
173        let json: Value = resp.json().await?;
174        Ok(json)
175    }
176
177    pub async fn user(&self, username: &str) -> Result<User> {
178        let url = endpoints::profile_url(username);
179        let json = self.get_json(&url).await?;
180
181        let user = json
182            .get("data")
183            .and_then(|d| d.get("user"))
184            .ok_or_else(|| Error::UserNotFound(username.into()))?;
185
186        Ok(User {
187            id: extract_str(user, "id"),
188            username: extract_str(user, "username"),
189            full_name: user
190                .get("full_name")
191                .and_then(|v| v.as_str())
192                .map(Into::into),
193            biography: user
194                .get("biography")
195                .and_then(|v| v.as_str())
196                .map(Into::into),
197            profile_pic_url: user
198                .get("profile_pic_url")
199                .and_then(|v| v.as_str())
200                .map(Into::into),
201            is_private: user
202                .get("is_private")
203                .and_then(|v| v.as_bool())
204                .unwrap_or(false),
205            is_verified: user
206                .get("is_verified")
207                .and_then(|v| v.as_bool())
208                .unwrap_or(false),
209            follower_count: extract_count(user, "edge_followed_by"),
210            following_count: extract_count(user, "edge_follow"),
211            media_count: extract_count(user, "edge_owner_to_timeline_media"),
212            external_url: user
213                .get("external_url")
214                .and_then(|v| v.as_str())
215                .map(Into::into),
216            business_email: user
217                .get("business_email")
218                .and_then(|v| v.as_str())
219                .map(Into::into),
220            business_phone: user
221                .get("business_phone_number")
222                .and_then(|v| v.as_str())
223                .map(Into::into),
224            category: user
225                .get("category_name")
226                .and_then(|v| v.as_str())
227                .map(Into::into),
228        })
229    }
230
231    pub async fn user_by_id(&self, user_id: &str) -> Result<User> {
232        let url = endpoints::user_info_url(user_id);
233        let json = self.get_json(&url).await?;
234
235        let user = json
236            .get("user")
237            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;
238
239        Ok(User {
240            id: extract_str(user, "pk"),
241            username: extract_str(user, "username"),
242            full_name: user
243                .get("full_name")
244                .and_then(|v| v.as_str())
245                .map(Into::into),
246            biography: user
247                .get("biography")
248                .and_then(|v| v.as_str())
249                .map(Into::into),
250            profile_pic_url: user
251                .get("profile_pic_url")
252                .and_then(|v| v.as_str())
253                .map(Into::into),
254            is_private: user
255                .get("is_private")
256                .and_then(|v| v.as_bool())
257                .unwrap_or(false),
258            is_verified: user
259                .get("is_verified")
260                .and_then(|v| v.as_bool())
261                .unwrap_or(false),
262            follower_count: user
263                .get("follower_count")
264                .and_then(|v| v.as_u64())
265                .unwrap_or(0),
266            following_count: user
267                .get("following_count")
268                .and_then(|v| v.as_u64())
269                .unwrap_or(0),
270            media_count: user
271                .get("media_count")
272                .and_then(|v| v.as_u64())
273                .unwrap_or(0),
274            external_url: user
275                .get("external_url")
276                .and_then(|v| v.as_str())
277                .map(Into::into),
278            business_email: None,
279            business_phone: None,
280            category: user
281                .get("category")
282                .and_then(|v| v.as_str())
283                .map(Into::into),
284        })
285    }
286
287    pub async fn media(&self, shortcode: &str) -> Result<Media> {
288        let vars = json!({
289            "shortcode": shortcode,
290            "fetch_tagged_user_count": null,
291            "hoisted_comment_id": null,
292            "hoisted_reply_id": null
293        });
294
295        let json = self
296            .graphql_query(graphql::doc_id::POST_DETAILS, vars)
297            .await?;
298
299        let media = json
300            .pointer("/data/xdt_shortcode_media")
301            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;
302
303        parse_media(media)
304    }
305
306    pub async fn reel(&self, shortcode: &str) -> Result<Reel> {
307        let vars = json!({
308            "shortcode": shortcode,
309            "fetch_tagged_user_count": null,
310            "hoisted_comment_id": null,
311            "hoisted_reply_id": null
312        });
313        let json = self
314            .graphql_query(graphql::doc_id::POST_DETAILS, vars)
315            .await?;
316
317        let item = json
318            .pointer("/data/xdt_shortcode_media")
319            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;
320
321        Ok(Reel {
322            id: extract_str(item, "id"),
323            shortcode: extract_str(item, "shortcode"),
324            caption: item
325                .pointer("/edge_media_to_caption/edges/0/node/text")
326                .and_then(|v| v.as_str())
327                .map(Into::into),
328            timestamp: item
329                .get("taken_at_timestamp")
330                .and_then(|v| v.as_i64())
331                .unwrap_or(0),
332            like_count: extract_count(item, "edge_media_preview_like"),
333            comment_count: extract_count(item, "edge_media_to_parent_comment"),
334            play_count: item
335                .get("video_play_count")
336                .and_then(|v| v.as_u64())
337                .unwrap_or(0),
338            view_count: item
339                .get("video_view_count")
340                .and_then(|v| v.as_u64())
341                .unwrap_or(0),
342            video_url: extract_str(item, "video_url"),
343            thumbnail_url: extract_str(item, "display_url"),
344            duration: item
345                .get("video_duration")
346                .and_then(|v| v.as_f64())
347                .unwrap_or(0.0),
348            owner: parse_media_owner(item.get("owner").unwrap_or(&Value::Null)),
349            audio: item.get("clips_music_attribution_info").map(|a| ReelAudio {
350                id: extract_str(a, "audio_id"),
351                title: extract_str(a, "song_name"),
352                artist: a
353                    .get("artist_name")
354                    .and_then(|v| v.as_str())
355                    .map(Into::into),
356            }),
357        })
358    }
359
360    pub async fn user_posts(
361        &self,
362        user_id: &str,
363        count: u32,
364        cursor: Option<&str>,
365    ) -> Result<Pagination<Media>> {
366        let vars = json!({
367            "id": user_id,
368            "first": count,
369            "after": cursor
370        });
371
372        let json = self
373            .graphql_query(graphql::doc_id::PROFILE_POSTS, vars)
374            .await?;
375
376        let timeline = json
377            .pointer("/data/xdt_api__v1__feed__user_timeline_graphql_connection")
378            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;
379
380        let edges = timeline.get("edges").and_then(|e| e.as_array());
381        let page_info = timeline.get("page_info");
382
383        let items = edges
384            .map(|arr| {
385                arr.iter()
386                    .filter_map(|e| e.get("node"))
387                    .filter_map(|n| parse_media(n).ok())
388                    .collect()
389            })
390            .unwrap_or_default();
391
392        Ok(Pagination {
393            items,
394            has_next: page_info
395                .and_then(|p| p.get("has_next_page"))
396                .and_then(|v| v.as_bool())
397                .unwrap_or(false),
398            cursor: page_info
399                .and_then(|p| p.get("end_cursor"))
400                .and_then(|v| v.as_str())
401                .map(Into::into),
402        })
403    }
404
405    pub async fn comments(
406        &self,
407        shortcode: &str,
408        count: u32,
409        cursor: Option<&str>,
410    ) -> Result<Pagination<Comment>> {
411        let vars = json!({
412            "shortcode": shortcode,
413            "fetch_tagged_user_count": null,
414            "hoisted_comment_id": null,
415            "hoisted_reply_id": null,
416            "first": count,
417            "after": cursor
418        });
419
420        let json = self
421            .graphql_query(graphql::doc_id::POST_DETAILS, vars)
422            .await?;
423
424        let comments = json
425            .pointer("/data/xdt_shortcode_media/edge_media_to_parent_comment")
426            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;
427
428        let edges = comments.get("edges").and_then(|e| e.as_array());
429        let page_info = comments.get("page_info");
430
431        let items = edges
432            .map(|arr| {
433                arr.iter()
434                    .filter_map(|e| parse_comment(e.get("node")?))
435                    .collect()
436            })
437            .unwrap_or_default();
438
439        Ok(Pagination {
440            items,
441            has_next: page_info
442                .and_then(|p| p.get("has_next_page"))
443                .and_then(|v| v.as_bool())
444                .unwrap_or(false),
445            cursor: page_info
446                .and_then(|p| p.get("end_cursor"))
447                .and_then(|v| v.as_str())
448                .map(Into::into),
449        })
450    }
451
452    pub async fn likers(
453        &self,
454        shortcode: &str,
455        count: u32,
456        cursor: Option<&str>,
457    ) -> Result<Pagination<Like>> {
458        let vars = json!({
459            "shortcode": shortcode,
460            "first": count,
461            "after": cursor
462        });
463
464        let url = format!(
465            "{}?query_hash={}&variables={}",
466            graphql::BASE,
467            graphql::query_hash::LIKERS,
468            urlencoding::encode(&vars.to_string())
469        );
470
471        let json = self.get_json(&url).await?;
472
473        let likers = json
474            .pointer("/data/shortcode_media/edge_liked_by")
475            .ok_or_else(|| Error::MediaNotFound(shortcode.into()))?;
476
477        let edges = likers.get("edges").and_then(|e| e.as_array());
478        let page_info = likers.get("page_info");
479
480        let items = edges
481            .map(|arr| {
482                arr.iter()
483                    .filter_map(|e| e.get("node"))
484                    .map(|n| Like {
485                        id: extract_str(n, "id"),
486                        username: extract_str(n, "username"),
487                        full_name: n.get("full_name").and_then(|v| v.as_str()).map(Into::into),
488                        profile_pic_url: n
489                            .get("profile_pic_url")
490                            .and_then(|v| v.as_str())
491                            .map(Into::into),
492                        is_verified: n
493                            .get("is_verified")
494                            .and_then(|v| v.as_bool())
495                            .unwrap_or(false),
496                    })
497                    .collect()
498            })
499            .unwrap_or_default();
500
501        Ok(Pagination {
502            items,
503            has_next: page_info
504                .and_then(|p| p.get("has_next_page"))
505                .and_then(|v| v.as_bool())
506                .unwrap_or(false),
507            cursor: page_info
508                .and_then(|p| p.get("end_cursor"))
509                .and_then(|v| v.as_str())
510                .map(Into::into),
511        })
512    }
513
514    pub async fn followers(
515        &self,
516        user_id: &str,
517        count: u32,
518        cursor: Option<&str>,
519    ) -> Result<Pagination<User>> {
520        self.fetch_connections(
521            user_id,
522            count,
523            cursor,
524            graphql::query_hash::FOLLOWERS,
525            "edge_followed_by",
526        )
527        .await
528    }
529
530    pub async fn following(
531        &self,
532        user_id: &str,
533        count: u32,
534        cursor: Option<&str>,
535    ) -> Result<Pagination<User>> {
536        self.fetch_connections(
537            user_id,
538            count,
539            cursor,
540            graphql::query_hash::FOLLOWING,
541            "edge_follow",
542        )
543        .await
544    }
545
546    async fn fetch_connections(
547        &self,
548        user_id: &str,
549        count: u32,
550        cursor: Option<&str>,
551        hash: &str,
552        edge_key: &str,
553    ) -> Result<Pagination<User>> {
554        let vars = json!({
555            "id": user_id,
556            "first": count,
557            "after": cursor
558        });
559
560        let url = format!(
561            "{}?query_hash={}&variables={}",
562            graphql::BASE,
563            hash,
564            urlencoding::encode(&vars.to_string())
565        );
566
567        let json = self.get_json(&url).await?;
568
569        let data = json
570            .pointer(&format!("/data/user/{}", edge_key))
571            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;
572
573        let edges = data.get("edges").and_then(|e| e.as_array());
574        let page_info = data.get("page_info");
575
576        let items = edges
577            .map(|arr| {
578                arr.iter()
579                    .filter_map(|e| e.get("node"))
580                    .map(|n| User {
581                        id: extract_str(n, "id"),
582                        username: extract_str(n, "username"),
583                        full_name: n.get("full_name").and_then(|v| v.as_str()).map(Into::into),
584                        biography: None,
585                        profile_pic_url: n
586                            .get("profile_pic_url")
587                            .and_then(|v| v.as_str())
588                            .map(Into::into),
589                        is_private: n
590                            .get("is_private")
591                            .and_then(|v| v.as_bool())
592                            .unwrap_or(false),
593                        is_verified: n
594                            .get("is_verified")
595                            .and_then(|v| v.as_bool())
596                            .unwrap_or(false),
597                        follower_count: 0,
598                        following_count: 0,
599                        media_count: 0,
600                        external_url: None,
601                        business_email: None,
602                        business_phone: None,
603                        category: None,
604                    })
605                    .collect()
606            })
607            .unwrap_or_default();
608
609        Ok(Pagination {
610            items,
611            has_next: page_info
612                .and_then(|p| p.get("has_next_page"))
613                .and_then(|v| v.as_bool())
614                .unwrap_or(false),
615            cursor: page_info
616                .and_then(|p| p.get("end_cursor"))
617                .and_then(|v| v.as_str())
618                .map(Into::into),
619        })
620    }
621
622    pub async fn highlights(&self, user_id: &str) -> Result<Vec<Highlight>> {
623        let vars = json!({
624            "user_id": user_id,
625            "include_chaining": false,
626            "include_reel": false,
627            "include_suggested_users": false,
628            "include_logged_out_extras": false,
629            "include_highlight_reels": true
630        });
631
632        let url = format!(
633            "{}?query_hash={}&variables={}",
634            graphql::BASE,
635            graphql::query_hash_legacy::HIGHLIGHTS,
636            urlencoding::encode(&vars.to_string())
637        );
638
639        let json = self.get_json(&url).await?;
640
641        let reels = json
642            .pointer("/data/user/edge_highlight_reels/edges")
643            .and_then(|e| e.as_array())
644            .ok_or_else(|| Error::UserNotFound(user_id.into()))?;
645
646        Ok(reels
647            .iter()
648            .filter_map(|e| e.get("node"))
649            .map(|n| Highlight {
650                id: extract_str(n, "id"),
651                title: extract_str(n, "title"),
652                cover_url: n
653                    .pointer("/cover_media/thumbnail_src")
654                    .and_then(|v| v.as_str())
655                    .unwrap_or_default()
656                    .into(),
657                media_count: n
658                    .pointer("/edge_highlight_media/count")
659                    .and_then(|v| v.as_u64())
660                    .unwrap_or(0),
661                items: vec![],
662            })
663            .collect())
664    }
665
666    pub async fn search(&self, query: &str) -> Result<SearchResult> {
667        let url = format!(
668            "{}?query={}",
669            endpoints::rest::SEARCH,
670            urlencoding::encode(query)
671        );
672        let json = self.get_json(&url).await?;
673
674        let users = json
675            .get("users")
676            .and_then(|u| u.as_array())
677            .map(|arr| {
678                arr.iter()
679                    .filter_map(|u| u.get("user"))
680                    .map(|u| UserSearchItem {
681                        id: extract_str(u, "pk"),
682                        username: extract_str(u, "username"),
683                        full_name: u.get("full_name").and_then(|v| v.as_str()).map(Into::into),
684                        profile_pic_url: u
685                            .get("profile_pic_url")
686                            .and_then(|v| v.as_str())
687                            .map(Into::into),
688                        is_verified: u
689                            .get("is_verified")
690                            .and_then(|v| v.as_bool())
691                            .unwrap_or(false),
692                        is_private: u
693                            .get("is_private")
694                            .and_then(|v| v.as_bool())
695                            .unwrap_or(false),
696                        follower_count: u.get("follower_count").and_then(|v| v.as_u64()),
697                    })
698                    .collect()
699            })
700            .unwrap_or_default();
701
702        let hashtags = json
703            .get("hashtags")
704            .and_then(|h| h.as_array())
705            .map(|arr| {
706                arr.iter()
707                    .filter_map(|h| h.get("hashtag"))
708                    .map(|h| HashtagSearchItem {
709                        id: extract_str(h, "id"),
710                        name: extract_str(h, "name"),
711                        media_count: h.get("media_count").and_then(|v| v.as_u64()).unwrap_or(0),
712                    })
713                    .collect()
714            })
715            .unwrap_or_default();
716
717        let places = json
718            .get("places")
719            .and_then(|p| p.as_array())
720            .map(|arr| {
721                arr.iter()
722                    .filter_map(|p| p.get("place"))
723                    .map(|p| PlaceSearchItem {
724                        id: p
725                            .pointer("/location/pk")
726                            .and_then(|v| v.as_str())
727                            .unwrap_or_default()
728                            .into(),
729                        name: p
730                            .get("title")
731                            .and_then(|v| v.as_str())
732                            .unwrap_or_default()
733                            .into(),
734                        address: p.get("subtitle").and_then(|v| v.as_str()).map(Into::into),
735                    })
736                    .collect()
737            })
738            .unwrap_or_default();
739
740        Ok(SearchResult {
741            users,
742            hashtags,
743            places,
744        })
745    }
746
747    pub async fn hashtag_media(
748        &self,
749        tag: &str,
750        count: u32,
751        cursor: Option<&str>,
752    ) -> Result<Pagination<Media>> {
753        let vars = json!({
754            "tag_name": tag,
755            "first": count,
756            "after": cursor
757        });
758
759        let url = format!(
760            "{}?query_hash={}&variables={}",
761            graphql::BASE,
762            graphql::query_hash::HASHTAG,
763            urlencoding::encode(&vars.to_string())
764        );
765
766        let json = self.get_json(&url).await?;
767
768        let data = json
769            .pointer("/data/hashtag/edge_hashtag_to_media")
770            .ok_or_else(|| Error::Api(format!("hashtag not found / {}", tag)))?;
771
772        let edges = data.get("edges").and_then(|e| e.as_array());
773        let page_info = data.get("page_info");
774
775        let items = edges
776            .map(|arr| {
777                arr.iter()
778                    .filter_map(|e| e.get("node"))
779                    .filter_map(|n| parse_media(n).ok())
780                    .collect()
781            })
782            .unwrap_or_default();
783
784        Ok(Pagination {
785            items,
786            has_next: page_info
787                .and_then(|p| p.get("has_next_page"))
788                .and_then(|v| v.as_bool())
789                .unwrap_or(false),
790            cursor: page_info
791                .and_then(|p| p.get("end_cursor"))
792                .and_then(|v| v.as_str())
793                .map(Into::into),
794        })
795    }
796
797    pub fn auth(&self) -> &Auth {
798        &self.auth
799    }
800}
801
802impl InstagramBuilder {
803    pub fn id(self, id: impl Into<String>) -> CredentialsBuilder {
804        CredentialsBuilder {
805            id: id.into(),
806            password: None,
807            user_agent: self.user_agent,
808        }
809    }
810
811    pub fn session(mut self, session: SessionData) -> Self {
812        self.auth = Auth::Session(session);
813        self
814    }
815
816    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
817        self.user_agent = Some(ua.into());
818        self
819    }
820
821    pub fn build(self) -> Result<Instagram> {
822        let ua = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
823
824        let http = HttpClient::builder()
825            .default_headers(Instagram::headers())
826            .user_agent(ua)
827            .cookie_store(true)
828            .gzip(true)
829            .brotli(true)
830            .build()?;
831
832        Ok(Instagram {
833            http,
834            auth: self.auth,
835            csrf: Arc::new(RwLock::new(None)),
836        })
837    }
838}
839
840pub struct CredentialsBuilder {
841    id: String,
842    password: Option<String>,
843    user_agent: Option<String>,
844}
845
846impl CredentialsBuilder {
847    pub fn password(mut self, password: impl Into<String>) -> Self {
848        self.password = Some(password.into());
849        self
850    }
851
852    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
853        self.user_agent = Some(ua.into());
854        self
855    }
856
857    pub fn build(self) -> Result<Instagram> {
858        let password = self.password.ok_or(Error::InvalidCredentials)?;
859        let ua = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
860
861        let http = HttpClient::builder()
862            .default_headers(Instagram::headers())
863            .user_agent(ua)
864            .cookie_store(true)
865            .gzip(true)
866            .brotli(true)
867            .build()?;
868
869        Ok(Instagram {
870            http,
871            auth: Auth::Credentials {
872                id: self.id,
873                password,
874            },
875            csrf: Arc::new(RwLock::new(None)),
876        })
877    }
878}
879
880fn extract_str(v: &Value, key: &str) -> String {
881    v.get(key)
882        .and_then(|v| v.as_str())
883        .unwrap_or_default()
884        .into()
885}
886
887fn extract_count(v: &Value, key: &str) -> u64 {
888    v.get(key)
889        .and_then(|e| e.get("count"))
890        .and_then(|c| c.as_u64())
891        .unwrap_or(0)
892}
893
894fn parse_media_owner(v: &Value) -> MediaOwner {
895    MediaOwner {
896        id: extract_str(v, "id"),
897        username: extract_str(v, "username"),
898        profile_pic_url: v
899            .get("profile_pic_url")
900            .and_then(|p| p.as_str())
901            .map(Into::into),
902        is_verified: v
903            .get("is_verified")
904            .and_then(|b| b.as_bool())
905            .unwrap_or(false),
906    }
907}
908
909fn parse_media(v: &Value) -> Result<Media> {
910    let typename = v.get("__typename").and_then(|t| t.as_str()).unwrap_or("");
911    let is_video = v.get("is_video").and_then(|b| b.as_bool()).unwrap_or(false);
912
913    let media_type = match typename {
914        "GraphSidecar" | "XDTGraphSidecar" => MediaType::Carousel,
915        "GraphStoryImage" | "GraphStoryVideo" => MediaType::Story,
916        _ if v.get("product_type").and_then(|p| p.as_str()) == Some("clips") => MediaType::Reel,
917        _ if is_video => MediaType::Video,
918        _ => MediaType::Image,
919    };
920
921    let tagged = v
922        .pointer("/edge_media_to_tagged_user/edges")
923        .and_then(|e| e.as_array())
924        .map(|arr| {
925            arr.iter()
926                .filter_map(|e| e.pointer("/node/user"))
927                .map(|u| TaggedUser {
928                    id: extract_str(u, "id"),
929                    username: extract_str(u, "username"),
930                })
931                .collect()
932        })
933        .unwrap_or_default();
934
935    let location = v.get("location").and_then(|l| {
936        if l.is_null() {
937            return None;
938        }
939        Some(Location {
940            id: extract_str(l, "id"),
941            name: extract_str(l, "name"),
942            slug: l.get("slug").and_then(|s| s.as_str()).map(Into::into),
943        })
944    });
945
946    Ok(Media {
947        id: extract_str(v, "id"),
948        shortcode: extract_str(v, "shortcode"),
949        media_type,
950        caption: v
951            .pointer("/edge_media_to_caption/edges/0/node/text")
952            .and_then(|c| c.as_str())
953            .map(Into::into),
954        timestamp: v
955            .get("taken_at_timestamp")
956            .and_then(|t| t.as_i64())
957            .unwrap_or(0),
958        like_count: extract_count(v, "edge_media_preview_like"),
959        comment_count: extract_count(v, "edge_media_to_parent_comment"),
960        view_count: v.get("video_view_count").and_then(|c| c.as_u64()),
961        play_count: v.get("video_play_count").and_then(|c| c.as_u64()),
962        display_url: extract_str(v, "display_url"),
963        video_url: v.get("video_url").and_then(|u| u.as_str()).map(Into::into),
964        owner: parse_media_owner(v.get("owner").unwrap_or(&Value::Null)),
965        is_video,
966        tagged_users: tagged,
967        location,
968    })
969}
970
971fn parse_comment(v: &Value) -> Option<Comment> {
972    let replies = v
973        .pointer("/edge_threaded_comments/edges")
974        .and_then(|e| e.as_array())
975        .map(|arr| {
976            arr.iter()
977                .filter_map(|e| parse_comment(e.get("node")?))
978                .collect()
979        })
980        .unwrap_or_default();
981
982    Some(Comment {
983        id: extract_str(v, "id"),
984        text: extract_str(v, "text"),
985        timestamp: v.get("created_at").and_then(|t| t.as_i64()).unwrap_or(0),
986        like_count: v
987            .get("edge_liked_by")
988            .and_then(|e| e.get("count"))
989            .and_then(|c| c.as_u64())
990            .unwrap_or(0),
991        owner: CommentOwner {
992            id: v
993                .pointer("/owner/id")
994                .and_then(|i| i.as_str())
995                .unwrap_or_default()
996                .into(),
997            username: v
998                .pointer("/owner/username")
999                .and_then(|u| u.as_str())
1000                .unwrap_or_default()
1001                .into(),
1002            profile_pic_url: v
1003                .pointer("/owner/profile_pic_url")
1004                .and_then(|p| p.as_str())
1005                .map(Into::into),
1006            is_verified: v
1007                .pointer("/owner/is_verified")
1008                .and_then(|b| b.as_bool())
1009                .unwrap_or(false),
1010        },
1011        replies,
1012    })
1013}