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