chameleon-cv 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +186 -0
- package/LICENSE +21 -0
- package/Makefile +136 -0
- package/README.md +356 -0
- package/bin/chameleon.js +136 -0
- package/chameleon.bat +286 -0
- package/chameleon.sh +353 -0
- package/package.json +39 -0
- package/profiles/github_repos.json +1266 -0
- package/requirements.txt +5 -0
- package/scan.sh +12 -0
- package/score.sh +12 -0
- package/scripts/__init__.py +1 -0
- package/scripts/__main_tui__.py +11 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
- package/scripts/__pycache__/config.cpython-312.pyc +0 -0
- package/scripts/__pycache__/config.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/db.cpython-312.pyc +0 -0
- package/scripts/__pycache__/db.cpython-313.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
- package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
- package/scripts/__pycache__/question.cpython-313.pyc +0 -0
- package/scripts/__pycache__/render.cpython-313.pyc +0 -0
- package/scripts/__pycache__/review.cpython-313.pyc +0 -0
- package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
- package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
- package/scripts/ats_ghost.py +170 -0
- package/scripts/cache.py +63 -0
- package/scripts/cli.py +109 -0
- package/scripts/config.py +376 -0
- package/scripts/cover_letter.py +105 -0
- package/scripts/db.py +331 -0
- package/scripts/fonts.py +186 -0
- package/scripts/ghost.py +88 -0
- package/scripts/job_matcher.py +636 -0
- package/scripts/job_scanner/__init__.py +8 -0
- package/scripts/job_scanner/__main__.py +6 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
- package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
- package/scripts/job_scanner/base.py +197 -0
- package/scripts/job_scanner/browser.py +244 -0
- package/scripts/job_scanner/cli.py +6 -0
- package/scripts/job_scanner/parsers/__init__.py +24 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
- package/scripts/job_scanner/parsers/adzuna.py +81 -0
- package/scripts/job_scanner/parsers/arc.py +65 -0
- package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
- package/scripts/job_scanner/parsers/dailyremote.py +91 -0
- package/scripts/job_scanner/parsers/flexjobs.py +91 -0
- package/scripts/job_scanner/parsers/himalayas.py +47 -0
- package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
- package/scripts/job_scanner/parsers/indeed.py +98 -0
- package/scripts/job_scanner/parsers/jobicy.py +60 -0
- package/scripts/job_scanner/parsers/jooble.py +68 -0
- package/scripts/job_scanner/parsers/linkedin.py +140 -0
- package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
- package/scripts/job_scanner/parsers/relocate.py +95 -0
- package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
- package/scripts/job_scanner/parsers/remoteok.py +63 -0
- package/scripts/job_scanner/parsers/remotive.py +57 -0
- package/scripts/job_scanner/parsers/web3_career.py +92 -0
- package/scripts/job_scanner/parsers/wellfound.py +94 -0
- package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
- package/scripts/job_scanner/parsers/ycomb.py +83 -0
- package/scripts/job_scanner/registry.py +65 -0
- package/scripts/job_scanner/scanner.py +162 -0
- package/scripts/question.py +107 -0
- package/scripts/render.py +110 -0
- package/scripts/review.py +89 -0
- package/scripts/setup.py +139 -0
- package/scripts/tailor_brief.py +379 -0
- package/scripts/tailor_cv.py +746 -0
- package/scripts/tailor_prompts.py +101 -0
- package/scripts/tui/__init__.py +1 -0
- package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
- package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
- package/scripts/tui/app.py +1547 -0
- package/scripts/tui/helpers.py +65 -0
- package/scripts/tui/screens.py +642 -0
- package/scripts/tui/widgets.py +276 -0
- package/scripts/tui_app.py +25 -0
- package/scripts/wizard.py +109 -0
- package/tui.bat +50 -0
- package/tui.sh +59 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Base interface for job platform parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field, asdict
|
|
7
|
+
import hashlib
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class JobListing:
|
|
14
|
+
"""Unified job listing from any platform."""
|
|
15
|
+
title: str
|
|
16
|
+
company: str
|
|
17
|
+
url: str
|
|
18
|
+
location: str = ""
|
|
19
|
+
remote: bool = True
|
|
20
|
+
tags: list[str] = field(default_factory=list)
|
|
21
|
+
salary: str = ""
|
|
22
|
+
posted_at: str = ""
|
|
23
|
+
description: str = ""
|
|
24
|
+
source: str = ""
|
|
25
|
+
job_id: str = ""
|
|
26
|
+
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if not self.job_id:
|
|
29
|
+
raw = f"{self.url}{self.title}{self.company}".encode()
|
|
30
|
+
try:
|
|
31
|
+
self.job_id = hashlib.md5(raw, usedforsecurity=False).hexdigest()[:12]
|
|
32
|
+
except TypeError:
|
|
33
|
+
self.job_id = hashlib.md5(raw).hexdigest()[:12]
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return asdict(self)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseParser(ABC):
|
|
40
|
+
"""Base class all platform parsers inherit from."""
|
|
41
|
+
|
|
42
|
+
platform_name: str = "unknown"
|
|
43
|
+
_last_request_time: float = 0.0
|
|
44
|
+
_min_request_interval: float = 0.5
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def fetch_jobs(
|
|
48
|
+
self,
|
|
49
|
+
query: str = "",
|
|
50
|
+
tags: list[str] | None = None,
|
|
51
|
+
limit: int = 25,
|
|
52
|
+
**kwargs,
|
|
53
|
+
) -> list[JobListing]:
|
|
54
|
+
"""Fetch jobs from the platform. Must be implemented by subclasses."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def _rate_limit(self) -> None:
|
|
58
|
+
"""Simple rate limiting between requests."""
|
|
59
|
+
now = time.time()
|
|
60
|
+
elapsed = now - self._last_request_time
|
|
61
|
+
if elapsed < self._min_request_interval:
|
|
62
|
+
time.sleep(self._min_request_interval - elapsed)
|
|
63
|
+
self._last_request_time = time.time()
|
|
64
|
+
|
|
65
|
+
def _http_get(self, url: str, params: dict | None = None, headers: dict | None = None,
|
|
66
|
+
timeout: int = 15, retries: int = 2) -> object | None:
|
|
67
|
+
"""HTTP GET with retries and rate limiting. Returns httpx.Response or None."""
|
|
68
|
+
self._rate_limit()
|
|
69
|
+
default_headers = {
|
|
70
|
+
"User-Agent": "Mozilla/5.0 (compatible; ChameleonBot/1.0)"
|
|
71
|
+
}
|
|
72
|
+
if headers:
|
|
73
|
+
default_headers.update(headers)
|
|
74
|
+
|
|
75
|
+
for attempt in range(retries + 1):
|
|
76
|
+
try:
|
|
77
|
+
import httpx
|
|
78
|
+
resp = httpx.get(url, params=params, headers=default_headers,
|
|
79
|
+
timeout=timeout, follow_redirects=True)
|
|
80
|
+
if resp.status_code in (429, 403):
|
|
81
|
+
if attempt < retries:
|
|
82
|
+
time.sleep(2 ** attempt)
|
|
83
|
+
continue
|
|
84
|
+
return None
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
return resp
|
|
87
|
+
except ImportError:
|
|
88
|
+
# Fallback to urllib if httpx not available
|
|
89
|
+
import urllib.request
|
|
90
|
+
import urllib.error
|
|
91
|
+
import json as _json
|
|
92
|
+
try:
|
|
93
|
+
if params:
|
|
94
|
+
from urllib.parse import urlencode
|
|
95
|
+
url = f"{url}?{urlencode(params)}"
|
|
96
|
+
req = urllib.request.Request(url, headers=default_headers)
|
|
97
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
98
|
+
data = resp.read().decode("utf-8", errors="replace")
|
|
99
|
+
# Return a minimal object mimicking httpx.Response
|
|
100
|
+
class _FakeResp:
|
|
101
|
+
def __init__(self, text, status_code=200):
|
|
102
|
+
self.text = text
|
|
103
|
+
self.status_code = status_code
|
|
104
|
+
def json(self):
|
|
105
|
+
return _json.loads(self.text)
|
|
106
|
+
def raise_for_status(self):
|
|
107
|
+
if self.status_code >= 400:
|
|
108
|
+
raise Exception(f"HTTP {self.status_code}")
|
|
109
|
+
return _FakeResp(data)
|
|
110
|
+
except Exception:
|
|
111
|
+
if attempt < retries:
|
|
112
|
+
time.sleep(1)
|
|
113
|
+
continue
|
|
114
|
+
return None
|
|
115
|
+
except Exception:
|
|
116
|
+
if attempt < retries:
|
|
117
|
+
time.sleep(1)
|
|
118
|
+
continue
|
|
119
|
+
return None
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _clean_html(self, text: str) -> str:
|
|
123
|
+
"""Strip HTML tags and decode entities from text."""
|
|
124
|
+
import html
|
|
125
|
+
text = html.unescape(text)
|
|
126
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
127
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
128
|
+
return text
|
|
129
|
+
|
|
130
|
+
def _truncate(self, text: str, max_len: int = 500) -> str:
|
|
131
|
+
"""Truncate text to max_len chars."""
|
|
132
|
+
if len(text) <= max_len:
|
|
133
|
+
return text
|
|
134
|
+
return text[:max_len].rsplit(" ", 1)[0] + "..."
|
|
135
|
+
|
|
136
|
+
def _extract_next_data(self, html: str) -> dict | None:
|
|
137
|
+
"""Extract __NEXT_DATA__ JSON from a Next.js page."""
|
|
138
|
+
import json
|
|
139
|
+
m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
|
140
|
+
if m:
|
|
141
|
+
try:
|
|
142
|
+
return json.loads(m.group(1))
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
return None
|
|
145
|
+
# Try data-inline-stat json
|
|
146
|
+
m2 = re.search(r'data-inline-stat=["\']({.*?})["\']', html, re.DOTALL)
|
|
147
|
+
if m2:
|
|
148
|
+
try:
|
|
149
|
+
return json.loads(m2.group(1))
|
|
150
|
+
except json.JSONDecodeError:
|
|
151
|
+
pass
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
def _extract_json_ld(self, html: str) -> list[dict]:
|
|
155
|
+
"""Extract all JSON-LD structured data blobs from HTML."""
|
|
156
|
+
import json
|
|
157
|
+
results = []
|
|
158
|
+
for m in re.finditer(r'<script[^>]*type="application/ld\+json"[^>]*>(.*?)</script>', html, re.DOTALL):
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(m.group(1))
|
|
161
|
+
if isinstance(data, list):
|
|
162
|
+
results.extend(data)
|
|
163
|
+
else:
|
|
164
|
+
results.append(data)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
pass
|
|
167
|
+
return results
|
|
168
|
+
|
|
169
|
+
def _browser_get(self, url: str, timeout: int = 15, **kwargs) -> object | None:
|
|
170
|
+
"""Fetch a page using Playwright browser automation (anti-bot bypass)."""
|
|
171
|
+
from .browser import browser_get
|
|
172
|
+
return browser_get(url, timeout=timeout, **kwargs)
|
|
173
|
+
|
|
174
|
+
def _browser_available(self) -> bool:
|
|
175
|
+
"""Check if Playwright browser automation is available."""
|
|
176
|
+
from .browser import is_playwright_available
|
|
177
|
+
return is_playwright_available()
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _is_login_wall(text: str) -> bool:
|
|
181
|
+
"""Detect if the page is behind a login/authentication wall.
|
|
182
|
+
|
|
183
|
+
Returns True if the page shows a sign-in wall (not just a form on
|
|
184
|
+
a legitimate content page).
|
|
185
|
+
"""
|
|
186
|
+
low = text.lower()
|
|
187
|
+
markers = [
|
|
188
|
+
"sign in to view", "sign in to see", "sign in to continue",
|
|
189
|
+
"please sign in", "please log in", "please login",
|
|
190
|
+
"create an account to view", "create your account to view",
|
|
191
|
+
"join to view", "join to see", "subscribe to view",
|
|
192
|
+
"login to view", "login to see",
|
|
193
|
+
"auth-wall", "authentication-wall",
|
|
194
|
+
]
|
|
195
|
+
# Require at least 2 markers to reduce false positives
|
|
196
|
+
count = sum(1 for m in markers if m in low)
|
|
197
|
+
return count >= 2
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Browser automation module for anti-bot / JS-dependent job sites.
|
|
2
|
+
|
|
3
|
+
Supports Playwright (auto-installed) with a graceful fallback
|
|
4
|
+
to plain HTTP when the browser is not available.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _FakeResp:
|
|
17
|
+
"""Mimics httpx.Response so callers don't need branching logic."""
|
|
18
|
+
def __init__(self, text: str = "", status_code: int = 200):
|
|
19
|
+
self.text = text
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
|
|
22
|
+
def json(self) -> Any:
|
|
23
|
+
import json
|
|
24
|
+
return json.loads(self.text)
|
|
25
|
+
|
|
26
|
+
def raise_for_status(self) -> None:
|
|
27
|
+
if self.status_code >= 400:
|
|
28
|
+
raise Exception(f"HTTP {self.status_code}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Lazy singleton for the browser manager
|
|
32
|
+
_BROWSER_INSTANCE: dict[str, Any] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_playwright_page(headless: bool = True, timeout_ms: int = 30000) -> Any:
|
|
36
|
+
"""Lazy-init a Playwright browser page. Returns None on failure."""
|
|
37
|
+
global _BROWSER_INSTANCE
|
|
38
|
+
if "page" in _BROWSER_INSTANCE:
|
|
39
|
+
try:
|
|
40
|
+
_BROWSER_INSTANCE["page"].evaluate("1")
|
|
41
|
+
return _BROWSER_INSTANCE["page"]
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from playwright.sync_api import sync_playwright as _sp
|
|
47
|
+
except ImportError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
p = _sp().start()
|
|
52
|
+
browser = p.chromium.launch(
|
|
53
|
+
headless=headless,
|
|
54
|
+
args=[
|
|
55
|
+
"--no-sandbox",
|
|
56
|
+
"--disable-blink-features=AutomationControlled",
|
|
57
|
+
"--disable-dev-shm-usage",
|
|
58
|
+
],
|
|
59
|
+
)
|
|
60
|
+
ctx = browser.new_context(
|
|
61
|
+
user_agent=(
|
|
62
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
63
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
64
|
+
"Chrome/128.0.0.0 Safari/537.36"
|
|
65
|
+
),
|
|
66
|
+
viewport={"width": 1920, "height": 1080},
|
|
67
|
+
locale="en-US",
|
|
68
|
+
)
|
|
69
|
+
page = ctx.new_page()
|
|
70
|
+
page.set_default_timeout(timeout_ms)
|
|
71
|
+
_BROWSER_INSTANCE["playwright"] = p
|
|
72
|
+
_BROWSER_INSTANCE["browser"] = browser
|
|
73
|
+
_BROWSER_INSTANCE["context"] = ctx
|
|
74
|
+
_BROWSER_INSTANCE["page"] = page
|
|
75
|
+
return page
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_playwright_available() -> bool:
|
|
81
|
+
"""Check if Playwright + chromium are installed."""
|
|
82
|
+
try:
|
|
83
|
+
import playwright # noqa: F401
|
|
84
|
+
except ImportError:
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
from playwright.sync_api import sync_playwright as _sp # noqa: F401
|
|
88
|
+
return True
|
|
89
|
+
except ImportError:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def browser_get(
|
|
94
|
+
url: str,
|
|
95
|
+
timeout: int = 15,
|
|
96
|
+
wait_for: str | None = None,
|
|
97
|
+
wait_selector: str | None = None,
|
|
98
|
+
scroll: bool = False,
|
|
99
|
+
headless: bool = True,
|
|
100
|
+
) -> _FakeResp | None:
|
|
101
|
+
"""Fetch a page using Playwright, falling back to None.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
url : str
|
|
106
|
+
The URL to fetch.
|
|
107
|
+
timeout : int
|
|
108
|
+
Page load timeout in seconds.
|
|
109
|
+
wait_for : str, optional
|
|
110
|
+
A CSS selector or XPath to wait for before returning.
|
|
111
|
+
wait_selector : str, optional
|
|
112
|
+
Alias for wait_for.
|
|
113
|
+
scroll : bool
|
|
114
|
+
If True, scroll to bottom to trigger lazy loading.
|
|
115
|
+
headless : bool
|
|
116
|
+
Run browser in headless mode.
|
|
117
|
+
"""
|
|
118
|
+
page = _get_playwright_page(headless=headless, timeout_ms=timeout * 1000)
|
|
119
|
+
if page is None:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
|
124
|
+
# Wait for network idle (up to half the timeout)
|
|
125
|
+
try:
|
|
126
|
+
page.wait_for_load_state("networkidle", timeout=min(timeout * 500, 10000))
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
# Wait for a specific selector if requested
|
|
130
|
+
selector = wait_for or wait_selector
|
|
131
|
+
if selector:
|
|
132
|
+
try:
|
|
133
|
+
page.wait_for_selector(selector, timeout=5000)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
# Scroll to trigger lazy content
|
|
137
|
+
if scroll:
|
|
138
|
+
try:
|
|
139
|
+
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
|
140
|
+
time.sleep(1)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
html = page.content()
|
|
144
|
+
return _FakeResp(text=html, status_code=200)
|
|
145
|
+
except Exception:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def browser_extract_json(
|
|
150
|
+
url: str,
|
|
151
|
+
timeout: int = 15,
|
|
152
|
+
script_pattern: str | None = None,
|
|
153
|
+
headless: bool = True,
|
|
154
|
+
) -> list[dict] | dict | None:
|
|
155
|
+
"""Fetch a page via browser and extract embedded JSON data.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
url : str
|
|
160
|
+
The URL to fetch.
|
|
161
|
+
timeout : int
|
|
162
|
+
Page load timeout in seconds.
|
|
163
|
+
script_pattern : str, optional
|
|
164
|
+
Regex pattern to find JSON inside <script> tags.
|
|
165
|
+
If None, returns full page as text.
|
|
166
|
+
headless : bool
|
|
167
|
+
Run browser in headless mode.
|
|
168
|
+
"""
|
|
169
|
+
page = _get_playwright_page(headless=headless, timeout_ms=timeout * 1000)
|
|
170
|
+
if page is None:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
|
175
|
+
try:
|
|
176
|
+
page.wait_for_load_state("networkidle", timeout=min(timeout * 500, 10000))
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
if script_pattern:
|
|
181
|
+
content = page.content()
|
|
182
|
+
import json
|
|
183
|
+
for m in re.finditer(script_pattern, content, re.DOTALL):
|
|
184
|
+
try:
|
|
185
|
+
return json.loads(m.group(1))
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
continue
|
|
188
|
+
return None
|
|
189
|
+
else:
|
|
190
|
+
return page.content()
|
|
191
|
+
except Exception:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def close_browser() -> None:
|
|
196
|
+
"""Clean up browser resources."""
|
|
197
|
+
global _BROWSER_INSTANCE
|
|
198
|
+
try:
|
|
199
|
+
if "page" in _BROWSER_INSTANCE:
|
|
200
|
+
_BROWSER_INSTANCE["page"].close()
|
|
201
|
+
if "context" in _BROWSER_INSTANCE:
|
|
202
|
+
_BROWSER_INSTANCE["context"].close()
|
|
203
|
+
if "browser" in _BROWSER_INSTANCE:
|
|
204
|
+
_BROWSER_INSTANCE["browser"].close()
|
|
205
|
+
if "playwright" in _BROWSER_INSTANCE:
|
|
206
|
+
_BROWSER_INSTANCE["playwright"].stop()
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
_BROWSER_INSTANCE = {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def install_playwright(progress_callback=None) -> bool:
|
|
213
|
+
"""Install Playwright and chromium browser."""
|
|
214
|
+
try:
|
|
215
|
+
import subprocess
|
|
216
|
+
if progress_callback:
|
|
217
|
+
progress_callback("Installing Playwright Python package...")
|
|
218
|
+
r = subprocess.run(
|
|
219
|
+
[sys.executable, "-m", "pip", "install", "--quiet", "playwright"],
|
|
220
|
+
capture_output=True, text=True,
|
|
221
|
+
)
|
|
222
|
+
if r.returncode != 0:
|
|
223
|
+
if progress_callback:
|
|
224
|
+
progress_callback(f"pip install failed: {r.stderr}")
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
if progress_callback:
|
|
228
|
+
progress_callback("Installing Chromium browser (~150MB)...")
|
|
229
|
+
r = subprocess.run(
|
|
230
|
+
[sys.executable, "-m", "playwright", "install", "chromium"],
|
|
231
|
+
capture_output=True, text=True,
|
|
232
|
+
)
|
|
233
|
+
if r.returncode != 0:
|
|
234
|
+
if progress_callback:
|
|
235
|
+
progress_callback(f"chromium install failed: {r.stderr}")
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
if progress_callback:
|
|
239
|
+
progress_callback("Playwright + Chromium installed successfully!")
|
|
240
|
+
return True
|
|
241
|
+
except Exception as e:
|
|
242
|
+
if progress_callback:
|
|
243
|
+
progress_callback(f"Install error: {e}")
|
|
244
|
+
return False
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Parsers package — auto-imports all platform parsers to trigger registration."""
|
|
2
|
+
|
|
3
|
+
from . import ( # noqa: F401
|
|
4
|
+
himalayas,
|
|
5
|
+
remoteok,
|
|
6
|
+
remotive,
|
|
7
|
+
weworkremotely,
|
|
8
|
+
hn_hiring,
|
|
9
|
+
jobicy,
|
|
10
|
+
adzuna,
|
|
11
|
+
jooble,
|
|
12
|
+
arc,
|
|
13
|
+
crypto_careers,
|
|
14
|
+
dailyremote,
|
|
15
|
+
relocate,
|
|
16
|
+
web3_career,
|
|
17
|
+
ycomb,
|
|
18
|
+
linkedin,
|
|
19
|
+
indeed,
|
|
20
|
+
wellfound,
|
|
21
|
+
flexjobs,
|
|
22
|
+
opentoworkremote,
|
|
23
|
+
remote_rocketship,
|
|
24
|
+
)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Adzuna — structured job API with salary data. Tier 1 (free tier).
|
|
2
|
+
|
|
3
|
+
Requires ADZUNA_APP_ID and ADZUNA_API_KEY env vars. Falls back gracefully.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import urllib.error
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..base import BaseParser, JobListing
|
|
15
|
+
from ..registry import register_parser
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@register_parser
|
|
19
|
+
class AdzunaParser(BaseParser):
|
|
20
|
+
platform_name = "adzuna"
|
|
21
|
+
|
|
22
|
+
def fetch_jobs(self, query: str = "", **kwargs: Any) -> list[JobListing]:
|
|
23
|
+
app_id = os.environ.get("ADZUNA_APP_ID")
|
|
24
|
+
api_key = os.environ.get("ADZUNA_API_KEY")
|
|
25
|
+
if not app_id or not api_key:
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
country = kwargs.get("country", "gb")
|
|
29
|
+
params = {
|
|
30
|
+
"app_id": app_id,
|
|
31
|
+
"app_key": api_key,
|
|
32
|
+
"results_per_page": str(min(kwargs.get("limit", 25), 50)),
|
|
33
|
+
"content_type": "application/json",
|
|
34
|
+
}
|
|
35
|
+
if query:
|
|
36
|
+
params["what"] = query
|
|
37
|
+
params["where"] = kwargs.get("location", "")
|
|
38
|
+
|
|
39
|
+
url = f"https://api.adzuna.com/v1/api/jobs/{country}/search/1?" + urllib.parse.urlencode(params)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Chameleon/1.0"})
|
|
43
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
44
|
+
data = json.loads(resp.read().decode())
|
|
45
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
jobs: list[JobListing] = []
|
|
49
|
+
for j in data.get("results", [])[:kwargs.get("limit", 25)]:
|
|
50
|
+
title = (j.get("title") or "").strip()
|
|
51
|
+
company = (j.get("company", {}) or {}).get("display_name") or "Unknown"
|
|
52
|
+
url = j.get("redirect_url") or j.get("url") or ""
|
|
53
|
+
desc = (j.get("description") or "")[:2000]
|
|
54
|
+
location = j.get("location", {}).get("display_name") or j.get("location") or ""
|
|
55
|
+
salary_min = j.get("salary_min") or j.get("salary_minimum")
|
|
56
|
+
salary_max = j.get("salary_max") or j.get("salary_maximum")
|
|
57
|
+
salary = ""
|
|
58
|
+
if salary_min and salary_max:
|
|
59
|
+
salary = f"${int(salary_min):,} - ${int(salary_max):,}"
|
|
60
|
+
elif salary_min:
|
|
61
|
+
salary = f"${int(salary_min):,}+"
|
|
62
|
+
remote = False
|
|
63
|
+
if desc and ("remote" in desc.lower() or "anywhere" in desc.lower()):
|
|
64
|
+
remote = True
|
|
65
|
+
if location and ("remote" in location.lower()):
|
|
66
|
+
remote = True
|
|
67
|
+
|
|
68
|
+
job_id = j.get("id") or f"adzuna-{hash(title + company)}"
|
|
69
|
+
jobs.append(JobListing(
|
|
70
|
+
job_id=f"adz-{job_id}",
|
|
71
|
+
title=title,
|
|
72
|
+
company=company,
|
|
73
|
+
url=url,
|
|
74
|
+
location=location,
|
|
75
|
+
description=desc,
|
|
76
|
+
salary=salary,
|
|
77
|
+
source=self.platform_name,
|
|
78
|
+
remote=remote,
|
|
79
|
+
tags=[],
|
|
80
|
+
))
|
|
81
|
+
return jobs
|