phone.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import re
  2. import phonenumbers
  3. from phonenumbers import (
  4. NumberParseException,
  5. PhoneNumberFormat,
  6. PhoneNumberType,
  7. )
  8. # Zeichen, die in einer normal formatierten Telefonnummer vorkommen dürfen.
  9. PHONE_CHARS = re.compile(r"[0-9+()\s./-]")
  10. def _extract_phone_candidate(raw: str) -> str | None:
  11. """
  12. Extrahiert eine Telefonnummer verlustfrei aus einem Rohwert.
  13. Regeln:
  14. - Ziffern werden niemals verworfen.
  15. - Übliche Formatierungszeichen werden entfernt.
  16. - Ungewöhnliche Zeichen mitten in der Nummer führen NICHT zum
  17. vorzeitigen Abbruch, solange danach noch Ziffern folgen.
  18. - Alphabetischer Text beendet die Telefonnummer.
  19. - Die Originalnummer bleibt in ``raw`` erhalten.
  20. """
  21. if raw is None:
  22. return None
  23. value = str(raw).strip()
  24. if not value:
  25. return None
  26. if not value[0].isdigit() and value[0] != "+":
  27. return None
  28. chars = []
  29. for char in value:
  30. if char.isdigit() or char == "+":
  31. chars.append(char)
  32. elif char in " ()/.-":
  33. # Normale Formatierung: ignorieren.
  34. continue
  35. elif char.isalpha():
  36. # Eindeutiger Beginn von Text.
  37. break
  38. else:
  39. # Ungewöhnliches Zeichen wie ^, ´, ` etc.
  40. #
  41. # NICHT abbrechen!
  42. # Solche Zeichen können Tipp-/Formatierungsfehler sein.
  43. # Ziffern danach müssen erhalten bleiben.
  44. continue
  45. candidate = "".join(chars)
  46. if candidate.startswith("+"):
  47. candidate = "+" + candidate[1:].replace("+", "")
  48. else:
  49. candidate = candidate.replace("+", "")
  50. if not any(char.isdigit() for char in candidate):
  51. return None
  52. return candidate
  53. def _looks_international_without_plus(candidate: str) -> bool:
  54. """
  55. Erkennt eine bereits internationale Nummer ohne führendes '+'.
  56. Beispiel:
  57. 4915159207553 -> True
  58. Lokale deutsche Nummern mit führender 0 bleiben unverändert.
  59. """
  60. if not candidate or candidate.startswith("+"):
  61. return False
  62. if candidate.startswith("00"):
  63. return True
  64. if candidate.startswith("0"):
  65. return False
  66. # Bekannte internationale Landesvorwahlen aus libphonenumber.
  67. try:
  68. country_codes = {
  69. str(code)
  70. for code in phonenumbers.COUNTRY_CODE_TO_REGION_CODE
  71. }
  72. except AttributeError:
  73. country_codes = set()
  74. return any(
  75. candidate.startswith(code)
  76. for code in sorted(country_codes, key=len, reverse=True)
  77. )
  78. def normalize(raw: str, country: str = "DE") -> dict:
  79. country = (country or "DE").upper()
  80. candidate = _extract_phone_candidate(raw)
  81. if not candidate:
  82. return {
  83. "raw": raw,
  84. "e164": None,
  85. "country": country,
  86. "valid": False,
  87. "type": "UNKNOWN",
  88. }
  89. try:
  90. parse_candidate = candidate
  91. # Bereits international geschriebene Nummer ohne '+'.
  92. # 4915159207553 -> +4915159207553
  93. if _looks_international_without_plus(candidate):
  94. if candidate.startswith("00"):
  95. parse_candidate = "+" + candidate[2:]
  96. else:
  97. parse_candidate = "+" + candidate
  98. number = phonenumbers.parse(
  99. parse_candidate,
  100. None if parse_candidate.startswith("+") else country,
  101. )
  102. # E.164 erlaubt maximal 15 Ziffern inklusive
  103. # Landesvorwahl. Eine längere Nummer darf niemals
  104. # als gültig zurückgegeben werden.
  105. e164_digits = str(number.country_code) + str(
  106. number.national_number
  107. )
  108. e164_length_valid = len(e164_digits) <= 15
  109. valid = (
  110. e164_length_valid
  111. and phonenumbers.is_possible_number(number)
  112. and phonenumbers.is_valid_number(number)
  113. )
  114. e164 = (
  115. phonenumbers.format_number(
  116. number,
  117. PhoneNumberFormat.E164,
  118. )
  119. if valid
  120. else None
  121. )
  122. number_type = phonenumbers.number_type(number)
  123. # Bei internationaler Schreibweise stammt das Land aus
  124. # der tatsächlich erkannten Landesvorwahl und NICHT aus
  125. # dem Default-Land der Funktion.
  126. detected_region = phonenumbers.region_code_for_number(number)
  127. result_country = (
  128. detected_region
  129. or country
  130. )
  131. types = {
  132. PhoneNumberType.FIXED_LINE: "FIXED_LINE",
  133. PhoneNumberType.MOBILE: "MOBILE",
  134. PhoneNumberType.FIXED_LINE_OR_MOBILE:
  135. "FIXED_LINE_OR_MOBILE",
  136. PhoneNumberType.VOIP: "VOIP",
  137. PhoneNumberType.PREMIUM_RATE: "PREMIUM_RATE",
  138. PhoneNumberType.TOLL_FREE: "TOLL_FREE",
  139. }
  140. return {
  141. "raw": raw,
  142. "e164": e164,
  143. "country": result_country,
  144. "valid": valid,
  145. "type": types.get(
  146. number_type,
  147. "UNKNOWN",
  148. ),
  149. }
  150. except NumberParseException:
  151. return {
  152. "raw": raw,
  153. "e164": None,
  154. "country": country,
  155. "valid": False,
  156. "type": "UNKNOWN",
  157. }