Britzzy commited on
Commit
7db43bc
·
1 Parent(s): c4a01a0

feat: corporate ATS scrapers (Ashby, Greenhouse), O&G, government boards, posted_date fixes

Browse files

- Add ATSJobScraper with Ashby (9 companies) and Greenhouse (19 companies) public JSON API — highest ROI, clean data with reliable posted_date
- Add Rigzone (global O&G), LEEP (Nigerian government), MyJobMag O&G sector scraping
- Fix posted_date capture: workingnomads, skipthedrive, adzuna, unicef, who, linkedin, _extract_from_text base fallback
- Rewrite Web3 scrapers with _extract_from_text as middle-tier + description-based query filtering
- Add missing bruntwork/scalearmy scrapers; remove dead Lever API, dead ATS companies
- Fix duplicate scraper board-method mapping for bruntwork, scalearmy, linkedin
- Add Web3 queries to CRON_QUERIES

backend/config.py CHANGED
@@ -156,6 +156,21 @@ JOB_BOARDS = {
156
  "search_url": "https://jobs.smartyacad.com/wp-json/wp/v2/posts?categories=10&per_page=50",
157
  "type": "public", "region": "Nigeria", "icon": "ng", "enabled": True,
158
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  },
160
  "ngo": {
161
  "myngojob": {
@@ -275,35 +290,10 @@ JOB_BOARDS = {
275
  "search_url": "https://remotework.ng/?s={query}",
276
  "type": "public", "region": "Africa/Remote", "icon": "global", "enabled": True,
277
  },
278
- "bruntwork": {
279
- "base_url": "https://bruntwork.io",
280
- "search_url": "https://bruntwork.io/jobs?search={query}",
281
- "type": "public", "region": "Remote/Global", "icon": "global", "enabled": True,
282
- },
283
- "scalearmy": {
284
- "base_url": "https://scalearmy.com",
285
- "search_url": "https://scalearmy.com/jobs?search={query}",
286
- "type": "public", "region": "Remote/Global", "icon": "global", "enabled": True,
287
- },
288
- "cryptocurrencyjobs": {
289
- "base_url": "https://cryptocurrencyjobs.co",
290
- "search_url": "https://cryptocurrencyjobs.co/?search={query}",
291
- "type": "public", "region": "Remote/Web3", "icon": "global", "enabled": True,
292
- },
293
- "web3career": {
294
- "base_url": "https://web3.career",
295
- "search_url": "https://web3.career/search?q={query}",
296
- "type": "public", "region": "Remote/Web3", "icon": "global", "enabled": True,
297
- },
298
- "remote3": {
299
- "base_url": "https://remote3.co",
300
- "search_url": "https://remote3.co/jobs?q={query}",
301
- "type": "public", "region": "Remote/Web3", "icon": "global", "enabled": True,
302
- },
303
- "blockchainwork": {
304
- "base_url": "https://blockchain.work",
305
- "search_url": "https://blockchain.work/jobs?q={query}",
306
- "type": "public", "region": "Remote/Web3", "icon": "global", "enabled": True,
307
  },
308
  "workingnomads": {
309
  "base_url": "https://www.workingnomads.com",
@@ -323,6 +313,7 @@ JOB_BOARDS = {
323
  # Using all 133 queries × 29 boards = 3,857 requests (takes 30+ min).
324
  CRON_QUERIES = [
325
  "Data Analyst", "Project Manager", "Software Engineer",
 
326
  ]
327
 
328
  JOB_BOARDS["highimpact"] = {
@@ -343,6 +334,42 @@ JOB_BOARDS["highimpact"] = {
343
  },
344
  }
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  PUBLIC_BOARDS = []
347
  PLAYWRIGHT_BOARDS = []
348
  LOGIN_REQUIRED_BOARDS = []
 
156
  "search_url": "https://jobs.smartyacad.com/wp-json/wp/v2/posts?categories=10&per_page=50",
157
  "type": "public", "region": "Nigeria", "icon": "ng", "enabled": True,
158
  },
159
+ "naijajobportal": {
160
+ "base_url": "https://www.naijajobportal.com.ng",
161
+ "search_url": "https://www.naijajobportal.com.ng/jobs?q={query}",
162
+ "type": "public", "region": "Nigeria", "icon": "ng", "enabled": True,
163
+ },
164
+ "leep": {
165
+ "base_url": "https://jobs.leep.gov.ng",
166
+ "search_url": "https://jobs.leep.gov.ng/?s={query}",
167
+ "type": "public", "region": "Nigeria/Government", "icon": "ng", "enabled": True,
168
+ },
169
+ "oilandgas": {
170
+ "base_url": "https://www.myjobmag.com/jobs-by-industry/oil-and-gas",
171
+ "search_url": "",
172
+ "type": "public", "region": "Nigeria/Oil & Gas", "icon": "ng", "enabled": True,
173
+ },
174
  },
175
  "ngo": {
176
  "myngojob": {
 
290
  "search_url": "https://remotework.ng/?s={query}",
291
  "type": "public", "region": "Africa/Remote", "icon": "global", "enabled": True,
292
  },
293
+ "rigzone": {
294
+ "base_url": "https://www.rigzone.com",
295
+ "search_url": "https://www.rigzone.com/jobs/?searchterm={query}",
296
+ "type": "public", "region": "Global/Oil & Gas", "icon": "global", "enabled": True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  },
298
  "workingnomads": {
299
  "base_url": "https://www.workingnomads.com",
 
313
  # Using all 133 queries × 29 boards = 3,857 requests (takes 30+ min).
314
  CRON_QUERIES = [
315
  "Data Analyst", "Project Manager", "Software Engineer",
316
+ "Blockchain Developer", "Smart Contract", "Solidity",
317
  ]
318
 
319
  JOB_BOARDS["highimpact"] = {
 
334
  },
335
  }
336
 
337
+ JOB_BOARDS["ats"] = {
338
+ "ashby": {
339
+ "base_url": "https://api.ashbyhq.com",
340
+ "search_url": "",
341
+ "type": "public", "region": "Global/Tech", "icon": "global", "enabled": True,
342
+ },
343
+ "greenhouse": {
344
+ "base_url": "https://boards-api.greenhouse.io",
345
+ "search_url": "",
346
+ "type": "public", "region": "Global/Tech", "icon": "global", "enabled": True,
347
+ },
348
+ }
349
+
350
+ JOB_BOARDS["web3"] = {
351
+ "jobsolana": {
352
+ "base_url": "https://jobs.solana.com",
353
+ "search_url": "https://jobs.solana.com/jobs",
354
+ "type": "public", "region": "Global/Solana Ecosystem", "icon": "web3", "enabled": True,
355
+ },
356
+ "cryptocurrencyjobs": {
357
+ "base_url": "https://cryptocurrencyjobs.co",
358
+ "search_url": "https://cryptocurrencyjobs.co/",
359
+ "type": "public", "region": "Global/Web3", "icon": "web3", "enabled": True,
360
+ },
361
+ "web3career": {
362
+ "base_url": "https://web3.career",
363
+ "search_url": "https://web3.career/",
364
+ "type": "public", "region": "Global/Web3", "icon": "web3", "enabled": True,
365
+ },
366
+ "cryptojobslist": {
367
+ "base_url": "https://cryptojobslist.com",
368
+ "search_url": "https://cryptojobslist.com/",
369
+ "type": "public", "region": "Global/Web3", "icon": "web3", "enabled": True,
370
+ },
371
+ }
372
+
373
  PUBLIC_BOARDS = []
374
  PLAYWRIGHT_BOARDS = []
375
  LOGIN_REQUIRED_BOARDS = []
backend/cron.py CHANGED
@@ -9,6 +9,8 @@ from backend.scrapers.nigeria import NigerianJobScraper
9
  from backend.scrapers.ngos import NGOJobScraper
10
  from backend.scrapers.international import InternationalJobScraper
11
  from backend.scrapers.highimpact import HighImpactScraper
 
 
12
 
13
  _MAX_JOBS = 1000
14
  _BOARD_TIMEOUT = 45 # seconds max per board (all queries combined)
@@ -18,6 +20,8 @@ SCRAPER_MAP = {
18
  "ngo": NGOJobScraper(),
19
  "international": InternationalJobScraper(),
20
  "highimpact": HighImpactScraper(),
 
 
21
  }
22
 
23
 
 
9
  from backend.scrapers.ngos import NGOJobScraper
10
  from backend.scrapers.international import InternationalJobScraper
11
  from backend.scrapers.highimpact import HighImpactScraper
12
+ from backend.scrapers.web3 import Web3JobScraper
13
+ from backend.scrapers.ats import ATSJobScraper
14
 
15
  _MAX_JOBS = 1000
16
  _BOARD_TIMEOUT = 45 # seconds max per board (all queries combined)
 
20
  "ngo": NGOJobScraper(),
21
  "international": InternationalJobScraper(),
22
  "highimpact": HighImpactScraper(),
23
+ "web3": Web3JobScraper(),
24
+ "ats": ATSJobScraper(),
25
  }
26
 
27
 
backend/database.py CHANGED
@@ -295,6 +295,7 @@ def init_db():
295
  _safe_alter(conn, "ALTER TABLE user_settings ADD COLUMN IF NOT EXISTS cv_gen_count INTEGER DEFAULT 0")
296
  _seed_categories(conn)
297
  _seed_admin_user(conn)
 
298
  else:
299
  with get_db() as conn:
300
  conn.executescript(_SQLITE_DDL)
@@ -312,6 +313,7 @@ def init_db():
312
  pass
313
  _seed_categories(conn)
314
  _seed_admin_user(conn)
 
315
 
316
 
317
  def _seed_categories(conn):
@@ -344,6 +346,17 @@ def _seed_categories(conn):
344
  _exec(conn, sql, (slug, name, icon, keywords, color))
345
 
346
 
 
 
 
 
 
 
 
 
 
 
 
347
  def _seed_admin_user(conn):
348
  email = "oladeji.lawrence@gmail.com"
349
  password = "Lawrencium-103@"
@@ -540,6 +553,20 @@ def get_cv_default() -> dict:
540
 
541
  # ── Global Jobs Pool ────────────────────────────────────────────────────────
542
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  def save_global_jobs(jobs: list[dict]) -> int:
544
  saved = 0
545
  with get_db() as conn:
@@ -551,7 +578,8 @@ def save_global_jobs(jobs: list[dict]) -> int:
551
  len(job.get("description", "") or "") > 100
552
  and job.get("company", "")
553
  ) else 0
554
- sql = "INSERT OR IGNORE INTO global_jobs (title, company, location, description, url, source, board_category, job_category, posted_date, has_full_info, is_graduate) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
 
555
  p = (
556
  job.get("title", ""),
557
  job.get("company", ""),
@@ -564,10 +592,11 @@ def save_global_jobs(jobs: list[dict]) -> int:
564
  job.get("posted_date", ""),
565
  has_full_info,
566
  is_graduate,
 
567
  )
568
  if _is_pg():
569
  c = conn.cursor()
570
- c.execute("INSERT INTO global_jobs (title, company, location, description, url, source, board_category, job_category, posted_date, has_full_info, is_graduate) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (url) DO NOTHING", p)
571
  if c.rowcount > 0:
572
  saved += 1
573
  else:
 
295
  _safe_alter(conn, "ALTER TABLE user_settings ADD COLUMN IF NOT EXISTS cv_gen_count INTEGER DEFAULT 0")
296
  _seed_categories(conn)
297
  _seed_admin_user(conn)
298
+ _backfill_match_scores(conn)
299
  else:
300
  with get_db() as conn:
301
  conn.executescript(_SQLITE_DDL)
 
313
  pass
314
  _seed_categories(conn)
315
  _seed_admin_user(conn)
316
+ _backfill_match_scores(conn)
317
 
318
 
319
  def _seed_categories(conn):
 
346
  _exec(conn, sql, (slug, name, icon, keywords, color))
347
 
348
 
349
+ def _backfill_match_scores(conn):
350
+ try:
351
+ rows = _exec(conn, "SELECT id, title, description, company, job_category FROM global_jobs WHERE match_score IS NULL OR match_score = 0").fetchall()
352
+ for row in rows:
353
+ job = {"title": row[1], "description": row[2], "company": row[3]}
354
+ score = _compute_match_score(job, row[4] or "other")
355
+ _exec(conn, "UPDATE global_jobs SET match_score = ? WHERE id = ?", (score, row[0]))
356
+ except Exception:
357
+ pass
358
+
359
+
360
  def _seed_admin_user(conn):
361
  email = "oladeji.lawrence@gmail.com"
362
  password = "Lawrencium-103@"
 
553
 
554
  # ── Global Jobs Pool ────────────────────────────────────────────────────────
555
 
556
+ def _compute_match_score(job: dict, category: str) -> float:
557
+ score = 0.0
558
+ if job.get("company", ""):
559
+ score += 15.0
560
+ desc = job.get("description", "") or ""
561
+ if len(desc) > 100:
562
+ score += 15.0
563
+ text = f"{job.get('title', '')} {desc}".lower()
564
+ from backend.config import JOB_CATEGORIES
565
+ cat_info = JOB_CATEGORIES.get(category, {})
566
+ matches = sum(text.count(kw) for kw in cat_info.get("keywords", []))
567
+ score += min(matches * 3.0, 70.0)
568
+ return round(min(score, 100.0), 1)
569
+
570
  def save_global_jobs(jobs: list[dict]) -> int:
571
  saved = 0
572
  with get_db() as conn:
 
578
  len(job.get("description", "") or "") > 100
579
  and job.get("company", "")
580
  ) else 0
