From f2d6f56831fd51e850261ccc1613a49ecf3affe5 Mon Sep 17 00:00:00 2001 From: DJP Date: Wed, 8 Apr 2026 09:52:08 -0400 Subject: [PATCH] Report quality overhaul: 11 feedback items 1. Remove Desk Research (Stage 7 skipped, sources removed from report) 2. Fix comments scraping: increase cap to 2000, handle alt field names 3. Dynamic stats bar: hide zero-value stats instead of showing "0 Comments" 4. Prompt improvements: enforce timeliness, comment-based insights, creator spotlight algorithm (2-10 videos, exclude >50% dominance) 5. Date filtering: pass date params to Apify actors (oldestCreateTime, onlyPostsNewerThan, uploadDate) + log filter counts 6. Pullquotes: 3-4 generated editorial dividers between sections 7. Thumbnails: download top 50 coverUrl as base64, store on EnrichedVideo 8. Visual Language section: 5 batches of 10 through Claude Vision, synthesized into 5-6 visual codes with thumbnail cards 9. Sticky navigation bar with anchor links to all sections 10. New types: VisualCode, thumbnailUrl on Video, thumbnailBase64 on EnrichedVideo, pullquotes/visualCodes on ReportJSON Co-Authored-By: Claude Opus 4.6 --- agents/social-listening/apify.ts | 2 +- agents/social-listening/claude-cli.ts | 35 +++++ agents/social-listening/html-report.ts | 117 +++++++++++----- agents/social-listening/pipeline-v2.ts | 8 +- .../stages/stage3-discovery-scrape.ts | 11 +- .../stages/stage5-enrichment-scrape.ts | 52 ++++++- .../social-listening/stages/stage8-report.ts | 129 +++++++++++++++--- agents/social-listening/types-v2.ts | 18 ++- 8 files changed, 302 insertions(+), 70 deletions(-) diff --git a/agents/social-listening/apify.ts b/agents/social-listening/apify.ts index ad75e61..372b0e0 100644 --- a/agents/social-listening/apify.ts +++ b/agents/social-listening/apify.ts @@ -205,5 +205,5 @@ export async function runActor( export function getLimits() { return IS_TEST ? { resultsPerPage: 100, resultsLimit: 100, maxResults: 100, maxComments: 100, transcriptBatch: 10, profileLimit: 100 } - : { resultsPerPage: 200, resultsLimit: 100, maxResults: 100, maxComments: 1000, transcriptBatch: 25, profileLimit: 200 }; + : { resultsPerPage: 200, resultsLimit: 100, maxResults: 100, maxComments: 2000, transcriptBatch: 25, profileLimit: 200 }; } diff --git a/agents/social-listening/claude-cli.ts b/agents/social-listening/claude-cli.ts index a31fb9b..f2ed4d9 100644 --- a/agents/social-listening/claude-cli.ts +++ b/agents/social-listening/claude-cli.ts @@ -283,3 +283,38 @@ export async function callClaudeJSON(prompt: string, model?: string, options? } throw new Error('Unreachable'); } + +/** Call Claude with images (vision) — accepts base64 data URIs + a text prompt */ +export async function callClaudeVision( + imageBase64s: string[], + textPrompt: string, + model?: string, +): Promise { + const m = model || DEFAULT_MODEL; + const content: ApiContentBlock[] = []; + + for (const b64 of imageBase64s) { + // Parse data:image/jpeg;base64,... format + const commaIdx = b64.indexOf(','); + const meta = b64.slice(0, commaIdx); + const data = b64.slice(commaIdx + 1); + const mediaType = meta.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; + content.push({ + type: 'image', + source: { type: 'base64', media_type: mediaType, data } as unknown as Record, + } as unknown as ApiContentBlock); + } + + content.push({ type: 'text', text: textPrompt }); + + const messages: ApiMessage[] = [{ role: 'user', content }]; + const response = await callApi(messages, m, { maxTokens: 4096 }); + const usage: ClaudeUsage = { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + costUsd: calculateCost(m, response.usage.input_tokens, response.usage.output_tokens), + model: m, + }; + reportUsage(usage, 'vision_analysis'); + return { text: extractText(response), usage }; +} diff --git a/agents/social-listening/html-report.ts b/agents/social-listening/html-report.ts index 6cc8d75..bf38b30 100644 --- a/agents/social-listening/html-report.ts +++ b/agents/social-listening/html-report.ts @@ -1,5 +1,5 @@ // ─── HTML Report Generator ─── -import { ReportJSON, ClientBrief, Trend, TrendVideo, ContentOpportunity } from './types-v2.js'; +import { ReportJSON, ClientBrief, Trend, TrendVideo, ContentOpportunity, VisualCode } from './types-v2.js'; interface ReportStats { videosScraped: number; @@ -16,9 +16,14 @@ export function buildMarkdown(report: ReportJSON, brief: ClientBrief, stats: Rep lines.push(`# Social Listening Report — ${brief.clientName}`); lines.push(`**${brief.category}** — ${formatDateRange(brief.dateRange)}`); lines.push(''); - lines.push(`| Videos Scraped | Comments Analysed | Transcripts | Desk Sources |`); - lines.push(`|---|---|---|---|`); - lines.push(`| ${stats.videosScraped} | ${stats.commentsAnalysed} | ${stats.transcriptsDownloaded} | ${stats.deskSources} |`); + const mdStats = [ + { label: 'Videos Scraped', value: stats.videosScraped }, + { label: 'Comments Analysed', value: stats.commentsAnalysed }, + { label: 'Transcripts', value: stats.transcriptsDownloaded }, + ].filter(s => s.value > 0); + lines.push(`| ${mdStats.map(s => s.label).join(' | ')} |`); + lines.push(`| ${mdStats.map(() => '---').join(' | ')} |`); + lines.push(`| ${mdStats.map(s => s.value).join(' | ')} |`); lines.push(''); lines.push('## Executive Summary'); @@ -72,11 +77,6 @@ export function buildMarkdown(report: ReportJSON, brief: ClientBrief, stats: Rep lines.push(''); } - lines.push('## Desk Research Sources'); - for (const s of report.deskSources) { - lines.push(`- [${s.title}](${s.url}) — ${s.summary}`); - } - return lines.join('\n'); } @@ -193,9 +193,37 @@ function deriveFormatCards(trends: Trend[]): { icon: string; name: string; desc: return formats.slice(0, 6); } -export function generateHtmlReport(report: ReportJSON, brief: ClientBrief, stats: ReportStats): string { +function renderVisualLanguageSection(visualCodes: VisualCode[], thumbnailMap?: Record): string { + if (!visualCodes?.length) return ''; + + const cards = visualCodes.map(vc => { + // Try to find a thumbnail for the example video + const thumb = thumbnailMap && vc.exampleVideoUrl ? thumbnailMap[vc.exampleVideoUrl] : null; + const thumbHtml = thumb + ? `
${esc(vc.name)}
` + : ''; + + return `
+
${esc(vc.name)}
+ ${thumbHtml} +
+

${esc(vc.description)}

+
${esc(vc.frequency)}
+ ${vc.exampleAuthor ? `
${esc(vc.exampleAuthor)} — ${(vc.examplePlays || 0).toLocaleString()} plays
` : ''} +
+
`; + }).join('\n'); + + return ` + +
Visual Language
+
${cards}
`; +} + +export function generateHtmlReport(report: ReportJSON, brief: ClientBrief, stats: ReportStats, thumbnailMap?: Record): string { const hasTikTok = report.trends.some(t => t.topVideoUrl?.includes('tiktok.com') || t.supportingVideos?.some(sv => sv.platform === 'tiktok')); const hasInstagram = report.trends.some(t => t.topVideoUrl?.includes('instagram.com') || t.supportingVideos?.some(sv => sv.platform === 'instagram')); + const visualLanguageHtml = renderVisualLanguageSection(report.visualCodes || [], thumbnailMap); const trendsHtml = report.trends.map((t, i) => { const variationsHtml = t.variations.map(v => `
  • ${esc(v)}
  • `).join('\n'); @@ -250,9 +278,11 @@ export function generateHtmlReport(report: ReportJSON, brief: ClientBrief, stats `; }).join('\n'); - // Pullquote after first half of trends - const pullquoteIndex = Math.floor(report.trends.length / 2); - const pullquoteText = report.trends[pullquoteIndex]?.humanTruth || report.executiveSummary.split('.')[0]; + // Pullquotes — use generated ones if available, fallback to trend humanTruth + const pullquotes = report.pullquotes?.length + ? report.pullquotes + : [report.trends[Math.floor(report.trends.length / 2)]?.humanTruth || report.executiveSummary.split('.')[0]]; + const pq = (i: number) => pullquotes[i] ? `
    ${esc(pullquotes[i])}
    ` : ''; const insightsHtml = report.audienceInsights.map(ins => `
    @@ -312,10 +342,6 @@ export function generateHtmlReport(report: ReportJSON, brief: ClientBrief, stats
    `; }).join('\n'); - const sourcesHtml = report.deskSources.map(s => - `
  • ${esc(s.title)} — ${esc(s.summary.slice(0, 120))}
  • ` - ).join('\n'); - return ` @@ -397,6 +423,17 @@ hr { border: none; border-top: 2px solid #1a1a1a; margin: 48px 0; } .supporting-author { font-size: 13px; font-weight: 700; color: #1a1a1a; margin-bottom: 4px; } .supporting-desc { font-size: 12px; color: #666; line-height: 1.4; margin-bottom: 6px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .supporting-plays { font-size: 11px; font-weight: 600; color: #f5a623; } +.vc-row { display: flex; flex-direction: column; gap: 16px; margin: 28px 0; } +.vc-card { display: flex; gap: 20px; background: #fff; border: 1px solid #e8e8e8; border-radius: 12px; overflow: hidden; align-items: stretch; } +.vc-label { writing-mode: vertical-rl; text-orientation: mixed; background: #1a1a1a; color: #fff; font-size: 12px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding: 20px 14px; display: flex; align-items: center; justify-content: center; min-width: 50px; } +.vc-thumb { flex-shrink: 0; display: flex; align-items: center; padding: 16px 0; } +.vc-desc { padding: 20px; flex: 1; display: flex; flex-direction: column; justify-content: center; } +.vc-desc p { color: #444; margin-bottom: 8px; font-size: 15px; } +.vc-freq { font-size: 12px; color: #888; font-weight: 600; } +.vc-example { font-size: 12px; color: #f5a623; font-weight: 600; margin-top: 4px; } +.sticky-nav { position: sticky; top: 0; z-index: 100; background: rgba(255,255,255,0.95); backdrop-filter: blur(8px); border-bottom: 1px solid #e8e8e8; padding: 12px 0; display: flex; gap: 24px; justify-content: center; flex-wrap: wrap; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; } +.sticky-nav a { color: #666; text-decoration: none; transition: color 0.2s; } +.sticky-nav a:hover { color: #1a1a1a; } .footer { text-align: center; padding: 48px 0; color: #888; font-size: 12px; } @media (max-width: 768px) { .container { padding: 24px 16px; } @@ -407,6 +444,15 @@ hr { border: none; border-top: 2px solid #1a1a1a; margin: 48px 0; } +
    @@ -415,49 +461,48 @@ hr { border: none; border-top: 2px solid #1a1a1a; margin: 48px 0; }
    ${esc(brief.category)} — ${formatDateRange(brief.dateRange)}
    -
    -
    ${stats.videosScraped}
    Videos Scraped
    -
    ${stats.commentsAnalysed}
    Comments Analysed
    -
    ${stats.transcriptsDownloaded}
    Transcripts Downloaded
    -
    ${stats.deskSources}
    Desk Sources
    +
    +${stats.videosScraped > 0 ? `
    ${stats.videosScraped}
    Videos Scraped
    ` : ''} +${stats.commentsAnalysed > 0 ? `
    ${stats.commentsAnalysed}
    Comments Analysed
    ` : ''} +${stats.transcriptsDownloaded > 0 ? `
    ${stats.transcriptsDownloaded}
    Transcripts Downloaded
    ` : ''}

    -
    ${esc(report.executiveSummary)}
    +
    ${esc(report.executiveSummary)}
    -
    01 — Category Trends
    + ${trendsHtml} -
    ${esc(pullquoteText)}
    +${visualLanguageHtml} + +${pq(0)} -
    02 — Audience Insights
    +
    02 — Audience Insights
    ${insightsHtml}
    +${pq(1)} + -
    The Formats That Drive Engagement
    +
    The Formats That Drive Engagement
    ${formatsHtml}
    -
    03 — Content Opportunities
    +
    03 — Content Opportunities
    ${oppsHtml} - -
    04 — Creator Spotlight
    -${creatorsHtml} +${pq(2)} - -
    Desk Research Sources
    -
      -${sourcesHtml} -
    + +
    04 — Creator Spotlight
    +${creatorsHtml}