Skip to main content

twapi_v2/
models.rs

1use std::collections::HashMap;
2
3use chrono::prelude::*;
4use serde::Deserialize;
5
6pub type TweetModel = crate::api::get_2_tweets_id::Response;
7
8fn str_to_utc(src: &str) -> Option<DateTime<Utc>> {
9    DateTime::parse_from_str(src, "%a %b %d %T %z %Y")
10        .ok()
11        .map(|it| it.into())
12}
13
14fn millis_to_utc(millis: i64) -> Option<DateTime<Utc>> {
15    match Utc.timestamp_millis_opt(millis) {
16        chrono::LocalResult::Single(v) => Some(v),
17        _ => None,
18    }
19}
20
21fn from_v1_indicies(src: &serde_json::Value) -> (Option<i64>, Option<i64>) {
22    match src["indices"].as_array() {
23        Some(indices) => (
24            #[allow(clippy::get_first)]
25            indices.get(0).and_then(|it| it.as_i64()),
26            indices.get(1).and_then(|it| it.as_i64()),
27        ),
28        None => (None, None),
29    }
30}
31
32fn from_v1_menthions(src: &serde_json::Value) -> Option<Vec<crate::responses::mentions::Mentions>> {
33    match src.as_array() {
34        Some(targets) => {
35            if targets.is_empty() {
36                None
37            } else {
38                Some(
39                    targets
40                        .iter()
41                        .map(|it| {
42                            let indices = from_v1_indicies(it);
43                            crate::responses::mentions::Mentions {
44                                username: it["screen_name"].as_str().map(|it| it.to_owned()),
45                                id: it["id_str"].as_str().map(|it| it.to_owned()),
46                                start: indices.0,
47                                end: indices.1,
48                                ..Default::default()
49                            }
50                        })
51                        .collect(),
52                )
53            }
54        }
55        None => None,
56    }
57}
58
59fn from_v1_hashtags(src: &serde_json::Value) -> Option<Vec<crate::responses::hashtags::Hashtags>> {
60    match src.as_array() {
61        Some(targets) => {
62            if targets.is_empty() {
63                None
64            } else {
65                Some(
66                    targets
67                        .iter()
68                        .map(|it| {
69                            let indices = from_v1_indicies(it);
70                            crate::responses::hashtags::Hashtags {
71                                tag: it["text"].as_str().map(|it| it.to_owned()),
72                                start: indices.0,
73                                end: indices.1,
74                                ..Default::default()
75                            }
76                        })
77                        .collect(),
78                )
79            }
80        }
81        None => None,
82    }
83}
84
85fn from_v1_urls(src: &serde_json::Value) -> Vec<crate::responses::urls::Urls> {
86    match src.as_array() {
87        Some(targets) => targets
88            .iter()
89            .map(|it| {
90                let indices = from_v1_indicies(it);
91                crate::responses::urls::Urls {
92                    url: it["url"].as_str().map(|it| it.to_owned()),
93                    display_url: it["display_url"].as_str().map(|it| it.to_owned()),
94                    expanded_url: it["expanded_url"].as_str().map(|it| it.to_owned()),
95                    title: it["unwound"]["title"].as_str().map(|it| it.to_owned()),
96                    description: it["unwound"]["description"]
97                        .as_str()
98                        .map(|it| it.to_owned()),
99                    status: it["unwound"]["status"].as_i64(),
100                    start: indices.0,
101                    end: indices.1,
102                    ..Default::default()
103                }
104            })
105            .collect(),
106        None => vec![],
107    }
108}
109
110fn from_v1_edit_controls(
111    src: &serde_json::Value,
112) -> Option<crate::responses::edit_controls::EditControls> {
113    if src.is_object() {
114        Some(crate::responses::edit_controls::EditControls {
115            editable_until: millis_to_utc(
116                src["edit_controls"]["editable_until_ms"]
117                    .as_i64()
118                    .unwrap_or_default(),
119            ),
120            edits_remaining: src["edit_controls"]["edits_remaining"].as_i64(),
121            is_edit_eligible: src["editable"].as_bool(),
122            ..Default::default()
123        })
124    } else {
125        None
126    }
127}
128
129fn from_v1_edit_history_tweet_ids(src: &serde_json::Value) -> Vec<String> {
130    if let Some(value) = src.as_array() {
131        value
132            .iter()
133            .map(|v| v.as_str().unwrap_or_default().to_owned())
134            .collect()
135    } else {
136        vec![]
137    }
138}
139
140fn from_v1_media_key(src: &serde_json::Value) -> String {
141    let media_type = match src["type"].as_str() {
142        // gifもwebpもここに落ちた。
143        Some("photo") => "3",
144        Some("video") => "7",
145        Some("animated_gif") => "16",
146        _ => "0",
147    };
148    format!(
149        "{}_{}",
150        media_type,
151        src["id_str"].as_str().unwrap_or_default()
152    )
153}
154
155fn from_v1_media_url(
156    src: &serde_json::Value,
157    media_map: &mut HashMap<String, crate::responses::media::Media>,
158) -> (
159    Vec<crate::responses::urls::Urls>,
160    Option<crate::responses::attachments::Attachments>,
161) {
162    let mut media_keys = vec![];
163
164    let urls = if let Some(targets) = src.as_array() {
165        targets
166            .iter()
167            .map(|it| {
168                let indices = from_v1_indicies(it);
169                let id = it["id_str"].as_str().unwrap_or_default().to_owned();
170                let media_key = if let Some(media) = media_map.get(&id) {
171                    if let Some(media_key) = &media.media_key {
172                        media_key.clone()
173                    } else {
174                        from_v1_media_key(it)
175                    }
176                } else {
177                    from_v1_media_key(it)
178                };
179                media_keys.push(media_key.clone());
180                crate::responses::urls::Urls {
181                    url: it["url"].as_str().map(|it| it.to_owned()),
182                    display_url: it["display_url"].as_str().map(|it| it.to_owned()),
183                    expanded_url: it["expanded_url"].as_str().map(|it| it.to_owned()),
184                    media_key: Some(media_key),
185                    start: indices.0,
186                    end: indices.1,
187                    ..Default::default()
188                }
189            })
190            .collect()
191    } else {
192        vec![]
193    };
194
195    let attachments = if !media_keys.is_empty() {
196        Some(crate::responses::attachments::Attachments {
197            media_keys: Some(media_keys),
198            ..Default::default()
199        })
200    } else {
201        None
202    };
203    (urls, attachments)
204}
205
206fn from_v1_entities(
207    src: &serde_json::Value,
208    media_map: &mut HashMap<String, crate::responses::media::Media>,
209) -> (
210    Option<crate::responses::entities::Entities>,
211    Option<crate::responses::attachments::Attachments>,
212) {
213    if src.is_object() {
214        let (mut urls1, attachments) = from_v1_media_url(&src["media"], media_map);
215        let mut urls2 = from_v1_urls(&src["urls"]);
216        urls1.append(&mut urls2);
217        (
218            Some(crate::responses::entities::Entities {
219                mentions: from_v1_menthions(&src["user_mentions"]),
220                hashtags: from_v1_hashtags(&src["hashtags"]),
221                urls: Some(urls1),
222                ..Default::default()
223            }),
224            attachments,
225        )
226    } else {
227        (None, None)
228    }
229}
230
231#[derive(Deserialize)]
232struct BoundingBox {
233    #[allow(dead_code)]
234    r#type: String,
235    coordinates: Vec<Vec<Vec<f64>>>,
236}
237
238impl BoundingBox {
239    fn coordinates(src: &serde_json::Value) -> Vec<f64> {
240        match serde_json::from_value::<BoundingBox>(src["bounding_box"].clone()) {
241            Ok(it) => {
242                let mut x0 = it.coordinates[0][0][0];
243                let mut y0 = it.coordinates[0][0][1];
244                let mut x1 = it.coordinates[0][0][0];
245                let mut y1 = it.coordinates[0][0][1];
246                for i in 1..4 {
247                    if x0 > it.coordinates[0][i][0] {
248                        x0 = it.coordinates[0][i][0];
249                    }
250                    if x1 < it.coordinates[0][i][0] {
251                        x1 = it.coordinates[0][i][0];
252                    }
253                    if y0 > it.coordinates[0][i][1] {
254                        y0 = it.coordinates[0][i][1];
255                    }
256                    if y1 < it.coordinates[0][i][1] {
257                        y1 = it.coordinates[0][i][1];
258                    }
259                }
260                vec![x0, y0, x1, y1]
261            }
262            Err(_) => vec![],
263        }
264    }
265}
266
267fn from_v1_place(
268    src: &serde_json::Value,
269) -> (
270    Option<crate::responses::places::Places>,
271    Option<crate::responses::geo::Geo>,
272) {
273    if src.is_object() {
274        let place_geo = crate::responses::geo::Geo {
275            r#type: Some("Feature".to_owned()),
276            bbox: Some(BoundingBox::coordinates(src)),
277            ..Default::default()
278        };
279        let place = crate::responses::places::Places {
280            id: src["id"].as_str().unwrap_or_default().to_owned(),
281            full_name: src["full_name"].as_str().unwrap_or_default().to_owned(),
282            country: src["country"].as_str().map(|it| it.to_owned()),
283            country_code: src["country_code"].as_str().map(|it| it.to_owned()),
284            name: src["name"].as_str().map(|it| it.to_owned()),
285            place_type: src["place_type"].as_str().map(|it| it.to_owned()),
286            geo: Some(place_geo),
287            ..Default::default()
288        };
289        let geo = crate::responses::geo::Geo {
290            place_id: Some(place.id.clone()),
291            ..Default::default()
292        };
293        (Some(place), Some(geo))
294    } else {
295        (None, None)
296    }
297}
298
299fn from_v1_public_metrics(
300    src: &serde_json::Value,
301) -> Option<crate::responses::public_metrics::PublicMetrics> {
302    Some(crate::responses::public_metrics::PublicMetrics {
303        retweet_count: src["retweet_count"].as_i64(),
304        quote_count: src["quote_count"].as_i64(),
305        reply_count: src["reply_count"].as_i64(),
306        like_count: src["favorite_count"].as_i64(),
307        ..Default::default()
308    })
309}
310
311fn from_v1_users(src: &serde_json::Value) -> crate::responses::users::Users {
312    let public_metrics = crate::responses::users::PublicMetrics {
313        followers_count: src["followers_count"].as_i64(),
314        following_count: src["friends_count"].as_i64(),
315        tweet_count: src["statuses_count"].as_i64(),
316        listed_count: src["listed_count"].as_i64(),
317        ..Default::default()
318    };
319
320    crate::responses::users::Users {
321        created_at: str_to_utc(src["created_at"].as_str().unwrap_or_default()),
322        description: src["description"].as_str().map(|it| it.to_owned()),
323        id: src["id_str"].as_str().unwrap_or_default().to_owned(),
324        location: src["location"].as_str().map(|it| it.to_owned()),
325        name: src["name"].as_str().unwrap_or_default().to_owned(),
326        profile_image_url: src["profile_image_url_https"]
327            .as_str()
328            .map(|it| it.to_owned()),
329        protected: src["protected"].as_bool(),
330        public_metrics: Some(public_metrics),
331        url: src["url"].as_str().map(|it| it.to_owned()),
332        username: src["screen_name"].as_str().unwrap_or_default().to_owned(),
333        verified: src["verified"].as_bool(),
334        verified_type: src["verified_type"].as_str().map(|it| it.to_owned()),
335        ..Default::default()
336    }
337}
338
339fn from_v1_exetend_entities_media_size(src: &serde_json::Value) -> (Option<i64>, Option<i64>) {
340    if let Some(sizes) = src["medium"].as_object() {
341        (sizes["w"].as_i64(), sizes["h"].as_i64())
342    } else if let Some(sizes) = src["small"].as_object() {
343        (sizes["w"].as_i64(), sizes["h"].as_i64())
344    } else if let Some(sizes) = src["large"].as_object() {
345        (sizes["w"].as_i64(), sizes["h"].as_i64())
346    } else if let Some(sizes) = src["thumb"].as_object() {
347        (sizes["w"].as_i64(), sizes["h"].as_i64())
348    } else {
349        (None, None)
350    }
351}
352
353fn from_v1_variants(src: &serde_json::Value) -> Option<Vec<crate::responses::variants::Variants>> {
354    src["video_info"]["variants"].as_array().map(|targets| {
355        targets
356            .iter()
357            .map(|it| crate::responses::variants::Variants {
358                bit_rate: it["bitrate"].as_i64(),
359                content_type: it["content_type"].as_str().map(|it| it.to_owned()),
360                url: it["url"].as_str().map(|it| it.to_owned()),
361                ..Default::default()
362            })
363            .collect()
364    })
365}
366
367fn from_v1_exetend_entities_media(
368    src: &serde_json::Value,
369    media_map: &mut HashMap<String, crate::responses::media::Media>,
370) {
371    if let Some(targets) = src["extended_entities"]["media"].as_array() {
372        targets.iter().for_each(|it| {
373            let (width, height) = from_v1_exetend_entities_media_size(&it["sizes"]);
374            let media = crate::responses::media::Media {
375                duration_ms: it["video_info"]["duration_millis"].as_i64(),
376                media_key: Some(from_v1_media_key(it)),
377                width,
378                height,
379                preview_image_url: it["media_url_https"].as_str().map(|it| it.to_owned()),
380                r#type: it["type"].as_str().map(|it| it.to_owned()),
381                variants: from_v1_variants(it),
382                ..Default::default()
383            };
384            media_map.insert(it["id_str"].as_str().unwrap_or_default().to_owned(), media);
385        })
386    }
387}
388
389fn from_v1_tweets(
390    src: &serde_json::Value,
391    tweet_map: &mut HashMap<String, crate::responses::tweets::Tweets>,
392    user_map: &mut HashMap<String, crate::responses::users::Users>,
393    place_map: &mut HashMap<String, crate::responses::places::Places>,
394    media_map: &mut HashMap<String, crate::responses::media::Media>,
395) -> Option<crate::responses::tweets::Tweets> {
396    if src.is_object() {
397        // extend_entities/media
398        // entitiesのURLのmedia_keyを先に計算する必要があるのでここでやる
399        from_v1_exetend_entities_media(src, media_map);
400
401        let (entities, attachments) = from_v1_entities(&src["entities"], media_map);
402        let (places, geo) = from_v1_place(&src["place"]);
403
404        let mut data = crate::responses::tweets::Tweets {
405            id: src["id_str"].as_str().unwrap_or_default().to_owned(),
406            text: src["text"].as_str().unwrap_or_default().to_owned(),
407            attachments,
408            source: src["source"].as_str().map(|it| it.to_owned()),
409            author_id: src["user"]["id_str"].as_str().map(|it| it.to_owned()),
410            conversation_id: src["edit_history"]["initial_tweet_id"]
411                .as_str()
412                .map(|it| it.to_owned()),
413            created_at: str_to_utc(src["created_at"].as_str().unwrap_or_default()),
414            edit_controls: from_v1_edit_controls(src),
415            edit_history_tweet_ids: Some(from_v1_edit_history_tweet_ids(
416                &src["edit_history"]["edit_tweet_ids"],
417            )),
418            entities,
419            geo,
420            in_reply_to_user_id: src["in_reply_to_user_id_str"]
421                .as_str()
422                .map(|it| it.to_owned()),
423            lang: src["lang"].as_str().map(|it| it.to_owned()),
424            possibly_sensitive: src["possibly_sensitive"].as_bool(),
425            public_metrics: from_v1_public_metrics(src),
426            ..Default::default()
427        };
428
429        if let Some(places) = places {
430            place_map.insert(places.id.clone(), places);
431        }
432
433        let mut referenced_tweets = vec![];
434
435        if src["retweeted_status"].is_object() {
436            if let Some(tweet) = from_v1_tweets(
437                &src["retweeted_status"],
438                tweet_map,
439                user_map,
440                place_map,
441                media_map,
442            ) {
443                referenced_tweets.push(crate::responses::referenced_tweets::ReferencedTweets {
444                    id: Some(tweet.id.clone()),
445                    r#type: Some(crate::responses::referenced_tweets::Type::Retweeted),
446                    ..Default::default()
447                });
448                tweet_map.insert(tweet.id.clone(), tweet);
449            }
450        }
451
452        if src["quoted_status"].is_object() {
453            if let Some(tweet) = from_v1_tweets(
454                &src["quoted_status"],
455                tweet_map,
456                user_map,
457                place_map,
458                media_map,
459            ) {
460                // 引用をリポストされると、両方入ってくる。しかしこの引用はリポスト元の引用になる。
461                // よって、このポストの関連ポストにしない。
462                if !src["retweeted_status"].is_object() {
463                    referenced_tweets.push(crate::responses::referenced_tweets::ReferencedTweets {
464                        id: Some(tweet.id.clone()),
465                        r#type: Some(crate::responses::referenced_tweets::Type::Quoted),
466                        ..Default::default()
467                    });
468                }
469                tweet_map.insert(tweet.id.clone(), tweet);
470            }
471        }
472
473        // リプライ
474        // リプライのリツイートはこの値がセットされていない
475        if let Some(id) = src["in_reply_to_status_id_str"].as_str() {
476            referenced_tweets.push(crate::responses::referenced_tweets::ReferencedTweets {
477                id: Some(id.to_owned()),
478                r#type: Some(crate::responses::referenced_tweets::Type::RepliedTo),
479                ..Default::default()
480            });
481        }
482
483        if !referenced_tweets.is_empty() {
484            data.referenced_tweets = Some(referenced_tweets);
485        }
486
487        let user = from_v1_users(&src["user"]);
488        user_map.insert(user.id.clone(), user);
489
490        // TODO : 最後に自分自身を関連にいれるか検討。入れなくて良いと思うがV2はそういう構造になっている
491
492        Some(data)
493    } else {
494        None
495    }
496}
497
498impl TweetModel {
499    pub fn from_v1(src: &serde_json::Value) -> Self {
500        let mut tweet_map = HashMap::new();
501        let mut user_map = HashMap::new();
502        let mut place_map = HashMap::new();
503        let mut media_map = HashMap::new();
504        let data = from_v1_tweets(
505            src,
506            &mut tweet_map,
507            &mut user_map,
508            &mut place_map,
509            &mut media_map,
510        );
511        Self {
512            data,
513            includes: Some(crate::responses::includes::Includes {
514                tweets: if tweet_map.is_empty() {
515                    None
516                } else {
517                    Some(tweet_map.into_values().collect())
518                },
519                users: if user_map.is_empty() {
520                    None
521                } else {
522                    Some(user_map.into_values().collect())
523                },
524                places: if place_map.is_empty() {
525                    None
526                } else {
527                    Some(place_map.into_values().collect())
528                },
529                media: if media_map.is_empty() {
530                    None
531                } else {
532                    Some(media_map.into_values().collect())
533                },
534                ..Default::default()
535            }),
536            ..Default::default()
537        }
538    }
539}