@rmdes/indiekit-endpoint-microsub 1.0.0-beta.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/README.md +111 -0
- package/index.js +140 -0
- package/lib/cache/redis.js +133 -0
- package/lib/controllers/block.js +85 -0
- package/lib/controllers/channels.js +135 -0
- package/lib/controllers/events.js +56 -0
- package/lib/controllers/follow.js +108 -0
- package/lib/controllers/microsub.js +138 -0
- package/lib/controllers/mute.js +124 -0
- package/lib/controllers/preview.js +67 -0
- package/lib/controllers/reader.js +218 -0
- package/lib/controllers/search.js +142 -0
- package/lib/controllers/timeline.js +117 -0
- package/lib/feeds/atom.js +61 -0
- package/lib/feeds/fetcher.js +205 -0
- package/lib/feeds/hfeed.js +177 -0
- package/lib/feeds/jsonfeed.js +43 -0
- package/lib/feeds/normalizer.js +586 -0
- package/lib/feeds/parser.js +124 -0
- package/lib/feeds/rss.js +61 -0
- package/lib/polling/processor.js +201 -0
- package/lib/polling/scheduler.js +128 -0
- package/lib/polling/tier.js +139 -0
- package/lib/realtime/broker.js +241 -0
- package/lib/search/indexer.js +90 -0
- package/lib/search/query.js +197 -0
- package/lib/storage/channels.js +281 -0
- package/lib/storage/feeds.js +286 -0
- package/lib/storage/filters.js +265 -0
- package/lib/storage/items.js +419 -0
- package/lib/storage/read-state.js +109 -0
- package/lib/utils/jf2.js +170 -0
- package/lib/utils/pagination.js +157 -0
- package/lib/utils/validation.js +217 -0
- package/lib/webmention/processor.js +214 -0
- package/lib/webmention/receiver.js +54 -0
- package/lib/webmention/verifier.js +308 -0
- package/lib/websub/discovery.js +129 -0
- package/lib/websub/handler.js +163 -0
- package/lib/websub/subscriber.js +181 -0
- package/locales/en.json +80 -0
- package/package.json +54 -0
- package/views/channel-new.njk +33 -0
- package/views/channel.njk +41 -0
- package/views/compose.njk +61 -0
- package/views/item.njk +85 -0
- package/views/partials/actions.njk +15 -0
- package/views/partials/author.njk +17 -0
- package/views/partials/item-card.njk +65 -0
- package/views/partials/timeline.njk +10 -0
- package/views/reader.njk +37 -0
- package/views/settings.njk +81 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed parser dispatcher
|
|
3
|
+
* @module feeds/parser
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseAtom } from "./atom.js";
|
|
7
|
+
import { parseHfeed } from "./hfeed.js";
|
|
8
|
+
import { parseJsonFeed } from "./jsonfeed.js";
|
|
9
|
+
import { parseRss } from "./rss.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect feed type from content
|
|
13
|
+
* @param {string} content - Feed content
|
|
14
|
+
* @param {string} contentType - HTTP Content-Type header
|
|
15
|
+
* @returns {string} Feed type: 'rss' | 'atom' | 'jsonfeed' | 'hfeed' | 'unknown'
|
|
16
|
+
*/
|
|
17
|
+
export function detectFeedType(content, contentType = "") {
|
|
18
|
+
const ct = contentType.toLowerCase();
|
|
19
|
+
|
|
20
|
+
// Check Content-Type header first
|
|
21
|
+
if (ct.includes("application/json") || ct.includes("application/feed+json")) {
|
|
22
|
+
return "jsonfeed";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (ct.includes("application/atom+xml")) {
|
|
26
|
+
return "atom";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
ct.includes("application/rss+xml") ||
|
|
31
|
+
ct.includes("application/xml") ||
|
|
32
|
+
ct.includes("text/xml")
|
|
33
|
+
) {
|
|
34
|
+
// Need to check content to distinguish RSS from Atom
|
|
35
|
+
const trimmed = content.trim();
|
|
36
|
+
if (
|
|
37
|
+
trimmed.includes("<feed") &&
|
|
38
|
+
trimmed.includes('xmlns="http://www.w3.org/2005/Atom"')
|
|
39
|
+
) {
|
|
40
|
+
return "atom";
|
|
41
|
+
}
|
|
42
|
+
if (trimmed.includes("<rss") || trimmed.includes("<rdf:RDF")) {
|
|
43
|
+
return "rss";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (ct.includes("text/html")) {
|
|
48
|
+
return "hfeed";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fall back to content inspection
|
|
52
|
+
const trimmed = content.trim();
|
|
53
|
+
|
|
54
|
+
// JSON Feed
|
|
55
|
+
if (trimmed.startsWith("{")) {
|
|
56
|
+
try {
|
|
57
|
+
const json = JSON.parse(trimmed);
|
|
58
|
+
if (json.version && json.version.includes("jsonfeed.org")) {
|
|
59
|
+
return "jsonfeed";
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Not JSON
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// XML feeds
|
|
67
|
+
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
|
|
68
|
+
if (
|
|
69
|
+
trimmed.includes("<feed") &&
|
|
70
|
+
trimmed.includes('xmlns="http://www.w3.org/2005/Atom"')
|
|
71
|
+
) {
|
|
72
|
+
return "atom";
|
|
73
|
+
}
|
|
74
|
+
if (trimmed.includes("<rss") || trimmed.includes("<rdf:RDF")) {
|
|
75
|
+
return "rss";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// HTML with potential h-feed
|
|
80
|
+
if (trimmed.includes("<!DOCTYPE html") || trimmed.includes("<html")) {
|
|
81
|
+
return "hfeed";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return "unknown";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse feed content into normalized items
|
|
89
|
+
* @param {string} content - Feed content
|
|
90
|
+
* @param {string} feedUrl - URL of the feed
|
|
91
|
+
* @param {object} options - Parse options
|
|
92
|
+
* @param {string} [options.contentType] - HTTP Content-Type header
|
|
93
|
+
* @returns {Promise<object>} Parsed feed with metadata and items
|
|
94
|
+
*/
|
|
95
|
+
export async function parseFeed(content, feedUrl, options = {}) {
|
|
96
|
+
const feedType = detectFeedType(content, options.contentType);
|
|
97
|
+
|
|
98
|
+
switch (feedType) {
|
|
99
|
+
case "rss": {
|
|
100
|
+
return parseRss(content, feedUrl);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case "atom": {
|
|
104
|
+
return parseAtom(content, feedUrl);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case "jsonfeed": {
|
|
108
|
+
return parseJsonFeed(content, feedUrl);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
case "hfeed": {
|
|
112
|
+
return parseHfeed(content, feedUrl);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
default: {
|
|
116
|
+
throw new Error(`Unable to detect feed type for ${feedUrl}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { parseAtom } from "./atom.js";
|
|
122
|
+
export { parseHfeed } from "./hfeed.js";
|
|
123
|
+
export { parseJsonFeed } from "./jsonfeed.js";
|
|
124
|
+
export { parseRss } from "./rss.js";
|
package/lib/feeds/rss.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS 1.0/2.0 feed parser
|
|
3
|
+
* @module feeds/rss
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
|
|
8
|
+
import FeedParser from "feedparser";
|
|
9
|
+
|
|
10
|
+
import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse RSS feed content
|
|
14
|
+
* @param {string} content - RSS XML content
|
|
15
|
+
* @param {string} feedUrl - URL of the feed
|
|
16
|
+
* @returns {Promise<object>} Parsed feed with metadata and items
|
|
17
|
+
*/
|
|
18
|
+
export async function parseRss(content, feedUrl) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const feedparser = new FeedParser({ feedurl: feedUrl });
|
|
21
|
+
const items = [];
|
|
22
|
+
let meta;
|
|
23
|
+
|
|
24
|
+
feedparser.on("error", (error) => {
|
|
25
|
+
reject(new Error(`RSS parse error: ${error.message}`));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
feedparser.on("meta", (feedMeta) => {
|
|
29
|
+
meta = feedMeta;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
feedparser.on("readable", function () {
|
|
33
|
+
let item;
|
|
34
|
+
while ((item = this.read())) {
|
|
35
|
+
items.push(item);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
feedparser.on("end", () => {
|
|
40
|
+
try {
|
|
41
|
+
const normalizedMeta = normalizeFeedMeta(meta, feedUrl);
|
|
42
|
+
const normalizedItems = items.map((item) =>
|
|
43
|
+
normalizeItem(item, feedUrl, "rss"),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
resolve({
|
|
47
|
+
type: "feed",
|
|
48
|
+
url: feedUrl,
|
|
49
|
+
...normalizedMeta,
|
|
50
|
+
items: normalizedItems,
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
reject(error);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Create readable stream from string and pipe to feedparser
|
|
58
|
+
const stream = Readable.from([content]);
|
|
59
|
+
stream.pipe(feedparser);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed processing pipeline
|
|
3
|
+
* @module polling/processor
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
|
7
|
+
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
|
8
|
+
import { getChannel } from "../storage/channels.js";
|
|
9
|
+
import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js";
|
|
10
|
+
import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
|
|
11
|
+
import { addItem } from "../storage/items.js";
|
|
12
|
+
|
|
13
|
+
import { calculateNewTier } from "./tier.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Process a single feed
|
|
17
|
+
* @param {object} application - Indiekit application
|
|
18
|
+
* @param {object} feed - Feed document from database
|
|
19
|
+
* @returns {Promise<object>} Processing result
|
|
20
|
+
*/
|
|
21
|
+
export async function processFeed(application, feed) {
|
|
22
|
+
const startTime = Date.now();
|
|
23
|
+
const result = {
|
|
24
|
+
feedId: feed._id,
|
|
25
|
+
url: feed.url,
|
|
26
|
+
success: false,
|
|
27
|
+
itemsAdded: 0,
|
|
28
|
+
error: undefined,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Get Redis client for caching
|
|
33
|
+
const redis = getRedisClient(application);
|
|
34
|
+
|
|
35
|
+
// Fetch and parse the feed
|
|
36
|
+
const parsed = await fetchAndParseFeed(feed.url, {
|
|
37
|
+
etag: feed.etag,
|
|
38
|
+
lastModified: feed.lastModified,
|
|
39
|
+
redis,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Handle 304 Not Modified
|
|
43
|
+
if (parsed.notModified) {
|
|
44
|
+
const tierResult = calculateNewTier({
|
|
45
|
+
currentTier: feed.tier,
|
|
46
|
+
hasNewItems: false,
|
|
47
|
+
consecutiveUnchanged: feed.unmodified || 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await updateFeedAfterFetch(application, feed._id, false, {
|
|
51
|
+
tier: tierResult.tier,
|
|
52
|
+
unmodified: tierResult.consecutiveUnchanged,
|
|
53
|
+
nextFetchAt: tierResult.nextFetchAt,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
result.success = true;
|
|
57
|
+
result.notModified = true;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Get channel for filtering
|
|
62
|
+
const channel = await getChannel(application, feed.channelId);
|
|
63
|
+
|
|
64
|
+
// Process items
|
|
65
|
+
let newItemCount = 0;
|
|
66
|
+
for (const item of parsed.items) {
|
|
67
|
+
// Apply channel filters
|
|
68
|
+
if (channel?.settings && !passesFilters(item, channel.settings)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Store the item
|
|
73
|
+
const stored = await addItem(application, {
|
|
74
|
+
channelId: feed.channelId,
|
|
75
|
+
feedId: feed._id,
|
|
76
|
+
uid: item.uid,
|
|
77
|
+
item,
|
|
78
|
+
});
|
|
79
|
+
if (stored) {
|
|
80
|
+
newItemCount++;
|
|
81
|
+
|
|
82
|
+
// Publish real-time event
|
|
83
|
+
if (redis) {
|
|
84
|
+
await publishEvent(redis, `microsub:${feed.channelId}`, {
|
|
85
|
+
type: "new-item",
|
|
86
|
+
channelId: feed.channelId.toString(),
|
|
87
|
+
item: stored,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
result.itemsAdded = newItemCount;
|
|
94
|
+
|
|
95
|
+
// Update tier based on whether we found new items
|
|
96
|
+
const tierResult = calculateNewTier({
|
|
97
|
+
currentTier: feed.tier,
|
|
98
|
+
hasNewItems: newItemCount > 0,
|
|
99
|
+
consecutiveUnchanged: newItemCount > 0 ? 0 : feed.unmodified || 0,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Update feed metadata
|
|
103
|
+
const updateData = {
|
|
104
|
+
tier: tierResult.tier,
|
|
105
|
+
unmodified: tierResult.consecutiveUnchanged,
|
|
106
|
+
nextFetchAt: tierResult.nextFetchAt,
|
|
107
|
+
etag: parsed.etag,
|
|
108
|
+
lastModified: parsed.lastModified,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Update feed title/photo if discovered
|
|
112
|
+
if (parsed.name && !feed.title) {
|
|
113
|
+
updateData.title = parsed.name;
|
|
114
|
+
}
|
|
115
|
+
if (parsed.photo && !feed.photo) {
|
|
116
|
+
updateData.photo = parsed.photo;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await updateFeedAfterFetch(
|
|
120
|
+
application,
|
|
121
|
+
feed._id,
|
|
122
|
+
newItemCount > 0,
|
|
123
|
+
updateData,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Handle WebSub hub discovery
|
|
127
|
+
if (parsed.hub && (!feed.websub || feed.websub.hub !== parsed.hub)) {
|
|
128
|
+
await updateFeedWebsub(application, feed._id, {
|
|
129
|
+
hub: parsed.hub,
|
|
130
|
+
topic: parsed.self || feed.url,
|
|
131
|
+
});
|
|
132
|
+
// TODO: Subscribe to hub
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result.success = true;
|
|
136
|
+
result.tier = tierResult.tier;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
result.error = error.message;
|
|
139
|
+
|
|
140
|
+
// Still update the feed to prevent retry storms
|
|
141
|
+
try {
|
|
142
|
+
const tierResult = calculateNewTier({
|
|
143
|
+
currentTier: feed.tier,
|
|
144
|
+
hasNewItems: false,
|
|
145
|
+
consecutiveUnchanged: (feed.unmodified || 0) + 1,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await updateFeedAfterFetch(application, feed._id, false, {
|
|
149
|
+
tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error
|
|
150
|
+
unmodified: tierResult.consecutiveUnchanged,
|
|
151
|
+
nextFetchAt: tierResult.nextFetchAt,
|
|
152
|
+
lastError: error.message,
|
|
153
|
+
lastErrorAt: new Date(),
|
|
154
|
+
});
|
|
155
|
+
} catch {
|
|
156
|
+
// Ignore update errors
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
result.duration = Date.now() - startTime;
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if an item passes channel filters
|
|
166
|
+
* @param {object} item - Feed item
|
|
167
|
+
* @param {object} settings - Channel settings
|
|
168
|
+
* @returns {boolean} Whether the item passes filters
|
|
169
|
+
*/
|
|
170
|
+
function passesFilters(item, settings) {
|
|
171
|
+
return passesTypeFilter(item, settings) && passesRegexFilter(item, settings);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Process multiple feeds in batch
|
|
176
|
+
* @param {object} application - Indiekit application
|
|
177
|
+
* @param {Array} feeds - Array of feed documents
|
|
178
|
+
* @param {object} options - Processing options
|
|
179
|
+
* @returns {Promise<object>} Batch processing result
|
|
180
|
+
*/
|
|
181
|
+
export async function processFeedBatch(application, feeds, options = {}) {
|
|
182
|
+
const { concurrency = 5 } = options;
|
|
183
|
+
const results = [];
|
|
184
|
+
|
|
185
|
+
// Process in batches with limited concurrency
|
|
186
|
+
for (let index = 0; index < feeds.length; index += concurrency) {
|
|
187
|
+
const batch = feeds.slice(index, index + concurrency);
|
|
188
|
+
const batchResults = await Promise.all(
|
|
189
|
+
batch.map((feed) => processFeed(application, feed)),
|
|
190
|
+
);
|
|
191
|
+
results.push(...batchResults);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
total: feeds.length,
|
|
196
|
+
successful: results.filter((r) => r.success).length,
|
|
197
|
+
failed: results.filter((r) => !r.success).length,
|
|
198
|
+
itemsAdded: results.reduce((sum, r) => sum + r.itemsAdded, 0),
|
|
199
|
+
results,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed polling scheduler
|
|
3
|
+
* @module polling/scheduler
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getFeedsToFetch } from "../storage/feeds.js";
|
|
7
|
+
|
|
8
|
+
import { processFeedBatch } from "./processor.js";
|
|
9
|
+
|
|
10
|
+
let schedulerInterval;
|
|
11
|
+
let indiekitInstance;
|
|
12
|
+
let isRunning = false;
|
|
13
|
+
|
|
14
|
+
const POLL_INTERVAL = 60 * 1000; // Run scheduler every minute
|
|
15
|
+
const BATCH_CONCURRENCY = 5; // Process 5 feeds at a time
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start the feed polling scheduler
|
|
19
|
+
* @param {object} indiekit - Indiekit instance
|
|
20
|
+
*/
|
|
21
|
+
export function startScheduler(indiekit) {
|
|
22
|
+
if (schedulerInterval) {
|
|
23
|
+
return; // Already running
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
indiekitInstance = indiekit;
|
|
27
|
+
|
|
28
|
+
// Run every minute
|
|
29
|
+
schedulerInterval = setInterval(async () => {
|
|
30
|
+
await runSchedulerCycle();
|
|
31
|
+
}, POLL_INTERVAL);
|
|
32
|
+
|
|
33
|
+
// Run immediately on start
|
|
34
|
+
runSchedulerCycle();
|
|
35
|
+
|
|
36
|
+
console.log("[Microsub] Feed polling scheduler started");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stop the feed polling scheduler
|
|
41
|
+
*/
|
|
42
|
+
export function stopScheduler() {
|
|
43
|
+
if (schedulerInterval) {
|
|
44
|
+
clearInterval(schedulerInterval);
|
|
45
|
+
schedulerInterval = undefined;
|
|
46
|
+
}
|
|
47
|
+
indiekitInstance = undefined;
|
|
48
|
+
console.log("[Microsub] Feed polling scheduler stopped");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run a single scheduler cycle
|
|
53
|
+
*/
|
|
54
|
+
async function runSchedulerCycle() {
|
|
55
|
+
if (!indiekitInstance) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Prevent overlapping runs
|
|
60
|
+
if (isRunning) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
isRunning = true;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const application = indiekitInstance;
|
|
68
|
+
const feeds = await getFeedsToFetch(application);
|
|
69
|
+
|
|
70
|
+
if (feeds.length === 0) {
|
|
71
|
+
isRunning = false;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
|
|
76
|
+
|
|
77
|
+
const result = await processFeedBatch(application, feeds, {
|
|
78
|
+
concurrency: BATCH_CONCURRENCY,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
console.log(
|
|
82
|
+
`[Microsub] Processed ${result.total} feeds: ${result.successful} successful, ` +
|
|
83
|
+
`${result.failed} failed, ${result.itemsAdded} new items`,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Log any errors
|
|
87
|
+
for (const feedResult of result.results) {
|
|
88
|
+
if (feedResult.error) {
|
|
89
|
+
console.error(
|
|
90
|
+
`[Microsub] Error processing ${feedResult.url}: ${feedResult.error}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
console.error("[Microsub] Error in scheduler cycle:", error.message);
|
|
96
|
+
} finally {
|
|
97
|
+
isRunning = false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Manually trigger a feed refresh
|
|
103
|
+
* @param {object} application - Indiekit application
|
|
104
|
+
* @param {string} feedId - Feed ID to refresh
|
|
105
|
+
* @returns {Promise<object>} Processing result
|
|
106
|
+
*/
|
|
107
|
+
export async function refreshFeedNow(application, feedId) {
|
|
108
|
+
const { getFeedById } = await import("../storage/feeds.js");
|
|
109
|
+
const { processFeed } = await import("./processor.js");
|
|
110
|
+
|
|
111
|
+
const feed = await getFeedById(application, feedId);
|
|
112
|
+
if (!feed) {
|
|
113
|
+
throw new Error("Feed not found");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return processFeed(application, feed);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get scheduler status
|
|
121
|
+
* @returns {object} Scheduler status
|
|
122
|
+
*/
|
|
123
|
+
export function getSchedulerStatus() {
|
|
124
|
+
return {
|
|
125
|
+
running: !!schedulerInterval,
|
|
126
|
+
processing: isRunning,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive tier-based polling algorithm
|
|
3
|
+
* Based on Ekster's approach: https://github.com/pstuifzand/ekster
|
|
4
|
+
*
|
|
5
|
+
* Tier determines poll interval: interval = 2^tier minutes
|
|
6
|
+
* - Tier 0: Every minute (active/new feeds)
|
|
7
|
+
* - Tier 1: Every 2 minutes
|
|
8
|
+
* - Tier 2: Every 4 minutes
|
|
9
|
+
* - Tier 3: Every 8 minutes
|
|
10
|
+
* - Tier 4: Every 16 minutes
|
|
11
|
+
* - Tier 5: Every 32 minutes
|
|
12
|
+
* - Tier 6: Every 64 minutes (~1 hour)
|
|
13
|
+
* - Tier 7: Every 128 minutes (~2 hours)
|
|
14
|
+
* - Tier 8: Every 256 minutes (~4 hours)
|
|
15
|
+
* - Tier 9: Every 512 minutes (~8 hours)
|
|
16
|
+
* - Tier 10: Every 1024 minutes (~17 hours)
|
|
17
|
+
*
|
|
18
|
+
* @module polling/tier
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const MIN_TIER = 0;
|
|
22
|
+
const MAX_TIER = 10;
|
|
23
|
+
const DEFAULT_TIER = 1;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get polling interval for a tier in milliseconds
|
|
27
|
+
* @param {number} tier - Polling tier (0-10)
|
|
28
|
+
* @returns {number} Interval in milliseconds
|
|
29
|
+
*/
|
|
30
|
+
export function getIntervalForTier(tier) {
|
|
31
|
+
const clampedTier = Math.max(MIN_TIER, Math.min(MAX_TIER, tier));
|
|
32
|
+
const minutes = Math.pow(2, clampedTier);
|
|
33
|
+
return minutes * 60 * 1000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get next fetch time based on tier
|
|
38
|
+
* @param {number} tier - Polling tier
|
|
39
|
+
* @returns {Date} Next fetch time
|
|
40
|
+
*/
|
|
41
|
+
export function getNextFetchTime(tier) {
|
|
42
|
+
const interval = getIntervalForTier(tier);
|
|
43
|
+
return new Date(Date.now() + interval);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Calculate new tier after a fetch
|
|
48
|
+
* @param {object} options - Options
|
|
49
|
+
* @param {number} options.currentTier - Current tier
|
|
50
|
+
* @param {boolean} options.hasNewItems - Whether new items were found
|
|
51
|
+
* @param {number} options.consecutiveUnchanged - Consecutive fetches with no changes
|
|
52
|
+
* @returns {object} New tier and metadata
|
|
53
|
+
*/
|
|
54
|
+
export function calculateNewTier(options) {
|
|
55
|
+
const {
|
|
56
|
+
currentTier = DEFAULT_TIER,
|
|
57
|
+
hasNewItems,
|
|
58
|
+
consecutiveUnchanged = 0,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
let newTier = currentTier;
|
|
62
|
+
let newConsecutiveUnchanged = consecutiveUnchanged;
|
|
63
|
+
|
|
64
|
+
if (hasNewItems) {
|
|
65
|
+
// Reset unchanged counter
|
|
66
|
+
newConsecutiveUnchanged = 0;
|
|
67
|
+
|
|
68
|
+
// Decrease tier (more frequent) if we found new items
|
|
69
|
+
if (currentTier > MIN_TIER) {
|
|
70
|
+
newTier = currentTier - 1;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
// Increment unchanged counter
|
|
74
|
+
newConsecutiveUnchanged = consecutiveUnchanged + 1;
|
|
75
|
+
|
|
76
|
+
// Increase tier (less frequent) after consecutive unchanged fetches
|
|
77
|
+
// The threshold increases with tier to prevent thrashing
|
|
78
|
+
const threshold = Math.max(2, currentTier);
|
|
79
|
+
if (newConsecutiveUnchanged >= threshold && currentTier < MAX_TIER) {
|
|
80
|
+
newTier = currentTier + 1;
|
|
81
|
+
// Reset counter after tier change
|
|
82
|
+
newConsecutiveUnchanged = 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
tier: newTier,
|
|
88
|
+
consecutiveUnchanged: newConsecutiveUnchanged,
|
|
89
|
+
nextFetchAt: getNextFetchTime(newTier),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get initial tier for a new feed subscription
|
|
95
|
+
* @returns {object} Initial tier settings
|
|
96
|
+
*/
|
|
97
|
+
export function getInitialTier() {
|
|
98
|
+
return {
|
|
99
|
+
tier: MIN_TIER, // Start at tier 0 for immediate first fetch
|
|
100
|
+
consecutiveUnchanged: 0,
|
|
101
|
+
nextFetchAt: new Date(), // Fetch immediately
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Determine if a feed is due for fetching
|
|
107
|
+
* @param {object} feed - Feed document
|
|
108
|
+
* @returns {boolean} Whether the feed should be fetched
|
|
109
|
+
*/
|
|
110
|
+
export function isDueForFetch(feed) {
|
|
111
|
+
if (!feed.nextFetchAt) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return new Date(feed.nextFetchAt) <= new Date();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get human-readable description of polling interval
|
|
120
|
+
* @param {number} tier - Polling tier
|
|
121
|
+
* @returns {string} Description
|
|
122
|
+
*/
|
|
123
|
+
export function getTierDescription(tier) {
|
|
124
|
+
const minutes = Math.pow(2, tier);
|
|
125
|
+
|
|
126
|
+
if (minutes < 60) {
|
|
127
|
+
return `every ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hours = minutes / 60;
|
|
131
|
+
if (hours < 24) {
|
|
132
|
+
return `every ${hours.toFixed(1)} hour${hours === 1 ? "" : "s"}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const days = hours / 24;
|
|
136
|
+
return `every ${days.toFixed(1)} day${days === 1 ? "" : "s"}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { MIN_TIER, MAX_TIER, DEFAULT_TIER };
|