Skip to main content

crawlkit_parser/
contact.rs

1//! # 联系方式提取模块
2//!
3//! 从 HTML 页面中提取邮箱、电话、地址、社交媒体链接等联系信息。
4//! 改编自 halldyll-parser 的联系方式提取逻辑。
5
6use 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// ============================================================================
15// 类型定义
16// ============================================================================
17
18/// 统一联系方式容器
19#[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/// 邮箱地址
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Email {
31    pub address: String,
32    pub label: Option<String>,
33    pub source: EmailSource,
34}
35
36/// 邮箱来源
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum EmailSource {
40    /// mailto: 链接
41    MailtoLink,
42    /// 页面正文文本
43    PageText,
44    /// meta 标签
45    MetaTag,
46    /// 结构化数据 (JSON-LD / Microdata)
47    StructuredData,
48}
49
50impl Default for EmailSource {
51    fn default() -> Self {
52        Self::PageText
53    }
54}
55
56/// 电话号码
57#[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/// 电话类型
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum PhoneType {
69    /// 固定电话
70    Landline,
71    /// 手机
72    Mobile,
73    /// 传真
74    Fax,
75    /// 免费热线
76    TollFree,
77    /// 未知
78    Unknown,
79}
80
81impl Default for PhoneType {
82    fn default() -> Self {
83        Self::Unknown
84    }
85}
86
87/// 物理地址
88#[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/// 地理坐标
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Coordinates {
102    pub latitude: f64,
103    pub longitude: f64,
104}
105
106/// 社交媒体链接
107#[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/// 社交平台枚举
116#[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
136// ============================================================================
137// 正则表达式(惰性初始化)
138// ============================================================================
139
140lazy_static! {
141    /// 标准的邮箱地址正则(RFC 5322 简化版)
142    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    /// 电话号码正则(支持国际格式、常见分隔符)
147    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    /// 美国免费热线正则(800/888/877/866/855/844/833)
152    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    /// 中国手机号正则
157    static ref CN_MOBILE_RE: Regex = Regex::new(
158        r#"1[3-9]\d{9}"#
159    ).expect("无效的中国手机正则");
160
161    /// 中国固定电话正则(含区号)
162    static ref CN_LANDLINE_RE: Regex = Regex::new(
163        r#"0\d{2,3}[-.\s]?\d{7,8}"#
164    ).expect("无效的中国固话正则");
165
166    /// 邮政编码正则(通用 5 位及 5+4 格式)
167    static ref POSTAL_CODE_RE: Regex = Regex::new(
168        r#"\b\d{5}(?:-\d{4})?\b"#
169    ).expect("无效的邮编正则");
170
171    /// 中国邮政编码正则(6 位)
172    static ref CN_POSTAL_CODE_RE: Regex = Regex::new(
173        r#"\b\d{6}\b"#
174    ).expect("无效的中国邮编正则");
175
176    /// 社交媒体 URL 正则
177    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    /// 联系页面路径正则
182    static ref CONTACT_PAGE_RE: Regex = Regex::new(
183        r#"(?i)(?:contact|about|support|联系|关于我们|客服)(?:[-_./]?(?:us|me|info|page|support))?"#
184    ).expect("无效的联系页面正则");
185}
186
187// ============================================================================
188// 邮箱提取
189// ============================================================================
190
191/// 从 HTML 文档中提取所有联系信息
192pub 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
209/// 从 HTML 文档中提取邮箱地址
210pub fn extract_emails(document: &Html) -> Vec<Email> {
211    let mut emails: Vec<Email> = Vec::new();
212    let mut seen = HashSet::new();
213
214    // 从 mailto: 链接提取
215    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    // 从 meta 标签提取
232    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    // 从页面文本提取
247    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
266/// 验证邮箱地址有效性(基础格式检查 + 排除常见无效地址)
267pub fn is_valid_email(email: &str) -> bool {
268    let email = email.trim();
269
270    // 基本格式检查
271    if !EMAIL_RE.is_match(email) {
272        return false;
273    }
274
275    // 排除明显无效的地址
276    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    // 排除占位邮箱
287    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        // 排除因文本拼接导致的虚假邮箱(TLD 过长或域名段数异常)
301        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
310/// 提取邮箱附近的标签文本
311pub fn extract_email_label(document: &Html, _address: &str) -> Option<String> {
312    // 查找 mailto 链接触近的文本
313    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            // 检查父元素中的前置文本
320            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
339// ============================================================================
340// 电话提取
341// ============================================================================
342
343/// 从 HTML 文档中提取电话号码
344pub fn extract_phones(document: &Html) -> Vec<Phone> {
345    let mut phones: Vec<Phone> = Vec::new();
346    let mut seen = HashSet::new();
347
348    // 从 tel: 链接提取
349    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    // 从页面文本提取
369    if let Ok(sel) = Selector::parse("body") {
370        for element in document.select(&sel) {
371            let text = element.text().collect::<String>();
372
373            // 免费热线优先匹配
374            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            // 普通电话匹配
388            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
407/// 提取电话号码附近的标签文本
408pub 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
420/// 检测电话号码类型
421pub fn detect_phone_type(number: &str) -> PhoneType {
422    let cleaned = number.chars().filter(|c| c.is_ascii_digit()).collect::<String>();
423
424    // 免费热线(含 US 1-800 前缀)
425    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    // 中国手机
439    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    // 中国固话(区号以 0 开头)
447    if cleaned.starts_with('0') && cleaned.len() >= 10 && cleaned.len() <= 12 {
448        return PhoneType::Landline;
449    }
450
451    PhoneType::Unknown
452}
453
454/// 标准化电话号码(仅保留数字)
455fn normalize_phone(number: &str) -> String {
456    number.chars().filter(|c| c.is_ascii_digit()).collect()
457}
458
459// ============================================================================
460// 地址提取
461// ============================================================================
462
463/// 从 HTML 文档中提取地址信息
464pub fn extract_addresses(document: &Html) -> Vec<Address> {
465    let mut addresses: Vec<Address> = Vec::new();
466
467    // 尝试结构化地址(schema.org/PostalAddress)
468    let structured = extract_structured_address(document);
469    addresses.extend(structured);
470
471    // 查找 common 地址标签
472    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    // 兜底:查找页面中包含地址模式的大段文本
488    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                // 包含常见地址关键词的文本段
495                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                // 包含邮政编码
503                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
530/// 从结构化数据(JSON-LD / Microdata)中提取地址
531pub fn extract_structured_address(document: &Html) -> Vec<Address> {
532    let mut addresses: Vec<Address> = Vec::new();
533
534    // Microdata 格式: itemscope itemtype="http://schema.org/PostalAddress"
535    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                            // 检查 meta 标签 content 属性
549                            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            // 提取地理坐标
568            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
610/// 从原始文本中提取地址组成部分
611pub fn extract_address_components(raw: &str) -> Address {
612    let text = raw.trim();
613
614    // 尝试提取邮政编码
615    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
630/// 辅助:从文本中提取指定标签后的字段值
631fn 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                    // 取到下一个换行或结束
641                    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
652// ============================================================================
653// 社交媒体提取
654// ============================================================================
655
656/// 从 HTML 文档中提取社交媒体链接
657pub fn extract_social_links(document: &Html) -> Vec<SocialLink> {
658    let mut links: Vec<SocialLink> = Vec::new();
659    let mut seen = HashSet::new();
660
661    // 从 a 标签提取
662    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    // 从 meta/link 标签提取
690    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
712/// 检测社交媒体平台类型
713pub 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
749/// 从社交 URL 中提取用户名
750pub 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            // 取路径最后一段
757            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            // linkedin.com/in/username 或 linkedin.com/company/name
768            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            // github.com/username
777            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            // t.me/username 或 t.me/s/username
782            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
796// ============================================================================
797// 实用工具函数
798// ============================================================================
799
800/// 查找页面中的联系方式页面链接
801pub fn find_contact_page(document: &Html) -> Option<String> {
802    // 从 a 标签查找
803    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    // 从 nav / header 查找
816    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
831/// 从 HTML 文档中提取企业/组织名称
832pub fn extract_business_name(document: &Html) -> Option<String> {
833    // 从 schema.org/Organization 提取
834    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    // 从 OpenGraph site_name 提取
850    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    // 从页面标题提取(取第一个 | - 分隔符前的部分)
862    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                // 尝试分割标题
868                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
884// ============================================================================
885// 便捷函数
886// ============================================================================
887
888/// 检查 HTML 文档中是否包含任何联系信息
889pub 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
896/// 直接获取所有邮箱地址字符串
897pub 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
905/// 直接获取所有电话号码字符串
906pub 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
914/// 直接获取所有社交链接 URL 字符串
915pub 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// ============================================================================
924// 测试
925// ============================================================================
926
927#[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        // tel:+8613800138000 与 13800138000 规范化后不同(含/不含量国家码)
1120        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}