backend-manager 5.9.17 → 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.
package/docs/ghostii.md CHANGED
@@ -27,9 +27,10 @@ trackContentSource() -- Firestore (for $feed:, $parent sources)
27
27
 
28
28
  | File | Purpose |
29
29
  |---|---|
30
+ | `src/manager/libraries/content/source-resolver.js` | **Shared SSOT**: prompt templates, anti-traceability rules, feed/parent resolution, Firestore tracking, fallback chain. Used by both blog + newsletter. |
30
31
  | `src/manager/libraries/content/ghostii.js` | `writeArticle()`, `publishArticle()`, `blocksToPost()` -- Ghostii API client + post transform |
31
32
  | `src/manager/libraries/content/feed-parser.js` | `parseFeed()`, `extractArticleContent()` -- RSS/Atom/JSON parser + article extractor |
32
- | `src/manager/events/cron/daily/blog-auto-publisher.js` | Daily cron: source resolution, feed processing, Firestore tracking, provider dispatch |
33
+ | `src/manager/events/cron/daily/blog-auto-publisher.js` | Daily cron: imports from source-resolver, manages harvest loop + provider dispatch |
33
34
  | `src/manager/routes/admin/post/post.js` | Publishing endpoint: image download + resize, GitHub commit |
34
35
 
35
36
  ## Source Types
@@ -145,11 +146,68 @@ When a `$feed:` or `$parent` source provides extracted article text, it's passed
145
146
 
146
147
  - **`extractArticleContent(url)`** -- Fetch URL, extract readable text from `<article>` -> `<main>` -> `<body>`, strip scripts/styles/nav/footer/header/aside, normalize whitespace, truncate to 14KB.
147
148
 
149
+ ## Prompt Templates (source-resolver.js)
150
+
151
+ Both blog and newsletter use shared prompt constants from `source-resolver.js`:
152
+
153
+ - **`PROMPT_SOURCE`** — used when a `$feed:` or `$parent` source is resolved. Directs the AI to cover the SAME topic with a different angle, title, and structure. Includes anti-traceability rules.
154
+ - **`PROMPT`** — used for `$brand`, URL, and text sources. Open-ended topic generation.
155
+ - **`ANTI_TRACEABILITY`** — shared rules: never name the source publication, paraphrase all data. Embedded in `PROMPT_SOURCE`, also used by the newsletter's system prompt.
156
+
157
+ The brand's `instructions` field is injected into both templates and can override the default behavior (e.g. a brand that WANTS genericized output can say so in instructions).
158
+
148
159
  ## Tests
149
160
 
161
+ ### Unit tests (fast, free)
162
+
163
+ ```bash
164
+ npx mgr test mgr:helpers/content/feed-parser # 32 tests: RSS/Atom/JSON parsing, edge cases
165
+ npx mgr test mgr:helpers/content/ghostii-write-article # 15 tests: override pass-through, sourceContent
166
+ npx mgr test mgr:helpers/content/blog-auto-publisher # 25+ tests: source detection, feed processing, tracking
167
+ npx mgr test mgr:helpers/content/ghostii-blocks # 8 tests: blocksToPost()
168
+ ```
169
+
170
+ ### Blog generation (full AI pipeline)
171
+
172
+ ```bash
173
+ # Config check only (fast, free)
174
+ npx mgr test mgr:content/blog-generate
175
+
176
+ # Full AI pipeline — generates article, does NOT publish
177
+ BLOG_NO_PUBLISH=1 TEST_EXTENDED_MODE=1 npx mgr test mgr:content/blog-generate
178
+
179
+ # Override source type
180
+ BLOG_SOURCE='$feed:https://feeds.arstechnica.com/arstechnica/index' \
181
+ BLOG_NO_PUBLISH=1 TEST_EXTENDED_MODE=1 npx mgr test mgr:content/blog-generate
182
+ ```
183
+
184
+ | Env var | Description |
185
+ |---|---|
186
+ | `TEST_EXTENDED_MODE=1` | Enable full AI pipeline (costs ~$0.10-0.50 per run) |
187
+ | `BLOG_NO_PUBLISH=1` | Generate but do NOT publish to website repo |
188
+ | `BLOG_SOURCE=<type>` | Override source: `$brand`, `$parent`, `$feed:<url>`, URL, or text |
189
+ | `BLOG_OPEN=1` | Auto-open generated article (macOS only) |
190
+
191
+ ### Newsletter generation (full AI pipeline)
192
+
150
193
  ```bash
151
- npx mgr test bem:helpers/content/feed-parser # 32 tests: RSS/Atom/JSON parsing, edge cases, HTML extraction
152
- npx mgr test bem:helpers/content/ghostii-write-article # 15 tests: override pass-through, sourceContent, backwards compat
153
- npx mgr test bem:helpers/content/blog-auto-publisher # 25+ tests: source detection, feed processing, Firestore tracking
154
- npx mgr test bem:helpers/content/ghostii-blocks # 8 tests: blocksToPost() (unchanged)
194
+ # Fixture render only (fast, free)
195
+ npx mgr test mgr:email/newsletter-generate
196
+
197
+ # Full AI pipeline with feed source — generates newsletter, does NOT publish article
198
+ NEWSLETTER_SOURCE='$feed:https://feeds.arstechnica.com/arstechnica/index' \
199
+ NEWSLETTER_NO_IMAGES=1 TEST_EXTENDED_MODE=1 npx mgr test mgr:email/newsletter-generate
155
200
  ```
