freshcontext-mcp 0.2.0 → 0.3.1
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.
- package/add-cache.cjs +86 -0
- package/dist/adapters/jobs.js +118 -52
- package/package.json +51 -55
- package/server.json +2 -2
package/add-cache.cjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
let content = fs.readFileSync('./worker/src/worker.ts', 'utf8');
|
|
3
|
+
|
|
4
|
+
// Tools that need withCache wrapping (github already done)
|
|
5
|
+
// For each: find the async handler start and add withCache open
|
|
6
|
+
// Then find the corresponding catch line and add the closing
|
|
7
|
+
|
|
8
|
+
const tools = [
|
|
9
|
+
{ name: 'extract_hackernews', adapter: 'hackernews', input: 'url' },
|
|
10
|
+
{ name: 'extract_scholar', adapter: 'scholar', input: 'url' },
|
|
11
|
+
{ name: 'extract_yc', adapter: 'yc', input: 'url' },
|
|
12
|
+
{ name: 'search_repos', adapter: 'reposearch', input: 'query' },
|
|
13
|
+
{ name: 'package_trends', adapter: 'packagetrends', input: 'packages' },
|
|
14
|
+
{ name: 'extract_reddit', adapter: 'reddit', input: 'url' },
|
|
15
|
+
{ name: 'extract_producthunt', adapter: 'producthunt', input: 'url' },
|
|
16
|
+
{ name: 'extract_finance', adapter: 'finance', input: 'url' },
|
|
17
|
+
{ name: 'search_jobs', adapter: 'jobs', input: 'query' },
|
|
18
|
+
{ name: 'extract_landscape', adapter: 'landscape', input: 'topic' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// We'll work line-by-line for precision
|
|
22
|
+
const lines = content.split('\n');
|
|
23
|
+
const result = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
|
|
26
|
+
while (i < lines.length) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
|
|
29
|
+
// Check if this line closes a tool handler that needs wrapping
|
|
30
|
+
// Pattern: " } catch (err: any) { return ..." followed by " });"
|
|
31
|
+
// We add " }); // end withCache" before the closing });
|
|
32
|
+
if (
|
|
33
|
+
line.match(/^\s+\} catch \(err: any\) \{ return \{ content/) &&
|
|
34
|
+
lines[i + 1] && lines[i + 1].trim() === '});' &&
|
|
35
|
+
!(lines[i + 1].includes('end withCache'))
|
|
36
|
+
) {
|
|
37
|
+
result.push(line);
|
|
38
|
+
result.push(' }); // end withCache');
|
|
39
|
+
i++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
result.push(line);
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
content = result.join('\n');
|
|
48
|
+
|
|
49
|
+
// Now add the withCache OPEN for each tool
|
|
50
|
+
// Pattern to find: the }, async ({ INPUT }) => {\n try {
|
|
51
|
+
// for each tool, but only if not already wrapped
|
|
52
|
+
|
|
53
|
+
for (const tool of tools) {
|
|
54
|
+
// Build a specific marker string that is unique per tool
|
|
55
|
+
// Look for the line: " }, async ({ url/query/etc }) => {"
|
|
56
|
+
// followed by next non-empty content being " try {"
|
|
57
|
+
// We replace " try {" with " return withCache(...); try {"
|
|
58
|
+
|
|
59
|
+
// Find the tool's registerTool call, then find the first "try {" after it
|
|
60
|
+
const toolMarker = `server.registerTool("${tool.name}"`;
|
|
61
|
+
const idx = content.indexOf(toolMarker);
|
|
62
|
+
if (idx === -1) { console.log('Tool not found: ' + tool.name); continue; }
|
|
63
|
+
|
|
64
|
+
// From that position, find the first " try {" that is NOT already preceded by withCache
|
|
65
|
+
const afterTool = content.slice(idx);
|
|
66
|
+
const tryIdx = afterTool.indexOf('\n try {');
|
|
67
|
+
if (tryIdx === -1) { console.log('No try block for: ' + tool.name); continue; }
|
|
68
|
+
|
|
69
|
+
// Check if withCache is already there (look 200 chars before the try)
|
|
70
|
+
const beforeTry = afterTool.slice(Math.max(0, tryIdx - 200), tryIdx);
|
|
71
|
+
if (beforeTry.includes('withCache')) {
|
|
72
|
+
console.log('Already wrapped: ' + tool.name);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Insert the withCache line before " try {"
|
|
77
|
+
const absoluteIdx = idx + tryIdx;
|
|
78
|
+
content = content.slice(0, absoluteIdx) +
|
|
79
|
+
`\n return withCache("${tool.adapter}", ${tool.input}, env.CACHE, async () => {` +
|
|
80
|
+
content.slice(absoluteIdx);
|
|
81
|
+
|
|
82
|
+
console.log('Wrapped: ' + tool.name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fs.writeFileSync('./worker/src/worker.ts', content, 'utf8');
|
|
86
|
+
console.log('\nAll done. Lines: ' + content.split('\n').length);
|
package/dist/adapters/jobs.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Jobs adapter
|
|
2
|
+
* Jobs adapter v3 — 5 sources, freshness badges, location + keyword filtering.
|
|
3
3
|
*
|
|
4
4
|
* Sources (all no-auth):
|
|
5
|
-
* - Remotive — remote jobs,
|
|
6
|
-
* - RemoteOK — pure remote,
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
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
|
-
*
|
|
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) ||
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
remoteOnly ? Promise.reject("skipped
|
|
63
|
-
fetchHNHiring(query, location, maxAgeDays, keywords
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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 } =
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
|
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.
|
|
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 } =
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
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
|
-
|
|
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 } =
|
|
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,
|
|
297
|
+
.slice(0, 400);
|
|
228
298
|
const text = highlightKeywords([
|
|
229
|
-
`[HN Hiring]
|
|
230
|
-
|
|
231
|
-
excerpt + (excerpt.length >=
|
|
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,55 +1,51 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "freshcontext-mcp",
|
|
3
|
-
"mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"mcp",
|
|
8
|
-
"mcp-server",
|
|
9
|
-
"ai-agents",
|
|
10
|
-
"llm",
|
|
11
|
-
"freshness",
|
|
12
|
-
"web-scraping",
|
|
13
|
-
"github-analytics",
|
|
14
|
-
"hackernews",
|
|
15
|
-
"yc",
|
|
16
|
-
"typescript",
|
|
17
|
-
"context",
|
|
18
|
-
"model-context-protocol"
|
|
19
|
-
],
|
|
20
|
-
"homepage": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp.git"
|
|
24
|
-
},
|
|
25
|
-
"license": "MIT",
|
|
26
|
-
"type": "module",
|
|
27
|
-
"main": "dist/server.js",
|
|
28
|
-
"bin": {
|
|
29
|
-
"freshcontext-mcp": "dist/server.js"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "tsc",
|
|
33
|
-
"dev": "tsx watch src/server.ts",
|
|
34
|
-
"start": "node dist/server.js",
|
|
35
|
-
"inspect": "npx @modelcontextprotocol/inspector tsx src/server.ts",
|
|
36
|
-
"test": "jest"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
|
-
"playwright": "^1.44.0",
|
|
41
|
-
"zod": "^3.23.0",
|
|
42
|
-
"dotenv": "^16.4.0"
|
|
43
|
-
},
|
|
44
|
-
"devDependencies": {
|
|
45
|
-
"@types/node": "^20.0.0",
|
|
46
|
-
"tsx": "^4.0.0",
|
|
47
|
-
"typescript": "^5.4.0",
|
|
48
|
-
"jest": "^29.0.0",
|
|
49
|
-
"@types/jest": "^29.0.0"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "freshcontext-mcp",
|
|
3
|
+
"mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
|
|
4
|
+
"version": "0.3.1",
|
|
5
|
+
"description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"mcp-server",
|
|
9
|
+
"ai-agents",
|
|
10
|
+
"llm",
|
|
11
|
+
"freshness",
|
|
12
|
+
"web-scraping",
|
|
13
|
+
"github-analytics",
|
|
14
|
+
"hackernews",
|
|
15
|
+
"yc",
|
|
16
|
+
"typescript",
|
|
17
|
+
"context",
|
|
18
|
+
"model-context-protocol"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/PrinceGabriel-lgtm/freshcontext-mcp.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "dist/server.js",
|
|
28
|
+
"bin": {
|
|
29
|
+
"freshcontext-mcp": "dist/server.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"dev": "tsx watch src/server.ts",
|
|
34
|
+
"start": "node dist/server.js",
|
|
35
|
+
"inspect": "npx @modelcontextprotocol/inspector tsx src/server.ts",
|
|
36
|
+
"test": "jest"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
|
+
"playwright": "^1.44.0",
|
|
41
|
+
"zod": "^3.23.0",
|
|
42
|
+
"dotenv": "^16.4.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^20.0.0",
|
|
46
|
+
"tsx": "^4.0.0",
|
|
47
|
+
"typescript": "^5.4.0",
|
|
48
|
+
"jest": "^29.0.0",
|
|
49
|
+
"@types/jest": "^29.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/server.json
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
"url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.1
|
|
9
|
+
"version": "0.3.1",
|
|
10
10
|
"website_url": "https://freshcontext-site.pages.dev",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registry_type": "npm",
|
|
14
14
|
"registry_base_url": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "freshcontext-mcp",
|
|
16
|
-
"version": "0.1
|
|
16
|
+
"version": "0.3.1",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|