Files
hermes-agent-mission-control/src/app/api/scrape-metrics/route.ts
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

179 lines
6.7 KiB
TypeScript

export const dynamic = "force-dynamic";
export const maxDuration = 60;
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
async function getYouTubeMetrics(url: string) {
try {
const match = url.match(/(?:v=|youtu\.be\/|\/shorts\/)([a-zA-Z0-9_-]{11})/);
if (!match) return null;
const videoId = match[1];
// Use YouTube Data API v3 if key available (most reliable)
const ytKey = process.env.YOUTUBE_API_KEY;
if (ytKey) {
const apiRes = await fetch(
`https://www.googleapis.com/youtube/v3/videos?id=${videoId}&part=snippet,statistics&key=${ytKey}`,
{ cache: "no-store" }
);
if (apiRes.ok) {
const data = await apiRes.json();
const item = data.items?.[0];
if (item) {
return {
title: item.snippet?.title || "",
thumbnail: item.snippet?.thumbnails?.maxres?.url ||
item.snippet?.thumbnails?.high?.url ||
`https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
viewCount: parseInt(item.statistics?.viewCount || "0"),
likeCount: parseInt(item.statistics?.likeCount || "0"),
commentCount: parseInt(item.statistics?.commentCount || "0"),
};
}
}
}
// Fallback: oEmbed for title/thumbnail + noembed for basic info
let title = "";
let thumbnail = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
let viewCount = 0;
let likeCount = 0;
try {
const oembed = await fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`,
{ cache: "no-store" }
);
if (oembed.ok) {
const d = await oembed.json();
title = d.title || "";
thumbnail = d.thumbnail_url || thumbnail;
}
} catch { /* ok */ }
// Try to get view count from YouTube page (parse simple patterns)
try {
const pageRes = await fetch(
`https://www.youtube.com/watch?v=${videoId}`,
{
headers: { "User-Agent": "Mozilla/5.0 (compatible; Googlebot/2.1)" },
cache: "no-store",
}
);
if (pageRes.ok) {
const html = await pageRes.text();
// View count appears as "viewCount\":\"12345\"
const vcMatch = html.match(/"viewCount"\s*:\s*"(\d+)"/);
if (vcMatch) viewCount = parseInt(vcMatch[1]);
// Like count
const lcMatch = html.match(/"likeCount"\s*:\s*"(\d+)"/);
if (lcMatch) likeCount = parseInt(lcMatch[1]);
// Title fallback
if (!title) {
const tMatch = html.match(/<title>([^<]+)<\/title>/);
if (tMatch) title = tMatch[1].replace(" - YouTube", "").trim();
}
}
} catch { /* ok */ }
return { title, thumbnail, viewCount, likeCount, commentCount: 0 };
} catch (e) {
console.error("YouTube scrape error:", e);
return null;
}
}
async function getTwitterMetrics(tweetUrl: string) {
const xaiKey = process.env.XAI_API_KEY;
if (!xaiKey) { console.error("[scrape-metrics] XAI_API_KEY not set"); return null; }
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 25000);
try {
console.log("[scrape-metrics] Calling xAI for:", tweetUrl);
const res = await fetch("https://api.x.ai/v1/responses", {
method: "POST",
headers: { "Authorization": `Bearer ${xaiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model: "grok-4-1-fast-non-reasoning",
tools: [{ type: "x_search" }],
input: [{
role: "user",
content: `Fetch the engagement stats for this specific tweet ID: ${tweetUrl}\nReturn ONLY this JSON (no markdown, no extra text): {"views":0,"likes":0,"retweets":0,"bookmarks":0,"replies":0}`
}]
}),
signal: controller.signal,
});
clearTimeout(timeout);
console.log("[scrape-metrics] xAI status:", res.status);
if (!res.ok) {
const errText = await res.text();
console.error("[scrape-metrics] xAI error:", errText.slice(0, 300));
return null;
}
const data = await res.json();
const text = data.output?.find((o: { type: string }) => o.type === "message")
?.content?.find((c: { type: string }) => c.type === "output_text")?.text || "";
console.log("[scrape-metrics] xAI response text:", text.slice(0, 300));
const jsonMatch = text.match(/\{[^{}]*\}/);
if (!jsonMatch) { console.error("[scrape-metrics] No JSON in response:", text.slice(0, 300)); return null; }
const parsed = JSON.parse(jsonMatch[0]);
console.log("[scrape-metrics] Parsed twitter metrics:", parsed);
return parsed;
} catch (e: any) {
clearTimeout(timeout);
console.error("[scrape-metrics] xAI fetch error:", e?.name, e?.message);
return null;
}
}
export async function POST(req: NextRequest) {
const { youtubeUrl, tweetUrl, scriptId } = await req.json();
const result: any = {};
// Run both in parallel
const [yt, twitter] = await Promise.all([
youtubeUrl ? getYouTubeMetrics(youtubeUrl) : Promise.resolve(null),
tweetUrl ? getTwitterMetrics(tweetUrl) : Promise.resolve(null),
]);
if (yt) result.youtube = yt;
if (twitter) result.twitter = twitter;
// If scriptId provided, save directly to DB — no client-side PATCH needed
if (scriptId && (yt || twitter)) {
try {
const updates: Record<string, unknown> = {};
if (yt) {
if (yt.title) updates.title = yt.title;
if (yt.thumbnail) updates.thumbnailUrl = yt.thumbnail;
if (yt.viewCount != null) updates.youtubeViews = yt.viewCount;
if (yt.likeCount != null) updates.youtubeLikes = yt.likeCount;
if (yt.commentCount != null) updates.youtubeComments = yt.commentCount;
}
if (twitter) {
if (twitter.views != null) updates.tweetViews = twitter.views;
if (twitter.likes != null) updates.tweetLikes = twitter.likes;
if (twitter.bookmarks != null) updates.tweetBookmarks = twitter.bookmarks;
if (twitter.retweets != null) updates.tweetRetweets = twitter.retweets;
if (twitter.replies != null) updates.tweetReplies = twitter.replies;
}
if (Object.keys(updates).length > 0) {
const saved = await prisma.longformScript.update({ where: { id: scriptId }, data: updates });
result.saved = true;
// Return the updated fields in result so client can update UI
result.updatedScript = saved;
console.log("[scrape-metrics] Saved to DB:", scriptId, Object.keys(updates));
}
} catch (e) {
console.error("[scrape-metrics] DB save error:", e);
result.saveError = String(e);
}
}
return NextResponse.json(result, {
headers: { "Cache-Control": "no-store" },
});
}