"""Phone number extraction and normalization for German telephone formats. Supports: - +49 international format (e.g., +49 30 12345678, +49-171-1234567) - 0xxx domestic format (e.g., 030 12345678, 0171-1234567) - (0xxx) parenthesized area code format (e.g., (030) 12345678, (0171) 1234567) """ import re # Pattern 1: +49 international format # +49 followed by 9 to 12 digits with optional spaces or hyphens _INTERNATIONAL_PATTERN = re.compile( r"\+49[\s\-]?(\d[\d\s\-]{8,14}\d)" ) # Pattern 2: 0xxx domestic format # 0 followed by area code (2-5 digits) then subscriber (4-8 digits), with optional separators _DOMESTIC_PATTERN = re.compile( r"(? str | None: """Extract the first German phone number from text. Supports formats: - +49 xxx xxxx xxxx (international) - 0xxx xxxxxxx (domestic with area code) - (0xxx) xxxxxxx (domestic with parenthesized area code) Returns the raw matched string or None if no match found. """ if not text: return None # Collect all matches with their start positions to find the first one matches: list[tuple[int, str]] = [] for m in _INTERNATIONAL_PATTERN.finditer(text): matches.append((m.start(), m.group(0))) for m in _PARENTHESIZED_PATTERN.finditer(text): matches.append((m.start(), m.group(0))) for m in _DOMESTIC_PATTERN.finditer(text): # Avoid matching numbers already captured by parenthesized pattern start = m.start() raw = m.group(0) # Check this isn't inside parentheses if start > 0 and text[start - 1] == "(": continue matches.append((start, raw)) if not matches: return None # Return the first match by position matches.sort(key=lambda x: x[0]) return matches[0][1] def normalize_phone_number(raw: str) -> str: """Normalize a phone number by removing separators and standardizing format. Strips spaces, hyphens, slashes, and parentheses. Converts domestic format (leading 0) to international (+49) format. Returns the normalized number string (e.g., "+4930123456"). """ if not raw: return raw # Remove all separators: spaces, hyphens, slashes, parentheses cleaned = re.sub(r"[\s\-/\(\)]", "", raw) # Convert domestic (0xxx) to international (+49xxx) if cleaned.startswith("0") and not cleaned.startswith("+"): cleaned = "+49" + cleaned[1:] return cleaned def parse_phone_number(text: str) -> dict | None: """Parse a phone number string into components. Returns a dict with keys: country_code, area_code, subscriber or None if the text cannot be parsed as a German phone number. """ if not text: return None # First try to extract a phone number from the text raw = extract_phone_number(text) if raw is None: # Try parsing the text directly as a phone number normalized = normalize_phone_number(text) if not normalized.startswith("+49"): return None digits_after_country = normalized[3:] if not digits_after_country or len(digits_after_country) < 5: return None # Use heuristic: area codes are 2-5 digits, rest is subscriber area_code, subscriber = _split_area_subscriber(digits_after_country) return { "country_code": "+49", "area_code": area_code, "subscriber": subscriber, } # Parse the extracted raw number # Try international pattern m = _INTERNATIONAL_PATTERN.match(raw) if m: digits = re.sub(r"[\s\-]", "", m.group(1)) area_code, subscriber = _split_area_subscriber(digits) return { "country_code": "+49", "area_code": area_code, "subscriber": subscriber, } # Try parenthesized pattern m = _PARENTHESIZED_PATTERN.match(raw) if m: area_code = m.group(1) subscriber = re.sub(r"[\s\-]", "", m.group(2)) return { "country_code": "+49", "area_code": area_code, "subscriber": subscriber, } # Try domestic pattern m = _DOMESTIC_PATTERN.match(raw) if m: area_code = m.group(1) subscriber = re.sub(r"[\s\-]", "", m.group(2)) return { "country_code": "+49", "area_code": area_code, "subscriber": subscriber, } return None def _split_area_subscriber(digits: str) -> tuple[str, str]: """Split a digit string into area code and subscriber number. Uses known German area code lengths: - 2 digits: major cities (30=Berlin, 40=Hamburg, etc.) - 3 digits: large cities (211=Düsseldorf, 221=Köln, etc.) - 4 digits: medium cities and mobile prefixes (171, 172, etc.) - 5 digits: smaller areas Heuristic: if total digits <= 10, use shorter area code. """ # Known 2-digit area codes (major cities) two_digit_codes = {"30", "40", "69", "89"} # Known 3-digit mobile prefixes three_digit_mobile = { "151", "152", "153", "155", "156", "157", "159", "160", "162", "163", "170", "171", "172", "173", "174", "175", "176", "177", "178", "179", } # Check 2-digit area codes if len(digits) >= 4 and digits[:2] in two_digit_codes: return digits[:2], digits[2:] # Check 3-digit codes (mobile and city) if len(digits) >= 5 and digits[:3] in three_digit_mobile: return digits[:3], digits[3:] # Check common 3-digit city codes three_digit_city = { "211", "212", "221", "228", "231", "241", "251", "261", "271", "281", "291", "311", "321", "331", "341", "351", "361", "371", "381", "391", "421", "431", "441", "451", "461", "471", "481", "511", "521", "531", "541", "551", "561", "571", "581", "591", "611", "621", "631", "641", "651", "661", "671", "681", "711", "721", "731", "741", "751", "761", "771", "811", "821", "831", "841", "851", "861", "871", "911", "921", "931", "941", "951", "961", "971", "981", "991", } if len(digits) >= 5 and digits[:3] in three_digit_city: return digits[:3], digits[3:] # Default heuristic based on total length if len(digits) <= 8: # Short number: assume 3-digit area code return digits[:3], digits[3:] elif len(digits) <= 10: # Medium: assume 4-digit area code return digits[:4], digits[4:] else: # Long: assume 4-digit area code return digits[:4], digits[4:]