feat: group private apps into andreknie-privat
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""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"(?<!\()0(\d{2,5})[\s\-/]?(\d[\d\s\-]{3,9}\d)"
|
||||
)
|
||||
|
||||
# Pattern 3: (0xxx) parenthesized area code format
|
||||
# (0xxx) followed by subscriber digits with optional separators
|
||||
_PARENTHESIZED_PATTERN = re.compile(
|
||||
r"\(0(\d{2,5})\)[\s\-]?(\d[\d\s\-]{3,9}\d)"
|
||||
)
|
||||
|
||||
|
||||
def extract_phone_number(text: str) -> 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:]
|
||||
Reference in New Issue
Block a user