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 +56 -29
- backend/cron.py +4 -0
- backend/database.py +31 -2
- backend/main.py +4 -0
- backend/scrapers/ats.py +104 -0
- backend/scrapers/base.py +11 -1
- backend/scrapers/international.py +34 -4
- backend/scrapers/ngos.py +4 -2
- backend/scrapers/nigeria.py +196 -4
- backend/scrapers/web3.py +233 -0
- frontend/app.js +8 -1
- frontend/forgot-password.html +1 -12
- frontend/login.html +0 -9
- frontend/register.html +2 -9
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 |
-
"
|
| 279 |
-
"base_url": "https://
|
| 280 |
-
"search_url": "https://
|
| 281 |
-
"type": "public", "region": "
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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":
|
| 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,
|
| 85 |
if not result:
|
| 86 |
-
return self._extract_from_text(soup,
|
| 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
| 72 |
|
|
@@ -104,7 +110,8 @@ function renderQualityScores(scores) {
|
|
| 104 |
}
|
| 105 |
|
| 106 |
function renderJobScore(score) {
|
| 107 |
-
|
|
|
|
| 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 ? "👀" : "👁";
|
| 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
| 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 ? "👀" : "👁";
|
| 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 ? "👀" : "👁";
|
| 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 ? "👀" : "👁";
|
| 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>
|