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>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user