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")]
39#[derive(Default)]
40pub enum EmailSource {
41    /// mailto: 链接
42    MailtoLink,
43    /// 页面正文文本
44    #[default]
45    PageText,
46    /// meta 标签
47    MetaTag,
48    /// 结构化数据 (JSON-LD / Microdata)
49    StructuredData,
50}
51
52
53/// 电话号码
54#[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/// 电话类型
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[derive(Default)]
66pub enum PhoneType {
67    /// 固定电话
68    Landline,
69    /// 手机
70    Mobile,
71    /// 传真
72    Fax,
73    /// 免费热线
74    TollFree,
75    /// 未知
76    #[default]
77    Unknown,
78}
79
80
81/// 物理地址
82#[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/// 地理坐标
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Coordinates {
96    pub latitude: f64,
97    pub longitude: f64,
98}
99
100/// 社交媒体链接
101#[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/// 社交平台枚举
110#[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
130// ============================================================================
131// 正则表达式(惰性初始化)
132// ============================================================================
133
134lazy_static! {
135    /// 标准的邮箱地址正则(RFC 5322 简化版)
136    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    /// 电话号码正则(支持国际格式、常见分隔符)
141    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    /// 美国免费热线正则(800/888/877/866/855/844/833)
146    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    /// 中国手机号正则
151    static ref CN_MOBILE_RE: Regex = Regex::new(
152        r"1[3-9]\d{9}"
153    ).expect("无效的中国手机正则");
154
155    /// 中国固定电话正则(含区号)
156    static ref CN_LANDLINE_RE: Regex = Regex::new(
157        r"0\d{2,3}[-.\s]?\d{7,8}"
158    ).expect("无效的中国固话正则");
159
160    /// 邮政编码正则(通用 5 位及 5+4 格式)
161    static ref POSTAL_CODE_RE: Regex = Regex::new(
162        r"\b\d{5}(?:-\d{4})?\b"
163    ).expect("无效的邮编正则");
164
165    /// 中国邮政编码正则(6 位)
166    static ref CN_POSTAL_CODE_RE: Regex = Regex::new(
167        r"\b\d{6}\b"
168    ).expect("无效的中国邮编正则");
169
170    /// 社交媒体 URL 正则
171    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    /// 联系页面路径正则
176    static ref CONTACT_PAGE_RE: Regex = Regex::new(
177        r"(?i)(?:contact|about|support|联系|关于我们|客服)(?:[-_./]?(?:us|me|info|page|support))?"
178    ).expect("无效的联系页面正则");
179}
180
181// ============================================================================
182// 邮箱提取
183// ============================================================================
184
185/// 从 HTML 文档中提取所有联系信息
186pub 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
203/// 从 HTML 文档中提取邮箱地址
204pub fn extract_emails(document: &Html) -> Vec<Email> {
205    let mut emails: Vec<Email> = Vec::new();
206    let mut seen = HashSet::new();
207
208    // 从 mailto: 链接提取
209    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    // 从 meta 标签提取
226    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    // 从页面文本提取
240    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
259/// 验证邮箱地址有效性(基础格式检查 + 排除常见无效地址)
260pub fn is_valid_email(email: &str) -> bool {
261    let email = email.trim();
262
263    // 基本格式检查
264    if !EMAIL_RE.is_match(email) {
265        return false;
266    }
267
268    // 排除明显无效的地址
269    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    // 排除占位邮箱
280    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        // 排除因文本拼接导致的虚假邮箱(TLD 过长或域名段数异常)
294        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
303/// 提取邮箱附近的标签文本
304pub fn extract_email_label(document: &Html, _address: &str) -> Option<String> {
305    // 查找 mailto 链接触近的文本
306    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            // 检查父元素中的前置文本
313            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
332// ============================================================================
333// 电话提取
334// ============================================================================
335
336/// 从 HTML 文档中提取电话号码
337pub fn extract_phones(document: &Html) -> Vec<Phone> {
338    let mut phones: Vec<Phone> = Vec::new();
339    let mut seen = HashSet::new();
340
341    // 从 tel: 链接提取
342    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    // 从页面文本提取
362    if let Ok(sel) = Selector::parse("body") {
363        for element in document.select(&sel) {
364            let text = element.text().collect::<String>();
365
366            // 免费热线优先匹配
367            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            // 普通电话匹配
381            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
400/// 提取电话号码附近的标签文本
401pub 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
413/// 检测电话号码类型
414pub fn detect_phone_type(number: &str) -> PhoneType {
415    let cleaned = number.chars().filter(char::is_ascii_digit).collect::<String>();
416
417    // 免费热线(含 US 1-800 前缀)
418    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    // 中国手机
432    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    // 中国固话(区号以 0 开头)
440    if cleaned.starts_with('0') && cleaned.len() >= 10 && cleaned.len() <= 12 {
441        return PhoneType::Landline;
442    }
443
444    PhoneType::Unknown
445}
446
447/// 标准化电话号码(仅保留数字)
448fn normalize_phone(number: &str) -> String {
449    number.chars().filter(char::is_ascii_digit).collect()
450}
451
452// ============================================================================
453// 地址提取
454// ============================================================================
455
456/// 从 HTML 文档中提取地址信息
457pub fn extract_addresses(document: &Html) -> Vec<Address> {
458    let mut addresses: Vec<Address> = Vec::new();
459
460    // 尝试结构化地址(schema.org/PostalAddress)
461    let structured = extract_structured_address(document);
462    addresses.extend(structured);
463
464    // 查找 common 地址标签
465    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    // 兜底:查找页面中包含地址模式的大段文本
481    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                // 包含常见地址关键词的文本段
488                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                // 包含邮政编码
496                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
522/// 从结构化数据(JSON-LD / Microdata)中提取地址
523pub fn extract_structured_address(document: &Html) -> Vec<Address> {
524    let mut addresses: Vec<Address> = Vec::new();
525
526    // Microdata 格式: itemscope itemtype="http://schema.org/PostalAddress"
527    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                            // 检查 meta 标签 content 属性
541                            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            // 提取地理坐标
559            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
601/// 从原始文本中提取地址组成部分
602pub fn extract_address_components(raw: &str) -> Address {
603    let text = raw.trim();
604
605    // 尝试提取邮政编码
606    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
621/// 辅助:从文本中提取指定标签后的字段值
622fn 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                    // 取到下一个换行或结束
632                    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
643// ============================================================================
644// 社交媒体提取
645// ============================================================================
646
647/// 从 HTML 文档中提取社交媒体链接
648pub fn extract_social_links(document: &Html) -> Vec<SocialLink> {
649    let mut links: Vec<SocialLink> = Vec::new();
650    let mut seen = HashSet::new();
651
652    // 从 a 标签提取
653    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    // 从 meta/link 标签提取
681    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
703/// 检测社交媒体平台类型
704pub 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
740/// 从社交 URL 中提取用户名
741pub 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            // 取路径最后一段
748            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            // linkedin.com/in/username 或 linkedin.com/company/name
759            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            // github.com/username
768            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            // t.me/username 或 t.me/s/username
773            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
787// ============================================================================
788// 实用工具函数
789// ============================================================================
790
791/// 查找页面中的联系方式页面链接
792pub fn find_contact_page(document: &Html) -> Option<String> {
793    // 从 a 标签查找
794    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    // 从 nav / header 查找
807    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
822/// 从 HTML 文档中提取企业/组织名称
823pub fn extract_business_name(document: &Html) -> Option<String> {
824    // 从 schema.org/Organization 提取
825    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    // 从 OpenGraph site_name 提取
841    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    // 从页面标题提取(取第一个 | - 分隔符前的部分)
853    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                // 尝试分割标题
859                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
875// ============================================================================
876// 便捷函数
877// ============================================================================
878
879/// 检查 HTML 文档中是否包含任何联系信息
880pub 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
887/// 直接获取所有邮箱地址字符串
888pub 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
896/// 直接获取所有电话号码字符串
897pub 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
905/// 直接获取所有社交链接 URL 字符串
906pub 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// ============================================================================
915// 测试
916// ============================================================================
917
918#[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        // tel:+8613800138000 与 13800138000 规范化后不同(含/不含量国家码)
1111        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}