backend-manager 5.10.2 → 5.10.3
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/CLAUDE.md +3 -1
- package/docs/admin-post-route.md +8 -5
- package/docs/cdp-debugging.md +15 -30
- package/docs/ghostii.md +18 -11
- package/docs/marketing-campaigns.md +11 -7
- package/package.json +1 -1
- package/src/cli/index.js +7 -1
- package/src/defaults/CLAUDE.md +2 -0
- package/src/manager/events/cron/daily/blog-auto-publisher.js +61 -150
- package/src/manager/libraries/content/source-resolver.js +296 -109
- package/src/manager/libraries/email/generators/newsletter.js +147 -69
- package/src/manager/routes/admin/post/post.js +69 -1
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +55 -57
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/templates/backend-manager-config.json +1 -0
- package/test/email/newsletter-generate.js +4 -5
- package/test/helpers/content/blog-auto-publisher.js +116 -132
- package/test/helpers/content/ghostii-feed-integration.js +2 -2
- package/test/routes/admin/post-convert-image.js +158 -0
- package/test/routes/admin/post-download-error.js +89 -0
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared content-source resolution for blog + newsletter pipelines.
|
|
3
3
|
*
|
|
4
|
-
* Both blog-auto-publisher and newsletter-generator
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
4
|
+
* Both blog-auto-publisher and newsletter-generator resolve their sources
|
|
5
|
+
* through the SAME function — resolveSources(). It:
|
|
6
|
+
* - Picks random source(s) from the entry's sources array (never anything
|
|
7
|
+
* outside the array)
|
|
8
|
+
* - Resolves each pick to a concrete item ($feed: article, $parent source,
|
|
9
|
+
* $brand seed, URL content, or text seed)
|
|
10
|
+
* - Checks Firestore (content-sources collection) so used items are never
|
|
11
|
+
* re-picked, and a session-used set so the same item is never returned
|
|
12
|
+
* twice in one call
|
|
13
|
+
* - Falls back on failure following a strict type hierarchy:
|
|
14
|
+
* $feed → other items in the same feed → other $feed sources
|
|
15
|
+
* → $parent (if listed) → give up
|
|
16
|
+
* $parent → other unused parent items → give up
|
|
17
|
+
* $brand → pick-only; NOTHING ever falls back to $brand
|
|
18
|
+
* URL/text→ pick-only; no fallback in or out
|
|
19
|
+
* - Leaves marking-as-used to the CALLER (trackContentSource) so sources
|
|
20
|
+
* are only marked used after the content that used them actually
|
|
21
|
+
* generated/published successfully
|
|
9
22
|
*
|
|
10
23
|
* This module is the single source of truth for all of that.
|
|
11
24
|
*/
|
|
@@ -18,6 +31,9 @@ const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
|
|
|
18
31
|
const FEED_PREFIX = '$feed:';
|
|
19
32
|
const CONTENT_SOURCES_COLLECTION = 'content-sources';
|
|
20
33
|
|
|
34
|
+
// Parent sources fetched per category when the $parent pool is built
|
|
35
|
+
const PARENT_SOURCES_PER_CATEGORY = 3;
|
|
36
|
+
|
|
21
37
|
// ── Prompt constants ─────────────────────────────────────────────────
|
|
22
38
|
|
|
23
39
|
const ANTI_TRACEABILITY = [
|
|
@@ -57,9 +73,44 @@ const PROMPT = `
|
|
|
57
73
|
{suggestion}
|
|
58
74
|
`;
|
|
59
75
|
|
|
60
|
-
// ──
|
|
76
|
+
// ── Resolver state ───────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create the per-call resolver state. Caches parsed feeds and the parent
|
|
80
|
+
* pool so a single resolveSources() call never fetches the same feed or
|
|
81
|
+
* the parent endpoint twice.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} args
|
|
84
|
+
* @param {string[]} args.sources - The entry's source pool ($feed:/$parent/$brand/URL/text)
|
|
85
|
+
* @param {string[]} [args.categories] - Categories for $parent fetches
|
|
86
|
+
* @param {object} [args.admin] - firebase-admin (used-source checks; omit to skip)
|
|
87
|
+
* @param {object} [args.Manager] - Manager instance (parent URL resolution)
|
|
88
|
+
* @param {object} args.assistant - logger
|
|
89
|
+
* @returns {object} state
|
|
90
|
+
*/
|
|
91
|
+
function createResolverState({ sources, categories, admin, Manager, assistant }) {
|
|
92
|
+
return {
|
|
93
|
+
pool: [...(sources || [])],
|
|
94
|
+
categories: categories || [],
|
|
95
|
+
admin,
|
|
96
|
+
Manager,
|
|
97
|
+
assistant,
|
|
98
|
+
sessionUsed: new Set(), // item url/id already returned this call
|
|
99
|
+
feedCache: new Map(), // feedUrl → { items: [...], dead: bool }
|
|
100
|
+
parentPool: null, // null = not fetched yet; [] = fetched (possibly exhausted)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Feed resolution ──────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async function loadFeed(state, feedUrl) {
|
|
107
|
+
if (state.feedCache.has(feedUrl)) {
|
|
108
|
+
return state.feedCache.get(feedUrl);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const entry = { items: [], dead: false };
|
|
112
|
+
state.feedCache.set(feedUrl, entry);
|
|
61
113
|
|
|
62
|
-
async function processFeedSource(assistant, feedUrl, brandId, admin) {
|
|
63
114
|
const feedText = await fetch(feedUrl, {
|
|
64
115
|
timeout: 30000,
|
|
65
116
|
tries: 2,
|
|
@@ -68,37 +119,53 @@ async function processFeedSource(assistant, feedUrl, brandId, admin) {
|
|
|
68
119
|
}).catch((e) => e);
|
|
69
120
|
|
|
70
121
|
if (feedText instanceof Error) {
|
|
71
|
-
assistant.error(`
|
|
72
|
-
|
|
122
|
+
state.assistant.error(`loadFeed(): Failed to fetch feed: ${feedUrl}`, feedText);
|
|
123
|
+
entry.dead = true;
|
|
124
|
+
return entry;
|
|
73
125
|
}
|
|
74
126
|
|
|
75
127
|
const { items } = parseFeed(feedText);
|
|
76
128
|
if (!items.length) {
|
|
77
|
-
assistant.log(`
|
|
78
|
-
|
|
129
|
+
state.assistant.log(`loadFeed(): No items in feed: ${feedUrl}`);
|
|
130
|
+
entry.dead = true;
|
|
131
|
+
return entry;
|
|
79
132
|
}
|
|
80
133
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
|
|
84
|
-
assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
|
|
134
|
+
const processedIds = await getProcessedItemIds(state.admin, feedUrl).catch((e) => {
|
|
135
|
+
state.assistant.error('loadFeed(): Error querying tracked items (continuing)', e);
|
|
85
136
|
return new Set();
|
|
86
137
|
});
|
|
87
138
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
139
|
+
entry.items = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
|
|
140
|
+
|
|
141
|
+
state.assistant.log(`loadFeed(): ${entry.items.length}/${items.length} unprocessed items in ${feedUrl}`);
|
|
142
|
+
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function resolveFeedItem(state, feedUrl) {
|
|
147
|
+
const feed = await loadFeed(state, feedUrl);
|
|
148
|
+
|
|
149
|
+
if (feed.dead) {
|
|
91
150
|
return null;
|
|
92
151
|
}
|
|
93
152
|
|
|
94
|
-
|
|
153
|
+
// Newest-first among items not yet used this session
|
|
154
|
+
const item = feed.items.find((i) => !state.sessionUsed.has(i.id) && !state.sessionUsed.has(i.url));
|
|
155
|
+
if (!item) {
|
|
156
|
+
state.assistant.log(`resolveFeedItem(): Feed exhausted for this session: ${feedUrl}`);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
95
159
|
|
|
96
|
-
|
|
160
|
+
state.sessionUsed.add(item.id);
|
|
161
|
+
if (item.url) {
|
|
162
|
+
state.sessionUsed.add(item.url);
|
|
163
|
+
}
|
|
97
164
|
|
|
98
165
|
let content = '';
|
|
99
166
|
if (item.url) {
|
|
100
167
|
content = await extractArticleContent(item.url).catch((e) => {
|
|
101
|
-
assistant.error(`
|
|
168
|
+
state.assistant.error(`resolveFeedItem(): Failed to extract content from ${item.url} (using summary)`, e);
|
|
102
169
|
return '';
|
|
103
170
|
});
|
|
104
171
|
}
|
|
@@ -107,26 +174,45 @@ async function processFeedSource(assistant, feedUrl, brandId, admin) {
|
|
|
107
174
|
content = item.content || item.summary || '';
|
|
108
175
|
}
|
|
109
176
|
|
|
110
|
-
return {
|
|
177
|
+
return {
|
|
178
|
+
type: 'feed',
|
|
179
|
+
id: item.id || item.url || '',
|
|
180
|
+
title: item.title || '',
|
|
181
|
+
url: item.url || '',
|
|
182
|
+
content,
|
|
183
|
+
feedUrl,
|
|
184
|
+
raw: item,
|
|
185
|
+
trackingData: {
|
|
186
|
+
url: item.url || item.id,
|
|
187
|
+
origin: `${FEED_PREFIX}${feedUrl}`,
|
|
188
|
+
feedUrl,
|
|
189
|
+
itemId: item.id,
|
|
190
|
+
itemTitle: item.title,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
111
193
|
}
|
|
112
194
|
|
|
113
|
-
// ── Parent
|
|
195
|
+
// ── Parent resolution ────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
async function loadParentPool(state) {
|
|
198
|
+
if (state.parentPool !== null) {
|
|
199
|
+
return state.parentPool;
|
|
200
|
+
}
|
|
114
201
|
|
|
115
|
-
|
|
116
|
-
const parentUrl = Manager?.getParentApiUrl?.();
|
|
202
|
+
state.parentPool = [];
|
|
117
203
|
|
|
204
|
+
const parentUrl = state.Manager?.getParentApiUrl?.();
|
|
118
205
|
if (!parentUrl) {
|
|
119
|
-
assistant.log('
|
|
120
|
-
return
|
|
206
|
+
state.assistant.log('loadParentPool(): No parent URL configured');
|
|
207
|
+
return state.parentPool;
|
|
121
208
|
}
|
|
122
209
|
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const categoriesToFetch = categories.length ? categories : [''];
|
|
210
|
+
const categoriesToFetch = state.categories.length ? state.categories : [''];
|
|
211
|
+
const fetched = [];
|
|
126
212
|
|
|
127
213
|
for (const category of categoriesToFetch) {
|
|
128
214
|
const query = {
|
|
129
|
-
limit:
|
|
215
|
+
limit: PARENT_SOURCES_PER_CATEGORY,
|
|
130
216
|
backendManagerKey: process.env.BACKEND_MANAGER_KEY,
|
|
131
217
|
};
|
|
132
218
|
|
|
@@ -140,29 +226,37 @@ async function processParentSource(assistant, entry, admin, Manager) {
|
|
|
140
226
|
timeout: 60000,
|
|
141
227
|
query: query,
|
|
142
228
|
}).catch((e) => {
|
|
143
|
-
assistant.error(`
|
|
229
|
+
state.assistant.error(`loadParentPool(): Failed to fetch sources for category=${category}: ${e.message}`);
|
|
144
230
|
return null;
|
|
145
231
|
});
|
|
146
232
|
|
|
147
233
|
if (data?.sources?.length) {
|
|
148
|
-
|
|
234
|
+
fetched.push(...data.sources);
|
|
149
235
|
}
|
|
150
236
|
}
|
|
151
237
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
238
|
+
// Dedupe across categories (same source can appear in multiple categories)
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
const deduped = fetched.filter((s) => {
|
|
241
|
+
const key = s.url || s.id;
|
|
242
|
+
if (!key || seen.has(key)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
seen.add(key);
|
|
246
|
+
return true;
|
|
247
|
+
});
|
|
156
248
|
|
|
157
|
-
|
|
249
|
+
// Filter out sources already used (Firestore content-sources)
|
|
250
|
+
let available = deduped;
|
|
251
|
+
if (state.admin) {
|
|
158
252
|
const usedUrls = new Set();
|
|
159
|
-
const snapshot = await admin.firestore()
|
|
253
|
+
const snapshot = await state.admin.firestore()
|
|
160
254
|
.collection(CONTENT_SOURCES_COLLECTION)
|
|
161
255
|
.where('origin', '==', '$parent')
|
|
162
256
|
.select('url')
|
|
163
257
|
.get()
|
|
164
258
|
.catch((e) => {
|
|
165
|
-
assistant.error('
|
|
259
|
+
state.assistant.error('loadParentPool(): Error querying tracked sources (continuing)', e);
|
|
166
260
|
return { docs: [] };
|
|
167
261
|
});
|
|
168
262
|
|
|
@@ -173,100 +267,195 @@ async function processParentSource(assistant, entry, admin, Manager) {
|
|
|
173
267
|
}
|
|
174
268
|
});
|
|
175
269
|
|
|
176
|
-
|
|
270
|
+
available = deduped.filter((s) => !usedUrls.has(s.url || s.id));
|
|
271
|
+
}
|
|
177
272
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
273
|
+
state.assistant.log(`loadParentPool(): ${available.length}/${fetched.length} parent sources available`);
|
|
274
|
+
|
|
275
|
+
state.parentPool = available;
|
|
276
|
+
return state.parentPool;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function resolveParentItem(state) {
|
|
280
|
+
const pool = await loadParentPool(state);
|
|
281
|
+
|
|
282
|
+
const candidates = pool.filter((s) => {
|
|
283
|
+
const key = s.url || s.id;
|
|
284
|
+
return key && !state.sessionUsed.has(key);
|
|
285
|
+
});
|
|
182
286
|
|
|
183
|
-
|
|
287
|
+
if (!candidates.length) {
|
|
288
|
+
state.assistant.log('resolveParentItem(): Parent pool exhausted for this session');
|
|
289
|
+
return null;
|
|
184
290
|
}
|
|
185
291
|
|
|
186
|
-
|
|
292
|
+
// Random pick among remaining candidates
|
|
293
|
+
const item = candidates[Math.floor(Math.random() * candidates.length)];
|
|
294
|
+
state.sessionUsed.add(item.url || item.id);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
type: 'parent',
|
|
298
|
+
id: item.id || '',
|
|
299
|
+
title: item.title || item.subject || '',
|
|
300
|
+
url: (item.url && item.url.startsWith('http')) ? item.url : '',
|
|
301
|
+
content: item.source?.content || item.content || item.summary || '',
|
|
302
|
+
raw: item,
|
|
303
|
+
trackingData: {
|
|
304
|
+
url: item.url || item.id,
|
|
305
|
+
origin: '$parent',
|
|
306
|
+
itemId: item.id,
|
|
307
|
+
itemTitle: item.title || item.subject || '',
|
|
308
|
+
},
|
|
309
|
+
};
|
|
187
310
|
}
|
|
188
311
|
|
|
189
|
-
// ──
|
|
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
|
-
});
|
|
312
|
+
// ── Pick resolution (type-aware fallback) ────────────────────────────
|
|
206
313
|
|
|
207
|
-
|
|
208
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Resolve ONE picked source, following the fallback hierarchy for its type.
|
|
316
|
+
* Exported for deterministic testing — production goes through resolveSources().
|
|
317
|
+
*
|
|
318
|
+
* @param {object} state - from createResolverState()
|
|
319
|
+
* @param {string} source - the picked source ($feed:X / $parent / $brand / URL / text)
|
|
320
|
+
* @returns {Promise<object|null>} resolved source or null when the chain is exhausted
|
|
321
|
+
*/
|
|
322
|
+
async function resolvePick(state, source) {
|
|
323
|
+
// --- $feed: → same feed → other feeds → $parent (if listed) ---
|
|
324
|
+
if (typeof source === 'string' && source.startsWith(FEED_PREFIX)) {
|
|
325
|
+
const startUrl = source.slice(FEED_PREFIX.length);
|
|
326
|
+
const otherFeeds = shuffle(
|
|
327
|
+
state.pool
|
|
328
|
+
.filter((s) => typeof s === 'string' && s.startsWith(FEED_PREFIX) && s !== source)
|
|
329
|
+
.map((s) => s.slice(FEED_PREFIX.length))
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
for (const feedUrl of [startUrl, ...otherFeeds]) {
|
|
333
|
+
const resolved = await resolveFeedItem(state, feedUrl);
|
|
334
|
+
if (resolved) {
|
|
335
|
+
return resolved;
|
|
209
336
|
}
|
|
210
|
-
} catch (e) {
|
|
211
|
-
assistant.error(`fetchParentSources(): Failed to fetch ${category} sources: ${e.message}`);
|
|
212
337
|
}
|
|
338
|
+
|
|
339
|
+
if (state.pool.includes('$parent')) {
|
|
340
|
+
state.assistant.log('resolvePick(): All feeds exhausted, falling back to $parent');
|
|
341
|
+
return resolveParentItem(state);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return null;
|
|
213
345
|
}
|
|
214
346
|
|
|
215
|
-
|
|
216
|
-
|
|
347
|
+
// --- $parent → other parent items only ---
|
|
348
|
+
if (source === '$parent') {
|
|
349
|
+
return resolveParentItem(state);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- $brand → pick-only seed (nothing ever falls back TO this) ---
|
|
353
|
+
if (source === '$brand') {
|
|
354
|
+
return {
|
|
355
|
+
type: 'brand',
|
|
356
|
+
id: '',
|
|
357
|
+
title: '',
|
|
358
|
+
url: '',
|
|
359
|
+
content: '',
|
|
360
|
+
raw: null,
|
|
361
|
+
trackingData: null,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
217
364
|
|
|
218
|
-
//
|
|
365
|
+
// --- URL → fetch content, no fallback ---
|
|
366
|
+
if (isURL(source)) {
|
|
367
|
+
const content = await getURLContent(source).catch((e) => {
|
|
368
|
+
state.assistant.error(`resolvePick(): Error fetching URL ${source}`, e);
|
|
369
|
+
return null;
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (content === null) {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
219
375
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
376
|
+
return {
|
|
377
|
+
type: 'url',
|
|
378
|
+
id: source,
|
|
379
|
+
title: '',
|
|
380
|
+
url: source,
|
|
381
|
+
content,
|
|
382
|
+
raw: null,
|
|
383
|
+
trackingData: null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
224
386
|
|
|
387
|
+
// --- Text seed → always resolves ---
|
|
225
388
|
return {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
from: hostname,
|
|
234
|
-
subject: item.title || '',
|
|
235
|
-
content: feedResult.content || item.content || item.summary || '',
|
|
236
|
-
},
|
|
237
|
-
ai: null,
|
|
389
|
+
type: 'text',
|
|
390
|
+
id: '',
|
|
391
|
+
title: '',
|
|
392
|
+
url: '',
|
|
393
|
+
content: source,
|
|
394
|
+
raw: null,
|
|
395
|
+
trackingData: null,
|
|
238
396
|
};
|
|
239
397
|
}
|
|
240
398
|
|
|
241
|
-
// ──
|
|
399
|
+
// ── Main entry: resolve N sources from a pool ────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Resolve `count` sources from the entry's source pool.
|
|
403
|
+
*
|
|
404
|
+
* Each pick starts with a RANDOM source from the pool, then follows the
|
|
405
|
+
* type fallback hierarchy (see module doc). Returns however many sources
|
|
406
|
+
* could be resolved (may be fewer than count when pools are exhausted).
|
|
407
|
+
*
|
|
408
|
+
* The caller is responsible for marking returned sources as used via
|
|
409
|
+
* trackContentSource(resolved.trackingData) AFTER the content that used
|
|
410
|
+
* them succeeds.
|
|
411
|
+
*
|
|
412
|
+
* @param {object} args
|
|
413
|
+
* @param {string[]} args.sources - The entry's source pool
|
|
414
|
+
* @param {number} [args.count=1] - How many resolved sources to return
|
|
415
|
+
* @param {string[]} [args.categories] - Categories for $parent fetches
|
|
416
|
+
* @param {object} [args.admin] - firebase-admin
|
|
417
|
+
* @param {object} [args.Manager]
|
|
418
|
+
* @param {object} args.assistant
|
|
419
|
+
* @returns {Promise<object[]>} resolved sources ({ type, id, title, url, content, feedUrl?, raw?, trackingData })
|
|
420
|
+
*/
|
|
421
|
+
async function resolveSources({ sources, count, categories, admin, Manager, assistant }) {
|
|
422
|
+
count = count || 1;
|
|
423
|
+
|
|
424
|
+
const state = createResolverState({ sources, categories, admin, Manager, assistant });
|
|
425
|
+
|
|
426
|
+
if (!state.pool.length) {
|
|
427
|
+
assistant.log('resolveSources(): Empty source pool');
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
242
430
|
|
|
243
|
-
async function resolveNewsletterSources({ sources, categories, admin, Manager, assistant }) {
|
|
244
431
|
const resolved = [];
|
|
245
432
|
|
|
246
|
-
|
|
247
|
-
|
|
433
|
+
for (let i = 0; i < count; i++) {
|
|
434
|
+
const pick = state.pool[Math.floor(Math.random() * state.pool.length)];
|
|
248
435
|
|
|
249
|
-
|
|
436
|
+
assistant.log(`resolveSources(): Pick ${i + 1}/${count} → ${typeof pick === 'string' ? pick.slice(0, 80) : pick}`);
|
|
250
437
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
resolved.push(normalizeFeedItemForNewsletter(feedResult, feedUrl, categories));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
438
|
+
const result = await resolvePick(state, pick).catch((e) => {
|
|
439
|
+
assistant.error('resolveSources(): Error resolving pick', e);
|
|
440
|
+
return null;
|
|
441
|
+
});
|
|
258
442
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
resolved.push(...parentSources);
|
|
443
|
+
if (result) {
|
|
444
|
+
resolved.push(result);
|
|
445
|
+
} else {
|
|
446
|
+
assistant.log(`resolveSources(): Pick ${i + 1}/${count} exhausted its fallback chain`);
|
|
264
447
|
}
|
|
265
448
|
}
|
|
266
449
|
|
|
450
|
+
assistant.log(`resolveSources(): Resolved ${resolved.length}/${count} sources`);
|
|
451
|
+
|
|
267
452
|
return resolved;
|
|
268
453
|
}
|
|
269
454
|
|
|
455
|
+
function shuffle(array) {
|
|
456
|
+
return [...array].sort(() => Math.random() - 0.5);
|
|
457
|
+
}
|
|
458
|
+
|
|
270
459
|
// ── Firestore tracking ──────────────────────────────────────────────
|
|
271
460
|
|
|
272
461
|
async function trackContentSource(admin, { url, origin, feedUrl, itemId, itemTitle, usedBy, brandId, postUrl, postSlug, postTitle }) {
|
|
@@ -411,11 +600,9 @@ module.exports = {
|
|
|
411
600
|
FEED_PREFIX,
|
|
412
601
|
CONTENT_SOURCES_COLLECTION,
|
|
413
602
|
USER_AGENT,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
resolveNewsletterSources,
|
|
418
|
-
normalizeFeedItemForNewsletter,
|
|
603
|
+
resolveSources,
|
|
604
|
+
createResolverState,
|
|
605
|
+
resolvePick,
|
|
419
606
|
trackContentSource,
|
|
420
607
|
contentSourceHash,
|
|
421
608
|
getProcessedItemIds,
|