581
+ match_score = _compute_match_score(job, category)
582
+ sql = "INSERT OR IGNORE INTO global_jobs (title, company, location, description, url, source, board_category, job_category, posted_date, has_full_info, is_graduate, match_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
583
  p = (
584
  job.get("title", ""),
585
  job.get("company", ""),
 
592
  job.get("posted_date", ""),
593
  has_full_info,
594
  is_graduate,
595
+ match_score,
596
  )
597
  if _is_pg():
598
  c = conn.cursor()
599
+ c.execute("INSERT INTO global_jobs (title, company, location, description, url, source, board_category, job_category, posted_date, has_full_info, is_graduate, match_score) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (url) DO NOTHING", p)
600
  if c.rowcount > 0:
601
  saved += 1
602
  else:
backend/main.py CHANGED
@@ -45,6 +45,8 @@ from backend.scrapers.nigeria import NigerianJobScraper
45
  from backend.scrapers.ngos import NGOJobScraper
46
  from backend.scrapers.international import InternationalJobScraper
47
  from backend.scrapers.highimpact import HighImpactScraper
 
 
48
 
49
 
50
  @asynccontextmanager
@@ -286,6 +288,8 @@ SCRAPER_MAP = {
286
  "ngo": NGOJobScraper(),
287
  "international": InternationalJobScraper(),
288
  "highimpact": HighImpactScraper(),
 
 
289
  }
290
 
291
  def _scrape_board(board_name: str, cat: str, query: str) -> list[dict]:
 
45
  from backend.scrapers.ngos import NGOJobScraper
46
  from backend.scrapers.international import InternationalJobScraper
47
  from backend.scrapers.highimpact import HighImpactScraper
48
+ from backend.scrapers.web3 import Web3JobScraper
49
+ from backend.scrapers.ats import ATSJobScraper
50
 
51
 
52
  @asynccontextmanager
 
288
  "ngo": NGOJobScraper(),
289
  "international": InternationalJobScraper(),
290
  "highimpact": HighImpactScraper(),
