From 7e7e9fbdad49758b2544fb6175ee6839fe94d6f4 Mon Sep 17 00:00:00 2001 From: Timothy Oluwatobi Ojebiyi Date: Tue, 20 Oct 2026 17:00:24 +0100 Subject: [PATCH] feat: Oracle, Eightfold and Teamtailor careers sites in the job feed Graduate employers' careers sites on three more job systems, each read through the public service its own pages call: - Oracle Recruiting (7): American Express, BNY (with Insight Investment), WTW, Compare the Market, Morrisons, UKRI (STFC), Kent. findReqs searches by keyword (expand=requisitionList, or no postings come back); a posting is read through recruitingCEJobRequisitionDetails (description, responsibilities, qualifications). AmEx's site shows on its own domain but searches on the oraclecloud.com host behind it. - Eightfold (1): Northrop Grumman, through /api/pcsx/search and position_details (the older /api/apply/v2 answers 403 "not authorized for PCSX"). It gives 10 a call and is mostly elsewhere without a location, so it's searched per query in each searched location. - Teamtailor (5): Isio, Tekever, 73 Strings, Dare, BRE: every posting with its description and places in /jobs.rss, read with patterns rather than an XML parser (no entity expansion of a page from the web). Oracle and Eightfold are searched like Workday: shared pacing and limits, now named for all three (_paced_call, _SEARCH_DEPTH, _READS_PER_BOARD, _SEARCH_CONCURRENCY, _BACKOFF_S). check_board knows all three. Live, one collection's pass over the 13 boards: 26 s, 130 found, 107 in London/UK, 49 past the title filter, all with full descriptions (AmEx Software Engineer and AI Engineer 2027 in London/Burgess Hill, BNY 2027 Analyst Program - Engineering, WTW 2027 Software Developer Graduate Programme, Kent Graduate Software Engineer, Northrop Grumman Graduate Control Systems Engineer). Autopilot doesn't fill these forms: they're applied to from the job page. Tests: Oracle searched and read only where it may be here (secondary locations too), its finder's separators dropped from a query; Eightfold searched in each location, each posting read once; a Teamtailor feed read (places, setting, entities, CDATA); all three checked by count, 404 = gone. --- api/app/job_searcher.py | 298 +++++++++++++++++++++++++++++++---- api/tests/test_job_search.py | 117 +++++++++++++- 2 files changed, 385 insertions(+), 30 deletions(-) diff --git a/api/app/job_searcher.py b/api/app/job_searcher.py index a20c252..1e8ccd4 100644 --- a/api/app/job_searcher.py +++ b/api/app/job_searcher.py @@ -17,6 +17,7 @@ import re import pathlib import json +from urllib.parse import quote import httpx @@ -834,6 +835,34 @@ async def _search_level1_playwright(keywords: list[str]) -> list[dict]: ("alfa/wd3/Alfa", "Alfa"), ] +# Oracle Recruiting (Candidate Experience) sites, as "/" (the oraclecloud.com host behind the +# site, also when it shows on the employer's own domain): graduate employers' careers sites (October 2026). +_ORACLE_BOARDS: list[tuple[str, str]] = [ + ("egug.fa.us2.oraclecloud.com/CX_1", "American Express"), + ("eofe.fa.us2.oraclecloud.com/BNY-Careers", "BNY"), + ("eedu.fa.em3.oraclecloud.com/CX_1003", "WTW"), + ("ebet.fa.em3.oraclecloud.com/MK", "Compare the Market"), + ("eicg.fa.em2.oraclecloud.com/MorrisonsJobs", "Morrisons"), + ("fa-evzn-saasfaukgovprod1.fa.ocs.oraclecloud.com/UKRI-Careers", "UKRI"), + ("fa-emqh-saasfaprod1.fa.ocs.oraclecloud.com/CX_1001", "Kent"), +] + +# Eightfold careers sites, as "/" (.eightfold.ai, searched for 's postings): +# graduate employers' careers sites (October 2026). +_EIGHTFOLD_BOARDS: list[tuple[str, str]] = [ + ("ngc/ngc.com", "Northrop Grumman"), +] + +# Teamtailor careers sites, by host (.teamtailor.com, or the employer's own domain): each publishes every +# posting in /jobs.rss. Graduate employers' careers sites (October 2026). +_TEAMTAILOR_BOARDS: list[tuple[str, str]] = [ + ("careers.isio.com", "Isio"), + ("careers.tekever.com", "Tekever"), + ("73strings.teamtailor.com", "73 Strings"), + ("careers.dare.global", "Dare"), + ("bre-1729757562.teamtailor.com", "BRE"), +] + async def _fetch_greenhouse(slug: str, company: str, keywords: list[str]) -> list[dict]: try: # content=true is required — without it the API omits the JD entirely. @@ -1019,30 +1048,31 @@ async def _fetch_smartrecruiters(slug: str, company: str, keywords: list[str]) - except Exception: return [] -# ── Workday ─────────────────────────────────────────────────────────────────── -# A Workday careers site searches through a public JSON service, the one its own pages call: POST -# .../wday/cxs///jobs lists postings (20 a call at most, no description) and GET -# .../wday/cxs// is one posting. Boards are big and worldwide (Citi lists 2000+), so -# each is searched with the collection's queries rather than read whole, and only postings whose title matches and -# that may be in the searched locations are read in full. +# ── Searched boards: Workday, Oracle, Eightfold ────────────────────────────── +# These careers sites search through a public JSON service, the one their own pages call. Their boards are big and +# worldwide (Citi lists 2000+), so each is searched with the collection's queries rather than read whole, and only +# postings whose title matches and that may be in the searched locations are read in full. -_WORKDAY_PAGE = 20 # postings a search call returns (Workday's most) -_WORKDAY_PAGES = 2 # search calls per query and board: its 40 best matches -_WORKDAY_READS = 25 # postings read in full per board and collection -_WORKDAY_CONCURRENCY = 4 # Workday calls at once, across every board -_WORKDAY_BACKOFF_S = 3.0 # wait before the one retry of a call a site refused as too many (429) +_SEARCH_DEPTH = 40 # best matches taken per search (query, and location where the service takes one) +_READS_PER_BOARD = 25 # postings read in full per board and collection +_SEARCH_CONCURRENCY = 4 # calls at once, across every searched board +_BACKOFF_S = 3.0 # wait before the one retry of a call a site refused as too many (429) _MULTI_PLACE = re.compile(r"^\s*\d+\s+locations?\s*$", re.I) # "2 Locations": the posting says which +# Workday: POST .../wday/cxs///jobs lists postings (no description) and GET +# .../wday/cxs// is one posting. +_WORKDAY_PAGE = 20 # postings a search call returns (Workday's most) + -async def _workday_call(client: httpx.AsyncClient, gate: asyncio.Semaphore, method: str, url: str, - **kwargs) -> httpx.Response: +async def _paced_call(client: httpx.AsyncClient, gate: asyncio.Semaphore, method: str, url: str, + **kwargs) -> httpx.Response: """One call, under `gate`; a 429 is waited out once before it's given up.""" for attempt in range(2): async with gate: resp = await client.request(method, url, **kwargs) if resp.status_code != 429 or attempt: return resp - await asyncio.sleep(_WORKDAY_BACKOFF_S) + await asyncio.sleep(_BACKOFF_S) return resp @@ -1051,19 +1081,19 @@ async def _fetch_workday(slug: str, company: str, keywords: list[str], queries: """ A Workday board ("/wd/"), searched with each of `queries`: each posting whose title matches `keywords` and that may be in `locations` (its place is one of them, or it lists several) is read in full, at - most _WORKDAY_READS. A site still refusing calls (429) after a wait keeps what was read before. + most _READS_PER_BOARD. A site still refusing calls (429) after a wait keeps what was read before. """ tenant, wd, site = slug.split("/") host = f"https://{tenant}.{wd}.myworkdayjobs.com" base = f"{host}/wday/cxs/{tenant}/{site}" - gate = gate or asyncio.Semaphore(_WORKDAY_CONCURRENCY) + gate = gate or asyncio.Semaphore(_SEARCH_CONCURRENCY) jobs: list[dict] = [] try: async with http_client.async_client(timeout=15.0, headers={"Accept": "application/json"}) as client: listed: dict[str, dict] = {} for query in queries or [" ".join(keywords)]: - for page in range(_WORKDAY_PAGES): - resp = await _workday_call(client, gate, "POST", f"{base}/jobs", json={ + for page in range(-(-_SEARCH_DEPTH // _WORKDAY_PAGE)): + resp = await _paced_call(client, gate, "POST", f"{base}/jobs", json={ "appliedFacets": {}, "limit": _WORKDAY_PAGE, "offset": page * _WORKDAY_PAGE, "searchText": query}) if resp.status_code == 429: @@ -1079,8 +1109,8 @@ async def _fetch_workday(slug: str, company: str, keywords: list[str], queries: wanted = [p for p in listed.values() if _title_matches_query(p.get("title") or "", keywords) and (_MULTI_PLACE.match(p.get("locationsText") or "") or jobs_db.in_locations(p.get("locationsText") or "", locations or []))] - for posting in wanted[:_WORKDAY_READS]: - resp = await _workday_call(client, gate, "GET", base + posting["externalPath"]) + for posting in wanted[:_READS_PER_BOARD]: + resp = await _paced_call(client, gate, "GET", base + posting["externalPath"]) if resp.status_code == 429: logger.info("Workday %s: refused as too many calls after %d postings", slug, len(jobs)) break @@ -1107,11 +1137,200 @@ async def _fetch_workday(slug: str, company: str, keywords: list[str], queries: return jobs +# Oracle Recruiting: GET /hcmRestApi/resources/latest/recruitingCEJobRequisitions with a findReqs finder +# lists postings (expand=requisitionList, or none come back), and recruitingCEJobRequisitionDetails with ById is one. +_ORACLE_PAGE = 25 # postings a search call returns +_ORACLE_API = "https://{host}/hcmRestApi/resources/latest" + + +def _oracle_search_url(host: str, site: str, query: str = "", limit: int = _ORACLE_PAGE, offset: int = 0) -> str: + """A findReqs search; the finder's own separators (, ; =) are dropped from the query.""" + finder = f"findReqs;siteNumber={site},limit={limit},offset={offset}" + if query: + finder += f",keyword={' '.join(re.sub(r'[,;=]+', ' ', query).split())},sortBy=RELEVANCY" + return (f"{_ORACLE_API.format(host=host)}/recruitingCEJobRequisitions?onlyData=true" + f"&expand=requisitionList.secondaryLocations&finder={quote(finder, safe=';=,')}") + + +async def _fetch_oracle(slug: str, company: str, keywords: list[str], queries: list[str] = (), + locations: list[str] | None = None, gate: asyncio.Semaphore | None = None) -> list[dict]: + """An Oracle Recruiting board ("/"), searched like a Workday one (see _fetch_workday).""" + host, site = slug.split("/") + gate = gate or asyncio.Semaphore(_SEARCH_CONCURRENCY) + jobs: list[dict] = [] + try: + async with http_client.async_client(timeout=15.0, headers={"Accept": "application/json"}) as client: + listed: dict[str, dict] = {} + for query in queries or [" ".join(keywords)]: + for page in range(-(-_SEARCH_DEPTH // _ORACLE_PAGE)): + resp = await _paced_call(client, gate, "GET", + _oracle_search_url(host, site, query, offset=page * _ORACLE_PAGE)) + if resp.status_code == 429: + logger.info("Oracle %s: refused as too many calls; searching it next time", slug) + return jobs + resp.raise_for_status() + postings = ((resp.json().get("items") or [{}])[0].get("requisitionList")) or [] + for posting in postings: + if posting.get("Id"): + listed.setdefault(str(posting["Id"]), posting) + if len(postings) < _ORACLE_PAGE: + break + + def places(posting: dict) -> list[str]: + return [posting.get("PrimaryLocation") or "", + *[(s or {}).get("Name") or "" for s in posting.get("secondaryLocations") or []]] + wanted = [p for p in listed.values() if _title_matches_query(p.get("Title") or "", keywords) + and jobs_db.in_locations("; ".join(x for x in places(p) if x), locations or [])] + for posting in wanted[:_READS_PER_BOARD]: + finder = quote(f'ById;Id="{posting["Id"]}",siteNumber={site}', safe=';=,"') + resp = await _paced_call(client, gate, "GET", f"{_ORACLE_API.format(host=host)}" + f"/recruitingCEJobRequisitionDetails?expand=all&onlyData=true&finder={finder}") + if resp.status_code == 429: + logger.info("Oracle %s: refused as too many calls after %d postings", slug, len(jobs)) + break + if resp.status_code != 200: + continue + info = (resp.json().get("items") or [{}])[0] + title = posting.get("Title") or "" + text = " ".join(info.get(k) or "" for k in ("ExternalDescriptionStr", "ExternalResponsibilitiesStr", + "ExternalQualificationsStr")) + jobs.append({ + "job_id": f"oracle-{host.split('.')[0]}-{posting['Id']}", + "title": title, + "company": company, + "location": _place_and_setting(places(posting), posting.get("WorkplaceType") or ""), + "description": _html_to_text(text)[:_DESC_MAX], + "url": f"https://{host}/hcmUI/CandidateExperience/en/sites/{site}/job/{posting['Id']}", + "salary_min": None, + "salary_max": None, + "exposure_score": round(occupation_exposure(title), 3), + }) + return jobs + except Exception as exc: + logger.debug("Oracle %s failed: %s", slug, type(exc).__name__) + return jobs + + +# Eightfold: GET .eightfold.ai/api/pcsx/search lists a domain's postings (10 a call at most, by query and +# location: a search without one is mostly elsewhere) and /api/pcsx/position_details is one. +_EIGHTFOLD_PAGE = 10 + + +async def _fetch_eightfold(slug: str, company: str, keywords: list[str], queries: list[str] = (), + locations: list[str] | None = None, gate: asyncio.Semaphore | None = None) -> list[dict]: + """An Eightfold board ("/"), searched with each of `queries` in each of `locations`, then read + like a Workday one (see _fetch_workday).""" + tenant, domain = slug.split("/") + base = f"https://{tenant}.eightfold.ai/api/pcsx" + gate = gate or asyncio.Semaphore(_SEARCH_CONCURRENCY) + places = [p for p in (locations or []) if p and p.strip().lower() != "remote"] or [""] + jobs: list[dict] = [] + try: + async with http_client.async_client(timeout=15.0, headers={"Accept": "application/json"}) as client: + listed: dict[str, dict] = {} + for query in queries or [" ".join(keywords)]: + for place in places: + for page in range(-(-_SEARCH_DEPTH // _EIGHTFOLD_PAGE)): + params = {"domain": domain, "query": query, "start": page * _EIGHTFOLD_PAGE, + "num": _EIGHTFOLD_PAGE, **({"location": place} if place else {})} + resp = await _paced_call(client, gate, "GET", f"{base}/search", params=params) + if resp.status_code == 429: + logger.info("Eightfold %s: refused as too many calls; searching it next time", slug) + return jobs + resp.raise_for_status() + postings = (resp.json().get("data") or {}).get("positions") or [] + for posting in postings: + if posting.get("id"): + listed.setdefault(str(posting["id"]), posting) + if len(postings) < _EIGHTFOLD_PAGE: + break + + def where(posting: dict) -> list[str]: + return list(posting.get("standardizedLocations") or posting.get("locations") or []) + wanted = [p for p in listed.values() if _title_matches_query(p.get("name") or "", keywords) + and jobs_db.in_locations("; ".join(where(p)), locations or [])] + for posting in wanted[:_READS_PER_BOARD]: + resp = await _paced_call(client, gate, "GET", f"{base}/position_details", + params={"position_id": posting["id"], "domain": domain, "hl": "en"}) + if resp.status_code == 429: + logger.info("Eightfold %s: refused as too many calls after %d postings", slug, len(jobs)) + break + if resp.status_code != 200: + continue + info = resp.json().get("data") or {} + title = posting.get("name") or "" + jobs.append({ + "job_id": f"eightfold-{tenant}-{posting.get('displayJobId') or posting['id']}", + "title": title, + "company": company, + "location": _place_and_setting(where(posting), posting.get("workLocationOption") or ""), + "description": _html_to_text(info.get("jobDescription") or "")[:_DESC_MAX], + "url": info.get("publicUrl") + or f"https://{tenant}.eightfold.ai/careers/job/{posting['id']}?domain={domain}", + "salary_min": None, + "salary_max": None, + "exposure_score": round(occupation_exposure(title), 3), + }) + return jobs + except Exception as exc: + logger.debug("Eightfold %s failed: %s", slug, type(exc).__name__) + return jobs + + +# Teamtailor: every posting is in /jobs.rss, with its description and places (tt:location's city and +# country). Read with patterns, not an XML parser: a page from the web is no place for entity expansion. +_RSS_ITEM = re.compile(r"(.*?)", re.S) +_TT_LOCATION = re.compile(r"(.*?)", re.S) +_TT_REMOTE = {"hybrid": "hybrid", "fully": "remote", "temporary": "remote"} + + +def _rss_text(item: str, tag: str) -> str: + """A tag's text in an RSS item: CDATA unwrapped and entities decoded.""" + m = re.search(rf"<{tag}>(.*?)", item, re.S) + raw = (m.group(1) if m else "").strip() + raw = re.sub(r"^$", r"\1", raw, flags=re.S) + return _html.unescape(raw).strip() + + +def _teamtailor_jobs(raw: str, company: str, keywords: list[str]) -> list[dict]: + jobs = [] + for item in _RSS_ITEM.findall(raw): + title = _rss_text(item, "title") + if not _title_matches_query(title, keywords): + continue + places = [", ".join(p for p in (_rss_text(loc, "tt:city"), _rss_text(loc, "tt:country")) if p) + for loc in _TT_LOCATION.findall(item)] + link = _rss_text(item, "link") + jobs.append({ + "job_id": f"teamtailor-{_rss_text(item, 'guid') or link}", + "title": title, + "company": company, + "location": _place_and_setting(places, _TT_REMOTE.get(_rss_text(item, "remoteStatus").lower(), "")), + "description": _html_to_text(_rss_text(item, "description"))[:_DESC_MAX], + "url": link, + "salary_min": None, + "salary_max": None, + "exposure_score": round(occupation_exposure(title), 3), + }) + return jobs + + +async def _fetch_teamtailor(slug: str, company: str, keywords: list[str]) -> list[dict]: + try: + async with http_client.async_client(timeout=10.0) as client: + resp = await client.get(f"https://{slug}/jobs.rss") + resp.raise_for_status() + return await asyncio.to_thread(_teamtailor_jobs, resp.text, company, keywords) + except Exception: + return [] + + def listed_boards() -> list[tuple[str, str, str]]: """(ats, slug, company) of every board listed in this module.""" lists = {"greenhouse": _GREENHOUSE_COMPANIES, "ashby": _ASHBY_COMPANIES, "lever": _LEVER_COMPANIES, "lever_eu": _LEVER_EU_COMPANIES, "workable": _WORKABLE_COMPANIES, - "smartrecruiters": _SMARTRECRUITERS_COMPANIES, "workday": _WORKDAY_BOARDS} + "smartrecruiters": _SMARTRECRUITERS_COMPANIES, "workday": _WORKDAY_BOARDS, "oracle": _ORACLE_BOARDS, + "eightfold": _EIGHTFOLD_BOARDS, "teamtailor": _TEAMTAILOR_BOARDS} return [(ats, slug, company) for ats, listed in lists.items() for slug, company in listed] @@ -1134,10 +1353,10 @@ def _all_boards(found: list[tuple[str, str, str]] = (), gone: set[tuple[str, str async def _search_level2_ats_apis(queries: list[str], boards: dict[str, list[tuple[str, str]]] | None = None, locations: list[str] | None = None) -> list[dict]: """ - Concurrently fetch from Greenhouse, Ashby, Lever, Workable, SmartRecruiters and Workday public APIs: `boards` - ({ats: [(slug, company)]}, see _all_boards), by default the listed ones. Filters each company's - listings by query keyword relevance in a single pass; Workday boards are searched with `queries` and read - in full only for postings that may be in `locations` (see _fetch_workday). + Concurrently fetch from Greenhouse, Ashby, Lever, Workable, SmartRecruiters, Teamtailor, Workday, Oracle and + Eightfold public APIs: `boards` ({ats: [(slug, company)]}, see _all_boards), by default the listed ones. Filters + each company's listings by query keyword relevance in a single pass; Workday, Oracle and Eightfold boards are + searched with `queries` and read in full only for postings that may be in `locations` (see _fetch_workday). """ all_keywords = [] for q in queries: @@ -1150,14 +1369,17 @@ async def _search_level2_ats_apis(queries: list[str], boards: dict[str, list[tup async def fetch_lever_eu(slug: str, company: str, keywords: list[str]) -> list[dict]: return await _fetch_lever(slug, company, keywords, eu=True) - workday_gate = asyncio.Semaphore(_WORKDAY_CONCURRENCY) # every Workday board's calls share it + searched = asyncio.Semaphore(_SEARCH_CONCURRENCY) # every searched board's calls share it - async def fetch_workday(slug: str, company: str, keywords: list[str]) -> list[dict]: - return await _fetch_workday(slug, company, keywords, queries, locations, workday_gate) + def searching(fetch): # a searched board's fetcher, given the queries, locations and shared gate + async def fetch_board(slug: str, company: str, keywords: list[str]) -> list[dict]: + return await fetch(slug, company, keywords, queries, locations, searched) + return fetch_board fetchers = {"greenhouse": _fetch_greenhouse, "ashby": _fetch_ashby, "lever": _fetch_lever, "lever_eu": fetch_lever_eu, "workable": _fetch_workable, "smartrecruiters": _fetch_smartrecruiters, - "workday": fetch_workday} + "teamtailor": _fetch_teamtailor, "workday": searching(_fetch_workday), + "oracle": searching(_fetch_oracle), "eightfold": searching(_fetch_eightfold)} every = _all_boards() if boards is None else boards tasks = [fetchers[ats](slug, name, keywords) for ats, listed in every.items() if ats in fetchers for slug, name in listed] @@ -1265,6 +1487,24 @@ async def check_board(client: httpx.AsyncClient, ats: str, slug: str) -> int | N return None resp.raise_for_status() return int(resp.json().get("total") or 0) + if ats in ("oracle", "eightfold", "teamtailor"): + if ats == "oracle": + host, site = slug.split("/") + resp = await client.get(_oracle_search_url(host, site, limit=1), headers={"Accept": "application/json"}) + elif ats == "eightfold": + tenant, domain = slug.split("/") + resp = await client.get(f"https://{tenant}.eightfold.ai/api/pcsx/search", + params={"domain": domain, "query": "", "start": 0, "num": 1}) + else: + resp = await client.get(f"https://{slug}/jobs.rss") + if resp.status_code == 404: + return None + resp.raise_for_status() + if ats == "oracle": + return int((resp.json().get("items") or [{}])[0].get("TotalJobsCount") or 0) + if ats == "eightfold": + return int((resp.json().get("data") or {}).get("count") or 0) + return len(_RSS_ITEM.findall(resp.text)) resp = await client.get(_BOARD_API[ats].format(slug=slug)) if resp.status_code == 404: return None diff --git a/api/tests/test_job_search.py b/api/tests/test_job_search.py index 5c22c63..a6b789c 100644 --- a/api/tests/test_job_search.py +++ b/api/tests/test_job_search.py @@ -1,5 +1,6 @@ """Live multi-source search: correct inputs per source, fixture handling, Adzuna routing.""" import asyncio +import re from unittest.mock import MagicMock import httpx @@ -424,7 +425,7 @@ def test_a_workday_board_is_searched_and_only_postings_that_may_be_here_are_read def test_a_workday_site_refusing_calls_is_waited_out_once_then_left(monkeypatch): site = _Workday(status=429) monkeypatch.setattr(job_searcher, "http_client", MagicMock(async_client=site)) - monkeypatch.setattr(job_searcher, "_WORKDAY_BACKOFF_S", 0) + monkeypatch.setattr(job_searcher, "_BACKOFF_S", 0) assert asyncio.run(job_searcher._fetch_workday("t/wd3/Site", "T", ["software"], ["Software"], ["London"])) == [] assert len(site.calls) == 2 # the call and its one retry; nothing more this collection @@ -458,6 +459,120 @@ async def get(self, url, **kwargs): "https://api.eu.lever.co/v0/postings/coretechsecurity/0a1b2c3d-1111-2222-3333-444455556666"] +class _Site: + """A fake careers site: `answer(method, url, params)` gives (status, json body or text). Calls are kept.""" + + def __init__(self, answer): + self.answer, self.calls = answer, [] + + def __call__(self, *a, **k): # as http_client.async_client + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + pass + + async def request(self, method, url, params=None, **kwargs): + self.calls.append((method, url, dict(params or {}))) + status, body = self.answer(method, url, params or {}) + req = httpx.Request(method, url) + if isinstance(body, str): + return httpx.Response(status, text=body, request=req) + return httpx.Response(status, json=body, request=req) + + async def get(self, url, params=None, **kwargs): + return await self.request("GET", url, params=params) + + +def _oracle_posting(pid, title, place, *elsewhere): + return {"Id": pid, "Title": title, "PrimaryLocation": place, "WorkplaceType": "", + "secondaryLocations": [{"Name": p} for p in elsewhere]} + + +def test_an_oracle_board_is_searched_and_only_postings_that_may_be_here_are_read(monkeypatch): + listed = [_oracle_posting("1", "Graduate Software Engineer", "London, England, United Kingdom"), + _oracle_posting("2", "Graduate Software Engineer", "Dallas, TX, United States"), + _oracle_posting("3", "Software Engineer", "Pittsburgh, PA, United States", "London, United Kingdom"), + _oracle_posting("4", "Account Manager", "London, United Kingdom")] + + def answer(method, url, params): + if "recruitingCEJobRequisitions?" in url: + return 200, {"items": [{"TotalJobsCount": 4, "requisitionList": listed}]} + pid = re.search(r"Id%3D%22(\d+)%22|Id=\"(\d+)\"", url) + return 200, {"items": [{"ExternalDescriptionStr": f"

