scrape / lib /social-copy.ts
kjbytes's picture
push code
ef4c36f
Raw
History Blame Contribute Delete
4.44 kB
import { callLlm } from "@/lib/llm";
import type { GitHubRepoData } from "@/types";
/** Exactly the fields the webhook consumer expects (snake_case, flat). */
export interface SocialCopy {
instagram_caption: string;
youtube_caption: string;
hashtags: string[];
}
function normalizeTags(tags: unknown, repo: GitHubRepoData): string[] {
const list = Array.isArray(tags) ? tags : [];
const cleaned = list
.filter((t): t is string => typeof t === "string")
.map((t) => `#${t.trim().replace(/^#+/, "").replace(/\s+/g, "")}`)
.filter((t) => t.length > 1);
// ponytail: dedupe case-insensitively — "#GitHub" and "#github" read as spam repeats
const seen = new Set<string>();
const unique = cleaned.filter((t) => {
const key = t.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
if (unique.length) return unique;
// fallback from repo metadata so the webhook always carries usable tags
return [
"#GitHub",
"#OpenSource",
"#Coding",
...(repo.language ? [`#${repo.language.replace(/\s+/g, "")}`] : []),
...repo.topics.slice(0, 3).map((t) => `#${t.replace(/[^a-z0-9]/gi, "")}`),
].filter((t) => t.length > 1);
}
function fallbackCopy(repo: GitHubRepoData, script: string): SocialCopy {
const summary = repo.description || script;
return {
instagram_caption: `${summary} Check out ${repo.fullName} on GitHub and see what you can build with it.`,
youtube_caption: `Repo: ${repo.fullName}. ${summary} GitHub: ${repo.htmlUrl}. Don't forget to subscribe for more developer tool updates!`,
hashtags: normalizeTags(null, repo),
};
}
/**
* Instagram + YouTube copy for a finished reel. Never throws: social text is a
* nice-to-have, so a flaky LLM must not sink an otherwise successful render.
*/
export async function generateSocialCopy(
repo: GitHubRepoData,
script: string,
): Promise<SocialCopy> {
const prompt = `You write social media copy for a short vertical video about a GitHub repo.
Repo: ${repo.fullName}
Description: ${repo.description || "No description"}
Language: ${repo.language}
Stars: ${repo.stars}
Topics: ${repo.topics.join(", ") || "none"}
Repo URL: ${repo.htmlUrl}
README excerpt:
${repo.readme.slice(0, 1500)}
The video narration says:
${script}
Return ONLY minified JSON, no markdown fence, exactly this shape:
{"instagram_caption":"...","youtube_caption":"...","hashtags":["#Tag"]}
instagram_caption rules:
- One flowing paragraph, 600-900 characters
- Opens with a question or pain point the repo solves
- Names the repo and explains what it actually does
- Mentions a concrete detail (stars, a key feature, who it is for)
- Ends with a question to the reader plus a call to check the repo
- No hashtags and no line breaks inside the caption
youtube_caption rules:
- One paragraph, 400-700 characters
- Starts exactly with "Repo: ${repo.fullName}."
- Explains the problem it solves and who should use it
- Includes "GitHub: ${repo.htmlUrl}"
- Ends with a line inviting viewers to subscribe for more developer tool updates
hashtags rules:
- 5-8 tags, PascalCase, each starting with #
- Broad reach tags (#AI, #OpenSource, #GitHub) plus 2-3 specific to this repo
- No spaces inside a tag`;
// ponytail: two tries — this model stalls now and then, and a retry is cheaper
// than shipping the metadata fallback to a live social account
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const raw = await callLlm(prompt, { maxTokens: 900, temperature: 0.7 });
// models like wrapping JSON in prose or a fence — take the outermost object
const json = raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1);
const parsed = JSON.parse(json) as Partial<SocialCopy>;
const instagram = parsed.instagram_caption?.trim().replace(/\s+/g, " ");
const youtube = parsed.youtube_caption?.trim().replace(/\s+/g, " ");
if (!instagram || !youtube) {
throw new Error("LLM omitted instagram_caption or youtube_caption");
}
return {
instagram_caption: instagram.slice(0, 2200),
youtube_caption: youtube.slice(0, 5000),
hashtags: normalizeTags(parsed.hashtags, repo),
};
} catch (error) {
console.error(`Social copy attempt ${attempt} failed:`, error);
}
}
console.error("Social copy generation failed twice, using metadata fallback");
return fallbackCopy(repo, script);
}