freshcontext-mcp 0.1.7 → 0.1.9
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/dist/adapters/jobs.js +139 -0
- package/dist/server.js +19 -0
- package/package.json +4 -1
- package/server.json +28 -0
- package/.env.example +0 -8
- package/src/adapters/arxiv.ts +0 -84
- package/src/adapters/finance.ts +0 -159
- package/src/adapters/github.ts +0 -54
- package/src/adapters/hackernews.ts +0 -95
- package/src/adapters/packageTrends.ts +0 -104
- package/src/adapters/productHunt.ts +0 -144
- package/src/adapters/reddit.ts +0 -87
- package/src/adapters/repoSearch.ts +0 -79
- package/src/adapters/scholar.ts +0 -69
- package/src/adapters/yc.ts +0 -103
- package/src/security.ts +0 -165
- package/src/server.ts +0 -215
- package/src/tools/freshnessStamp.ts +0 -33
- package/src/types.ts +0 -22
- package/start-server.bat +0 -2
- package/tsconfig.json +0 -17
- package/worker/package-lock.json +0 -3578
- package/worker/package.json +0 -19
- package/worker/src/worker.ts +0 -463
- package/worker/tsconfig.json +0 -12
- package/worker/wrangler.jsonc +0 -16
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs adapter — two sources, pure fetch, no auth required.
|
|
3
|
+
*
|
|
4
|
+
* Source 1: Remotive API (structured remote jobs, publication_date on every listing)
|
|
5
|
+
* https://remotive.com/api/remote-jobs?search=QUERY&limit=10
|
|
6
|
+
*
|
|
7
|
+
* Source 2: HN "Who is Hiring" comments (community-sourced, timestamped)
|
|
8
|
+
* Searches Algolia for relevant comments in hiring threads
|
|
9
|
+
*
|
|
10
|
+
* This adapter was the whole reason freshcontext exists.
|
|
11
|
+
* Claude kept returning job listings from 2022. This fixes that.
|
|
12
|
+
*/
|
|
13
|
+
export async function jobsAdapter(options) {
|
|
14
|
+
const query = options.url.trim();
|
|
15
|
+
const maxLength = options.maxLength ?? 6000;
|
|
16
|
+
const perSource = Math.floor(maxLength / 2);
|
|
17
|
+
const [remotiveResult, hnResult] = await Promise.allSettled([
|
|
18
|
+
fetchRemotive(query, perSource),
|
|
19
|
+
fetchHNHiring(query, perSource),
|
|
20
|
+
]);
|
|
21
|
+
const sections = [];
|
|
22
|
+
let newestDate = null;
|
|
23
|
+
const trackDate = (d) => {
|
|
24
|
+
if (d && (!newestDate || d > newestDate))
|
|
25
|
+
newestDate = d;
|
|
26
|
+
};
|
|
27
|
+
// ── Remotive ──────────────────────────────────────────────────────────────
|
|
28
|
+
if (remotiveResult.status === "fulfilled" && remotiveResult.value.raw) {
|
|
29
|
+
sections.push(`## 🌐 Remote Jobs (Remotive)\n${remotiveResult.value.raw}`);
|
|
30
|
+
trackDate(remotiveResult.value.content_date);
|
|
31
|
+
}
|
|
32
|
+
else if (remotiveResult.status === "rejected") {
|
|
33
|
+
sections.push(`## 🌐 Remote Jobs (Remotive)\n[Unavailable: ${remotiveResult.reason}]`);
|
|
34
|
+
}
|
|
35
|
+
// ── HN Who is Hiring ──────────────────────────────────────────────────────
|
|
36
|
+
if (hnResult.status === "fulfilled" && hnResult.value.raw) {
|
|
37
|
+
sections.push(`## 💬 HN "Who is Hiring" (Community)\n${hnResult.value.raw}`);
|
|
38
|
+
trackDate(hnResult.value.content_date);
|
|
39
|
+
}
|
|
40
|
+
else if (hnResult.status === "rejected") {
|
|
41
|
+
sections.push(`## 💬 HN "Who is Hiring"\n[Unavailable: ${hnResult.reason}]`);
|
|
42
|
+
}
|
|
43
|
+
if (!sections.length) {
|
|
44
|
+
return {
|
|
45
|
+
raw: `No job results found for "${query}". Try broader terms like "typescript", "remote python", or "senior engineer".`,
|
|
46
|
+
content_date: null,
|
|
47
|
+
freshness_confidence: "low",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const raw = [
|
|
51
|
+
`# Job Search: "${query}"`,
|
|
52
|
+
`⚠️ Every listing below includes its publication date. Check it before you apply.`,
|
|
53
|
+
"",
|
|
54
|
+
...sections,
|
|
55
|
+
].join("\n\n");
|
|
56
|
+
return {
|
|
57
|
+
raw: raw.slice(0, maxLength),
|
|
58
|
+
content_date: newestDate,
|
|
59
|
+
freshness_confidence: newestDate ? "high" : "medium",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// ─── Remotive ─────────────────────────────────────────────────────────────────
|
|
63
|
+
async function fetchRemotive(query, maxLength) {
|
|
64
|
+
const url = `https://remotive.com/api/remote-jobs?search=${encodeURIComponent(query)}&limit=10`;
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
headers: {
|
|
67
|
+
"User-Agent": "freshcontext-mcp/0.1.9 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
|
|
68
|
+
"Accept": "application/json",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
throw new Error(`Remotive API error: ${res.status}`);
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
if (!data.jobs?.length) {
|
|
75
|
+
return { raw: `No remote listings found for "${query}".`, content_date: null, freshness_confidence: "medium" };
|
|
76
|
+
}
|
|
77
|
+
const listings = data.jobs.map((job, i) => {
|
|
78
|
+
const lines = [
|
|
79
|
+
`[${i + 1}] ${job.title} — ${job.company_name}`,
|
|
80
|
+
`Type: ${job.job_type || "N/A"} | Location: ${job.candidate_required_location || "Remote"}`,
|
|
81
|
+
`Posted: ${job.publication_date}`,
|
|
82
|
+
job.salary ? `Salary: ${job.salary}` : null,
|
|
83
|
+
job.tags?.length ? `Tags: ${job.tags.slice(0, 5).join(", ")}` : null,
|
|
84
|
+
`Apply: ${job.url}`,
|
|
85
|
+
].filter(Boolean).join("\n");
|
|
86
|
+
return lines;
|
|
87
|
+
});
|
|
88
|
+
const raw = listings.join("\n\n").slice(0, maxLength);
|
|
89
|
+
const dates = data.jobs
|
|
90
|
+
.map(j => j.publication_date)
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.sort()
|
|
93
|
+
.reverse();
|
|
94
|
+
return {
|
|
95
|
+
raw,
|
|
96
|
+
content_date: dates[0] ?? null,
|
|
97
|
+
freshness_confidence: dates[0] ? "high" : "medium",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// ─── HN Who is Hiring ─────────────────────────────────────────────────────────
|
|
101
|
+
async function fetchHNHiring(query, maxLength) {
|
|
102
|
+
const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query + " hiring")}&tags=comment&hitsPerPage=8`;
|
|
103
|
+
const res = await fetch(url, {
|
|
104
|
+
headers: { "User-Agent": "freshcontext-mcp/0.1.9" },
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok)
|
|
107
|
+
throw new Error(`HN Algolia error: ${res.status}`);
|
|
108
|
+
const data = await res.json();
|
|
109
|
+
const jobHits = data.hits.filter(h => {
|
|
110
|
+
const t = (h.comment_text ?? "").toLowerCase();
|
|
111
|
+
return (t.includes("hiring") ||
|
|
112
|
+
t.includes("remote") ||
|
|
113
|
+
t.includes("full-time") ||
|
|
114
|
+
t.includes("salary") ||
|
|
115
|
+
t.includes("apply"));
|
|
116
|
+
});
|
|
117
|
+
if (!jobHits.length) {
|
|
118
|
+
return { raw: `No HN hiring comments found for "${query}".`, content_date: null, freshness_confidence: "medium" };
|
|
119
|
+
}
|
|
120
|
+
const listings = jobHits.map((hit, i) => {
|
|
121
|
+
const text = (hit.comment_text ?? "")
|
|
122
|
+
.replace(/<[^>]+>/g, " ")
|
|
123
|
+
.replace(/\s+/g, " ")
|
|
124
|
+
.trim()
|
|
125
|
+
.slice(0, 300);
|
|
126
|
+
return [
|
|
127
|
+
`[${i + 1}] Posted by ${hit.author} on ${hit.created_at.slice(0, 10)}`,
|
|
128
|
+
text + (text.length >= 300 ? "…" : ""),
|
|
129
|
+
`Source: https://news.ycombinator.com/item?id=${hit.objectID}`,
|
|
130
|
+
].join("\n");
|
|
131
|
+
});
|
|
132
|
+
const raw = listings.join("\n\n").slice(0, maxLength);
|
|
133
|
+
const dates = jobHits.map(h => h.created_at).sort().reverse();
|
|
134
|
+
return {
|
|
135
|
+
raw,
|
|
136
|
+
content_date: dates[0]?.slice(0, 10) ?? null,
|
|
137
|
+
freshness_confidence: dates[0] ? "high" : "medium",
|
|
138
|
+
};
|
|
139
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { hackerNewsAdapter } from "./adapters/hackernews.js";
|
|
|
8
8
|
import { ycAdapter } from "./adapters/yc.js";
|
|
9
9
|
import { repoSearchAdapter } from "./adapters/repoSearch.js";
|
|
10
10
|
import { packageTrendsAdapter } from "./adapters/packageTrends.js";
|
|
11
|
+
import { jobsAdapter } from "./adapters/jobs.js";
|
|
11
12
|
import { stampFreshness, formatForLLM } from "./tools/freshnessStamp.js";
|
|
12
13
|
import { formatSecurityError } from "./security.js";
|
|
13
14
|
const server = new McpServer({
|
|
@@ -152,6 +153,24 @@ server.registerTool("extract_landscape", {
|
|
|
152
153
|
].join("\n\n");
|
|
153
154
|
return { content: [{ type: "text", text: combined }] };
|
|
154
155
|
});
|
|
156
|
+
// ─── Tool: search_jobs ───────────────────────────────────────────────────────
|
|
157
|
+
server.registerTool("search_jobs", {
|
|
158
|
+
description: "Search for real-time job listings with publication dates on every result — so you never apply to a role that closed 2 years ago. Sources: Remotive (remote jobs) + HN 'Who is Hiring' (community). Returns timestamped freshcontext.",
|
|
159
|
+
inputSchema: z.object({
|
|
160
|
+
query: z.string().describe("Job search query e.g. 'typescript remote', 'senior python', 'mcp developer'"),
|
|
161
|
+
max_length: z.number().optional().default(6000).describe("Max content length"),
|
|
162
|
+
}),
|
|
163
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
164
|
+
}, async ({ query, max_length }) => {
|
|
165
|
+
try {
|
|
166
|
+
const result = await jobsAdapter({ url: query, maxLength: max_length });
|
|
167
|
+
const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "jobs");
|
|
168
|
+
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
172
|
+
}
|
|
173
|
+
});
|
|
155
174
|
// ─── Start ───────────────────────────────────────────────────────────────────
|
|
156
175
|
async function main() {
|
|
157
176
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freshcontext-mcp",
|
|
3
|
-
"
|
|
3
|
+
"mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
|
|
4
|
+
"version": "0.1.9",
|
|
4
5
|
"description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"mcp",
|
|
@@ -50,3 +51,5 @@
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
|
|
55
|
+
|
package/server.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
|
|
3
|
+
"name": "io.github.PrinceGabriel-lgtm/freshcontext",
|
|
4
|
+
"description": "Real-time web intelligence for AI agents. 11 tools, no API keys. GitHub, HN, Reddit, arXiv & more.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.1.7",
|
|
10
|
+
"website_url": "https://freshcontext-site.pages.dev",
|
|
11
|
+
"packages": [
|
|
12
|
+
{
|
|
13
|
+
"registry_type": "npm",
|
|
14
|
+
"registry_base_url": "https://registry.npmjs.org",
|
|
15
|
+
"identifier": "freshcontext-mcp",
|
|
16
|
+
"version": "0.1.7",
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"remotes": [
|
|
23
|
+
{
|
|
24
|
+
"type": "streamable-http",
|
|
25
|
+
"url": "https://freshcontext-mcp.gimmanuel73.workers.dev/mcp"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
package/.env.example
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# freshcontext-mcp environment variables
|
|
2
|
-
# Copy to .env and fill in
|
|
3
|
-
|
|
4
|
-
# Optional: GitHub Personal Access Token (increases rate limits for GitHub API fallback)
|
|
5
|
-
GITHUB_TOKEN=
|
|
6
|
-
|
|
7
|
-
# Optional: Proxy URL if needed for certain extractions
|
|
8
|
-
# PROXY_URL=http://user:pass@host:port
|
package/src/adapters/arxiv.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { AdapterResult, ExtractOptions } from "../types.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* arXiv adapter — uses the official arXiv API (no scraping, no auth needed).
|
|
5
|
-
* Accepts a search query or a direct arXiv API URL.
|
|
6
|
-
* Docs: https://arxiv.org/help/api/user-manual
|
|
7
|
-
*/
|
|
8
|
-
export async function arxivAdapter(options: ExtractOptions): Promise<AdapterResult> {
|
|
9
|
-
const input = options.url.trim();
|
|
10
|
-
|
|
11
|
-
// Build API URL — if they pass a plain query, construct it
|
|
12
|
-
const apiUrl = input.startsWith("http")
|
|
13
|
-
? input
|
|
14
|
-
: `https://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(input)}&start=0&max_results=10&sortBy=relevance&sortOrder=descending`;
|
|
15
|
-
|
|
16
|
-
const res = await fetch(apiUrl, {
|
|
17
|
-
headers: { "User-Agent": "freshcontext-mcp/0.1.7 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)" },
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
if (!res.ok) throw new Error(`arXiv API error: ${res.status} ${res.statusText}`);
|
|
21
|
-
|
|
22
|
-
const xml = await res.text();
|
|
23
|
-
|
|
24
|
-
// Parse the Atom XML response
|
|
25
|
-
const entries = [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)];
|
|
26
|
-
|
|
27
|
-
if (!entries.length) {
|
|
28
|
-
return { raw: "No results found for this query.", content_date: null, freshness_confidence: "low" };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const getTag = (block: string, tag: string): string => {
|
|
32
|
-
const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
|
|
33
|
-
return m ? m[1].trim().replace(/\s+/g, " ") : "";
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const getAttr = (block: string, tag: string, attr: string): string => {
|
|
37
|
-
const m = block.match(new RegExp(`<${tag}[^>]*${attr}="([^"]*)"`, "i"));
|
|
38
|
-
return m ? m[1].trim() : "";
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const papers = entries.map((match, i) => {
|
|
42
|
-
const block = match[1];
|
|
43
|
-
|
|
44
|
-
const title = getTag(block, "title").replace(/\n/g, " ");
|
|
45
|
-
const summary = getTag(block, "summary").slice(0, 300).replace(/\n/g, " ");
|
|
46
|
-
const published = getTag(block, "published").slice(0, 10); // YYYY-MM-DD
|
|
47
|
-
const updated = getTag(block, "updated").slice(0, 10);
|
|
48
|
-
const id = getTag(block, "id").replace("http://arxiv.org/abs/", "https://arxiv.org/abs/");
|
|
49
|
-
|
|
50
|
-
// Authors — can be multiple
|
|
51
|
-
const authorMatches = [...block.matchAll(/<author>([\s\S]*?)<\/author>/g)];
|
|
52
|
-
const authors = authorMatches
|
|
53
|
-
.map(a => getTag(a[1], "name"))
|
|
54
|
-
.filter(Boolean)
|
|
55
|
-
.slice(0, 4)
|
|
56
|
-
.join(", ");
|
|
57
|
-
|
|
58
|
-
// Categories
|
|
59
|
-
const primaryCat = getAttr(block, "arxiv:primary_category", "term") ||
|
|
60
|
-
getAttr(block, "category", "term");
|
|
61
|
-
|
|
62
|
-
return [
|
|
63
|
-
`[${i + 1}] ${title}`,
|
|
64
|
-
`Authors: ${authors || "Unknown"}`,
|
|
65
|
-
`Published: ${published}${updated !== published ? ` (updated ${updated})` : ""}`,
|
|
66
|
-
primaryCat ? `Category: ${primaryCat}` : null,
|
|
67
|
-
`Abstract: ${summary}…`,
|
|
68
|
-
`Link: ${id}`,
|
|
69
|
-
].filter(Boolean).join("\n");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const raw = papers.join("\n\n").slice(0, options.maxLength ?? 6000);
|
|
73
|
-
|
|
74
|
-
// Most recent publication date
|
|
75
|
-
const dates = entries
|
|
76
|
-
.map(m => getTag(m[1], "published").slice(0, 10))
|
|
77
|
-
.filter(Boolean)
|
|
78
|
-
.sort()
|
|
79
|
-
.reverse();
|
|
80
|
-
|
|
81
|
-
const content_date = dates[0] ?? null;
|
|
82
|
-
|
|
83
|
-
return { raw, content_date, freshness_confidence: content_date ? "high" : "medium" };
|
|
84
|
-
}
|
package/src/adapters/finance.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { AdapterResult, ExtractOptions } from "../types.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Finance adapter — Yahoo Finance public API, no auth required.
|
|
5
|
-
* Accepts:
|
|
6
|
-
* - A ticker symbol e.g. "AAPL" or "MSFT,GOOG"
|
|
7
|
-
* - A company name e.g. "Apple" (will search for ticker first)
|
|
8
|
-
* - Comma-separated tickers for comparison
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
interface YahooQuote {
|
|
12
|
-
symbol: string;
|
|
13
|
-
shortName?: string;
|
|
14
|
-
longName?: string;
|
|
15
|
-
regularMarketPrice?: number;
|
|
16
|
-
regularMarketChange?: number;
|
|
17
|
-
regularMarketChangePercent?: number;
|
|
18
|
-
marketCap?: number;
|
|
19
|
-
regularMarketVolume?: number;
|
|
20
|
-
fiftyTwoWeekHigh?: number;
|
|
21
|
-
fiftyTwoWeekLow?: number;
|
|
22
|
-
trailingPE?: number;
|
|
23
|
-
dividendYield?: number;
|
|
24
|
-
currency?: string;
|
|
25
|
-
exchangeName?: string;
|
|
26
|
-
regularMarketTime?: number;
|
|
27
|
-
longBusinessSummary?: string;
|
|
28
|
-
sector?: string;
|
|
29
|
-
industry?: string;
|
|
30
|
-
fullTimeEmployees?: number;
|
|
31
|
-
website?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function formatMarketCap(cap: number | undefined): string {
|
|
35
|
-
if (!cap) return "N/A";
|
|
36
|
-
if (cap >= 1e12) return `$${(cap / 1e12).toFixed(2)}T`;
|
|
37
|
-
if (cap >= 1e9) return `$${(cap / 1e9).toFixed(2)}B`;
|
|
38
|
-
if (cap >= 1e6) return `$${(cap / 1e6).toFixed(2)}M`;
|
|
39
|
-
return `$${cap.toLocaleString()}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function formatChange(change: number | undefined, pct: number | undefined): string {
|
|
43
|
-
if (change === undefined || pct === undefined) return "N/A";
|
|
44
|
-
const sign = change >= 0 ? "+" : "";
|
|
45
|
-
return `${sign}${change.toFixed(2)} (${sign}${pct.toFixed(2)}%)`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export async function financeAdapter(options: ExtractOptions): Promise<AdapterResult> {
|
|
49
|
-
const input = options.url.trim();
|
|
50
|
-
|
|
51
|
-
// Support comma-separated tickers
|
|
52
|
-
const rawTickers = input
|
|
53
|
-
.split(",")
|
|
54
|
-
.map((t) => t.trim().toUpperCase())
|
|
55
|
-
.filter(Boolean)
|
|
56
|
-
.slice(0, 5); // max 5 at once
|
|
57
|
-
|
|
58
|
-
const results: string[] = [];
|
|
59
|
-
let latestTimestamp: number | null = null;
|
|
60
|
-
|
|
61
|
-
for (const ticker of rawTickers) {
|
|
62
|
-
try {
|
|
63
|
-
const quoteData = await fetchQuote(ticker);
|
|
64
|
-
if (quoteData) {
|
|
65
|
-
results.push(formatQuote(quoteData));
|
|
66
|
-
if (quoteData.regularMarketTime) {
|
|
67
|
-
latestTimestamp = Math.max(latestTimestamp ?? 0, quoteData.regularMarketTime);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
} catch (err) {
|
|
71
|
-
results.push(`[${ticker}] Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const raw = results.join("\n\n─────────────────────────────\n\n").slice(0, options.maxLength ?? 5000);
|
|
76
|
-
const content_date = latestTimestamp
|
|
77
|
-
? new Date(latestTimestamp * 1000).toISOString()
|
|
78
|
-
: new Date().toISOString();
|
|
79
|
-
|
|
80
|
-
return { raw, content_date, freshness_confidence: "high" };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function fetchQuote(ticker: string): Promise<YahooQuote | null> {
|
|
84
|
-
// v7 quote endpoint — public, no auth
|
|
85
|
-
const quoteUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(ticker)}&fields=shortName,longName,regularMarketPrice,regularMarketChange,regularMarketChangePercent,marketCap,regularMarketVolume,fiftyTwoWeekHigh,fiftyTwoWeekLow,trailingPE,dividendYield,currency,exchangeName,regularMarketTime`;
|
|
86
|
-
|
|
87
|
-
const quoteRes = await fetch(quoteUrl, {
|
|
88
|
-
headers: {
|
|
89
|
-
"User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)",
|
|
90
|
-
"Accept": "application/json",
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
if (!quoteRes.ok) throw new Error(`Yahoo Finance API error: ${quoteRes.status}`);
|
|
95
|
-
|
|
96
|
-
const quoteJson = await quoteRes.json() as {
|
|
97
|
-
quoteResponse?: { result?: YahooQuote[] };
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const quote = quoteJson?.quoteResponse?.result?.[0];
|
|
101
|
-
if (!quote) throw new Error(`No data found for ticker: ${ticker}`);
|
|
102
|
-
|
|
103
|
-
// Optionally fetch company summary (v11 quoteSummary)
|
|
104
|
-
try {
|
|
105
|
-
const summaryUrl = `https://query1.finance.yahoo.com/v11/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=assetProfile`;
|
|
106
|
-
const summaryRes = await fetch(summaryUrl, {
|
|
107
|
-
headers: { "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)" },
|
|
108
|
-
});
|
|
109
|
-
if (summaryRes.ok) {
|
|
110
|
-
const summaryJson = await summaryRes.json() as {
|
|
111
|
-
quoteSummary?: { result?: Array<{ assetProfile?: YahooQuote }> };
|
|
112
|
-
};
|
|
113
|
-
const profile = summaryJson?.quoteSummary?.result?.[0]?.assetProfile;
|
|
114
|
-
if (profile) {
|
|
115
|
-
Object.assign(quote, {
|
|
116
|
-
longBusinessSummary: profile.longBusinessSummary,
|
|
117
|
-
sector: profile.sector,
|
|
118
|
-
industry: profile.industry,
|
|
119
|
-
fullTimeEmployees: profile.fullTimeEmployees,
|
|
120
|
-
website: profile.website,
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
} catch {
|
|
125
|
-
// Summary is optional — continue without it
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return quote;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function formatQuote(q: YahooQuote): string {
|
|
132
|
-
const lines = [
|
|
133
|
-
`${q.symbol} — ${q.longName ?? q.shortName ?? "Unknown"}`,
|
|
134
|
-
`Exchange: ${q.exchangeName ?? "N/A"} · Currency: ${q.currency ?? "USD"}`,
|
|
135
|
-
"",
|
|
136
|
-
`Price: ${q.regularMarketPrice !== undefined ? `$${q.regularMarketPrice.toFixed(2)}` : "N/A"}`,
|
|
137
|
-
`Change: ${formatChange(q.regularMarketChange, q.regularMarketChangePercent)}`,
|
|
138
|
-
`Market Cap: ${formatMarketCap(q.marketCap)}`,
|
|
139
|
-
`Volume: ${q.regularMarketVolume?.toLocaleString() ?? "N/A"}`,
|
|
140
|
-
`52w High: ${q.fiftyTwoWeekHigh !== undefined ? `$${q.fiftyTwoWeekHigh.toFixed(2)}` : "N/A"}`,
|
|
141
|
-
`52w Low: ${q.fiftyTwoWeekLow !== undefined ? `$${q.fiftyTwoWeekLow.toFixed(2)}` : "N/A"}`,
|
|
142
|
-
`P/E Ratio: ${q.trailingPE !== undefined ? q.trailingPE.toFixed(2) : "N/A"}`,
|
|
143
|
-
`Div Yield: ${q.dividendYield !== undefined ? `${(q.dividendYield * 100).toFixed(2)}%` : "N/A"}`,
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
if (q.sector || q.industry) {
|
|
147
|
-
lines.push("");
|
|
148
|
-
if (q.sector) lines.push(`Sector: ${q.sector}`);
|
|
149
|
-
if (q.industry) lines.push(`Industry: ${q.industry}`);
|
|
150
|
-
if (q.fullTimeEmployees) lines.push(`Employees: ${q.fullTimeEmployees.toLocaleString()}`);
|
|
151
|
-
if (q.website) lines.push(`Website: ${q.website}`);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (q.longBusinessSummary) {
|
|
155
|
-
lines.push("", "About:", q.longBusinessSummary.slice(0, 500) + (q.longBusinessSummary.length > 500 ? "…" : ""));
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return lines.join("\n");
|
|
159
|
-
}
|
package/src/adapters/github.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
2
|
-
import { AdapterResult, ExtractOptions } from "../types.js";
|
|
3
|
-
import { validateUrl } from "../security.js";
|
|
4
|
-
|
|
5
|
-
export async function githubAdapter(options: ExtractOptions): Promise<AdapterResult> {
|
|
6
|
-
const safeUrl = validateUrl(options.url, "github");
|
|
7
|
-
options = { ...options, url: safeUrl };
|
|
8
|
-
|
|
9
|
-
const browser = await chromium.launch({ headless: true });
|
|
10
|
-
const page = await browser.newPage();
|
|
11
|
-
|
|
12
|
-
// Spoof a real browser UA to avoid bot detection
|
|
13
|
-
await page.setExtraHTTPHeaders({
|
|
14
|
-
"User-Agent":
|
|
15
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
await page.goto(options.url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
19
|
-
|
|
20
|
-
// Extract key repo signals — no inner functions to avoid esbuild __name injection
|
|
21
|
-
const data = await page.evaluate(`(function() {
|
|
22
|
-
var readme = (document.querySelector('[data-target="readme-toc.content"]') || document.querySelector('.markdown-body') || {}).textContent || null;
|
|
23
|
-
var starsEl = document.querySelector('[id="repo-stars-counter-star"]') || document.querySelector('.Counter.js-social-count');
|
|
24
|
-
var stars = starsEl ? starsEl.textContent.trim() : null;
|
|
25
|
-
var forksEl = document.querySelector('[id="repo-network-counter"]');
|
|
26
|
-
var forks = forksEl ? forksEl.textContent.trim() : null;
|
|
27
|
-
var commitEl = document.querySelector('relative-time');
|
|
28
|
-
var lastCommit = commitEl ? commitEl.getAttribute('datetime') : null;
|
|
29
|
-
var descEl = document.querySelector('.f4.my-3');
|
|
30
|
-
var description = descEl ? descEl.textContent.trim() : null;
|
|
31
|
-
var topics = Array.from(document.querySelectorAll('.topic-tag')).map(function(t) { return t.textContent.trim(); });
|
|
32
|
-
var langEl = document.querySelector('.color-fg-default.text-bold.mr-1');
|
|
33
|
-
var language = langEl ? langEl.textContent.trim() : null;
|
|
34
|
-
return { readme: readme, stars: stars, forks: forks, lastCommit: lastCommit, description: description, topics: topics, language: language };
|
|
35
|
-
})()`);
|
|
36
|
-
const typedData = data as { readme: string | null; stars: string | null; forks: string | null; lastCommit: string | null; description: string | null; topics: string[]; language: string | null };
|
|
37
|
-
|
|
38
|
-
await browser.close();
|
|
39
|
-
|
|
40
|
-
const raw = [
|
|
41
|
-
`Description: ${typedData.description ?? "N/A"}`,
|
|
42
|
-
`Stars: ${typedData.stars ?? "N/A"} | Forks: ${typedData.forks ?? "N/A"}`,
|
|
43
|
-
`Language: ${typedData.language ?? "N/A"}`,
|
|
44
|
-
`Last commit: ${typedData.lastCommit ?? "N/A"}`,
|
|
45
|
-
`Topics: ${typedData.topics?.join(", ") ?? "none"}`,
|
|
46
|
-
`\n--- README ---\n${typedData.readme ?? "No README found"}`,
|
|
47
|
-
].join("\n");
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
raw,
|
|
51
|
-
content_date: typedData.lastCommit ?? null,
|
|
52
|
-
freshness_confidence: typedData.lastCommit ? "high" : "medium",
|
|
53
|
-
};
|
|
54
|
-
}
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { chromium } from "playwright";
|
|
2
|
-
import { AdapterResult, ExtractOptions } from "../types.js";
|
|
3
|
-
import { validateUrl } from "../security.js";
|
|
4
|
-
|
|
5
|
-
export async function hackerNewsAdapter(options: ExtractOptions): Promise<AdapterResult> {
|
|
6
|
-
// Validate URL — allow both HN and Algolia domains
|
|
7
|
-
validateUrl(options.url, "hackernews");
|
|
8
|
-
const url = options.url;
|
|
9
|
-
|
|
10
|
-
if (url.includes("hn.algolia.com/api/") || url.startsWith("hn-search:")) {
|
|
11
|
-
const query = url.startsWith("hn-search:")
|
|
12
|
-
? url.replace("hn-search:", "").trim()
|
|
13
|
-
: url;
|
|
14
|
-
|
|
15
|
-
const apiUrl = url.includes("hn.algolia.com/api/")
|
|
16
|
-
? url
|
|
17
|
-
: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&tags=story&hitsPerPage=20`;
|
|
18
|
-
|
|
19
|
-
const res = await fetch(apiUrl);
|
|
20
|
-
if (!res.ok) throw new Error(`HN Algolia API error: ${res.status}`);
|
|
21
|
-
const data = await res.json() as {
|
|
22
|
-
hits: Array<{
|
|
23
|
-
title: string;
|
|
24
|
-
url: string | null;
|
|
25
|
-
points: number;
|
|
26
|
-
num_comments: number;
|
|
27
|
-
author: string;
|
|
28
|
-
created_at: string;
|
|
29
|
-
objectID: string;
|
|
30
|
-
}>;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const raw = data.hits
|
|
34
|
-
.map((r, i) =>
|
|
35
|
-
[
|
|
36
|
-
`[${i + 1}] ${r.title ?? "Untitled"}`,
|
|
37
|
-
`URL: ${r.url ?? `https://news.ycombinator.com/item?id=${r.objectID}`}`,
|
|
38
|
-
`Score: ${r.points} points | ${r.num_comments} comments`,
|
|
39
|
-
`Author: ${r.author} | Posted: ${r.created_at}`,
|
|
40
|
-
].join("\n")
|
|
41
|
-
)
|
|
42
|
-
.join("\n\n")
|
|
43
|
-
.slice(0, options.maxLength ?? 4000);
|
|
44
|
-
|
|
45
|
-
const newest = data.hits.map((r) => r.created_at).sort().reverse()[0] ?? null;
|
|
46
|
-
return { raw, content_date: newest, freshness_confidence: newest ? "high" : "medium" };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Default: browser-based scrape for HN front page or search pages
|
|
50
|
-
const browser = await chromium.launch({ headless: true });
|
|
51
|
-
const page = await browser.newPage();
|
|
52
|
-
|
|
53
|
-
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
54
|
-
|
|
55
|
-
const data = await page.evaluate(`(function() {
|
|
56
|
-
var items = Array.from(document.querySelectorAll('.athing')).slice(0, 20);
|
|
57
|
-
var results = items.map(function(el) {
|
|
58
|
-
var titleLineEl = el.querySelector('.titleline > a');
|
|
59
|
-
var title = titleLineEl ? titleLineEl.textContent.trim() : null;
|
|
60
|
-
var link = titleLineEl ? titleLineEl.getAttribute('href') : null;
|
|
61
|
-
var subtext = el.nextElementSibling;
|
|
62
|
-
var scoreEl = subtext ? subtext.querySelector('.score') : null;
|
|
63
|
-
var score = scoreEl ? scoreEl.textContent.trim() : null;
|
|
64
|
-
var ageEl = subtext ? subtext.querySelector('.age') : null;
|
|
65
|
-
var age = ageEl ? ageEl.getAttribute('title') : null;
|
|
66
|
-
var anchors = subtext ? subtext.querySelectorAll('a') : [];
|
|
67
|
-
var commentLink = anchors.length > 0 ? anchors[anchors.length - 1].textContent.trim() : null;
|
|
68
|
-
return { title: title, link: link, score: score, age: age, commentLink: commentLink };
|
|
69
|
-
});
|
|
70
|
-
return results;
|
|
71
|
-
})()`);
|
|
72
|
-
|
|
73
|
-
await browser.close();
|
|
74
|
-
|
|
75
|
-
const typedData = data as Array<{ title: string | null; link: string | null; score: string | null; age: string | null; commentLink: string | null }>;
|
|
76
|
-
|
|
77
|
-
const raw = typedData
|
|
78
|
-
.map((r, i) =>
|
|
79
|
-
[
|
|
80
|
-
`[${i + 1}] ${r.title ?? "Untitled"}`,
|
|
81
|
-
`URL: ${r.link ?? "N/A"}`,
|
|
82
|
-
`Score: ${r.score ?? "N/A"} | ${r.commentLink ?? ""}`,
|
|
83
|
-
`Posted: ${r.age ?? "unknown"}`,
|
|
84
|
-
].join("\n")
|
|
85
|
-
)
|
|
86
|
-
.join("\n\n");
|
|
87
|
-
|
|
88
|
-
const newestDate = typedData.map((r) => r.age).filter(Boolean).sort().reverse()[0] ?? null;
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
raw,
|
|
92
|
-
content_date: newestDate,
|
|
93
|
-
freshness_confidence: newestDate ? "high" : "medium",
|
|
94
|
-
};
|
|
95
|
-
}
|