diff options
| author | Adam Soltys <[email protected]> | 2026-11-11 22:11:05 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-11 22:11:05 -0700 |
| commit | e87faa6ac650fe00388159ce149ec95777a11608 (patch) | |
| tree | a0cae5793aa8953ab49e709f89b31f17a70bbf4b | |
| parent | c7455ec7416fb39d4c57e92f5094cc3368b0edfb (diff) | |
| download | coinosv3-e87faa6ac650fe00388159ce149ec95777a11608.tar.gz coinosv3-e87faa6ac650fe00388159ce149ec95777a11608.zip | |
Post search shows a bot fleet's newsletter once, not forty-six times. "searching "fiatjaf" surfaced this same post over and over i guess from different people all boosting it" (Adam, 2026-11-11). Not boosts: about a dozen keys each post the NostrMag "Quiet Coup" issue every few hours, reworded a little each time (an emoji, "BREAKING", the picture moved), so the exact-repeat spam check never matches. In a search feed a post whose words and links overlap half or more (Jaccard) with one already shown is dropped; posts under 8 words are left alone. Measured on 120 live hits for "fiatjaf" from the three search relays: newsletter copies overlap 0.53 and up, distinct posts 0.45 at most; 46 copies → 1, nothing else dropped. post-search-test adds three reworded copies from three keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS
| -rw-r--r-- | src/features/messages.js | 30 | ||||
| -rw-r--r-- | tools/post-search-test.js | 8 |
2 files changed, 37 insertions, 1 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 3ed1fa7..a42396c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7504,6 +7504,35 @@ export function messagesFeature(ctx) { // Posts at the door: filtered in synchronously, so the same note from a // second relay is dropped while the first copy is still warming. const feedStaged = new WeakMap(); + // A word search turns up bot fleets posting one newsletter dozens of times + // from a dozen keys, each copy reworded a little (an emoji, "BREAKING", the + // picture moved) so it isn't the same text. Words and links as a set: a + // post sharing half of them with one already in the search feed is that + // post again, and only the first is shown. + const textSigs = new WeakMap(); // event -> Set of words + function textSig(e) { + let sig = textSigs.get(e); + if (!sig) { + const words = String(e.content || '').toLowerCase().match(/https?:\/\/\S+|[\p{L}\p{N}]{3,}/gu) || []; + textSigs.set(e, sig = new Set(words)); + } + return sig; + } + const NEAR_COPY = 0.5; // measured 2026-11-11: copies 0.53+, distinct posts at most 0.45 + const nearCopies = new WeakMap(); // feed state -> sigs admitted + function nearCopy(c, held, e) { + const sig = textSig(e); + if (sig.size < 8) return false; // short posts ("gm fiatjaf") are allowed to agree + let seen = nearCopies.get(c); + if (!seen) nearCopies.set(c, seen = held.map(textSig).filter((x) => x.size >= 8)); + for (const o of seen) { + let both = 0; + for (const w of sig) if (o.has(w)) both++; + if (both / (sig.size + o.size - both) >= NEAR_COPY) return true; + } + seen.push(sig); + return false; + } async function mergeFeed(evs, opts = {}, c = feedNow()) { let staged = feedStaged.get(c); if (!staged) feedStaged.set(c, staged = new Set()); @@ -7525,6 +7554,7 @@ export function messagesFeature(ctx) { const def = feedDef(c.id); let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e) && fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id)); + if (def && def.search) add = add.filter((e) => !nearCopy(c, held, e)); if (!add.length) return false; if (def && def.curated) { const kept = curateFeed(add); diff --git a/tools/post-search-test.js b/tools/post-search-test.js index 2f32812..81e5b62 100644 --- a/tools/post-search-test.js +++ b/tools/post-search-test.js @@ -13,6 +13,9 @@ const SK = generateSecretKey(); const now = Math.floor(Date.now() / 1000); const post = finalizeEvent({ kind: 1, created_at: now - 300, tags: [], content: 'talked to fiatjaf about relays today' }, SK); const parent = 'a'.repeat(64); +// one newsletter, posted by bot keys with a word or emoji changed each time +const NL = 'In this week\'s issue: The Quiet Coup: Fiatjaf Wipes Authorship From 57 NIPs. In a 12-hour window, the documentation layer was rewritten without consent. https://nostrmag.com/article/w45'; +const copies = ['📰 ' + NL, '🗞️ BREAKING ' + NL, NL + ' 👤 NM team'].map((content, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i, tags: [], content }, generateSecretKey())); const reply = finalizeEvent({ kind: 1, created_at: now - 200, tags: [['e', parent, '', 'reply']], content: 'fiatjaf would say just use a relay' }, SK); const html = await buildHtml({ minify: true, pwa: false }); @@ -41,7 +44,7 @@ await page.evaluateOnNewDocument((hits) => { }; window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); -}, [post, reply]); +}, [post, reply, ...copies]); const errs = []; page.on('pageerror', (e) => errs.push(String(e))); const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); @@ -65,6 +68,9 @@ try { await page.keyboard.press('Enter'); check('Enter opens a feed of posts that say it', await waitText('talked to fiatjaf about relays', 10000)); check('...replies too', await waitText('fiatjaf would say just use a relay', 3000)); + await waitText('quiet coup', 10000); await sleep(1500); + const nl = await page.evaluate(() => (document.body.innerText.match(/Quiet Coup/g) || []).length); + check('a newsletter posted by three bot keys shows once', nl === 1, nl + ' copies'); const s = await page.evaluate(() => window.__searches.filter((x) => x.f.search === 'fiatjaf' && !(x.f.kinds || []).includes(0))); check('asked NIP-50 relays for kind 1 with the words', s.length > 0 && s.every((x) => JSON.stringify(x.f.kinds) === '[1]'), JSON.stringify(s.map((x) => x.url))); check('the feed is named for the search', await page.evaluate(() => [...document.querySelectorAll('.feed-chip')].some((c) => c.classList.contains('on') && c.textContent.includes('fiatjaf'))), |
