|
| 1 | +"""Phenom People careers-site monitor. |
| 2 | +
|
| 3 | +Phenom is a SaaS careers platform (phenompeople.com) used by large |
| 4 | +enterprises (Marriott, Nike, Nordstrom, Elevance, McDonald's, etc.). |
| 5 | +Every tenant exposes a sitemap at ``/sitemap.xml`` that is a sitemap-index |
| 6 | +pointing to child sitemaps. Child naming is either: |
| 7 | +
|
| 8 | +- **Per-language** — one child per supported locale, each emitting a |
| 9 | + distinct URL path per job (Marriott ``/ar/<slug>/job/<hex>__ar``, |
| 10 | + Nike ``/de/<slug>/job/R-xxxxx``). Union-of-all-children explodes the |
| 11 | + URL count by N languages; we keep only ``-en`` / ``-en-us`` children |
| 12 | + to match the pre-existing ``site_available_languages: [en, en-us]`` |
| 13 | + behaviour of the old api_sniffer config. |
| 14 | +- **Sharded** — many children, all the same language suffix, each |
| 15 | + carrying a subset of URLs (mcdonalds-au 210 shards, mcdonalds-canada |
| 16 | + 471 shards, mcdonalds-us 1000 shards). No per-language filtering is |
| 17 | + applied; every shard contributes to the union. |
| 18 | +
|
| 19 | +The sitemap is the authoritative URL set for gone detection; rich data |
| 20 | +for each job is extracted from the detail page's JSON-LD ``JobPosting`` |
| 21 | +by the pipeline's ``json-ld`` scraper. |
| 22 | +
|
| 23 | +Why not the ``/api/get-jobs`` endpoint? Phenom's API returns rich rows |
| 24 | +but has (a) no per-job timestamp, (b) no sort-by-recency, (c) Akamai- |
| 25 | +gated TLS/JS fingerprint that requires a real Chrome profile. That |
| 26 | +combination means the only way to get new-since-last-cycle from the |
| 27 | +API is a full paginated crawl of every tenant every cycle — 20 min |
| 28 | +for Marriott, 90 min for mchire. Sitemap plus per-URL json-ld is |
| 29 | +linear in *new URLs*, not total URLs, so steady-state is cheap. |
| 30 | +
|
| 31 | +Incremental semantics come for free from the shared pipeline: |
| 32 | +``_DIFF_BATCH`` classifies each sitemap URL as new/relisted/touched |
| 33 | +against ``job_posting.last_seen_at``; ``_MARK_GONE_BY_TIMESTAMP`` |
| 34 | +retires any active row whose URL wasn't re-seen this cycle. No |
| 35 | +watermark state, no hybrid flag, no API-sort assumption. |
| 36 | +
|
| 37 | +Tenants migrated (2026-04-23): marriott, nike, nordstrom, |
| 38 | +elevance-health, nationwide, mondelez, mcdonalds-au, mcdonalds-canada, |
| 39 | +mcdonalds-us. |
| 40 | +""" |
| 41 | + |
| 42 | +from __future__ import annotations |
| 43 | + |
| 44 | +import re |
| 45 | +from urllib.parse import urlparse |
| 46 | + |
| 47 | +import httpx |
| 48 | +import structlog |
| 49 | + |
| 50 | +from src.core.monitors import register |
| 51 | +from src.core.monitors.sitemap import ( |
| 52 | + MAX_URLS, |
| 53 | + _extract_child_sitemaps, |
| 54 | + _extract_urls, |
| 55 | + _is_sitemap_index, |
| 56 | + _try_fetch_xml, |
| 57 | +) |
| 58 | + |
| 59 | +log = structlog.get_logger() |
| 60 | + |
| 61 | +# Phenom detail URLs contain either ``/job/<id>`` (canonical Phenom pattern, |
| 62 | +# e.g. ``careers.marriott.com/<slug>/job/<hex>``) or ``?job_id=<id>`` / |
| 63 | +# ``&job_id=<id>`` (mchire variant used by mcdonalds-us franchisees). |
| 64 | +# Case-insensitive because mchire renders ``/Job?job_id=...`` with a capital J. |
| 65 | +_JOB_URL_RE = re.compile(r"/job/|[?&]job_id=", re.IGNORECASE) |
| 66 | + |
| 67 | +# Phenom child sitemap filenames follow ``sitemap-<hex>-<lang>.xml`` (e.g. |
| 68 | +# ``sitemap-0a80f330-en.xml``). Used as the fingerprint in ``can_handle``. |
| 69 | +_PHENOM_CHILD_RE = re.compile(r"sitemap-[a-f0-9]+-[a-z-]+\.xml", re.IGNORECASE) |
| 70 | + |
| 71 | +# Languages we keep when the sitemap-index carries multiple locales. Matches |
| 72 | +# the old ``site_available_languages: [en, en-us]`` in the pre-migration |
| 73 | +# api_sniffer configs so the URL set stays equivalent. |
| 74 | +_KEEP_LANGS = frozenset({"en", "en-us"}) |
| 75 | + |
| 76 | + |
| 77 | +def _is_phenom_job_url(url: str) -> bool: |
| 78 | + return bool(_JOB_URL_RE.search(url)) |
| 79 | + |
| 80 | + |
| 81 | +def _default_sitemap_url(board_url: str) -> str: |
| 82 | + parsed = urlparse(board_url) |
| 83 | + return f"{parsed.scheme}://{parsed.netloc}/sitemap.xml" |
| 84 | + |
| 85 | + |
| 86 | +def _child_language(child_url: str) -> str | None: |
| 87 | + """Return the language suffix of a Phenom child sitemap filename, lowercased. |
| 88 | +
|
| 89 | + Phenom filenames follow ``sitemap-<hex>-<lang>[-<region>].xml``; the |
| 90 | + language segment is everything after the second dash. Files with no |
| 91 | + third segment (e.g. ``sitemap-content.xml`` at nationwide, which |
| 92 | + holds only site-root URLs) return None — they get filtered out by |
| 93 | + the job-URL regex downstream anyway. |
| 94 | + """ |
| 95 | + name = child_url.rsplit("/", 1)[-1] |
| 96 | + if not name.endswith(".xml"): |
| 97 | + return None |
| 98 | + parts = name[: -len(".xml")].split("-") |
| 99 | + if len(parts) < 3: |
| 100 | + return None |
| 101 | + return "-".join(parts[2:]).lower() |
| 102 | + |
| 103 | + |
| 104 | +def _select_children(children: list[str]) -> list[str]: |
| 105 | + """Filter child sitemap URLs to the English-equivalent subset. |
| 106 | +
|
| 107 | + Per-language indexes (marriott = 22 locales, nike = 16 locales) get |
| 108 | + reduced to ``-en``/``-en-us`` children only. Sharded indexes where |
| 109 | + every child carries the same language suffix (mcdonalds-*) pass |
| 110 | + through unchanged because there is only one real language in the set. |
| 111 | + Children without a language suffix are always kept — they're |
| 112 | + typically low-cardinality content maps whose non-job URLs drop out |
| 113 | + via ``_is_phenom_job_url``. |
| 114 | + """ |
| 115 | + if not children: |
| 116 | + return children |
| 117 | + langs_with_suffix = {_child_language(c) for c in children} - {None} |
| 118 | + # Only one real language → sharded layout, keep everything. |
| 119 | + if len(langs_with_suffix) <= 1: |
| 120 | + return children |
| 121 | + return [c for c in children if _child_language(c) is None or _child_language(c) in _KEEP_LANGS] |
| 122 | + |
| 123 | + |
| 124 | +async def _collect_urls( |
| 125 | + sitemap_url: str, |
| 126 | + client: httpx.AsyncClient, |
| 127 | +) -> tuple[set[str], bool]: |
| 128 | + """Fetch sitemap (index or flat), traverse selectively, return URL set. |
| 129 | +
|
| 130 | + Returns ``(urls, truncated)`` where *truncated* is True when we hit |
| 131 | + ``sitemap.MAX_URLS`` during accumulation; the caller may log this. |
| 132 | + """ |
| 133 | + root = await _try_fetch_xml(sitemap_url, client) |
| 134 | + if root is None: |
| 135 | + return set(), False |
| 136 | + |
| 137 | + if not _is_sitemap_index(root): |
| 138 | + return set(_extract_urls(root)), False |
| 139 | + |
| 140 | + children = _extract_child_sitemaps(root) |
| 141 | + selected = _select_children(children) |
| 142 | + skipped = len(children) - len(selected) |
| 143 | + if skipped: |
| 144 | + log.debug( |
| 145 | + "phenom.children_filtered", |
| 146 | + sitemap=sitemap_url, |
| 147 | + total=len(children), |
| 148 | + kept=len(selected), |
| 149 | + skipped=skipped, |
| 150 | + ) |
| 151 | + |
| 152 | + urls: set[str] = set() |
| 153 | + truncated = False |
| 154 | + for child_url in selected: |
| 155 | + if len(urls) >= MAX_URLS: |
| 156 | + truncated = True |
| 157 | + break |
| 158 | + child_root = await _try_fetch_xml(child_url, client) |
| 159 | + if child_root is None: |
| 160 | + continue |
| 161 | + # Nested sitemap-index (rare; defensive): single-level recurse. |
| 162 | + if _is_sitemap_index(child_root): |
| 163 | + for grandchild in _select_children(_extract_child_sitemaps(child_root)): |
| 164 | + if len(urls) >= MAX_URLS: |
| 165 | + truncated = True |
| 166 | + break |
| 167 | + gc_root = await _try_fetch_xml(grandchild, client) |
| 168 | + if gc_root is not None: |
| 169 | + urls.update(_extract_urls(gc_root)) |
| 170 | + else: |
| 171 | + urls.update(_extract_urls(child_root)) |
| 172 | + return urls, truncated |
| 173 | + |
| 174 | + |
| 175 | +async def discover( |
| 176 | + board: dict, |
| 177 | + client: httpx.AsyncClient, |
| 178 | + pw=None, |
| 179 | +) -> tuple[set[str], str | None]: |
| 180 | + """Fetch the Phenom sitemap, return (job_urls, new_sitemap_url). |
| 181 | +
|
| 182 | + Derives ``/sitemap.xml`` from the board host when not cached in |
| 183 | + metadata, traverses the index (preferring English children for |
| 184 | + per-language layouts), and filters the result to URLs that look |
| 185 | + like job detail pages (``/job/`` or ``?job_id=``). |
| 186 | +
|
| 187 | + ``new_sitemap_url`` is non-None only when the monitor had to derive |
| 188 | + the URL (mirrors the contract of ``sitemap.discover``). For boards |
| 189 | + that already cache it in metadata, None is returned so the pipeline |
| 190 | + skips the metadata-update write. |
| 191 | + """ |
| 192 | + metadata = board.get("metadata") or {} |
| 193 | + cached = metadata.get("sitemap_url") |
| 194 | + sitemap_url = cached or _default_sitemap_url(board["board_url"]) |
| 195 | + new_sitemap_url = None if cached else sitemap_url |
| 196 | + |
| 197 | + urls, truncated = await _collect_urls(sitemap_url, client) |
| 198 | + job_urls = {u for u in urls if _is_phenom_job_url(u)} |
| 199 | + |
| 200 | + log_fn = log.warning if truncated else log.info |
| 201 | + log_fn( |
| 202 | + "phenom.discover", |
| 203 | + board_id=board.get("id"), |
| 204 | + sitemap_urls=len(urls), |
| 205 | + job_urls=len(job_urls), |
| 206 | + truncated=truncated, |
| 207 | + ) |
| 208 | + return job_urls, new_sitemap_url |
| 209 | + |
| 210 | + |
| 211 | +async def can_handle( |
| 212 | + url: str, |
| 213 | + client: httpx.AsyncClient | None = None, |
| 214 | + pw=None, |
| 215 | +) -> dict | None: |
| 216 | + """Detect Phenom by fingerprinting the sitemap-index child naming. |
| 217 | +
|
| 218 | + Every Phenom tenant's ``/sitemap.xml`` is a sitemap-index whose |
| 219 | + children follow ``sitemap-<hex>-<lang>.xml``. Non-Phenom sitemaps |
| 220 | + (static generators, WordPress, etc.) use different conventions, so |
| 221 | + matching a single child filename is sufficient signal. |
| 222 | +
|
| 223 | + We avoid probing ``/api/get-jobs`` because Akamai returns 403 to |
| 224 | + datacenter IPs (the probe can_handle runs from) even for valid |
| 225 | + Phenom tenants — a 403 would be indistinguishable from a non- |
| 226 | + Phenom host blocking the endpoint. The sitemap fingerprint is |
| 227 | + cheaper and does not require egress proxying. |
| 228 | + """ |
| 229 | + if client is None: |
| 230 | + return None |
| 231 | + parsed = urlparse(url) |
| 232 | + sitemap = f"{parsed.scheme}://{parsed.netloc}/sitemap.xml" |
| 233 | + root = await _try_fetch_xml(sitemap, client) |
| 234 | + if root is None: |
| 235 | + return None |
| 236 | + children = _extract_child_sitemaps(root) |
| 237 | + if not any(_PHENOM_CHILD_RE.search(c) for c in children): |
| 238 | + return None |
| 239 | + urls, _truncated = await _collect_urls(sitemap, client) |
| 240 | + job_urls = sum(1 for u in urls if _is_phenom_job_url(u)) |
| 241 | + return {"sitemap_url": sitemap, "urls": len(urls), "jobs": job_urls} |
| 242 | + |
| 243 | + |
| 244 | +# Cost between eightfold (8) and sitemap (50) — same band as other |
| 245 | +# dedicated ATS monitors — so ``detect_monitor_type`` tries the Phenom |
| 246 | +# fingerprint before the generic sitemap path for new Phenom tenants. |
| 247 | +register("phenom", discover, cost=9, can_handle=can_handle) |
0 commit comments