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 +63 -5
- package/package.json +1 -1
- package/src/manager/events/cron/daily/blog-auto-publisher.js +22 -398
- package/src/manager/libraries/content/source-resolver.js +397 -0
- package/src/manager/libraries/email/generators/newsletter.js +18 -46
- package/test/email/newsletter-generate.js +65 -50
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
|
|
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
|
-
|
|
152
|
-
npx mgr test
|
|
153
|
-
|
|
154
|
-
|
|
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
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
232
|
+
const parentResults = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
|
|
310
233
|
|
|
311
|
-
if (
|
|
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
|
-
//
|
|
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('../../../
|
|
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
|
-
|
|
103
|
-
if (contentSources.includes('$parent')) {
|
|
104
|
-
const categories = config.categories || [];
|
|
102
|
+
const categories = config.categories || [];
|
|
105
103
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
assistant.log('Newsletter generator: no parent URL configured');
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
109
|
+
const admin = Manager.libraries?.admin;
|
|
117
110
|
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
366
|
+
// --- Fetch sources ---
|
|
367
|
+
const claim = !!env.NEWSLETTER_CLAIM;
|
|
368
|
+
sources = await fetchSourcesForRun({
|
|
334
369
|
parentUrl,
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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:
|
|
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
|