backend-manager 5.7.6 → 5.8.1
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/CHANGELOG.md +21 -0
- package/CLAUDE.md +1 -0
- package/PROGRESS.md +29 -5
- package/docs/ghostii.md +127 -0
- package/package.json +3 -1
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +287 -28
- package/src/manager/libraries/content/feed-parser.js +198 -0
- package/src/manager/libraries/content/ghostii.js +38 -17
- package/src/manager/libraries/email/data/disposable-domains.json +78 -1
- package/src/manager/libraries/email/generators/lib/structure.js +2 -2
- package/templates/backend-manager-config.json +25 -3
- package/test/helpers/content/feed-parser.js +528 -0
- package/test/helpers/content/ghostii-auto-publisher.js +342 -0
- package/test/helpers/content/ghostii-feed-integration.js +399 -0
- package/test/helpers/content/ghostii-write-article.js +243 -0
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.8.1] - 2026-06-19
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Ghostii 502 timeout fix.** Switched `writeArticle()` from the Firebase Hosting URL (`api.ghostii.ai`) to the raw Cloud Functions URL. Firebase Hosting rewrites have a hard 60-second proxy timeout that caused 502 errors when the AI article pipeline exceeded that limit. The raw URL uses the function's own 5-minute timeout.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Ghostii call duration logging.** `writeArticle()` now logs the round-trip time of each Ghostii API call (`[ghostii] writeArticle() completed in Xms`).
|
|
24
|
+
|
|
25
|
+
# [5.8.0] - 2026-06-19
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- **RSS/Atom feed sources for Ghostii.** New `$feed:<url>` source type in `config.ghostii[]` entries. Parses RSS 2.0, Atom 1.0, and JSON Feed formats, selects unprocessed articles, extracts content, and passes it to the Ghostii API as `sourceContent` for AI-assisted original article generation. Feed items tracked in Firestore (`ghostii-feed-items`) to prevent re-processing. Falls back to `$app` when feeds are unreachable or exhausted.
|
|
29
|
+
- **Per-entry Ghostii API overrides.** `config.ghostii[].overrides` object lets each entry customize keywords, length, research, insertImages, headerImageUrl, maxLinks, sectionQuantity, and feedUrl — previously hardcoded in `writeArticle()`.
|
|
30
|
+
- **Configurable postPath.** `config.ghostii[].postPath` controls the `_posts/{year}/` sub-folder for published articles (default: `'ghostii'`).
|
|
31
|
+
- **Feed parser library.** New `src/manager/libraries/content/feed-parser.js` — `parseFeed()` and `extractArticleContent()` via Cheerio for parsing syndication feeds and extracting readable article text from URLs.
|
|
32
|
+
- **New dependencies:** `fast-xml-parser` for RSS/Atom parsing, `cheerio` for article content extraction.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- **`writeArticle()` accepts overrides and sourceContent.** Signature changed from `({ brand, description, links })` to `({ brand, description, links, sourceContent, overrides })`. Existing callers (newsletter generator) pass no new args and get identical behavior.
|
|
36
|
+
- **Newsletter fact paraphrasing.** Newsletter writer prompt now instructs the AI to paraphrase all facts, figures, and statistics from sources — never copy exact numbers or phrasing.
|
|
37
|
+
|
|
17
38
|
# [5.7.6] - 2026-06-18
|
|
18
39
|
|
|
19
40
|
### Fixed
|
package/CLAUDE.md
CHANGED
|
@@ -162,6 +162,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
|
|
|
162
162
|
|
|
163
163
|
### Subsystems & Libraries
|
|
164
164
|
|
|
165
|
+
- [docs/ghostii.md](docs/ghostii.md) — Ghostii article publisher: source types (`$app` / `$feed:` / URL / text), per-entry API overrides, RSS/Atom feed parser, Firestore feed-item tracking, `sourceContent` pass-through to Ghostii API
|
|
165
166
|
- [docs/email-system.md](docs/email-system.md) — unified MJML email rendering pipeline: shared preparation (`prepare.js`), composable template system (`base.js` blocks), 4 email templates (card, plain, order, feedback), no SendGrid dynamic templates — everything rendered server-side
|
|
166
167
|
- [docs/usage-rate-limiting.md](docs/usage-rate-limiting.md) — usage tracking, monthly/daily caps, `setUser()` + mirrors for proxy usage, reset schedule
|
|
167
168
|
- [docs/ai-library.md](docs/ai-library.md) — `Manager.AI()` unified entry for OpenAI + Anthropic (text via `.request()`, images via `.image()` → `gpt-image-2`)
|
package/PROGRESS.md
CHANGED
|
@@ -2,13 +2,37 @@
|
|
|
2
2
|
> Agents and maintainers should update this file regularly to reflect the current state of the project.
|
|
3
3
|
|
|
4
4
|
## 🎯 Current Focus
|
|
5
|
-
* **Goal:**
|
|
6
|
-
* **Current Phase:**
|
|
7
|
-
* **Priority:**
|
|
8
|
-
* **Last Updated:** 2026-06-
|
|
9
|
-
* **Notes:** v5.
|
|
5
|
+
* **Goal:** RSS/Atom feed-based article generation for the Ghostii system
|
|
6
|
+
* **Current Phase:** Shipped
|
|
7
|
+
* **Priority:** Complete
|
|
8
|
+
* **Last Updated:** 2026-06-18 7:30 PM PDT
|
|
9
|
+
* **Notes:** BEM v5.8.0 published to npm. Ghostii-backend v1.0.5 deployed to Firebase. All code, tests, and docs shipped.
|
|
10
10
|
|
|
11
11
|
## 📌 Active Task List
|
|
12
|
+
* [ ] Phase 5: Ghostii feed-based article system
|
|
13
|
+
* [x] Task 5.1: Create `feed-parser.js` (RSS 2.0, Atom 1.0, JSON Feed parser + article extractor)
|
|
14
|
+
* [x] Task 5.2: Add `fast-xml-parser` dependency to BEM
|
|
15
|
+
* [x] Task 5.3: Add `sourceContent` field to Ghostii backend schema (16KB)
|
|
16
|
+
* [x] Task 5.4: Update Ghostii outline prompt (Step 2 only) with sourceContent for efficient spinning
|
|
17
|
+
* [x] Task 5.5: Update `writeArticle()` to accept `sourceContent` and per-entry `overrides`
|
|
18
|
+
* [x] Task 5.6: Upgrade `ghostii-auto-publisher.js` — `$feed:` source type, feed processing, Firestore tracking, fallback
|
|
19
|
+
* [x] Task 5.7: Update config template with new source types and overrides
|
|
20
|
+
* [x] Task 5.8: Write extensive test suite (4 test files: unit, integration, extended E2E)
|
|
21
|
+
* [x] Task 5.9: Verify all tests pass (84 standard + 8 extended against real RSS feeds)
|
|
22
|
+
* [x] Task 5.10: Create `docs/ghostii.md` deep reference, update CLAUDE.md + CHANGELOG.md
|
|
23
|
+
* [x] Task 5.11: Verify all recommended feed URLs work (The Verge removed — CDN blocks programmatic access)
|
|
24
|
+
* [x] Task 5.12: Write ghostii-backend tests for sourceContent (4 schema + 1 extended generation)
|
|
25
|
+
* [x] Task 5.13: Ghostii sourceContent accepts URL (auto-fetches + extracts article text)
|
|
26
|
+
* [x] Task 5.14: Add fact paraphrasing to outline prompt + newsletter writer
|
|
27
|
+
* [x] Task 5.15: Fix Step 3 prompts — assertive link insertion + blockquote in every body section
|
|
28
|
+
* [x] Task 5.16: Fix humanizer stripping links — `injectBurstiness` was breaking `[text](url)` syntax; added protect-and-restore pattern + tests
|
|
29
|
+
* [x] Task 5.17: Fix 403 links treated as "working" in link verification step
|
|
30
|
+
* [x] Task 5.18: Add detective-level `[LINKS]` diagnostic logging to article pipeline
|
|
31
|
+
* [x] Task 5.19: Ship BEM v5.8.0 to npm
|
|
32
|
+
* [x] Task 5.20: Ship Ghostii-backend v1.0.5 + deploy to Firebase
|
|
33
|
+
* [ ] Task 5.17: Publish BEM with feed support
|
|
34
|
+
* [ ] Task 5.12: Publish BEM with feed support
|
|
35
|
+
* [ ] Task 5.13: Configure consumer project(s) with `$feed:` sources
|
|
12
36
|
* [ ] Phase 3: Post-audit bug fixes
|
|
13
37
|
* [x] Newsletter ReferenceError: `beehiivConfig` → `newsletterRoleConfig` (committed v5.7.1)
|
|
14
38
|
* [x] HTTPS proxy: `serve.js` returns boolean, caller uses `httpsReady` not `httpsEnabled`
|
package/docs/ghostii.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Ghostii Article Publisher
|
|
2
|
+
|
|
3
|
+
The Ghostii system generates and publishes blog posts via the daily `bm_cronDaily` cron job. Two independent paths:
|
|
4
|
+
|
|
5
|
+
1. **Standalone publisher** — `config.ghostii[]` entries: daily cron picks sources, calls the Ghostii AI API, publishes via `admin/post`.
|
|
6
|
+
2. **Newsletter-linked articles** — `marketing.newsletter.content.article.enabled`: the newsletter generator expands its lead section into a full blog post via Ghostii.
|
|
7
|
+
|
|
8
|
+
This doc covers the standalone publisher. For newsletter integration, see [marketing-campaigns.md](marketing-campaigns.md).
|
|
9
|
+
|
|
10
|
+
## Architecture
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
config.ghostii[] entry
|
|
14
|
+
↓
|
|
15
|
+
ghostii-auto-publisher cron (daily)
|
|
16
|
+
↓ (per entry, per article)
|
|
17
|
+
resolveSource() — detect source type, fetch/parse if needed
|
|
18
|
+
↓
|
|
19
|
+
writeArticle() — call Ghostii AI API (api.ghostii.ai/write/article)
|
|
20
|
+
↓
|
|
21
|
+
publishArticle() — POST to admin/post → GitHub commit
|
|
22
|
+
↓
|
|
23
|
+
trackFeedItem() — Firestore (for $feed: sources only)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Key files
|
|
27
|
+
|
|
28
|
+
| File | Purpose |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `src/manager/libraries/content/ghostii.js` | `writeArticle()`, `publishArticle()`, `blocksToPost()` — API client + post transform |
|
|
31
|
+
| `src/manager/libraries/content/feed-parser.js` | `parseFeed()`, `extractArticleContent()` — RSS/Atom/JSON parser + article extractor |
|
|
32
|
+
| `src/manager/events/cron/daily/ghostii-auto-publisher.js` | Daily cron: source resolution, feed processing, Firestore tracking |
|
|
33
|
+
| `src/manager/routes/admin/post/post.js` | Publishing endpoint: image download + resize, GitHub commit |
|
|
34
|
+
|
|
35
|
+
## Source Types
|
|
36
|
+
|
|
37
|
+
Each `ghostii[]` entry has a `sources` array. The cron picks one at random per article.
|
|
38
|
+
|
|
39
|
+
| Source | Format | Behavior |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Generic topic | `'$app'` | Prompts Ghostii to write about any topic relevant to the brand |
|
|
42
|
+
| RSS/Atom feed | `'$feed:https://example.com/feed/'` | Parses feed, picks one unprocessed article, extracts content, passes as `sourceContent` to Ghostii |
|
|
43
|
+
| URL | `'https://example.com/page'` | Fetches page HTML, extracts body text, uses as prompt seed |
|
|
44
|
+
| Text | `'Write about blockchain'` | Uses directly as prompt seed |
|
|
45
|
+
|
|
46
|
+
### Feed source flow
|
|
47
|
+
|
|
48
|
+
1. Fetch and parse the RSS/Atom/JSON feed via `feed-parser.parseFeed()`
|
|
49
|
+
2. Query `ghostii-feed-items` in Firestore for already-processed items
|
|
50
|
+
3. Filter to unprocessed items, pick the newest
|
|
51
|
+
4. Extract full article content from the item URL via `feed-parser.extractArticleContent()`
|
|
52
|
+
5. Pass extracted text as `sourceContent` to the Ghostii API (separate from the `description` prompt)
|
|
53
|
+
6. After publish, write a tracking doc to `ghostii-feed-items/{hash}`
|
|
54
|
+
7. On failure (feed unreachable, unparseable, exhausted): fall back to `$app` behavior
|
|
55
|
+
|
|
56
|
+
### Feed item tracking
|
|
57
|
+
|
|
58
|
+
Collection: `ghostii-feed-items` (consumer project Firestore)
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
ghostii-feed-items/{sha256(feedUrl + '::' + itemId).slice(0,20)}: {
|
|
62
|
+
feedUrl: 'https://...',
|
|
63
|
+
itemId: 'guid-or-url',
|
|
64
|
+
itemUrl: 'https://...',
|
|
65
|
+
itemTitle: '...',
|
|
66
|
+
processedAt: Timestamp,
|
|
67
|
+
brandId: '...',
|
|
68
|
+
postUrl: '...',
|
|
69
|
+
postSlug: '...',
|
|
70
|
+
metadata: { created: Timestamp },
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// backend-manager-config.json
|
|
78
|
+
ghostii: [
|
|
79
|
+
{
|
|
80
|
+
articles: 1, // articles per cron run (0 = disabled)
|
|
81
|
+
sources: ['$app'], // source pool (mixed types OK)
|
|
82
|
+
links: [], // URLs to inject into article body
|
|
83
|
+
prompt: '', // custom instructions for the AI
|
|
84
|
+
chance: 1.0, // probability of running (0-1)
|
|
85
|
+
author: null, // author slug (null = admin/post default)
|
|
86
|
+
postPath: 'ghostii', // sub-folder under _posts/{year}/
|
|
87
|
+
overrides: { // per-entry Ghostii API param overrides
|
|
88
|
+
// keywords: ['AI'],
|
|
89
|
+
// length: 'long', // short | medium | long | comprehensive
|
|
90
|
+
// research: true, // web search for real links
|
|
91
|
+
// insertImages: true,
|
|
92
|
+
// insertLinks: true,
|
|
93
|
+
// headerImageUrl: 'unsplash', // disabled | unsplash | generate
|
|
94
|
+
// maxLinks: 6,
|
|
95
|
+
// sectionQuantity: 5,
|
|
96
|
+
// feedUrl: '', // brand blog feed (title dedup)
|
|
97
|
+
},
|
|
98
|
+
// brand: 'other-brand-id', // cross-brand publishing
|
|
99
|
+
// brandUrl: 'https://api.other.com',
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Per-entry overrides
|
|
105
|
+
|
|
106
|
+
All Ghostii API params were previously hardcoded in `writeArticle()`. They now accept per-entry overrides via the `overrides` object. Unspecified values fall back to framework defaults (length: 'long', research: true, etc.).
|
|
107
|
+
|
|
108
|
+
### `sourceContent` — Ghostii API field
|
|
109
|
+
|
|
110
|
+
When a `$feed:` source provides extracted article text, it's passed to the Ghostii API as `sourceContent` (max 16KB). This is separate from `description` (the editorial brief, max 2KB). Ghostii's title generation, outline, and section writing prompts reference the source content as context for writing original articles — not rewrites.
|
|
111
|
+
|
|
112
|
+
## Feed Parser
|
|
113
|
+
|
|
114
|
+
`src/manager/libraries/content/feed-parser.js` exports:
|
|
115
|
+
|
|
116
|
+
- **`parseFeed(text)`** — Parse RSS 2.0, Atom 1.0, or JSON Feed into `{ items: [{ id, title, url, summary, content, publishedAt }] }`. Handles CDATA, namespaced elements (`content:encoded`), BOM, attribute-based links. Returns `{ items: [] }` on invalid input.
|
|
117
|
+
|
|
118
|
+
- **`extractArticleContent(url)`** — Fetch URL, extract readable text from `<article>` → `<main>` → `<body>`, strip scripts/styles/nav/footer/header/aside, normalize whitespace, truncate to 14KB.
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx mgr test bem:helpers/content/feed-parser # 32 tests: RSS/Atom/JSON parsing, edge cases, HTML extraction
|
|
124
|
+
npx mgr test bem:helpers/content/ghostii-write-article # 15 tests: override pass-through, sourceContent, backwards compat
|
|
125
|
+
npx mgr test bem:helpers/content/ghostii-auto-publisher # 20 tests: source detection, feed processing, Firestore tracking
|
|
126
|
+
npx mgr test bem:helpers/content/ghostii-blocks # 8 tests: blocksToPost() (unchanged)
|
|
127
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-manager",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.1",
|
|
4
4
|
"description": "Quick tools for developing Firebase functions",
|
|
5
5
|
"main": "src/manager/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -72,9 +72,11 @@
|
|
|
72
72
|
"body-parser": "^2.2.2",
|
|
73
73
|
"busboy": "^1.6.0",
|
|
74
74
|
"chalk": "^5.6.2",
|
|
75
|
+
"cheerio": "^1.2.0",
|
|
75
76
|
"cors": "^2.8.6",
|
|
76
77
|
"dotenv": "^17.4.2",
|
|
77
78
|
"express": "^5.2.1",
|
|
79
|
+
"fast-xml-parser": "^5.9.2",
|
|
78
80
|
"fs-jetpack": "^5.1.0",
|
|
79
81
|
"glob": "^13.0.6",
|
|
80
82
|
"hcaptcha": "^0.2.0",
|
|
@@ -1,9 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghostii Auto Publisher — daily cron job.
|
|
3
|
+
*
|
|
4
|
+
* Generates and publishes blog posts using the Ghostii AI article engine.
|
|
5
|
+
* Iterates `config.ghostii[]` entries, each defining a source pool (generic
|
|
6
|
+
* topics, URLs, text prompts, or RSS/Atom feeds) and per-entry overrides for
|
|
7
|
+
* the Ghostii API params.
|
|
8
|
+
*
|
|
9
|
+
* Source types:
|
|
10
|
+
* '$app' — generic brand-topic generation
|
|
11
|
+
* '$feed:<url>' — RSS/Atom/JSON feed: pick one unprocessed article per run
|
|
12
|
+
* 'https://...' — fetch URL content as prompt seed
|
|
13
|
+
* '<text>' — use directly as prompt seed
|
|
14
|
+
*
|
|
15
|
+
* Feed items are tracked in Firestore (`ghostii-feed-items`) so the same
|
|
16
|
+
* article is never processed twice. When a feed is unreachable or exhausted,
|
|
17
|
+
* the entry falls back to $app behavior.
|
|
18
|
+
*/
|
|
19
|
+
const crypto = require('crypto');
|
|
1
20
|
const fetch = require('wonderful-fetch');
|
|
2
21
|
const powertools = require('node-powertools');
|
|
3
22
|
const moment = require('moment');
|
|
4
23
|
const JSON5 = require('json5');
|
|
5
24
|
|
|
6
25
|
const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
|
|
26
|
+
const { parseFeed, extractArticleContent } = require('../../../libraries/content/feed-parser.js');
|
|
7
27
|
|
|
8
28
|
const PROMPT = `
|
|
9
29
|
Company: {brand.brand.name}: {brand.brand.description}
|
|
@@ -14,7 +34,18 @@ const PROMPT = `
|
|
|
14
34
|
{suggestion}
|
|
15
35
|
`;
|
|
16
36
|
|
|
37
|
+
const PROMPT_SOURCE = `
|
|
38
|
+
Company: {brand.brand.name}: {brand.brand.description}
|
|
39
|
+
Date: {date}
|
|
40
|
+
Instructions: {prompt}
|
|
41
|
+
|
|
42
|
+
Write an original article inspired by and referencing this source material.
|
|
43
|
+
Do NOT copy the source — use it as context and inspiration for a unique take.
|
|
44
|
+
Source title: {sourceTitle}
|
|
45
|
+
`;
|
|
46
|
+
|
|
17
47
|
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';
|
|
48
|
+
const FEED_PREFIX = '$feed:';
|
|
18
49
|
|
|
19
50
|
// State
|
|
20
51
|
let postId;
|
|
@@ -40,6 +71,9 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
40
71
|
// Get settings
|
|
41
72
|
const settingsArray = powertools.arrayify(Manager.config.ghostii);
|
|
42
73
|
|
|
74
|
+
// Get admin for Firestore tracking
|
|
75
|
+
const { admin } = libraries;
|
|
76
|
+
|
|
43
77
|
// Loop through each item
|
|
44
78
|
for (const settings of settingsArray) {
|
|
45
79
|
// Fix settings
|
|
@@ -49,6 +83,8 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
49
83
|
settings.prompt = settings.prompt || '';
|
|
50
84
|
settings.chance = settings.chance || 1.0;
|
|
51
85
|
settings.author = settings.author || undefined;
|
|
86
|
+
settings.postPath = settings.postPath || 'ghostii';
|
|
87
|
+
settings.overrides = settings.overrides || {};
|
|
52
88
|
|
|
53
89
|
// Resolve brand data for this ghostii item
|
|
54
90
|
if (settings.brand && settings.brandUrl) {
|
|
@@ -81,7 +117,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
81
117
|
}
|
|
82
118
|
|
|
83
119
|
// Harvest articles
|
|
84
|
-
const result = await harvest(assistant, settings).catch((e) => e);
|
|
120
|
+
const result = await harvest(assistant, settings, admin).catch((e) => e);
|
|
85
121
|
if (result instanceof Error) {
|
|
86
122
|
throw result;
|
|
87
123
|
}
|
|
@@ -111,7 +147,7 @@ function fetchRemoteBrand(brandUrl) {
|
|
|
111
147
|
});
|
|
112
148
|
}
|
|
113
149
|
|
|
114
|
-
async function harvest(assistant, settings) {
|
|
150
|
+
async function harvest(assistant, settings, admin) {
|
|
115
151
|
const date = moment().format('MMMM YYYY');
|
|
116
152
|
|
|
117
153
|
// Log
|
|
@@ -120,43 +156,27 @@ async function harvest(assistant, settings) {
|
|
|
120
156
|
// Process the number of sources in the settings
|
|
121
157
|
for (let index = 0; index < settings.articles; index++) {
|
|
122
158
|
const source = powertools.random(settings.sources);
|
|
123
|
-
const sourceIsURL = isURL(source);
|
|
124
|
-
let suggestion = null;
|
|
125
159
|
|
|
126
160
|
// Log
|
|
127
|
-
assistant.log(`harvest(): Processing ${index + 1}/${settings.articles}
|
|
128
|
-
|
|
129
|
-
// Get suggestion
|
|
130
|
-
if (source === '$app') {
|
|
131
|
-
suggestion = 'Write an article about any topic that would be relevant to our website and business (it does not have to be about our company, but it can be)';
|
|
132
|
-
} else if (sourceIsURL) {
|
|
133
|
-
suggestion = await getURLContent(source).catch((e) => e);
|
|
134
|
-
} else {
|
|
135
|
-
suggestion = source;
|
|
136
|
-
}
|
|
161
|
+
assistant.log(`harvest(): Processing ${index + 1}/${settings.articles}`, source);
|
|
137
162
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
163
|
+
// Resolve the source into a prompt description + optional sourceContent
|
|
164
|
+
const resolved = await resolveSource(assistant, source, settings, admin).catch((e) => e);
|
|
165
|
+
if (resolved instanceof Error) {
|
|
166
|
+
assistant.error('harvest(): Error resolving source', resolved);
|
|
141
167
|
break;
|
|
142
168
|
}
|
|
143
169
|
|
|
144
|
-
// Set suggestion
|
|
145
|
-
const final = powertools.template(PROMPT, {
|
|
146
|
-
...settings,
|
|
147
|
-
prompt: settings.prompt,
|
|
148
|
-
date: date,
|
|
149
|
-
suggestion: suggestion,
|
|
150
|
-
});
|
|
151
|
-
|
|
152
170
|
// Log
|
|
153
|
-
assistant.log('harvest():
|
|
171
|
+
assistant.log('harvest(): Resolved source', resolved);
|
|
154
172
|
|
|
155
173
|
// Request to Ghostii
|
|
156
174
|
const article = await writeArticle({
|
|
157
175
|
brand: settings.brand,
|
|
158
|
-
description:
|
|
176
|
+
description: resolved.description,
|
|
159
177
|
links: settings.links,
|
|
178
|
+
sourceContent: resolved.sourceContent || '',
|
|
179
|
+
overrides: settings.overrides,
|
|
160
180
|
}).catch((e) => e);
|
|
161
181
|
if (article instanceof Error) {
|
|
162
182
|
assistant.error('harvest(): Error requesting Ghostii', article);
|
|
@@ -172,7 +192,7 @@ async function harvest(assistant, settings) {
|
|
|
172
192
|
article,
|
|
173
193
|
id: postId++,
|
|
174
194
|
author: settings.author,
|
|
175
|
-
postPath:
|
|
195
|
+
postPath: settings.postPath,
|
|
176
196
|
}).catch((e) => e);
|
|
177
197
|
if (uploadedPost instanceof Error) {
|
|
178
198
|
assistant.error('harvest(): Error uploading post to blog', uploadedPost);
|
|
@@ -181,7 +201,239 @@ async function harvest(assistant, settings) {
|
|
|
181
201
|
|
|
182
202
|
// Log
|
|
183
203
|
assistant.log('harvest(): Uploaded post', uploadedPost);
|
|
204
|
+
|
|
205
|
+
// Track feed item in Firestore (if this was a feed source)
|
|
206
|
+
if (resolved.feedItem && admin) {
|
|
207
|
+
await trackFeedItem(admin, {
|
|
208
|
+
feedUrl: resolved.feedUrl,
|
|
209
|
+
item: resolved.feedItem,
|
|
210
|
+
brandId: settings.brand.brand.id,
|
|
211
|
+
postUrl: uploadedPost.url,
|
|
212
|
+
postSlug: uploadedPost.slug,
|
|
213
|
+
}).catch((e) => {
|
|
214
|
+
assistant.error('harvest(): Error tracking feed item (non-fatal)', e);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a source entry into { description, sourceContent, feedItem?, feedUrl? }.
|
|
222
|
+
*
|
|
223
|
+
* Source types:
|
|
224
|
+
* '$app' — generic topic prompt, no sourceContent
|
|
225
|
+
* '$feed:<url>' — RSS/Atom feed: pick unprocessed article, extract content
|
|
226
|
+
* URL — fetch page content as prompt seed
|
|
227
|
+
* text — use directly as prompt seed
|
|
228
|
+
*
|
|
229
|
+
* Feed failures fall back to $app behavior.
|
|
230
|
+
*/
|
|
231
|
+
async function resolveSource(assistant, source, settings, admin) {
|
|
232
|
+
const date = moment().format('MMMM YYYY');
|
|
233
|
+
|
|
234
|
+
// --- $feed: source ---
|
|
235
|
+
if (typeof source === 'string' && source.startsWith(FEED_PREFIX)) {
|
|
236
|
+
const feedUrl = source.slice(FEED_PREFIX.length);
|
|
237
|
+
assistant.log(`resolveSource(): Processing feed: ${feedUrl}`);
|
|
238
|
+
|
|
239
|
+
const feedResult = await processFeedSource(assistant, feedUrl, settings.brand.brand.id, admin).catch((e) => e);
|
|
240
|
+
|
|
241
|
+
if (feedResult instanceof Error || !feedResult) {
|
|
242
|
+
assistant.log('resolveSource(): Feed failed or exhausted, falling back to $app');
|
|
243
|
+
return resolveSource(assistant, '$app', settings, admin);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const description = powertools.template(PROMPT_SOURCE, {
|
|
247
|
+
...settings,
|
|
248
|
+
prompt: settings.prompt,
|
|
249
|
+
date: date,
|
|
250
|
+
sourceTitle: feedResult.item.title,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
description,
|
|
255
|
+
sourceContent: feedResult.content || feedResult.item.summary || '',
|
|
256
|
+
feedItem: feedResult.item,
|
|
257
|
+
feedUrl: feedUrl,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- $app source ---
|
|
262
|
+
if (source === '$app') {
|
|
263
|
+
const suggestion = 'Write an article about any topic that would be relevant to our website and business (it does not have to be about our company, but it can be)';
|
|
264
|
+
const description = powertools.template(PROMPT, {
|
|
265
|
+
...settings,
|
|
266
|
+
prompt: settings.prompt,
|
|
267
|
+
date: date,
|
|
268
|
+
suggestion: suggestion,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return { description, sourceContent: '' };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- URL source ---
|
|
275
|
+
if (isURL(source)) {
|
|
276
|
+
const suggestion = await getURLContent(source).catch((e) => e);
|
|
277
|
+
|
|
278
|
+
if (suggestion instanceof Error) {
|
|
279
|
+
assistant.error(`resolveSource(): Error fetching URL ${source}`, suggestion);
|
|
280
|
+
return resolveSource(assistant, '$app', settings, admin);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const description = powertools.template(PROMPT, {
|
|
284
|
+
...settings,
|
|
285
|
+
prompt: settings.prompt,
|
|
286
|
+
date: date,
|
|
287
|
+
suggestion: suggestion,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return { description, sourceContent: '' };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Text source ---
|
|
294
|
+
const description = powertools.template(PROMPT, {
|
|
295
|
+
...settings,
|
|
296
|
+
prompt: settings.prompt,
|
|
297
|
+
date: date,
|
|
298
|
+
suggestion: source,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return { description, sourceContent: '' };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Process a $feed: source — fetch, parse, select unprocessed item, extract content.
|
|
306
|
+
*
|
|
307
|
+
* @param {object} assistant - BEM assistant
|
|
308
|
+
* @param {string} feedUrl - RSS/Atom/JSON feed URL
|
|
309
|
+
* @param {string} brandId - Brand ID for tracking
|
|
310
|
+
* @param {object} admin - Firebase Admin SDK
|
|
311
|
+
* @returns {Promise<{ item: FeedItem, content: string }|null>} Selected item + extracted content, or null
|
|
312
|
+
*/
|
|
313
|
+
async function processFeedSource(assistant, feedUrl, brandId, admin) {
|
|
314
|
+
// Fetch the feed
|
|
315
|
+
const feedText = await fetch(feedUrl, {
|
|
316
|
+
timeout: 30000,
|
|
317
|
+
tries: 2,
|
|
318
|
+
response: 'text',
|
|
319
|
+
headers: { 'User-Agent': USER_AGENT },
|
|
320
|
+
}).catch((e) => e);
|
|
321
|
+
|
|
322
|
+
if (feedText instanceof Error) {
|
|
323
|
+
assistant.error(`processFeedSource(): Failed to fetch feed: ${feedUrl}`, feedText);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Parse the feed
|
|
328
|
+
const { items } = parseFeed(feedText);
|
|
329
|
+
if (!items.length) {
|
|
330
|
+
assistant.log(`processFeedSource(): No items in feed: ${feedUrl}`);
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
assistant.log(`processFeedSource(): Parsed ${items.length} items from feed`);
|
|
335
|
+
|
|
336
|
+
// Get already-processed item IDs from Firestore
|
|
337
|
+
const processedIds = await getProcessedItemIds(admin, feedUrl).catch((e) => {
|
|
338
|
+
assistant.error('processFeedSource(): Error querying tracked items (continuing)', e);
|
|
339
|
+
return new Set();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Filter to unprocessed items
|
|
343
|
+
const unprocessed = items.filter((item) => !processedIds.has(item.id) && !processedIds.has(item.url));
|
|
344
|
+
if (!unprocessed.length) {
|
|
345
|
+
assistant.log(`processFeedSource(): All ${items.length} items already processed for: ${feedUrl}`);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
assistant.log(`processFeedSource(): ${unprocessed.length} unprocessed items available`);
|
|
350
|
+
|
|
351
|
+
// Pick the first (newest) unprocessed item
|
|
352
|
+
const item = unprocessed[0];
|
|
353
|
+
|
|
354
|
+
// Extract full article content from the item URL
|
|
355
|
+
let content = '';
|
|
356
|
+
if (item.url) {
|
|
357
|
+
content = await extractArticleContent(item.url).catch((e) => {
|
|
358
|
+
assistant.error(`processFeedSource(): Failed to extract content from ${item.url} (using summary)`, e);
|
|
359
|
+
return '';
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Fall back to inline feed content or summary
|
|
364
|
+
if (!content || content.length < 100) {
|
|
365
|
+
content = item.content || item.summary || '';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return { item, content };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Query Firestore for already-processed feed item IDs.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} admin - Firebase Admin SDK
|
|
375
|
+
* @param {string} feedUrl - The feed URL to query
|
|
376
|
+
* @returns {Promise<Set<string>>} Set of processed item IDs
|
|
377
|
+
*/
|
|
378
|
+
async function getProcessedItemIds(admin, feedUrl) {
|
|
379
|
+
if (!admin) {
|
|
380
|
+
return new Set();
|
|
184
381
|
}
|
|
382
|
+
|
|
383
|
+
const snapshot = await admin.firestore()
|
|
384
|
+
.collection('ghostii-feed-items')
|
|
385
|
+
.where('feedUrl', '==', feedUrl)
|
|
386
|
+
.select('itemId', 'itemUrl')
|
|
387
|
+
.get();
|
|
388
|
+
|
|
389
|
+
const ids = new Set();
|
|
390
|
+
snapshot.docs.forEach((doc) => {
|
|
391
|
+
const data = doc.data();
|
|
392
|
+
if (data.itemId) {
|
|
393
|
+
ids.add(data.itemId);
|
|
394
|
+
}
|
|
395
|
+
if (data.itemUrl) {
|
|
396
|
+
ids.add(data.itemUrl);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return ids;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Write a tracking doc for a processed feed item.
|
|
405
|
+
*
|
|
406
|
+
* @param {object} admin - Firebase Admin SDK
|
|
407
|
+
* @param {object} args
|
|
408
|
+
* @param {string} args.feedUrl - The source feed URL
|
|
409
|
+
* @param {object} args.item - The processed feed item
|
|
410
|
+
* @param {string} args.brandId - Brand that processed it
|
|
411
|
+
* @param {string} args.postUrl - Published blog post URL
|
|
412
|
+
* @param {string} args.postSlug - Published blog post slug
|
|
413
|
+
*/
|
|
414
|
+
async function trackFeedItem(admin, { feedUrl, item, brandId, postUrl, postSlug }) {
|
|
415
|
+
const docId = feedItemHash(feedUrl, item.id || item.url);
|
|
416
|
+
|
|
417
|
+
await admin.firestore().doc(`ghostii-feed-items/${docId}`).set({
|
|
418
|
+
feedUrl: feedUrl,
|
|
419
|
+
itemId: item.id || item.url,
|
|
420
|
+
itemUrl: item.url,
|
|
421
|
+
itemTitle: item.title,
|
|
422
|
+
processedAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
423
|
+
brandId: brandId,
|
|
424
|
+
postUrl: postUrl || null,
|
|
425
|
+
postSlug: postSlug || null,
|
|
426
|
+
metadata: {
|
|
427
|
+
created: admin.firestore.FieldValue.serverTimestamp(),
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Deterministic hash for a feed item — used as the Firestore doc ID.
|
|
434
|
+
*/
|
|
435
|
+
function feedItemHash(feedUrl, itemId) {
|
|
436
|
+
return crypto.createHash('sha256').update(`${feedUrl}::${itemId}`).digest('hex').slice(0, 20);
|
|
185
437
|
}
|
|
186
438
|
|
|
187
439
|
function getURLContent(url) {
|
|
@@ -250,3 +502,10 @@ function tryParse(json) {
|
|
|
250
502
|
function randomize(array) {
|
|
251
503
|
return array.sort(() => Math.random() - 0.5);
|
|
252
504
|
}
|
|
505
|
+
|
|
506
|
+
module.exports.resolveSource = resolveSource;
|
|
507
|
+
module.exports.processFeedSource = processFeedSource;
|
|
508
|
+
module.exports.feedItemHash = feedItemHash;
|
|
509
|
+
module.exports.getProcessedItemIds = getProcessedItemIds;
|
|
510
|
+
module.exports.trackFeedItem = trackFeedItem;
|
|
511
|
+
module.exports.isURL = isURL;
|