optimal-cli 0.1.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 +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- package/package.json +50 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Post Generation Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Ported from Python: ~/projects/newsletter-automation social post pipeline
|
|
5
|
+
*
|
|
6
|
+
* Pipeline: Groq AI generates post ideas -> Unsplash image search -> Strapi push
|
|
7
|
+
*
|
|
8
|
+
* Functions:
|
|
9
|
+
* callGroq() — call Groq API (OpenAI-compatible) for AI content
|
|
10
|
+
* searchUnsplashImage() — search Unsplash NAPI for a stock photo URL
|
|
11
|
+
* generateSocialPosts() — orchestrator: generate posts and push to Strapi
|
|
12
|
+
*/
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
import { strapiPost } from '../cms/strapi-client.js';
|
|
15
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
16
|
+
const OVERLAY_STYLES = [
|
|
17
|
+
'dark-bottom',
|
|
18
|
+
'brand-bottom',
|
|
19
|
+
'brand-full',
|
|
20
|
+
'dark-full',
|
|
21
|
+
];
|
|
22
|
+
const BRAND_CONFIGS = {
|
|
23
|
+
'CRE-11TRUST': {
|
|
24
|
+
displayName: 'ElevenTrust Commercial Real Estate',
|
|
25
|
+
ctaUrl: 'https://eleventrust.com',
|
|
26
|
+
industry: 'commercial real estate in South Florida',
|
|
27
|
+
},
|
|
28
|
+
'LIFEINSUR': {
|
|
29
|
+
displayName: 'Anchor Point Insurance Co.',
|
|
30
|
+
ctaUrl: 'https://anchorpointinsurance.com',
|
|
31
|
+
industry: 'life insurance and financial protection',
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
// ── Environment helper ───────────────────────────────────────────────
|
|
35
|
+
function requireEnv(name) {
|
|
36
|
+
const val = process.env[name];
|
|
37
|
+
if (!val)
|
|
38
|
+
throw new Error(`Missing env var: ${name}`);
|
|
39
|
+
return val;
|
|
40
|
+
}
|
|
41
|
+
// ── 1. Groq API Call ─────────────────────────────────────────────────
|
|
42
|
+
/**
|
|
43
|
+
* Call the Groq API (OpenAI-compatible) and return the assistant's response text.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const response = await callGroq('You are a copywriter.', 'Write a tagline.')
|
|
47
|
+
*/
|
|
48
|
+
export async function callGroq(systemPrompt, userPrompt) {
|
|
49
|
+
const apiKey = requireEnv('GROQ_API_KEY');
|
|
50
|
+
const model = process.env.GROQ_MODEL ?? 'llama-3.3-70b-versatile';
|
|
51
|
+
const messages = [
|
|
52
|
+
{ role: 'system', content: systemPrompt },
|
|
53
|
+
{ role: 'user', content: userPrompt },
|
|
54
|
+
];
|
|
55
|
+
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
Authorization: `Bearer ${apiKey}`,
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
messages,
|
|
64
|
+
temperature: 0.8,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
const data = await response.json();
|
|
68
|
+
if (!data.choices || data.choices.length === 0) {
|
|
69
|
+
throw new Error(`Groq error: ${JSON.stringify(data.error ?? data)}`);
|
|
70
|
+
}
|
|
71
|
+
return data.choices[0].message.content;
|
|
72
|
+
}
|
|
73
|
+
// ── 2. Unsplash Image Search ─────────────────────────────────────────
|
|
74
|
+
/**
|
|
75
|
+
* Search Unsplash NAPI for a themed stock photo URL.
|
|
76
|
+
* Returns the `.results[0].urls.regular` URL, or null if not found.
|
|
77
|
+
*
|
|
78
|
+
* Note: Uses the public NAPI endpoint — no auth required but may be rate-limited.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* const url = await searchUnsplashImage('life insurance family protection')
|
|
82
|
+
*/
|
|
83
|
+
export async function searchUnsplashImage(query) {
|
|
84
|
+
try {
|
|
85
|
+
const encodedQuery = encodeURIComponent(query);
|
|
86
|
+
const response = await fetch(`https://unsplash.com/napi/search/photos?query=${encodedQuery}&per_page=3`, {
|
|
87
|
+
headers: {
|
|
88
|
+
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
89
|
+
Accept: 'application/json',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
console.warn(` [Unsplash] HTTP ${response.status} for query: "${query}"`);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const data = await response.json();
|
|
97
|
+
const url = data.results?.[0]?.urls?.regular ?? null;
|
|
98
|
+
if (!url) {
|
|
99
|
+
console.warn(` [Unsplash] No results for query: "${query}"`);
|
|
100
|
+
}
|
|
101
|
+
return url;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.warn(` [Unsplash] Error searching for "${query}": ${err.message}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// ── 3. Build Weekly Schedule ─────────────────────────────────────────
|
|
109
|
+
/**
|
|
110
|
+
* Distribute post dates across a week (Mon-Fri + weekend for overflow).
|
|
111
|
+
* Returns an array of ISO date strings (YYYY-MM-DD).
|
|
112
|
+
*/
|
|
113
|
+
function buildWeeklySchedule(weekOf, count) {
|
|
114
|
+
const start = new Date(weekOf);
|
|
115
|
+
// Ensure we start from Monday — find the Monday of the given week
|
|
116
|
+
const dayOfWeek = start.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
|
|
117
|
+
const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
118
|
+
start.setDate(start.getDate() + daysToMonday);
|
|
119
|
+
// Build Mon-Sun sequence (7 days)
|
|
120
|
+
const weekDays = [];
|
|
121
|
+
for (let i = 0; i < 7; i++) {
|
|
122
|
+
const d = new Date(start);
|
|
123
|
+
d.setDate(start.getDate() + i);
|
|
124
|
+
weekDays.push(d.toISOString().slice(0, 10));
|
|
125
|
+
}
|
|
126
|
+
// Fill schedule: Mon-Fri first, then Sat-Sun for overflow
|
|
127
|
+
const schedule = [];
|
|
128
|
+
const weekdaySlots = weekDays.slice(0, 5); // Mon-Fri
|
|
129
|
+
const weekendSlots = weekDays.slice(5); // Sat-Sun
|
|
130
|
+
for (let i = 0; i < count; i++) {
|
|
131
|
+
if (i < weekdaySlots.length) {
|
|
132
|
+
schedule.push(weekdaySlots[i]);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// Overflow into weekend, then repeat weekdays
|
|
136
|
+
const overflowSlots = [...weekendSlots, ...weekdaySlots];
|
|
137
|
+
schedule.push(overflowSlots[(i - weekdaySlots.length) % overflowSlots.length]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return schedule;
|
|
141
|
+
}
|
|
142
|
+
// ── 4. Build AI Prompts ──────────────────────────────────────────────
|
|
143
|
+
function buildSystemPrompt(brand) {
|
|
144
|
+
const config = BRAND_CONFIGS[brand];
|
|
145
|
+
const displayName = config?.displayName ?? brand;
|
|
146
|
+
const industry = config?.industry ?? 'professional services';
|
|
147
|
+
return `You are an expert social media copywriter specializing in ${industry} for ${displayName}.
|
|
148
|
+
|
|
149
|
+
Your task is to generate engaging, conversion-focused social media ad posts. Each post must:
|
|
150
|
+
- Hook the viewer in the first line (no generic openers)
|
|
151
|
+
- Speak to a specific pain point or aspiration
|
|
152
|
+
- Include a clear, action-oriented CTA
|
|
153
|
+
- Be authentic and avoid jargon
|
|
154
|
+
- Be appropriate for paid social advertising (Instagram and Facebook)
|
|
155
|
+
|
|
156
|
+
Always respond with ONLY valid JSON — no markdown fences, no explanations, no extra text.`;
|
|
157
|
+
}
|
|
158
|
+
function buildUserPrompt(brand, platforms, weekStart, count) {
|
|
159
|
+
const config = BRAND_CONFIGS[brand];
|
|
160
|
+
const displayName = config?.displayName ?? brand;
|
|
161
|
+
const ctaUrl = config?.ctaUrl ?? 'https://example.com';
|
|
162
|
+
const industry = config?.industry ?? 'professional services';
|
|
163
|
+
return `Generate ${count} social media ad posts for ${displayName} (brand: ${brand}).
|
|
164
|
+
|
|
165
|
+
Target platforms: ${platforms.join(', ')}
|
|
166
|
+
Week of: ${weekStart}
|
|
167
|
+
Industry: ${industry}
|
|
168
|
+
CTA URL: ${ctaUrl}
|
|
169
|
+
|
|
170
|
+
Return a JSON array of exactly ${count} post objects. Each object must have these exact fields:
|
|
171
|
+
- "headline": string — attention-grabbing first line (max 60 chars)
|
|
172
|
+
- "body": string — 2-4 sentence post copy (max 280 chars)
|
|
173
|
+
- "cta_text": string — button label (e.g., "Get a Free Quote", "Learn More", "Schedule a Call")
|
|
174
|
+
- "cta_url": string — full URL for the CTA
|
|
175
|
+
- "image_search_query": string — 3-5 keyword search string to find a relevant stock photo on Unsplash
|
|
176
|
+
- "overlay_style": string — one of: "dark-bottom", "brand-bottom", "brand-full", "dark-full"
|
|
177
|
+
- "template": string — one of: "standard", "quote", "offer", "testimonial", "educational"
|
|
178
|
+
|
|
179
|
+
Vary the templates, tones, and angles across the ${count} posts. Mix benefit-focused, story-driven, and urgency-based approaches.
|
|
180
|
+
|
|
181
|
+
Return ONLY the JSON array, no other text.`;
|
|
182
|
+
}
|
|
183
|
+
// ── 5. Parse AI Response ─────────────────────────────────────────────
|
|
184
|
+
function parseAiPostIdeas(raw) {
|
|
185
|
+
let content = raw.trim();
|
|
186
|
+
// Strip markdown fences if present
|
|
187
|
+
if (content.startsWith('```')) {
|
|
188
|
+
const firstNewline = content.indexOf('\n');
|
|
189
|
+
content = firstNewline !== -1 ? content.slice(firstNewline + 1) : content.slice(3);
|
|
190
|
+
}
|
|
191
|
+
if (content.endsWith('```')) {
|
|
192
|
+
content = content.slice(0, -3).trim();
|
|
193
|
+
}
|
|
194
|
+
const parsed = JSON.parse(content);
|
|
195
|
+
if (!Array.isArray(parsed)) {
|
|
196
|
+
throw new Error('AI response is not a JSON array');
|
|
197
|
+
}
|
|
198
|
+
return parsed;
|
|
199
|
+
}
|
|
200
|
+
// ── 6. Orchestrator ──────────────────────────────────────────────────
|
|
201
|
+
/**
|
|
202
|
+
* Main orchestrator: generate AI-powered social media posts and push to Strapi.
|
|
203
|
+
*
|
|
204
|
+
* Steps:
|
|
205
|
+
* 1. Call Groq to generate post ideas as JSON
|
|
206
|
+
* 2. For each post, search Unsplash for a themed image
|
|
207
|
+
* 3. Build SocialPostData and push to Strapi via POST /api/social-posts
|
|
208
|
+
* 4. Return summary of created posts and any errors
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* const result = await generateSocialPosts({
|
|
212
|
+
* brand: 'LIFEINSUR',
|
|
213
|
+
* count: 9,
|
|
214
|
+
* weekOf: '2026-03-02',
|
|
215
|
+
* })
|
|
216
|
+
* console.log(`Created ${result.postsCreated} posts`)
|
|
217
|
+
*/
|
|
218
|
+
export async function generateSocialPosts(opts) {
|
|
219
|
+
const { brand, count = 9, weekOf = new Date().toISOString().slice(0, 10), platforms = ['instagram', 'facebook'], dryRun = false, } = opts;
|
|
220
|
+
const config = BRAND_CONFIGS[brand];
|
|
221
|
+
const displayName = config?.displayName ?? brand;
|
|
222
|
+
console.log('='.repeat(60));
|
|
223
|
+
console.log(`SOCIAL POST GENERATOR — ${displayName}`);
|
|
224
|
+
console.log('='.repeat(60));
|
|
225
|
+
console.log(`Brand: ${brand}`);
|
|
226
|
+
console.log(`Count: ${count}`);
|
|
227
|
+
console.log(`Week of: ${weekOf}`);
|
|
228
|
+
console.log(`Platforms: ${platforms.join(', ')}`);
|
|
229
|
+
if (dryRun)
|
|
230
|
+
console.log('DRY RUN — will not push to Strapi');
|
|
231
|
+
const result = {
|
|
232
|
+
brand,
|
|
233
|
+
postsCreated: 0,
|
|
234
|
+
posts: [],
|
|
235
|
+
errors: [],
|
|
236
|
+
};
|
|
237
|
+
// Step 1: Generate post ideas via Groq
|
|
238
|
+
console.log(`\n1. Calling Groq to generate ${count} post ideas...`);
|
|
239
|
+
let postIdeas;
|
|
240
|
+
try {
|
|
241
|
+
const systemPrompt = buildSystemPrompt(brand);
|
|
242
|
+
const userPrompt = buildUserPrompt(brand, platforms, weekOf, count);
|
|
243
|
+
const raw = await callGroq(systemPrompt, userPrompt);
|
|
244
|
+
postIdeas = parseAiPostIdeas(raw);
|
|
245
|
+
console.log(` Generated ${postIdeas.length} post ideas`);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const msg = `Failed to generate post ideas: ${err.message}`;
|
|
249
|
+
console.error(` ERROR: ${msg}`);
|
|
250
|
+
result.errors.push(msg);
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
// Step 2: Build weekly schedule
|
|
254
|
+
const schedule = buildWeeklySchedule(weekOf, count);
|
|
255
|
+
console.log(`\n2. Scheduling posts: ${schedule[0]} → ${schedule[schedule.length - 1]}`);
|
|
256
|
+
// Step 3: Process each post idea
|
|
257
|
+
console.log(`\n3. Processing ${postIdeas.length} posts (image search + Strapi push)...`);
|
|
258
|
+
for (let i = 0; i < postIdeas.length; i++) {
|
|
259
|
+
const idea = postIdeas[i];
|
|
260
|
+
const platform = platforms[i % platforms.length];
|
|
261
|
+
const scheduled_date = schedule[i] ?? schedule[schedule.length - 1];
|
|
262
|
+
const overlay_style = OVERLAY_STYLES[i % OVERLAY_STYLES.length];
|
|
263
|
+
console.log(`\n [${i + 1}/${postIdeas.length}] "${idea.headline}"`);
|
|
264
|
+
console.log(` Platform: ${platform} | Date: ${scheduled_date}`);
|
|
265
|
+
// Search Unsplash for image
|
|
266
|
+
let image_url = null;
|
|
267
|
+
if (idea.image_search_query) {
|
|
268
|
+
console.log(` Searching Unsplash: "${idea.image_search_query}"`);
|
|
269
|
+
image_url = await searchUnsplashImage(idea.image_search_query);
|
|
270
|
+
if (image_url) {
|
|
271
|
+
console.log(` Image found: ${image_url.slice(0, 60)}...`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
console.log(` No image found — posting without image`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Build post data
|
|
278
|
+
const postData = {
|
|
279
|
+
headline: idea.headline,
|
|
280
|
+
body: idea.body,
|
|
281
|
+
cta_text: idea.cta_text,
|
|
282
|
+
cta_url: idea.cta_url,
|
|
283
|
+
image_url,
|
|
284
|
+
overlay_style: idea.overlay_style ?? overlay_style,
|
|
285
|
+
template: idea.template ?? 'standard',
|
|
286
|
+
platform,
|
|
287
|
+
brand,
|
|
288
|
+
scheduled_date,
|
|
289
|
+
delivery_status: 'pending',
|
|
290
|
+
};
|
|
291
|
+
// Push to Strapi (unless dry run)
|
|
292
|
+
if (dryRun) {
|
|
293
|
+
console.log(` DRY RUN — would create post in Strapi`);
|
|
294
|
+
result.posts.push({
|
|
295
|
+
documentId: `dry-run-${i + 1}`,
|
|
296
|
+
headline: postData.headline,
|
|
297
|
+
platform: postData.platform,
|
|
298
|
+
scheduled_date: postData.scheduled_date,
|
|
299
|
+
});
|
|
300
|
+
result.postsCreated++;
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
try {
|
|
304
|
+
const created = await strapiPost('/api/social-posts', postData);
|
|
305
|
+
const documentId = created.documentId ?? 'unknown';
|
|
306
|
+
console.log(` Created in Strapi (documentId: ${documentId})`);
|
|
307
|
+
result.posts.push({
|
|
308
|
+
documentId,
|
|
309
|
+
headline: postData.headline,
|
|
310
|
+
platform: postData.platform,
|
|
311
|
+
scheduled_date: postData.scheduled_date,
|
|
312
|
+
});
|
|
313
|
+
result.postsCreated++;
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
const msg = `Failed to create post "${idea.headline}": ${err.message}`;
|
|
317
|
+
console.error(` ERROR: ${msg}`);
|
|
318
|
+
result.errors.push(msg);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Summary
|
|
323
|
+
console.log('\n' + '='.repeat(60));
|
|
324
|
+
console.log(`DONE! Created ${result.postsCreated}/${count} posts`);
|
|
325
|
+
if (result.errors.length > 0) {
|
|
326
|
+
console.log(`Errors (${result.errors.length}):`);
|
|
327
|
+
for (const e of result.errors) {
|
|
328
|
+
console.log(` - ${e}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
console.log('='.repeat(60));
|
|
332
|
+
return result;
|
|
333
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Post Publisher
|
|
3
|
+
*
|
|
4
|
+
* Handles publishing social posts from Strapi to platforms via n8n webhooks,
|
|
5
|
+
* with delivery status tracking written back to Strapi.
|
|
6
|
+
*
|
|
7
|
+
* Functions:
|
|
8
|
+
* publishSocialPosts() — Main orchestrator: fetch pending posts, publish to Strapi,
|
|
9
|
+
* trigger n8n webhook, update delivery_status
|
|
10
|
+
* getPublishQueue() — List posts ready to publish (pending + has scheduled_date)
|
|
11
|
+
* retryFailed() — Re-attempt posts with delivery_status = 'failed'
|
|
12
|
+
*/
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
export interface PublishOptions {
|
|
15
|
+
brand: string;
|
|
16
|
+
/** Max posts to publish (default: all pending) */
|
|
17
|
+
limit?: number;
|
|
18
|
+
/** Preview without actually publishing */
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface QueuedPost {
|
|
22
|
+
documentId: string;
|
|
23
|
+
headline: string;
|
|
24
|
+
platform: string;
|
|
25
|
+
brand: string;
|
|
26
|
+
scheduled_date: string;
|
|
27
|
+
}
|
|
28
|
+
export interface PublishResult {
|
|
29
|
+
published: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
details: Array<{
|
|
33
|
+
documentId: string;
|
|
34
|
+
headline: string;
|
|
35
|
+
status: 'published' | 'failed' | 'skipped';
|
|
36
|
+
error?: string;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fetch pending social posts for a brand and publish them:
|
|
41
|
+
* 1. Publish in Strapi (set publishedAt)
|
|
42
|
+
* 2. Trigger n8n webhook
|
|
43
|
+
* 3. Update delivery_status to 'scheduled' (or 'failed' on error)
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const result = await publishSocialPosts({ brand: 'LIFEINSUR', limit: 3 })
|
|
47
|
+
* console.log(`Published: ${result.published}, Failed: ${result.failed}`)
|
|
48
|
+
*/
|
|
49
|
+
export declare function publishSocialPosts(opts: PublishOptions): Promise<PublishResult>;
|
|
50
|
+
/**
|
|
51
|
+
* List posts ready to publish: delivery_status = 'pending' AND has a scheduled_date.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* const queue = await getPublishQueue('LIFEINSUR')
|
|
55
|
+
* queue.forEach(p => console.log(p.scheduled_date, p.headline))
|
|
56
|
+
*/
|
|
57
|
+
export declare function getPublishQueue(brand: string): Promise<QueuedPost[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Re-attempt publishing posts with delivery_status = 'failed'.
|
|
60
|
+
* Resets delivery_status to 'pending' on each post before re-processing.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const result = await retryFailed('LIFEINSUR')
|
|
64
|
+
* console.log(`Re-published: ${result.published}, Still failing: ${result.failed}`)
|
|
65
|
+
*/
|
|
66
|
+
export declare function retryFailed(brand: string): Promise<PublishResult>;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social Post Publisher
|
|
3
|
+
*
|
|
4
|
+
* Handles publishing social posts from Strapi to platforms via n8n webhooks,
|
|
5
|
+
* with delivery status tracking written back to Strapi.
|
|
6
|
+
*
|
|
7
|
+
* Functions:
|
|
8
|
+
* publishSocialPosts() — Main orchestrator: fetch pending posts, publish to Strapi,
|
|
9
|
+
* trigger n8n webhook, update delivery_status
|
|
10
|
+
* getPublishQueue() — List posts ready to publish (pending + has scheduled_date)
|
|
11
|
+
* retryFailed() — Re-attempt posts with delivery_status = 'failed'
|
|
12
|
+
*/
|
|
13
|
+
import 'dotenv/config';
|
|
14
|
+
import { strapiGet, strapiPut, publish, } from '../cms/strapi-client.js';
|
|
15
|
+
// ── Config ────────────────────────────────────────────────────────────
|
|
16
|
+
function getN8nWebhookUrl() {
|
|
17
|
+
const url = process.env.N8N_WEBHOOK_URL;
|
|
18
|
+
if (!url) {
|
|
19
|
+
throw new Error('Missing env var: N8N_WEBHOOK_URL\n' +
|
|
20
|
+
'Set it in your .env file, e.g.:\n' +
|
|
21
|
+
' N8N_WEBHOOK_URL=https://n8n.op-hub.com');
|
|
22
|
+
}
|
|
23
|
+
return url.replace(/\/+$/, '');
|
|
24
|
+
}
|
|
25
|
+
// ── Internal helpers ──────────────────────────────────────────────────
|
|
26
|
+
/** Trigger n8n webhook for a single social post */
|
|
27
|
+
async function triggerN8nWebhook(documentId, platform, brand) {
|
|
28
|
+
const baseUrl = getN8nWebhookUrl();
|
|
29
|
+
const webhookUrl = `${baseUrl}/webhook/social-post-publish`;
|
|
30
|
+
const res = await fetch(webhookUrl, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'Content-Type': 'application/json' },
|
|
33
|
+
body: JSON.stringify({ documentId, platform, brand }),
|
|
34
|
+
});
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
let detail = `HTTP ${res.status}: ${res.statusText}`;
|
|
37
|
+
try {
|
|
38
|
+
const body = await res.text();
|
|
39
|
+
if (body)
|
|
40
|
+
detail += ` — ${body.slice(0, 200)}`;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// non-text body, ignore
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`n8n webhook failed: ${detail}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Fetch social posts by brand + delivery_status from Strapi */
|
|
49
|
+
async function fetchPostsByStatus(brand, deliveryStatus) {
|
|
50
|
+
const result = await strapiGet('/api/social-posts', {
|
|
51
|
+
'filters[brand][$eq]': brand,
|
|
52
|
+
'filters[delivery_status][$eq]': deliveryStatus,
|
|
53
|
+
'sort': 'scheduled_date:asc',
|
|
54
|
+
'pagination[pageSize]': '250',
|
|
55
|
+
});
|
|
56
|
+
return result.data;
|
|
57
|
+
}
|
|
58
|
+
/** Process a single post: publish in Strapi, trigger n8n, update status */
|
|
59
|
+
async function processPost(post, dryRun) {
|
|
60
|
+
const documentId = post.documentId;
|
|
61
|
+
const headline = post.headline ?? '(no headline)';
|
|
62
|
+
const platform = post.platform ?? 'unknown';
|
|
63
|
+
const brand = post.brand ?? 'unknown';
|
|
64
|
+
if (dryRun) {
|
|
65
|
+
return { status: 'skipped' };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
// Step 1: Publish in Strapi (set publishedAt)
|
|
69
|
+
await publish('social-posts', documentId);
|
|
70
|
+
// Step 2: Trigger n8n webhook
|
|
71
|
+
try {
|
|
72
|
+
await triggerN8nWebhook(documentId, platform, brand);
|
|
73
|
+
}
|
|
74
|
+
catch (webhookErr) {
|
|
75
|
+
// Webhook failure: mark failed, but don't rethrow — continue to next post
|
|
76
|
+
const errMsg = webhookErr instanceof Error ? webhookErr.message : String(webhookErr);
|
|
77
|
+
await strapiPut('/api/social-posts', documentId, {
|
|
78
|
+
delivery_status: 'failed',
|
|
79
|
+
delivery_errors: [{ timestamp: new Date().toISOString(), error: errMsg }],
|
|
80
|
+
});
|
|
81
|
+
return { status: 'failed', error: errMsg };
|
|
82
|
+
}
|
|
83
|
+
// Step 3: Update delivery_status to 'scheduled' on success
|
|
84
|
+
await strapiPut('/api/social-posts', documentId, {
|
|
85
|
+
delivery_status: 'scheduled',
|
|
86
|
+
});
|
|
87
|
+
return { status: 'published' };
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
91
|
+
// Best-effort status update on unexpected errors
|
|
92
|
+
try {
|
|
93
|
+
await strapiPut('/api/social-posts', documentId, {
|
|
94
|
+
delivery_status: 'failed',
|
|
95
|
+
delivery_errors: [{ timestamp: new Date().toISOString(), error: errMsg }],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Ignore secondary failure — original error is more important
|
|
100
|
+
}
|
|
101
|
+
return { status: 'failed', error: errMsg };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ── Core orchestrator ─────────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* Fetch pending social posts for a brand and publish them:
|
|
107
|
+
* 1. Publish in Strapi (set publishedAt)
|
|
108
|
+
* 2. Trigger n8n webhook
|
|
109
|
+
* 3. Update delivery_status to 'scheduled' (or 'failed' on error)
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* const result = await publishSocialPosts({ brand: 'LIFEINSUR', limit: 3 })
|
|
113
|
+
* console.log(`Published: ${result.published}, Failed: ${result.failed}`)
|
|
114
|
+
*/
|
|
115
|
+
export async function publishSocialPosts(opts) {
|
|
116
|
+
const { brand, limit, dryRun = false } = opts;
|
|
117
|
+
// Validate n8n URL up front (unless dry run)
|
|
118
|
+
if (!dryRun) {
|
|
119
|
+
getN8nWebhookUrl();
|
|
120
|
+
}
|
|
121
|
+
const posts = await fetchPostsByStatus(brand, 'pending');
|
|
122
|
+
const postsToProcess = limit !== undefined ? posts.slice(0, limit) : posts;
|
|
123
|
+
const result = {
|
|
124
|
+
published: 0,
|
|
125
|
+
failed: 0,
|
|
126
|
+
skipped: 0,
|
|
127
|
+
details: [],
|
|
128
|
+
};
|
|
129
|
+
for (const post of postsToProcess) {
|
|
130
|
+
const documentId = post.documentId;
|
|
131
|
+
const headline = post.headline ?? '(no headline)';
|
|
132
|
+
const outcome = await processPost(post, dryRun);
|
|
133
|
+
if (outcome.status === 'published')
|
|
134
|
+
result.published++;
|
|
135
|
+
else if (outcome.status === 'failed')
|
|
136
|
+
result.failed++;
|
|
137
|
+
else
|
|
138
|
+
result.skipped++;
|
|
139
|
+
result.details.push({
|
|
140
|
+
documentId,
|
|
141
|
+
headline,
|
|
142
|
+
status: outcome.status,
|
|
143
|
+
...(outcome.error !== undefined && { error: outcome.error }),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
// ── Publish queue ─────────────────────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* List posts ready to publish: delivery_status = 'pending' AND has a scheduled_date.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* const queue = await getPublishQueue('LIFEINSUR')
|
|
154
|
+
* queue.forEach(p => console.log(p.scheduled_date, p.headline))
|
|
155
|
+
*/
|
|
156
|
+
export async function getPublishQueue(brand) {
|
|
157
|
+
const posts = await fetchPostsByStatus(brand, 'pending');
|
|
158
|
+
return posts
|
|
159
|
+
.filter((post) => {
|
|
160
|
+
const scheduledDate = post.scheduled_date;
|
|
161
|
+
return scheduledDate != null && scheduledDate !== '';
|
|
162
|
+
})
|
|
163
|
+
.map((post) => ({
|
|
164
|
+
documentId: post.documentId,
|
|
165
|
+
headline: post.headline ?? '(no headline)',
|
|
166
|
+
platform: post.platform ?? 'unknown',
|
|
167
|
+
brand: post.brand ?? brand,
|
|
168
|
+
scheduled_date: post.scheduled_date,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
// ── Retry failed ──────────────────────────────────────────────────────
|
|
172
|
+
/**
|
|
173
|
+
* Re-attempt publishing posts with delivery_status = 'failed'.
|
|
174
|
+
* Resets delivery_status to 'pending' on each post before re-processing.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* const result = await retryFailed('LIFEINSUR')
|
|
178
|
+
* console.log(`Re-published: ${result.published}, Still failing: ${result.failed}`)
|
|
179
|
+
*/
|
|
180
|
+
export async function retryFailed(brand) {
|
|
181
|
+
// Validate n8n URL up front
|
|
182
|
+
getN8nWebhookUrl();
|
|
183
|
+
const posts = await fetchPostsByStatus(brand, 'failed');
|
|
184
|
+
const result = {
|
|
185
|
+
published: 0,
|
|
186
|
+
failed: 0,
|
|
187
|
+
skipped: 0,
|
|
188
|
+
details: [],
|
|
189
|
+
};
|
|
190
|
+
for (const post of posts) {
|
|
191
|
+
const documentId = post.documentId;
|
|
192
|
+
const headline = post.headline ?? '(no headline)';
|
|
193
|
+
// Reset to pending so processPost can re-publish cleanly
|
|
194
|
+
try {
|
|
195
|
+
await strapiPut('/api/social-posts', documentId, {
|
|
196
|
+
delivery_status: 'pending',
|
|
197
|
+
delivery_errors: null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
202
|
+
result.failed++;
|
|
203
|
+
result.details.push({
|
|
204
|
+
documentId,
|
|
205
|
+
headline,
|
|
206
|
+
status: 'failed',
|
|
207
|
+
error: `Could not reset delivery_status: ${errMsg}`,
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const outcome = await processPost(post, false);
|
|
212
|
+
if (outcome.status === 'published')
|
|
213
|
+
result.published++;
|
|
214
|
+
else if (outcome.status === 'failed')
|
|
215
|
+
result.failed++;
|
|
216
|
+
else
|
|
217
|
+
result.skipped++;
|
|
218
|
+
result.details.push({
|
|
219
|
+
documentId,
|
|
220
|
+
headline,
|
|
221
|
+
status: outcome.status,
|
|
222
|
+
...(outcome.error !== undefined && { error: outcome.error }),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta Ad Library Scraper
|
|
3
|
+
*
|
|
4
|
+
* Ported from Python: ~/projects/meta-ad-scraper/scripts/meta_ad_scraper_v2.py
|
|
5
|
+
*
|
|
6
|
+
* Scrapes Facebook Ad Library for competitor ad intelligence.
|
|
7
|
+
* Uses Playwright headless Chromium with anti-detection measures.
|
|
8
|
+
* Splits ads by Library ID pattern, extracts metadata via regex.
|
|
9
|
+
*
|
|
10
|
+
* Functions:
|
|
11
|
+
* buildUrl() — construct Facebook Ad Library URL for a company
|
|
12
|
+
* scrollAndLoad() — auto-scroll page to load all ads (max 15 scrolls)
|
|
13
|
+
* extractAds() — two-stage extraction: DOM containers, then text split fallback
|
|
14
|
+
* parseAdText() — regex extraction of ad metadata from text blocks
|
|
15
|
+
* extractLandingUrls() — find landing page URLs from DOM links
|
|
16
|
+
* scrapeCompany() — orchestrate single company scrape
|
|
17
|
+
* scrapeCompanies() — batch-scrape multiple companies with configurable parallelism
|
|
18
|
+
* formatCsv() — convert ad records to CSV string
|
|
19
|
+
*/
|
|
20
|
+
import { type Page } from 'playwright';
|
|
21
|
+
export interface AdRecord {
|
|
22
|
+
company_searched: string;
|
|
23
|
+
ad_id: string;
|
|
24
|
+
page_name: string;
|
|
25
|
+
ad_text: string;
|
|
26
|
+
status: string;
|
|
27
|
+
start_date: string;
|
|
28
|
+
impressions: string;
|
|
29
|
+
spend: string;
|
|
30
|
+
media_type: string;
|
|
31
|
+
platforms: string;
|
|
32
|
+
landing_page_url: string;
|
|
33
|
+
full_text_snippet: string;
|
|
34
|
+
}
|
|
35
|
+
export interface ScrapeOptions {
|
|
36
|
+
/** Companies to scrape */
|
|
37
|
+
companies: string[];
|
|
38
|
+
/** Output file path (if undefined, return results only) */
|
|
39
|
+
outputPath?: string;
|
|
40
|
+
/** Batch size for parallel processing (default: 6) */
|
|
41
|
+
batchSize?: number;
|
|
42
|
+
/** Maximum scrolls per page (default: 15) */
|
|
43
|
+
maxScrolls?: number;
|
|
44
|
+
/** Delay between companies in ms (default: 4000) */
|
|
45
|
+
companyDelay?: number;
|
|
46
|
+
/** Run headless (default: true) */
|
|
47
|
+
headless?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface ScrapeResult {
|
|
50
|
+
ads: AdRecord[];
|
|
51
|
+
totalCompanies: number;
|
|
52
|
+
companiesScraped: number;
|
|
53
|
+
outputPath?: string;
|
|
54
|
+
}
|
|
55
|
+
export declare function buildUrl(companyName: string): string;
|
|
56
|
+
export declare function scrollAndLoad(page: Page, maxScrolls?: number): Promise<void>;
|
|
57
|
+
export declare function parseAdText(text: string, companyName: string): AdRecord | null;
|
|
58
|
+
export declare function extractAds(page: Page, companyName: string, maxScrolls?: number): Promise<AdRecord[]>;
|
|
59
|
+
export declare function extractLandingUrls(page: Page, adIds: string[]): Promise<Record<string, string>>;
|
|
60
|
+
export declare function scrapeCompany(page: Page, companyName: string, maxScrolls?: number): Promise<AdRecord[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Scrape multiple companies in batches.
|
|
63
|
+
* Default: 6 companies per batch, 3 parallel batches (as documented in memory).
|
|
64
|
+
*/
|
|
65
|
+
export declare function scrapeCompanies(opts: ScrapeOptions): Promise<ScrapeResult>;
|
|
66
|
+
/** Convert ad records to CSV string */
|
|
67
|
+
export declare function formatCsv(ads: AdRecord[]): string;
|