#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::BTreeSet;
use regex::Regex;
use std::sync::LazyLock;
use unicode_normalization::UnicodeNormalization;
mod normalize;
mod score;
mod scored;
#[cfg(feature = "html")]
mod html;
#[allow(dead_code)]
mod segment;
#[allow(dead_code)]
mod candidate;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Extracted {
pub emails: Vec<String>,
pub phones: Vec<String>,
pub fax: Option<String>,
pub postcode: Option<String>,
pub city: Option<String>,
pub street: Option<String>,
pub hr_number: Option<String>,
pub hr_court: Option<String>,
pub vat_id: Option<String>,
pub tax_number: Option<String>,
pub iban: Option<String>,
pub bic: Option<String>,
pub legal_form: Option<String>,
pub year_founded: Option<i32>,
pub persons: Vec<Person>,
pub register_type: Option<String>,
pub supervisory_authority: Option<String>,
pub professional_chamber: Option<String>,
pub de_mail: Option<String>,
pub dispute_resolution_url: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Person {
pub first_name: Option<String>,
pub last_name: Option<String>,
pub full_raw: String,
pub role: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Address {
pub postcode: Option<String>,
pub city: Option<String>,
pub street: Option<String>,
}
pub use scored::{Scored, ScoredExtracted};
static EMAIL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}").unwrap());
static EMAIL_OBFUSCATED_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b([a-z0-9._%+\-]+)\s*[\[\(]?\s*(?:at|@)\s*[\]\)]?\s*([a-z0-9.\-]+)\s*[\[\(]?\s*(?:dot|\.)\s*[\]\)]?\s*([a-z]{2,})\b",
)
.unwrap()
});
static PHONE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?x)
(?:\+49|0049|0)
[\s\-/]?
(?:\(0?\)|\d{1,5})
[\s\-/]?
\d{2,4}
[\s\-/]?
\d{2,4}
(?:[\s\-/]?\d{0,4})?
",
)
.unwrap()
});
static GERMAN_POSTCODE_AND_CITY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(\d{5})\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-/. ]{1,40})\b").unwrap());
static STREET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"\b([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-./ ]{2,60}?(?:str(?:asse|aße|\.)|weg|allee|platz|ring|gasse|damm))\s+(\d{1,4}[a-zA-Z]?(?:[\-–]\d{1,4}[a-zA-Z]?)?)\b",
)
.unwrap()
});
static HR_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bHR[AB]?\s*[:\-]?\s*(?:Nr\.?\s*)?(\d{2,7}(?:\s*[A-Z])?)\b").unwrap()
});
static HR_COURT_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)Amtsgericht\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-./ ()]{1,60})").unwrap()
});
static HR_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+HR[AB]?\b").unwrap());
static VAT_DE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bDE[\s\-]?(?:\d[\s\-]?){8}\d\b").unwrap());
static IBAN_DE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bDE(?:[\s\-]?\d){20}\b").unwrap());
static BIC_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b").unwrap());
static FAX_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
(?:tele)?fax\.?\s*[:\-]?\s*
((?:\+49|0049|0)
[\s\-/]?(?:\(0?\)|\d{1,5})
[\s\-/]?\d{2,4}
[\s\-/]?\d{2,4}
(?:[\s\-/]?\d{0,4})?)
",
)
.unwrap()
});
static TAX_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
(?:Steuer-?nummer | Steuer-?Nr\.? | St\.?-?Nr\.?)
[:\s]*
([\d/\s]{8,20})
",
)
.unwrap()
});
static GF_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
(?:
Gesch(?:ä|ae)ftsf(?:ü|ue)hrer(?:in)?
| Inhaber(?:in)?
| Vorstand
| Vertretungsberechtigt(?:e[rn]?)?
| vertreten\s+durch
| Verantwortlich(?:e[rn]?)?\s+f(?:ü|ue)r\s+den\s+Inhalt
| Redaktionell\s+verantwortlich
| Verantwortlich(?:e[rn]?)?(?:\s+(?:i\.?\s?S\.?\s?[dv]\.?|gem(?:ä|ae)ß|nach)[^:\n]{0,40})?
)
[\s:\-]+
(?:sind\s+|ist\s+)?
([A-ZÄÖÜ][\w\s.\-,&/äöüÄÖÜß]{2,160})
",
)
.unwrap()
});
static YEAR_FOUNDED_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
\b
(?:gegr(?:ü|ue)ndet|seit|founded\s*in?|since|established\s*in?|gründungsjahr)
\s*[:\-]?\s*
(\d{4})
\b
",
)
.unwrap()
});
static LEGAL_FORM_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?ix)
\b
(
GmbH\s*&\s*Co\.?\s*KGaA
| GmbH\s*&\s*Co\.?\s*KG
| KGaA
| GmbH
| UG\s*\(?haftungsbeschr(?:ä|ae)nkt\)?
| UG
| AG
| KG
| OHG
| GbR
| e\.K\.
| eG
| SE
)
\b
",
)
.unwrap()
});
static SUPERVISORY_AUTHORITY_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*Aufsichtsbeh(?:ö|oe)rde\s*[:\-]?\s*([^\n]{2,100})").unwrap()
});
static CHAMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?im)^\s*(?:zust(?:ä|ae)ndige\s+Kammer|Berufskammer)\s*[:\-]?\s*([^\n]{2,100})")
.unwrap()
});
static DE_MAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\bDe-?Mail\s*[:\-]?\s*([a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,})").unwrap()
});
static ODR_URL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)https?://(?:www\.)?ec\.europa\.eu/consumers/odr/?").unwrap());
const REAL_TLDS: &[&str] = &[
"com", "net", "org", "info", "biz", "name", "pro", "io", "co", "gov", "edu", "mil", "int", "eu",
"app", "dev", "shop", "online", "site", "tech", "gmbh", "email", "de", "berlin",
"at", "ch", "li", "lu", "nl", "be", "fr", "it", "es", "pt", "pl", "cz", "sk", "hu", "dk", "se",
"no", "fi", "is", "ie", "uk", "gr", "ro", "bg", "hr", "si", "rs", "ua",
"us", "ca", "mx", "br", "ar", "au", "nz", "jp", "cn", "kr", "in", "sg", "hk", "tw", "za", "ru",
"tr", "ae", "sa", "il",
];
const BLOCKED_EMAIL_DOMAINS: &[&str] = &["sapp.com", "tet.soweit"];
const NOT_A_NAME: &[&str] = &[
"der",
"die",
"das",
"den",
"dem",
"des",
"ein",
"eine",
"einem",
"einer",
"einen",
"eines",
"in",
"an",
"auf",
"zu",
"zum",
"zur",
"von",
"bei",
"mit",
"nach",
"vor",
"aus",
"durch",
"ohne",
"gegen",
"um",
"für",
"über",
"unter",
"zwischen",
"als",
"sich",
"uns",
"wir",
"ihre",
"ihren",
"ihrer",
"seinen",
"seine",
"seiner",
"deren",
"dessen",
"nicht",
"auch",
"nur",
"noch",
"schon",
"sowie",
"und",
"oder",
"ist",
"hat",
"sind",
"haben",
"wird",
"werden",
"kann",
"soll",
"bemühen",
"sollten",
"vertreten",
"geschäftsführer",
"geschäftsführerin",
"geschäftsführung",
"gesellschafterin",
"gesellschafter",
"vorstand",
"vorständin",
"inhaber",
"inhaberin",
"vertretungsberechtigt",
"verantwortlich",
"domain",
"inhalt",
"inhalte",
"gmbh",
"ug",
"ag",
"ohg",
"gbr",
"kg",
"eg",
"team",
"kontakt",
"impressum",
"datenschutz",
"vertrieb",
"büro",
"sekretariat",
"webdesign",
"webseite",
"homepage",
"copyright",
"firma",
"unternehmen",
"postfach",
"telefon",
"telefax",
"mobil",
"adresse",
];
pub fn extract_all(text: &str) -> Extracted {
let doc = segment::Document::parse(normalize::normalize_text(text));
build_extracted(&doc)
}
#[cfg(feature = "html")]
pub fn extract_all_html(html: &str) -> Extracted {
let doc = segment::Document::parse(html::html_to_impressum_text(html));
build_extracted(&doc)
}
#[cfg(feature = "html")]
pub use html::html_to_impressum_text;
pub fn extract_all_scored(text: &str) -> ScoredExtracted {
let doc = segment::Document::parse(normalize::normalize_text(text));
score::score_extracted(build_extracted(&doc), &doc)
}
#[cfg(feature = "html")]
pub fn extract_all_scored_html(html: &str) -> ScoredExtracted {
let doc = segment::Document::parse(html::html_to_impressum_text(html));
score::score_extracted(build_extracted(&doc), &doc)
}
fn build_extracted(doc: &segment::Document) -> Extracted {
let text = doc.text();
let fax = extract_fax_core(text);
let iban_spans: Vec<(usize, usize)> = IBAN_DE_RE
.find_iter(text)
.map(|m| (m.start(), m.end()))
.collect();
let phones = collect_phones(text, &iban_spans)
.into_iter()
.filter(|p| fax.as_deref() != Some(p.as_str()))
.collect();
let (postcode, city, street) = address_from_document(doc);
let hr_number = extract_hr_number_core(text);
let hr_court = extract_hr_court_core(text);
let tax_number = extract_tax_number_core(text);
Extracted {
emails: extract_emails_core(text),
phones,
fax,
postcode,
city,
street,
hr_number,
hr_court,
vat_id: extract_vat_id_core(text),
tax_number,
iban: extract_iban_core(text),
bic: extract_bic_core(text),
legal_form: extract_legal_form_core(text),
year_founded: extract_year_founded_core(text),
persons: extract_persons_core(text),
register_type: extract_register_type_core(text),
supervisory_authority: extract_supervisory_authority_core(text),
professional_chamber: extract_professional_chamber_core(text),
de_mail: extract_de_mail_core(text),
dispute_resolution_url: extract_dispute_resolution_url_core(text),
}
}
pub fn extract_emails(text: &str) -> Vec<String> {
extract_emails_core(&normalize::normalize_text(text))
}
fn extract_emails_core(text: &str) -> Vec<String> {
let mut emails: BTreeSet<String> = BTreeSet::new();
for m in EMAIL_RE.find_iter(text) {
let e = m.as_str().to_ascii_lowercase();
if is_plausible_email(&e) {
emails.insert(e);
}
}
for cap in EMAIL_OBFUSCATED_RE.captures_iter(text) {
if let (Some(local), Some(host), Some(tld)) = (cap.get(1), cap.get(2), cap.get(3)) {
let e = format!("{}@{}.{}", local.as_str(), host.as_str(), tld.as_str())
.to_ascii_lowercase();
if is_plausible_email(&e) {
emails.insert(e);
}
}
}
emails.into_iter().collect()
}
pub fn extract_phones(text: &str) -> Vec<String> {
let normalized = normalize::normalize_text(text);
collect_phones(normalized.as_str(), &[])
}
fn collect_phones(text: &str, skip_spans: &[(usize, usize)]) -> Vec<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
for m in PHONE_RE.find_iter(text) {
if skip_spans
.iter()
.any(|(s, e)| m.start() < *e && m.end() > *s)
{
continue;
}
let p = clean_phone(m.as_str());
if p.len() >= 7 {
out.insert(p);
}
}
out.into_iter().collect()
}
pub fn extract_fax(text: &str) -> Option<String> {
extract_fax_core(&normalize::normalize_text(text))
}
fn extract_fax_core(text: &str) -> Option<String> {
let cap = FAX_RE.captures(text)?;
let p = clean_phone(cap.get(1)?.as_str());
if p.len() >= 7 { Some(p) } else { None }
}
pub fn extract_iban(text: &str) -> Option<String> {
extract_iban_core(&normalize::normalize_text(text))
}
fn extract_iban_core(text: &str) -> Option<String> {
let m = IBAN_DE_RE.find(text)?;
let normalized: String = m
.as_str()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
if normalized.len() == 22 {
Some(normalized)
} else {
None
}
}
pub fn extract_bic(text: &str) -> Option<String> {
extract_bic_core(&normalize::normalize_text(text))
}
fn extract_bic_core(text: &str) -> Option<String> {
for m in BIC_RE.find_iter(text) {
let candidate = m.as_str().to_ascii_uppercase();
let start = m.start();
let ctx_start = floor_char_boundary(text, start.saturating_sub(40));
let ctx = text[ctx_start..start].to_ascii_lowercase();
if ctx.contains("bic") || ctx.contains("swift") || ctx.contains("bankverbindung") {
return Some(candidate);
}
}
None
}
pub fn extract_address(text: &str) -> (Option<String>, Option<String>, Option<String>) {
let doc = segment::Document::parse(normalize::normalize_text(text));
address_from_document(&doc)
}
pub fn extract_addresses(text: &str) -> Vec<Address> {
let doc = segment::Document::parse(normalize::normalize_text(text));
addresses_from_document(&doc)
}
fn addresses_from_document(doc: &segment::Document) -> Vec<Address> {
let mut out: Vec<Address> = Vec::new();
for block in doc.block_texts() {
let pc = parse_postcode_city(block);
let street = parse_street(block);
if pc.is_none() && street.is_none() {
continue;
}
let (postcode, city) = match pc {
Some((code, city)) => (Some(code), Some(city)),
None => (None, None),
};
let addr = Address {
postcode,
city,
street,
};
if !out.contains(&addr) {
out.push(addr);
}
}
out
}
fn parse_postcode_city(block: &str) -> Option<(String, String)> {
let cap = GERMAN_POSTCODE_AND_CITY_RE.captures(block)?;
Some((
cap.get(1)?.as_str().to_string(),
cap.get(2)?.as_str().trim().to_string(),
))
}
fn parse_street(block: &str) -> Option<String> {
let cap = STREET_RE.captures(block)?;
Some(format!(
"{} {}",
cap.get(1).map(|m| m.as_str().trim()).unwrap_or(""),
cap.get(2).map(|m| m.as_str().trim()).unwrap_or("")
))
}
fn address_from_document(
doc: &segment::Document,
) -> (Option<String>, Option<String>, Option<String>) {
use candidate::Candidate;
use segment::LabelKind;
let mut pc_cands: Vec<Candidate<(String, String)>> = Vec::new();
let mut street_cands: Vec<Candidate<String>> = Vec::new();
for (idx, block) in doc.block_texts().enumerate() {
let pc = parse_postcode_city(block);
let st = parse_street(block);
if let (Some((code, city)), Some(street)) = (&pc, &st) {
return (Some(code.clone()), Some(city.clone()), Some(street.clone()));
}
if let Some(pcv) = pc {
pc_cands.push(Candidate::new(pcv, 0..0, idx, Some(LabelKind::Postal)));
}
if let Some(sv) = st {
street_cands.push(Candidate::new(sv, 0..0, idx, None));
}
}
let (postcode, city) = match pc_cands.into_iter().next() {
Some(c) => (Some(c.value.0), Some(c.value.1)),
None => (None, None),
};
let street = street_cands.into_iter().next().map(|c| c.value);
(postcode, city, street)
}
pub fn extract_legal_form(text: &str) -> Option<String> {
extract_legal_form_core(&normalize::normalize_text(text))
}
fn extract_legal_form_core(text: &str) -> Option<String> {
LEGAL_FORM_RE
.find(text)
.map(|m| canonicalize_legal_form(m.as_str()))
}
pub fn extract_hr_number(text: &str) -> Option<String> {
extract_hr_number_core(&normalize::normalize_text(text))
}
fn extract_hr_number_core(text: &str) -> Option<String> {
HR_NUMBER_RE.captures(text).map(|c| {
c.get(0)
.unwrap()
.as_str()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
}
pub fn extract_hr_court(text: &str) -> Option<String> {
extract_hr_court_core(&normalize::normalize_text(text))
}
fn extract_hr_court_core(text: &str) -> Option<String> {
HR_COURT_RE
.captures(text)
.and_then(|c| c.get(1))
.map(|m| clean_hr_court(m.as_str()))
.filter(|s| !s.is_empty())
}
pub fn extract_tax_number(text: &str) -> Option<String> {
extract_tax_number_core(&normalize::normalize_text(text))
}
fn extract_tax_number_core(text: &str) -> Option<String> {
TAX_NUMBER_RE
.captures(text)
.and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))
}
pub fn extract_vat_id(text: &str) -> Option<String> {
extract_vat_id_core(&normalize::normalize_text(text))
}
fn extract_vat_id_core(text: &str) -> Option<String> {
let iban_spans: Vec<(usize, usize)> = IBAN_DE_RE
.find_iter(text)
.map(|m| (m.start(), m.end()))
.collect();
for m in VAT_DE_RE.find_iter(text) {
let overlaps_iban = iban_spans
.iter()
.any(|(s, e)| m.start() < *e && m.end() > *s);
if overlaps_iban {
continue;
}
let normalized: String = m
.as_str()
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.collect();
return Some(normalized.to_ascii_uppercase());
}
None
}
pub fn extract_year_founded(text: &str) -> Option<i32> {
extract_year_founded_core(&normalize::normalize_text(text))
}
fn extract_year_founded_core(text: &str) -> Option<i32> {
let cap = YEAR_FOUNDED_RE.captures(text)?;
let y: i32 = cap.get(1)?.as_str().parse().ok()?;
if (1700..=2100).contains(&y) {
Some(y)
} else {
None
}
}
pub fn extract_persons(text: &str) -> Vec<Person> {
extract_persons_core(&normalize::normalize_text(text))
}
fn extract_persons_core(text: &str) -> Vec<Person> {
let mut out = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for cap in GF_REGEX.captures_iter(text) {
let Some(m) = cap.get(1) else { continue };
let role = detect_role(cap.get(0).map(|g| g.as_str()).unwrap_or(""));
let trimmed = truncate_at_sentence_end(m.as_str().trim());
for piece in split_persons(&trimmed) {
let p = parse_person(&piece, role.clone());
if p.last_name.is_none() {
continue;
}
let key = format!(
"{}_{}",
p.first_name.clone().unwrap_or_default(),
p.last_name.clone().unwrap_or_default()
);
if seen.insert(key) {
out.push(p);
}
}
}
out
}
pub fn extract_supervisory_authority(text: &str) -> Option<String> {
extract_supervisory_authority_core(&normalize::normalize_text(text))
}
fn extract_supervisory_authority_core(text: &str) -> Option<String> {
SUPERVISORY_AUTHORITY_RE
.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn extract_professional_chamber(text: &str) -> Option<String> {
extract_professional_chamber_core(&normalize::normalize_text(text))
}
fn extract_professional_chamber_core(text: &str) -> Option<String> {
CHAMBER_RE
.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn extract_de_mail(text: &str) -> Option<String> {
extract_de_mail_core(&normalize::normalize_text(text))
}
fn extract_de_mail_core(text: &str) -> Option<String> {
DE_MAIL_RE
.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_ascii_lowercase())
}
pub fn extract_dispute_resolution_url(text: &str) -> Option<String> {
extract_dispute_resolution_url_core(&normalize::normalize_text(text))
}
fn extract_dispute_resolution_url_core(text: &str) -> Option<String> {
ODR_URL_RE.find(text).map(|m| m.as_str().to_string())
}
pub fn extract_register_type(text: &str) -> Option<String> {
extract_register_type_core(&normalize::normalize_text(text))
}
fn extract_register_type_core(text: &str) -> Option<String> {
let hr = extract_hr_number_core(text)?;
let upper = hr.to_ascii_uppercase();
if upper.starts_with("HRB") {
Some("HRB".to_string())
} else if upper.starts_with("HRA") {
Some("HRA".to_string())
} else {
None
}
}
fn floor_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn clean_phone(s: &str) -> String {
let s = s.nfc().collect::<String>();
let s: String = s
.chars()
.filter(|c| c.is_ascii_digit() || *c == '+')
.collect();
if let Some(rest) = s.strip_prefix("0049") {
format!("+49{}", rest)
} else if let Some(rest) = s.strip_prefix('0') {
if rest.starts_with('+') {
s
} else {
format!("+49{}", rest)
}
} else {
s
}
}
fn clean_hr_court(raw: &str) -> String {
let trimmed = raw.trim();
let cut = HR_SUFFIX_RE
.find(trimmed)
.map(|m| m.start())
.unwrap_or(trimmed.len());
trimmed[..cut].trim().to_string()
}
fn canonicalize_legal_form(raw: &str) -> String {
let r = raw.trim();
let lower = r.to_ascii_lowercase();
if lower.contains("co") && lower.contains("kgaa") {
"GmbH & Co. KGaA".to_string()
} else if lower.contains("kgaa") {
"KGaA".to_string()
} else if lower.contains("co") && lower.contains("kg") {
"GmbH & Co. KG".to_string()
} else if r.to_ascii_uppercase().contains("GMBH") {
"GmbH".to_string()
} else if r.to_ascii_uppercase().contains("UG") {
"UG".to_string()
} else {
r.to_string()
}
}
fn truncate_at_sentence_end(s: &str) -> String {
let stop_words = [
" Sitz",
" Tel",
" Telefon",
" Fax",
" E-Mail",
" Email",
" USt",
" Eingetragen",
" HRB",
" HRA",
" Steuer",
" Adresse",
" Anschrift",
" Datenschutz",
" Web",
" Geschäfts",
" Handelsregister",
" Amtsgericht",
" UID",
];
let mut end = s.len();
for sw in stop_words {
if let Some(pos) = s.find(sw) {
end = end.min(pos);
}
}
if let Some(pos) = s.find(['\n', '\r']) {
end = end.min(pos);
}
let end = floor_char_boundary(s, end);
s[..end]
.trim()
.trim_end_matches(',')
.trim_end_matches('.')
.to_string()
}
fn split_persons(raw: &str) -> Vec<String> {
let separators = [" und ", " sowie ", " & ", ",", ";", " / "];
let mut parts = vec![raw.to_string()];
for sep in separators {
let mut next = Vec::new();
for p in parts.drain(..) {
for sub in p.split(sep) {
let s = sub.trim();
if !s.is_empty() {
next.push(s.to_string());
}
}
}
parts = next;
}
parts
}
fn parse_person(raw: &str, role: Option<String>) -> Person {
let cleaned = strip_titles(raw);
let parts: Vec<&str> = cleaned
.split_whitespace()
.filter(|t| is_valid_name_part(t))
.collect();
match parts.len() {
0 => Person {
full_raw: raw.to_string(),
role,
..Default::default()
},
1 => Person {
last_name: Some(parts[0].to_string()),
full_raw: raw.to_string(),
role,
..Default::default()
},
_ => Person {
first_name: Some(parts[0].to_string()),
last_name: Some(parts[parts.len() - 1].to_string()),
full_raw: raw.to_string(),
role,
},
}
}
fn detect_role(full_match: &str) -> Option<String> {
let lower = full_match.to_ascii_lowercase();
if lower.contains("gesch") {
Some("Geschäftsführer".to_string())
} else if lower.contains("vorstand") {
Some("Vorstand".to_string())
} else if lower.contains("verantwortlich") {
Some("Verantwortlich".to_string())
} else if lower.contains("inhaber") {
Some("Inhaber".to_string())
} else {
None
}
}
fn is_valid_name_part(s: &str) -> bool {
let trimmed = s.trim().trim_matches(|c: char| !c.is_alphanumeric());
if trimmed.chars().count() <= 1 {
return false;
}
if trimmed.chars().any(|c| c.is_ascii_digit()) {
return false;
}
if NOT_A_NAME.contains(&trimmed.to_lowercase().as_str()) {
return false;
}
match trimmed.chars().next() {
Some(first) => first.is_uppercase(),
None => false,
}
}
fn strip_titles(s: &str) -> String {
const TITLES: &[&str] = &[
"dr",
"dr.",
"prof",
"prof.",
"dipl.-ing.",
"dipl.-kfm.",
"dipl.-ök.",
"dipl",
"b.sc.",
"m.sc.",
"mba",
"b.a.",
"m.a.",
"ll.m.",
"ll.b.",
"herr",
"frau",
"ra",
"h.c.",
];
s.split_whitespace()
.filter(|token| {
let key = token.to_ascii_lowercase();
!TITLES.contains(&key.as_str())
})
.collect::<Vec<_>>()
.join(" ")
}
fn is_plausible_email(email: &str) -> bool {
let Some((_, domain)) = email.rsplit_once('@') else {
return false;
};
if BLOCKED_EMAIL_DOMAINS.contains(&domain) {
return false;
}
match domain.rsplit_once('.') {
Some((_, tld)) => REAL_TLDS.contains(&tld),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_basic_and_obfuscated() {
let txt = "info@a.de info [at] b [dot] de support@a.de";
let e = extract_emails(txt);
assert!(e.contains(&"info@a.de".to_string()));
assert!(e.contains(&"info@b.de".to_string()));
assert!(e.contains(&"support@a.de".to_string()));
}
#[test]
fn phones_normalised() {
let p = extract_phones("Tel +49 30 12345-67 oder 0049 30 12345 68 oder 030/123 45 69");
assert!(p.iter().any(|x| x.starts_with("+493012345")));
}
#[test]
fn legal_forms() {
assert_eq!(
extract_legal_form("Muster GmbH & Co. KG"),
Some("GmbH & Co. KG".into())
);
assert_eq!(extract_legal_form("Muster GmbH"), Some("GmbH".into()));
assert_eq!(
extract_legal_form("Muster UG (haftungsbeschränkt)"),
Some("UG".into())
);
}
#[test]
fn year_founded_pattern() {
assert_eq!(
extract_year_founded("Unser Unternehmen wurde gegründet 1973"),
Some(1973)
);
assert_eq!(extract_year_founded("Founded in 2010"), Some(2010));
}
#[test]
fn vat_id_extracted() {
assert_eq!(
extract_vat_id("USt-ID: DE 123456789"),
Some("DE123456789".into())
);
}
#[test]
fn hr_number_extracted() {
assert!(extract_hr_number("Eingetragen im Handelsregister Berlin HRB 12345 B").is_some());
}
#[test]
fn persons_with_titles_and_connectors() {
let p = extract_persons("Geschäftsführer: Dr. Hans Müller und Anna Schmidt");
assert!(p.iter().any(|x| x.last_name.as_deref() == Some("Müller")));
assert!(p.iter().any(|x| x.last_name.as_deref() == Some("Schmidt")));
}
#[test]
fn full_extract() {
let text = "
Musterreinigung GmbH & Co. KG
Geschäftsführer: Hans Müller
Hauptstraße 12, 10115 Berlin
Tel: +49 30 1234567
E-Mail: info@musterreinigung.de
Eingetragen im Handelsregister Berlin HRB 12345 B
USt-IdNr.: DE 123456789
Gegründet 1985
";
let d = extract_all(text);
assert_eq!(d.legal_form.as_deref(), Some("GmbH & Co. KG"));
assert_eq!(d.postcode.as_deref(), Some("10115"));
assert!(d.emails.contains(&"info@musterreinigung.de".to_string()));
assert_eq!(d.year_founded, Some(1985));
assert_eq!(d.vat_id.as_deref(), Some("DE123456789"));
assert!(
d.persons
.iter()
.any(|p| p.last_name.as_deref() == Some("Müller"))
);
}
}