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.
286 lines
9.4 KiB
Python
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())
|