@timmeck/brain-core 2.15.0 → 2.17.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/dist/api/server.js +10 -0
- package/dist/api/server.js.map +1 -1
- package/dist/codegen/code-generator.d.ts +42 -0
- package/dist/codegen/code-generator.js +225 -0
- package/dist/codegen/code-generator.js.map +1 -0
- package/dist/codegen/code-miner.d.ts +45 -0
- package/dist/codegen/code-miner.js +243 -0
- package/dist/codegen/code-miner.js.map +1 -0
- package/dist/codegen/context-builder.d.ts +15 -0
- package/dist/codegen/context-builder.js +137 -0
- package/dist/codegen/context-builder.js.map +1 -0
- package/dist/codegen/index.d.ts +5 -0
- package/dist/codegen/index.js +5 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/codegen/pattern-extractor.d.ts +25 -0
- package/dist/codegen/pattern-extractor.js +222 -0
- package/dist/codegen/pattern-extractor.js.map +1 -0
- package/dist/codegen/types.d.ts +127 -0
- package/dist/codegen/types.js +3 -0
- package/dist/codegen/types.js.map +1 -0
- package/dist/consciousness/consciousness-server.js +10 -0
- package/dist/consciousness/consciousness-server.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/http-server.js +10 -0
- package/dist/mcp/http-server.js.map +1 -1
- package/dist/prediction/types.d.ts +1 -1
- package/dist/research/adapters/index.d.ts +1 -0
- package/dist/research/adapters/index.js +1 -0
- package/dist/research/adapters/index.js.map +1 -1
- package/dist/research/adapters/scanner-adapter.d.ts +14 -0
- package/dist/research/adapters/scanner-adapter.js +209 -0
- package/dist/research/adapters/scanner-adapter.js.map +1 -0
- package/dist/research/data-miner.d.ts +3 -1
- package/dist/research/data-miner.js +79 -69
- package/dist/research/data-miner.js.map +1 -1
- package/dist/research/research-orchestrator.d.ts +13 -1
- package/dist/research/research-orchestrator.js +78 -2
- package/dist/research/research-orchestrator.js.map +1 -1
- package/dist/scanner/crypto-collector.d.ts +17 -0
- package/dist/scanner/crypto-collector.js +89 -0
- package/dist/scanner/crypto-collector.js.map +1 -0
- package/dist/scanner/github-collector.d.ts +22 -0
- package/dist/scanner/github-collector.js +104 -0
- package/dist/scanner/github-collector.js.map +1 -0
- package/dist/scanner/hn-collector.d.ts +17 -0
- package/dist/scanner/hn-collector.js +58 -0
- package/dist/scanner/hn-collector.js.map +1 -0
- package/dist/scanner/index.d.ts +6 -0
- package/dist/scanner/index.js +6 -0
- package/dist/scanner/index.js.map +1 -0
- package/dist/scanner/signal-scanner.d.ts +83 -0
- package/dist/scanner/signal-scanner.js +859 -0
- package/dist/scanner/signal-scanner.js.map +1 -0
- package/dist/scanner/signal-scorer.d.ts +31 -0
- package/dist/scanner/signal-scorer.js +205 -0
- package/dist/scanner/signal-scorer.js.map +1 -0
- package/dist/scanner/types.d.ts +170 -0
- package/dist/scanner/types.js +3 -0
- package/dist/scanner/types.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { getLogger } from '../utils/logger.js';
|
|
3
|
+
import { GitHubCollector } from './github-collector.js';
|
|
4
|
+
import { HnCollector } from './hn-collector.js';
|
|
5
|
+
import { CryptoCollector } from './crypto-collector.js';
|
|
6
|
+
import { scoreRepo, classifyWithHysteresis, scoreCrypto } from './signal-scorer.js';
|
|
7
|
+
const log = getLogger();
|
|
8
|
+
// ── Migration ───────────────────────────────────────────────
|
|
9
|
+
export function runScannerMigration(db) {
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS scanned_repos (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
13
|
+
github_id INTEGER UNIQUE NOT NULL,
|
|
14
|
+
full_name TEXT NOT NULL,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
owner TEXT NOT NULL,
|
|
17
|
+
url TEXT NOT NULL,
|
|
18
|
+
description TEXT,
|
|
19
|
+
language TEXT,
|
|
20
|
+
topics TEXT DEFAULT '[]',
|
|
21
|
+
created_at TEXT,
|
|
22
|
+
first_seen_at TEXT DEFAULT (datetime('now')),
|
|
23
|
+
current_stars INTEGER DEFAULT 0,
|
|
24
|
+
current_forks INTEGER DEFAULT 0,
|
|
25
|
+
current_watchers INTEGER DEFAULT 0,
|
|
26
|
+
current_issues INTEGER DEFAULT 0,
|
|
27
|
+
signal_score REAL DEFAULT 0,
|
|
28
|
+
signal_level TEXT DEFAULT 'noise',
|
|
29
|
+
phase TEXT DEFAULT 'discovery',
|
|
30
|
+
peak_signal_level TEXT,
|
|
31
|
+
peak_level_since TEXT,
|
|
32
|
+
star_velocity_24h INTEGER DEFAULT 0,
|
|
33
|
+
star_velocity_7d INTEGER DEFAULT 0,
|
|
34
|
+
star_acceleration REAL DEFAULT 0,
|
|
35
|
+
last_scanned_at TEXT,
|
|
36
|
+
is_active INTEGER DEFAULT 1
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_repos_level ON scanned_repos(signal_level);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_repos_score ON scanned_repos(signal_score DESC);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_repos_language ON scanned_repos(language);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_scanned_repos_stars ON scanned_repos(current_stars DESC);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS repo_daily_stats (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
repo_id INTEGER NOT NULL,
|
|
47
|
+
date TEXT NOT NULL,
|
|
48
|
+
stars INTEGER DEFAULT 0,
|
|
49
|
+
forks INTEGER DEFAULT 0,
|
|
50
|
+
watchers INTEGER DEFAULT 0,
|
|
51
|
+
issues INTEGER DEFAULT 0,
|
|
52
|
+
star_velocity_24h INTEGER DEFAULT 0,
|
|
53
|
+
star_velocity_7d INTEGER DEFAULT 0,
|
|
54
|
+
star_acceleration REAL DEFAULT 0,
|
|
55
|
+
fork_velocity_24h INTEGER DEFAULT 0,
|
|
56
|
+
UNIQUE(repo_id, date)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS hn_mentions (
|
|
60
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
hn_id INTEGER UNIQUE,
|
|
62
|
+
title TEXT NOT NULL,
|
|
63
|
+
url TEXT,
|
|
64
|
+
score INTEGER DEFAULT 0,
|
|
65
|
+
comment_count INTEGER DEFAULT 0,
|
|
66
|
+
author TEXT,
|
|
67
|
+
posted_at TEXT,
|
|
68
|
+
detected_at TEXT DEFAULT (datetime('now')),
|
|
69
|
+
repo_id INTEGER
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS crypto_tokens (
|
|
73
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
74
|
+
coingecko_id TEXT UNIQUE NOT NULL,
|
|
75
|
+
symbol TEXT NOT NULL,
|
|
76
|
+
name TEXT NOT NULL,
|
|
77
|
+
category TEXT,
|
|
78
|
+
current_price REAL,
|
|
79
|
+
market_cap REAL,
|
|
80
|
+
market_cap_rank INTEGER,
|
|
81
|
+
price_change_24h REAL,
|
|
82
|
+
price_change_7d REAL,
|
|
83
|
+
total_volume REAL,
|
|
84
|
+
signal_score REAL DEFAULT 0,
|
|
85
|
+
signal_level TEXT DEFAULT 'noise',
|
|
86
|
+
last_scanned_at TEXT,
|
|
87
|
+
is_active INTEGER DEFAULT 1
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS scanner_state (
|
|
91
|
+
key TEXT PRIMARY KEY,
|
|
92
|
+
value TEXT NOT NULL,
|
|
93
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
94
|
+
);
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
// ── Scanner ─────────────────────────────────────────────────
|
|
98
|
+
export class SignalScanner {
|
|
99
|
+
db;
|
|
100
|
+
config;
|
|
101
|
+
github;
|
|
102
|
+
hn;
|
|
103
|
+
crypto;
|
|
104
|
+
timer = null;
|
|
105
|
+
scanning = false;
|
|
106
|
+
lastResult = null;
|
|
107
|
+
constructor(db, config) {
|
|
108
|
+
this.db = db;
|
|
109
|
+
this.config = {
|
|
110
|
+
enabled: config.enabled ?? true,
|
|
111
|
+
githubToken: config.githubToken ?? process.env['GITHUB_TOKEN'] ?? '',
|
|
112
|
+
scanIntervalMs: config.scanIntervalMs ?? 21_600_000, // 6h
|
|
113
|
+
minStarsEmerging: config.minStarsEmerging ?? 15,
|
|
114
|
+
minStarsTrending: config.minStarsTrending ?? 200,
|
|
115
|
+
maxReposPerScan: config.maxReposPerScan ?? 5000,
|
|
116
|
+
cryptoEnabled: config.cryptoEnabled ?? true,
|
|
117
|
+
hnEnabled: config.hnEnabled ?? true,
|
|
118
|
+
};
|
|
119
|
+
this.github = new GitHubCollector(this.config);
|
|
120
|
+
this.hn = new HnCollector();
|
|
121
|
+
this.crypto = new CryptoCollector();
|
|
122
|
+
runScannerMigration(db);
|
|
123
|
+
// Load last result from state
|
|
124
|
+
this.loadLastResult();
|
|
125
|
+
}
|
|
126
|
+
/** Start periodic scanning. */
|
|
127
|
+
start() {
|
|
128
|
+
if (!this.config.enabled || this.timer)
|
|
129
|
+
return;
|
|
130
|
+
if (!this.config.githubToken) {
|
|
131
|
+
log.warn('[scanner] No GITHUB_TOKEN — scanner disabled');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
log.info(`[scanner] Starting (interval: ${this.config.scanIntervalMs}ms)`);
|
|
135
|
+
// First scan after 30 seconds (let other engines initialize)
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
this.scan().catch(err => log.error(`[scanner] Initial scan error: ${err.message}`));
|
|
138
|
+
}, 30_000);
|
|
139
|
+
this.timer = setInterval(() => {
|
|
140
|
+
this.scan().catch(err => log.error(`[scanner] Periodic scan error: ${err.message}`));
|
|
141
|
+
}, this.config.scanIntervalMs);
|
|
142
|
+
}
|
|
143
|
+
/** Stop periodic scanning. */
|
|
144
|
+
stop() {
|
|
145
|
+
if (this.timer) {
|
|
146
|
+
clearInterval(this.timer);
|
|
147
|
+
this.timer = null;
|
|
148
|
+
}
|
|
149
|
+
this.abortScan();
|
|
150
|
+
}
|
|
151
|
+
/** Abort currently running scan. */
|
|
152
|
+
abortScan() {
|
|
153
|
+
this.github.abort();
|
|
154
|
+
this.hn.abort();
|
|
155
|
+
this.crypto.abort();
|
|
156
|
+
this.scanning = false;
|
|
157
|
+
}
|
|
158
|
+
/** Run a full scan pipeline. */
|
|
159
|
+
async scan() {
|
|
160
|
+
if (this.scanning) {
|
|
161
|
+
log.warn('[scanner] Scan already in progress');
|
|
162
|
+
return this.lastResult ?? createEmptyResult();
|
|
163
|
+
}
|
|
164
|
+
this.scanning = true;
|
|
165
|
+
this.github.reset();
|
|
166
|
+
this.hn.reset();
|
|
167
|
+
this.crypto.reset();
|
|
168
|
+
const started = new Date().toISOString();
|
|
169
|
+
const start = Date.now();
|
|
170
|
+
const errors = [];
|
|
171
|
+
let reposDiscovered = 0;
|
|
172
|
+
let reposUpdated = 0;
|
|
173
|
+
let newBreakouts = 0;
|
|
174
|
+
let newSignals = 0;
|
|
175
|
+
let hnMentionsFound = 0;
|
|
176
|
+
let cryptoTokensScanned = 0;
|
|
177
|
+
try {
|
|
178
|
+
// Step 1: GitHub Emerging Repos
|
|
179
|
+
log.info('[scanner] Step 1/8: Collecting emerging repos...');
|
|
180
|
+
let emerging = [];
|
|
181
|
+
try {
|
|
182
|
+
emerging = await this.github.collectEmerging();
|
|
183
|
+
log.info(`[scanner] Found ${emerging.length} emerging repos`);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
errors.push(`emerging: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
// Step 2: GitHub Trending Repos
|
|
189
|
+
log.info('[scanner] Step 2/8: Collecting trending repos...');
|
|
190
|
+
let trending = [];
|
|
191
|
+
try {
|
|
192
|
+
trending = await this.github.collectTrending();
|
|
193
|
+
log.info(`[scanner] Found ${trending.length} trending repos`);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
errors.push(`trending: ${err.message}`);
|
|
197
|
+
}
|
|
198
|
+
// Step 3: Upsert repos + calculate velocity
|
|
199
|
+
log.info('[scanner] Step 3/8: Upserting repos and calculating velocity...');
|
|
200
|
+
const allRepos = dedupeRepos([...emerging, ...trending]);
|
|
201
|
+
for (const ghRepo of allRepos) {
|
|
202
|
+
const isNew = this.upsertRepo(ghRepo);
|
|
203
|
+
if (isNew)
|
|
204
|
+
reposDiscovered++;
|
|
205
|
+
else
|
|
206
|
+
reposUpdated++;
|
|
207
|
+
}
|
|
208
|
+
this.calculateVelocities();
|
|
209
|
+
log.info(`[scanner] Upserted: ${reposDiscovered} new, ${reposUpdated} updated`);
|
|
210
|
+
// Step 4: HN Mentions
|
|
211
|
+
if (this.config.hnEnabled) {
|
|
212
|
+
log.info('[scanner] Step 4/8: Scanning HackerNews...');
|
|
213
|
+
try {
|
|
214
|
+
const hnHits = await this.hn.collectFrontpage();
|
|
215
|
+
hnMentionsFound = this.processHnMentions(hnHits);
|
|
216
|
+
log.info(`[scanner] HN: ${hnMentionsFound} mentions processed`);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
errors.push(`hn: ${err.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// Step 5: Score all active repos
|
|
223
|
+
log.info('[scanner] Step 5/8: Scoring repos...');
|
|
224
|
+
this.scoreAllRepos();
|
|
225
|
+
// Step 6: Classify with hysteresis
|
|
226
|
+
log.info('[scanner] Step 6/8: Classifying signal levels...');
|
|
227
|
+
const classResult = this.classifyAll();
|
|
228
|
+
newBreakouts = classResult.newBreakouts;
|
|
229
|
+
newSignals = classResult.newSignals;
|
|
230
|
+
// Step 7: Crypto scan
|
|
231
|
+
if (this.config.cryptoEnabled) {
|
|
232
|
+
log.info('[scanner] Step 7/8: Scanning crypto...');
|
|
233
|
+
try {
|
|
234
|
+
cryptoTokensScanned = await this.scanCrypto();
|
|
235
|
+
log.info(`[scanner] Crypto: ${cryptoTokensScanned} tokens scanned`);
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
errors.push(`crypto: ${err.message}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Step 8: Update state
|
|
242
|
+
log.info('[scanner] Step 8/8: Updating state...');
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
errors.push(`fatal: ${err.message}`);
|
|
246
|
+
log.error(`[scanner] Fatal error: ${err.message}`);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
this.scanning = false;
|
|
250
|
+
}
|
|
251
|
+
const result = {
|
|
252
|
+
started_at: started,
|
|
253
|
+
finished_at: new Date().toISOString(),
|
|
254
|
+
duration_ms: Date.now() - start,
|
|
255
|
+
repos_discovered: reposDiscovered,
|
|
256
|
+
repos_updated: reposUpdated,
|
|
257
|
+
new_breakouts: newBreakouts,
|
|
258
|
+
new_signals: newSignals,
|
|
259
|
+
hn_mentions_found: hnMentionsFound,
|
|
260
|
+
crypto_tokens_scanned: cryptoTokensScanned,
|
|
261
|
+
errors,
|
|
262
|
+
};
|
|
263
|
+
this.lastResult = result;
|
|
264
|
+
this.saveState('last_scan', JSON.stringify(result));
|
|
265
|
+
this.saveState('last_scan_at', result.finished_at);
|
|
266
|
+
log.info(`[scanner] Scan complete in ${result.duration_ms}ms — ${reposDiscovered} new, ${newBreakouts} breakouts, ${newSignals} signals`);
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
// ── Read Methods ─────────────────────────────────────────
|
|
270
|
+
getStatus() {
|
|
271
|
+
const counts = this.db.prepare(`
|
|
272
|
+
SELECT signal_level, COUNT(*) as count FROM scanned_repos
|
|
273
|
+
WHERE is_active = 1 GROUP BY signal_level
|
|
274
|
+
`).all();
|
|
275
|
+
const byLevel = { breakout: 0, signal: 0, watch: 0, noise: 0 };
|
|
276
|
+
let totalActive = 0;
|
|
277
|
+
for (const r of counts) {
|
|
278
|
+
byLevel[r.signal_level] = r.count;
|
|
279
|
+
totalActive += r.count;
|
|
280
|
+
}
|
|
281
|
+
const totalRepos = this.db.prepare('SELECT COUNT(*) as c FROM scanned_repos').get().c;
|
|
282
|
+
const nextScan = this.timer ? new Date(Date.now() + this.config.scanIntervalMs).toISOString() : null;
|
|
283
|
+
return {
|
|
284
|
+
running: this.scanning,
|
|
285
|
+
enabled: this.config.enabled && !!this.config.githubToken,
|
|
286
|
+
last_scan: this.lastResult,
|
|
287
|
+
total_repos: totalRepos,
|
|
288
|
+
total_active: totalActive,
|
|
289
|
+
by_level: byLevel,
|
|
290
|
+
next_scan_at: nextScan,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
getSignals(level, limit = 50) {
|
|
294
|
+
const rows = this.db.prepare(`
|
|
295
|
+
SELECT * FROM scanned_repos
|
|
296
|
+
WHERE signal_level = ? AND is_active = 1
|
|
297
|
+
ORDER BY signal_score DESC
|
|
298
|
+
LIMIT ?
|
|
299
|
+
`).all(level, limit);
|
|
300
|
+
return rows.map(deserializeRepo);
|
|
301
|
+
}
|
|
302
|
+
getTrending(limit = 30) {
|
|
303
|
+
const rows = this.db.prepare(`
|
|
304
|
+
SELECT * FROM scanned_repos
|
|
305
|
+
WHERE is_active = 1
|
|
306
|
+
ORDER BY star_velocity_24h DESC
|
|
307
|
+
LIMIT ?
|
|
308
|
+
`).all(limit);
|
|
309
|
+
return rows.map(deserializeRepo);
|
|
310
|
+
}
|
|
311
|
+
searchRepos(query, language, limit = 50) {
|
|
312
|
+
let sql = 'SELECT * FROM scanned_repos WHERE is_active = 1';
|
|
313
|
+
const params = [];
|
|
314
|
+
if (query) {
|
|
315
|
+
sql += ' AND (full_name LIKE ? OR description LIKE ? OR topics LIKE ?)';
|
|
316
|
+
const like = `%${query}%`;
|
|
317
|
+
params.push(like, like, like);
|
|
318
|
+
}
|
|
319
|
+
if (language) {
|
|
320
|
+
sql += ' AND language = ?';
|
|
321
|
+
params.push(language);
|
|
322
|
+
}
|
|
323
|
+
sql += ' ORDER BY signal_score DESC LIMIT ?';
|
|
324
|
+
params.push(limit);
|
|
325
|
+
const rows = this.db.prepare(sql).all(...params);
|
|
326
|
+
return rows.map(deserializeRepo);
|
|
327
|
+
}
|
|
328
|
+
getRepo(githubId) {
|
|
329
|
+
const row = this.db.prepare('SELECT * FROM scanned_repos WHERE github_id = ?').get(githubId);
|
|
330
|
+
if (!row)
|
|
331
|
+
return null;
|
|
332
|
+
const repo = deserializeRepo(row);
|
|
333
|
+
const daily = this.db.prepare(`
|
|
334
|
+
SELECT * FROM repo_daily_stats WHERE repo_id = ?
|
|
335
|
+
ORDER BY date DESC LIMIT 30
|
|
336
|
+
`).all(repo.id);
|
|
337
|
+
return { ...repo, daily_stats: daily };
|
|
338
|
+
}
|
|
339
|
+
getHnMentions(limit = 50) {
|
|
340
|
+
return this.db.prepare(`
|
|
341
|
+
SELECT * FROM hn_mentions ORDER BY score DESC LIMIT ?
|
|
342
|
+
`).all(limit);
|
|
343
|
+
}
|
|
344
|
+
getCryptoTokens(limit = 50) {
|
|
345
|
+
return this.db.prepare(`
|
|
346
|
+
SELECT * FROM crypto_tokens WHERE is_active = 1
|
|
347
|
+
ORDER BY signal_score DESC LIMIT ?
|
|
348
|
+
`).all(limit);
|
|
349
|
+
}
|
|
350
|
+
getCryptoTrending() {
|
|
351
|
+
return this.db.prepare(`
|
|
352
|
+
SELECT * FROM crypto_tokens WHERE is_active = 1
|
|
353
|
+
ORDER BY ABS(COALESCE(price_change_24h, 0)) DESC LIMIT 20
|
|
354
|
+
`).all();
|
|
355
|
+
}
|
|
356
|
+
getStats() {
|
|
357
|
+
const totalRepos = this.db.prepare('SELECT COUNT(*) as c FROM scanned_repos').get().c;
|
|
358
|
+
const activeRepos = this.db.prepare('SELECT COUNT(*) as c FROM scanned_repos WHERE is_active = 1').get().c;
|
|
359
|
+
const byLanguage = this.db.prepare(`
|
|
360
|
+
SELECT language, COUNT(*) as count FROM scanned_repos
|
|
361
|
+
WHERE is_active = 1 AND language IS NOT NULL
|
|
362
|
+
GROUP BY language ORDER BY count DESC LIMIT 20
|
|
363
|
+
`).all();
|
|
364
|
+
const byLevel = this.db.prepare(`
|
|
365
|
+
SELECT signal_level, COUNT(*) as count FROM scanned_repos
|
|
366
|
+
WHERE is_active = 1 GROUP BY signal_level
|
|
367
|
+
`).all();
|
|
368
|
+
const hnTotal = this.db.prepare('SELECT COUNT(*) as c FROM hn_mentions').get().c;
|
|
369
|
+
const cryptoTotal = this.db.prepare('SELECT COUNT(*) as c FROM crypto_tokens WHERE is_active = 1').get().c;
|
|
370
|
+
const avgScore = this.db.prepare('SELECT AVG(signal_score) as avg FROM scanned_repos WHERE is_active = 1').get().avg ?? 0;
|
|
371
|
+
return {
|
|
372
|
+
total_repos: totalRepos,
|
|
373
|
+
active_repos: activeRepos,
|
|
374
|
+
by_language: byLanguage,
|
|
375
|
+
by_level: byLevel,
|
|
376
|
+
hn_mentions: hnTotal,
|
|
377
|
+
crypto_tokens: cryptoTotal,
|
|
378
|
+
avg_score: Math.round(avgScore * 100) / 100,
|
|
379
|
+
last_scan: this.lastResult,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
getConfig() {
|
|
383
|
+
return { ...this.config };
|
|
384
|
+
}
|
|
385
|
+
updateConfig(updates) {
|
|
386
|
+
Object.assign(this.config, updates);
|
|
387
|
+
return { ...this.config };
|
|
388
|
+
}
|
|
389
|
+
// ── Internal Write Methods ───────────────────────────────
|
|
390
|
+
/** Upsert a GitHub repo. Returns true if new. */
|
|
391
|
+
upsertRepo(gh) {
|
|
392
|
+
const existing = this.db.prepare('SELECT id FROM scanned_repos WHERE github_id = ?').get(gh.id);
|
|
393
|
+
const now = new Date().toISOString();
|
|
394
|
+
if (!existing) {
|
|
395
|
+
this.db.prepare(`
|
|
396
|
+
INSERT INTO scanned_repos (github_id, full_name, name, owner, url, description, language, topics, created_at, current_stars, current_forks, current_watchers, current_issues, last_scanned_at)
|
|
397
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
398
|
+
`).run(gh.id, gh.full_name, gh.name, gh.owner.login, gh.html_url, gh.description, gh.language, JSON.stringify(gh.topics ?? []), gh.created_at, gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count, now);
|
|
399
|
+
// Record first daily stats
|
|
400
|
+
const repoId = this.db.prepare('SELECT id FROM scanned_repos WHERE github_id = ?').get(gh.id).id;
|
|
401
|
+
this.recordDailyStats(repoId, gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
// Update existing
|
|
405
|
+
this.db.prepare(`
|
|
406
|
+
UPDATE scanned_repos SET
|
|
407
|
+
current_stars = ?, current_forks = ?, current_watchers = ?,
|
|
408
|
+
current_issues = ?, description = ?, language = ?,
|
|
409
|
+
topics = ?, last_scanned_at = ?
|
|
410
|
+
WHERE github_id = ?
|
|
411
|
+
`).run(gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count, gh.description, gh.language, JSON.stringify(gh.topics ?? []), now, gh.id);
|
|
412
|
+
// Record daily stats
|
|
413
|
+
const row = this.db.prepare('SELECT id FROM scanned_repos WHERE github_id = ?').get(gh.id);
|
|
414
|
+
this.recordDailyStats(row.id, gh.stargazers_count, gh.forks_count, gh.watchers_count, gh.open_issues_count);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
/** Record daily stats (UPSERT). */
|
|
418
|
+
recordDailyStats(repoId, stars, forks, watchers, issues) {
|
|
419
|
+
const today = new Date().toISOString().split('T')[0];
|
|
420
|
+
this.db.prepare(`
|
|
421
|
+
INSERT INTO repo_daily_stats (repo_id, date, stars, forks, watchers, issues)
|
|
422
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
423
|
+
ON CONFLICT(repo_id, date) DO UPDATE SET
|
|
424
|
+
stars = excluded.stars, forks = excluded.forks,
|
|
425
|
+
watchers = excluded.watchers, issues = excluded.issues
|
|
426
|
+
`).run(repoId, today, stars, forks, watchers, issues);
|
|
427
|
+
}
|
|
428
|
+
/** Calculate velocity for all active repos from daily_stats. */
|
|
429
|
+
calculateVelocities() {
|
|
430
|
+
const repos = this.db.prepare('SELECT id FROM scanned_repos WHERE is_active = 1').all();
|
|
431
|
+
const today = new Date().toISOString().split('T')[0];
|
|
432
|
+
for (const { id } of repos) {
|
|
433
|
+
const stats = this.db.prepare(`
|
|
434
|
+
SELECT date, stars FROM repo_daily_stats
|
|
435
|
+
WHERE repo_id = ? ORDER BY date DESC LIMIT 8
|
|
436
|
+
`).all(id);
|
|
437
|
+
if (stats.length < 2)
|
|
438
|
+
continue;
|
|
439
|
+
const todayStars = stats[0].stars;
|
|
440
|
+
const yesterday = stats.find(s => s.date !== today);
|
|
441
|
+
const weekAgo = stats[stats.length - 1];
|
|
442
|
+
const vel24h = yesterday ? Math.max(0, todayStars - yesterday.stars) : 0;
|
|
443
|
+
const vel7d = weekAgo ? Math.max(0, todayStars - weekAgo.stars) : 0;
|
|
444
|
+
// Acceleration: change in velocity
|
|
445
|
+
let accel = 0;
|
|
446
|
+
if (stats.length >= 3) {
|
|
447
|
+
const prevVel = stats[1].stars - stats[2].stars;
|
|
448
|
+
const curVel = stats[0].stars - stats[1].stars;
|
|
449
|
+
accel = curVel - prevVel;
|
|
450
|
+
}
|
|
451
|
+
this.db.prepare(`
|
|
452
|
+
UPDATE scanned_repos SET star_velocity_24h = ?, star_velocity_7d = ?, star_acceleration = ?
|
|
453
|
+
WHERE id = ?
|
|
454
|
+
`).run(vel24h, vel7d, accel, id);
|
|
455
|
+
// Also update today's daily_stats
|
|
456
|
+
this.db.prepare(`
|
|
457
|
+
UPDATE repo_daily_stats SET star_velocity_24h = ?, star_velocity_7d = ?, star_acceleration = ?
|
|
458
|
+
WHERE repo_id = ? AND date = ?
|
|
459
|
+
`).run(vel24h, vel7d, accel, id, today);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Process HN mentions and link to repos. */
|
|
463
|
+
processHnMentions(hits) {
|
|
464
|
+
let count = 0;
|
|
465
|
+
for (const hit of hits) {
|
|
466
|
+
const hnId = parseInt(hit.objectID, 10);
|
|
467
|
+
const existing = this.db.prepare('SELECT id FROM hn_mentions WHERE hn_id = ?').get(hnId);
|
|
468
|
+
if (existing)
|
|
469
|
+
continue;
|
|
470
|
+
// Try to match URL to a repo
|
|
471
|
+
let repoId = null;
|
|
472
|
+
if (hit.url && hit.url.includes('github.com')) {
|
|
473
|
+
const match = hit.url.match(/github\.com\/([^/]+\/[^/]+)/);
|
|
474
|
+
if (match) {
|
|
475
|
+
const repo = this.db.prepare('SELECT id FROM scanned_repos WHERE full_name = ?').get(match[1]);
|
|
476
|
+
repoId = repo?.id ?? null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
this.db.prepare(`
|
|
480
|
+
INSERT INTO hn_mentions (hn_id, title, url, score, comment_count, author, posted_at, repo_id)
|
|
481
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
482
|
+
`).run(hnId, hit.title, hit.url, hit.points, hit.num_comments, hit.author, hit.created_at, repoId);
|
|
483
|
+
count++;
|
|
484
|
+
}
|
|
485
|
+
return count;
|
|
486
|
+
}
|
|
487
|
+
/** Score all active repos. */
|
|
488
|
+
scoreAllRepos() {
|
|
489
|
+
const repos = this.db.prepare(`
|
|
490
|
+
SELECT * FROM scanned_repos WHERE is_active = 1
|
|
491
|
+
`).all();
|
|
492
|
+
for (const repo of repos) {
|
|
493
|
+
// Get HN mentions for this repo
|
|
494
|
+
const mentions = repo.id
|
|
495
|
+
? this.db.prepare('SELECT score, comment_count FROM hn_mentions WHERE repo_id = ?').all(repo.id)
|
|
496
|
+
: [];
|
|
497
|
+
const deserialized = deserializeRepo(repo);
|
|
498
|
+
const breakdown = scoreRepo(deserialized, mentions);
|
|
499
|
+
this.db.prepare(`
|
|
500
|
+
UPDATE scanned_repos SET signal_score = ? WHERE id = ?
|
|
501
|
+
`).run(breakdown.total, repo.id);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/** Classify all repos with hysteresis. Returns new breakout/signal counts. */
|
|
505
|
+
classifyAll() {
|
|
506
|
+
const repos = this.db.prepare(`
|
|
507
|
+
SELECT id, signal_score, signal_level, peak_signal_level, peak_level_since
|
|
508
|
+
FROM scanned_repos WHERE is_active = 1
|
|
509
|
+
`).all();
|
|
510
|
+
let newBreakouts = 0;
|
|
511
|
+
let newSignals = 0;
|
|
512
|
+
for (const repo of repos) {
|
|
513
|
+
const { level, peak, peakSince } = classifyWithHysteresis(repo.signal_score, repo.signal_level, repo.peak_signal_level, repo.peak_level_since);
|
|
514
|
+
if (level !== repo.signal_level) {
|
|
515
|
+
if (level === 'breakout' && repo.signal_level !== 'breakout')
|
|
516
|
+
newBreakouts++;
|
|
517
|
+
if (level === 'signal' && repo.signal_level !== 'signal' && repo.signal_level !== 'breakout')
|
|
518
|
+
newSignals++;
|
|
519
|
+
}
|
|
520
|
+
this.db.prepare(`
|
|
521
|
+
UPDATE scanned_repos SET signal_level = ?, phase = ?, peak_signal_level = ?, peak_level_since = ?
|
|
522
|
+
WHERE id = ?
|
|
523
|
+
`).run(level, this.getPhase(repo.signal_score), peak, peakSince, repo.id);
|
|
524
|
+
}
|
|
525
|
+
return { newBreakouts, newSignals };
|
|
526
|
+
}
|
|
527
|
+
getPhase(score) {
|
|
528
|
+
// Phase is based on stars, but we need the stars from DB
|
|
529
|
+
// This is a simplified version; real phase is set during scoring
|
|
530
|
+
return 'discovery';
|
|
531
|
+
}
|
|
532
|
+
/** Scan crypto tokens. */
|
|
533
|
+
async scanCrypto() {
|
|
534
|
+
let count = 0;
|
|
535
|
+
const now = new Date().toISOString();
|
|
536
|
+
// Watchlist
|
|
537
|
+
const watchlist = await this.crypto.collectWatchlist();
|
|
538
|
+
for (const coin of watchlist) {
|
|
539
|
+
this.upsertCrypto(coin, 'watchlist', now);
|
|
540
|
+
count++;
|
|
541
|
+
}
|
|
542
|
+
// Trending
|
|
543
|
+
const trending = await this.crypto.collectTrending();
|
|
544
|
+
if (trending) {
|
|
545
|
+
for (const item of trending.coins) {
|
|
546
|
+
// Trending API returns minimal data; mark as trending
|
|
547
|
+
this.db.prepare(`
|
|
548
|
+
INSERT INTO crypto_tokens (coingecko_id, symbol, name, category, market_cap_rank, last_scanned_at)
|
|
549
|
+
VALUES (?, ?, ?, 'trending', ?, ?)
|
|
550
|
+
ON CONFLICT(coingecko_id) DO UPDATE SET category = 'trending', last_scanned_at = ?
|
|
551
|
+
`).run(item.item.id, item.item.symbol, item.item.name, item.item.market_cap_rank, now, now);
|
|
552
|
+
count++;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return count;
|
|
556
|
+
}
|
|
557
|
+
upsertCrypto(coin, category, now) {
|
|
558
|
+
const { score, level } = scoreCrypto(coin.price_change_percentage_24h, coin.price_change_percentage_7d_in_currency ?? null, coin.total_volume, coin.market_cap);
|
|
559
|
+
this.db.prepare(`
|
|
560
|
+
INSERT INTO crypto_tokens (coingecko_id, symbol, name, category, current_price, market_cap, market_cap_rank, price_change_24h, price_change_7d, total_volume, signal_score, signal_level, last_scanned_at)
|
|
561
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
562
|
+
ON CONFLICT(coingecko_id) DO UPDATE SET
|
|
563
|
+
current_price = excluded.current_price, market_cap = excluded.market_cap,
|
|
564
|
+
market_cap_rank = excluded.market_cap_rank, price_change_24h = excluded.price_change_24h,
|
|
565
|
+
price_change_7d = excluded.price_change_7d, total_volume = excluded.total_volume,
|
|
566
|
+
signal_score = excluded.signal_score, signal_level = excluded.signal_level,
|
|
567
|
+
last_scanned_at = excluded.last_scanned_at
|
|
568
|
+
`).run(coin.id, coin.symbol, coin.name, category, coin.current_price, coin.market_cap, coin.market_cap_rank, coin.price_change_percentage_24h, coin.price_change_percentage_7d_in_currency ?? null, coin.total_volume, score, level, now);
|
|
569
|
+
}
|
|
570
|
+
// ── Bulk Import from Reposignal API ─────────────────────────
|
|
571
|
+
/**
|
|
572
|
+
* Import repos from the reposignal.dev API into scanned_repos.
|
|
573
|
+
* Fetches all signals from the live API and upserts them.
|
|
574
|
+
*/
|
|
575
|
+
async importFromApi(apiUrl = 'https://www.reposignal.dev/api/signals', options = {}) {
|
|
576
|
+
const start = Date.now();
|
|
577
|
+
const limit = options.limit ?? 50000;
|
|
578
|
+
let url = `${apiUrl}?limit=${limit}`;
|
|
579
|
+
if (options.level)
|
|
580
|
+
url += `&level=${options.level}`;
|
|
581
|
+
if (options.adminKey)
|
|
582
|
+
url += `&key=${options.adminKey}`;
|
|
583
|
+
log.info(`[scanner] Fetching repos from ${apiUrl} (limit=${limit})...`);
|
|
584
|
+
const res = await fetch(url);
|
|
585
|
+
if (!res.ok) {
|
|
586
|
+
throw new Error(`API request failed: ${res.status} ${res.statusText}`);
|
|
587
|
+
}
|
|
588
|
+
const data = await res.json();
|
|
589
|
+
const signals = data.signals ?? [];
|
|
590
|
+
log.info(`[scanner] Received ${signals.length} repos from API`);
|
|
591
|
+
let repos = 0, skipped = 0;
|
|
592
|
+
const insertRepo = this.db.prepare(`
|
|
593
|
+
INSERT INTO scanned_repos (
|
|
594
|
+
github_id, full_name, name, owner, url, description, language, topics,
|
|
595
|
+
created_at, first_seen_at, current_stars, current_forks, current_watchers, current_issues,
|
|
596
|
+
signal_score, signal_level, phase, peak_signal_level, peak_level_since,
|
|
597
|
+
star_velocity_24h, star_velocity_7d, star_acceleration, last_scanned_at, is_active
|
|
598
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
599
|
+
ON CONFLICT(github_id) DO UPDATE SET
|
|
600
|
+
current_stars = excluded.current_stars, current_forks = excluded.current_forks,
|
|
601
|
+
current_watchers = excluded.current_watchers, current_issues = excluded.current_issues,
|
|
602
|
+
signal_score = excluded.signal_score, signal_level = excluded.signal_level,
|
|
603
|
+
phase = excluded.phase, star_velocity_24h = excluded.star_velocity_24h,
|
|
604
|
+
star_velocity_7d = excluded.star_velocity_7d, star_acceleration = excluded.star_acceleration,
|
|
605
|
+
last_scanned_at = excluded.last_scanned_at, description = excluded.description,
|
|
606
|
+
language = excluded.language, topics = excluded.topics
|
|
607
|
+
`);
|
|
608
|
+
const importBatch = this.db.transaction(() => {
|
|
609
|
+
for (const r of signals) {
|
|
610
|
+
const ghId = r.github_id;
|
|
611
|
+
if (!ghId) {
|
|
612
|
+
skipped++;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const topics = Array.isArray(r.topics) ? JSON.stringify(r.topics) : (r.topics ?? '[]');
|
|
617
|
+
insertRepo.run(ghId, r.full_name, r.name ?? r.full_name.split('/')[1], r.owner ?? r.full_name.split('/')[0], r.url ?? `https://github.com/${r.full_name}`, r.description, r.language, topics, r.created_at, r.first_seen_at ?? new Date().toISOString(), r.current_stars ?? 0, r.current_forks ?? 0, r.current_watchers ?? 0, r.current_issues ?? 0, r.signal_score ?? 0, r.signal_level ?? 'noise', r.phase ?? 'discovery', r.peak_signal_level ?? r.signal_level, r.peak_level_since, r.star_velocity_24h ?? 0, r.star_velocity_7d ?? 0, r.star_acceleration ?? 0, r.last_scanned_at, r.is_active ?? 1);
|
|
618
|
+
repos++;
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
skipped++;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
importBatch();
|
|
626
|
+
const duration_ms = Date.now() - start;
|
|
627
|
+
log.info(`[scanner] API import complete: ${repos} repos imported, ${skipped} skipped in ${duration_ms}ms`);
|
|
628
|
+
this.saveState('api_import', JSON.stringify({ repos, skipped, duration_ms, source: apiUrl, importedAt: new Date().toISOString() }));
|
|
629
|
+
return { repos, skipped, duration_ms };
|
|
630
|
+
}
|
|
631
|
+
// ── Bulk Import from Reposignal DB ────────────────────────
|
|
632
|
+
/**
|
|
633
|
+
* Import repos from a reposignal/aisurvival SQLite database directly into scanned_repos.
|
|
634
|
+
* Copies: repositories → scanned_repos, repo_daily_stats → repo_daily_stats,
|
|
635
|
+
* hn_mentions → hn_mentions, crypto_tokens → crypto_tokens.
|
|
636
|
+
*/
|
|
637
|
+
importFromReposignal(dbPath, options = {}) {
|
|
638
|
+
const start = Date.now();
|
|
639
|
+
const minLevel = options.minLevel ?? 'noise'; // Import everything by default
|
|
640
|
+
const extDb = new Database(dbPath, { readonly: true });
|
|
641
|
+
let repos = 0, dailyStats = 0, hnMentions = 0, crypto = 0, skipped = 0;
|
|
642
|
+
try {
|
|
643
|
+
// 1. Import repositories → scanned_repos
|
|
644
|
+
const levelOrder = ['noise', 'watch', 'signal', 'breakout'];
|
|
645
|
+
const minIdx = levelOrder.indexOf(minLevel);
|
|
646
|
+
const allowed = levelOrder.filter((_, i) => i >= minIdx);
|
|
647
|
+
const placeholders = allowed.map(() => '?').join(',');
|
|
648
|
+
const extRepos = extDb.prepare(`
|
|
649
|
+
SELECT github_id, full_name, name, owner, url, description, language, topics,
|
|
650
|
+
created_at, first_seen_at, current_stars, current_forks, current_watchers, current_issues,
|
|
651
|
+
signal_score, signal_level, phase, star_velocity_24h, star_velocity_7d, star_acceleration,
|
|
652
|
+
last_scanned_at, is_active
|
|
653
|
+
FROM repositories
|
|
654
|
+
WHERE signal_level IN (${placeholders})
|
|
655
|
+
ORDER BY signal_score DESC
|
|
656
|
+
`).all(...allowed);
|
|
657
|
+
log.info(`[scanner] Importing ${extRepos.length} repos from reposignal DB...`);
|
|
658
|
+
const insertRepo = this.db.prepare(`
|
|
659
|
+
INSERT INTO scanned_repos (
|
|
660
|
+
github_id, full_name, name, owner, url, description, language, topics,
|
|
661
|
+
created_at, first_seen_at, current_stars, current_forks, current_watchers, current_issues,
|
|
662
|
+
signal_score, signal_level, phase, peak_signal_level, peak_level_since,
|
|
663
|
+
star_velocity_24h, star_velocity_7d, star_acceleration, last_scanned_at, is_active
|
|
664
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
665
|
+
ON CONFLICT(github_id) DO UPDATE SET
|
|
666
|
+
current_stars = excluded.current_stars, current_forks = excluded.current_forks,
|
|
667
|
+
current_watchers = excluded.current_watchers, current_issues = excluded.current_issues,
|
|
668
|
+
signal_score = excluded.signal_score, signal_level = excluded.signal_level,
|
|
669
|
+
phase = excluded.phase, star_velocity_24h = excluded.star_velocity_24h,
|
|
670
|
+
star_velocity_7d = excluded.star_velocity_7d, star_acceleration = excluded.star_acceleration,
|
|
671
|
+
last_scanned_at = excluded.last_scanned_at, description = excluded.description,
|
|
672
|
+
language = excluded.language, topics = excluded.topics
|
|
673
|
+
`);
|
|
674
|
+
const importRepos = this.db.transaction(() => {
|
|
675
|
+
for (const r of extRepos) {
|
|
676
|
+
try {
|
|
677
|
+
insertRepo.run(r.github_id, r.full_name, r.name, r.owner, r.url ?? `https://github.com/${r.full_name}`, r.description, r.language, r.topics ?? '[]', r.created_at, r.first_seen_at ?? new Date().toISOString(), r.current_stars ?? 0, r.current_forks ?? 0, r.current_watchers ?? 0, r.current_issues ?? 0, r.signal_score ?? 0, r.signal_level ?? 'noise', r.phase ?? 'discovery', r.signal_level, new Date().toISOString(), r.star_velocity_24h ?? 0, r.star_velocity_7d ?? 0, r.star_acceleration ?? 0, r.last_scanned_at, r.is_active ?? 1);
|
|
678
|
+
repos++;
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
skipped++;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
importRepos();
|
|
686
|
+
log.info(`[scanner] Imported ${repos} repos (${skipped} skipped)`);
|
|
687
|
+
// 2. Import repo_daily_stats (need to map repo_id from github_id)
|
|
688
|
+
try {
|
|
689
|
+
const idMap = new Map();
|
|
690
|
+
const mappings = this.db.prepare('SELECT id, github_id FROM scanned_repos').all();
|
|
691
|
+
for (const m of mappings)
|
|
692
|
+
idMap.set(m.github_id, m.id);
|
|
693
|
+
// Get external repo id → github_id mapping
|
|
694
|
+
const extIdMap = new Map();
|
|
695
|
+
const extMappings = extDb.prepare('SELECT id, github_id FROM repositories').all();
|
|
696
|
+
for (const m of extMappings)
|
|
697
|
+
extIdMap.set(m.id, m.github_id);
|
|
698
|
+
const extStats = extDb.prepare(`
|
|
699
|
+
SELECT repo_id, date, stars, forks, watchers, issues,
|
|
700
|
+
star_velocity_24h, star_velocity_7d, star_acceleration, fork_velocity_24h
|
|
701
|
+
FROM repo_daily_stats ORDER BY date DESC
|
|
702
|
+
`).all();
|
|
703
|
+
const insertStats = this.db.prepare(`
|
|
704
|
+
INSERT INTO repo_daily_stats (repo_id, date, stars, forks, watchers, issues,
|
|
705
|
+
star_velocity_24h, star_velocity_7d, star_acceleration, fork_velocity_24h)
|
|
706
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
707
|
+
ON CONFLICT(repo_id, date) DO NOTHING
|
|
708
|
+
`);
|
|
709
|
+
const importStats = this.db.transaction(() => {
|
|
710
|
+
for (const s of extStats) {
|
|
711
|
+
const ghId = extIdMap.get(s.repo_id);
|
|
712
|
+
if (!ghId)
|
|
713
|
+
continue;
|
|
714
|
+
const localId = idMap.get(ghId);
|
|
715
|
+
if (!localId)
|
|
716
|
+
continue;
|
|
717
|
+
try {
|
|
718
|
+
insertStats.run(localId, s.date, s.stars ?? 0, s.forks ?? 0, s.watchers ?? 0, s.issues ?? 0, s.star_velocity_24h ?? 0, s.star_velocity_7d ?? 0, s.star_acceleration ?? 0, s.fork_velocity_24h ?? 0);
|
|
719
|
+
dailyStats++;
|
|
720
|
+
}
|
|
721
|
+
catch { /* skip duplicates */ }
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
importStats();
|
|
725
|
+
log.info(`[scanner] Imported ${dailyStats} daily stats`);
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
log.warn(`[scanner] daily_stats import skipped: ${err.message}`);
|
|
729
|
+
}
|
|
730
|
+
// 3. Import hn_mentions
|
|
731
|
+
try {
|
|
732
|
+
const extHn = extDb.prepare(`
|
|
733
|
+
SELECT hn_id, title, url, score, comment_count, author, posted_at, repo_id
|
|
734
|
+
FROM hn_mentions ORDER BY score DESC
|
|
735
|
+
`).all();
|
|
736
|
+
const insertHn = this.db.prepare(`
|
|
737
|
+
INSERT INTO hn_mentions (hn_id, title, url, score, comment_count, author, posted_at, repo_id)
|
|
738
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
739
|
+
ON CONFLICT(hn_id) DO NOTHING
|
|
740
|
+
`);
|
|
741
|
+
// Map ext repo_id to local repo_id via github_id
|
|
742
|
+
const idMap = new Map();
|
|
743
|
+
const mappings = this.db.prepare('SELECT id, github_id FROM scanned_repos').all();
|
|
744
|
+
for (const m of mappings)
|
|
745
|
+
idMap.set(m.github_id, m.id);
|
|
746
|
+
const extIdMap = new Map();
|
|
747
|
+
try {
|
|
748
|
+
const extMappings = extDb.prepare('SELECT id, github_id FROM repositories').all();
|
|
749
|
+
for (const m of extMappings)
|
|
750
|
+
extIdMap.set(m.id, m.github_id);
|
|
751
|
+
}
|
|
752
|
+
catch { /* no repositories table */ }
|
|
753
|
+
const importHn = this.db.transaction(() => {
|
|
754
|
+
for (const h of extHn) {
|
|
755
|
+
let localRepoId = null;
|
|
756
|
+
if (h.repo_id) {
|
|
757
|
+
const ghId = extIdMap.get(h.repo_id);
|
|
758
|
+
if (ghId)
|
|
759
|
+
localRepoId = idMap.get(ghId) ?? null;
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
insertHn.run(h.hn_id, h.title, h.url, h.score ?? 0, h.comment_count ?? 0, h.author, h.posted_at, localRepoId);
|
|
763
|
+
hnMentions++;
|
|
764
|
+
}
|
|
765
|
+
catch { /* skip dupes */ }
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
importHn();
|
|
769
|
+
log.info(`[scanner] Imported ${hnMentions} HN mentions`);
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
log.warn(`[scanner] hn_mentions import skipped: ${err.message}`);
|
|
773
|
+
}
|
|
774
|
+
// 4. Import crypto_tokens
|
|
775
|
+
try {
|
|
776
|
+
const extCrypto = extDb.prepare(`
|
|
777
|
+
SELECT coingecko_id, symbol, name, category, current_price, market_cap, market_cap_rank,
|
|
778
|
+
price_change_24h, price_change_7d, total_volume, signal_score, signal_level,
|
|
779
|
+
last_scanned_at, is_active
|
|
780
|
+
FROM crypto_tokens
|
|
781
|
+
`).all();
|
|
782
|
+
const insertCrypto = this.db.prepare(`
|
|
783
|
+
INSERT INTO crypto_tokens (coingecko_id, symbol, name, category, current_price, market_cap, market_cap_rank,
|
|
784
|
+
price_change_24h, price_change_7d, total_volume, signal_score, signal_level, last_scanned_at, is_active)
|
|
785
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
786
|
+
ON CONFLICT(coingecko_id) DO UPDATE SET
|
|
787
|
+
current_price = excluded.current_price, market_cap = excluded.market_cap,
|
|
788
|
+
signal_score = excluded.signal_score, signal_level = excluded.signal_level
|
|
789
|
+
`);
|
|
790
|
+
const importCrypto = this.db.transaction(() => {
|
|
791
|
+
for (const c of extCrypto) {
|
|
792
|
+
try {
|
|
793
|
+
insertCrypto.run(c.coingecko_id, c.symbol, c.name, c.category, c.current_price, c.market_cap, c.market_cap_rank, c.price_change_24h, c.price_change_7d, c.total_volume, c.signal_score ?? 0, c.signal_level ?? 'noise', c.last_scanned_at, c.is_active ?? 1);
|
|
794
|
+
crypto++;
|
|
795
|
+
}
|
|
796
|
+
catch { /* skip */ }
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
importCrypto();
|
|
800
|
+
log.info(`[scanner] Imported ${crypto} crypto tokens`);
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
log.warn(`[scanner] crypto_tokens import skipped: ${err.message}`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
finally {
|
|
807
|
+
extDb.close();
|
|
808
|
+
}
|
|
809
|
+
const duration_ms = Date.now() - start;
|
|
810
|
+
log.info(`[scanner] Reposignal import complete: ${repos} repos, ${dailyStats} stats, ${hnMentions} HN, ${crypto} crypto in ${duration_ms}ms`);
|
|
811
|
+
// Save import state
|
|
812
|
+
this.saveState('reposignal_import', JSON.stringify({ repos, dailyStats, hnMentions, crypto, skipped, duration_ms, importedAt: new Date().toISOString() }));
|
|
813
|
+
return { repos, dailyStats, hnMentions, crypto, skipped, duration_ms };
|
|
814
|
+
}
|
|
815
|
+
// ── State Persistence ────────────────────────────────────
|
|
816
|
+
saveState(key, value) {
|
|
817
|
+
this.db.prepare(`
|
|
818
|
+
INSERT INTO scanner_state (key, value, updated_at)
|
|
819
|
+
VALUES (?, ?, datetime('now'))
|
|
820
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')
|
|
821
|
+
`).run(key, value);
|
|
822
|
+
}
|
|
823
|
+
loadLastResult() {
|
|
824
|
+
const row = this.db.prepare('SELECT value FROM scanner_state WHERE key = ?').get('last_scan');
|
|
825
|
+
if (row) {
|
|
826
|
+
try {
|
|
827
|
+
this.lastResult = JSON.parse(row.value);
|
|
828
|
+
}
|
|
829
|
+
catch { /* ignore */ }
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
// ── Helpers ─────────────────────────────────────────────────
|
|
834
|
+
function dedupeRepos(repos) {
|
|
835
|
+
const seen = new Set();
|
|
836
|
+
return repos.filter(r => {
|
|
837
|
+
if (seen.has(r.id))
|
|
838
|
+
return false;
|
|
839
|
+
seen.add(r.id);
|
|
840
|
+
return true;
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function deserializeRepo(row) {
|
|
844
|
+
return {
|
|
845
|
+
...row,
|
|
846
|
+
topics: typeof row.topics === 'string' ? JSON.parse(row.topics) : (row.topics ?? []),
|
|
847
|
+
is_active: Boolean(row.is_active),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function createEmptyResult() {
|
|
851
|
+
const now = new Date().toISOString();
|
|
852
|
+
return {
|
|
853
|
+
started_at: now, finished_at: now, duration_ms: 0,
|
|
854
|
+
repos_discovered: 0, repos_updated: 0, new_breakouts: 0,
|
|
855
|
+
new_signals: 0, hn_mentions_found: 0, crypto_tokens_scanned: 0,
|
|
856
|
+
errors: ['Scan already in progress'],
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
//# sourceMappingURL=signal-scanner.js.map
|