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.
Files changed (56) hide show
  1. package/README.md +175 -0
  2. package/dist/bin/optimal.d.ts +2 -0
  3. package/dist/bin/optimal.js +995 -0
  4. package/dist/lib/budget/projections.d.ts +115 -0
  5. package/dist/lib/budget/projections.js +384 -0
  6. package/dist/lib/budget/scenarios.d.ts +93 -0
  7. package/dist/lib/budget/scenarios.js +214 -0
  8. package/dist/lib/cms/publish-blog.d.ts +62 -0
  9. package/dist/lib/cms/publish-blog.js +74 -0
  10. package/dist/lib/cms/strapi-client.d.ts +123 -0
  11. package/dist/lib/cms/strapi-client.js +213 -0
  12. package/dist/lib/config.d.ts +55 -0
  13. package/dist/lib/config.js +206 -0
  14. package/dist/lib/infra/deploy.d.ts +29 -0
  15. package/dist/lib/infra/deploy.js +58 -0
  16. package/dist/lib/infra/migrate.d.ts +34 -0
  17. package/dist/lib/infra/migrate.js +103 -0
  18. package/dist/lib/kanban.d.ts +46 -0
  19. package/dist/lib/kanban.js +118 -0
  20. package/dist/lib/newsletter/distribute.d.ts +52 -0
  21. package/dist/lib/newsletter/distribute.js +193 -0
  22. package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
  23. package/dist/lib/newsletter/generate-insurance.js +36 -0
  24. package/dist/lib/newsletter/generate.d.ts +104 -0
  25. package/dist/lib/newsletter/generate.js +571 -0
  26. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  27. package/dist/lib/returnpro/anomalies.js +166 -0
  28. package/dist/lib/returnpro/audit.d.ts +32 -0
  29. package/dist/lib/returnpro/audit.js +147 -0
  30. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  31. package/dist/lib/returnpro/diagnose.js +281 -0
  32. package/dist/lib/returnpro/kpis.d.ts +32 -0
  33. package/dist/lib/returnpro/kpis.js +192 -0
  34. package/dist/lib/returnpro/templates.d.ts +48 -0
  35. package/dist/lib/returnpro/templates.js +229 -0
  36. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  37. package/dist/lib/returnpro/upload-income.js +235 -0
  38. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  39. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  40. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  41. package/dist/lib/returnpro/upload-r1.js +398 -0
  42. package/dist/lib/social/post-generator.d.ts +83 -0
  43. package/dist/lib/social/post-generator.js +333 -0
  44. package/dist/lib/social/publish.d.ts +66 -0
  45. package/dist/lib/social/publish.js +226 -0
  46. package/dist/lib/social/scraper.d.ts +67 -0
  47. package/dist/lib/social/scraper.js +361 -0
  48. package/dist/lib/supabase.d.ts +4 -0
  49. package/dist/lib/supabase.js +20 -0
  50. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  51. package/dist/lib/transactions/delete-batch.js +203 -0
  52. package/dist/lib/transactions/ingest.d.ts +43 -0
  53. package/dist/lib/transactions/ingest.js +555 -0
  54. package/dist/lib/transactions/stamp.d.ts +51 -0
  55. package/dist/lib/transactions/stamp.js +524 -0
  56. 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;