github_trending_rs/
lib.rs1#![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#[derive(Clone, Debug)]
18pub struct Repository {
19 pub name: String,
20 pub owner: String,
21 pub description: String,
22 }
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
42pub 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
111pub 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 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}