1use regex::Regex;
7use scraper::{ElementRef, Html, Selector};
8use serde::{Deserialize, Serialize};
9use lazy_static::lazy_static;
10use std::collections::HashSet;
11
12use crate::types::ParserResult;
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct ContactInfo {
21 pub emails: Vec<Email>,
22 pub phones: Vec<Phone>,
23 pub addresses: Vec<Address>,
24 pub social_links: Vec<SocialLink>,
25 pub business_name: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Email {
31 pub address: String,
32 pub label: Option<String>,
33 pub source: EmailSource,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39#[derive(Default)]
40pub enum EmailSource {
41 MailtoLink,
43 #[default]
45 PageText,
46 MetaTag,
48 StructuredData,
50}
51
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct Phone {
56 pub number: String,
57 pub normalized: String,
58 pub label: Option<String>,
59 pub phone_type: PhoneType,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[derive(Default)]
66pub enum PhoneType {
67 Landline,
69 Mobile,
71 Fax,
73 TollFree,
75 #[default]
77 Unknown,
78}
79
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Address {
84 pub raw: String,
85 pub street: Option<String>,
86 pub city: Option<String>,
87 pub state: Option<String>,
88 pub postal_code: Option<String>,
89 pub country: Option<String>,
90 pub coordinates: Option<Coordinates>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Coordinates {
96 pub latitude: f64,
97 pub longitude: f64,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SocialLink {
103 pub url: String,
104 pub platform: SocialPlatform,
105 pub username: Option<String>,
106 pub label: Option<String>,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum SocialPlatform {
113 Facebook,
114 Twitter,
115 LinkedIn,
116 Instagram,
117 YouTube,
118 GitHub,
119 TikTok,
120 Pinterest,
121 Snapchat,
122 Reddit,
123 Telegram,
124 WhatsApp,
125 WeChat,
126 Weibo,
127 Other,
128}
129
130lazy_static! {
135 static ref EMAIL_RE: Regex = Regex::new(
137 r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}"
138 ).expect("无效的邮箱正则");
139
140 static ref PHONE_RE: Regex = Regex::new(
142 r"(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}(?:\s*(?:分机|ext|x|#)\s*\d+)?\b"
143 ).expect("无效的电话正则");
144
145 static ref TOLLFREE_RE: Regex = Regex::new(
147 r"(?:\+?1[-.\s]?)?(?:8(?:00|88|77|66|55|44|33))[-.\s]?\d{3}[-.\s]?\d{4}"
148 ).expect("无效的免费热线正则");
149
150 static ref CN_MOBILE_RE: Regex = Regex::new(
152 r"1[3-9]\d{9}"
153 ).expect("无效的中国手机正则");
154
155 static ref CN_LANDLINE_RE: Regex = Regex::new(
157 r"0\d{2,3}[-.\s]?\d{7,8}"
158 ).expect("无效的中国固话正则");
159
160 static ref POSTAL_CODE_RE: Regex = Regex::new(
162 r"\b\d{5}(?:-\d{4})?\b"
163 ).expect("无效的邮编正则");
164
165 static ref CN_POSTAL_CODE_RE: Regex = Regex::new(
167 r"\b\d{6}\b"
168 ).expect("无效的中国邮编正则");
169
170 static ref SOCIAL_URL_RE: Regex = Regex::new(
172 r"(?:https?://)?(?:www\.)?(?:facebook|twitter|x|linkedin|instagram|youtube|github|tiktok|pinterest|snapchat|reddit|t\.me|wa\.me|weixin|weibo)\.(?:com|me|io|tv|cn)/(?:[a-zA-Z0-9_.\-]+/?)+"
173 ).expect("无效的社交链接正则");
174
175 static ref CONTACT_PAGE_RE: Regex = Regex::new(
177 r"(?i)(?:contact|about|support|联系|关于我们|客服)(?:[-_./]?(?:us|me|info|page|support))?"
178 ).expect("无效的联系页面正则");
179}
180
181pub fn extract_contact_info(html: &str) -> ParserResult<ContactInfo> {
187 let document = Html::parse_document(html);
188 let emails = extract_emails(&document);
189 let phones = extract_phones(&document);
190 let addresses = extract_addresses(&document);
191 let social_links = extract_social_links(&document);
192 let business_name = extract_business_name(&document);
193
194 Ok(ContactInfo {
195 emails,
196 phones,
197 addresses,
198 social_links,
199 business_name,
200 })
201}
202
203pub fn extract_emails(document: &Html) -> Vec<Email> {
205 let mut emails: Vec<Email> = Vec::new();
206 let mut seen = HashSet::new();
207
208 if let Ok(sel) = Selector::parse(r#"a[href^="mailto:"]"#) {
210 for element in document.select(&sel) {
211 if let Some(href) = element.value().attr("href") {
212 let address = href.trim_start_matches("mailto:").trim();
213 if is_valid_email(address) && seen.insert(address.to_string()) {
214 let label = element.text().collect::<String>().trim().to_string();
215 emails.push(Email {
216 address: address.to_string(),
217 label: if label.is_empty() || label == address { None } else { Some(label) },
218 source: EmailSource::MailtoLink,
219 });
220 }
221 }
222 }
223 }
224
225 if let Ok(sel) = Selector::parse(r#"meta[name="email"], meta[property="email"]"#) {
227 for element in document.select(&sel) {
228 if let Some(content) = element.value().attr("content")
229 && is_valid_email(content.trim()) && seen.insert(content.trim().to_string()) {
230 emails.push(Email {
231 address: content.trim().to_string(),
232 label: None,
233 source: EmailSource::MetaTag,
234 });
235 }
236 }
237 }
238
239 if let Ok(sel) = Selector::parse("body") {
241 for element in document.select(&sel) {
242 let text = element.text().collect::<String>();
243 for cap in EMAIL_RE.captures_iter(&text) {
244 let address = cap[0].to_string();
245 if is_valid_email(&address) && seen.insert(address.clone()) {
246 emails.push(Email {
247 address,
248 label: None,
249 source: EmailSource::PageText,
250 });
251 }
252 }
253 }
254 }
255
256 emails
257}
258
259pub fn is_valid_email(email: &str) -> bool {
261 let email = email.trim();
262
263 if !EMAIL_RE.is_match(email) {
265 return false;
266 }
267
268 let invalid_prefixes = [
270 "example@", "test@", "admin@", "info@",
271 "noreply@", "no-reply@", "donotreply@",
272 ];
273 for prefix in &invalid_prefixes {
274 if email.to_lowercase().starts_with(prefix) {
275 return false;
276 }
277 }
278
279 let placeholder_domains = [
281 "example.com", "example.org", "example.net",
282 "domain.com", "domain.org", "test.com",
283 "localhost", "local",
284 ];
285 let parts: Vec<&str> = email.split('@').collect();
286 if parts.len() == 2 {
287 let domain = parts[1].to_lowercase();
288 for placeholder in &placeholder_domains {
289 if domain == *placeholder {
290 return false;
291 }
292 }
293 let domain_parts: Vec<&str> = domain.split('.').collect();
295 if domain_parts.len() > 3 || domain_parts.last().is_some_and(|tld| tld.len() > 8) {
296 return false;
297 }
298 }
299
300 true
301}
302
303pub fn extract_email_label(document: &Html, _address: &str) -> Option<String> {
305 if let Ok(sel) = Selector::parse(&format!(r#"a[href="mailto:{_address}"]"#)) {
307 for element in document.select(&sel) {
308 let text = element.text().collect::<String>().trim().to_string();
309 if !text.is_empty() && text != _address {
310 return Some(text);
311 }
312 let mut parent = element.parent();
314 while let Some(p) = parent {
315 if let Some(parent_ref) = ElementRef::wrap(p) {
316 let parent_text = parent_ref.text().collect::<String>();
317 let cleaned: String = parent_text
318 .chars()
319 .filter(|c| !c.is_whitespace())
320 .collect();
321 if !cleaned.is_empty() && cleaned != _address {
322 return Some(parent_text.trim().to_string());
323 }
324 }
325 parent = p.parent();
326 }
327 }
328 }
329 None
330}
331
332pub fn extract_phones(document: &Html) -> Vec<Phone> {
338 let mut phones: Vec<Phone> = Vec::new();
339 let mut seen = HashSet::new();
340
341 if let Ok(sel) = Selector::parse(r#"a[href^="tel:"]"#) {
343 for element in document.select(&sel) {
344 if let Some(href) = element.value().attr("href") {
345 let number = href.trim_start_matches("tel:").trim().to_string();
346 let normalized = normalize_phone(&number);
347 if !number.is_empty() && seen.insert(normalized.clone()) {
348 let label = element.text().collect::<String>().trim().to_string();
349 let phone_type = detect_phone_type(&number);
350 phones.push(Phone {
351 number: number.clone(),
352 normalized,
353 label: if label.is_empty() || label == number { None } else { Some(label) },
354 phone_type,
355 });
356 }
357 }
358 }
359 }
360
361 if let Ok(sel) = Selector::parse("body") {
363 for element in document.select(&sel) {
364 let text = element.text().collect::<String>();
365
366 for cap in TOLLFREE_RE.captures_iter(&text) {
368 let number = cap[0].to_string();
369 let normalized = normalize_phone(&number);
370 if seen.insert(normalized.clone()) {
371 phones.push(Phone {
372 number,
373 normalized,
374 label: None,
375 phone_type: PhoneType::TollFree,
376 });
377 }
378 }
379
380 for cap in PHONE_RE.captures_iter(&text) {
382 let number = cap[0].to_string();
383 let normalized = normalize_phone(&number);
384 if seen.insert(normalized.clone()) {
385 let phone_type = detect_phone_type(&number);
386 phones.push(Phone {
387 number,
388 normalized,
389 label: None,
390 phone_type,
391 });
392 }
393 }
394 }
395 }
396
397 phones
398}
399
400pub fn extract_phone_label(document: &Html, _number: &str) -> Option<String> {
402 if let Ok(sel) = Selector::parse(&format!(r#"a[href="tel:{_number}"]"#)) {
403 for element in document.select(&sel) {
404 let text = element.text().collect::<String>().trim().to_string();
405 if !text.is_empty() && text != _number {
406 return Some(text);
407 }
408 }
409 }
410 None
411}
412
413pub fn detect_phone_type(number: &str) -> PhoneType {
415 let cleaned = number.chars().filter(char::is_ascii_digit).collect::<String>();
416
417 if cleaned.len() >= 10 && cleaned.len() <= 12 {
419 let digits = cleaned.as_str();
420 let first_three = if digits.starts_with('1') && digits.len() >= 11 {
421 &digits[1..4]
422 } else {
423 &digits[..3]
424 };
425 match first_three {
426 "800" | "888" | "877" | "866" | "855" | "844" | "833" => return PhoneType::TollFree,
427 _ => {}
428 }
429 }
430
431 if cleaned.len() == 11 && cleaned.starts_with('1') {
433 let second = cleaned.chars().nth(1).unwrap_or('0');
434 if ('3'..='9').contains(&second) {
435 return PhoneType::Mobile;
436 }
437 }
438
439 if cleaned.starts_with('0') && cleaned.len() >= 10 && cleaned.len() <= 12 {
441 return PhoneType::Landline;
442 }
443
444 PhoneType::Unknown
445}
446
447fn normalize_phone(number: &str) -> String {
449 number.chars().filter(char::is_ascii_digit).collect()
450}
451
452pub fn extract_addresses(document: &Html) -> Vec<Address> {
458 let mut addresses: Vec<Address> = Vec::new();
459
460 let structured = extract_structured_address(document);
462 addresses.extend(structured);
463
464 if let Ok(sel) = Selector::parse(
466 r#"[itemtype*="PostalAddress"], address, .address, .adr, .location, .contact-address"#
467 ) {
468 for element in document.select(&sel) {
469 let raw = element.text().collect::<String>().trim().to_string();
470 if !raw.is_empty() && !addresses.iter().any(|a| a.raw == raw) {
471 let components = extract_address_components(&raw);
472 addresses.push(Address {
473 raw,
474 ..components
475 });
476 }
477 }
478 }
479
480 if addresses.is_empty()
482 && let Ok(sel) = Selector::parse("body") {
483 for element in document.select(&sel) {
484 let text = element.text().collect::<String>();
485 let lower = text.to_lowercase();
486
487 let keywords = [
489 "street", "road", "avenue", "boulevard", "lane", "drive",
490 "street", "路", "街", "大道", "巷", "弄",
491 "邮编", "postal code", "zip code", "zip",
492 ];
493 let has_addr_keyword = keywords.iter().any(|k| lower.contains(k));
494
495 let has_postal = POSTAL_CODE_RE.is_match(&text)
497 || CN_POSTAL_CODE_RE.is_match(&text);
498
499 if has_addr_keyword && has_postal {
500 let lines: Vec<&str> = text.lines()
501 .filter(|l| {
502 let lt = l.trim().to_lowercase();
503 keywords.iter().any(|k| lt.contains(k))
504 || POSTAL_CODE_RE.is_match(l)
505 || CN_POSTAL_CODE_RE.is_match(l)
506 })
507 .collect();
508 for line in lines {
509 let raw = line.trim().to_string();
510 if !raw.is_empty() && !addresses.iter().any(|a| a.raw == raw) {
511 let components = extract_address_components(&raw);
512 addresses.push(Address { raw, ..components });
513 }
514 }
515 }
516 }
517 }
518
519 addresses
520}
521
522pub fn extract_structured_address(document: &Html) -> Vec<Address> {
524 let mut addresses: Vec<Address> = Vec::new();
525
526 if let Ok(sel) = Selector::parse(r#"[itemscope][itemtype*="PostalAddress"]"#) {
528 for element in document.select(&sel) {
529 let raw = element.text().collect::<String>().trim().to_string();
530 if raw.is_empty() {
531 continue;
532 }
533
534 let extract_prop = |prop: &str| -> Option<String> {
535 let query = format!(r#"[itemprop="{prop}"]"#);
536 if let Ok(inner_sel) = Selector::parse(&query) {
537 for inner in element.select(&inner_sel) {
538 let val = inner.text().collect::<String>().trim().to_string();
539 if !val.is_empty() {
540 if let Some(content) = inner.value().attr("content")
542 && !content.is_empty() {
543 return Some(content.to_string());
544 }
545 return Some(val);
546 }
547 }
548 }
549 None
550 };
551
552 let street = extract_prop("streetAddress");
553 let city = extract_prop("addressLocality");
554 let state = extract_prop("addressRegion");
555 let postal_code = extract_prop("postalCode");
556 let country = extract_prop("addressCountry");
557
558 let coords = (|| -> Option<Coordinates> {
560 let geo_sel = Selector::parse(r#"[itemscope][itemtype*="GeoCoordinates"]"#).ok()?;
561 let geo = element.select(&geo_sel).next()?;
562
563 let lat_sel = Selector::parse(r#"[itemprop="latitude"]"#).ok()?;
564 let lat = geo.select(&lat_sel).next().and_then(|e| {
565 if let Some(c) = e.value().attr("content") {
566 c.parse::<f64>().ok()
567 } else {
568 let text = e.text().collect::<String>();
569 text.trim().parse::<f64>().ok()
570 }
571 })?;
572
573 let lng_sel = Selector::parse(r#"[itemprop="longitude"]"#).ok()?;
574 let lng = geo.select(&lng_sel).next().and_then(|e| {
575 if let Some(c) = e.value().attr("content") {
576 c.parse::<f64>().ok()
577 } else {
578 let text = e.text().collect::<String>();
579 text.trim().parse::<f64>().ok()
580 }
581 })?;
582
583 Some(Coordinates { latitude: lat, longitude: lng })
584 })();
585
586 addresses.push(Address {
587 raw,
588 street,
589 city,
590 state,
591 postal_code,
592 country,
593 coordinates: coords,
594 });
595 }
596 }
597
598 addresses
599}
600
601pub fn extract_address_components(raw: &str) -> Address {
603 let text = raw.trim();
604
605 let postal_code = POSTAL_CODE_RE.find(text)
607 .or_else(|| CN_POSTAL_CODE_RE.find(text))
608 .map(|m| m.as_str().to_string());
609
610 Address {
611 raw: text.to_string(),
612 street: if text.len() < 200 { Some(text.to_string()) } else { None },
613 city: extract_field_between(text, &["city", "城市", "市", "city/state"], ':'),
614 state: extract_field_between(text, &["state", "province", "省", "州"], ':'),
615 postal_code,
616 country: extract_field_between(text, &["country", "国家", "country/region"], ':'),
617 coordinates: None,
618 }
619}
620
621fn extract_field_between(text: &str, labels: &[&str], delimiter: char) -> Option<String> {
623 let lower = text.to_lowercase();
624 for label in labels {
625 let label_lower = label.to_lowercase();
626 if let Some(pos) = lower.find(&label_lower) {
627 let after_tag = &text[pos + label.len()..].trim();
628 if let Some(delim_pos) = after_tag.find(delimiter) {
629 let value = after_tag[delim_pos + 1..].trim().to_string();
630 if !value.is_empty() {
631 let value = value.lines().next().unwrap_or("").trim().to_string();
633 if !value.is_empty() && value.len() < 100 {
634 return Some(value);
635 }
636 }
637 }
638 }
639 }
640 None
641}
642
643pub fn extract_social_links(document: &Html) -> Vec<SocialLink> {
649 let mut links: Vec<SocialLink> = Vec::new();
650 let mut seen = HashSet::new();
651
652 if let Ok(sel) = Selector::parse("a[href]") {
654 for element in document.select(&sel) {
655 if let Some(href) = element.value().attr("href") {
656 let trimmed = href.trim();
657 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("javascript:") {
658 continue;
659 }
660
661 let platform = detect_social_platform(trimmed);
662 if platform == SocialPlatform::Other {
663 continue;
664 }
665
666 if seen.insert(trimmed.to_string()) {
667 let username = extract_social_username(trimmed, platform);
668 let label = element.text().collect::<String>().trim().to_string();
669 links.push(SocialLink {
670 url: trimmed.to_string(),
671 platform,
672 username,
673 label: if label.is_empty() { None } else { Some(label) },
674 });
675 }
676 }
677 }
678 }
679
680 for attr in &["me", "url", "social"] {
682 if let Ok(sel) = Selector::parse(&format!(r#"link[rel="{attr}"], a[rel="{attr}"]"#)) {
683 for element in document.select(&sel) {
684 if let Some(href) = element.value().attr("href") {
685 let platform = detect_social_platform(href);
686 if platform != SocialPlatform::Other && seen.insert(href.to_string()) {
687 let username = extract_social_username(href, platform);
688 links.push(SocialLink {
689 url: href.to_string(),
690 platform,
691 username,
692 label: None,
693 });
694 }
695 }
696 }
697 }
698 }
699
700 links
701}
702
703pub fn detect_social_platform(url: &str) -> SocialPlatform {
705 let lower = url.to_lowercase();
706
707 if lower.contains("facebook") || lower.contains("fb.com") {
708 SocialPlatform::Facebook
709 } else if lower.contains("twitter") || lower.contains("x.com") {
710 SocialPlatform::Twitter
711 } else if lower.contains("linkedin") {
712 SocialPlatform::LinkedIn
713 } else if lower.contains("instagram") {
714 SocialPlatform::Instagram
715 } else if lower.contains("youtube") || lower.contains("youtu.be") {
716 SocialPlatform::YouTube
717 } else if lower.contains("github") {
718 SocialPlatform::GitHub
719 } else if lower.contains("tiktok") {
720 SocialPlatform::TikTok
721 } else if lower.contains("pinterest") {
722 SocialPlatform::Pinterest
723 } else if lower.contains("snapchat") {
724 SocialPlatform::Snapchat
725 } else if lower.contains("reddit") {
726 SocialPlatform::Reddit
727 } else if lower.contains("t.me") || lower.contains("telegram") {
728 SocialPlatform::Telegram
729 } else if lower.contains("wa.me") || lower.contains("whatsapp") {
730 SocialPlatform::WhatsApp
731 } else if lower.contains("weixin") || lower.contains("wechat") {
732 SocialPlatform::WeChat
733 } else if lower.contains("weibo") {
734 SocialPlatform::Weibo
735 } else {
736 SocialPlatform::Other
737 }
738}
739
740pub fn extract_social_username(url: &str, platform: SocialPlatform) -> Option<String> {
742 let url = url.trim_end_matches('/');
743
744 match platform {
745 SocialPlatform::Twitter | SocialPlatform::Facebook | SocialPlatform::Instagram
746 | SocialPlatform::TikTok | SocialPlatform::Pinterest | SocialPlatform::Snapchat => {
747 let parts: Vec<&str> = url.split('/').collect();
749 let last = parts.iter().rev().find(|s| !s.is_empty() && **s != "www" && !s.contains('.'))?;
750 let username = last.trim_start_matches('@').to_string();
751 if !username.is_empty() && !username.contains('#') && !username.contains('?') {
752 Some(username)
753 } else {
754 None
755 }
756 }
757 SocialPlatform::LinkedIn => {
758 let parts: Vec<&str> = url.split('/').collect();
760 if let Some(idx) = parts.iter().position(|s| *s == "in" || *s == "company") {
761 parts.get(idx + 1).map(std::string::ToString::to_string)
762 } else {
763 None
764 }
765 }
766 SocialPlatform::GitHub => {
767 let parts: Vec<&str> = url.split('/').collect();
769 parts.get(3).map(std::string::ToString::to_string).filter(|s| !s.is_empty())
770 }
771 SocialPlatform::Telegram => {
772 let parts: Vec<&str> = url.split('/').collect();
774 let last = parts.last()?;
775 if *last != "s" && !last.is_empty() {
776 Some(last.to_string())
777 } else {
778 None
779 }
780 }
781 SocialPlatform::YouTube | SocialPlatform::Reddit
782 | SocialPlatform::WhatsApp | SocialPlatform::WeChat
783 | SocialPlatform::Weibo | SocialPlatform::Other => None,
784 }
785}
786
787pub fn find_contact_page(document: &Html) -> Option<String> {
793 if let Ok(sel) = Selector::parse("a[href]") {
795 for element in document.select(&sel) {
796 if let Some(href) = element.value().attr("href") {
797 let href = href.trim();
798 let text = element.text().collect::<String>();
799 if CONTACT_PAGE_RE.is_match(href) || CONTACT_PAGE_RE.is_match(&text) {
800 return Some(href.to_string());
801 }
802 }
803 }
804 }
805
806 if let Ok(sel) = Selector::parse("nav a[href], header a[href], .nav a[href]") {
808 for element in document.select(&sel) {
809 if let Some(href) = element.value().attr("href") {
810 let href = href.trim();
811 let text = element.text().collect::<String>();
812 if CONTACT_PAGE_RE.is_match(href) || CONTACT_PAGE_RE.is_match(&text) {
813 return Some(href.to_string());
814 }
815 }
816 }
817 }
818
819 None
820}
821
822pub fn extract_business_name(document: &Html) -> Option<String> {
824 if let Ok(sel) = Selector::parse(r#"[itemscope][itemtype*="Organization"] [itemprop="name"]"#) {
826 for element in document.select(&sel) {
827 if let Some(content) = element.value().attr("content") {
828 let name = content.trim();
829 if !name.is_empty() {
830 return Some(name.to_string());
831 }
832 }
833 let text = element.text().collect::<String>().trim().to_string();
834 if !text.is_empty() {
835 return Some(text);
836 }
837 }
838 }
839
840 if let Ok(sel) = Selector::parse(r#"meta[property="og:site_name"]"#) {
842 for element in document.select(&sel) {
843 if let Some(content) = element.value().attr("content") {
844 let name = content.trim();
845 if !name.is_empty() {
846 return Some(name.to_string());
847 }
848 }
849 }
850 }
851
852 if let Ok(sel) = Selector::parse("title") {
854 for element in document.select(&sel) {
855 let title = element.text().collect::<String>();
856 let title = title.trim();
857 if !title.is_empty() {
858 for sep in &[" | ", " - ", " – ", " — ", " |", " -"] {
860 if let Some(name) = title.split(sep).next() {
861 let name = name.trim();
862 if !name.is_empty() && name.len() < 80 {
863 return Some(name.to_string());
864 }
865 }
866 }
867 return Some(title.to_string());
868 }
869 }
870 }
871
872 None
873}
874
875pub fn has_contact_info(html: &str) -> bool {
881 let document = Html::parse_document(html);
882 !extract_emails(&document).is_empty()
883 || !extract_phones(&document).is_empty()
884 || !extract_social_links(&document).is_empty()
885}
886
887pub fn get_emails(html: &str) -> Vec<String> {
889 let document = Html::parse_document(html);
890 extract_emails(&document)
891 .into_iter()
892 .map(|e| e.address)
893 .collect()
894}
895
896pub fn get_phones(html: &str) -> Vec<String> {
898 let document = Html::parse_document(html);
899 extract_phones(&document)
900 .into_iter()
901 .map(|p| p.number)
902 .collect()
903}
904
905pub fn get_social_links(html: &str) -> Vec<(String, SocialPlatform)> {
907 let document = Html::parse_document(html);
908 extract_social_links(&document)
909 .into_iter()
910 .map(|s| (s.url, s.platform))
911 .collect()
912}
913
914#[cfg(test)]
919mod tests {
920 use super::*;
921
922 #[test]
923 fn test_contact_info_empty() {
924 let info = extract_contact_info("<body></body>").unwrap();
925 assert!(info.emails.is_empty() && info.phones.is_empty() && info.addresses.is_empty());
926 }
927
928 #[test]
929 fn test_contact_info_full() {
930 let html = r#"<html><head><meta property="og:site_name" content="测试公司"><meta name="email" content="contact@realcompany.com"></head><body><a href="mailto:info@realcompany.com">联系我们</a><a href="tel:+8613800138000">客服热线</a><a href="https://twitter.com/testcorp">Twitter</a></body></html>"#;
931 let info = extract_contact_info(html).unwrap();
932 assert!(!info.emails.is_empty() && !info.phones.is_empty() && !info.social_links.is_empty());
933 assert_eq!(info.business_name.as_deref(), Some("测试公司"));
934 }
935
936 #[test]
937 fn test_extract_emails_from_mailto() {
938 let doc = Html::parse_document(r#"<a href="mailto:hello@realcompany.com">Send email</a>"#);
939 let emails = extract_emails(&doc);
940 assert_eq!(emails.len(), 1);
941 assert_eq!(emails[0].address, "hello@realcompany.com");
942 assert_eq!(emails[0].source, EmailSource::MailtoLink);
943 assert_eq!(emails[0].label.as_deref(), Some("Send email"));
944 }
945
946 #[test]
947 fn test_extract_emails_from_meta_and_text() {
948 let doc = Html::parse_document(r#"<meta name="email" content="contact@realcompany.com"><body>support@realcompany.com</body>"#);
949 let emails = extract_emails(&doc);
950 assert_eq!(emails.len(), 2);
951 assert!(emails.iter().any(|e| e.source == EmailSource::MetaTag));
952 assert!(emails.iter().any(|e| e.source == EmailSource::PageText));
953 }
954
955 #[test]
956 fn test_extract_emails_dedup_and_filter() {
957 let doc = Html::parse_document(r#"<html><head><meta name="email" content="dup@realcompany.com"></head><body><a href="mailto:dup@realcompany.com">Email</a><p>dup@realcompany.com</p><!-- placeholder should be filtered --><p>example@example.com</p></body></html>"#);
958 let emails = extract_emails(&doc);
959 assert_eq!(emails.len(), 1);
960 assert_eq!(emails[0].address, "dup@realcompany.com");
961 }
962
963 #[test]
964 fn test_is_valid_email() {
965 assert!(is_valid_email("user@realcompany.com") && is_valid_email("user.name+tag@domain.co.uk"));
966 assert!(!is_valid_email("example@example.com") && !is_valid_email("notanemail") && !is_valid_email("user@localhost"));
967 }
968
969 #[test]
970 fn test_extract_email_label() {
971 let doc = Html::parse_document(r#"<a href="mailto:contact@co.com">业务合作</a>"#);
972 assert_eq!(extract_email_label(&doc, "contact@co.com").as_deref(), Some("业务合作"));
973 }
974
975 #[test]
976 fn test_extract_phones_from_tel_and_text() {
977 let doc = Html::parse_document(r#"<a href="tel:+86-10-8888-6666">电话</a><body>Call 1-800-555-0199</body>"#);
978 let phones = extract_phones(&doc);
979 assert!(!phones.is_empty());
980 assert!(phones[0].number.contains("+86"));
981 assert_eq!(phones[0].label.as_deref(), Some("电话"));
982 }
983
984 #[test]
985 fn test_detect_phone_types() {
986 assert_eq!(detect_phone_type("1-800-555-0199"), PhoneType::TollFree);
987 assert_eq!(detect_phone_type("13800138000"), PhoneType::Mobile);
988 assert_eq!(detect_phone_type("010-8888-6666"), PhoneType::Landline);
989 assert_eq!(detect_phone_type("12345"), PhoneType::Unknown);
990 }
991
992 #[test]
993 fn test_extract_phone_label() {
994 let doc = Html::parse_document(r#"<a href="tel:+8613800138000">客服</a>"#);
995 assert_eq!(extract_phone_label(&doc, "+8613800138000").as_deref(), Some("客服"));
996 }
997
998 #[test]
999 fn test_extract_structured_address_microdata() {
1000 let html = r#"<div itemscope itemtype="http://schema.org/PostalAddress"><span itemprop="streetAddress">123 Main St</span><span itemprop="addressLocality">Springfield</span><span itemprop="addressRegion">IL</span><span itemprop="postalCode">62701</span><span itemprop="addressCountry">US</span></div>"#;
1001 let doc = Html::parse_document(html);
1002 let addrs = extract_structured_address(&doc);
1003 assert_eq!(addrs.len(), 1);
1004 assert_eq!(addrs[0].street.as_deref(), Some("123 Main St"));
1005 assert_eq!(addrs[0].city.as_deref(), Some("Springfield"));
1006 assert_eq!(addrs[0].state.as_deref(), Some("IL"));
1007 assert_eq!(addrs[0].postal_code.as_deref(), Some("62701"));
1008 }
1009
1010 #[test]
1011 fn test_extract_address_from_element() {
1012 let doc = Html::parse_document(r"<address>北京朝阳区建国路88号 邮编:100025</address>");
1013 let addrs = extract_addresses(&doc);
1014 assert!(!addrs.is_empty() && addrs[0].raw.contains("北京"));
1015 }
1016
1017 #[test]
1018 fn test_extract_address_components() {
1019 let a = extract_address_components("123 Main St, Springfield, IL 62701 US");
1020 assert_eq!(a.postal_code.as_deref(), Some("62701"));
1021 let b = extract_address_components("北京市海淀区中关村大街1号 邮编:100086");
1022 assert_eq!(b.postal_code.as_deref(), Some("100086"));
1023 }
1024
1025 #[test]
1026 fn test_extract_structured_address_with_coordinates() {
1027 let html = r#"<div itemscope itemtype="http://schema.org/PostalAddress"><span itemprop="streetAddress">1600 Amphitheatre Pkwy</span><span itemprop="addressLocality">Mountain View</span><span itemprop="addressRegion">CA</span><span itemprop="postalCode">94043</span><div itemscope itemtype="http://schema.org/GeoCoordinates"><meta itemprop="latitude" content="37.422"><meta itemprop="longitude" content="-122.084"></div></div>"#;
1028 let doc = Html::parse_document(html);
1029 let addrs = extract_structured_address(&doc);
1030 let coords = addrs[0].coordinates.as_ref().unwrap();
1031 assert!((coords.latitude - 37.422).abs() < 0.001);
1032 assert!((coords.longitude + 122.084).abs() < 0.001);
1033 }
1034
1035 #[test]
1036 fn test_extract_social_links() {
1037 let doc = Html::parse_document(r#"<a href="https://twitter.com/testuser">Twitter</a><a href="https://github.com/testuser">GitHub</a><a href="https://linkedin.com/in/testuser">LinkedIn</a>"#);
1038 let links = extract_social_links(&doc);
1039 assert_eq!(links.len(), 3);
1040 assert_eq!(links[0].platform, SocialPlatform::Twitter);
1041 }
1042
1043 #[test]
1044 fn test_extract_social_links_dedup() {
1045 let doc = Html::parse_document(r#"<a href="https://twitter.com/user">A</a><a href="https://twitter.com/user">B</a>"#);
1046 assert_eq!(extract_social_links(&doc).len(), 1);
1047 }
1048
1049 #[test]
1050 fn test_detect_social_platforms() {
1051 assert_eq!(detect_social_platform("https://facebook.com/u"), SocialPlatform::Facebook);
1052 assert_eq!(detect_social_platform("https://x.com/u"), SocialPlatform::Twitter);
1053 assert_eq!(detect_social_platform("https://linkedin.com/in/u"), SocialPlatform::LinkedIn);
1054 assert_eq!(detect_social_platform("https://t.me/u"), SocialPlatform::Telegram);
1055 assert_eq!(detect_social_platform("https://example.com"), SocialPlatform::Other);
1056 }
1057
1058 #[test]
1059 fn test_extract_social_username() {
1060 assert_eq!(extract_social_username("https://twitter.com/elonmusk", SocialPlatform::Twitter), Some("elonmusk".to_string()));
1061 assert_eq!(extract_social_username("https://github.com/rust-lang", SocialPlatform::GitHub), Some("rust-lang".to_string()));
1062 assert_eq!(extract_social_username("https://linkedin.com/in/johndoe", SocialPlatform::LinkedIn), Some("johndoe".to_string()));
1063 assert_eq!(extract_social_username("https://t.me/rustlang", SocialPlatform::Telegram), Some("rustlang".to_string()));
1064 assert_eq!(extract_social_username("https://instagram.com/natgeo", SocialPlatform::Instagram), Some("natgeo".to_string()));
1065 assert_eq!(extract_social_username("https://twitter.com/@user", SocialPlatform::Twitter), Some("user".to_string()));
1066 assert!(extract_social_username("https://youtube.com/user", SocialPlatform::YouTube).is_none());
1067 }
1068
1069 #[test]
1070 fn test_find_contact_page() {
1071 let doc = Html::parse_document(r#"<a href="/contact">Contact Us</a>"#);
1072 assert_eq!(find_contact_page(&doc).as_deref(), Some("/contact"));
1073 let doc2 = Html::parse_document(r#"<a href="/about/contact">联系我们</a>"#);
1074 assert_eq!(find_contact_page(&doc2).as_deref(), Some("/about/contact"));
1075 let doc3 = Html::parse_document(r#"<a href="/products">Products</a>"#);
1076 assert!(find_contact_page(&doc3).is_none());
1077 }
1078
1079 #[test]
1080 fn test_extract_business_name() {
1081 let doc1 = Html::parse_document(r#"<meta property="og:site_name" content="ACME 公司">"#);
1082 assert_eq!(extract_business_name(&doc1).as_deref(), Some("ACME 公司"));
1083 let doc2 = Html::parse_document("<title>ACME Corp | 创新科技</title>");
1084 assert_eq!(extract_business_name(&doc2).as_deref(), Some("ACME Corp"));
1085 let doc3 = Html::parse_document(r#"<div itemscope itemtype="http://schema.org/Organization"><span itemprop="name">大数据有限公司</span></div>"#);
1086 assert_eq!(extract_business_name(&doc3).as_deref(), Some("大数据有限公司"));
1087 let doc4 = Html::parse_document("<html><head></head><body></body></html>");
1088 assert!(extract_business_name(&doc4).is_none());
1089 }
1090
1091 #[test]
1092 fn test_convenience_functions() {
1093 assert!(has_contact_info(r#"<a href="mailto:a@b.com">Email</a>"#));
1094 assert!(!has_contact_info("<body>nothing</body>"));
1095
1096 let html = r#"<a href="mailto:a@b.com">A</a><a href="mailto:c@d.com">C</a>"#;
1097 assert_eq!(get_emails(html).len(), 2);
1098
1099 let html2 = r#"<a href="tel:+8613800138000">M</a><a href="tel:+8613900139000">M2</a>"#;
1100 assert_eq!(get_phones(html2).len(), 2);
1101
1102 let html3 = r#"<a href="https://twitter.com/u1">T</a><a href="https://github.com/u2">G</a>"#;
1103 assert_eq!(get_social_links(html3).len(), 2);
1104 }
1105
1106 #[test]
1107 fn test_extract_phones_deduplication() {
1108 let doc = Html::parse_document(r#"<a href="tel:+8613800138000">Phone</a><body>13800138000</body>"#);
1109 let phones = extract_phones(&doc);
1110 assert_eq!(phones.len(), 2);
1112 assert!(phones.iter().any(|p| p.number.contains("+86")));
1113 assert!(phones.iter().any(|p| !p.number.contains("+86")));
1114 }
1115
1116 #[test]
1117 fn test_empty_html_edge_cases() {
1118 assert!(extract_emails(&Html::parse_document("")).is_empty());
1119 assert!(extract_phones(&Html::parse_document("")).is_empty());
1120 assert!(extract_social_links(&Html::parse_document("<body></body>")).is_empty());
1121 assert!(extract_addresses(&Html::parse_document("<body></body>")).is_empty());
1122 assert!(ContactInfo::default().emails.is_empty());
1123 }
1124
1125 #[test]
1126 fn test_normalize_phone_removes_non_digits() {
1127 assert_eq!(normalize_phone("+1 (800) 555-0199"), "18005550199");
1128 }
1129}