108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
"""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/<slug>.
|
|
|
|
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"), trailing sentence punctuation glued on by
|
|
# layout, or line-wraps inside the path ("linkedin.com/in/\njane-doe").
|
|
# /pub/ is the legacy public-profile path; /mwlite/in/ is the mobile web path.
|
|
_SLUG_RE = re.compile(
|
|
r"linkedin\.com/(?:in|pub|mwlite/in)/([A-Za-z0-9\-_.%]+)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# pypdf wraps URLs across lines / glyph gaps. Flatten those runs before matching
|
|
# so "linkedin.com/in/\n jane-doe" still yields a slug.
|
|
_LINKEDIN_RUN_RE = re.compile(
|
|
r"(?:https?://)?(?:(?:[a-z0-9-]+\.)*)linkedin\.com(?:\s*/\s*[A-Za-z0-9\-_.%]*)+",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Clickable CV icons often store the URL only in an HTML href or a PDF
|
|
# annotation, not in the visible text layer.
|
|
_HREF_RE = re.compile(
|
|
r"""href\s*=\s*["']([^"'>\s]*(?:linkedin\.com|lnkd\.in)[^"']*)["']""",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Short links from LinkedIn's own share button. Not a match key (no /in/<slug>)
|
|
# but enough to open a profile from the inbox button.
|
|
_LNKD_RE = re.compile(r"lnkd\.in/([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(_flatten_linkedin_runs(str(url)))
|
|
return normalize_slug(match.group(1)) if match else None
|
|
|
|
|
|
def _flatten_linkedin_runs(text: str) -> str:
|
|
"""Remove whitespace inside linkedin.com/... runs so wrapped PDFs still match."""
|
|
if not text:
|
|
return ""
|
|
return _LINKEDIN_RUN_RE.sub(lambda m: re.sub(r"\s+", "", m.group(0)), text)
|
|
|
|
|
|
def _haystack(text) -> str:
|
|
"""Flatten wrapped LinkedIn URLs and splice href= targets into the scan text."""
|
|
raw = text or ""
|
|
hrefs = "\n".join(_HREF_RE.findall(raw))
|
|
blob = f"{raw}\n{hrefs}" if hrefs else raw
|
|
return _flatten_linkedin_runs(blob)
|
|
|
|
|
|
def slugs_from_text(text) -> list[str]:
|
|
"""Every distinct slug mentioned in a CV, in order of first appearance."""
|
|
found: list[str] = []
|
|
for match in _SLUG_RE.finditer(_haystack(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
|
|
|
|
|
|
def profile_url_from_text(text) -> str | None:
|
|
"""Public profile URL for the inbox LinkedIn button, or None.
|
|
|
|
Prefers /in/<slug> (and /pub/, /mwlite/in/). Falls back to lnkd.in short
|
|
links which open the profile but are not a Find Talent match key.
|
|
"""
|
|
slug = primary_slug_from_text(text)
|
|
if slug:
|
|
return f"https://www.linkedin.com/in/{slug}"
|
|
short = _LNKD_RE.search(_haystack(text))
|
|
if short:
|
|
return f"https://lnkd.in/{short.group(1)}"
|
|
return None
|