backend-manager 5.8.2 → 5.9.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.
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Blog Auto Publisher — daily cron job.
3
+ *
4
+ * Generates and publishes blog posts using a provider-based article engine
5
+ * (default: Ghostii). Iterates `config.blog.content[]` entries, each defining
6
+ * a source pool (generic topics, URLs, text prompts, RSS/Atom feeds, or parent
7
+ * server sources) and per-entry overrides for the provider API params.
8
+ *
9
+ * Source types:
10
+ * '$brand' — generic brand-topic generation
11
+ * '$feed:<url>' — RSS/Atom/JSON feed: pick one unprocessed article per run
12
+ * '$parent' — fetch sources from parent server's source pool (without claiming)
13
+ * 'https://...' — fetch URL content as prompt seed
14
+ * '<text>' — use directly as prompt seed
15
+ *
16
+ * All feed/parent sources are tracked in Firestore (`content-sources`) so the
17
+ * same article is never processed twice. When a feed is unreachable or
18
+ * exhausted, the entry falls back to $brand behavior.
19
+ */
20
+ const crypto = require('crypto');
21
+ const fetch = require('wonderful-fetch');
22
+ const powertools = require('node-powertools');
23
+ const moment = require('moment');
24
+ const JSON5 = require('json5');
25
+
26
+ const { parseFeed, extractArticleContent } = require('../../../libraries/content/feed-parser.js');
27
+
28
+ const PROMPT = `
29
+ Company: {brand.brand.name}: {brand.brand.description}
30
+ Date: {date}
31
+ Instructions: {instructions}
32
+ Tone: {tone}
33
+ Categories: {categories}
34
+ Keywords: {keywords}
35
+
36
+ Use the following information to find a topic for our company blog (it can be about our company OR any topic that would be relevant to our website and business BUT not about a competitor):
37
+ {suggestion}
38
+ `;
39
+
40
+ const PROMPT_SOURCE = `
41
+ Company: {brand.brand.name}: {brand.brand.description}
42
+ Date: {date}
43
+ Instructions: {instructions}
44
+ Tone: {tone}
45
+ Categories: {categories}
46
+ Keywords: {keywords}
47
+
48
+ Write an original article inspired by and referencing this source material.
49
+ Do NOT copy the source — use it as context and inspiration for a unique take.
50
+ Source title: {sourceTitle}
51
+ `;
52
+
53
+ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';
54
+ const FEED_PREFIX = '$feed:';
55
+ const CONTENT_SOURCES_COLLECTION = 'content-sources';
56
+
57
+ // State
58
+ let postId;
59
+
60
+ /**
61
+ * Blog Auto Publisher cron job
62
+ *
63
+ * Automatically generates and publishes blog posts using the configured platform provider.
64
+ */
65
+ module.exports = async ({ Manager, assistant, context, libraries }) => {
66
+ // Gate on blog.enabled
67
+ if (!Manager.config.blog?.enabled) {
68
+ assistant.log('Blog auto-publisher disabled in config');
69
+ return;
70
+ }
71
+
72
+ // Log
73
+ assistant.log('Starting...');
74
+
75
+ // Set post ID
76
+ postId = moment().unix();
77
+
78
+ // Build brand config from local config
79
+ const brandConfig = buildBrandConfig(Manager.config);
80
+
81
+ // Log
82
+ assistant.log('Brand config', brandConfig);
83
+
84
+ // Get blog config
85
+ const blog = Manager.config.blog;
86
+
87
+ // Get content entries (array or single object)
88
+ const contentArray = powertools.arrayify(blog.content);
89
+
90
+ // Get admin for Firestore tracking
91
+ const { admin } = libraries;
92
+
93
+ // Load the provider based on blog.platform
94
+ const platform = blog.platform || 'ghostii';
95
+ const provider = require(`../../../libraries/content/${platform}.js`);
96
+
97
+ // Loop through each content entry
98
+ for (const entry of contentArray) {
99
+ // Normalize entry fields
100
+ entry.quantity = entry.quantity || 0;
101
+ entry.sources = randomize(entry.sources || []);
102
+ entry.links = randomize(entry.links || []);
103
+ entry.instructions = entry.instructions || '';
104
+ entry.tone = entry.tone || 'professional';
105
+ entry.categories = entry.categories || [];
106
+ entry.keywords = entry.keywords || [];
107
+ entry.chance = entry.chance || 1.0;
108
+ entry.author = entry.author || undefined;
109
+ entry.postPath = entry.postPath || platform;
110
+ entry.overrides = entry.overrides || {};
111
+
112
+ // Resolve brand data for this entry
113
+ if (entry.brand && entry.brandUrl) {
114
+ // Cross-brand: fetch from the other project's /brand endpoint
115
+ entry.brand = await fetchRemoteBrand(entry.brandUrl).catch((e) => e);
116
+
117
+ if (entry.brand instanceof Error) {
118
+ assistant.error('Error fetching remote brand data', entry.brand);
119
+ continue;
120
+ }
121
+ } else {
122
+ // Same-brand: use local config
123
+ entry.brand = brandConfig;
124
+ }
125
+
126
+ // Log
127
+ assistant.log(`Entry (brand=${entry.brand.brand.id})`, entry);
128
+
129
+ // Quit if quantity is zero or no sources
130
+ if (!entry.quantity || !entry.sources.length) {
131
+ assistant.log('Quitting because quantity is 0 or no sources');
132
+ continue;
133
+ }
134
+
135
+ // Quit if the chance is not met
136
+ const chance = Math.random();
137
+ if (chance > entry.chance) {
138
+ assistant.log(`Quitting because the chance is not met (${chance} <= ${entry.chance})`);
139
+ continue;
140
+ }
141
+
142
+ // Harvest articles
143
+ const result = await harvest(assistant, entry, admin, provider, Manager).catch((e) => e);
144
+ if (result instanceof Error) {
145
+ throw result;
146
+ }
147
+
148
+ // Log
149
+ assistant.log('Finished!', result);
150
+ }
151
+ };
152
+
153
+ /**
154
+ * Build brand config from Manager.config (same shape as /brand endpoint response)
155
+ */
156
+ function buildBrandConfig(config) {
157
+ const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', '..', 'routes', 'brand', 'get.js'));
158
+
159
+ return buildPublicConfig(config);
160
+ }
161
+
162
+ /**
163
+ * Fetch brand data from a remote BEM project's /brand endpoint
164
+ */
165
+ function fetchRemoteBrand(brandUrl) {
166
+ return fetch(`${brandUrl}/backend-manager/brand`, {
167
+ timeout: 120000,
168
+ tries: 3,
169
+ response: 'json',
170
+ });
171
+ }
172
+
173
+ async function harvest(assistant, entry, admin, provider, Manager) {
174
+ const date = moment().format('MMMM YYYY');
175
+
176
+ // Log
177
+ assistant.log(`harvest(): Starting ${entry.brand.brand.id}...`);
178
+
179
+ // Process the number of sources in the entry
180
+ for (let index = 0; index < entry.quantity; index++) {
181
+ const source = powertools.random(entry.sources);
182
+
183
+ // Log
184
+ assistant.log(`harvest(): Processing ${index + 1}/${entry.quantity}`, source);
185
+
186
+ // Resolve the source into a prompt description + optional sourceContent
187
+ const resolved = await resolveSource(assistant, source, entry, admin, Manager).catch((e) => e);
188
+ if (resolved instanceof Error) {
189
+ assistant.error('harvest(): Error resolving source', resolved);
190
+ break;
191
+ }
192
+
193
+ // Log
194
+ assistant.log('harvest(): Resolved source', resolved);
195
+
196
+ // Use keywords from entry if overrides.keywords is not set
197
+ const overrides = { ...entry.overrides };
198
+ if (!overrides.keywords && entry.keywords.length) {
199
+ overrides.keywords = entry.keywords;
200
+ }
201
+
202
+ // Request article from provider
203
+ const article = await provider.writeArticle({
204
+ brand: entry.brand,
205
+ description: resolved.description,
206
+ links: entry.links,
207
+ sourceContent: resolved.sourceContent || '',
208
+ overrides: overrides,
209
+ }).catch((e) => e);
210
+ if (article instanceof Error) {
211
+ assistant.error('harvest(): Error requesting article from provider', article);
212
+ break;
213
+ }
214
+
215
+ // Log
216
+ assistant.log('harvest(): Article', article);
217
+
218
+ // Upload post to blog
219
+ const uploadedPost = await provider.publishArticle(assistant, {
220
+ brand: entry.brand,
221
+ article,
222
+ id: postId++,
223
+ author: entry.author,
224
+ postPath: entry.postPath,
225
+ }).catch((e) => e);
226
+ if (uploadedPost instanceof Error) {
227
+ assistant.error('harvest(): Error uploading post to blog', uploadedPost);
228
+ break;
229
+ }
230
+
231
+ // Log
232
+ assistant.log('harvest(): Uploaded post', uploadedPost);
233
+
234
+ // Track content source in Firestore
235
+ if (resolved.trackingData && admin) {
236
+ await trackContentSource(admin, {
237
+ ...resolved.trackingData,
238
+ brandId: entry.brand.brand.id,
239
+ postUrl: uploadedPost.url,
240
+ postSlug: uploadedPost.slug,
241
+ }).catch((e) => {
242
+ assistant.error('harvest(): Error tracking content source (non-fatal)', e);
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Resolve a source entry into { description, sourceContent, trackingData? }.
250
+ *
251
+ * Source types:
252
+ * '$brand' — generic topic prompt, no sourceContent
253
+ * '$feed:<url>' — RSS/Atom feed: pick unprocessed article, extract content
254
+ * '$parent' — fetch sources from parent server without claiming
255
+ * URL — fetch page content as prompt seed
256
+ * text — use directly as prompt seed
257
+ *
258
+ * Feed/parent failures fall back to $brand behavior.
259
+ */
260
+ async function resolveSource(assistant, source, entry, admin, Manager) {
261
+ const date = moment().format('MMMM YYYY');
262
+
263
+ // Build template vars for prompts
264
+ const templateVars = {
265
+ ...entry,
266
+ instructions: entry.instructions,
267
+ date: date,
268
+ tone: entry.tone || '',
269
+ categories: (entry.categories || []).join(', '),
270
+ keywords: (entry.keywords || []).join(', '),
271
+ };
272
+
273
+ // --- $feed: source ---
274
+ if (typeof source === 'string' && source.startsWith(FEED_PREFIX)) {
275
+ const feedUrl = source.slice(FEED_PREFIX.length);
276
+ assistant.log(`resolveSource(): Processing feed: ${feedUrl}`);
277
+
278
+ const feedResult = await processFeedSource(assistant, feedUrl, entry.brand.brand.id, admin).catch((e) => e);
279
+
280
+ if (feedResult instanceof Error || !feedResult) {
281
+ assistant.log('resolveSource(): Feed failed or exhausted, falling back to $brand');
282
+ return resolveSource(assistant, '$brand', entry, admin, Manager);
283
+ }
284
+
285
+ const description = powertools.template(PROMPT_SOURCE, {
286
+ ...templateVars,
287
+ sourceTitle: feedResult.item.title,
288
+ });
289
+
290
+ return {
291
+ description,
292
+ sourceContent: feedResult.content || feedResult.item.summary || '',
293
+ trackingData: {
294
+ url: feedResult.item.url || feedResult.item.id,
295
+ origin: source,
296
+ feedUrl: feedUrl,
297
+ itemId: feedResult.item.id,
298
+ itemTitle: feedResult.item.title,
299
+ usedBy: 'blog',
300
+ },
301
+ };
302
+ }
303
+
304
+ // --- $parent source ---
305
+ if (source === '$parent') {
306
+ assistant.log('resolveSource(): Processing $parent source');
307
+
308
+ const parentResult = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
309
+
310
+ if (parentResult instanceof Error || !parentResult) {
311
+ assistant.log('resolveSource(): Parent source failed or exhausted, falling back to $brand');
312
+ return resolveSource(assistant, '$brand', entry, admin, Manager);
313
+ }
314
+
315
+ const description = powertools.template(PROMPT_SOURCE, {
316
+ ...templateVars,
317
+ sourceTitle: parentResult.title,
318
+ });
319
+
320
+ return {
321
+ description,
322
+ sourceContent: parentResult.content || parentResult.summary || '',
323
+ trackingData: {
324
+ url: parentResult.url || parentResult.id,
325
+ origin: '$parent',
326
+ itemId: parentResult.id,
327
+ itemTitle: parentResult.title,
328
+ usedBy: 'blog',
329
+ },
330
+ };
331
+ }
332
+
333
+ // --- $brand source ---
334
+ if (source === '$brand') {
335
+ const suggestion = 'Write an article about any topic that would be relevant to our website and business (it does not have to be about our company, but it can be)';
336
+ const description = powertools.template(PROMPT, {
337
+ ...templateVars,
338
+ suggestion: suggestion,
339
+ });
340
+
341
+ return { description, sourceContent: '' };
342
+ }
343
+
344
+ // --- URL source ---
345
+ if (isURL(source)) {
346
+ const suggestion = await getURLContent(source).catch((e) => e);
347
+
348
+ if (suggestion instanceof Error) {
349
+ assistant.error(`resolveSource(): Error fetching URL ${source}`, suggestion);
350
+ return resolveSource(assistant, '$brand', entry, admin, Manager);
351
+ }
352
+
353
+ const description = powertools.template(PROMPT, {
354
+ ...templateVars,
355
+ suggestion: suggestion,
356
+ });
357
+
358
+ return { description, sourceContent: '' };
359
+ }
360
+
361
+ // --- Text source ---
362
+ const description = powertools.template(PROMPT, {
363
+ ...templateVars,
364
+ suggestion: source,
365
+ });
366
+
367
+ return { description, sourceContent: '' };
368
+ }
369
+
370
+ /**
371
+ * Process a $parent source — fetch sources from the parent server without claiming.
372
+ *
373
+ * Fetches available sources via GET /newsletter-sources (no claimFor parameter),
374
+ * filters out any already tracked locally in `content-sources`, and returns
375
+ * the first available source.
376
+ *
377
+ * @param {object} assistant - BEM assistant
378
+ * @param {object} entry - Content entry config
379
+ * @param {object} admin - Firebase Admin SDK
380
+ * @param {object} Manager - BEM Manager instance
381
+ * @returns {Promise<{ id, title, url, content, summary }|null>} Selected source or null
382
+ */
383
+ async function processParentSource(assistant, entry, admin, Manager) {
384
+ const parentUrl = Manager?.getParentApiUrl?.();
385
+
386
+ if (!parentUrl) {
387
+ assistant.log('processParentSource(): No parent URL configured');
388
+ return null;
389
+ }
390
+
391
+ const categories = entry.categories || [];
392
+ const allSources = [];
393
+
394
+ // Fetch sources from each category (or all if no categories)
395
+ const categoriesToFetch = categories.length ? categories : [''];
396
+
397
+ for (const category of categoriesToFetch) {
398
+ const query = {
399
+ limit: 3,
400
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
401
+ };
402
+
403
+ if (category) {
404
+ query.category = category;
405
+ }
406
+
407
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
408
+ method: 'get',
409
+ response: 'json',
410
+ timeout: 60000,
411
+ query: query,
412
+ }).catch((e) => {
413
+ assistant.error(`processParentSource(): Failed to fetch sources for category=${category}: ${e.message}`);
414
+ return null;
415
+ });
416
+
417
+ if (data?.sources?.length) {
418
+ allSources.push(...data.sources);
419
+ }
420
+ }
421
+
422
+ if (!allSources.length) {
423
+ assistant.log('processParentSource(): No sources available from parent');
424
+ return null;
425
+ }
426
+
427
+ // Filter out already-used sources by checking content-sources collection locally
428
+ if (admin) {
429
+ const usedUrls = new Set();
430
+ const snapshot = await admin.firestore()
431
+ .collection(CONTENT_SOURCES_COLLECTION)
432
+ .where('origin', '==', '$parent')
433
+ .select('url')
434
+ .get()
435
+ .catch((e) => {
436
+ assistant.error('processParentSource(): Error querying tracked sources (continuing)', e);
437
+ return { docs: [] };
438
+ });
439
+
440
+ snapshot.docs.forEach((doc) => {
441
+ const data = doc.data();
442
+ if (data.url) {
443
+ usedUrls.add(data.url);
444
+ }
445
+ });
446
+
447
+ const available = allSources.filter((s) => !usedUrls.has(s.url || s.id));
448
+
449
+ if (!available.length) {
450
+ assistant.log('processParentSource(): All parent sources already used');
451
+ return null;
452
+ }
453
+
454
+ return available[0];
455
+ }
456
+
457
+ // No admin — just return the first source
458
+ return allSources[0];
459
+ }
460
+
461
+ /**
462
+ * Process a $feed: source — fetch, parse, select unprocessed item, extract content.
463
+ *
464
+ * @param {object} assistant - BEM assistant
465
+ * @param {string} feedUrl - RSS/Atom/JSON feed URL
466
+ * @param {string} brandId - Brand ID for tracking
467
+ * @param {object} admin - Firebase Admin SDK
468
+ * @returns {Promise<{ item: FeedItem, content: string }|null>} Selected item + extracted content, or null
469
+ */
470
+ async function processFeedSource(assistant, feedUrl, brandId, admin) {
471
+ // Fetch the feed
472
+ const feedText = await fetch(feedUrl, {
473
+ timeout: 30000,
474
+ tries: 2,
475
+ response: 'text',
476
+ headers: { 'User-Agent': USER_AGENT },
477
+ }).catch((e) => e);
478
+
479
+ if (feedText instanceof Error) {
480
+ assistant.error(`processFeedSource(): Failed to fetch feed: ${feedUrl}`, feedText);
481
+ return null;
482
+ }
483
+
484
+ // Parse the feed
485
+ const { items } = parseFeed(feedText);
486
+ if (!items.length) {
487
+ assistant.log(`processFeedSource(): No items in feed: ${feedUrl}`);
488
+ return null;
489
+ }
490
+
491
+ assistant.log(`processFeedSource(): Parsed ${items.length} items from feed`);
492
+
493
+ // Get already-processed item IDs from Firestore
494
+ const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
495
+ assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
496
+ return new Set();
497
+ });
498
+
499
+ // Filter to unprocessed items
500
+ const unprocessed = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
501
+ if (!unprocessed.length) {
502
+ assistant.log(`processFeedSource(): All ${items.length} items already processed for: ${feedUrl}`);
503
+ return null;
504
+ }
505
+
506
+ assistant.log(`processFeedSource(): ${unprocessed.length} unprocessed items available`);
507
+
508
+ // Pick the first (newest) unprocessed item
509
+ const item = unprocessed[0];
510
+
511
+ // Extract full article content from the item URL
512
+ let content = '';
513
+ if (item.url) {
514
+ content = await extractArticleContent(item.url).catch((e) => {
515
+ assistant.error(`processFeedSource(): Failed to extract content from ${item.url} (using summary)`, e);
516
+ return '';
517
+ });
518
+ }
519
+
520
+ // Fall back to inline feed content or summary
521
+ if (!content || content.length < 100) {
522
+ content = item.content || item.summary || '';
523
+ }
524
+
525
+ return { item, content };
526
+ }
527
+
528
+ /**
529
+ * Query Firestore for already-processed feed item IDs.
530
+ *
531
+ * @param {object} admin - Firebase Admin SDK
532
+ * @param {string} feedUrl - The feed URL to query
533
+ * @returns {Promise<Set<string>>} Set of processed item IDs
534
+ */
535
+ async function getProcessedItemIds(admin, feedUrl) {
536
+ if (!admin) {
537
+ return new Set();
538
+ }
539
+
540
+ const snapshot = await admin.firestore()
541
+ .collection(CONTENT_SOURCES_COLLECTION)
542
+ .where('feedUrl', '==', feedUrl)
543
+ .select('itemId', 'url')
544
+ .get();
545
+
546
+ const ids = new Set();
547
+ snapshot.docs.forEach((doc) => {
548
+ const data = doc.data();
549
+ if (data.itemId) {
550
+ ids.add(data.itemId);
551
+ }
552
+ if (data.url) {
553
+ ids.add(data.url);
554
+ }
555
+ });
556
+
557
+ return ids;
558
+ }
559
+
560
+ /**
561
+ * Write a tracking doc for a processed content source.
562
+ *
563
+ * Unified tracker for all source types (feed, parent, brand).
564
+ * Used by both blog-auto-publisher and newsletter generator.
565
+ *
566
+ * @param {object} admin - Firebase Admin SDK
567
+ * @param {object} args
568
+ * @param {string} args.url - Unique identifier (article URL or source ID)
569
+ * @param {string} args.origin - Source type ('$feed:https://...', '$parent', '$brand')
570
+ * @param {string} [args.feedUrl] - The source feed URL (for feed sources)
571
+ * @param {string} [args.itemId] - Item ID within the feed
572
+ * @param {string} [args.itemTitle] - Item title
573
+ * @param {string} args.usedBy - Which system used it ('blog' or 'newsletter')
574
+ * @param {string} args.brandId - Brand that processed it
575
+ * @param {string} [args.postUrl] - Published blog post URL
576
+ * @param {string} [args.postSlug] - Published blog post slug
577
+ */
578
+ async function trackContentSource(admin, { url, origin, feedUrl, itemId, itemTitle, usedBy, brandId, postUrl, postSlug }) {
579
+ const docId = contentSourceHash(origin || '', url || '');
580
+ const nowISO = new Date().toISOString();
581
+ const nowUNIX = Math.round(Date.now() / 1000);
582
+
583
+ await admin.firestore().doc(`${CONTENT_SOURCES_COLLECTION}/${docId}`).set({
584
+ url: url || null,
585
+ origin: origin || null,
586
+ feedUrl: feedUrl || null,
587
+ itemId: itemId || null,
588
+ itemTitle: itemTitle || null,
589
+ usedBy: usedBy || null,
590
+ brandId: brandId || null,
591
+ postUrl: postUrl || null,
592
+ postSlug: postSlug || null,
593
+ metadata: {
594
+ created: { timestamp: nowISO, timestampUNIX: nowUNIX },
595
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
596
+ },
597
+ });
598
+ }
599
+
600
+ /**
601
+ * Deterministic hash for a content source — used as the Firestore doc ID.
602
+ */
603
+ function contentSourceHash(origin, url) {
604
+ return crypto.createHash('sha256').update(`${origin}::${url}`).digest('hex').slice(0, 20);
605
+ }
606
+
607
+ function getURLContent(url) {
608
+ return fetch(url, {
609
+ timeout: 120000,
610
+ tries: 3,
611
+ response: 'raw',
612
+ headers: {
613
+ 'User-Agent': USER_AGENT,
614
+ },
615
+ })
616
+ .then(async (res) => {
617
+ const contentType = res.headers.get('content-type');
618
+ const text = await res.text();
619
+
620
+ return extractBodyContent(text, contentType, url);
621
+ });
622
+ }
623
+
624
+ function isURL(url) {
625
+ try {
626
+ return !!new URL(url);
627
+ } catch (e) {
628
+ return false;
629
+ }
630
+ }
631
+
632
+ function extractBodyContent(text, contentType, url) {
633
+ const parsed = tryParse(text);
634
+
635
+ // Try JSON
636
+ if (parsed) {
637
+ // If it's from rss.app, extract the content
638
+ if (parsed.items) {
639
+ return parsed.items.map((i) => `${i.title}: ${i.content_text}`).join('\n');
640
+ }
641
+
642
+ // If we can't recognize the JSON, return the original text
643
+ return text;
644
+ }
645
+
646
+ // Extract the content within the body tag
647
+ const bodyMatch = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
648
+ if (!bodyMatch) {
649
+ return '';
650
+ }
651
+
652
+ let bodyContent = bodyMatch[1];
653
+
654
+ // Remove script and meta tags
655
+ bodyContent = bodyContent.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
656
+ bodyContent = bodyContent.replace(/<meta[^>]*>/gi, '');
657
+
658
+ // Remove remaining HTML tags
659
+ return bodyContent.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
660
+ }
661
+
662
+ function tryParse(json) {
663
+ try {
664
+ return JSON5.parse(json);
665
+ } catch (e) {
666
+ return null;
667
+ }
668
+ }
669
+
670
+ function randomize(array) {
671
+ return array.sort(() => Math.random() - 0.5);
672
+ }
673
+
674
+ // Exported for testing and for newsletter to import
675
+ module.exports.resolveSource = resolveSource;
676
+ module.exports.processFeedSource = processFeedSource;
677
+ module.exports.contentSourceHash = contentSourceHash;
678
+ module.exports.getProcessedItemIds = getProcessedItemIds;
679
+ module.exports.trackContentSource = trackContentSource;
680
+ module.exports.isURL = isURL;
@@ -2,7 +2,7 @@
2
2
  * Feed Parser — RSS 2.0, Atom 1.0, and JSON Feed parser + article content extractor.
3
3
  *
4
4
  * Parses standard syndication feeds into a normalized item array and extracts
5
- * readable article text from URLs. Used by the ghostii-auto-publisher cron to
5
+ * readable article text from URLs. Used by the blog-auto-publisher cron to
6
6
  * select individual articles from third-party feeds for AI-assisted content
7
7
  * generation.
8
8
  *
@@ -14,8 +14,8 @@
14
14
  * adds those itself from the separate `title`/`headerImageURL` fields).
15
15
  *
16
16
  * Two consumers:
17
- * - events/cron/daily/ghostii-auto-publisher.js — standalone daily article job
18
- * - libraries/email/generators/newsletter.js — newsletter-driven linked article
17
+ * - events/cron/daily/blog-auto-publisher.js — standalone daily article job
18
+ * - libraries/email/generators/newsletter.js — newsletter-driven linked article
19
19
  *
20
20
  * Both call writeArticle() then publishArticle(). Kept here as the single source
21
21
  * of truth for the Ghostii request shape and the admin/post payload.
@@ -42,7 +42,7 @@ async function writeArticle({ brand, description, links, sourceContent, override
42
42
 
43
43
  const body = {
44
44
  backendManagerKey: process.env.BACKEND_MANAGER_KEY,
45
- keywords: o.keywords || [''],
45
+ keywords: o.keywords || [],
46
46
  description: description,
47
47
  insertLinks: o.insertLinks ?? true,
48
48
  research: o.research ?? true,