1#![forbid(unsafe_code)]
74#![warn(missing_docs)]
75
76use std::collections::BTreeSet;
77
78use regex::Regex;
79use std::sync::LazyLock;
80use unicode_normalization::UnicodeNormalization;
81
82mod normalize;
83mod score;
84mod scored;
85
86#[cfg(feature = "html")]
87mod html;
88
89#[allow(dead_code)]
94mod segment;
95
96#[allow(dead_code)]
100mod candidate;
101
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105#[non_exhaustive]
106pub struct Extracted {
107 pub emails: Vec<String>,
109 pub phones: Vec<String>,
112 pub fax: Option<String>,
114 pub postcode: Option<String>,
116 pub city: Option<String>,
118 pub street: Option<String>,
120 pub hr_number: Option<String>,
122 pub hr_court: Option<String>,
124 pub vat_id: Option<String>,
126 pub tax_number: Option<String>,
128 pub iban: Option<String>,
130 pub bic: Option<String>,
132 pub legal_form: Option<String>,
134 pub year_founded: Option<i32>,
136 pub persons: Vec<Person>,
139 pub register_type: Option<String>,
141 pub supervisory_authority: Option<String>,
143 pub professional_chamber: Option<String>,
145 pub de_mail: Option<String>,
147 pub dispute_resolution_url: Option<String>,
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Eq)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154pub struct Person {
155 pub first_name: Option<String>,
157 pub last_name: Option<String>,
159 pub full_raw: String,
161 pub role: Option<String>,
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
171pub struct Address {
172 pub postcode: Option<String>,
174 pub city: Option<String>,
176 pub street: Option<String>,
178}
179
180pub use scored::{Scored, ScoredExtracted};
181
182static EMAIL_RE: LazyLock<Regex> =
185 LazyLock::new(|| Regex::new(r"(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}").unwrap());
186
187static EMAIL_OBFUSCATED_RE: LazyLock<Regex> = LazyLock::new(|| {
188 Regex::new(
189 r"(?i)\b([a-z0-9._%+\-]+)\s*[\[\(]?\s*(?:at|@)\s*[\]\)]?\s*([a-z0-9.\-]+)\s*[\[\(]?\s*(?:dot|\.)\s*[\]\)]?\s*([a-z]{2,})\b",
190 )
191 .unwrap()
192});
193
194static PHONE_RE: LazyLock<Regex> = LazyLock::new(|| {
195 Regex::new(
196 r"(?x)
197 (?:\+49|0049|0)
198 [\s\-/]?
199 (?:\(0?\)|\d{1,5})
200 [\s\-/]?
201 \d{2,4}
202 [\s\-/]?
203 \d{2,4}
204 (?:[\s\-/]?\d{0,4})?
205 ",
206 )
207 .unwrap()
208});
209
210static GERMAN_POSTCODE_AND_CITY_RE: LazyLock<Regex> =
211 LazyLock::new(|| Regex::new(r"\b(\d{5})\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-/. ]{1,40})\b").unwrap());
212
213static STREET_RE: LazyLock<Regex> = LazyLock::new(|| {
214 Regex::new(
215 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",
216 )
217 .unwrap()
218});
219
220static HR_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
221 Regex::new(r"(?i)\bHR[AB]?\s*[:\-]?\s*(?:Nr\.?\s*)?(\d{2,7}(?:\s*[A-Z])?)\b").unwrap()
222});
223
224static HR_COURT_RE: LazyLock<Regex> = LazyLock::new(|| {
229 Regex::new(r"(?i)Amtsgericht\s+([A-ZÄÖÜ][A-Za-zÄÖÜäöüß\-./ ()]{1,60})").unwrap()
230});
231
232static HR_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+HR[AB]?\b").unwrap());
236
237static VAT_DE_RE: LazyLock<Regex> =
240 LazyLock::new(|| Regex::new(r"(?i)\bDE[\s\-]?(?:\d[\s\-]?){8}\d\b").unwrap());
241
242static IBAN_DE_RE: LazyLock<Regex> =
245 LazyLock::new(|| Regex::new(r"(?i)\bDE(?:[\s\-]?\d){20}\b").unwrap());
246
247static BIC_RE: LazyLock<Regex> =
251 LazyLock::new(|| Regex::new(r"(?i)\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b").unwrap());
252
253static FAX_RE: LazyLock<Regex> = LazyLock::new(|| {
255 Regex::new(
256 r"(?ix)
257 (?:tele)?fax\.?\s*[:\-]?\s*
258 ((?:\+49|0049|0)
259 [\s\-/]?(?:\(0?\)|\d{1,5})
260 [\s\-/]?\d{2,4}
261 [\s\-/]?\d{2,4}
262 (?:[\s\-/]?\d{0,4})?)
263 ",
264 )
265 .unwrap()
266});
267
268static TAX_NUMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
271 Regex::new(
272 r"(?ix)
273 (?:Steuer-?nummer | Steuer-?Nr\.? | St\.?-?Nr\.?)
274 [:\s]*
275 ([\d/\s]{8,20})
276 ",
277 )
278 .unwrap()
279});
280
281static GF_REGEX: LazyLock<Regex> = LazyLock::new(|| {
282 Regex::new(
283 r"(?ix)
284 (?:
285 Gesch(?:ä|ae)ftsf(?:ü|ue)hrer(?:in)?
286 | Inhaber(?:in)?
287 | Vorstand
288 | Vertretungsberechtigt(?:e[rn]?)?
289 | vertreten\s+durch
290 | Verantwortlich(?:e[rn]?)?\s+f(?:ü|ue)r\s+den\s+Inhalt
291 | Redaktionell\s+verantwortlich
292 | Verantwortlich(?:e[rn]?)?(?:\s+(?:i\.?\s?S\.?\s?[dv]\.?|gem(?:ä|ae)ß|nach)[^:\n]{0,40})?
293 )
294 [\s:\-]+
295 (?:sind\s+|ist\s+)?
296 ([A-ZÄÖÜ][\w\s.\-,&/äöüÄÖÜß]{2,160})
297 ",
298 )
299 .unwrap()
300});
301
302static YEAR_FOUNDED_RE: LazyLock<Regex> = LazyLock::new(|| {
303 Regex::new(
304 r"(?ix)
305 \b
306 (?:gegr(?:ü|ue)ndet|seit|founded\s*in?|since|established\s*in?|gründungsjahr)
307 \s*[:\-]?\s*
308 (\d{4})
309 \b
310 ",
311 )
312 .unwrap()
313});
314
315static LEGAL_FORM_RE: LazyLock<Regex> = LazyLock::new(|| {
316 Regex::new(
317 r"(?ix)
318 \b
319 (
320 GmbH\s*&\s*Co\.?\s*KGaA
321 | GmbH\s*&\s*Co\.?\s*KG
322 | KGaA
323 | GmbH
324 | UG\s*\(?haftungsbeschr(?:ä|ae)nkt\)?
325 | UG
326 | AG
327 | KG
328 | OHG
329 | GbR
330 | e\.K\.
331 | eG
332 | SE
333 )
334 \b
335 ",
336 )
337 .unwrap()
338});
339
340static SUPERVISORY_AUTHORITY_RE: LazyLock<Regex> = LazyLock::new(|| {
341 Regex::new(r"(?im)^\s*Aufsichtsbeh(?:ö|oe)rde\s*[:\-]?\s*([^\n]{2,100})").unwrap()
342});
343
344static CHAMBER_RE: LazyLock<Regex> = LazyLock::new(|| {
345 Regex::new(r"(?im)^\s*(?:zust(?:ä|ae)ndige\s+Kammer|Berufskammer)\s*[:\-]?\s*([^\n]{2,100})")
346 .unwrap()
347});
348
349static DE_MAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
350 Regex::new(r"(?i)\bDe-?Mail\s*[:\-]?\s*([a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,})").unwrap()
351});
352
353static ODR_URL_RE: LazyLock<Regex> =
354 LazyLock::new(|| Regex::new(r"(?i)https?://(?:www\.)?ec\.europa\.eu/consumers/odr/?").unwrap());
355
356const REAL_TLDS: &[&str] = &[
367 "com", "net", "org", "info", "biz", "name", "pro", "io", "co", "gov", "edu", "mil", "int", "eu",
369 "app", "dev", "shop", "online", "site", "tech", "gmbh", "email", "de", "berlin",
370 "at", "ch", "li", "lu", "nl", "be", "fr", "it", "es", "pt", "pl", "cz", "sk", "hu", "dk", "se",
372 "no", "fi", "is", "ie", "uk", "gr", "ro", "bg", "hr", "si", "rs", "ua",
373 "us", "ca", "mx", "br", "ar", "au", "nz", "jp", "cn", "kr", "in", "sg", "hk", "tw", "za", "ru",
375 "tr", "ae", "sa", "il",
376];
377
378const BLOCKED_EMAIL_DOMAINS: &[&str] = &["sapp.com", "tet.soweit"];
380
381const NOT_A_NAME: &[&str] = &[
384 "der",
386 "die",
387 "das",
388 "den",
389 "dem",
390 "des",
391 "ein",
392 "eine",
393 "einem",
394 "einer",
395 "einen",
396 "eines",
397 "in",
399 "an",
400 "auf",
401 "zu",
402 "zum",
403 "zur",
404 "von",
405 "bei",
406 "mit",
407 "nach",
408 "vor",
409 "aus",
410 "durch",
411 "ohne",
412 "gegen",
413 "um",
414 "für",
415 "über",
416 "unter",
417 "zwischen",
418 "als",
419 "sich",
421 "uns",
422 "wir",
423 "ihre",
424 "ihren",
425 "ihrer",
426 "seinen",
427 "seine",
428 "seiner",
429 "deren",
430 "dessen",
431 "nicht",
432 "auch",
433 "nur",
434 "noch",
435 "schon",
436 "sowie",
437 "und",
438 "oder",
439 "ist",
441 "hat",
442 "sind",
443 "haben",
444 "wird",
445 "werden",
446 "kann",
447 "soll",
448 "bemühen",
449 "sollten",
450 "vertreten",
451 "geschäftsführer",
453 "geschäftsführerin",
454 "geschäftsführung",
455 "gesellschafterin",
456 "gesellschafter",
457 "vorstand",
458 "vorständin",
459 "inhaber",
460 "inhaberin",
461 "vertretungsberechtigt",
462 "verantwortlich",
463 "domain",
464 "inhalt",
465 "inhalte",
466 "gmbh",
467 "ug",
468 "ag",
469 "ohg",
470 "gbr",
471 "kg",
472 "eg",
473 "team",
475 "kontakt",
476 "impressum",
477 "datenschutz",
478 "vertrieb",
479 "büro",
480 "sekretariat",
481 "webdesign",
482 "webseite",
483 "homepage",
484 "copyright",
485 "firma",
486 "unternehmen",
487 "postfach",
488 "telefon",
489 "telefax",
490 "mobil",
491 "adresse",
492];
493
494pub fn extract_all(text: &str) -> Extracted {
501 let doc = segment::Document::parse(normalize::normalize_text(text));
502 build_extracted(&doc)
503}
504
505#[cfg(feature = "html")]
510pub fn extract_all_html(html: &str) -> Extracted {
511 let doc = segment::Document::parse(html::html_to_impressum_text(html));
512 build_extracted(&doc)
513}
514
515#[cfg(feature = "html")]
519pub use html::html_to_impressum_text;
520
521pub fn extract_all_scored(text: &str) -> ScoredExtracted {
526 let doc = segment::Document::parse(normalize::normalize_text(text));
527 score::score_extracted(build_extracted(&doc), &doc)
528}
529
530#[cfg(feature = "html")]
532pub fn extract_all_scored_html(html: &str) -> ScoredExtracted {
533 let doc = segment::Document::parse(html::html_to_impressum_text(html));
534 score::score_extracted(build_extracted(&doc), &doc)
535}
536
537fn build_extracted(doc: &segment::Document) -> Extracted {
538 let text = doc.text();
539 let fax = extract_fax_core(text);
541 let iban_spans: Vec<(usize, usize)> = IBAN_DE_RE
544 .find_iter(text)
545 .map(|m| (m.start(), m.end()))
546 .collect();
547 let phones = collect_phones(text, &iban_spans)
548 .into_iter()
549 .filter(|p| fax.as_deref() != Some(p.as_str()))
550 .collect();
551
552 let (postcode, city, street) = address_from_document(doc);
553
554 let hr_number = extract_hr_number_core(text);
555 let hr_court = extract_hr_court_core(text);
556 let tax_number = extract_tax_number_core(text);
557
558 Extracted {
559 emails: extract_emails_core(text),
560 phones,
561 fax,
562 postcode,
563 city,
564 street,
565 hr_number,
566 hr_court,
567 vat_id: extract_vat_id_core(text),
568 tax_number,
569 iban: extract_iban_core(text),
570 bic: extract_bic_core(text),
571 legal_form: extract_legal_form_core(text),
572 year_founded: extract_year_founded_core(text),
573 persons: extract_persons_core(text),
574 register_type: extract_register_type_core(text),
575 supervisory_authority: extract_supervisory_authority_core(text),
576 professional_chamber: extract_professional_chamber_core(text),
577 de_mail: extract_de_mail_core(text),
578 dispute_resolution_url: extract_dispute_resolution_url_core(text),
579 }
580}
581
582pub fn extract_emails(text: &str) -> Vec<String> {
586 extract_emails_core(&normalize::normalize_text(text))
587}
588
589fn extract_emails_core(text: &str) -> Vec<String> {
590 let mut emails: BTreeSet<String> = BTreeSet::new();
591 for m in EMAIL_RE.find_iter(text) {
592 let e = m.as_str().to_ascii_lowercase();
593 if is_plausible_email(&e) {
594 emails.insert(e);
595 }
596 }
597 for cap in EMAIL_OBFUSCATED_RE.captures_iter(text) {
598 if let (Some(local), Some(host), Some(tld)) = (cap.get(1), cap.get(2), cap.get(3)) {
599 let e = format!("{}@{}.{}", local.as_str(), host.as_str(), tld.as_str())
600 .to_ascii_lowercase();
601 if is_plausible_email(&e) {
602 emails.insert(e);
603 }
604 }
605 }
606 emails.into_iter().collect()
607}
608
609pub fn extract_phones(text: &str) -> Vec<String> {
615 let normalized = normalize::normalize_text(text);
616 collect_phones(normalized.as_str(), &[])
617}
618
619fn collect_phones(text: &str, skip_spans: &[(usize, usize)]) -> Vec<String> {
622 let mut out: BTreeSet<String> = BTreeSet::new();
623 for m in PHONE_RE.find_iter(text) {
624 if skip_spans
625 .iter()
626 .any(|(s, e)| m.start() < *e && m.end() > *s)
627 {
628 continue;
629 }
630 let p = clean_phone(m.as_str());
631 if p.len() >= 7 {
632 out.insert(p);
633 }
634 }
635 out.into_iter().collect()
636}
637
638pub fn extract_fax(text: &str) -> Option<String> {
640 extract_fax_core(&normalize::normalize_text(text))
641}
642
643fn extract_fax_core(text: &str) -> Option<String> {
644 let cap = FAX_RE.captures(text)?;
645 let p = clean_phone(cap.get(1)?.as_str());
646 if p.len() >= 7 { Some(p) } else { None }
647}
648
649pub fn extract_iban(text: &str) -> Option<String> {
651 extract_iban_core(&normalize::normalize_text(text))
652}
653
654fn extract_iban_core(text: &str) -> Option<String> {
655 let m = IBAN_DE_RE.find(text)?;
656 let normalized: String = m
657 .as_str()
658 .chars()
659 .filter(|c| c.is_ascii_alphanumeric())
660 .collect::<String>()
661 .to_ascii_uppercase();
662 if normalized.len() == 22 {
664 Some(normalized)
665 } else {
666 None
667 }
668}
669
670pub fn extract_bic(text: &str) -> Option<String> {
674 extract_bic_core(&normalize::normalize_text(text))
675}
676
677fn extract_bic_core(text: &str) -> Option<String> {
678 for m in BIC_RE.find_iter(text) {
679 let candidate = m.as_str().to_ascii_uppercase();
680 let start = m.start();
683 let ctx_start = floor_char_boundary(text, start.saturating_sub(40));
687 let ctx = text[ctx_start..start].to_ascii_lowercase();
688 if ctx.contains("bic") || ctx.contains("swift") || ctx.contains("bankverbindung") {
689 return Some(candidate);
690 }
691 }
692 None
693}
694
695pub fn extract_address(text: &str) -> (Option<String>, Option<String>, Option<String>) {
700 let doc = segment::Document::parse(normalize::normalize_text(text));
701 address_from_document(&doc)
702}
703
704pub fn extract_addresses(text: &str) -> Vec<Address> {
720 let doc = segment::Document::parse(normalize::normalize_text(text));
721 addresses_from_document(&doc)
722}
723
724fn addresses_from_document(doc: &segment::Document) -> Vec<Address> {
725 let mut out: Vec<Address> = Vec::new();
726 for block in doc.block_texts() {
727 let pc = parse_postcode_city(block);
728 let street = parse_street(block);
729 if pc.is_none() && street.is_none() {
730 continue;
731 }
732 let (postcode, city) = match pc {
733 Some((code, city)) => (Some(code), Some(city)),
734 None => (None, None),
735 };
736 let addr = Address {
737 postcode,
738 city,
739 street,
740 };
741 if !out.contains(&addr) {
742 out.push(addr);
743 }
744 }
745 out
746}
747
748fn parse_postcode_city(block: &str) -> Option<(String, String)> {
749 let cap = GERMAN_POSTCODE_AND_CITY_RE.captures(block)?;
750 Some((
751 cap.get(1)?.as_str().to_string(),
752 cap.get(2)?.as_str().trim().to_string(),
753 ))
754}
755
756fn parse_street(block: &str) -> Option<String> {
757 let cap = STREET_RE.captures(block)?;
758 Some(format!(
759 "{} {}",
760 cap.get(1).map(|m| m.as_str().trim()).unwrap_or(""),
761 cap.get(2).map(|m| m.as_str().trim()).unwrap_or("")
762 ))
763}
764
765fn address_from_document(
766 doc: &segment::Document,
767) -> (Option<String>, Option<String>, Option<String>) {
768 use candidate::Candidate;
769 use segment::LabelKind;
770
771 let mut pc_cands: Vec<Candidate<(String, String)>> = Vec::new();
772 let mut street_cands: Vec<Candidate<String>> = Vec::new();
773
774 for (idx, block) in doc.block_texts().enumerate() {
775 let pc = parse_postcode_city(block);
776 let st = parse_street(block);
777 if let (Some((code, city)), Some(street)) = (&pc, &st) {
779 return (Some(code.clone()), Some(city.clone()), Some(street.clone()));
780 }
781 if let Some(pcv) = pc {
782 pc_cands.push(Candidate::new(pcv, 0..0, idx, Some(LabelKind::Postal)));
783 }
784 if let Some(sv) = st {
785 street_cands.push(Candidate::new(sv, 0..0, idx, None));
787 }
788 }
789
790 let (postcode, city) = match pc_cands.into_iter().next() {
792 Some(c) => (Some(c.value.0), Some(c.value.1)),
793 None => (None, None),
794 };
795 let street = street_cands.into_iter().next().map(|c| c.value);
796 (postcode, city, street)
797}
798
799pub fn extract_legal_form(text: &str) -> Option<String> {
801 extract_legal_form_core(&normalize::normalize_text(text))
802}
803
804fn extract_legal_form_core(text: &str) -> Option<String> {
805 LEGAL_FORM_RE
806 .find(text)
807 .map(|m| canonicalize_legal_form(m.as_str()))
808}
809
810pub fn extract_hr_number(text: &str) -> Option<String> {
812 extract_hr_number_core(&normalize::normalize_text(text))
813}
814
815fn extract_hr_number_core(text: &str) -> Option<String> {
816 HR_NUMBER_RE.captures(text).map(|c| {
817 c.get(0)
818 .unwrap()
819 .as_str()
820 .split_whitespace()
821 .collect::<Vec<_>>()
822 .join(" ")
823 })
824}
825
826pub fn extract_hr_court(text: &str) -> Option<String> {
833 extract_hr_court_core(&normalize::normalize_text(text))
834}
835
836fn extract_hr_court_core(text: &str) -> Option<String> {
837 HR_COURT_RE
838 .captures(text)
839 .and_then(|c| c.get(1))
840 .map(|m| clean_hr_court(m.as_str()))
841 .filter(|s| !s.is_empty())
842}
843
844pub fn extract_tax_number(text: &str) -> Option<String> {
849 extract_tax_number_core(&normalize::normalize_text(text))
850}
851
852fn extract_tax_number_core(text: &str) -> Option<String> {
853 TAX_NUMBER_RE
854 .captures(text)
855 .and_then(|c| c.get(1).map(|m| m.as_str().trim().to_string()))
856}
857
858pub fn extract_vat_id(text: &str) -> Option<String> {
864 extract_vat_id_core(&normalize::normalize_text(text))
865}
866
867fn extract_vat_id_core(text: &str) -> Option<String> {
868 let iban_spans: Vec<(usize, usize)> = IBAN_DE_RE
869 .find_iter(text)
870 .map(|m| (m.start(), m.end()))
871 .collect();
872 for m in VAT_DE_RE.find_iter(text) {
873 let overlaps_iban = iban_spans
874 .iter()
875 .any(|(s, e)| m.start() < *e && m.end() > *s);
876 if overlaps_iban {
877 continue;
878 }
879 let normalized: String = m
880 .as_str()
881 .chars()
882 .filter(|c| !c.is_whitespace() && *c != '-')
883 .collect();
884 return Some(normalized.to_ascii_uppercase());
885 }
886 None
887}
888
889pub fn extract_year_founded(text: &str) -> Option<i32> {
891 extract_year_founded_core(&normalize::normalize_text(text))
892}
893
894fn extract_year_founded_core(text: &str) -> Option<i32> {
895 let cap = YEAR_FOUNDED_RE.captures(text)?;
896 let y: i32 = cap.get(1)?.as_str().parse().ok()?;
897 if (1700..=2100).contains(&y) {
898 Some(y)
899 } else {
900 None
901 }
902}
903
904pub fn extract_persons(text: &str) -> Vec<Person> {
906 extract_persons_core(&normalize::normalize_text(text))
907}
908
909fn extract_persons_core(text: &str) -> Vec<Person> {
910 let mut out = Vec::new();
911 let mut seen: BTreeSet<String> = BTreeSet::new();
912 for cap in GF_REGEX.captures_iter(text) {
913 let Some(m) = cap.get(1) else { continue };
914 let role = detect_role(cap.get(0).map(|g| g.as_str()).unwrap_or(""));
916 let trimmed = truncate_at_sentence_end(m.as_str().trim());
917 for piece in split_persons(&trimmed) {
918 let p = parse_person(&piece, role.clone());
919 if p.last_name.is_none() {
920 continue;
921 }
922 let key = format!(
923 "{}_{}",
924 p.first_name.clone().unwrap_or_default(),
925 p.last_name.clone().unwrap_or_default()
926 );
927 if seen.insert(key) {
928 out.push(p);
929 }
930 }
931 }
932 out
933}
934
935pub fn extract_supervisory_authority(text: &str) -> Option<String> {
937 extract_supervisory_authority_core(&normalize::normalize_text(text))
938}
939
940fn extract_supervisory_authority_core(text: &str) -> Option<String> {
941 SUPERVISORY_AUTHORITY_RE
942 .captures(text)
943 .and_then(|c| c.get(1))
944 .map(|m| m.as_str().trim().to_string())
945 .filter(|s| !s.is_empty())
946}
947
948pub fn extract_professional_chamber(text: &str) -> Option<String> {
950 extract_professional_chamber_core(&normalize::normalize_text(text))
951}
952
953fn extract_professional_chamber_core(text: &str) -> Option<String> {
954 CHAMBER_RE
955 .captures(text)
956 .and_then(|c| c.get(1))
957 .map(|m| m.as_str().trim().to_string())
958 .filter(|s| !s.is_empty())
959}
960
961pub fn extract_de_mail(text: &str) -> Option<String> {
963 extract_de_mail_core(&normalize::normalize_text(text))
964}
965
966fn extract_de_mail_core(text: &str) -> Option<String> {
967 DE_MAIL_RE
968 .captures(text)
969 .and_then(|c| c.get(1))
970 .map(|m| m.as_str().to_ascii_lowercase())
971}
972
973pub fn extract_dispute_resolution_url(text: &str) -> Option<String> {
975 extract_dispute_resolution_url_core(&normalize::normalize_text(text))
976}
977
978fn extract_dispute_resolution_url_core(text: &str) -> Option<String> {
979 ODR_URL_RE.find(text).map(|m| m.as_str().to_string())
980}
981
982pub fn extract_register_type(text: &str) -> Option<String> {
984 extract_register_type_core(&normalize::normalize_text(text))
985}
986
987fn extract_register_type_core(text: &str) -> Option<String> {
988 let hr = extract_hr_number_core(text)?;
989 let upper = hr.to_ascii_uppercase();
990 if upper.starts_with("HRB") {
991 Some("HRB".to_string())
992 } else if upper.starts_with("HRA") {
993 Some("HRA".to_string())
994 } else {
995 None
996 }
997}
998
999fn floor_char_boundary(s: &str, index: usize) -> usize {
1008 if index >= s.len() {
1009 return s.len();
1010 }
1011 let mut i = index;
1012 while i > 0 && !s.is_char_boundary(i) {
1013 i -= 1;
1014 }
1015 i
1016}
1017
1018fn clean_phone(s: &str) -> String {
1019 let s = s.nfc().collect::<String>();
1020 let s: String = s
1021 .chars()
1022 .filter(|c| c.is_ascii_digit() || *c == '+')
1023 .collect();
1024 if let Some(rest) = s.strip_prefix("0049") {
1025 format!("+49{}", rest)
1026 } else if let Some(rest) = s.strip_prefix('0') {
1027 if rest.starts_with('+') {
1028 s
1029 } else {
1030 format!("+49{}", rest)
1031 }
1032 } else {
1033 s
1034 }
1035}
1036
1037fn clean_hr_court(raw: &str) -> String {
1040 let trimmed = raw.trim();
1041 let cut = HR_SUFFIX_RE
1042 .find(trimmed)
1043 .map(|m| m.start())
1044 .unwrap_or(trimmed.len());
1045 trimmed[..cut].trim().to_string()
1046}
1047
1048fn canonicalize_legal_form(raw: &str) -> String {
1049 let r = raw.trim();
1050 let lower = r.to_ascii_lowercase();
1051 if lower.contains("co") && lower.contains("kgaa") {
1054 "GmbH & Co. KGaA".to_string()
1055 } else if lower.contains("kgaa") {
1056 "KGaA".to_string()
1057 } else if lower.contains("co") && lower.contains("kg") {
1058 "GmbH & Co. KG".to_string()
1059 } else if r.to_ascii_uppercase().contains("GMBH") {
1060 "GmbH".to_string()
1061 } else if r.to_ascii_uppercase().contains("UG") {
1062 "UG".to_string()
1063 } else {
1064 r.to_string()
1065 }
1066}
1067
1068fn truncate_at_sentence_end(s: &str) -> String {
1069 let stop_words = [
1070 " Sitz",
1071 " Tel",
1072 " Telefon",
1073 " Fax",
1074 " E-Mail",
1075 " Email",
1076 " USt",
1077 " Eingetragen",
1078 " HRB",
1079 " HRA",
1080 " Steuer",
1081 " Adresse",
1082 " Anschrift",
1083 " Datenschutz",
1084 " Web",
1085 " Geschäfts",
1086 " Handelsregister",
1087 " Amtsgericht",
1088 " UID",
1089 ];
1090 let mut end = s.len();
1091 for sw in stop_words {
1092 if let Some(pos) = s.find(sw) {
1093 end = end.min(pos);
1094 }
1095 }
1096 if let Some(pos) = s.find(['\n', '\r']) {
1098 end = end.min(pos);
1099 }
1100 let end = floor_char_boundary(s, end);
1104 s[..end]
1105 .trim()
1106 .trim_end_matches(',')
1107 .trim_end_matches('.')
1108 .to_string()
1109}
1110
1111fn split_persons(raw: &str) -> Vec<String> {
1112 let separators = [" und ", " sowie ", " & ", ",", ";", " / "];
1113 let mut parts = vec![raw.to_string()];
1114 for sep in separators {
1115 let mut next = Vec::new();
1116 for p in parts.drain(..) {
1117 for sub in p.split(sep) {
1118 let s = sub.trim();
1119 if !s.is_empty() {
1120 next.push(s.to_string());
1121 }
1122 }
1123 }
1124 parts = next;
1125 }
1126 parts
1127}
1128
1129fn parse_person(raw: &str, role: Option<String>) -> Person {
1130 let cleaned = strip_titles(raw);
1131 let parts: Vec<&str> = cleaned
1133 .split_whitespace()
1134 .filter(|t| is_valid_name_part(t))
1135 .collect();
1136 match parts.len() {
1137 0 => Person {
1139 full_raw: raw.to_string(),
1140 role,
1141 ..Default::default()
1142 },
1143 1 => Person {
1144 last_name: Some(parts[0].to_string()),
1145 full_raw: raw.to_string(),
1146 role,
1147 ..Default::default()
1148 },
1149 _ => Person {
1150 first_name: Some(parts[0].to_string()),
1151 last_name: Some(parts[parts.len() - 1].to_string()),
1152 full_raw: raw.to_string(),
1153 role,
1154 },
1155 }
1156}
1157
1158fn detect_role(full_match: &str) -> Option<String> {
1160 let lower = full_match.to_ascii_lowercase();
1161 if lower.contains("gesch") {
1162 Some("Geschäftsführer".to_string())
1163 } else if lower.contains("vorstand") {
1164 Some("Vorstand".to_string())
1165 } else if lower.contains("verantwortlich") {
1166 Some("Verantwortlich".to_string())
1167 } else if lower.contains("inhaber") {
1168 Some("Inhaber".to_string())
1169 } else {
1170 None
1171 }
1172}
1173
1174fn is_valid_name_part(s: &str) -> bool {
1178 let trimmed = s.trim().trim_matches(|c: char| !c.is_alphanumeric());
1179 if trimmed.chars().count() <= 1 {
1180 return false;
1181 }
1182 if trimmed.chars().any(|c| c.is_ascii_digit()) {
1183 return false;
1184 }
1185 if NOT_A_NAME.contains(&trimmed.to_lowercase().as_str()) {
1186 return false;
1187 }
1188 match trimmed.chars().next() {
1189 Some(first) => first.is_uppercase(),
1190 None => false,
1191 }
1192}
1193
1194fn strip_titles(s: &str) -> String {
1198 const TITLES: &[&str] = &[
1199 "dr",
1200 "dr.",
1201 "prof",
1202 "prof.",
1203 "dipl.-ing.",
1204 "dipl.-kfm.",
1205 "dipl.-ök.",
1206 "dipl",
1207 "b.sc.",
1208 "m.sc.",
1209 "mba",
1210 "b.a.",
1211 "m.a.",
1212 "ll.m.",
1213 "ll.b.",
1214 "herr",
1215 "frau",
1216 "ra",
1217 "h.c.",
1218 ];
1219 s.split_whitespace()
1220 .filter(|token| {
1221 let key = token.to_ascii_lowercase();
1222 !TITLES.contains(&key.as_str())
1223 })
1224 .collect::<Vec<_>>()
1225 .join(" ")
1226}
1227
1228fn is_plausible_email(email: &str) -> bool {
1232 let Some((_, domain)) = email.rsplit_once('@') else {
1233 return false;
1234 };
1235 if BLOCKED_EMAIL_DOMAINS.contains(&domain) {
1236 return false;
1237 }
1238 match domain.rsplit_once('.') {
1239 Some((_, tld)) => REAL_TLDS.contains(&tld),
1240 None => false,
1241 }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246 use super::*;
1247
1248 #[test]
1249 fn email_basic_and_obfuscated() {
1250 let txt = "info@a.de info [at] b [dot] de support@a.de";
1251 let e = extract_emails(txt);
1252 assert!(e.contains(&"info@a.de".to_string()));
1253 assert!(e.contains(&"info@b.de".to_string()));
1254 assert!(e.contains(&"support@a.de".to_string()));
1255 }
1256
1257 #[test]
1258 fn phones_normalised() {
1259 let p = extract_phones("Tel +49 30 12345-67 oder 0049 30 12345 68 oder 030/123 45 69");
1260 assert!(p.iter().any(|x| x.starts_with("+493012345")));
1261 }
1262
1263 #[test]
1264 fn legal_forms() {
1265 assert_eq!(
1266 extract_legal_form("Muster GmbH & Co. KG"),
1267 Some("GmbH & Co. KG".into())
1268 );
1269 assert_eq!(extract_legal_form("Muster GmbH"), Some("GmbH".into()));
1270 assert_eq!(
1271 extract_legal_form("Muster UG (haftungsbeschränkt)"),
1272 Some("UG".into())
1273 );
1274 }
1275
1276 #[test]
1277 fn year_founded_pattern() {
1278 assert_eq!(
1279 extract_year_founded("Unser Unternehmen wurde gegründet 1973"),
1280 Some(1973)
1281 );
1282 assert_eq!(extract_year_founded("Founded in 2010"), Some(2010));
1283 }
1284
1285 #[test]
1286 fn vat_id_extracted() {
1287 assert_eq!(
1288 extract_vat_id("USt-ID: DE 123456789"),
1289 Some("DE123456789".into())
1290 );
1291 }
1292
1293 #[test]
1294 fn hr_number_extracted() {
1295 assert!(extract_hr_number("Eingetragen im Handelsregister Berlin HRB 12345 B").is_some());
1296 }
1297
1298 #[test]
1299 fn persons_with_titles_and_connectors() {
1300 let p = extract_persons("Geschäftsführer: Dr. Hans Müller und Anna Schmidt");
1301 assert!(p.iter().any(|x| x.last_name.as_deref() == Some("Müller")));
1302 assert!(p.iter().any(|x| x.last_name.as_deref() == Some("Schmidt")));
1303 }
1304
1305 #[test]
1306 fn full_extract() {
1307 let text = "
1308 Musterreinigung GmbH & Co. KG
1309 Geschäftsführer: Hans Müller
1310 Hauptstraße 12, 10115 Berlin
1311 Tel: +49 30 1234567
1312 E-Mail: info@musterreinigung.de
1313 Eingetragen im Handelsregister Berlin HRB 12345 B
1314 USt-IdNr.: DE 123456789
1315 Gegründet 1985
1316 ";
1317 let d = extract_all(text);
1318 assert_eq!(d.legal_form.as_deref(), Some("GmbH & Co. KG"));
1319 assert_eq!(d.postcode.as_deref(), Some("10115"));
1320 assert!(d.emails.contains(&"info@musterreinigung.de".to_string()));
1321 assert_eq!(d.year_founded, Some(1985));
1322 assert_eq!(d.vat_id.as_deref(), Some("DE123456789"));
1323 assert!(
1324 d.persons
1325 .iter()
1326 .any(|p| p.last_name.as_deref() == Some("Müller"))
1327 );
1328 }
1329}