Add disinterest tag filter stage

This commit is contained in:
thePR0M3TH3AN committed 2026-01-22 19:10:27 -05:00
1 parent abf6c003cc
commit 4abe9f3d1e
4 files changed
+139 -99

No files matched your search

+2
View File
@@ -68,6 +68,8 @@ constructed today behave exactly like the existing UI.
| --- | --- |
| `createDedupeByRootStage` | Reuses the application’s `dedupeVideosByRoot` helper (falling back to `dedupeToNewestByRoot`) to drop older versions of the same videoRoot. Adds "why" metadata for removed entries. |
| `createBlacklistFilterStage` | Calls `nostrService.shouldIncludeVideo(...)` so moderators and block lists stay enforced. Each rejection logs a "blacklist" reason in the why-trail. |
| `createTagPreferenceFilterStage` | Normalizes interest/disinterest tags via `normalizeHashtag`, drops videos that match disinterests, and (by default) filters out videos that do not match interest tags. Set `enforceInterests: false` to keep items even when they lack interest tags. |
| `createDisinterestFilterStage` | Normalizes tag preferences via `normalizeHashtag` and drops videos that match disinterests without excluding items that lack interest tags (useful for Explore-style feeds). |
| `createWatchHistorySuppressionStage` | Invokes feed-provided hooks to optionally suppress watched items. Useful for per-feed watch history preferences. |
Stages receive `(items, context)` and should return the transformed list. They
+2 -4
View File
@@ -36,6 +36,7 @@ import {
createFeedEngine,
createActiveNostrSource,
createBlacklistFilterStage,
createDisinterestFilterStage,
createDedupeByRootStage,
createExploreDiversitySorter,
createExploreScorerStage,
@@ -5647,10 +5648,7 @@ class Application {
return this.feedEngine.registerFeed("explore", {
source: createActiveNostrSource({ service: this.nostrService }),
stages: [
createTagPreferenceFilterStage({
stageName: "tag-disinterest-filter",
matchInterests: false,
}),
createDisinterestFilterStage(),
createBlacklistFilterStage({
shouldIncludeVideo: (video, options) =>
this.nostrService.shouldIncludeVideo(video, options),
+1
View File
@@ -4,6 +4,7 @@ export { createFeedEngine } from "./engine.js";
export {
createDedupeByRootStage,
createBlacklistFilterStage,
createDisinterestFilterStage,
createTagPreferenceFilterStage,
createWatchHistorySuppressionStage,
createModerationStage,
+134 -95
View File
@@ -275,107 +275,146 @@ export function createResolvePostedAtStage({
export function createTagPreferenceFilterStage({
stageName = "tag-preference-filter",
enforceInterests,
matchInterests = true,
} = {}) {
const shouldMatchInterests =
typeof enforceInterests === "boolean"
? enforceInterests
: matchInterests !== false;
return async function tagPreferenceFilterStage(items = [], context = {}) {
const shouldMatchInterests = matchInterests !== false;
const tagPreferences = context?.runtime?.tagPreferences;
const interests = normalizeTagSet(tagPreferences?.interests);
const disinterests = normalizeTagSet(tagPreferences?.disinterests);
if (!interests.size && !disinterests.size) {
return items;
}
const results = [];
for (const item of items) {
const video = item?.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
const videoTags = new Set();
if (Array.isArray(video.tags)) {
for (const tag of video.tags) {
if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") {
const normalized = normalizeHashtag(tag[1]);
if (normalized) {
videoTags.add(normalized);
}
}
}
}
if (Array.isArray(video.nip71?.hashtags)) {
for (const tag of video.nip71.hashtags) {
if (typeof tag === "string") {
const normalized = normalizeHashtag(tag);
if (normalized) {
videoTags.add(normalized);
}
}
}
}
let disinterested = false;
if (disinterests.size) {
for (const tag of videoTags) {
if (disinterests.has(tag)) {
disinterested = true;
break;
}
}
}
if (disinterested) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "disinterested-tag",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
let matchedInterests = [];
if (interests.size) {
matchedInterests = [...videoTags].filter((tag) => interests.has(tag));
if (shouldMatchInterests && matchedInterests.length === 0) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "no-interest-match",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
}
if (matchedInterests.length > 0) {
if (!isPlainObject(item.metadata)) {
item.metadata = {};
}
item.metadata.matchedInterests = matchedInterests;
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "matched-interests",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
tags: matchedInterests,
});
}
results.push(item);
}
return results;
return filterByTagPreferences({
items,
context,
stageName,
enforceInterests: shouldMatchInterests,
});
};
}
export function createDisinterestFilterStage({
stageName = "tag-disinterest-filter",
} = {}) {
return async function disinterestFilterStage(items = [], context = {}) {
return filterByTagPreferences({
items,
context,
stageName,
enforceInterests: false,
});
};
}
function collectVideoTags(video) {
const videoTags = new Set();
if (Array.isArray(video.tags)) {
for (const tag of video.tags) {
if (Array.isArray(tag) && tag[0] === "t" && typeof tag[1] === "string") {
const normalized = normalizeHashtag(tag[1]);
if (normalized) {
videoTags.add(normalized);
}
}
}
}
if (Array.isArray(video.nip71?.hashtags)) {
for (const tag of video.nip71.hashtags) {
if (typeof tag === "string") {
const normalized = normalizeHashtag(tag);
if (normalized) {
videoTags.add(normalized);
}
}
}
}
return videoTags;
}
function filterByTagPreferences({
items = [],
context = {},
stageName,
enforceInterests,
}) {
const tagPreferences = context?.runtime?.tagPreferences;
const interests = normalizeTagSet(tagPreferences?.interests);
const disinterests = normalizeTagSet(tagPreferences?.disinterests);
if (!interests.size && !disinterests.size) {
return items;
}
const results = [];
for (const item of items) {
const video = item?.video;
if (!video || typeof video !== "object") {
results.push(item);
continue;
}
const videoTags = collectVideoTags(video);
let disinterested = false;
if (disinterests.size) {
for (const tag of videoTags) {
if (disinterests.has(tag)) {
disinterested = true;
break;
}
}
}
if (disinterested) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "disinterested-tag",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
let matchedInterests = [];
if (interests.size) {
matchedInterests = [...videoTags].filter((tag) => interests.has(tag));
if (enforceInterests && matchedInterests.length === 0) {
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "no-interest-match",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
});
continue;
}
}
if (matchedInterests.length > 0) {
if (!isPlainObject(item.metadata)) {
item.metadata = {};
}
item.metadata.matchedInterests = matchedInterests;
context?.addWhy?.({
stage: stageName,
type: "filter",
reason: "matched-interests",
videoId: typeof video.id === "string" ? video.id : null,
pubkey: typeof video.pubkey === "string" ? video.pubkey : null,
tags: matchedInterests,
});
}
results.push(item);
}
return results;
}
export function createBlacklistFilterStage({
stageName = "blacklist-filter",
shouldIncludeVideo,