Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
208 lines
6.7 KiB
Python
208 lines
6.7 KiB
Python
"""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:]
|