Files
Jobber/extractors/jobspy/scrape_jobs.py
T
ilia 40c7cdece3
CI / skip-ci-check (pull_request) Successful in 8s
CI / docker-ci (pull_request) Successful in 10s
CI / secret-scan (pull_request) Successful in 7s
feat: add Google Jobs extractor and fix scraper reliability gaps
Ship a Camoufox-backed Google Jobs source, restore Glassdoor results discarded by a python-jobspy GraphQL quirk, and fix Himalayas/Gradcracker zero-job failures. Also harden prior-skip dismiss matching and multi-profile basic-auth switching for CA/US runs.
2026-07-09 15:11:53 -04:00

286 lines
9.4 KiB
Python

import csv
import json
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import pandas as pd
from jobspy import scrape_jobs
from jobspy.exception import GlassdoorException
from jobspy.glassdoor import Glassdoor
from jobspy.glassdoor.util import get_cursor_for_page
PROGRESS_PREFIX = "JOBOPS_PROGRESS "
_glassdoor_log = logging.getLogger("JobSpy:Glassdoor")
def _patched_glassdoor_fetch_jobs_page(
self, scraper_input, location_id, location_type, page_num, cursor
):
"""Replaces python-jobspy's Glassdoor._fetch_jobs_page (as of 1.1.82).
Upstream treats *any* `errors` entry in Glassdoor's GraphQL response as
fatal and discards the whole page, even when the job listings we need
parsed fine. In practice Glassdoor's backend intermittently (but
consistently, as of writing) returns a partial error for an unrelated
field — observed: `jobsPageSeoData` failing with
`SUBREQUEST_HTTP_ERROR` / "dns error" on Glassdoor's own infra — while
`data.jobListings.jobListings` still comes back with real results. The
upstream check throws that good data away, so every Glassdoor run
returns 0 jobs. This patch only bails out when the job listings payload
itself is actually missing.
"""
jobs = []
self.scraper_input = scraper_input
try:
payload = self._add_payload(location_id, location_type, page_num, cursor)
response = self.session.post(
f"{self.base_url}/graph",
timeout_seconds=15,
data=payload,
)
if response.status_code != 200:
raise GlassdoorException(
f"bad response status code: {response.status_code}"
)
res_json = response.json()[0]
job_listings = res_json.get("data", {}).get("jobListings")
if job_listings is None:
raise ValueError("Error encountered in API response")
if "errors" in res_json:
_glassdoor_log.warning(
"Glassdoor GraphQL returned partial errors for unrelated "
"fields; continuing with the job listings that did parse: %s",
res_json["errors"],
)
except (GlassdoorException, ValueError, Exception) as e:
_glassdoor_log.error(f"Glassdoor: {str(e)}")
return jobs, None
jobs_data = job_listings["jobListings"]
with ThreadPoolExecutor(max_workers=self.jobs_per_page) as executor:
future_to_job_data = {
executor.submit(self._process_job, job): job for job in jobs_data
}
for future in as_completed(future_to_job_data):
try:
job_post = future.result()
if job_post:
jobs.append(job_post)
except Exception as exc:
raise GlassdoorException(f"Glassdoor generated an exception: {exc}")
return jobs, get_cursor_for_page(job_listings["paginationCursors"], page_num + 1)
Glassdoor._fetch_jobs_page = _patched_glassdoor_fetch_jobs_page
COUNTRY_ALIASES = {
"uk": "united kingdom",
"united kingdom": "united kingdom",
"us": "united states",
"usa": "united states",
"united states": "united states",
"türkiye": "turkey",
"czech republic": "czechia",
}
GLASSDOOR_COUNTRY_TO_CITY = {
"australia": "Sydney",
"austria": "Vienna",
"belgium": "Brussels",
"brazil": "Sao Paulo",
"canada": "Toronto",
"france": "Paris",
"germany": "Berlin",
"hong kong": "Hong Kong",
"india": "Bengaluru",
"ireland": "Dublin",
"italy": "Milan",
"mexico": "Mexico City",
"netherlands": "Amsterdam",
"new zealand": "Auckland",
"singapore": "Singapore",
"spain": "Madrid",
"switzerland": "Zurich",
"united kingdom": "London",
"united states": "New York",
"vietnam": "Ho Chi Minh City",
}
def _env_str(name: str, default: str) -> str:
value = os.getenv(name)
return value if value and value.strip() else default
def _env_int(name: str, default: int) -> int:
value = os.getenv(name)
if value is None or value.strip() == "":
return default
try:
return int(value)
except ValueError:
return default
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None or value.strip() == "":
return default
return value.strip().lower() in ("1", "true", "yes", "y", "on")
def _emit_progress(event: str, payload: dict) -> None:
serialized = json.dumps({"event": event, **payload}, ensure_ascii=True)
print(f"{PROGRESS_PREFIX}{serialized}", flush=True)
def _parse_sites(raw: str) -> list[str]:
return [s.strip() for s in raw.split(",") if s.strip()]
def _normalize_country_token(value: str) -> str:
normalized = " ".join(value.strip().lower().split())
return COUNTRY_ALIASES.get(normalized, normalized)
def _is_country_level_location(location: str, country_indeed: str) -> bool:
if not location.strip() or not country_indeed.strip():
return False
return _normalize_country_token(location) == _normalize_country_token(country_indeed)
def _glassdoor_city_for_country(country_indeed: str, location: str) -> str | None:
country_key = _normalize_country_token(country_indeed or location)
return GLASSDOOR_COUNTRY_TO_CITY.get(country_key)
def _scrape_for_sites(
*,
sites: list[str],
search_term: str,
location: str | None,
results_wanted: int,
hours_old: int,
country_indeed: str,
linkedin_fetch_description: bool,
is_remote: bool,
) -> pd.DataFrame:
kwargs: dict[str, object] = {
"site_name": sites,
"search_term": search_term,
"results_wanted": results_wanted,
"hours_old": hours_old,
"country_indeed": country_indeed,
"linkedin_fetch_description": linkedin_fetch_description,
"is_remote": is_remote,
}
if location and location.strip():
kwargs["location"] = location
return scrape_jobs(**kwargs)
def main() -> int:
sites = _parse_sites(_env_str("JOBSPY_SITES", "indeed,linkedin"))
search_term = _env_str("JOBSPY_SEARCH_TERM", "web developer")
location = _env_str("JOBSPY_LOCATION", "UK")
results_wanted = _env_int("JOBSPY_RESULTS_WANTED", 200)
hours_old = _env_int("JOBSPY_HOURS_OLD", 72)
country_indeed = _env_str("JOBSPY_COUNTRY_INDEED", "UK")
linkedin_fetch_description = _env_bool("JOBSPY_LINKEDIN_FETCH_DESCRIPTION", True)
is_remote = _env_bool("JOBSPY_IS_REMOTE", False)
term_index = _env_int("JOBSPY_TERM_INDEX", 1)
term_total = _env_int("JOBSPY_TERM_TOTAL", 1)
output_csv = Path(_env_str("JOBSPY_OUTPUT_CSV", "jobs.csv"))
output_json = Path(
_env_str("JOBSPY_OUTPUT_JSON", str(output_csv.with_suffix(".json")))
)
output_csv.parent.mkdir(parents=True, exist_ok=True)
output_json.parent.mkdir(parents=True, exist_ok=True)
print(f"jobspy: Search term: {search_term}")
_emit_progress(
"term_start",
{
"termIndex": term_index,
"termTotal": term_total,
"searchTerm": search_term,
},
)
frames: list[pd.DataFrame] = []
non_glassdoor_sites = [site for site in sites if site != "glassdoor"]
if non_glassdoor_sites:
frames.append(
_scrape_for_sites(
sites=non_glassdoor_sites,
search_term=search_term,
location=location,
results_wanted=results_wanted,
hours_old=hours_old,
country_indeed=country_indeed,
linkedin_fetch_description=linkedin_fetch_description,
is_remote=is_remote,
)
)
if "glassdoor" in sites:
glassdoor_location = location
if _is_country_level_location(location, country_indeed):
# Glassdoor works best with city-level location terms.
fallback_city = _glassdoor_city_for_country(country_indeed, location)
if fallback_city:
glassdoor_location = fallback_city
print(
"jobspy: Glassdoor location matched country; using city fallback "
f"({fallback_city})"
)
else:
print(
"jobspy: Glassdoor location matched country; keeping original location"
)
frames.append(
_scrape_for_sites(
sites=["glassdoor"],
search_term=search_term,
location=glassdoor_location,
results_wanted=results_wanted,
hours_old=hours_old,
country_indeed=country_indeed,
linkedin_fetch_description=linkedin_fetch_description,
is_remote=is_remote,
)
)
jobs = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
print(f"Found {len(jobs)} jobs")
_emit_progress(
"term_complete",
{
"termIndex": term_index,
"termTotal": term_total,
"searchTerm": search_term,
"jobsFoundTerm": int(len(jobs)),
},
)
jobs.to_csv(
output_csv,
quoting=csv.QUOTE_NONNUMERIC,
escapechar="\\",
index=False,
)
jobs.to_json(output_json, orient="records", force_ascii=False)
print(f"Wrote CSV: {output_csv}")
print(f"Wrote JSON: {output_json}")
return 0
if __name__ == "__main__":
raise SystemExit(main())