freshcontext-mcp 0.2.0 → 0.3.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 (2) hide show
  1. package/dist/adapters/jobs.js +118 -52
  2. package/package.json +1 -5
@@ -1,19 +1,18 @@
1
1
  /**
2
- * Jobs adapter v24 sources, freshness badges, location + keyword filtering.
2
+ * Jobs adapter v35 sources, freshness badges, location + keyword filtering.
3
3
  *
4
4
  * Sources (all no-auth):
5
- * - Remotive — remote jobs, location filterable
6
- * - RemoteOK — pure remote, salary data, unix timestamps
7
- * - The Muse structured jobs, location + category
8
- * - HN Who is Hiring — community-sourced, raw but real
5
+ * - Remotive — remote tech jobs, salary data
6
+ * - RemoteOK — pure remote, unix timestamps
7
+ * - Arbeitnow broad jobs API (tech + non-tech, location-aware)
8
+ * - The Muse — structured listings, level info
9
+ * - HN Who is Hiring — monthly thread, community-sourced
9
10
  *
10
- * Every listing gets a freshness badge:
11
+ * Freshness badges on every listing:
11
12
  * 🟢 < 7 days — FRESH, apply now
12
13
  * 🟡 7–30 days — still good
13
14
  * 🔴 31–90 days — apply with caution
14
15
  * ⛔ > 90 days — likely expired, shown last
15
- *
16
- * This adapter was the whole reason freshcontext exists.
17
16
  */
18
17
  // ─── Freshness Scoring ────────────────────────────────────────────────────────
