@iletai/nzb 1.9.1 → 1.10.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.
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
3
|
+
const USER_AGENT = "nzb-research/1.0";
|
|
4
|
+
// ── HTTP helper ──────────────────────────────────────────────────────
|
|
5
|
+
async function fetchJson(url) {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
8
|
+
try {
|
|
9
|
+
const res = await fetch(url, {
|
|
10
|
+
signal: controller.signal,
|
|
11
|
+
headers: { "User-Agent": USER_AGENT },
|
|
12
|
+
});
|
|
13
|
+
if (!res.ok)
|
|
14
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
15
|
+
return (await res.json());
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// ── Source fetchers ──────────────────────────────────────────────────
|
|
22
|
+
async function fetchHackerNews() {
|
|
23
|
+
const ids = await fetchJson("https://hacker-news.firebaseio.com/v0/topstories.json");
|
|
24
|
+
const top10 = ids.slice(0, 10);
|
|
25
|
+
const items = await Promise.all(top10.map((id) => fetchJson(`https://hacker-news.firebaseio.com/v0/item/${id}.json`)));
|
|
26
|
+
const lines = items.map((item, i) => `${i + 1}. ${item.title ?? "Untitled"} (${item.score ?? 0} pts, ${item.descendants ?? 0} comments)${item.url ? `\n ${item.url}` : ""}`);
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
|
29
|
+
async function fetchGitHubTrending() {
|
|
30
|
+
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
31
|
+
.toISOString()
|
|
32
|
+
.split("T")[0];
|
|
33
|
+
const data = await fetchJson(`https://api.github.com/search/repositories?q=created:>${since}&sort=stars&order=desc&per_page=5`);
|
|
34
|
+
const repos = data.items ?? [];
|
|
35
|
+
const lines = repos.map((r, i) => `${i + 1}. ${r.full_name ?? "unknown"} ⭐${r.stargazers_count ?? 0} [${r.language ?? "N/A"}]\n ${r.description ?? "No description"}\n ${r.html_url ?? ""}`);
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
async function fetchReddit(subreddit) {
|
|
39
|
+
const data = await fetchJson(`https://www.reddit.com/r/${subreddit}/hot.json?limit=5`);
|
|
40
|
+
const posts = data.data?.children ?? [];
|
|
41
|
+
const lines = posts
|
|
42
|
+
.filter((p) => p.data.title)
|
|
43
|
+
.map((p, i) => `${i + 1}. ${p.data.title} (${p.data.score ?? 0} pts, ${p.data.num_comments ?? 0} comments)`);
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
async function fetchGoldPrice() {
|
|
47
|
+
// Use a free metals API. Falls back to a message if unavailable.
|
|
48
|
+
try {
|
|
49
|
+
const data = await fetchJson("https://api.metals.dev/v1/latest?api_key=demo¤cy=USD&unit=toz");
|
|
50
|
+
const metals = data.metals;
|
|
51
|
+
if (metals) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
if (metals.gold)
|
|
54
|
+
lines.push(`Gold: $${metals.gold}/oz`);
|
|
55
|
+
if (metals.silver)
|
|
56
|
+
lines.push(`Silver: $${metals.silver}/oz`);
|
|
57
|
+
if (metals.platinum)
|
|
58
|
+
lines.push(`Platinum: $${metals.platinum}/oz`);
|
|
59
|
+
return lines.join("\n") || "No metal price data available.";
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify(data).slice(0, 500);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Fallback: try alternative free API
|
|
65
|
+
try {
|
|
66
|
+
const data = await fetchJson("https://www.goldapi.io/api/XAU/USD");
|
|
67
|
+
const price = data.price;
|
|
68
|
+
return price ? `Gold: $${price}/oz` : JSON.stringify(data).slice(0, 300);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return "Gold price APIs unavailable. Could not fetch current prices.";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function fetchCryptoPrices() {
|
|
76
|
+
const data = await fetchJson("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum,solana,cardano&vs_currencies=usd&include_24hr_change=true");
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const [coin, info] of Object.entries(data)) {
|
|
79
|
+
const change = info.usd_24h_change != null ? ` (${info.usd_24h_change > 0 ? "+" : ""}${info.usd_24h_change.toFixed(2)}%)` : "";
|
|
80
|
+
lines.push(`${coin}: $${info.usd ?? "N/A"}${change}`);
|
|
81
|
+
}
|
|
82
|
+
return lines.join("\n") || "No crypto data available.";
|
|
83
|
+
}
|
|
84
|
+
// ── Presets ───────────────────────────────────────────────────────────
|
|
85
|
+
function getPresetSources(preset) {
|
|
86
|
+
switch (preset) {
|
|
87
|
+
case "tech-trends":
|
|
88
|
+
return [
|
|
89
|
+
{ name: "HackerNews Top Stories", fetchData: fetchHackerNews },
|
|
90
|
+
{ name: "GitHub Trending Repos (last 7 days)", fetchData: fetchGitHubTrending },
|
|
91
|
+
{ name: "Reddit r/programming", fetchData: () => fetchReddit("programming") },
|
|
92
|
+
{ name: "Reddit r/MachineLearning", fetchData: () => fetchReddit("MachineLearning") },
|
|
93
|
+
];
|
|
94
|
+
case "gold-price":
|
|
95
|
+
return [
|
|
96
|
+
{ name: "Gold & Metals Prices", fetchData: fetchGoldPrice },
|
|
97
|
+
];
|
|
98
|
+
case "crypto":
|
|
99
|
+
return [
|
|
100
|
+
{ name: "Cryptocurrency Prices", fetchData: fetchCryptoPrices },
|
|
101
|
+
];
|
|
102
|
+
default:
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function buildCustomSources(sources) {
|
|
107
|
+
return sources.map((s) => ({
|
|
108
|
+
name: s.name,
|
|
109
|
+
fetchData: async () => {
|
|
110
|
+
const data = await fetchJson(s.url);
|
|
111
|
+
return typeof data === "string" ? data : JSON.stringify(data).slice(0, 2000);
|
|
112
|
+
},
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
// ── Fetch all sources in parallel ────────────────────────────────────
|
|
116
|
+
async function fetchAllSources(sources) {
|
|
117
|
+
const results = await Promise.allSettled(sources.map(async (src) => {
|
|
118
|
+
try {
|
|
119
|
+
const data = await src.fetchData();
|
|
120
|
+
return { name: src.name, data };
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
+
return { name: src.name, data: `[Failed to fetch: ${msg}]` };
|
|
125
|
+
}
|
|
126
|
+
}));
|
|
127
|
+
const sections = [];
|
|
128
|
+
for (const result of results) {
|
|
129
|
+
if (result.status === "fulfilled") {
|
|
130
|
+
sections.push(`## ${result.value.name}\n${result.value.data}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return sections.join("\n\n");
|
|
134
|
+
}
|
|
135
|
+
// ── Main entry point ─────────────────────────────────────────────────
|
|
136
|
+
export async function executeResearchTask(job, payload) {
|
|
137
|
+
// 1. Determine sources
|
|
138
|
+
const preset = payload.preset;
|
|
139
|
+
const customSources = payload.sources;
|
|
140
|
+
const prompt = payload.prompt || "Summarize the following data concisely.";
|
|
141
|
+
let sources = [];
|
|
142
|
+
if (preset) {
|
|
143
|
+
sources = getPresetSources(preset);
|
|
144
|
+
if (sources.length === 0) {
|
|
145
|
+
throw new Error(`Unknown research preset: ${preset}. Available: tech-trends, gold-price, crypto`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (customSources && Array.isArray(customSources)) {
|
|
149
|
+
sources = sources.concat(buildCustomSources(customSources));
|
|
150
|
+
}
|
|
151
|
+
if (sources.length === 0) {
|
|
152
|
+
throw new Error("Research task requires 'preset' or 'sources' in payload. Available presets: tech-trends, gold-price, crypto");
|
|
153
|
+
}
|
|
154
|
+
// 2. Fetch real data from all sources
|
|
155
|
+
console.log(`[nzb] Research task '${job.id}': fetching ${sources.length} source(s)...`);
|
|
156
|
+
const fetchedData = await fetchAllSources(sources);
|
|
157
|
+
if (!fetchedData.trim()) {
|
|
158
|
+
throw new Error("All research sources failed to return data.");
|
|
159
|
+
}
|
|
160
|
+
// 3. Build AI prompt with real data
|
|
161
|
+
const aiPrompt = `[Scheduled research task]
|
|
162
|
+
|
|
163
|
+
You are given REAL-TIME data fetched from the internet just now. Use ONLY this data to produce your summary — do NOT use your built-in knowledge for facts or figures.
|
|
164
|
+
|
|
165
|
+
USER INSTRUCTIONS:
|
|
166
|
+
${prompt}
|
|
167
|
+
|
|
168
|
+
--- FETCHED DATA (${new Date().toISOString()}) ---
|
|
169
|
+
|
|
170
|
+
${fetchedData}
|
|
171
|
+
|
|
172
|
+
--- END OF DATA ---
|
|
173
|
+
|
|
174
|
+
Based on the data above, provide your summary following the user's instructions.`;
|
|
175
|
+
// 4. Send to AI for summarization
|
|
176
|
+
console.log(`[nzb] Research task '${job.id}': sending to AI for summarization...`);
|
|
177
|
+
let aiResponse;
|
|
178
|
+
try {
|
|
179
|
+
if (job.model) {
|
|
180
|
+
const { runOneOffPrompt } = await import("../copilot/orchestrator.js");
|
|
181
|
+
aiResponse = await runOneOffPrompt(aiPrompt, job.model);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const { sendToOrchestrator } = await import("../copilot/orchestrator.js");
|
|
185
|
+
aiResponse = await new Promise((resolve) => {
|
|
186
|
+
sendToOrchestrator(aiPrompt, { type: "background" }, (text, done) => {
|
|
187
|
+
if (done)
|
|
188
|
+
resolve(text);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
throw new Error(`Research AI summarization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
195
|
+
}
|
|
196
|
+
// 5. Format and send to Telegram
|
|
197
|
+
const header = preset
|
|
198
|
+
? `🔬 Research: ${preset}`
|
|
199
|
+
: "🔬 Research Report";
|
|
200
|
+
const formattedMessage = `${header}\n\n${aiResponse}`;
|
|
201
|
+
if (config.telegramEnabled) {
|
|
202
|
+
const { sendProactiveMessage } = await import("../telegram/bot.js");
|
|
203
|
+
await sendProactiveMessage(formattedMessage);
|
|
204
|
+
}
|
|
205
|
+
console.log(`[nzb] Research task '${job.id}': completed successfully.`);
|
|
206
|
+
return formattedMessage;
|
|
207
|
+
}
|
|
208
|
+
//# sourceMappingURL=research-runner.js.map
|
package/dist/cron/scheduler.js
CHANGED
|
@@ -107,7 +107,10 @@ async function runJob(jobId) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
console.log(`[nzb] Cron job '${job.id}' completed in ${finishedAt.getTime() - startedAt.getTime()}ms`);
|
|
110
|
-
|
|
110
|
+
// Vocab and research tasks handle their own Telegram notifications
|
|
111
|
+
if (job.taskType !== "vocab" && job.taskType !== "research") {
|
|
112
|
+
await notifyResult(job, `✅ Completed\n${result}`);
|
|
113
|
+
}
|
|
111
114
|
return result;
|
|
112
115
|
}
|
|
113
116
|
catch (err) {
|
package/dist/cron/task-runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import { freemem, totalmem } from "os";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
import { DB_PATH, NZB_HOME } from "../paths.js";
|
|
7
|
+
import { executeResearchTask } from "./research-runner.js";
|
|
7
8
|
const BACKUPS_DIR = join(NZB_HOME, "backups");
|
|
8
9
|
/** Notify result to Telegram if the job has notifyTelegram enabled. */
|
|
9
10
|
export async function notifyResult(job, message) {
|
|
@@ -33,6 +34,8 @@ export async function executeCronTask(job) {
|
|
|
33
34
|
return await executeWebhookTask(payload, job.timeoutMs);
|
|
34
35
|
case "vocab":
|
|
35
36
|
return await executeVocabTask(job, payload);
|
|
37
|
+
case "research":
|
|
38
|
+
return await executeResearchTask(job, payload);
|
|
36
39
|
default:
|
|
37
40
|
throw new Error(`Unknown task type: ${job.taskType}`);
|
|
38
41
|
}
|