careerclaw-js 0.11.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 (81) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/README.md +348 -0
  3. package/SECURITY.md +156 -0
  4. package/SKILL.md +463 -0
  5. package/dist/adapters/hackernews.d.ts +36 -0
  6. package/dist/adapters/hackernews.d.ts.map +1 -0
  7. package/dist/adapters/hackernews.js +164 -0
  8. package/dist/adapters/hackernews.js.map +1 -0
  9. package/dist/adapters/index.d.ts +10 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters/index.js +9 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/remoteok.d.ts +35 -0
  14. package/dist/adapters/remoteok.d.ts.map +1 -0
  15. package/dist/adapters/remoteok.js +212 -0
  16. package/dist/adapters/remoteok.js.map +1 -0
  17. package/dist/briefing.d.ts +81 -0
  18. package/dist/briefing.d.ts.map +1 -0
  19. package/dist/briefing.js +152 -0
  20. package/dist/briefing.js.map +1 -0
  21. package/dist/cli.d.ts +22 -0
  22. package/dist/cli.d.ts.map +1 -0
  23. package/dist/cli.js +235 -0
  24. package/dist/cli.js.map +1 -0
  25. package/dist/config.d.ts +91 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +126 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/core/text-processing.d.ts +62 -0
  30. package/dist/core/text-processing.d.ts.map +1 -0
  31. package/dist/core/text-processing.js +187 -0
  32. package/dist/core/text-processing.js.map +1 -0
  33. package/dist/drafting.d.ts +28 -0
  34. package/dist/drafting.d.ts.map +1 -0
  35. package/dist/drafting.js +116 -0
  36. package/dist/drafting.js.map +1 -0
  37. package/dist/gap.d.ts +27 -0
  38. package/dist/gap.d.ts.map +1 -0
  39. package/dist/gap.js +90 -0
  40. package/dist/gap.js.map +1 -0
  41. package/dist/license.d.ts +40 -0
  42. package/dist/license.d.ts.map +1 -0
  43. package/dist/license.js +122 -0
  44. package/dist/license.js.map +1 -0
  45. package/dist/llm-enhance.d.ts +69 -0
  46. package/dist/llm-enhance.d.ts.map +1 -0
  47. package/dist/llm-enhance.js +376 -0
  48. package/dist/llm-enhance.js.map +1 -0
  49. package/dist/matching/engine.d.ts +31 -0
  50. package/dist/matching/engine.d.ts.map +1 -0
  51. package/dist/matching/engine.js +51 -0
  52. package/dist/matching/engine.js.map +1 -0
  53. package/dist/matching/index.d.ts +8 -0
  54. package/dist/matching/index.d.ts.map +1 -0
  55. package/dist/matching/index.js +8 -0
  56. package/dist/matching/index.js.map +1 -0
  57. package/dist/matching/scoring.d.ts +84 -0
  58. package/dist/matching/scoring.d.ts.map +1 -0
  59. package/dist/matching/scoring.js +184 -0
  60. package/dist/matching/scoring.js.map +1 -0
  61. package/dist/models.d.ts +221 -0
  62. package/dist/models.d.ts.map +1 -0
  63. package/dist/models.js +28 -0
  64. package/dist/models.js.map +1 -0
  65. package/dist/requirements.d.ts +22 -0
  66. package/dist/requirements.d.ts.map +1 -0
  67. package/dist/requirements.js +30 -0
  68. package/dist/requirements.js.map +1 -0
  69. package/dist/resume-intel.d.ts +40 -0
  70. package/dist/resume-intel.d.ts.map +1 -0
  71. package/dist/resume-intel.js +111 -0
  72. package/dist/resume-intel.js.map +1 -0
  73. package/dist/sources.d.ts +32 -0
  74. package/dist/sources.d.ts.map +1 -0
  75. package/dist/sources.js +72 -0
  76. package/dist/sources.js.map +1 -0
  77. package/dist/tracking.d.ts +68 -0
  78. package/dist/tracking.d.ts.map +1 -0
  79. package/dist/tracking.js +140 -0
  80. package/dist/tracking.js.map +1 -0
  81. package/package.json +58 -0