About {pid.group(1) or pid.group(2)}.

", + "ExternalQualificationsStr": "

Python.

"}]} + site = _Site(answer) + monkeypatch.setattr(job_searcher, "http_client", MagicMock(async_client=site)) + jobs = asyncio.run(job_searcher._fetch_oracle("h.fa.em3.oraclecloud.com/CX_1", "Co", ["graduate", "software", "engineer"], + ["Graduate Software Engineer"], ["London", "United Kingdom"])) + searches = [url for _, url, _ in site.calls if "recruitingCEJobRequisitions?" in url] + assert len(searches) == 1 and "keyword=Graduate%20Software%20Engineer,sortBy=RELEVANCY" in searches[0] + assert [(j["job_id"], j["location"], " ".join(j["description"].split()), j["url"]) for j in jobs] == [ + ("oracle-h-1", "London, England, United Kingdom", "About 1. Python.", + "https://h.fa.em3.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1/job/1"), + ("oracle-h-3", "Pittsburgh, PA, United States; London, United Kingdom", "About 3. Python.", + "https://h.fa.em3.oraclecloud.com/hcmUI/CandidateExperience/en/sites/CX_1/job/3"), + ] + + +def test_an_oracle_search_drops_the_finders_own_separators(): + url = job_searcher._oracle_search_url("h", "CX_1", "C++, Java; dev=ops") + assert "keyword=C%2B%2B%20Java%20dev%20ops" in url and "siteNumber=CX_1,limit=25,offset=0" in url + + +def test_an_eightfold_board_is_searched_in_each_location_and_read_once(monkeypatch): + def answer(method, url, params): + if url.endswith("/search"): + here = params.get("location") + return 200, {"data": {"count": 2, "positions": [ + {"id": 11, "displayJobId": "R11", "name": "Graduate Software Engineer", + "standardizedLocations": ["London, England, GB"], "workLocationOption": "hybrid"}, + {"id": 12, "displayJobId": "R12", "name": "Software Engineer", + "standardizedLocations": ["Roy, UT, US"] if here == "London" else ["Roy, UT, US"]}, + ]}} + return 200, {"data": {"jobDescription": f"

