@rmdes/indiekit-endpoint-rss 1.0.0
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/index.js +108 -0
- package/lib/controllers/dashboard.js +106 -0
- package/lib/controllers/feeds.js +198 -0
- package/lib/controllers/items.js +95 -0
- package/lib/controllers/status.js +90 -0
- package/lib/rss-client.js +408 -0
- package/lib/sync.js +299 -0
- package/lib/utils.js +204 -0
- package/locales/en.json +50 -0
- package/package.json +54 -0
- package/views/rss.njk +498 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import Parser from "rss-parser";
|
|
2
|
+
import { IndiekitError } from "@indiekit/error";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TIMEOUT = 10_000;
|
|
5
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
6
|
+
|
|
7
|
+
export class RssClient {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
10
|
+
this.maxRedirects = options.maxRedirects || DEFAULT_MAX_REDIRECTS;
|
|
11
|
+
this.parser = new Parser({
|
|
12
|
+
timeout: this.timeout,
|
|
13
|
+
maxRedirects: this.maxRedirects,
|
|
14
|
+
headers: {
|
|
15
|
+
"User-Agent": "Indiekit-RSS-Reader/1.0 (+https://getindiekit.com)",
|
|
16
|
+
Accept:
|
|
17
|
+
"application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
|
|
18
|
+
},
|
|
19
|
+
customFields: {
|
|
20
|
+
feed: ["image", "icon", "logo"],
|
|
21
|
+
item: [
|
|
22
|
+
["media:content", "media"],
|
|
23
|
+
["media:thumbnail", "mediaThumbnail"],
|
|
24
|
+
["enclosure", "enclosure"],
|
|
25
|
+
["dc:creator", "creator"],
|
|
26
|
+
["content:encoded", "contentEncoded"],
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Fetch and parse an RSS/Atom/JSON feed
|
|
34
|
+
* @param {string} url - Feed URL
|
|
35
|
+
* @returns {Promise<{feed: Object, items: Array}>}
|
|
36
|
+
*/
|
|
37
|
+
async fetchFeed(url) {
|
|
38
|
+
try {
|
|
39
|
+
// First, fetch the content to detect format
|
|
40
|
+
const response = await fetch(url, {
|
|
41
|
+
headers: {
|
|
42
|
+
"User-Agent": "Indiekit-RSS-Reader/1.0 (+https://getindiekit.com)",
|
|
43
|
+
Accept:
|
|
44
|
+
"application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
|
|
45
|
+
},
|
|
46
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const contentType = response.headers.get("content-type") || "";
|
|
54
|
+
const text = await response.text();
|
|
55
|
+
|
|
56
|
+
// Detect JSON feed (JSON Feed format or Google Reader API JSON)
|
|
57
|
+
if (
|
|
58
|
+
contentType.includes("application/json") ||
|
|
59
|
+
contentType.includes("application/feed+json") ||
|
|
60
|
+
url.includes("f=greader") ||
|
|
61
|
+
url.includes("f=json")
|
|
62
|
+
) {
|
|
63
|
+
try {
|
|
64
|
+
const json = JSON.parse(text);
|
|
65
|
+
return this.parseJsonFeed(json, url);
|
|
66
|
+
} catch {
|
|
67
|
+
// Fall through to RSS parser if JSON parse fails
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Use rss-parser for RSS/Atom
|
|
72
|
+
const parsed = await this.parser.parseString(text);
|
|
73
|
+
return {
|
|
74
|
+
feed: this.extractFeedMeta(parsed, url),
|
|
75
|
+
items: this.transformItems(parsed.items || [], url),
|
|
76
|
+
};
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new IndiekitError(`Failed to fetch feed: ${error.message}`, {
|
|
79
|
+
status: 502,
|
|
80
|
+
cause: error,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse JSON feed (JSON Feed spec or Google Reader API format)
|
|
87
|
+
* @param {Object} json - Parsed JSON
|
|
88
|
+
* @param {string} feedUrl - Original feed URL
|
|
89
|
+
* @returns {{feed: Object, items: Array}}
|
|
90
|
+
*/
|
|
91
|
+
parseJsonFeed(json, feedUrl) {
|
|
92
|
+
// Google Reader API format (items array at root level)
|
|
93
|
+
if (json.items && !json.version) {
|
|
94
|
+
return this.parseGoogleReaderJson(json, feedUrl);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// JSON Feed spec (https://jsonfeed.org/version/1.1)
|
|
98
|
+
if (json.version?.startsWith("https://jsonfeed.org/")) {
|
|
99
|
+
return this.parseJsonFeedSpec(json, feedUrl);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Generic JSON with items array
|
|
103
|
+
if (Array.isArray(json.items)) {
|
|
104
|
+
return this.parseGoogleReaderJson(json, feedUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error("Unknown JSON feed format");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse Google Reader API / FreshRSS JSON format
|
|
112
|
+
* @param {Object} json - Google Reader JSON
|
|
113
|
+
* @param {string} feedUrl - Feed URL
|
|
114
|
+
* @returns {{feed: Object, items: Array}}
|
|
115
|
+
*/
|
|
116
|
+
parseGoogleReaderJson(json, feedUrl) {
|
|
117
|
+
const feed = {
|
|
118
|
+
title: json.title || "RSS Feed",
|
|
119
|
+
description: json.description || "",
|
|
120
|
+
siteUrl: json.link || json.alternate?.[0]?.href || this.extractBaseUrl(feedUrl),
|
|
121
|
+
feedUrl: feedUrl,
|
|
122
|
+
imageUrl: null,
|
|
123
|
+
language: null,
|
|
124
|
+
lastBuildDate: null,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const items = (json.items || []).map((item) => {
|
|
128
|
+
// Parse timestamp - FreshRSS uses `published` (Unix seconds) or `timestampUsec` (microseconds)
|
|
129
|
+
let pubDate = null;
|
|
130
|
+
if (item.published) {
|
|
131
|
+
// Unix timestamp in seconds
|
|
132
|
+
pubDate = new Date(item.published * 1000);
|
|
133
|
+
} else if (item.timestampUsec) {
|
|
134
|
+
// Microseconds timestamp
|
|
135
|
+
pubDate = new Date(parseInt(item.timestampUsec) / 1000);
|
|
136
|
+
} else if (item.crawlTimeMsec) {
|
|
137
|
+
// Milliseconds timestamp
|
|
138
|
+
pubDate = new Date(parseInt(item.crawlTimeMsec));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Extract content from various FreshRSS structures
|
|
142
|
+
const content = item.content?.content ||
|
|
143
|
+
(typeof item.content === "string" ? item.content : "") ||
|
|
144
|
+
item.summary?.content ||
|
|
145
|
+
(typeof item.summary === "string" ? item.summary : "") ||
|
|
146
|
+
"";
|
|
147
|
+
|
|
148
|
+
// Extract link from Google Reader format
|
|
149
|
+
const link = item.canonical?.[0]?.href ||
|
|
150
|
+
item.alternate?.[0]?.href ||
|
|
151
|
+
item.link ||
|
|
152
|
+
null;
|
|
153
|
+
|
|
154
|
+
// Filter out internal FreshRSS categories, keep only user tags
|
|
155
|
+
const categories = (item.categories || []).filter(cat =>
|
|
156
|
+
!cat.includes("state/com.google/") &&
|
|
157
|
+
!cat.includes("state/org.freshrss/") &&
|
|
158
|
+
!cat.startsWith("user/")
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
guid: item["frss:id"] || item.id || item.guid || link,
|
|
163
|
+
title: item.title || "Untitled",
|
|
164
|
+
link,
|
|
165
|
+
description: item.summary?.content ||
|
|
166
|
+
(typeof item.summary === "string" ? item.summary : "") ||
|
|
167
|
+
"",
|
|
168
|
+
content,
|
|
169
|
+
author: item.author || item.origin?.title || null,
|
|
170
|
+
pubDate,
|
|
171
|
+
imageUrl: this.extractJsonItemImage(item),
|
|
172
|
+
categories,
|
|
173
|
+
enclosure: item.enclosure || null,
|
|
174
|
+
// Preserve FreshRSS-specific metadata
|
|
175
|
+
origin: item.origin ? {
|
|
176
|
+
streamId: item.origin.streamId,
|
|
177
|
+
title: item.origin.title,
|
|
178
|
+
htmlUrl: item.origin.htmlUrl,
|
|
179
|
+
feedUrl: item.origin.feedUrl,
|
|
180
|
+
} : null,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return { feed, items };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse JSON Feed spec format
|
|
189
|
+
* @param {Object} json - JSON Feed object
|
|
190
|
+
* @param {string} feedUrl - Feed URL
|
|
191
|
+
* @returns {{feed: Object, items: Array}}
|
|
192
|
+
*/
|
|
193
|
+
parseJsonFeedSpec(json, feedUrl) {
|
|
194
|
+
const feed = {
|
|
195
|
+
title: json.title || "Untitled Feed",
|
|
196
|
+
description: json.description || "",
|
|
197
|
+
siteUrl: json.home_page_url || this.extractBaseUrl(feedUrl),
|
|
198
|
+
feedUrl: json.feed_url || feedUrl,
|
|
199
|
+
imageUrl: json.icon || json.favicon || null,
|
|
200
|
+
language: json.language || null,
|
|
201
|
+
lastBuildDate: null,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const items = (json.items || []).map((item) => ({
|
|
205
|
+
guid: item.id || item.url,
|
|
206
|
+
title: item.title || "Untitled",
|
|
207
|
+
link: item.url || item.external_url || null,
|
|
208
|
+
description: item.summary || "",
|
|
209
|
+
content: item.content_html || item.content_text || item.summary || "",
|
|
210
|
+
author: item.authors?.[0]?.name || item.author?.name || null,
|
|
211
|
+
pubDate: this.parseDate(item.date_published || item.date_modified),
|
|
212
|
+
imageUrl: item.image || item.banner_image || null,
|
|
213
|
+
categories: item.tags || [],
|
|
214
|
+
enclosure: item.attachments?.[0]
|
|
215
|
+
? {
|
|
216
|
+
url: item.attachments[0].url,
|
|
217
|
+
type: item.attachments[0].mime_type,
|
|
218
|
+
length: item.attachments[0].size_in_bytes,
|
|
219
|
+
}
|
|
220
|
+
: null,
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
return { feed, items };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Extract image from JSON feed item
|
|
228
|
+
* @param {Object} item - JSON item
|
|
229
|
+
* @returns {string|null}
|
|
230
|
+
*/
|
|
231
|
+
extractJsonItemImage(item) {
|
|
232
|
+
// Direct image property
|
|
233
|
+
if (item.image) return item.image;
|
|
234
|
+
|
|
235
|
+
// Media content
|
|
236
|
+
if (item.media?.$?.url) return item.media.$.url;
|
|
237
|
+
|
|
238
|
+
// Enclosure
|
|
239
|
+
if (item.enclosure?.href && this.isImageUrl(item.enclosure.href)) {
|
|
240
|
+
return item.enclosure.href;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Extract from content
|
|
244
|
+
const content = item.content?.content || item.content || item.summary?.content || "";
|
|
245
|
+
if (typeof content === "string") {
|
|
246
|
+
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
247
|
+
if (imgMatch) return imgMatch[1];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Extract feed metadata
|
|
255
|
+
* @param {Object} parsed - Parsed feed object
|
|
256
|
+
* @param {string} feedUrl - Original feed URL
|
|
257
|
+
* @returns {Object}
|
|
258
|
+
*/
|
|
259
|
+
extractFeedMeta(parsed, feedUrl) {
|
|
260
|
+
return {
|
|
261
|
+
title: parsed.title || "Untitled Feed",
|
|
262
|
+
description: parsed.description || "",
|
|
263
|
+
siteUrl: parsed.link || this.extractBaseUrl(feedUrl),
|
|
264
|
+
feedUrl: feedUrl,
|
|
265
|
+
imageUrl: this.extractFeedImage(parsed),
|
|
266
|
+
language: parsed.language || null,
|
|
267
|
+
lastBuildDate: parsed.lastBuildDate
|
|
268
|
+
? new Date(parsed.lastBuildDate)
|
|
269
|
+
: null,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Extract feed image from various formats
|
|
275
|
+
* @param {Object} parsed - Parsed feed
|
|
276
|
+
* @returns {string|null}
|
|
277
|
+
*/
|
|
278
|
+
extractFeedImage(parsed) {
|
|
279
|
+
// RSS 2.0 image
|
|
280
|
+
if (parsed.image?.url) return parsed.image.url;
|
|
281
|
+
// Atom icon
|
|
282
|
+
if (parsed.icon) return parsed.icon;
|
|
283
|
+
// Atom logo
|
|
284
|
+
if (parsed.logo) return parsed.logo;
|
|
285
|
+
// itunes:image
|
|
286
|
+
if (parsed.itunes?.image) return parsed.itunes.image;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Transform feed items to normalized format
|
|
292
|
+
* @param {Array} items - Raw feed items
|
|
293
|
+
* @param {string} feedUrl - Feed URL for context
|
|
294
|
+
* @returns {Array}
|
|
295
|
+
*/
|
|
296
|
+
transformItems(items, feedUrl) {
|
|
297
|
+
return items.map((item) => this.transformItem(item, feedUrl));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Transform a single feed item
|
|
302
|
+
* @param {Object} item - Raw feed item
|
|
303
|
+
* @param {string} feedUrl - Feed URL
|
|
304
|
+
* @returns {Object}
|
|
305
|
+
*/
|
|
306
|
+
transformItem(item, feedUrl) {
|
|
307
|
+
const pubDate = this.parseDate(item.pubDate || item.isoDate);
|
|
308
|
+
return {
|
|
309
|
+
guid: item.guid || item.id || item.link || `${feedUrl}#${pubDate?.getTime()}`,
|
|
310
|
+
title: item.title || "Untitled",
|
|
311
|
+
link: item.link || null,
|
|
312
|
+
description: item.contentSnippet || item.summary || "",
|
|
313
|
+
content: item.contentEncoded || item.content || item.summary || "",
|
|
314
|
+
author: item.creator || item.author || item["dc:creator"] || null,
|
|
315
|
+
pubDate: pubDate,
|
|
316
|
+
imageUrl: this.extractItemImage(item),
|
|
317
|
+
categories: this.extractCategories(item),
|
|
318
|
+
enclosure: this.extractEnclosure(item),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Extract image URL from item
|
|
324
|
+
* @param {Object} item - Feed item
|
|
325
|
+
* @returns {string|null}
|
|
326
|
+
*/
|
|
327
|
+
extractItemImage(item) {
|
|
328
|
+
// Media RSS
|
|
329
|
+
if (item.media?.["$"]?.url) return item.media["$"].url;
|
|
330
|
+
if (item.mediaThumbnail?.["$"]?.url) return item.mediaThumbnail["$"].url;
|
|
331
|
+
|
|
332
|
+
// Enclosure image
|
|
333
|
+
if (item.enclosure?.url && this.isImageUrl(item.enclosure.url)) {
|
|
334
|
+
return item.enclosure.url;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Extract from content
|
|
338
|
+
const content = item.contentEncoded || item.content || "";
|
|
339
|
+
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
|
|
340
|
+
if (imgMatch) return imgMatch[1];
|
|
341
|
+
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check if URL is an image
|
|
347
|
+
* @param {string} url - URL to check
|
|
348
|
+
* @returns {boolean}
|
|
349
|
+
*/
|
|
350
|
+
isImageUrl(url) {
|
|
351
|
+
if (!url) return false;
|
|
352
|
+
const imageExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"];
|
|
353
|
+
const lowerUrl = url.toLowerCase();
|
|
354
|
+
return imageExtensions.some((ext) => lowerUrl.includes(ext));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Extract categories/tags from item
|
|
359
|
+
* @param {Object} item - Feed item
|
|
360
|
+
* @returns {Array<string>}
|
|
361
|
+
*/
|
|
362
|
+
extractCategories(item) {
|
|
363
|
+
if (!item.categories) return [];
|
|
364
|
+
return item.categories
|
|
365
|
+
.map((cat) => (typeof cat === "string" ? cat : cat.name || cat.term))
|
|
366
|
+
.filter(Boolean);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Extract enclosure (podcast/media attachment)
|
|
371
|
+
* @param {Object} item - Feed item
|
|
372
|
+
* @returns {Object|null}
|
|
373
|
+
*/
|
|
374
|
+
extractEnclosure(item) {
|
|
375
|
+
if (!item.enclosure) return null;
|
|
376
|
+
return {
|
|
377
|
+
url: item.enclosure.url,
|
|
378
|
+
type: item.enclosure.type || null,
|
|
379
|
+
length: item.enclosure.length ? parseInt(item.enclosure.length, 10) : null,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Parse date from various formats
|
|
385
|
+
* @param {string|Date} dateInput - Date input
|
|
386
|
+
* @returns {Date|null}
|
|
387
|
+
*/
|
|
388
|
+
parseDate(dateInput) {
|
|
389
|
+
if (!dateInput) return null;
|
|
390
|
+
if (dateInput instanceof Date) return dateInput;
|
|
391
|
+
const parsed = new Date(dateInput);
|
|
392
|
+
return isNaN(parsed.getTime()) ? null : parsed;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Extract base URL from feed URL
|
|
397
|
+
* @param {string} feedUrl - Feed URL
|
|
398
|
+
* @returns {string}
|
|
399
|
+
*/
|
|
400
|
+
extractBaseUrl(feedUrl) {
|
|
401
|
+
try {
|
|
402
|
+
const url = new URL(feedUrl);
|
|
403
|
+
return `${url.protocol}//${url.host}`;
|
|
404
|
+
} catch {
|
|
405
|
+
return feedUrl;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
package/lib/sync.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { RssClient } from "./rss-client.js";
|
|
2
|
+
|
|
3
|
+
let syncInterval = null;
|
|
4
|
+
let syncState = {
|
|
5
|
+
lastSync: null,
|
|
6
|
+
syncing: false,
|
|
7
|
+
lastError: null,
|
|
8
|
+
feedsProcessed: 0,
|
|
9
|
+
itemsAdded: 0,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get current sync state
|
|
14
|
+
* @returns {Object}
|
|
15
|
+
*/
|
|
16
|
+
export function getSyncState() {
|
|
17
|
+
return { ...syncState };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start background sync
|
|
22
|
+
* @param {Object} Indiekit - Indiekit instance
|
|
23
|
+
* @param {Object} options - Plugin options
|
|
24
|
+
*/
|
|
25
|
+
export function startSync(Indiekit, options) {
|
|
26
|
+
const intervalMs = options.syncInterval || 900_000; // 15 minutes default
|
|
27
|
+
|
|
28
|
+
console.log(
|
|
29
|
+
`[RSS] Starting background sync with ${intervalMs / 60_000}min interval`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Initial sync after delay
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
runSync(Indiekit, options).catch((err) => {
|
|
35
|
+
console.error("[RSS] Initial sync error:", err.message);
|
|
36
|
+
});
|
|
37
|
+
}, 10_000); // 10 second delay
|
|
38
|
+
|
|
39
|
+
// Schedule recurring sync
|
|
40
|
+
syncInterval = setInterval(() => {
|
|
41
|
+
runSync(Indiekit, options).catch((err) => {
|
|
42
|
+
console.error("[RSS] Sync error:", err.message);
|
|
43
|
+
});
|
|
44
|
+
}, intervalMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Stop background sync
|
|
49
|
+
*/
|
|
50
|
+
export function stopSync() {
|
|
51
|
+
if (syncInterval) {
|
|
52
|
+
clearInterval(syncInterval);
|
|
53
|
+
syncInterval = null;
|
|
54
|
+
console.log("[RSS] Background sync stopped");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run a single sync cycle
|
|
60
|
+
* @param {Object} Indiekit - Indiekit instance
|
|
61
|
+
* @param {Object} options - Plugin options
|
|
62
|
+
* @returns {Promise<Object>}
|
|
63
|
+
*/
|
|
64
|
+
export async function runSync(Indiekit, options) {
|
|
65
|
+
const db = Indiekit.database;
|
|
66
|
+
if (!db) {
|
|
67
|
+
syncState.lastError = "No database available";
|
|
68
|
+
return { error: syncState.lastError };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (syncState.syncing) {
|
|
72
|
+
return { error: "Sync already in progress" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
syncState.syncing = true;
|
|
76
|
+
syncState.lastError = null;
|
|
77
|
+
syncState.feedsProcessed = 0;
|
|
78
|
+
syncState.itemsAdded = 0;
|
|
79
|
+
|
|
80
|
+
const client = new RssClient({
|
|
81
|
+
timeout: options.fetchTimeout || 10_000,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const feedsCollection = db.collection("rssFeeds");
|
|
86
|
+
const itemsCollection = db.collection("rssItems");
|
|
87
|
+
|
|
88
|
+
// Create indexes if they don't exist
|
|
89
|
+
await createIndexes(feedsCollection, itemsCollection);
|
|
90
|
+
|
|
91
|
+
// Get all enabled feeds
|
|
92
|
+
const feeds = await feedsCollection.find({ enabled: true }).toArray();
|
|
93
|
+
|
|
94
|
+
if (feeds.length === 0) {
|
|
95
|
+
syncState.lastSync = new Date();
|
|
96
|
+
syncState.syncing = false;
|
|
97
|
+
return { feedsProcessed: 0, itemsAdded: 0 };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Process feeds with concurrency limit
|
|
101
|
+
const maxConcurrent = options.maxConcurrentFetches || 3;
|
|
102
|
+
const results = await processFeedsWithLimit(
|
|
103
|
+
feeds,
|
|
104
|
+
maxConcurrent,
|
|
105
|
+
async (feed) => {
|
|
106
|
+
return syncFeed(feed, feedsCollection, itemsCollection, client, options);
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Aggregate results
|
|
111
|
+
for (const result of results) {
|
|
112
|
+
if (result.itemsAdded) {
|
|
113
|
+
syncState.itemsAdded += result.itemsAdded;
|
|
114
|
+
}
|
|
115
|
+
syncState.feedsProcessed++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
syncState.lastSync = new Date();
|
|
119
|
+
syncState.syncing = false;
|
|
120
|
+
|
|
121
|
+
console.log(
|
|
122
|
+
`[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
feedsProcessed: syncState.feedsProcessed,
|
|
127
|
+
itemsAdded: syncState.itemsAdded,
|
|
128
|
+
};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
syncState.lastError = error.message;
|
|
131
|
+
syncState.syncing = false;
|
|
132
|
+
console.error("[RSS] Sync failed:", error.message);
|
|
133
|
+
return { error: error.message };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sync a single feed
|
|
139
|
+
* @param {Object} feed - Feed document
|
|
140
|
+
* @param {Collection} feedsCollection - Feeds collection
|
|
141
|
+
* @param {Collection} itemsCollection - Items collection
|
|
142
|
+
* @param {RssClient} client - RSS client
|
|
143
|
+
* @param {Object} options - Plugin options
|
|
144
|
+
* @returns {Promise<Object>}
|
|
145
|
+
*/
|
|
146
|
+
async function syncFeed(
|
|
147
|
+
feed,
|
|
148
|
+
feedsCollection,
|
|
149
|
+
itemsCollection,
|
|
150
|
+
client,
|
|
151
|
+
options
|
|
152
|
+
) {
|
|
153
|
+
const maxItemsPerFeed = options.maxItemsPerFeed || 50;
|
|
154
|
+
let itemsAdded = 0;
|
|
155
|
+
let lastError = null;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const { feed: feedMeta, items } = await client.fetchFeed(feed.url);
|
|
159
|
+
|
|
160
|
+
// Update feed metadata
|
|
161
|
+
await feedsCollection.updateOne(
|
|
162
|
+
{ _id: feed._id },
|
|
163
|
+
{
|
|
164
|
+
$set: {
|
|
165
|
+
title: feedMeta.title,
|
|
166
|
+
siteUrl: feedMeta.siteUrl,
|
|
167
|
+
description: feedMeta.description,
|
|
168
|
+
imageUrl: feedMeta.imageUrl,
|
|
169
|
+
lastFetchedAt: new Date(),
|
|
170
|
+
lastError: null,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Insert new items
|
|
176
|
+
const recentItems = items.slice(0, maxItemsPerFeed);
|
|
177
|
+
for (const item of recentItems) {
|
|
178
|
+
try {
|
|
179
|
+
const result = await itemsCollection.updateOne(
|
|
180
|
+
{
|
|
181
|
+
feedId: feed._id,
|
|
182
|
+
guid: item.guid,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
$setOnInsert: {
|
|
186
|
+
feedId: feed._id,
|
|
187
|
+
feedTitle: feedMeta.title,
|
|
188
|
+
...item,
|
|
189
|
+
fetchedAt: new Date(),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{ upsert: true }
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (result.upsertedCount > 0) {
|
|
196
|
+
itemsAdded++;
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Ignore duplicate key errors
|
|
200
|
+
if (err.code !== 11000) {
|
|
201
|
+
console.error(`[RSS] Error inserting item: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Update item count
|
|
207
|
+
const itemCount = await itemsCollection.countDocuments({ feedId: feed._id });
|
|
208
|
+
await feedsCollection.updateOne(
|
|
209
|
+
{ _id: feed._id },
|
|
210
|
+
{ $set: { itemCount } }
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return { feedId: feed._id, itemsAdded };
|
|
214
|
+
} catch (error) {
|
|
215
|
+
lastError = error.message;
|
|
216
|
+
console.error(`[RSS] Error syncing ${feed.url}: ${lastError}`);
|
|
217
|
+
|
|
218
|
+
// Update feed with error
|
|
219
|
+
await feedsCollection.updateOne(
|
|
220
|
+
{ _id: feed._id },
|
|
221
|
+
{
|
|
222
|
+
$set: {
|
|
223
|
+
lastFetchedAt: new Date(),
|
|
224
|
+
lastError: lastError,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
return { feedId: feed._id, itemsAdded: 0, error: lastError };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Create indexes for collections
|
|
235
|
+
* @param {Collection} feedsCollection
|
|
236
|
+
* @param {Collection} itemsCollection
|
|
237
|
+
*/
|
|
238
|
+
async function createIndexes(feedsCollection, itemsCollection) {
|
|
239
|
+
// Feeds indexes
|
|
240
|
+
await feedsCollection.createIndex({ url: 1 }, { unique: true });
|
|
241
|
+
await feedsCollection.createIndex({ enabled: 1 });
|
|
242
|
+
|
|
243
|
+
// Items indexes
|
|
244
|
+
await itemsCollection.createIndex({ feedId: 1, guid: 1 }, { unique: true });
|
|
245
|
+
await itemsCollection.createIndex({ feedId: 1 });
|
|
246
|
+
await itemsCollection.createIndex({ pubDate: -1 });
|
|
247
|
+
await itemsCollection.createIndex({ fetchedAt: -1 });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Process feeds with concurrency limit
|
|
252
|
+
* @param {Array} feeds - Array of feeds
|
|
253
|
+
* @param {number} limit - Concurrency limit
|
|
254
|
+
* @param {Function} processor - Async function to process each feed
|
|
255
|
+
* @returns {Promise<Array>}
|
|
256
|
+
*/
|
|
257
|
+
async function processFeedsWithLimit(feeds, limit, processor) {
|
|
258
|
+
const results = [];
|
|
259
|
+
const executing = [];
|
|
260
|
+
|
|
261
|
+
for (const feed of feeds) {
|
|
262
|
+
const promise = processor(feed).then((result) => {
|
|
263
|
+
executing.splice(executing.indexOf(promise), 1);
|
|
264
|
+
return result;
|
|
265
|
+
});
|
|
266
|
+
results.push(promise);
|
|
267
|
+
executing.push(promise);
|
|
268
|
+
|
|
269
|
+
if (executing.length >= limit) {
|
|
270
|
+
await Promise.race(executing);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return Promise.all(results);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Sync a single feed by ID (for manual refresh)
|
|
279
|
+
* @param {Object} db - Database instance
|
|
280
|
+
* @param {string} feedId - Feed ID
|
|
281
|
+
* @param {Object} options - Plugin options
|
|
282
|
+
* @returns {Promise<Object>}
|
|
283
|
+
*/
|
|
284
|
+
export async function syncSingleFeed(db, feedId, options) {
|
|
285
|
+
const { ObjectId } = await import("mongodb");
|
|
286
|
+
const feedsCollection = db.collection("rssFeeds");
|
|
287
|
+
const itemsCollection = db.collection("rssItems");
|
|
288
|
+
|
|
289
|
+
const feed = await feedsCollection.findOne({ _id: new ObjectId(feedId) });
|
|
290
|
+
if (!feed) {
|
|
291
|
+
return { error: "Feed not found" };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const client = new RssClient({
|
|
295
|
+
timeout: options.fetchTimeout || 10_000,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return syncFeed(feed, feedsCollection, itemsCollection, client, options);
|
|
299
|
+
}
|