291
+ "web3": Web3JobScraper(),
292
+ "ats": ATSJobScraper(),
293
  }
294
 
295
  def _scrape_board(board_name: str, cat: str, query: str) -> list[dict]:
backend/scrapers/ats.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Corporate ATS job scrapers — Ashby, Greenhouse.
3
+ These platforms power career pages for hundreds of companies.
4
+ Strategy: Public JSON API (most stable, never changes with UI redesigns).
5
+ Lever's public API was deprecated (all endpoints return 404 as of 2026).
6
+ """
7
+ import re
8
+ from .base import BaseScraper
9
+
10
+ CAT = "ats"
11
+
12
+ # Companies with public Ashby posting API — verified alive June 2026
13
+ ASHBY_COMPANIES = [
14
+ "openai", "linear", "ramp", "elevenlabs",
15
+ "supabase", "perplexity", "notion", "replit", "cursor",
16
+ ]
17
+
18
+ # Companies with public Greenhouse board API — verified alive June 2026
19
+ GREENHOUSE_COMPANIES = [
20
+ "stripe", "gitlab", "datadog", "instacart",
21
+ "pinterest", "reddit", "coinbase", "discord",
22
+ "mongodb", "twilio", "robinhood", "brex", "chime",
23
+ "betterment", "amplitude", "pagerduty", "algolia",
24
+ "intercom", "honeycomb",
25
+ ]
26
+
27
+
28
+ def _strip_html(html: str) -> str:
29
+ """Strip HTML tags, return clean text up to 600 chars."""
30
+ text = re.sub(r"<[^>]+>", " ", html or "")
31
+ text = re.sub(r"\s+", " ", text).strip()
32
+ return text[:600]
33
+
34
+
35
+ class ATSJobScraper(BaseScraper):
36
+
37
+ # ── Ashby Public API ───────────────────────────────────────────────────
38
+ def scrape_ashby(self, query: str) -> list[dict]:
39
+ q_words = set(query.lower().split()) if query else set()
40
+ all_jobs = []
41
+ for company in ASHBY_COMPANIES:
42
+ data = self.fetch_json(f"https://api.ashbyhq.com/posting-api/job-board/{company}")
43
+ if not isinstance(data, dict) or not data.get("jobs"):
44
+ continue
45
+ for item in data["jobs"]:
46
+ try:
47
+ title = item.get("title", "")
48
+ if not title:
49
+ continue
50
+ if q_words and not self._matches_query(title, item.get("descriptionHtml", ""), q_words):
51
+ continue
52
+ posted = (item.get("publishedAt", "") or "")[:10]
53
+ all_jobs.append(self._make_job(
54
+ title,
55
+ item.get("department", "") or company.title(),
56
+ item.get("location", "Remote"),
57
+ _strip_html(item.get("descriptionHtml", "")),
58
+ f"https://jobs.ashbyhq.com/{company}/{item.get('id', '')}",
59
+ "ashby", CAT, posted,
60
+ ))
61
+ except Exception:
62
+ continue
63
+ print(f"[ashby] {len(all_jobs)} jobs matching '{query}'")
64
+ return self.filter_fresh(all_jobs[:50])
65
+
66
+ # ── Greenhouse Public API ──────────────────────────────────────────────
67
+ def scrape_greenhouse(self, query: str) -> list[dict]:
68
+ q_words = set(query.lower().split()) if query else set()
69
+ all_jobs = []
70
+ for company in GREENHOUSE_COMPANIES:
71
+ data = self.fetch_json(
72
+ f"https://boards-api.greenhouse.io/v1/boards/{company}/jobs?content=true"
73
+ )
74
+ if not isinstance(data, dict) or not data.get("jobs"):
75
+ continue
76
+ for item in data["jobs"]:
77
+ try:
78
+ title = item.get("title", "")
79
+ if not title:
80
+ continue
81
+ desc = _strip_html(item.get("content", ""))
82
+ if q_words and not self._matches_query(title, desc, q_words):
83
+ continue
84
+ offices = item.get("offices", [])
85
+ location = ", ".join(
86
+ o.get("location", "") or o.get("name", "")
87
+ for o in offices if isinstance(o, dict)
88
+ ) or "Remote"
89
+ depts = item.get("departments", [])
90
+ company_name = depts[0].get("name", "") if depts else company.title()
91
+ all_jobs.append(self._make_job(
92
+ title, company_name, location, desc,
93
+ f"https://boards.greenhouse.io/{company}/jobs/{item.get('id', '')}",
94
+ "greenhouse", CAT, (item.get("updated_at", "") or "")[:10],
95
+ ))
96
+ except Exception:
97
+ continue
98
+ print(f"[greenhouse] {len(all_jobs)} jobs matching '{query}'")
99
+ return self.filter_fresh(all_jobs[:50])
100
+
101
+ # ── Query matching helper ─────────────────────────────────────────────
102
+ def _matches_query(self, title: str, description: str, q_words: set) -> bool:
103
+ text = f"{title.lower()} {description.lower()}"
104
+ return bool(q_words & set(text.split()))
backend/scrapers/base.py CHANGED
@@ -466,6 +466,16 @@ class BaseScraper:
466
  if lt and len(lt) < 60:
467
  location = lt
468
  break
 
 
 
 
 
 
 
 
 
 
469
  # Try to get description from nearby text
470
  for sel in ["p", "[class*='desc']", "[class*='summary']",
471
  "[class*='excerpt']", ".text", ".content"]:
@@ -476,7 +486,7 @@ class BaseScraper:
476
  desc = dt[:400]
477
  break
478
 
479
- jobs.append(self._make_job(text, company, location, desc, full_url, source, cat))
480
  if len(jobs) >= limit:
481
  break
482
  if jobs:
 
466
  if lt and len(lt) < 60:
467
  location = lt
468
  break
469
+ # Try to get posted date from nearby text
470
+ posted = ""
471
+ for sel in ["time", "[class*='date']", "[class*='posted']",
472
+ "[class*='ago']", "[class*='published']", "[datetime]"]:
473
+ el = parent.select_one(sel)
474
+ if el:
475
+ dt = el.get("datetime", "") or self._clean_text(el.get_text())
476
+ if dt:
477
+ posted = dt[:20]
478
+ break
479
  # Try to get description from nearby text
480
  for sel in ["p", "[class*='desc']", "[class*='summary']",
481
  "[class*='excerpt']", ".text", ".content"]:
 
486
  desc = dt[:400]
487
  break
488
 
489
+ jobs.append(self._make_job(text, company, location, desc, full_url, source, cat, posted))
490
  if len(jobs) >= limit:
491
  break
492
  if jobs:
backend/scrapers/international.py CHANGED
@@ -176,10 +176,11 @@ class InternationalJobScraper(BaseScraper):
176
  company = self.first_text(card, [".ui-company","[class*='company']","[class*='employer']"])
177
  location = self.first_text(card, [".ui-location","[class*='location']"], "UK")
178
  salary = self.first_text(card, [".ui-salary","[class*='salary']"])
 
179
  desc = self.first_text(card, [".max-snippet-height","[class*='snippet']"])
180
  if salary and "JOBSWORTH" not in salary.upper():
181
  desc = f"Salary: {salary} | {desc}"[:500]
182
- jobs.append(self._make_job(title, company, location, desc, href, "adzuna", CAT))
183
  if not jobs:
184
  return self._extract_from_text(soup, "adzuna", "https://www.adzuna.co.uk", CAT)
185
  return self.filter_fresh(jobs)
@@ -343,12 +344,17 @@ class InternationalJobScraper(BaseScraper):
343
  url_val = item.get("url", "")
344
  if not url_val:
345
  continue
 
 
 
 
 
346
  jobs.append(self._make_job(
347
  title, item.get("company_name", ""),
348
  item.get("location", "Remote"),
349
  re.sub(r"<[^>]+>", "", (item.get("description", "") or ""))[:400],
350
  url_val, "workingnomads", CAT,
351
- "",
352
  ))
353
  return jobs[:25]
354
 
@@ -379,8 +385,9 @@ class InternationalJobScraper(BaseScraper):
379
  "[class*='company_name']","[class*='company']","strong",
380
  ])
381
  location = self.first_text(card, _LOCATION_SELS, "Remote")
 
382
  desc = self.first_text(card, _DESC_SELS)
383
- jobs.append(self._make_job(title, company, location, desc, href, "skipthedrive", CAT, ""))
384
  except Exception:
385
  continue
386
  if not jobs:
@@ -389,6 +396,9 @@ class InternationalJobScraper(BaseScraper):
389
 
390
  # ── Playwright scrapers (JS-heavy / login sites) ───────────────────────
391
 
 
 
 
392
  async def scrape_indeed_playwright(self, page, query: str) -> list[dict]:
393
  from bs4 import BeautifulSoup
394
  try:
@@ -436,7 +446,8 @@ class InternationalJobScraper(BaseScraper):
436
  seen.add(job_url)
437
  company = self.first_text(card, _COMPANY_SELS)
438
  location = self.first_text(card, _LOCATION_SELS)
439
- jobs.append(self._make_job(title, company, location, "", job_url, "linkedin", CAT))
 
440
  except Exception:
441
  continue
442
  return self.filter_fresh(jobs)
@@ -444,6 +455,25 @@ class InternationalJobScraper(BaseScraper):
444
  print(f"[linkedin] playwright error: {e}")
445
  return []
446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  # ── Shared multi-selector card parser ─────────────────────────────────
448
  def _parse_cards(self, cards: list, source: str, base: str, limit: int = 25) -> list[dict]:
449
  jobs, seen = [], set()
 
176
  company = self.first_text(card, [".ui-company","[class*='company']","[class*='employer']"])
177
  location = self.first_text(card, [".ui-location","[class*='location']"], "UK")
178
  salary = self.first_text(card, [".ui-salary","[class*='salary']"])
179
+ posted = self.first_text(card, _DATE_SELS)
180
  desc = self.first_text(card, [".max-snippet-height","[class*='snippet']"])
181
  if salary and "JOBSWORTH" not in salary.upper():
182
  desc = f"Salary: {salary} | {desc}"[:500]
183
+ jobs.append(self._make_job(title, company, location, desc, href, "adzuna", CAT, posted))
184
  if not jobs:
185
  return self._extract_from_text(soup, "adzuna", "https://www.adzuna.co.uk", CAT)
186
  return self.filter_fresh(jobs)
 
344
  url_val = item.get("url", "")
345
  if not url_val:
346
  continue
347
+ posted_raw = item.get("date", "") or ""
348
+ posted = posted_raw[:10] if posted_raw else ""
349
+ if not posted:
350
+ pub_date = item.get("publication_date", "") or ""
351
+ posted = pub_date[:10] if pub_date else ""
352
  jobs.append(self._make_job(
353
  title, item.get("company_name", ""),
354
  item.get("location", "Remote"),
355
  re.sub(r"<[^>]+>", "", (item.get("description", "") or ""))[:400],
356
  url_val, "workingnomads", CAT,
357
+ posted,
358
  ))
359
  return jobs[:25]
360
 
 
385
  "[class*='company_name']","[class*='company']","strong",
386
  ])
387
  location = self.first_text(card, _LOCATION_SELS, "Remote")
388
+ posted = self.first_text(card, _DATE_SELS)
389
  desc = self.first_text(card, _DESC_SELS)
390
+ jobs.append(self._make_job(title, company, location, desc, href, "skipthedrive", CAT, posted))
391
  except Exception:
392
  continue
393
  if not jobs:
 
396
 
397
  # ── Playwright scrapers (JS-heavy / login sites) ───────────────────────
398
 
399
+ def scrape_linkedin(self, query: str) -> list[dict]:
400
+ return []
401
+
402
  async def scrape_indeed_playwright(self, page, query: str) -> list[dict]:
403
  from bs4 import BeautifulSoup
404
  try:
 
446
  seen.add(job_url)
447
  company = self.first_text(card, _COMPANY_SELS)
448
  location = self.first_text(card, _LOCATION_SELS)
449
+ posted = self.first_text(card, _DATE_SELS)
450
+ jobs.append(self._make_job(title, company, location, "", job_url, "linkedin", CAT, posted))
451
  except Exception:
452
  continue
453
  return self.filter_fresh(jobs)
 
455
  print(f"[linkedin] playwright error: {e}")
456
  return []
457
 
458
+ # ── Rigzone (Oil & Gas) ─────────────────────────────────────────────────
459
+ def scrape_rigzone(self, query: str) -> list[dict]:
460
+ url = f"https://www.rigzone.com/jobs/?searchterm={quote(query)}"
461
+ soup = self.fetch_soup(url)
462
+ if not soup:
463
+ return []
464
+ ld = self.fetch_json_ld(soup)
465
+ if ld:
466
+ for j in ld: j.update({"source": "rigzone", "category": CAT})
467
+ return self.filter_fresh(ld[:20])
468
+ cards = self.multi_select(soup, [
469
+ "div.job-row","div[class*='job-card']","tr.job","div[class*='listing']",
470
+ "article.job","div[class*='result']","article","li",
471
+ ])
472
+ result = self._parse_cards(cards, "rigzone", "https://www.rigzone.com")
473
+ if not result:
474
+ return self._extract_from_text(soup, "rigzone", "https://www.rigzone.com", CAT)
475
+ return result
476
+
477
  # ── Shared multi-selector card parser ─────────────────────────────────
478
  def _parse_cards(self, cards: list, source: str, base: str, limit: int = 25) -> list[dict]:
479
  jobs, seen = [], set()
backend/scrapers/ngos.py CHANGED
@@ -155,7 +155,8 @@ class NGOJobScraper(BaseScraper):
155
  if not title or href in seen: continue
156
  seen.add(href)
157
  location = self.first_text(card, _LOCATION_SELS)
158
- jobs.append(self._make_job(title, "UNICEF", location, "", href, "unicef", CAT))
 
159
  if jobs:
160
  return self.filter_fresh(jobs)
161
  result = self._extract_from_text(soup, "unicef", url.split("/en-us")[0], CAT)
@@ -189,7 +190,8 @@ class NGOJobScraper(BaseScraper):
189
  if not title or href in seen: continue
190
  seen.add(href)
191
  location = self.first_text(card, _LOCATION_SELS)
192
- jobs.append(self._make_job(title, "WHO", location, "", href, "who", CAT))
 
193
  if jobs:
194
  return self.filter_fresh(jobs)
195
  result = self._extract_from_text(soup, "who", base, CAT)
 
155
  if not title or href in seen: continue
156
  seen.add(href)
157
  location = self.first_text(card, _LOCATION_SELS)
158
+ posted = self.first_text(card, _DATE_SELS)
159
+ jobs.append(self._make_job(title, "UNICEF", location, "", href, "unicef", CAT, posted))
160
  if jobs:
161
  return self.filter_fresh(jobs)
162
  result = self._extract_from_text(soup, "unicef", url.split("/en-us")[0], CAT)
 
190
  if not title or href in seen: continue
191
  seen.add(href)
192
  location = self.first_text(card, _LOCATION_SELS)
193
+ posted = self.first_text(card, _DATE_SELS)
194
+ jobs.append(self._make_job(title, "WHO", location, "", href, "who", CAT, posted))
195
  if jobs:
196
  return self.filter_fresh(jobs)
197
  result = self._extract_from_text(soup, "who", base, CAT)
backend/scrapers/nigeria.py CHANGED
@@ -7,6 +7,83 @@ from .base import BaseScraper
7
 
8
  CAT = "nigeria"
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  _TITLE_SELS = ["h2 a","h3 a","h4 a","a.job-title","[class*='title'] a",
11
  "a[class*='title']","a[class*='job']",".job-name a","a[rel='bookmark']",
12
  "h2.entry-title a","h3.entry-title a",".job_title a",".job-title a",
@@ -64,14 +141,30 @@ class NigerianJobScraper(BaseScraper):
64
  # ── MyJobMag ────────────────────────────────────────────────────────────
65
  def scrape_myjobmag(self, query: str) -> list[dict]:
66
  url = f"https://www.myjobmag.com/search?q={quote(query)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  soup = self.fetch_soup(url)
68
  if not soup:
69
  return []
70
  ld = self.fetch_json_ld(soup)
71
  if ld:
72
- for j in ld: j.update({"source": "myjobmag", "category": CAT})
73
  return self.filter_fresh(ld[:20])
74
- # Try common card selectors first, then site-specific
75
  cards = self.multi_select(soup, [
76
  "li.job-list-li",
77
  "article.job-item","div.job-item","li.job-item",
@@ -81,11 +174,29 @@ class NigerianJobScraper(BaseScraper):
81
  "div.listing-item","div.post-item","li.listing-item",
82
  "tr","div.row","div[class*='list'] > div","article",
83
  ])
84
- result = self._parse_cards(cards, "myjobmag", "https://www.myjobmag.com", 20)
85
  if not result:
86
- return self._extract_from_text(soup, "myjobmag", "https://www.myjobmag.com", CAT)
87
  return result
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  # ── Jobberman ───────────────────────────────────────────────────────────
90
  def scrape_jobberman(self, query: str) -> list[dict]:
91
  url = f"https://www.jobberman.com/jobs?q={quote(query)}"
@@ -189,6 +300,87 @@ class NigerianJobScraper(BaseScraper):
189
  return self._extract_from_text(soup, "jobzilla", "https://www.jobzilla.ng", CAT)
190
  return result
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  # ── Shared card parser ──────────────────────────────────────────────────
193
  def _parse_cards(self, cards: list, source: str, base: str, limit: int) -> list[dict]:
194
  jobs, seen = [], set()
 
7
 
8
  CAT = "nigeria"
9
 
10
+ # ── MyJobMag Industry Sectors (39 categories) ──────────────────────────────
11
+ MYJOBMAG_SECTORS = [
12
+ ("advertising-branding", "Advertising / Branding / PR"),
13
+ ("agriculture", "Agriculture / Agro-Allied"),
14
+ ("aviation", "Aviation / Airline"),
15
+ ("banking-financial-services", "Banking / Financial Services"),
16
+ ("blockchain", "Blockchain"),
17
+ ("building-construction", "Building / Construction"),
18
+ ("consulting", "Consulting"),
19
+ ("creative", "Creative / Arts"),
20
+ ("education", "Education / Teaching"),
21
+ ("engineering", "Engineering / Technical"),
22
+ ("food-services", "Food Services"),
23
+ ("general", "General"),
24
+ ("government", "Government"),
25
+ ("medical", "Healthcare / Medical"),
26
+ ("hospitality", "Hotels & Restaurants"),
27
+ ("ict-telecommunications", "ICT / Telecommunication"),
28
+ ("insurance", "Insurance"),
29
+ ("internet-e-commerce", "Internet / E-commerce"),
30
+ ("janitorial-services", "Janitorial Services / Environment"),
31
+ ("legal", "Law / Legal"),
32
+ ("logistics-and-transportation", "Logistics and Transportation"),
33
+ ("manufacturing", "Manufacturing / Production / FMCG"),
34
+ ("marine-shipping", "Marine and Shipping"),
35
+ ("broadcasting", "Media / Radio / TV"),
36
+ ("sales-marketing", "Merchandising, Retail & eCommerce"),
37
+ ("ngo", "NGO / Non-Profit Associations"),
38
+ ("oil-and-gas", "Oil and Gas"),
39
+ ("online-sales-marketing", "Online Sales / Marketing"),
40
+ ("pharmaceuticals", "Pharmaceuticals"),
41
+ ("power-energy", "Power / Energy"),
42
+ ("professional-social-associations", "Professional / Social Associations"),
43
+ ("raffle", "Raffle"),
44
+ ("real-estate", "Real Estate"),
45
+ ("religious", "Religious"),
46
+ ("research", "Research"),
47
+ ("science", "Science"),
48
+ ("security", "Security"),
49
+ ("travel-and-tours", "Travel and Tourism"),
50
+ ]
51
+
52
+ SECTOR_SLUGS = [s[0] for s in MYJOBMAG_SECTORS]
53
+ SECTOR_BY_SLUG = dict(MYJOBMAG_SECTORS)
54
+
55
+ # Keyword terms for boards without native category filtering
56
+ SECTOR_SEARCH_TERMS = {
57
+ "oil-and-gas": "oil+gas+petroleum+energy",
58
+ "insurance": "insurance+underwriting",
59
+ "banking-financial-services": "banking+finance+financial+investment",
60
+ "ict-telecommunications": "ICT+telecommunications+telecom+network",
61
+ "manufacturing": "manufacturing+FMCG+production+factory",
62
+ "power-energy": "power+energy+electricity+renewable",
63
+ "medical": "medical+healthcare+health+pharmaceutical",
64
+ "building-construction": "construction+building+civil+engineering+infrastructure",
65
+ "ngo": "NGO+nonprofit+development+humanitarian",
66
+ "education": "education+teaching+academic+training",
67
+ "government": "government+civil+service+public+sector",
68
+ "legal": "legal+law+compliance+regulatory",
69
+ "consulting": "consulting+advisory+strategy",
70
+ "engineering": "engineering+technical+maintenance+installation",
71
+ "logistics-and-transportation": "logistics+transportation+supply+chain+fleet",
72
+ "agriculture": "agriculture+farming+agro+allied",
73
+ "real-estate": "real+estate+property+housing+facility",
74
+ "hospitality": "hospitality+hotel+restaurant+tourism+travel",
75
+ "creative": "creative+design+arts+multimedia+graphic",
76
+ "broadcasting": "media+broadcasting+radio+TV+journalism",
77
+ "sales-marketing": "sales+marketing+merchandising+retail+ecommerce",
78
+ "online-sales-marketing": "digital+marketing+online+sales+social+media",
79
+ "security": "security+safety+guard+surveillance",
80
+ "research": "research+analysis+data+monitoring+evaluation",
81
+ "science": "science+laboratory+lab+research+scientist",
82
+ "human-resources": "HR+human+resources+recruitment+talent+payroll",
83
+ "accounting-finance": "accounting+audit+tax+treasury+finance+accounts",
84
+ }
85
+
86
+
87
  _TITLE_SELS = ["h2 a","h3 a","h4 a","a.job-title","[class*='title'] a",
88
  "a[class*='title']","a[class*='job']",".job-name a","a[rel='bookmark']",
89
  "h2.entry-title a","h3.entry-title a",".job_title a",".job-title a",
 
141
  # ── MyJobMag ────────────────────────────────────────────────────────────
142
  def scrape_myjobmag(self, query: str) -> list[dict]:
143
  url = f"https://www.myjobmag.com/search?q={quote(query)}"
144
+ return self._parse_myjobmag(url, "myjobmag")
145
+
146
+ def scrape_myjobmag_by_industry(self, slug: str, pages: int = 2) -> list[dict]:
147
+ """Scrape MyJobMag by industry category slug (e.g. 'oil-and-gas')."""
148
+ all_jobs = []
149
+ for page in range(1, pages + 1):
150
+ if page == 1:
151
+ url = f"https://www.myjobmag.com/jobs-by-industry/{slug}"
152
+ else:
153
+ url = f"https://www.myjobmag.com/jobs-by-industry/{slug}/page/{page}"
154
+ jobs = self._parse_myjobmag(url, f"myjobmag/{slug}")
155
+ if not jobs:
156
+ break
157
+ all_jobs.extend(jobs)
158
+ return self.filter_fresh(all_jobs[:30])
159
+
160
+ def _parse_myjobmag(self, url: str, source: str) -> list[dict]:
161
  soup = self.fetch_soup(url)
162
  if not soup:
163
  return []
164
  ld = self.fetch_json_ld(soup)
165
  if ld:
166
+ for j in ld: j.update({"source": source, "category": CAT})
167
  return self.filter_fresh(ld[:20])
 
168
  cards = self.multi_select(soup, [
169
  "li.job-list-li",
170
  "article.job-item","div.job-item","li.job-item",
 
174
  "div.listing-item","div.post-item","li.listing-item",
175
  "tr","div.row","div[class*='list'] > div","article",
176
  ])
177
+ result = self._parse_cards(cards, source, "https://www.myjobmag.com", 20)
178
  if not result:
179
+ return self._extract_from_text(soup, source, "https://www.myjobmag.com", CAT)
180
  return result
181
 
182
+ # ── Sector-scrape all boards for given sectors ──────────────────────────
183
+ def scrape_sectors(self, sector_slugs: list[str]) -> list[dict]:
184
+ """Scrape all applicable Nigerian boards for specific industry sectors."""
185
+ all_jobs = []
186
+ for slug in sector_slugs:
187
+ sector_name = SECTOR_BY_SLUG.get(slug, slug.replace("-", " "))
188
+ # MyJobMag has native industry pages
189
+ jobs = self.scrape_myjobmag_by_industry(slug, pages=1)
190
+ all_jobs.extend(jobs or [])
191
+ # Keyword-board sectors: jobzilla
192
+ search_term = SECTOR_SEARCH_TERMS.get(slug, slug.replace("-", "+"))
193
+ jobs_z = self.scrape_jobzilla(search_term)
194
+ all_jobs.extend(jobs_z or [])
195
+ # NaijaJobPortal
196
+ jobs_n = self.scrape_naijajobportal(search_term)
197
+ all_jobs.extend(jobs_n or [])
198
+ return self.filter_fresh(all_jobs)
199
+
200
  # ── Jobberman ───────────────────────────────────────────────────────────
201
  def scrape_jobberman(self, query: str) -> list[dict]:
202
  url = f"https://www.jobberman.com/jobs?q={quote(query)}"
 
300
  return self._extract_from_text(soup, "jobzilla", "https://www.jobzilla.ng", CAT)
301
  return result
302
 
303
+ # ── NaijaJobPortal ───────────────────────────────────────────────────────
304
+ def scrape_naijajobportal(self, query: str) -> list[dict]:
305
+ url = f"https://www.naijajobportal.com.ng/jobs?q={quote(query)}"
306
+ soup = self.fetch_soup(url)
307
+ if not soup:
308
+ return []
309
+ ld = self.fetch_json_ld(soup)
310
+ if ld:
311
+ for j in ld: j.update({"source": "naijajobportal", "category": CAT})
312
+ return self.filter_fresh(ld[:20])
313
+ cards = soup.select("article.bj-job-card")
314
+ if not cards:
315
+ return self._extract_from_text(soup, "naijajobportal",
316
+ "https://www.naijajobportal.com.ng", CAT)
317
+ jobs, seen = [], set()
318
+ for card in cards[:25]:
319
+ try:
320
+ title_el = card.select_one("h2.bj-job-title a")
321
+ if not title_el:
322
+ continue
323
+ title = self._clean_text(title_el.get_text())
324
+ href = title_el.get("href", "")
325
+ full_url = self._full_url("https://www.naijajobportal.com.ng", href)
326
+ if not title or not full_url or full_url in seen:
327
+ continue
328
+ seen.add(full_url)
329
+ company = self._clean_text(
330
+ card.select_one("p.bj-company a").get_text()
331
+ ) if card.select_one("p.bj-company a") else ""
332
+ location = ""
333
+ loc_el = card.select_one("span.bj-chip i.fa-map-marker-alt")
334
+ if loc_el:
335
+ location = self._clean_text(loc_el.parent.get_text()) if loc_el.parent else ""
336
+ posted = self._clean_text(
337
+ card.select_one("p.bj-posted").get_text()
338
+ ) if card.select_one("p.bj-posted") else ""
339
+ desc = self._clean_text(
340
+ card.select_one("p.bj-desc").get_text()
341
+ ) if card.select_one("p.bj-desc") else ""
342
+ if not company:
343
+ logo = card.select_one("img.bj-logo")
344
+ if logo and logo.get("alt"):
345
+ company = logo["alt"].strip()
346
+ jobs.append(self._make_job(title, company, location, desc,
347
+ full_url, "naijajobportal", CAT, posted))
348
+ except Exception:
349
+ continue
350
+ return self.filter_fresh(jobs)
351
+
352
+ # ── Oil & Gas (Nigerian boards via sector) ──────────────────────────────
353
+ def scrape_oilandgas(self, query: str = "") -> list[dict]:
354
+ """Scrape O&G roles from MyJobMag industry page + keyword boards."""
355
+ return self.scrape_sectors(["oil-and-gas"])
356
+
357
+ # ── LEEP Jobs (Nigerian Government Portal) ──────────────────────────────
358
+ def scrape_leep(self, query: str) -> list[dict]:
359
+ url = f"https://jobs.leep.gov.ng/?s={quote(query)}"
360
+ soup = self.fetch_soup(url)
361
+ if not soup:
362
+ return []
363
+ ld = self.fetch_json_ld(soup)
364
+ if ld:
365
+ for j in ld: j.update({"source": "leep", "category": CAT})
366
+ return self.filter_fresh(ld[:20])
367
+ cards = self.multi_select(soup, [
368
+ "article.job","div.job-listing","div.job-card",
369
+ "li.job_listing","div.search-result","article.post",
370
+ "div[class*='job']","article","li.post",
371
+ ])
372
+ result = self._parse_cards(cards, "leep", "https://jobs.leep.gov.ng", 20)
373
+ if not result:
374
+ return self._extract_from_text(soup, "leep", "https://jobs.leep.gov.ng", CAT)
375
+ return result
376
+
377
+ def _full_url(self, base: str, path: str) -> str:
378
+ if not path:
379
+ return ""
380
+ if path.startswith("http"):
381
+ return path
382
+ return base.rstrip("/") + "/" + path.lstrip("/")
383
+
384
  # ── Shared card parser ──────────────────────────────────────────────────
385
  def _parse_cards(self, cards: list, source: str, base: str, limit: int) -> list[dict]:
386
  jobs, seen = [], set()
backend/scrapers/web3.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Web3 job scraper — Solana ecosystem, crypto, and blockchain job boards.
3
+ Strategy (in priority order per board):
4
+ 1. Inline JSON (__NEXT_DATA__, etc.) — full structured data
5
+ 2. JSON-LD (schema.org/JobPosting)
6
+ 3. Heuristic text extraction (robust against layout changes)
7
+ 4. Multi-selector CSS card parsing (last resort — most fragile)
8
+ """
9
+ import re
10
+ from urllib.parse import quote
11
+ from .base import BaseScraper
12
+
13
+ CAT = "web3"
14
+
15
+ _TITLE_SELS = ["h2 a","h3 a","h4 a","a[class*='title']","a[class*='job-title']",
16
+ "[class*='title'] a","a[class*='position']","strong a","a"]
17
+ _COMPANY_SELS = ["[class*='company'] a","[class*='company']","[class*='employer']",
18
+ "[class*='org']","span.custom_fields_company_name_display_search_results",
19
+ "[class*='recruiter']","[class*='name']"]
20
+ _LOCATION_SELS = ["[class*='location']","[class*='place']","[class*='city']",
21
+ "[class*='country']","span[class*='loc']","[class*='region']"]
22
+ _DATE_SELS = ["time","[class*='date']","[class*='posted']","[class*='age']","span.date"]
23
+ _DESC_SELS = ["[class*='desc']","[class*='excerpt']","[class*='snippet']",
24
+ "[class*='summary']","p"]
25
+
26
+
27
+ class Web3JobScraper(BaseScraper):
28
+
29
+ # ── Solana Ecosystem Board (Getro/Next.js platform) ───────────────────
30
+ def scrape_jobsolana(self, query: str) -> list[dict]:
31
+ url = "https://jobs.solana.com/jobs"
32
+ soup = self.fetch_soup(url)
33
+ if not soup:
34
+ return []
35
+ import json
36
+ next_data = soup.find("script", id="__NEXT_DATA__")
37
+ if next_data:
38
+ try:
39
+ raw = next_data.string or ""
40
+ data = json.loads(raw)
41
+ jobs_list = (
42
+ data.get("props", {}).get("pageProps", {})
43
+ .get("initialState", {}).get("jobs", {}).get("found", [])
44
+ ) or (
45
+ data.get("props", {}).get("pageProps", {})
46
+ .get("jobs", [])
47
+ )
48
+ if jobs_list:
49
+ jobs = []
50
+ for item in jobs_list[:50]:
51
+ title = item.get("title", "")
52
+ if not title:
53
+ continue
54
+ url_val = item.get("url", "") or f"https://jobs.solana.com/jobs/{item.get('slug','')}"
55
+ company = item.get("organization", {}).get("name", "")
56
+ locations = item.get("locations", [])
57
+ location = ", ".join(locations) if locations else "Remote"
58
+ created = item.get("createdAt", 0)
59
+ posted = ""
60
+ if created:
61
+ from datetime import datetime
62
+ posted = datetime.utcfromtimestamp(created).strftime("%Y-%m-%d")
63
+ jobs.append(self._make_job(title, company, location, "",
64
+ url_val, "jobsolana", CAT, posted))
65
+ return self._filter_by_query(self.filter_fresh(jobs), query)
66
+ except Exception as e:
67
+ print(f"[jobsolana] __NEXT_DATA__ parse: {e}")
68
+ ld = self.fetch_json_ld(soup)
69
+ if ld:
70
+ for j in ld: j.update({"source": "jobsolana", "category": CAT})
71
+ return self._filter_by_query(ld[:25], query)
72
+ return self._filter_by_query(
73
+ self._extract_from_text(soup, "jobsolana",
74
+ "https://jobs.solana.com", CAT),
75
+ query)
76
+
77
+ # ── CryptocurrencyJobs (Hugo SSR site) ────────────────────────────────
78
+ def scrape_cryptocurrencyjobs(self, query: str) -> list[dict]:
79
+ # Try main listing; board doesn't support search params
80
+ for url in [
81
+ "https://cryptocurrencyjobs.co/remote/",
82
+ "https://cryptocurrencyjobs.co/",
83
+ ]:
84
+ soup = self.fetch_soup(url)
85
+ if not soup:
86
+ continue
87
+ ld = self.fetch_json_ld(soup)
88
+ if ld:
89
+ for j in ld: j.update({"source": "cryptocurrencyjobs", "category": CAT})
90
+ return self._filter_by_query(ld[:25], query)
91
+ result = self._filter_by_query(
92
+ self._extract_from_text(soup, "cryptocurrencyjobs",
93
+ "https://cryptocurrencyjobs.co", CAT),
94
+ query)
95
+ if result:
96
+ return result
97
+ cards = soup.select("li.grid, li[class*='job'], article, div[class*='card'], div[class*='listing']")
98
+ if cards:
99
+ jobs, seen = [], set()
100
+ for card in cards[:50]:
101
+ try:
102
+ for img in card.find_all("img"):
103
+ img.decompose()
104
+ h2 = card.find("h2")
105
+ if not h2:
106
+ continue
107
+ a = h2.find("a") if h2 else None
108
+ title = (a or h2).get_text(strip=True)
109
+ href = ""
110
+ if a:
111
+ href = a.get("href", "")
112
+ if href and not href.startswith("http"):
113
+ href = "https://cryptocurrencyjobs.co" + href
114
+ if not title or not href or href in seen:
115
+ continue
116
+ company = ""
117
+ co_link = card.find("a", href=lambda x: x and x.startswith("/startups/"))
118
+ if co_link:
119
+ company = co_link.get_text(strip=True)
120
+ if not company:
121
+ all_texts = card.get_text("|", strip=True).split("|")
122
+ for idx, t in enumerate(all_texts):
123
+ if t == title and idx + 1 < len(all_texts):
124
+ nxt = all_texts[idx + 1].strip()
125
+ if nxt and nxt not in ("Remote",):
126
+ company = nxt
127
+ break
128
+ seen.add(href)
129
+ date_match = re.search(r'(\d+[dhm])\s*(?:Featured)?', card.get_text())
130
+ posted = date_match.group(1) if date_match else ""
131
+ jobs.append(self._make_job(title, company, "Remote", "",
132
+ href, "cryptocurrencyjobs", CAT, posted))
133
+ except Exception:
134
+ continue
135
+ if jobs:
136
+ return self._filter_by_query(self.filter_fresh(jobs), query)
137
+ return []
138
+
139
+ # ── Web3.Career ───────────────────────────────────────────────────────
140
+ def scrape_web3career(self, query: str) -> list[dict]:
141
+ soup = self.fetch_soup("https://web3.career/remote-jobs")
142
+ if not soup:
143
+ return []
144
+ ld = self.fetch_json_ld(soup)
145
+ if ld:
146
+ for j in ld: j.update({"source": "web3career", "category": CAT})
147
+ return self._filter_by_query(ld[:25], query)
148
+ result = self._filter_by_query(
149
+ self._extract_from_text(soup, "web3career",
150
+ "https://web3.career", CAT),
151
+ query)
152
+ if result:
153
+ return result
154
+ cards = self.multi_select(soup, [
155
+ "tr[class*='job']","tr","div[class*='job-row']","div[class*='job-card']",
156
+ "div[class*='table'] > div","div[class*='listing']","article","li",
157
+ ])
158
+ jobs, seen = [], set()
159
+ for card in cards[:40]:
160
+ try:
161
+ title = self.first_text(card, [
162
+ "td:first-child a","td:nth-child(2) a","td:nth-child(1) a",
163
+ "h2 a","h3 a","strong a","a",
164
+ ])
165
+ href = self.first_href(card, [
166
+ "td:first-child a","a[href*='/job/']","a[href*='/position/']","a",
167
+ ], "https://web3.career")
168
+ if not title or not href or href in seen:
169
+ continue
170
+ seen.add(href)
171
+ company = self.first_text(card, [
172
+ "td:nth-child(2)","[class*='company']","[class*='org']",
173
+ ])
174
+ location = self.first_text(card, _LOCATION_SELS, "Remote")
175
+ posted = self.first_text(card, _DATE_SELS)
176
+ salary = self.first_text(card, ["[class*='salary']","td:nth-child(4)"])
177
+ desc = f"Salary: {salary}" if salary else ""
178
+ jobs.append(self._make_job(title, company, location, desc, href,
179
+ "web3career", CAT, posted))
180
+ except Exception:
181
+ continue
182
+ return self._filter_by_query(self.filter_fresh(jobs), query)
183
+
184
+ # ── CryptoJobsList ────────────────────────────────────────────────────
185
+ def scrape_cryptojobslist(self, query: str) -> list[dict]:
186
+ soup = self.fetch_soup("https://cryptojobslist.com/")
187
+ if not soup:
188
+ return []
189
+ ld = self.fetch_json_ld(soup)
190
+ if ld:
191
+ for j in ld: j.update({"source": "cryptojobslist", "category": CAT})
192
+ return self._filter_by_query(ld[:25], query)
193
+ result = self._filter_by_query(
194
+ self._extract_from_text(soup, "cryptojobslist",
195
+ "https://cryptojobslist.com", CAT),
196
+ query)
197
+ if result:
198
+ return result
199
+ cards = self.multi_select(soup, [
200
+ "div[class*='job-card']","div[class*='listing']","div[class*='job']",
201
+ "article","li","a[class*='job']","div[class*='card']",
202
+ ])
203
+ jobs, seen = [], set()
204
+ for card in cards[:40]:
205
+ try:
206
+ title = self.first_text(card, _TITLE_SELS)
207
+ href = self.first_href(card, [
208
+ "a[href*='/']","h2 a","h3 a",
209
+ ], "https://cryptojobslist.com")
210
+ if not title or not href or href in seen:
211
+ continue
212
+ seen.add(href)
213
+ company = self.first_text(card, _COMPANY_SELS)
214
+ location = self.first_text(card, _LOCATION_SELS, "Remote")
215
+ posted = self.first_text(card, _DATE_SELS)
216
+ desc = self.first_text(card, _DESC_SELS)
217
+ jobs.append(self._make_job(title, company, location, desc, href,
218
+ "cryptojobslist", CAT, posted))
219
+ except Exception:
220
+ continue
221
+ return self._filter_by_query(self.filter_fresh(jobs), query)
222
+
223
+ # ── Shared: filter results by query keywords ───────────────────────────
224
+ def _filter_by_query(self, jobs: list[dict], query: str) -> list[dict]:
225
+ if not query or not jobs:
226
+ return jobs
227
+ q_words = set(query.lower().split())
228
+ filtered = []
229
+ for j in jobs:
230
+ text = (j.get("title", "") + " " + j.get("company", "") + " " + j.get("description", "")).lower()
231
+ if q_words & set(text.split()):
232
+ filtered.append(j)
233
+ return filtered[:25]
frontend/app.js CHANGED
@@ -67,6 +67,12 @@ function toggleAdminMode() {
67
  localStorage.setItem("admin_mode", newVal);
68
  location.reload();
69
  }
 
 
 
 
 
 
