mcp-scraper 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/bin/api-server.cjs +448 -74
- package/dist/bin/api-server.cjs.map +1 -1
- package/dist/bin/api-server.js +2 -2
- package/dist/bin/mcp-scraper-cli.cjs +1 -1
- package/dist/bin/mcp-scraper-cli.cjs.map +1 -1
- package/dist/bin/mcp-scraper-cli.js +1 -1
- package/dist/bin/mcp-scraper-install.cjs +2 -2
- package/dist/bin/mcp-scraper-install.cjs.map +1 -1
- package/dist/bin/mcp-scraper-install.js +2 -2
- package/dist/bin/mcp-stdio-server.cjs +2137 -1778
- package/dist/bin/mcp-stdio-server.cjs.map +1 -1
- package/dist/bin/mcp-stdio-server.js +5 -4
- package/dist/bin/mcp-stdio-server.js.map +1 -1
- package/dist/bin/paa-harvest.js +2 -2
- package/dist/{chunk-RMPPYKUV.js → chunk-2HDMYW4B.js} +7 -218
- package/dist/chunk-2HDMYW4B.js.map +1 -0
- package/dist/chunk-D7ZT27HY.js +244 -0
- package/dist/chunk-D7ZT27HY.js.map +1 -0
- package/dist/{chunk-FMEDPBIV.js → chunk-O2S5TOCG.js} +383 -22
- package/dist/chunk-O2S5TOCG.js.map +1 -0
- package/dist/chunk-OQHYDW4Q.js +7 -0
- package/dist/chunk-OQHYDW4Q.js.map +1 -0
- package/dist/{chunk-BX5RCOG5.js → chunk-Q44DN6T2.js} +2 -2
- package/dist/{chunk-BX5RCOG5.js.map → chunk-Q44DN6T2.js.map} +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/{server-MQDCAR6I.js → server-VWXDE64Y.js} +69 -59
- package/dist/server-VWXDE64Y.js.map +1 -0
- package/dist/{worker-JQTS437L.js → worker-AM2DHUWG.js} +6 -6
- package/docs/mcp-tool-manifest.generated.json +401 -10
- package/docs/specs/meta-ad-creative-media-resolution-spec.md +31 -0
- package/package.json +1 -1
- package/dist/chunk-FMEDPBIV.js.map +0 -1
- package/dist/chunk-HPV4VOQX.js +0 -27
- package/dist/chunk-HPV4VOQX.js.map +0 -1
- package/dist/chunk-RLWCHLV3.js +0 -7
- package/dist/chunk-RLWCHLV3.js.map +0 -1
- package/dist/chunk-RMPPYKUV.js.map +0 -1
- package/dist/server-MQDCAR6I.js.map +0 -1
- /package/dist/{worker-JQTS437L.js.map → worker-AM2DHUWG.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/version.ts"],"sourcesContent":["export const PACKAGE_VERSION = '0.19.0'\n"],"mappings":";AAAO,IAAM,kBAAkB;","names":[]}
|
|
@@ -50,7 +50,7 @@ function renderInstallTerminal(options) {
|
|
|
50
50
|
"1/1 install surfaces ready",
|
|
51
51
|
colorize("Newest: any approved connection read can become an indexed Memory snapshot in one call. OAuth stays tenant-isolated and provider content is redacted and marked untrusted.", "lime", color),
|
|
52
52
|
"",
|
|
53
|
-
`${colorize("Tools", "cyan", color)} ${colorize("(
|
|
53
|
+
`${colorize("Tools", "cyan", color)} ${colorize("(158 MCP tools)", "muted", color)}`,
|
|
54
54
|
toolRow("search", ["harvest_paa", "search_serp", "maps_search", "maps_place_intel"], color),
|
|
55
55
|
toolRow("extract", ["extract_url", "map_site_urls", "extract_site", "audit_site", "directory_workflow"], color),
|
|
56
56
|
toolRow("build", ["rank_tracker_workflow", "cron plan", "database prompt"], color),
|
|
@@ -105,4 +105,4 @@ function renderInstallTerminal(options) {
|
|
|
105
105
|
export {
|
|
106
106
|
renderInstallTerminal
|
|
107
107
|
};
|
|
108
|
-
//# sourceMappingURL=chunk-
|
|
108
|
+
//# sourceMappingURL=chunk-Q44DN6T2.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/install-terminal.ts"],"sourcesContent":["export interface InstallTerminalOptions {\n color?: boolean\n version: string\n apiKeyConfigured?: boolean\n}\n\ntype Tone = 'cyan' | 'lime' | 'amber' | 'red' | 'muted' | 'bold'\n\nconst CODES: Record<Tone | 'reset', string> = {\n reset: '\\x1b[0m',\n cyan: '\\x1b[36m',\n lime: '\\x1b[32m',\n amber: '\\x1b[33m',\n red: '\\x1b[31m',\n muted: '\\x1b[90m',\n bold: '\\x1b[1m',\n}\n\nfunction colorize(value: string, tone: Tone, enabled: boolean): string {\n if (!enabled) return value\n return `${CODES[tone]}${value}${CODES.reset}`\n}\n\nfunction toolRow(label: string, tools: string[], enabled: boolean): string {\n const padded = label.padEnd(9, ' ')\n return ` ${colorize(padded, 'muted', enabled)} ${tools.join(colorize(' . ', 'muted', enabled))}`\n}\n\nexport function renderInstallTerminal(options: InstallTerminalOptions): string {\n const color = options.color ?? true\n const apiKeyValue = options.apiKeyConfigured ? '$MCP_SCRAPER_API_KEY' : 'sk_live_your_key'\n const ascii = String.raw`\n __ __ ____ ____ \n| \\/ |/ ___| _ \\\n| |\\/| | | | |_) |\n| | | | |___| __/\n|_| |_|\\____|_|\n\n ____ ____ ____ _ ____ _____ ____\n/ ___| / ___| _ \\ / \\ | _ \\| ____| _ \\\n\\___ \\| | | |_) | / _ \\ | |_) | _| | |_) |\n ___) | |___| _ < / ___ \\| __/| |___| _ <\n|____/ \\____|_| \\_\\/_/ \\_\\_| |_____|_| \\_\\\n`\n\n const claudeCommand = [\n `MCP_SCRAPER_API_KEY=${apiKeyValue} npx -y -p mcp-scraper@latest \\\\`,\n ' mcp-scraper-cli agent install claude --apply',\n ].join('\\n')\n\n const codexConfig = [\n '[mcp_servers.mcp-scraper]',\n 'command = \"npx\"',\n 'args = [\"-y\", \"-p\", \"mcp-scraper@latest\", \"mcp-scraper\"]',\n `env = { MCP_SCRAPER_API_KEY = \"${apiKeyValue}\" }`,\n ].join('\\n')\n\n return [\n colorize(`mcp-scraper v${options.version}`, 'bold', color),\n colorize('> mcp-scraper-install', 'muted', color),\n colorize(ascii, 'amber', color),\n `${colorize('MCP Scraper Agent', 'cyan', color)} . v${options.version} . mcpscraper.dev`,\n '1/1 install surfaces ready',\n colorize('Newest: any approved connection read can become an indexed Memory snapshot in one call. OAuth stays tenant-isolated and provider content is redacted and marked untrusted.', 'lime', color),\n '',\n `${colorize('Tools', 'cyan', color)} ${colorize('(
|
|
1
|
+
{"version":3,"sources":["../src/install-terminal.ts"],"sourcesContent":["export interface InstallTerminalOptions {\n color?: boolean\n version: string\n apiKeyConfigured?: boolean\n}\n\ntype Tone = 'cyan' | 'lime' | 'amber' | 'red' | 'muted' | 'bold'\n\nconst CODES: Record<Tone | 'reset', string> = {\n reset: '\\x1b[0m',\n cyan: '\\x1b[36m',\n lime: '\\x1b[32m',\n amber: '\\x1b[33m',\n red: '\\x1b[31m',\n muted: '\\x1b[90m',\n bold: '\\x1b[1m',\n}\n\nfunction colorize(value: string, tone: Tone, enabled: boolean): string {\n if (!enabled) return value\n return `${CODES[tone]}${value}${CODES.reset}`\n}\n\nfunction toolRow(label: string, tools: string[], enabled: boolean): string {\n const padded = label.padEnd(9, ' ')\n return ` ${colorize(padded, 'muted', enabled)} ${tools.join(colorize(' . ', 'muted', enabled))}`\n}\n\nexport function renderInstallTerminal(options: InstallTerminalOptions): string {\n const color = options.color ?? true\n const apiKeyValue = options.apiKeyConfigured ? '$MCP_SCRAPER_API_KEY' : 'sk_live_your_key'\n const ascii = String.raw`\n __ __ ____ ____ \n| \\/ |/ ___| _ \\\n| |\\/| | | | |_) |\n| | | | |___| __/\n|_| |_|\\____|_|\n\n ____ ____ ____ _ ____ _____ ____\n/ ___| / ___| _ \\ / \\ | _ \\| ____| _ \\\n\\___ \\| | | |_) | / _ \\ | |_) | _| | |_) |\n ___) | |___| _ < / ___ \\| __/| |___| _ <\n|____/ \\____|_| \\_\\/_/ \\_\\_| |_____|_| \\_\\\n`\n\n const claudeCommand = [\n `MCP_SCRAPER_API_KEY=${apiKeyValue} npx -y -p mcp-scraper@latest \\\\`,\n ' mcp-scraper-cli agent install claude --apply',\n ].join('\\n')\n\n const codexConfig = [\n '[mcp_servers.mcp-scraper]',\n 'command = \"npx\"',\n 'args = [\"-y\", \"-p\", \"mcp-scraper@latest\", \"mcp-scraper\"]',\n `env = { MCP_SCRAPER_API_KEY = \"${apiKeyValue}\" }`,\n ].join('\\n')\n\n return [\n colorize(`mcp-scraper v${options.version}`, 'bold', color),\n colorize('> mcp-scraper-install', 'muted', color),\n colorize(ascii, 'amber', color),\n `${colorize('MCP Scraper Agent', 'cyan', color)} . v${options.version} . mcpscraper.dev`,\n '1/1 install surfaces ready',\n colorize('Newest: any approved connection read can become an indexed Memory snapshot in one call. OAuth stays tenant-isolated and provider content is redacted and marked untrusted.', 'lime', color),\n '',\n `${colorize('Tools', 'cyan', color)} ${colorize('(158 MCP tools)', 'muted', color)}`,\n toolRow('search', ['harvest_paa', 'search_serp', 'maps_search', 'maps_place_intel'], color),\n toolRow('extract', ['extract_url', 'map_site_urls', 'extract_site', 'audit_site', 'directory_workflow'], color),\n toolRow('build', ['rank_tracker_workflow', 'cron plan', 'database prompt'], color),\n toolRow('media', ['youtube_harvest', 'youtube_transcribe', 'facebook_ad_search', 'facebook_page_intel', 'facebook_ad_transcribe', 'facebook_video_transcribe', 'instagram_profile_content', 'instagram_media_download', 'reddit_thread'], color),\n toolRow('browser', ['browser_open', 'browser_profile_connect', 'browser_profile_list', 'browser_close', 'browser_screenshot', 'browser_read', 'browser_locate', 'browser_replay_mark', 'browser_replay_annotate'], color),\n toolRow('connect', ['list_service_connections', 'describe_service_connection_tool', 'import_service_connection_to_memory', 'export_connected_service_data', 'renew_connected_data_download', 'read_service_connection', 'call_service_connection_action'], color),\n toolRow('account', ['credits_info', 'reports', 'MCP resources'], color),\n toolRow('memory', ['memory-put', 'memory-get', 'memory-search', 'list-vaults', 'record-fact', 'list-scheduled-actions'], color),\n `${colorize('Workflows', 'cyan', color)} ${colorize('(MCP + CLI + API)', 'muted', color)}`,\n toolRow('route', ['workflow_list', 'workflow_suggest', 'workflow_run', 'workflow_step', 'workflow_status', 'workflow_artifact_read'], color),\n toolRow('seo', ['directory', 'agent-packet', 'competitive audit', 'map/serp comparison', 'PAA/AIO briefs', 'scheduled runs'], color),\n '',\n colorize('Usage tips:', 'amber', color),\n 'Run mcp-scraper-install for this visible card. Run mcp-scraper-cli for setup utilities and subcommands.',\n 'Run mcp-scraper in a human terminal to print this card; MCP clients get the same command as a silent stdio server.',\n 'Explicit card command: npx -y -p mcp-scraper@latest mcp-scraper-install',\n 'Hosted browser sessions use direct/no-proxy egress by default.',\n 'Customer auth setup: run browser_profile_connect, send the watch_url, let the user sign in, then call browser_profile_list until AUTHENTICATED.',\n 'Connected account ranges: call export_connected_service_data once. It handles Gmail, Calendar, Zoom, and Resend pagination; do not loop read_service_connection over individual records.',\n 'Connected account RAG: call import_service_connection_to_memory for one bounded approved read. It writes a redacted, untrusted snapshot to a stable Memory path and embeds it for search.',\n 'Stack logins / reconnect: run browser_profile_connect again with the same profile name and another domain to add accounts or refresh a login.',\n 'Start with workflow_suggest for broad jobs like market analysis, ICP research, CRO audits, brand briefs, content gaps, and AI visibility.',\n 'For MCP clients, use mcp-scraper so one install can mix SERP, Maps, browser, reports, and saved MCP resources.',\n 'If you hit the concurrency limit, add an extra slot for $5/month with mcp-scraper-cli billing concurrency checkout.',\n '',\n `${colorize('Ready.', 'lime', color)} Install the combined MCP server with one command:`,\n '',\n colorize('Setup doctor', 'amber', color),\n 'npx -y -p mcp-scraper@latest mcp-scraper-cli doctor',\n '',\n colorize('Hosted profile setup', 'amber', color),\n 'In your MCP client, call browser_profile_connect with email=\"seo@example.com\" and domain=\"chatgpt.com\".',\n 'Give the returned watch_url to the user. After they sign in, call browser_profile_list, then browser_open with the returned profile. Add more logins by calling browser_profile_connect again with the same profile and a new domain.',\n '',\n colorize('Claude Code one-command setup', 'amber', color),\n claudeCommand,\n 'Then fully exit Claude Code and open a new Claude terminal. Check with: claude mcp list',\n '',\n colorize('Codex config', 'amber', color),\n codexConfig,\n '',\n colorize('Claude Desktop Extension', 'amber', color),\n 'Download: https://mcpscraper.dev/downloads/mcp-scraper.mcpb',\n '',\n colorize('Safety note:', 'muted', color),\n 'mcp-scraper prints this card only when stdin/stdout are an interactive TTY. In MCP clients it writes only JSON-RPC to stdout.',\n 'Use --stdio or MCP_SCRAPER_FORCE_STDIO=1 to force server mode from a terminal.',\n '',\n ].join('\\n')\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
harvest
|
|
2
|
+
harvest
|
|
3
|
+
} from "./chunk-2HDMYW4B.js";
|
|
4
|
+
import {
|
|
3
5
|
recordVendorUsage
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-HPV4VOQX.js";
|
|
6
|
+
} from "./chunk-D7ZT27HY.js";
|
|
6
7
|
import "./chunk-K443GQY5.js";
|
|
7
8
|
import "./chunk-XGIPATLV.js";
|
|
8
9
|
import "./chunk-M2S27J6Z.js";
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/video/VideoGenerator.ts","../src/video/promptBuilder.ts","../src/video/AudioGenerator.ts","../src/video/VideoMixer.ts"],"sourcesContent":["import { execSync } from 'node:child_process'\nimport { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { fal } from '@fal-ai/client'\nimport { buildClipPrompts, extractEpisodePrompts } from './promptBuilder.js'\nimport type { EpisodeBrief } from './episodeBrief.js'\nimport { generateVoiceover, addBackgroundAudio } from './AudioGenerator.js'\nimport { concatenateClips, uploadToFal, overlayVoiceover } from './VideoMixer.js'\n\nexport interface ClipPairOptions {\n resolution?: '480p' | '720p' | '1080p'\n aspectRatio?: '16:9' | '9:16' | '1:1'\n clipDurationSeconds?: number\n generateAudio?: boolean\n seed?: number\n outputDir?: string\n}\n\nexport interface ClipPairResult {\n clip1Url: string\n clip2Url: string\n finalVideoPath: string\n seed: number\n promptClip1: string\n promptClip2: string\n voiceover: string\n audioMood: string\n}\n\ninterface VideoOutput { video: { url: string }; seed: number }\n\nconst T2V = 'bytedance/seedance-2.0/text-to-video'\nconst I2V = 'bytedance/seedance-2.0/image-to-video'\n\nfunction buildInput(prompt: string, opts: ClipPairOptions, seed?: number, imageUrl?: string) {\n return {\n prompt,\n resolution: opts.resolution ?? '720p',\n duration: opts.clipDurationSeconds ?? 8,\n aspect_ratio: opts.aspectRatio ?? '16:9',\n generate_audio: false,\n ...(seed !== undefined ? { seed } : {}),\n ...(imageUrl !== undefined ? { image_url: imageUrl } : {}),\n }\n}\n\nasync function generate(model: string, input: Record<string, unknown>): Promise<VideoOutput> {\n const { request_id } = await fal.queue.submit(model, { input })\n console.log(`[fal] submitted ${model} → ${request_id}`)\n while (true) {\n await new Promise(r => setTimeout(r, 5000))\n const s = await fal.queue.status(model, { requestId: request_id, logs: false })\n console.log(`[fal] ${request_id} → ${s.status}`)\n if ((s.status as string) === 'FAILED') throw new Error(`Request ${request_id} failed`)\n if ((s.status as string) !== 'COMPLETED') continue\n const result = await fal.queue.result(model, { requestId: request_id })\n return result.data as VideoOutput\n }\n}\n\nasync function extractLastFrame(videoUrl: string, outDir: string): Promise<string> {\n const ts = Date.now()\n const mp4Path = join(outDir, `clip1-raw-${ts}.mp4`)\n const jpgPath = join(outDir, `last-frame-${ts}.jpg`)\n\n const res = await fetch(videoUrl)\n if (!res.ok) throw new Error(`Failed to download clip 1 (${res.status})`)\n writeFileSync(mp4Path, Buffer.from(await res.arrayBuffer()))\n\n try {\n execSync(`ffmpeg -sseof -0.1 -i \"${mp4Path}\" -vframes 1 -y \"${jpgPath}\" -loglevel error`)\n } finally {\n try { unlinkSync(mp4Path) } catch {}\n }\n return jpgPath\n}\n\nexport class VideoGenerator {\n constructor(apiKey?: string) {\n const key = apiKey ?? process.env['FAL_KEY']\n if (!key) throw new Error('FAL_KEY is required')\n fal.config({ credentials: key })\n }\n\n async generateClipPair(\n question: string,\n answer: string,\n opts: ClipPairOptions = {},\n ): Promise<ClipPairResult> {\n const outDir = opts.outputDir ?? join(tmpdir(), `paa-video-${Date.now()}`)\n mkdirSync(outDir, { recursive: true })\n\n console.log('\\n[1/7] Generating prompts via QWEN 3.6...')\n const prompts = await buildClipPrompts(question, answer)\n console.log(' Voiceover:', prompts.voiceover)\n console.log(' Audio mood:', prompts.audioMood)\n\n console.log('\\n[2/7] Generating clip 1 (text-to-video)...')\n const result1 = await generate(T2V, buildInput(prompts.clip1, opts, opts.seed))\n\n console.log('\\n[3/7] Extracting last frame → clip 2 start...')\n const jpgPath = await extractLastFrame(result1.video.url, outDir)\n const imageBlob = new Blob([readFileSync(jpgPath)], { type: 'image/jpeg' })\n const frameUrl = await fal.storage.upload(imageBlob)\n try { unlinkSync(jpgPath) } catch {}\n\n console.log('\\n[4/7] Generating clip 2 (image-to-video from last frame)...')\n const seed2 = opts.seed !== undefined ? opts.seed + 1 : undefined\n const result2 = await generate(I2V, buildInput(prompts.clip2, opts, seed2, frameUrl))\n\n console.log('\\n[5/7] Concatenating clips + generating voiceover (parallel)...')\n const [combinedPath, voiceoverUrl] = await Promise.all([\n concatenateClips(result1.video.url, result2.video.url, outDir),\n generateVoiceover(prompts.voiceover),\n ])\n\n console.log('\\n[6/7] Adding background audio via MMAudio V2...')\n const falVideoUrl = await uploadToFal(combinedPath)\n const totalDuration = (opts.clipDurationSeconds ?? 8) * 2\n const videoWithAudioUrl = await addBackgroundAudio(falVideoUrl, prompts.audioMood, totalDuration)\n\n console.log('\\n[7/7] Overlaying voiceover on final video...')\n const videoWithAudioPath = join(outDir, `with-bg-audio-${Date.now()}.mp4`)\n const bgRes = await fetch(videoWithAudioUrl)\n writeFileSync(videoWithAudioPath, Buffer.from(await bgRes.arrayBuffer()))\n const finalVideoPath = await overlayVoiceover(videoWithAudioPath, voiceoverUrl, outDir)\n\n return {\n clip1Url: result1.video.url,\n clip2Url: result2.video.url,\n finalVideoPath,\n seed: result1.seed,\n promptClip1: prompts.clip1,\n promptClip2: prompts.clip2,\n voiceover: prompts.voiceover,\n audioMood: prompts.audioMood,\n }\n }\n\n async generateEpisode(\n brief: EpisodeBrief,\n opts: ClipPairOptions = {},\n ): Promise<ClipPairResult> {\n const outDir = opts.outputDir ?? join(tmpdir(), `episode-${brief.episodeNumber}-${Date.now()}`)\n mkdirSync(outDir, { recursive: true })\n\n const prompts = extractEpisodePrompts(brief)\n console.log(`\\n[Episode ${brief.episodeNumber}/${brief.episodeCount}] ${brief.sectionTitle}`)\n console.log(' Voiceover:', prompts.voiceover)\n\n console.log('\\n[2/7] Generating clip 1 (text-to-video)...')\n const result1 = await generate(T2V, buildInput(prompts.clip1, opts, opts.seed))\n\n console.log('\\n[3/7] Extracting last frame → clip 2 start...')\n const jpgPath = await extractLastFrame(result1.video.url, outDir)\n const imageBlob = new Blob([readFileSync(jpgPath)], { type: 'image/jpeg' })\n const frameUrl = await fal.storage.upload(imageBlob)\n try { unlinkSync(jpgPath) } catch {}\n\n console.log('\\n[4/7] Generating clip 2 (image-to-video from last frame)...')\n const seed2 = opts.seed !== undefined ? opts.seed + 1 : undefined\n const result2 = await generate(I2V, buildInput(prompts.clip2, opts, seed2, frameUrl))\n\n console.log('\\n[5/7] Concatenating clips + generating voiceover (parallel)...')\n const [combinedPath, voiceoverUrl] = await Promise.all([\n concatenateClips(result1.video.url, result2.video.url, outDir),\n generateVoiceover(prompts.voiceover),\n ])\n\n console.log('\\n[6/7] Adding background audio via MMAudio V2...')\n const falVideoUrl = await uploadToFal(combinedPath)\n const totalDuration = (opts.clipDurationSeconds ?? 8) * 2\n const videoWithAudioUrl = await addBackgroundAudio(falVideoUrl, prompts.audioMood, totalDuration)\n\n console.log('\\n[7/7] Overlaying voiceover on final video...')\n const videoWithAudioPath = join(outDir, `with-bg-audio-${Date.now()}.mp4`)\n const bgRes = await fetch(videoWithAudioUrl)\n writeFileSync(videoWithAudioPath, Buffer.from(await bgRes.arrayBuffer()))\n const finalVideoPath = await overlayVoiceover(videoWithAudioPath, voiceoverUrl, outDir)\n\n return {\n clip1Url: result1.video.url,\n clip2Url: result2.video.url,\n finalVideoPath,\n seed: result1.seed,\n promptClip1: prompts.clip1,\n promptClip2: prompts.clip2,\n voiceover: prompts.voiceover,\n audioMood: prompts.audioMood,\n }\n }\n}\n","import { recordVendorUsage } from '../api/cost-telemetry.js'\n\nexport interface ClipPromptPair {\n clip1: string\n clip2: string\n voiceover: string\n audioMood: string\n}\n\nexport interface EpisodeBriefInput {\n storyMoment: string\n characterName: string\n sectionTitle: string\n clip1: string\n clip2: string\n voiceover: string\n audioMood: string\n}\n\nconst DEEPINFRA_URL = 'https://api.deepinfra.com/v1/openai/chat/completions'\nconst OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'\nconst QWEN_MODEL = 'Qwen/Qwen3.6-35B-A3B'\n\nconst SYSTEM_PROMPT = `You are a video prompt engineer for Seedance 2.0 text-to-video AI.\n\nIMPORTANT TECHNICAL CONTEXT: Clip 2 will be generated using image-to-video, meaning it will start from the exact last frame of Clip 1. The two clips will be visually seamless — same location, same characters, continuous motion. You must write prompts that make this feel like one uninterrupted 16-second video.\n\nYour job: turn a PAA question and its answer into a complete short-form video with visuals, narration, and a background audio mood.\n\nProduce four things:\n\n1. clip1 (~8s): Show a real person experiencing the situation the question describes. End on a specific frozen moment — a held expression, a paused action — that clip 2 continues from.\n\n2. clip2 (~8s): Continue from that exact frozen frame. Deliver the informational payoff from the answer. Show specific facts playing out visually. Describe motion continuing from clip 1's ending frame, not a new scene.\n\n3. voiceover: A spoken narration that fits in 10 seconds of TTS audio. HARD LIMIT: 22 words maximum. Count every word before finalizing — if it exceeds 22 words, cut it. Starts with the problem, ends with the answer. No filler, no \"in conclusion\". Informational and direct.\n\n4. audioMood: 6-10 words describing INSTRUMENTAL background music only — no vocals, no voice, no lyrics. Describe instruments and mood (e.g. \"warm acoustic guitar, uplifting, professional home service, no vocals\"). Will be passed to an AI audio model.\n\nRules for visuals:\n- No text, captions, graphics, or overlays\n- Photorealistic, natural lighting, specific details\n- Describe exactly what the camera sees\n\nRespond with JSON only:\n{\"clip1\": \"...\", \"clip2\": \"...\", \"voiceover\": \"...\", \"audioMood\": \"...\"}`\n\ninterface ChatResponse {\n choices: Array<{ message: { content: string } }>\n usage?: { cost?: number; completion_tokens?: number }\n}\n\nasync function callLLM(apiKey: string, baseUrl: string, question: string, answer: string): Promise<ClipPromptPair> {\n const res = await fetch(baseUrl, {\n method: 'POST',\n headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: QWEN_MODEL,\n temperature: 0.7,\n messages: [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: `Question: ${question}\\n\\nAnswer: ${answer.slice(0, 500)}` },\n ],\n }),\n })\n if (!res.ok) throw new Error(`LLM call failed (${res.status}): ${await res.text()}`)\n\n const data = (await res.json()) as ChatResponse\n if (baseUrl === OPENROUTER_URL) {\n void recordVendorUsage({ vendor: 'openrouter', model: QWEN_MODEL, units: data.usage?.cost ?? 0, unitType: 'usd' })\n } else {\n void recordVendorUsage({ vendor: 'deepinfra_qwen', model: QWEN_MODEL, units: data.usage?.completion_tokens ?? 0, unitType: 'tokens' })\n }\n const raw = data.choices[0]?.message?.content?.trim() ?? ''\n const match = raw.match(/\\{[\\s\\S]*\\}/)\n if (!match) throw new Error(`No JSON in QWEN response: ${raw.slice(0, 200)}`)\n\n const parsed = JSON.parse(match[0]) as Partial<ClipPromptPair>\n if (!parsed.clip1 || !parsed.clip2 || !parsed.voiceover || !parsed.audioMood) {\n throw new Error(`QWEN response missing fields: ${raw.slice(0, 200)}`)\n }\n return parsed as ClipPromptPair\n}\n\nexport async function buildClipPrompts(question: string, answer: string): Promise<ClipPromptPair> {\n const deepinfraKey = process.env['DEEPINFRA_API_KEY']\n const openrouterKey = process.env['OPENROUTER_API_KEY']\n\n if (deepinfraKey) {\n try {\n return await callLLM(deepinfraKey, DEEPINFRA_URL, question, answer)\n } catch (err) {\n console.warn('[promptBuilder] DeepInfra failed, trying OpenRouter:', (err as Error).message)\n }\n }\n if (openrouterKey) {\n return await callLLM(openrouterKey, OPENROUTER_URL, question, answer)\n }\n throw new Error('No LLM key — set DEEPINFRA_API_KEY or OPENROUTER_API_KEY')\n}\n\nexport function extractEpisodePrompts(brief: EpisodeBriefInput): ClipPromptPair {\n if (!brief.clip1 || !brief.clip2 || !brief.voiceover || !brief.audioMood) {\n throw new Error('Episode brief is missing prompt fields — run blog-to-video skill to regenerate')\n }\n return { clip1: brief.clip1, clip2: brief.clip2, voiceover: brief.voiceover, audioMood: brief.audioMood }\n}\n\nconst EPISODE_SYSTEM_PROMPT = `You are a video prompt engineer for Seedance 2.0 text-to-video AI.\n\nYou are working with a character-driven narrative brief. The clips must feel like a continuous 16-second moment in one person's story — not an explainer video.\n\nIMPORTANT TECHNICAL CONTEXT: Clip 2 will start from the exact last frame of Clip 1. Both clips must show the same person, same location, same lighting. Clip 2 continues motion from Clip 1's frozen ending frame.\n\nUse the Kling prompt format for every clip:\n[Shot type] of [character name] [action]. The scene starts at [keyframe 1]. Camera Motion: [motion] ending in [keyframe 2]. Lighting: [style]. Style: [style].\n\nShot types: Close-up, Medium shot, Over-the-shoulder, Wide shot, Tracking shot.\nCamera motions: Slow push in, Slow pull back, Rack focus, Static hold, Subtle drift right/left, Pan left/right.\n\nRules for visuals:\n- No text, no captions, no UI overlays, no readable code on screen\n- Photorealistic, specific objects, natural-feeling details\n- Keyframe 2 (clip 1's end) must be a specific held moment clip 2 continues from\n\nvoiceover: The character's internal tension, in 22 words maximum. Not a summary. Count every word.\naudioMood: Specific instruments + tempo + emotional register + \"no vocals, no lyrics\".\n\nRespond with JSON only: {\"clip1\": \"...\", \"clip2\": \"...\", \"voiceover\": \"...\", \"audioMood\": \"...\"}`\n\nasync function callEpisodeLLM(apiKey: string, baseUrl: string, brief: EpisodeBriefInput): Promise<ClipPromptPair> {\n const userContent = `Character: ${brief.characterName}\nSection: ${brief.sectionTitle}\nStory moment: ${brief.storyMoment}\n\nExisting clip1 (refine if needed): ${brief.clip1}\nExisting clip2 (refine if needed): ${brief.clip2}\nExisting voiceover: ${brief.voiceover}\nExisting audioMood: ${brief.audioMood}\n\nProduce refined Kling-format prompts that follow all rules. Keep what's good, fix what violates the format.`\n\n const res = await fetch(baseUrl, {\n method: 'POST',\n headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: QWEN_MODEL,\n temperature: 0.5,\n messages: [\n { role: 'system', content: EPISODE_SYSTEM_PROMPT },\n { role: 'user', content: userContent },\n ],\n }),\n })\n if (!res.ok) throw new Error(`LLM episode call failed (${res.status}): ${await res.text()}`)\n\n const data = (await res.json()) as ChatResponse\n const raw = data.choices[0]?.message?.content?.trim() ?? ''\n const match = raw.match(/\\{[\\s\\S]*\\}/)\n if (!match) throw new Error(`No JSON in episode response: ${raw.slice(0, 200)}`)\n\n const parsed = JSON.parse(match[0]) as Partial<ClipPromptPair>\n if (!parsed.clip1 || !parsed.clip2 || !parsed.voiceover || !parsed.audioMood) {\n throw new Error(`Episode response missing fields: ${raw.slice(0, 200)}`)\n }\n return parsed as ClipPromptPair\n}\n\nexport async function regenerateEpisodePrompts(brief: EpisodeBriefInput): Promise<ClipPromptPair> {\n const deepinfraKey = process.env['DEEPINFRA_API_KEY']\n const openrouterKey = process.env['OPENROUTER_API_KEY']\n\n if (deepinfraKey) {\n try {\n return await callEpisodeLLM(deepinfraKey, DEEPINFRA_URL, brief)\n } catch (err) {\n console.warn('[promptBuilder] DeepInfra failed, trying OpenRouter:', (err as Error).message)\n }\n }\n if (openrouterKey) {\n return await callEpisodeLLM(openrouterKey, OPENROUTER_URL, brief)\n }\n throw new Error('No LLM key — set DEEPINFRA_API_KEY or OPENROUTER_API_KEY')\n}\n","import { writeFileSync, mkdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { fal } from '@fal-ai/client'\n\nconst MMAUDIO_MODEL = 'fal-ai/mmaudio-v2'\nconst ELEVENLABS_MODEL = 'fal-ai/elevenlabs/tts'\nconst GEMINI_TTS_MODEL = 'fal-ai/google/gemini-2.5-flash-preview-tts'\n\ninterface VideoOutput { video: { url: string } }\ninterface TTSOutput { audio: { url: string } }\n\nasync function downloadAudio(url: string): Promise<Buffer> {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`Failed to download TTS audio (${res.status})`)\n return Buffer.from(await res.arrayBuffer())\n}\n\nexport async function generateVoiceover(text: string): Promise<string> {\n console.log('[AudioGenerator] Generating voiceover...')\n\n const outDir = join(tmpdir(), `tts-${Date.now()}`)\n mkdirSync(outDir, { recursive: true })\n const outPath = join(outDir, 'voiceover.mp3')\n\n try {\n const voiceId = process.env['ELEVENLABS_VOICE_ID'] ?? 'pNInz6obpgDQGcFmaJgB'\n const result = await fal.run(ELEVENLABS_MODEL, {\n input: { text, voice_id: voiceId, model_id: 'eleven_v3' },\n }) as unknown as TTSOutput\n writeFileSync(outPath, await downloadAudio(result.audio.url))\n console.log('[AudioGenerator] TTS: ElevenLabs via fal')\n return outPath\n } catch (err) {\n console.warn('[AudioGenerator] ElevenLabs via fal failed, trying Gemini:', (err as Error).message)\n }\n\n const voice = process.env['GEMINI_TTS_VOICE'] ?? 'Kore'\n const result = await fal.run(GEMINI_TTS_MODEL, { input: { text, voice } }) as unknown as TTSOutput\n writeFileSync(outPath, await downloadAudio(result.audio.url))\n console.log('[AudioGenerator] TTS: Gemini via fal')\n return outPath\n}\n\nexport async function addBackgroundAudio(videoUrl: string, mood: string, durationSeconds: number): Promise<string> {\n console.log('[AudioGenerator] Adding background audio via MMAudio V2...')\n const result = await fal.run(MMAUDIO_MODEL, {\n input: {\n video_url: videoUrl,\n prompt: mood,\n negative_prompt: 'speech, voice, talking, dialogue, narration, vocals, singing, human voice, conversation, words, lyrics, announcer, commentary',\n duration: durationSeconds,\n cfg_strength: 4.5,\n },\n }) as unknown as VideoOutput\n return result.video.url\n}\n","import { execSync } from 'node:child_process'\nimport { writeFileSync, mkdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { fal } from '@fal-ai/client'\n\nasync function download(url: string, destPath: string): Promise<void> {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`)\n writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))\n}\n\nexport async function concatenateClips(clip1Url: string, clip2Url: string, outDir: string): Promise<string> {\n mkdirSync(outDir, { recursive: true })\n const ts = Date.now()\n const p1 = join(outDir, `clip1-${ts}.mp4`)\n const p2 = join(outDir, `clip2-${ts}.mp4`)\n const out = join(outDir, `combined-${ts}.mp4`)\n\n console.log('[VideoMixer] Downloading clips...')\n await Promise.all([download(clip1Url, p1), download(clip2Url, p2)])\n\n console.log('[VideoMixer] Concatenating...')\n execSync(\n `ffmpeg -i \"${p1}\" -i \"${p2}\" -filter_complex \"[0:v][1:v]concat=n=2:v=1:a=0[v]\" -map \"[v]\" -y \"${out}\" -loglevel error`\n )\n return out\n}\n\nexport async function uploadToFal(localPath: string): Promise<string> {\n const { readFileSync } = await import('node:fs')\n const blob = new Blob([readFileSync(localPath)], { type: 'video/mp4' })\n const url = await fal.storage.upload(blob)\n console.log('[VideoMixer] Uploaded to fal:', url)\n return url\n}\n\nexport async function overlayVoiceover(\n videoPath: string,\n voiceoverUrl: string,\n outDir: string,\n): Promise<string> {\n const ts = Date.now()\n const wav = join(outDir, `voiceover-${ts}.wav`)\n const out = join(outDir, `final-${ts}.mp4`)\n\n console.log('[VideoMixer] Downloading voiceover...')\n await download(voiceoverUrl, wav)\n\n console.log('[VideoMixer] Mixing voiceover over background audio...')\n execSync(\n `ffmpeg -i \"${videoPath}\" -i \"${wav}\" ` +\n `-filter_complex \"[0:a]volume=0.2[bg];[1:a]volume=1.0[vo];[bg][vo]amix=inputs=2:duration=first[a]\" ` +\n `-map 0:v -map \"[a]\" -c:v copy -y \"${out}\" -loglevel error`\n )\n return out\n}\n"],"mappings":"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","names":["execSync","writeFileSync","mkdirSync","tmpdir","join","fal","result","writeFileSync","mkdirSync","join","fal","readFileSync","fal","join","writeFileSync","execSync","tmpdir","mkdirSync"]}
|
|
1
|
+
{"version":3,"sources":["../src/video/VideoGenerator.ts","../src/video/promptBuilder.ts","../src/video/AudioGenerator.ts","../src/video/VideoMixer.ts"],"sourcesContent":["import { execSync } from 'node:child_process'\nimport { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'\nimport { tmpdir } from 'node:os'\nimport { join } from 'node:path'\nimport { fal } from '@fal-ai/client'\nimport { buildClipPrompts, extractEpisodePrompts } from './promptBuilder.js'\nimport type { EpisodeBrief } from './episodeBrief.js'\nimport { generateVoiceover, addBackgroundAudio } from './AudioGenerator.js'\nimport { concatenateClips, uploadToFal, overlayVoiceover } from './VideoMixer.js'\n\nexport interface ClipPairOptions {\n resolution?: '480p' | '720p' | '1080p'\n aspectRatio?: '16:9' | '9:16' | '1:1'\n clipDurationSeconds?: number\n generateAudio?: boolean\n seed?: number\n outputDir?: string\n}\n\nexport interface ClipPairResult {\n clip1Url: string\n clip2Url: string\n finalVideoPath: string\n seed: number\n promptClip1: string\n promptClip2: string\n voiceover: string\n audioMood: string\n}\n\ninterface VideoOutput { video: { url: string }; seed: number }\n\nconst T2V = 'bytedance/seedance-2.0/text-to-video'\nconst I2V = 'bytedance/seedance-2.0/image-to-video'\n\nfunction buildInput(prompt: string, opts: ClipPairOptions, seed?: number, imageUrl?: string) {\n return {\n prompt,\n resolution: opts.resolution ?? '720p',\n duration: opts.clipDurationSeconds ?? 8,\n aspect_ratio: opts.aspectRatio ?? '16:9',\n generate_audio: false,\n ...(seed !== undefined ? { seed } : {}),\n ...(imageUrl !== undefined ? { image_url: imageUrl } : {}),\n }\n}\n\nasync function generate(model: string, input: Record<string, unknown>): Promise<VideoOutput> {\n const { request_id } = await fal.queue.submit(model, { input })\n console.log(`[fal] submitted ${model} → ${request_id}`)\n while (true) {\n await new Promise(r => setTimeout(r, 5000))\n const s = await fal.queue.status(model, { requestId: request_id, logs: false })\n console.log(`[fal] ${request_id} → ${s.status}`)\n if ((s.status as string) === 'FAILED') throw new Error(`Request ${request_id} failed`)\n if ((s.status as string) !== 'COMPLETED') continue\n const result = await fal.queue.result(model, { requestId: request_id })\n return result.data as VideoOutput\n }\n}\n\nasync function extractLastFrame(videoUrl: string, outDir: string): Promise<string> {\n const ts = Date.now()\n const mp4Path = join(outDir, `clip1-raw-${ts}.mp4`)\n const jpgPath = join(outDir, `last-frame-${ts}.jpg`)\n\n const res = await fetch(videoUrl)\n if (!res.ok) throw new Error(`Failed to download clip 1 (${res.status})`)\n writeFileSync(mp4Path, Buffer.from(await res.arrayBuffer()))\n\n try {\n execSync(`ffmpeg -sseof -0.1 -i \"${mp4Path}\" -vframes 1 -y \"${jpgPath}\" -loglevel error`)\n } finally {\n try { unlinkSync(mp4Path) } catch {}\n }\n return jpgPath\n}\n\nexport class VideoGenerator {\n constructor(apiKey?: string) {\n const key = apiKey ?? process.env['FAL_KEY']\n if (!key) throw new Error('FAL_KEY is required')\n fal.config({ credentials: key })\n }\n\n async generateClipPair(\n question: string,\n answer: string,\n opts: ClipPairOptions = {},\n ): Promise<ClipPairResult> {\n const outDir = opts.outputDir ?? join(tmpdir(), `paa-video-${Date.now()}`)\n mkdirSync(outDir, { recursive: true })\n\n console.log('\\n[1/7] Generating prompts via QWEN 3.6...')\n const prompts = await buildClipPrompts(question, answer)\n console.log(' Voiceover:', prompts.voiceover)\n console.log(' Audio mood:', prompts.audioMood)\n\n console.log('\\n[2/7] Generating clip 1 (text-to-video)...')\n const result1 = await generate(T2V, buildInput(prompts.clip1, opts, opts.seed))\n\n console.log('\\n[3/7] Extracting last frame → clip 2 start...')\n const jpgPath = await extractLastFrame(result1.video.url, outDir)\n const imageBlob = new Blob([readFileSync(jpgPath)], { type: 'image/jpeg' })\n const frameUrl = await fal.storage.upload(imageBlob)\n try { unlinkSync(jpgPath) } catch {}\n\n console.log('\\n[4/7] Generating clip 2 (image-to-video from last frame)...')\n const seed2 = opts.seed !== undefined ? opts.seed + 1 : undefined\n const result2 = await generate(I2V, buildInput(prompts.clip2, opts, seed2, frameUrl))\n\n console.log('\\n[5/7] Concatenating clips + generating voiceover (parallel)...')\n const [combinedPath, voiceoverUrl] = await Promise.all([\n concatenateClips(result1.video.url, result2.video.url, outDir),\n generateVoiceover(prompts.voiceover),\n ])\n\n console.log('\\n[6/7] Adding background audio via MMAudio V2...')\n const falVideoUrl = await uploadToFal(combinedPath)\n const totalDuration = (opts.clipDurationSeconds ?? 8) * 2\n const videoWithAudioUrl = await addBackgroundAudio(falVideoUrl, prompts.audioMood, totalDuration)\n\n console.log('\\n[7/7] Overlaying voiceover on final video...')\n const videoWithAudioPath = join(outDir, `with-bg-audio-${Date.now()}.mp4`)\n const bgRes = await fetch(videoWithAudioUrl)\n writeFileSync(videoWithAudioPath, Buffer.from(await bgRes.arrayBuffer()))\n const finalVideoPath = await overlayVoiceover(videoWithAudioPath, voiceoverUrl, outDir)\n\n return {\n clip1Url: result1.video.url,\n clip2Url: result2.video.url,\n finalVideoPath,\n seed: result1.seed,\n promptClip1: prompts.clip1,\n promptClip2: prompts.clip2,\n voiceover: prompts.voiceover,\n audioMood: prompts.audioMood,\n }\n }\n\n async generateEpisode(\n brief: EpisodeBrief,\n opts: ClipPairOptions = {},\n ): Promise<ClipPairResult> {\n const outDir = opts.outputDir ?? join(tmpdir(), `episode-${brief.episodeNumber}-${Date.now()}`)\n mkdirSync(outDir, { recursive: true })\n\n const prompts = extractEpisodePrompts(brief)\n console.log(`\\n[Episode ${brief.episodeNumber}/${brief.episodeCount}] ${brief.sectionTitle}`)\n console.log(' Voiceover:', prompts.voiceover)\n\n console.log('\\n[2/7] Generating clip 1 (text-to-video)...')\n const result1 = await generate(T2V, buildInput(prompts.clip1, opts, opts.seed))\n\n console.log('\\n[3/7] Extracting last frame → clip 2 start...')\n const jpgPath = await extractLastFrame(result1.video.url, outDir)\n const imageBlob = new Blob([readFileSync(jpgPath)], { type: 'image/jpeg' })\n const frameUrl = await fal.storage.upload(imageBlob)\n try { unlinkSync(jpgPath) } catch {}\n\n console.log('\\n[4/7] Generating clip 2 (image-to-video from last frame)...')\n const seed2 = opts.seed !== undefined ? opts.seed + 1 : undefined\n const result2 = await generate(I2V, buildInput(prompts.clip2, opts, seed2, frameUrl))\n\n console.log('\\n[5/7] Concatenating clips + generating voiceover (parallel)...')\n const [combinedPath, voiceoverUrl] = await Promise.all([\n concatenateClips(result1.video.url, result2.video.url, outDir),\n generateVoiceover(prompts.voiceover),\n ])\n\n console.log('\\n[6/7] Adding background audio via MMAudio V2...')\n const falVideoUrl = await uploadToFal(combinedPath)\n const totalDuration = (opts.clipDurationSeconds ?? 8) * 2\n const videoWithAudioUrl = await addBackgroundAudio(falVideoUrl, prompts.audioMood, totalDuration)\n\n console.log('\\n[7/7] Overlaying voiceover on final video...')\n const videoWithAudioPath = join(outDir, `with-bg-audio-${Date.now()}.mp4`)\n const bgRes = await fetch(videoWithAudioUrl)\n writeFileSync(videoWithAudioPath, Buffer.from(await bgRes.arrayBuffer()))\n const finalVideoPath = await overlayVoiceover(videoWithAudioPath, voiceoverUrl, outDir)\n\n return {\n clip1Url: result1.video.url,\n clip2Url: result2.video.url,\n finalVideoPath,\n seed: result1.seed,\n promptClip1: prompts.clip1,\n promptClip2: prompts.clip2,\n voiceover: prompts.voiceover,\n audioMood: prompts.audioMood,\n }\n }\n}\n","import { recordVendorUsage } from '../api/cost-telemetry.js'\n\nexport interface ClipPromptPair {\n clip1: string\n clip2: string\n voiceover: string\n audioMood: string\n}\n\nexport interface EpisodeBriefInput {\n storyMoment: string\n characterName: string\n sectionTitle: string\n clip1: string\n clip2: string\n voiceover: string\n audioMood: string\n}\n\nconst DEEPINFRA_URL = 'https://api.deepinfra.com/v1/openai/chat/completions'\nconst OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'\nconst QWEN_MODEL = 'Qwen/Qwen3.6-35B-A3B'\n\nconst SYSTEM_PROMPT = `You are a video prompt engineer for Seedance 2.0 text-to-video AI.\n\nIMPORTANT TECHNICAL CONTEXT: Clip 2 will be generated using image-to-video, meaning it will start from the exact last frame of Clip 1. The two clips will be visually seamless — same location, same characters, continuous motion. You must write prompts that make this feel like one uninterrupted 16-second video.\n\nYour job: turn a PAA question and its answer into a complete short-form video with visuals, narration, and a background audio mood.\n\nProduce four things:\n\n1. clip1 (~8s): Show a real person experiencing the situation the question describes. End on a specific frozen moment — a held expression, a paused action — that clip 2 continues from.\n\n2. clip2 (~8s): Continue from that exact frozen frame. Deliver the informational payoff from the answer. Show specific facts playing out visually. Describe motion continuing from clip 1's ending frame, not a new scene.\n\n3. voiceover: A spoken narration that fits in 10 seconds of TTS audio. HARD LIMIT: 22 words maximum. Count every word before finalizing — if it exceeds 22 words, cut it. Starts with the problem, ends with the answer. No filler, no \"in conclusion\". Informational and direct.\n\n4. audioMood: 6-10 words describing INSTRUMENTAL background music only — no vocals, no voice, no lyrics. Describe instruments and mood (e.g. \"warm acoustic guitar, uplifting, professional home service, no vocals\"). Will be passed to an AI audio model.\n\nRules for visuals:\n- No text, captions, graphics, or overlays\n- Photorealistic, natural lighting, specific details\n- Describe exactly what the camera sees\n\nRespond with JSON only:\n{\"clip1\": \"...\", \"clip2\": \"...\", \"voiceover\": \"...\", \"audioMood\": \"...\"}`\n\ninterface ChatResponse {\n choices: Array<{ message: { content: string } }>\n usage?: { cost?: number; completion_tokens?: number }\n}\n\nasync function callLLM(apiKey: string, baseUrl: string, question: string, answer: string): Promise<ClipPromptPair> {\n const res = await fetch(baseUrl, {\n method: 'POST',\n headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: QWEN_MODEL,\n temperature: 0.7,\n messages: [\n { role: 'system', content: SYSTEM_PROMPT },\n { role: 'user', content: `Question: ${question}\\n\\nAnswer: ${answer.slice(0, 500)}` },\n ],\n }),\n })\n if (!res.ok) throw new Error(`LLM call failed (${res.status}): ${await res.text()}`)\n\n const data = (await res.json()) as ChatResponse\n if (baseUrl === OPENROUTER_URL) {\n void recordVendorUsage({ vendor: 'openrouter', model: QWEN_MODEL, units: data.usage?.cost ?? 0, unitType: 'usd' })\n } else {\n void recordVendorUsage({ vendor: 'deepinfra_qwen', model: QWEN_MODEL, units: data.usage?.completion_tokens ?? 0, unitType: 'tokens' })\n }\n const raw = data.choices[0]?.message?.content?.trim() ?? ''\n const match = raw.match(/\\{[\\s\\S]*\\}/)\n if (!match) throw new Error(`No JSON in QWEN response: ${raw.slice(0, 200)}`)\n\n const parsed = JSON.parse(match[0]) as Partial<ClipPromptPair>\n if (!parsed.clip1 || !parsed.clip2 || !parsed.voiceover || !parsed.audioMood) {\n throw new Error(`QWEN response missing fields: ${raw.slice(0, 200)}`)\n }\n return parsed as ClipPromptPair\n}\n\nexport async function buildClipPrompts(question: string, answer: string): Promise<ClipPromptPair> {\n const deepinfraKey = process.env['DEEPINFRA_API_KEY']\n const openrouterKey = process.env['OPENROUTER_API_KEY']\n\n if (deepinfraKey) {\n try {\n return await callLLM(deepinfraKey, DEEPINFRA_URL, question, answer)\n } catch (err) {\n console.warn('[promptBuilder] DeepInfra failed, trying OpenRouter:', (err as Error).message)\n }\n }\n if (openrouterKey) {\n return await callLLM(openrouterKey, OPENROUTER_URL, question, answer)\n }\n throw new Error('No LLM key — set DEEPINFRA_API_KEY or OPENROUTER_API_KEY')\n}\n\nexport function extractEpisodePrompts(brief: EpisodeBriefInput): ClipPromptPair {\n if (!brief.clip1 || !brief.clip2 || !brief.voiceover || !brief.audioMood) {\n throw new Error('Episode brief is missing prompt fields — run blog-to-video skill to regenerate')\n }\n return { clip1: brief.clip1, clip2: brief.clip2, voiceover: brief.voiceover, audioMood: brief.audioMood }\n}\n\nconst EPISODE_SYSTEM_PROMPT = `You are a video prompt engineer for Seedance 2.0 text-to-video AI.\n\nYou are working with a character-driven narrative brief. The clips must feel like a continuous 16-second moment in one person's story — not an explainer video.\n\nIMPORTANT TECHNICAL CONTEXT: Clip 2 will start from the exact last frame of Clip 1. Both clips must show the same person, same location, same lighting. Clip 2 continues motion from Clip 1's frozen ending frame.\n\nUse the Kling prompt format for every clip:\n[Shot type] of [character name] [action]. The scene starts at [keyframe 1]. Camera Motion: [motion] ending in [keyframe 2]. Lighting: [style]. Style: [style].\n\nShot types: Close-up, Medium shot, Over-the-shoulder, Wide shot, Tracking shot.\nCamera motions: Slow push in, Slow pull back, Rack focus, Static hold, Subtle drift right/left, Pan left/right.\n\nRules for visuals:\n- No text, no captions, no UI overlays, no readable code on screen\n- Photorealistic, specific objects, natural-feeling details\n- Keyframe 2 (clip 1's end) must be a specific held moment clip 2 continues from\n\nvoiceover: The character's internal tension, in 22 words maximum. Not a summary. Count every word.\naudioMood: Specific instruments + tempo + emotional register + \"no vocals, no lyrics\".\n\nRespond with JSON only: {\"clip1\": \"...\", \"clip2\": \"...\", \"voiceover\": \"...\", \"audioMood\": \"...\"}`\n\nasync function callEpisodeLLM(apiKey: string, baseUrl: string, brief: EpisodeBriefInput): Promise<ClipPromptPair> {\n const userContent = `Character: ${brief.characterName}\nSection: ${brief.sectionTitle}\nStory moment: ${brief.storyMoment}\n\nExisting clip1 (refine if needed): ${brief.clip1}\nExisting clip2 (refine if needed): ${brief.clip2}\nExisting voiceover: ${brief.voiceover}\nExisting audioMood: ${brief.audioMood}\n\nProduce refined Kling-format prompts that follow all rules. Keep what's good, fix what violates the format.`\n\n const res = await fetch(baseUrl, {\n method: 'POST',\n headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: QWEN_MODEL,\n temperature: 0.5,\n messages: [\n { role: 'system', content: EPISODE_SYSTEM_PROMPT },\n { role: 'user', content: userContent },\n ],\n }),\n })\n if (!res.ok) throw new Error(`LLM episode call failed (${res.status}): ${await res.text()}`)\n\n const data = (await res.json()) as ChatResponse\n const raw = data.choices[0]?.message?.content?.trim() ?? ''\n const match = raw.match(/\\{[\\s\\S]*\\}/)\n if (!match) throw new Error(`No JSON in episode response: ${raw.slice(0, 200)}`)\n\n const parsed = JSON.parse(match[0]) as Partial<ClipPromptPair>\n if (!parsed.clip1 || !parsed.clip2 || !parsed.voiceover || !parsed.audioMood) {\n throw new Error(`Episode response missing fields: ${raw.slice(0, 200)}`)\n }\n return parsed as ClipPromptPair\n}\n\nexport async function regenerateEpisodePrompts(brief: EpisodeBriefInput): Promise<ClipPromptPair> {\n const deepinfraKey = process.env['DEEPINFRA_API_KEY']\n const openrouterKey = process.env['OPENROUTER_API_KEY']\n\n if (deepinfraKey) {\n try {\n return await callEpisodeLLM(deepinfraKey, DEEPINFRA_URL, brief)\n } catch (err) {\n console.warn('[promptBuilder] DeepInfra failed, trying OpenRouter:', (err as Error).message)\n }\n }\n if (openrouterKey) {\n return await callEpisodeLLM(openrouterKey, OPENROUTER_URL, brief)\n }\n throw new Error('No LLM key — set DEEPINFRA_API_KEY or OPENROUTER_API_KEY')\n}\n","import { writeFileSync, mkdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { fal } from '@fal-ai/client'\n\nconst MMAUDIO_MODEL = 'fal-ai/mmaudio-v2'\nconst ELEVENLABS_MODEL = 'fal-ai/elevenlabs/tts'\nconst GEMINI_TTS_MODEL = 'fal-ai/google/gemini-2.5-flash-preview-tts'\n\ninterface VideoOutput { video: { url: string } }\ninterface TTSOutput { audio: { url: string } }\n\nasync function downloadAudio(url: string): Promise<Buffer> {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`Failed to download TTS audio (${res.status})`)\n return Buffer.from(await res.arrayBuffer())\n}\n\nexport async function generateVoiceover(text: string): Promise<string> {\n console.log('[AudioGenerator] Generating voiceover...')\n\n const outDir = join(tmpdir(), `tts-${Date.now()}`)\n mkdirSync(outDir, { recursive: true })\n const outPath = join(outDir, 'voiceover.mp3')\n\n try {\n const voiceId = process.env['ELEVENLABS_VOICE_ID'] ?? 'pNInz6obpgDQGcFmaJgB'\n const result = await fal.run(ELEVENLABS_MODEL, {\n input: { text, voice_id: voiceId, model_id: 'eleven_v3' },\n }) as unknown as TTSOutput\n writeFileSync(outPath, await downloadAudio(result.audio.url))\n console.log('[AudioGenerator] TTS: ElevenLabs via fal')\n return outPath\n } catch (err) {\n console.warn('[AudioGenerator] ElevenLabs via fal failed, trying Gemini:', (err as Error).message)\n }\n\n const voice = process.env['GEMINI_TTS_VOICE'] ?? 'Kore'\n const result = await fal.run(GEMINI_TTS_MODEL, { input: { text, voice } }) as unknown as TTSOutput\n writeFileSync(outPath, await downloadAudio(result.audio.url))\n console.log('[AudioGenerator] TTS: Gemini via fal')\n return outPath\n}\n\nexport async function addBackgroundAudio(videoUrl: string, mood: string, durationSeconds: number): Promise<string> {\n console.log('[AudioGenerator] Adding background audio via MMAudio V2...')\n const result = await fal.run(MMAUDIO_MODEL, {\n input: {\n video_url: videoUrl,\n prompt: mood,\n negative_prompt: 'speech, voice, talking, dialogue, narration, vocals, singing, human voice, conversation, words, lyrics, announcer, commentary',\n duration: durationSeconds,\n cfg_strength: 4.5,\n },\n }) as unknown as VideoOutput\n return result.video.url\n}\n","import { execSync } from 'node:child_process'\nimport { writeFileSync, mkdirSync } from 'node:fs'\nimport { join } from 'node:path'\nimport { fal } from '@fal-ai/client'\n\nasync function download(url: string, destPath: string): Promise<void> {\n const res = await fetch(url)\n if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`)\n writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))\n}\n\nexport async function concatenateClips(clip1Url: string, clip2Url: string, outDir: string): Promise<string> {\n mkdirSync(outDir, { recursive: true })\n const ts = Date.now()\n const p1 = join(outDir, `clip1-${ts}.mp4`)\n const p2 = join(outDir, `clip2-${ts}.mp4`)\n const out = join(outDir, `combined-${ts}.mp4`)\n\n console.log('[VideoMixer] Downloading clips...')\n await Promise.all([download(clip1Url, p1), download(clip2Url, p2)])\n\n console.log('[VideoMixer] Concatenating...')\n execSync(\n `ffmpeg -i \"${p1}\" -i \"${p2}\" -filter_complex \"[0:v][1:v]concat=n=2:v=1:a=0[v]\" -map \"[v]\" -y \"${out}\" -loglevel error`\n )\n return out\n}\n\nexport async function uploadToFal(localPath: string): Promise<string> {\n const { readFileSync } = await import('node:fs')\n const blob = new Blob([readFileSync(localPath)], { type: 'video/mp4' })\n const url = await fal.storage.upload(blob)\n console.log('[VideoMixer] Uploaded to fal:', url)\n return url\n}\n\nexport async function overlayVoiceover(\n videoPath: string,\n voiceoverUrl: string,\n outDir: string,\n): Promise<string> {\n const ts = Date.now()\n const wav = join(outDir, `voiceover-${ts}.wav`)\n const out = join(outDir, `final-${ts}.mp4`)\n\n console.log('[VideoMixer] Downloading voiceover...')\n await download(voiceoverUrl, wav)\n\n console.log('[VideoMixer] Mixing voiceover over background audio...')\n execSync(\n `ffmpeg -i \"${videoPath}\" -i \"${wav}\" ` +\n `-filter_complex \"[0:a]volume=0.2[bg];[1:a]volume=1.0[vo];[bg][vo]amix=inputs=2:duration=first[a]\" ` +\n `-map 0:v -map \"[a]\" -c:v copy -y \"${out}\" -loglevel error`\n )\n return out\n}\n"],"mappings":"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","names":["execSync","writeFileSync","mkdirSync","tmpdir","join","fal","result","writeFileSync","mkdirSync","join","fal","readFileSync","fal","join","writeFileSync","execSync","tmpdir","mkdirSync"]}
|
|
@@ -8,20 +8,14 @@ import {
|
|
|
8
8
|
BrowserDriver,
|
|
9
9
|
MapsSelectors,
|
|
10
10
|
buildYouTubeChannelVideosUrl,
|
|
11
|
-
currentCostContext,
|
|
12
11
|
deleteKernelProxyId,
|
|
13
12
|
harvest,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
recordKernelSession,
|
|
17
|
-
recordVendorUsage,
|
|
18
|
-
resolveKernelProxyId,
|
|
19
|
-
runWithCostContext,
|
|
20
|
-
vendorCostUsd
|
|
21
|
-
} from "./chunk-RMPPYKUV.js";
|
|
13
|
+
resolveKernelProxyId
|
|
14
|
+
} from "./chunk-2HDMYW4B.js";
|
|
22
15
|
import {
|
|
23
16
|
HttpMcpToolExecutor,
|
|
24
17
|
MemoryMcpToolExecutor,
|
|
18
|
+
assessTranscriptSignal,
|
|
25
19
|
buildLinkGraph,
|
|
26
20
|
buildPaaExtractorMcpServer,
|
|
27
21
|
cleanupExpiredConnectedDataArtifacts,
|
|
@@ -36,8 +30,9 @@ import {
|
|
|
36
30
|
registerSerpIntelligenceCaptureTools,
|
|
37
31
|
renewConnectedDataArtifactDownload,
|
|
38
32
|
sanitizeAttempts,
|
|
39
|
-
sanitizeHarvestResult
|
|
40
|
-
|
|
33
|
+
sanitizeHarvestResult,
|
|
34
|
+
transcribeMediaUrl
|
|
35
|
+
} from "./chunk-O2S5TOCG.js";
|
|
41
36
|
import {
|
|
42
37
|
auditImages,
|
|
43
38
|
buildLinkReport,
|
|
@@ -48,8 +43,14 @@ import {
|
|
|
48
43
|
browserServiceApiKey,
|
|
49
44
|
browserServiceProfileName,
|
|
50
45
|
browserServiceProfileSaveChanges,
|
|
51
|
-
browserServiceProxyId
|
|
52
|
-
|
|
46
|
+
browserServiceProxyId,
|
|
47
|
+
currentCostContext,
|
|
48
|
+
kernelCostUsd,
|
|
49
|
+
migrateCostTelemetry,
|
|
50
|
+
recordKernelSession,
|
|
51
|
+
recordVendorUsage,
|
|
52
|
+
runWithCostContext
|
|
53
|
+
} from "./chunk-D7ZT27HY.js";
|
|
53
54
|
import {
|
|
54
55
|
csvRecords,
|
|
55
56
|
listWorkflowDefinitions,
|
|
@@ -73,7 +74,7 @@ import {
|
|
|
73
74
|
RawMapsOverviewSchema,
|
|
74
75
|
RawMapsReviewStatsSchema
|
|
75
76
|
} from "./chunk-XGIPATLV.js";
|
|
76
|
-
import "./chunk-
|
|
77
|
+
import "./chunk-OQHYDW4Q.js";
|
|
77
78
|
import {
|
|
78
79
|
completeExtractJob,
|
|
79
80
|
countSuccessfulPages,
|
|
@@ -8797,41 +8798,6 @@ async function resolveYouTubeAudio(videoId) {
|
|
|
8797
8798
|
}
|
|
8798
8799
|
}
|
|
8799
8800
|
|
|
8800
|
-
// src/services/media-transcription.ts
|
|
8801
|
-
import { fal } from "@fal-ai/client";
|
|
8802
|
-
function transcriptMarkdown(title, text, chunks, durationMs) {
|
|
8803
|
-
const fmtTs2 = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
8804
|
-
const lines = [title, "", `*Transcribed in ${(durationMs / 1e3).toFixed(1)}s*`, "", "## Full Text", "", text, ""];
|
|
8805
|
-
if (chunks.length) {
|
|
8806
|
-
lines.push("## Timestamped Segments", "");
|
|
8807
|
-
for (const ch of chunks) {
|
|
8808
|
-
lines.push(`**[${fmtTs2(ch.timestamp[0])} -> ${fmtTs2(ch.timestamp[1])}]** ${ch.text.trim()}`, "");
|
|
8809
|
-
}
|
|
8810
|
-
}
|
|
8811
|
-
return lines.join("\n");
|
|
8812
|
-
}
|
|
8813
|
-
async function transcribeMediaUrl(mediaUrl, markdownTitle = "# Media Transcript") {
|
|
8814
|
-
const startMs = Date.now();
|
|
8815
|
-
const result = await fal.subscribe("fal-ai/wizper", {
|
|
8816
|
-
input: { audio_url: mediaUrl, task: "transcribe", language: "en" },
|
|
8817
|
-
logs: false,
|
|
8818
|
-
pollInterval: 3e3
|
|
8819
|
-
});
|
|
8820
|
-
const data = result.data;
|
|
8821
|
-
const text = data.text ?? "";
|
|
8822
|
-
const chunks = data.chunks ?? [];
|
|
8823
|
-
const durationMs = Date.now() - startMs;
|
|
8824
|
-
void recordVendorUsage({ vendor: "fal_wizper", model: "fal-ai/wizper", units: durationMs / 1e3, unitType: "compute_sec" });
|
|
8825
|
-
const costUsd = vendorCostUsd("fal_wizper", durationMs / 1e3);
|
|
8826
|
-
return {
|
|
8827
|
-
text,
|
|
8828
|
-
chunks,
|
|
8829
|
-
durationMs,
|
|
8830
|
-
costUsd,
|
|
8831
|
-
markdown: transcriptMarkdown(markdownTitle, text, chunks, durationMs)
|
|
8832
|
-
};
|
|
8833
|
-
}
|
|
8834
|
-
|
|
8835
8801
|
// src/youtube/CaptionFetcher.ts
|
|
8836
8802
|
async function fetchViaYoutubeTranscript(videoId) {
|
|
8837
8803
|
try {
|
|
@@ -10047,11 +10013,13 @@ var FacebookAdExtractor = class {
|
|
|
10047
10013
|
if (n) nameFreq.set(n, (nameFreq.get(n) ?? 0) + 1);
|
|
10048
10014
|
}
|
|
10049
10015
|
const advertiserName = nameFreq.size ? [...nameFreq.entries()].sort((a, b) => b[1] - a[1])[0][0] : null;
|
|
10016
|
+
const matchedAdvertisers = [...nameFreq.entries()].map(([name, adCount]) => ({ name, adCount })).sort((a, b) => b.adCount - a.adCount).slice(0, 20);
|
|
10050
10017
|
return {
|
|
10051
10018
|
listingUrl,
|
|
10052
10019
|
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
10053
10020
|
advertiserPageId,
|
|
10054
10021
|
advertiserName,
|
|
10022
|
+
matchedAdvertisers,
|
|
10055
10023
|
summary: {
|
|
10056
10024
|
totalAds,
|
|
10057
10025
|
activeCount,
|
|
@@ -10435,7 +10403,7 @@ async function extractFacebookOrganicVideoFromPage(page, sourceUrl, quality = "b
|
|
|
10435
10403
|
}
|
|
10436
10404
|
|
|
10437
10405
|
// src/api/facebook-ad-routes.ts
|
|
10438
|
-
import { fal
|
|
10406
|
+
import { fal } from "@fal-ai/client";
|
|
10439
10407
|
var FacebookAdBodySchema = z13.object({
|
|
10440
10408
|
url: z13.string().trim().optional(),
|
|
10441
10409
|
libraryId: z13.string().trim().optional(),
|
|
@@ -10486,6 +10454,45 @@ function buildPageIntelUrl(body, country) {
|
|
|
10486
10454
|
if (body.pageId?.trim()) return `https://www.facebook.com/ads/library/?active_status=all&ad_type=all&country=${country}&is_targeted_country=false&media_type=all&search_type=page&view_all_page_id=${body.pageId.trim()}`;
|
|
10487
10455
|
return `https://www.facebook.com/ads/library/?active_status=all&ad_type=all&country=${country}&q=${encodeURIComponent(body.query.trim())}&search_type=keyword_unordered`;
|
|
10488
10456
|
}
|
|
10457
|
+
function normalizeAdvertiserName(value) {
|
|
10458
|
+
return value.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
|
|
10459
|
+
}
|
|
10460
|
+
function advertiserTokenOverlap(query, candidate) {
|
|
10461
|
+
const queryTokens = new Set(normalizeAdvertiserName(query).split(" ").filter(Boolean));
|
|
10462
|
+
const candidateTokens = new Set(normalizeAdvertiserName(candidate).split(" ").filter(Boolean));
|
|
10463
|
+
if (queryTokens.size === 0 || candidateTokens.size === 0) return 0;
|
|
10464
|
+
let overlap = 0;
|
|
10465
|
+
for (const token of queryTokens) if (candidateTokens.has(token)) overlap++;
|
|
10466
|
+
return overlap / queryTokens.size;
|
|
10467
|
+
}
|
|
10468
|
+
function assessPageIntelMatch(body, result) {
|
|
10469
|
+
if (body.pageId) return { inputMode: "pageId", matchConfidence: "high", matchReason: "Exact Facebook page ID filter.", warnings: [] };
|
|
10470
|
+
if (body.libraryId) return { inputMode: "libraryId", matchConfidence: "high", matchReason: "Exact Ad Library archive ID filter.", warnings: [] };
|
|
10471
|
+
const query = body.query?.trim() ?? "";
|
|
10472
|
+
const normalizedQuery = normalizeAdvertiserName(query);
|
|
10473
|
+
const matches = result.matchedAdvertisers ?? [];
|
|
10474
|
+
const exact = matches.find((match) => normalizeAdvertiserName(match.name) === normalizedQuery);
|
|
10475
|
+
const top = matches[0];
|
|
10476
|
+
const topShare = top && result.summary.totalAds > 0 ? top.adCount / result.summary.totalAds : 0;
|
|
10477
|
+
const overlap = top ? advertiserTokenOverlap(query, top.name) : 0;
|
|
10478
|
+
const warnings = [];
|
|
10479
|
+
let matchConfidence;
|
|
10480
|
+
let matchReason;
|
|
10481
|
+
if (exact && exact.adCount / Math.max(1, result.summary.totalAds) >= 0.7) {
|
|
10482
|
+
matchConfidence = "high";
|
|
10483
|
+
matchReason = "The exact advertiser name accounts for most returned ads.";
|
|
10484
|
+
} else if (top && overlap >= 0.75 && topShare >= 0.5) {
|
|
10485
|
+
matchConfidence = "medium";
|
|
10486
|
+
matchReason = "The leading advertiser overlaps strongly with the query, but keyword search is not page-scoped.";
|
|
10487
|
+
} else {
|
|
10488
|
+
matchConfidence = "low";
|
|
10489
|
+
matchReason = "Keyword search did not identify one dominant exact advertiser match.";
|
|
10490
|
+
}
|
|
10491
|
+
if (matches.length > 1) warnings.push(`Keyword search mixed ${matches.length} advertiser names. Confirm the intended pageId before using these ads for performance or creative analysis.`);
|
|
10492
|
+
if (matchConfidence === "low") warnings.push("Low-specificity Ad Library match: treat these results as discovery candidates, not as verified ads from the requested brand.");
|
|
10493
|
+
warnings.push("Paused, cancelled, or otherwise inactive non-political ads may be absent from Ad Library. Use meta_ad_creative_media for connected ad-account creatives.");
|
|
10494
|
+
return { inputMode: "query", matchConfidence, matchReason, warnings };
|
|
10495
|
+
}
|
|
10489
10496
|
function configuredKernelProxy() {
|
|
10490
10497
|
return (process.env.BROWSER_SERVICE_PROXY_ID ?? process.env.KERNEL_PROXY_ID)?.trim() || void 0;
|
|
10491
10498
|
}
|
|
@@ -10582,8 +10589,10 @@ facebookAdApp.post("/page-intel", createApiKeyAuth(), async (c) => {
|
|
|
10582
10589
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "failed", query: body.pageId ?? body.query ?? body.libraryId ?? "", error: "soft-block: empty result refunded" });
|
|
10583
10590
|
return c.json({ error: "soft-block: no ads returned (refunded)" }, 503);
|
|
10584
10591
|
}
|
|
10585
|
-
|
|
10586
|
-
|
|
10592
|
+
const match = assessPageIntelMatch(body, result);
|
|
10593
|
+
const output = { ...result, ...match };
|
|
10594
|
+
await logRequestEvent({ userId: fbUser.id, source: "facebook_page_intel", status: "done", query: body.pageId ?? body.query ?? body.libraryId ?? "", resultCount: result.ads.length, result: output });
|
|
10595
|
+
return c.json(output);
|
|
10587
10596
|
} catch (err) {
|
|
10588
10597
|
const msg = err instanceof Error ? err.message : String(err);
|
|
10589
10598
|
if (debited && !refunded) await creditMc(fbUser.id, MC_COSTS.fb_ad, LedgerOperation.FB_AD_REFUND, "failed call");
|
|
@@ -10615,7 +10624,7 @@ facebookAdApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
10615
10624
|
metadata: { videoUrl }
|
|
10616
10625
|
});
|
|
10617
10626
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10618
|
-
|
|
10627
|
+
fal.config({ credentials: process.env.FAL_KEY });
|
|
10619
10628
|
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
10620
10629
|
let debited = false;
|
|
10621
10630
|
try {
|
|
@@ -10659,7 +10668,7 @@ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
|
|
|
10659
10668
|
metadata: { url: sourceUrl.href }
|
|
10660
10669
|
});
|
|
10661
10670
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
10662
|
-
|
|
10671
|
+
fal.config({ credentials: process.env.FAL_KEY });
|
|
10663
10672
|
const driver = new BrowserDriver();
|
|
10664
10673
|
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
10665
10674
|
let debited = false;
|
|
@@ -10684,7 +10693,8 @@ facebookAdApp.post("/video-transcribe", createApiKeyAuth(), async (c) => {
|
|
|
10684
10693
|
text: transcript.text,
|
|
10685
10694
|
chunks: transcript.chunks,
|
|
10686
10695
|
durationMs: transcript.durationMs,
|
|
10687
|
-
markdown: transcript.markdown
|
|
10696
|
+
markdown: transcript.markdown,
|
|
10697
|
+
transcriptSignal: assessTranscriptSignal(transcript.text, transcript.chunks, video.durationSec)
|
|
10688
10698
|
};
|
|
10689
10699
|
await logRequestEvent({ userId: fbUser.id, source: "facebook_video_transcribe", status: "done", query: sourceUrl.href, resultCount: transcript.chunks.length, result });
|
|
10690
10700
|
return c.json(result);
|
|
@@ -11018,7 +11028,7 @@ var GoogleAdsExtractor = class {
|
|
|
11018
11028
|
};
|
|
11019
11029
|
|
|
11020
11030
|
// src/api/google-ads-routes.ts
|
|
11021
|
-
import { fal as
|
|
11031
|
+
import { fal as fal2 } from "@fal-ai/client";
|
|
11022
11032
|
var GoogleAdsSearchBodySchema = z14.object({
|
|
11023
11033
|
query: z14.string().trim().min(1, "query is required"),
|
|
11024
11034
|
region: z14.string().trim().toUpperCase().length(2).optional(),
|
|
@@ -11148,7 +11158,7 @@ googleAdsApp.post("/transcribe", createApiKeyAuth(), async (c) => {
|
|
|
11148
11158
|
const user = c.get("user");
|
|
11149
11159
|
const gate = await acquireConcurrencyGate(user, "google_ads_transcribe", { reuseLockId: c.req.header("x-mcp-scraper-concurrency-lock"), metadata: { videoUrl } });
|
|
11150
11160
|
if (!gate.ok) return c.json(concurrencyLimitExceededResponse(gate), 429, { "Retry-After": String(gate.retryAfterSeconds) });
|
|
11151
|
-
|
|
11161
|
+
fal2.config({ credentials: process.env.FAL_KEY });
|
|
11152
11162
|
const holdMc = MEDIA_TRANSCRIBE_HOLD_MC;
|
|
11153
11163
|
let debited = false;
|
|
11154
11164
|
try {
|
|
@@ -24260,7 +24270,7 @@ app.get("/cron/tick", async (c) => {
|
|
|
24260
24270
|
if (!process.env.CRON_SECRET || secret2 !== `Bearer ${process.env.CRON_SECRET}`) {
|
|
24261
24271
|
return c.json({ error: "Unauthorized" }, 401);
|
|
24262
24272
|
}
|
|
24263
|
-
const { drainQueue } = await import("./worker-
|
|
24273
|
+
const { drainQueue } = await import("./worker-AM2DHUWG.js");
|
|
24264
24274
|
const budget = { maxJobs: 10, deadlineMs: Date.now() + 28e4 };
|
|
24265
24275
|
const workflowDispatchResult = await dispatchDueWorkflowSchedules(`${new URL(c.req.url).protocol}//${new URL(c.req.url).host}`);
|
|
24266
24276
|
const [results, sweepResult, reapResult, expiredResult, blobCleanup, connectedDataArtifactCleanup] = await Promise.all([
|
|
@@ -24461,4 +24471,4 @@ app.get("/blog/:slug/", (c) => {
|
|
|
24461
24471
|
export {
|
|
24462
24472
|
app
|
|
24463
24473
|
};
|
|
24464
|
-
//# sourceMappingURL=server-
|
|
24474
|
+
//# sourceMappingURL=server-VWXDE64Y.js.map
|