feedkeeper 0.25.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -127,9 +127,76 @@ export function deleteEdition(userId) {
127
127
  dismissEdition(userId);
128
128
  return existed;
129
129
  }
130
+ /** The automatic edition looks at every candidate; smaller limits are shortlists for an agent or the curator. */
131
+ const AUTOMATIC_EDITION_LIMIT = 4000;
132
+ /** How often each feed appeared in the editions of the last 72 hours, so a feed that was shown a lot waits its turn. */
133
+ function recentSourceUse(userId, itemIds) {
134
+ const use = new Map();
135
+ for (let start = 0; start < itemIds.length; start += 500) {
136
+ const chunk = itemIds.slice(start, start + 500);
137
+ const rows = db.prepare(`SELECT i.feed_id FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
138
+ WHERE i.id IN (${chunk.map(() => "?").join(",")})`).all(userId, ...chunk);
139
+ for (const row of rows)
140
+ use.set(row.feed_id, (use.get(row.feed_id) ?? 0) + 1);
141
+ }
142
+ return use;
143
+ }
144
+ /**
145
+ * Takes candidates round by round, one per source: volume says nothing about relevance, so a feed with three
146
+ * articles a week competes on equal terms with one that posts eighty. Sources that were shown less lately (and are
147
+ * not yet in the head of the list) go first within a round; inside a source the better scored article goes first,
148
+ * and an article offered before only after the new ones.
149
+ */
150
+ export function takeFairly(rest, head, recentUse) {
151
+ const queues = new Map();
152
+ for (const item of [...rest].sort((a, b) => Number(a.previouslySelected) - Number(b.previouslySelected) || b.score - a.score || b.id - a.id)) {
153
+ const queue = queues.get(item.subscriptionId);
154
+ if (queue)
155
+ queue.push(item);
156
+ else
157
+ queues.set(item.subscriptionId, [item]);
158
+ }
159
+ const inHead = new Map();
160
+ for (const item of head)
161
+ inHead.set(item.subscriptionId, (inHead.get(item.subscriptionId) ?? 0) + 1);
162
+ const order = [...queues.keys()].sort((a, b) => (inHead.get(a) ?? 0) - (inHead.get(b) ?? 0) || (recentUse.get(a) ?? 0) - (recentUse.get(b) ?? 0) ||
163
+ queues.get(b)[0].score - queues.get(a)[0].score || a - b);
164
+ const result = [];
165
+ for (let round = 0; result.length < rest.length; round++) {
166
+ for (const source of order) {
167
+ const item = queues.get(source)[round];
168
+ if (item)
169
+ result.push(item);
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+ /**
175
+ * A source may fill at most its fair share of the shortlist (the size divided by the number of sources), however
176
+ * much it publishes; the surplus of busy feeds is left out instead of crowding out the rest. A shortlist that would
177
+ * become too small to choose from is topped up in the same fair order, so no account ends up with an empty one.
178
+ */
179
+ export function fairShortlist(head, fairOrder, limit, sources) {
180
+ const share = Math.max(2, Math.ceil(limit / Math.max(1, sources)));
181
+ const used = new Map();
182
+ const kept = [];
183
+ const surplus = [];
184
+ for (const item of [...head, ...fairOrder]) {
185
+ const count = used.get(item.subscriptionId) ?? 0;
186
+ if (count < share) {
187
+ used.set(item.subscriptionId, count + 1);
188
+ kept.push(item);
189
+ }
190
+ else
191
+ surplus.push(item);
192
+ }
193
+ const minimum = Math.min(limit, 24);
194
+ return [...kept, ...surplus.slice(0, Math.max(0, minimum - kept.length))].slice(0, limit);
195
+ }
130
196
  export function editionCandidates(userId, now = new Date(), limit = 200) {
131
197
  const prefs = getNativePreferences(userId);
132
- const previous = new Set(db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids)));
198
+ const previousItemIds = db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids));
199
+ const previous = new Set(previousItemIds);
133
200
  // Fetch bounded candidates per source, so a busy feed cannot hide the others.
134
201
  const rows = db.prepare(`SELECT * FROM (
135
202
  SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
@@ -153,11 +220,12 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
153
220
  score: Math.pow(0.5, Math.max(0, now.getTime() - Date.parse(row.published)) / (18 * 3_600_000)) *
154
221
  (row.folder_id !== null && prefs.preferredFolderIds.includes(row.folder_id) ? 1.4 : 1),
155
222
  }));
156
- // Candidate requests expose a diversified shortlist rather than only the busiest source.
157
- if (limit < scored.length) {
223
+ // Candidate requests expose a shortlist in which every source has a fair chance, not only the busiest one.
224
+ if (limit < AUTOMATIC_EDITION_LIMIT) {
158
225
  const sorted = selectEdition(scored, { ...prefs, editionSize: Math.min(limit, 24), readingMinutes: null });
159
226
  const selected = new Set(sorted.map((item) => item.id));
160
- return [...sorted, ...scored.filter((item) => !selected.has(item.id)).sort((a, b) => b.score - a.score || b.id - a.id)].slice(0, limit);
227
+ const rest = scored.filter((item) => !selected.has(item.id));
228
+ return fairShortlist(sorted, takeFairly(rest, sorted, recentSourceUse(userId, previousItemIds)), limit, new Set(scored.map((item) => item.subscriptionId)).size);
161
229
  }
162
230
  return scored.sort((a, b) => b.score - a.score || b.id - a.id);
163
231
  }
@@ -187,7 +255,7 @@ export function generateEdition(userId, input = {}, now = new Date()) {
187
255
  if (row?.status === "active" && !state.edition) {
188
256
  db.prepare("UPDATE editions SET status = 'expired', revision = revision + 1, updated_at = ? WHERE user_id = ?").run(now.toISOString(), userId);
189
257
  }
190
- const chosen = selectEdition(editionCandidates(userId, now, 4000), prefs);
258
+ const chosen = selectEdition(editionCandidates(userId, now, AUTOMATIC_EDITION_LIMIT), prefs);
191
259
  if (!chosen.length)
192
260
  return state.edition;
193
261
  if (state.edition && JSON.stringify(state.edition.itemIds) === JSON.stringify(chosen.map((item) => item.id)))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }