Skip to content

Commit cf9ab18

Browse files
Replace Phenom api_sniffer with dedicated sitemap monitor (#2538)
* Replace Phenom api_sniffer with dedicated sitemap monitor All 9 Phenom boards were silently truncating to ~500 jobs per cycle: the api_sniffer path hit a 50-page hard cap with Phenom's fixed 10-per-page API, and find_total_count() didn't recognise Phenom's totalJob field so the cap-lifting logic at api_sniffer.py:1085 never kicked in. Two fixes, independently shippable: 1. Extend COUNT_FIELDS regex to match total<Suffix> patterns so the existing cap-lifting logic picks up vendor-specific names like Phenom's totalJob. This alone unblocks every api_sniffer board whose ATS exposes a non-standard total field. 2. New phenom monitor (core/monitors/phenom.py) that discovers URLs via the tenant's /sitemap.xml sitemap-index. Replaces the 20-min (Marriott) to 90-min (mchire) paginated browser fetch with a ~5s plain-httpx sitemap traversal. Per-URL content comes from the detail page's JSON-LD JobPosting via the json-ld scraper (with render:true for tenants whose detail pages need Playwright render). Sitemap-index classification is language-aware: tenants with per- locale children (Marriott = 22 locales, Nike = 16 locales) get non-English children filtered out, matching the old api_sniffer's site_available_languages: [en, en-us] behaviour. Sharded indexes where all children share one language (mcdonalds-* = 200-1000 shards) pass through unchanged. No watermark state, no API call, no hybrid flag. Phenom's API has (a) no per-job timestamp, (b) no sort-by-recency, (c) Akamai-gated TLS/JS fingerprint needing a real Chrome profile — so the Eightfold watermark-on-postedTs pattern doesn't apply. Sitemap + _DIFF_BATCH + _MARK_GONE_BY_TIMESTAMP already provide the exact new/relisted/gone semantics from the shared pipeline. Live-verified on Hetzner worker (2026-04-23) across all 9 tenants: tenant before after api totalJob marriott 500 12,454 12,454 nike 500 919 920 nordstrom 500 1,105 1,107 elevance-health 321 321 321 mondelez 464 464 464 nationwide 151 151 151 mcdonalds-au 500 1,899 1,899 mcdonalds-canada 500 7,938 3,889 (*) mcdonalds-us 500 37,496 53,844 (**) (*) sitemap carries more URLs than the API's active filter; excess will age out via MARK_GONE_BY_TIMESTAMP if not re-seen. (**) ~93/1000 shards carry French/Spanish suffixes filtered by the English-language gate; English coverage is the intent. Tests: 28 new in test_phenom_monitor.py covering URL classification, child-language extraction, per-language vs. sharded index selection, and can_handle detection. 3,380 existing tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Bump crawler version to 0.8.75 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f0f2cdc commit cf9ab18

9 files changed

Lines changed: 638 additions & 11 deletions

File tree

apps/crawler/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.8.74
1+
0.8.75

apps/crawler/data/boards.csv

Lines changed: 9 additions & 9 deletions
Large diffs are not rendered by default.

apps/crawler/src/core/monitors/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,12 @@ def _build_comment(name: str, metadata: dict) -> str:
441441
if jobs is not None:
442442
return f"Personio XML \u2014 slug: {slug}, {jobs} jobs"
443443
return f"Personio XML \u2014 slug: {slug}"
444+
if name == "phenom":
445+
sitemap_url = metadata.get("sitemap_url", "?")
446+
jobs = metadata.get("jobs")
447+
if jobs is not None:
448+
return f"Phenom \u2014 {jobs} jobs at {sitemap_url}"
449+
return f"Phenom \u2014 {sitemap_url}"
444450
if name == "jobylon":
445451
group = metadata.get("company_group_id")
446452
company = metadata.get("company_id")
@@ -558,6 +564,7 @@ async def _probe_one(monitor: MonitorType) -> tuple[str, dict | None, str]:
558564
notion, # noqa: F401
559565
oracle_hcm, # noqa: F401
560566
personio, # noqa: F401
567+
phenom, # noqa: F401
561568
pinpoint, # noqa: F401
562569
recruitee, # noqa: F401
563570
recruiter_co_kr, # noqa: F401
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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)

apps/crawler/src/shared/api_sniff.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,11 @@ async def _fetch(method: str, url: str, headers: dict, body: str | None) -> obje
9797
)
9898

