"""LinkedIn profile-link extraction and normalization. One shared vocabulary for "the same person" across the two places a LinkedIn identity appears: sourced talent profiles (a normalized URL from the Apify actor) and CV text (a link the candidate wrote, often mangled by PDF extraction). The match key is the lowercase public slug from /in/. Top-level module on purpose: talent/, inbox/ and job/ all need it, and any package-local home would invite an import cycle. """ import re from urllib.parse import unquote # CV text arrives from PDF extraction: URLs may carry percent-escapes, no # scheme ("linkedin.com/in/jane-doe"), or trailing sentence punctuation glued # on by layout. /pub/ is the legacy public-profile path some older CVs still # carry. _SLUG_RE = re.compile(r"linkedin\.com/(?:in|pub)/([A-Za-z0-9\-_.%]+)", re.IGNORECASE) # Sentinel stored on application rows: NULL means "never scanned", the empty # string means "scanned, no link found". The distinction is what lets the lazy # backfill converge instead of rescanning every CV on every request. NO_SLUG = "" def normalize_slug(raw) -> str | None: """Lowercase, percent-decoded, stripped of trailing sentence punctuation.""" if not raw: return None slug = unquote(str(raw)).strip().lower().rstrip(".") return slug or None def slug_from_url(url) -> str | None: """Slug from an already-normalized profile URL (talent_profiles.linkedin_url).""" if not url: return None match = _SLUG_RE.search(str(url)) return normalize_slug(match.group(1)) if match else None def slugs_from_text(text) -> list[str]: """Every distinct slug mentioned in a CV, in order of first appearance.""" if not text: return [] found: list[str] = [] for match in _SLUG_RE.finditer(text): slug = normalize_slug(match.group(1)) if slug and slug not in found: found.append(slug) return found def primary_slug_from_text(text) -> str: """The slug to persist on an application row; NO_SLUG when the CV has none.""" slugs = slugs_from_text(text) return slugs[0] if slugs else NO_SLUG