github_trending_rs/
lib.rs

1#![doc = include_str!("../README.md")]
2
3mod params;
4#[cfg(test)]
5mod tests;
6
7use std::marker::PhantomData;
8
9pub use params::{Language, Since, SpokenLanguage};
10
11use soupy::{Node, Queryable};
12use thiserror::Error;
13
14// TODO: trending developers?
15
16/// A trending github repository, may add more fields later
17#[derive(Clone, Debug)]
18pub struct Repository {
19    pub name: String,
20    pub owner: String,
21    pub description: String,
22    // TODO: extract more fields from response
23    // pub stars: usize,
24    // pub forks: usize,
25    // pub stars_since: usize,
26    // pub language: String,
27    // pub contributors: Vec<String>,
28}
29
30impl Repository {
31    pub fn url(&self) -> String {
32        format!("https://github.com/{}/{}", self.owner, self.name)
33    }
34}
35
36#[derive(Debug, Error)]
37pub enum Error {
38    #[error("Reqwest Error: {0}")]
39    ReqwestError(#[from] reqwest::Error),
40}
41
42/// Extend reqwest::Client with a method to create a TrendBuilder
43/// This trait is not meant to be general but to make API simplier
44/// since we don't require any additional information like api keys
45pub trait TrendExt {
46    fn github_trending(self) -> TrendingBuilder;
47}
48
49impl TrendExt for reqwest::Client {
50    fn github_trending(self) -> TrendingBuilder {
51        TrendingBuilder {
52            client: self,
53            lang: None,
54            spoken: None,
55            since: None,
56        }
57    }
58}
59
60pub struct TrendingBuilder {
61    client: reqwest::Client,
62    lang: Option<Language>,
63    spoken: Option<SpokenLanguage>,
64    since: Option<Since>,
65}
66
67impl TrendingBuilder {
68    pub fn with_language(self, lang: Language) -> Self {
69        Self {
70            lang: Some(lang),
71            ..self
72        }
73    }
74
75    pub fn since(self, since: Since) -> Self {
76        Self {
77            since: Some(since),
78            ..self
79        }
80    }
81
82    pub fn with_spoken_language(self, spoken_language: SpokenLanguage) -> Self {
83        Self {
84            spoken: Some(spoken_language),
85            ..self
86        }
87    }
88
89    pub async fn repositories(self) -> Result<Trending<Repository>, Error> {
90        let url = match self.lang {
91            Some(lang) => format!("https://github.com/trending/{}", lang.code()),
92            None => "https://github.com/trending".to_string(),
93        };
94        let req = self.client.get(url);
95        let req = match self.since {
96            Some(since) => req.query(&[("since", since.code())]),
97            None => req,
98        };
99        let req = match self.spoken {
100            Some(spoken) => req.query(&[("spoken_language_code", spoken.code())]),
101            None => req,
102        };
103        let resp = req.send().await?.text().await?;
104        Ok(Trending {
105            raw: resp,
106            _t: PhantomData,
107        })
108    }
109}
110
111/// trending information extraction interface
112/// the input text is the raw HTML text from the requesting response
113pub trait Extract: Sized {
114    fn extract(text: &str) -> Vec<Self>;
115}
116
117impl Extract for Repository {
118    fn extract(text: &str) -> Vec<Self> {
119        let soup = soupy::Soup::html(text);
120        soup.tag("article")
121            .into_iter()
122            .filter_map(|article| {
123                // any failed parse is silently discarded
124                let url = article.query().tag("h2").first().and_then(|item| {
125                    item.query()
126                        .tag("a")
127                        .first()
128                        .and_then(|item| item.get("href").cloned())
129                        .and_then(|url| {
130                            url.strip_prefix("/").and_then(|url| {
131                                url.split_once("/")
132                                    .map(|(owner, name)| (owner.to_string(), name.to_string()))
133                            })
134                        })
135                });
136                let desc = article
137                    .query()
138                    .tag("p")
139                    .first()
140                    .map(|item| item.all_text().trim().to_string());
141                url.zip(desc)
142                    .map(|((owner, name), description)| Repository {
143                        name,
144                        owner,
145                        description,
146                    })
147            })
148            .collect()
149    }
150}
151
152pub struct Trending<T: Extract> {
153    raw: String,
154    _t: PhantomData<T>,
155}
156
157impl<T: Extract> Trending<T> {
158    pub fn all(&self) -> Vec<T> {
159        T::extract(&self.raw)
160    }
161
162    pub fn raw(&self) -> &str {
163        &self.raw
164    }
165}