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>
326 lines
14 KiB
JavaScript
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);
|
|
});
|