201
+
202
+ | Env var | Description |
203
+ |---|---|
204
+ | `TEST_EXTENDED_MODE=1` | Enable full AI pipeline |
205
+ | `NEWSLETTER_SOURCE=<type>` | Override source: `$feed:<url>`, `$parent`. Bypasses parent-server fetch, lets the generator's resolver run. |
206
+ | `NEWSLETTER_NO_IMAGES=1` | Skip image generation (fast iteration on copy) |
207
+ | `NEWSLETTER_CREATE_ARTICLE=1` | Publish linked blog article (OFF by default) |
208
+ | `NEWSLETTER_FIXTURE=<name>` | Load specific fixture for render test |
209
+ | `NEWSLETTER_TEMPLATE=<name>` | Override layout template |
210
+ | `NEWSLETTER_OPEN=1` | Auto-open preview (macOS only) |
211
+ | `NEWSLETTER_PEEK=1` | List ready parent sources, don't generate |
212
+ | `NEWSLETTER_SOURCE_ID=<id>` | Test specific parent source |
213
+ | `NEWSLETTER_CAMPAIGN_ID=<id>` | Override campaign folder name |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.9.17",
3
+ "version": "5.9.18",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "files": [
@@ -16,87 +16,49 @@
16
16
  * All feed/parent sources are tracked in Firestore (`content-sources`) so the
17
17
  * same article is never processed twice. When a feed is unreachable or
18
18
  * exhausted, the entry falls back to $brand behavior.
19
+ *
20
+ * Source resolution, tracking, and prompt constants live in the shared
21
+ * source-resolver library (src/manager/libraries/content/source-resolver.js).
19
22
  */
20
- const crypto = require('crypto');
21
- const fetch = require('wonderful-fetch');
22
23
  const powertools = require('node-powertools');
23
24
  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
25
+
26
+ const {
27
+ PROMPT_SOURCE,
28
+ PROMPT,
29
+ FEED_PREFIX,
30
+ processFeedSource,
31
+ processParentSource,
32
+ trackContentSource,
33
+ contentSourceHash,
34
+ getProcessedItemIds,
35
+ getURLContent,
36
+ isURL,
37
+ } = require('../../../libraries/content/source-resolver.js');
38
+
58
39
  let postId;
59
40
 
60
- /**
61
- * Blog Auto Publisher cron job
62
- *
63
- * Automatically generates and publishes blog posts using the configured platform provider.
64
- */
65
41
  module.exports = async ({ Manager, assistant, context, libraries }) => {
66
- // Gate on blog.enabled
67
42
  if (!Manager.config.blog?.enabled) {
68
43
  assistant.log('Blog auto-publisher disabled in config');
69
44
  return;
70
45
  }
71
46
 
72
- // Log
73
47
  assistant.log('Starting...');
74
48
 
75
- // Set post ID
76
49
  postId = moment().unix();
77
50
 
78
- // Build brand config from local config
79
51
  const brandConfig = buildBrandConfig(Manager.config);
80
-
81
- // Log
82
52
  assistant.log('Brand config', brandConfig);
83
53
 
84
- // Get blog config
85
54
  const blog = Manager.config.blog;
86
-
87
- // Get content entries (array or single object)
88
55
  const contentArray = powertools.arrayify(blog.content);
89
-
90
- // Get admin for Firestore tracking
91
56
  const { admin } = libraries;
92
57
 
93
- // Load the provider based on blog.platform
94
58
  const platform = blog.platform || 'ghostii';
95
59
  const provider = require(`../../../libraries/content/${platform}.js`);
96
60
 
97
- // Loop through each content entry
98
61
  for (const entry of contentArray) {
99
- // Normalize entry fields
100
62
  entry.quantity = entry.quantity || 0;
101
63
  entry.sources = randomize(entry.sources || []);
102
64
  entry.links = randomize(entry.links || []);
@@ -109,60 +71,45 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
109
71
  entry.postPath = entry.postPath || platform;
110
72
  entry.overrides = entry.overrides || {};
111
73
 
112
- // Resolve brand data for this entry
113
74
  if (entry.brand && entry.brandUrl) {
114
- // Cross-brand: fetch from the other project's /brand endpoint
115
75
  entry.brand = await fetchRemoteBrand(entry.brandUrl).catch((e) => e);
116
-
117
76
  if (entry.brand instanceof Error) {
118
77
  assistant.error('Error fetching remote brand data', entry.brand);
119
78
  continue;
120
79
  }
121
80
  } else {
122
- // Same-brand: use local config
123
81
  entry.brand = brandConfig;
124
82
  }
125
83
 
126
- // Log
127
84
  assistant.log(`Entry (brand=${entry.brand.brand.id})`, entry);
128
85
 
129
- // Quit if quantity is zero or no sources
130
86
  if (!entry.quantity || !entry.sources.length) {
131
87
  assistant.log('Quitting because quantity is 0 or no sources');
132
88
  continue;
133
89
  }
134
90
 
135
- // Quit if the chance is not met
136
91
  const chance = Math.random();
137
92
  if (chance > entry.chance) {
138
93
  assistant.log(`Quitting because the chance is not met (${chance} <= ${entry.chance})`);
139
94
  continue;
140
95
  }
141
96
 
142
- // Harvest articles
143
97
  const result = await harvest(assistant, entry, admin, provider, Manager).catch((e) => e);
144
98
  if (result instanceof Error) {
145
99
  throw result;
146
100
  }
147
101
 
148
- // Log
149
102
  assistant.log('Finished!', result);
150
103
  }
151
104
  };
152
105
 
153
- /**
154
- * Build brand config from Manager.config (same shape as /brand endpoint response)
155
- */
156
106
  function buildBrandConfig(config) {
157
107
  const { buildPublicConfig } = require(require('path').join(__dirname, '..', '..', '..', 'routes', 'brand', 'get.js'));
158
-
159
108
  return buildPublicConfig(config);
160
109
  }
161
110
 
162
- /**
163
- * Fetch brand data from a remote BEM project's /brand endpoint
164
- */
165
111
  function fetchRemoteBrand(brandUrl) {
112
+ const fetch = require('wonderful-fetch');
166
113
  return fetch(`${brandUrl}/backend-manager/brand`, {
167
114
  timeout: 120000,
168
115
  tries: 3,
@@ -173,33 +120,26 @@ function fetchRemoteBrand(brandUrl) {
173
120
  async function harvest(assistant, entry, admin, provider, Manager) {
174
121
  const date = moment().format('MMMM YYYY');
175
122
 
176
- // Log
177
123
  assistant.log(`harvest(): Starting ${entry.brand.brand.id}...`);
178
124
 
179
- // Process the number of sources in the entry
180
125
  for (let index = 0; index < entry.quantity; index++) {
181
126
  const source = powertools.random(entry.sources);
182
127
 
183
- // Log
184
128
  assistant.log(`harvest(): Processing ${index + 1}/${entry.quantity}`, source);
185
129
 
186
- // Resolve the source into a prompt description + optional sourceContent
187
130
  const resolved = await resolveSource(assistant, source, entry, admin, Manager).catch((e) => e);
188
131
  if (resolved instanceof Error) {
189
132
  assistant.error('harvest(): Error resolving source', resolved);
190
133
  break;
191
134
  }
192
135
 
193
- // Log
194
136
  assistant.log('harvest(): Resolved source', resolved);
195
137
 
196
- // Use keywords from entry if overrides.keywords is not set
197
138
  const overrides = { ...entry.overrides };
198
139
  if (!overrides.keywords && entry.keywords.length) {
199
140
  overrides.keywords = entry.keywords;
200
141
  }
201
142
 
202
- // Request article from provider
203
143
  const article = await provider.writeArticle({
204
144
  brand: entry.brand,
205
145
  description: resolved.description,
@@ -212,10 +152,8 @@ async function harvest(assistant, entry, admin, provider, Manager) {
212
152
  break;
213
153
  }
214
154
 
215
- // Log
216
155
  assistant.log('harvest(): Article', article);
217
156
 
218
- // Upload post to blog
219
157
  const uploadedPost = await provider.publishArticle(assistant, {
220
158
  brand: entry.brand,
221
159
  article,
@@ -229,10 +167,8 @@ async function harvest(assistant, entry, admin, provider, Manager) {
229
167
  break;
230
168
  }
231
169
 
232
- // Log
233
170
  assistant.log('harvest(): Uploaded post', uploadedPost);
234
171
 
235
- // Track content source in Firestore
236
172
  if (resolved.trackingData && admin) {
237
173
  await trackContentSource(admin, {
238
174
  ...resolved.trackingData,
@@ -246,22 +182,9 @@ async function harvest(assistant, entry, admin, provider, Manager) {
246
182
  }
247
183
  }
248
184
 
249
- /**
250
- * Resolve a source entry into { description, sourceContent, trackingData? }.
251
- *
252
- * Source types:
253
- * '$brand' — generic topic prompt, no sourceContent
254
- * '$feed:<url>' — RSS/Atom feed: pick unprocessed article, extract content
255
- * '$parent' — fetch sources from parent server without claiming
256
- * URL — fetch page content as prompt seed
257
- * text — use directly as prompt seed
258
- *
259
- * Feed/parent failures fall back to $brand behavior.
260
- */
261
185
  async function resolveSource(assistant, source, entry, admin, Manager) {
262
186
  const date = moment().format('MMMM YYYY');
263
187
 
264
- // Build template vars for prompts
265
188
  const templateVars = {
266
189
  ...entry,
267
190
  instructions: entry.instructions,
@@ -306,13 +229,14 @@ async function resolveSource(assistant, source, entry, admin, Manager) {
306
229
  if (source === '$parent') {
307
230
  assistant.log('resolveSource(): Processing $parent source');
308
231
 
309
- const parentResult = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
232
+ const parentResults = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
310
233
 
311
- if (parentResult instanceof Error || !parentResult) {
234
+ if (parentResults instanceof Error || !parentResults?.length) {
312
235
  assistant.log('resolveSource(): Parent source failed or exhausted, falling back to $brand');
313
236
  return resolveSource(assistant, '$brand', entry, admin, Manager);
314
237
  }
315
238
 
239
+ const parentResult = parentResults[0];
316
240
  const description = powertools.template(PROMPT_SOURCE, {
317
241
  ...templateVars,
318
242
  sourceTitle: parentResult.title,
@@ -368,311 +292,11 @@ async function resolveSource(assistant, source, entry, admin, Manager) {
368
292
  return { description, sourceContent: '' };
369
293
  }
370
294
 
371
- /**
372
- * Process a $parent source — fetch sources from the parent server without claiming.
373
- *
374
- * Fetches available sources via GET /newsletter-sources (no claimFor parameter),
375
- * filters out any already tracked locally in `content-sources`, and returns
376
- * the first available source.
377
- *
378
- * @param {object} assistant - BEM assistant
379
- * @param {object} entry - Content entry config
380
- * @param {object} admin - Firebase Admin SDK
381
- * @param {object} Manager - BEM Manager instance
382
- * @returns {Promise<{ id, title, url, content, summary }|null>} Selected source or null
383
- */
384
- async function processParentSource(assistant, entry, admin, Manager) {
385
- const parentUrl = Manager?.getParentApiUrl?.();
386
-
387
- if (!parentUrl) {
388
- assistant.log('processParentSource(): No parent URL configured');
389
- return null;
390
- }
391
-
392
- const categories = entry.categories || [];
393
- const allSources = [];
394
-
395
- // Fetch sources from each category (or all if no categories)
396
- const categoriesToFetch = categories.length ? categories : [''];
397
-
398
- for (const category of categoriesToFetch) {
399
- const query = {
400
- limit: 3,
401
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
402
- };
403
-
404
- if (category) {
405
- query.category = category;
406
- }
407
-
408
- const data = await fetch(`${parentUrl}/newsletter-sources`, {
409
- method: 'get',
410
- response: 'json',
411
- timeout: 60000,
412
- query: query,
413
- }).catch((e) => {
414
- assistant.error(`processParentSource(): Failed to fetch sources for category=${category}: ${e.message}`);
415
- return null;
416
- });
417
-
418
- if (data?.sources?.length) {
419
- allSources.push(...data.sources);
420
- }
421
- }
422
-
423
- if (!allSources.length) {
424
- assistant.log('processParentSource(): No sources available from parent');
425
- return null;
426
- }
427
-
428
- // Filter out already-used sources by checking content-sources collection locally
429
- if (admin) {
430
- const usedUrls = new Set();
431
- const snapshot = await admin.firestore()
432
- .collection(CONTENT_SOURCES_COLLECTION)
433
- .where('origin', '==', '$parent')
434
- .select('url')
435
- .get()
436
- .catch((e) => {
437
- assistant.error('processParentSource(): Error querying tracked sources (continuing)', e);
438
- return { docs: [] };
439
- });
440
-
441
- snapshot.docs.forEach((doc) => {
442
- const data = doc.data();
443
- if (data.url) {
444
- usedUrls.add(data.url);
445
- }
446
- });
447
-
448
- const available = allSources.filter((s) => !usedUrls.has(s.url || s.id));
449
-
450
- if (!available.length) {
451
- assistant.log('processParentSource(): All parent sources already used');
452
- return null;
453
- }
454
-
455
- return available[0];
456
- }
457
-
458
- // No admin — just return the first source
459
- return allSources[0];
460
- }
461
-
462
- /**
463
- * Process a $feed: source — fetch, parse, select unprocessed item, extract content.
464
- *
465
- * @param {object} assistant - BEM assistant
466
- * @param {string} feedUrl - RSS/Atom/JSON feed URL
467
- * @param {string} brandId - Brand ID for tracking
468
- * @param {object} admin - Firebase Admin SDK
469
- * @returns {Promise<{ item: FeedItem, content: string }|null>} Selected item + extracted content, or null
470
- */
471
- async function processFeedSource(assistant, feedUrl, brandId, admin) {
472
- // Fetch the feed
473
- const feedText = await fetch(feedUrl, {
474
- timeout: 30000,
475
- tries: 2,
476
- response: 'text',
477
- headers: { 'User-Agent': USER_AGENT },
478
- }).catch((e) => e);
479
-
480
- if (feedText instanceof Error) {
481
- assistant.error(`processFeedSource(): Failed to fetch feed: ${feedUrl}`, feedText);
482
- return null;
483
- }
484
-
485
- // Parse the feed
486
- const { items } = parseFeed(feedText);
487
- if (!items.length) {
488
- assistant.log(`processFeedSource(): No items in feed: ${feedUrl}`);
489
- return null;
490
- }
491
-
492
- assistant.log(`processFeedSource(): Parsed ${items.length} items from feed`);
493
-
494
- // Get already-processed item IDs from Firestore
495
- const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
496
- assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
497
- return new Set();
498
- });
499
-
500
- // Filter to unprocessed items
501
- const unprocessed = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
502
- if (!unprocessed.length) {
503
- assistant.log(`processFeedSource(): All ${items.length} items already processed for: ${feedUrl}`);
504
- return null;
505
- }
506
-
507
- assistant.log(`processFeedSource(): ${unprocessed.length} unprocessed items available`);
508
-
509
- // Pick the first (newest) unprocessed item
510
- const item = unprocessed[0];
511
-
512
- // Extract full article content from the item URL
513
- let content = '';
514
- if (item.url) {
515
- content = await extractArticleContent(item.url).catch((e) => {
516
- assistant.error(`processFeedSource(): Failed to extract content from ${item.url} (using summary)`, e);
517
- return '';
518
- });
519
- }
520
-
521
- // Fall back to inline feed content or summary
522
- if (!content || content.length < 100) {
523
- content = item.content || item.summary || '';
524
- }
525
-
526
- return { item, content };
527
- }
528
-
529
- /**
530
- * Query Firestore for already-processed feed item IDs.
531
- *
532
- * @param {object} admin - Firebase Admin SDK
533
- * @param {string} feedUrl - The feed URL to query
534
- * @returns {Promise<Set<string>>} Set of processed item IDs
535
- */
536
- async function getProcessedItemIds(admin, feedUrl) {
537
- if (!admin) {
538
- return new Set();
539
- }
540
-
541
- const snapshot = await admin.firestore()
542
- .collection(CONTENT_SOURCES_COLLECTION)
543
- .where('feedUrl', '==', feedUrl)
544
- .select('itemId', 'url')
545
- .get();
546
-
547
- const ids = new Set();
548
- snapshot.docs.forEach((doc) => {
549
- const data = doc.data();
550
- if (data.itemId) {
551
- ids.add(data.itemId);
552
- }
553
- if (data.url) {
554
- ids.add(data.url);
555
- }
556
- });
557
-
558
- return ids;
559
- }
560
-
561
- /**
562
- * Write a tracking doc for a processed content source.
563
- *
564
- * Unified tracker for all source types (feed, parent, brand).
565
- * Used by both blog-auto-publisher and newsletter generator.
566
- *
567
- * @param {object} admin - Firebase Admin SDK
568
- * @param {object} args
569
- * @param {string} args.url - Unique identifier (article URL or source ID)
570
- * @param {string} args.origin - Source type ('$feed:https://...', '$parent', '$brand')
571
- * @param {string} [args.feedUrl] - The source feed URL (for feed sources)
572
- * @param {string} [args.itemId] - Item ID within the feed
573
- * @param {string} [args.itemTitle] - Item title
574
- * @param {string} args.usedBy - Which system used it ('blog' or 'newsletter')
575
- * @param {string} args.brandId - Brand that processed it
576
- * @param {string} [args.postUrl] - Published blog post URL
577
- * @param {string} [args.postSlug] - Published blog post slug
578
- */
579
- async function trackContentSource(admin, { url, origin, feedUrl, itemId, itemTitle, usedBy, brandId, postUrl, postSlug }) {
580
- const docId = contentSourceHash(origin || '', url || '');
581
- const nowISO = new Date().toISOString();
582
- const nowUNIX = Math.round(Date.now() / 1000);
583
-
584
- await admin.firestore().doc(`${CONTENT_SOURCES_COLLECTION}/${docId}`).set({
585
- url: url || null,
586
- origin: origin || null,
587
- feedUrl: feedUrl || null,
588
- itemId: itemId || null,
589
- itemTitle: itemTitle || null,
590
- usedBy: usedBy || null,
591
- brandId: brandId || null,
592
- postUrl: postUrl || null,
593
- postSlug: postSlug || null,
594
- metadata: {
595
- created: { timestamp: nowISO, timestampUNIX: nowUNIX },
596
- updated: { timestamp: nowISO, timestampUNIX: nowUNIX },
597
- },
598
- });
599
- }
600
-
601
- /**
602
- * Deterministic hash for a content source — used as the Firestore doc ID.
603
- */
604
- function contentSourceHash(origin, url) {
605
- return crypto.createHash('sha256').update(`${origin}::${url}`).digest('hex').slice(0, 20);
606
- }
607
-
608
- function getURLContent(url) {
609
- return fetch(url, {
610
- timeout: 120000,
611
- tries: 3,
612
- response: 'raw',
613
- headers: {
614
- 'User-Agent': USER_AGENT,
615
- },
616
- })
617
- .then(async (res) => {
618
- const contentType = res.headers.get('content-type');
619
- const text = await res.text();
620
-
621
- return extractBodyContent(text, contentType, url);
622
- });
623
- }
624
-
625
- function isURL(url) {
626
- try {
627
- return !!new URL(url);
628
- } catch (e) {
629
- return false;
630
- }
631
- }
632
-
633
- function extractBodyContent(text, contentType, url) {
634
- const parsed = tryParse(text);
635
-
636
- // Try JSON
637
- if (parsed) {
638
- // If it's from rss.app, extract the content
639
- if (parsed.items) {
640
- return parsed.items.map((i) => `${i.title}: ${i.content_text}`).join('\n');
641
- }
642
-
643
- // If we can't recognize the JSON, return the original text
644
- return text;
645
- }
646
-
647
- // Extract the content within the body tag
648
- const bodyMatch = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
649
- if (!bodyMatch) {
650
- return '';
651
- }
652
-
653
- let bodyContent = bodyMatch[1];
654
-
655
- // Remove script and meta tags
656
- bodyContent = bodyContent.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
657
- bodyContent = bodyContent.replace(/<meta[^>]*>/gi, '');
658
-
659
- // Remove remaining HTML tags
660
- return bodyContent.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
661
- }
662
-
663
- function tryParse(json) {
664
- try {
665
- return JSON5.parse(json);
666
- } catch (e) {
667
- return null;
668
- }
669
- }
670
-
671
295
  function randomize(array) {
672
296
  return array.sort(() => Math.random() - 0.5);
673
297
  }
674
298
 
675
- // Exported for testing and for newsletter to import
299
+ // Re-export shared functions for backwards compatibility (newsletter + tests import from here)
676
300
  module.exports.resolveSource = resolveSource;
677
301
  module.exports.processFeedSource = processFeedSource;
678
302
  module.exports.contentSourceHash = contentSourceHash;
@@ -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
  /**
@@ -41,6 +41,11 @@
41
41
  * NEWSLETTER_OPEN=1 Auto-open the rendered preview in the default browser (macOS only).
42
42
  *
43
43
  * --- AI-mode-only env vars (require TEST_EXTENDED_MODE=1) ---
44
+ * NEWSLETTER_SOURCE=<type> Override the source type for this run. Bypasses parent-server
45
+ * fetch and lets the generator's built-in resolver run.
46
+ * Values: '$feed:<url>', '$parent'. When set, sources[] in the
47
+ * config is replaced with this single source.
48
+ * Example: NEWSLETTER_SOURCE='$feed:https://feeds.arstechnica.com/arstechnica/index'
44
49
  * NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
45
50
  * NEWSLETTER_SOURCE_ID=<id> Generate from one specific source WITHOUT claiming it.
46
51
  * NEWSLETTER_LIMIT=10 Sources per category for PEEK (default 10).
@@ -325,60 +330,57 @@ module.exports = {
325
330
  // `api.` subdomain (e.g. 'https://itwcreativeworks.com'); the helper
326
331
  // inserts `api.` at call time. PARENT_API_URL env override is honored
327
332
  // verbatim for one-off testing against a different parent.
328
- const parentUrl = env.PARENT_API_URL || Manager.getParentApiUrl();
329
- assert.ok(parentUrl, 'PARENT_API_URL (env) or parent (config) must be set for the AI pipeline. Set TEST_EXTENDED_MODE=1 to run it, or omit TEST_EXTENDED_MODE for the fast fixture preview.');
333
+ // When NEWSLETTER_SOURCE is set, skip parent URL checks and source fetching —
334
+ // the generator's built-in resolver (resolveNewsletterSources) handles everything.
335
+ let sources = [];
336
+
337
+ if (!env.NEWSLETTER_SOURCE) {
338
+ const parentUrl = env.PARENT_API_URL || Manager.getParentApiUrl();
339
+ assert.ok(parentUrl, 'PARENT_API_URL (env) or parent (config) must be set for the AI pipeline. Set TEST_EXTENDED_MODE=1 to run it, or omit TEST_EXTENDED_MODE for the fast fixture preview.');
340
+
341
+ // --- Peek mode (early return) ---
342
+ if (env.NEWSLETTER_PEEK) {
343
+ const peeked = await peekSources({
344
+ parentUrl,
345
+ categories: newsletterConfig.categories || [],
346
+ limit: parseInt(env.NEWSLETTER_LIMIT, 10) || 10,
347
+ key: env.BACKEND_MANAGER_KEY,
348
+ });
349
+
350
+ console.log(`\nPeek mode — ${peeked.length} ready source(s):\n`);
351
+ peeked.forEach((s, i) => {
352
+ const raw = s.source || {};
353
+ const cats = (s.categories || []).join(', ') || s.category || '(none)';
354
+ console.log(`[${i + 1}] ${s.id}`);
355
+ console.log(` Categories: ${cats}`);
356
+ console.log(` From: ${raw.from || s.from || '(unknown)'}`);
357
+ console.log(` Subject: ${raw.subject || s.subject || '(none)'}`);
358
+ console.log(` Headline: ${s.ai?.headline || '(none — not yet AI-processed)'}`);
359
+ console.log('');
360
+ });
361
+
362
+ assert.ok(true, `Peeked ${peeked.length} sources`);
363
+ return;
364
+ }
330
365
 
331
- // --- Peek mode (early return) ---
332
- if (env.NEWSLETTER_PEEK) {
333
- const sources = await peekSources({
366
+ // --- Fetch sources ---
367
+ const claim = !!env.NEWSLETTER_CLAIM;
368
+ sources = await fetchSourcesForRun({
334
369
  parentUrl,
335
- categories: newsletterConfig.categories || [],
336
- limit: parseInt(env.NEWSLETTER_LIMIT, 10) || 10,
370
+ newsletterConfig,
371
+ brandId: config.brand?.id,
372
+ sourceId: env.NEWSLETTER_SOURCE_ID,
337
373
  key: env.BACKEND_MANAGER_KEY,
374
+ claim,
338
375
  });
339
376
 
340
- console.log(`\nPeek mode — ${sources.length} ready source(s):\n`);
341
- sources.forEach((s, i) => {
342
- const raw = s.source || {};
343
- const cats = (s.categories || []).join(', ') || s.category || '(none)';
344
- console.log(`[${i + 1}] ${s.id}`);
345
- console.log(` Categories: ${cats}`);
346
- console.log(` From: ${raw.from || s.from || '(unknown)'}`);
347
- console.log(` Subject: ${raw.subject || s.subject || '(none)'}`);
348
- console.log(` Headline: ${s.ai?.headline || '(none — not yet AI-processed)'}`);
349
- console.log('');
350
- });
351
-
352
- assert.ok(true, `Peeked ${sources.length} sources`);
353
- return;
354
- }
355
-
356
- // --- Fetch sources ---
357
- // By DEFAULT the test does NOT claim sources — it fetches them without
358
- // claimFor, so they stay 'ready' and available for the real newsletter.
359
- // This keeps test runs repeatable and non-destructive (a test should never
360
- // silently consume production resources). Set NEWSLETTER_CLAIM=1 to exercise
361
- // the real claim/consume path (then use NEWSLETTER_RELEASE=1 to put them back).
362
- const claim = !!env.NEWSLETTER_CLAIM;
363
- const sources = await fetchSourcesForRun({
364
- parentUrl,
365
- newsletterConfig,
366
- brandId: config.brand?.id,
367
- sourceId: env.NEWSLETTER_SOURCE_ID,
368
- key: env.BACKEND_MANAGER_KEY,
369
- claim,
370
- });
371
-
372
- // Environmental precondition: the parent server must have ready sources in
373
- // at least one configured category. Skip cleanly when the pool is empty
374
- // (transient state — no point hard-failing CI on an external queue).
375
- if (sources.length === 0) {
376
- return skip('No ready newsletter sources available on parent server (environmental)');
377
- }
377
+ if (sources.length === 0) {
378
+ return skip('No ready newsletter sources available on parent server (environmental)');
379
+ }
378
380
 
379
- // Track claimed IDs for later --release-all (only when we actually claimed)
380
- if (claim && !env.NEWSLETTER_SOURCE_ID) {
381
- appendClaimed(claimedFile, sources.map((s) => s.id));
381
+ if (claim && !env.NEWSLETTER_SOURCE_ID) {
382
+ appendClaimed(claimedFile, sources.map((s) => s.id));
383
+ }
382
384
  }
383
385
 
384
386
  // Force `newsletter.enabled: true` and inject the per-run newsletter config
@@ -432,12 +434,25 @@ module.exports = {
432
434
 
433
435
  console.log(`[extended] uploading to itw-creative-works/newsletter-assets/${config.brand?.id}/${campaignId || '<auto-id>'}/ + Beehiiv draft`);
434
436
 
437
+ // NEWSLETTER_SOURCE overrides the configured sources[] and lets the
438
+ // generator's built-in resolver run (resolveNewsletterSources). Without
439
+ // this, the test always passes pre-fetched parent sources via opts.sources,
440
+ // bypassing feed resolution entirely.
441
+ // NEWSLETTER_SOURCE='$feed:https://feeds.arstechnica.com/arstechnica/index'
442
+ // NEWSLETTER_SOURCE='$parent' (default behavior, explicit)
443
+ const useResolverSources = !!env.NEWSLETTER_SOURCE;
444
+
445
+ if (useResolverSources) {
446
+ newsletterConfig.sources = [env.NEWSLETTER_SOURCE];
447
+ Manager.config.marketing.newsletter.content = newsletterConfig;
448
+ }
449
+
435
450
  const result = await generator.generate(
436
451
  Manager,
437
452
  assistant,
438
- { name: `Somiibo Newsletter — Iteration ${stamp}` },
453
+ { name: `${config.brand?.name || 'Brand'} Newsletter — Iteration ${stamp}` },
439
454
  {
440
- sources,
455
+ sources: useResolverSources ? undefined : sources,
441
456
  skipClaim: true, // We manage the claim/release lifecycle ourselves
442
457
  skipImages: !!env.NEWSLETTER_NO_IMAGES,
443
458
  // The article is GENERATED whenever the brand's config.article.enabled is on