Files
hermes-agent-mission-control/scanner/outlier-scanner.js
T
sharbelxyzandClaude Opus 4.8 b463027468 Hermy HQ: self-hostable mission-control template for your Hermes agent
A Next.js 16 + Prisma/Postgres dashboard that pairs with a local Hermes
agent over a Postgres message bus: dispatch work, approve side-effecting
actions, browse the agent's memory, and watch it run. Ships with an
agent-onboarding prompt (ONBOARDING.md) so your Hermes can install it for
you step by step. All secrets are env-configured; nothing sensitive is
bundled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:33:24 +02:00

326 lines
14 KiB
JavaScript

const https = require('https');
const fs = require('fs');
const path = require('path');
// ── Config ──────────────────────────────────────────────────────────────────────
const NICHES = {
'openclaw': { label: '🦞 OpenClaw & AI Agents', queries: ['OpenClaw tutorial', 'build AI agents OpenClaw', 'run AI agents locally 2026'] },
'polymarket-trading': { label: '📈 Polymarket & Trading Bots', queries: ['Polymarket trading bot', 'prediction market bot', 'AI trading bot build 2026'] },
'ai-agent-build': { label: '🤖 AI Agent Builds', queries: ['build AI agent no code', 'AI automation agent tutorial', 'how to build AI employees'] },
'agency-growth': { label: '🏢 Agency & Clients', queries: ['marketing agency growth', 'how to grow a marketing agency', 'agency owner content creator'] },
'ai-tools': { label: '🛠️ AI for Creators', queries: ['AI tools content creators', 'AI automation marketing', 'AI replace marketing team'] },
};
const MAX_CHANNEL_SUBS = 1000000;
const MIN_OUTLIER_SCORE = 1.5;
const DAYS_AGO = 30;
const MAX_RESULTS = 15;
// ── Load API key from .env.local ────────────────────────────────────────────────
function loadApiKey() {
const envPath = path.join(__dirname, '..', '.env.local');
if (!fs.existsSync(envPath)) {
console.error('ERROR: .env.local not found at', envPath);
process.exit(1);
}
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('YOUTUBE_API_KEY=')) {
const val = trimmed.slice('YOUTUBE_API_KEY='.length).trim();
return val.replace(/^["']|["']$/g, '');
}
}
console.error('ERROR: YOUTUBE_API_KEY not found in .env.local');
process.exit(1);
}
// Add one or more YouTube Data API keys here (or via YOUTUBE_API_KEY in .env.local).
// Multiple keys are rotated when quota is hit.
const API_KEYS = [loadApiKey()].filter(Boolean);
let currentKeyIndex = 0;
function getApiKey() { return API_KEYS[currentKeyIndex]; }
function rotateKey() {
currentKeyIndex = (currentKeyIndex + 1) % API_KEYS.length;
console.log(` ⚡ Rotated to API key ${currentKeyIndex + 1}`);
}
// ── HTTP helper ─────────────────────────────────────────────────────────────────
function fetchJSON(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { reject(new Error('JSON parse error: ' + data.slice(0, 200))); }
});
}).on('error', reject);
});
}
// ── ISO 8601 duration parser ────────────────────────────────────────────────────
function parseDuration(iso) {
if (!iso) return 0;
const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return 0;
const h = parseInt(match[1] || '0', 10);
const m = parseInt(match[2] || '0', 10);
const s = parseInt(match[3] || '0', 10);
return h * 3600 + m * 60 + s;
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function median(arr) {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : Math.floor((sorted[mid - 1] + sorted[mid]) / 2);
}
// ── YouTube API calls ───────────────────────────────────────────────────────────
async function searchVideos(query, publishedAfter) {
const url = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&order=relevance&publishedAfter=${publishedAfter}&maxResults=${MAX_RESULTS}&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error) {
if (data.error.message && data.error.message.includes('quota')) {
rotateKey();
const url2 = `https://www.googleapis.com/youtube/v3/search?part=snippet&q=${encodeURIComponent(query)}&type=video&order=relevance&publishedAfter=${publishedAfter}&maxResults=${MAX_RESULTS}&key=${getApiKey()}`;
const data2 = await fetchJSON(url2);
if (data2.error) { console.error(' Search API error (both keys exhausted):', data2.error.message); return []; }
return (data2.items || []).map((item) => ({ id: item.id.videoId, title: item.snippet.title, publishedAt: item.snippet.publishedAt, channelId: item.snippet.channelId, channelTitle: item.snippet.channelTitle, thumbnail: item.snippet.thumbnails.high ? item.snippet.thumbnails.high.url : item.snippet.thumbnails.default.url }));
}
console.error(' Search API error:', data.error.message);
return [];
}
return (data.items || []).map((item) => ({
id: item.id.videoId,
title: item.snippet.title,
publishedAt: item.snippet.publishedAt,
channelId: item.snippet.channelId,
channelTitle: item.snippet.channelTitle,
thumbnail: item.snippet.thumbnails.high ? item.snippet.thumbnails.high.url : item.snippet.thumbnails.default.url,
}));
}
async function getVideoDetails(videoIds) {
if (videoIds.length === 0) return {};
const url = `https://www.googleapis.com/youtube/v3/videos?part=statistics,contentDetails&id=${videoIds.join(',')}&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error) {
console.error(' Video details API error:', data.error.message);
return {};
}
const map = {};
for (const item of (data.items || [])) {
const dur = parseDuration(item.contentDetails.duration);
map[item.id] = {
views: parseInt(item.statistics.viewCount || '0', 10),
likes: parseInt(item.statistics.likeCount || '0', 10),
comments: parseInt(item.statistics.commentCount || '0', 10),
duration: formatDuration(dur),
durationSeconds: dur,
};
}
return map;
}
async function getChannelInfo(channelId) {
const url = `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet,contentDetails&id=${channelId}&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error || !data.items || data.items.length === 0) return null;
const ch = data.items[0];
return {
subs: parseInt(ch.statistics.subscriberCount || '0', 10),
uploadsPlaylistId: ch.contentDetails.relatedPlaylists.uploads,
thumb: ch.snippet.thumbnails.default ? ch.snippet.thumbnails.default.url : '',
};
}
async function getChannelMedianViews(uploadsPlaylistId) {
const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&playlistId=${uploadsPlaylistId}&maxResults=30&key=${getApiKey()}`;
const data = await fetchJSON(url);
if (data.error || !data.items || data.items.length === 0) return 0;
const videoIds = data.items.map((i) => i.contentDetails.videoId);
// Batch in groups of 50 (API limit)
const allViews = [];
for (let i = 0; i < videoIds.length; i += 50) {
const batch = videoIds.slice(i, i + 50);
const vUrl = `https://www.googleapis.com/youtube/v3/videos?part=statistics&id=${batch.join(',')}&key=${getApiKey()}`;
const vData = await fetchJSON(vUrl);
if (vData.items) {
for (const v of vData.items) {
allViews.push(parseInt(v.statistics.viewCount || '0', 10));
}
}
}
return median(allViews);
}
// ── Main scanner ────────────────────────────────────────────────────────────────
async function main() {
console.log('=== YouTube Outlier Scanner ===');
console.log(`Scanning ${Object.keys(NICHES).length} niches, ${Object.values(NICHES).reduce((s, n) => s + n.queries.length, 0)} queries total\n`);
const publishedAfter = new Date(Date.now() - DAYS_AGO * 24 * 60 * 60 * 1000).toISOString();
const seenIds = new Set();
const allVideos = [];
const nicheCounts = {};
const channelCache = {}; // channelId -> { subs, medianViews, thumb }
for (const [nicheKey, niche] of Object.entries(NICHES)) {
console.log(`\n── ${niche.label} ──`);
nicheCounts[nicheKey] = { label: niche.label, count: 0 };
for (const query of niche.queries) {
console.log(` Searching: "${query}"`);
const results = await searchVideos(query, publishedAfter);
if (results.length === 0) { console.log(' No results'); continue; }
// Filter already-seen
const fresh = results.filter((v) => !seenIds.has(v.id));
if (fresh.length === 0) { console.log(' All duplicates, skipping'); continue; }
// Get video details
const details = await getVideoDetails(fresh.map((v) => v.id));
for (const video of fresh) {
if (seenIds.has(video.id)) continue;
const det = details[video.id];
if (!det) continue;
// Skip Shorts (<=90s)
if (det.durationSeconds <= 90) continue;
// Get/cache channel info
if (!channelCache[video.channelId]) {
const chInfo = await getChannelInfo(video.channelId);
if (!chInfo) continue;
if (chInfo.subs > MAX_CHANNEL_SUBS) {
channelCache[video.channelId] = { subs: chInfo.subs, skip: true };
continue;
}
const medViews = await getChannelMedianViews(chInfo.uploadsPlaylistId);
channelCache[video.channelId] = { subs: chInfo.subs, medianViews: medViews, thumb: chInfo.thumb, skip: false };
}
const ch = channelCache[video.channelId];
if (ch.skip) continue;
if (ch.medianViews === 0) continue;
const outlierScore = Math.round((det.views / ch.medianViews) * 10) / 10;
if (outlierScore < MIN_OUTLIER_SCORE) continue;
const daysSincePublish = Math.max(1, Math.floor((Date.now() - new Date(video.publishedAt).getTime()) / (1000 * 60 * 60 * 24)));
const velocityScore = Math.round(det.views / daysSincePublish); // views per day
seenIds.add(video.id);
nicheCounts[nicheKey].count++;
allVideos.push({
id: video.id,
title: video.title,
publishedAt: video.publishedAt,
thumbnail: video.thumbnail,
views: det.views,
likes: det.likes,
comments: det.comments,
duration: det.duration,
durationSeconds: det.durationSeconds,
outlierScore,
velocityScore,
daysSincePublish,
niche: nicheKey,
nicheLabel: niche.label,
query,
channelId: video.channelId,
channelTitle: video.channelTitle,
channelSubs: ch.subs,
channelMedianViews: ch.medianViews,
channelThumb: ch.thumb,
});
console.log(` ✓ ${outlierScore}x | ${det.views.toLocaleString()} views | ${video.title.slice(0, 60)}`);
}
}
}
// Sort by outlierScore descending
allVideos.sort((a, b) => b.outlierScore - a.outlierScore);
const now = new Date();
const dateStr = now.toISOString().slice(0, 10);
const output = {
date: dateStr,
scannedAt: now.toISOString(),
totalOutliers: allVideos.length,
nicheCounts,
videos: allVideos,
};
// Save to data/
const dataDir = path.join(__dirname, '..', 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const datePath = path.join(dataDir, `${dateStr}.json`);
const latestPath = path.join(dataDir, 'latest.json');
fs.writeFileSync(datePath, JSON.stringify(output, null, 2));
fs.writeFileSync(latestPath, JSON.stringify(output, null, 2));
console.log(`\n=== Done ===`);
console.log(`Total outliers: ${allVideos.length}`);
console.log(`Saved to: ${datePath}`);
console.log(`Saved to: ${latestPath}`);
for (const [k, v] of Object.entries(nicheCounts)) {
console.log(` ${v.label}: ${v.count}`);
}
// ── Sync to Prisma DB ───────────────────────────────────────────────────────
console.log('\n── Syncing to database…');
try {
const { Client } = require('pg');
// Try .env.production.local first, then .env.local
let dbUrl = '';
for (const envFile of ['.env.production.local', '.env.local']) {
const envPath = path.join(__dirname, '..', envFile);
if (!fs.existsSync(envPath)) continue;
const envLines = fs.readFileSync(envPath, 'utf-8').split('\n');
for (const line of envLines) {
const t = line.trim();
if (t.startsWith('DATABASE_URL=')) {
dbUrl = t.slice('DATABASE_URL='.length).trim().replace(/^["']|["']$/g, '');
break;
}
}
if (dbUrl) break;
}
const client = new Client({ connectionString: dbUrl, ssl: { rejectUnauthorized: false } });
await client.connect();
const dataJson = JSON.stringify(output);
await client.query(
`INSERT INTO "DataStore" (key, data, "updatedAt")
VALUES ($1, $2::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET data = $2::jsonb, "updatedAt" = NOW()`,
['youtube-outliers', dataJson]
);
await client.end();
console.log('✓ Database synced successfully');
} catch (err) {
console.error('✗ DB sync failed:', err.message);
console.error(' (Local files still saved — run sync manually)');
}
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});