backend-manager 5.8.3 → 5.9.1

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.
@@ -1,513 +0,0 @@
1
- /**
2
- * Ghostii Auto Publisher — daily cron job.
3
- *
4
- * Generates and publishes blog posts using the Ghostii AI article engine.
5
- * Iterates `config.ghostii[]` entries, each defining a source pool (generic
6
- * topics, URLs, text prompts, or RSS/Atom feeds) and per-entry overrides for
7
- * the Ghostii API params.
8
- *
9
- * Source types:
10
- * '$app' — generic brand-topic generation
11
- * '$feed:<url>' — RSS/Atom/JSON feed: pick one unprocessed article per run
12
- * 'https://...' — fetch URL content as prompt seed
13
- * '<text>' — use directly as prompt seed
14
- *
15
- * Feed items are tracked in Firestore (`ghostii-sources`) so the same
16
- * article is never processed twice. When a feed is unreachable or exhausted,
17
- * the entry falls back to $app behavior.
18
- */
19
- const crypto = require('crypto');
20
- const fetch = require('wonderful-fetch');
21
- const powertools = require('node-powertools');
22
- const moment = require('moment');
23
- const JSON5 = require('json5');
24
-
25
- const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
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: {prompt}
32
-
33
- 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):
34
- {suggestion}
35
- `;
36
-
37
- const PROMPT_SOURCE = `
38
- Company: {brand.brand.name}: {brand.brand.description}
39
- Date: {date}
40
- Instructions: {prompt}
41
-
42
- Write an original article inspired by and referencing this source material.
43
- Do NOT copy the source — use it as context and inspiration for a unique take.
44
- Source title: {sourceTitle}
45
- `;
46
-
47
- 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';
48
- const FEED_PREFIX = '$feed:';
49
-
50
- // State
51
- let postId;
52
-
53
- /**
54
- * Ghostii Auto Publisher cron job
55
- *
56
- * Automatically generates and publishes blog posts using Ghostii AI.
57
- */
58
- module.exports = async ({ Manager, assistant, context, libraries }) => {
59
- // Log
60
- assistant.log('Starting...');
61
-
62
- // Set post ID
63
- postId = moment().unix();
64
-
65
- // Build brand config from local config
66
- const brandConfig = buildBrandConfig(Manager.config);
67
-
68
- // Log
69
- assistant.log('Brand config', brandConfig);
70
-
71
- // Get settings
72
- const settingsArray = powertools.arrayify(Manager.config.ghostii);
73
-
74
- // Get admin for Firestore tracking
75
- const { admin } = libraries;
76
-
77
- // Loop through each item
78
- for (const settings of settingsArray) {
79
- // Fix settings
80
- settings.articles = settings.articles || 0;
81
- settings.sources = randomize(settings.sources || []);
82
- settings.links = randomize(settings.links || []);
83
- settings.prompt = settings.prompt || '';
84
- settings.chance = settings.chance || 1.0;
85
- settings.author = settings.author || undefined;
86
- settings.postPath = settings.postPath || 'ghostii';
87
- settings.overrides = settings.overrides || {};
88
-
89
- // Resolve brand data for this ghostii item
90
- if (settings.brand && settings.brandUrl) {
91
- // Cross-brand: fetch from the other project's /brand endpoint
92
- settings.brand = await fetchRemoteBrand(settings.brandUrl).catch((e) => e);
93
-
94
- if (settings.brand instanceof Error) {
95
- assistant.error('Error fetching remote brand data', settings.brand);
96
- continue;
97
- }
98
- } else {
99
- // Same-brand: use local config
100
- settings.brand = brandConfig;
101
- }
102
-
103
- // Log
104
- assistant.log(`Settings (brand=${settings.brand.brand.id})`, settings);
105
-
106
- // Quit if articles are disabled
107
- if (!settings.articles || !settings.sources.length) {
108
- assistant.log('Quitting because articles are disabled');
109
- continue;
110
- }
111
-
112
- // Quit if the chance is not met
113
- const chance = Math.random();
114
- if (chance > settings.chance) {
115
- assistant.log(`Quitting because the chance is not met (${chance} <= ${settings.chance})`);
116
- continue;
117
- }
118
-
119
- // Harvest articles
120
- const result = await harvest(assistant, settings, admin).catch((e) => e);
121
- if (result instanceof Error) {
122
- throw result;
123
- }
124
-
125
- // Log
126
- assistant.log('Finished!', result);
127
- }
128
- };
129
-
130
- /**
131
- * Build brand config from Manager.config (same shape as /brand endpoint response)
132
- */
133
- function buildBrandConfig(config) {
134
- const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', '..', 'routes', 'brand', 'get.js'));
135
-
136
- return buildPublicConfig(config);
137
- }
138
-
139
- /**
140
- * Fetch brand data from a remote BEM project's /brand endpoint
141
- */
142
- function fetchRemoteBrand(brandUrl) {
143
- return fetch(`${brandUrl}/backend-manager/brand`, {
144
- timeout: 120000,
145
- tries: 3,
146
- response: 'json',
147
- });
148
- }
149
-
150
- async function harvest(assistant, settings, admin) {
151
- const date = moment().format('MMMM YYYY');
152
-
153
- // Log
154
- assistant.log(`harvest(): Starting ${settings.brand.brand.id}...`);
155
-
156
- // Process the number of sources in the settings
157
- for (let index = 0; index < settings.articles; index++) {
158
- const source = powertools.random(settings.sources);
159
-
160
- // Log
161
- assistant.log(`harvest(): Processing ${index + 1}/${settings.articles}`, source);
162
-
163
- // Resolve the source into a prompt description + optional sourceContent
164
- const resolved = await resolveSource(assistant, source, settings, admin).catch((e) => e);
165
- if (resolved instanceof Error) {
166
- assistant.error('harvest(): Error resolving source', resolved);
167
- break;
168
- }
169
-
170
- // Log
171
- assistant.log('harvest(): Resolved source', resolved);
172
-
173
- // Request to Ghostii
174
- const article = await writeArticle({
175
- brand: settings.brand,
176
- description: resolved.description,
177
- links: settings.links,
178
- sourceContent: resolved.sourceContent || '',
179
- overrides: settings.overrides,
180
- }).catch((e) => e);
181
- if (article instanceof Error) {
182
- assistant.error('harvest(): Error requesting Ghostii', article);
183
- break;
184
- }
185
-
186
- // Log
187
- assistant.log('harvest(): Article', article);
188
-
189
- // Upload post to blog
190
- const uploadedPost = await publishArticle(assistant, {
191
- brand: settings.brand,
192
- article,
193
- id: postId++,
194
- author: settings.author,
195
- postPath: settings.postPath,
196
- }).catch((e) => e);
197
- if (uploadedPost instanceof Error) {
198
- assistant.error('harvest(): Error uploading post to blog', uploadedPost);
199
- break;
200
- }
201
-
202
- // Log
203
- assistant.log('harvest(): Uploaded post', uploadedPost);
204
-
205
- // Track feed item in Firestore (if this was a feed source)
206
- if (resolved.feedItem && admin) {
207
- await trackFeedItem(admin, {
208
- feedUrl: resolved.feedUrl,
209
- item: resolved.feedItem,
210
- brandId: settings.brand.brand.id,
211
- postUrl: uploadedPost.url,
212
- postSlug: uploadedPost.slug,
213
- }).catch((e) => {
214
- assistant.error('harvest(): Error tracking feed item (non-fatal)', e);
215
- });
216
- }
217
- }
218
- }
219
-
220
- /**
221
- * Resolve a source entry into { description, sourceContent, feedItem?, feedUrl? }.
222
- *
223
- * Source types:
224
- * '$app' — generic topic prompt, no sourceContent
225
- * '$feed:<url>' — RSS/Atom feed: pick unprocessed article, extract content
226
- * URL — fetch page content as prompt seed
227
- * text — use directly as prompt seed
228
- *
229
- * Feed failures fall back to $app behavior.
230
- */
231
- async function resolveSource(assistant, source, settings, admin) {
232
- const date = moment().format('MMMM YYYY');
233
-
234
- // --- $feed: source ---
235
- if (typeof source === 'string' && source.startsWith(FEED_PREFIX)) {
236
- const feedUrl = source.slice(FEED_PREFIX.length);
237
- assistant.log(`resolveSource(): Processing feed: ${feedUrl}`);
238
-
239
- const feedResult = await processFeedSource(assistant, feedUrl, settings.brand.brand.id, admin).catch((e) => e);
240
-
241
- if (feedResult instanceof Error || !feedResult) {
242
- assistant.log('resolveSource(): Feed failed or exhausted, falling back to $app');
243
- return resolveSource(assistant, '$app', settings, admin);
244
- }
245
-
246
- const description = powertools.template(PROMPT_SOURCE, {
247
- ...settings,
248
- prompt: settings.prompt,
249
- date: date,
250
- sourceTitle: feedResult.item.title,
251
- });
252
-
253
- return {
254
- description,
255
- sourceContent: feedResult.content || feedResult.item.summary || '',
256
- feedItem: feedResult.item,
257
- feedUrl: feedUrl,
258
- };
259
- }
260
-
261
- // --- $app source ---
262
- if (source === '$app') {
263
- 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)';
264
- const description = powertools.template(PROMPT, {
265
- ...settings,
266
- prompt: settings.prompt,
267
- date: date,
268
- suggestion: suggestion,
269
- });
270
-
271
- return { description, sourceContent: '' };
272
- }
273
-
274
- // --- URL source ---
275
- if (isURL(source)) {
276
- const suggestion = await getURLContent(source).catch((e) => e);
277
-
278
- if (suggestion instanceof Error) {
279
- assistant.error(`resolveSource(): Error fetching URL ${source}`, suggestion);
280
- return resolveSource(assistant, '$app', settings, admin);
281
- }
282
-
283
- const description = powertools.template(PROMPT, {
284
- ...settings,
285
- prompt: settings.prompt,
286
- date: date,
287
- suggestion: suggestion,
288
- });
289
-
290
- return { description, sourceContent: '' };
291
- }
292
-
293
- // --- Text source ---
294
- const description = powertools.template(PROMPT, {
295
- ...settings,
296
- prompt: settings.prompt,
297
- date: date,
298
- suggestion: source,
299
- });
300
-
301
- return { description, sourceContent: '' };
302
- }
303
-
304
- /**
305
- * Process a $feed: source — fetch, parse, select unprocessed item, extract content.
306
- *
307
- * @param {object} assistant - BEM assistant
308
- * @param {string} feedUrl - RSS/Atom/JSON feed URL
309
- * @param {string} brandId - Brand ID for tracking
310
- * @param {object} admin - Firebase Admin SDK
311
- * @returns {Promise<{ item: FeedItem, content: string }|null>} Selected item + extracted content, or null
312
- */
313
- async function processFeedSource(assistant, feedUrl, brandId, admin) {
314
- // Fetch the feed
315
- const feedText = await fetch(feedUrl, {
316
- timeout: 30000,
317
- tries: 2,
318
- response: 'text',
319
- headers: { 'User-Agent': USER_AGENT },
320
- }).catch((e) => e);
321
-
322
- if (feedText instanceof Error) {
323
- assistant.error(`processFeedSource(): Failed to fetch feed: ${feedUrl}`, feedText);
324
- return null;
325
- }
326
-
327
- // Parse the feed
328
- const { items } = parseFeed(feedText);
329
- if (!items.length) {
330
- assistant.log(`processFeedSource(): No items in feed: ${feedUrl}`);
331
- return null;
332
- }
333
-
334
- assistant.log(`processFeedSource(): Parsed ${items.length} items from feed`);
335
-
336
- // Get already-processed item IDs from Firestore
337
- const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
338
- assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
339
- return new Set();
340
- });
341
-
342
- // Filter to unprocessed items
343
- const unprocessed = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
344
- if (!unprocessed.length) {
345
- assistant.log(`processFeedSource(): All ${items.length} items already processed for: ${feedUrl}`);
346
- return null;
347
- }
348
-
349
- assistant.log(`processFeedSource(): ${unprocessed.length} unprocessed items available`);
350
-
351
- // Pick the first (newest) unprocessed item
352
- const item = unprocessed[0];
353
-
354
- // Extract full article content from the item URL
355
- let content = '';
356
- if (item.url) {
357
- content = await extractArticleContent(item.url).catch((e) => {
358
- assistant.error(`processFeedSource(): Failed to extract content from ${item.url} (using summary)`, e);
359
- return '';
360
- });
361
- }
362
-
363
- // Fall back to inline feed content or summary
364
- if (!content || content.length < 100) {
365
- content = item.content || item.summary || '';
366
- }
367
-
368
- return { item, content };
369
- }
370
-
371
- /**
372
- * Query Firestore for already-processed feed item IDs.
373
- *
374
- * @param {object} admin - Firebase Admin SDK
375
- * @param {string} feedUrl - The feed URL to query
376
- * @returns {Promise<Set<string>>} Set of processed item IDs
377
- */
378
- async function getProcessedItemIds(admin, feedUrl) {
379
- if (!admin) {
380
- return new Set();
381
- }
382
-
383
- const snapshot = await admin.firestore()
384
- .collection('ghostii-sources')
385
- .where('feedUrl', '==', feedUrl)
386
- .select('itemId', 'itemUrl')
387
- .get();
388
-
389
- const ids = new Set();
390
- snapshot.docs.forEach((doc) => {
391
- const data = doc.data();
392
- if (data.itemId) {
393
- ids.add(data.itemId);
394
- }
395
- if (data.itemUrl) {
396
- ids.add(data.itemUrl);
397
- }
398
- });
399
-
400
- return ids;
401
- }
402
-
403
- /**
404
- * Write a tracking doc for a processed feed item.
405
- *
406
- * @param {object} admin - Firebase Admin SDK
407
- * @param {object} args
408
- * @param {string} args.feedUrl - The source feed URL
409
- * @param {object} args.item - The processed feed item
410
- * @param {string} args.brandId - Brand that processed it
411
- * @param {string} args.postUrl - Published blog post URL
412
- * @param {string} args.postSlug - Published blog post slug
413
- */
414
- async function trackFeedItem(admin, { feedUrl, item, brandId, postUrl, postSlug }) {
415
- const docId = feedItemHash(feedUrl, item.id || item.url);
416
- const nowISO = new Date().toISOString();
417
- const nowUNIX = Math.round(Date.now() / 1000);
418
-
419
- await admin.firestore().doc(`ghostii-sources/${docId}`).set({
420
- feedUrl: feedUrl,
421
- itemId: item.id || item.url,
422
- itemUrl: item.url,
423
- itemTitle: item.title,
424
- brandId: brandId,
425
- postUrl: postUrl || null,
426
- postSlug: postSlug || null,
427
- metadata: {
428
- created: { timestamp: nowISO, timestampUNIX: nowUNIX },
429
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
430
- },
431
- });
432
- }
433
-
434
- /**
435
- * Deterministic hash for a feed item — used as the Firestore doc ID.
436
- */
437
- function feedItemHash(feedUrl, itemId) {
438
- return crypto.createHash('sha256').update(`${feedUrl}::${itemId}`).digest('hex').slice(0, 20);
439
- }
440
-
441
- function getURLContent(url) {
442
- return fetch(url, {
443
- timeout: 120000,
444
- tries: 3,
445
- response: 'raw',
446
- headers: {
447
- 'User-Agent': USER_AGENT,
448
- },
449
- })
450
- .then(async (res) => {
451
- const contentType = res.headers.get('content-type');
452
- const text = await res.text();
453
-
454
- return extractBodyContent(text, contentType, url);
455
- });
456
- }
457
-
458
- function isURL(url) {
459
- try {
460
- return !!new URL(url);
461
- } catch (e) {
462
- return false;
463
- }
464
- }
465
-
466
- function extractBodyContent(text, contentType, url) {
467
- const parsed = tryParse(text);
468
-
469
- // Try JSON
470
- if (parsed) {
471
- // If it's from rss.app, extract the content
472
- if (parsed.items) {
473
- return parsed.items.map((i) => `${i.title}: ${i.content_text}`).join('\n');
474
- }
475
-
476
- // If we can't recognize the JSON, return the original text
477
- return text;
478
- }
479
-
480
- // Extract the content within the body tag
481
- const bodyMatch = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
482
- if (!bodyMatch) {
483
- return '';
484
- }
485
-
486
- let bodyContent = bodyMatch[1];
487
-
488
- // Remove script and meta tags
489
- bodyContent = bodyContent.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
490
- bodyContent = bodyContent.replace(/<meta[^>]*>/gi, '');
491
-
492
- // Remove remaining HTML tags
493
- return bodyContent.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
494
- }
495
-
496
- function tryParse(json) {
497
- try {
498
- return JSON5.parse(json);
499
- } catch (e) {
500
- return null;
501
- }
502
- }
503
-
504
- function randomize(array) {
505
- return array.sort(() => Math.random() - 0.5);
506
- }
507
-
508
- module.exports.resolveSource = resolveSource;
509
- module.exports.processFeedSource = processFeedSource;
510
- module.exports.feedItemHash = feedItemHash;
511
- module.exports.getProcessedItemIds = getProcessedItemIds;
512
- module.exports.trackFeedItem = trackFeedItem;
513
- module.exports.isURL = isURL;