use regex::Regex;
use scraper::{Html, Selector, ElementRef};
use serde::{Deserialize, Serialize};
use lazy_static::lazy_static;
use std::collections::HashSet;
use crate::types::ParserResult;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ContactInfo {
pub emails: Vec<Email>,
pub phones: Vec<Phone>,
pub addresses: Vec<Address>,
pub social_links: Vec<SocialLink>,
pub contact_page: Option<String>,
pub business_name: Option<String>,
}
impl ContactInfo {
pub fn new() -> Self {
Self::default()
}
pub fn has_contact_info(&self) -> bool {
!self.emails.is_empty() ||
!self.phones.is_empty() ||
!self.addresses.is_empty() ||
!self.social_links.is_empty()
}
pub fn primary_email(&self) -> Option<&Email> {
self.emails.iter().find(|e| !e.is_generic)
.or_else(|| self.emails.first())
}
pub fn primary_phone(&self) -> Option<&Phone> {
self.phones.first()
}
pub fn email_addresses(&self) -> Vec<&str> {
self.emails.iter().map(|e| e.address.as_str()).collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Email {
pub address: String,
pub label: Option<String>,
pub is_generic: bool,
pub source: EmailSource,
}
impl Email {
pub fn new(address: String) -> Self {
let is_generic = Self::check_is_generic(&address);
Self {
address,
label: None,
is_generic,
source: EmailSource::Text,
}
}
fn check_is_generic(address: &str) -> bool {
let local = address.split('@').next().unwrap_or("").to_lowercase();
let generic_prefixes = [
"info", "contact", "hello", "hi", "support", "help",
"sales", "admin", "webmaster", "noreply", "no-reply",
"mail", "email", "enquiries", "enquiry", "general",
];
generic_prefixes.iter().any(|p| local == *p || local.starts_with(&format!("{}.", p)))
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum EmailSource {
MailtoLink,
#[default]
Text,
StructuredData,
MetaTag,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Phone {
pub raw: String,
pub normalized: String,
pub international: Option<String>,
pub phone_type: PhoneType,
pub label: Option<String>,
}
impl Phone {
pub fn new(raw: String) -> Self {
let normalized = Self::normalize(&raw);
Self {
raw,
normalized,
international: None,
phone_type: PhoneType::Unknown,
label: None,
}
}
fn normalize(raw: &str) -> String {
raw.chars().filter(|c| c.is_ascii_digit() || *c == '+').collect()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum PhoneType {
Main,
Mobile,
Fax,
TollFree,
#[default]
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct Address {
pub full_text: String,
pub street: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
pub coordinates: Option<Coordinates>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub struct Coordinates {
pub latitude: f64,
pub longitude: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SocialLink {
pub platform: SocialPlatform,
pub url: String,
pub username: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SocialPlatform {
Facebook,
Twitter,
Instagram,
LinkedIn,
YouTube,
TikTok,
Pinterest,
GitHub,
Reddit,
Discord,
Telegram,
WhatsApp,
Snapchat,
Tumblr,
Medium,
Twitch,
Vimeo,
Flickr,
Other,
}
lazy_static! {
static ref EMAIL_PATTERN: Regex = Regex::new(
r"(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}"
).unwrap();
static ref PHONE_PATTERN: Regex = Regex::new(
r"(?x)
(?:\+?1[-.\s]?)? # Optional country code
(?:\(?\d{3}\)?[-.\s]?) # Area code
\d{3}[-.\s]? # Exchange
\d{4} # Subscriber
|
\+\d{1,3}[-.\s]?\d{1,4}[-.\s]?\d{1,4}[-.\s]?\d{1,9} # International
"
).unwrap();
static ref TOLL_FREE_PATTERN: Regex = Regex::new(
r"(?i)1?[-.\s]?(?:800|888|877|866|855|844|833)[-.\s]?\d{3}[-.\s]?\d{4}"
).unwrap();
static ref US_ZIP_PATTERN: Regex = Regex::new(r"\b\d{5}(?:-\d{4})?\b").unwrap();
static ref UK_POSTAL_PATTERN: Regex = Regex::new(
r"(?i)\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b"
).unwrap();
static ref CA_POSTAL_PATTERN: Regex = Regex::new(
r"(?i)\b[A-Z]\d[A-Z]\s*\d[A-Z]\d\b"
).unwrap();
}
pub fn extract_contact_info(document: &Html) -> ParserResult<ContactInfo> {
let mut info = ContactInfo::new();
info.emails = extract_emails(document);
info.phones = extract_phones(document);
info.addresses = extract_addresses(document);
info.social_links = extract_social_links(document);
info.contact_page = find_contact_page(document);
info.business_name = extract_business_name(document);
Ok(info)
}
pub fn extract_emails(document: &Html) -> Vec<Email> {
let mut emails = Vec::new();
let mut seen = HashSet::new();
if let Ok(sel) = Selector::parse("a[href^='mailto:']") {
for el in document.select(&sel) {
if let Some(href) = el.value().attr("href") {
let addr = href.trim_start_matches("mailto:")
.split('?').next()
.unwrap_or("")
.to_lowercase();
if EMAIL_PATTERN.is_match(&addr) && !seen.contains(&addr) {
seen.insert(addr.clone());
let mut email = Email::new(addr);
email.source = EmailSource::MailtoLink;
email.label = extract_email_label(&el);
emails.push(email);
}
}
}
}
let text = document.root_element().text().collect::<String>();
for caps in EMAIL_PATTERN.find_iter(&text) {
let addr = caps.as_str().to_lowercase();
if !seen.contains(&addr) && is_valid_email(&addr) {
seen.insert(addr.clone());
emails.push(Email::new(addr));
}
}
if let Ok(sel) = Selector::parse("[itemprop='email'], [property='email']") {
for el in document.select(&sel) {
let content = el.value().attr("content")
.or_else(|| el.value().attr("href"))
.map(|s| s.trim_start_matches("mailto:").to_lowercase())
.or_else(|| Some(el.text().collect::<String>().trim().to_lowercase()));
if let Some(addr) = content {
if EMAIL_PATTERN.is_match(&addr) && !seen.contains(&addr) {
seen.insert(addr.clone());
let mut email = Email::new(addr);
email.source = EmailSource::StructuredData;
emails.push(email);
}
}
}
}
emails
}
fn is_valid_email(email: &str) -> bool {
let invalid_endings = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
!invalid_endings.iter().any(|e| email.ends_with(e))
}
fn extract_email_label(element: &ElementRef) -> Option<String> {
let text = element.text().collect::<String>().trim().to_lowercase();
let labels = ["support", "sales", "info", "contact", "help", "billing", "press", "careers", "jobs"];
for label in labels {
if text.contains(label) {
return Some(label.to_string());
}
}
None
}
pub fn extract_phones(document: &Html) -> Vec<Phone> {
let mut phones = Vec::new();
let mut seen = HashSet::new();
if let Ok(sel) = Selector::parse("a[href^='tel:']") {
for el in document.select(&sel) {
if let Some(href) = el.value().attr("href") {
let raw = href.trim_start_matches("tel:").to_string();
let normalized = Phone::new(raw.clone()).normalized.clone();
if normalized.len() >= 10 && !seen.contains(&normalized) {
seen.insert(normalized);
let mut phone = Phone::new(raw);
phone.label = extract_phone_label(&el);
phone.phone_type = detect_phone_type(&phone);
phones.push(phone);
}
}
}
}
let text = document.root_element().text().collect::<String>();
for caps in PHONE_PATTERN.find_iter(&text) {
let raw = caps.as_str().to_string();
let normalized = Phone::new(raw.clone()).normalized.clone();
if normalized.len() >= 10 && !seen.contains(&normalized) {
seen.insert(normalized);
let mut phone = Phone::new(raw);
phone.phone_type = detect_phone_type(&phone);
phones.push(phone);
}
}
if let Ok(sel) = Selector::parse("[itemprop='telephone'], [property='telephone']") {
for el in document.select(&sel) {
let content = el.value().attr("content")
.map(|s| s.to_string())
.or_else(|| Some(el.text().collect::<String>().trim().to_string()));
if let Some(raw) = content {
let normalized = Phone::new(raw.clone()).normalized.clone();
if normalized.len() >= 10 && !seen.contains(&normalized) {
seen.insert(normalized);
phones.push(Phone::new(raw));
}
}
}
}
phones
}
fn extract_phone_label(element: &ElementRef) -> Option<String> {
let text = element.text().collect::<String>().to_lowercase();
if text.contains("fax") {
Some("fax".to_string())
} else if text.contains("mobile") || text.contains("cell") {
Some("mobile".to_string())
} else if text.contains("main") || text.contains("office") {
Some("main".to_string())
} else {
None
}
}
fn detect_phone_type(phone: &Phone) -> PhoneType {
if TOLL_FREE_PATTERN.is_match(&phone.raw) {
PhoneType::TollFree
} else if phone.label.as_ref().map(|l| l.contains("fax")).unwrap_or(false) {
PhoneType::Fax
} else if phone.label.as_ref().map(|l| l.contains("mobile") || l.contains("cell")).unwrap_or(false) {
PhoneType::Mobile
} else {
PhoneType::Unknown
}
}
pub fn extract_addresses(document: &Html) -> Vec<Address> {
let mut addresses = Vec::new();
if let Ok(sel) = Selector::parse("[itemtype*='PostalAddress'], [itemprop='address']") {
for el in document.select(&sel) {
if let Some(addr) = extract_structured_address(&el) {
addresses.push(addr);
}
}
}
if let Ok(sel) = Selector::parse("address") {
for el in document.select(&sel) {
let text = el.text().collect::<String>().trim().to_string();
if !text.is_empty() && text.len() > 10 {
let mut addr = Address {
full_text: text,
..Default::default()
};
extract_address_components(&mut addr);
addresses.push(addr);
}
}
}
addresses.dedup_by(|a, b| a.full_text == b.full_text);
addresses
}
fn extract_structured_address(element: &ElementRef) -> Option<Address> {
let mut addr = Address::default();
let selectors = [
("streetAddress", "street"),
("addressLocality", "city"),
("addressRegion", "state"),
("postalCode", "postal_code"),
("addressCountry", "country"),
];
for (itemprop, field) in selectors {
if let Ok(sel) = Selector::parse(&format!("[itemprop='{}']", itemprop)) {
if let Some(el) = element.select(&sel).next() {
let value = el.value().attr("content")
.map(|s| s.to_string())
.or_else(|| Some(el.text().collect::<String>().trim().to_string()));
match field {
"street" => addr.street = value,
"city" => addr.city = value,
"state" => addr.state = value,
"postal_code" => addr.postal_code = value,
"country" => addr.country = value,
_ => {}
}
}
}
}
let parts: Vec<&str> = [
addr.street.as_deref(),
addr.city.as_deref(),
addr.state.as_deref(),
addr.postal_code.as_deref(),
addr.country.as_deref(),
].into_iter().flatten().collect();
if parts.is_empty() {
return None;
}
addr.full_text = parts.join(", ");
Some(addr)
}
fn extract_address_components(addr: &mut Address) {
if let Some(caps) = US_ZIP_PATTERN.find(&addr.full_text) {
addr.postal_code = Some(caps.as_str().to_string());
} else if let Some(caps) = UK_POSTAL_PATTERN.find(&addr.full_text) {
addr.postal_code = Some(caps.as_str().to_string());
} else if let Some(caps) = CA_POSTAL_PATTERN.find(&addr.full_text) {
addr.postal_code = Some(caps.as_str().to_string());
}
}
pub fn extract_social_links(document: &Html) -> Vec<SocialLink> {
let mut links = Vec::new();
let mut seen = HashSet::new();
if let Ok(sel) = Selector::parse("a[href]") {
for el in document.select(&sel) {
if let Some(href) = el.value().attr("href") {
if let Some(platform) = detect_social_platform(href) {
let url = href.to_string();
if !seen.contains(&url) {
seen.insert(url.clone());
links.push(SocialLink {
platform,
url: url.clone(),
username: extract_social_username(&url, platform),
});
}
}
}
}
}
links
}
fn detect_social_platform(url: &str) -> Option<SocialPlatform> {
let url_lower = url.to_lowercase();
let platforms = [
("facebook.com", SocialPlatform::Facebook),
("fb.com", SocialPlatform::Facebook),
("twitter.com", SocialPlatform::Twitter),
("x.com", SocialPlatform::Twitter),
("instagram.com", SocialPlatform::Instagram),
("linkedin.com", SocialPlatform::LinkedIn),
("youtube.com", SocialPlatform::YouTube),
("youtu.be", SocialPlatform::YouTube),
("tiktok.com", SocialPlatform::TikTok),
("pinterest.com", SocialPlatform::Pinterest),
("github.com", SocialPlatform::GitHub),
("reddit.com", SocialPlatform::Reddit),
("discord.gg", SocialPlatform::Discord),
("discord.com", SocialPlatform::Discord),
("t.me", SocialPlatform::Telegram),
("telegram.me", SocialPlatform::Telegram),
("wa.me", SocialPlatform::WhatsApp),
("whatsapp.com", SocialPlatform::WhatsApp),
("snapchat.com", SocialPlatform::Snapchat),
("tumblr.com", SocialPlatform::Tumblr),
("medium.com", SocialPlatform::Medium),
("twitch.tv", SocialPlatform::Twitch),
("vimeo.com", SocialPlatform::Vimeo),
("flickr.com", SocialPlatform::Flickr),
];
for (domain, platform) in platforms {
if url_lower.contains(domain) {
return Some(platform);
}
}
None
}
fn extract_social_username(url: &str, platform: SocialPlatform) -> Option<String> {
let url_lower = url.to_lowercase();
let cleaned = url_lower
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("www.");
let parts: Vec<&str> = cleaned.split('/').collect();
match platform {
SocialPlatform::Twitter | SocialPlatform::Instagram |
SocialPlatform::GitHub | SocialPlatform::TikTok => {
parts.get(1).filter(|s| !s.is_empty() && !s.starts_with('?')).map(|s| s.to_string())
}
SocialPlatform::Facebook => {
parts.get(1).filter(|s| !s.is_empty() && **s != "pages" && !s.starts_with('?')).map(|s| s.to_string())
}
SocialPlatform::YouTube => {
if let Some(p) = parts.get(1) {
if *p == "c" || *p == "channel" || *p == "user" {
return parts.get(2).map(|s| s.to_string());
}
if p.starts_with('@') {
return Some(p.to_string());
}
}
None
}
_ => None,
}
}
fn find_contact_page(document: &Html) -> Option<String> {
let contact_patterns = ["contact", "kontakt", "contacto", "contato"];
if let Ok(sel) = Selector::parse("a[href]") {
for el in document.select(&sel) {
if let Some(href) = el.value().attr("href") {
let href_lower = href.to_lowercase();
let text_lower = el.text().collect::<String>().to_lowercase();
for pattern in contact_patterns {
if href_lower.contains(pattern) || text_lower.contains(pattern) {
return Some(href.to_string());
}
}
}
}
}
None
}
fn extract_business_name(document: &Html) -> Option<String> {
if let Ok(sel) = Selector::parse("[itemprop='name'], [property='og:site_name']") {
if let Some(el) = document.select(&sel).next() {
let text_content = el.text().collect::<String>();
let name = el.value().attr("content")
.or(Some(text_content.as_str()))
.map(|s| s.trim().to_string());
if let Some(ref n) = name {
if !n.is_empty() && n.len() < 100 {
return name;
}
}
}
}
None
}
pub fn has_contact_info(document: &Html) -> bool {
extract_contact_info(document)
.map(|c| c.has_contact_info())
.unwrap_or(false)
}
pub fn get_emails(document: &Html) -> Vec<String> {
extract_emails(document)
.into_iter()
.map(|e| e.address)
.collect()
}
pub fn get_phones(document: &Html) -> Vec<String> {
extract_phones(document)
.into_iter()
.map(|p| p.raw)
.collect()
}
pub fn get_social_links(document: &Html) -> Vec<String> {
extract_social_links(document)
.into_iter()
.map(|s| s.url)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_html(html: &str) -> Html {
Html::parse_document(html)
}
#[test]
fn test_extract_mailto_email() {
let html = r#"
<a href="mailto:contact@example.com">Contact Us</a>
"#;
let doc = parse_html(html);
let emails = extract_emails(&doc);
assert_eq!(emails.len(), 1);
assert_eq!(emails[0].address, "contact@example.com");
assert_eq!(emails[0].source, EmailSource::MailtoLink);
assert!(emails[0].is_generic);
}
#[test]
fn test_extract_text_email() {
let html = r#"
<p>Email us at john.doe@company.org for inquiries.</p>
"#;
let doc = parse_html(html);
let emails = extract_emails(&doc);
assert_eq!(emails.len(), 1);
assert_eq!(emails[0].address, "john.doe@company.org");
assert!(!emails[0].is_generic);
}
#[test]
fn test_extract_phone_tel() {
let html = r#"
<a href="tel:+1-555-123-4567">Call Us</a>
"#;
let doc = parse_html(html);
let phones = extract_phones(&doc);
assert_eq!(phones.len(), 1);
assert_eq!(phones[0].normalized, "+15551234567");
}
#[test]
fn test_extract_toll_free() {
let html = r#"
<p>Call us at 1-800-555-1234</p>
"#;
let doc = parse_html(html);
let phones = extract_phones(&doc);
assert!(!phones.is_empty());
assert_eq!(phones[0].phone_type, PhoneType::TollFree);
}
#[test]
fn test_extract_social_links() {
let html = r#"
<a href="https://twitter.com/example">Twitter</a>
<a href="https://facebook.com/example">Facebook</a>
<a href="https://github.com/example">GitHub</a>
"#;
let doc = parse_html(html);
let links = extract_social_links(&doc);
assert_eq!(links.len(), 3);
assert!(links.iter().any(|l| l.platform == SocialPlatform::Twitter));
assert!(links.iter().any(|l| l.platform == SocialPlatform::Facebook));
assert!(links.iter().any(|l| l.platform == SocialPlatform::GitHub));
}
#[test]
fn test_extract_social_username() {
assert_eq!(
extract_social_username("https://twitter.com/johndoe", SocialPlatform::Twitter),
Some("johndoe".to_string())
);
assert_eq!(
extract_social_username("https://github.com/rust-lang", SocialPlatform::GitHub),
Some("rust-lang".to_string())
);
}
#[test]
fn test_extract_structured_address() {
let html = r#"
<div itemscope itemtype="https://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">USA</span>
</div>
"#;
let doc = parse_html(html);
let addresses = extract_addresses(&doc);
assert_eq!(addresses.len(), 1);
assert_eq!(addresses[0].street, Some("123 Main St".to_string()));
assert_eq!(addresses[0].city, Some("Springfield".to_string()));
assert_eq!(addresses[0].postal_code, Some("62701".to_string()));
}
#[test]
fn test_find_contact_page() {
let html = r#"
<nav>
<a href="/about">About</a>
<a href="/contact">Contact Us</a>
</nav>
"#;
let doc = parse_html(html);
let contact = find_contact_page(&doc);
assert_eq!(contact, Some("/contact".to_string()));
}
#[test]
fn test_email_is_generic() {
assert!(Email::new("info@example.com".to_string()).is_generic);
assert!(Email::new("contact@example.com".to_string()).is_generic);
assert!(Email::new("support@example.com".to_string()).is_generic);
assert!(!Email::new("john.doe@example.com".to_string()).is_generic);
}
#[test]
fn test_primary_email() {
let mut info = ContactInfo::new();
info.emails = vec![
Email::new("info@example.com".to_string()),
Email::new("john@example.com".to_string()),
];
let primary = info.primary_email().unwrap();
assert_eq!(primary.address, "john@example.com");
}
#[test]
fn test_has_contact_info() {
let html = r#"
<a href="mailto:test@example.com">Email</a>
"#;
let doc = parse_html(html);
assert!(has_contact_info(&doc));
let html_empty = "<html><body><p>No contact info</p></body></html>";
let doc_empty = parse_html(html_empty);
assert!(!has_contact_info(&doc_empty));
}
#[test]
fn test_x_twitter_detection() {
let html = r#"
<a href="https://x.com/example">X (Twitter)</a>
"#;
let doc = parse_html(html);
let links = extract_social_links(&doc);
assert_eq!(links.len(), 1);
assert_eq!(links[0].platform, SocialPlatform::Twitter);
}
#[test]
fn test_youtube_username_extraction() {
assert_eq!(
extract_social_username("https://youtube.com/@handle", SocialPlatform::YouTube),
Some("@handle".to_string())
);
assert_eq!(
extract_social_username("https://youtube.com/c/channelname", SocialPlatform::YouTube),
Some("channelname".to_string())
);
}
}