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.
Files changed (151) hide show
  1. package/AGENTS.md +186 -0
  2. package/LICENSE +21 -0
  3. package/Makefile +136 -0
  4. package/README.md +356 -0
  5. package/bin/chameleon.js +136 -0
  6. package/chameleon.bat +286 -0
  7. package/chameleon.sh +353 -0
  8. package/package.json +39 -0
  9. package/profiles/github_repos.json +1266 -0
  10. package/requirements.txt +5 -0
  11. package/scan.sh +12 -0
  12. package/score.sh +12 -0
  13. package/scripts/__init__.py +1 -0
  14. package/scripts/__main_tui__.py +11 -0
  15. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
  18. package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
  19. package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
  20. package/scripts/__pycache__/config.cpython-312.pyc +0 -0
  21. package/scripts/__pycache__/config.cpython-313.pyc +0 -0
  22. package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
  23. package/scripts/__pycache__/db.cpython-312.pyc +0 -0
  24. package/scripts/__pycache__/db.cpython-313.pyc +0 -0
  25. package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
  26. package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
  27. package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
  28. package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
  29. package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
  30. package/scripts/__pycache__/question.cpython-313.pyc +0 -0
  31. package/scripts/__pycache__/render.cpython-313.pyc +0 -0
  32. package/scripts/__pycache__/review.cpython-313.pyc +0 -0
  33. package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
  34. package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
  35. package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
  36. package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
  37. package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
  38. package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
  39. package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
  40. package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
  41. package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
  42. package/scripts/ats_ghost.py +170 -0
  43. package/scripts/cache.py +63 -0
  44. package/scripts/cli.py +109 -0
  45. package/scripts/config.py +376 -0
  46. package/scripts/cover_letter.py +105 -0
  47. package/scripts/db.py +331 -0
  48. package/scripts/fonts.py +186 -0
  49. package/scripts/ghost.py +88 -0
  50. package/scripts/job_matcher.py +636 -0
  51. package/scripts/job_scanner/__init__.py +8 -0
  52. package/scripts/job_scanner/__main__.py +6 -0
  53. package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
  56. package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
  57. package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
  58. package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
  59. package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
  60. package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
  61. package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
  62. package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
  63. package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
  64. package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
  65. package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
  66. package/scripts/job_scanner/base.py +197 -0
  67. package/scripts/job_scanner/browser.py +244 -0
  68. package/scripts/job_scanner/cli.py +6 -0
  69. package/scripts/job_scanner/parsers/__init__.py +24 -0
  70. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
  71. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
  72. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
  73. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
  74. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
  75. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
  76. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
  77. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
  78. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
  79. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
  80. package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
  81. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
  82. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
  83. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
  84. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
  85. package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
  86. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
  87. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
  88. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
  89. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
  90. package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
  91. package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
  92. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
  93. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
  94. package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
  95. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
  96. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
  97. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
  98. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
  99. package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
  100. package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
  101. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
  102. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
  103. package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
  104. package/scripts/job_scanner/parsers/adzuna.py +81 -0
  105. package/scripts/job_scanner/parsers/arc.py +65 -0
  106. package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
  107. package/scripts/job_scanner/parsers/dailyremote.py +91 -0
  108. package/scripts/job_scanner/parsers/flexjobs.py +91 -0
  109. package/scripts/job_scanner/parsers/himalayas.py +47 -0
  110. package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
  111. package/scripts/job_scanner/parsers/indeed.py +98 -0
  112. package/scripts/job_scanner/parsers/jobicy.py +60 -0
  113. package/scripts/job_scanner/parsers/jooble.py +68 -0
  114. package/scripts/job_scanner/parsers/linkedin.py +140 -0
  115. package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
  116. package/scripts/job_scanner/parsers/relocate.py +95 -0
  117. package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
  118. package/scripts/job_scanner/parsers/remoteok.py +63 -0
  119. package/scripts/job_scanner/parsers/remotive.py +57 -0
  120. package/scripts/job_scanner/parsers/web3_career.py +92 -0
  121. package/scripts/job_scanner/parsers/wellfound.py +94 -0
  122. package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
  123. package/scripts/job_scanner/parsers/ycomb.py +83 -0
  124. package/scripts/job_scanner/registry.py +65 -0
  125. package/scripts/job_scanner/scanner.py +162 -0
  126. package/scripts/question.py +107 -0
  127. package/scripts/render.py +110 -0
  128. package/scripts/review.py +89 -0
  129. package/scripts/setup.py +139 -0
  130. package/scripts/tailor_brief.py +379 -0
  131. package/scripts/tailor_cv.py +746 -0
  132. package/scripts/tailor_prompts.py +101 -0
  133. package/scripts/tui/__init__.py +1 -0
  134. package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
  135. package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
  137. package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
  138. package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
  139. package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
  140. package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
  141. package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
  142. package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
  143. package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
  144. package/scripts/tui/app.py +1547 -0
  145. package/scripts/tui/helpers.py +65 -0
  146. package/scripts/tui/screens.py +642 -0
  147. package/scripts/tui/widgets.py +276 -0
  148. package/scripts/tui_app.py +25 -0
  149. package/scripts/wizard.py +109 -0
  150. package/tui.bat +50 -0
  151. 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,6 @@
1
+ """CLI entry point for job scanner: python3 -m scripts.job_scanner.scanner"""
2
+
3
+ from .scanner import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ )
@@ -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