Job {params['position_id']}.

", + "publicUrl": f"https://jobs.example.com/careers/job/{params['position_id']}"}} + site = _Site(answer) + monkeypatch.setattr(job_searcher, "http_client", MagicMock(async_client=site)) + jobs = asyncio.run(job_searcher._fetch_eightfold("ngc/ngc.com", "NG", ["software", "engineer"], + ["Software Engineer"], ["London", "United Kingdom"])) + searched = [(p["query"], p.get("location"), p["domain"]) for _, url, p in site.calls if url.endswith("/search")] + assert searched == [("Software Engineer", "London", "ngc.com"), ("Software Engineer", "United Kingdom", "ngc.com")] + assert [p["position_id"] for _, url, p in site.calls if url.endswith("/position_details")] == [11] + assert [(j["job_id"], j["location"], j["description"], j["url"]) for j in jobs] == [ + ("eightfold-ngc-R11", "London, England, GB (Hybrid)", "Job 11.", "https://jobs.example.com/careers/job/11")] + + +TEAMTAILOR_RSS = """ +Graduate Software Engineer +<p>Build &amp; ship.</p> +https://careers.example.com/jobs/1-graduate-software-engineertt-1 +hybrid +HQLondonUnited Kingdom +LisbonPortugal +<![CDATA[Account Executive]]>Sell.https://careers.example.com/jobs/2 +""" + + +def test_a_teamtailor_board_is_read_from_its_feed(): + jobs = job_searcher._teamtailor_jobs(TEAMTAILOR_RSS, "Co", ["software", "engineer"]) + assert [(j["job_id"], j["title"], j["location"], j["description"], j["url"]) for j in jobs] == [ + ("teamtailor-tt-1", "Graduate Software Engineer", "London, United Kingdom; Lisbon, Portugal (Hybrid)", + "Build & ship.", "https://careers.example.com/jobs/1-graduate-software-engineer")] + + +@pytest.mark.parametrize("ats, slug, body, expected", [ + ("oracle", "h.fa.em3.oraclecloud.com/CX_1", {"items": [{"TotalJobsCount": 132}]}, 132), + ("eightfold", "ngc/ngc.com", {"data": {"count": 26, "positions": []}}, 26), + ("teamtailor", "careers.example.com", TEAMTAILOR_RSS, 2), +]) +def test_searched_and_feed_boards_are_checked(ats, slug, body, expected): + assert asyncio.run(job_searcher.check_board(_Site(lambda m, u, p: (200, body)), ats, slug)) == expected + assert asyncio.run(job_searcher.check_board(_Site(lambda m, u, p: (404, {})), ats, slug)) is None + + def test_no_board_is_listed_twice(): listed = [(ats, slug.lower()) for ats, slug, _ in job_searcher.listed_boards()] assert len(listed) == len(set(listed))