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
|
@@ -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
|
|
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
|
|
157
|
-
if (limit <
|
|
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
|
-
|
|
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,
|
|
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)))
|