1use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17#[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#[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#[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#[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#[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
195pub fn format_instagram_post(post: &InstagramPost, index: usize) -> String {
202 let mut out = String::new();
203
204 out.push_str(&format!(
206 "--- Instagram Post #{} (@{}) ---\n",
207 index + 1,
208 post.owner_username
209 ));
210
211 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 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 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 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 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 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 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
351fn 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}