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,65 @@
1
+ """Arc.dev — parser for remote dev jobs via __NEXT_DATA__."""
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 ArcParser(BaseParser):
10
+ platform_name = "arc"
11
+ BASE_URL = "https://arc.dev/remote-jobs"
12
+
13
+ def fetch_jobs(
14
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
15
+ ) -> list[JobListing]:
16
+ url = self.BASE_URL
17
+ if query:
18
+ slug = query.lower().replace(" ", "-")
19
+ url = f"{self.BASE_URL}/{slug}"
20
+
21
+ resp = self._http_get(url)
22
+ if resp is None:
23
+ return []
24
+
25
+ data = self._extract_next_data(resp.text)
26
+ if data is None:
27
+ return []
28
+
29
+ page_props = data.get("props", {}).get("pageProps", {})
30
+ jobs = []
31
+
32
+ for source_key in ("arcJobs", "externalJobs"):
33
+ items = page_props.get(source_key, [])
34
+ for item in items[:limit]:
35
+ company = item.get("company", {})
36
+ if isinstance(company, dict):
37
+ company_name = company.get("name", "")
38
+ else:
39
+ company_name = str(company) if company else ""
40
+
41
+ job_title = item.get("title", "") or ""
42
+ url_str = item.get("urlString", "")
43
+ job_url = f"https://arc.dev/remote-jobs/{url_str}" if url_str else ""
44
+
45
+ categories = item.get("categories", [])
46
+ tags_list = [c.get("name", str(c)) if isinstance(c, dict) else str(c) for c in categories]
47
+
48
+ jobs.append(
49
+ JobListing(
50
+ title=job_title,
51
+ company=company_name,
52
+ url=job_url,
53
+ location="Remote",
54
+ remote=True,
55
+ tags=tags_list,
56
+ posted_at=item.get("postedAt", ""),
57
+ source=self.platform_name,
58
+ )
59
+ )
60
+ if len(jobs) >= limit:
61
+ break
62
+ if len(jobs) >= limit:
63
+ break
64
+
65
+ return jobs
@@ -0,0 +1,88 @@
1
+ """Crypto Careers — parser with HTTP + Playwright browser fallback for Cloudflare."""
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 CryptoCareersParser(BaseParser):
12
+ platform_name = "crypto_careers"
13
+ BASE_URL = "https://www.crypto-careers.com"
14
+
15
+ def fetch_jobs(
16
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
17
+ ) -> list[JobListing]:
18
+ jobs = []
19
+
20
+ # Attempt 1: HTTP (usually Cloudflare-blocked)
21
+ resp = self._http_get(self.BASE_URL)
22
+ if resp and "cloudflare" not in resp.text[:500].lower() and len(resp.text) > 2000:
23
+ self._parse_html(resp.text, jobs, query, limit)
24
+
25
+ # Attempt 2: Browser fallback (bypasses Cloudflare)
26
+ if not jobs and self._browser_available():
27
+ browser_resp = self._browser_get(
28
+ self.BASE_URL, timeout=25,
29
+ wait_selector="[class*='job'], article, .card, tr",
30
+ scroll=True,
31
+ )
32
+ if browser_resp and not self._is_login_wall(browser_resp.text):
33
+ self._parse_html(browser_resp.text, jobs, query, limit)
34
+
35
+ return jobs
36
+
37
+ def _parse_html(self, html: str, jobs: list, query: str, limit: int) -> None:
38
+ # Try JSON-LD
39
+ ld = self._extract_json_ld(html)
40
+ seen = set()
41
+ for entry in ld:
42
+ if entry.get("@type") != "JobPosting":
43
+ continue
44
+ title = (entry.get("title", "") or "").strip()
45
+ org = entry.get("hiringOrganization", {})
46
+ company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
47
+ url = entry.get("url", "")
48
+ if not title or not company_name:
49
+ continue
50
+ key = f"{title}|{company_name}"
51
+ if key in seen:
52
+ continue
53
+ seen.add(key)
54
+ if query and query.lower() not in f"{title} {company_name}".lower():
55
+ continue
56
+ jobs.append(
57
+ JobListing(
58
+ title=title, company=company_name, url=url,
59
+ location="Remote", remote=True,
60
+ tags=["crypto", "blockchain"],
61
+ description=self._truncate(self._clean_html(entry.get("description", "")), 500),
62
+ source=self.platform_name,
63
+ )
64
+ )
65
+ if len(jobs) >= limit:
66
+ return
67
+
68
+ # HTML card pattern
69
+ card = re.compile(
70
+ r'<a[^>]*href="(/job/[^"]+)"[^>]*>.*?'
71
+ r'<(?:h[234]|span|div)[^>]*>([^<]+)</.*?'
72
+ r'<(?:span|div|p)[^>]*class="[^"]*(?:company|employer)[^"]*"[^>]*>([^<]+)<',
73
+ re.DOTALL,
74
+ )
75
+ for match in card.finditer(html):
76
+ path, title, company = match.groups()
77
+ if query and query.lower() not in f"{title} {company}".lower():
78
+ continue
79
+ jobs.append(
80
+ JobListing(
81
+ title=title.strip(), company=company.strip(),
82
+ url=f"{self.BASE_URL}{path}",
83
+ location="Remote", remote=True,
84
+ tags=["crypto", "blockchain"], source=self.platform_name,
85
+ )
86
+ )
87
+ if len(jobs) >= limit:
88
+ return
@@ -0,0 +1,91 @@
1
+ """DailyRemote — 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 DailyRemoteParser(BaseParser):
12
+ platform_name = "dailyremote"
13
+ SEARCH_URL = "https://dailyremote.com/remote-jobs"
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
+ params["page"] = kwargs.get("page", 1)
22
+
23
+ jobs = []
24
+
25
+ # Attempt 1: HTTP
26
+ resp = self._http_get(self.SEARCH_URL, params=params)
27
+ if resp and len(resp.text) > 1000:
28
+ self._parse_html(resp.text, jobs, query, limit)
29
+
30
+ # Attempt 2: Browser fallback
31
+ if not jobs and self._browser_available():
32
+ qs = "&".join(f"{k}={v}" for k, v in params.items())
33
+ browser_resp = self._browser_get(
34
+ f"{self.SEARCH_URL}?{qs}", timeout=20,
35
+ wait_selector="[class*='card'], [class*='job'], article, tr",
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
+ description=self._truncate(self._clean_html(entry.get("description", "")), 500),
67
+ )
68
+ )
69
+ if len(jobs) >= limit:
70
+ return
71
+
72
+ # HTML card pattern
73
+ card = re.compile(
74
+ r'<a[^>]*href="(/remote-jobs/[^"]+)"[^>]*>.*?'
75
+ r'<(?:h[234]|span)[^>]*>([^<]+)</.*?'
76
+ r'<(?:span|div)[^>]*class="[^"]*(?:company|employer)[^"]*"[^>]*>([^<]+)<',
77
+ re.DOTALL,
78
+ )
79
+ for match in card.finditer(html):
80
+ path, title, company = match.groups()
81
+ if query and query.lower() not in f"{title} {company}".lower():
82
+ continue
83
+ jobs.append(
84
+ JobListing(
85
+ title=title.strip(), company=company.strip(),
86
+ url=f"https://dailyremote.com{path}",
87
+ location="Remote", remote=True, source=self.platform_name,
88
+ )
89
+ )
90
+ if len(jobs) >= limit:
91
+ return
@@ -0,0 +1,91 @@
1
+ """FlexJobs — 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 FlexJobsParser(BaseParser):
12
+ platform_name = "flexjobs"
13
+ SEARCH_URL = "https://www.flexjobs.com/search"
14
+
15
+ def fetch_jobs(
16
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
17
+ ) -> list[JobListing]:
18
+ params = {"search": query}
19
+ location = kwargs.get("location", "")
20
+ if location:
21
+ params["location"] = location
22
+
23
+ jobs = []
24
+
25
+ # Attempt 1: HTTP
26
+ resp = self._http_get(self.SEARCH_URL, params=params)
27
+ if resp and len(resp.text) > 1000:
28
+ self._parse_html(resp.text, jobs, query, limit, location)
29
+
30
+ # Attempt 2: Browser fallback
31
+ if not jobs and self._browser_available():
32
+ qs = "&".join(f"{k}={v}" for k, v in params.items())
33
+ browser_resp = self._browser_get(
34
+ f"{self.SEARCH_URL}?{qs}", timeout=20,
35
+ wait_selector="[class*='job'], [class*='card'], .mosaic",
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, location)
40
+
41
+ return jobs
42
+
43
+ def _parse_html(self, html: str, jobs: list, query: str, limit: int, location: str) -> None:
44
+ ld = self._extract_json_ld(html)
45
+ seen = set()
46
+ for entry in ld:
47
+ if entry.get("@type") != "JobPosting":
48
+ continue
49
+ title = (entry.get("title", "") or "").strip()
50
+ org = entry.get("hiringOrganization", {})
51
+ company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
52
+ url = entry.get("url", "")
53
+ if not title or not company_name:
54
+ continue
55
+ key = f"{title}|{company_name}"
56
+ if key in seen:
57
+ continue
58
+ seen.add(key)
59
+ if query and query.lower() not in f"{title} {company_name}".lower():
60
+ continue
61
+ jobs.append(
62
+ JobListing(
63
+ title=title, company=company_name, url=url,
64
+ location=location or "",
65
+ remote=True, tags=["flexible"],
66
+ source=self.platform_name,
67
+ )
68
+ )
69
+ if len(jobs) >= limit:
70
+ return
71
+
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"https://www.flexjobs.com{path}",
86
+ location=location or "", remote=True,
87
+ tags=["flexible"], source=self.platform_name,
88
+ )
89
+ )
90
+ if len(jobs) >= limit:
91
+ return
@@ -0,0 +1,47 @@
1
+ """Himalayas — 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 HimalayasParser(BaseParser):
11
+ platform_name = "himalayas"
12
+ API_URL = "https://himalayas.app/jobs/api"
13
+ SEARCH_URL = "https://himalayas.app/jobs/api/search"
14
+
15
+ def fetch_jobs(
16
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
17
+ ) -> list[JobListing]:
18
+ params: dict = {"limit": min(limit, 50)}
19
+ url = self.SEARCH_URL if query else self.API_URL
20
+ if query:
21
+ params["q"] = query
22
+
23
+ resp = self._http_get(url, params=params)
24
+ if resp is None:
25
+ return []
26
+
27
+ try:
28
+ data = resp.json()
29
+ except Exception:
30
+ return []
31
+
32
+ jobs = []
33
+ for item in data.get("jobs", [])[:limit]:
34
+ jobs.append(
35
+ JobListing(
36
+ title=item.get("title", ""),
37
+ company=item.get("companyName", ""),
38
+ url=item.get("applicationLink") or item.get("guid", ""),
39
+ location=item.get("location", ""),
40
+ remote=item.get("remote", True),
41
+ tags=[t.get("name", "") for t in item.get("tags", [])],
42
+ posted_at=item.get("datePosted", ""),
43
+ description=self._truncate(self._clean_html(item.get("description", ""))),
44
+ source=self.platform_name,
45
+ )
46
+ )
47
+ return jobs
@@ -0,0 +1,94 @@
1
+ """HN Hiring (Who Is Hiring) — Algolia API, no auth required."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..base import BaseParser, JobListing
8
+ from ..registry import register_parser
9
+
10
+
11
+ @register_parser
12
+ class HNHiringParser(BaseParser):
13
+ platform_name = "hn_hiring"
14
+ ALGOLIA_URL = "https://hn.algolia.com/api/v1/search_by_date"
15
+ ITEMS_URL = "https://hn.algolia.com/api/v1/items"
16
+
17
+ def fetch_jobs(
18
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
19
+ ) -> list[JobListing]:
20
+ # Find the latest "Ask HN: Who is hiring?" thread
21
+ resp = self._http_get(
22
+ self.ALGOLIA_URL,
23
+ params={"tags": "story", "query": "Ask HN Who is hiring"},
24
+ )
25
+ if resp is None:
26
+ return []
27
+
28
+ try:
29
+ hits = resp.json().get("hits", [])
30
+ except Exception:
31
+ return []
32
+
33
+ # Find the actual "Ask HN: Who is hiring?" thread
34
+ thread_id = None
35
+ for hit in hits:
36
+ title = hit.get("title", "")
37
+ lower = title.lower()
38
+ if "ask hn" in lower and "who is hiring?" in lower and "(" in title:
39
+ thread_id = hit.get("objectID")
40
+ break
41
+
42
+ if not thread_id:
43
+ return []
44
+
45
+ # Fetch thread comments
46
+ resp = self._http_get(f"{self.ITEMS_URL}/{thread_id}")
47
+ if resp is None:
48
+ return []
49
+
50
+ try:
51
+ thread = resp.json()
52
+ except Exception:
53
+ return []
54
+
55
+ comments = thread.get("children", [])
56
+
57
+ jobs = []
58
+ for comment in comments:
59
+ text = comment.get("text", "")
60
+ if not text or comment.get("dead") or comment.get("deleted"):
61
+ continue
62
+
63
+ clean = self._clean_html(text)
64
+ lines = clean.split("\n")
65
+ header = lines[0] if lines else ""
66
+
67
+ parts = [p.strip() for p in header.split("|")]
68
+ company = parts[0] if parts else ""
69
+ title = parts[1] if len(parts) > 1 else ""
70
+ location = parts[2] if len(parts) > 2 else ""
71
+
72
+ if query and query.lower() not in clean.lower():
73
+ continue
74
+
75
+ url = f"https://news.ycombinator.com/item?id={comment.get('id', '')}"
76
+
77
+ jobs.append(
78
+ JobListing(
79
+ title=title or "See posting",
80
+ company=company,
81
+ url=url,
82
+ location=location,
83
+ remote="remote" in clean.lower(),
84
+ tags=[],
85
+ posted_at=thread.get("time", ""),
86
+ description=self._truncate(clean),
87
+ source=self.platform_name,
88
+ job_id=str(comment.get("id", "")),
89
+ )
90
+ )
91
+ if len(jobs) >= limit:
92
+ break
93
+
94
+ return jobs
@@ -0,0 +1,98 @@
1
+ """Indeed — 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 IndeedParser(BaseParser):
12
+ platform_name = "indeed"
13
+ SEARCH_URL = "https://www.indeed.com/jobs"
14
+
15
+ def fetch_jobs(
16
+ self, query: str = "", tags: list[str] | None = None, limit: int = 25, **kwargs
17
+ ) -> list[JobListing]:
18
+ params = {"q": query}
19
+ location = kwargs.get("location", "")
20
+ if location:
21
+ params["l"] = location
22
+ remote = kwargs.get("remote", False)
23
+ if remote:
24
+ params["remotejob"] = "032b3046-06a3-4876-8dfd-474eb5e7ed11"
25
+
26
+ jobs = []
27
+
28
+ # Attempt 1: HTTP
29
+ resp = self._http_get(self.SEARCH_URL, params=params, retries=1)
30
+ if resp and len(resp.text) > 1000:
31
+ self._parse_html(resp.text, jobs, query, limit, location, remote)
32
+
33
+ # Attempt 2: Browser fallback
34
+ if not jobs and self._browser_available():
35
+ qs = "&".join(f"{k}={v}" for k, v in params.items())
36
+ browser_resp = self._browser_get(
37
+ f"{self.SEARCH_URL}?{qs}",
38
+ timeout=20,
39
+ wait_selector=".jobsearch-ResultsList",
40
+ scroll=True,
41
+ )
42
+ if browser_resp and not self._is_login_wall(browser_resp.text):
43
+ self._parse_html(browser_resp.text, jobs, query, limit, location, remote)
44
+
45
+ return jobs
46
+
47
+ def _parse_html(self, html: str, jobs: list, query: str, limit: int,
48
+ location: str, remote: bool) -> None:
49
+ # Try JSON-LD first
50
+ ld = self._extract_json_ld(html)
51
+ seen = set()
52
+ for entry in ld:
53
+ if entry.get("@type") != "JobPosting":
54
+ continue
55
+ title = (entry.get("title", "") or "").strip()
56
+ org = entry.get("hiringOrganization", {})
57
+ company_name = (org.get("name", "") if isinstance(org, dict) else str(org)).strip()
58
+ url = entry.get("url", "")
59
+ if not title or not company_name:
60
+ continue
61
+ key = f"{title}|{company_name}"
62
+ if key in seen:
63
+ continue
64
+ seen.add(key)
65
+ if query and query.lower() not in f"{title} {company_name}".lower():
66
+ continue
67
+ jobs.append(
68
+ JobListing(
69
+ title=title, company=company_name, url=url,
70
+ location=location or "", remote=remote,
71
+ description=self._truncate(self._clean_html(entry.get("description", "")), 500),
72
+ source=self.platform_name,
73
+ )
74
+ )
75
+ if len(jobs) >= limit:
76
+ return
77
+
78
+ # Fallback: HTML card pattern
79
+ card = re.compile(
80
+ r'<a[^>]*href="(/rc/clk[^"]*|/viewjob[^"]*)"[^>]*>.*?'
81
+ r'<(?:h2|span)[^>]*class="[^"]*jobTitle[^"]*"[^>]*>([^<]+)</.*?'
82
+ r'<(?:span|div)[^>]*data-testid="[^"]*company[^"]*"[^>]*>([^<]+)<',
83
+ re.DOTALL,
84
+ )
85
+ for match in card.finditer(html):
86
+ path, title, company = match.groups()
87
+ url = f"https://www.indeed.com{path.split('?')[0]}"
88
+ if query and query.lower() not in f"{title} {company}".lower():
89
+ continue
90
+ jobs.append(
91
+ JobListing(
92
+ title=title.strip(), company=company.strip(),
93
+ url=url, location=location or "",
94
+ remote=remote, source=self.platform_name,
95
+ )
96
+ )
97
+ if len(jobs) >= limit:
98
+ return
@@ -0,0 +1,60 @@
1
+ """Jobicy — remote job board with a clean JSON API. Tier 1."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import urllib.parse
7
+ import urllib.request
8
+ import urllib.error
9
+ from typing import Any
10
+
11
+ from ..base import BaseParser, JobListing
12
+ from ..registry import register_parser
13
+
14
+
15
+ @register_parser
16
+ class JobicyParser(BaseParser):
17
+ platform_name = "jobicy"
18
+
19
+ def fetch_jobs(self, query: str = "", **kwargs: Any) -> list[JobListing]:
20
+ api_url = "https://jobicy.com/api/v2/remote-jobs"
21
+ params = []
22
+ if query:
23
+ params.append(f"tag={urllib.parse.quote(query)}")
24
+ url = api_url + ("?" + "&".join(params) if params else "")
25
+
26
+ try:
27
+ req = urllib.request.Request(url, headers={"User-Agent": "Chameleon/1.0"})
28
+ with urllib.request.urlopen(req, timeout=15) as resp:
29
+ data = json.loads(resp.read().decode())
30
+ except (urllib.error.URLError, json.JSONDecodeError, OSError):
31
+ return []
32
+
33
+ jobs: list[JobListing] = []
34
+ raw_jobs = data.get("jobs", []) if isinstance(data, dict) else data
35
+ for j in raw_jobs[:kwargs.get("limit", 25)]:
36
+ title = (j.get("jobTitle") or j.get("title") or "").strip()
37
+ company = (j.get("companyName") or j.get("company") or "").strip()
38
+ url = j.get("url") or j.get("jobURL") or ""
39
+ desc = (j.get("jobDescription") or j.get("description") or "")[:2000]
40
+ location = j.get("jobGeo") or j.get("location") or "Remote"
41
+ salary = j.get("salary") or j.get("salaryRange") or ""
42
+ remote = "remote" in str(location).lower() or "anywhere" in str(location).lower()
43
+ tags = j.get("jobIndustry") or j.get("tags") or []
44
+ if isinstance(tags, str):
45
+ tags = [t.strip() for t in tags.split(",") if t.strip()]
46
+
47
+ job_id = j.get("id") or j.get("jobId") or f"jobicy-{hash(title + company)}"
48
+ jobs.append(JobListing(
49
+ job_id=str(job_id),
50
+ title=title,
51
+ company=company,
52
+ url=url,
53
+ location=location,
54
+ description=desc,
55
+ salary=str(salary),
56
+ source=self.platform_name,
57
+ remote=remote,
58
+ tags=tags if isinstance(tags, list) else [str(tags)],
59
+ ))
60
+ return jobs