| """ |
| Robust base scraper with: |
| - Safe scraping: polite delays per domain, UA rotation, retry on 403/429 |
| - Circuit breaker: auto-disables sources that fail repeatedly |
| - 72-hour freshness filter (relaxed from 48h for better coverage) |
| - JSON-LD / RSS / multi-selector / text-heuristic fallback chain |
| - Title-from-text heuristic: extracts job data even from unstructured pages |
| - Domain health tracker: exponential backoff per failing domain |
| """ |
| import re |
| import time |
| import json |
| import random |
| import hashlib |
| import requests |
| from datetime import datetime, timedelta, timezone |
| from bs4 import BeautifulSoup |
| from urllib.parse import urlparse, urljoin |
|
|
| _UA_POOL = [ |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0", |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", |
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0", |
| ] |
|
|
| |
| _DOMAIN_LAST_HIT: dict[str, float] = {} |
| _MIN_DELAY = 3.0 |
| _MAX_DELAY = 8.0 |
|
|
| |
| _DOMAIN_FAILURES: dict[str, int] = {} |
| _DOMAIN_BACKOFF_UNTIL: dict[str, float] = {} |
| _MAX_FAILURES = 5 |
| _BACKOFF_SECONDS = 300 |
|
|
| |
| _ROBOTS_DISALLOWED: dict[str, list[str]] = {} |
| _ROBOTS_CHECKED: set[str] = set() |
|
|
| |
| _FRESHNESS_HOURS = 72 |
|
|
|
|
| def filter_fresh(jobs: list[dict]) -> list[dict]: |
| """Standalone freshness filter. Unknown dates pass through.""" |
| fresh = [j for j in jobs if is_within_window(j.get("posted_date", ""))] |
| stale = len(jobs) - len(fresh) |
| if stale: |
| print(f" [freshness] dropped {stale} stale jobs (>{_FRESHNESS_HOURS}h)") |
| return fresh |
|
|
|
|
| def is_within_window(date_text: str) -> bool: |
| """True = keep job, False = discard (too old). Unknown dates always kept.""" |
| if not date_text: |
| return True |
| dt = date_text.lower().strip() |
| |
| if any(w in dt for w in ["today", "just now", "moments", "1 hour", "2 hour", |
| "3 hour", "4 hour", "5 hour", "6 hour", "12 hour", |
| "yesterday", "1 day ago", "2 day", "3 day"]): |
| return True |
| |
| stale_days = [str(n) for n in range(4, 365)] |
| for n in stale_days: |
| if f"{n} day" in dt: |
| return False |
| if any(w in dt for w in ["week", "month", "year", "30+", "60", "90"]): |
| return False |
| |
| try: |
| from dateutil import parser as dateutil_parser |
| parsed = dateutil_parser.parse(date_text, fuzzy=True) |
| if parsed.tzinfo is not None: |
| parsed = parsed.replace(tzinfo=None) |
| hours_ago = (datetime.now() - parsed).total_seconds() / 3600 |
| return hours_ago <= _FRESHNESS_HOURS |
| except Exception: |
| pass |
| return True |
|
|
|
|
| |
| is_within_48h = is_within_window |
|
|
|
|
| def is_job_valid(job: dict) -> bool: |
| """Check if a job has the required fields and is not stale.""" |
| if not job.get("title") or not job.get("url"): |
| return False |
| posted_date = job.get("posted_date", "") |
| if posted_date and not is_within_window(posted_date): |
| return False |
| return True |
|
|
|
|
| def _url_fingerprint(url: str) -> str: |
| """Stable short fingerprint for deduplication β survives minor URL param changes.""" |
| parsed = urlparse(url) |
| |
| core = f"{parsed.scheme}://{parsed.netloc}{parsed.path}".rstrip("/") |
| return hashlib.md5(core.encode()).hexdigest()[:12] |
|
|
|
|
| class BaseScraper: |
| def __init__(self): |
| self.session = requests.Session() |
| self._ua_idx = 0 |
| self._set_ua(0) |
|
|
| |
|
|
| def _set_ua(self, idx: int = 0): |
| ua = _UA_POOL[idx % len(_UA_POOL)] |
| self.session.headers.update({ |
| "User-Agent": ua, |
| "Accept-Language": "en-US,en;q=0.9", |
| "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", |
| "Accept-Encoding": "gzip, deflate", |
| "Connection": "keep-alive", |
| "DNT": "1", |
| "Sec-Fetch-Dest": "document", |
| "Sec-Fetch-Mode": "navigate", |
| "Sec-Fetch-Site": "none", |
| "Upgrade-Insecure-Requests": "1", |
| }) |
|
|
| |
|
|
| def _is_domain_backed_off(self, url: str) -> bool: |
| domain = urlparse(url).netloc |
| until = _DOMAIN_BACKOFF_UNTIL.get(domain, 0) |
| if time.time() < until: |
| remaining = int(until - time.time()) |
| print(f" [circuit] {domain} backed off for {remaining}s more β skipping") |
| return True |
| return False |
|
|
| def _record_domain_success(self, url: str): |
| domain = urlparse(url).netloc |
| _DOMAIN_FAILURES[domain] = 0 |
|
|
| def _record_domain_failure(self, url: str): |
| domain = urlparse(url).netloc |
| _DOMAIN_FAILURES[domain] = _DOMAIN_FAILURES.get(domain, 0) + 1 |
| count = _DOMAIN_FAILURES[domain] |
| if count >= _MAX_FAILURES: |
| backoff = _BACKOFF_SECONDS * (2 ** min(count - _MAX_FAILURES, 4)) |
| _DOMAIN_BACKOFF_UNTIL[domain] = time.time() + backoff |
| print(f" [circuit] {domain} failed {count}x β backing off {backoff}s") |
|
|
| |
|
|
| def _respect_robots(self, url: str) -> bool: |
| """Check URL against cached robots.txt disallow rules. True = allowed.""" |
| domain = urlparse(url).netloc |
| path = urlparse(url).path |
| if domain not in _ROBOTS_CHECKED: |
| _ROBOTS_CHECKED.add(domain) |
| try: |
| robots_url = f"https://{domain}/robots.txt" |
| resp = requests.get(robots_url, |
| headers={"User-Agent": "Mozilla/5.0 (compatible; JobApply/1.0)"}, |
| timeout=8, |
| ) |
| if resp.status_code == 200: |
| disallowed = [] |
| in_our_block = False |
| for line in resp.text.splitlines(): |
| line = line.strip() |
| if line.lower().startswith("user-agent:"): |
| agent = line.split(":", 1)[1].strip().lower() |
| in_our_block = agent in ("*", "jobapply", "mozilla") |
| elif in_our_block and line.lower().startswith("disallow:"): |
| dis_path = line.split(":", 1)[1].strip() |
| if dis_path: |
| disallowed.append(dis_path) |
| _ROBOTS_DISALLOWED[domain] = disallowed |
| if disallowed: |
| print(f" [robots] {domain}: {len(disallowed)} disallowed paths") |
| else: |
| _ROBOTS_DISALLOWED[domain] = [] |
| except Exception as e: |
| print(f" [robots] {domain}: could not fetch ({e})") |
| _ROBOTS_DISALLOWED[domain] = [] |
| for dis_path in _ROBOTS_DISALLOWED.get(domain, []): |
| if dis_path and dis_path != "/" and path.startswith(dis_path): |
| print(f" [robots] BLOCKED by robots.txt: {url[:80]}") |
| return False |
| return True |
|
|
| |
|
|
| def _polite_wait(self, url: str): |
| """Sleep enough so we hit each domain at most 1 req / _MIN_DELAY seconds.""" |
| domain = urlparse(url).netloc |
| last = _DOMAIN_LAST_HIT.get(domain, 0) |
| wait = random.uniform(_MIN_DELAY, _MAX_DELAY) - (time.time() - last) |
| if wait > 0: |
| time.sleep(wait) |
| _DOMAIN_LAST_HIT[domain] = time.time() |
|
|
| |
|
|
| def fetch(self, url: str, timeout: int = 25, _attempt: int = 0) -> str | None: |
| if self._is_domain_backed_off(url): |
| return None |
| if not self._respect_robots(url): |
| return None |
| self._polite_wait(url) |
| try: |
| resp = self.session.get(url, timeout=timeout, allow_redirects=True) |
| |
| if resp.status_code in (403, 429, 503) and _attempt < 3: |
| wait = (2 ** _attempt) * random.uniform(3, 7) |
| print(f"[scraper] {resp.status_code} on {url[:60]} β waiting {wait:.1f}s, rotating UA") |
| time.sleep(wait) |
| self._set_ua(_attempt + 1) |
| return self.fetch(url, timeout, _attempt + 1) |
| if resp.status_code == 200: |
| self._record_domain_success(url) |
| return resp.text |
| print(f"[scraper] HTTP {resp.status_code} for {url[:60]}") |
| self._record_domain_failure(url) |
| return None |
| except requests.exceptions.Timeout: |
| print(f"[scraper] Timeout: {url[:60]}") |
| self._record_domain_failure(url) |
| return None |
| except Exception as e: |
| print(f"[scraper] Error: {url[:60]} β {e}") |
| self._record_domain_failure(url) |
| return None |
|
|
| def fetch_soup(self, url: str, timeout: int = 25) -> BeautifulSoup | None: |
| html = self.fetch(url, timeout) |
| return BeautifulSoup(html, "html.parser") if html else None |
|
|
| def fetch_json(self, url: str, timeout: int = 20, extra_headers: dict = None) -> dict | list | None: |
| if self._is_domain_backed_off(url): |
| return None |
| self._polite_wait(url) |
| try: |
| headers = {**self.session.headers, "Accept": "application/json"} |
| if extra_headers: |
| headers.update(extra_headers) |
| resp = self.session.get(url, timeout=timeout, headers=headers, allow_redirects=True) |
| resp.raise_for_status() |
| self._record_domain_success(url) |
| return resp.json() |
| except Exception as e: |
| print(f"[scraper] JSON error: {url[:60]} β {e}") |
| self._record_domain_failure(url) |
| return None |
|
|
| def post_json(self, url: str, body: dict, headers: dict = None, timeout: int = 20) -> dict | list | None: |
| """POST JSON β used for Algolia and similar APIs.""" |
| try: |
| h = {"Content-Type": "application/json", "Accept": "application/json"} |
| if headers: |
| h.update(headers) |
| resp = requests.post(url, json=body, headers=h, timeout=timeout) |
| resp.raise_for_status() |
| return resp.json() |
| except Exception as e: |
| print(f"[scraper] POST JSON error: {url[:60]} β {e}") |
| return None |
|
|
| def fetch_rss(self, url: str) -> list[dict]: |
| """Parse RSS/Atom feed β much more stable than HTML scraping.""" |
| html = self.fetch(url) |
| if not html: |
| return [] |
| soup = BeautifulSoup(html, "xml") |
| items = soup.find_all("item") or soup.find_all("entry") |
| out = [] |
| for it in items[:40]: |
| title = self._tag_text(it, ["title"]) |
| link_el = it.find("link") |
| link = "" |
| if link_el: |
| link = link_el.get("href", "") or link_el.get_text(strip=True) or "" |
| desc_el = it.find("description") or it.find("summary") or it.find("content") |
| desc = self._clean_text(desc_el.get_text()[:400]) if desc_el else "" |
| date_el = it.find("pubDate") or it.find("published") or it.find("updated") |
| posted = self._extract_date(date_el) if date_el else "" |
| if title and link: |
| out.append({"title": title, "url": link, "description": desc, "posted_date": posted}) |
| return out |
|
|
| def fetch_json_ld(self, soup: BeautifulSoup) -> list[dict]: |
| """Extract schema.org/JobPosting β very stable, preferred over HTML selectors.""" |
| jobs = [] |
| for script in soup.find_all("script", type="application/ld+json"): |
| try: |
| raw = script.string or script.get_text() |
| if not raw or not raw.strip(): |
| continue |
| data = json.loads(raw) |
| entries = [] |
| if isinstance(data, dict): |
| if data.get("@type") == "JobPosting": |
| entries = [data] |
| elif isinstance(data.get("@graph"), list): |
| entries = [e for e in data["@graph"] if e.get("@type") == "JobPosting"] |
| elif isinstance(data, list): |
| entries = [e for e in data if isinstance(e, dict) and e.get("@type") == "JobPosting"] |
| for e in entries: |
| loc = e.get("jobLocation", {}) or {} |
| if isinstance(loc, list): |
| loc = loc[0] if loc else {} |
| addr = (loc.get("address", {}) or {}) if isinstance(loc, dict) else {} |
| location_parts = [] |
| for key in ("addressLocality", "addressRegion", "addressCountry"): |
| v = addr.get(key, "") if isinstance(addr, dict) else "" |
| if v and isinstance(v, str): |
| location_parts.append(v) |
| location = ", ".join(location_parts) |
| url = (e.get("url", "") or |
| (e.get("identifier", {}) or {}).get("url", "") or |
| e.get("sameAs", "") or "") |
| posted = (e.get("datePosted", "") or "")[:10] |
| title = e.get("title", "") or e.get("name", "") |
| if title and url: |
| company = ((e.get("hiringOrganization") or {}).get("name", "")) |
| if not company: |
| for sep in [" at ", " β ", " β ", " - ", " / ", " | ", " @ "]: |
| parts = title.split(sep) |
| if len(parts) >= 2: |
| company = parts[-1].strip() |
| title = sep.join(parts[:-1]).strip() |
| break |
| desc_raw = e.get("description", "") or "" |
| |
| desc_soup = BeautifulSoup(desc_raw[:1000], "html.parser") |
| desc = self._clean_text(desc_soup.get_text())[:600] |
| jobs.append({ |
| "title": title, |
| "company": company, |
| "location": location, |
| "description": desc, |
| "url": url, |
| "posted_date": posted, |
| }) |
| except Exception: |
| pass |
| return jobs |
|
|
| |
|
|
| def is_within_48h(self, date_text: str) -> bool: |
| return is_within_window(date_text) |
|
|
| def filter_fresh(self, jobs: list[dict]) -> list[dict]: |
| return filter_fresh(jobs) |
|
|
| |
|
|
| def multi_select(self, parent, selectors: list[str]) -> list: |
| for sel in selectors: |
| try: |
| found = parent.select(sel) |
| if found: |
| return found |
| except Exception: |
| pass |
| return [] |
|
|
| def first_text(self, el, selectors: list[str], default: str = "") -> str: |
| for sel in selectors: |
| try: |
| found = el.select_one(sel) |
| if found: |
| text = self._clean_text(found.get_text()) |
| if text: |
| return text |
| except Exception: |
| pass |
| return default |
|
|
| def first_href(self, el, selectors: list[str], base_url: str = "") -> str: |
| for sel in selectors: |
| try: |
| found = el.select_one(sel) |
| if found: |
| href = found.get("href", "") |
| if href: |
| return urljoin(base_url, href) if not href.startswith("http") else href |
| except Exception: |
| pass |
| return "" |
|
|
| |
|
|
| def _extract_from_text(self, soup: BeautifulSoup, source: str, base_url: str, cat: str, limit: int = 25) -> list[dict]: |
| JOB_KEYWORDS = [ |
| "analyst", "officer", "manager", "engineer", "developer", "coordinator", |
| "specialist", "associate", "consultant", "director", "intern", "trainee", |
| "researcher", "advisor", "supervisor", "lead", "head", "assistant", |
| "analytics", "scientist", "architect", "analyst/programmer", "professor", |
| ] |
| SKIP_TITLES = ["job openings", "job vacancies", "job recruitment", |
| "subscribe", "newsletter", "affiliate", "success stories", |
| "reviews", "goody bag", "sign up", "create account", |
| "forgot password", "privacy policy", "terms of service", |
| "sitemap", "contact us", "about us", "career tips", |
| "interview tips", "cv writing", "resume writing"] |
| jobs, seen = [], set() |
| for a in soup.find_all("a", href=True)[:250]: |
| text = self._clean_text(a.get_text()) |
| if not text or len(text) < 5 or len(text) > 120: |
| continue |
| text_lower = text.lower() |
| if not any(kw in text_lower for kw in JOB_KEYWORDS): |
| continue |
| if any(sk in text_lower for sk in SKIP_TITLES): |
| continue |
| href = a.get("href", "") |
| full_url = urljoin(base_url, href) if not href.startswith("http") else href |
| fp = _url_fingerprint(full_url) |
| if fp in seen: |
| continue |
| seen.add(fp) |
|
|
| |
| company = "" |
| location = "" |
| desc = "" |
| parent = a.parent |
| for _ in range(5): |
| if not parent or parent.name in ("html", "body", "div", "section"): |
| break |
| parent = parent.parent |
| if parent: |
| parent_text = parent.get_text(" ", strip=True) |
| |
| for sep in [" at ", " β ", " β ", " - ", " | ", " @ "]: |
| if sep in text and text.index(sep) < len(text) * 0.7: |
| parts = text.split(sep, 1) |
| candidate = parts[-1].strip() |
| if candidate and len(candidate) < 60: |
| company = candidate |
| text = parts[0].strip() |
| break |
| |
| if not company: |
| for sel in [".company", "[class*='company']", "[class*='employer']", |
| "[class*='org']", ".organization", "span[class*='name']", |
| ".job-meta", ".meta", "small", ".text-muted"]: |
| el = parent.select_one(sel) |
| if el: |
| ct = self._clean_text(el.get_text()) |
| if ct and len(ct) < 60 and ct not in text: |
| company = ct |
| break |
| |
| for sel in ["[class*='location']", "[class*='place']", "[class*='city']", |
| "[class*='state']", "[class*='country']", ".job-location"]: |
| el = parent.select_one(sel) |
| if el: |
| lt = self._clean_text(el.get_text()) |
| if lt and len(lt) < 60: |
| location = lt |
| break |
| |
| posted = "" |
| for sel in ["time", "[class*='date']", "[class*='posted']", |
| "[class*='ago']", "[class*='published']", "[datetime]"]: |
| el = parent.select_one(sel) |
| if el: |
| dt = el.get("datetime", "") or self._clean_text(el.get_text()) |
| if dt: |
| posted = dt[:20] |
| break |
| |
| for sel in ["p", "[class*='desc']", "[class*='summary']", |
| "[class*='excerpt']", ".text", ".content"]: |
| el = parent.select_one(sel) |
| if el and el != a: |
| dt = self._clean_text(el.get_text()) |
| if dt and len(dt) > 20 and dt != text: |
| desc = dt[:400] |
| break |
|
|
| jobs.append(self._make_job(text, company, location, desc, full_url, source, cat, posted)) |
| if len(jobs) >= limit: |
| break |
| if jobs: |
| print(f" [heuristic] {source}: extracted {len(jobs)} jobs via text fallback") |
| return jobs |
|
|
| |
|
|
| def _tag_text(self, el, tags: list[str]) -> str: |
| for tag in tags: |
| found = el.find(tag) |
| if found: |
| return self._clean_text(found.get_text()) |
| return "" |
|
|
| def _clean_text(self, text: str) -> str: |
| if not text: |
| return "" |
| |
| text = re.sub(r"\s+", " ", str(text)).strip() |
| |
| for noise in ["View job", "Apply now", "Apply Now", "Read more", "See more"]: |
| text = text.replace(noise, "").strip() |
| return text |
|
|
| def _extract_date(self, element) -> str: |
| if not element: |
| return "" |
| raw = self._clean_text(element.get("datetime", "") or element.get_text()) |
| patterns = [ |
| r"(\d{4}-\d{2}-\d{2})", |
| r"(\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4})", |
| r"(\d{1,2}/\d{1,2}/\d{4})", |
| ] |
| for p in patterns: |
| m = re.search(p, raw, re.IGNORECASE) |
| if m: |
| return m.group(1) |
| lower = raw.lower() |
| if any(w in lower for w in ["today", "just now", "moment", "1 hour", "hour"]): |
| return "Today" |
| if "yesterday" in lower: |
| return "Yesterday" |
| return raw[:25] |
|
|
| def _make_job(self, title, company, location, description, url, |
| source, category, posted="") -> dict: |
| company = self._clean_text(company) |
| title = self._clean_text(title) |
| |
| if not company and title: |
| for sep in [" at ", " β ", " β ", " - ", " / ", " | ", " @ "]: |
| parts = title.split(sep) |
| if len(parts) >= 2: |
| company = parts[-1].strip() |
| title = sep.join(parts[:-1]).strip() |
| break |
| return { |
| "title": title, |
| "company": company, |
| "location": self._clean_text(location), |
| "description": self._clean_text(description)[:600], |
| "url": url.strip() if url else "", |
| "source": source, |
| "category": category, |
| "posted_date": posted or "", |
| } |
|
|