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.
- package/CHANGELOG.md +35 -0
- package/CLAUDE.md +1 -1
- package/PROGRESS.md +4 -4
- package/docs/admin-post-route.md +1 -1
- package/docs/ghostii.md +89 -61
- package/docs/marketing-campaigns.md +2 -2
- package/package.json +1 -1
- package/src/manager/events/cron/daily/blog-auto-publisher.js +680 -0
- package/src/manager/libraries/content/feed-parser.js +1 -1
- package/src/manager/libraries/content/ghostii.js +3 -3
- package/src/manager/libraries/email/generators/lib/structure.js +5 -1
- package/src/manager/libraries/email/generators/newsletter.js +47 -46
- package/src/manager/routes/brand/get.js +1 -1
- package/templates/backend-manager-config.json +66 -70
- package/test/helpers/content/blog-auto-publisher.js +484 -0
- package/test/helpers/content/ghostii-feed-integration.js +33 -28
- package/test/helpers/content/ghostii-write-article.js +1 -1
- package/test/routes/test/usage.js +1 -1
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +0 -511
- package/test/helpers/content/ghostii-auto-publisher.js +0 -342
|
@@ -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
|
|
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/
|
|
18
|
-
* - libraries/email/generators/newsletter.js
|
|
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,
|