Skip to main content

apify_rs/
instagram.rs

1//! Typed output of the *Instagram Profile Scraper* Actor.
2//!
3//! These structs map the JSON items that the Actor writes into its default
4//! Dataset.  They demonstrate how you can take an Actor's output schema and
5//! turn it into strongly-typed Rust structs for safe consumption.
6//!
7//! To use them:
8//!
9//! ```ignore
10//! let posts: Vec<InstagramPost> =
11//!     client.runs().get_typed_dataset_items(&run_id, None).await?;
12//! ```
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17/// One Instagram post (image, video, reel, or sidecar/carousel).
18///
19/// Fields mirror the JSON produced by the Apify *Instagram Profile Scraper*.
20/// Many are optional because the available metadata depends on the post type
21/// and whether the Actor was configured to fetch comments / child posts.
22#[derive(Debug, Clone, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct InstagramPost {
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub alt: Option<String>,
27
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub audio_url: Option<String>,
30
31    pub caption: String,
32
33    #[serde(default)]
34    pub child_posts: Vec<InstagramChildPost>,
35
36    pub comments_count: u64,
37    pub dimensions_height: u64,
38    pub dimensions_width: u64,
39    pub display_url: String,
40
41    #[serde(default)]
42    pub first_comment: String,
43
44    #[serde(default)]
45    pub hashtags: Vec<String>,
46
47    pub id: String,
48
49    #[serde(default)]
50    pub images: Vec<String>,
51
52    pub input_url: String,
53    pub is_comments_disabled: bool,
54
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub is_pinned: Option<bool>,
57
58    #[serde(default)]
59    pub latest_comments: Vec<InstagramComment>,
60
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub likes_count: Option<u64>,
63
64    #[serde(default)]
65    pub mentions: Vec<String>,
66
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub music_info: Option<InstagramMusicInfo>,
69
70    pub owner_full_name: String,
71    pub owner_id: String,
72    pub owner_username: String,
73
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub product_type: Option<String>,
76
77    pub short_code: String,
78
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub timestamp: Option<DateTime<Utc>>,
81
82    #[serde(rename = "type")]
83    pub post_type: String,
84
85    pub url: String,
86
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub video_duration: Option<f64>,
89
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub video_play_count: Option<u64>,
92
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub video_url: Option<String>,
95
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub video_view_count: Option<u64>,
98}
99
100/// One slide inside a multi-media (sidecar / carousel) post.
101///
102/// Shares most fields with [`InstagramPost`] but omits engagement stats
103/// because those live on the parent post.
104#[derive(Debug, Clone, Deserialize, Serialize)]
105#[serde(rename_all = "camelCase")]
106pub struct InstagramChildPost {
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub alt: Option<String>,
109
110    #[serde(default)]
111    pub caption: String,
112
113    #[serde(default)]
114    pub child_posts: Vec<InstagramChildPost>,
115
116    pub comments_count: u64,
117    pub dimensions_height: u64,
118    pub dimensions_width: u64,
119    pub display_url: String,
120
121    #[serde(default)]
122    pub first_comment: String,
123
124    #[serde(default)]
125    pub hashtags: Vec<String>,
126
127    pub id: String,
128
129    #[serde(default)]
130    pub images: Vec<String>,
131
132    #[serde(default)]
133    pub latest_comments: Vec<InstagramComment>,
134
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub likes_count: Option<u64>,
137
138    #[serde(default)]
139    pub mentions: Vec<String>,
140
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub owner_id: Option<String>,
143
144    pub short_code: String,
145
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub timestamp: Option<DateTime<Utc>>,
148
149    #[serde(rename = "type")]
150    pub post_type: String,
151
152    pub url: String,
153}
154
155/// A comment on an Instagram post, optionally containing threaded replies.
156#[derive(Debug, Clone, Deserialize, Serialize)]
157#[serde(rename_all = "camelCase")]
158pub struct InstagramComment {
159    pub id: String,
160    pub likes_count: u64,
161    pub owner: InstagramOwner,
162    pub owner_profile_pic_url: String,
163    pub owner_username: String,
164
165    #[serde(default)]
166    pub replies: Vec<InstagramComment>,
167
168    pub replies_count: u64,
169    pub text: String,
170    pub timestamp: DateTime<Utc>,
171}
172
173/// Bare-bones profile of a comment author.
174#[derive(Debug, Clone, Deserialize, Serialize)]
175#[serde(rename_all = "snake_case")]
176pub struct InstagramOwner {
177    pub id: String,
178    pub is_verified: bool,
179    pub profile_pic_url: String,
180    pub username: String,
181}
182
183/// Music / audio metadata attached to a Reel or video post.
184#[derive(Debug, Clone, Deserialize, Serialize)]
185#[serde(rename_all = "snake_case")]
186pub struct InstagramMusicInfo {
187    pub artist_name: String,
188    pub audio_id: String,
189    pub should_mute_audio: bool,
190    pub should_mute_audio_reason: String,
191    pub song_name: String,
192    pub uses_original_audio: bool,
193}
194
195// ---------------------------------------------------------------------------
196// Human-friendly formatting helpers
197// ---------------------------------------------------------------------------
198
199/// Render a single `InstagramPost` as a nicely formatted string suitable for
200/// CLI output.
201pub fn format_instagram_post(post: &InstagramPost, index: usize) -> String {
202    let mut out = String::new();
203
204    // --- header --------------------------------------------------------------
205    out.push_str(&format!(
206        "--- Instagram Post #{} (@{}) ---\n",
207        index + 1,
208        post.owner_username
209    ));
210
211    // --- basic metadata ------------------------------------------------------
212    out.push_str(&format!("Type:        {}\n", post.post_type));
213    out.push_str(&format!("URL:         {}\n", post.url));
214
215    if let Some(ts) = post.timestamp {
216        out.push_str(&format!("Posted:      {}\n", ts.format("%Y-%m-%d %H:%M:%S UTC")));
217    }
218
219    out.push_str(&format!(
220        "Author:      {} (@{})\n",
221        post.owner_full_name,
222        post.owner_username
223    ));
224
225    if post.is_pinned == Some(true) {
226        out.push_str("Pinned:      yes\n");
227    }
228
229    out.push('\n');
230
231    // --- engagement ----------------------------------------------------------
232    out.push_str("Engagement:\n");
233
234    if let Some(likes) = post.likes_count {
235        out.push_str(&format!("  Likes:    {}\n", fmt_number(likes)));
236    }
237    out.push_str(&format!("  Comments: {}\n", fmt_number(post.comments_count)));
238
239    if let Some(views) = post.video_view_count {
240        out.push_str(&format!("  Views:    {}\n", fmt_number(views)));
241    }
242    if let Some(plays) = post.video_play_count {
243        out.push_str(&format!("  Plays:    {}\n", fmt_number(plays)));
244    }
245    if let Some(dur) = post.video_duration {
246        out.push_str(&format!("  Duration: {:.1}s\n", dur));
247    }
248    if !post.child_posts.is_empty() {
249        out.push_str(&format!(
250            "  Carousel: {} image{}\n",
251            post.child_posts.len(),
252            if post.child_posts.len() == 1 { "" } else { "s" }
253        ));
254    }
255
256    out.push('\n');
257
258    // --- caption -------------------------------------------------------------
259    if !post.caption.is_empty() {
260        out.push_str("Caption:\n");
261        for line in post.caption.lines() {
262            out.push_str(&format!("  {}\n", line));
263        }
264        out.push('\n');
265    }
266
267    // --- hashtags ------------------------------------------------------------
268    if !post.hashtags.is_empty() {
269        let joined: Vec<String> = post.hashtags.iter().map(|t| format!("#{}", t)).collect();
270        out.push_str(&format!("Hashtags: {}\n\n", joined.join(" ")));
271    }
272
273    // --- carousel images preview ---------------------------------------------
274    if !post.child_posts.is_empty() {
275        out.push_str("Carousel:\n");
276        for (i, child) in post.child_posts.iter().enumerate() {
277            out.push_str(&format!(
278                "  [{}] {}x{} — {}\n",
279                i + 1,
280                child.dimensions_width,
281                child.dimensions_height,
282                child.display_url
283            ));
284        }
285        out.push('\n');
286    }
287
288    // --- comments preview ----------------------------------------------------
289    if !post.latest_comments.is_empty() {
290        let max_show = 5_usize.min(post.latest_comments.len());
291        out.push_str(&format!(
292            "Comments ({} shown of {} total):\n",
293            max_show,
294            post.latest_comments.len()
295        ));
296        for (i, c) in post.latest_comments.iter().take(max_show).enumerate() {
297            out.push_str(&fmt_comment(c, i + 1, 0));
298        }
299        out.push('\n');
300    }
301
302    // --- music info ----------------------------------------------------------
303    if let Some(ref music) = post.music_info {
304        out.push_str(&format!(
305            "Music: {} — {}\n",
306            music.artist_name, music.song_name
307        ));
308        out.push('\n');
309    }
310
311    out.push('\n');
312    out
313}
314
315fn fmt_comment(c: &InstagramComment, idx: usize, depth: usize) -> String {
316    let indent = "  ".repeat(depth + 1);
317    let mut out = String::new();
318
319    let verified = if c.owner.is_verified { " [verified]" } else { "" };
320    let text = if c.text.trim().is_empty() {
321        "(empty comment)".to_string()
322    } else if c.text.len() > 150 {
323        format!("{}...", &c.text[..150])
324    } else {
325        c.text.clone()
326    };
327
328    out.push_str(&format!(
329        "{}#{}) @{}{}: \"{}\"\n",
330        indent, idx, c.owner_username, verified, text.replace('\n', " ")
331    ));
332
333    let mut meta = Vec::new();
334    if c.likes_count > 0 {
335        meta.push(format!("{} likes", fmt_number(c.likes_count)));
336    }
337    if c.replies_count > 0 {
338        meta.push(format!("{} repl{}", c.replies_count, if c.replies_count == 1 { "y" } else { "ies" }));
339    }
340    if !meta.is_empty() {
341        out.push_str(&format!("{}    ({})\n", indent, meta.join(" | ")));
342    }
343
344    for (i, reply) in c.replies.iter().enumerate() {
345        out.push_str(&fmt_comment(reply, i + 1, depth + 1));
346    }
347
348    out
349}
350
351/// Insert comma thousands-separators into a `u64`.
352fn fmt_number(n: u64) -> String {
353    let s = n.to_string();
354    s.as_bytes()
355        .rchunks(3)
356        .rev()
357        .map(|chunk| std::str::from_utf8(chunk).unwrap())
358        .collect::<Vec<_>>()
359        .join(",")
360}