70
  function logout() { localStorage.removeItem("joblin_token"); localStorage.removeItem("joblin_user"); localStorage.removeItem("admin_mode"); window.location.href = "/login.html"; }
71
  function escHtml(s) { if (!s) return ""; return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
72
 
@@ -104,7 +110,8 @@ function renderQualityScores(scores) {
104
  }
105
 
106
  function renderJobScore(score) {
107
- const pct = Math.round(score || 0);
 
108
  let color = "var(--text-subtle)";
109
  if (pct >= 70) color = "var(--color-primary-500)";
110
  else if (pct >= 40) color = "var(--color-warning-500)";
 
67
  localStorage.setItem("admin_mode", newVal);
68
  location.reload();
69
  }
70
+ function togglePwd(id, el) {
71
+ const inp = document.getElementById(id);
72
+ const isPwd = inp.type === "password";
73
+ inp.type = isPwd ? "text" : "password";
74
+ el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
75
+ }
76
  function logout() { localStorage.removeItem("joblin_token"); localStorage.removeItem("joblin_user"); localStorage.removeItem("admin_mode"); window.location.href = "/login.html"; }
77
  function escHtml(s) { if (!s) return ""; return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
78
 
 
110
  }
111
 
112
  function renderJobScore(score) {
113
+ if (!score || score <= 0) return "";
114
+ const pct = Math.round(score);
115
  let color = "var(--text-subtle)";
116
  if (pct >= 70) color = "var(--color-primary-500)";
117
  else if (pct >= 40) color = "var(--color-warning-500)";
frontend/forgot-password.html CHANGED
@@ -50,19 +50,8 @@
50
  <p class="footer-link"><a href="login.html">Back to sign in</a></p>
51
  </div>
52
  </div>
53
- <script>
54
- function togglePwd(id, el) {
55
- const inp = document.getElementById(id);
56
- const isPwd = inp.type === "password";
57
- inp.type = isPwd ? "text" : "password";
58
- el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
59
- }
60
- </script>
61
  <script src="app.js"></script>
62
- <script>
63
- const API_BASE = window.location.origin;
64
-
65
- document.getElementById("tokenForm").addEventListener("submit", async (e) => {
66
  e.preventDefault();
67
  const btn = document.getElementById("tokenBtn");
68
  btn.disabled = true;
 
50
  <p class="footer-link"><a href="login.html">Back to sign in</a></p>
51
  </div>
52
  </div>
 
 
 
 
 
 
 
 
53
  <script src="app.js"></script>
54
+ <script>document.getElementById("tokenForm").addEventListener("submit", async (e) => {
 
 
 
55
  e.preventDefault();
56
  const btn = document.getElementById("tokenBtn");
57
  btn.disabled = true;
frontend/login.html CHANGED
@@ -13,7 +13,6 @@
13
  <div class="auth-brand-icon">J</div>
14
  <h1>Job<span>lin</span></h1>
15
  </a>
16
- <h1>Job<span>lin</span></h1>
17
  <p class="subtitle">AI-powered CV & job matching platform</p>
18
  <form id="loginForm">
19
  <div class="form-group">
@@ -33,14 +32,6 @@
33
  <p class="footer-link">Don't have an account? <a href="register.html">Create one</a></p>
34
  </div>
35
  </div>
36
- <script>
37
- function togglePwd(id, el) {
38
- const inp = document.getElementById(id);
39
- const isPwd = inp.type === "password";
40
- inp.type = isPwd ? "text" : "password";
41
- el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
42
- }
43
- </script>
44
  <script src="app.js"></script>
45
  <script>
46
  const btn = document.getElementById("loginBtn");
 
13
  <div class="auth-brand-icon">J</div>
14
  <h1>Job<span>lin</span></h1>
15
  </a>
 
16
  <p class="subtitle">AI-powered CV & job matching platform</p>
17
  <form id="loginForm">
18
  <div class="form-group">
 
32
  <p class="footer-link">Don't have an account? <a href="register.html">Create one</a></p>
33
  </div>
34
  </div>
 
 
 
 
 
 
 
 
35
  <script src="app.js"></script>
36
  <script>
37
  const btn = document.getElementById("loginBtn");
frontend/register.html CHANGED
@@ -13,7 +13,6 @@
13
  <div class="auth-brand-icon">J</div>
14
  <h1>Job<span>lin</span></h1>
15
  </a>
16
- <h1>Job<span>lin</span></h1>
17
  <p class="subtitle">Create your account</p>
18
  <form id="registerForm">
19
  <div class="form-group">
@@ -36,14 +35,6 @@
36
  <p class="footer-link">Already have an account? <a href="login.html">Sign in</a></p>
37
  </div>
38
  </div>
39
- <script>
40
- function togglePwd(id, el) {
41
- const inp = document.getElementById(id);
42
- const isPwd = inp.type === "password";
43
- inp.type = isPwd ? "text" : "password";
44
- el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
45
- }
46
- </script>
47
  <script src="app.js"></script>
48
  <script>
49
  const btn = document.getElementById("registerBtn");
@@ -67,6 +58,8 @@ document.getElementById("registerForm").addEventListener("submit", async (e) =>
67
  }
68
  });
69
  if (getToken()) window.location.href = "dashboard.html";
 
 
70
  </script>
71
  </body>
72
  </html>
 
13
  <div class="auth-brand-icon">J</div>
14
  <h1>Job<span>lin</span></h1>
15
  </a>
 
16
  <p class="subtitle">Create your account</p>
17
  <form id="registerForm">
18
  <div class="form-group">
 
35
  <p class="footer-link">Already have an account? <a href="login.html">Sign in</a></p>
36
  </div>
37
  </div>
 
 
 
 
 
 
 
 
38
  <script src="app.js"></script>
39
  <script>
40
  const btn = document.getElementById("registerBtn");
 
58
  }
59
  });
60
  if (getToken()) window.location.href = "dashboard.html";
61
+ const params = new URLSearchParams(window.location.search);
62
+ if (params.get("email")) document.getElementById("email").value = params.get("email");
63
  </script>
64
  </body>
65
  </html>