backend-manager 5.9.16 → 5.9.18

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,397 @@
1
+ /**
2
+ * Shared content-source resolution for blog + newsletter pipelines.
3
+ *
4
+ * Both blog-auto-publisher and newsletter-generator need to:
5
+ * - Resolve $feed:, $parent, $brand, URL, and text sources
6
+ * - Track used sources in Firestore (content-sources collection)
7
+ * - Deduplicate across runs
8
+ * - Follow anti-traceability rules
9
+ *
10
+ * This module is the single source of truth for all of that.
11
+ */
12
+ const crypto = require('crypto');
13
+ const fetch = require('wonderful-fetch');
14
+
15
+ const { parseFeed, extractArticleContent } = require('./feed-parser.js');
16
+
17
+ 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';
18
+ const FEED_PREFIX = '$feed:';
19
+ const CONTENT_SOURCES_COLLECTION = 'content-sources';
20
+
21
+ // ── Prompt constants ─────────────────────────────────────────────────
22
+
23
+ const ANTI_TRACEABILITY = [
24
+ 'ATTRIBUTION RULES:',
25
+ '- NEVER name the source publication, newsletter, blog, or author.',
26
+ '- NEVER use phrases like "according to sources", "a recent article said", "as reported by", or similar.',
27
+ '- Write AS IF the source did not exist — content should read as original, first-party work.',
28
+ '- If the source mentions a third-party platform, product, or company by name, THAT is fine — those are subjects, not sources.',
29
+ '- Paraphrase all facts, figures, and data. Never copy exact numbers verbatim.',
30
+ ].join('\n');
31
+
32
+ const PROMPT_SOURCE = `
33
+ Company: {brand.brand.name}: {brand.brand.description}
34
+ Date: {date}
35
+ Instructions: {instructions}
36
+ Tone: {tone}
37
+ Categories: {categories}
38
+ Keywords: {keywords}
39
+
40
+ Write an article covering the SAME topic as this source material.
41
+ Keep the specific subject matter — names, companies, events, dates, numbers.
42
+ Do NOT copy the source text — rewrite it in your own voice with a different angle, title, and structure.
43
+ The article should read as if written by a completely different author covering the same subject.
44
+ ${ANTI_TRACEABILITY}
45
+ Source title: {sourceTitle}
46
+ `;
47
+
48
+ const PROMPT = `
49
+ Company: {brand.brand.name}: {brand.brand.description}
50
+ Date: {date}
51
+ Instructions: {instructions}
52
+ Tone: {tone}
53
+ Categories: {categories}
54
+ Keywords: {keywords}
55
+
56
+ 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):
57
+ {suggestion}
58
+ `;
59
+
60
+ // ── Feed source processing ───────────────────────────────────────────
61
+
62
+ async function processFeedSource(assistant, feedUrl, brandId, admin) {
63
+ const feedText = await fetch(feedUrl, {
64
+ timeout: 30000,
65
+ tries: 2,
66
+ response: 'text',
67
+ headers: { 'User-Agent': USER_AGENT },
68
+ }).catch((e) => e);
69
+
70
+ if (feedText instanceof Error) {
71
+ assistant.error(`processFeedSource(): Failed to fetch feed: ${feedUrl}`, feedText);
72
+ return null;
73
+ }
74
+
75
+ const { items } = parseFeed(feedText);
76
+ if (!items.length) {
77
+ assistant.log(`processFeedSource(): No items in feed: ${feedUrl}`);
78
+ return null;
79
+ }
80
+
81
+ assistant.log(`processFeedSource(): Parsed ${items.length} items from feed`);
82
+
83
+ const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
84
+ assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
85
+ return new Set();
86
+ });
87
+
88
+ const unprocessed = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
89
+ if (!unprocessed.length) {
90
+ assistant.log(`processFeedSource(): All ${items.length} items already processed for: ${feedUrl}`);
91
+ return null;
92
+ }
93
+
94
+ assistant.log(`processFeedSource(): ${unprocessed.length} unprocessed items available`);
95
+
96
+ const item = unprocessed[0];
97
+
98
+ let content = '';
99
+ if (item.url) {
100
+ content = await extractArticleContent(item.url).catch((e) => {
101
+ assistant.error(`processFeedSource(): Failed to extract content from ${item.url} (using summary)`, e);
102
+ return '';
103
+ });
104
+ }
105
+
106
+ if (!content || content.length < 100) {
107
+ content = item.content || item.summary || '';
108
+ }
109
+
110
+ return { item, content };
111
+ }
112
+
113
+ // ── Parent source processing ─────────────────────────────────────────
114
+
115
+ async function processParentSource(assistant, entry, admin, Manager) {
116
+ const parentUrl = Manager?.getParentApiUrl?.();
117
+
118
+ if (!parentUrl) {
119
+ assistant.log('processParentSource(): No parent URL configured');
120
+ return null;
121
+ }
122
+
123
+ const categories = entry.categories || [];
124
+ const allSources = [];
125
+ const categoriesToFetch = categories.length ? categories : [''];
126
+
127
+ for (const category of categoriesToFetch) {
128
+ const query = {
129
+ limit: 3,
130
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
131
+ };
132
+
133
+ if (category) {
134
+ query.category = category;
135
+ }
136
+
137
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
138
+ method: 'get',
139
+ response: 'json',
140
+ timeout: 60000,
141
+ query: query,
142
+ }).catch((e) => {
143
+ assistant.error(`processParentSource(): Failed to fetch sources for category=${category}: ${e.message}`);
144
+ return null;
145
+ });
146
+
147
+ if (data?.sources?.length) {
148
+ allSources.push(...data.sources);
149
+ }
150
+ }
151
+
152
+ if (!allSources.length) {
153
+ assistant.log('processParentSource(): No sources available from parent');
154
+ return null;
155
+ }
156
+
157
+ if (admin) {
158
+ const usedUrls = new Set();
159
+ const snapshot = await admin.firestore()
160
+ .collection(CONTENT_SOURCES_COLLECTION)
161
+ .where('origin', '==', '$parent')
162
+ .select('url')
163
+ .get()
164
+ .catch((e) => {
165
+ assistant.error('processParentSource(): Error querying tracked sources (continuing)', e);
166
+ return { docs: [] };
167
+ });
168
+
169
+ snapshot.docs.forEach((doc) => {
170
+ const data = doc.data();
171
+ if (data.url) {
172
+ usedUrls.add(data.url);
173
+ }
174
+ });
175
+
176
+ const available = allSources.filter((s) => !usedUrls.has(s.url || s.id));
177
+
178
+ if (!available.length) {
179
+ assistant.log('processParentSource(): All parent sources already used');
180
+ return null;
181
+ }
182
+
183
+ return available;
184
+ }
185
+
186
+ return allSources;
187
+ }
188
+
189
+ // ── Parent source fetching for newsletter ────────────────────────────
190
+
191
+ async function fetchParentSources(parentUrl, categories, assistant) {
192
+ const allSources = [];
193
+
194
+ for (const category of categories) {
195
+ try {
196
+ const data = await fetch(`${parentUrl}/newsletter-sources`, {
197
+ method: 'get',
198
+ response: 'json',
199
+ timeout: 60000,
200
+ query: {
201
+ category,
202
+ limit: 3,
203
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
204
+ },
205
+ });
206
+
207
+ if (data.sources?.length) {
208
+ allSources.push(...data.sources);
209
+ }
210
+ } catch (e) {
211
+ assistant.error(`fetchParentSources(): Failed to fetch ${category} sources: ${e.message}`);
212
+ }
213
+ }
214
+
215
+ return allSources;
216
+ }
217
+
218
+ // ── Feed-to-newsletter normalization ─────────────────────────────────
219
+
220
+ function normalizeFeedItemForNewsletter(feedResult, feedUrl, categories) {
221
+ const item = feedResult.item;
222
+ let hostname = '';
223
+ try { hostname = new URL(feedUrl).hostname; } catch (e) { /* ignore */ }
224
+
225
+ return {
226
+ id: item.id || item.url || '',
227
+ title: item.title || '',
228
+ subject: item.title || '',
229
+ category: (categories && categories[0]) || '',
230
+ categories: categories || [],
231
+ url: item.url || '',
232
+ source: {
233
+ from: hostname,
234
+ subject: item.title || '',
235
+ content: feedResult.content || item.content || item.summary || '',
236
+ },
237
+ ai: null,
238
+ };
239
+ }
240
+
241
+ // ── Resolve newsletter sources (feeds + parent + fallback) ───────────
242
+
243
+ async function resolveNewsletterSources({ sources, categories, admin, Manager, assistant }) {
244
+ const resolved = [];
245
+
246
+ const feedUrls = (sources || []).filter((s) => typeof s === 'string' && s.startsWith(FEED_PREFIX));
247
+ const hasParent = (sources || []).includes('$parent');
248
+
249
+ const brandId = Manager?.config?.brand?.id || '';
250
+
251
+ for (const source of feedUrls) {
252
+ const feedUrl = source.slice(FEED_PREFIX.length);
253
+ const feedResult = await processFeedSource(assistant, feedUrl, brandId, admin);
254
+ if (feedResult) {
255
+ resolved.push(normalizeFeedItemForNewsletter(feedResult, feedUrl, categories));
256
+ }
257
+ }
258
+
259
+ if (hasParent) {
260
+ const parentUrl = Manager?.getParentApiUrl?.();
261
+ if (parentUrl) {
262
+ const parentSources = await fetchParentSources(parentUrl, categories || [], assistant);
263
+ resolved.push(...parentSources);
264
+ }
265
+ }
266
+
267
+ return resolved;
268
+ }
269
+
270
+ // ── Firestore tracking ──────────────────────────────────────────────
271
+
272
+ async function trackContentSource(admin, { url, origin, feedUrl, itemId, itemTitle, usedBy, brandId, postUrl, postSlug }) {
273
+ const docId = contentSourceHash(origin || '', url || '');
274
+ const nowISO = new Date().toISOString();
275
+ const nowUNIX = Math.round(Date.now() / 1000);
276
+
277
+ await admin.firestore().doc(`${CONTENT_SOURCES_COLLECTION}/${docId}`).set({
278
+ url: url || null,
279
+ origin: origin || null,
280
+ feedUrl: feedUrl || null,
281
+ itemId: itemId || null,
282
+ itemTitle: itemTitle || null,
283
+ usedBy: usedBy || null,
284
+ brandId: brandId || null,
285
+ postUrl: postUrl || null,
286
+ postSlug: postSlug || null,
287
+ metadata: {
288
+ created: { timestamp: nowISO, timestampUNIX: nowUNIX },
289
+ updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
290
+ },
291
+ });
292
+ }
293
+
294
+ function contentSourceHash(origin, url) {
295
+ return crypto.createHash('sha256').update(`${origin}::${url}`).digest('hex').slice(0, 20);
296
+ }
297
+
298
+ async function getProcessedItemIds(admin, feedUrl) {
299
+ if (!admin) {
300
+ return new Set();
301
+ }
302
+
303
+ const snapshot = await admin.firestore()
304
+ .collection(CONTENT_SOURCES_COLLECTION)
305
+ .where('feedUrl', '==', feedUrl)
306
+ .select('itemId', 'url')
307
+ .get();
308
+
309
+ const ids = new Set();
310
+ snapshot.docs.forEach((doc) => {
311
+ const data = doc.data();
312
+ if (data.itemId) {
313
+ ids.add(data.itemId);
314
+ }
315
+ if (data.url) {
316
+ ids.add(data.url);
317
+ }
318
+ });
319
+
320
+ return ids;
321
+ }
322
+
323
+ // ── URL content extraction ──────────────────────────────────────────
324
+
325
+ function getURLContent(url) {
326
+ return fetch(url, {
327
+ timeout: 120000,
328
+ tries: 3,
329
+ response: 'raw',
330
+ headers: { 'User-Agent': USER_AGENT },
331
+ })
332
+ .then(async (res) => {
333
+ const contentType = res.headers.get('content-type');
334
+ const text = await res.text();
335
+ return extractBodyContent(text, contentType, url);
336
+ });
337
+ }
338
+
339
+ function isURL(url) {
340
+ try {
341
+ return !!new URL(url);
342
+ } catch (e) {
343
+ return false;
344
+ }
345
+ }
346
+
347
+ function extractBodyContent(text) {
348
+ const parsed = tryParse(text);
349
+
350
+ if (parsed) {
351
+ if (parsed.items) {
352
+ return parsed.items.map((i) => `${i.title}: ${i.content_text}`).join('\n');
353
+ }
354
+ return text;
355
+ }
356
+
357
+ const bodyMatch = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
358
+ if (!bodyMatch) {
359
+ return '';
360
+ }
361
+
362
+ let bodyContent = bodyMatch[1];
363
+ bodyContent = bodyContent.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
364
+ bodyContent = bodyContent.replace(/<meta[^>]*>/gi, '');
365
+
366
+ return bodyContent.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
367
+ }
368
+
369
+ function tryParse(json) {
370
+ try {
371
+ return require('json5').parse(json);
372
+ } catch (e) {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ // ── Exports ─────────────────────────────────────────────────────────
378
+
379
+ module.exports = {
380
+ PROMPT_SOURCE,
381
+ PROMPT,
382
+ ANTI_TRACEABILITY,
383
+ FEED_PREFIX,
384
+ CONTENT_SOURCES_COLLECTION,
385
+ USER_AGENT,
386
+ processFeedSource,
387
+ processParentSource,
388
+ fetchParentSources,
389
+ resolveNewsletterSources,
390
+ normalizeFeedItemForNewsletter,
391
+ trackContentSource,
392
+ contentSourceHash,
393
+ getProcessedItemIds,
394
+ getURLContent,
395
+ isURL,
396
+ extractBodyContent,
397
+ };
@@ -42,7 +42,7 @@ const { renderMarkdown } = require('./lib/markdown-renderer.js');
42
42
  const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
43
43
  const { buildPublicConfig } = require('../../../routes/brand/get.js');
44
44
  const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
45
- const { trackContentSource, contentSourceHash } = require('../../../events/cron/daily/blog-auto-publisher.js');
45
+ const { trackContentSource, contentSourceHash, resolveNewsletterSources } = require('../../../libraries/content/source-resolver.js');
46
46
 
47
47
  /**
48
48
  * Generate newsletter content from parent server sources.
@@ -99,24 +99,22 @@ async function generate(Manager, assistant, settings, opts = {}) {
99
99
  let sources = opts.sources;
100
100
 
101
101
  if (!sources) {
102
- // For now, newsletter resolves $parent sources via the parent server
103
- if (contentSources.includes('$parent')) {
104
- const categories = config.categories || [];
102
+ const categories = config.categories || [];
105
103
 
106
- if (!categories.length) {
107
- assistant.log('Newsletter generator: no categories configured');
108
- return null;
109
- }
110
-
111
- const parentUrl = Manager.getParentApiUrl();
104
+ if (!categories.length) {
105
+ assistant.log('Newsletter generator: no categories configured');
106
+ return null;
107
+ }
112
108
 
113
- if (!parentUrl) {
114
- assistant.log('Newsletter generator: no parent URL configured');
115
- return null;
116
- }
109
+ const admin = Manager.libraries?.admin;
117
110
 
118
- sources = await fetchSources(parentUrl, categories, assistant);
119
- }
111
+ sources = await resolveNewsletterSources({
112
+ sources: contentSources,
113
+ categories,
114
+ admin,
115
+ Manager,
116
+ assistant,
117
+ });
120
118
  }
121
119
 
122
120
  if (!sources?.length) {
@@ -402,9 +400,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
402
400
  const admin = Manager.libraries?.admin;
403
401
  if (admin) {
404
402
  for (const source of sources) {
403
+ const origin = source.source?.from?.startsWith?.('http') ? `$feed:${source.source.from}` : '$parent';
405
404
  await trackContentSource(admin, {
406
405
  url: source.url || source.id,
407
- origin: '$parent',
406
+ origin,
407
+ feedUrl: origin.startsWith('$feed:') ? origin.slice(6) : undefined,
408
408
  itemId: source.id,
409
409
  itemTitle: source.title || '',
410
410
  usedBy: 'newsletter',
@@ -696,35 +696,7 @@ function aggregateTotals(filterMeta, structureMeta, images) {
696
696
  };
697
697
  }
698
698
 
699
- /**
700
- * Fetch ready newsletter sources from the parent server.
701
- */
702
- async function fetchSources(parentUrl, categories, assistant) {
703
- const allSources = [];
704
-
705
- for (const category of categories) {
706
- try {
707
- const data = await fetch(`${parentUrl}/newsletter-sources`, {
708
- method: 'get',
709
- response: 'json',
710
- timeout: 60000,
711
- query: {
712
- category,
713
- limit: 3,
714
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
715
- },
716
- });
717
-
718
- if (data.sources?.length) {
719
- allSources.push(...data.sources);
720
- }
721
- } catch (e) {
722
- assistant.error(`Newsletter generator: Failed to fetch ${category} sources: ${e.message}`);
723
- }
724
- }
725
-
726
- return allSources;
727
- }
699
+ // fetchSources() removed — replaced by resolveNewsletterSources() from source-resolver.js
728
700
 
729
701
 
730
702
  /**
@@ -87,6 +87,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
87
87
  settings.tags = settings.tags;
88
88
  settings.categories = settings.categories;
89
89
  settings.layout = settings.layout;
90
+ settings.source = settings.source || null;
90
91
  settings.date = moment(settings.date || now).subtract(1, 'days').format('YYYY-MM-DD');
91
92
  settings.id = settings.id || Math.round(new Date(now).getTime() / 1000);
92
93
  settings.directory = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
@@ -10,6 +10,7 @@ post:
10
10
  id: { id }
11
11
  tags: { tags }
12
12
  categories: { categories }
13
+ source: {source}
13
14
  ---
14
15
 
15
16
  { body }
@@ -100,6 +100,7 @@ module.exports = async ({ assistant, Manager, settings, analytics }) => {
100
100
  id: parsed.post.id,
101
101
  tags: parsed.post.tags,
102
102
  categories: parsed.post.categories,
103
+ source: parsed.post.source || null,
103
104
 
104
105
  // Derived
105
106
  headerImageURL: `${url.origin}/assets/images/blog/post-${parsed.post.id}/${filename}.jpg`,
@@ -2,5 +2,5 @@
2
2
  "env": {
3
3
  "TEST_EXTENDED_MODE": ""
4
4
  },
5
- "updatedAt": "2026-06-26T04:48:40.374Z"
5
+ "updatedAt": "2026-06-27T00:05:42.330Z"
6
6
  }
@@ -2,11 +2,11 @@ WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
2
2
  WARNING: sun.misc.Unsafe::objectFieldOffset has been called by akka.util.Unsafe (file:/Users/ian/.cache/firebase/emulators/firebase-database-emulator-v4.11.2.jar)
3
3
  WARNING: Please consider reporting this to the maintainers of class akka.util.Unsafe
4
4
  WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
5
- 21:48:49.365 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
6
- 21:48:49.461 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
7
- 21:48:59.683 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 76ms, init: 0ms
8
- 21:48:59.719 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
9
- 21:49:11.203 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
10
- 21:49:11.207 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
11
- 21:49:11.211 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
12
- 21:49:11.214 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.
5
+ 17:05:48.613 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
6
+ 17:05:48.710 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
7
+ 17:05:58.279 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 70ms, init: 0ms
8
+ 17:05:58.314 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
9
+ 17:06:10.472 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
10
+ 17:06:10.477 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
11
+ 17:06:10.480 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
12
+ 17:06:10.483 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.