job-hunter-kit 0.1__tar.gz

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 (236) hide show
  1. job_hunter_kit-0.1/LICENSE +21 -0
  2. job_hunter_kit-0.1/PKG-INFO +123 -0
  3. job_hunter_kit-0.1/README.md +80 -0
  4. job_hunter_kit-0.1/job_hunter/__init__.py +6 -0
  5. job_hunter_kit-0.1/job_hunter/agent_context/__init__.py +24 -0
  6. job_hunter_kit-0.1/job_hunter/agent_context/_types.py +25 -0
  7. job_hunter_kit-0.1/job_hunter/agent_context/_utils.py +44 -0
  8. job_hunter_kit-0.1/job_hunter/agent_context/batch.py +125 -0
  9. job_hunter_kit-0.1/job_hunter/agent_context/briefing.py +194 -0
  10. job_hunter_kit-0.1/job_hunter/agent_context/candidates.py +219 -0
  11. job_hunter_kit-0.1/job_hunter/agent_context/lifecycle.py +205 -0
  12. job_hunter_kit-0.1/job_hunter/agent_context/score_context.py +77 -0
  13. job_hunter_kit-0.1/job_hunter/agent_context/stories.py +121 -0
  14. job_hunter_kit-0.1/job_hunter/briefing.py +74 -0
  15. job_hunter_kit-0.1/job_hunter/cli/__init__.py +1094 -0
  16. job_hunter_kit-0.1/job_hunter/cli/__main__.py +7 -0
  17. job_hunter_kit-0.1/job_hunter/cli/_dispatch.py +134 -0
  18. job_hunter_kit-0.1/job_hunter/config/__init__.py +27 -0
  19. job_hunter_kit-0.1/job_hunter/config/defaults.py +307 -0
  20. job_hunter_kit-0.1/job_hunter/config/loader.py +224 -0
  21. job_hunter_kit-0.1/job_hunter/constants.py +9 -0
  22. job_hunter_kit-0.1/job_hunter/core/__init__.py +1 -0
  23. job_hunter_kit-0.1/job_hunter/core/api_budget.py +139 -0
  24. job_hunter_kit-0.1/job_hunter/core/config.py +66 -0
  25. job_hunter_kit-0.1/job_hunter/core/config_schema.py +35 -0
  26. job_hunter_kit-0.1/job_hunter/core/llm_utils.py +84 -0
  27. job_hunter_kit-0.1/job_hunter/core/metrics.py +22 -0
  28. job_hunter_kit-0.1/job_hunter/core/url_liveness.py +49 -0
  29. job_hunter_kit-0.1/job_hunter/core/utils.py +64 -0
  30. job_hunter_kit-0.1/job_hunter/data_contract.py +89 -0
  31. job_hunter_kit-0.1/job_hunter/linkedin/__init__.py +0 -0
  32. job_hunter_kit-0.1/job_hunter/linkedin/_config.py +190 -0
  33. job_hunter_kit-0.1/job_hunter/linkedin/defaults.yml +81 -0
  34. job_hunter_kit-0.1/job_hunter/linkedin/drafts.py +160 -0
  35. job_hunter_kit-0.1/job_hunter/linkedin/engagement.py +598 -0
  36. job_hunter_kit-0.1/job_hunter/linkedin/ideas.py +135 -0
  37. job_hunter_kit-0.1/job_hunter/llm/__init__.py +9 -0
  38. job_hunter_kit-0.1/job_hunter/llm/client.py +246 -0
  39. job_hunter_kit-0.1/job_hunter/models.py +221 -0
  40. job_hunter_kit-0.1/job_hunter/pipeline/__init__.py +0 -0
  41. job_hunter_kit-0.1/job_hunter/pipeline/cover_writer.py +197 -0
  42. job_hunter_kit-0.1/job_hunter/pipeline/enrichment.py +184 -0
  43. job_hunter_kit-0.1/job_hunter/pipeline/hunt.py +193 -0
  44. job_hunter_kit-0.1/job_hunter/pipeline/llm_stage.py +74 -0
  45. job_hunter_kit-0.1/job_hunter/pipeline/orchestrator.py +492 -0
  46. job_hunter_kit-0.1/job_hunter/pipeline/pdf_compiler.py +115 -0
  47. job_hunter_kit-0.1/job_hunter/pipeline/readme_writer.py +201 -0
  48. job_hunter_kit-0.1/job_hunter/pipeline/resolve_hunt_region.py +122 -0
  49. job_hunter_kit-0.1/job_hunter/pipeline/scorer.py +284 -0
  50. job_hunter_kit-0.1/job_hunter/pipeline/snapshot.py +43 -0
  51. job_hunter_kit-0.1/job_hunter/pipeline/tailor.py +120 -0
  52. job_hunter_kit-0.1/job_hunter/pipeline/tailorer.py +223 -0
  53. job_hunter_kit-0.1/job_hunter/pipeline/validator.py +235 -0
  54. job_hunter_kit-0.1/job_hunter/sources/__init__.py +0 -0
  55. job_hunter_kit-0.1/job_hunter/sources/_base.py +57 -0
  56. job_hunter_kit-0.1/job_hunter/sources/_policy.py +298 -0
  57. job_hunter_kit-0.1/job_hunter/sources/_scraper.py +11 -0
  58. job_hunter_kit-0.1/job_hunter/sources/adzuna_source.py +182 -0
  59. job_hunter_kit-0.1/job_hunter/sources/ai_web_search.py +507 -0
  60. job_hunter_kit-0.1/job_hunter/sources/arbeitsagentur_source.py +104 -0
  61. job_hunter_kit-0.1/job_hunter/sources/ats/__init__.py +53 -0
  62. job_hunter_kit-0.1/job_hunter/sources/ats/_base.py +68 -0
  63. job_hunter_kit-0.1/job_hunter/sources/ats/ashby.py +59 -0
  64. job_hunter_kit-0.1/job_hunter/sources/ats/bamboohr.py +79 -0
  65. job_hunter_kit-0.1/job_hunter/sources/ats/breezy.py +58 -0
  66. job_hunter_kit-0.1/job_hunter/sources/ats/dispatch.py +70 -0
  67. job_hunter_kit-0.1/job_hunter/sources/ats/greenhouse.py +64 -0
  68. job_hunter_kit-0.1/job_hunter/sources/ats/hibob.py +81 -0
  69. job_hunter_kit-0.1/job_hunter/sources/ats/lever.py +74 -0
  70. job_hunter_kit-0.1/job_hunter/sources/ats/personio.py +62 -0
  71. job_hunter_kit-0.1/job_hunter/sources/ats/recruitee.py +55 -0
  72. job_hunter_kit-0.1/job_hunter/sources/ats/smartrecruiters.py +82 -0
  73. job_hunter_kit-0.1/job_hunter/sources/ats/teamtailor.py +58 -0
  74. job_hunter_kit-0.1/job_hunter/sources/ats/workable.py +57 -0
  75. job_hunter_kit-0.1/job_hunter/sources/ats/workday.py +64 -0
  76. job_hunter_kit-0.1/job_hunter/sources/ats_urls.py +212 -0
  77. job_hunter_kit-0.1/job_hunter/sources/boards/__init__.py +75 -0
  78. job_hunter_kit-0.1/job_hunter/sources/career_pages/__init__.py +174 -0
  79. job_hunter_kit-0.1/job_hunter/sources/career_pages/_ats_patterns.py +129 -0
  80. job_hunter_kit-0.1/job_hunter/sources/career_pages/_jsonld.py +117 -0
  81. job_hunter_kit-0.1/job_hunter/sources/career_pages/_ladder.py +73 -0
  82. job_hunter_kit-0.1/job_hunter/sources/career_pages/_rendering.py +122 -0
  83. job_hunter_kit-0.1/job_hunter/sources/career_pages/_sitemap.py +101 -0
  84. job_hunter_kit-0.1/job_hunter/sources/careerjet_source.py +180 -0
  85. job_hunter_kit-0.1/job_hunter/sources/glints_source.py +177 -0
  86. job_hunter_kit-0.1/job_hunter/sources/gulftalent_source.py +198 -0
  87. job_hunter_kit-0.1/job_hunter/sources/himalayas_source.py +122 -0
  88. job_hunter_kit-0.1/job_hunter/sources/jd_fetcher.py +778 -0
  89. job_hunter_kit-0.1/job_hunter/sources/job_boards.py +289 -0
  90. job_hunter_kit-0.1/job_hunter/sources/jobbank_source.py +130 -0
  91. job_hunter_kit-0.1/job_hunter/sources/jobicy_source.py +89 -0
  92. job_hunter_kit-0.1/job_hunter/sources/jobspy_source.py +263 -0
  93. job_hunter_kit-0.1/job_hunter/sources/jobstreet_source.py +258 -0
  94. job_hunter_kit-0.1/job_hunter/sources/jooble_source.py +124 -0
  95. job_hunter_kit-0.1/job_hunter/sources/llm_search.py +15 -0
  96. job_hunter_kit-0.1/job_hunter/sources/mycareersfuture_source.py +145 -0
  97. job_hunter_kit-0.1/job_hunter/sources/orchestrator.py +114 -0
  98. job_hunter_kit-0.1/job_hunter/sources/reed_source.py +154 -0
  99. job_hunter_kit-0.1/job_hunter/sources/remoteok_source.py +87 -0
  100. job_hunter_kit-0.1/job_hunter/sources/remotive_source.py +89 -0
  101. job_hunter_kit-0.1/job_hunter/sources/scraper/__init__.py +23 -0
  102. job_hunter_kit-0.1/job_hunter/sources/scraper/_boards.py +227 -0
  103. job_hunter_kit-0.1/job_hunter/sources/scraper/_companies.py +32 -0
  104. job_hunter_kit-0.1/job_hunter/sources/scraper/_config.py +36 -0
  105. job_hunter_kit-0.1/job_hunter/sources/scraper/_discovery.py +88 -0
  106. job_hunter_kit-0.1/job_hunter/sources/scraper/_stats.py +91 -0
  107. job_hunter_kit-0.1/job_hunter/sources/search_providers/__init__.py +122 -0
  108. job_hunter_kit-0.1/job_hunter/sources/search_providers/_constants.py +76 -0
  109. job_hunter_kit-0.1/job_hunter/sources/search_providers/_result.py +34 -0
  110. job_hunter_kit-0.1/job_hunter/sources/search_providers/_url_utils.py +61 -0
  111. job_hunter_kit-0.1/job_hunter/sources/search_providers/ats_discovery.py +378 -0
  112. job_hunter_kit-0.1/job_hunter/sources/search_providers/discovery.py +41 -0
  113. job_hunter_kit-0.1/job_hunter/sources/search_providers/fetchers.py +267 -0
  114. job_hunter_kit-0.1/job_hunter/sources/search_providers/preflight.py +608 -0
  115. job_hunter_kit-0.1/job_hunter/sources/search_providers/providers.py +151 -0
  116. job_hunter_kit-0.1/job_hunter/sources/search_providers/router.py +270 -0
  117. job_hunter_kit-0.1/job_hunter/sources/source_config.py +91 -0
  118. job_hunter_kit-0.1/job_hunter/sources/the_muse_source.py +100 -0
  119. job_hunter_kit-0.1/job_hunter/sources/web_search/__init__.py +38 -0
  120. job_hunter_kit-0.1/job_hunter/sources/weworkremotely_source.py +95 -0
  121. job_hunter_kit-0.1/job_hunter/sources/workingnomads_source.py +75 -0
  122. job_hunter_kit-0.1/job_hunter/templates/__init__.py +1 -0
  123. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/SKILL.md +137 -0
  124. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/batch.md +99 -0
  125. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/brief.md +17 -0
  126. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/finalize.md +55 -0
  127. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/interview.md +33 -0
  128. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/one.md +40 -0
  129. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/outreach.md +34 -0
  130. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/research.md +41 -0
  131. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/score.md +68 -0
  132. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/screen.md +44 -0
  133. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/search.md +38 -0
  134. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/stories.md +33 -0
  135. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/job-hunter/modes/tailor.md +44 -0
  136. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/linkedin/SKILL.md +47 -0
  137. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/linkedin/modes/draft.md +31 -0
  138. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/linkedin/modes/engage.md +31 -0
  139. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/linkedin/modes/ideas.md +29 -0
  140. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/linkedin/modes/network.md +30 -0
  141. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/setup/SKILL.md +39 -0
  142. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/setup/modes/doctor.md +44 -0
  143. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/setup/modes/onboard.md +45 -0
  144. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/setup/modes/region.md +57 -0
  145. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/setup/modes/stories.md +33 -0
  146. job_hunter_kit-0.1/job_hunter/templates/workspace/.claude/skills/setup/modes/style.md +33 -0
  147. job_hunter_kit-0.1/job_hunter/templates/workspace/.env.example +24 -0
  148. job_hunter_kit-0.1/job_hunter/templates/workspace/.github/copilot-instructions.md +49 -0
  149. job_hunter_kit-0.1/job_hunter/templates/workspace/.github/workflows/find-jobs.yml +172 -0
  150. job_hunter_kit-0.1/job_hunter/templates/workspace/.github/workflows/linkedin.yml +72 -0
  151. job_hunter_kit-0.1/job_hunter/templates/workspace/.github/workflows/tailor-job.yml +92 -0
  152. job_hunter_kit-0.1/job_hunter/templates/workspace/.gitignore +18 -0
  153. job_hunter_kit-0.1/job_hunter/templates/workspace/AGENTS.md +53 -0
  154. job_hunter_kit-0.1/job_hunter/templates/workspace/CLAUDE.md +1 -0
  155. job_hunter_kit-0.1/job_hunter/templates/workspace/GEMINI.md +1 -0
  156. job_hunter_kit-0.1/job_hunter/templates/workspace/README.md +30 -0
  157. job_hunter_kit-0.1/job_hunter/templates/workspace/config/job_hunter.yml +105 -0
  158. job_hunter_kit-0.1/job_hunter/templates/workspace/config/schemas/job_hunter.schema.json +148 -0
  159. job_hunter_kit-0.1/job_hunter/templates/workspace/outputs/state/discovered_urls.yml +5 -0
  160. job_hunter_kit-0.1/job_hunter/templates/workspace/profile/altacv.cls +491 -0
  161. job_hunter_kit-0.1/job_hunter/templates/workspace/profile/career_context.md +89 -0
  162. job_hunter_kit-0.1/job_hunter/templates/workspace/profile/resume_double_column.tex +237 -0
  163. job_hunter_kit-0.1/job_hunter/templates/workspace/profile/resume_single_column.tex +220 -0
  164. job_hunter_kit-0.1/job_hunter/templates/workspace/profile/story_bank.md +25 -0
  165. job_hunter_kit-0.1/job_hunter/tracker.py +182 -0
  166. job_hunter_kit-0.1/job_hunter/tracking/__init__.py +0 -0
  167. job_hunter_kit-0.1/job_hunter/tracking/discovery_cache.py +110 -0
  168. job_hunter_kit-0.1/job_hunter/tracking/tracker.py +80 -0
  169. job_hunter_kit-0.1/job_hunter/update_safety.py +40 -0
  170. job_hunter_kit-0.1/job_hunter/ux/__init__.py +0 -0
  171. job_hunter_kit-0.1/job_hunter/ux/analytics.py +135 -0
  172. job_hunter_kit-0.1/job_hunter/ux/applications.py +286 -0
  173. job_hunter_kit-0.1/job_hunter/ux/briefing.py +82 -0
  174. job_hunter_kit-0.1/job_hunter/ux/dashboard.py +78 -0
  175. job_hunter_kit-0.1/job_hunter/ux/health.py +322 -0
  176. job_hunter_kit-0.1/job_hunter/workspace/__init__.py +1 -0
  177. job_hunter_kit-0.1/job_hunter/workspace/_assets.py +127 -0
  178. job_hunter_kit-0.1/job_hunter/workspace/init.py +84 -0
  179. job_hunter_kit-0.1/job_hunter/workspace/manifest.py +105 -0
  180. job_hunter_kit-0.1/job_hunter/workspace/skills.py +37 -0
  181. job_hunter_kit-0.1/job_hunter_kit.egg-info/PKG-INFO +123 -0
  182. job_hunter_kit-0.1/job_hunter_kit.egg-info/SOURCES.txt +234 -0
  183. job_hunter_kit-0.1/job_hunter_kit.egg-info/dependency_links.txt +1 -0
  184. job_hunter_kit-0.1/job_hunter_kit.egg-info/entry_points.txt +2 -0
  185. job_hunter_kit-0.1/job_hunter_kit.egg-info/requires.txt +27 -0
  186. job_hunter_kit-0.1/job_hunter_kit.egg-info/top_level.txt +1 -0
  187. job_hunter_kit-0.1/pyproject.toml +130 -0
  188. job_hunter_kit-0.1/setup.cfg +4 -0
  189. job_hunter_kit-0.1/tests/test_agent_context.py +773 -0
  190. job_hunter_kit-0.1/tests/test_ai_web_search.py +272 -0
  191. job_hunter_kit-0.1/tests/test_api_budget.py +210 -0
  192. job_hunter_kit-0.1/tests/test_applications.py +160 -0
  193. job_hunter_kit-0.1/tests/test_arbeitsagentur_source.py +88 -0
  194. job_hunter_kit-0.1/tests/test_ats.py +521 -0
  195. job_hunter_kit-0.1/tests/test_ats_urls.py +112 -0
  196. job_hunter_kit-0.1/tests/test_career_pages.py +321 -0
  197. job_hunter_kit-0.1/tests/test_cli.py +354 -0
  198. job_hunter_kit-0.1/tests/test_config.py +180 -0
  199. job_hunter_kit-0.1/tests/test_config_yaml.py +73 -0
  200. job_hunter_kit-0.1/tests/test_core_utils.py +13 -0
  201. job_hunter_kit-0.1/tests/test_cover_writer.py +99 -0
  202. job_hunter_kit-0.1/tests/test_dashboard_analytics.py +68 -0
  203. job_hunter_kit-0.1/tests/test_data_contract.py +73 -0
  204. job_hunter_kit-0.1/tests/test_enrichment.py +78 -0
  205. job_hunter_kit-0.1/tests/test_find_jobs_e2e.py +88 -0
  206. job_hunter_kit-0.1/tests/test_health.py +101 -0
  207. job_hunter_kit-0.1/tests/test_himalayas_source.py +122 -0
  208. job_hunter_kit-0.1/tests/test_hunt_pipeline.py +81 -0
  209. job_hunter_kit-0.1/tests/test_jd_fetcher.py +223 -0
  210. job_hunter_kit-0.1/tests/test_job_boards.py +446 -0
  211. job_hunter_kit-0.1/tests/test_job_policy.py +139 -0
  212. job_hunter_kit-0.1/tests/test_jobspy_source.py +240 -0
  213. job_hunter_kit-0.1/tests/test_linkedin.py +464 -0
  214. job_hunter_kit-0.1/tests/test_llm_client.py +76 -0
  215. job_hunter_kit-0.1/tests/test_llm_utils.py +64 -0
  216. job_hunter_kit-0.1/tests/test_models.py +32 -0
  217. job_hunter_kit-0.1/tests/test_new_sources.py +785 -0
  218. job_hunter_kit-0.1/tests/test_orchestrator.py +343 -0
  219. job_hunter_kit-0.1/tests/test_pdf_compiler.py +139 -0
  220. job_hunter_kit-0.1/tests/test_preflight.py +75 -0
  221. job_hunter_kit-0.1/tests/test_remotive_source.py +85 -0
  222. job_hunter_kit-0.1/tests/test_resolve_hunt_region.py +102 -0
  223. job_hunter_kit-0.1/tests/test_scorer.py +284 -0
  224. job_hunter_kit-0.1/tests/test_scraper.py +600 -0
  225. job_hunter_kit-0.1/tests/test_search_providers.py +553 -0
  226. job_hunter_kit-0.1/tests/test_skill_contracts.py +171 -0
  227. job_hunter_kit-0.1/tests/test_skills.py +232 -0
  228. job_hunter_kit-0.1/tests/test_source_base.py +41 -0
  229. job_hunter_kit-0.1/tests/test_source_preflight.py +157 -0
  230. job_hunter_kit-0.1/tests/test_sources.py +1599 -0
  231. job_hunter_kit-0.1/tests/test_tailorer.py +134 -0
  232. job_hunter_kit-0.1/tests/test_the_muse_source.py +73 -0
  233. job_hunter_kit-0.1/tests/test_tracker.py +106 -0
  234. job_hunter_kit-0.1/tests/test_url_liveness.py +34 -0
  235. job_hunter_kit-0.1/tests/test_validator.py +234 -0
  236. job_hunter_kit-0.1/tests/test_workspace_init.py +131 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abdul Basit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: job-hunter-kit
