| import { callLlm } from "@/lib/llm"; |
| import type { GitHubRepoData } from "@/types"; |
|
|
| |
| 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); |
|
|
| |
| 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; |
|
|
| |
| 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), |
| }; |
| } |
|
|
| |
| |
| |
| |
| 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`; |
|
|
| |
| |
| for (let attempt = 1; attempt <= 2; attempt++) { |
| try { |
| const raw = await callLlm(prompt, { maxTokens: 900, temperature: 0.7 }); |
|
|
| |
| 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); |
| } |
|
|