saitejatirunagari Cursor commited on
Commit
9e9393d
·
1 Parent(s): 355c4d1

feat: Scrapling anti-block fetch layer + Direct Company ATS source

Browse files

Scrape 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 CHANGED
@@ -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 (e.g. Adzuna/Jooble API or
405
- `jobdrop`) HTML scrapers on datacenter IPs get blocked.
 
 
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
 
Dockerfile CHANGED
@@ -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
HISTORY.md CHANGED
@@ -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
config.py CHANGED
@@ -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",
requirements.txt CHANGED
@@ -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
scripts/verify_scrapling_integration.py ADDED
@@ -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": "&lt;p&gt;Own the roadmap&lt;/p&gt;"},
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)
src/ever_jobs_bridge/platforms.py CHANGED
@@ -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",
src/scrapers/base.py CHANGED
@@ -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, retries: int = 3) -> Optional[requests.Response]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- wait = (attempt + 1) * 10
69
- time.sleep(wait)
70
  elif resp.status_code in (403, 401):
71
  break
72
- except Exception as e:
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
 
src/scrapers/company_ats.py ADDED
@@ -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
src/scrapers/fetch.py ADDED
@@ -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
src/scrapers/glassdoor.py CHANGED
@@ -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(
ui.py CHANGED
@@ -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", "weworkremotely", "naukri"}
 
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