Skip to content

Commit 9c6287f

Browse files
contentcontent
andcommitted
feat: restore ideation cron route (lost during merge)
Restores app/api/cron/ingest/route.ts and adds cron entry to vercel.json. This file was lost during the multi-branch merge process. Co-authored-by: content <content@miriad.systems>
1 parent 0150cea commit 9c6287f

File tree

2 files changed

+393
-8
lines changed

2 files changed

+393
-8
lines changed

app/api/cron/ingest/route.ts

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
export const fetchCache = "force-no-store";
2+
3+
import type { NextRequest } from "next/server";
4+
5+
import { generateWithGemini } from "@/lib/gemini";
6+
import { writeClient } from "@/lib/sanity-write-client";
7+
8+
// ---------------------------------------------------------------------------
9+
// Types
10+
// ---------------------------------------------------------------------------
11+
12+
interface RSSItem {
13+
title: string;
14+
url: string;
15+
}
16+
17+
interface ScriptScene {
18+
sceneNumber: number;
19+
narration: string;
20+
visualDescription: string;
21+
bRollKeywords: string[];
22+
durationEstimate: number;
23+
}
24+
25+
interface GeneratedScript {
26+
title: string;
27+
summary: string;
28+
sourceUrl: string;
29+
topics: string[];
30+
script: {
31+
hook: string;
32+
scenes: ScriptScene[];
33+
cta: string;
34+
};
35+
qualityScore: number;
36+
}
37+
38+
interface CriticResult {
39+
score: number;
40+
issues: string[];
41+
summary: string;
42+
}
43+
44+
// ---------------------------------------------------------------------------
45+
// RSS Feed Helpers
46+
// ---------------------------------------------------------------------------
47+
48+
const RSS_FEEDS = [
49+
"https://hnrss.org/newest?q=javascript+OR+react+OR+nextjs+OR+typescript&points=50",
50+
"https://dev.to/feed/tag/webdev",
51+
];
52+
53+
const FALLBACK_TOPICS: RSSItem[] = [
54+
{
55+
title: "React Server Components: The Future of Web Development",
56+
url: "https://react.dev/blog",
57+
},
58+
{
59+
title: "TypeScript 5.x: New Features Every Developer Should Know",
60+
url: "https://devblogs.microsoft.com/typescript/",
61+
},
62+
{
63+
title: "Next.js App Router Best Practices for 2025",
64+
url: "https://nextjs.org/blog",
65+
},
66+
{
67+
title: "The State of CSS in 2025: Container Queries, Layers, and More",
68+
url: "https://web.dev/blog",
69+
},
70+
{
71+
title: "WebAssembly is Changing How We Build Web Apps",
72+
url: "https://webassembly.org/",
73+
},
74+
];
75+
76+
function extractRSSItems(xml: string): RSSItem[] {
77+
const items: RSSItem[] = [];
78+
const itemRegex = /<item>([\s\S]*?)<\/item>/gi;
79+
let itemMatch: RegExpExecArray | null;
80+
81+
while ((itemMatch = itemRegex.exec(xml)) !== null) {
82+
const block = itemMatch[1];
83+
84+
const titleMatch = block.match(/<title><!\[CDATA\[(.*?)\]\]><\/title>/);
85+
const titleAlt = block.match(/<title>(.*?)<\/title>/);
86+
const title = titleMatch?.[1] ?? titleAlt?.[1] ?? "";
87+
88+
const linkMatch = block.match(/<link>(.*?)<\/link>/);
89+
const url = linkMatch?.[1] ?? "";
90+
91+
if (title && url) {
92+
items.push({ title: title.trim(), url: url.trim() });
93+
}
94+
}
95+
96+
return items;
97+
}
98+
99+
async function fetchTrendingTopics(): Promise<RSSItem[]> {
100+
const allItems: RSSItem[] = [];
101+
102+
const results = await Promise.allSettled(
103+
RSS_FEEDS.map(async (feedUrl) => {
104+
const controller = new AbortController();
105+
const timeout = setTimeout(() => controller.abort(), 10_000);
106+
try {
107+
const res = await fetch(feedUrl, { signal: controller.signal });
108+
if (!res.ok) {
109+
console.warn(
110+
`[CRON/ingest] RSS fetch failed for ${feedUrl}: ${res.status}`,
111+
);
112+
return [];
113+
}
114+
const xml = await res.text();
115+
return extractRSSItems(xml);
116+
} finally {
117+
clearTimeout(timeout);
118+
}
119+
}),
120+
);
121+
122+
for (const result of results) {
123+
if (result.status === "fulfilled") {
124+
allItems.push(...result.value);
125+
} else {
126+
console.warn("[CRON/ingest] RSS feed error:", result.reason);
127+
}
128+
}
129+
130+
const seen = new Set<string>();
131+
const unique = allItems.filter((item) => {
132+
const key = item.title.toLowerCase();
133+
if (seen.has(key)) return false;
134+
seen.add(key);
135+
return true;
136+
});
137+
138+
if (unique.length === 0) {
139+
console.warn("[CRON/ingest] No RSS items fetched, using fallback topics");
140+
return FALLBACK_TOPICS;
141+
}
142+
143+
return unique.slice(0, 10);
144+
}
145+
146+
// ---------------------------------------------------------------------------
147+
// Gemini Script Generation
148+
// ---------------------------------------------------------------------------
149+
150+
const SYSTEM_INSTRUCTION =
151+
"You are a content strategist for CodingCat.dev, a web development education channel. You create engaging, Cleo Abram-style explainer video scripts that are educational, energetic, and concise (60-90 seconds).";
152+
153+
function buildPrompt(topics: RSSItem[]): string {
154+
const topicList = topics
155+
.map((t, i) => `${i + 1}. "${t.title}" — ${t.url}`)
156+
.join("\n");
157+
158+
return `Here are today's trending web development topics:
159+
160+
${topicList}
161+
162+
Pick the MOST interesting and timely topic for a short explainer video (60-90 seconds). Then generate a complete video script as JSON matching this exact schema:
163+
164+
{
165+
"title": "string - catchy video title",
166+
"summary": "string - 1-2 sentence summary of what the video covers",
167+
"sourceUrl": "string - URL of the source article/trend you picked",
168+
"topics": ["string array of relevant tags, e.g. react, nextjs, typescript"],
169+
"script": {
170+
"hook": "string - attention-grabbing opening line (5-10 seconds)",
171+
"scenes": [
172+
{
173+
"sceneNumber": 1,
174+
"narration": "string - what the narrator says",
175+
"visualDescription": "string - what to show on screen",
176+
"bRollKeywords": ["keyword1", "keyword2"],
177+
"durationEstimate": 15
178+
}
179+
],
180+
"cta": "string - call to action (subscribe, check link, etc.)"
181+
},
182+
"qualityScore": 75
183+
}
184+
185+
Requirements:
186+
- The script should have 3-5 scenes totaling 60-90 seconds
187+
- The hook should be punchy and curiosity-driven
188+
- Each scene should have clear visual direction
189+
- The qualityScore should be your honest self-assessment (0-100)
190+
- Return ONLY the JSON object, no markdown or extra text`;
191+
}
192+
193+
// ---------------------------------------------------------------------------
194+
// Optional Claude Critic
195+
// ---------------------------------------------------------------------------
196+
197+
async function claudeCritic(script: GeneratedScript): Promise<CriticResult> {
198+
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
199+
if (!ANTHROPIC_API_KEY) {
200+
return { score: script.qualityScore, issues: [], summary: "No critic available" };
201+
}
202+
203+
try {
204+
const res = await fetch("https://api.anthropic.com/v1/messages", {
205+
method: "POST",
206+
headers: {
207+
"Content-Type": "application/json",
208+
"x-api-key": ANTHROPIC_API_KEY,
209+
"anthropic-version": "2023-06-01",
210+
},
211+
body: JSON.stringify({
212+
model: "claude-sonnet-4-20250514",
213+
max_tokens: 1024,
214+
messages: [
215+
{
216+
role: "user",
217+
content: `You are a quality reviewer for short-form educational video scripts about web development.
218+
219+
Review this video script and provide a JSON response with:
220+
- "score": number 0-100 (overall quality rating)
221+
- "issues": string[] (list of specific problems, if any)
222+
- "summary": string (brief overall assessment)
223+
224+
Evaluate based on:
225+
1. Educational value — does it teach something useful?
226+
2. Engagement — is the hook compelling? Is the pacing good?
227+
3. Accuracy — are there any technical inaccuracies?
228+
4. Clarity — is the narration clear and concise?
229+
5. Visual direction — are the visual descriptions actionable?
230+
231+
Script to review:
232+
${JSON.stringify(script, null, 2)}
233+
234+
Respond with ONLY the JSON object.`,
235+
},
236+
],
237+
}),
238+
});
239+
240+
if (!res.ok) {
241+
console.warn(`[CRON/ingest] Claude critic failed: ${res.status}`);
242+
return { score: script.qualityScore, issues: [], summary: "Critic API error" };
243+
}
244+
245+
const data = await res.json();
246+
const text = data.content?.[0]?.text ?? "{}";
247+
248+
const jsonMatch = text.match(/\{[\s\S]*\}/);
249+
if (!jsonMatch) {
250+
return { score: script.qualityScore, issues: [], summary: "Could not parse critic response" };
251+
}
252+
253+
const parsed = JSON.parse(jsonMatch[0]) as CriticResult;
254+
return {
255+
score: typeof parsed.score === "number" ? parsed.score : script.qualityScore,
256+
issues: Array.isArray(parsed.issues) ? parsed.issues : [],
257+
summary: typeof parsed.summary === "string" ? parsed.summary : "No summary",
258+
};
259+
} catch (err) {
260+
console.warn("[CRON/ingest] Claude critic error:", err);
261+
return { score: script.qualityScore, issues: [], summary: "Critic error" };
262+
}
263+
}
264+
265+
// ---------------------------------------------------------------------------
266+
// Sanity Document Creation
267+
// ---------------------------------------------------------------------------
268+
269+
async function createSanityDocuments(
270+
script: GeneratedScript,
271+
criticResult: CriticResult,
272+
) {
273+
const isFlagged = criticResult.score < 50;
274+
275+
const contentIdea = await writeClient.create({
276+
_type: "contentIdea",
277+
title: script.title,
278+
summary: script.summary,
279+
sourceUrl: script.sourceUrl,
280+
topics: script.topics,
281+
collectedAt: new Date().toISOString(),
282+
status: "approved",
283+
});
284+
285+
console.log(`[CRON/ingest] Created contentIdea: ${contentIdea._id}`);
286+
287+
const automatedVideo = await writeClient.create({
288+
_type: "automatedVideo",
289+
title: script.title,
290+
slug: { _type: "slug", current: script.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") },
291+
contentIdea: {
292+
_type: "reference",
293+
_ref: contentIdea._id,
294+
},
295+
script: script.script,
296+
scriptQualityScore: criticResult.score,
297+
status: isFlagged ? "flagged" : "script_ready",
298+
...(isFlagged && {
299+
flaggedReason: `Quality score ${criticResult.score}/100. Issues: ${criticResult.issues.join("; ") || "Low quality score"}`,
300+
}),
301+
});
302+
303+
console.log(`[CRON/ingest] Created automatedVideo: ${automatedVideo._id}`);
304+
305+
return {
306+
contentIdeaId: contentIdea._id,
307+
automatedVideoId: automatedVideo._id,
308+
status: isFlagged ? "flagged" : "script_ready",
309+
};
310+
}
311+
312+
// ---------------------------------------------------------------------------
313+
// Route Handler
314+
// ---------------------------------------------------------------------------
315+
316+
export async function GET(request: NextRequest) {
317+
const authHeader = request.headers.get("authorization");
318+
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
319+
console.error(
320+
"[CRON/ingest] Unauthorized request: invalid authorization header",
321+
);
322+
return Response.json({ error: "Unauthorized" }, { status: 401 });
323+
}
324+
325+
try {
326+
console.log("[CRON/ingest] Fetching trending topics...");
327+
const topics = await fetchTrendingTopics();
328+
console.log(`[CRON/ingest] Found ${topics.length} topics`);
329+
330+
console.log("[CRON/ingest] Generating script with Gemini...");
331+
const prompt = buildPrompt(topics);
332+
const rawResponse = await generateWithGemini(prompt, SYSTEM_INSTRUCTION);
333+
334+
let script: GeneratedScript;
335+
try {
336+
script = JSON.parse(rawResponse) as GeneratedScript;
337+
} catch (parseErr) {
338+
console.error(
339+
"[CRON/ingest] Failed to parse Gemini response:",
340+
rawResponse,
341+
);
342+
return Response.json(
343+
{
344+
error: "Failed to parse Gemini response",
345+
raw: rawResponse.slice(0, 500),
346+
},
347+
{ status: 502 },
348+
);
349+
}
350+
351+
console.log(`[CRON/ingest] Generated script: "${script.title}"`);
352+
353+
console.log("[CRON/ingest] Running critic pass...");
354+
const criticResult = await claudeCritic(script);
355+
console.log(
356+
`[CRON/ingest] Critic score: ${criticResult.score}/100 — ${criticResult.summary}`,
357+
);
358+
359+
console.log("[CRON/ingest] Creating Sanity documents...");
360+
const result = await createSanityDocuments(script, criticResult);
361+
362+
console.log("[CRON/ingest] Done!", result);
363+
364+
return Response.json({
365+
success: true,
366+
...result,
367+
title: script.title,
368+
criticScore: criticResult.score,
369+
topicCount: topics.length,
370+
});
371+
} catch (err) {
372+
console.error("[CRON/ingest] Unexpected error:", err);
373+
return Response.json(
374+
{
375+
success: false,
376+
error: err instanceof Error ? err.message : String(err),
377+
},
378+
{ status: 500 },
379+
);
380+
}
381+
}

0 commit comments

Comments
 (0)