freshcontext-mcp 0.1.7 → 0.1.8
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/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
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.8",
|
|
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
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { AdapterResult, ExtractOptions } from "../types.js";
|
|
2
|
-
import { sanitizePackages } from "../security.js";
|
|
3
|
-
|
|
4
|
-
// Uses npm registry API + PyPI JSON API (no auth needed)
|
|
5
|
-
export async function packageTrendsAdapter(options: ExtractOptions): Promise<AdapterResult> {
|
|
6
|
-
// Sanitize package input
|
|
7
|
-
const raw_input = sanitizePackages(options.url.replace(/^https?:\/\//, "").trim());
|
|
8
|
-
|
|
9
|
-
// Parse ecosystem prefix
|
|
10
|
-
const parts = raw_input.split(",").map((s) => s.trim());
|
|
11
|
-
const results: string[] = [];
|
|
12
|
-
let latestDate: string | null = null;
|
|
13
|
-
|
|
14
|
-
for (const pkg of parts) {
|
|
15
|
-
const isExplicitPypi = pkg.startsWith("pypi:");
|
|
16
|
-
const isExplicitNpm = pkg.startsWith("npm:");
|
|
17
|
-
const pkgName = pkg.replace(/^(pypi:|npm:)/, "");
|
|
18
|
-
|
|
19
|
-
// Try npm
|
|
20
|
-
if (!isExplicitPypi) {
|
|
21
|
-
try {
|
|
22
|
-
const npmRes = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}`, {
|
|
23
|
-
headers: { Accept: "application/json" },
|
|
24
|
-
});
|
|
25
|
-
if (npmRes.ok) {
|
|
26
|
-
const npmData = await npmRes.json() as {
|
|
27
|
-
name: string;
|
|
28
|
-
description?: string;
|
|
29
|
-
"dist-tags"?: { latest?: string };
|
|
30
|
-
time?: Record<string, string>;
|
|
31
|
-
homepage?: string;
|
|
32
|
-
keywords?: string[];
|
|
33
|
-
repository?: { url?: string };
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
const latestVersion = npmData["dist-tags"]?.latest ?? "unknown";
|
|
37
|
-
const modified = npmData.time?.modified ?? null;
|
|
38
|
-
const created = npmData.time?.created ?? null;
|
|
39
|
-
const versions = Object.keys(npmData.time ?? {}).filter((k) => !["created", "modified"].includes(k)).length;
|
|
40
|
-
|
|
41
|
-
if (modified && (!latestDate || modified > latestDate)) latestDate = modified;
|
|
42
|
-
|
|
43
|
-
results.push([
|
|
44
|
-
`📦 [npm] ${npmData.name}`,
|
|
45
|
-
`Latest version: ${latestVersion}`,
|
|
46
|
-
`Total versions: ${versions}`,
|
|
47
|
-
`Description: ${npmData.description ?? "N/A"}`,
|
|
48
|
-
`Keywords: ${npmData.keywords?.join(", ") ?? "none"}`,
|
|
49
|
-
`Created: ${created ?? "unknown"}`,
|
|
50
|
-
`Last updated: ${modified ?? "unknown"}`,
|
|
51
|
-
`Homepage: ${npmData.homepage ?? "N/A"}`,
|
|
52
|
-
].join("\n"));
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
} catch { /* fall through to PyPI */ }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Try PyPI
|
|
59
|
-
if (!isExplicitNpm) {
|
|
60
|
-
try {
|
|
61
|
-
const pypiRes = await fetch(`https://pypi.org/pypi/${encodeURIComponent(pkgName)}/json`);
|
|
62
|
-
if (pypiRes.ok) {
|
|
63
|
-
const pypiData = await pypiRes.json() as {
|
|
64
|
-
info: {
|
|
65
|
-
name: string;
|
|
66
|
-
version: string;
|
|
67
|
-
summary?: string;
|
|
68
|
-
keywords?: string;
|
|
69
|
-
home_page?: string;
|
|
70
|
-
project_urls?: Record<string, string>;
|
|
71
|
-
};
|
|
72
|
-
releases?: Record<string, unknown[]>;
|
|
73
|
-
urls?: Array<{ upload_time: string }>;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const info = pypiData.info;
|
|
77
|
-
const releaseCount = Object.keys(pypiData.releases ?? {}).length;
|
|
78
|
-
const latestUpload = pypiData.urls?.[0]?.upload_time ?? null;
|
|
79
|
-
|
|
80
|
-
if (latestUpload && (!latestDate || latestUpload > latestDate)) latestDate = latestUpload;
|
|
81
|
-
|
|
82
|
-
results.push([
|
|
83
|
-
`🐍 [PyPI] ${info.name}`,
|
|
84
|
-
`Latest version: ${info.version}`,
|
|
85
|
-
`Total releases: ${releaseCount}`,
|
|
86
|
-
`Description: ${info.summary ?? "N/A"}`,
|
|
87
|
-
`Keywords: ${info.keywords ?? "none"}`,
|
|
88
|
-
`Last release: ${latestUpload ?? "unknown"}`,
|
|
89
|
-
`Homepage: ${info.home_page ?? info.project_urls?.Homepage ?? "N/A"}`,
|
|
90
|
-
].join("\n"));
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
} catch { /* not found */ }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
results.push(`❌ Package not found on npm or PyPI: ${pkgName}`);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
raw: results.join("\n\n").slice(0, options.maxLength ?? 5000),
|
|
101
|
-
content_date: latestDate,
|
|
102
|
-
freshness_confidence: latestDate ? "high" : "low",
|
|
103
|
-
};
|
|
104
|
-
}
|