Spaces:
Running
feat: Scrapling anti-block fetch layer + Direct Company ATS source
Browse filesScrape in bulk on HF without getting blocked. New src/scrapers/fetch.py routes all scraper HTTP through Scrapling (Chrome TLS impersonation via Fetcher; Cloudflare bypass via StealthyFetcher/Camoufox) with a transparent requests fallback and a requests.Response-compatible shim, so existing scrapers are unchanged. BaseScraper._get delegates to it; Glassdoor tries the stealth browser before Playwright.
Proxy rotation: SCRAPER_PROXIES (comma/newline) rotated round-robin across all fetchers + the requests fallback -- the only real fix for datacenter-IP blocking (fingerprint stealth != IP reputation).
New src/scrapers/company_ats.py: aggregates PM jobs from Greenhouse/Lever/Ashby public JSON boards (rarely IP-blocked -- most reliable bulk source on HF). Default-on platform key company_ats, routed to the new scraper not the ever-jobs sidecar; seed list overridable via COMPANY_ATS_BOARDS env.
Deps: scrapling[fetchers] in requirements.txt; scrapling install (Camoufox) in Dockerfile (non-fatal). Verified offline by scripts/verify_scrapling_integration.py (shim, proxy round-robin, ATS parsers + PM/location filtering, graceful degradation, ui/config/deps wiring).
Co-authored-by: Cursor <cursoragent@cursor.com>
- AGENT_CONTEXT.md +13 -2
- Dockerfile +6 -0
- HISTORY.md +36 -0
- config.py +33 -0
- requirements.txt +4 -0
- scripts/verify_scrapling_integration.py +164 -0
- src/ever_jobs_bridge/platforms.py +4 -0
- src/scrapers/base.py +32 -4
- src/scrapers/company_ats.py +252 -0
- src/scrapers/fetch.py +308 -0
- src/scrapers/glassdoor.py +13 -0
- ui.py +11 -1
|
@@ -392,6 +392,15 @@ for resume header address/location ATS compliance.
|
|
| 392 |
pandas/numpy binary mismatch — unrelated to our code; HF's clean deps are fine.
|
| 393 |
- **ever-jobs sidecar** fails to start on HF unless `EVER_JOBS_API_URL` points to
|
| 394 |
an externally hosted sidecar; known/non-blocking; dedicated scrapers still work.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
- **Streamlit batch persistence:** `hf_storage.py` syncs run history/resumes/CSV to
|
| 396 |
a private HF Dataset so data survives Space container restarts (needs
|
| 397 |
`HF_DATASET_REPO` + write token in Space secrets).
|
|
@@ -401,8 +410,10 @@ for resume header address/location ATS compliance.
|
|
| 401 |
### Open / next
|
| 402 |
- Run `evaluate_model_providers.py` + `run_20_job_validation.py` with real
|
| 403 |
Kimi/NVIDIA keys (user's environment) — the live provider validation.
|
| 404 |
-
- Integrate a no-block job source for HF batch scraping
|
| 405 |
-
|
|
|
|
|
|
|
| 406 |
|
| 407 |
---
|
| 408 |
|
|
|
|
| 392 |
pandas/numpy binary mismatch — unrelated to our code; HF's clean deps are fine.
|
| 393 |
- **ever-jobs sidecar** fails to start on HF unless `EVER_JOBS_API_URL` points to
|
| 394 |
an externally hosted sidecar; known/non-blocking; dedicated scrapers still work.
|
| 395 |
+
- **Scrapling anti-block fetch layer** (`src/scrapers/fetch.py`): all scraper HTTP
|
| 396 |
+
goes through Scrapling (Chrome TLS impersonation via `Fetcher`; Cloudflare
|
| 397 |
+
bypass via `StealthyFetcher`/Camoufox) with a `requests` fallback. Set
|
| 398 |
+
`SCRAPER_PROXIES` (comma/newline list) to rotate proxies — the ONLY fix for
|
| 399 |
+
datacenter-IP blocking (fingerprint stealth ≠ IP reputation). Camoufox is
|
| 400 |
+
installed via `scrapling install` in the Dockerfile (non-fatal).
|
| 401 |
+
- **Direct Company ATS** (`src/scrapers/company_ats.py`, platform key
|
| 402 |
+
`company_ats`, default-on): Greenhouse/Lever/Ashby public JSON boards — the most
|
| 403 |
+
block-resistant bulk source on HF. Extend via `COMPANY_ATS_BOARDS` env (JSON).
|
| 404 |
- **Streamlit batch persistence:** `hf_storage.py` syncs run history/resumes/CSV to
|
| 405 |
a private HF Dataset so data survives Space container restarts (needs
|
| 406 |
`HF_DATASET_REPO` + write token in Space secrets).
|
|
|
|
| 410 |
### Open / next
|
| 411 |
- Run `evaluate_model_providers.py` + `run_20_job_validation.py` with real
|
| 412 |
Kimi/NVIDIA keys (user's environment) — the live provider validation.
|
| 413 |
+
- Integrate a no-block job source for HF batch scraping — DONE via Scrapling
|
| 414 |
+
fetch layer + Direct Company ATS (Greenhouse/Lever/Ashby). Next: add a
|
| 415 |
+
residential proxy (`SCRAPER_PROXIES`) to make LinkedIn/Indeed reliable on HF,
|
| 416 |
+
and run a live bulk test to confirm block rates dropped.
|
| 417 |
|
| 418 |
---
|
| 419 |
|
|
@@ -30,6 +30,12 @@ RUN pip install --no-cache-dir --upgrade pip \
|
|
| 30 |
# Install Playwright Chromium browser
|
| 31 |
RUN playwright install chromium
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
# Install Tectonic (self-contained LaTeX engine) for compiling user resume
|
| 34 |
# LaTeX → PDF. Pin a specific MUSL-STATIC release: the rolling drop-sh installer
|
| 35 |
# shipped a glibc build that crashed at COMPILE time with "free(): invalid
|
|
|
|
| 30 |
# Install Playwright Chromium browser
|
| 31 |
RUN playwright install chromium
|
| 32 |
|
| 33 |
+
# Install Scrapling's stealth browser (Camoufox) + fetcher deps for the
|
| 34 |
+
# anti-block fetch layer. Non-fatal: if the Camoufox download hiccups, the
|
| 35 |
+
# lightweight HTTP TLS-impersonation path (Fetcher) and the requests fallback
|
| 36 |
+
# still work — only the Cloudflare-bypass StealthyFetcher would be unavailable.
|
| 37 |
+
RUN (scrapling install || python -m scrapling install || true)
|
| 38 |
+
|
| 39 |
# Install Tectonic (self-contained LaTeX engine) for compiling user resume
|
| 40 |
# LaTeX → PDF. Pin a specific MUSL-STATIC release: the rolling drop-sh installer
|
| 41 |
# shipped a glibc build that crashed at COMPILE time with "free(): invalid
|
|
@@ -4,6 +4,42 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-06-23 (PM2) — Tectonic crash fix + guaranteed PDF + panel/popup sync (v1.5.2)
|
| 8 |
|
| 9 |
Second live run: JD now extracted correctly (real PM keywords), but Tectonic
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-06-23 (PM3) — Scrapling anti-block fetch layer + Direct Company ATS source
|
| 8 |
+
|
| 9 |
+
Goal: scrape in bulk on HF without getting blocked, beyond just LinkedIn.
|
| 10 |
+
|
| 11 |
+
- **Scrapling stealth fetch layer** (`src/scrapers/fetch.py`, NEW): routes every
|
| 12 |
+
HTTP fetch through Scrapling when installed — real-Chrome **TLS impersonation**
|
| 13 |
+
(`Fetcher`) and **Cloudflare-Turnstile bypass** (`StealthyFetcher`/Camoufox) —
|
| 14 |
+
with a transparent `requests` fallback. Exposes a `requests.Response`-compatible
|
| 15 |
+
shim (`.text/.content/.json()/.status_code/.ok`) so existing scrapers are
|
| 16 |
+
unchanged. `BaseScraper._get()` now delegates to it, so LinkedIn / Remotive /
|
| 17 |
+
WWR / Naukri-fallback all gain fingerprint stealth at once.
|
| 18 |
+
- **Proxy rotation hook** — the *only* real fix for datacenter-IP blocking (HF):
|
| 19 |
+
`SCRAPER_PROXIES` (comma/newline-separated) is rotated round-robin across all
|
| 20 |
+
fetchers + the requests fallback. Empty = best-effort fingerprint stealth.
|
| 21 |
+
(Important caveat: Scrapling fixes *fingerprint* blocks, not *IP-reputation*
|
| 22 |
+
blocks — LinkedIn/Indeed from a bare HF IP may still throttle without a proxy.)
|
| 23 |
+
- **Glassdoor** now tries the Camoufox stealth browser (solves Cloudflare) first,
|
| 24 |
+
falling back to its existing Playwright path.
|
| 25 |
+
- **Direct Company ATS scraper** (`src/scrapers/company_ats.py`, NEW): aggregates
|
| 26 |
+
PM jobs from **Greenhouse / Lever / Ashby** public JSON board APIs. These are
|
| 27 |
+
meant to be embedded on careers pages, so they're almost never IP-blocked —
|
| 28 |
+
the most reliable bulk source on a shared cloud IP. Seeded with ~25 PM-hiring
|
| 29 |
+
companies; override/extend via the `COMPANY_ATS_BOARDS` env (JSON). Registered
|
| 30 |
+
as a default-on platform (`company_ats`), routed to the new scraper (NOT the
|
| 31 |
+
ever-jobs sidecar).
|
| 32 |
+
- **Config** (`config.py`): `SCRAPER` (impersonate/timeout/proxies/browser-stealth)
|
| 33 |
+
+ `COMPANY_ATS_BOARDS` env loader.
|
| 34 |
+
- **Deps**: `scrapling[fetchers]` in `requirements.txt`; `scrapling install`
|
| 35 |
+
(Camoufox) added to the Dockerfile (non-fatal — HTTP path works without it).
|
| 36 |
+
|
| 37 |
+
Verified offline by `scripts/verify_scrapling_integration.py` (fetch shim, proxy
|
| 38 |
+
round-robin, Greenhouse/Lever/Ashby parsers + PM/location filtering, graceful
|
| 39 |
+
degradation when Scrapling is absent, and ui/config/deps wiring). All green.
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
## 2026-06-23 (PM2) — Tectonic crash fix + guaranteed PDF + panel/popup sync (v1.5.2)
|
| 44 |
|
| 45 |
Second live run: JD now extracted correctly (real PM keywords), but Tectonic
|
|
@@ -304,6 +304,39 @@ EVER_JOBS = {
|
|
| 304 |
"max_platforms_per_call": 20,
|
| 305 |
}
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
# Output
|
| 308 |
OUTPUT = {
|
| 309 |
"excel_path": "data/output/reports/job_report.xlsx",
|
|
|
|
| 304 |
"max_platforms_per_call": 20,
|
| 305 |
}
|
| 306 |
|
| 307 |
+
# ── Scraper stealth + anti-block (Scrapling) ─────────────────────────────────
|
| 308 |
+
# The fetch layer (src/scrapers/fetch.py) uses Scrapling for real-Chrome TLS
|
| 309 |
+
# impersonation and Cloudflare bypass, falling back to plain requests. These
|
| 310 |
+
# settings are read mostly via env so they can be set as HF Space secrets.
|
| 311 |
+
SCRAPER = {
|
| 312 |
+
# Browser TLS fingerprint to impersonate for HTTP fetches.
|
| 313 |
+
"impersonate": os.getenv("SCRAPER_IMPERSONATE", "chrome"),
|
| 314 |
+
"timeout": int(os.getenv("SCRAPER_TIMEOUT", "20")),
|
| 315 |
+
# Comma/newline-separated proxy URLs (http://user:pass@host:port). The ONLY
|
| 316 |
+
# real fix for datacenter-IP blocking (e.g. LinkedIn/Indeed on HF). Rotated
|
| 317 |
+
# round-robin. Empty = no proxy (best-effort fingerprint stealth only).
|
| 318 |
+
"proxies": os.getenv("SCRAPER_PROXIES", "") or os.getenv("SCRAPER_PROXY", ""),
|
| 319 |
+
# Use the Camoufox stealth browser for Cloudflare-protected sites.
|
| 320 |
+
"use_browser_stealth": os.getenv("SCRAPER_BROWSER_STEALTH", "1") not in ("0", "false", "False"),
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
# Direct-company ATS boards (Greenhouse/Lever/Ashby public JSON APIs). These are
|
| 324 |
+
# the most block-resistant bulk source on a shared cloud IP. Override/extend via
|
| 325 |
+
# the COMPANY_ATS_BOARDS env var (JSON list) without touching code. If empty, the
|
| 326 |
+
# scraper uses its built-in seed list (src/scrapers/company_ats.DEFAULT_BOARDS).
|
| 327 |
+
def _load_company_ats_boards():
|
| 328 |
+
raw = os.getenv("COMPANY_ATS_BOARDS", "").strip()
|
| 329 |
+
if not raw:
|
| 330 |
+
return []
|
| 331 |
+
try:
|
| 332 |
+
import json as _json
|
| 333 |
+
boards = _json.loads(raw)
|
| 334 |
+
return boards if isinstance(boards, list) else []
|
| 335 |
+
except Exception:
|
| 336 |
+
return []
|
| 337 |
+
|
| 338 |
+
COMPANY_ATS_BOARDS = _load_company_ats_boards()
|
| 339 |
+
|
| 340 |
# Output
|
| 341 |
OUTPUT = {
|
| 342 |
"excel_path": "data/output/reports/job_report.xlsx",
|
|
@@ -24,6 +24,10 @@ openpyxl>=3.1.0
|
|
| 24 |
playwright>=1.40.0
|
| 25 |
aiohttp>=3.9.0
|
| 26 |
httpx>=0.27.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# Google Sheets
|
| 29 |
gspread>=6.0.0
|
|
|
|
| 24 |
playwright>=1.40.0
|
| 25 |
aiohttp>=3.9.0
|
| 26 |
httpx>=0.27.0
|
| 27 |
+
# Scrapling: real-Chrome TLS impersonation (Fetcher) + Cloudflare-bypass stealth
|
| 28 |
+
# browser (StealthyFetcher/Camoufox). [fetchers] pulls curl_cffi etc. The
|
| 29 |
+
# Camoufox browser itself is installed in the Dockerfile via `scrapling install`.
|
| 30 |
+
scrapling[fetchers]>=0.3.6
|
| 31 |
|
| 32 |
# Google Sheets
|
| 33 |
gspread>=6.0.0
|
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline verification for the Scrapling anti-block integration.
|
| 2 |
+
|
| 3 |
+
Deterministic, no network: validates the fetch-layer shim + proxy rotation, the
|
| 4 |
+
Greenhouse/Lever/Ashby ATS parsers (via canned JSON), graceful degradation when
|
| 5 |
+
Scrapling isn't installed, and the ui/config/deps wiring. Run:
|
| 6 |
+
|
| 7 |
+
python scripts/verify_scrapling_integration.py
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import json
|
| 12 |
+
import types
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 15 |
+
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
| 16 |
+
|
| 17 |
+
_ok = True
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def check(label, cond, extra=""):
|
| 21 |
+
global _ok
|
| 22 |
+
_ok = _ok and bool(cond)
|
| 23 |
+
print(f" [{'PASS' if cond else 'FAIL'}] {label}" + (f" {extra}" if extra else ""))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def read(rel):
|
| 27 |
+
with open(os.path.join(REPO, rel), encoding="utf-8") as f:
|
| 28 |
+
return f.read()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
print("=" * 70)
|
| 32 |
+
print("Scrapling anti-block integration verification")
|
| 33 |
+
print("=" * 70)
|
| 34 |
+
|
| 35 |
+
# ── [A] fetch layer: shim + graceful import ──────────────────────────────────
|
| 36 |
+
print("[A] Fetch layer (requests.Response-compatible shim + graceful degrade)")
|
| 37 |
+
from src.scrapers import fetch # noqa: E402
|
| 38 |
+
|
| 39 |
+
check("scrapling_available() returns a bool (no import crash)",
|
| 40 |
+
isinstance(fetch.scrapling_available(), bool),
|
| 41 |
+
f"scrapling={fetch.scrapling_available()} stealthy={fetch.stealthy_available()}")
|
| 42 |
+
|
| 43 |
+
r = fetch.FetchResponse(200, '{"a": 1}', url="http://x")
|
| 44 |
+
check("FetchResponse.ok true for 200", r.ok is True)
|
| 45 |
+
check("FetchResponse.json() parses body", r.json() == {"a": 1})
|
| 46 |
+
check("FetchResponse.content is bytes", isinstance(r.content, bytes))
|
| 47 |
+
check("FetchResponse non-200 not ok", fetch.FetchResponse(403, "").ok is False)
|
| 48 |
+
|
| 49 |
+
# ── [B] proxy rotation (the real anti-IP-block hook) ─────────────────────────
|
| 50 |
+
print("[B] Proxy env hook + round-robin rotation")
|
| 51 |
+
os.environ["SCRAPER_PROXIES"] = "http://p1:1,http://p2:2"
|
| 52 |
+
rotated = [fetch._next_proxy() for _ in range(4)]
|
| 53 |
+
check("rotates configured proxies round-robin",
|
| 54 |
+
rotated == ["http://p1:1", "http://p2:2", "http://p1:1", "http://p2:2"],
|
| 55 |
+
str(rotated))
|
| 56 |
+
os.environ.pop("SCRAPER_PROXIES", None)
|
| 57 |
+
check("no proxy configured -> None", fetch._next_proxy() is None)
|
| 58 |
+
|
| 59 |
+
# ── [C] ATS parsers (Greenhouse/Lever/Ashby) via canned JSON ─────────────────
|
| 60 |
+
print("[C] Company ATS parsers normalize to Job + filter non-PM")
|
| 61 |
+
from src.scrapers.company_ats import CompanyATSScraper # noqa: E402
|
| 62 |
+
|
| 63 |
+
GH = json.dumps({"jobs": [
|
| 64 |
+
{"id": 1, "title": "Senior Product Manager", "absolute_url": "https://gh/1",
|
| 65 |
+
"updated_at": "2026-06-01T00:00:00Z", "location": {"name": "Bangalore, India"},
|
| 66 |
+
"content": "<p>Own the roadmap</p>"},
|
| 67 |
+
{"id": 2, "title": "Software Engineer", "absolute_url": "https://gh/2",
|
| 68 |
+
"location": {"name": "Remote"}, "content": "x"},
|
| 69 |
+
]})
|
| 70 |
+
LEVER = json.dumps([
|
| 71 |
+
{"id": "a", "text": "Product Manager, Growth", "hostedUrl": "https://lever/a",
|
| 72 |
+
"categories": {"location": "Remote"}, "descriptionPlain": "Lead growth",
|
| 73 |
+
"createdAt": 1700000000000},
|
| 74 |
+
])
|
| 75 |
+
ASHBY = json.dumps({"jobs": [
|
| 76 |
+
{"id": "z", "title": "AI Product Manager", "jobUrl": "https://ashby/z",
|
| 77 |
+
"location": "Remote", "descriptionPlain": "Build AI products",
|
| 78 |
+
"publishedAt": "2026-05-01"},
|
| 79 |
+
]})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def fake_get(self, url, **kw):
|
| 83 |
+
if "greenhouse" in url:
|
| 84 |
+
return fetch.FetchResponse(200, GH)
|
| 85 |
+
if "lever" in url:
|
| 86 |
+
return fetch.FetchResponse(200, LEVER)
|
| 87 |
+
if "ashby" in url:
|
| 88 |
+
return fetch.FetchResponse(200, ASHBY)
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
boards = [
|
| 93 |
+
{"provider": "greenhouse", "token": "x", "company": "GH Co"},
|
| 94 |
+
{"provider": "lever", "token": "y", "company": "Lever Co"},
|
| 95 |
+
{"provider": "ashby", "token": "z", "company": "Ashby Co"},
|
| 96 |
+
]
|
| 97 |
+
s = CompanyATSScraper(boards=boards)
|
| 98 |
+
s._get = types.MethodType(fake_get, s)
|
| 99 |
+
jobs = s.search("product manager", "remote", max_results=25)
|
| 100 |
+
|
| 101 |
+
titles = [j.title for j in jobs]
|
| 102 |
+
check("3 PM jobs across 3 providers (engineer filtered out)", len(jobs) == 3, str(titles))
|
| 103 |
+
check("non-PM 'Software Engineer' excluded", "Software Engineer" not in titles)
|
| 104 |
+
gh = next((j for j in jobs if j.company == "GH Co"), None)
|
| 105 |
+
check("greenhouse HTML description stripped + unescaped",
|
| 106 |
+
gh is not None and gh.description == "Own the roadmap",
|
| 107 |
+
gh.description if gh else "none")
|
| 108 |
+
lev = next((j for j in jobs if j.company == "Lever Co"), None)
|
| 109 |
+
check("lever createdAt(ms) -> YYYY-MM-DD posted_date",
|
| 110 |
+
lev is not None and len(lev.posted_date) == 10 and lev.posted_date.startswith("20"),
|
| 111 |
+
lev.posted_date if lev else "none")
|
| 112 |
+
check("all ATS jobs carry platform=CompanyATS + url",
|
| 113 |
+
all(j.platform == "CompanyATS" and j.url for j in jobs))
|
| 114 |
+
|
| 115 |
+
# location filter rejects mismatched city when a specific location is given
|
| 116 |
+
s2 = CompanyATSScraper(boards=[{"provider": "ashby", "token": "z", "company": "Ashby Co"}])
|
| 117 |
+
ASHBY_SF = json.dumps({"jobs": [
|
| 118 |
+
{"id": "z", "title": "Product Manager", "jobUrl": "https://ashby/sf",
|
| 119 |
+
"location": "Munich, Germany", "descriptionPlain": "d", "publishedAt": "2026-05-01"},
|
| 120 |
+
]})
|
| 121 |
+
s2._get = types.MethodType(lambda self, url, **kw: fetch.FetchResponse(200, ASHBY_SF), s2)
|
| 122 |
+
check("location filter drops a non-matching city for a specific location",
|
| 123 |
+
len(s2.search("product manager", "Hyderabad, India", 25)) == 0)
|
| 124 |
+
|
| 125 |
+
# ── [D] base scraper uses the fetch layer ────────────────────────────────────
|
| 126 |
+
print("[D] BaseScraper._get routes through the stealth fetch layer")
|
| 127 |
+
base_src = read("src/scrapers/base.py")
|
| 128 |
+
check("base imports the fetch layer", "from . import fetch" in base_src)
|
| 129 |
+
check("base._get calls fetch.get(", "fetch.get(" in base_src)
|
| 130 |
+
check("base._get supports use_browser/solve_cloudflare",
|
| 131 |
+
"use_browser" in base_src and "solve_cloudflare" in base_src)
|
| 132 |
+
|
| 133 |
+
gd_src = read("src/scrapers/glassdoor.py")
|
| 134 |
+
check("glassdoor tries StealthyFetcher (Cloudflare) before Playwright",
|
| 135 |
+
"fetch_browser_html" in gd_src)
|
| 136 |
+
|
| 137 |
+
# ── [E] config + ui + deps wiring ────────────────────────────────────────────
|
| 138 |
+
print("[E] config / ui / dependency wiring")
|
| 139 |
+
import config # noqa: E402
|
| 140 |
+
check("config.SCRAPER present with proxies + impersonate",
|
| 141 |
+
isinstance(getattr(config, "SCRAPER", None), dict)
|
| 142 |
+
and "proxies" in config.SCRAPER and "impersonate" in config.SCRAPER)
|
| 143 |
+
check("config.COMPANY_ATS_BOARDS exists (list)",
|
| 144 |
+
isinstance(getattr(config, "COMPANY_ATS_BOARDS", None), list))
|
| 145 |
+
|
| 146 |
+
ui_src = read("ui.py")
|
| 147 |
+
check("ui adds company_ats to legacy keys (not routed to ever-jobs)",
|
| 148 |
+
'"company_ats"' in ui_src and "_legacy_keys" in ui_src)
|
| 149 |
+
check("ui registers CompanyATSScraper", "CompanyATSScraper" in ui_src)
|
| 150 |
+
|
| 151 |
+
plat_src = read("src/ever_jobs_bridge/platforms.py")
|
| 152 |
+
check("platforms registry exposes company_ats option", '"company_ats"' in plat_src)
|
| 153 |
+
check("company_ats is an India default platform",
|
| 154 |
+
"company_ats" in plat_src and "INDIA_DEFAULT_PLATFORMS" in plat_src)
|
| 155 |
+
|
| 156 |
+
reqs = read("requirements.txt")
|
| 157 |
+
check("requirements.txt pins scrapling[fetchers]", "scrapling[fetchers]" in reqs)
|
| 158 |
+
docker = read("Dockerfile")
|
| 159 |
+
check("Dockerfile installs the Camoufox stealth browser", "scrapling install" in docker)
|
| 160 |
+
|
| 161 |
+
print("-" * 70)
|
| 162 |
+
print("PASS - Scrapling integration wired + ATS parsers verified"
|
| 163 |
+
if _ok else "FAIL - see above")
|
| 164 |
+
sys.exit(0 if _ok else 1)
|
|
@@ -178,6 +178,9 @@ EVER_JOBS_PLATFORMS: dict[str, dict] = {
|
|
| 178 |
"byju": {"group": "Company Pages", "display": "BYJU'S"},
|
| 179 |
"unacademy": {"group": "Company Pages", "display": "Unacademy"},
|
| 180 |
"phonepe": {"group": "Company Pages", "display": "PhonePe"},
|
|
|
|
|
|
|
|
|
|
| 181 |
}
|
| 182 |
|
| 183 |
|
|
@@ -186,6 +189,7 @@ INDIA_DEFAULT_PLATFORMS: list[str] = [
|
|
| 186 |
"naukri",
|
| 187 |
"indeed",
|
| 188 |
"glassdoor",
|
|
|
|
| 189 |
"google",
|
| 190 |
"bdjobs",
|
| 191 |
"internshala",
|
|
|
|
| 178 |
"byju": {"group": "Company Pages", "display": "BYJU'S"},
|
| 179 |
"unacademy": {"group": "Company Pages", "display": "Unacademy"},
|
| 180 |
"phonepe": {"group": "Company Pages", "display": "PhonePe"},
|
| 181 |
+
# Direct-company ATS aggregator (Greenhouse/Lever/Ashby public JSON APIs).
|
| 182 |
+
# Routed to src/scrapers/company_ats.py, NOT the ever-jobs sidecar.
|
| 183 |
+
"company_ats": {"group": "Company Pages", "display": "Direct Company ATS (Greenhouse/Lever/Ashby)"},
|
| 184 |
}
|
| 185 |
|
| 186 |
|
|
|
|
| 189 |
"naukri",
|
| 190 |
"indeed",
|
| 191 |
"glassdoor",
|
| 192 |
+
"company_ats",
|
| 193 |
"google",
|
| 194 |
"bdjobs",
|
| 195 |
"internshala",
|
|
@@ -7,6 +7,8 @@ from dataclasses import dataclass, field
|
|
| 7 |
from typing import Optional
|
| 8 |
from datetime import datetime
|
| 9 |
|
|
|
|
|
|
|
| 10 |
|
| 11 |
@dataclass
|
| 12 |
class Job:
|
|
@@ -55,7 +57,34 @@ class BaseScraper:
|
|
| 55 |
"Upgrade-Insecure-Requests": "1",
|
| 56 |
})
|
| 57 |
|
| 58 |
-
def _get(self, url: str, params: dict = None, headers: dict = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
for attempt in range(retries):
|
| 60 |
try:
|
| 61 |
self.session.headers["User-Agent"] = self.ua.random
|
|
@@ -65,11 +94,10 @@ class BaseScraper:
|
|
| 65 |
if resp.status_code == 200:
|
| 66 |
return resp
|
| 67 |
elif resp.status_code == 429:
|
| 68 |
-
|
| 69 |
-
time.sleep(wait)
|
| 70 |
elif resp.status_code in (403, 401):
|
| 71 |
break
|
| 72 |
-
except Exception
|
| 73 |
time.sleep(2 ** attempt)
|
| 74 |
return None
|
| 75 |
|
|
|
|
| 7 |
from typing import Optional
|
| 8 |
from datetime import datetime
|
| 9 |
|
| 10 |
+
from . import fetch
|
| 11 |
+
|
| 12 |
|
| 13 |
@dataclass
|
| 14 |
class Job:
|
|
|
|
| 57 |
"Upgrade-Insecure-Requests": "1",
|
| 58 |
})
|
| 59 |
|
| 60 |
+
def _get(self, url: str, params: dict = None, headers: dict = None,
|
| 61 |
+
retries: int = 3, use_browser: bool = False,
|
| 62 |
+
solve_cloudflare: bool = False):
|
| 63 |
+
"""Fetch a URL through the stealth fetch layer (Scrapling when present,
|
| 64 |
+
requests otherwise). Per-scraper session headers (e.g. LinkedIn's
|
| 65 |
+
Referer) are merged in so behaviour is preserved. Returns a
|
| 66 |
+
requests.Response-compatible object on HTTP 200, else None."""
|
| 67 |
+
merged = {}
|
| 68 |
+
# Carry over session-level headers (Referer etc.) set by subclasses,
|
| 69 |
+
# minus the per-request UA which the fetch layer rotates itself.
|
| 70 |
+
for k, v in self.session.headers.items():
|
| 71 |
+
if k.lower() != "user-agent":
|
| 72 |
+
merged[k] = v
|
| 73 |
+
if headers:
|
| 74 |
+
merged.update(headers)
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
resp = fetch.get(
|
| 78 |
+
url, params=params, headers=merged or None, retries=retries,
|
| 79 |
+
use_browser=use_browser, solve_cloudflare=solve_cloudflare,
|
| 80 |
+
)
|
| 81 |
+
if resp is not None:
|
| 82 |
+
return resp
|
| 83 |
+
except Exception:
|
| 84 |
+
pass
|
| 85 |
+
|
| 86 |
+
# Last-resort: the original requests.Session path (kept for resilience
|
| 87 |
+
# if the fetch layer itself errors in an unexpected environment).
|
| 88 |
for attempt in range(retries):
|
| 89 |
try:
|
| 90 |
self.session.headers["User-Agent"] = self.ua.random
|
|
|
|
| 94 |
if resp.status_code == 200:
|
| 95 |
return resp
|
| 96 |
elif resp.status_code == 429:
|
| 97 |
+
time.sleep((attempt + 1) * 10)
|
|
|
|
| 98 |
elif resp.status_code in (403, 401):
|
| 99 |
break
|
| 100 |
+
except Exception:
|
| 101 |
time.sleep(2 ** attempt)
|
| 102 |
return None
|
| 103 |
|
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Direct-company ATS scraper — Greenhouse, Lever, and Ashby public job boards.
|
| 2 |
+
|
| 3 |
+
Why this exists: LinkedIn/Indeed aggressively block datacenter IPs (like HF
|
| 4 |
+
Spaces). Company ATS boards, by contrast, expose PUBLIC JSON APIs meant to be
|
| 5 |
+
embedded on careers pages — they are almost never IP-blocked and return clean,
|
| 6 |
+
structured data (title, location, full description, apply URL). That makes them
|
| 7 |
+
the single most reliable bulk job source when running on a shared cloud IP.
|
| 8 |
+
|
| 9 |
+
Each board is identified by a provider + a board "token" (the company's slug):
|
| 10 |
+
|
| 11 |
+
* Greenhouse : https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true
|
| 12 |
+
* Lever : https://api.lever.co/v0/postings/{token}?mode=json
|
| 13 |
+
* Ashby : https://api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true
|
| 14 |
+
|
| 15 |
+
The board list comes from config.COMPANY_ATS_BOARDS (falls back to a built-in
|
| 16 |
+
seed of well-known PM-hiring companies). All fetches go through the shared
|
| 17 |
+
stealth fetch layer, so any configured proxy applies here too.
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import html as _html
|
| 22 |
+
import logging
|
| 23 |
+
from datetime import datetime, timezone
|
| 24 |
+
from typing import Optional
|
| 25 |
+
|
| 26 |
+
from bs4 import BeautifulSoup
|
| 27 |
+
|
| 28 |
+
from .base import BaseScraper, Job
|
| 29 |
+
from . import fetch
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger("scraper.company_ats")
|
| 32 |
+
|
| 33 |
+
# Built-in seed list — used only if config.COMPANY_ATS_BOARDS is empty/missing.
|
| 34 |
+
# Tokens verified against each provider's public board URL scheme. Companies
|
| 35 |
+
# chosen for active PM hiring + (often) India presence.
|
| 36 |
+
DEFAULT_BOARDS: list[dict] = [
|
| 37 |
+
# ── Greenhouse ──
|
| 38 |
+
{"provider": "greenhouse", "token": "stripe", "company": "Stripe"},
|
| 39 |
+
{"provider": "greenhouse", "token": "airbnb", "company": "Airbnb"},
|
| 40 |
+
{"provider": "greenhouse", "token": "databricks", "company": "Databricks"},
|
| 41 |
+
{"provider": "greenhouse", "token": "dropbox", "company": "Dropbox"},
|
| 42 |
+
{"provider": "greenhouse", "token": "robinhood", "company": "Robinhood"},
|
| 43 |
+
{"provider": "greenhouse", "token": "coinbase", "company": "Coinbase"},
|
| 44 |
+
{"provider": "greenhouse", "token": "gitlab", "company": "GitLab"},
|
| 45 |
+
{"provider": "greenhouse", "token": "cloudflare", "company": "Cloudflare"},
|
| 46 |
+
{"provider": "greenhouse", "token": "razorpaysoftwareprivatelimited", "company": "Razorpay"},
|
| 47 |
+
{"provider": "greenhouse", "token": "postman", "company": "Postman"},
|
| 48 |
+
{"provider": "greenhouse", "token": "freshworks", "company": "Freshworks"},
|
| 49 |
+
{"provider": "greenhouse", "token": "swiggy", "company": "Swiggy"},
|
| 50 |
+
# ── Lever ──
|
| 51 |
+
{"provider": "lever", "token": "netflix", "company": "Netflix"},
|
| 52 |
+
{"provider": "lever", "token": "spotify", "company": "Spotify"},
|
| 53 |
+
{"provider": "lever", "token": "plaid", "company": "Plaid"},
|
| 54 |
+
{"provider": "lever", "token": "ramp", "company": "Ramp"},
|
| 55 |
+
{"provider": "lever", "token": "razorpay", "company": "Razorpay"},
|
| 56 |
+
{"provider": "lever", "token": "swiggy", "company": "Swiggy"},
|
| 57 |
+
{"provider": "lever", "token": "groww", "company": "Groww"},
|
| 58 |
+
{"provider": "lever", "token": "phonepe", "company": "PhonePe"},
|
| 59 |
+
# ── Ashby ──
|
| 60 |
+
{"provider": "ashby", "token": "openai", "company": "OpenAI"},
|
| 61 |
+
{"provider": "ashby", "token": "ramp", "company": "Ramp"},
|
| 62 |
+
{"provider": "ashby", "token": "linear", "company": "Linear"},
|
| 63 |
+
{"provider": "ashby", "token": "notion", "company": "Notion"},
|
| 64 |
+
{"provider": "ashby", "token": "vercel", "company": "Vercel"},
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
_REMOTE_TOKENS = ("remote", "worldwide", "anywhere", "global")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _load_boards() -> list[dict]:
|
| 71 |
+
try:
|
| 72 |
+
import config
|
| 73 |
+
boards = getattr(config, "COMPANY_ATS_BOARDS", None)
|
| 74 |
+
if boards:
|
| 75 |
+
return list(boards)
|
| 76 |
+
except Exception: # noqa: BLE001
|
| 77 |
+
pass
|
| 78 |
+
return DEFAULT_BOARDS
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _strip_html(raw: str) -> str:
|
| 82 |
+
if not raw:
|
| 83 |
+
return ""
|
| 84 |
+
raw = _html.unescape(raw)
|
| 85 |
+
if "<" in raw:
|
| 86 |
+
try:
|
| 87 |
+
raw = BeautifulSoup(raw, "lxml").get_text(separator="\n", strip=True)
|
| 88 |
+
except Exception: # noqa: BLE001
|
| 89 |
+
pass
|
| 90 |
+
return raw.strip()[:3000]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class CompanyATSScraper(BaseScraper):
|
| 94 |
+
"""Aggregates PM jobs across configured Greenhouse/Lever/Ashby boards."""
|
| 95 |
+
|
| 96 |
+
GREENHOUSE = "https://boards-api.greenhouse.io/v1/boards/{token}/jobs?content=true"
|
| 97 |
+
LEVER = "https://api.lever.co/v0/postings/{token}?mode=json"
|
| 98 |
+
ASHBY = "https://api.ashbyhq.com/posting-api/job-board/{token}?includeCompensation=true"
|
| 99 |
+
|
| 100 |
+
def __init__(self, boards: Optional[list[dict]] = None):
|
| 101 |
+
super().__init__("CompanyATS")
|
| 102 |
+
self.boards = boards if boards is not None else _load_boards()
|
| 103 |
+
|
| 104 |
+
# ── public ────────────────────────────────────────────────────────────────
|
| 105 |
+
def search(self, role: str, location: str, max_results: int = 25) -> list[Job]:
|
| 106 |
+
role_tokens = [t for t in (role or "").lower().split() if len(t) > 2]
|
| 107 |
+
jobs: list[Job] = []
|
| 108 |
+
seen: set[str] = set()
|
| 109 |
+
|
| 110 |
+
for board in self.boards:
|
| 111 |
+
if len(jobs) >= max_results:
|
| 112 |
+
break
|
| 113 |
+
provider = (board.get("provider") or "").lower()
|
| 114 |
+
token = board.get("token") or ""
|
| 115 |
+
company = board.get("company") or token.title()
|
| 116 |
+
if not token:
|
| 117 |
+
continue
|
| 118 |
+
try:
|
| 119 |
+
if provider == "greenhouse":
|
| 120 |
+
raw = self._fetch_greenhouse(token, company)
|
| 121 |
+
elif provider == "lever":
|
| 122 |
+
raw = self._fetch_lever(token, company)
|
| 123 |
+
elif provider == "ashby":
|
| 124 |
+
raw = self._fetch_ashby(token, company)
|
| 125 |
+
else:
|
| 126 |
+
continue
|
| 127 |
+
except Exception as exc: # noqa: BLE001
|
| 128 |
+
log.debug("ATS board %s/%s failed: %s", provider, token, exc)
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
for job in raw:
|
| 132 |
+
if job.url in seen:
|
| 133 |
+
continue
|
| 134 |
+
if not self.is_pm_role(job.title):
|
| 135 |
+
continue
|
| 136 |
+
if role_tokens and not self._role_match(job.title, role_tokens):
|
| 137 |
+
continue
|
| 138 |
+
if not self._loc_ok(job.location, location):
|
| 139 |
+
continue
|
| 140 |
+
seen.add(job.url)
|
| 141 |
+
jobs.append(job)
|
| 142 |
+
if len(jobs) >= max_results:
|
| 143 |
+
break
|
| 144 |
+
|
| 145 |
+
log.info("CompanyATS: %d PM jobs across %d boards", len(jobs), len(self.boards))
|
| 146 |
+
return jobs
|
| 147 |
+
|
| 148 |
+
def get_details_bulk(self, jobs: list, progress_cb=None) -> None:
|
| 149 |
+
"""Descriptions are already included in the ATS JSON — nothing to fetch."""
|
| 150 |
+
if progress_cb:
|
| 151 |
+
try:
|
| 152 |
+
progress_cb(len(jobs), len(jobs))
|
| 153 |
+
except Exception: # noqa: BLE001
|
| 154 |
+
pass
|
| 155 |
+
|
| 156 |
+
# ── matching helpers ────────────────────────────────────────────────────────
|
| 157 |
+
@staticmethod
|
| 158 |
+
def _role_match(title: str, role_tokens: list[str]) -> bool:
|
| 159 |
+
t = title.lower()
|
| 160 |
+
# Match if the title shares the meaningful role words ("product"/"manager"
|
| 161 |
+
# are common; require at least one non-generic token overlap).
|
| 162 |
+
meaningful = [tok for tok in role_tokens if tok not in ("product", "manager")]
|
| 163 |
+
if not meaningful:
|
| 164 |
+
return True
|
| 165 |
+
return any(tok in t for tok in meaningful) or "product" in t
|
| 166 |
+
|
| 167 |
+
@staticmethod
|
| 168 |
+
def _loc_ok(job_loc: str, selected_loc: str) -> bool:
|
| 169 |
+
jl = (job_loc or "").lower()
|
| 170 |
+
sl = (selected_loc or "").lower().strip()
|
| 171 |
+
if not sl or sl in _REMOTE_TOKENS:
|
| 172 |
+
return True
|
| 173 |
+
if any(tok in jl for tok in _REMOTE_TOKENS):
|
| 174 |
+
return True
|
| 175 |
+
# Match the selected city/country or a generic India hit.
|
| 176 |
+
for token in [p.strip() for p in sl.replace("/", ",").split(",") if p.strip()]:
|
| 177 |
+
if token in jl:
|
| 178 |
+
return True
|
| 179 |
+
if "india" in sl and "india" in jl:
|
| 180 |
+
return True
|
| 181 |
+
# Many boards list no/HQ-only location — keep them rather than over-filter.
|
| 182 |
+
return jl == "" or jl == "remote"
|
| 183 |
+
|
| 184 |
+
# ── provider fetchers ───────────────────────────────────────────────────────
|
| 185 |
+
def _fetch_greenhouse(self, token: str, company: str) -> list[Job]:
|
| 186 |
+
resp = self._get(self.GREENHOUSE.format(token=token))
|
| 187 |
+
if not resp:
|
| 188 |
+
return []
|
| 189 |
+
data = resp.json()
|
| 190 |
+
out = []
|
| 191 |
+
for j in data.get("jobs", []):
|
| 192 |
+
loc = (j.get("location") or {}).get("name") or ""
|
| 193 |
+
out.append(Job(
|
| 194 |
+
title=(j.get("title") or "").strip(),
|
| 195 |
+
company=company,
|
| 196 |
+
location=loc,
|
| 197 |
+
url=(j.get("absolute_url") or "").strip(),
|
| 198 |
+
platform="CompanyATS",
|
| 199 |
+
description=_strip_html(j.get("content") or ""),
|
| 200 |
+
posted_date=(j.get("updated_at") or "")[:10],
|
| 201 |
+
job_id=str(j.get("id") or ""),
|
| 202 |
+
))
|
| 203 |
+
return out
|
| 204 |
+
|
| 205 |
+
def _fetch_lever(self, token: str, company: str) -> list[Job]:
|
| 206 |
+
resp = self._get(self.LEVER.format(token=token))
|
| 207 |
+
if not resp:
|
| 208 |
+
return []
|
| 209 |
+
data = resp.json()
|
| 210 |
+
out = []
|
| 211 |
+
for j in data if isinstance(data, list) else []:
|
| 212 |
+
cats = j.get("categories") or {}
|
| 213 |
+
created = j.get("createdAt")
|
| 214 |
+
posted = ""
|
| 215 |
+
if isinstance(created, (int, float)):
|
| 216 |
+
try:
|
| 217 |
+
posted = datetime.fromtimestamp(
|
| 218 |
+
created / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
|
| 219 |
+
except Exception: # noqa: BLE001
|
| 220 |
+
posted = ""
|
| 221 |
+
out.append(Job(
|
| 222 |
+
title=(j.get("text") or "").strip(),
|
| 223 |
+
company=company,
|
| 224 |
+
location=(cats.get("location") or "").strip(),
|
| 225 |
+
url=(j.get("hostedUrl") or j.get("applyUrl") or "").strip(),
|
| 226 |
+
platform="CompanyATS",
|
| 227 |
+
description=_strip_html(j.get("descriptionPlain")
|
| 228 |
+
or j.get("description") or ""),
|
| 229 |
+
posted_date=posted,
|
| 230 |
+
job_id=str(j.get("id") or ""),
|
| 231 |
+
))
|
| 232 |
+
return out
|
| 233 |
+
|
| 234 |
+
def _fetch_ashby(self, token: str, company: str) -> list[Job]:
|
| 235 |
+
resp = self._get(self.ASHBY.format(token=token))
|
| 236 |
+
if not resp:
|
| 237 |
+
return []
|
| 238 |
+
data = resp.json()
|
| 239 |
+
out = []
|
| 240 |
+
for j in data.get("jobs", []) if isinstance(data, dict) else []:
|
| 241 |
+
out.append(Job(
|
| 242 |
+
title=(j.get("title") or "").strip(),
|
| 243 |
+
company=company,
|
| 244 |
+
location=(j.get("location") or "").strip(),
|
| 245 |
+
url=(j.get("jobUrl") or j.get("applyUrl") or "").strip(),
|
| 246 |
+
platform="CompanyATS",
|
| 247 |
+
description=_strip_html(j.get("descriptionPlain")
|
| 248 |
+
or j.get("description") or ""),
|
| 249 |
+
posted_date=(j.get("publishedAt") or "")[:10],
|
| 250 |
+
job_id=str(j.get("id") or ""),
|
| 251 |
+
))
|
| 252 |
+
return out
|
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified, stealth-capable fetch layer for all job scrapers.
|
| 2 |
+
|
| 3 |
+
Goal: scrape without getting blocked. This module routes every HTTP fetch
|
| 4 |
+
through Scrapling when it is installed (real-Chrome TLS fingerprint via
|
| 5 |
+
`Fetcher`, Cloudflare-Turnstile bypass via `StealthyFetcher`), and transparently
|
| 6 |
+
falls back to plain `requests` when Scrapling (or its browsers) are unavailable.
|
| 7 |
+
|
| 8 |
+
Two kinds of blocking, and what this layer does about each:
|
| 9 |
+
|
| 10 |
+
1. Fingerprint blocking (TLS / headers / headless detection / Cloudflare)
|
| 11 |
+
-> Scrapling's `impersonate=` + `StealthyFetcher` defeat this.
|
| 12 |
+
2. IP-reputation blocking (shared datacenter IP, e.g. on HF Spaces)
|
| 13 |
+
-> Only a proxy fixes this. Set the `SCRAPER_PROXIES` env var (comma- or
|
| 14 |
+
newline-separated proxy URLs) and they are rotated round-robin across
|
| 15 |
+
requests. Without proxies we still try our best with fingerprint stealth.
|
| 16 |
+
|
| 17 |
+
The public surface is intentionally tiny and `requests.Response`-compatible
|
| 18 |
+
(`.text`, `.content`, `.json()`, `.status_code`, `.ok`) so existing scrapers do
|
| 19 |
+
not need to change how they read responses.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
import time
|
| 25 |
+
import json as _json
|
| 26 |
+
import logging
|
| 27 |
+
import itertools
|
| 28 |
+
import threading
|
| 29 |
+
from typing import Optional
|
| 30 |
+
|
| 31 |
+
log = logging.getLogger("scraper.fetch")
|
| 32 |
+
|
| 33 |
+
# ── Optional Scrapling import (degrade gracefully if missing) ─────────────────
|
| 34 |
+
try: # pragma: no cover - import side effect depends on the environment
|
| 35 |
+
from scrapling.fetchers import Fetcher as _SFetcher # HTTP, TLS-impersonate
|
| 36 |
+
_HAS_SCRAPLING = True
|
| 37 |
+
except Exception: # noqa: BLE001 - any import error => fall back to requests
|
| 38 |
+
_SFetcher = None
|
| 39 |
+
_HAS_SCRAPLING = False
|
| 40 |
+
|
| 41 |
+
try: # StealthyFetcher needs the extra Camoufox browser (`scrapling install`)
|
| 42 |
+
from scrapling.fetchers import StealthyFetcher as _SStealthy
|
| 43 |
+
_HAS_STEALTHY = True
|
| 44 |
+
except Exception: # noqa: BLE001
|
| 45 |
+
_SStealthy = None
|
| 46 |
+
_HAS_STEALTHY = False
|
| 47 |
+
|
| 48 |
+
# `requests` is always available (a hard dependency of the project).
|
| 49 |
+
import requests # noqa: E402
|
| 50 |
+
from fake_useragent import UserAgent # noqa: E402
|
| 51 |
+
|
| 52 |
+
_DEFAULT_IMPERSONATE = os.getenv("SCRAPER_IMPERSONATE", "chrome")
|
| 53 |
+
_DEFAULT_TIMEOUT = int(os.getenv("SCRAPER_TIMEOUT", "20"))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def scrapling_available() -> bool:
|
| 57 |
+
return _HAS_SCRAPLING
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def stealthy_available() -> bool:
|
| 61 |
+
return _HAS_STEALTHY
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── Proxy rotation (the only real fix for datacenter-IP blocking) ─────────────
|
| 65 |
+
def _load_proxies() -> list[str]:
|
| 66 |
+
"""Read proxies from env at call time so secrets can be set after import.
|
| 67 |
+
|
| 68 |
+
Accepts comma- OR newline-separated proxy URLs in `SCRAPER_PROXIES`
|
| 69 |
+
(e.g. ``http://user:pass@host:port``). A single `SCRAPER_PROXY` also works.
|
| 70 |
+
"""
|
| 71 |
+
raw = os.getenv("SCRAPER_PROXIES", "") or os.getenv("SCRAPER_PROXY", "")
|
| 72 |
+
if not raw:
|
| 73 |
+
return []
|
| 74 |
+
parts = [p.strip() for chunk in raw.split("\n") for p in chunk.split(",")]
|
| 75 |
+
return [p for p in parts if p]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
_proxy_lock = threading.Lock()
|
| 79 |
+
_proxy_cycle: Optional["itertools.cycle"] = None
|
| 80 |
+
_proxy_snapshot: tuple[str, ...] = ()
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _next_proxy() -> Optional[str]:
|
| 84 |
+
"""Round-robin the configured proxies; rebuild the cycle if env changed."""
|
| 85 |
+
global _proxy_cycle, _proxy_snapshot
|
| 86 |
+
proxies = tuple(_load_proxies())
|
| 87 |
+
if not proxies:
|
| 88 |
+
return None
|
| 89 |
+
with _proxy_lock:
|
| 90 |
+
if proxies != _proxy_snapshot or _proxy_cycle is None:
|
| 91 |
+
_proxy_snapshot = proxies
|
| 92 |
+
_proxy_cycle = itertools.cycle(proxies)
|
| 93 |
+
return next(_proxy_cycle)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ── requests.Response-compatible shim ─────────────────────────────────────────
|
| 97 |
+
class FetchResponse:
|
| 98 |
+
"""Minimal response wrapper exposing the bits scrapers actually use."""
|
| 99 |
+
|
| 100 |
+
__slots__ = ("status_code", "text", "url", "backend")
|
| 101 |
+
|
| 102 |
+
def __init__(self, status_code: int, text: str, url: str = "",
|
| 103 |
+
backend: str = "requests"):
|
| 104 |
+
self.status_code = int(status_code or 0)
|
| 105 |
+
self.text = text or ""
|
| 106 |
+
self.url = url
|
| 107 |
+
self.backend = backend
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def ok(self) -> bool:
|
| 111 |
+
return 200 <= self.status_code < 300
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def content(self) -> bytes:
|
| 115 |
+
return self.text.encode("utf-8", "replace")
|
| 116 |
+
|
| 117 |
+
def json(self):
|
| 118 |
+
return _json.loads(self.text)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
_ua = UserAgent()
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _scrapling_response_text(page) -> str:
|
| 125 |
+
"""Extract the response body text across Scrapling versions, defensively."""
|
| 126 |
+
for attr in ("body", "text", "html_content", "html"):
|
| 127 |
+
try:
|
| 128 |
+
val = getattr(page, attr, None)
|
| 129 |
+
except Exception: # noqa: BLE001
|
| 130 |
+
val = None
|
| 131 |
+
if isinstance(val, bytes):
|
| 132 |
+
return val.decode("utf-8", "replace")
|
| 133 |
+
if isinstance(val, str) and val:
|
| 134 |
+
return val
|
| 135 |
+
try:
|
| 136 |
+
return str(page)
|
| 137 |
+
except Exception: # noqa: BLE001
|
| 138 |
+
return ""
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _scrapling_status(page) -> int:
|
| 142 |
+
for attr in ("status", "status_code"):
|
| 143 |
+
val = getattr(page, attr, None)
|
| 144 |
+
if isinstance(val, int):
|
| 145 |
+
return val
|
| 146 |
+
return 200
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _build_url(url: str, params: Optional[dict]) -> str:
|
| 150 |
+
if not params:
|
| 151 |
+
return url
|
| 152 |
+
from urllib.parse import urlencode
|
| 153 |
+
sep = "&" if "?" in url else "?"
|
| 154 |
+
return f"{url}{sep}{urlencode(params)}"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ── Public API ────────────────────────────────────────────────────────────────
|
| 158 |
+
def get(
|
| 159 |
+
url: str,
|
| 160 |
+
*,
|
| 161 |
+
params: Optional[dict] = None,
|
| 162 |
+
headers: Optional[dict] = None,
|
| 163 |
+
timeout: int = _DEFAULT_TIMEOUT,
|
| 164 |
+
retries: int = 3,
|
| 165 |
+
impersonate: str = _DEFAULT_IMPERSONATE,
|
| 166 |
+
use_browser: bool = False,
|
| 167 |
+
solve_cloudflare: bool = False,
|
| 168 |
+
) -> Optional[FetchResponse]:
|
| 169 |
+
"""Fetch a URL, preferring Scrapling stealth, falling back to requests.
|
| 170 |
+
|
| 171 |
+
`use_browser`/`solve_cloudflare` route through `StealthyFetcher` (Camoufox)
|
| 172 |
+
for JS-heavy or Cloudflare-protected pages when it is installed.
|
| 173 |
+
Returns a `FetchResponse` on HTTP 200, else None (mirrors the old `_get`).
|
| 174 |
+
"""
|
| 175 |
+
full_url = _build_url(url, params)
|
| 176 |
+
|
| 177 |
+
if use_browser or solve_cloudflare:
|
| 178 |
+
r = _get_browser(full_url, timeout=timeout, solve_cloudflare=solve_cloudflare)
|
| 179 |
+
if r is not None:
|
| 180 |
+
return r
|
| 181 |
+
# fall through to HTTP if the browser path is unavailable/failed
|
| 182 |
+
|
| 183 |
+
if _HAS_SCRAPLING:
|
| 184 |
+
r = _get_scrapling_http(full_url, headers, timeout, retries, impersonate)
|
| 185 |
+
if r is not None:
|
| 186 |
+
return r
|
| 187 |
+
# fall through to requests on total failure
|
| 188 |
+
|
| 189 |
+
return _get_requests(full_url, headers, timeout, retries)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _get_scrapling_http(url, headers, timeout, retries, impersonate):
|
| 193 |
+
for attempt in range(retries):
|
| 194 |
+
proxy = _next_proxy()
|
| 195 |
+
try:
|
| 196 |
+
kwargs = {
|
| 197 |
+
"timeout": timeout,
|
| 198 |
+
"stealthy_headers": True,
|
| 199 |
+
"impersonate": impersonate,
|
| 200 |
+
}
|
| 201 |
+
if headers:
|
| 202 |
+
kwargs["headers"] = headers
|
| 203 |
+
if proxy:
|
| 204 |
+
kwargs["proxy"] = proxy
|
| 205 |
+
page = _SFetcher.get(url, **kwargs)
|
| 206 |
+
status = _scrapling_status(page)
|
| 207 |
+
if status == 200:
|
| 208 |
+
return FetchResponse(status, _scrapling_response_text(page),
|
| 209 |
+
url=url, backend="scrapling-http")
|
| 210 |
+
if status == 429:
|
| 211 |
+
time.sleep((attempt + 1) * 8)
|
| 212 |
+
continue
|
| 213 |
+
if status in (401, 403):
|
| 214 |
+
# try once more via a different proxy; else give up on this backend
|
| 215 |
+
if proxy and attempt < retries - 1:
|
| 216 |
+
continue
|
| 217 |
+
return None
|
| 218 |
+
except TypeError:
|
| 219 |
+
# Older/newer Scrapling signature mismatch — drop unsupported kwargs.
|
| 220 |
+
try:
|
| 221 |
+
page = _SFetcher.get(url, timeout=timeout)
|
| 222 |
+
if _scrapling_status(page) == 200:
|
| 223 |
+
return FetchResponse(200, _scrapling_response_text(page),
|
| 224 |
+
url=url, backend="scrapling-http")
|
| 225 |
+
except Exception: # noqa: BLE001
|
| 226 |
+
pass
|
| 227 |
+
return None
|
| 228 |
+
except Exception as exc: # noqa: BLE001
|
| 229 |
+
log.debug("scrapling http error (%s): %s", url, exc)
|
| 230 |
+
time.sleep(2 ** attempt)
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _get_browser(url, timeout, solve_cloudflare):
|
| 235 |
+
if not _HAS_STEALTHY:
|
| 236 |
+
return None
|
| 237 |
+
proxy = _next_proxy()
|
| 238 |
+
try:
|
| 239 |
+
kwargs = {
|
| 240 |
+
"headless": True,
|
| 241 |
+
"network_idle": True,
|
| 242 |
+
"timeout": timeout * 1000, # Scrapling browser timeouts are in ms
|
| 243 |
+
}
|
| 244 |
+
if solve_cloudflare:
|
| 245 |
+
kwargs["solve_cloudflare"] = True
|
| 246 |
+
if proxy:
|
| 247 |
+
kwargs["proxy"] = proxy
|
| 248 |
+
page = _SStealthy.fetch(url, **kwargs)
|
| 249 |
+
status = _scrapling_status(page)
|
| 250 |
+
if status and status != 200:
|
| 251 |
+
return None
|
| 252 |
+
return FetchResponse(status or 200, _scrapling_response_text(page),
|
| 253 |
+
url=url, backend="scrapling-stealth")
|
| 254 |
+
except TypeError:
|
| 255 |
+
try:
|
| 256 |
+
page = _SStealthy.fetch(url, headless=True)
|
| 257 |
+
return FetchResponse(_scrapling_status(page),
|
| 258 |
+
_scrapling_response_text(page),
|
| 259 |
+
url=url, backend="scrapling-stealth")
|
| 260 |
+
except Exception: # noqa: BLE001
|
| 261 |
+
return None
|
| 262 |
+
except Exception as exc: # noqa: BLE001
|
| 263 |
+
log.debug("scrapling stealth error (%s): %s", url, exc)
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _get_requests(url, headers, timeout, retries):
|
| 268 |
+
base_headers = {
|
| 269 |
+
"User-Agent": _ua.random,
|
| 270 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
|
| 271 |
+
"image/webp,*/*;q=0.8",
|
| 272 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 273 |
+
"Accept-Encoding": "gzip, deflate, br",
|
| 274 |
+
"Connection": "keep-alive",
|
| 275 |
+
"Upgrade-Insecure-Requests": "1",
|
| 276 |
+
}
|
| 277 |
+
if headers:
|
| 278 |
+
base_headers.update(headers)
|
| 279 |
+
|
| 280 |
+
for attempt in range(retries):
|
| 281 |
+
proxy = _next_proxy()
|
| 282 |
+
proxies = {"http": proxy, "https": proxy} if proxy else None
|
| 283 |
+
try:
|
| 284 |
+
base_headers["User-Agent"] = _ua.random
|
| 285 |
+
resp = requests.get(url, headers=base_headers, timeout=timeout,
|
| 286 |
+
proxies=proxies)
|
| 287 |
+
if resp.status_code == 200:
|
| 288 |
+
return FetchResponse(200, resp.text, url=resp.url,
|
| 289 |
+
backend="requests")
|
| 290 |
+
if resp.status_code == 429:
|
| 291 |
+
time.sleep((attempt + 1) * 10)
|
| 292 |
+
elif resp.status_code in (401, 403):
|
| 293 |
+
break
|
| 294 |
+
except Exception: # noqa: BLE001
|
| 295 |
+
time.sleep(2 ** attempt)
|
| 296 |
+
return None
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def fetch_browser_html(url: str, *, solve_cloudflare: bool = True,
|
| 300 |
+
timeout: int = 30) -> Optional[str]:
|
| 301 |
+
"""Return raw HTML for a JS/Cloudflare page via StealthyFetcher, or None.
|
| 302 |
+
|
| 303 |
+
Convenience wrapper for scrapers that already parse HTML with BeautifulSoup
|
| 304 |
+
(e.g. Glassdoor) and want a Camoufox stealth fetch before falling back to
|
| 305 |
+
their own Playwright path.
|
| 306 |
+
"""
|
| 307 |
+
r = _get_browser(url, timeout=timeout, solve_cloudflare=solve_cloudflare)
|
| 308 |
+
return r.text if (r and r.text) else None
|
|
@@ -2,6 +2,7 @@ import re
|
|
| 2 |
from bs4 import BeautifulSoup
|
| 3 |
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
| 4 |
from .base import BaseScraper, Job
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
class GlassdoorScraper(BaseScraper):
|
|
@@ -17,6 +18,18 @@ class GlassdoorScraper(BaseScraper):
|
|
| 17 |
end = 6 + len(role)
|
| 18 |
url = f"https://www.glassdoor.co.in/Job/india-{role_slug}-jobs-SRCH_IN115_KO6,{end}.htm"
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
try:
|
| 21 |
with sync_playwright() as pw:
|
| 22 |
browser = pw.chromium.launch(
|
|
|
|
| 2 |
from bs4 import BeautifulSoup
|
| 3 |
from playwright.sync_api import sync_playwright, TimeoutError as PWTimeout
|
| 4 |
from .base import BaseScraper, Job
|
| 5 |
+
from . import fetch
|
| 6 |
|
| 7 |
|
| 8 |
class GlassdoorScraper(BaseScraper):
|
|
|
|
| 18 |
end = 6 + len(role)
|
| 19 |
url = f"https://www.glassdoor.co.in/Job/india-{role_slug}-jobs-SRCH_IN115_KO6,{end}.htm"
|
| 20 |
|
| 21 |
+
# Preferred path: Scrapling StealthyFetcher (Camoufox) solves Cloudflare
|
| 22 |
+
# Turnstile out of the box. Falls back to the Playwright path below if
|
| 23 |
+
# Scrapling/Camoufox isn't installed or returns nothing.
|
| 24 |
+
try:
|
| 25 |
+
html = fetch.fetch_browser_html(url, solve_cloudflare=True, timeout=30)
|
| 26 |
+
if html:
|
| 27 |
+
jobs = self._parse_jobs(BeautifulSoup(html, "lxml"), location, max_results)
|
| 28 |
+
if jobs:
|
| 29 |
+
return jobs[:max_results]
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
try:
|
| 34 |
with sync_playwright() as pw:
|
| 35 |
browser = pw.chromium.launch(
|
|
@@ -1797,7 +1797,8 @@ if show_config and start and not st.session_state.running:
|
|
| 1797 |
skipped_dup: int = 0
|
| 1798 |
|
| 1799 |
_all_plats = set(_platforms.get("all_platforms", []))
|
| 1800 |
-
_legacy_keys = {"linkedin", "indeed", "glassdoor", "remotive",
|
|
|
|
| 1801 |
scraper_map = {}
|
| 1802 |
|
| 1803 |
if "linkedin" in _all_plats:
|
|
@@ -1836,6 +1837,15 @@ if show_config and start and not st.session_state.running:
|
|
| 1836 |
else:
|
| 1837 |
_step_skip("naukri")
|
| 1838 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1839 |
_ej_platforms = [p for p in _all_plats if p not in _legacy_keys]
|
| 1840 |
if _ej_platforms:
|
| 1841 |
# The 160+ "ever-jobs" platforms need the NestJS sidecar on
|
|
|
|
| 1797 |
skipped_dup: int = 0
|
| 1798 |
|
| 1799 |
_all_plats = set(_platforms.get("all_platforms", []))
|
| 1800 |
+
_legacy_keys = {"linkedin", "indeed", "glassdoor", "remotive",
|
| 1801 |
+
"weworkremotely", "naukri", "company_ats"}
|
| 1802 |
scraper_map = {}
|
| 1803 |
|
| 1804 |
if "linkedin" in _all_plats:
|
|
|
|
| 1837 |
else:
|
| 1838 |
_step_skip("naukri")
|
| 1839 |
|
| 1840 |
+
# Direct-company ATS boards (Greenhouse/Lever/Ashby). Public JSON
|
| 1841 |
+
# APIs that rarely IP-block — the most reliable bulk source on HF.
|
| 1842 |
+
if "company_ats" in _all_plats:
|
| 1843 |
+
from src.scrapers.company_ats import CompanyATSScraper
|
| 1844 |
+
scraper_map["company_ats"] = ("CompanyATS", CompanyATSScraper())
|
| 1845 |
+
_q_log("🏢 Direct Company ATS enabled (Greenhouse/Lever/Ashby — no IP blocks)")
|
| 1846 |
+
else:
|
| 1847 |
+
_step_skip("company_ats")
|
| 1848 |
+
|
| 1849 |
_ej_platforms = [p for p in _all_plats if p not in _legacy_keys]
|
| 1850 |
if _ej_platforms:
|
| 1851 |
# The 160+ "ever-jobs" platforms need the NestJS sidecar on
|