open-navigator / scripts /discovery /meetings_platform_heuristics.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
46.5 kB
"""
Heuristics inspired by civic meeting scrapers (e.g. City-Bureau city-scrapers_): detect common
vendor stacks and extract document / navigation URLs — **without** Scrapy.
.. _city-scrapers: https://github.com/City-Bureau/city-scrapers
"""
from __future__ import annotations
import re
from html import unescape
from typing import Any, Dict, FrozenSet, List, Optional, Sequence, Set, Tuple
from urllib.parse import parse_qs, quote_plus, urljoin, urlparse, urlunparse
from bs4 import BeautifulSoup
from loguru import logger
def _beautifulsoup_meetings(html: Optional[str], *, page_url: str, log_label: str) -> Optional[BeautifulSoup]:
"""
Parse page HTML for meeting heuristics.
Returns ``None`` when the body is empty or when BeautifulSoup rejects the markup
(e.g. gzipped/binary served as ``text/html``, corrupted CDATA) so crawls keep going.
"""
raw = html or ""
if not raw.strip():
return None
try:
return BeautifulSoup(raw, "html.parser")
except Exception as exc:
logger.debug(
"meetings_bs4_skip label={lbl} page_url={pu!r} exc_type={tn} exc={ex!r}",
lbl=log_label,
pu=page_url,
tn=type(exc).__name__,
ex=exc,
)
return None
# Hosts where PDFs / meeting UI often live off the jurisdiction’s marketing domain.
_OFFSITE_SUFFIXES: Tuple[str, ...] = (
"legistar.com",
"legistar1.granicus.com",
"legistarcloud.com",
"granicus.com",
"granicusideas.com",
"civicclerk.com",
"civicweb.net",
"revize.com",
"civicplus.com",
"streamlinevillage.com",
"boarddocs.com",
"municodemeetings.com",
"suiteonemedia.com",
"eboardsolutions.com",
# PowerDMS public libraries (agenda/minutes archives), e.g. Tuscaloosa County Commission.
"powerdms.com",
)
# Offsite HTML we may follow (narrow path hints to avoid crawling the whole vendor CDN).
_VENDOR_PATH_SNIPPETS: Tuple[str, ...] = (
"viewmeeting",
"viewpublisher",
"mediaplayer",
"calendar.aspx",
"meetingdetail",
"meetinginformation",
"/portal/",
"view.ashx",
"legistar",
"granicus",
"events.aspx",
"event.aspx",
"commission",
"cityclerk",
)
_STACK_PATTERNS: Tuple[Tuple[str, re.Pattern], ...] = (
("youtube", re.compile(r"youtube\.com|youtu\.be|youtube-nocookie\.com", re.I)),
("legistar", re.compile(r"legistar\.(com|net)|/legistar/|view\.ashx", re.I)),
("granicus", re.compile(r"granicus\.com|granicusideas\.com|viewmeeting\.aspx|viewpublisher", re.I)),
("civicclerk", re.compile(r"civicclerk\.com", re.I)),
("civicweb", re.compile(r"civicweb\.net", re.I)),
("revize", re.compile(r"revize\.com", re.I)),
("wordpress", re.compile(r"/wp-content/|/wp-json/|wordpress|xmlrpc\.php", re.I)),
("boarddocs", re.compile(r"boarddocs\.com", re.I)),
("powerdms", re.compile(r"powerdms\.com", re.I)),
("eboard_simbli", re.compile(r"eboardsolutions\.com|simbli\.eboardsolutions", re.I)),
)
_DOC_TYPE_RULES: Tuple[Tuple[str, re.Pattern], ...] = (
(
"minutes",
re.compile(
r"minute|approved\s*minute|action\s*minutes?|proceedings",
re.I,
),
),
("agenda", re.compile(r"agenda|packet", re.I)),
("transcript", re.compile(r"transcript|caption|verbatim", re.I)),
("video", re.compile(r"video|webcast|recording|youtube|vimeo", re.I)),
)
def _host(url: str) -> str:
try:
return urlparse(url).netloc.lower()
except Exception:
return ""
def _host_matches_suffix(host: str, suffix: str) -> bool:
h = host.rstrip(".").lower()
s = suffix.lower().lstrip(".")
return h == s or h.endswith("." + s)
def _link_anchor_text(tag: Any) -> str:
"""Visible link text; Wix often leaves ``<a>`` empty and uses ``aria-label``."""
text = (tag.get_text() or "").strip()
if text:
return text
for attr in ("aria-label", "title", "data-label"):
val = (tag.get(attr) or "").strip()
if val:
return val
return ""
def is_trusted_offsite(url: str) -> bool:
h = _host(url)
if not h:
return False
return any(_host_matches_suffix(h, s) for s in _OFFSITE_SUFFIXES)
def is_amazonaws_document_cdn_host(url: str) -> bool:
"""
True when the URL host is an AWS object store / CDN hostname (``*.amazonaws.com``).
Used only together with :data:`MEETING_DOWNLOAD_EXT` so we do not crawl arbitrary HTML off S3.
"""
h = _host(url)
return bool(h) and "amazonaws.com" in h
def _host_without_www(netloc: str) -> str:
"""Compare apex and ``www`` as the same site (Revize / county sites often mix both)."""
h = (netloc or "").lower().split(":")[0].rstrip(".")
return h[4:] if h.startswith("www.") else h
def is_same_site(url: str, homepage: str) -> bool:
try:
a = urlparse(url).netloc
b = urlparse(homepage).netloc
if not a or not b:
return False
return _host_without_www(a) == _host_without_www(b)
except Exception:
return False
def _path_query_lower(url: str) -> str:
try:
p = urlparse(url)
return f"{p.path.lower()}?{p.query.lower()}"
except Exception:
return (url or "").lower()
def is_suiteone_document_download_url(url: str) -> bool:
"""SuiteOne agenda/minutes endpoints without a ``.pdf`` suffix (response is still PDF)."""
try:
host = urlparse(url).netloc.lower().split(":")[0].rstrip(".")
except Exception:
return False
if not host or not _host_matches_suffix(host, "suiteonemedia.com"):
return False
pq = _path_query_lower(url)
return "/event/getagendafile/" in pq or "/event/getminutesfile/" in pq
def is_vendor_meeting_page_url(url: str) -> bool:
pq = _path_query_lower(url)
try:
host = urlparse(url).netloc.lower().split(":")[0].rstrip(".")
except Exception:
host = ""
if host and _host_matches_suffix(host, "suiteonemedia.com"):
if "/web/home.aspx" in pq or "/event/" in pq or "embed=1" in pq:
return True
if host and _host_matches_suffix(host, "eboardsolutions.com"):
if "/sb_meetings/" in pq or "/sb_online" in pq.replace("\\", "/"):
return True
if any(
tok in pq
for tok in (
"meetinglisting.aspx",
"meeting.aspx",
"agenda.aspx",
"minutes.aspx",
"onlineagenda",
"onlineminutes",
"viewagenda",
"viewminutes",
)
):
return True
if host and _host_matches_suffix(host, "powerdms.com"):
if re.search(r"(login|oauth|authorize|logout|sso)(/|\?|$)", pq, re.I):
return False
# Public libraries / document folders linked from county agenda index pages.
if re.search(
r"(/libraries?/|/documents?/|/files?/|/browse|/share|/published"
r"|documentid=|fileid=|libraryid=|folderid=|recordid=|permalink"
r"|commission|agenda|minute|meeting|archive|prior|published)",
pq,
re.I,
):
return True
return False
if not is_trusted_offsite(url):
return False
return any(snippet in pq for snippet in _VENDOR_PATH_SNIPPETS)
def is_simbli_eboard_host(url: str) -> bool:
h = _host(url)
return bool(h and _host_matches_suffix(h, "eboardsolutions.com"))
def is_simbli_meeting_listing_page_url(url: str) -> bool:
"""True on Simbli meeting index pages that expand ``ViewMeeting`` rows into crawl seeds."""
if not is_simbli_eboard_host(url):
return False
pq = _path_query_lower(url)
return "meetinglisting.aspx" in pq or "sb_meetinglisting" in pq
_SIMBLI_SKIP_HTML_PRINT_MARKERS: Tuple[str, ...] = (
"meetinglisting.aspx",
"sb_meetinglisting",
"login.aspx",
"logout.aspx",
"register.aspx",
"membership.aspx",
"directory.aspx",
"changepassword.aspx",
)
def is_simbli_agenda_minutes_html_target(url: str, anchor_text: str) -> bool:
"""
Simbli / eBoard Solutions often serves agendas and minutes as **ASP.NET HTML pages**, not PDFs.
Conservative: only URLs that look like agenda/minutes viewers (plus anchor/text classified as
agenda/minutes on allowed paths).
"""
if not is_simbli_eboard_host(url):
return False
low = _path_query_lower(url)
if any(m in low for m in _SIMBLI_SKIP_HTML_PRINT_MARKERS):
return False
try:
path_only = urlparse(url).path.lower()
except Exception:
path_only = ""
blob_l = f"{low} {path_only}".lower()
ct = classify_document(url, anchor_text)
if ct in ("agenda", "minutes"):
# Still exclude navigation shells.
if "meetinglisting.aspx" in blob_l:
return False
return True
# URL-shaped agenda/minutes pages even when anchor text is generic ("View", "Online").
if re.search(
r"(online)?agenda|viewagenda|/agenda\.aspx|agenda_|_agenda|aid=",
blob_l,
re.I,
):
return True
if re.search(
r"(online)?minutes|viewminutes|/minutes\.aspx|minutes_|_minutes|mid=",
blob_l,
re.I,
):
return True
# Meeting hub opened from SB_MeetingListing grids (onclick ``ViewMeeting`` → ``ViewMeeting.aspx``).
if "viewmeeting.aspx" in blob_l and "mid=" in blob_l:
return True
return False
_VIEWMEETING_ONCLICK_RE = re.compile(
r"""ViewMeeting\s*\(\s*(?:["']|&quot;)(\d+)(?:["']|&quot;)\s*,\s*(?:["']|&quot;)(\d+)(?:["']|&quot;)""",
re.I,
)
_VIEWMINUTES_ONCLICK_RE = re.compile(
r"""ViewMinutes\s*\(\s*(?:["']|&quot;)(\d+)(?:["']|&quot;)\s*,\s*(?:["']|&quot;)(\d+)(?:["']|&quot;)\s*,\s*(?:["']|&quot;)([^"']+)(?:["']|&quot;)""",
re.I,
)
def extract_simbli_meeting_listing_synthetic_nav_urls(html: str, page_url: str) -> List[Tuple[str, str]]:
"""
Simbli ``SB_MeetingListing.aspx`` rows often use ``href="javascript:void(0)"`` with ``onclick``
calling ``ViewMeeting`` / ``ViewMinutes``. Expand those into real ``ViewMeeting.aspx`` URLs so the
crawl can reach agendas/minutes (see ``SB_MeetingListingJS.js``).
Note: Listing grids often load **50 meetings per chunk** via AJAX — only meetings present in the
supplied HTML can be expanded here (additional pages may require scrolling/pagination in-browser).
"""
if not html or not is_simbli_eboard_host(page_url):
return []
low = (html or "").lower()
if "meetinglisting.aspx" not in low and "sb_meetinglisting" not in low:
return []
if "viewmeeting(" not in low:
return []
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="extract_simbli_listing_syn")
if soup is None:
return []
try:
base_root = f"{urlparse(page_url).scheme}://{urlparse(page_url).netloc}"
except Exception:
return []
seen: Set[str] = set()
out: List[Tuple[str, str]] = []
def push(abs_url: str, label: str) -> None:
nu = urlunparse(urlparse(abs_url)._replace(fragment=""))
if nu not in seen:
seen.add(nu)
out.append((nu, label.strip()[:500]))
for tag in soup.find_all(attrs={"onclick": True}):
oc_raw = str(tag.get("onclick") or "")
oc = unescape(oc_raw).replace("&quot;", '"')
vm = _VIEWMEETING_ONCLICK_RE.search(oc)
if vm:
sid, mid = vm.group(1), vm.group(2)
title = (
(tag.get("aria-label") or "").strip()
or (tag.get_text() or "").strip()
or f"Simbli meeting {mid}"
)
rel = f"/SB_Meetings/ViewMeeting.aspx?S={sid}&MID={mid}"
push(urljoin(base_root, rel), title)
mm = _VIEWMINUTES_ONCLICK_RE.search(oc)
if mm:
sid, mid, status = mm.group(1), mm.group(2), (mm.group(3) or "").strip().upper()
if status != "PUBLISHED":
continue
title = (
(tag.get("aria-label") or "").strip()
or (tag.get_text() or "").strip()
or f"Simbli published minutes {mid}"
)
rel = f"/SB_Meetings/ViewMeeting.aspx?S={sid}&MID={mid}&T=1"
push(urljoin(base_root, rel), title)
return out
def extract_simbli_agenda_minutes_html_pairs(
html: str,
page_url: str,
homepage: str,
) -> List[Tuple[str, str]]:
"""
Extract absolute HTTP(S) links to Simbli HTML agenda/minutes pages for PDF rendering.
Includes **the ViewMeeting hub URL itself** when ``page_url`` is ``ViewMeeting.aspx?MID=…`` — agendas
and minutes usually render inside that shell; nested ``<a href>`` alone often misses everything.
Same-origin / trusted-host rules mirror PDF extraction: homepage, current page, or trusted offsite.
"""
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="extract_simbli_html")
if soup is None:
return []
join_base = document_join_base(page_url, soup)
out: List[Tuple[str, str]] = []
seen: Set[str] = set()
try:
pq_self = _path_query_lower(page_url)
except Exception:
pq_self = ""
if (
is_simbli_eboard_host(page_url)
and "viewmeeting.aspx" in pq_self
and "mid=" in pq_self
):
nu_self = urlunparse(urlparse(page_url)._replace(fragment=""))
if nu_self not in seen:
seen.add(nu_self)
minutes_tab = bool(re.search(r"(?:^|[?&])t=1(?:&|$)", pq_self))
label = (
"Simbli ViewMeeting published minutes"
if minutes_tab
else "Simbli ViewMeeting packet"
)
out.append((nu_self, label))
def eligible_target(full: str) -> bool:
if not full.lower().startswith(("http://", "https://")):
return False
if not (
is_same_site(full, homepage)
or is_same_site(full, page_url)
or is_trusted_offsite(full)
):
return False
return True
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
continue
full = resolve_page_href(join_base, href)
text = _link_anchor_text(a)
if not eligible_target(full):
continue
if not is_simbli_agenda_minutes_html_target(full, text):
continue
nu = urlunparse(urlparse(full)._replace(fragment=""))
if nu in seen:
continue
seen.add(nu)
out.append((nu, text))
# onclick / JS redirects occasionally expose agenda URLs without proper href — skip for safety.
return out
# Counties sometimes link a short branded host (e.g. ``tallaco.com/commission-meetings/``) that is
# not the GSA/NACO ``website_url`` host. Only follow when the href is present in HTML and path
# looks like a commission/board meeting archive — no blind host generation.
_LINKED_MEETING_ARCHIVE_PATH_MARKERS: Tuple[str, ...] = (
"commission-meeting",
"commission_meeting",
"board-meeting",
"board_meeting",
"/meetings/",
"/minutes/",
"proceedings",
"action-minutes",
"action_minutes",
"/agendas/",
"commission-minute",
"meeting-archive",
"meeting_archive",
"county-commission",
"county_commission",
)
_THIRD_PARTY_MEETING_NAV_EXCLUDED_HOST_SUFFIXES: Tuple[str, ...] = (
"facebook.com",
"fb.com",
"twitter.com",
"x.com",
"instagram.com",
"linkedin.com",
"youtube.com",
"youtu.be",
"google.com",
"tiktok.com",
"pinterest.com",
"yelp.com",
"wikipedia.org",
"amazon.com",
)
def is_linked_local_meeting_microsite(url: str, homepage: str) -> bool:
"""
True for http(s) URLs on a host **different** from ``homepage`` whose path/query suggests a
local meeting archive (linked from the county site). Excludes known social / search hosts and
vendor stacks handled by :func:`is_vendor_meeting_page_url`.
"""
if not url or not homepage:
return False
if is_same_site(url, homepage):
return False
if is_trusted_offsite(url):
return False
if is_amazonaws_document_cdn_host(url):
# S3 / CloudFront-style hosts: meeting materials are downloaded via ``MEETING_DOWNLOAD_EXT``,
# not crawled as HTML (paths like ``/Minutes/…`` would otherwise match archive markers).
return False
try:
p = urlparse(url)
if p.scheme not in ("http", "https"):
return False
host = p.netloc.lower().split(":")[0].rstrip(".")
if not host or "." not in host:
return False
if any(
host == s or host.endswith("." + s) for s in _THIRD_PARTY_MEETING_NAV_EXCLUDED_HOST_SUFFIXES
):
return False
pq = _path_query_lower(url)
return any(m in pq for m in _LINKED_MEETING_ARCHIVE_PATH_MARKERS)
except Exception:
return False
PDF_EXT = re.compile(r"\.pdf(\?|#|$)", re.I)
# Meeting materials linked from government pages (same-site, vendor, or :func:`is_amazonaws_document_cdn_host`).
# ``.doc`` is matched with a trailing boundary so ``.docker`` / ``.docs`` style paths are not treated as Word.
MEETING_DOWNLOAD_EXT = re.compile(
r"\.(?:pdf|docx|rtf|pptx?|mp3|m4a|wav)(?:\?|#|$)|\.doc(?:\?|#|$)",
re.I,
)
# Revize / classic ASP / similar CMS “site search” portals (e.g. Autauga
# ``Default.asp?ID=122&pg=Site+Search&action=search``).
_SITE_SEARCH_URL_RE = re.compile(
r"(?:^|[?&])pg=site\+search"
r"|(?:^|[?&])pg=site%20search"
r"|action=search"
r"|site\+search"
r"|site%20search"
r"|/site[_\-]?search"
r"|sitesearch\.asp"
r"|pagesearch",
re.I,
)
def strip_url_fragment(url: str) -> str:
p = urlparse(url)
return urlunparse((p.scheme, p.netloc, p.path, p.params, p.query, ""))
def dedupe_repeated_url_path(url: str) -> str:
"""
Collapse duplicated consecutive path blocks, e.g.
``/departments/board/departments/board/index.php`` -> ``/departments/board/index.php``.
Some CMS pages use site-root-style relative hrefs (``departments/...``) **without** a leading
``/``; :func:`urllib.parse.urljoin` then resolves them under the current directory and repeats the
path prefix (Burke County GA ``board_of_commissioners`` nav, etc.).
"""
if not url or not url.strip():
return url
try:
p = urlparse(url)
path = p.path or "/"
parts = [s for s in path.split("/") if s != ""]
min_block = 2
if len(parts) < min_block * 2:
return url
changed = True
while changed and len(parts) >= min_block * 2:
changed = False
max_block = len(parts) // 2
for block in range(max_block, min_block - 1, -1):
if parts[:block] == parts[block : 2 * block]:
parts = parts[block:]
changed = True
break
new_path = "/" + "/".join(parts) if parts else "/"
if new_path == path:
return url
return urlunparse((p.scheme, p.netloc, new_path, p.params, p.query, ""))
except Exception:
return url
def resolve_page_href(page_url: str, href: str) -> str:
"""Absolute URL from ``page_url`` + ``href``, strip fragment, fix duplicated path segments."""
full = strip_url_fragment(urljoin((page_url or "").strip(), (href or "").strip()))
return dedupe_repeated_url_path(full)
def document_join_base(page_url: str, soup: BeautifulSoup) -> str:
"""
Base URL for resolving relative ``href``/``src`` in this document.
When the page has ``<base href="…">`` (common on Revize / IIS sites), relative links like
``Documents/…`` must join to that base — not to ``…/Default.asp?…``, or ``urljoin`` drops the
site subdirectory (e.g. Autauga County ``/Sites/Autauga_County/``).
"""
pu = (page_url or "").strip()
tag = soup.find("base", href=True)
if tag:
b = (tag.get("href") or "").strip()
if b and not b.lower().startswith("javascript:"):
return resolve_page_href(pu, b)
return pu
def site_search_portal_variants(portal_url: str) -> List[str]:
"""
For same-site search portal URLs, return the base URL plus a few GET variants with meeting
keywords (CMS-specific; harmless 404s are dropped later).
"""
base = strip_url_fragment((portal_url or "").strip())
if not base:
return []
low = base.lower()
if "content-search" in low and ("keyword" in low or "dlv_" in low):
return opencivic_content_search_query_variants(base)
if not _SITE_SEARCH_URL_RE.search(base):
return [base]
sep = "&" if "?" in base else "?"
out: List[str] = [base]
for param in (
"keywords=meetings",
"keyword=meetings",
"search=meetings",
"q=meetings",
"Search=meetings",
"txtSearch=meetings",
"criteria=meetings",
"SearchString=meetings",
"keywords=youtube",
"keywords=video",
"keywords=webcast",
"search=live+stream",
):
cand = f"{base}{sep}{param}"
if cand not in out:
out.append(cand)
return out
def html_suggests_wordpress_site(html: str) -> bool:
"""
Heuristic: HTML likely comes from WordPress, so root ``/?s=`` site search is plausible.
Avoids hammering non-WP sites (e.g. Revize, Granicus OpenCivic) with WordPress-only URLs.
"""
if not html:
return False
sample = html[:600_000]
low = sample.lower()
if "/wp-json/" in low or "/wp-content/" in low or "/wp-includes/" in low:
return True
if "wp-embed.min.js" in low or 'content="wordpress' in low:
return True
if re.search(r'<link[^>]+rel=["\']https://api\.w\.org/', sample, re.I):
return True
if re.search(
r'<form[^>]{0,600}action=["\'][^"\']*["\'][^>]{0,1200}name=["\']s["\']',
sample,
re.I | re.DOTALL,
):
return True
if re.search(
r'name=["\']s["\'][^>]{0,400}action=["\'][^"\']*[\?&]s=',
sample,
re.I | re.DOTALL,
):
return True
return False
def extract_opencivic_content_search_portals(html: str, page_url: str, homepage: str) -> List[str]:
"""
Granicus / OpenCivic-style site search, e.g.
``/Content-search?dlv_…Public%20Site%20Search=(keyword=)`` (filled per-query later).
Example host: ``dallascounty-al.org`` (Granicus OpenCivic ``Content-search``).
"""
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="opencivic_content_search")
if soup is None:
return []
join_base = document_join_base(page_url, soup)
found: List[str] = []
seen: Set[str] = set()
def consider(raw: str) -> None:
u0 = (raw or "").strip()
if not u0 or u0.startswith("#") or u0.lower().startswith("javascript:"):
return
if "content-search" not in u0.lower():
return
if "keyword" not in u0.lower() and "dlv_" not in u0.lower():
return
full = resolve_page_href(join_base, u0)
if not is_same_site(full, homepage):
return
if full not in seen:
seen.add(full)
found.append(full)
for a in soup.find_all("a", href=True):
consider(a.get("href") or "")
for form in soup.find_all("form", action=True):
if (form.get("method") or "get").upper() != "GET":
continue
consider(form.get("action") or "")
return found
def opencivic_content_search_query_variants(
template_url: str,
keywords: Optional[Sequence[str]] = None,
) -> List[str]:
"""
Turn one ``Content-search`` template into concrete GET URLs by filling ``(keyword=…)``.
If there is no ``(keyword=)`` placeholder, returns the template unchanged (single URL).
"""
base = strip_url_fragment((template_url or "").strip())
if not base:
return []
terms: Tuple[str, ...] = tuple(keywords) if keywords else (
"meetings",
"minutes",
"agenda",
"board",
"council",
"commission",
"youtube",
"video",
"webcast",
"live",
"meeting minutes",
)
if "(keyword=" not in base and "(keyword%3d=" not in base.lower():
return [base]
out: List[str] = []
for kw in terms:
filled = re.sub(
r"\(keyword=\)",
f"(keyword={quote_plus(kw)})",
base,
count=1,
flags=re.I,
)
if filled != base:
out.append(filled)
return list(dict.fromkeys(out))
def extract_site_search_portal_urls(html: str, page_url: str, homepage: str) -> List[str]:
"""
Discover same-site links to vendor “site search” pages (ASP ``Default.asp``, etc.).
These are not covered by WordPress ``/?s=`` heuristics; we scrape the homepage/nav HTML for
``pg=Site+Search``, ``action=search``, and similar patterns.
"""
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="site_search_portal")
if soup is None:
return []
found: List[str] = []
seen: Set[str] = set()
join_base = document_join_base(page_url, soup)
def add(raw: str) -> None:
u = resolve_page_href(join_base, raw.strip())
if not u or u in seen:
return
if not is_same_site(u, homepage):
return
if not _SITE_SEARCH_URL_RE.search(u):
return
seen.add(u)
found.append(u)
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
continue
full = resolve_page_href(join_base, href)
if _SITE_SEARCH_URL_RE.search(full) or _SITE_SEARCH_URL_RE.search(href):
add(full)
continue
text = (a.get_text() or "").strip().lower()
if "site search" in text and "search" in full.lower():
add(full)
for form in soup.find_all("form", action=True):
action = (form.get("action") or "").strip()
if not action:
continue
if (form.get("method") or "get").upper() != "GET":
continue
full = resolve_page_href(join_base, action)
if is_same_site(full, homepage) and _SITE_SEARCH_URL_RE.search(full):
add(full)
for u in extract_opencivic_content_search_portals(html, page_url, homepage):
if u not in seen:
seen.add(u)
found.append(u)
return found
def _is_youtube_related_host(host: str) -> bool:
h = (host or "").lower().split(":")[0].lstrip(".")
if h in ("youtu.be", "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"):
return True
if h.endswith(".youtube.com") or h.endswith(".youtu.be"):
return True
if "youtube-nocookie.com" in h:
return True
return False
def normalize_youtube_url(raw: str, page_url: str) -> Optional[str]:
"""Return canonical ``https`` YouTube URL without fragment, or ``None`` if not YouTube."""
u = (raw or "").strip()
if not u or u.lower().startswith(("javascript:", "mailto:", "tel:")):
return None
if u.startswith("//"):
u = "https:" + u
u = dedupe_repeated_url_path(strip_url_fragment(urljoin(page_url, u)))
try:
p = urlparse(u)
except Exception:
return None
host = (p.netloc or "").lower().split(":")[0]
if not _is_youtube_related_host(host):
return None
scheme = "https"
if host == "youtu.be":
vid = (p.path or "").strip("/").split("/")[0]
if not vid:
return None
vid = vid.split("?")[0]
return strip_url_fragment(f"{scheme}://www.youtube.com/watch?v={vid}")
netloc = host
if "youtube-nocookie.com" in netloc:
netloc = "www.youtube-nocookie.com"
elif netloc in ("youtube.com", "m.youtube.com"):
netloc = "www.youtube.com"
return strip_url_fragment(urlunparse((scheme, netloc, p.path, p.params, p.query, "")))
def classify_youtube_link(url: str) -> str:
"""Rough bucket: ``video``, ``channel``, ``playlist``, or ``other``."""
low = (url or "").lower()
if "/playlist" in low and "list=" in low:
return "playlist"
if "/playlist/" in low:
return "playlist"
if "/channel/" in low:
return "channel"
if "/@" in low:
return "channel"
if "/user/" in low:
return "channel"
if "/c/" in low and "/channel/" not in low:
return "channel"
if "/watch" in low or "/embed/" in low or "/shorts/" in low or "/live" in low:
return "video"
if "youtu.be/" in low:
return "video"
return "other"
_YOUTUBE_MEETING_STRONG = re.compile(
r"\b(?:city|town|village|borough)\s+council\b|"
r"\bcounty\s+commission\b|\bboard\s+of\s+(?:commissioners|supervisors|trustees)\b|"
r"\b(?:school|planning|zoning|park|library)\s+board\b|"
r"\b(?:public|town)\s+hearing\b|"
r"\b(?:committee|commission)\s+of\s+the\s+whole\b|"
r"\b(?:regular|special|study|work)\s+session\b|\bworkshop\b|"
r"\b(?:full|complete)\s+meeting\b|\bmeeting\s+(?:video|recording|replay)\b|"
r"\b(?:live|recorded)\s+(?:stream|meeting)\b|"
r"\bagenda\b|\bminutes\b|\bproceedings\b|\baction\s*minutes\b|\bwebcast\b|\bgovernment\s+meeting\b|"
r"\bmunicipal\b|\b(?:city|county)\s+hall\b",
re.I,
)
_YOUTUBE_MEETING_WEAK = re.compile(
r"\b(?:meetings?|hearing|session|council|commission|board|supervisor|trustee|"
r"alderm(?:an|en)|mayor|clerk|agenda|minutes?|proceedings|action\s*minutes|townhall)\b",
re.I,
)
_YOUTUBE_NON_MEETING = re.compile(
r"\b(?:music\s+video|official\s+video|lyrics|gameplay|minecraft|fortnite|"
r"tutorial|asmr|unboxing|reaction\s+video|highlights\s+only|sports\s+highlights)\b",
re.I,
)
def youtube_url_for_oembed(url: str) -> Optional[str]:
"""
Normalize to a ``https://www.youtube.com/watch?v=…`` URL for YouTube's public oEmbed API.
Returns ``None`` for channels, playlists, and other shapes oEmbed does not resolve to
a single video.
"""
u = (url or "").strip()
if not u:
return None
if classify_youtube_link(u) != "video":
return None
try:
p = urlparse(u)
except Exception:
return None
host = (p.netloc or "").lower().split(":")[0]
path = p.path or ""
q = p.query or ""
if host in ("youtu.be", "www.youtu.be"):
vid = path.strip("/").split("/")[0]
return f"https://www.youtube.com/watch?v={vid}" if vid else None
if "/embed/" in path:
m = re.search(r"/embed/([^/?&]+)", path)
if m:
return f"https://www.youtube.com/watch?v={m.group(1)}"
if "/shorts/" in path:
m = re.search(r"/shorts/([^/?&]+)", path)
if m:
return f"https://www.youtube.com/watch?v={m.group(1)}"
if "/watch" in path or path.rstrip("/").endswith("watch"):
vids = parse_qs(q).get("v") or []
if vids and vids[0]:
return strip_url_fragment(f"https://www.youtube.com/watch?v={vids[0]}")
if "/live" in path:
vids = parse_qs(q).get("v") or []
if vids and vids[0]:
return strip_url_fragment(f"https://www.youtube.com/watch?v={vids[0]}")
return None
def score_youtube_meeting_relevance(
title: str,
author_name: str = "",
*,
homepage_host: str = "",
) -> Tuple[str, float, List[str]]:
"""
Score oEmbed ``title`` / ``author_name`` for civic-meeting similarity.
Returns ``(label, score, signal_tags)`` where ``label`` is one of:
``likely_meeting``, ``possible_meeting``, ``weak_signal``, ``unlikely_meeting``, ``unclear``,
or ``unknown`` (empty text).
"""
t = (title or "").strip()
a = (author_name or "").strip()
blob = f"{t}\n{a}".strip()
signals: List[str] = []
if not blob:
return "unknown", 0.0, []
score = 0.0
if _YOUTUBE_NON_MEETING.search(blob):
score -= 2.5
signals.append("non_meeting_topic_hint")
if _YOUTUBE_MEETING_STRONG.search(blob):
score += 4.0
signals.append("strong_meeting_term")
if _YOUTUBE_MEETING_WEAK.search(blob):
score += 1.5
signals.append("weak_meeting_term")
h = (homepage_host or "").lower().split(":")[0].lstrip(".")
if h and h in a.lower():
score += 1.25
signals.append("author_matches_homepage_host")
if score >= 3.5:
label = "likely_meeting"
elif score >= 2.0:
label = "possible_meeting"
elif score > 0:
label = "weak_signal"
elif score < 0:
label = "unlikely_meeting"
else:
label = "unclear"
return label, score, signals
def extract_youtube_refs(html: str, page_url: str) -> List[Dict[str, str]]:
"""
Collect YouTube video and channel URLs from ``<a href>``, ``<iframe>`` / ``<embed>`` src,
``<link href>``, and common ``data-*`` lazy attributes.
"""
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="youtube_refs")
if soup is None:
return []
out: List[Dict[str, str]] = []
seen: Set[str] = set()
def consider(raw: str, found_via: str) -> None:
nu = normalize_youtube_url(raw, page_url)
if not nu:
return
if nu in seen:
return
seen.add(nu)
out.append(
{
"url": nu,
"link_type": classify_youtube_link(nu),
"found_via": found_via,
}
)
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#"):
continue
if "youtube" in href.lower() or "youtu.be" in href.lower():
consider(href, "a_href")
for tag in soup.find_all(["iframe", "embed", "object"], src=True):
src = (tag.get("src") or "").strip()
if not src:
continue
if "youtube" in src.lower() or "youtu.be" in src.lower():
consider(src, tag.name)
for tag in soup.find_all(True):
for attr in ("data-youtube-url", "data-src", "data-url"):
v = tag.get(attr)
if v and isinstance(v, str) and ("youtube" in v.lower() or "youtu.be" in v.lower()):
consider(v, attr)
for link in soup.find_all("link", href=True):
href = (link.get("href") or "").strip()
if "youtube.com" in href.lower() or "youtu.be" in href.lower():
consider(href, "link_href")
return out
def _looks_like_youtube_url(url: str) -> bool:
low = (url or "").lower()
return "youtube.com" in low or "youtu.be" in low or "youtube-nocookie.com" in low
def _absolute_http_stream_url(raw: str, page_url: str) -> Optional[str]:
u = (raw or "").strip()
if not u or u.startswith("#") or u.lower().startswith(("javascript:", "mailto:", "tel:")):
return None
if u.startswith("//"):
u = "https:" + u
try:
full = dedupe_repeated_url_path(strip_url_fragment(urljoin(page_url, u)))
p = urlparse(full)
except Exception:
return None
if p.scheme not in ("http", "https"):
return None
if len(full) > 2500:
return None
return full
# Non-YouTube players and meeting-video hosts (YouTube handled by :pyfunc:`extract_youtube_refs`).
_OTHER_VIDEO_STREAM_PATTERNS: Tuple[Tuple[str, re.Pattern], ...] = (
("vimeo", re.compile(r"vimeo\.com|player\.vimeo\.com", re.I)),
(
"facebook",
re.compile(
r"facebook\.com/(?:watch|plugins/video|[^/]+/videos)|fb\.watch|fb\.com/plugins/video",
re.I,
),
),
("twitch", re.compile(r"twitch\.tv|player\.twitch\.tv", re.I)),
("dailymotion", re.compile(r"dailymotion\.com|dai\.ly", re.I)),
("zoom", re.compile(r"(?:[\w.-]+\.)?zoom\.us/(?:j/|wc/|w/|join|my/|rec/)", re.I)),
("google_meet", re.compile(r"meet\.google\.com/[a-z0-9\-]+", re.I)),
("teams", re.compile(r"teams\.microsoft\.com|teams\.live\.com", re.I)),
("webex", re.compile(r"webex\.com|(?:[\w.-]+\.)?webex\.com", re.I)),
("granicus", re.compile(r"granicus\.com|granicusideas\.com", re.I)),
("civicclerk", re.compile(r"civicclerk\.com", re.I)),
("civicengage_video", re.compile(r"videoplayer\.civicengage\.|streamingvideoplayer", re.I)),
("wistia", re.compile(r"wistia\.com|wistia\.net|fast\.wistia\.net", re.I)),
("brightcove", re.compile(r"brightcove\.com|players\.brightcove\.net|edge\.brightcove\.com", re.I)),
("boxcast", re.compile(r"boxcast\.tv|boxcast\.io", re.I)),
("swagit", re.compile(r"swagit\.com|swagit\.net", re.I)),
("dacast", re.compile(r"dacast\.com", re.I)),
("panopto", re.compile(r"panopto\.com", re.I)),
("kaltura", re.compile(r"kaltura\.com|cdnsecakmi\.kaltura\.com", re.I)),
("microsoft_stream", re.compile(r"microsoftstream\.com|web\.microsoftstream\.com", re.I)),
("jwplayer", re.compile(r"jwplayer\.com|cdn\.jwplayer\.com", re.I)),
("uscreen", re.compile(r"uscreen\.io|videodelivery\.net", re.I)),
# GoMeet / Meetmaps-style hosted recordings (e.g. Sweet Grass County MT commission table links).
("gomeet", re.compile(r"gomeet\.com/", re.I)),
# Champ Digital Solutions (TV Gwinnett and similar ``play.champds.com`` embeds).
("champds", re.compile(r"play\.champds\.com|playapi\.champds\.com|securestream\d+\.champds\.com", re.I)),
)
def _classify_other_stream_platform(full: str) -> Optional[str]:
if _looks_like_youtube_url(full):
return None
if re.search(
r"\.amazonaws\.com/suiteone\.(?:[^/]+/videofiles/.+\.mp4|[^/]+\.videofiles/[^/]+\.mp4)",
full,
re.I,
):
return "suiteone_s3_mp4"
if re.search(r"\.m3u8(?:\?|#|$)", full, re.I):
return "hls_manifest"
for platform, pat in _OTHER_VIDEO_STREAM_PATTERNS:
if pat.search(full):
return platform
return None
def extract_other_video_stream_refs(html: str, page_url: str) -> List[Dict[str, str]]:
"""
Collect **non-YouTube** video / livestream URLs (Vimeo, Facebook video, Granicus, Zoom links,
Teams, GoMeet, Wistia, Brightcove, ``.m3u8`` manifests, …). Same discovery surfaces as YouTube:
``<a href>``, ``<iframe>`` / ``<embed>`` ``src``, ``<video>`` / ``<source>``, and common
``data-*`` player attributes.
"""
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="other_video_streams")
if soup is None:
return []
out: List[Dict[str, str]] = []
seen: Set[str] = set()
def consider(raw: str, found_via: str, anchor_text: str = "") -> None:
full = _absolute_http_stream_url(raw, page_url)
if not full:
return
plat = _classify_other_stream_platform(full)
if not plat:
return
if full in seen:
return
seen.add(full)
row: Dict[str, str] = {"url": full, "platform": plat, "found_via": found_via}
label = re.sub(r"\s+", " ", (anchor_text or "").strip())
if label:
row["anchor_text"] = label[:500]
out.append(row)
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#"):
continue
consider(href, "a_href", _link_anchor_text(a))
for tag in soup.find_all(["iframe", "embed", "object"], src=True):
src = (tag.get("src") or "").strip()
if src:
consider(src, tag.name)
for tag in soup.find_all("object", data=True):
d = (tag.get("data") or "").strip()
if d:
consider(d, "object_data")
for tag in soup.find_all("video", src=True):
src = (tag.get("src") or "").strip()
if src:
consider(src, "video_tag")
for tag in soup.find_all("source", src=True):
src = (tag.get("src") or "").strip()
if src:
consider(src, "source_tag")
for tag in soup.find_all(True):
for attr in (
"data-src",
"data-url",
"data-stream-url",
"data-video-url",
"data-embed-url",
"data-player-url",
"data-live-url",
):
v = tag.get(attr)
if v and isinstance(v, str):
consider(v, attr)
for link in soup.find_all("link", href=True):
href = (link.get("href") or "").strip()
if href:
consider(href, "link_href")
blob = html or ""
for label, pat in (
("jwplayer_var_src", r"var\s+src\s*=\s*['\"](https?://[^'\"]+\.mp4[^'\"]*)['\"]"),
(
"jwplayer_sources_file",
r"sources\s*:\s*\[\s*\{\s*file\s*:\s*['\"](https?://[^'\"]+\.mp4[^'\"]*)['\"]",
),
(
"suiteone_s3_mp4_literal",
r"(https://s3\.amazonaws\.com/suiteone[^\\s\"'<>]+\.mp4(?:\?[^\\s\"'<>]*)?)",
),
):
for m in re.finditer(pat, blob, re.I):
consider(m.group(1), label)
return out
def classify_document(url: str, anchor_text: str = "") -> str:
blob = f"{url} {anchor_text}".lower()
for name, pat in _DOC_TYPE_RULES:
if pat.search(blob):
return name
return "unknown"
def detect_meeting_stacks(html: str, page_url: str) -> List[str]:
"""Return ordered unique stack ids (strong signals first)."""
blob = f"{html[:120_000]} {page_url}".lower()
found: List[str] = []
seen: Set[str] = set()
for name, pat in _STACK_PATTERNS:
if pat.search(blob) and name not in seen:
seen.add(name)
found.append(name)
return found
def merge_stack_hints(existing: Sequence[str], page_hints: Sequence[str]) -> List[str]:
out: List[str] = []
seen: Set[str] = set()
for seq in (existing, page_hints):
for h in seq:
if h not in seen:
seen.add(h)
out.append(h)
return out
def extract_meeting_urls(
html: str,
page_url: str,
homepage: str,
*,
generic_hint: re.Pattern,
) -> Tuple[List[str], List[Tuple[str, str]]]:
"""
Return ``(nav_urls, pdf_pairs)`` to crawl or download.
``pdf_pairs`` are ``(absolute_url, anchor_text)`` for :pyfunc:`classify_document`.
- Same-site links matching ``generic_hint`` on URL or anchor text, or meeting documents
(``pdf`` / ``docx`` / ``mp3`` / … — see :data:`MEETING_DOWNLOAD_EXT`).
- Trusted offsite PDFs (Legistar / Granicus / …) and **AWS** ``*.amazonaws.com`` document URLs.
- SuiteOne ``/event/GetAgendaFile/…`` and ``/event/GetMinutesFile/…`` (no ``.pdf`` suffix).
- Trusted offsite vendor meeting pages (narrow path heuristics).
"""
soup = _beautifulsoup_meetings(html, page_url=page_url, log_label="extract_meeting_urls")
if soup is None:
return [], []
join_base = document_join_base(page_url, soup)
nav: List[str] = []
pdfs: List[Tuple[str, str]] = []
seen_nav: Set[str] = set()
seen_pdf: Set[str] = set()
def add_nav(u: str) -> None:
if u not in seen_nav:
seen_nav.add(u)
nav.append(u)
def add_pdf(u: str, anchor: str) -> None:
if u not in seen_pdf:
seen_pdf.add(u)
pdfs.append((u, anchor))
for a in soup.find_all("a", href=True):
href = (a.get("href") or "").strip()
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
continue
full = resolve_page_href(join_base, href)
text = _link_anchor_text(a)
text_l = text.lower()
if MEETING_DOWNLOAD_EXT.search(full):
if (
is_same_site(full, homepage)
or is_same_site(full, page_url)
or is_trusted_offsite(full)
or is_amazonaws_document_cdn_host(full)
):
add_pdf(full, text)
continue
if is_suiteone_document_download_url(full):
if (
is_same_site(full, homepage)
or is_same_site(full, page_url)
or is_trusted_offsite(full)
or is_amazonaws_document_cdn_host(full)
):
add_pdf(full, text)
continue
if is_same_site(full, homepage) or is_same_site(full, page_url):
if (
generic_hint.search(full)
or generic_hint.search(text_l)
or _SITE_SEARCH_URL_RE.search(full)
or _SITE_SEARCH_URL_RE.search(href)
):
add_nav(full)
elif is_vendor_meeting_page_url(full):
add_nav(full)
elif is_linked_local_meeting_microsite(full, homepage):
if (
generic_hint.search(full)
or generic_hint.search(text_l)
or _SITE_SEARCH_URL_RE.search(full)
or _SITE_SEARCH_URL_RE.search(href)
):
add_nav(full)
# iframes (embedded Legistar / Granicus calendars)
for tag in soup.find_all(["iframe", "frame"], src=True):
src = (tag.get("src") or "").strip()
if not src:
continue
full = resolve_page_href(join_base, src)
if (
is_same_site(full, homepage)
or is_same_site(full, page_url)
or is_vendor_meeting_page_url(full)
):
add_nav(full)
for syn_u, _syn_a in extract_simbli_meeting_listing_synthetic_nav_urls(html, page_url):
if not syn_u.lower().startswith(("http://", "https://")):
continue
if is_vendor_meeting_page_url(syn_u):
add_nav(syn_u)
return nav, pdfs
def all_pdf_urls_from_page(
html: str,
page_url: str,
homepage: str,
generic_hint: re.Pattern,
) -> FrozenSet[str]:
_, pairs = extract_meeting_urls(html, page_url, homepage, generic_hint=generic_hint)
return frozenset(u for u, _ in pairs)