1use regex::Regex;
2
3#[derive(Debug, Clone)]
38pub struct UrlRule {
39 #[allow(dead_code)]
40 pub name: String,
41 pub include_patterns: Vec<Regex>,
43 pub exclude_patterns: Vec<Regex>,
45 pub link_selector: Option<String>,
47}
48
49#[allow(dead_code)]
50impl UrlRule {
51 pub fn is_article_url(&self, url: &str) -> bool {
66 for ex in &self.exclude_patterns {
67 if ex.is_match(url) {
68 return false;
69 }
70 }
71 for pat in &self.include_patterns {
72 if pat.is_match(url) {
73 return true;
74 }
75 }
76 false
77 }
78
79 pub fn with_include(mut self, pattern: &str) -> Self {
89 self.include_patterns.push(Regex::new(pattern).expect("invalid regex"));
90 self
91 }
92
93 pub fn with_exclude(mut self, pattern: &str) -> Self {
103 self.exclude_patterns.push(Regex::new(pattern).expect("invalid regex"));
104 self
105 }
106
107 pub fn with_selector(mut self, selector: &str) -> Self {
119 self.link_selector = Some(selector.to_string());
120 self
121 }
122}
123
124fn default_include_patterns() -> Vec<Regex> {
125 vec![
126 Regex::new(r"/\d{4}/\d{2}/\d{2}/").unwrap(),
127 Regex::new(r"/\d{4}/\d{2}/").unwrap(),
128 Regex::new(r"\.(html|shtml|htm|cfm|asp|aspx|php)(?:\?|$)").unwrap(),
129 Regex::new(r"(?i)/news").unwrap(),
130 Regex::new(r"(?i)/articles?/").unwrap(),
131 Regex::new(r"(?i)/story/").unwrap(),
132 Regex::new(r"(?i)/blog").unwrap(),
133 Regex::new(r"(?i)/post").unwrap(),
134 Regex::new(r"(?i)/press").unwrap(),
135 Regex::new(r"(?i)/releases?/").unwrap(),
136 Regex::new(r"(?i)/hearings?").unwrap(),
137 Regex::new(r"(?i)/events?/").unwrap(),
138 Regex::new(r"(?i)/video/").unwrap(),
139 Regex::new(r"(?i)/media/").unwrap(),
140 Regex::new(r"(?i)/download/").unwrap(),
141 Regex::new(r"(?i)/document/").unwrap(),
142 Regex::new(r"(?i)/publication").unwrap(),
143 Regex::new(r"(?i)/research/").unwrap(),
144 Regex::new(r"(?i)/analysis/").unwrap(),
145 Regex::new(r"(?i)/commentary/").unwrap(),
146 Regex::new(r"(?i)/report").unwrap(),
147 Regex::new(r"(?i)/briefing").unwrap(),
148 Regex::new(r"(?i)/archives/").unwrap(),
149 Regex::new(r"(?i)/content/").unwrap(),
150 Regex::new(r"(?i)/detail/").unwrap(),
151 Regex::new(r"(?i)/show/").unwrap(),
152 Regex::new(r"(?i)/highlights?/").unwrap(),
153 Regex::new(r"(?i)/statuses/").unwrap(),
154 Regex::new(r"/p/\d+").unwrap(),
155 Regex::new(r"/a/\d+").unwrap(),
156 Regex::new(r"(?i)/node/").unwrap(),
157 Regex::new(r"(?i)/opa/").unwrap(),
158 Regex::new(r"(?i)/news-events/").unwrap(),
159 Regex::new(r"(?i)/profiles/").unwrap(),
160 ]
161}
162
163fn default_exclude_patterns() -> Vec<Regex> {
164 vec![
165 Regex::new(r"\.(css|js|json|xml|rss|ico|png|jpg|jpeg|gif|svg|webp|pdf|zip|rar|exe|dmg|mp4|mp3)$").unwrap(),
166 Regex::new(r"(?i)/(tag|tags|category|categories|author|page|login|register|logout|search|wp-content|wp-includes)/").unwrap(),
167 Regex::new(r"(javascript:|mailto:|tel:)").unwrap(),
168 Regex::new(r"#comment").unwrap(),
169 ]
170}
171
172impl Default for UrlRule {
173 fn default() -> Self {
174 Self {
175 name: "default".into(),
176 include_patterns: default_include_patterns(),
177 exclude_patterns: default_exclude_patterns(),
178 link_selector: None,
179 }
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_typical_article_urls() {
189 let rule = UrlRule::default();
190 let cases = vec![
191 "https://example.com/2024/01/15/hello-world",
192 "https://example.com/news/breaking-story",
193 "https://example.com/article/some-title",
194 "https://example.com/story/something",
195 "https://example.com/2024/01/15/hello-world.html",
196 "https://example.com/p/12345",
197 "https://example.com/a/67890",
198 "https://example.com/archives/123",
199 "https://example.com/detail/xyz",
200 "https://example.com/content/abc",
201 "https://example.com/show/abc",
202
203 ];
204 for url in cases {
205 assert!(rule.is_article_url(url), "should match: {url}");
206 }
207 }
208
209 #[test]
210 fn test_non_article_urls() {
211 let rule = UrlRule::default();
212 let cases = vec![
213 "https://example.com/style.css",
214 "https://example.com/app.js",
215 "https://example.com/image.jpg",
216 "https://example.com/tag/rust",
217 "https://example.com/categories/tech",
218 "https://example.com/login",
219 "https://example.com/register",
220 "https://example.com/search?q=news",
221 "javascript:void(0)",
222 "mailto:test@example.com",
223 ];
224 for url in cases {
225 assert!(!rule.is_article_url(url), "should reject: {url}");
226 }
227 }
228
229 #[test]
230 fn test_real_world_unmatched_urls() {
231 let rule = UrlRule::default();
232 let cases = vec![
233 "https://www.heritage.org/progressivism/commentary/the-state-escape",
234 "https://www.hoover.org/research/irans-nightmares",
235 "https://www.csis.org/analysis/great-sudan-policy-reset",
236 "https://www.cnas.org/publications/reports/russia-china-relations",
237 "https://fas.org/publication/talent-pipeline-for-the-clean-energy-transition/",
238 "https://www.nextgov.com/defense/2023/08/army-gets-new-prototypes/389037/",
239 "https://www.defense.gov/News/Releases/Release/Article/3496391/",
240 "https://www.justice.gov/opa/pr/final-defendant-sentenced",
241 "https://www.fcc.gov/document/cgb-announces-second-round-acp",
242 "https://www.transportation.gov/briefing-room/icymi-usdot-launches",
243 "https://www.state.gov/reports/country-reports-on-terrorism-2021/",
244 "https://www.finance.senate.gov/hearings/open-executive-session",
245 "https://www.judiciary.senate.gov/press/releases/durbin-statement",
246 "https://energycommerce.house.gov/posts/chair-rodgers-statement",
247 "https://trumpstruth.org/statuses/32029",
248 "https://www.darpa.mil/news-events/2023-08-15",
249 "https://cset.georgetown.edu/publication/china-science-ethics-guiding/",
250 "https://www.navy.mil/Press-Office/Press-Briefings/Article/3357084/",
251 "https://www.dol.gov/newsroom/releases/eta/eta20230817-0",
252 "https://foreignaffairs.house.gov/press-release/mccaul-requests-interview/",
253 ];
254 for url in cases {
255 assert!(rule.is_article_url(url), "should match: {url}");
256 }
257 }
258
259 #[test]
260 fn test_new_patterns_coverage() {
261 let rule = UrlRule::default();
262 let cases = vec![
263 "https://www.energy.gov/articles/biden-harris-announces",
264 "https://cyberscoop.com/video/understanding-the-economic-impact/",
265 "https://cyberscoop.com/event/microsoft-federal-innovation-series/",
266 "https://www.stimson.org/event/taiwans-economic-security/",
267 "https://www.atlanticcouncil.org/in-depth-research-reports/report/sanctioning-china/",
268 "https://www.disa.mil/en/NewsandEvents/2023/AbilityOne-Base-Supply-Center",
269 "https://science.osti.gov/bes/Highlights/2023/BES-2023-08-b",
270 "https://www.finance.senate.gov/download/122222-letter",
271 "https://www.afcea.org/signal/resources/linkreq.cfm?id=491",
272 "https://www.usda.gov/media/radio/daily-newsline/2023-08-16/actuality-opportunities",
273 ];
274 for url in cases {
275 assert!(rule.is_article_url(url), "should match: {url}");
276 }
277 }
278
279 #[test]
280 fn test_year_month_without_day() {
281 let rule = UrlRule::default();
282 assert!(rule.is_article_url("https://example.com/2024/03/some-article"));
283 assert!(rule.is_article_url("https://example.com/2023/08/another-one"));
284 }
285
286 #[test]
287 fn test_with_include_extension() {
288 let rule = UrlRule::default();
289 assert!(rule.is_article_url("https://example.com/blog/my-post"));
290 }
291
292 #[test]
293 fn test_with_exclude_override() {
294 let rule = UrlRule::default().with_exclude(r"print=true");
295 assert!(!rule.is_article_url("https://example.com/news/foo?print=true"));
296 }
297}