3
+ Version: 0.1
4
+ Summary: Job search automation with LLM API pipeline mode and Claude Code agent mode.
5
+ Author: Abdul Basit
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/abdulrbasit/job-hunter
8
+ Project-URL: Repository, https://github.com/abdulrbasit/job-hunter
9
+ Project-URL: Issues, https://github.com/abdulrbasit/job-hunter/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Office/Business
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: typer>=0.12
22
+ Requires-Dist: anthropic>=0.50.0
23
+ Requires-Dist: openai>=1.68.0
24
+ Requires-Dist: google-genai>=1.0.0
25
+ Requires-Dist: requests>=2.31.0
26
+ Requires-Dist: beautifulsoup4>=4.12.0
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: python-jobspy>=0.20.0
29
+ Requires-Dist: jsonschema>=4.0
30
+ Requires-Dist: headroom-ai>=0.1.0; sys_platform != "win32"
31
+ Provides-Extra: browser
32
+ Requires-Dist: playwright>=1.40.0; extra == "browser"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=8.0; extra == "dev"
35
+ Requires-Dist: ruff>=0.4; extra == "dev"
36
+ Requires-Dist: pytest-cov; extra == "dev"
37
+ Requires-Dist: ty>=0.0.1a20; extra == "dev"
38
+ Provides-Extra: secrets
39
+ Requires-Dist: keyring>=24.0.0; extra == "secrets"
40
+ Provides-Extra: all
41
+ Requires-Dist: job-hunter-kit[browser,secrets]; extra == "all"
42
+ Dynamic: license-file
43
+
44
+ # Job Hunter
45
+
46
+ Job Hunter is an installable Python package for running a personal job-search workspace. It has one CLI and two modes:
47
+
48
+ - `agent`: Python handles deterministic work; Claude/Codex skills handle screening, scoring, tailoring, and writing.
49
+ - `llm-api`: Python runs the autonomous LLM-backed pipeline for unattended jobs.
50
+
51
+ The default mode is `agent`.
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install job-hunter-kit
57
+ # or
58
+ uv tool install job-hunter-kit
59
+ ```
60
+
61
+ ## Create a Workspace
62
+
63
+ ```bash
64
+ job-hunter init my-job-hunter-workspace
65
+ cd my-job-hunter-workspace
66
+ cp .env.example .env
67
+ job-hunter config check
68
+ job-hunter doctor
69
+ ```
70
+
71
+ Edit `config/job_hunter.yml` with deterministic machine choices: titles, regions, exclusions, profile paths, mode, scoring thresholds, LLM search gate, and provider/model choices. Put personal positioning and writing preferences in `profile/career_context.md`. Secrets use fixed environment variable names in `.env` or GitHub Actions.
72
+
73
+ ## Daily Use
74
+
75
+ ```bash
76
+ job-hunter hunt --region primary
77
+ job-hunter brief
78
+ job-hunter dashboard --no-interactive
79
+ ```
80
+
81
+ In `agent` mode, open the workspace in Claude Code or Codex and use:
82
+
83
+ ```text
84
+ /job-hunter brief
85
+ /job-hunter batch
86
+ /job-hunter one <url>
87
+ /job-hunter finalize
88
+ ```
89
+
90
+ In `llm-api` mode, `job-hunter hunt` runs scrape, score, tailor, cover letter, PDF, tracker, and README updates in one pipeline.
91
+
92
+ ## Public CLI
93
+
94
+ - `job-hunter init <workspace>` creates a workspace.
95
+ - `job-hunter config check` validates `config/job_hunter.yml`.
96
+ - `job-hunter doctor` checks setup health.
97
+ - `job-hunter hunt` discovers and enriches jobs.
98
+ - `job-hunter brief` writes the daily briefing.
99
+ - `job-hunter tailor` processes provided job URLs or JD text.
100
+ - `job-hunter dashboard`, `job-hunter applications`, and `job-hunter analytics` inspect application state.
101
+ - `job-hunter update-skills` refreshes bundled `.claude/skills/` only.
102
+ - `job-hunter version` and `job-hunter update-info` show version and upgrade guidance.
103
+
104
+ Support commands such as `agent-context`, `import-job`, `compile-pdf`, `update-readme`, `mark-processed`, `discard-job`, `cleanup-transient`, and `finalize-run` exist for skills and automation.
105
+
106
+ ## Data Contract
107
+
108
+ Deterministic user choices live in `config/job_hunter.yml`; human career and writing guidance lives in `profile/career_context.md`. Persistent URL dedup lives in `outputs/state/discovered_urls.yml`. Product updates must not overwrite `config/`, `profile/`, `outputs/`, or `.env`.
109
+
110
+ See `DATA_CONTRACT.md` for the full contract.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ uv sync --extra dev
116
+ uv run pytest tests/ -q --tb=short
117
+ uv run ruff format --check job_hunter tests .github/scripts
118
+ uv run ruff check job_hunter tests .github/scripts
119
+ uv run ty check job_hunter tests
120
+ uv build
121
+ ```
122
+
123
+ MIT licensed. See `CONTRIBUTING.md`.
@@ -0,0 +1,80 @@
1
+ # Job Hunter
2
+
3
+ Job Hunter is an installable Python package for running a personal job-search workspace. It has one CLI and two modes:
4
+
5
+ - `agent`: Python handles deterministic work; Claude/Codex skills handle screening, scoring, tailoring, and writing.
6
+ - `llm-api`: Python runs the autonomous LLM-backed pipeline for unattended jobs.
7
+
8
+ The default mode is `agent`.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install job-hunter-kit
14
+ # or
15
+ uv tool install job-hunter-kit
16
+ ```
17
+
18
+ ## Create a Workspace
19
+
20
+ ```bash
21
+ job-hunter init my-job-hunter-workspace
22
+ cd my-job-hunter-workspace
23
+ cp .env.example .env
24
+ job-hunter config check
25
+ job-hunter doctor
26
+ ```
27
+
28
+ Edit `config/job_hunter.yml` with deterministic machine choices: titles, regions, exclusions, profile paths, mode, scoring thresholds, LLM search gate, and provider/model choices. Put personal positioning and writing preferences in `profile/career_context.md`. Secrets use fixed environment variable names in `.env` or GitHub Actions.
29
+
30
+ ## Daily Use
31
+
32
+ ```bash
33
+ job-hunter hunt --region primary
34
+ job-hunter brief
35
+ job-hunter dashboard --no-interactive
36
+ ```
37
+
38
+ In `agent` mode, open the workspace in Claude Code or Codex and use:
39
+
40
+ ```text
41
+ /job-hunter brief
42
+ /job-hunter batch
43
+ /job-hunter one <url>
44
+ /job-hunter finalize
45
+ ```
46
+
47
+ In `llm-api` mode, `job-hunter hunt` runs scrape, score, tailor, cover letter, PDF, tracker, and README updates in one pipeline.
48
+
49
+ ## Public CLI
50
+
51
+ - `job-hunter init <workspace>` creates a workspace.
52
+ - `job-hunter config check` validates `config/job_hunter.yml`.
53
+ - `job-hunter doctor` checks setup health.
54
+ - `job-hunter hunt` discovers and enriches jobs.
55
+ - `job-hunter brief` writes the daily briefing.
56
+ - `job-hunter tailor` processes provided job URLs or JD text.
57
+ - `job-hunter dashboard`, `job-hunter applications`, and `job-hunter analytics` inspect application state.
58
+ - `job-hunter update-skills` refreshes bundled `.claude/skills/` only.
59
+ - `job-hunter version` and `job-hunter update-info` show version and upgrade guidance.
60
+
61
+ Support commands such as `agent-context`, `import-job`, `compile-pdf`, `update-readme`, `mark-processed`, `discard-job`, `cleanup-transient`, and `finalize-run` exist for skills and automation.
62
+
63
+ ## Data Contract
64
+
65
+ Deterministic user choices live in `config/job_hunter.yml`; human career and writing guidance lives in `profile/career_context.md`. Persistent URL dedup lives in `outputs/state/discovered_urls.yml`. Product updates must not overwrite `config/`, `profile/`, `outputs/`, or `.env`.
66
+
67
+ See `DATA_CONTRACT.md` for the full contract.
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ uv sync --extra dev
73
+ uv run pytest tests/ -q --tb=short
74
+ uv run ruff format --check job_hunter tests .github/scripts
75
+ uv run ruff check job_hunter tests .github/scripts
76
+ uv run ty check job_hunter tests
77
+ uv build
78
+ ```
79
+
80
+ MIT licensed. See `CONTRIBUTING.md`.
@@ -0,0 +1,6 @@
1
+ """Job Hunter — dual-mode job search automation.
2
+
3
+ Modes (set via config/job_hunter.yml → mode):
4
+ agent: Python prepares bounded context; Claude Code skills drive judgment.
5
+ llm-api: Full autonomous pipeline; LLM APIs called directly inside Python.
6
+ """
@@ -0,0 +1,24 @@
1
+ """Agent context package — bounded context builders for Claude Code skills."""
2
+
3
+ from job_hunter.agent_context.batch import build_candidate_batch, screen_candidate_batch
4
+ from job_hunter.agent_context.briefing import brief_context, linkedin_weekly_context, llm_search_config
5
+ from job_hunter.agent_context.candidates import build_candidate_queue, candidate_from_queue
6
+ from job_hunter.agent_context.lifecycle import candidate_lifecycle, validate_score_file
7
+ from job_hunter.agent_context.score_context import score_context
8
+ from job_hunter.agent_context.stories import final_stories_text, story_by_id, story_index
9
+
10
+ __all__ = [
11
+ "brief_context",
12
+ "build_candidate_batch",
13
+ "build_candidate_queue",
14
+ "candidate_from_queue",
15
+ "candidate_lifecycle",
16
+ "final_stories_text",
17
+ "linkedin_weekly_context",
18
+ "llm_search_config",
19
+ "score_context",
20
+ "screen_candidate_batch",
21
+ "story_by_id",
22
+ "story_index",
23
+ "validate_score_file",
24
+ ]
@@ -0,0 +1,25 @@
1
+ """Shared types and constants for agent_context sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ MAX_SNIPPET_CHARS = 700
9
+ MAX_JD_CHARS = 6000
10
+ DEFAULT_QUEUE_PATH = "outputs/state/agent_candidate_queue.json"
11
+ DEFAULT_CANDIDATE_SCOPE = "briefing-backlog"
12
+ JD_LIFECYCLE_IMPORT_STATUSES = {"thin", "fetch_failed", "page_noise"}
13
+ STORY_HEADING_RE = re.compile(r"^###\s+([A-Za-z0-9]+-\d+)\s+[—-]\s+(.+?)\s*$")
14
+ RATING_RE = re.compile(r"Rating:\s*([0-9]+(?:\.[0-9]+)?/10)")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class StoryBlock:
19
+ story_id: str
20
+ title: str
21
+ role: str
22
+ rating: str
23
+ tags: list[str]
24
+ summary: str
25
+ text: str
@@ -0,0 +1,44 @@
1
+ """Shared utility helpers for agent_context sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from job_hunter.tracker import repo_path
13
+
14
+
15
+ def _root(root: Path | None = None) -> Path:
16
+ return root if root is not None else repo_path()
17
+
18
+
19
+ def _read_yaml(path: Path) -> Any:
20
+ if not path.exists():
21
+ return {}
22
+ return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
23
+
24
+
25
+ def _read_json_or_yaml(path: Path) -> Any:
26
+ text = path.read_text(encoding="utf-8")
27
+ if path.suffix.lower() == ".json":
28
+ return json.loads(text)
29
+ return yaml.safe_load(text) or {}
30
+
31
+
32
+ def _clip(value: Any, limit: int) -> str:
33
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
34
+ if len(text) <= limit:
35
+ return text
36
+ suffix = " ... [truncated]"
37
+ if limit <= len(suffix):
38
+ return suffix[:limit]
39
+ return text[: limit - len(suffix)].rstrip() + suffix
40
+
41
+
42
+ def _resolve_path(root: Path, path: Path | str) -> Path:
43
+ resolved = Path(path)
44
+ return resolved if resolved.is_absolute() else root / resolved
@@ -0,0 +1,125 @@
1
+ """Candidate batch building and screening helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import date
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from job_hunter.agent_context._utils import _read_yaml, _root
12
+ from job_hunter.agent_context.candidates import _title_key
13
+ from job_hunter.config import get_config
14
+ from job_hunter.constants import DEFAULT_BATCH_SIZE
15
+ from job_hunter.sources._policy import JobPolicy
16
+
17
+
18
+ def build_candidate_batch(
19
+ queue: dict[str, Any],
20
+ *,
21
+ batch_size: int = DEFAULT_BATCH_SIZE,
22
+ batch_number: int = 1,
23
+ ) -> dict[str, Any]:
24
+ jobs = queue.get("jobs", []) if isinstance(queue, dict) else []
25
+ selected = jobs[:batch_size]
26
+ return {
27
+ "generated": date.today().isoformat(),
28
+ "batch_number": batch_number,
29
+ "batch_size": batch_size,
30
+ "source_queue_count": len(jobs),
31
+ "count": len(selected),
32
+ "jobs": selected,
33
+ }
34
+
35
+
36
+ def _applied_title_keys(root: Path) -> set[str]:
37
+ keys: set[str] = set()
38
+ jobs_dir = root / "outputs" / "jobs"
39
+ if jobs_dir.exists():
40
+ for meta_path in jobs_dir.glob("*/meta.json"):
41
+ try:
42
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
43
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
44
+ continue
45
+ key = _title_key(meta)
46
+ if key != "::":
47
+ keys.add(key)
48
+
49
+ readme_path = root / "README.md"
50
+ if readme_path.exists():
51
+ try:
52
+ readme = readme_path.read_text(encoding="utf-8")
53
+ except UnicodeDecodeError:
54
+ readme = readme_path.read_text(encoding="utf-8", errors="replace")
55
+ for match in re.finditer(r"\[([^\]]+?) @ ([^\]]+?)\]\(https?://", readme):
56
+ title, company = match.group(1), match.group(2)
57
+ keys.add(_title_key({"company": company, "title": title}))
58
+ return keys
59
+
60
+
61
+ def _region_config(search_config: dict[str, Any], region: str) -> dict[str, Any]:
62
+ regions = search_config.get("regions", {}) or {}
63
+ cfg = regions.get(region, {})
64
+ return cfg if isinstance(cfg, dict) else {}
65
+
66
+
67
+ def screen_candidate_batch(
68
+ batch: dict[str, Any],
69
+ *,
70
+ root: Path | None = None,
71
+ ) -> dict[str, Any]:
72
+ base = _root(root)
73
+ search_config = get_config("job_hunter") if base == _root() else _read_yaml(base / "config" / "job_hunter.yml")
74
+ policy = JobPolicy(search_config)
75
+ title_filters = search_config.get("job_titles", []) or []
76
+ applied_keys = _applied_title_keys(base)
77
+
78
+ retained: list[dict[str, Any]] = []
79
+ skipped: list[dict[str, Any]] = []
80
+ for candidate in batch.get("jobs", []):
81
+ reasons: list[str] = []
82
+ title = str(candidate.get("title") or "")
83
+ snippet = str(candidate.get("snippet") or "")
84
+ region = str(candidate.get("region") or "")
85
+ if not policy.accepts_job_content(candidate, title_filters):
86
+ if policy.is_excluded_company(str(candidate.get("company") or "")):
87
+ reasons.append("excluded_company")
88
+ if policy.is_excluded_language(title, snippet):
89
+ reasons.append("excluded_language")
90
+ if any(term.lower() in title.lower() for term in policy.excluded_title_terms):
91
+ reasons.append("excluded_title")
92
+ if policy.is_excluded_industry(snippet):
93
+ reasons.append("excluded_industry")
94
+ if not reasons:
95
+ reasons.append("title_not_matched")
96
+ if policy.has_wrong_location(candidate, _region_config(search_config, region)):
97
+ reasons.append("wrong_location")
98
+ if policy.is_stale_posting(title, snippet):
99
+ reasons.append("stale_posting")
100
+ if _title_key(candidate) in applied_keys:
101
+ reasons.append("duplicate_application")
102
+
103
+ row = {
104
+ "candidate_id": candidate.get("candidate_id"),
105
+ "queue_index": candidate.get("queue_index"),
106
+ "title": candidate.get("title"),
107
+ "company": candidate.get("company"),
108
+ "url": candidate.get("url"),
109
+ "reasons": reasons,
110
+ }
111
+ if reasons:
112
+ skipped.append(row)
113
+ else:
114
+ retained.append(row)
115
+
116
+ return {
117
+ "generated": date.today().isoformat(),
118
+ "batch_number": batch.get("batch_number", 1),
119
+ "batch_size": batch.get("batch_size", DEFAULT_BATCH_SIZE),
120
+ "loaded": len(batch.get("jobs", [])),
121
+ "retained_count": len(retained),
122
+ "skipped_count": len(skipped),
123
+ "retained": retained,
124
+ "skipped": skipped,
125
+ }
@@ -0,0 +1,194 @@
1
+ """Briefing and LinkedIn weekly context helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from datetime import date, datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from job_hunter.agent_context._utils import (
11
+ _read_json_or_yaml,
12
+ _read_yaml,
13
+ _root,
14
+ )
15
+ from job_hunter.agent_context.candidates import (
16
+ _candidate_files,
17
+ _jobs_from_candidate_file,
18
+ build_candidate_queue,
19
+ )
20
+ from job_hunter.agent_context.stories import story_index
21
+ from job_hunter.config.defaults import EXCLUDED_LISTING_URL_PATTERNS, STALE_INDICATORS
22
+ from job_hunter.constants import DEFAULT_BATCH_SIZE
23
+
24
+
25
+ def _latest_commit_subject(root: Path) -> str:
26
+ try:
27
+ result = subprocess.run(
28
+ ["git", "log", "-1", "--pretty=%s"], # noqa: S607
29
+ cwd=root,
30
+ text=True,
31
+ capture_output=True,
32
+ timeout=5,
33
+ check=False,
34
+ )
35
+ except Exception:
36
+ return "unknown"
37
+ return result.stdout.strip() or "unknown"
38
+
39
+
40
+ def brief_context(*, root: Path | None = None) -> str:
41
+ base = _root(root)
42
+ subject = _latest_commit_subject(base)
43
+ discovery = "yes" if "discovery" in subject.lower() else "no"
44
+ queue = build_candidate_queue(root=base, today_only=False, limit=10000)
45
+ candidate_lines = []
46
+ for path in _candidate_files(base):
47
+ try:
48
+ jobs = _jobs_from_candidate_file(path)
49
+ candidate_lines.append(f"- `{path.name}`: {len(jobs)} candidates")
50
+ except Exception:
51
+ candidate_lines.append(f"- `{path.name}`: unreadable")
52
+ if not candidate_lines:
53
+ candidate_lines.append("- none")
54
+
55
+ today = date.today().isoformat()
56
+ jobs_dir = base / "outputs" / "jobs"
57
+ today_rows: list[str] = []
58
+ if jobs_dir.exists():
59
+ for folder in sorted(jobs_dir.iterdir()):
60
+ if not folder.is_dir():
61
+ continue
62
+ meta = _read_json_or_yaml(folder / "meta.json") if (folder / "meta.json").exists() else {}
63
+ if not (folder.name.startswith(today) or meta.get("date") == today):
64
+ continue
65
+ score = _read_yaml(folder / "score.yml")
66
+ today_rows.append(
67
+ "| {folder} | {company} | {title} | {score} | {decision} |".format(
68
+ folder=folder.name,
69
+ company=meta.get("company", ""),
70
+ title=meta.get("title", ""),
71
+ score=score.get("score", ""),
72
+ decision=score.get("decision", score.get("status", "")),
73
+ )
74
+ )
75
+
76
+ jobs_table = "\n".join(today_rows) if today_rows else "No job folders created today."
77
+ candidate_summary = "\n".join(candidate_lines[:20])
78
+ if len(candidate_lines) > 20:
79
+ candidate_summary += f"\n- ... {len(candidate_lines) - 20} more file(s)"
80
+
81
+ return f"""# Agent Brief - {today}
82
+
83
+ Latest commit: {subject}
84
+ Discovery commit: {discovery}
85
+
86
+ ## Candidate Snapshots
87
+ {candidate_summary}
88
+
89
+ Unprocessed queue: {queue["count"]} candidate(s) from {len(queue["source_files"])} file(s).
90
+
91
+ ## Today's Jobs
92
+ | Folder | Company | Title | Score | Decision |
93
+ |---|---|---|---|---|
94
+ {jobs_table}
95
+
96
+ Next:
97
+ - Run `/job-hunter batch` when candidates are ready.
98
+ - Run `/job-hunter one <url>` for a single posting.
99
+ """
100
+
101
+
102
+ def _linkedin_job_limit(root: Path, days: int, limit: int | None) -> tuple[int, str]:
103
+ if limit is not None and limit > 0:
104
+ return limit, "cli"
105
+ scoring = _read_yaml(root / "config" / "job_hunter.yml").get("scoring", {})
106
+ daily_limit = int(scoring.get("batch_size") or 0)
107
+ if daily_limit > 0:
108
+ return daily_limit * max(days, 1), "config:scoring.batch_size * days"
109
+ return 0, "unlimited"
110
+
111
+
112
+ def _effective_region_titles(region_config: dict[str, Any], global_titles: list[str]) -> list[str]:
113
+ titles = region_config.get("job_titles") or global_titles
114
+ return [str(title) for title in titles if str(title).strip()]
115
+
116
+
117
+ def _llm_search_regions(config: dict[str, Any]) -> list[dict[str, Any]]:
118
+ global_titles = [str(title) for title in config.get("job_titles", []) or [] if str(title).strip()]
119
+ regions: list[dict[str, Any]] = []
120
+ for region_name, region_config in (config.get("regions") or {}).items():
121
+ if not isinstance(region_config, dict) or not region_config.get("enabled", True):
122
+ continue
123
+ regions.append(
124
+ {
125
+ "region": str(region_name),
126
+ "country": str(region_config.get("country") or ""),
127
+ "location": str(region_config.get("location") or ""),
128
+ "search_lang": str(region_config.get("search_lang") or ""),
129
+ "primary": bool(region_config.get("primary", False)),
130
+ "job_titles": _effective_region_titles(region_config, global_titles),
131
+ }
132
+ )
133
+ return regions
134
+
135
+
136
+ def llm_search_config(*, root: Path | None = None) -> dict[str, Any]:
137
+ """Return compact region/title/exclusion context for agent-driven web search."""
138
+ base = _root(root)
139
+ config = _read_yaml(base / "config" / "job_hunter.yml")
140
+ ljs = (config.get("search") or {}).get("llm_search") or {}
141
+ scoring = config.get("scoring") or {}
142
+ exclusions = config.get("exclusions") or {}
143
+ return {
144
+ "enabled": bool(ljs.get("enabled", False)),
145
+ "trigger_threshold": int(ljs.get("trigger_threshold", 999)),
146
+ "max_results_per_run": int(ljs.get("max_results_per_run", 20)),
147
+ "batch_size": int(scoring.get("batch_size", DEFAULT_BATCH_SIZE)),
148
+ "searches_per_title_per_region": 5,
149
+ "regions": _llm_search_regions(config),
150
+ "exclusions": {
151
+ "excluded_companies": [str(company) for company in exclusions.get("companies", []) or []],
152
+ "excluded_title_terms": [str(term) for term in exclusions.get("title_terms", []) or []],
153
+ "excluded_url_patterns": [str(pattern) for pattern in EXCLUDED_LISTING_URL_PATTERNS],
154
+ "excluded_languages": [str(language) for language in exclusions.get("languages", []) or []],
155
+ "stale_indicators": [str(indicator) for indicator in STALE_INDICATORS],
156
+ },
157
+ }
158
+
159
+
160
+ def linkedin_weekly_context(
161
+ *,
162
+ root: Path | None = None,
163
+ days: int = 7,
164
+ limit: int | None = None,
165
+ ) -> dict[str, Any]:
166
+ base = _root(root)
167
+ job_limit, limit_source = _linkedin_job_limit(base, days, limit)
168
+ cutoff = datetime.now() - timedelta(days=days)
169
+ jobs: list[dict[str, Any]] = []
170
+ jobs_dir = base / "outputs" / "jobs"
171
+ if jobs_dir.exists():
172
+ for folder in sorted(jobs_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
173
+ if not folder.is_dir() or datetime.fromtimestamp(folder.stat().st_mtime) < cutoff:
174
+ continue
175
+ meta = _read_json_or_yaml(folder / "meta.json") if (folder / "meta.json").exists() else {}
176
+ score = _read_yaml(folder / "score.yml")
177
+ jobs.append(
178
+ {
179
+ "slug": folder.name,
180
+ "company": meta.get("company", ""),
181
+ "title": meta.get("title", ""),
182
+ "score": score.get("score", ""),
183
+ "decision": score.get("decision", score.get("status", "")),
184
+ }
185
+ )
186
+ if job_limit and len(jobs) >= job_limit:
187
+ break
188
+ return {
189
+ "days": days,
190
+ "job_limit": job_limit or None,
191
+ "job_limit_source": limit_source,
192
+ "jobs": jobs,
193
+ "story_index": story_index(root=base),
194
+ }