9999
COUNT_FIELDS = re.compile(
100+
# `total[A-Za-z]+` covers vendor-specific suffixes like Phenom's `totalJob`
101+
# (job siblings to the array are always int-valued and evaluated in context,
102+
# so the broader match stays anchored to the array's parent object).
100103
r"^(total|count|total_?count|total_?results|total_?items|hits|num_?found|result_?count"
101-
r"|size|totalCount|totalResults|totalItems|totalHits|nbHits)$",
104+
r"|size|total[A-Za-z]+|nbHits)$",
102105
re.IGNORECASE,
103106
)
104107

apps/crawler/src/workspace/_compat.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def auto_skip_crawler_types() -> frozenset[str]:
6666
"workday",
6767
"ycombinator",
6868
"sitemap",
69+
"phenom",
6970
"nextdata",
7071
"notion",
7172
"dom",
@@ -254,6 +255,11 @@ def auto_scraper_type(
254255
return ("workday", None)
255256
if monitor_type == "eightfold":
256257
return ("eightfold", None)
258+
if monitor_type == "phenom":
259+
# Every tenant has a JSON-LD JobPosting on the detail page.
260+
# Tenants whose pages need Playwright render (mcdonalds-*, nationwide
261+
# detail) override with ``{"render": true}`` in boards.csv.
262+
return ("json-ld", None)
257263
if monitor_type == "softgarden":
258264
return ("json-ld", None)
259265
if monitor_type == "ycombinator":

apps/crawler/src/workspace/commands/help.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,45 @@
606606
607607
Pair with: json-ld (try first) or dom scraper"""
608608

609+
MONITOR_PHENOM = """\
610+
phenom — Phenom People Careers Platform (sitemap + json-ld)
611+
612+
Returns: URL set only (needs scraper)
613+
Cap: 50,000 URLs (inherited from sitemap)
614+
615+
Phenom tenants (e.g. careers.marriott.com, careers.nike.com) expose a
616+
sitemap-index at /sitemap.xml with per-language child sitemaps named
617+
sitemap-<hex>-<lang>.xml. The sitemap is the authoritative URL set.
618+
Rich job data comes from the detail page's JSON-LD JobPosting script,
619+
extracted by the json-ld scraper.
620+
621+
Why a dedicated monitor vs. plain sitemap:
622+
• Phenom-specific can_handle fingerprint (child naming) avoids
623+
mis-detecting generic XML sitemaps as Phenom during ws probe.
624+
• Filters discovered URLs to job detail pages (contain "/job/" or
625+
"?job_id="), dropping site root, "/jobs" index, language pages.
626+
627+
No API is used. Phenom's /api/get-jobs has no per-job timestamp and
628+
no sort-by-recency, so there's no meaningful incremental signal; the
629+
sitemap URL set + last_seen_at in Postgres already give _DIFF_BATCH
630+
everything it needs for new/relisted/gone classification.
631+
632+
Config:
633+
{} — no configuration required. sitemap_url is derived from the
634+
board URL's scheme+host (board metadata stores the discovered
635+
URL for future runs, as with the sitemap monitor).
636+
637+
Scrapers:
638+
json-ld (default) — works for marriott, nike, nordstrom, elevance,
639+
mondelez. Detail page returns JSON-LD natively.
640+
json-ld + render:true — for mcdonalds-* and nationwide detail pages
641+
where Playwright render is needed before
642+
the JobPosting <script> appears in DOM.
643+
644+
Browser flags on the board (persistent_context, channel=chrome,
645+
proxy) only matter for the scraper path; the monitor itself uses
646+
plain httpx and runs in ~5 seconds regardless of job count."""
647+
609648
MONITOR_NEXTDATA = """\
610649
nextdata — Next.js __NEXT_DATA__ Discovery
611650
@@ -1945,6 +1984,7 @@
19451984
"personio": MONITOR_PERSONIO,
19461985
"rss": MONITOR_RSS,
19471986
"sitemap": MONITOR_SITEMAP,
1987+
"phenom": MONITOR_PHENOM,
19481988
"nextdata": MONITOR_NEXTDATA,
19491989
"notion": MONITOR_NOTION,
19501990
"oracle_hcm": """\

apps/crawler/tests/test_api_sniff.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ def test_total_items_nested(self):
181181
body = {"data": {"totalItems": 42, "jobs": [{"id": 1}]}}
182182
assert find_total_count(body, "data.jobs") == 42
183183

184+
def test_total_job_phenom(self):
185+
body = {"totalJob": 12441, "jobs": [{"id": 1}], "facets": []}
186+
assert find_total_count(body, "jobs") == 12441
187+
184188

185189
class TestScoreCandidate:
186190
def test_high_score_with_url_and_title(self):

0 commit comments

Comments
 (0)