"""
Detect people-style profile images in HTML, score “person + photo” pages, and download images.
Saved files use the contact’s name in lower snake_case. If the name is missing or not usable,
the first file is ``unknown``, then ``unknown_2``, ``unknown_3``, and so on. Job rows still carry
``title_or_role`` for context, but titles are not used in the filename.
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import unicodedata
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import urljoin, urlparse
_IMG_EXT_RE = re.compile(r"\.(jpe?g|png|gif|webp)(\?|$)", re.I)
_PLACEHOLDER_IMG = re.compile(r"(spacer|blank\.|placeholder|pixel\.gif|1x1)", re.I)
_SKIP_IMG_HOST = re.compile(
r"(gravatar|fbcdn\.net|fbsbx\.com|platform\.facebook\.com|connect\.facebook\.net|"
r"facebook\.com/(tr/|plugins/|rsrc\.php)|"
r"instagram\.com|cdninstagram\.com|pbs\.twimg\.com|twimg\.com/media|"
r"doubleclick|googlesyndication|google-analytics|pixel\.|tracking)",
re.I,
)
_IMG_CLASS_HINT = re.compile(
r"(avatar|photo|headshot|portrait|profile|staff|member|team|bio|thumbnail|head\s*shot)",
re.I,
)
_NAMEISH = re.compile(r"[A-Za-z][A-Za-z][A-Za-z].*[A-Za-z]")
_ROLE_HEADING_HINT = re.compile(
r"(?is)\b("
r"commission(\s+chairman|\s+chair|\s+district\s*\d+|\s+member)?"
r"|district\s*\d+"
r"|county\s+commission"
r"|mayor|vice\s*mayor|council(\s*member)?"
r"|trustee|judge|clerk|sheriff|superintendent|assessor|treasurer"
r")\b",
)
# Headings / chrome that share a site logo or stock wp-block-image — not directory headshots.
_NON_PERSON_PHOTO_SUBJECT_RE = re.compile(
r"(?is)\b("
r"frequently\s+asked|faq\b|common\s+questions"
r"|search[\s\w]{0,48}site\b"
r"|privacy\s+policy|terms\s+of\s+(service|use)|cookie\s+policy"
r"|subscribe|newsletter|sign\s+up"
r"|welcome\s+to|thank\s+you\s+for\s+visiting"
r"|facebook\s+posts|instagram\s+feed|twitter\s+feed|social\s+media\s+feed"
r"|county\s+seal|site\s+logo|logo\b|icon\b|outline\b|calendar\s+icon"
r"|announcements?\b|meeting\s+agenda|meeting\s+minutes|archive\s+commission"
r"|job\s+opportunities|bid\s+advertisements?|car\s*[- ]?boat\s+tags"
r"|report\s+a\s+concern|tell\s+me\s+how\s+to|county\s+government"
r"|safe\s+streets|transportation\s+plan|veterans\s+service\s+office"
r")\b",
)
_NON_PERSON_NAME_LINE_RE = re.compile(
r"(?is)\b("
r"welcome|promote|growth|progress|hub|industry|shopping|entertainment|"
r"connectivity|major\s+thoroughfares|central\s+to|southeast\s+georgia|"
r"click\s+here|learn\s+more|visit\s+our|discover|explore|community|"
r"announcements?|county\s+government|county\s+seal|logo|icon|outline|"
r"agenda|minutes|archive|safe\s+streets|transportation|veterans\s+service"
r")\b"
)
_UI_IMG_ALT_RE = re.compile(
r"^(?:flag|flags|icon|icons|menu|logo|image|avatar|placeholder|spacer|close|search|arrow|"
r"english|german|spanish|french|italian|portuguese|polish|swedish|finnish|romanian|"
r"slovak|hungarian|dutch|czech|turkish|russian|ukrainian|japanese|korean|chinese|"
r"arabic|hebrew|drop[\s-]?down)$",
re.I,
)
_ACCESSIBILITY_PLUGIN_URL_RE = re.compile(
r"(?is)(/wp-content/plugins/(?:accessibility|onetap)[^/]*/|"
r"/assets/images/(?:english|german|spanish|french|italia|poland|portugal|"
r"rumania|slowakia|swedish|finnland|icon-drop-down-menu))",
)
_STREET_ADDRESS_LINE_RE = re.compile(
r"^\d{1,6}\s+(?:north|south|east|west|n|s|e|w\.?\s+)?[a-z0-9\s.'-]*(?:"
r"st(?:reet)?|ave(?:nue)?|rd|road|hwy|highway|blvd|boulevard|drive|dr|ln|lane|"
r"way|route|al-|hwy\.)\b",
re.I,
)
def contact_profile_image_stem_from_name(person_name: Optional[str]) -> Optional[str]:
"""
Lower snake_case stem from the contact’s **name** only (no extension).
Returns ``None`` when there is no usable person name (caller saves as ``unknown``, ``unknown_2``, …).
"""
raw = (person_name or "").strip()
raw = re.sub(r"^councilor\s+", "", raw, flags=re.I).strip()
if len(raw) < 2 or not _NAMEISH.search(raw):
return None
nfkd = unicodedata.normalize("NFKD", raw)
ascii_fold = nfkd.encode("ascii", "ignore").decode("ascii").lower()
s = re.sub(r"[^a-z0-9]+", "_", ascii_fold)
s = re.sub(r"_+", "_", s).strip("_")
if not s:
return None
return s[:120]
def img_best_abs_url(img: Any, page_url: str) -> str:
"""
Resolve a usable absolute image URL from lazy / responsive attributes (WordPress, etc.).
Prefers ``data-src`` / ``data-lazy-src`` when ``src`` is empty or a placeholder.
"""
from bs4 import Tag
if not isinstance(img, Tag):
return ""
candidates: List[str] = []
for attr in ("data-src", "data-lazy-src", "data-lazy-loaded", "data-original"):
v = (img.get(attr) or "").strip()
if v and not v.lower().startswith("data:"):
candidates.append(v)
srcset = (img.get("srcset") or "").strip()
if srcset:
best_u = ""
best_w = -1
for chunk in srcset.split(","):
chunk = chunk.strip()
if not chunk:
continue
bits = chunk.split()
u = bits[0].strip()
w = -1
if len(bits) > 1 and bits[1].endswith("w"):
try:
w = int(bits[1][:-1])
except ValueError:
w = -1
if u and not u.lower().startswith("data:"):
if w >= best_w:
best_w = w
best_u = u
if best_u:
candidates.append(best_u)
else:
for chunk in srcset.split(","):
chunk = chunk.strip()
if not chunk:
continue
u = chunk.split()[0].strip()
if u and not u.lower().startswith("data:"):
candidates.append(u)
break
src = (img.get("src") or "").strip()
if src and not src.lower().startswith("data:") and not _PLACEHOLDER_IMG.search(src):
candidates.append(src)
elif src and not src.lower().startswith("data:"):
candidates.append(src)
for c in candidates:
abs_u = urljoin(page_url, c)
if abs_u.lower().startswith(("http://", "https://")) and not _SKIP_IMG_HOST.search(abs_u):
return abs_u
return ""
def _profile_image_url_is_plugin_or_ui_chrome(url: str) -> bool:
"""Accessibility widgets, language pickers, weather badges — not people."""
if not url:
return True
low = url.lower()
if _ACCESSIBILITY_PLUGIN_URL_RE.search(low):
return True
if re.search(r"(weatherforyou\.net|/hw3\.cgi\b|icon-drop-down)", low, re.I):
return True
return False
def _profile_image_url_is_brand_or_chrome(url: str) -> bool:
"""Sitewide logos, favicons, and header marks — not official portrait photos."""
from scrapers.discovery.contact_extract_from_html import is_decorative_profile_image_url
if _profile_image_url_is_plugin_or_ui_chrome(url):
return True
if is_decorative_profile_image_url(url):
return True
if not url or not url.lower().startswith(("http://", "https://")):
return True
path = (urlparse(url).path or "").lower()
base = path.rsplit("/", 1)[-1] if path else ""
blob = f"{path} {base} {url.lower()}"
if re.search(r"(^|/)(favicon|apple-touch-icon|site-icon|mstile)(/|\.|-)", blob, re.I):
return True
if re.search(r"/(logos?|branding|identity)/", blob, re.I):
return True
if re.search(r"[-_/]logo[-_.]|[-_]logo\.(png|jpe?g|gif|webp)(\?|$)", blob, re.I):
return True
if re.search(r"\b(logo|wordmark|lockup|site-logo|header-logo)\b", base, re.I):
return True
if re.search(r"\blogonew\b|[-_]logo[-_.]", blob, re.I):
return True
return False
def _img_looks_oversized_decorative(img: Any) -> bool:
"""Skip site logos, group commission photos, and other non-headshot assets."""
try:
w = int(str(img.get("width") or "0"))
h = int(str(img.get("height") or "0"))
except ValueError:
w, h = 0, 0
if w >= 900 or h >= 900:
return True
u = (img.get("src") or img.get("data-src") or "").lower()
if any(tok in u for tok in ("commission_2024", "fbog_v01", "tuscco-logo", "favicon")):
return True
return False
def _name_from_portrait_url(url: str) -> str:
"""``stan-acker.webp`` → ``Stan Acker`` when the page has no ``h3``."""
base = (urlparse(url).path or "").rsplit("/", 1)[-1]
stem = base.rsplit(".", 1)[0] if "." in base else base
if not stem or stem.isdigit() or len(stem) < 4:
return ""
parts = [p for p in re.split(r"[-_]+", stem) if p and p.isalpha()]
if len(parts) < 2:
return ""
return " ".join(p.title() for p in parts)
def _is_plausible_person_display_name(name: Optional[str]) -> bool:
"""Require at least two name-like tokens (filters ``flag``, ``icon``, single words)."""
raw = (name or "").strip()
if not raw or not _looks_like_person_name_line(raw):
return False
if _UI_IMG_ALT_RE.match(raw):
return False
if _STREET_ADDRESS_LINE_RE.match(raw):
return False
tokens = [t for t in re.findall(r"[A-Za-z]+", raw) if len(t) >= 2]
if len(tokens) < 2:
return False
return True
def _label_is_non_person_photo_subject(name: Optional[str], title: Optional[str]) -> bool:
blob = f"{name or ''} {title or ''}".strip()
if not blob:
return True
if name and (_UI_IMG_ALT_RE.match(name.strip()) or _STREET_ADDRESS_LINE_RE.match(name.strip())):
return True
if _NON_PERSON_NAME_LINE_RE.search(blob):
return True
if _NON_PERSON_PHOTO_SUBJECT_RE.search(blob):
return True
# Long marketing / intro blurbs used as subtitle next to decorative images.
if (title or "").strip():
t = (title or "").strip()
if len(t) > 140 and re.search(r"\b(for answers|to learn more|click here|visit our)\b", t, re.I):
return True
return False
def collect_person_jsonld_image_urls(person_obj: Dict[str, Any], page_url: str) -> List[str]:
"""Resolve ``image`` / ``ImageObject`` URLs on a single ``Person`` JSON-LD dict."""
img_raw = person_obj.get("image")
urls: List[str] = []
if isinstance(img_raw, str) and img_raw.strip():
urls.append(img_raw.strip())
elif isinstance(img_raw, dict):
u = str(img_raw.get("url") or img_raw.get("@id") or "").strip()
if u:
urls.append(u)
elif isinstance(img_raw, list):
for it in img_raw:
if isinstance(it, str) and it.strip():
urls.append(it.strip())
elif isinstance(it, dict):
u = str(it.get("url") or "").strip()
if u:
urls.append(u)
out: List[str] = []
for u in urls:
abs_u = urljoin(page_url, u)
if not abs_u.lower().startswith(("http://", "https://")):
continue
if _SKIP_IMG_HOST.search(abs_u) or _profile_image_url_is_brand_or_chrome(abs_u):
continue
out.append(abs_u)
return out
def score_person_adjacent_images(html: str, page_url: str = "") -> int:
"""
Heuristic count of “person near profile image” cues (JSON-LD ``Person`` + ``image``,
```` with portrait-ish classes/alt near headings, etc.). Capped for stability.
"""
from bs4 import BeautifulSoup
score = 0
soup = BeautifulSoup(html or "", "html.parser")
for script in soup.find_all("script", attrs={"type": re.compile(r"ld\+json", re.I)}):
raw = (script.string or script.get_text() or "").strip()
if not raw:
continue
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError, ValueError):
continue
score += _json_ld_person_image_score(data)
for img in soup.find_all("img"):
src = img_best_abs_url(img, page_url)
if not src:
continue
cls = " ".join(img.get("class") or [])
alt = (img.get("alt") or "").strip()
if _IMG_CLASS_HINT.search(cls) or _IMG_CLASS_HINT.search(alt):
score += 3
elif len(alt) >= 4 and _NAMEISH.search(alt) and not alt.lower().startswith("logo"):
score += 2
elif "wp-image-" in cls or "wp-block-image" in cls:
if not _profile_image_url_is_brand_or_chrome(src):
score += 2
w = str(img.get("width") or "").strip()
h = str(img.get("height") or "").strip()
if w.isdigit() and h.isdigit():
wi, hi = int(w), int(h)
if 40 <= wi <= 800 and 40 <= hi <= 800:
score += 1
for h in soup.find_all(["h2", "h3", "h4", "h5", "h6"]):
prev = h.find_previous_sibling()
if prev is not None and prev.name in ("figure", "div"):
img0 = prev.find("img")
u0 = img_best_abs_url(img0, page_url) if img0 else ""
if u0 and not _profile_image_url_is_brand_or_chrome(u0):
score += 3
break
return int(min(score, 80))
def _json_ld_person_image_score(obj: Any) -> int:
n = 0
if isinstance(obj, dict):
types = obj.get("@type")
tset: Set[str] = set()
if isinstance(types, str):
tset.add(types.strip().lower())
elif isinstance(types, list):
tset.update(str(x).strip().lower() for x in types if x)
if "person" in tset and obj.get("image"):
n += 4
for v in obj.values():
n += _json_ld_person_image_score(v)
elif isinstance(obj, list):
for it in obj:
n += _json_ld_person_image_score(it)
return n
def extract_profile_image_jobs(html: str, page_url: str, *, max_jobs: int = 80) -> List[Dict[str, Any]]:
"""
Return download jobs: ``person_name``, ``title_or_role``, ``image_url`` (absolute).
Sources: JSON-LD ``Person`` ``image``; ``
`` near headings / portrait-ish classes.
"""
from bs4 import BeautifulSoup
from scrapers.discovery.contact_extract_from_html import (
extract_caboose_background_profile_jobs,
extract_caboose_flex_grid_profile_jobs,
extract_centreville_big_box_profile_background_profile_jobs,
extract_civicplus_bio_detail_profile_jobs,
extract_divi_team_member_profile_jobs,
extract_infomedia_official_paragraph_profile_jobs,
extract_wp_caption_figure_profile_jobs,
is_generic_district_label,
split_office_holder_fields,
)
out: List[Dict[str, Any]] = []
seen_url: Set[str] = set()
soup = BeautifulSoup(html or "", "html.parser")
for job in extract_caboose_background_profile_jobs(html, page_url, max_jobs=max_jobs):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for job in extract_caboose_flex_grid_profile_jobs(html, page_url, max_jobs=max_jobs):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for job in extract_centreville_big_box_profile_background_profile_jobs(
html, page_url, max_jobs=max_jobs
):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for job in extract_wp_caption_figure_profile_jobs(html, page_url, max_jobs=max_jobs):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for job in extract_divi_team_member_profile_jobs(html, page_url, max_jobs=max_jobs):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for job in extract_infomedia_official_paragraph_profile_jobs(html, page_url, max_jobs=max_jobs):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for job in extract_civicplus_bio_detail_profile_jobs(html, page_url, max_jobs=max_jobs):
u = str(job.get("image_url") or "")
if u and u not in seen_url:
seen_url.add(u)
out.append(job)
for script in soup.find_all("script", attrs={"type": re.compile(r"ld\+json", re.I)}):
raw = (script.string or script.get_text() or "").strip()
if not raw:
continue
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError, ValueError):
continue
_json_ld_collect_person_images(data, page_url, out, seen_url, max_jobs=max_jobs)
from scrapers.discovery.contact_extract_from_html import (
_iter_elementor_official_bands,
_parse_elementor_official_band,
)
# Fusion / Avada theme person cards — very common in municipal WordPress sites
# (e.g. dekalbcountyal.us/members/). Structure is consistent:
#