backend-manager 5.9.17 → 5.9.19

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.19",
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,27 +152,26 @@ 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
- const uploadedPost = await provider.publishArticle(assistant, {
157
+ const publishArgs = {
220
158
  brand: entry.brand,
221
159
  article,
222
160
  id: postId++,
223
161
  author: entry.author,
224
162
  postPath: entry.postPath,
225
163
  source: resolved.trackingData?.url || null,
226
- }).catch((e) => e);
164
+ };
165
+ assistant.log('harvest(): publishArgs', publishArgs);
166
+
167
+ const uploadedPost = await provider.publishArticle(assistant, publishArgs).catch((e) => e);
227
168
  if (uploadedPost instanceof Error) {
228
169
  assistant.error('harvest(): Error uploading post to blog', uploadedPost);
229
170
  break;
230
171
  }
231
172
 
232
- // Log
233
173
  assistant.log('harvest(): Uploaded post', uploadedPost);
234
174
 
235
- // Track content source in Firestore
236
175
  if (resolved.trackingData && admin) {
237
176
  await trackContentSource(admin, {
238
177
  ...resolved.trackingData,
@@ -246,22 +185,9 @@ async function harvest(assistant, entry, admin, provider, Manager) {
246
185
  }
247
186
  }
248
187
 
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
188
  async function resolveSource(assistant, source, entry, admin, Manager) {
262
189
  const date = moment().format('MMMM YYYY');
263
190
 
264
- // Build template vars for prompts
265
191
  const templateVars = {
266
192
  ...entry,
267
193
  instructions: entry.instructions,
@@ -306,13 +232,14 @@ async function resolveSource(assistant, source, entry, admin, Manager) {
306
232
  if (source === '$parent') {
307
233
  assistant.log('resolveSource(): Processing $parent source');
308
234
 
309
- const parentResult = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
235
+ const parentResults = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
310
236
 
311
- if (parentResult instanceof Error || !parentResult) {
237
+ if (parentResults instanceof Error || !parentResults?.length) {
312
238
  assistant.log('resolveSource(): Parent source failed or exhausted, falling back to $brand');
313
239
  return resolveSource(assistant, '$brand', entry, admin, Manager);
314
240
  }
315
241
 
242
+ const parentResult = parentResults[0];
316
243
  const description = powertools.template(PROMPT_SOURCE, {
317
244
  ...templateVars,
318
245
  sourceTitle: parentResult.title,
@@ -368,311 +295,11 @@ async function resolveSource(assistant, source, entry, admin, Manager) {
368
295
  return { description, sourceContent: '' };
369
296
  }
370
297
 
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
298
  function randomize(array) {
672
299
  return array.sort(() => Math.random() - 0.5);
673
300
  }
674
301
 
675
- // Exported for testing and for newsletter to import
302
+ // Re-export shared functions for backwards compatibility (newsletter + tests import from here)
676
303
  module.exports.resolveSource = resolveSource;
677
304
  module.exports.processFeedSource = processFeedSource;
678
305
  module.exports.contentSourceHash = contentSourceHash;