| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- 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",
- }
|