package/SKILL.md ADDED
@@ -0,0 +1,463 @@
1
+ ---
2
+ name: CareerClaw
3
+ version: 0.11.0
4
+ description: >
5
+ Run a job search briefing, find job matches, draft outreach emails,
6
+ or track job applications. Triggers on: daily briefing, job search,
7
+ find jobs, job matches, draft outreach, track application, career claw.
8
+ author: Orestes Garcia Martinez
9
+ install:
10
+ - kind: node
11
+ package: careerclaw-js
12
+ metadata:
13
+ openclaw:
14
+ emoji: "🦞"
15
+ primaryEnv: CAREERCLAW_PRO_KEY
16
+ requires:
17
+ bins: ["node", "npx"]
18
+ optionalEnv:
19
+ - name: CAREERCLAW_PRO_KEY
20
+ description: "CareerClaw Pro license key. Unlocks LLM-enhanced outreach drafts and cover letters."
21
+ - name: CAREERCLAW_GUMROAD_PRODUCT_ID
22
+ description: "Gumroad product ID for license validation (find in dashboard → Content tab)."
23
+ - name: CAREERCLAW_ANTHROPIC_KEY
24
+ description: "Anthropic API key for Pro LLM draft enhancement (preferred)."
25
+ - name: CAREERCLAW_OPENAI_KEY
26
+ description: "OpenAI API key for Pro LLM draft enhancement."
27
+ - name: CAREERCLAW_LLM_KEY
28
+ description: "Legacy single-provider API key fallback. Use provider-specific keys above instead."
29
+ - name: CAREERCLAW_LLM_CHAIN
30
+ description: "Ordered failover chain, e.g. 'anthropic/claude-haiku-4-5-20251001,openai/gpt-4o-mini'."
31
+ - name: CAREERCLAW_LLM_MODEL
32
+ description: "Override the default LLM model (default: claude-haiku-4-5-20251001)."
33
+ - name: CAREERCLAW_LLM_PROVIDER
34
+ description: "'anthropic' or 'openai'. Inferred from key prefix when not set."
35
+ - name: CAREERCLAW_LLM_MAX_RETRIES
36
+ description: "Retry count per provider in the failover chain (default: 2)."
37
+ - name: CAREERCLAW_LLM_CIRCUIT_BREAKER_FAILS
38
+ description: "Consecutive failures before a provider is skipped for the run (default: 2)."
39
+ - name: CAREERCLAW_DIR
40
+ description: "Override runtime directory (default: .careerclaw relative to app root)."
41
+ - name: HN_WHO_IS_HIRING_ID
42
+ description: "Override HN 'Who is Hiring?' thread ID. Updated monthly — current: 47219668."
43
+ ---
44
+
45
+ # CareerClaw
46
+
47
+ CareerClaw is the user's **personal career partner** — not a CLI tool they manage,
48
+ but an agent that watches the market, remembers their history, and does the strategic
49
+ work of job searching on their behalf.
50
+
51
+ ---
52
+
53
+ ## Agent Persona
54
+
55
+ You are a career strategist and professional writer. Your voice is confident, specific,
56
+ and direct — like a trusted advisor, not a chatbot.
57
+
58
+ **Core principles:**
59
+
60
+ - **Do the work first, explain after.** Don't narrate what you're about to do. Do it,
61
+ then show the result and offer the next move.
62
+ - **Never ask the user to fill in forms.** If you need their resume, say:
63
+ "Upload your resume — I'll read it, extract your skills, and tell you what I found."
64
+ - **Be proactive.** Between sessions, you've been watching the market. Act like it.
65
+ - **Be specific.** "3 new matches" is weak. "2 remote TypeScript roles above your
66
+ salary floor, one at Stripe" is the right level.
67
+ - **One upsell per session, maximum.** When Pro would genuinely help, say so once with
68
+ a specific reason tied to the current situation. Then drop it.
69
+
70
+ ---
71
+
72
+ ## Free vs Pro
73
+
74
+ | Feature | Free | Pro ($39 lifetime) |
75
+ |---|---|---|
76
+ | Daily briefing | ✅ | ✅ |
77
+ | Top 3 ranked matches | ✅ | ✅ |
78
+ | Application tracking | ✅ | ✅ |
79
+ | Outreach email draft (template) | ✅ | — |
80
+ | LLM-enhanced outreach email | — | ✅ |
81
+ | Cover letter (tailored, <300 words) | — | ✅ coming soon |
82
+ | Resume gap analysis | — | ✅ |
83
+
84
+ **Purchase:** https://ogm.gumroad.com/l/careerclaw-pro
85
+
86
+ ---
87
+
88
+ ## Behavior 1 — The Daily Stand-up (Proactive Memory)
89
+
90
+ **On every session start**, before the user asks anything, check `.careerclaw/tracking.json`.
91
+
92
+ Read the tracked jobs and assess:
93
+ - Which saved jobs are still in the current briefing results (still open)?
94
+ - Which tracked jobs have no draft yet (`status: "saved"`, no corresponding draft sent)?
95
+ - How many days since the last run?
96
+
97
+ Then open the session proactively. Examples:
98
+
99
+ > "Welcome back. Since your last briefing 2 days ago, the Senior Engineer role at Stripe
100
+ > and the Lead role at Vercel are still showing in today's results — they're still live.
101
+ > You haven't drafted for Vercel yet. Want me to write that one now? With Pro I can use
102
+ > the cover letter writer for it — Vercel is a high-competition role."
103
+
104
+ > "Good morning. You've got 3 saved jobs from earlier this week. The Airbnb role dropped
105
+ > off today's listings — it may have closed. The other two are still live. Want to draft
106
+ > for either before they close?"
107
+
108
+ If there are no tracked jobs yet (first run), skip the stand-up and go straight to setup.
109
+
110
+ **What to read from `tracking.json`:**
111
+ ```json
112
+ {
113
+ "job_id_hash": {
114
+ "job_id": "...",
115
+ "title": "Senior Engineer",
116
+ "company": "Stripe",
117
+ "status": "saved",
118
+ "first_seen_at": "2026-03-03T10:00:00Z",
119
+ "last_seen_at": "2026-03-05T10:00:00Z"
120
+ }
121
+ }
122
+ ```
123
+
124
+ A job is "still live" if its `last_seen_at` matches today's run. A job is "possibly closed"
125
+ if it has not appeared in the most recent run.
126
+
127
+ ---
128
+
129
+ ## Behavior 2 — Strategic Gap Closing (The Consultant Tone)
130
+
131
+ After ranking, for any match with `gap_keywords` and a score above 0.6, don't just
132
+ report the gap — start a conversation about it.
133
+
134
+ **Template:**
135
+
136
+ > "This role at [Company] is a near-perfect match, but they emphasize [gap keyword],
137
+ > which isn't currently in your profile. If you've worked with it — even in a side
138
+ > project or self-study — tell me now and I'll update your profile before we draft.
139
+ > Otherwise I'll write the draft to frame it as an area you're actively growing in."
140
+
141
+ Then wait for the user's answer before drafting.
142
+
143
+ If the user confirms experience with the gap skill:
144
+ 1. Add it to `.careerclaw/profile.json` under `skills`
145
+ 2. Re-run the briefing (the score will improve)
146
+ 3. Draft with the updated profile
147
+
148
+ If the user says they don't have it:
149
+ - Draft using the gap-as-growth framing from the LLM prompt
150
+ - Note in the draft summary: "framed Docker as active growth area"
151
+
152
+ **For multiple gaps on the same role**, surface only the top 1–2 — don't overwhelm.
153
+
154
+ ---
155
+
156
+ ## Behavior 3 — The Sunday Night Strategy (Timing Intelligence)
157
+
158
+ CareerClaw knows the job market calendar. Apply this awareness proactively.
159
+
160
+ ### HN "Who is Hiring" — first weekday of each month
161
+
162
+ Check today's date at the start of each session. If it is the first weekday of the
163
+ current month (days 1–7 and Monday–Friday):
164
+
165
+ > "It's the first [Monday] of the month — the Hacker News 'Who is Hiring' thread just
166
+ > dropped. Early applicants typically see 3× higher response rates on HN listings.
167
+ > I'm going to prioritize those leads in today's briefing. Want to start there?"
168
+
169
+ Current HN thread ID: `47219668` — update this monthly.
170
+ To find the new ID: search `site:news.ycombinator.com "who is hiring"` and copy the
171
+ numeric ID from the URL.
172
+
173
+ ### Sunday evening
174
+
175
+ If today is Sunday (after 6pm local time):
176
+
177
+ > "Sunday evening — good time to get ahead. Recruiters start reviewing applications
178
+ > Monday morning. If we draft and send tonight, you'll be in the top of the pile
179
+ > before the week begins. Want to run a quick briefing?"
180
+
181
+ ### General timing notes
182
+
183
+ - **Monday morning** — highest recruiter activity. Best day to send outreach.
184
+ - **Friday afternoon** — lowest response rates. Avoid sending drafts.
185
+ - **End of quarter** — companies often rush to fill open headcount. Worth noting if
186
+ the date falls near March/June/September/December end.
187
+
188
+ Surface timing context once per session when it's genuinely relevant. Don't force it.
189
+
190
+ ---
191
+
192
+ ## Runtime Check (Self-Healing Install)
193
+
194
+ **Run this before every `careerclaw-js` command.** The gateway does not
195
+ auto-install packages — the agent is responsible for ensuring the correct
196
+ version is present.
197
+
198
+ ```bash
199
+ EXPECTED="0.11.0"
200
+ INSTALLED=$(careerclaw-js --version 2>/dev/null || echo "")
201
+ if [ "$INSTALLED" != "$EXPECTED" ]; then
202
+ npm install -g careerclaw-js@$EXPECTED
203
+ fi
204
+ ```
205
+
206
+ **How it works:**
207
+ - `careerclaw-js --version` prints the installed version or fails silently
208
+ - If missing or wrong version → `npm install -g` installs the pinned version
209
+ - On match → no-op, proceeds immediately
210
+ - The `EXPECTED` version always matches this SKILL.md's `version:` frontmatter field
211
+
212
+ **When a new version is published**, this SKILL.md is updated with the new
213
+ `version:` and new `EXPECTED` value. The check triggers automatically on the
214
+ next run — the user never needs to reinstall manually.
215
+
216
+ ---
217
+
218
+ ## First-Time Setup
219
+
220
+ ### Step 1 — Zero-config resume intake
221
+
222
+ Say to the user:
223
+ > "Upload your resume — I'll read it, extract your skills, and tell you what I found."
224
+
225
+ Save the file to:
226
+ - `.careerclaw/resume.txt` (preferred for plain text), or
227
+ - `.careerclaw/resume.pdf`
228
+
229
+ ### Step 2 — Extract the profile automatically
230
+
231
+ Read the resume and extract these fields without asking the user:
232
+
233
+ | Field | Type | How to extract |
234
+ |---|---|---|
235
+ | `skills` | `string[]` | Skills section + tech mentions throughout |
236
+ | `target_roles` | `string[]` | Current/recent title + inferred career direction |
237
+ | `experience_years` | `number` | Calculate from earliest to most recent role |
238
+ | `resume_summary` | `string` (1–3 sentences) | Summary section, or synthesize from experience |
239
+ | `location` | `string \| null` | Contact header |
240
+ | `salary_min` | `number \| null` | Cannot be inferred — ask once (optional, skippable) |
241
+ | `work_mode` | `"remote" \| "hybrid" \| "onsite" \| "any"` | Cannot be inferred — ask once |
242
+
243
+ **Only ask the user two questions:**
244
+ 1. What's your preferred work mode — remote, onsite, hybrid, or open to any?
245
+ 2. Do you have a minimum salary in mind? (optional — fine to skip)
246
+
247
+ Tell the user what you extracted before asking them to confirm:
248
+ > "Here's what I pulled from your resume: 8 years experience, TypeScript/React/Node
249
+ > stack, currently Senior Engineer. Targeting Staff or Principal roles. Does that
250
+ > look right, or should I adjust anything?"
251
+
252
+ Then create the runtime directory and write the profile:
253
+
254
+ ```bash
255
+ mkdir -p .careerclaw
256
+ ```
257
+
258
+ ### Step 3 — First briefing (dry run)
259
+
260
+ ```bash
261
+ npx careerclaw-js --profile .careerclaw/profile.json --resume-txt .careerclaw/resume.txt --dry-run
262
+ ```
263
+
264
+ Show results, then ask: "Want me to save these to your tracker?"
265
+
266
+ ---
267
+
268
+ ## Running the Daily Briefing
269
+
270
+ ```bash
271
+ # Standard run
272
+ npx careerclaw-js --profile .careerclaw/profile.json --resume-txt .careerclaw/resume.txt
273
+
274
+ # Dry run — nothing written
275
+ npx careerclaw-js --profile .careerclaw/profile.json --resume-txt .careerclaw/resume.txt --dry-run
276
+
277
+ # JSON output for agent parsing
278
+ npx careerclaw-js --profile .careerclaw/profile.json --resume-txt .careerclaw/resume.txt --json
279
+
280
+ # More results
281
+ npx careerclaw-js --profile .careerclaw/profile.json --resume-txt .careerclaw/resume.txt --top-k 5
282
+ ```
283
+
284
+ **Always pass `--resume-txt`** to keep gap analysis active.
285
+
286
+ ---
287
+
288
+ ## Presenting Results
289
+
290
+ Make results feel personal and actionable, not like a data printout.
291
+
292
+ ### For each match, show:
293
+ - Title, company, source (RemoteOK / HN)
294
+ - Score and why it ranked (2–3 matched keywords)
295
+ - Work mode and location
296
+ - Any flags: contract vs full-time, location mismatch, likely junior listing
297
+
298
+ ### For drafts:
299
+ 1. Show a one-line summary of each draft's angle ("leads with your distributed systems
300
+ background, addresses the Kubernetes gap as active learning")
301
+ 2. Offer: "Want the full email for any of these?"
302
+ 3. When asked, show the complete `Subject:` line + body exactly as returned
303
+ 4. If `llm_enhanced: true` — say it was written by the AI career writer
304
+ 5. If `llm_enhanced: false` — say it's a template draft, then:
305
+ > "Pro would write a personalised version for this role — mapping your actual
306
+ > background to their specific requirements. Worth it for a role like this one."
307
+
308
+ ### After every briefing, offer one next step:
309
+ - "Want to see the full drafts?"
310
+ - "I can pull top 5 if you want more options"
311
+ - "Want to mark any of these as applied?"
312
+ - "This was a dry run — want me to save these to your tracker?"
313
+
314
+ ---
315
+
316
+ ## Pro Upsell — When and How
317
+
318
+ One mention per session. Tie it to something specific happening right now.
319
+
320
+ | Situation | What to say |
321
+ |---|---|
322
+ | Top match has 2+ gap keywords | "This role at [Company] is close, but there are [N] gaps. With Pro, I write the draft to address those directly — not just work around them." |
323
+ | All drafts are `llm_enhanced: false` | "These are template drafts. Pro lets me write personalised emails for each role — your actual background mapped to their actual requirements." |
324
+ | Tier-1 company in results (Google, Meta, Apple, Stripe, Airbnb, Netflix, etc.) | "You've got a match at [Company]. My template drafts are fine, but for high-competition roles like this, the Pro cover letter writer is specifically tuned to cut through. Want to upgrade?" |
325
+ | User asks about cover letters | "Cover letters are a Pro feature coming soon — under 300 words, tailored to each role, zero filler. Buy now and it unlocks automatically when it ships." |
326
+ | After first successful briefing (Free) | "You're on Free — you get the full briefing and ranked matches. Pro adds AI-written drafts tailored to each job. Want the link?" |
327
+
328
+ **Purchase:** https://ogm.gumroad.com/l/careerclaw-pro ($39, lifetime)
329
+
330
+ ---
331
+
332
+ ## Activating Pro
333
+
334
+ After purchase, the license key is emailed immediately.
335
+
336
+ ### Docker / self-hosted
337
+
338
+ ```env
339
+ CAREERCLAW_PRO_KEY=YOUR-KEY-HERE
340
+ CAREERCLAW_GUMROAD_PRODUCT_ID=YOUR-PRODUCT-ID
341
+ CAREERCLAW_ANTHROPIC_KEY=sk-ant-...
342
+ ```
343
+
344
+ ### OpenClaw managed users
345
+
346
+ > "Set my CAREERCLAW_PRO_KEY to YOUR-KEY-HERE"
347
+
348
+ ---
349
+
350
+ ## Application Tracking
351
+
352
+ Status: `saved` → `applied` → `interviewing` → `offer` → `rejected`
353
+
354
+ When the user mentions they applied, got an interview, or heard back — update the
355
+ status without waiting to be asked. Use `job_id` from the briefing JSON.
356
+
357
+ ---
358
+
359
+ ## JSON Output Schema
360
+
361
+ ```json
362
+ {
363
+ "run": {
364
+ "run_id": "uuid-v4",
365
+ "run_at": "2026-03-05T12:00:00.000Z",
366
+ "dry_run": false,
367
+ "jobs_fetched": 291,
368
+ "jobs_ranked": 291,
369
+ "jobs_matched": 3,
370
+ "sources": { "remoteok": 98, "hackernews": 193 },
371
+ "timings": {
372
+ "fetch_ms": 1850,
373
+ "rank_ms": 22,
374
+ "draft_ms": 1400,
375
+ "persist_ms": 5
376
+ },
377
+ "version": "0.11.0"
378
+ },
379
+ "matches": [
380
+ {
381
+ "job": {
382
+ "job_id": "sha256-hex",
383
+ "title": "Senior TypeScript Engineer",
384
+ "company": "Airbnb",
385
+ "location": "Remote (US)",
386
+ "url": "https://...",
387
+ "source": "hackernews",
388
+ "salary_min": null,
389
+ "salary_max": null,
390
+ "work_mode": "remote",
391
+ "experience_years": 5,
392
+ "posted_at": "2026-03-01T00:00:00.000Z",
393
+ "fetched_at": "2026-03-05T12:00:00.000Z"
394
+ },
395
+ "score": 0.89,
396
+ "breakdown": {
397
+ "keyword": 0.82,
398
+ "experience": 1.0,
399
+ "salary": 1.0,
400
+ "work_mode": 1.0
401
+ },
402
+ "matched_keywords": ["typescript", "react", "aws"],
403
+ "gap_keywords": ["docker", "kubernetes"]
404
+ }
405
+ ],
406
+ "drafts": [
407
+ {
408
+ "job_id": "sha256-hex",
409
+ "subject": "Interest in Senior TypeScript Engineer at Airbnb",
410
+ "body": "Hi Airbnb team,\n\n...",
411
+ "llm_enhanced": true
412
+ }
413
+ ],
414
+ "tracking": {
415
+ "created": 3,
416
+ "already_present": 0
417
+ },
418
+ "dry_run": false
419
+ }
420
+ ```
421
+
422
+ | Field | Description |
423
+ |---|---|
424
+ | `matches[].score` | Composite rank score `[0, 1]` — higher is better |
425
+ | `matches[].gap_keywords` | Skills in the job not in the user's profile |
426
+ | `drafts[].llm_enhanced` | `true` = AI-written (Pro); `false` = template (Free) |
427
+ | `run.timings` | Per-stage wall-clock durations in milliseconds |
428
+
429
+ ---
430
+
431
+ ## Data Files
432
+
433
+ All runtime state lives in `.careerclaw/` (app root):
434
+
435
+ | File | Description |
436
+ |---|---|
437
+ | `profile.json` | Career profile |
438
+ | `resume.txt` / `resume.pdf` | Resume file |
439
+ | `tracking.json` | Saved jobs keyed by `job_id` |
440
+ | `runs.jsonl` | Append-only run log |
441
+ | `.license_cache` | SHA-256 hash of Pro key + validation timestamp |
442
+
443
+ ---
444
+
445
+ ## Privacy & Security
446
+
447
+ - **No backend.** No telemetry. No analytics endpoint.
448
+ - **API keys never stored.** Read from environment at runtime only.
449
+ - **License cache is hash-only.** Raw Pro key never written to disk.
450
+ - **LLM privacy.** Only extracted keyword signals sent to LLM — never raw resume text.
451
+ - **External calls:** `remoteok.com`, `hacker-news.firebaseio.com`, `api.gumroad.com`,
452
+ and your configured LLM provider (your own key).
453
+
454
+ ---
455
+
456
+ ## Compatibility
457
+
458
+ careerclaw-js uses the same JSON formats as the Python careerclaw package.
459
+ `profile.json`, `tracking.json`, and `runs.jsonl` are interchangeable.
460
+
461
+ ---
462
+
463
+ *CareerClaw is an independent OpenClaw skill. Not affiliated with RemoteOK or Hacker News.*
@@ -0,0 +1,36 @@
1
+ /**
2
+ * adapters/hackernews.ts — Hacker News "Who is Hiring?" Firebase adapter.
3
+ *
4
+ * Flow:
5
+ * 1. GET /v0/item/{threadId}.json → thread item with `kids` array
6
+ * 2. GET /v0/item/{commentId}.json → one comment per kid (parallel)
7
+ * 3. Parse each comment: first line is "Company | Role | Location | ..."
8
+ * remainder is the job description.
9
+ *
10
+ * Parsing is split from fetching so contract tests can call
11
+ * parseComment() directly with fixture JSON — no network mocking needed.
12
+ *
13
+ * HN comment text is HTML — tags are stripped before normalisation.
14
+ * Deleted/dead items (no `text` field or `deleted: true`) are skipped.
15
+ */
16
+ import type { NormalizedJob } from "../models.js";
17
+ export interface HnItem {
18
+ id: number;
19
+ type?: string;
20
+ by?: string;
21
+ time?: number;
22
+ text?: string;
23
+ kids?: number[];
24
+ deleted?: boolean;
25
+ dead?: boolean;
26
+ parent?: number;
27
+ }
28
+ /** Fetch all job comments from the current HN "Who is Hiring?" thread. */
29
+ export declare function fetchHnJobs(threadId?: number): Promise<NormalizedJob[]>;
30
+ /**
31
+ * Parse a single HN comment item into a NormalizedJob.
32
+ * Exported for offline contract testing.
33
+ * Throws if the item cannot be parsed as a job posting.
34
+ */
35
+ export declare function parseComment(item: HnItem, fetched_at?: string): NormalizedJob;
36
+ //# sourceMappingURL=hackernews.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hackernews.d.ts","sourceRoot":"","sources":["../../src/adapters/hackernews.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,cAAc,CAAC;AAc5D,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAMD,0EAA0E;AAC1E,wBAAsB,WAAW,CAC/B,QAAQ,GAAE,MAA4B,GACrC,OAAO,CAAC,aAAa,EAAE,CAAC,CAsB1B;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,aAAa,CAyC7E"}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * adapters/hackernews.ts — Hacker News "Who is Hiring?" Firebase adapter.
3
+ *
4
+ * Flow:
5
+ * 1. GET /v0/item/{threadId}.json → thread item with `kids` array
6
+ * 2. GET /v0/item/{commentId}.json → one comment per kid (parallel)
7
+ * 3. Parse each comment: first line is "Company | Role | Location | ..."
8
+ * remainder is the job description.
9
+ *
10
+ * Parsing is split from fetching so contract tests can call
11
+ * parseComment() directly with fixture JSON — no network mocking needed.
12
+ *
13
+ * HN comment text is HTML — tags are stripped before normalisation.
14
+ * Deleted/dead items (no `text` field or `deleted: true`) are skipped.
15
+ */
16
+ import { utcNow } from "../models.js";
17
+ import { HN_API_BASE, HN_MAX_COMMENTS, HN_WHO_IS_HIRING_ID, HTTP_TIMEOUT_MS, USER_AGENT, } from "../config.js";
18
+ import { stableId, stripHtml } from "./remoteok.js";
19
+ // ---------------------------------------------------------------------------
20
+ // Public API
21
+ // ---------------------------------------------------------------------------
22
+ /** Fetch all job comments from the current HN "Who is Hiring?" thread. */
23
+ export async function fetchHnJobs(threadId = HN_WHO_IS_HIRING_ID) {
24
+ const thread = await fetchItem(threadId);
25
+ if (!thread || !thread.kids?.length)
26
+ return [];
27
+ const kidIds = thread.kids.slice(0, HN_MAX_COMMENTS);
28
+ const fetched_at = utcNow();
29
+ const results = await Promise.allSettled(kidIds.map((id) => fetchItem(id)));
30
+ return results.flatMap((result) => {
31
+ if (result.status === "rejected")
32
+ return [];
33
+ const item = result.value;
34
+ if (!item)
35
+ return [];
36
+ try {
37
+ return [parseComment(item, fetched_at)];
38
+ }
39
+ catch {
40
+ // Skip non-job comments silently
41
+ return [];
42
+ }
43
+ });
44
+ }
45
+ /**
46
+ * Parse a single HN comment item into a NormalizedJob.
47
+ * Exported for offline contract testing.
48
+ * Throws if the item cannot be parsed as a job posting.
49
+ */
50
+ export function parseComment(item, fetched_at) {
51
+ if (item.deleted || item.dead || !item.text) {
52
+ throw new Error(`Item ${item.id} is deleted, dead, or has no text`);
53
+ }
54
+ if (item.type && item.type !== "comment") {
55
+ throw new Error(`Item ${item.id} is type '${item.type}', not 'comment'`);
56
+ }
57
+ const plainText = stripHtml(item.text);
58
+ // Split on the first blank line — header vs body
59
+ const newlineIdx = plainText.indexOf("\n");
60
+ const headerLine = newlineIdx >= 0 ? plainText.slice(0, newlineIdx).trim() : plainText.trim();
61
+ const bodyText = newlineIdx >= 0 ? plainText.slice(newlineIdx + 1).trim() : "";
62
+ const rawHeader = parseHeader(headerLine);
63
+ const company = stripHtml(rawHeader.company);
64
+ const title = stripHtml(rawHeader.title);
65
+ const location = rawHeader.location;
66
+ const description = bodyText || headerLine;
67
+ const canonical_url = `https://news.ycombinator.com/item?id=${item.id}`;
68
+ const posted_at = item.time
69
+ ? new Date(item.time * 1000).toISOString()
70
+ : null;
71
+ return {
72
+ job_id: stableId(canonical_url),
73
+ title,
74
+ company,
75
+ location,
76
+ description,
77
+ url: canonical_url,
78
+ source: "hackernews",
79
+ salary_min: parseSalaryFromText(description),
80
+ salary_max: null,
81
+ work_mode: inferWorkMode(location, description),
82
+ experience_years: inferExperienceYears(description),
83
+ posted_at,
84
+ fetched_at: fetched_at ?? utcNow(),
85
+ };
86
+ }
87
+ // ---------------------------------------------------------------------------
88
+ // Internal helpers
89
+ // ---------------------------------------------------------------------------
90
+ /**
91
+ * Parse the HN comment header line.
92
+ *
93
+ * Common formats:
94
+ * "Acme | Senior Engineer | Remote | Full-time"
95
+ * "Acme | Senior Engineer | Remote"
96
+ * "Acme (hiring) | Engineer"
97
+ * "Acme - Senior Engineer - Remote" ← less common, dash separator
98
+ */
99
+ function parseHeader(header) {
100
+ // Prefer pipe separator; fall back to dash
101
+ const separator = header.includes("|") ? "|" : " - ";
102
+ const parts = header
103
+ .split(separator)
104
+ .map((p) => p.trim())
105
+ .filter(Boolean);
106
+ const company = parts[0] ?? "";
107
+ const title = parts[1] ?? "";
108
+ // Location is usually the 3rd segment; if it looks like a work-mode
109
+ // keyword or a city, use it. Otherwise leave empty.
110
+ const location = parts[2] ?? "";
111
+ return { company, title, location };
112
+ }
113
+ function inferWorkMode(location, description) {
114
+ const text = `${location} ${description}`.toLowerCase();
115
+ if (/\bhybrid\b/.test(text))
116
+ return "hybrid";
117
+ if (/\bremote\b/.test(text))
118
+ return "remote";
119
+ if (/\bon-?site\b|\bin-?office\b/.test(text))
120
+ return "onsite";
121
+ return null;
122
+ }
123
+ function inferExperienceYears(text) {
124
+ const m = text.match(/(\d+)\+?\s*(?:or more\s+)?years?\s+(?:of\s+)?experience/i);
125
+ return m && m[1] ? parseInt(m[1], 10) : null;
126
+ }
127
+ /**
128
+ * Best-effort salary extraction from free-form description text.
129
+ * Returns the lower bound (salary_min) only; salary_max deferred to Phase 3+.
130
+ */
131
+ function parseSalaryFromText(text) {
132
+ // "$120k", "$120,000", "120k", "$120K+"
133
+ const m = text.match(/\$\s*([\d,]+)\s*k\b/i);
134
+ if (m && m[1])
135
+ return parseInt(m[1].replace(/,/g, ""), 10) * 1_000;
136
+ const m2 = text.match(/\$\s*([\d]{3,3}),([\d]{3})/);
137
+ if (m2 && m2[1] && m2[2]) {
138
+ return parseInt(`${m2[1]}${m2[2]}`, 10);
139
+ }
140
+ return null;
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // HTTP fetch helper
144
+ // ---------------------------------------------------------------------------
145
+ async function fetchItem(id) {
146
+ const controller = new AbortController();
147
+ const timer = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS);
148
+ try {
149
+ const res = await fetch(`${HN_API_BASE}/item/${id}.json`, {
150
+ signal: controller.signal,
151
+ headers: { "User-Agent": USER_AGENT },
152
+ });
153
+ if (!res.ok)
154
+ return null;
155
+ return (await res.json());
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ finally {
161
+ clearTimeout(timer);
162
+ }
163
+ }
164
+ //# sourceMappingURL=hackernews.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hackernews.js","sourceRoot":"","sources":["../../src/adapters/hackernews.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * adapters/index.ts — Public adapter API.
3
+ *
4
+ * Import adapters from here rather than individual files.
5
+ * sources.ts (Phase 3) will use fetchAll() to merge results.
6
+ */
7
+ export { fetchRemoteOkJobs, parseRss, stripHtml, stableId } from "./remoteok.js";
8
+ export { fetchHnJobs, parseComment } from "./hackernews.js";
9
+ export type { HnItem } from "./hackernews.js";
10
+ //# sourceMappingURL=index.d.ts.map