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}