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,68 @@
1
+ """Jooble — job aggregator with structured API. Tier 1 (free tier key).
2
+
3
+ Requires JOOBLE_API_KEY env var. Falls back gracefully.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import urllib.request
10
+ import urllib.error
11
+ from typing import Any
12
+
13
+ from ..base import BaseParser, JobListing
14
+ from ..registry import register_parser
15
+
16
+
17
+ @register_parser
18
+ class JoobleParser(BaseParser):
19
+ platform_name = "jooble"
20
+
21
+ def fetch_jobs(self, query: str = "", **kwargs: Any) -> list[JobListing]:
22
+ api_key = os.environ.get("JOOBLE_API_KEY")
23
+ if not api_key:
24
+ return []
25
+
26
+ url = f"https://jooble.org/api/{api_key}"
27
+ payload = {"keywords": query or "software engineer", "page": 1}
28
+ if kwargs.get("location"):
29
+ payload["location"] = kwargs["location"]
30
+
31
+ try:
32
+ req = urllib.request.Request(
33
+ url,
34
+ data=json.dumps(payload).encode(),
35
+ headers={
36
+ "Content-Type": "application/json",
37
+ "User-Agent": "Chameleon/1.0",
38
+ },
39
+ )
40
+ with urllib.request.urlopen(req, timeout=15) as resp:
41
+ data = json.loads(resp.read().decode())
42
+ except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError):
43
+ return []
44
+
45
+ jobs: list[JobListing] = []
46
+ for j in data.get("jobs", [])[:kwargs.get("limit", 25)]:
47
+ title = (j.get("title") or "").strip()
48
+ company = (j.get("company") or "Unknown").strip()
49
+ url = j.get("link") or j.get("url") or ""
50
+ desc = (j.get("snippet") or j.get("description") or "")[:2000]
51
+ location = j.get("location") or ""
52
+ salary = j.get("salary") or ""
53
+ remote = "remote" in str(location).lower() or "anywhere" in str(desc).lower()
54
+
55
+ job_id = j.get("id") or f"jooble-{hash(title + company)}"
56
+ jobs.append(JobListing(
57
+ job_id=f"job-{job_id}",
58
+ title=title,
59
+ company=company,
60
+ url=url,
61
+ location=location,
62
+ description=desc,
63
+ salary=str(salary),
64
+ source=self.platform_name,
65
+ remote=remote,
66
+ tags=[],
67
+ ))
68
+ return jobs
@@ -0,0 +1,140 @@
1
+ """LinkedIn Jobs — guest API + browser fallback for anti-bot."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from ..base import BaseParser, JobListing
7
+ from ..registry import register_parser
8
+
9
+
10
+ @register_parser
11
+ class LinkedInParser(BaseParser):
12
+ platform_name = "linkedin"
13
+ GUEST_API = "https://www.linkedin.com/jobs-guest/jobs/api/seeMoreJobPostings/search"
14
+ BROWSE_URL = "https://www.linkedin.com/jobs/search"
15
+
16
+ def fetch_jobs(
17
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
18
+ ) -> list[JobListing]:
19
+ params = {
20
+ "keywords": query,
21
+ "start": kwargs.get("start", 0),
22
+ "f_TPR": "r604800",
23
+ }
24
+ location = kwargs.get("location", "")
25
+ if location:
26
+ params["location"] = location
27
+
28
+ jobs = []
29
+
30
+ # Attempt 1: Guest API (HTTP)
31
+ resp = self._http_get(
32
+ self.GUEST_API,
33
+ params=params,
34
+ headers={"Accept": "text/html,application/xhtml+xml"},
35
+ )
36
+ if resp and len(resp.text) > 500:
37
+ self._parse_guest_api(resp.text, jobs, query, limit)
38
+
39
+ # Attempt 2: Browser automation fallback
40
+ if not jobs and self._browser_available():
41
+ browse_params = {
42
+ "keywords": query or "software engineer",
43
+ "f_TPR": "r604800",
44
+ }
45
+ qs = "&".join(f"{k}={v}" for k, v in browse_params.items())
46
+ browser_resp = self._browser_get(
47
+ f"{self.BROWSE_URL}?{qs}",
48
+ timeout=20,
49
+ wait_selector=".job-card-container",
50
+ scroll=True,
51
+ )
52
+ if browser_resp and not self._is_login_wall(browser_resp.text):
53
+ self._parse_browser_html(browser_resp.text, jobs, query, limit)
54
+
55
+ return jobs
56
+
57
+ def _parse_guest_api(self, html: str, jobs: list, query: str, limit: int) -> None:
58
+ card_pattern = re.compile(
59
+ r'<a[^>]*href="(https://www\.linkedin\.com/jobs/view/[^"]+)"[^>]*>.*?'
60
+ r'<h3[^>]*>([^<]+)</h3>.*?'
61
+ r'<h4[^>]*>([^<]+)</h4>',
62
+ re.DOTALL,
63
+ )
64
+ for match in card_pattern.finditer(html):
65
+ url, title, company = match.groups()
66
+ if query and query.lower() not in f"{title} {company}".lower():
67
+ continue
68
+ jobs.append(
69
+ JobListing(
70
+ title=self._clean_html(title).strip(),
71
+ company=self._clean_html(company).strip(),
72
+ url=url.split("?")[0],
73
+ location="",
74
+ remote=True,
75
+ source=self.platform_name,
76
+ )
77
+ )
78
+ if len(jobs) >= limit:
79
+ break
80
+
81
+ def _parse_browser_html(self, html: str, jobs: list, query: str, limit: int) -> None:
82
+ # Try JSON-LD first
83
+ ld = self._extract_json_ld(html)
84
+ seen = set()
85
+ for entry in ld:
86
+ if entry.get("@type") != "JobPosting":
87
+ continue
88
+ title = (entry.get("title", "") or "").strip()
89
+ org = entry.get("hiringOrganization", {})
90
+ company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
91
+ url = entry.get("url", "") or entry.get("directApply", "")
92
+ if isinstance(url, dict):
93
+ url = url.get("url", "")
94
+ if not title or not company_name:
95
+ continue
96
+ key = f"{title}|{company_name}"
97
+ if key in seen:
98
+ continue
99
+ seen.add(key)
100
+ if query and query.lower() not in f"{title} {company_name}".lower():
101
+ continue
102
+ jobs.append(
103
+ JobListing(
104
+ title=title,
105
+ company=company_name,
106
+ url=url,
107
+ location="Remote",
108
+ remote=True,
109
+ description=self._truncate(self._clean_html(entry.get("description", "")), 500),
110
+ source=self.platform_name,
111
+ )
112
+ )
113
+ if len(jobs) >= limit:
114
+ break
115
+
116
+ # Fallback: HTML card pattern
117
+ if not jobs:
118
+ card = re.compile(
119
+ r'class="[^"]*job-card-container[^"]*"[^>]*>.*?'
120
+ r'<a[^>]*href="(/jobs/view/[^"]+)"[^>]*>.*?'
121
+ r'<span[^>]*>([^<]+)</span>.*?'
122
+ r'<span[^>]*>([^<]+)</span>',
123
+ re.DOTALL,
124
+ )
125
+ for match in card.finditer(html):
126
+ path, title, company = match.groups()
127
+ if query and query.lower() not in f"{title} {company}".lower():
128
+ continue
129
+ jobs.append(
130
+ JobListing(
131
+ title=self._clean_html(title).strip(),
132
+ company=self._clean_html(company).strip(),
133
+ url=f"https://www.linkedin.com{path}",
134
+ location="",
135
+ remote=True,
136
+ source=self.platform_name,
137
+ )
138
+ )
139
+ if len(jobs) >= limit:
140
+ break
@@ -0,0 +1,53 @@
1
+ """OpenToWorkRemote — RapidAPI aggregator (requires API key)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from ..base import BaseParser, JobListing
8
+ from ..registry import register_parser
9
+
10
+
11
+ @register_parser
12
+ class OpenToWorkRemoteParser(BaseParser):
13
+ platform_name = "opentoworkremote"
14
+ API_URL = "https://remote-jobs1.p.rapidapi.com/search"
15
+
16
+ def fetch_jobs(
17
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
18
+ ) -> list[JobListing]:
19
+ import os
20
+ api_key = kwargs.get("rapidapi_key") or os.environ.get("RAPIDAPI_KEY", "")
21
+ if not api_key:
22
+ return []
23
+
24
+ params = {"title": query, "limit": limit}
25
+ try:
26
+ resp = httpx.get(
27
+ self.API_URL,
28
+ params=params,
29
+ timeout=15,
30
+ headers={
31
+ "X-RapidAPI-Key": api_key,
32
+ "X-RapidAPI-Host": "remote-jobs1.p.rapidapi.com",
33
+ },
34
+ )
35
+ resp.raise_for_status()
36
+ data = resp.json()
37
+ except Exception:
38
+ return []
39
+
40
+ jobs = []
41
+ for item in data.get("jobs", data if isinstance(data, list) else [])[:limit]:
42
+ jobs.append(
43
+ JobListing(
44
+ title=item.get("title", ""),
45
+ company=item.get("company_name", item.get("company", "")),
46
+ url=item.get("url", item.get("job_url", "")),
47
+ location=item.get("location", "Remote"),
48
+ remote=True,
49
+ tags=item.get("tags", []),
50
+ source=self.platform_name,
51
+ )
52
+ )
53
+ return jobs
@@ -0,0 +1,95 @@
1
+ """Relocate.me — scraping-based parser for relocation-friendly jobs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ import re
7
+
8
+ from ..base import BaseParser, JobListing
9
+ from ..registry import register_parser
10
+
11
+
12
+ @register_parser
13
+ class RelocateParser(BaseParser):
14
+ platform_name = "relocate"
15
+ SEARCH_URL = "https://relocate.me/search"
16
+
17
+ def fetch_jobs(
18
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
19
+ ) -> list[JobListing]:
20
+ params = {}
21
+ if query:
22
+ params["query"] = query
23
+
24
+ resp = self._http_get(self.SEARCH_URL, params=params, timeout=15)
25
+ if resp is None:
26
+ return []
27
+
28
+ html = resp.text
29
+ jobs = []
30
+
31
+ # Try JSON-LD first
32
+ ld = self._extract_json_ld(html)
33
+ seen = set()
34
+ for entry in ld:
35
+ if entry.get("@type") != "JobPosting":
36
+ continue
37
+ title = (entry.get("title", "") or "").strip()
38
+ org = entry.get("hiringOrganization", {})
39
+ company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
40
+ url = entry.get("url", "")
41
+ if not title or not company_name:
42
+ continue
43
+ key = f"{title}|{company_name}"
44
+ if key in seen:
45
+ continue
46
+ seen.add(key)
47
+ if query and query.lower() not in f"{title} {company_name}".lower():
48
+ continue
49
+ location = entry.get("jobLocation", "")
50
+ if isinstance(location, dict):
51
+ location = location.get("address", {}).get("addressLocality", location.get("name", "Remote"))
52
+ if isinstance(location, dict):
53
+ location = "Relocation required"
54
+ jobs.append(
55
+ JobListing(
56
+ title=title,
57
+ company=company_name,
58
+ url=url or f"https://relocate.me/job/{title.lower().replace(' ', '-')}",
59
+ location=str(location),
60
+ remote=False,
61
+ tags=["relocation"],
62
+ description=self._truncate(self._clean_html(entry.get("description", "")), 500),
63
+ source=self.platform_name,
64
+ )
65
+ )
66
+ if len(jobs) >= limit:
67
+ break
68
+
69
+ # Fallback: HTML card parsing
70
+ if not jobs:
71
+ card_pattern = re.compile(
72
+ r'<a[^>]*href="(/job[^"]+)"[^>]*>.*?'
73
+ r'<(?:h[234]|span)[^>]*>([^<]+)</.*?'
74
+ r'(?:<(?:span|div)[^>]*class="[^"]*company[^"]*"[^>]*>([^<]+)<|([^<]{3,50})</)',
75
+ re.DOTALL,
76
+ )
77
+ for match in card_pattern.finditer(html):
78
+ path, title, company = match.group(1), match.group(2), match.group(3) or match.group(4) or ""
79
+ if query and query.lower() not in f"{title} {company}".lower():
80
+ continue
81
+ jobs.append(
82
+ JobListing(
83
+ title=title.strip(),
84
+ company=company.strip() if company else "Unknown",
85
+ url=f"https://relocate.me{path}",
86
+ location="Relocation required",
87
+ remote=False,
88
+ tags=["relocation"],
89
+ source=self.platform_name,
90
+ )
91
+ )
92
+ if len(jobs) >= limit:
93
+ break
94
+
95
+ return jobs
@@ -0,0 +1,90 @@
1
+ """Remote Rocketship — parser with HTTP + Playwright browser fallback."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from ..base import BaseParser, JobListing
7
+ from ..registry import register_parser
8
+
9
+
10
+ @register_parser
11
+ class RemoteRocketshipParser(BaseParser):
12
+ platform_name = "remote_rocketship"
13
+ BASE_URL = "https://remoterocketship.com"
14
+
15
+ def fetch_jobs(
16
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
17
+ ) -> list[JobListing]:
18
+ params = {}
19
+ if query:
20
+ params["q"] = query
21
+
22
+ jobs = []
23
+
24
+ # Attempt 1: HTTP (usually Cloudflare-blocked)
25
+ resp = self._http_get(self.BASE_URL, params=params)
26
+ if resp and "cloudflare" not in resp.text[:500].lower() and len(resp.text) > 2000:
27
+ self._parse_html(resp.text, jobs, query, limit)
28
+
29
+ # Attempt 2: Browser fallback
30
+ if not jobs and self._browser_available():
31
+ qs = "&".join(f"{k}={v}" for k, v in params.items()) if params else ""
32
+ url = f"{self.BASE_URL}?{qs}" if qs else self.BASE_URL
33
+ browser_resp = self._browser_get(
34
+ url, timeout=25,
35
+ wait_selector="[class*='job'], [class*='card'], article, .position",
36
+ scroll=True,
37
+ )
38
+ if browser_resp and not self._is_login_wall(browser_resp.text):
39
+ self._parse_html(browser_resp.text, jobs, query, limit)
40
+
41
+ return jobs
42
+
43
+ def _parse_html(self, html: str, jobs: list, query: str, limit: int) -> None:
44
+ # Try JSON-LD
45
+ ld = self._extract_json_ld(html)
46
+ seen = set()
47
+ for entry in ld:
48
+ if entry.get("@type") != "JobPosting":
49
+ continue
50
+ title = (entry.get("title", "") or "").strip()
51
+ org = entry.get("hiringOrganization", {})
52
+ company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
53
+ url = entry.get("url", "")
54
+ if not title or not company_name:
55
+ continue
56
+ key = f"{title}|{company_name}"
57
+ if key in seen:
58
+ continue
59
+ seen.add(key)
60
+ if query and query.lower() not in f"{title} {company_name}".lower():
61
+ continue
62
+ jobs.append(
63
+ JobListing(
64
+ title=title, company=company_name, url=url,
65
+ location="Remote", remote=True, source=self.platform_name,
66
+ )
67
+ )
68
+ if len(jobs) >= limit:
69
+ return
70
+
71
+ # HTML card pattern
72
+ card = re.compile(
73
+ r'<a[^>]*href="(/job/[^"]+)"[^>]*>.*?'
74
+ r'<(?:h[234]|span)[^>]*>([^<]+)</.*?'
75
+ r'<(?:span|div)[^>]*class="[^"]*(?:company|employer)[^"]*"[^>]*>([^<]+)<',
76
+ re.DOTALL,
77
+ )
78
+ for match in card.finditer(html):
79
+ path, title, company = match.groups()
80
+ if query and query.lower() not in f"{title} {company}".lower():
81
+ continue
82
+ jobs.append(
83
+ JobListing(
84
+ title=title.strip(), company=company.strip(),
85
+ url=f"{self.BASE_URL}{path}",
86
+ location="Remote", remote=True, source=self.platform_name,
87
+ )
88
+ )
89
+ if len(jobs) >= limit:
90
+ return
@@ -0,0 +1,63 @@
1
+ """Remote OK — free JSON API + RSS, no auth required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..base import BaseParser, JobListing
6
+ from ..registry import register_parser
7
+
8
+
9
+ @register_parser
10
+ class RemoteOKParser(BaseParser):
11
+ platform_name = "remoteok"
12
+ API_URL = "https://remoteok.com/api"
13
+
14
+ def fetch_jobs(
15
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
16
+ ) -> list[JobListing]:
17
+ url = self.API_URL
18
+ if tags:
19
+ url += "?tag=" + tags[0]
20
+
21
+ resp = self._http_get(url)
22
+ if resp is None:
23
+ return []
24
+
25
+ try:
26
+ items = resp.json()
27
+ except Exception:
28
+ return []
29
+
30
+ # First item is metadata
31
+ if items and isinstance(items[0], dict) and "legal" in str(items[0]):
32
+ items = items[1:]
33
+
34
+ q = query.lower() if query else ""
35
+ jobs = []
36
+ for item in items:
37
+ if not isinstance(item, dict):
38
+ continue
39
+ title = item.get("position", "")
40
+ company = item.get("company", "")
41
+ job_tags = item.get("tags", [])
42
+ desc = item.get("description", "")
43
+ searchable = f"{title} {company} {' '.join(job_tags)} {desc}".lower()
44
+ if q and q not in searchable:
45
+ continue
46
+ jobs.append(
47
+ JobListing(
48
+ title=title,
49
+ company=company,
50
+ url=item.get("url", f"https://remoteok.com/remote-jobs/{item.get('id', '')}"),
51
+ location=item.get("location", "Remote"),
52
+ remote=True,
53
+ tags=item.get("tags", []),
54
+ salary=item.get("salary", ""),
55
+ posted_at=item.get("date", ""),
56
+ description=self._truncate(item.get("description", "")),
57
+ source=self.platform_name,
58
+ job_id=str(item.get("id", "")),
59
+ )
60
+ )
61
+ if len(jobs) >= limit:
62
+ break
63
+ return jobs
@@ -0,0 +1,57 @@
1
+ """Remotive — free JSON API + RSS, no auth required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..base import BaseParser, JobListing
6
+ from ..registry import register_parser
7
+
8
+
9
+ @register_parser
10
+ class RemotiveParser(BaseParser):
11
+ platform_name = "remotive"
12
+ API_URL = "https://remotive.com/api/remote-jobs"
13
+
14
+ def fetch_jobs(
15
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
16
+ ) -> list[JobListing]:
17
+ params: dict = {"limit": min(limit, 100)}
18
+ if query:
19
+ params["search"] = query
20
+ category = kwargs.get("category", "")
21
+ if category:
22
+ params["category"] = category
23
+
24
+ resp = self._http_get(self.API_URL, params=params)
25
+ if resp is None:
26
+ return []
27
+
28
+ try:
29
+ data = resp.json()
30
+ except Exception:
31
+ return []
32
+
33
+ jobs = []
34
+ q = query.lower() if query else ""
35
+ for item in data.get("jobs", []):
36
+ if q:
37
+ searchable = f"{item.get('title', '')} {item.get('company_name', '')} {' '.join(item.get('tags', []))}".lower()
38
+ if q not in searchable:
39
+ continue
40
+ jobs.append(
41
+ JobListing(
42
+ title=item.get("title", ""),
43
+ company=item.get("company_name", ""),
44
+ url=item.get("url", ""),
45
+ location=item.get("candidate_required_location", ""),
46
+ remote=item.get("remote", True),
47
+ tags=item.get("tags", []),
48
+ salary=item.get("salary", ""),
49
+ posted_at=item.get("publication_date", ""),
50
+ description=self._truncate(self._clean_html(item.get("description", ""))),
51
+ source=self.platform_name,
52
+ job_id=str(item.get("id", "")),
53
+ )
54
+ )
55
+ if len(jobs) >= limit:
56
+ break
57
+ return jobs
@@ -0,0 +1,92 @@
1
+ """Web3 Career — parser for web3/blockchain jobs via JSON-LD."""
2
+ from __future__ import annotations
3
+
4
+ from ..base import BaseParser, JobListing
5
+ from ..registry import register_parser
6
+
7
+
8
+ @register_parser
9
+ class Web3CareerParser(BaseParser):
10
+ platform_name = "web3_career"
11
+ BASE_URL = "https://web3.career"
12
+
13
+ def fetch_jobs(
14
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
15
+ ) -> list[JobListing]:
16
+ url = f"{self.BASE_URL}/web3-jobs"
17
+ if query:
18
+ slug = query.lower().replace(" ", "+")
19
+ url = f"{self.BASE_URL}/{slug}+crypto-jobs"
20
+
21
+ resp = self._http_get(url)
22
+ if resp is None:
23
+ return []
24
+
25
+ ld = self._extract_json_ld(resp.text)
26
+ jobs = []
27
+ seen = set()
28
+
29
+ for entry in ld:
30
+ if entry.get("@type") != "JobPosting":
31
+ continue
32
+ title = (entry.get("title", "") or "").strip()
33
+ company = entry.get("hiringOrganization", {})
34
+ if isinstance(company, dict):
35
+ company_name = (company.get("name", "") or "").strip()
36
+ else:
37
+ company_name = str(company).strip() if company else ""
38
+ # Skip artist/portfolio entries mislabeled as JobPosting
39
+ if company_name.lower() in ("nftphotographers.xyz",) or not company_name:
40
+ continue
41
+ if not title or len(title) < 5:
42
+ continue
43
+
44
+ direct_apply = entry.get("directApply", entry.get("url", ""))
45
+ if isinstance(direct_apply, dict):
46
+ direct_apply = direct_apply.get("url", "")
47
+ if not direct_apply:
48
+ direct_apply = entry.get("url", "")
49
+
50
+ desc = self._clean_html(entry.get("description", ""))
51
+ location = entry.get("jobLocation", "")
52
+ if isinstance(location, dict):
53
+ location = location.get("address", {}).get("addressLocality", location.get("name", "Remote"))
54
+ if isinstance(location, dict):
55
+ location = "Remote"
56
+
57
+ salary = entry.get("baseSalary", {})
58
+ salary_str = ""
59
+ if isinstance(salary, dict):
60
+ min_v = salary.get("value", {}).get("minValue", salary.get("value", ""))
61
+ max_v = salary.get("value", {}).get("maxValue", "")
62
+ if min_v and max_v:
63
+ salary_str = f"${min_v} - ${max_v}"
64
+ elif min_v:
65
+ salary_str = f"${min_v}+"
66
+
67
+ dedup_key = f"{title}|{company_name}"
68
+ if dedup_key in seen:
69
+ continue
70
+ seen.add(dedup_key)
71
+
72
+ if query and query.lower() not in f"{title} {company_name}".lower():
73
+ continue
74
+
75
+ job_url = direct_apply or f"{self.BASE_URL}/job/{title.lower().replace(' ', '-')}"
76
+ jobs.append(
77
+ JobListing(
78
+ title=title.strip(),
79
+ company=company_name.strip(),
80
+ url=job_url,
81
+ location=location if isinstance(location, str) else "Remote",
82
+ remote="remote" in str(location).lower() or "anywhere" in str(location).lower(),
83
+ tags=["web3", "crypto"],
84
+ description=self._truncate(desc, 500),
85
+ salary=salary_str,
86
+ source=self.platform_name,
87
+ )
88
+ )
89
+ if len(jobs) >= limit:
90
+ break
91
+
92
+ return jobs