heyhank 0.1.0 → 0.2.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/LICENSE +21 -0
- package/README.md +83 -10
- package/bin/cli.ts +7 -7
- package/bin/ctl.ts +42 -42
- package/dist/assets/{AgentsPage-BPhirnCe.js → AgentsPage-B-AAmsMK.js} +3 -3
- package/dist/assets/AssistantPage-BV1Mfwdt.js +2 -0
- package/dist/assets/BusinessPage-tLpNEz19.js +1 -0
- package/dist/assets/{CronManager-DDbz-yiT.js → CronManager-B-K_n3Jg.js} +1 -1
- package/dist/assets/HelpPage-Bhf_j6Xr.js +1 -0
- package/dist/assets/{IntegrationsPage-CrOitCmJ.js → IntegrationsPage-DAMjs9tM.js} +1 -1
- package/dist/assets/JarvisHUD-C_TGXCCn.js +120 -0
- package/dist/assets/MediaPage-C48HTTrt.js +1 -0
- package/dist/assets/MemoryPage-JkC-qtgp.js +1 -0
- package/dist/assets/{PlatformDashboard-Do6F0O2p.js → PlatformDashboard-AUo7tNnE.js} +1 -1
- package/dist/assets/{Playground-Fc5cdc5p.js → Playground-AzNMsRBL.js} +1 -1
- package/dist/assets/{ProcessPanel-CslEiZkI.js → ProcessPanel-DpE_2sX3.js} +1 -1
- package/dist/assets/{PromptsPage-D2EhsdNO.js → PromptsPage-C2RQOs6p.js} +2 -2
- package/dist/assets/RunsPage-B9UOyO79.js +1 -0
- package/dist/assets/{SandboxManager-a1AVI5q2.js → SandboxManager-jHvYjwfh.js} +1 -1
- package/dist/assets/SettingsPage-BBJax6gt.js +51 -0
- package/dist/assets/SkillsMarketplace-IjmjfdjD.js +1 -0
- package/dist/assets/SocialMediaPage-DoPZHhr2.js +10 -0
- package/dist/assets/{TailscalePage-CHiFhZXF.js → TailscalePage-DDEY7ckO.js} +1 -1
- package/dist/assets/TelephonyPage-OPNBZYKt.js +9 -0
- package/dist/assets/{TerminalPage-Drwyrnfd.js → TerminalPage-BjMbHHW3.js} +1 -1
- package/dist/assets/{gemini-live-client-C7rqAW7G.js → gemini-live-client-C70FEtX2.js} +11 -8
- package/dist/assets/{index-CEqZnThB.js → index-BgYM4wXw.js} +94 -93
- package/dist/assets/index-BkjSoVgn.css +32 -0
- package/dist/assets/sw-register-C7NOHtIu.js +1 -0
- package/dist/assets/text-chat-client-BSbLJerZ.js +2 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/package.json +6 -1
- package/server/agent-executor.ts +37 -2
- package/server/agent-store.ts +3 -3
- package/server/agent-types.ts +11 -0
- package/server/assistant-store.ts +232 -6
- package/server/auth-manager.ts +9 -0
- package/server/cache-headers.ts +1 -1
- package/server/calendar-service.ts +10 -0
- package/server/ceo/document-store.ts +129 -0
- package/server/ceo/finance-store.ts +343 -0
- package/server/ceo/kpi-store.ts +208 -0
- package/server/ceo/memory-import.ts +277 -0
- package/server/ceo/news-store.ts +208 -0
- package/server/ceo/template-store.ts +134 -0
- package/server/ceo/time-tracking-store.ts +227 -0
- package/server/claude-auth-monitor.ts +128 -0
- package/server/claude-code-worker.ts +86 -0
- package/server/claude-session-discovery.ts +74 -1
- package/server/cli-launcher.ts +32 -10
- package/server/codex-adapter.ts +2 -2
- package/server/codex-ws-proxy.cjs +1 -1
- package/server/container-manager.ts +4 -4
- package/server/content-intelligence/content-engine.ts +1112 -0
- package/server/content-intelligence/platform-knowledge.ts +870 -0
- package/server/cron-store.ts +3 -3
- package/server/embedding-service.ts +49 -0
- package/server/event-bus-types.ts +13 -0
- package/server/federation/node-store.ts +5 -4
- package/server/fs-utils.ts +28 -1
- package/server/hank-notifications-store.ts +91 -0
- package/server/hank-tool-executor.ts +1835 -0
- package/server/hank-tools.ts +2107 -0
- package/server/image-pull-manager.ts +2 -2
- package/server/index.ts +25 -2
- package/server/llm-providers-streaming.ts +541 -0
- package/server/llm-providers.ts +12 -0
- package/server/marketplace.ts +249 -0
- package/server/mcp-registry.ts +158 -0
- package/server/memory-service.ts +296 -0
- package/server/obsidian-sync.ts +184 -0
- package/server/provider-manager.ts +5 -2
- package/server/provider-registry.ts +12 -0
- package/server/reminder-scheduler.ts +37 -1
- package/server/routes/agent-routes.ts +2 -1
- package/server/routes/assistant-routes.ts +198 -5
- package/server/routes/ceo-finance-kpi-routes.ts +167 -0
- package/server/routes/ceo-news-time-routes.ts +137 -0
- package/server/routes/ceo-routes.ts +99 -0
- package/server/routes/content-routes.ts +116 -0
- package/server/routes/email-routes.ts +147 -0
- package/server/routes/env-routes.ts +3 -3
- package/server/routes/fs-routes.ts +12 -9
- package/server/routes/hank-chat-routes.ts +592 -0
- package/server/routes/llm-routes.ts +12 -0
- package/server/routes/marketplace-routes.ts +63 -0
- package/server/routes/media-routes.ts +1 -1
- package/server/routes/memory-routes.ts +127 -0
- package/server/routes/platform-routes.ts +14 -675
- package/server/routes/sandbox-routes.ts +1 -1
- package/server/routes/settings-routes.ts +51 -1
- package/server/routes/socialmedia-routes.ts +152 -2
- package/server/routes/system-routes.ts +2 -2
- package/server/routes/team-routes.ts +71 -0
- package/server/routes/telephony-routes.ts +98 -18
- package/server/routes.ts +36 -9
- package/server/session-creation-service.ts +2 -2
- package/server/session-orchestrator.ts +54 -2
- package/server/session-types.ts +2 -0
- package/server/settings-manager.ts +50 -2
- package/server/skill-discovery.ts +68 -0
- package/server/socialmedia/adapters/browser-adapter.ts +179 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +291 -14
- package/server/socialmedia/manager.ts +234 -15
- package/server/socialmedia/store.ts +51 -1
- package/server/socialmedia/types.ts +35 -2
- package/server/socialview/browser-manager.ts +150 -0
- package/server/socialview/extractors.ts +1298 -0
- package/server/socialview/image-describe.ts +188 -0
- package/server/socialview/library.ts +119 -0
- package/server/socialview/poster.ts +276 -0
- package/server/socialview/routes.ts +371 -0
- package/server/socialview/style-analyzer.ts +187 -0
- package/server/socialview/style-profiles.ts +67 -0
- package/server/socialview/types.ts +166 -0
- package/server/socialview/vision.ts +127 -0
- package/server/socialview/vnc-manager.ts +110 -0
- package/server/style-injector.ts +135 -0
- package/server/team-service.ts +239 -0
- package/server/team-store.ts +75 -0
- package/server/team-types.ts +52 -0
- package/server/telephony/audio-bridge.ts +281 -35
- package/server/telephony/audio-recorder.ts +132 -0
- package/server/telephony/call-manager.ts +803 -104
- package/server/telephony/call-types.ts +67 -1
- package/server/telephony/esl-client.ts +319 -0
- package/server/telephony/freeswitch-sync.ts +155 -0
- package/server/telephony/phone-utils.ts +63 -0
- package/server/telephony/telephony-store.ts +9 -8
- package/server/url-validator.ts +82 -0
- package/server/vault-markdown.ts +317 -0
- package/server/vault-migration.ts +121 -0
- package/server/vault-store.ts +466 -0
- package/server/vault-watcher.ts +59 -0
- package/server/vector-store.ts +210 -0
- package/server/voice-pipeline/gemini-live-adapter.ts +97 -0
- package/server/voice-pipeline/greeting-cache.ts +200 -0
- package/server/voice-pipeline/manager.ts +249 -0
- package/server/voice-pipeline/pipeline.ts +335 -0
- package/server/voice-pipeline/providers/index.ts +47 -0
- package/server/voice-pipeline/providers/llm-internal.ts +527 -0
- package/server/voice-pipeline/providers/stt-google.ts +157 -0
- package/server/voice-pipeline/providers/tts-google.ts +126 -0
- package/server/voice-pipeline/types.ts +247 -0
- package/server/ws-bridge-types.ts +6 -1
- package/dist/assets/AssistantPage-DJ-cMQfb.js +0 -1
- package/dist/assets/HelpPage-DMfkzERp.js +0 -1
- package/dist/assets/MediaPage-CE5rdvkC.js +0 -1
- package/dist/assets/RunsPage-C5BZF5Rx.js +0 -1
- package/dist/assets/SettingsPage-DirhjQrJ.js +0 -51
- package/dist/assets/SocialMediaPage-DBuM28vD.js +0 -1
- package/dist/assets/TelephonyPage-x0VV0fOo.js +0 -1
- package/dist/assets/index-C8M_PUmX.css +0 -32
- package/dist/assets/sw-register-LSSpj6RU.js +0 -1
- package/server/socialmedia/adapters/ayrshare-adapter.ts +0 -169
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
// ─── Content Engine / Ad Creator ────────────────────────────────────────────
|
|
2
|
+
// Comprehensive social media content generation system.
|
|
3
|
+
// Analyzes websites, creates content strategies, and generates
|
|
4
|
+
// platform-optimized content and ad creatives.
|
|
5
|
+
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { callInternalAI } from "../internal-ai.js";
|
|
8
|
+
import { selectForFewShot } from "../socialview/library.js";
|
|
9
|
+
import { getProfile as getStyleProfile } from "../socialview/style-profiles.js";
|
|
10
|
+
import type { SocialPlatform, StyleProfile } from "../socialview/types.js";
|
|
11
|
+
import { SOCIAL_PLATFORMS } from "../socialview/types.js";
|
|
12
|
+
import {
|
|
13
|
+
getPlatform,
|
|
14
|
+
buildPlatformSummary,
|
|
15
|
+
ALL_PLATFORMS,
|
|
16
|
+
type PlatformSpec,
|
|
17
|
+
} from "./platform-knowledge.js";
|
|
18
|
+
import { listHashtagPools } from "../socialmedia/store.js";
|
|
19
|
+
|
|
20
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface WebsiteIntelligence {
|
|
23
|
+
url: string;
|
|
24
|
+
businessType: "ecommerce" | "service" | "saas" | "blog" | "portfolio" | "agency" | "other";
|
|
25
|
+
industry: string;
|
|
26
|
+
companyName: string;
|
|
27
|
+
language: string;
|
|
28
|
+
products: Array<{ name: string; description: string; price?: string; imageUrl?: string }>;
|
|
29
|
+
services: Array<{ name: string; description: string }>;
|
|
30
|
+
usp: string[];
|
|
31
|
+
targetAudience: string;
|
|
32
|
+
tone: string;
|
|
33
|
+
colors: string[];
|
|
34
|
+
fonts: string[];
|
|
35
|
+
logo?: { url: string; alt?: string };
|
|
36
|
+
heroImages: Array<{ url: string; alt?: string }>;
|
|
37
|
+
productImages: Array<{ url: string; alt?: string }>;
|
|
38
|
+
headlines: string[];
|
|
39
|
+
ctas: string[];
|
|
40
|
+
testimonials: string[];
|
|
41
|
+
title: string;
|
|
42
|
+
description: string;
|
|
43
|
+
ogImage?: string;
|
|
44
|
+
crawledPages: string[];
|
|
45
|
+
analyzedAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ContentPillar {
|
|
49
|
+
name: string;
|
|
50
|
+
description: string;
|
|
51
|
+
painPoints: string[];
|
|
52
|
+
contentIdeas: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ContentSchedule {
|
|
56
|
+
platform: string;
|
|
57
|
+
postsPerWeek: number;
|
|
58
|
+
bestDays: string[];
|
|
59
|
+
bestHours: string;
|
|
60
|
+
formats: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ContentStrategy {
|
|
64
|
+
businessType: string;
|
|
65
|
+
pillars: ContentPillar[];
|
|
66
|
+
schedules: ContentSchedule[];
|
|
67
|
+
tone: string;
|
|
68
|
+
ctas: string[];
|
|
69
|
+
journeyMapping: {
|
|
70
|
+
attract: string[];
|
|
71
|
+
convert: string[];
|
|
72
|
+
close: string[];
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type JourneyStage = "attract" | "convert" | "close";
|
|
77
|
+
export type CopyFramework = "PAS" | "AIDA" | "BAB" | "StoryBrand";
|
|
78
|
+
|
|
79
|
+
export interface ContentPiece {
|
|
80
|
+
id: string;
|
|
81
|
+
platform: string;
|
|
82
|
+
type: "social-post" | "blog" | "newsletter" | "ad";
|
|
83
|
+
journeyStage: JourneyStage;
|
|
84
|
+
framework: CopyFramework;
|
|
85
|
+
pillar: string;
|
|
86
|
+
targetPain: string;
|
|
87
|
+
hook: string;
|
|
88
|
+
headline: string;
|
|
89
|
+
body: string;
|
|
90
|
+
cta: string;
|
|
91
|
+
hashtags: string[];
|
|
92
|
+
imagePrompt?: string;
|
|
93
|
+
imageUrl?: string;
|
|
94
|
+
scheduledFor?: string;
|
|
95
|
+
status: "draft" | "review" | "approved" | "published";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AdCreative {
|
|
99
|
+
id: string;
|
|
100
|
+
platform: string;
|
|
101
|
+
format: string;
|
|
102
|
+
aspectRatio: string;
|
|
103
|
+
resolution: string;
|
|
104
|
+
headline: string;
|
|
105
|
+
body: string;
|
|
106
|
+
cta: string;
|
|
107
|
+
imagePrompt: string;
|
|
108
|
+
brandColors: string[];
|
|
109
|
+
tone: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Cache ──────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const analysisCache = new Map<string, { data: WebsiteIntelligence; expiresAt: number }>();
|
|
115
|
+
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
116
|
+
|
|
117
|
+
function getCached(url: string): WebsiteIntelligence | null {
|
|
118
|
+
const entry = analysisCache.get(url);
|
|
119
|
+
if (!entry) return null;
|
|
120
|
+
if (Date.now() > entry.expiresAt) {
|
|
121
|
+
analysisCache.delete(url);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return entry.data;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function setCache(url: string, data: WebsiteIntelligence): void {
|
|
128
|
+
analysisCache.set(url, { data, expiresAt: Date.now() + CACHE_TTL_MS });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── HTML Parsing Helpers ───────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function extractMetaTags(html: string): Record<string, string> {
|
|
134
|
+
const tags: Record<string, string> = {};
|
|
135
|
+
const metaRegex = /<meta\s+[^>]*?(?:name|property|http-equiv)\s*=\s*["']([^"']+)["'][^>]*?content\s*=\s*["']([^"']*?)["'][^>]*?\/?>/gi;
|
|
136
|
+
const metaRegex2 = /<meta\s+[^>]*?content\s*=\s*["']([^"']*?)["'][^>]*?(?:name|property)\s*=\s*["']([^"']+)["'][^>]*?\/?>/gi;
|
|
137
|
+
let match: RegExpExecArray | null;
|
|
138
|
+
while ((match = metaRegex.exec(html)) !== null) {
|
|
139
|
+
tags[match[1].toLowerCase()] = match[2];
|
|
140
|
+
}
|
|
141
|
+
while ((match = metaRegex2.exec(html)) !== null) {
|
|
142
|
+
tags[match[2].toLowerCase()] = match[1];
|
|
143
|
+
}
|
|
144
|
+
// Title tag
|
|
145
|
+
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
|
146
|
+
if (titleMatch) tags["title"] = titleMatch[1].trim();
|
|
147
|
+
return tags;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function extractImages(html: string, baseUrl: string): Array<{ src: string; alt?: string; width?: number; height?: number }> {
|
|
151
|
+
const images: Array<{ src: string; alt?: string; width?: number; height?: number }> = [];
|
|
152
|
+
const imgRegex = /<img\s+[^>]*?src\s*=\s*["']([^"']+)["'][^>]*?\/?>/gi;
|
|
153
|
+
let match: RegExpExecArray | null;
|
|
154
|
+
while ((match = imgRegex.exec(html)) !== null) {
|
|
155
|
+
const tag = match[0];
|
|
156
|
+
const src = resolveUrl(match[1], baseUrl);
|
|
157
|
+
if (!src) continue;
|
|
158
|
+
const altMatch = tag.match(/alt\s*=\s*["']([^"']*?)["']/i);
|
|
159
|
+
const widthMatch = tag.match(/width\s*=\s*["']?(\d+)/i);
|
|
160
|
+
const heightMatch = tag.match(/height\s*=\s*["']?(\d+)/i);
|
|
161
|
+
images.push({
|
|
162
|
+
src,
|
|
163
|
+
alt: altMatch?.[1] || undefined,
|
|
164
|
+
width: widthMatch ? parseInt(widthMatch[1], 10) : undefined,
|
|
165
|
+
height: heightMatch ? parseInt(heightMatch[1], 10) : undefined,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return images;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractLinks(html: string, baseUrl: string): string[] {
|
|
172
|
+
const links: string[] = [];
|
|
173
|
+
const linkRegex = /<a\s+[^>]*?href\s*=\s*["']([^"'#]+)["'][^>]*?>/gi;
|
|
174
|
+
let match: RegExpExecArray | null;
|
|
175
|
+
const baseHostname = getHostname(baseUrl);
|
|
176
|
+
while ((match = linkRegex.exec(html)) !== null) {
|
|
177
|
+
const href = resolveUrl(match[1], baseUrl);
|
|
178
|
+
if (!href) continue;
|
|
179
|
+
const hrefHostname = getHostname(href);
|
|
180
|
+
if (hrefHostname === baseHostname) {
|
|
181
|
+
links.push(href);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return [...new Set(links)];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractHeadings(html: string): string[] {
|
|
188
|
+
const headings: string[] = [];
|
|
189
|
+
const headingRegex = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
|
190
|
+
let match: RegExpExecArray | null;
|
|
191
|
+
while ((match = headingRegex.exec(html)) !== null) {
|
|
192
|
+
const text = stripHtml(match[1]).trim();
|
|
193
|
+
if (text) headings.push(text);
|
|
194
|
+
}
|
|
195
|
+
return headings;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function extractParagraphs(html: string): string[] {
|
|
199
|
+
const paragraphs: string[] = [];
|
|
200
|
+
const pRegex = /<p[^>]*>([\s\S]*?)<\/p>/gi;
|
|
201
|
+
let match: RegExpExecArray | null;
|
|
202
|
+
while ((match = pRegex.exec(html)) !== null) {
|
|
203
|
+
const text = stripHtml(match[1]).trim();
|
|
204
|
+
if (text && text.length > 20) paragraphs.push(text);
|
|
205
|
+
}
|
|
206
|
+
return paragraphs;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function extractCssColors(html: string): string[] {
|
|
210
|
+
const colors: string[] = [];
|
|
211
|
+
const colorRegex = /#(?:[0-9a-fA-F]{3}){1,2}\b/g;
|
|
212
|
+
const rgbRegex = /rgb\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)/g;
|
|
213
|
+
let match: RegExpExecArray | null;
|
|
214
|
+
while ((match = colorRegex.exec(html)) !== null) {
|
|
215
|
+
colors.push(match[0]);
|
|
216
|
+
}
|
|
217
|
+
while ((match = rgbRegex.exec(html)) !== null) {
|
|
218
|
+
colors.push(match[0]);
|
|
219
|
+
}
|
|
220
|
+
// Deduplicate and take top 10
|
|
221
|
+
return [...new Set(colors)].slice(0, 10);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function extractLogoUrl(html: string, images: Array<{ src: string; alt?: string }>): { url: string; alt?: string } | undefined {
|
|
225
|
+
// Look for images with "logo" in src, alt, or class
|
|
226
|
+
const logoRegex = /<img\s+[^>]*?(?:src|alt|class)\s*=\s*["'][^"']*logo[^"']*["'][^>]*?>/gi;
|
|
227
|
+
let match: RegExpExecArray | null;
|
|
228
|
+
while ((match = logoRegex.exec(html)) !== null) {
|
|
229
|
+
const srcMatch = match[0].match(/src\s*=\s*["']([^"']+)["']/i);
|
|
230
|
+
const altMatch = match[0].match(/alt\s*=\s*["']([^"']*?)["']/i);
|
|
231
|
+
if (srcMatch) {
|
|
232
|
+
return { url: srcMatch[1], alt: altMatch?.[1] };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Fallback: look in images array
|
|
236
|
+
for (const img of images) {
|
|
237
|
+
if (img.src.toLowerCase().includes("logo") || img.alt?.toLowerCase().includes("logo")) {
|
|
238
|
+
return { url: img.src, alt: img.alt };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stripHtml(html: string): string {
|
|
245
|
+
return html.replace(/<[^>]+>/g, "").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, " ");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveUrl(href: string, base: string): string | null {
|
|
249
|
+
try {
|
|
250
|
+
return new URL(href, base).href;
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function getHostname(url: string): string {
|
|
257
|
+
try {
|
|
258
|
+
return new URL(url).hostname;
|
|
259
|
+
} catch {
|
|
260
|
+
return "";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Website Fetching ───────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
async function fetchPage(url: string): Promise<string | null> {
|
|
267
|
+
try {
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
270
|
+
const response = await fetch(url, {
|
|
271
|
+
headers: {
|
|
272
|
+
"User-Agent": "HeyHank-ContentEngine/1.0 (https://heyhank.com)",
|
|
273
|
+
"Accept": "text/html,application/xhtml+xml",
|
|
274
|
+
},
|
|
275
|
+
signal: controller.signal,
|
|
276
|
+
redirect: "follow",
|
|
277
|
+
});
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
if (!response.ok) return null;
|
|
280
|
+
const contentType = response.headers.get("content-type") || "";
|
|
281
|
+
if (!contentType.includes("text/html") && !contentType.includes("text/plain") && !contentType.includes("application/xhtml")) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
return await response.text();
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Priority pages to crawl ────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
const PRIORITY_PATHS = ["/about", "/services", "/products", "/pricing", "/contact", "/about-us", "/our-services", "/ueber-uns", "/leistungen", "/produkte", "/kontakt"];
|
|
293
|
+
|
|
294
|
+
function prioritizeLinks(links: string[], baseUrl: string): string[] {
|
|
295
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
296
|
+
const prioritized: string[] = [];
|
|
297
|
+
const rest: string[] = [];
|
|
298
|
+
|
|
299
|
+
for (const link of links) {
|
|
300
|
+
if (link === baseUrl || link === base || link === base + "/") continue;
|
|
301
|
+
const path = new URL(link).pathname.toLowerCase();
|
|
302
|
+
if (PRIORITY_PATHS.some((p) => path === p || path === p + "/")) {
|
|
303
|
+
prioritized.push(link);
|
|
304
|
+
} else {
|
|
305
|
+
rest.push(link);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return [...prioritized, ...rest].slice(0, 5);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ─── LLM Analysis ──────────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
async function analyzeWithLLM(collectedData: {
|
|
315
|
+
url: string;
|
|
316
|
+
meta: Record<string, string>;
|
|
317
|
+
headings: string[];
|
|
318
|
+
paragraphs: string[];
|
|
319
|
+
images: Array<{ src: string; alt?: string }>;
|
|
320
|
+
colors: string[];
|
|
321
|
+
}): Promise<Partial<WebsiteIntelligence>> {
|
|
322
|
+
const prompt = `Analyze this website data and extract business intelligence. Return ONLY valid JSON.
|
|
323
|
+
|
|
324
|
+
URL: ${collectedData.url}
|
|
325
|
+
|
|
326
|
+
META TAGS:
|
|
327
|
+
${Object.entries(collectedData.meta).map(([k, v]) => `${k}: ${v}`).join("\n")}
|
|
328
|
+
|
|
329
|
+
HEADINGS:
|
|
330
|
+
${collectedData.headings.slice(0, 30).join("\n")}
|
|
331
|
+
|
|
332
|
+
CONTENT (first paragraphs):
|
|
333
|
+
${collectedData.paragraphs.slice(0, 20).join("\n\n")}
|
|
334
|
+
|
|
335
|
+
IMAGES (${collectedData.images.length} total):
|
|
336
|
+
${collectedData.images.slice(0, 15).map((i) => `${i.src} (alt: ${i.alt || "none"})`).join("\n")}
|
|
337
|
+
|
|
338
|
+
COLORS FOUND: ${collectedData.colors.join(", ")}
|
|
339
|
+
|
|
340
|
+
Respond with this exact JSON structure (no markdown, no explanation):
|
|
341
|
+
{
|
|
342
|
+
"businessType": "ecommerce|service|saas|blog|portfolio|agency|other",
|
|
343
|
+
"industry": "specific industry name",
|
|
344
|
+
"companyName": "company name",
|
|
345
|
+
"language": "detected language code (e.g. en, de, fr)",
|
|
346
|
+
"products": [{"name": "...", "description": "...", "price": "..."}],
|
|
347
|
+
"services": [{"name": "...", "description": "..."}],
|
|
348
|
+
"usp": ["unique selling point 1", "..."],
|
|
349
|
+
"targetAudience": "description of target audience",
|
|
350
|
+
"tone": "brand tone of voice (e.g. professional, friendly, casual, authoritative)",
|
|
351
|
+
"testimonials": ["testimonial quote 1", "..."],
|
|
352
|
+
"ctas": ["call to action text found on site"]
|
|
353
|
+
}`;
|
|
354
|
+
|
|
355
|
+
const result = await callInternalAI({
|
|
356
|
+
systemPrompt: "You are a business intelligence analyst. Extract structured data from websites. Return ONLY valid JSON, no markdown fences, no explanation.",
|
|
357
|
+
userPrompt: prompt,
|
|
358
|
+
maxTokens: 2048,
|
|
359
|
+
temperature: 0.3,
|
|
360
|
+
timeoutMs: 30_000,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (!result.ok || !result.text) {
|
|
364
|
+
console.error("[content-engine] LLM analysis failed:", result.error);
|
|
365
|
+
return {};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
// Try to extract JSON from the response (handle markdown fences)
|
|
370
|
+
let jsonText = result.text.trim();
|
|
371
|
+
const fenceMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
372
|
+
if (fenceMatch) jsonText = fenceMatch[1].trim();
|
|
373
|
+
return JSON.parse(jsonText) as Partial<WebsiteIntelligence>;
|
|
374
|
+
} catch (e) {
|
|
375
|
+
console.error("[content-engine] Failed to parse LLM response:", e);
|
|
376
|
+
return {};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Main Functions ─────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Analyze a website to extract brand identity, business type, products/services,
|
|
384
|
+
* colors, images, and tone of voice.
|
|
385
|
+
*/
|
|
386
|
+
export async function analyzeWebsite(url: string): Promise<WebsiteIntelligence> {
|
|
387
|
+
// Check cache first
|
|
388
|
+
const cached = getCached(url);
|
|
389
|
+
if (cached) return cached;
|
|
390
|
+
|
|
391
|
+
console.log(`[content-engine] Analyzing website: ${url}`);
|
|
392
|
+
|
|
393
|
+
// Normalize URL
|
|
394
|
+
if (!url.startsWith("http")) url = "https://" + url;
|
|
395
|
+
|
|
396
|
+
// 1. Fetch main page
|
|
397
|
+
const mainHtml = await fetchPage(url);
|
|
398
|
+
if (!mainHtml) {
|
|
399
|
+
throw new Error(`Could not fetch website: ${url}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 2. Extract data from main page
|
|
403
|
+
const meta = extractMetaTags(mainHtml);
|
|
404
|
+
const allImages = extractImages(mainHtml, url);
|
|
405
|
+
const links = extractLinks(mainHtml, url);
|
|
406
|
+
const headings = extractHeadings(mainHtml);
|
|
407
|
+
const paragraphs = extractParagraphs(mainHtml);
|
|
408
|
+
const colors = extractCssColors(mainHtml);
|
|
409
|
+
const logo = extractLogoUrl(mainHtml, allImages);
|
|
410
|
+
|
|
411
|
+
// 3. Crawl additional pages
|
|
412
|
+
const additionalPages = prioritizeLinks(links, url);
|
|
413
|
+
const crawledPages = [url];
|
|
414
|
+
const additionalHeadings: string[] = [];
|
|
415
|
+
const additionalParagraphs: string[] = [];
|
|
416
|
+
const additionalImages: typeof allImages = [];
|
|
417
|
+
|
|
418
|
+
for (const pageUrl of additionalPages) {
|
|
419
|
+
const pageHtml = await fetchPage(pageUrl);
|
|
420
|
+
if (!pageHtml) continue;
|
|
421
|
+
crawledPages.push(pageUrl);
|
|
422
|
+
additionalHeadings.push(...extractHeadings(pageHtml));
|
|
423
|
+
additionalParagraphs.push(...extractParagraphs(pageHtml));
|
|
424
|
+
additionalImages.push(...extractImages(pageHtml, pageUrl));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const combinedHeadings = [...headings, ...additionalHeadings];
|
|
428
|
+
const combinedParagraphs = [...paragraphs, ...additionalParagraphs];
|
|
429
|
+
const combinedImages = [...allImages, ...additionalImages];
|
|
430
|
+
|
|
431
|
+
// 4. Filter images (skip tiny icons <100px)
|
|
432
|
+
const significantImages = combinedImages.filter((img) => {
|
|
433
|
+
if (img.width && img.width < 100) return false;
|
|
434
|
+
if (img.height && img.height < 100) return false;
|
|
435
|
+
const src = img.src.toLowerCase();
|
|
436
|
+
if (src.includes("favicon") || src.includes("icon") || src.endsWith(".ico")) return false;
|
|
437
|
+
if (src.includes("pixel") || src.includes("tracking") || src.includes("analytics")) return false;
|
|
438
|
+
return true;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// 5. Send to LLM for analysis
|
|
442
|
+
const llmAnalysis = await analyzeWithLLM({
|
|
443
|
+
url,
|
|
444
|
+
meta,
|
|
445
|
+
headings: combinedHeadings,
|
|
446
|
+
paragraphs: combinedParagraphs,
|
|
447
|
+
images: significantImages.slice(0, 20),
|
|
448
|
+
colors,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// 6. Build the intelligence object
|
|
452
|
+
const intelligence: WebsiteIntelligence = {
|
|
453
|
+
url,
|
|
454
|
+
businessType: llmAnalysis.businessType || "other",
|
|
455
|
+
industry: llmAnalysis.industry || "Unknown",
|
|
456
|
+
companyName: llmAnalysis.companyName || meta["og:site_name"] || meta["title"] || "Unknown",
|
|
457
|
+
language: llmAnalysis.language || "en",
|
|
458
|
+
products: llmAnalysis.products || [],
|
|
459
|
+
services: llmAnalysis.services || [],
|
|
460
|
+
usp: llmAnalysis.usp || [],
|
|
461
|
+
targetAudience: llmAnalysis.targetAudience || "General audience",
|
|
462
|
+
tone: llmAnalysis.tone || "professional",
|
|
463
|
+
colors,
|
|
464
|
+
fonts: [], // Would need CSS parsing for fonts
|
|
465
|
+
logo: logo ? { url: resolveUrl(logo.url, url) || logo.url, alt: logo.alt } : undefined,
|
|
466
|
+
heroImages: significantImages.slice(0, 5).map((i) => ({ url: i.src, alt: i.alt })),
|
|
467
|
+
productImages: significantImages
|
|
468
|
+
.filter((i) => i.alt?.toLowerCase().includes("product") || i.src.toLowerCase().includes("product"))
|
|
469
|
+
.slice(0, 10)
|
|
470
|
+
.map((i) => ({ url: i.src, alt: i.alt })),
|
|
471
|
+
headlines: combinedHeadings.slice(0, 20),
|
|
472
|
+
ctas: llmAnalysis.ctas || [],
|
|
473
|
+
testimonials: llmAnalysis.testimonials || [],
|
|
474
|
+
title: meta["title"] || meta["og:title"] || "",
|
|
475
|
+
description: meta["description"] || meta["og:description"] || "",
|
|
476
|
+
ogImage: meta["og:image"] || undefined,
|
|
477
|
+
crawledPages,
|
|
478
|
+
analyzedAt: new Date().toISOString(),
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Cache it
|
|
482
|
+
setCache(url, intelligence);
|
|
483
|
+
|
|
484
|
+
return intelligence;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── Content Strategy ───────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
const PILLAR_TEMPLATES: Record<string, ContentPillar[]> = {
|
|
490
|
+
ecommerce: [
|
|
491
|
+
{
|
|
492
|
+
name: "Product Highlights",
|
|
493
|
+
description: "Showcase products, features, and benefits",
|
|
494
|
+
painPoints: ["Can't find quality products", "Unsure about product quality", "Too many choices"],
|
|
495
|
+
contentIdeas: ["Product spotlights", "Feature breakdowns", "Use cases", "Comparison posts"],
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: "Customer Stories",
|
|
499
|
+
description: "Social proof through customer experiences",
|
|
500
|
+
painPoints: ["Need validation before buying", "Want real reviews", "Risk of bad purchase"],
|
|
501
|
+
contentIdeas: ["Customer testimonials", "Before/after transformations", "User-generated content", "Review roundups"],
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
name: "Behind the Scenes",
|
|
505
|
+
description: "Build trust through transparency",
|
|
506
|
+
painPoints: ["Don't trust online brands", "Want to know who makes products"],
|
|
507
|
+
contentIdeas: ["Team introductions", "Production process", "Packaging and shipping", "Company values"],
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
name: "Industry Tips",
|
|
511
|
+
description: "Position as helpful expert",
|
|
512
|
+
painPoints: ["Need guidance on product use", "Want to maximize value"],
|
|
513
|
+
contentIdeas: ["How-to guides", "Tips and tricks", "Seasonal guides", "Expert advice"],
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
service: [
|
|
517
|
+
{
|
|
518
|
+
name: "Expertise & Tips",
|
|
519
|
+
description: "Demonstrate authority and provide value",
|
|
520
|
+
painPoints: ["Don't know where to start", "Need expert guidance", "Information overload"],
|
|
521
|
+
contentIdeas: ["Quick tips", "Common mistakes", "Step-by-step guides", "FAQ answers"],
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
name: "Case Studies",
|
|
525
|
+
description: "Prove results with real examples",
|
|
526
|
+
painPoints: ["Skeptical about results", "Need proof it works"],
|
|
527
|
+
contentIdeas: ["Client success stories", "Before/after results", "Process breakdowns", "ROI showcases"],
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
name: "Behind the Scenes",
|
|
531
|
+
description: "Humanize the brand",
|
|
532
|
+
painPoints: ["Want to know who they're working with", "Need personal connection"],
|
|
533
|
+
contentIdeas: ["Day in the life", "Team spotlights", "Office/workspace tours", "Company culture"],
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: "Industry Insights",
|
|
537
|
+
description: "Position as thought leader",
|
|
538
|
+
painPoints: ["Need to stay current", "Want informed decisions"],
|
|
539
|
+
contentIdeas: ["Trend analysis", "Industry news commentary", "Data-driven insights", "Predictions"],
|
|
540
|
+
},
|
|
541
|
+
],
|
|
542
|
+
saas: [
|
|
543
|
+
{
|
|
544
|
+
name: "Feature Highlights",
|
|
545
|
+
description: "Showcase product capabilities",
|
|
546
|
+
painPoints: ["Current tools are inefficient", "Need better solutions", "Too complex"],
|
|
547
|
+
contentIdeas: ["Feature demos", "Tips and shortcuts", "New feature announcements", "Integration showcases"],
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: "Tutorials",
|
|
551
|
+
description: "Help users get maximum value",
|
|
552
|
+
painPoints: ["Hard to learn new tools", "Underusing the product"],
|
|
553
|
+
contentIdeas: ["Step-by-step tutorials", "Use case walkthroughs", "Power user tips", "Template showcases"],
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "Industry Trends",
|
|
557
|
+
description: "Position as forward-thinking leader",
|
|
558
|
+
painPoints: ["Falling behind competitors", "Need to stay current"],
|
|
559
|
+
contentIdeas: ["Market analysis", "Technology trends", "Future predictions", "Data reports"],
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
name: "Customer Success",
|
|
563
|
+
description: "Social proof and inspiration",
|
|
564
|
+
painPoints: ["Unsure if the tool will work for them", "Need validation"],
|
|
565
|
+
contentIdeas: ["Success stories", "User interviews", "ROI case studies", "Community highlights"],
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Create a content strategy based on business analysis and target platforms.
|
|
572
|
+
*/
|
|
573
|
+
export function createContentStrategy(
|
|
574
|
+
intelligence: WebsiteIntelligence,
|
|
575
|
+
platforms: string[],
|
|
576
|
+
): ContentStrategy {
|
|
577
|
+
// 1. Get pillar templates based on business type
|
|
578
|
+
const pillars = PILLAR_TEMPLATES[intelligence.businessType] || PILLAR_TEMPLATES["service"]!;
|
|
579
|
+
|
|
580
|
+
// 2. Create posting schedules per platform
|
|
581
|
+
const schedules: ContentSchedule[] = [];
|
|
582
|
+
for (const platformKey of platforms) {
|
|
583
|
+
const spec = getPlatform(platformKey);
|
|
584
|
+
if (!spec) continue;
|
|
585
|
+
schedules.push({
|
|
586
|
+
platform: spec.key,
|
|
587
|
+
postsPerWeek: parsePostsPerWeek(spec.frequency.recommended),
|
|
588
|
+
bestDays: spec.bestTimes.bestDays,
|
|
589
|
+
bestHours: spec.bestTimes.bestHours,
|
|
590
|
+
formats: spec.formats.slice(0, 3).map((f) => f.name),
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// 3. Define journey mapping
|
|
595
|
+
const journeyMapping = {
|
|
596
|
+
attract: [
|
|
597
|
+
"Educational content addressing pain points",
|
|
598
|
+
"Industry insights and trends",
|
|
599
|
+
"Entertaining/engaging content",
|
|
600
|
+
"Shareable infographics and tips",
|
|
601
|
+
],
|
|
602
|
+
convert: [
|
|
603
|
+
"Case studies and success stories",
|
|
604
|
+
"Product/service comparisons",
|
|
605
|
+
"Free resources (guides, templates)",
|
|
606
|
+
"Webinars and live demos",
|
|
607
|
+
],
|
|
608
|
+
close: [
|
|
609
|
+
"Testimonials and social proof",
|
|
610
|
+
"Limited-time offers",
|
|
611
|
+
"Free trials and demos",
|
|
612
|
+
"Direct CTAs with clear value proposition",
|
|
613
|
+
],
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// 4. Generate CTAs based on business type
|
|
617
|
+
const ctas = intelligence.ctas.length > 0
|
|
618
|
+
? intelligence.ctas
|
|
619
|
+
: generateDefaultCTAs(intelligence.businessType);
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
businessType: intelligence.businessType,
|
|
623
|
+
pillars,
|
|
624
|
+
schedules,
|
|
625
|
+
tone: intelligence.tone,
|
|
626
|
+
ctas,
|
|
627
|
+
journeyMapping,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function parsePostsPerWeek(recommended: string): number {
|
|
632
|
+
const match = recommended.match(/(\d+)(?:\s*-\s*(\d+))?/);
|
|
633
|
+
if (!match) return 3;
|
|
634
|
+
const low = parseInt(match[1], 10);
|
|
635
|
+
const high = match[2] ? parseInt(match[2], 10) : low;
|
|
636
|
+
return Math.round((low + high) / 2);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function generateDefaultCTAs(businessType: string): string[] {
|
|
640
|
+
switch (businessType) {
|
|
641
|
+
case "ecommerce":
|
|
642
|
+
return ["Shop Now", "Get Yours Today", "Limited Stock", "Free Shipping"];
|
|
643
|
+
case "service":
|
|
644
|
+
return ["Book a Consultation", "Get Started", "Contact Us", "Learn More"];
|
|
645
|
+
case "saas":
|
|
646
|
+
return ["Start Free Trial", "See Demo", "Sign Up Free", "Try It Now"];
|
|
647
|
+
default:
|
|
648
|
+
return ["Learn More", "Get Started", "Contact Us"];
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ─── Hashtag Pool Integration ───────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Build hashtag context from saved pools for the LLM prompt.
|
|
656
|
+
* Matches by industry/language if possible, otherwise uses all pools.
|
|
657
|
+
*/
|
|
658
|
+
/**
|
|
659
|
+
* Build a few-shot reference block from the SocialView library for the given
|
|
660
|
+
* platform. Only gold-marked posts (highest-engagement, manually approved) are
|
|
661
|
+
* used so the agent learns from curated examples, not noise.
|
|
662
|
+
* Returns "" if platform is not one of the supported social platforms or if
|
|
663
|
+
* the library has no gold posts for it.
|
|
664
|
+
*/
|
|
665
|
+
function buildFewShotBlock(platform: string): string {
|
|
666
|
+
// Normalize content-engine platform strings to SocialPlatform union.
|
|
667
|
+
const plat = normalizePlatform(platform);
|
|
668
|
+
if (!plat) return "";
|
|
669
|
+
|
|
670
|
+
const examples = selectForFewShot(plat, 5);
|
|
671
|
+
if (examples.length === 0) return "";
|
|
672
|
+
|
|
673
|
+
const lines: string[] = [
|
|
674
|
+
"",
|
|
675
|
+
`REFERENCE POSTS (top-performing examples from the ${plat} library — study the hook pattern, tone, length, CTA style, and visual direction; do NOT copy verbatim):`,
|
|
676
|
+
];
|
|
677
|
+
for (const p of examples) {
|
|
678
|
+
lines.push("---");
|
|
679
|
+
if (p.engagementRate !== null) {
|
|
680
|
+
lines.push(`engagement_rate=${p.engagementRate.toFixed(3)} source=${p.source}`);
|
|
681
|
+
} else {
|
|
682
|
+
lines.push(`source=${p.source}`);
|
|
683
|
+
}
|
|
684
|
+
if (p.hook) lines.push(`hook: ${p.hook}`);
|
|
685
|
+
if (p.cta) lines.push(`cta: ${p.cta}`);
|
|
686
|
+
if (p.text) lines.push(`body: ${p.text.slice(0, 500)}`);
|
|
687
|
+
if (p.hashtags.length) lines.push(`hashtags: ${p.hashtags.map((h) => "#" + h).join(" ")}`);
|
|
688
|
+
const visual = p.media.find((m) => m.description)?.description;
|
|
689
|
+
if (visual) lines.push(`visual: ${visual.slice(0, 240)}`);
|
|
690
|
+
if (p.tags.length) lines.push(`tags: ${p.tags.join(", ")}`);
|
|
691
|
+
}
|
|
692
|
+
lines.push("---");
|
|
693
|
+
return lines.join("\n");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function normalizePlatform(platform: string): SocialPlatform | null {
|
|
697
|
+
const p = platform.toLowerCase();
|
|
698
|
+
if (p === "x") return "twitter";
|
|
699
|
+
if (SOCIAL_PLATFORMS.includes(p as SocialPlatform)) return p as SocialPlatform;
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Render a `StyleProfile` as an instruction block for the generation prompt.
|
|
705
|
+
* Token-efficient: structured rules, not raw post examples (those still come
|
|
706
|
+
* from `buildFewShotBlock`).
|
|
707
|
+
*/
|
|
708
|
+
function buildStyleProfileBlock(profile: StyleProfile): string {
|
|
709
|
+
const lines: string[] = [];
|
|
710
|
+
lines.push("");
|
|
711
|
+
lines.push(
|
|
712
|
+
`STYLE PROFILE — schreibe im Stil von ${profile.displayName} (@${profile.handle}, ${profile.platform}). ` +
|
|
713
|
+
`Imitiere Stil und Struktur, NICHT Inhalte. Diese Person ist die Vorlage:`,
|
|
714
|
+
);
|
|
715
|
+
lines.push(`- Tonfall: ${profile.toneOfVoice || "nicht spezifiziert"}`);
|
|
716
|
+
lines.push(
|
|
717
|
+
`- Länge: ~${profile.averageWordCount} Wörter (${profile.lengthCategory}). Nicht signifikant abweichen.`,
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
if (profile.hookPatterns.length > 0) {
|
|
721
|
+
const top = profile.hookPatterns
|
|
722
|
+
.slice()
|
|
723
|
+
.sort((a, b) => b.frequency - a.frequency)
|
|
724
|
+
.slice(0, 3)
|
|
725
|
+
.map((h) => `${h.type} (${Math.round(h.frequency * 100)}%)`)
|
|
726
|
+
.join(", ");
|
|
727
|
+
lines.push(`- Bevorzugte Hook-Pattern: ${top}`);
|
|
728
|
+
const exampleHook = profile.hookPatterns[0]?.examples?.[0];
|
|
729
|
+
if (exampleHook) lines.push(` Beispiel-Hook: "${exampleHook}"`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (profile.ctaPatterns.length > 0) {
|
|
733
|
+
const top = profile.ctaPatterns
|
|
734
|
+
.slice()
|
|
735
|
+
.sort((a, b) => b.frequency - a.frequency)
|
|
736
|
+
.slice(0, 2)
|
|
737
|
+
.map((c) => `${c.type} (${Math.round(c.frequency * 100)}%)`)
|
|
738
|
+
.join(", ");
|
|
739
|
+
lines.push(`- CTA-Pattern: ${top}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
lines.push(
|
|
743
|
+
`- Emoji-Stil: ${profile.emojiStyle}` +
|
|
744
|
+
(profile.emojiList.length > 0 ? ` — typisch: ${profile.emojiList.slice(0, 6).join(" ")}` : ""),
|
|
745
|
+
);
|
|
746
|
+
lines.push(`- Hashtag-Stil: ${profile.hashtagStyle}`);
|
|
747
|
+
|
|
748
|
+
if (profile.contentPillars.length > 0) {
|
|
749
|
+
lines.push(`- Themen-Säulen: ${profile.contentPillars.join(", ")}`);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (profile.commentEngagementPattern) {
|
|
753
|
+
lines.push(`- Engagement-Trick (Eigenkommentare): ${profile.commentEngagementPattern}`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (profile.rawAnalysis) {
|
|
757
|
+
lines.push(`- Stil-Zusammenfassung: ${profile.rawAnalysis}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return lines.join("\n");
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function getHashtagPoolContext(industry: string, language: string): Promise<string> {
|
|
764
|
+
try {
|
|
765
|
+
const pools = listHashtagPools();
|
|
766
|
+
if (pools.length === 0) return "";
|
|
767
|
+
|
|
768
|
+
// Try to find matching pool by industry, fall back to all
|
|
769
|
+
const matching = pools.filter(
|
|
770
|
+
(p) =>
|
|
771
|
+
p.industry.toLowerCase() === industry.toLowerCase() ||
|
|
772
|
+
p.language === language
|
|
773
|
+
);
|
|
774
|
+
const selected = matching.length > 0 ? matching : pools;
|
|
775
|
+
|
|
776
|
+
const lines: string[] = [
|
|
777
|
+
"HASHTAG POOL (use these curated hashtags, mix popular + medium + niche):",
|
|
778
|
+
];
|
|
779
|
+
for (const pool of selected) {
|
|
780
|
+
lines.push(` Business: ${pool.name} (${pool.industry})`);
|
|
781
|
+
if (pool.popular.length > 0) lines.push(` Popular (high reach): ${pool.popular.join(", ")}`);
|
|
782
|
+
if (pool.medium.length > 0) lines.push(` Medium (balanced): ${pool.medium.join(", ")}`);
|
|
783
|
+
if (pool.niche.length > 0) lines.push(` Niche (targeted): ${pool.niche.join(", ")}`);
|
|
784
|
+
if (pool.branded.length > 0) lines.push(` Branded: ${pool.branded.join(", ")}`);
|
|
785
|
+
if (pool.blocked.length > 0) lines.push(` NEVER USE: ${pool.blocked.join(", ")}`);
|
|
786
|
+
}
|
|
787
|
+
lines.push("- Pick 1-2 popular + 1-2 medium + 1-2 niche per post. Always include 1 branded if available.");
|
|
788
|
+
lines.push("- You may add 1 situational hashtag that fits the specific post topic.");
|
|
789
|
+
return lines.join("\n");
|
|
790
|
+
} catch {
|
|
791
|
+
return "";
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ─── Smart Content Generation ───────────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
const COPY_FRAMEWORKS: CopyFramework[] = ["PAS", "AIDA", "BAB", "StoryBrand"];
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Generate platform-optimized content pieces.
|
|
801
|
+
*/
|
|
802
|
+
export async function generateSmartContent(opts: {
|
|
803
|
+
intelligence: WebsiteIntelligence;
|
|
804
|
+
strategy: ContentStrategy;
|
|
805
|
+
platform: string;
|
|
806
|
+
journeyStage?: JourneyStage;
|
|
807
|
+
count?: number;
|
|
808
|
+
/**
|
|
809
|
+
* Handle of a SocialView role-model whose `StyleProfile` should drive the
|
|
810
|
+
* voice/structure of the generated posts. Pass e.g. "rene.remsik" to write
|
|
811
|
+
* "im Stil von Rene Remsik". If the profile doesn't exist for the given
|
|
812
|
+
* platform/handle, generation falls back to default few-shot only.
|
|
813
|
+
*/
|
|
814
|
+
styleProfileHandle?: string;
|
|
815
|
+
}): Promise<ContentPiece[]> {
|
|
816
|
+
const { intelligence, strategy, platform, journeyStage, count = 5, styleProfileHandle } = opts;
|
|
817
|
+
|
|
818
|
+
const spec = getPlatform(platform);
|
|
819
|
+
if (!spec) {
|
|
820
|
+
throw new Error(`Unknown platform: ${platform}`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const platformSummary = buildPlatformSummary(platform);
|
|
824
|
+
const fewShot = buildFewShotBlock(platform);
|
|
825
|
+
|
|
826
|
+
// Optional: pull a saved style profile for the requested handle.
|
|
827
|
+
let styleBlock = "";
|
|
828
|
+
if (styleProfileHandle) {
|
|
829
|
+
const plat = normalizePlatform(platform);
|
|
830
|
+
if (plat) {
|
|
831
|
+
const profile = getStyleProfile(plat, styleProfileHandle);
|
|
832
|
+
if (profile) styleBlock = buildStyleProfileBlock(profile);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const stage = journeyStage || "attract";
|
|
836
|
+
const pillar = strategy.pillars[Math.floor(Math.random() * strategy.pillars.length)]!;
|
|
837
|
+
const painPoint = pillar.painPoints[Math.floor(Math.random() * pillar.painPoints.length)] || "General challenge";
|
|
838
|
+
|
|
839
|
+
const prompt = `Generate ${count} social media content pieces for ${spec.name}.
|
|
840
|
+
|
|
841
|
+
BUSINESS CONTEXT:
|
|
842
|
+
- Company: ${intelligence.companyName}
|
|
843
|
+
- Industry: ${intelligence.industry}
|
|
844
|
+
- Business Type: ${intelligence.businessType}
|
|
845
|
+
- Target Audience: ${intelligence.targetAudience}
|
|
846
|
+
- USPs: ${intelligence.usp.join(", ")}
|
|
847
|
+
- Tone: ${intelligence.tone}
|
|
848
|
+
- Language: ${intelligence.language}
|
|
849
|
+
|
|
850
|
+
CONTENT PILLAR: ${pillar.name} — ${pillar.description}
|
|
851
|
+
CUSTOMER PAIN POINT TO ADDRESS: ${painPoint}
|
|
852
|
+
JOURNEY STAGE: ${stage} (${stage === "attract" ? "awareness, education" : stage === "convert" ? "consideration, comparison" : "decision, action"})
|
|
853
|
+
|
|
854
|
+
${platformSummary}
|
|
855
|
+
|
|
856
|
+
COPYWRITING FRAMEWORKS TO USE (rotate between them):
|
|
857
|
+
- PAS: Problem → Agitate → Solution
|
|
858
|
+
- AIDA: Attention → Interest → Desire → Action
|
|
859
|
+
- BAB: Before → After → Bridge
|
|
860
|
+
- StoryBrand: Character has a problem → meets a guide → who gives them a plan → calls them to action → helps them avoid failure → ends in success
|
|
861
|
+
|
|
862
|
+
REQUIREMENTS:
|
|
863
|
+
- Each post must have a strong hook in the first line
|
|
864
|
+
- Follow platform best practices for length and format
|
|
865
|
+
- Include relevant hashtags (${spec.hashtags.optimal})
|
|
866
|
+
- Write in ${intelligence.language === "de" ? "German" : intelligence.language === "fr" ? "French" : "English"}
|
|
867
|
+
- Include an image generation prompt for each post
|
|
868
|
+
${await getHashtagPoolContext(intelligence.industry, intelligence.language)}
|
|
869
|
+
${styleBlock}
|
|
870
|
+
${fewShot}
|
|
871
|
+
|
|
872
|
+
Return ONLY valid JSON array (no markdown, no explanation):
|
|
873
|
+
[
|
|
874
|
+
{
|
|
875
|
+
"framework": "PAS|AIDA|BAB|StoryBrand",
|
|
876
|
+
"pillar": "${pillar.name}",
|
|
877
|
+
"targetPain": "the pain point addressed",
|
|
878
|
+
"hook": "the opening hook line",
|
|
879
|
+
"headline": "post headline/title",
|
|
880
|
+
"body": "full post body text",
|
|
881
|
+
"cta": "call to action text",
|
|
882
|
+
"hashtags": ["tag1", "tag2"],
|
|
883
|
+
"imagePrompt": "detailed image generation prompt for this post"
|
|
884
|
+
}
|
|
885
|
+
]`;
|
|
886
|
+
|
|
887
|
+
const result = await callInternalAI({
|
|
888
|
+
systemPrompt: "You are an expert social media content strategist and copywriter. Generate platform-optimized content. Return ONLY valid JSON arrays, no markdown fences, no explanation.",
|
|
889
|
+
userPrompt: prompt,
|
|
890
|
+
maxTokens: 4096,
|
|
891
|
+
temperature: 0.8,
|
|
892
|
+
timeoutMs: 60_000,
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
if (!result.ok || !result.text) {
|
|
896
|
+
console.error("[content-engine] Content generation failed:", result.error);
|
|
897
|
+
throw new Error(`Content generation failed: ${result.error || "Unknown error"}`);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
try {
|
|
901
|
+
let jsonText = result.text.trim();
|
|
902
|
+
const fenceMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
903
|
+
if (fenceMatch) jsonText = fenceMatch[1].trim();
|
|
904
|
+
|
|
905
|
+
const rawPieces = JSON.parse(jsonText) as Array<{
|
|
906
|
+
framework?: string;
|
|
907
|
+
pillar?: string;
|
|
908
|
+
targetPain?: string;
|
|
909
|
+
hook?: string;
|
|
910
|
+
headline?: string;
|
|
911
|
+
body?: string;
|
|
912
|
+
cta?: string;
|
|
913
|
+
hashtags?: string[];
|
|
914
|
+
imagePrompt?: string;
|
|
915
|
+
}>;
|
|
916
|
+
|
|
917
|
+
return rawPieces.map((raw) => ({
|
|
918
|
+
id: randomUUID(),
|
|
919
|
+
platform,
|
|
920
|
+
type: "social-post" as const,
|
|
921
|
+
journeyStage: stage,
|
|
922
|
+
framework: (COPY_FRAMEWORKS.includes(raw.framework as CopyFramework) ? raw.framework : "PAS") as CopyFramework,
|
|
923
|
+
pillar: raw.pillar || pillar.name,
|
|
924
|
+
targetPain: raw.targetPain || painPoint,
|
|
925
|
+
hook: raw.hook || "",
|
|
926
|
+
headline: raw.headline || "",
|
|
927
|
+
body: raw.body || "",
|
|
928
|
+
cta: raw.cta || "",
|
|
929
|
+
hashtags: raw.hashtags || [],
|
|
930
|
+
imagePrompt: raw.imagePrompt,
|
|
931
|
+
status: "draft" as const,
|
|
932
|
+
}));
|
|
933
|
+
} catch (e) {
|
|
934
|
+
console.error("[content-engine] Failed to parse content response:", e);
|
|
935
|
+
throw new Error("Failed to parse generated content");
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ─── Ad Creative Generation ─────────────────────────────────────────────────
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Generate ad creatives with copy, image prompts, and brand-aligned design specs.
|
|
943
|
+
*/
|
|
944
|
+
export async function generateAdCreatives(opts: {
|
|
945
|
+
intelligence: WebsiteIntelligence;
|
|
946
|
+
platform: string;
|
|
947
|
+
count?: number;
|
|
948
|
+
}): Promise<AdCreative[]> {
|
|
949
|
+
const { intelligence, platform, count = 3 } = opts;
|
|
950
|
+
|
|
951
|
+
const spec = getPlatform(platform);
|
|
952
|
+
if (!spec) {
|
|
953
|
+
throw new Error(`Unknown platform: ${platform}`);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const prompt = `Generate ${count} ad creatives for ${spec.name}.
|
|
957
|
+
|
|
958
|
+
BUSINESS:
|
|
959
|
+
- Company: ${intelligence.companyName}
|
|
960
|
+
- Industry: ${intelligence.industry}
|
|
961
|
+
- USPs: ${intelligence.usp.join(", ")}
|
|
962
|
+
- Target Audience: ${intelligence.targetAudience}
|
|
963
|
+
- Tone: ${intelligence.tone}
|
|
964
|
+
- Brand Colors: ${intelligence.colors.slice(0, 5).join(", ") || "not specified"}
|
|
965
|
+
- Language: ${intelligence.language}
|
|
966
|
+
|
|
967
|
+
AD SPECS FOR ${spec.name.toUpperCase()}:
|
|
968
|
+
- Best Format: ${spec.adSpecs.bestFormat}
|
|
969
|
+
- Best Aspect Ratio: ${spec.adSpecs.bestAspectRatio}
|
|
970
|
+
- Best Resolution: ${spec.adSpecs.bestResolution}
|
|
971
|
+
- Headline Length: ${spec.adSpecs.headlineLength}
|
|
972
|
+
- Body Length: ${spec.adSpecs.bodyLength}
|
|
973
|
+
|
|
974
|
+
REQUIREMENTS:
|
|
975
|
+
- Create compelling ad copy with clear value propositions
|
|
976
|
+
- Headlines should be punchy and within the platform's recommended length
|
|
977
|
+
- Include a clear CTA
|
|
978
|
+
- Image prompts should incorporate brand colors and style
|
|
979
|
+
- Write in ${intelligence.language === "de" ? "German" : intelligence.language === "fr" ? "French" : "English"}
|
|
980
|
+
|
|
981
|
+
Return ONLY valid JSON array (no markdown, no explanation):
|
|
982
|
+
[
|
|
983
|
+
{
|
|
984
|
+
"format": "${spec.adSpecs.bestFormat}",
|
|
985
|
+
"headline": "ad headline",
|
|
986
|
+
"body": "ad body copy",
|
|
987
|
+
"cta": "call to action button text",
|
|
988
|
+
"imagePrompt": "detailed image prompt incorporating brand colors ${intelligence.colors.slice(0, 3).join(", ")} and brand style"
|
|
989
|
+
}
|
|
990
|
+
]`;
|
|
991
|
+
|
|
992
|
+
const result = await callInternalAI({
|
|
993
|
+
systemPrompt: "You are an expert advertising creative director. Generate high-converting ad creatives. Return ONLY valid JSON arrays, no markdown fences, no explanation.",
|
|
994
|
+
userPrompt: prompt,
|
|
995
|
+
maxTokens: 2048,
|
|
996
|
+
temperature: 0.7,
|
|
997
|
+
timeoutMs: 30_000,
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
if (!result.ok || !result.text) {
|
|
1001
|
+
console.error("[content-engine] Ad generation failed:", result.error);
|
|
1002
|
+
throw new Error(`Ad generation failed: ${result.error || "Unknown error"}`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
try {
|
|
1006
|
+
let jsonText = result.text.trim();
|
|
1007
|
+
const fenceMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
1008
|
+
if (fenceMatch) jsonText = fenceMatch[1].trim();
|
|
1009
|
+
|
|
1010
|
+
const rawAds = JSON.parse(jsonText) as Array<{
|
|
1011
|
+
format?: string;
|
|
1012
|
+
headline?: string;
|
|
1013
|
+
body?: string;
|
|
1014
|
+
cta?: string;
|
|
1015
|
+
imagePrompt?: string;
|
|
1016
|
+
}>;
|
|
1017
|
+
|
|
1018
|
+
return rawAds.map((raw) => ({
|
|
1019
|
+
id: randomUUID(),
|
|
1020
|
+
platform,
|
|
1021
|
+
format: raw.format || spec.adSpecs.bestFormat,
|
|
1022
|
+
aspectRatio: spec.adSpecs.bestAspectRatio,
|
|
1023
|
+
resolution: spec.adSpecs.bestResolution,
|
|
1024
|
+
headline: raw.headline || "",
|
|
1025
|
+
body: raw.body || "",
|
|
1026
|
+
cta: raw.cta || "",
|
|
1027
|
+
imagePrompt: raw.imagePrompt || "",
|
|
1028
|
+
brandColors: intelligence.colors.slice(0, 5),
|
|
1029
|
+
tone: intelligence.tone,
|
|
1030
|
+
}));
|
|
1031
|
+
} catch (e) {
|
|
1032
|
+
console.error("[content-engine] Failed to parse ad response:", e);
|
|
1033
|
+
throw new Error("Failed to parse generated ad creatives");
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ─── Full Content Plan ──────────────────────────────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
export interface ContentPlan {
|
|
1040
|
+
intelligence: WebsiteIntelligence;
|
|
1041
|
+
strategy: ContentStrategy;
|
|
1042
|
+
content: Record<string, ContentPiece[]>; // keyed by platform
|
|
1043
|
+
ads: Record<string, AdCreative[]>; // keyed by platform
|
|
1044
|
+
weeks: number;
|
|
1045
|
+
generatedAt: string;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Generate a complete content plan for multiple weeks across platforms.
|
|
1050
|
+
*/
|
|
1051
|
+
export async function generateContentPlan(opts: {
|
|
1052
|
+
url: string;
|
|
1053
|
+
platforms?: string[];
|
|
1054
|
+
weeks?: number;
|
|
1055
|
+
}): Promise<ContentPlan> {
|
|
1056
|
+
const { url, platforms = ["instagram", "linkedin", "facebook"], weeks = 4 } = opts;
|
|
1057
|
+
|
|
1058
|
+
// 1. Analyze website
|
|
1059
|
+
const intelligence = await analyzeWebsite(url);
|
|
1060
|
+
|
|
1061
|
+
// 2. Create strategy
|
|
1062
|
+
const strategy = createContentStrategy(intelligence, platforms);
|
|
1063
|
+
|
|
1064
|
+
// 3. Generate content for each platform
|
|
1065
|
+
const content: Record<string, ContentPiece[]> = {};
|
|
1066
|
+
const ads: Record<string, AdCreative[]> = {};
|
|
1067
|
+
const stages: JourneyStage[] = ["attract", "convert", "close"];
|
|
1068
|
+
|
|
1069
|
+
for (const platform of platforms) {
|
|
1070
|
+
const spec = getPlatform(platform);
|
|
1071
|
+
if (!spec) continue;
|
|
1072
|
+
|
|
1073
|
+
const postsPerWeek = parsePostsPerWeek(spec.frequency.recommended);
|
|
1074
|
+
const totalPosts = Math.min(postsPerWeek * weeks, 20); // Cap at 20 per platform
|
|
1075
|
+
|
|
1076
|
+
// Generate content across journey stages
|
|
1077
|
+
const allPieces: ContentPiece[] = [];
|
|
1078
|
+
for (const stage of stages) {
|
|
1079
|
+
const stageCount = Math.max(1, Math.round(totalPosts * (stage === "attract" ? 0.5 : stage === "convert" ? 0.3 : 0.2)));
|
|
1080
|
+
try {
|
|
1081
|
+
const pieces = await generateSmartContent({
|
|
1082
|
+
intelligence,
|
|
1083
|
+
strategy,
|
|
1084
|
+
platform,
|
|
1085
|
+
journeyStage: stage,
|
|
1086
|
+
count: stageCount,
|
|
1087
|
+
});
|
|
1088
|
+
allPieces.push(...pieces);
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
console.error(`[content-engine] Failed to generate ${stage} content for ${platform}:`, e);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
content[platform] = allPieces;
|
|
1094
|
+
|
|
1095
|
+
// Generate ad creatives
|
|
1096
|
+
try {
|
|
1097
|
+
ads[platform] = await generateAdCreatives({ intelligence, platform, count: 3 });
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
console.error(`[content-engine] Failed to generate ads for ${platform}:`, e);
|
|
1100
|
+
ads[platform] = [];
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
return {
|
|
1105
|
+
intelligence,
|
|
1106
|
+
strategy,
|
|
1107
|
+
content,
|
|
1108
|
+
ads,
|
|
1109
|
+
weeks,
|
|
1110
|
+
generatedAt: new Date().toISOString(),
|
|
1111
|
+
};
|
|
1112
|
+
}
|