nodebench-mcp 2.17.0 → 2.18.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.
- package/LICENSE +21 -0
- package/NODEBENCH_AGENTS.md +2 -2
- package/README.md +514 -82
- package/dist/__tests__/analytics.test.d.ts +11 -0
- package/dist/__tests__/analytics.test.js +546 -0
- package/dist/__tests__/analytics.test.js.map +1 -0
- package/dist/__tests__/dynamicLoading.test.d.ts +1 -0
- package/dist/__tests__/dynamicLoading.test.js +278 -0
- package/dist/__tests__/dynamicLoading.test.js.map +1 -0
- package/dist/__tests__/evalHarness.test.js +1 -1
- package/dist/__tests__/evalHarness.test.js.map +1 -1
- package/dist/__tests__/helpers/answerMatch.js +22 -22
- package/dist/__tests__/presetRealWorldBench.test.js +9 -0
- package/dist/__tests__/presetRealWorldBench.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +1 -1
- package/dist/__tests__/toolsetGatingEval.test.js +9 -1
- package/dist/__tests__/toolsetGatingEval.test.js.map +1 -1
- package/dist/analytics/index.d.ts +10 -0
- package/dist/analytics/index.js +11 -0
- package/dist/analytics/index.js.map +1 -0
- package/dist/analytics/projectDetector.d.ts +19 -0
- package/dist/analytics/projectDetector.js +259 -0
- package/dist/analytics/projectDetector.js.map +1 -0
- package/dist/analytics/schema.d.ts +57 -0
- package/dist/analytics/schema.js +157 -0
- package/dist/analytics/schema.js.map +1 -0
- package/dist/analytics/smartPreset.d.ts +63 -0
- package/dist/analytics/smartPreset.js +300 -0
- package/dist/analytics/smartPreset.js.map +1 -0
- package/dist/analytics/toolTracker.d.ts +59 -0
- package/dist/analytics/toolTracker.js +163 -0
- package/dist/analytics/toolTracker.js.map +1 -0
- package/dist/analytics/usageStats.d.ts +64 -0
- package/dist/analytics/usageStats.js +252 -0
- package/dist/analytics/usageStats.js.map +1 -0
- package/dist/db.js +359 -321
- package/dist/db.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +652 -89
- package/dist/index.js.map +1 -1
- package/dist/tools/architectTools.js +13 -13
- package/dist/tools/critterTools.js +14 -14
- package/dist/tools/parallelAgentTools.js +176 -176
- package/dist/tools/patternTools.js +11 -11
- package/dist/tools/progressiveDiscoveryTools.d.ts +5 -1
- package/dist/tools/progressiveDiscoveryTools.js +111 -19
- package/dist/tools/progressiveDiscoveryTools.js.map +1 -1
- package/dist/tools/researchWritingTools.js +42 -42
- package/dist/tools/rssTools.js +396 -396
- package/dist/tools/toolRegistry.d.ts +17 -0
- package/dist/tools/toolRegistry.js +65 -17
- package/dist/tools/toolRegistry.js.map +1 -1
- package/dist/tools/voiceBridgeTools.js +498 -498
- package/dist/toolsetRegistry.d.ts +10 -0
- package/dist/toolsetRegistry.js +84 -0
- package/dist/toolsetRegistry.js.map +1 -0
- package/package.json +4 -4
package/dist/tools/rssTools.js
CHANGED
|
@@ -8,25 +8,25 @@ import { getDb } from "../db.js";
|
|
|
8
8
|
// ── SQLite schema ────────────────────────────────────────────────────────────
|
|
9
9
|
function ensureRssTables() {
|
|
10
10
|
const db = getDb();
|
|
11
|
-
db.exec(`
|
|
12
|
-
CREATE TABLE IF NOT EXISTS rss_sources (
|
|
13
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
-
url TEXT NOT NULL UNIQUE,
|
|
15
|
-
name TEXT,
|
|
16
|
-
category TEXT,
|
|
17
|
-
created_at TEXT DEFAULT (datetime('now'))
|
|
18
|
-
);
|
|
19
|
-
CREATE TABLE IF NOT EXISTS rss_articles (
|
|
20
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
-
source_url TEXT NOT NULL,
|
|
22
|
-
title TEXT,
|
|
23
|
-
link TEXT NOT NULL,
|
|
24
|
-
published TEXT,
|
|
25
|
-
summary TEXT,
|
|
26
|
-
fetched_at TEXT DEFAULT (datetime('now')),
|
|
27
|
-
is_new INTEGER DEFAULT 1,
|
|
28
|
-
UNIQUE(source_url, link)
|
|
29
|
-
);
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE IF NOT EXISTS rss_sources (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
url TEXT NOT NULL UNIQUE,
|
|
15
|
+
name TEXT,
|
|
16
|
+
category TEXT,
|
|
17
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
18
|
+
);
|
|
19
|
+
CREATE TABLE IF NOT EXISTS rss_articles (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
source_url TEXT NOT NULL,
|
|
22
|
+
title TEXT,
|
|
23
|
+
link TEXT NOT NULL,
|
|
24
|
+
published TEXT,
|
|
25
|
+
summary TEXT,
|
|
26
|
+
fetched_at TEXT DEFAULT (datetime('now')),
|
|
27
|
+
is_new INTEGER DEFAULT 1,
|
|
28
|
+
UNIQUE(source_url, link)
|
|
29
|
+
);
|
|
30
30
|
`);
|
|
31
31
|
}
|
|
32
32
|
/** Extract text content from an XML tag, handling CDATA */
|
|
@@ -266,14 +266,14 @@ export const rssTools = [
|
|
|
266
266
|
const format = args.format || "markdown";
|
|
267
267
|
// Query new articles
|
|
268
268
|
const params = [sinceHours];
|
|
269
|
-
let query = `
|
|
270
|
-
SELECT a.title, a.link, a.published, a.summary, a.source_url, a.fetched_at,
|
|
271
|
-
COALESCE(s.name, a.source_url) as source_name,
|
|
272
|
-
COALESCE(s.category, 'uncategorized') as category
|
|
273
|
-
FROM rss_articles a
|
|
274
|
-
LEFT JOIN rss_sources s ON a.source_url = s.url
|
|
275
|
-
WHERE a.is_new = 1
|
|
276
|
-
AND a.fetched_at >= datetime('now', '-' || ? || ' hours')
|
|
269
|
+
let query = `
|
|
270
|
+
SELECT a.title, a.link, a.published, a.summary, a.source_url, a.fetched_at,
|
|
271
|
+
COALESCE(s.name, a.source_url) as source_name,
|
|
272
|
+
COALESCE(s.category, 'uncategorized') as category
|
|
273
|
+
FROM rss_articles a
|
|
274
|
+
LEFT JOIN rss_sources s ON a.source_url = s.url
|
|
275
|
+
WHERE a.is_new = 1
|
|
276
|
+
AND a.fetched_at >= datetime('now', '-' || ? || ' hours')
|
|
277
277
|
`;
|
|
278
278
|
if (category) {
|
|
279
279
|
query += " AND s.category = ?";
|
|
@@ -283,9 +283,9 @@ export const rssTools = [
|
|
|
283
283
|
const articles = db.prepare(query).all(...params);
|
|
284
284
|
// Mark articles as seen
|
|
285
285
|
const markParams = [sinceHours];
|
|
286
|
-
let markQuery = `
|
|
287
|
-
UPDATE rss_articles SET is_new = 0
|
|
288
|
-
WHERE is_new = 1 AND fetched_at >= datetime('now', '-' || ? || ' hours')
|
|
286
|
+
let markQuery = `
|
|
287
|
+
UPDATE rss_articles SET is_new = 0
|
|
288
|
+
WHERE is_new = 1 AND fetched_at >= datetime('now', '-' || ? || ' hours')
|
|
289
289
|
`;
|
|
290
290
|
if (category) {
|
|
291
291
|
markQuery += ` AND source_url IN (SELECT url FROM rss_sources WHERE category = ?)`;
|
|
@@ -326,24 +326,24 @@ export const rssTools = [
|
|
|
326
326
|
}
|
|
327
327
|
if (format === "html") {
|
|
328
328
|
const sections = [...byCategory.entries()]
|
|
329
|
-
.map(([cat, items]) => `
|
|
330
|
-
<h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">${cat} (${items.length})</h2>
|
|
329
|
+
.map(([cat, items]) => `
|
|
330
|
+
<h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">${cat} (${items.length})</h2>
|
|
331
331
|
${items
|
|
332
|
-
.map((a) => `
|
|
333
|
-
<div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
|
|
334
|
-
<a href="${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">${a.title}</a>
|
|
335
|
-
<div style="color:#666;font-size:12px;margin-top:4px">${a.source_name} · ${a.published || a.fetched_at}</div>
|
|
336
|
-
${a.summary ? `<p style="margin-top:8px;color:#444;font-size:14px">${a.summary}</p>` : ""}
|
|
332
|
+
.map((a) => `
|
|
333
|
+
<div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
|
|
334
|
+
<a href="${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">${a.title}</a>
|
|
335
|
+
<div style="color:#666;font-size:12px;margin-top:4px">${a.source_name} · ${a.published || a.fetched_at}</div>
|
|
336
|
+
${a.summary ? `<p style="margin-top:8px;color:#444;font-size:14px">${a.summary}</p>` : ""}
|
|
337
337
|
</div>`)
|
|
338
338
|
.join("")}`)
|
|
339
339
|
.join("");
|
|
340
|
-
const html = `
|
|
341
|
-
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:640px;margin:0 auto;padding:20px">
|
|
342
|
-
<h1 style="color:#111;margin-bottom:4px">Research Digest</h1>
|
|
343
|
-
<p style="color:#666;margin-top:0">${articles.length} new articles · ${new Date().toLocaleDateString()}</p>
|
|
344
|
-
${sections}
|
|
345
|
-
<hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
|
|
346
|
-
<p style="color:#999;font-size:11px">Generated by NodeBench MCP · build_research_digest</p>
|
|
340
|
+
const html = `
|
|
341
|
+
<div style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:640px;margin:0 auto;padding:20px">
|
|
342
|
+
<h1 style="color:#111;margin-bottom:4px">Research Digest</h1>
|
|
343
|
+
<p style="color:#666;margin-top:0">${articles.length} new articles · ${new Date().toLocaleDateString()}</p>
|
|
344
|
+
${sections}
|
|
345
|
+
<hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
|
|
346
|
+
<p style="color:#999;font-size:11px">Generated by NodeBench MCP · build_research_digest</p>
|
|
347
347
|
</div>`;
|
|
348
348
|
return [
|
|
349
349
|
{
|
|
@@ -427,299 +427,299 @@ export const rssTools = [
|
|
|
427
427
|
? feeds.map((f) => ` { url: "${f.url}", name: "${f.name || f.url}", category: "${f.category || "general"}" },`).join("\n")
|
|
428
428
|
: ` // Add your feeds here:\n // { url: "https://arxiv.org/rss/cs.AI", name: "arXiv AI", category: "ai-research" },\n // { url: "https://hnrss.org/newest?points=100", name: "Hacker News Top", category: "tech" },`;
|
|
429
429
|
// ── Generate the main script ──
|
|
430
|
-
const mainScript = `#!/usr/bin/env node
|
|
431
|
-
/**
|
|
432
|
-
* ${projectName} — Automated Research Digest Pipeline
|
|
433
|
-
*
|
|
434
|
-
* Subscribe to RSS/Atom feeds, fetch new articles, build a digest, and email it.
|
|
435
|
-
* Generated by NodeBench MCP scaffold_research_pipeline.
|
|
436
|
-
*
|
|
437
|
-
* Usage:
|
|
438
|
-
* node digest.mjs # Run once (fetch + digest + email)
|
|
439
|
-
* node digest.mjs --fetch-only # Just fetch, don't email
|
|
440
|
-
* node digest.mjs --list-sources # Show registered feeds
|
|
441
|
-
* node digest.mjs --add-feed <url> # Add a new feed
|
|
442
|
-
*
|
|
443
|
-
* Schedule with cron:
|
|
444
|
-
* crontab -e
|
|
445
|
-
* ${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
|
|
446
|
-
*/
|
|
447
|
-
|
|
448
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
449
|
-
import * as tls from "node:tls";
|
|
450
|
-
|
|
451
|
-
// ── Config ────────────────────────────────────────────────────────────────────
|
|
452
|
-
|
|
453
|
-
const DATA_DIR = new URL("./data/", import.meta.url).pathname.replace(/^\\//, "");
|
|
454
|
-
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
455
|
-
|
|
456
|
-
const SOURCES_FILE = DATA_DIR + "sources.json";
|
|
457
|
-
const ARTICLES_FILE = DATA_DIR + "articles.json";
|
|
458
|
-
|
|
459
|
-
const EMAIL_USER = process.env.EMAIL_USER || "";
|
|
460
|
-
const EMAIL_PASS = process.env.EMAIL_PASS || "";
|
|
461
|
-
const EMAIL_TO = process.env.DIGEST_TO || "${emailTo}" || EMAIL_USER;
|
|
462
|
-
const SMTP_HOST = process.env.EMAIL_SMTP_HOST || "smtp.gmail.com";
|
|
463
|
-
const SMTP_PORT = parseInt(process.env.EMAIL_SMTP_PORT || "465");
|
|
464
|
-
|
|
465
|
-
// ── Feed storage (JSON file-based, no SQLite needed) ──────────────────────────
|
|
466
|
-
|
|
467
|
-
function loadSources() {
|
|
468
|
-
if (!existsSync(SOURCES_FILE)) return [];
|
|
469
|
-
return JSON.parse(readFileSync(SOURCES_FILE, "utf-8"));
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function saveSources(sources) {
|
|
473
|
-
writeFileSync(SOURCES_FILE, JSON.stringify(sources, null, 2));
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function loadArticles() {
|
|
477
|
-
if (!existsSync(ARTICLES_FILE)) return {};
|
|
478
|
-
return JSON.parse(readFileSync(ARTICLES_FILE, "utf-8"));
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function saveArticles(articles) {
|
|
482
|
-
writeFileSync(ARTICLES_FILE, JSON.stringify(articles, null, 2));
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// ── RSS/Atom parser ───────────────────────────────────────────────────────────
|
|
486
|
-
|
|
487
|
-
function extractTag(xml, tag) {
|
|
488
|
-
const m = xml.match(new RegExp(\`<\${tag}[^>]*>\\\\s*(?:<!\\\\[CDATA\\\\[)?([\\\\s\\\\S]*?)(?:\\\\]\\\\]>)?\\\\s*</\${tag}>\`, "i"));
|
|
489
|
-
return m ? m[1].trim() : "";
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function extractAtomLink(xml) {
|
|
493
|
-
const alt = xml.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/i);
|
|
494
|
-
if (alt) return alt[1];
|
|
495
|
-
const any = xml.match(/<link[^>]*href="([^"]+)"/i);
|
|
496
|
-
return any ? any[1] : "";
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
function stripHtml(html) {
|
|
500
|
-
return html.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<")
|
|
501
|
-
.replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'")
|
|
502
|
-
.replace(/\\s+/g, " ").trim();
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
async function parseFeed(url) {
|
|
506
|
-
const res = await fetch(url, {
|
|
507
|
-
headers: { "User-Agent": "${projectName} RSS Reader" },
|
|
508
|
-
signal: AbortSignal.timeout(15000),
|
|
509
|
-
});
|
|
510
|
-
if (!res.ok) throw new Error(\`Feed fetch failed: \${res.status}\`);
|
|
511
|
-
const xml = await res.text();
|
|
512
|
-
const items = [];
|
|
513
|
-
|
|
514
|
-
const rssItems = xml.match(/<item>([\\s\\S]*?)<\\/item>/gi) || [];
|
|
515
|
-
if (rssItems.length > 0) {
|
|
516
|
-
for (const item of rssItems) {
|
|
517
|
-
items.push({
|
|
518
|
-
title: stripHtml(extractTag(item, "title")),
|
|
519
|
-
link: extractTag(item, "link"),
|
|
520
|
-
published: extractTag(item, "pubDate"),
|
|
521
|
-
summary: stripHtml(extractTag(item, "description")).substring(0, 500),
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
return { title: stripHtml(extractTag(xml, "title")), items };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const atomEntries = xml.match(/<entry>([\\s\\S]*?)<\\/entry>/gi) || [];
|
|
528
|
-
for (const entry of atomEntries) {
|
|
529
|
-
items.push({
|
|
530
|
-
title: stripHtml(extractTag(entry, "title")),
|
|
531
|
-
link: extractAtomLink(entry) || extractTag(entry, "link"),
|
|
532
|
-
published: extractTag(entry, "published") || extractTag(entry, "updated"),
|
|
533
|
-
summary: stripHtml(extractTag(entry, "summary") || extractTag(entry, "content")).substring(0, 500),
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
return { title: stripHtml(extractTag(xml, "title")), items };
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// ── SMTP email sender ─────────────────────────────────────────────────────────
|
|
540
|
-
|
|
541
|
-
function readSmtp(socket, timeout = 10000) {
|
|
542
|
-
return new Promise((resolve, reject) => {
|
|
543
|
-
let buf = "";
|
|
544
|
-
const timer = setTimeout(() => { socket.removeAllListeners("data"); reject(new Error("SMTP timeout")); }, timeout);
|
|
545
|
-
const onData = (chunk) => {
|
|
546
|
-
buf += chunk.toString();
|
|
547
|
-
const lines = buf.split("\\r\\n").filter(Boolean);
|
|
548
|
-
const last = lines[lines.length - 1];
|
|
549
|
-
if (last && /^\\d{3} /.test(last)) {
|
|
550
|
-
clearTimeout(timer); socket.removeListener("data", onData);
|
|
551
|
-
const code = parseInt(last.substring(0, 3));
|
|
552
|
-
if (code >= 400) reject(new Error(\`SMTP \${code}: \${buf.trim()}\`));
|
|
553
|
-
else resolve(buf.trim());
|
|
554
|
-
}
|
|
555
|
-
};
|
|
556
|
-
socket.on("data", onData);
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
async function smtpCmd(socket, cmd) { socket.write(cmd + "\\r\\n"); return readSmtp(socket); }
|
|
561
|
-
|
|
562
|
-
async function sendDigestEmail(to, subject, html, plainText) {
|
|
563
|
-
if (!EMAIL_USER || !EMAIL_PASS) {
|
|
564
|
-
console.log(" [skip] EMAIL_USER/EMAIL_PASS not set — digest printed to stdout instead");
|
|
565
|
-
console.log(plainText);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
const boundary = "----DigestBoundary" + Date.now();
|
|
570
|
-
const message = [
|
|
571
|
-
\`From: \${EMAIL_USER}\`, \`To: \${to}\`, \`Subject: \${subject}\`,
|
|
572
|
-
\`Date: \${new Date().toUTCString()}\`, "MIME-Version: 1.0",
|
|
573
|
-
\`Content-Type: multipart/alternative; boundary="\${boundary}"\`,
|
|
574
|
-
"", \`--\${boundary}\`, "Content-Type: text/plain; charset=UTF-8", "", plainText,
|
|
575
|
-
\`--\${boundary}\`, "Content-Type: text/html; charset=UTF-8", "", html,
|
|
576
|
-
\`--\${boundary}--\`,
|
|
577
|
-
].join("\\r\\n");
|
|
578
|
-
|
|
579
|
-
const socket = tls.connect({ host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true });
|
|
580
|
-
await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); });
|
|
581
|
-
try {
|
|
582
|
-
await readSmtp(socket);
|
|
583
|
-
await smtpCmd(socket, "EHLO digest-pipeline");
|
|
584
|
-
await smtpCmd(socket, "AUTH LOGIN");
|
|
585
|
-
await smtpCmd(socket, Buffer.from(EMAIL_USER).toString("base64"));
|
|
586
|
-
await smtpCmd(socket, Buffer.from(EMAIL_PASS).toString("base64"));
|
|
587
|
-
await smtpCmd(socket, \`MAIL FROM:<\${EMAIL_USER}>\`);
|
|
588
|
-
await smtpCmd(socket, \`RCPT TO:<\${to}>\`);
|
|
589
|
-
await smtpCmd(socket, "DATA");
|
|
590
|
-
socket.write(message.replace(/\\r\\n\\./g, "\\r\\n..") + "\\r\\n.\\r\\n");
|
|
591
|
-
await readSmtp(socket);
|
|
592
|
-
await smtpCmd(socket, "QUIT").catch(() => {});
|
|
593
|
-
console.log(\` [sent] Digest emailed to \${to}\`);
|
|
594
|
-
} finally { socket.destroy(); }
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// ── Main pipeline ─────────────────────────────────────────────────────────────
|
|
598
|
-
|
|
599
|
-
async function fetchAll() {
|
|
600
|
-
const sources = loadSources();
|
|
601
|
-
if (sources.length === 0) {
|
|
602
|
-
console.log("No sources registered. Use: node digest.mjs --add-feed <url> [name] [category]");
|
|
603
|
-
return [];
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const seenArticles = loadArticles();
|
|
607
|
-
const newArticles = [];
|
|
608
|
-
|
|
609
|
-
for (const source of sources) {
|
|
610
|
-
try {
|
|
611
|
-
const feed = await parseFeed(source.url);
|
|
612
|
-
let newCount = 0;
|
|
613
|
-
for (const item of feed.items.slice(0, 20)) {
|
|
614
|
-
if (!item.link) continue;
|
|
615
|
-
const key = source.url + "|" + item.link;
|
|
616
|
-
if (seenArticles[key]) continue;
|
|
617
|
-
seenArticles[key] = { fetchedAt: new Date().toISOString(), seen: false };
|
|
618
|
-
newArticles.push({ ...item, sourceName: source.name, category: source.category });
|
|
619
|
-
newCount++;
|
|
620
|
-
}
|
|
621
|
-
console.log(\` [\${source.name}] \${feed.items.length} articles, \${newCount} new\`);
|
|
622
|
-
} catch (e) {
|
|
623
|
-
console.log(\` [\${source.name}] ERROR: \${e.message}\`);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
saveArticles(seenArticles);
|
|
628
|
-
return newArticles;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
function buildDigest(articles) {
|
|
632
|
-
if (articles.length === 0) return { html: "", plainText: "", count: 0 };
|
|
633
|
-
|
|
634
|
-
const byCategory = new Map();
|
|
635
|
-
for (const a of articles) {
|
|
636
|
-
const cat = a.category || "general";
|
|
637
|
-
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
|
638
|
-
byCategory.get(cat).push(a);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// Plain text
|
|
642
|
-
const lines = [\`Research Digest — \${articles.length} new articles — \${new Date().toLocaleDateString()}\`, ""];
|
|
643
|
-
for (const [cat, items] of byCategory) {
|
|
644
|
-
lines.push(\`## \${cat} (\${items.length})\`, "");
|
|
645
|
-
for (const a of items) {
|
|
646
|
-
lines.push(\`- \${a.title}\`);
|
|
647
|
-
lines.push(\` \${a.sourceName} · \${a.published || "recent"}\`);
|
|
648
|
-
lines.push(\` \${a.link}\`);
|
|
649
|
-
if (a.summary) lines.push(\` > \${a.summary.substring(0, 200)}\`);
|
|
650
|
-
lines.push("");
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// HTML
|
|
655
|
-
const sections = [...byCategory.entries()].map(([cat, items]) => \`
|
|
656
|
-
<h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">\${cat} (\${items.length})</h2>
|
|
657
|
-
\${items.map(a => \`
|
|
658
|
-
<div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
|
|
659
|
-
<a href="\${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">\${a.title}</a>
|
|
660
|
-
<div style="color:#666;font-size:12px;margin-top:4px">\${a.sourceName} · \${a.published || "recent"}</div>
|
|
661
|
-
\${a.summary ? \`<p style="margin-top:8px;color:#444;font-size:14px">\${a.summary.substring(0, 300)}</p>\` : ""}
|
|
662
|
-
</div>\`).join("")}\`).join("");
|
|
663
|
-
|
|
664
|
-
const html = \`<div style="font-family:-apple-system,sans-serif;max-width:640px;margin:0 auto;padding:20px">
|
|
665
|
-
<h1 style="color:#111">Research Digest</h1>
|
|
666
|
-
<p style="color:#666">\${articles.length} new articles · \${new Date().toLocaleDateString()}</p>
|
|
667
|
-
\${sections}
|
|
668
|
-
<hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
|
|
669
|
-
<p style="color:#999;font-size:11px">Generated by ${projectName}</p>
|
|
670
|
-
</div>\`;
|
|
671
|
-
|
|
672
|
-
return { html, plainText: lines.join("\\n"), count: articles.length };
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
676
|
-
|
|
677
|
-
const args = process.argv.slice(2);
|
|
678
|
-
|
|
679
|
-
if (args.includes("--list-sources")) {
|
|
680
|
-
const sources = loadSources();
|
|
681
|
-
if (sources.length === 0) console.log("No sources. Use --add-feed <url> [name] [category]");
|
|
682
|
-
else sources.forEach((s, i) => console.log(\`\${i + 1}. [\${s.category}] \${s.name} — \${s.url}\`));
|
|
683
|
-
} else if (args.includes("--add-feed")) {
|
|
684
|
-
const idx = args.indexOf("--add-feed");
|
|
685
|
-
const url = args[idx + 1];
|
|
686
|
-
const name = args[idx + 2] || url;
|
|
687
|
-
const category = args[idx + 3] || "general";
|
|
688
|
-
if (!url) { console.error("Usage: --add-feed <url> [name] [category]"); process.exit(1); }
|
|
689
|
-
try {
|
|
690
|
-
const feed = await parseFeed(url);
|
|
691
|
-
const sources = loadSources();
|
|
692
|
-
sources.push({ url, name, category });
|
|
693
|
-
saveSources(sources);
|
|
694
|
-
console.log(\`Added: \${name} (\${feed.title}) — \${feed.items.length} articles available\`);
|
|
695
|
-
} catch (e) {
|
|
696
|
-
console.error(\`Failed to validate feed: \${e.message}\`);
|
|
697
|
-
}
|
|
698
|
-
} else {
|
|
699
|
-
console.log(\`\\n${"=".repeat(60)}\`);
|
|
700
|
-
console.log(\` ${projectName} — \${new Date().toISOString()}\`);
|
|
701
|
-
console.log(\`${"=".repeat(60)}\\n\`);
|
|
702
|
-
|
|
703
|
-
console.log("Fetching feeds...");
|
|
704
|
-
const articles = await fetchAll();
|
|
705
|
-
|
|
706
|
-
if (articles.length === 0) {
|
|
707
|
-
console.log("\\nNo new articles found.");
|
|
708
|
-
} else {
|
|
709
|
-
const digest = buildDigest(articles);
|
|
710
|
-
console.log(\`\\nDigest: \${digest.count} new articles\`);
|
|
711
|
-
|
|
712
|
-
if (!args.includes("--fetch-only")) {
|
|
713
|
-
const subject = \`Research Digest — \${digest.count} articles — \${new Date().toLocaleDateString()}\`;
|
|
714
|
-
await sendDigestEmail(EMAIL_TO, subject, digest.html, digest.plainText);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Save digest to file
|
|
718
|
-
const digestFile = DATA_DIR + \`digest-\${new Date().toISOString().slice(0,10)}.md\`;
|
|
719
|
-
writeFileSync(digestFile, digest.plainText);
|
|
720
|
-
console.log(\` [saved] \${digestFile}\`);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
430
|
+
const mainScript = `#!/usr/bin/env node
|
|
431
|
+
/**
|
|
432
|
+
* ${projectName} — Automated Research Digest Pipeline
|
|
433
|
+
*
|
|
434
|
+
* Subscribe to RSS/Atom feeds, fetch new articles, build a digest, and email it.
|
|
435
|
+
* Generated by NodeBench MCP scaffold_research_pipeline.
|
|
436
|
+
*
|
|
437
|
+
* Usage:
|
|
438
|
+
* node digest.mjs # Run once (fetch + digest + email)
|
|
439
|
+
* node digest.mjs --fetch-only # Just fetch, don't email
|
|
440
|
+
* node digest.mjs --list-sources # Show registered feeds
|
|
441
|
+
* node digest.mjs --add-feed <url> # Add a new feed
|
|
442
|
+
*
|
|
443
|
+
* Schedule with cron:
|
|
444
|
+
* crontab -e
|
|
445
|
+
* ${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
|
|
446
|
+
*/
|
|
447
|
+
|
|
448
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
449
|
+
import * as tls from "node:tls";
|
|
450
|
+
|
|
451
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
452
|
+
|
|
453
|
+
const DATA_DIR = new URL("./data/", import.meta.url).pathname.replace(/^\\//, "");
|
|
454
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
455
|
+
|
|
456
|
+
const SOURCES_FILE = DATA_DIR + "sources.json";
|
|
457
|
+
const ARTICLES_FILE = DATA_DIR + "articles.json";
|
|
458
|
+
|
|
459
|
+
const EMAIL_USER = process.env.EMAIL_USER || "";
|
|
460
|
+
const EMAIL_PASS = process.env.EMAIL_PASS || "";
|
|
461
|
+
const EMAIL_TO = process.env.DIGEST_TO || "${emailTo}" || EMAIL_USER;
|
|
462
|
+
const SMTP_HOST = process.env.EMAIL_SMTP_HOST || "smtp.gmail.com";
|
|
463
|
+
const SMTP_PORT = parseInt(process.env.EMAIL_SMTP_PORT || "465");
|
|
464
|
+
|
|
465
|
+
// ── Feed storage (JSON file-based, no SQLite needed) ──────────────────────────
|
|
466
|
+
|
|
467
|
+
function loadSources() {
|
|
468
|
+
if (!existsSync(SOURCES_FILE)) return [];
|
|
469
|
+
return JSON.parse(readFileSync(SOURCES_FILE, "utf-8"));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function saveSources(sources) {
|
|
473
|
+
writeFileSync(SOURCES_FILE, JSON.stringify(sources, null, 2));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function loadArticles() {
|
|
477
|
+
if (!existsSync(ARTICLES_FILE)) return {};
|
|
478
|
+
return JSON.parse(readFileSync(ARTICLES_FILE, "utf-8"));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function saveArticles(articles) {
|
|
482
|
+
writeFileSync(ARTICLES_FILE, JSON.stringify(articles, null, 2));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── RSS/Atom parser ───────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
function extractTag(xml, tag) {
|
|
488
|
+
const m = xml.match(new RegExp(\`<\${tag}[^>]*>\\\\s*(?:<!\\\\[CDATA\\\\[)?([\\\\s\\\\S]*?)(?:\\\\]\\\\]>)?\\\\s*</\${tag}>\`, "i"));
|
|
489
|
+
return m ? m[1].trim() : "";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function extractAtomLink(xml) {
|
|
493
|
+
const alt = xml.match(/<link[^>]*rel="alternate"[^>]*href="([^"]+)"/i);
|
|
494
|
+
if (alt) return alt[1];
|
|
495
|
+
const any = xml.match(/<link[^>]*href="([^"]+)"/i);
|
|
496
|
+
return any ? any[1] : "";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function stripHtml(html) {
|
|
500
|
+
return html.replace(/<[^>]+>/g, "").replace(/&/g, "&").replace(/</g, "<")
|
|
501
|
+
.replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'")
|
|
502
|
+
.replace(/\\s+/g, " ").trim();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function parseFeed(url) {
|
|
506
|
+
const res = await fetch(url, {
|
|
507
|
+
headers: { "User-Agent": "${projectName} RSS Reader" },
|
|
508
|
+
signal: AbortSignal.timeout(15000),
|
|
509
|
+
});
|
|
510
|
+
if (!res.ok) throw new Error(\`Feed fetch failed: \${res.status}\`);
|
|
511
|
+
const xml = await res.text();
|
|
512
|
+
const items = [];
|
|
513
|
+
|
|
514
|
+
const rssItems = xml.match(/<item>([\\s\\S]*?)<\\/item>/gi) || [];
|
|
515
|
+
if (rssItems.length > 0) {
|
|
516
|
+
for (const item of rssItems) {
|
|
517
|
+
items.push({
|
|
518
|
+
title: stripHtml(extractTag(item, "title")),
|
|
519
|
+
link: extractTag(item, "link"),
|
|
520
|
+
published: extractTag(item, "pubDate"),
|
|
521
|
+
summary: stripHtml(extractTag(item, "description")).substring(0, 500),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return { title: stripHtml(extractTag(xml, "title")), items };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const atomEntries = xml.match(/<entry>([\\s\\S]*?)<\\/entry>/gi) || [];
|
|
528
|
+
for (const entry of atomEntries) {
|
|
529
|
+
items.push({
|
|
530
|
+
title: stripHtml(extractTag(entry, "title")),
|
|
531
|
+
link: extractAtomLink(entry) || extractTag(entry, "link"),
|
|
532
|
+
published: extractTag(entry, "published") || extractTag(entry, "updated"),
|
|
533
|
+
summary: stripHtml(extractTag(entry, "summary") || extractTag(entry, "content")).substring(0, 500),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
return { title: stripHtml(extractTag(xml, "title")), items };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── SMTP email sender ─────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
function readSmtp(socket, timeout = 10000) {
|
|
542
|
+
return new Promise((resolve, reject) => {
|
|
543
|
+
let buf = "";
|
|
544
|
+
const timer = setTimeout(() => { socket.removeAllListeners("data"); reject(new Error("SMTP timeout")); }, timeout);
|
|
545
|
+
const onData = (chunk) => {
|
|
546
|
+
buf += chunk.toString();
|
|
547
|
+
const lines = buf.split("\\r\\n").filter(Boolean);
|
|
548
|
+
const last = lines[lines.length - 1];
|
|
549
|
+
if (last && /^\\d{3} /.test(last)) {
|
|
550
|
+
clearTimeout(timer); socket.removeListener("data", onData);
|
|
551
|
+
const code = parseInt(last.substring(0, 3));
|
|
552
|
+
if (code >= 400) reject(new Error(\`SMTP \${code}: \${buf.trim()}\`));
|
|
553
|
+
else resolve(buf.trim());
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
socket.on("data", onData);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function smtpCmd(socket, cmd) { socket.write(cmd + "\\r\\n"); return readSmtp(socket); }
|
|
561
|
+
|
|
562
|
+
async function sendDigestEmail(to, subject, html, plainText) {
|
|
563
|
+
if (!EMAIL_USER || !EMAIL_PASS) {
|
|
564
|
+
console.log(" [skip] EMAIL_USER/EMAIL_PASS not set — digest printed to stdout instead");
|
|
565
|
+
console.log(plainText);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const boundary = "----DigestBoundary" + Date.now();
|
|
570
|
+
const message = [
|
|
571
|
+
\`From: \${EMAIL_USER}\`, \`To: \${to}\`, \`Subject: \${subject}\`,
|
|
572
|
+
\`Date: \${new Date().toUTCString()}\`, "MIME-Version: 1.0",
|
|
573
|
+
\`Content-Type: multipart/alternative; boundary="\${boundary}"\`,
|
|
574
|
+
"", \`--\${boundary}\`, "Content-Type: text/plain; charset=UTF-8", "", plainText,
|
|
575
|
+
\`--\${boundary}\`, "Content-Type: text/html; charset=UTF-8", "", html,
|
|
576
|
+
\`--\${boundary}--\`,
|
|
577
|
+
].join("\\r\\n");
|
|
578
|
+
|
|
579
|
+
const socket = tls.connect({ host: SMTP_HOST, port: SMTP_PORT, rejectUnauthorized: true });
|
|
580
|
+
await new Promise((resolve, reject) => { socket.once("secureConnect", resolve); socket.once("error", reject); });
|
|
581
|
+
try {
|
|
582
|
+
await readSmtp(socket);
|
|
583
|
+
await smtpCmd(socket, "EHLO digest-pipeline");
|
|
584
|
+
await smtpCmd(socket, "AUTH LOGIN");
|
|
585
|
+
await smtpCmd(socket, Buffer.from(EMAIL_USER).toString("base64"));
|
|
586
|
+
await smtpCmd(socket, Buffer.from(EMAIL_PASS).toString("base64"));
|
|
587
|
+
await smtpCmd(socket, \`MAIL FROM:<\${EMAIL_USER}>\`);
|
|
588
|
+
await smtpCmd(socket, \`RCPT TO:<\${to}>\`);
|
|
589
|
+
await smtpCmd(socket, "DATA");
|
|
590
|
+
socket.write(message.replace(/\\r\\n\\./g, "\\r\\n..") + "\\r\\n.\\r\\n");
|
|
591
|
+
await readSmtp(socket);
|
|
592
|
+
await smtpCmd(socket, "QUIT").catch(() => {});
|
|
593
|
+
console.log(\` [sent] Digest emailed to \${to}\`);
|
|
594
|
+
} finally { socket.destroy(); }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── Main pipeline ─────────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
async function fetchAll() {
|
|
600
|
+
const sources = loadSources();
|
|
601
|
+
if (sources.length === 0) {
|
|
602
|
+
console.log("No sources registered. Use: node digest.mjs --add-feed <url> [name] [category]");
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const seenArticles = loadArticles();
|
|
607
|
+
const newArticles = [];
|
|
608
|
+
|
|
609
|
+
for (const source of sources) {
|
|
610
|
+
try {
|
|
611
|
+
const feed = await parseFeed(source.url);
|
|
612
|
+
let newCount = 0;
|
|
613
|
+
for (const item of feed.items.slice(0, 20)) {
|
|
614
|
+
if (!item.link) continue;
|
|
615
|
+
const key = source.url + "|" + item.link;
|
|
616
|
+
if (seenArticles[key]) continue;
|
|
617
|
+
seenArticles[key] = { fetchedAt: new Date().toISOString(), seen: false };
|
|
618
|
+
newArticles.push({ ...item, sourceName: source.name, category: source.category });
|
|
619
|
+
newCount++;
|
|
620
|
+
}
|
|
621
|
+
console.log(\` [\${source.name}] \${feed.items.length} articles, \${newCount} new\`);
|
|
622
|
+
} catch (e) {
|
|
623
|
+
console.log(\` [\${source.name}] ERROR: \${e.message}\`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
saveArticles(seenArticles);
|
|
628
|
+
return newArticles;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function buildDigest(articles) {
|
|
632
|
+
if (articles.length === 0) return { html: "", plainText: "", count: 0 };
|
|
633
|
+
|
|
634
|
+
const byCategory = new Map();
|
|
635
|
+
for (const a of articles) {
|
|
636
|
+
const cat = a.category || "general";
|
|
637
|
+
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
|
638
|
+
byCategory.get(cat).push(a);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Plain text
|
|
642
|
+
const lines = [\`Research Digest — \${articles.length} new articles — \${new Date().toLocaleDateString()}\`, ""];
|
|
643
|
+
for (const [cat, items] of byCategory) {
|
|
644
|
+
lines.push(\`## \${cat} (\${items.length})\`, "");
|
|
645
|
+
for (const a of items) {
|
|
646
|
+
lines.push(\`- \${a.title}\`);
|
|
647
|
+
lines.push(\` \${a.sourceName} · \${a.published || "recent"}\`);
|
|
648
|
+
lines.push(\` \${a.link}\`);
|
|
649
|
+
if (a.summary) lines.push(\` > \${a.summary.substring(0, 200)}\`);
|
|
650
|
+
lines.push("");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// HTML
|
|
655
|
+
const sections = [...byCategory.entries()].map(([cat, items]) => \`
|
|
656
|
+
<h2 style="color:#333;border-bottom:2px solid #007bff;padding-bottom:4px">\${cat} (\${items.length})</h2>
|
|
657
|
+
\${items.map(a => \`
|
|
658
|
+
<div style="margin-bottom:16px;padding:12px;background:#f8f9fa;border-radius:6px">
|
|
659
|
+
<a href="\${a.link}" style="font-size:16px;font-weight:600;color:#007bff;text-decoration:none">\${a.title}</a>
|
|
660
|
+
<div style="color:#666;font-size:12px;margin-top:4px">\${a.sourceName} · \${a.published || "recent"}</div>
|
|
661
|
+
\${a.summary ? \`<p style="margin-top:8px;color:#444;font-size:14px">\${a.summary.substring(0, 300)}</p>\` : ""}
|
|
662
|
+
</div>\`).join("")}\`).join("");
|
|
663
|
+
|
|
664
|
+
const html = \`<div style="font-family:-apple-system,sans-serif;max-width:640px;margin:0 auto;padding:20px">
|
|
665
|
+
<h1 style="color:#111">Research Digest</h1>
|
|
666
|
+
<p style="color:#666">\${articles.length} new articles · \${new Date().toLocaleDateString()}</p>
|
|
667
|
+
\${sections}
|
|
668
|
+
<hr style="margin-top:32px;border:none;border-top:1px solid #ddd">
|
|
669
|
+
<p style="color:#999;font-size:11px">Generated by ${projectName}</p>
|
|
670
|
+
</div>\`;
|
|
671
|
+
|
|
672
|
+
return { html, plainText: lines.join("\\n"), count: articles.length };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
const args = process.argv.slice(2);
|
|
678
|
+
|
|
679
|
+
if (args.includes("--list-sources")) {
|
|
680
|
+
const sources = loadSources();
|
|
681
|
+
if (sources.length === 0) console.log("No sources. Use --add-feed <url> [name] [category]");
|
|
682
|
+
else sources.forEach((s, i) => console.log(\`\${i + 1}. [\${s.category}] \${s.name} — \${s.url}\`));
|
|
683
|
+
} else if (args.includes("--add-feed")) {
|
|
684
|
+
const idx = args.indexOf("--add-feed");
|
|
685
|
+
const url = args[idx + 1];
|
|
686
|
+
const name = args[idx + 2] || url;
|
|
687
|
+
const category = args[idx + 3] || "general";
|
|
688
|
+
if (!url) { console.error("Usage: --add-feed <url> [name] [category]"); process.exit(1); }
|
|
689
|
+
try {
|
|
690
|
+
const feed = await parseFeed(url);
|
|
691
|
+
const sources = loadSources();
|
|
692
|
+
sources.push({ url, name, category });
|
|
693
|
+
saveSources(sources);
|
|
694
|
+
console.log(\`Added: \${name} (\${feed.title}) — \${feed.items.length} articles available\`);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
console.error(\`Failed to validate feed: \${e.message}\`);
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
console.log(\`\\n${"=".repeat(60)}\`);
|
|
700
|
+
console.log(\` ${projectName} — \${new Date().toISOString()}\`);
|
|
701
|
+
console.log(\`${"=".repeat(60)}\\n\`);
|
|
702
|
+
|
|
703
|
+
console.log("Fetching feeds...");
|
|
704
|
+
const articles = await fetchAll();
|
|
705
|
+
|
|
706
|
+
if (articles.length === 0) {
|
|
707
|
+
console.log("\\nNo new articles found.");
|
|
708
|
+
} else {
|
|
709
|
+
const digest = buildDigest(articles);
|
|
710
|
+
console.log(\`\\nDigest: \${digest.count} new articles\`);
|
|
711
|
+
|
|
712
|
+
if (!args.includes("--fetch-only")) {
|
|
713
|
+
const subject = \`Research Digest — \${digest.count} articles — \${new Date().toLocaleDateString()}\`;
|
|
714
|
+
await sendDigestEmail(EMAIL_TO, subject, digest.html, digest.plainText);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Save digest to file
|
|
718
|
+
const digestFile = DATA_DIR + \`digest-\${new Date().toISOString().slice(0,10)}.md\`;
|
|
719
|
+
writeFileSync(digestFile, digest.plainText);
|
|
720
|
+
console.log(\` [saved] \${digestFile}\`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
723
|
`;
|
|
724
724
|
// ── Generate package.json ──
|
|
725
725
|
const packageJson = JSON.stringify({
|
|
@@ -737,67 +737,67 @@ if (args.includes("--list-sources")) {
|
|
|
737
737
|
license: "MIT",
|
|
738
738
|
}, null, 2);
|
|
739
739
|
// ── Generate .env template ──
|
|
740
|
-
const envTemplate = `# Email configuration (required for email delivery)
|
|
741
|
-
# For Gmail: use an App Password (Google Account → Security → App passwords)
|
|
742
|
-
EMAIL_USER=your.email@gmail.com
|
|
743
|
-
EMAIL_PASS=your-16-char-app-password
|
|
744
|
-
DIGEST_TO=${emailTo || "your.email@gmail.com"}
|
|
745
|
-
|
|
746
|
-
# Optional: non-Gmail SMTP
|
|
747
|
-
# EMAIL_SMTP_HOST=smtp.gmail.com
|
|
748
|
-
# EMAIL_SMTP_PORT=465
|
|
740
|
+
const envTemplate = `# Email configuration (required for email delivery)
|
|
741
|
+
# For Gmail: use an App Password (Google Account → Security → App passwords)
|
|
742
|
+
EMAIL_USER=your.email@gmail.com
|
|
743
|
+
EMAIL_PASS=your-16-char-app-password
|
|
744
|
+
DIGEST_TO=${emailTo || "your.email@gmail.com"}
|
|
745
|
+
|
|
746
|
+
# Optional: non-Gmail SMTP
|
|
747
|
+
# EMAIL_SMTP_HOST=smtp.gmail.com
|
|
748
|
+
# EMAIL_SMTP_PORT=465
|
|
749
749
|
`;
|
|
750
750
|
// ── Generate README ──
|
|
751
|
-
const readme = `# ${projectName}
|
|
752
|
-
|
|
753
|
-
Automated research digest pipeline. Subscribes to RSS/Atom feeds, fetches new articles, builds a categorized digest, and emails it to you.
|
|
754
|
-
|
|
755
|
-
## Quick Start
|
|
756
|
-
|
|
757
|
-
\`\`\`bash
|
|
758
|
-
# 1. Set up email (see .env.example)
|
|
759
|
-
cp .env.example .env
|
|
760
|
-
# Edit .env with your email credentials
|
|
761
|
-
|
|
762
|
-
# 2. Add feeds
|
|
763
|
-
node digest.mjs --add-feed "https://arxiv.org/rss/cs.AI" "arXiv AI" "ai-research"
|
|
764
|
-
node digest.mjs --add-feed "https://hnrss.org/newest?points=100" "HN Top" "tech"
|
|
765
|
-
node digest.mjs --add-feed "https://blog.anthropic.com/rss.xml" "Anthropic" "ai-research"
|
|
766
|
-
|
|
767
|
-
# 3. Run
|
|
768
|
-
node digest.mjs
|
|
769
|
-
\`\`\`
|
|
770
|
-
|
|
771
|
-
## Schedule (cron)
|
|
772
|
-
|
|
773
|
-
\`\`\`bash
|
|
774
|
-
# Edit crontab
|
|
775
|
-
crontab -e
|
|
776
|
-
|
|
777
|
-
# Add (${schedule}):
|
|
778
|
-
${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
|
|
779
|
-
\`\`\`
|
|
780
|
-
|
|
781
|
-
## Commands
|
|
782
|
-
|
|
783
|
-
| Command | What it does |
|
|
784
|
-
|---|---|
|
|
785
|
-
| \`node digest.mjs\` | Fetch + digest + email |
|
|
786
|
-
| \`node digest.mjs --fetch-only\` | Fetch only (no email) |
|
|
787
|
-
| \`node digest.mjs --list-sources\` | Show registered feeds |
|
|
788
|
-
| \`node digest.mjs --add-feed <url> [name] [category]\` | Add a new feed |
|
|
789
|
-
|
|
790
|
-
## How It Works
|
|
791
|
-
|
|
792
|
-
1. **Fetch**: Pulls latest articles from all registered RSS/Atom feeds
|
|
793
|
-
2. **Deduplicate**: Skips articles already seen (tracked in \`data/articles.json\`)
|
|
794
|
-
3. **Digest**: Builds a categorized summary (plain text + HTML)
|
|
795
|
-
4. **Email**: Sends via SMTP over TLS (Gmail default, configurable)
|
|
796
|
-
5. **Save**: Archives digest as markdown in \`data/\`
|
|
797
|
-
|
|
798
|
-
No dependencies. Pure Node.js (>= 18). Zero npm packages.
|
|
799
|
-
|
|
800
|
-
Generated by [NodeBench MCP](https://www.npmjs.com/package/nodebench-mcp) \`scaffold_research_pipeline\`
|
|
751
|
+
const readme = `# ${projectName}
|
|
752
|
+
|
|
753
|
+
Automated research digest pipeline. Subscribes to RSS/Atom feeds, fetches new articles, builds a categorized digest, and emails it to you.
|
|
754
|
+
|
|
755
|
+
## Quick Start
|
|
756
|
+
|
|
757
|
+
\`\`\`bash
|
|
758
|
+
# 1. Set up email (see .env.example)
|
|
759
|
+
cp .env.example .env
|
|
760
|
+
# Edit .env with your email credentials
|
|
761
|
+
|
|
762
|
+
# 2. Add feeds
|
|
763
|
+
node digest.mjs --add-feed "https://arxiv.org/rss/cs.AI" "arXiv AI" "ai-research"
|
|
764
|
+
node digest.mjs --add-feed "https://hnrss.org/newest?points=100" "HN Top" "tech"
|
|
765
|
+
node digest.mjs --add-feed "https://blog.anthropic.com/rss.xml" "Anthropic" "ai-research"
|
|
766
|
+
|
|
767
|
+
# 3. Run
|
|
768
|
+
node digest.mjs
|
|
769
|
+
\`\`\`
|
|
770
|
+
|
|
771
|
+
## Schedule (cron)
|
|
772
|
+
|
|
773
|
+
\`\`\`bash
|
|
774
|
+
# Edit crontab
|
|
775
|
+
crontab -e
|
|
776
|
+
|
|
777
|
+
# Add (${schedule}):
|
|
778
|
+
${cronExpr[schedule]} cd /path/to/${projectName} && node digest.mjs >> digest.log 2>&1
|
|
779
|
+
\`\`\`
|
|
780
|
+
|
|
781
|
+
## Commands
|
|
782
|
+
|
|
783
|
+
| Command | What it does |
|
|
784
|
+
|---|---|
|
|
785
|
+
| \`node digest.mjs\` | Fetch + digest + email |
|
|
786
|
+
| \`node digest.mjs --fetch-only\` | Fetch only (no email) |
|
|
787
|
+
| \`node digest.mjs --list-sources\` | Show registered feeds |
|
|
788
|
+
| \`node digest.mjs --add-feed <url> [name] [category]\` | Add a new feed |
|
|
789
|
+
|
|
790
|
+
## How It Works
|
|
791
|
+
|
|
792
|
+
1. **Fetch**: Pulls latest articles from all registered RSS/Atom feeds
|
|
793
|
+
2. **Deduplicate**: Skips articles already seen (tracked in \`data/articles.json\`)
|
|
794
|
+
3. **Digest**: Builds a categorized summary (plain text + HTML)
|
|
795
|
+
4. **Email**: Sends via SMTP over TLS (Gmail default, configurable)
|
|
796
|
+
5. **Save**: Archives digest as markdown in \`data/\`
|
|
797
|
+
|
|
798
|
+
No dependencies. Pure Node.js (>= 18). Zero npm packages.
|
|
799
|
+
|
|
800
|
+
Generated by [NodeBench MCP](https://www.npmjs.com/package/nodebench-mcp) \`scaffold_research_pipeline\`
|
|
801
801
|
`;
|
|
802
802
|
return [
|
|
803
803
|
{
|