1use std::collections::HashMap;
39
40use scraper::{ElementRef, Html, Selector};
41use url::Url;
42
43use crate::rules::UrlRule;
44
45#[derive(Debug, Clone)]
60pub struct ExtractedLink {
61 pub url: String,
63 pub text: String,
65 pub score: f32,
67 pub cluster_size: usize,
69}
70
71#[derive(Debug, Clone)]
102pub struct ExtractorConfig {
103 pub score_threshold: f32,
105 pub cluster_depth: usize,
108 pub cluster_min_size: usize,
110 pub min_text_len: usize,
112 pub use_url_rule: bool,
114 pub non_article_paths: Vec<String>,
116}
117
118fn default_non_article_paths() -> Vec<String> {
119 vec![
120 "/people/", "/team/", "/about/", "/contact/",
121 "/careers/", "/staff/", "/leadership/", "/board/",
122 "/privacy", "/join", "/copyright", "/terms", "/subscribe",
123 "/accessibility", "/press-inquiries", "/internships",
124 "/jurisdiction", "/committee-", "/subcommittee-",
125 "/chairman-", "/ranking-member", "/whistleblower",
126 "/sitemap", "/faq", "/donate", "/support-",
127 ]
128 .into_iter()
129 .map(String::from)
130 .collect()
131}
132
133impl Default for ExtractorConfig {
134 fn default() -> Self {
135 Self {
136 score_threshold: 1.5,
137 cluster_depth: 4,
138 cluster_min_size: 4,
139 min_text_len: 8,
140 use_url_rule: true,
141 non_article_paths: default_non_article_paths(),
142 }
143 }
144}
145
146pub struct LinkExtractor {
174 config: ExtractorConfig,
175 url_rule: UrlRule,
176}
177
178struct Candidate {
179 url: String,
180 text: String,
181 path_sig: String,
182 href_raw: String,
183}
184
185impl LinkExtractor {
186 pub fn new(config: ExtractorConfig) -> Self {
196 Self {
197 config,
198 url_rule: UrlRule::default(),
199 }
200 }
201
202 pub fn with_url_rule(mut self, rule: UrlRule) -> Self {
212 self.url_rule = rule;
213 self
214 }
215
216 pub fn extract(&self, html: &str, base_url: &str) -> Vec<ExtractedLink> {
244 let document = Html::parse_document(html);
245 let a_selector = Selector::parse("a[href]").expect("valid selector");
246 let base = Url::parse(base_url).ok();
247 let base_host = base.as_ref().and_then(|u| u.host_str()).unwrap_or("");
248
249 let mut candidates: Vec<Candidate> = Vec::new();
250
251 for a in document.select(&a_selector) {
252 let href = match a.value().attr("href") {
253 Some(h) => h.trim(),
254 None => continue,
255 };
256 if href.is_empty() || href.starts_with('#') {
257 continue;
258 }
259 if href.starts_with("javascript:") || href.starts_with("mailto:") || href.starts_with("tel:") {
260 continue;
261 }
262
263 let resolved = resolve_url(&base, href);
264 let text = clean_text(&a.text().collect::<Vec<_>>().join(" "));
265 let path_sig = dom_path_signature(a, self.config.cluster_depth);
266
267 candidates.push(Candidate {
268 url: resolved,
269 text,
270 path_sig,
271 href_raw: href.to_string(),
272 });
273 }
274
275 let mut cluster_counts: HashMap<String, usize> = HashMap::new();
276 for c in &candidates {
277 *cluster_counts.entry(c.path_sig.clone()).or_insert(0) += 1;
278 }
279
280 let mut best_by_url: HashMap<String, ExtractedLink> = HashMap::new();
281
282 for c in &candidates {
283 let cluster_size = *cluster_counts.get(&c.path_sig).unwrap_or(&0);
284 let score = self.score_candidate(c, cluster_size, base_host);
285
286 best_by_url
287 .entry(c.url.clone())
288 .and_modify(|entry| {
289 if c.text.chars().count() > entry.text.chars().count() {
290 entry.text = c.text.clone();
291 }
292 entry.score = entry.score.max(score);
293 entry.cluster_size = entry.cluster_size.max(cluster_size);
294 })
295 .or_insert_with(|| ExtractedLink {
296 url: c.url.clone(),
297 text: c.text.clone(),
298 score,
299 cluster_size,
300 });
301 }
302
303 let mut results: Vec<ExtractedLink> = best_by_url
304 .into_values()
305 .filter(|l| l.score >= self.config.score_threshold)
306 .collect();
307
308 results.sort_by(|a, b| {
309 b.score
310 .partial_cmp(&a.score)
311 .unwrap_or(std::cmp::Ordering::Equal)
312 });
313 results
314 }
315
316 fn score_candidate(&self, c: &Candidate, cluster_size: usize, base_host: &str) -> f32 {
329 let mut score = 0.0f32;
330
331 let text_len = c.text.chars().count();
332 if text_len >= self.config.min_text_len {
333 score += 1.0;
334 if text_len <= 120 {
335 score += 0.5;
336 }
337 } else if text_len == 0 {
338 score -= 0.5;
339 }
340
341 if cluster_size >= self.config.cluster_min_size {
342 score += 1.5;
343 } else if cluster_size >= 2 {
344 score += 0.5;
345 }
346
347 if self.config.use_url_rule && self.url_rule.is_article_url(&c.url) {
348 score += 0.5;
349 }
350
351 let path_segments = c
352 .url
353 .split('/')
354 .skip(3)
355 .filter(|s| !s.is_empty())
356 .count();
357 if path_segments >= 2 {
358 score += 0.3;
359 } else {
360 score -= 1.0;
361 }
362
363 let lower_text = c.text.to_lowercase();
364 const NAV_WORDS: [&str; 12] = [
365 "home", "login", "sign in", "sign up", "subscribe", "next", "prev",
366 "previous", "more", "更多", "首页", "登录",
367 ];
368 if NAV_WORDS.iter().any(|w| lower_text == *w) {
369 score -= 1.5;
370 }
371
372 if c.href_raw.contains("?page=") || c.href_raw.contains("&page=") {
373 score -= 2.0;
374 }
375 if !c.text.trim().is_empty() && c.text.trim().chars().all(|c| c.is_ascii_digit()) {
376 score -= 1.5;
377 }
378
379 if let Ok(url) = Url::parse(&c.url)
380 && let Some(host) = url.host_str()
381 && !host.is_empty() && host != base_host {
382 score -= 2.0;
383 }
384
385 if self.config.non_article_paths.iter().any(|p| c.url.contains(p)) {
386 score -= 2.0;
387 }
388
389 score
390 }
391}
392
393fn resolve_url(base: &Option<Url>, href: &str) -> String {
394 match base {
395 Some(b) => b
396 .join(href).map_or_else(|_| href.to_string(), |u| u.to_string()),
397 None => href.to_string(),
398 }
399}
400
401fn clean_text(s: &str) -> String {
402 s.split_whitespace().collect::<Vec<_>>().join(" ")
403}
404
405fn dom_path_signature(a: ElementRef, depth: usize) -> String {
406 let mut parts = Vec::new();
407 let mut current = a.parent();
408 let mut d = 0;
409
410 while let Some(node) = current {
411 if d >= depth {
412 break;
413 }
414 if let Some(el) = ElementRef::wrap(node) {
415 let tag = el.value().name();
416 if tag == "html" || tag == "body" {
417 break;
418 }
419 let mut classes: Vec<&str> = el.value().classes().collect();
420 classes.sort_unstable();
421 let class_part = if classes.is_empty() {
422 String::new()
423 } else {
424 format!(".{}", classes.join("."))
425 };
426 parts.push(format!("{tag}{class_part}"));
427 }
428 current = node.parent();
429 d += 1;
430 }
431
432 parts.reverse();
433 parts.join(">")
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 #[test]
441 fn test_extracts_repeated_list_items_over_scattered_nav_links() {
442 let html = r#"
443 <html><body>
444 <nav>
445 <a href="/">首页</a>
446 <a href="/login">登录</a>
447 </nav>
448 <div class="news-list">
449 <ul>
450 <li><a href="/2024/01/15/story-one">这是第一条新闻标题</a></li>
451 <li><a href="/2024/01/16/story-two">这是第二条新闻标题</a></li>
452 <li><a href="/2024/01/17/story-three">这是第三条新闻标题</a></li>
453 <li><a href="/2024/01/18/story-four">这是第四条新闻标题</a></li>
454 <li><a href="/2024/01/19/story-five">这是第五条新闻标题</a></li>
455 </ul>
456 </div>
457 </body></html>
458 "#;
459
460 let extractor = LinkExtractor::new(ExtractorConfig::default());
461 let results = extractor.extract(html, "https://example.com/");
462 let urls: Vec<&str> = results.iter().map(|l| l.url.as_str()).collect();
463
464 assert!(urls.iter().any(|u| u.contains("story-one")));
465 assert!(urls.iter().any(|u| u.contains("story-five")));
466 assert!(!urls.iter().any(|u| u.ends_with("/login")));
467 }
468
469 #[test]
470 fn test_short_nav_text_is_penalized() {
471 let html = r#"
472 <html><body>
473 <a href="/next">Next</a>
474 <a href="/article/full-title-of-a-real-article">完整的一篇真实文章标题在这里</a>
475 </body></html>
476 "#;
477 let extractor = LinkExtractor::new(ExtractorConfig::default());
478 let results = extractor.extract(html, "https://example.com/");
479 assert!(results[0].url.contains("full-title-of-a-real-article"));
480 }
481
482 #[test]
483 fn test_relative_url_resolution() {
484 let html = r#"<html><body><a href="/news/foo">新闻标题足够长这样才算数</a></body></html>"#;
485 let extractor = LinkExtractor::new(ExtractorConfig::default());
486 let results = extractor.extract(html, "https://example.com/section/");
487 assert_eq!(results[0].url, "https://example.com/news/foo");
488 }
489
490 #[test]
491 fn test_slug_only_site_without_regex_hints() {
492 let html = r#"
493 <html><body>
494 <div class="views-row"><a href="/health-policy/making-a-deposit/">Making a Deposit in Health Policy</a></div>
495 <div class="views-row"><a href="/economics/inflation-outlook/">The Inflation Outlook</a></div>
496 <div class="views-row"><a href="/foreign-policy/china-strategy/">Rethinking China Strategy</a></div>
497 <div class="views-row"><a href="/education/school-choice/">The Case for School Choice</a></div>
498 </body></html>
499 "#;
500 let extractor = LinkExtractor::new(ExtractorConfig {
501 use_url_rule: false,
502 ..ExtractorConfig::default()
503 });
504 let results = extractor.extract(html, "https://www.aei.org/");
505 assert!(results.iter().any(|l| l.url.contains("making-a-deposit")));
506 assert!(results.iter().any(|l| l.url.contains("china-strategy")));
507 }
508
509 #[test]
510 fn test_with_url_rule_boosts_score() {
511 let html = r#"<html><body><a href="/2024/01/15/some-story">一篇独立的新闻文章标题</a></body></html>"#;
512 let with_rule = LinkExtractor::new(ExtractorConfig::default());
513 let without_rule = LinkExtractor::new(ExtractorConfig {
514 use_url_rule: false,
515 ..ExtractorConfig::default()
516 });
517 let r1 = with_rule.extract(html, "https://example.com/");
518 let r2 = without_rule.extract(html, "https://example.com/");
519 assert!(r1[0].score > r2[0].score);
520 }
521}