19
18
  function freshnessBadge(dateStr) {
@@ -35,7 +34,11 @@ function matchesLocation(locationField, filterLocation) {
35
34
  return true;
36
35
  const loc = locationField.toLowerCase();
37
36
  const filter = filterLocation.toLowerCase();
38
- return loc.includes(filter) || filter.includes(loc) || loc.includes("worldwide") || loc.includes("anywhere") || loc.includes("remote");
37
+ return (loc.includes(filter) ||
38
+ filter.includes(loc) ||
39
+ loc.includes("worldwide") ||
40
+ loc.includes("anywhere") ||
41
+ loc.includes("remote"));
39
42
  }
40
43
  function highlightKeywords(text, keywords) {
41
44
  if (!keywords.length)
@@ -55,40 +58,58 @@ export async function jobsAdapter(options) {
55
58
  const remoteOnly = options.remoteOnly ?? false;
56
59
  const maxAgeDays = options.maxAgeDays ?? 60;
57
60
  const keywords = options.keywords ?? [];
58
- const perSource = Math.floor(maxLength / 4);
59
- const [remotiveRes, remoteOkRes, museRes, hnRes] = await Promise.allSettled([
60
- fetchRemotive(query, location, maxAgeDays, keywords, perSource),
61
- fetchRemoteOK(query, location, maxAgeDays, keywords, perSource),
62
- remoteOnly ? Promise.reject("skipped (remote_only mode)") : fetchMuse(query, location, maxAgeDays, keywords, perSource),
63
- fetchHNHiring(query, location, maxAgeDays, keywords, perSource),
61
+ const [remotiveRes, remoteOkRes, arbeitnowRes, museRes, hnRes] = await Promise.allSettled([
62
+ fetchRemotive(query, location, maxAgeDays, keywords),
63
+ fetchRemoteOK(query, location, maxAgeDays, keywords),
64
+ fetchArbeitnow(query, location, maxAgeDays, keywords, remoteOnly),
65
+ remoteOnly ? Promise.reject("skipped") : fetchMuse(query, location, maxAgeDays, keywords),
66
+ fetchHNHiring(query, location, maxAgeDays, keywords),
64
67
  ]);
65
68
  const pool = [];
69
+ const sourceStats = {};
66
70
  const harvest = (res, label) => {
67
- if (res.status === "fulfilled")
71
+ if (res.status === "fulfilled") {
68
72
  pool.push(...res.value.listings);
69
- // silently skip rejected sources — don't clutter output
73
+ sourceStats[label] = res.value.listings.length;
74
+ }
75
+ else {
76
+ sourceStats[label] = 0;
77
+ }
70
78
  };
71
79
  harvest(remotiveRes, "Remotive");
72
80
  harvest(remoteOkRes, "RemoteOK");
81
+ harvest(arbeitnowRes, "Arbeitnow");
73
82
  harvest(museRes, "The Muse");
74
- harvest(hnRes, "HN");
83
+ harvest(hnRes, "HN Hiring");
75
84
  if (!pool.length) {
76
85
  return {
77
- raw: `No job listings found for "${query}"${location ? ` in ${location}` : ""}.\n\nTips:\n• Try broader terms e.g. "engineer" instead of "senior TypeScript engineer"\n• Set location to "remote" for worldwide results\n• Increase max_age_days`,
86
+ raw: [
87
+ `No job listings found for "${query}"${location ? ` in ${location}` : ""}.`,
88
+ "",
89
+ "Tips:",
90
+ "• Try broader terms e.g. \"engineer\" instead of \"senior TypeScript engineer\"",
91
+ "• Set location to \"remote\" for worldwide results",
92
+ "• Increase max_age_days (default: 60)",
93
+ "• Note: FIFO/mining/trades jobs are on specialist boards (myJobsNamibia, SEEK, mining-specific sites) — these sources are tech/remote focused",
94
+ ].join("\n"),
78
95
  content_date: null,
79
96
  freshness_confidence: "low",
80
97
  };
81
98
  }
82
- // Sort: freshest first, expired listings last
99
+ // Sort: freshest first
83
100
  pool.sort((a, b) => a.days - b.days);
84
101
  const freshCount = pool.filter(l => l.days <= 7).length;
85
102
  const goodCount = pool.filter(l => l.days > 7 && l.days <= 30).length;
86
103
  const staleCount = pool.filter(l => l.days > 30).length;
104
+ const sourceSummary = Object.entries(sourceStats)
105
+ .map(([src, n]) => `${src}:${n}`)
106
+ .join(" ");
87
107
  const header = [
88
108
  `# Job Search: "${query}"${location ? ` · ${location}` : ""}${remoteOnly ? " · remote only" : ""}`,
89
109
  `Retrieved: ${new Date().toISOString()}`,
90
110
  `Found: ${pool.length} listings — 🟢 ${freshCount} fresh 🟡 ${goodCount} recent 🔴 ${staleCount} older`,
91
- `⚠️ Listings sorted freshest first. Check the date badge before you apply.`,
111
+ `Sources: ${sourceSummary}`,
112
+ `⚠️ Sorted freshest first. Check badge before applying.`,
92
113
  keywords.length ? `🔍 Watching for: ${keywords.map(k => `⚡${k}`).join(", ")}` : null,
93
114
  "",
94
115
  ].filter(Boolean).join("\n");
@@ -107,10 +128,10 @@ export async function jobsAdapter(options) {
107
128
  };
108
129
  }
109
130
  // ─── Source: Remotive ─────────────────────────────────────────────────────────
110
- async function fetchRemotive(query, location, maxAgeDays, keywords, maxLength) {
131
+ async function fetchRemotive(query, location, maxAgeDays, keywords) {
111
132
  const url = `https://remotive.com/api/remote-jobs?search=${encodeURIComponent(query)}&limit=15`;
112
133
  const res = await fetch(url, {
113
- headers: { "User-Agent": "freshcontext-mcp/0.2.0", "Accept": "application/json" },
134
+ headers: { "User-Agent": "freshcontext-mcp/0.3.0", "Accept": "application/json" },
114
135
  });
115
136
  if (!res.ok)
116
137
  throw new Error(`Remotive ${res.status}`);
@@ -123,7 +144,7 @@ async function fetchRemotive(query, location, maxAgeDays, keywords, maxLength) {
123
144
  return null;
124
145
  const text = highlightKeywords([
125
146
  `[Remotive] ${j.title} — ${j.company_name}`,
126
- `${badge}`,
147
+ badge,
127
148
  `Location: ${j.candidate_required_location || "Remote"} | Type: ${j.job_type || "N/A"}`,
128
149
  j.salary ? `Salary: ${j.salary}` : null,
129
150
  j.tags?.length ? `Tags: ${j.tags.slice(0, 6).join(", ")}` : null,
@@ -136,22 +157,21 @@ async function fetchRemotive(query, location, maxAgeDays, keywords, maxLength) {
136
157
  return { listings };
137
158
  }
138
159
  // ─── Source: RemoteOK ─────────────────────────────────────────────────────────
139
- async function fetchRemoteOK(query, location, maxAgeDays, keywords, maxLength) {
160
+ async function fetchRemoteOK(query, location, maxAgeDays, keywords) {
140
161
  const tag = query.toLowerCase().replace(/\s+/g, "-");
141
162
  const url = `https://remoteok.com/api?tag=${encodeURIComponent(tag)}`;
142
163
  const res = await fetch(url, {
143
- headers: { "User-Agent": "freshcontext-mcp/0.2.0", "Accept": "application/json" },
164
+ headers: { "User-Agent": "freshcontext-mcp/0.3.0", "Accept": "application/json" },
144
165
  });
145
166
  if (!res.ok)
146
167
  throw new Error(`RemoteOK ${res.status}`);
147
168
  const raw = await res.json();
148
- // First element is a legal notice object, skip it
149
169
  const jobs = raw.filter(j => j.id && j.position);
150
170
  const listings = jobs
151
171
  .filter(j => matchesLocation(j.location ?? "Remote", location))
152
172
  .map(j => {
153
173
  const dateStr = j.date ?? (j.epoch ? new Date(j.epoch * 1000).toISOString() : null);
154
- const { badge, days } = freshnessDate(dateStr);
174
+ const { badge, days } = freshnessBadge(dateStr);
155
175
  if (days > maxAgeDays)
156
176
  return null;
157
177
  const salary = j.salary_min && j.salary_max
@@ -159,7 +179,7 @@ async function fetchRemoteOK(query, location, maxAgeDays, keywords, maxLength) {
159
179
  : null;
160
180
  const text = highlightKeywords([
161
181
  `[RemoteOK] ${j.position} — ${j.company ?? "Unknown"}`,
162
- `${badge}`,
182
+ badge,
163
183
  `Location: ${j.location || "Remote Worldwide"}`,
164
184
  salary ? `Salary: ${salary}` : null,
165
185
  j.tags?.length ? `Tags: ${j.tags.slice(0, 6).join(", ")}` : null,
@@ -171,14 +191,49 @@ async function fetchRemoteOK(query, location, maxAgeDays, keywords, maxLength) {
171
191
  .slice(0, 8);
172
192
  return { listings };
173
193
  }
194
+ // ─── Source: Arbeitnow (NEW) ──────────────────────────────────────────────────
195
+ // Free public API — broader than tech boards. Good for non-remote, non-tech roles.
196
+ async function fetchArbeitnow(query, location, maxAgeDays, keywords, remoteOnly) {
197
+ const params = new URLSearchParams({ search: query });
198
+ if (remoteOnly)
199
+ params.set("remote", "true");
200
+ const url = `https://arbeitnow.com/api/job-board-api?${params.toString()}`;
201
+ const res = await fetch(url, {
202
+ headers: { "User-Agent": "freshcontext-mcp/0.3.0", "Accept": "application/json" },
203
+ });
204
+ if (!res.ok)
205
+ throw new Error(`Arbeitnow ${res.status}`);
206
+ const data = await res.json();
207
+ const listings = (data.data ?? [])
208
+ .filter(j => matchesLocation(j.location ?? "", location))
209
+ .map(j => {
210
+ const dateStr = new Date(j.created_at * 1000).toISOString();
211
+ const { badge, days } = freshnessBadge(dateStr);
212
+ if (days > maxAgeDays)
213
+ return null;
214
+ const text = highlightKeywords([
215
+ `[Arbeitnow] ${j.title} — ${j.company_name}`,
216
+ badge,
217
+ `Location: ${j.location || "Remote"}${j.remote ? " (Remote OK)" : ""}`,
218
+ j.job_types?.length ? `Type: ${j.job_types.join(", ")}` : null,
219
+ j.tags?.length ? `Tags: ${j.tags.slice(0, 6).join(", ")}` : null,
220
+ `Apply: ${j.url}`,
221
+ ].filter(Boolean).join("\n"), keywords);
222
+ return { text, days, source: "arbeitnow" };
223
+ })
224
+ .filter((l) => l !== null)
225
+ .slice(0, 8);
226
+ return { listings };
227
+ }
174
228
  // ─── Source: The Muse ─────────────────────────────────────────────────────────
175
- async function fetchMuse(query, location, maxAgeDays, keywords, maxLength) {
229
+ // Fixed: now uses `name` param for text search instead of `category`
230
+ async function fetchMuse(query, location, maxAgeDays, keywords) {
176
231
  const locParam = location && location.toLowerCase() !== "remote"
177
232
  ? `&location=${encodeURIComponent(location)}`
178
233
  : "&location=Flexible%20%2F%20Remote";
179
- const url = `https://www.themuse.com/api/public/jobs?category=${encodeURIComponent(query)}${locParam}&page=0&descending=true`;
234
+ const url = `https://www.themuse.com/api/public/jobs?name=${encodeURIComponent(query)}${locParam}&page=0&descending=true`;
180
235
  const res = await fetch(url, {
181
- headers: { "User-Agent": "freshcontext-mcp/0.2.0", "Accept": "application/json" },
236
+ headers: { "User-Agent": "freshcontext-mcp/0.3.0", "Accept": "application/json" },
182
237
  });
183
238
  if (!res.ok)
184
239
  throw new Error(`The Muse ${res.status}`);
@@ -186,13 +241,13 @@ async function fetchMuse(query, location, maxAgeDays, keywords, maxLength) {
186
241
  const listings = (data.results ?? [])
187
242
  .map(j => {
188
243
  const locationStr = j.locations?.map(l => l.name).join(", ") || "Flexible/Remote";
189
- const { badge, days } = freshnessDate(j.publication_date);
244
+ const { badge, days } = freshnessBadge(j.publication_date);
190
245
  if (days > maxAgeDays)
191
246
  return null;
192
247
  const level = j.levels?.map(l => l.name).join(", ") || null;
193
248
  const text = highlightKeywords([
194
249
  `[The Muse] ${j.name} — ${j.company?.name ?? "Unknown"}`,
195
- `${badge}`,
250
+ badge,
196
251
  `Location: ${locationStr}`,
197
252
  level ? `Level: ${level}` : null,
198
253
  `Apply: ${j.refs?.landing_page ?? "N/A"}`,
@@ -204,31 +259,46 @@ async function fetchMuse(query, location, maxAgeDays, keywords, maxLength) {
204
259
  return { listings };
205
260
  }
206
261
  // ─── Source: HN Who is Hiring ─────────────────────────────────────────────────
207
- async function fetchHNHiring(query, location, maxAgeDays, keywords, maxLength) {
208
- const searchQ = [query, location, "hiring"].filter(Boolean).join(" ");
209
- const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchQ)}&tags=comment&hitsPerPage=10`;
210
- const res = await fetch(url, { headers: { "User-Agent": "freshcontext-mcp/0.2.0" } });
211
- if (!res.ok)
212
- throw new Error(`HN ${res.status}`);
213
- const data = await res.json();
214
- const listings = (data.hits ?? [])
262
+ // Fixed: now searches within the actual monthly "Who is Hiring" thread
263
+ // instead of all HN comments. Uses the parent_id filter to target the thread.
264
+ async function fetchHNHiring(query, location, maxAgeDays, keywords) {
265
+ // Step 1: Find the most recent "Ask HN: Who is hiring?" thread
266
+ const threadRes = await fetch(`https://hn.algolia.com/api/v1/search?query=Ask+HN+Who+is+hiring&tags=story&hitsPerPage=5`, { headers: { "User-Agent": "freshcontext-mcp/0.3.0" } });
267
+ if (!threadRes.ok)
268
+ throw new Error(`HN thread search ${threadRes.status}`);
269
+ const threadData = await threadRes.json();
270
+ // Pick most recent hiring thread (they post monthly)
271
+ const hiringThread = threadData.hits.find(h => h.title?.toLowerCase().includes("who is hiring"));
272
+ if (!hiringThread)
273
+ throw new Error("HN hiring thread not found");
274
+ // Step 2: Search comments within that thread for the query
275
+ const searchTerms = [query, location].filter(Boolean).join(" ");
276
+ const commentsRes = await fetch(`https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchTerms)}&tags=comment,story_${hiringThread.objectID}&hitsPerPage=10`, { headers: { "User-Agent": "freshcontext-mcp/0.3.0" } });
277
+ if (!commentsRes.ok)
278
+ throw new Error(`HN comments ${commentsRes.status}`);
279
+ const commentsData = await commentsRes.json();
280
+ const listings = (commentsData.hits ?? [])
215
281
  .filter(h => {
216
282
  const t = (h.comment_text ?? "").toLowerCase();
217
- return t.includes("hiring") || t.includes("remote") || t.includes("full-time") || t.includes("apply");
283
+ // Must look like a job post, not a meta comment
284
+ return t.length > 50 && (t.includes("hiring") || t.includes("remote") ||
285
+ t.includes("full-time") || t.includes("apply") ||
286
+ t.includes("|") // common delimiter in HN job posts
287
+ );
218
288
  })
219
289
  .map(h => {
220
- const { badge, days } = freshnessDate(h.created_at);
290
+ const { badge, days } = freshnessBadge(h.created_at);
221
291
  if (days > maxAgeDays)
222
292
  return null;
223
293
  const excerpt = (h.comment_text ?? "")
224
294
  .replace(/<[^>]+>/g, " ")
225
295
  .replace(/\s+/g, " ")
226
296
  .trim()
227
- .slice(0, 350);
297
+ .slice(0, 400);
228
298
  const text = highlightKeywords([
229
- `[HN Hiring] Posted by ${h.author}`,
230
- `${badge}`,
231
- excerpt + (excerpt.length >= 350 ? "…" : ""),
299
+ `[HN Hiring · ${hiringThread.title}] by ${h.author}`,
300
+ badge,
301
+ excerpt + (excerpt.length >= 400 ? "…" : ""),
232
302
  `Source: https://news.ycombinator.com/item?id=${h.objectID}`,
233
303
  ].join("\n"), keywords);
234
304
  return { text, days, source: "hn" };
@@ -237,7 +307,3 @@ async function fetchHNHiring(query, location, maxAgeDays, keywords, maxLength) {
237
307
  .slice(0, 6);
238
308
  return { listings };
239
309
  }
240
- // ─── Shared date helper ───────────────────────────────────────────────────────
241
- function freshnessDate(dateStr) {
242
- return freshnessBadge(dateStr ?? null);
243
- }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "freshcontext-mcp",
3
3
  "mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
6
6
  "keywords": [
7
7
  "mcp",
@@ -49,7 +49,3 @@
49
49
  "@types/jest": "^29.0.0"
50
50
  }
51
51
  }
52
-
53
-
54
-
55
-