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 if let Some(host) = url.host_str() {
381 if !host.is_empty() && host != base_host {
382 score -= 2.0;
383 }
384 }
385 }
386
387 if self.config.non_article_paths.iter().any(|p| c.url.contains(p)) {
388 score -= 2.0;
389 }
390
391 score
392 }
393}
394
395fn resolve_url(base: &Option<Url>, href: &str) -> String {
396 match base {
397 Some(b) => b
398 .join(href)
399 .map(|u| u.to_string())
400 .unwrap_or_else(|_| href.to_string()),
401 None => href.to_string(),
402 }
403}
404
405fn clean_text(s: &str) -> String {
406 s.split_whitespace().collect::<Vec<_>>().join(" ")
407}
408
409fn dom_path_signature(a: ElementRef, depth: usize) -> String {
410 let mut parts = Vec::new();
411 let mut current = a.parent();
412 let mut d = 0;
413
414 while let Some(node) = current {
415 if d >= depth {
416 break;
417 }
418 if let Some(el) = ElementRef::wrap(node) {
419 let tag = el.value().name();
420 if tag == "html" || tag == "body" {
421 break;
422 }
423 let mut classes: Vec<&str> = el.value().classes().collect();
424 classes.sort_unstable();
425 let class_part = if classes.is_empty() {
426 String::new()
427 } else {
428 format!(".{}", classes.join("."))
429 };
430 parts.push(format!("{tag}{class_part}"));
431 }
432 current = node.parent();
433 d += 1;
434 }
435
436 parts.reverse();
437 parts.join(">")
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn test_extracts_repeated_list_items_over_scattered_nav_links() {
446 let html = r#"
447 <html><body>
448 <nav>
449 <a href="/">首页</a>
450 <a href="/login">登录</a>
451 </nav>
452 <div class="news-list">
453 <ul>
454 <li><a href="/2024/01/15/story-one">这是第一条新闻标题</a></li>
455 <li><a href="/2024/01/16/story-two">这是第二条新闻标题</a></li>
456 <li><a href="/2024/01/17/story-three">这是第三条新闻标题</a></li>
457 <li><a href="/2024/01/18/story-four">这是第四条新闻标题</a></li>
458 <li><a href="/2024/01/19/story-five">这是第五条新闻标题</a></li>
459 </ul>
460 </div>
461 </body></html>
462 "#;
463
464 let extractor = LinkExtractor::new(ExtractorConfig::default());
465 let results = extractor.extract(html, "https://example.com/");
466 let urls: Vec<&str> = results.iter().map(|l| l.url.as_str()).collect();
467
468 assert!(urls.iter().any(|u| u.contains("story-one")));
469 assert!(urls.iter().any(|u| u.contains("story-five")));
470 assert!(!urls.iter().any(|u| u.ends_with("/login")));
471 }
472
473 #[test]
474 fn test_short_nav_text_is_penalized() {
475 let html = r#"
476 <html><body>
477 <a href="/next">Next</a>
478 <a href="/article/full-title-of-a-real-article">完整的一篇真实文章标题在这里</a>
479 </body></html>
480 "#;
481 let extractor = LinkExtractor::new(ExtractorConfig::default());
482 let results = extractor.extract(html, "https://example.com/");
483 assert!(results[0].url.contains("full-title-of-a-real-article"));
484 }
485
486 #[test]
487 fn test_relative_url_resolution() {
488 let html = r#"<html><body><a href="/news/foo">新闻标题足够长这样才算数</a></body></html>"#;
489 let extractor = LinkExtractor::new(ExtractorConfig::default());
490 let results = extractor.extract(html, "https://example.com/section/");
491 assert_eq!(results[0].url, "https://example.com/news/foo");
492 }
493
494 #[test]
495 fn test_slug_only_site_without_regex_hints() {
496 let html = r#"
497 <html><body>
498 <div class="views-row"><a href="/health-policy/making-a-deposit/">Making a Deposit in Health Policy</a></div>
499 <div class="views-row"><a href="/economics/inflation-outlook/">The Inflation Outlook</a></div>
500 <div class="views-row"><a href="/foreign-policy/china-strategy/">Rethinking China Strategy</a></div>
501 <div class="views-row"><a href="/education/school-choice/">The Case for School Choice</a></div>
502 </body></html>
503 "#;
504 let extractor = LinkExtractor::new(ExtractorConfig {
505 use_url_rule: false,
506 ..ExtractorConfig::default()
507 });
508 let results = extractor.extract(html, "https://www.aei.org/");
509 assert!(results.iter().any(|l| l.url.contains("making-a-deposit")));
510 assert!(results.iter().any(|l| l.url.contains("china-strategy")));
511 }
512
513 #[test]
514 fn test_with_url_rule_boosts_score() {
515 let html = r#"<html><body><a href="/2024/01/15/some-story">一篇独立的新闻文章标题</a></body></html>"#;
516 let with_rule = LinkExtractor::new(ExtractorConfig::default());
517 let without_rule = LinkExtractor::new(ExtractorConfig {
518 use_url_rule: false,
519 ..ExtractorConfig::default()
520 });
521 let r1 = with_rule.extract(html, "https://example.com/");
522 let r2 = without_rule.extract(html, "https://example.com/");
523 assert!(r1[0].score > r2[0].score);
524 }
525}