import re import phonenumbers from phonenumbers import ( NumberParseException, PhoneNumberFormat, PhoneNumberType, ) # Zeichen, die in einer normal formatierten Telefonnummer vorkommen dürfen. PHONE_CHARS = re.compile(r"[0-9+()\s./-]") def _extract_phone_candidate(raw: str) -> str | None: """ Extrahiert eine Telefonnummer verlustfrei aus einem Rohwert. Regeln: - Ziffern werden niemals verworfen. - Übliche Formatierungszeichen werden entfernt. - Ungewöhnliche Zeichen mitten in der Nummer führen NICHT zum vorzeitigen Abbruch, solange danach noch Ziffern folgen. - Alphabetischer Text beendet die Telefonnummer. - Die Originalnummer bleibt in ``raw`` erhalten. """ if raw is None: return None value = str(raw).strip() if not value: return None if not value[0].isdigit() and value[0] != "+": return None chars = [] for char in value: if char.isdigit() or char == "+": chars.append(char) elif char in " ()/.-": # Normale Formatierung: ignorieren. continue elif char.isalpha(): # Eindeutiger Beginn von Text. break else: # Ungewöhnliches Zeichen wie ^, ´, ` etc. # # NICHT abbrechen! # Solche Zeichen können Tipp-/Formatierungsfehler sein. # Ziffern danach müssen erhalten bleiben. continue candidate = "".join(chars) if candidate.startswith("+"): candidate = "+" + candidate[1:].replace("+", "") else: candidate = candidate.replace("+", "") if not any(char.isdigit() for char in candidate): return None return candidate def _looks_international_without_plus(candidate: str) -> bool: """ Erkennt eine bereits internationale Nummer ohne führendes '+'. Beispiel: 4915159207553 -> True Lokale deutsche Nummern mit führender 0 bleiben unverändert. """ if not candidate or candidate.startswith("+"): return False if candidate.startswith("00"): return True if candidate.startswith("0"): return False # Bekannte internationale Landesvorwahlen aus libphonenumber. try: country_codes = { str(code) for code in phonenumbers.COUNTRY_CODE_TO_REGION_CODE } except AttributeError: country_codes = set() return any( candidate.startswith(code) for code in sorted(country_codes, key=len, reverse=True) ) def normalize(raw: str, country: str = "DE") -> dict: country = (country or "DE").upper() candidate = _extract_phone_candidate(raw) if not candidate: return { "raw": raw, "e164": None, "country": country, "valid": False, "type": "UNKNOWN", } try: parse_candidate = candidate # Bereits international geschriebene Nummer ohne '+'. # 4915159207553 -> +4915159207553 if _looks_international_without_plus(candidate): if candidate.startswith("00"): parse_candidate = "+" + candidate[2:] else: parse_candidate = "+" + candidate number = phonenumbers.parse( parse_candidate, None if parse_candidate.startswith("+") else country, ) # E.164 erlaubt maximal 15 Ziffern inklusive # Landesvorwahl. Eine längere Nummer darf niemals # als gültig zurückgegeben werden. e164_digits = str(number.country_code) + str( number.national_number ) e164_length_valid = len(e164_digits) <= 15 valid = ( e164_length_valid and phonenumbers.is_possible_number(number) and phonenumbers.is_valid_number(number) ) e164 = ( phonenumbers.format_number( number, PhoneNumberFormat.E164, ) if valid else None ) number_type = phonenumbers.number_type(number) # Bei internationaler Schreibweise stammt das Land aus # der tatsächlich erkannten Landesvorwahl und NICHT aus # dem Default-Land der Funktion. detected_region = phonenumbers.region_code_for_number(number) result_country = ( detected_region or country ) types = { PhoneNumberType.FIXED_LINE: "FIXED_LINE", PhoneNumberType.MOBILE: "MOBILE", PhoneNumberType.FIXED_LINE_OR_MOBILE: "FIXED_LINE_OR_MOBILE", PhoneNumberType.VOIP: "VOIP", PhoneNumberType.PREMIUM_RATE: "PREMIUM_RATE", PhoneNumberType.TOLL_FREE: "TOLL_FREE", } return { "raw": raw, "e164": e164, "country": result_country, "valid": valid, "type": types.get( number_type, "UNKNOWN", ), } except NumberParseException: return { "raw": raw, "e164": None, "country": country, "valid": False, "type": "UNKNOWN", }