// ==UserScript== // @name 小红书 Web 增强 // @name:zh-CN 小红书 Web 增强 // @name:en Xiaohongshu Web Enhancer // @namespace xiaohongshu-web-enhancer // @version 0.2.1 // @author Onlydreams // @description 自动打开“只看图文”,并按标题关键词无感过滤首页信息流。 // @description:zh-CN 自动打开“只看图文”,并按标题关键词无感过滤首页信息流。 // @description:en Automatically enables the image-only filter and seamlessly filters Explore feed notes by title keywords. // @homepageURL https://github.com/Onlydreams/xiaohongshu-web-enhancer // @supportURL https://github.com/Onlydreams/xiaohongshu-web-enhancer/issues // @match https://www.xiaohongshu.com/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant window.onurlchange // @noframes // @license MIT // ==/UserScript== (() => { 'use strict'; const DOCUMENT_STARTED_AT = Date.now(); const AUTO_IMAGE_NOTE_FILTER = Object.freeze({ controlSelector: '#image-note-filter-el', clickTargetSelector: '.btn-wrapper', labelSelector: '.tip-text', pendingClass: 'xhs-enhancer-image-filter-pending', checkIntervalMs: 1500, revealTimeoutMs: 6000, automaticAttemptTimeoutMs: 15000, }); const TITLE_FILTER_DOM = Object.freeze({ activeClass: 'xhs-enhancer-title-filter-active', cardReadyAttribute: 'data-xhs-enhancer-card-ready', compactedAttribute: 'data-xhs-enhancer-compacted', filteredAttribute: 'data-xhs-enhancer-title-filtered', ownedAttribute: 'data-xhs-enhancer-owned', readyAttribute: 'data-xhs-enhancer-title-filter-ready', bypassAttribute: 'data-xhs-enhancer-title-filter-bypass', cardSelector: 'section.note-item[data-note-id]', floatingControlsSelector: '.floating-btn-sets', rootSelector: '#exploreFeeds.feeds-container', titleSelector: 'a.title[href^="/explore/"]', feedHeightProperty: '--xhs-enhancer-feed-height', xProperty: '--xhs-enhancer-x', yProperty: '--xhs-enhancer-y', }); const TITLE_FILTER_LIFECYCLE = Object.freeze({ bypassFallbackMaxAttempts: 5, bypassFallbackMaxDelayMs: 4000, initialRevealTimeoutMs: 6000, routeFallbackIntervalMs: 250, transactionWatchdogMs: 250, }); const TITLE_FILTER_LAYOUT = Object.freeze({ // 标题异步换行时,卡片实际高度可能比网站已分配的瀑布流槽位多一行。 cardHeightDriftTolerance: 24, }); const FEED_TITLE_FILTER_STORAGE = Object.freeze({ enabled: 'xhsEnhancer.feedTitleKeywordFilter.enabled', keywords: 'xhsEnhancer.feedTitleKeywordFilter.keywords', }); function normalizeBlockKeywordText(text) { return String(text ?? '').replace(/\s+/gu, ' ').trim(); } function compileBlockKeywords(rawKeywords) { const keywordParts = Array.isArray(rawKeywords) ? rawKeywords : String(rawKeywords ?? '').split('|'); const compiledKeywords = []; const seenKeywords = new Set(); for (const keywordPart of keywordParts) { const keyword = normalizeBlockKeywordText(keywordPart).toLowerCase(); if (!keyword || seenKeywords.has(keyword)) { continue; } seenKeywords.add(keyword); compiledKeywords.push(keyword); } return Object.freeze(compiledKeywords); } function matchesAnyBlockKeyword(title, compiledKeywords) { const normalizedTitle = normalizeBlockKeywordText(title).toLowerCase(); if (!normalizedTitle) { return false; } return compiledKeywords.some((keyword) => normalizedTitle.includes(keyword), ); } function parseNativeTranslation(transform) { if (typeof transform !== 'string') { return null; } const translateMatch = transform.match( /^translate\(\s*(-?(?:\d+\.?\d*|\.\d+))px\s*,\s*(-?(?:\d+\.?\d*|\.\d+))px\s*\)$/u, ); if (translateMatch) { return { x: Number(translateMatch[1]), y: Number(translateMatch[2]), }; } const matrixMatch = transform.match( /^matrix\(\s*(-?(?:\d+\.?\d*|\.\d+))\s*,\s*(-?(?:\d+\.?\d*|\.\d+))\s*,\s*(-?(?:\d+\.?\d*|\.\d+))\s*,\s*(-?(?:\d+\.?\d*|\.\d+))\s*,\s*(-?(?:\d+\.?\d*|\.\d+))\s*,\s*(-?(?:\d+\.?\d*|\.\d+))\s*\)$/u, ); if (!matrixMatch) { return null; } const [scaleX, skewY, skewX, scaleY, x, y] = matrixMatch .slice(1) .map(Number); if ( scaleX !== 1 || skewY !== 0 || skewX !== 0 || scaleY !== 1 ) { return null; } return { x, y }; } function clusterNativeColumns(cards, tolerance) { const clusters = []; for (const card of [...cards].sort((left, right) => left.x - right.x)) { let cluster = clusters.find( (candidate) => Math.abs(candidate.x - card.x) <= tolerance, ); if (!cluster) { cluster = { cards: [], x: card.x }; clusters.push(cluster); } cluster.cards.push(card); cluster.x = cluster.cards.reduce((sum, item) => sum + item.x, 0) / cluster.cards.length; } return clusters.sort((left, right) => left.x - right.x); } function validationFailure(reason) { return { ok: false, reason }; } function validateNativeMasonryModel( snapshot, { cardHeightDriftTolerance = TITLE_FILTER_LAYOUT.cardHeightDriftTolerance, positionTolerance = 0.1, sizeTolerance = 0.5, } = {}, ) { const root = snapshot?.root; const cards = snapshot?.cards; const horizontalGap = snapshot?.gaps?.horizontal; const verticalGap = snapshot?.gaps?.vertical; if ( !root || root.connected === false || !Number.isFinite(root.borderBoxWidth) || root.borderBoxWidth <= 0 || !Number.isFinite(root.borderBoxHeight) || root.borderBoxHeight < 0 ) { return validationFailure('invalid-root'); } if (root.position !== 'relative' || root.boxSizing !== 'border-box') { return validationFailure('unsupported-root-position'); } const boxEdges = [...(root.padding ?? []), ...(root.border ?? [])]; if ( boxEdges.length !== 8 || boxEdges.some( (value) => !Number.isFinite(value) || Math.abs(value) > sizeTolerance, ) ) { return validationFailure('unsupported-root-box-model'); } if (root.layoutFrozen || root.staticLayout || root.unknownLayoutMode) { return validationFailure('unsupported-layout-mode'); } if ( !Number.isFinite(horizontalGap) || horizontalGap < 0 || !Number.isFinite(verticalGap) || verticalGap < 0 ) { return validationFailure('invalid-gaps'); } if (!Array.isArray(cards) || cards.length < 2) { return validationFailure('insufficient-cards'); } const firstCardWidth = cards[0].borderBoxWidth; for (const card of cards) { if ( !Number.isFinite(card.borderBoxWidth) || card.borderBoxWidth <= 0 || !Number.isFinite(card.borderBoxHeight) || card.borderBoxHeight < 0 || !Number.isFinite(card.x) || !Number.isFinite(card.y) || card.x < -positionTolerance || card.y < -positionTolerance ) { return validationFailure('invalid-card-geometry'); } if (Math.abs(card.borderBoxWidth - firstCardWidth) > sizeTolerance) { return validationFailure('inconsistent-card-width'); } if (card.position !== 'absolute') { return validationFailure('unsupported-card-position'); } const parsedTranslation = parseNativeTranslation(card.transform); if ( !parsedTranslation || Math.abs(parsedTranslation.x - card.x) > positionTolerance || Math.abs(parsedTranslation.y - card.y) > positionTolerance ) { return validationFailure('unsupported-transform'); } } for (const child of snapshot.otherDirectChildren ?? []) { if ( child.kind !== 'floating-controls' || child.position !== 'fixed' || child.participatesInLayout !== false ) { return validationFailure('unknown-direct-child'); } } const columns = clusterNativeColumns(cards, positionTolerance); for (const column of columns) { if (column.cards.length < 2) { return validationFailure('insufficient-column-sample'); } } for (let index = 1; index < columns.length; index += 1) { const actualStep = columns[index].x - columns[index - 1].x; if ( Math.abs(actualStep - (firstCardWidth + horizontalGap)) > sizeTolerance ) { return validationFailure('column-width-mismatch'); } } const nativeSpan = columns[columns.length - 1].x + firstCardWidth - columns[0].x; if (Math.abs(columns[0].x) > sizeTolerance) { return validationFailure('root-origin-mismatch'); } if (Math.abs(nativeSpan - root.borderBoxWidth) > sizeTolerance) { return validationFailure('root-width-mismatch'); } const removalAmounts = []; for (const column of columns) { const orderedCards = [...column.cards].sort( (left, right) => left.y - right.y, ); for (let index = 0; index < orderedCards.length; index += 1) { const card = orderedCards[index]; const nextCard = orderedCards[index + 1]; const removalAmount = nextCard ? nextCard.y - card.y : card.borderBoxHeight + verticalGap; const allocatedHeight = removalAmount - verticalGap; if ( !Number.isFinite(removalAmount) || allocatedHeight < 0 || Math.abs(allocatedHeight - card.borderBoxHeight) > cardHeightDriftTolerance ) { return validationFailure('discontinuous-column'); } removalAmounts.push({ amount: removalAmount, id: card.id }); } } const maxNativeEnd = Math.max( ...cards.map((card) => card.y + card.borderBoxHeight), ); return { ok: true, columnXs: columns.map((column) => column.x), removalAmounts, coversFeedStart: columns.every( (column) => Math.min(...column.cards.map((card) => card.y)) <= positionTolerance, ), coversRootTail: Math.abs( maxNativeEnd + verticalGap - root.borderBoxHeight, ) <= sizeTolerance, }; } function requireFiniteNonNegative(value, name) { if (!Number.isFinite(value) || value < 0) { throw new TypeError(`${name} must be a finite non-negative number`); } } function computeColumnCompaction({ items, verticalGap, priorContributions = [], previousLogicalRootHeight = 0, nativeRootHeight = 0, coversFeedStart = false, coversRootTail = false, positionTolerance = 0.1, }) { if (!Array.isArray(items) || items.length === 0) { throw new TypeError('items must contain at least one card'); } requireFiniteNonNegative(verticalGap, 'verticalGap'); requireFiniteNonNegative( previousLogicalRootHeight, 'previousLogicalRootHeight', ); requireFiniteNonNegative(nativeRootHeight, 'nativeRootHeight'); requireFiniteNonNegative(positionTolerance, 'positionTolerance'); const itemIds = new Set(); const itemById = new Map(); for (const item of items) { if (item?.id === undefined || item?.id === null || itemIds.has(item.id)) { throw new TypeError('items must have unique ids'); } itemIds.add(item.id); itemById.set(item.id, item); requireFiniteNonNegative(item.height, 'item.height'); requireFiniteNonNegative(item.nativeX, 'item.nativeX'); requireFiniteNonNegative(item.nativeY, 'item.nativeY'); if (item.removalAmount !== undefined) { requireFiniteNonNegative(item.removalAmount, 'item.removalAmount'); } } const contributionById = new Map(); for (const contribution of priorContributions) { if ( contribution?.id === undefined || contribution?.id === null || contributionById.has(contribution.id) ) { continue; } requireFiniteNonNegative( contribution.nativeColumnX, 'contribution.nativeColumnX', ); requireFiniteNonNegative( contribution.nativeY, 'contribution.nativeY', ); requireFiniteNonNegative(contribution.amount, 'contribution.amount'); contributionById.set(contribution.id, { ...contribution }); } for (const item of items) { if (!item.filtered) { contributionById.delete(item.id); } } const columns = clusterNativeColumns( items.map((item) => ({ ...item, x: item.nativeX })), positionTolerance, ); const placements = []; for (const column of columns) { const orderedItems = [...column.cards].sort( (left, right) => left.nativeY - right.nativeY, ); const firstNativeY = orderedItems[0].nativeY; let removedBefore = 0; for (const contribution of contributionById.values()) { if ( Math.abs(contribution.nativeColumnX - column.x) <= positionTolerance && contribution.nativeY < firstNativeY - positionTolerance ) { removedBefore += contribution.amount; } } for (const item of orderedItems) { if (item.filtered) { const contribution = { id: item.id, nativeColumnX: column.x, nativeY: item.nativeY, amount: item.removalAmount ?? item.height + verticalGap, }; contributionById.set(item.id, contribution); removedBefore += contribution.amount; continue; } let y = item.nativeY - removedBefore; if (y < 0 && Math.abs(y) <= positionTolerance) { y = 0; } if (!Number.isFinite(y) || y < 0) { throw new RangeError('contributions produce an invalid placement'); } placements.push({ element: item.element, id: item.id, x: item.nativeX, y, }); } } const allFiltered = placements.length === 0; const adjustedConnectedEnd = allFiltered ? 0 : Math.max( ...placements.map((placement) => { const item = itemById.get(placement.id); return placement.y + item.height + verticalGap; }), ); let containerHeight; if (allFiltered) { containerHeight = Math.max( nativeRootHeight, previousLogicalRootHeight, ); } else if (coversFeedStart || coversRootTail) { containerHeight = adjustedConnectedEnd; } else { containerHeight = Math.max( nativeRootHeight, previousLogicalRootHeight, adjustedConnectedEnd, ); } const placementById = new Map( placements.map((placement) => [placement.id, placement]), ); const cardPlans = items.map((item) => { if (item.filtered) { return { element: item.element, filtered: true, id: item.id, }; } const placement = placementById.get(item.id); return { element: item.element, filtered: false, id: item.id, x: placement.x, y: placement.y, }; }); return { cards: cardPlans, columnCount: columns.length, containerHeight, contributions: [...contributionById.values()].sort( (left, right) => left.nativeY - right.nativeY, ), }; } function parseCssPixelValue(value) { if (typeof value !== 'string' || !value.trim().endsWith('px')) { return Number.NaN; } return Number.parseFloat(value); } function measureNativeLayout( root, { getComputedStyle: readComputedStyle = globalThis.getComputedStyle, scrollY = globalThis.scrollY ?? 0, } = {}, ) { if (!root || typeof readComputedStyle !== 'function') { throw new TypeError('root and getComputedStyle are required'); } const rootBox = root.getBoundingClientRect(); const rootStyle = readComputedStyle(root); const cards = []; const otherDirectChildren = []; for (const child of Array.from(root.children ?? [])) { const childStyle = readComputedStyle(child); if (child.matches(TITLE_FILTER_DOM.cardSelector)) { const cardBox = child.getBoundingClientRect(); const inlineTransform = child.style.getPropertyValue('transform') || child.style.transform; cards.push({ borderBoxHeight: cardBox.height, borderBoxWidth: cardBox.width, element: child, id: child.getAttribute('data-note-id'), position: childStyle.position, transform: inlineTransform || childStyle.transform, x: cardBox.left - rootBox.left, y: cardBox.top - rootBox.top, }); continue; } const isFloatingControls = child.matches( TITLE_FILTER_DOM.floatingControlsSelector, ); otherDirectChildren.push({ element: child, kind: isFloatingControls ? 'floating-controls' : 'unknown', participatesInLayout: childStyle.position !== 'fixed', position: childStyle.position, }); } return { cards, gaps: { horizontal: parseCssPixelValue( rootStyle.getPropertyValue('--horizontalGapPx'), ), vertical: parseCssPixelValue( rootStyle.getPropertyValue('--verticalGapPx'), ), }, otherDirectChildren, root: { border: [ parseCssPixelValue(rootStyle.borderTopWidth), parseCssPixelValue(rootStyle.borderRightWidth), parseCssPixelValue(rootStyle.borderBottomWidth), parseCssPixelValue(rootStyle.borderLeftWidth), ], borderBoxHeight: rootBox.height, borderBoxWidth: rootBox.width, boxSizing: rootStyle.boxSizing, connected: root.isConnected, inlineHeight: root.style.getPropertyValue('height') || root.style.height, layoutFrozen: root.classList.contains('layout-frozen'), padding: [ parseCssPixelValue(rootStyle.paddingTop), parseCssPixelValue(rootStyle.paddingRight), parseCssPixelValue(rootStyle.paddingBottom), parseCssPixelValue(rootStyle.paddingLeft), ], position: rootStyle.position, staticLayout: root.classList.contains('static-layout'), }, scrollY, }; } function createRootLayoutTransaction({ deadlineAt, epoch, expectedCards, generation, root, }) { if ( !root || !Array.isArray(expectedCards) || !Number.isFinite(deadlineAt) ) { throw new TypeError('root, expectedCards and deadlineAt are required'); } return { deadlineAt, epoch, expectedCards: [...expectedCards], generation, journal: [], root, status: 'pending', writeCount: 0, }; } function createStaleTransactionError() { const error = new Error('root layout transaction is no longer current'); error.code = 'XHS_ENHANCER_STALE_TRANSACTION'; return error; } function assertTransactionCanWrite(transaction, runtime) { if ( transaction.status !== 'pending' || transaction.root.isConnected === false || runtime.now() > transaction.deadlineAt || !runtime.isCurrent(transaction) ) { throw createStaleTransactionError(); } } function recordAttribute(transaction, element, name) { transaction.journal.push({ element, hadValue: element.hasAttribute(name), kind: 'attribute', name, value: element.getAttribute(name), }); } function recordStyleProperty(transaction, element, name) { const value = element.style.getPropertyValue(name); transaction.journal.push({ element, hadValue: value !== '', kind: 'style', name, priority: element.style.getPropertyPriority(name), value, }); } function writeTransactionProperty( transaction, runtime, { element, kind, name, operation, priority = '', value = '' }, ) { assertTransactionCanWrite(transaction, runtime); runtime.beforeWrite?.({ element, index: transaction.writeCount, kind, name, operation, transaction, value, }); assertTransactionCanWrite(transaction, runtime); if (kind === 'attribute') { recordAttribute(transaction, element, name); if (operation === 'remove') { element.removeAttribute(name); } else { element.setAttribute(name, value); } } else { recordStyleProperty(transaction, element, name); if (operation === 'remove') { element.style.removeProperty(name); } else { element.style.setProperty(name, value, priority); } } runtime.onWrite?.({ element, index: transaction.writeCount, kind, name, operation, transaction, value, }); transaction.writeCount += 1; } function restorePropertyRecords(records) { const restoreErrors = []; for (const record of [...records].reverse()) { try { if (record.kind === 'attribute') { if (record.hadValue) { record.element.setAttribute(record.name, record.value); } else { record.element.removeAttribute(record.name); } } else if (record.hadValue) { record.element.style.setProperty( record.name, record.value, record.priority, ); } else { record.element.style.removeProperty(record.name); } } catch (error) { restoreErrors.push(error); } } if (restoreErrors.length > 0) { throw new AggregateError( restoreErrors, 'failed to restore root layout transaction', ); } } function rollbackRootLayoutTransaction(transaction) { if (transaction.status !== 'pending') { return false; } restorePropertyRecords(transaction.journal); transaction.journal = []; transaction.status = 'rolledback'; return true; } function validateRootLayoutPlan(transaction, plan) { requireFiniteNonNegative(plan?.containerHeight, 'plan.containerHeight'); if (!Array.isArray(plan?.cards)) { throw new TypeError('plan.cards must be an array'); } const expectedCards = new Set(transaction.expectedCards); const plannedCards = new Set(); for (const cardPlan of plan.cards) { if ( !expectedCards.has(cardPlan?.element) || plannedCards.has(cardPlan.element) || cardPlan.element.parentElement !== transaction.root || cardPlan.element.isConnected === false ) { throw new TypeError('plan must contain each connected card once'); } plannedCards.add(cardPlan.element); if (typeof cardPlan.filtered !== 'boolean') { throw new TypeError('cardPlan.filtered must be a boolean'); } if (!cardPlan.filtered) { requireFiniteNonNegative(cardPlan.x, 'cardPlan.x'); requireFiniteNonNegative(cardPlan.y, 'cardPlan.y'); } } if (plannedCards.size !== expectedCards.size) { throw new TypeError('plan must contain each connected card once'); } } function commitRootLayoutTransaction( transaction, plan, { beforeWrite, isCurrent = () => true, now = () => Date.now(), onWrite, } = {}, ) { const runtime = { beforeWrite, isCurrent, now, onWrite }; try { assertTransactionCanWrite(transaction, runtime); validateRootLayoutPlan(transaction, plan); for (const cardPlan of plan.cards) { writeTransactionProperty(transaction, runtime, { element: cardPlan.element, kind: 'attribute', name: TITLE_FILTER_DOM.filteredAttribute, operation: cardPlan.filtered ? 'set' : 'remove', }); writeTransactionProperty(transaction, runtime, { element: cardPlan.element, kind: 'style', name: TITLE_FILTER_DOM.xProperty, operation: cardPlan.filtered ? 'remove' : 'set', value: `${cardPlan.x}px`, }); writeTransactionProperty(transaction, runtime, { element: cardPlan.element, kind: 'style', name: TITLE_FILTER_DOM.yProperty, operation: cardPlan.filtered ? 'remove' : 'set', value: `${cardPlan.y}px`, }); writeTransactionProperty(transaction, runtime, { element: cardPlan.element, kind: 'attribute', name: TITLE_FILTER_DOM.ownedAttribute, operation: 'set', }); writeTransactionProperty(transaction, runtime, { element: cardPlan.element, kind: 'attribute', name: TITLE_FILTER_DOM.cardReadyAttribute, operation: 'set', }); } writeTransactionProperty(transaction, runtime, { element: transaction.root, kind: 'attribute', name: TITLE_FILTER_DOM.ownedAttribute, operation: 'set', }); writeTransactionProperty(transaction, runtime, { element: transaction.root, kind: 'style', name: TITLE_FILTER_DOM.feedHeightProperty, operation: 'set', value: `${plan.containerHeight}px`, }); writeTransactionProperty(transaction, runtime, { element: transaction.root, kind: 'attribute', name: TITLE_FILTER_DOM.compactedAttribute, operation: 'set', }); assertTransactionCanWrite(transaction, runtime); writeTransactionProperty(transaction, runtime, { element: transaction.root, kind: 'attribute', name: TITLE_FILTER_DOM.readyAttribute, operation: 'set', }); writeTransactionProperty(transaction, runtime, { element: transaction.root, kind: 'attribute', name: TITLE_FILTER_DOM.bypassAttribute, operation: 'remove', }); const committedState = { epoch: transaction.epoch, generation: transaction.generation, records: transaction.journal, root: transaction.root, status: 'committed', }; transaction.journal = []; transaction.status = 'committed'; return committedState; } catch (commitError) { try { rollbackRootLayoutTransaction(transaction); } catch (rollbackError) { throw new AggregateError( [commitError, rollbackError], 'root layout commit and rollback both failed', ); } throw commitError; } } function restoreCommittedLayout(committedState) { if (committedState?.status !== 'committed') { return false; } restorePropertyRecords(committedState.records); committedState.records = []; committedState.status = 'restored'; return true; } function isExploreRoute(location) { return location?.pathname === '/explore'; } function getDirectFeedCards(root) { return Array.from(root?.children ?? []).filter((child) => child.matches?.(TITLE_FILTER_DOM.cardSelector), ); } function getCardTitle(card) { return card ?.querySelector?.(TITLE_FILTER_DOM.titleSelector) ?.textContent ?? ''; } function findDirectCard(root, node) { let current = node?.nodeType === 3 ? node.parentElement : node; while (current && current !== root) { if ( current.parentElement === root && current.matches?.(TITLE_FILTER_DOM.cardSelector) ) { return current; } current = current.parentElement; } return null; } function isAnchorElement(element) { return String(element?.tagName ?? '').toLowerCase() === 'a'; } function findAnchorWithinCard(root, node) { const card = findDirectCard(root, node); if (!card) { return null; } let current = node?.nodeType === 3 ? node.parentElement : node; while (current && current !== card) { if (isAnchorElement(current)) { return current; } current = current.parentElement; } return isAnchorElement(card) ? card : null; } function nodeContainsTitleLink(node) { return Boolean( node?.matches?.(TITLE_FILTER_DOM.titleSelector) || node?.querySelector?.(TITLE_FILTER_DOM.titleSelector), ); } function collectAffectedCards(root, mutationRecords) { const addedCards = new Set(); const affectedCards = new Set(); const removedCards = new Set(); let structureChanged = false; let unsupportedRootStructureChanged = false; for (const record of mutationRecords) { if (record.type === 'attributes') { if (!findAnchorWithinCard(root, record.target)) { continue; } const card = findDirectCard(root, record.target); if (card) { affectedCards.add(card); } continue; } if (record.type === 'characterData') { if (!findAnchorWithinCard(root, record.target)) { continue; } const card = findDirectCard(root, record.target); if (card) { affectedCards.add(card); } continue; } if (record.type !== 'childList') { continue; } if (record.target === root) { for (const node of record.addedNodes ?? []) { if (node?.matches?.(TITLE_FILTER_DOM.cardSelector)) { structureChanged = true; affectedCards.add(node); addedCards.add(node); } else if ( node?.nodeType === 1 && !node.matches?.(TITLE_FILTER_DOM.floatingControlsSelector) ) { unsupportedRootStructureChanged = true; } } for (const node of record.removedNodes ?? []) { if (node?.matches?.(TITLE_FILTER_DOM.cardSelector)) { structureChanged = true; affectedCards.add(node); removedCards.add(node); } else if ( node?.nodeType === 1 && !node.matches?.(TITLE_FILTER_DOM.floatingControlsSelector) ) { unsupportedRootStructureChanged = true; } } continue; } const targetCard = findDirectCard(root, record.target); if (!targetCard) { continue; } if ( findAnchorWithinCard(root, record.target) || [...(record.addedNodes ?? []), ...(record.removedNodes ?? [])].some( nodeContainsTitleLink, ) ) { affectedCards.add(targetCard); } } return { addedCards, affectedCards, removedCards, structureChanged, unsupportedRootStructureChanged, }; } function findRootReplacementObserverTarget(root) { let ancestor = root?.parentElement; while (ancestor) { if (ancestor.getAttribute?.('id') === 'app') { return ancestor; } ancestor = ancestor.parentElement; } return null; } function createBrowserRuntime() { return { document, findRootReplacementObserverTarget, getComputedStyle: (element) => globalThis.getComputedStyle(element), getScrollY: () => globalThis.scrollY ?? 0, location: globalThis.location, observers: { createMutationObserver(callback) { return new MutationObserver(callback); }, createResizeObserver(callback) { if (typeof ResizeObserver !== 'function') { return null; } return new ResizeObserver(callback); }, }, routeSignals: { subscribe(callback) { const events = ['pageshow', 'popstate']; if ('onurlchange' in window) { events.push('urlchange'); } for (const eventName of events) { window.addEventListener(eventName, callback); } return () => { for (const eventName of events) { window.removeEventListener(eventName, callback); } }; }, }, scheduler: { cancelFrame: (id) => cancelAnimationFrame(id), clearTimer: (id) => clearTimeout(id), now: () => Date.now(), requestFrame: (callback) => requestAnimationFrame(callback), setTimer: (callback, delay) => setTimeout(callback, delay), }, }; } function createFeedTitleKeywordFilterController({ runtime, logger }) { if (!runtime?.document || !runtime?.scheduler || !runtime?.observers) { throw new TypeError('runtime document, scheduler and observers are required'); } const logError = typeof logger?.error === 'function' ? logger.error.bind(logger) : () => {}; const scheduler = runtime.scheduler; const initialRevealDeadline = (runtime.documentStartedAt ?? DOCUMENT_STARTED_AT) + TITLE_FILTER_LIFECYCLE.initialRevealTimeoutMs; const ownedBypassRoots = new Set(); const pendingAppendCards = new Set(); const pendingMatchByCard = new Map(); const observedCardHeights = new Map(); const seenCardIds = new Set(); const nativeTailByColumn = new Map(); let started = false; let effective = false; let enabled = true; let rawKeywords = ''; let compiledKeywords = compileBlockKeywords(''); let currentRoot = null; let currentGeneration = 0; let currentEpoch = 0; let currentWork = null; let committedState = null; let contributions = []; let logicalRootHeight = 0; let nativeLayoutSignature = null; let nativeModel = null; let lastRootWidth = null; let hasCommitted = false; let frameId = null; let watchdogTimerId = null; let initialRevealTimerId = null; let routeFallbackTimerId = null; let routeUnsubscribe = null; let discoveryObserver = null; let feedObserver = null; let replacementObserver = null; let resizeObserver = null; let bypassFallbackAttempts = 0; let bypassFallbackRetryAt = 0; let resetEpochRequested = false; let requireStableSnapshot = false; let stableSnapshotFingerprint = null; function setActiveClass(active) { runtime.document.documentElement?.classList.toggle( TITLE_FILTER_DOM.activeClass, active, ); } function clearFrame() { if (frameId !== null) { scheduler.cancelFrame(frameId); frameId = null; } } function clearWatchdog() { if (watchdogTimerId !== null) { scheduler.clearTimer(watchdogTimerId); watchdogTimerId = null; } } function clearInitialRevealTimer() { if (initialRevealTimerId !== null) { scheduler.clearTimer(initialRevealTimerId); initialRevealTimerId = null; } } function resetBypassFallbackRetry() { bypassFallbackAttempts = 0; bypassFallbackRetryAt = 0; } function removeOwnedBypass(root) { if (!root || !ownedBypassRoots.has(root)) { return; } root.removeAttribute(TITLE_FILTER_DOM.bypassAttribute); ownedBypassRoots.delete(root); if (root === currentRoot) { resetBypassFallbackRetry(); } } function isRootBypassRecord(record, root) { return ( record.element === root && record.kind === 'attribute' && record.name === TITLE_FILTER_DOM.bypassAttribute ); } function restoreCurrentCommit() { if (!committedState) { return; } if (currentRoot && ownedBypassRoots.has(currentRoot)) { // 测量期 bypass 必须跨过旧提交恢复;旧 journal 里的“原先无 bypass” // 记录若参与恢复,会在 ready 尚未重建时制造一次双 gate 缺失。 committedState.records = committedState.records.filter( (record) => !isRootBypassRecord(record, currentRoot), ); } try { restoreCommittedLayout(committedState); } catch (error) { logError('恢复标题过滤布局失败', error); } committedState = null; } function markRootBypass(root) { if (!root?.isConnected) { return; } const isNewBypass = !ownedBypassRoots.has(root); root.setAttribute(TITLE_FILTER_DOM.bypassAttribute, ''); ownedBypassRoots.add(root); if (isNewBypass && root === currentRoot) { resetBypassFallbackRetry(); } } function failOpenCurrentRoot(work = currentWork) { if (work && work !== currentWork) { return; } clearFrame(); clearWatchdog(); currentWork = null; restoreCurrentCommit(); if (currentRoot) { currentRoot.removeAttribute(TITLE_FILTER_DOM.readyAttribute); markRootBypass(currentRoot); } pendingMatchByCard.clear(); pendingAppendCards.clear(); contributions = []; logicalRootHeight = 0; nativeLayoutSignature = null; nativeModel = null; nativeTailByColumn.clear(); seenCardIds.clear(); requireStableSnapshot = false; stableSnapshotFingerprint = null; } function disconnectRootObservers() { feedObserver?.disconnect(); feedObserver = null; replacementObserver?.disconnect(); replacementObserver = null; resizeObserver?.disconnect(); resizeObserver = null; observedCardHeights.clear(); } function detachCurrentRoot() { clearFrame(); clearWatchdog(); currentWork = null; disconnectRootObservers(); restoreCurrentCommit(); removeOwnedBypass(currentRoot); currentRoot = null; pendingMatchByCard.clear(); pendingAppendCards.clear(); contributions = []; logicalRootHeight = 0; nativeLayoutSignature = null; nativeModel = null; nativeTailByColumn.clear(); seenCardIds.clear(); lastRootWidth = null; requireStableSnapshot = false; stableSnapshotFingerprint = null; } function stopDiscovery() { discoveryObserver?.disconnect(); discoveryObserver = null; } function primeCardMatches(cards) { for (const card of cards) { if (card?.parentElement !== currentRoot) { continue; } pendingMatchByCard.set( card, matchesAnyBlockKeyword(getCardTitle(card), compiledKeywords), ); } } function createLayoutSignature(snapshot, validation) { const firstCardWidth = snapshot.cards[0]?.borderBoxWidth ?? 0; return JSON.stringify({ cardWidth: firstCardWidth.toFixed(2), columnXs: validation.columnXs.map((value) => value.toFixed(2)), horizontalGap: snapshot.gaps.horizontal.toFixed(2), rootWidth: snapshot.root.borderBoxWidth.toFixed(2), verticalGap: snapshot.gaps.vertical.toFixed(2), }); } function createSnapshotFingerprint(snapshot) { return JSON.stringify({ cards: snapshot.cards.map((card) => [ card.id, card.x.toFixed(2), card.y.toFixed(2), card.borderBoxWidth.toFixed(2), card.borderBoxHeight.toFixed(2), ]), horizontalGap: snapshot.gaps.horizontal.toFixed(2), rootWidth: snapshot.root.borderBoxWidth.toFixed(2), verticalGap: snapshot.gaps.vertical.toFixed(2), }); } function findNativeColumnX(x, columnXs, tolerance = 0.1) { return columnXs.find((columnX) => Math.abs(columnX - x) <= tolerance); } function updateNativeLedger(snapshot, validation, reset) { if (reset) { nativeTailByColumn.clear(); seenCardIds.clear(); } nativeModel = { cardWidth: snapshot.cards[0].borderBoxWidth, columnXs: [...validation.columnXs], horizontalGap: snapshot.gaps.horizontal, rootWidth: snapshot.root.borderBoxWidth, verticalGap: snapshot.gaps.vertical, }; for (const card of snapshot.cards) { seenCardIds.add(card.id); const columnX = findNativeColumnX(card.x, nativeModel.columnXs); if (columnX === undefined) { continue; } nativeTailByColumn.set( columnX, Math.max( nativeTailByColumn.get(columnX) ?? 0, card.y + card.borderBoxHeight + nativeModel.verticalGap, ), ); } } function pruneDisconnectedCommitRecords() { if (!committedState || !currentRoot) { return; } const disconnectedRecords = []; const connectedRecords = []; for (const record of committedState.records) { if ( record.element !== currentRoot && (record.element.isConnected === false || record.element.parentElement !== currentRoot) ) { disconnectedRecords.push(record); } else { connectedRecords.push(record); } } if (disconnectedRecords.length > 0) { restorePropertyRecords(disconnectedRecords); committedState.records = connectedRecords; } } function measureAppendCards(cards) { if (!nativeModel || !currentRoot) { return null; } const rootBox = currentRoot.getBoundingClientRect(); const rootStyle = runtime.getComputedStyle(currentRoot); const horizontalGap = parseCssPixelValue( rootStyle.getPropertyValue('--horizontalGapPx'), ); const verticalGap = parseCssPixelValue( rootStyle.getPropertyValue('--verticalGapPx'), ); if ( currentRoot.classList.contains('layout-frozen') || currentRoot.classList.contains('static-layout') || Math.abs(rootBox.width - nativeModel.rootWidth) > 0.5 || Math.abs(horizontalGap - nativeModel.horizontalGap) > 0.5 || Math.abs(verticalGap - nativeModel.verticalGap) > 0.5 ) { return null; } const measuredCards = []; const batchIds = new Set(); for (const element of cards) { if ( element.parentElement !== currentRoot || element.isConnected === false || !element.matches(TITLE_FILTER_DOM.cardSelector) ) { continue; } const id = element.getAttribute('data-note-id'); const cardStyle = runtime.getComputedStyle(element); const box = element.getBoundingClientRect(); const inlineTransform = element.style.getPropertyValue('transform') || element.style.transform; const translation = parseNativeTranslation( inlineTransform || cardStyle.transform, ); const columnX = translation ? findNativeColumnX(translation.x, nativeModel.columnXs) : undefined; if ( !id || batchIds.has(id) || cardStyle.position !== 'absolute' || !translation || columnX === undefined || !Number.isFinite(box.width) || Math.abs(box.width - nativeModel.cardWidth) > 0.5 || !Number.isFinite(box.height) || box.height < 0 || translation.y < 0 ) { return null; } batchIds.add(id); measuredCards.push({ borderBoxHeight: box.height, element, id, nativeX: columnX, nativeY: translation.y, }); } if (measuredCards.length === 0) { return null; } const cardsByColumn = clusterNativeColumns( measuredCards.map((card) => ({ ...card, x: card.nativeX })), 0.1, ); for (const column of cardsByColumn) { const orderedCards = [...column.cards].sort( (left, right) => left.nativeY - right.nativeY, ); for (let index = 0; index < orderedCards.length; index += 1) { const card = orderedCards[index]; const nextCard = orderedCards[index + 1]; const removalAmount = nextCard ? nextCard.nativeY - card.nativeY : card.borderBoxHeight + nativeModel.verticalGap; const allocatedHeight = removalAmount - nativeModel.verticalGap; if ( !Number.isFinite(removalAmount) || allocatedHeight < 0 || Math.abs(allocatedHeight - card.borderBoxHeight) > TITLE_FILTER_LAYOUT.cardHeightDriftTolerance ) { return { epochReset: true }; } card.removalAmount = removalAmount; } } const nextTailByColumn = new Map(nativeTailByColumn); for (const card of [...measuredCards].sort( (left, right) => left.nativeY - right.nativeY, )) { const previousTail = nextTailByColumn.get(card.nativeX); if ( !seenCardIds.has(card.id) && previousTail !== undefined && Math.abs(card.nativeY - previousTail) > TITLE_FILTER_LAYOUT.cardHeightDriftTolerance ) { return { epochReset: true }; } nextTailByColumn.set( card.nativeX, Math.max( previousTail ?? 0, card.nativeY + card.borderBoxHeight + nativeModel.verticalGap, ), ); } const inlineRootHeight = parseCssPixelValue( currentRoot.style.getPropertyValue('height') || currentRoot.style.height, ); return { cards: measuredCards, nativeRootHeight: Number.isFinite(inlineRootHeight) ? inlineRootHeight : logicalRootHeight, nextTailByColumn, }; } function refreshResizeObservation(snapshot, plan) { resizeObserver?.disconnect(); observedCardHeights.clear(); if (!resizeObserver || !currentRoot) { return; } resizeObserver.observe(currentRoot); const snapshotCardByElement = new Map( snapshot.cards.map((card) => [card.element, card]), ); for (const cardPlan of plan.cards) { if (cardPlan.filtered) { continue; } const height = snapshotCardByElement.get(cardPlan.element)?.borderBoxHeight; if (Number.isFinite(height)) { observedCardHeights.set(cardPlan.element, height); resizeObserver.observe(cardPlan.element); } } } function isWorkCurrent(work) { return ( work === currentWork && work.root === currentRoot && work.generation === currentGeneration && effective && isExploreRoute(runtime.location) ); } function finishAppendFrame(work) { frameId = null; if ( !isWorkCurrent(work) || !committedState || !currentRoot?.isConnected ) { return; } pruneDisconnectedCommitRecords(); const appendMeasurement = measureAppendCards(pendingAppendCards); if (appendMeasurement?.epochReset) { pendingAppendCards.clear(); work.kind = 'root'; resetEpochRequested = true; finishLayoutFrame(work); return; } if (!appendMeasurement) { return; } primeCardMatches( appendMeasurement.cards.map((card) => card.element), ); let plan; try { plan = computeColumnCompaction({ items: appendMeasurement.cards.map((card) => ({ element: card.element, filtered: pendingMatchByCard.get(card.element) === true, height: card.borderBoxHeight, id: card.id, nativeX: card.nativeX, nativeY: card.nativeY, removalAmount: card.removalAmount, })), nativeRootHeight: appendMeasurement.nativeRootHeight, previousLogicalRootHeight: logicalRootHeight, priorContributions: contributions, verticalGap: nativeModel.verticalGap, }); } catch (error) { logError('计算新增卡片标题过滤布局失败', error); failOpenCurrentRoot(work); return; } const transaction = createRootLayoutTransaction({ deadlineAt: work.deadlineAt, epoch: currentEpoch, expectedCards: appendMeasurement.cards.map((card) => card.element), generation: work.generation, root: currentRoot, }); let appendState; try { appendState = commitRootLayoutTransaction(transaction, plan, { isCurrent: () => isWorkCurrent(work), now: scheduler.now, }); } catch (error) { logError('提交新增卡片标题过滤布局失败', error); failOpenCurrentRoot(work); return; } committedState.records.push( ...appendState.records.filter((record) => record.element !== currentRoot), ); appendState.records = []; appendState.status = 'merged'; contributions = plan.contributions; logicalRootHeight = plan.containerHeight; nativeTailByColumn.clear(); for (const [columnX, tail] of appendMeasurement.nextTailByColumn) { nativeTailByColumn.set(columnX, tail); } for (const card of appendMeasurement.cards) { seenCardIds.add(card.id); pendingAppendCards.delete(card.element); } for (const cardPlan of plan.cards) { if (!cardPlan.filtered) { const height = appendMeasurement.cards.find( (card) => card.element === cardPlan.element, )?.borderBoxHeight; if (Number.isFinite(height)) { observedCardHeights.set(cardPlan.element, height); resizeObserver?.observe(cardPlan.element); } } } currentWork = null; clearWatchdog(); } function finishLayoutFrame(work) { frameId = null; if (!isWorkCurrent(work) || !currentRoot?.isConnected) { return; } // Edge Trace 证明强制样式读取可能捕获同一工作帧内的中间 CSS 状态。 // 已展示过的文档必须先用 bypass 接住网站布局,再撤 ready 读取原生几何。 if (hasCommitted) { markRootBypass(currentRoot); } currentRoot.removeAttribute(TITLE_FILTER_DOM.readyAttribute); let snapshot; let validation; try { snapshot = measureNativeLayout(currentRoot, { getComputedStyle: runtime.getComputedStyle, scrollY: runtime.getScrollY?.() ?? 0, }); validation = validateNativeMasonryModel(snapshot); } catch (error) { logError('读取小红书原生瀑布流失败', error); failOpenCurrentRoot(work); return; } if (!validation.ok) { if (committedState && isWorkCurrent(work)) { currentRoot.setAttribute(TITLE_FILTER_DOM.readyAttribute, ''); removeOwnedBypass(currentRoot); } return; } if (requireStableSnapshot) { const nextFingerprint = createSnapshotFingerprint(snapshot); if (stableSnapshotFingerprint !== nextFingerprint) { stableSnapshotFingerprint = nextFingerprint; if (committedState && isWorkCurrent(work)) { // 先恢复 ready 再撤 bypass,任何样式刷新点都至少保留一个可见 gate。 currentRoot.setAttribute(TITLE_FILTER_DOM.readyAttribute, ''); removeOwnedBypass(currentRoot); } frameId = scheduler.requestFrame(() => finishWorkFrame(work)); return; } requireStableSnapshot = false; stableSnapshotFingerprint = null; } primeCardMatches(snapshot.cards.map((card) => card.element)); const nextSignature = createLayoutSignature(snapshot, validation); const didResetEpoch = resetEpochRequested || nativeLayoutSignature !== nextSignature; if (didResetEpoch) { contributions = []; logicalRootHeight = snapshot.root.borderBoxHeight; nativeLayoutSignature = nextSignature; currentEpoch += 1; } resetEpochRequested = false; let plan; try { const removalAmountById = new Map( validation.removalAmounts.map(({ amount, id }) => [id, amount]), ); plan = computeColumnCompaction({ coversFeedStart: validation.coversFeedStart, coversRootTail: validation.coversRootTail, items: snapshot.cards.map((card) => ({ element: card.element, filtered: pendingMatchByCard.get(card.element) === true, height: card.borderBoxHeight, id: card.id, nativeX: card.x, nativeY: card.y, removalAmount: removalAmountById.get(card.id), })), nativeRootHeight: snapshot.root.borderBoxHeight, previousLogicalRootHeight: logicalRootHeight, priorContributions: contributions, verticalGap: snapshot.gaps.vertical, }); } catch (error) { logError('计算标题过滤布局失败', error); failOpenCurrentRoot(work); return; } restoreCurrentCommit(); if (!isWorkCurrent(work)) { return; } const transaction = createRootLayoutTransaction({ deadlineAt: work.deadlineAt, epoch: currentEpoch, expectedCards: snapshot.cards.map((card) => card.element), generation: work.generation, root: currentRoot, }); const committingOwnedBypass = ownedBypassRoots.has(currentRoot); try { committedState = commitRootLayoutTransaction(transaction, plan, { isCurrent: () => isWorkCurrent(work), now: scheduler.now, }); } catch (error) { logError('提交标题过滤布局失败', error); failOpenCurrentRoot(work); return; } if (committingOwnedBypass) { // bypass 是测量期的脚本临时状态;提交成功后不能把它写进 stop 的恢复基线。 committedState.records = committedState.records.filter( (record) => !isRootBypassRecord(record, currentRoot), ); ownedBypassRoots.delete(currentRoot); } contributions = plan.contributions; logicalRootHeight = plan.containerHeight; lastRootWidth = snapshot.root.borderBoxWidth; hasCommitted = true; clearInitialRevealTimer(); currentWork = null; pendingAppendCards.clear(); clearWatchdog(); setActiveClass(true); updateNativeLedger(snapshot, validation, didResetEpoch); refreshResizeObservation(snapshot, plan); } function finishWorkFrame(work) { if (work.kind === 'append') { finishAppendFrame(work); } else { finishLayoutFrame(work); } } function startWorkWatchdog(work) { const remaining = Math.max(0, work.deadlineAt - scheduler.now()); watchdogTimerId = scheduler.setTimer(() => { watchdogTimerId = null; if (isWorkCurrent(work)) { failOpenCurrentRoot(work); } }, remaining); } function scheduleLayout({ allowBypass = false, appendCards = [], resetEpoch = false, } = {}) { if ( !effective || !currentRoot?.isConnected || !isExploreRoute(runtime.location) || (ownedBypassRoots.has(currentRoot) && !allowBypass) ) { return; } resetEpochRequested ||= resetEpoch; for (const card of appendCards) { if (card.parentElement === currentRoot && card.isConnected !== false) { pendingAppendCards.add(card); } } const canAppendIncrementally = !resetEpoch && appendCards.length > 0 && committedState !== null && nativeModel !== null && pendingAppendCards.size > 0; if (!canAppendIncrementally) { primeCardMatches(getDirectFeedCards(currentRoot)); } if (!currentWork) { const now = scheduler.now(); const canUseInitialDeadline = !hasCommitted && now < initialRevealDeadline; currentWork = { deadlineAt: canUseInitialDeadline ? initialRevealDeadline : now + TITLE_FILTER_LIFECYCLE.transactionWatchdogMs, epoch: currentEpoch + 1, generation: currentGeneration, kind: canAppendIncrementally ? 'append' : 'root', root: currentRoot, }; clearWatchdog(); startWorkWatchdog(currentWork); } else if (!canAppendIncrementally) { currentWork.kind = 'root'; } if (currentWork.kind === 'root' && !committedState) { // 首次接管仍需预 gate;已有提交则保持可见,到工作帧内再原子测量和替换。 currentRoot.removeAttribute(TITLE_FILTER_DOM.readyAttribute); } if (frameId === null) { const work = currentWork; frameId = scheduler.requestFrame(() => finishWorkFrame(work)); } } function retryBypassedRoot({ fromFallback = false, resetEpoch = true } = {}) { if (!currentRoot || !ownedBypassRoots.has(currentRoot)) { return; } if ( currentRoot.classList.contains('layout-frozen') || currentRoot.classList.contains('static-layout') ) { return; } if (fromFallback) { if (currentWork) { return; } const now = scheduler.now(); if ( bypassFallbackAttempts >= TITLE_FILTER_LIFECYCLE.bypassFallbackMaxAttempts || now < bypassFallbackRetryAt ) { return; } bypassFallbackAttempts += 1; const retryDelay = Math.min( TITLE_FILTER_LIFECYCLE.routeFallbackIntervalMs * 2 ** (bypassFallbackAttempts - 1), TITLE_FILTER_LIFECYCLE.bypassFallbackMaxDelayMs, ); bypassFallbackRetryAt = now + retryDelay; } if (!currentWork) { requireStableSnapshot = true; stableSnapshotFingerprint = null; } scheduleLayout({ allowBypass: true, resetEpoch }); } function handleFeedMutations(records) { if (!effective || !currentRoot) { return; } const layoutModeChanged = records.some( (record) => record.type === 'attributes' && record.attributeName === 'class' && record.target === currentRoot, ); const rootStructureChanged = records.some( (record) => record.type === 'childList' && record.target === currentRoot, ); if ( layoutModeChanged && (currentRoot.classList.contains('layout-frozen') || currentRoot.classList.contains('static-layout')) ) { if (!ownedBypassRoots.has(currentRoot)) { failOpenCurrentRoot(); } return; } if (ownedBypassRoots.has(currentRoot)) { if (layoutModeChanged || rootStructureChanged) { resetBypassFallbackRetry(); retryBypassedRoot({ resetEpoch: true }); } return; } const { addedCards, affectedCards, removedCards, structureChanged, unsupportedRootStructureChanged, } = collectAffectedCards(currentRoot, records); if (unsupportedRootStructureChanged) { failOpenCurrentRoot(); return; } if (!structureChanged && affectedCards.size === 0) { return; } primeCardMatches(affectedCards); for (const card of removedCards) { pendingAppendCards.delete(card); pendingMatchByCard.delete(card); observedCardHeights.delete(card); resizeObserver?.unobserve?.(card); } pruneDisconnectedCommitRecords(); if (currentWork?.kind === 'append' && pendingAppendCards.size === 0) { clearFrame(); clearWatchdog(); currentWork = null; } const connectedUnreadyCards = [...affectedCards].filter( (card) => card.parentElement === currentRoot && !card.hasAttribute(TITLE_FILTER_DOM.cardReadyAttribute), ); const touchesCommittedCard = [...affectedCards].some( (card) => card.parentElement === currentRoot && card.hasAttribute(TITLE_FILTER_DOM.cardReadyAttribute), ); const canAppendIncrementally = committedState !== null && !touchesCommittedCard && [...addedCards].every((card) => connectedUnreadyCards.includes(card)); if (canAppendIncrementally) { if (connectedUnreadyCards.length > 0) { scheduleLayout({ appendCards: connectedUnreadyCards }); } return; } scheduleLayout(); } function handleResize(entries) { if (!effective || !currentRoot) { return; } let rootWidthChanged = false; let cardHeightChanged = false; for (const entry of entries) { if (entry.target === currentRoot) { const width = currentRoot.getBoundingClientRect().width; if ( Number.isFinite(width) && lastRootWidth !== null && Math.abs(width - lastRootWidth) > 0.5 ) { rootWidthChanged = true; } continue; } const previousHeight = observedCardHeights.get(entry.target); if (previousHeight === undefined) { continue; } const height = entry.target.getBoundingClientRect().height; if (Number.isFinite(height) && Math.abs(height - previousHeight) > 0.5) { observedCardHeights.set(entry.target, height); cardHeightChanged = true; } } if (rootWidthChanged) { requireStableSnapshot = true; stableSnapshotFingerprint = null; resetBypassFallbackRetry(); retryBypassedRoot({ resetEpoch: true }); scheduleLayout({ resetEpoch: true }); } else if (cardHeightChanged) { scheduleLayout(); } } function connectRoot(root) { if (!root?.isConnected || root === currentRoot) { return; } detachCurrentRoot(); stopDiscovery(); currentRoot = root; currentGeneration += 1; currentEpoch = 0; resetEpochRequested = true; const preserveNativeLayoutUntilCommit = hasCommitted; if (preserveNativeLayoutUntilCommit) { // 关闭后重启、SPA 返回或响应式根替换时,页面已经向用户展示过信息流。 // 先保留新根的原生布局,待稳定快照提交的同一帧再撤掉 bypass,避免整根闪白。 markRootBypass(currentRoot); } try { feedObserver = runtime.observers.createMutationObserver((records) => { try { handleFeedMutations(records); } catch (error) { logError('处理信息流变更失败', error); failOpenCurrentRoot(); } }); feedObserver.observe(currentRoot, { attributeFilter: ['href', 'class'], attributeOldValue: true, attributes: true, characterData: true, childList: true, subtree: true, }); const replacementObserverTarget = runtime.findRootReplacementObserverTarget?.(currentRoot); if ( replacementObserverTarget && replacementObserverTarget !== currentRoot ) { replacementObserver = runtime.observers.createMutationObserver(() => { if (currentRoot?.isConnected) { return; } try { syncRouteAndRoot(); } catch (error) { logError('处理信息流根替换失败', error); failOpenCurrentRoot(); } }); replacementObserver.observe(replacementObserverTarget, { childList: true, subtree: true, }); } resizeObserver = runtime.observers.createResizeObserver((entries) => { try { handleResize(entries); } catch (error) { logError('处理信息流尺寸变化失败', error); failOpenCurrentRoot(); } }); resizeObserver?.observe(currentRoot); scheduleLayout({ allowBypass: preserveNativeLayoutUntilCommit, resetEpoch: true, }); } catch (error) { logError('启动信息流观察失败', error); disconnectRootObservers(); failOpenCurrentRoot(); } } function startDiscovery() { if (discoveryObserver || !runtime.document.documentElement) { return; } try { discoveryObserver = runtime.observers.createMutationObserver(() => { try { syncRouteAndRoot(); } catch (error) { logError('发现信息流根失败', error); setActiveClass(false); } }); discoveryObserver.observe(runtime.document.documentElement, { childList: true, subtree: true, }); } catch (error) { discoveryObserver = null; logError('启动信息流根发现观察失败', error); setActiveClass(false); } } function syncRouteAndRoot() { if (!effective) { return; } if (!isExploreRoute(runtime.location)) { // 详情以遮罩覆盖首页;先撤预隐藏门控,再恢复布局,保留网站原生背景。 setActiveClass(false); stopDiscovery(); detachCurrentRoot(); return; } if (scheduler.now() < initialRevealDeadline || hasCommitted) { setActiveClass(true); } const nextRoot = runtime.document.querySelector( TITLE_FILTER_DOM.rootSelector, ); if (nextRoot) { if (nextRoot !== currentRoot) { connectRoot(nextRoot); } return; } if (currentRoot) { detachCurrentRoot(); } startDiscovery(); } function routeFallbackTick() { routeFallbackTimerId = null; if (!effective) { return; } try { syncRouteAndRoot(); if ( currentRoot && hasCommitted && ownedBypassRoots.has(currentRoot) ) { // bypass 保持网站布局可见;后台取得两份稳定快照后再原子接管。 retryBypassedRoot({ fromFallback: true, resetEpoch: true }); } else if (currentRoot && lastRootWidth !== null) { const width = currentRoot.getBoundingClientRect().width; if (Number.isFinite(width) && Math.abs(width - lastRootWidth) > 0.5) { requireStableSnapshot = true; stableSnapshotFingerprint = null; resetBypassFallbackRetry(); retryBypassedRoot({ resetEpoch: true }); scheduleLayout({ resetEpoch: true }); } } } catch (error) { logError('路由 fallback 检查失败', error); failOpenCurrentRoot(); } if (effective) { routeFallbackTimerId = scheduler.setTimer( routeFallbackTick, TITLE_FILTER_LIFECYCLE.routeFallbackIntervalMs, ); } } function expireInitialGate() { initialRevealTimerId = null; if (!effective || hasCommitted) { return; } setActiveClass(false); if (currentWork) { failOpenCurrentRoot(currentWork); } } function activate() { if (effective) { return; } effective = true; if ( isExploreRoute(runtime.location) && (scheduler.now() < initialRevealDeadline || hasCommitted) ) { setActiveClass(true); } else { setActiveClass(false); } try { routeUnsubscribe = runtime.routeSignals?.subscribe(() => { try { syncRouteAndRoot(); } catch (error) { logError('处理页面路由变化失败', error); failOpenCurrentRoot(); } }) ?? null; } catch (error) { routeUnsubscribe = null; logError('订阅页面路由变化失败', error); } routeFallbackTick(); const remaining = initialRevealDeadline - scheduler.now(); if (!hasCommitted && remaining > 0) { initialRevealTimerId = scheduler.setTimer(expireInitialGate, remaining); } syncRouteAndRoot(); } function deactivate() { if (!effective) { return; } effective = false; setActiveClass(false); clearInitialRevealTimer(); if (routeFallbackTimerId !== null) { scheduler.clearTimer(routeFallbackTimerId); routeFallbackTimerId = null; } routeUnsubscribe?.(); routeUnsubscribe = null; stopDiscovery(); detachCurrentRoot(); } function applySettings(nextSettings) { enabled = nextSettings?.enabled !== false; rawKeywords = String(nextSettings?.keywords ?? ''); compiledKeywords = compileBlockKeywords(rawKeywords); const shouldBeEffective = enabled && compiledKeywords.length > 0; if (!shouldBeEffective) { deactivate(); return; } if (!effective) { activate(); return; } contributions = []; logicalRootHeight = currentRoot?.getBoundingClientRect().height ?? 0; nativeLayoutSignature = null; pendingMatchByCard.clear(); resetBypassFallbackRetry(); retryBypassedRoot({ resetEpoch: true }); scheduleLayout({ resetEpoch: true }); } return Object.freeze({ start(initialSettings) { if (started) { return false; } started = true; applySettings(initialSettings); return true; }, stop() { if (!started) { return false; } deactivate(); started = false; return true; }, updateSettings(nextSettings) { if (!started) { throw new Error('controller must be started before updating settings'); } applySettings(nextSettings); }, }); } function bootstrapFeedTitleKeywordFilter(overrides = {}) { const getValue = overrides.getValue ?? (typeof GM_getValue === 'function' ? GM_getValue : null); const setValue = overrides.setValue ?? (typeof GM_setValue === 'function' ? GM_setValue : null); const registerMenu = overrides.registerMenu ?? (typeof GM_registerMenuCommand === 'function' ? GM_registerMenuCommand : null); const promptUser = overrides.promptUser ?? globalThis.prompt; const logger = overrides.logger ?? console; if (!getValue || !setValue || !registerMenu) { return null; } let settings; try { settings = { enabled: getValue(FEED_TITLE_FILTER_STORAGE.enabled, true) !== false, keywords: String( getValue(FEED_TITLE_FILTER_STORAGE.keywords, '') ?? '', ), }; } catch (error) { logger.error('[小红书 Web 增强] 读取标题屏蔽设置失败', error); return null; } const controller = overrides.createController ? overrides.createController() : createFeedTitleKeywordFilterController({ logger, runtime: overrides.runtime ?? createBrowserRuntime(), }); controller.start(settings); let statusMenuId = null; let editMenuId = null; function getConfiguredKeywordCount() { return compileBlockKeywords(settings.keywords).length; } function editKeywords() { const nextKeywords = promptUser?.( '请输入标题屏蔽词,多个关键词用 | 分隔:', settings.keywords, ); if (nextKeywords === null || nextKeywords === undefined) { return; } const nextSettings = { ...settings, keywords: nextKeywords }; let settingsChanged = false; try { setValue(FEED_TITLE_FILTER_STORAGE.keywords, nextSettings.keywords); settings = nextSettings; settingsChanged = true; controller.updateSettings(settings); } catch (error) { logger.error('[小红书 Web 增强] 保存标题屏蔽词失败', error); } finally { if (settingsChanged) { refreshMenus(); } } } function toggleKeywordFilter() { if (getConfiguredKeywordCount() === 0) { editKeywords(); return; } const nextSettings = { ...settings, enabled: !settings.enabled }; let settingsChanged = false; try { setValue(FEED_TITLE_FILTER_STORAGE.enabled, nextSettings.enabled); settings = nextSettings; settingsChanged = true; controller.updateSettings(settings); } catch (error) { logger.error('[小红书 Web 增强] 保存标题屏蔽开关失败', error); } finally { if (settingsChanged) { refreshMenus(); } } } function refreshMenus() { const keywordCount = getConfiguredKeywordCount(); const statusLabel = keywordCount === 0 ? '标题关键词屏蔽:未配置' : settings.enabled ? `标题关键词屏蔽:已开启(${keywordCount} 个词)` : `标题关键词屏蔽:已关闭(已保存 ${keywordCount} 个词)`; const editLabel = keywordCount === 0 ? '编辑标题屏蔽词' : `编辑标题屏蔽词(${keywordCount} 个)`; try { statusMenuId = registerMenu( statusLabel, toggleKeywordFilter, statusMenuId === null ? undefined : { id: statusMenuId }, ); editMenuId = registerMenu( editLabel, editKeywords, editMenuId === null ? undefined : { id: editMenuId }, ); } catch (error) { logger.error('[小红书 Web 增强] 刷新脚本菜单状态失败', error); } } refreshMenus(); return controller; } let checkScheduled = false; let automaticClickAttempted = false; let automaticFilterCompleted = false; let automaticLoadingObserved = false; let automaticAttemptTimeoutId = null; let filterCheckIntervalId = null; let filterObserver = null; let feedRevealTimeoutId = null; let feedVisibilityRevealed = false; let feedGuardStyleInstalled = false; function installFeedGuardStyle() { if (feedGuardStyleInstalled) { return; } const style = document.createElement('style'); style.textContent = ` #exploreFeeds { transition: opacity 120ms ease-out; } html.${AUTO_IMAGE_NOTE_FILTER.pendingClass} #exploreFeeds { opacity: 0 !important; pointer-events: none !important; visibility: hidden !important; } html.${TITLE_FILTER_DOM.activeClass} ${TITLE_FILTER_DOM.rootSelector}:not([${TITLE_FILTER_DOM.readyAttribute}]):not([${TITLE_FILTER_DOM.bypassAttribute}]) { pointer-events: none !important; visibility: hidden !important; } html.${TITLE_FILTER_DOM.activeClass} ${TITLE_FILTER_DOM.rootSelector}[${TITLE_FILTER_DOM.readyAttribute}]:not([${TITLE_FILTER_DOM.bypassAttribute}]) > ${TITLE_FILTER_DOM.cardSelector}:not([${TITLE_FILTER_DOM.cardReadyAttribute}]) { pointer-events: none !important; visibility: hidden !important; } html.${TITLE_FILTER_DOM.activeClass} ${TITLE_FILTER_DOM.rootSelector}[${TITLE_FILTER_DOM.ownedAttribute}][${TITLE_FILTER_DOM.compactedAttribute}][${TITLE_FILTER_DOM.readyAttribute}] { height: var(${TITLE_FILTER_DOM.feedHeightProperty}) !important; } html.${TITLE_FILTER_DOM.activeClass} ${TITLE_FILTER_DOM.rootSelector}[${TITLE_FILTER_DOM.compactedAttribute}][${TITLE_FILTER_DOM.readyAttribute}] > ${TITLE_FILTER_DOM.cardSelector}[${TITLE_FILTER_DOM.ownedAttribute}][${TITLE_FILTER_DOM.cardReadyAttribute}]:not([${TITLE_FILTER_DOM.filteredAttribute}]) { transform: translate( var(${TITLE_FILTER_DOM.xProperty}), var(${TITLE_FILTER_DOM.yProperty}) ) !important; transition: none !important; } html.${TITLE_FILTER_DOM.activeClass} ${TITLE_FILTER_DOM.rootSelector}[${TITLE_FILTER_DOM.compactedAttribute}][${TITLE_FILTER_DOM.readyAttribute}] > ${TITLE_FILTER_DOM.cardSelector}[${TITLE_FILTER_DOM.ownedAttribute}][${TITLE_FILTER_DOM.cardReadyAttribute}][${TITLE_FILTER_DOM.filteredAttribute}] { display: none !important; } .floating-btn-sets .back-top:not(.active) { display: none !important; } .floating-btn-sets:has(.back-top.active) #image-note-filter-el { display: none !important; } @media (prefers-reduced-motion: reduce) { #exploreFeeds { transition: none; } } `; document.documentElement.append(style); feedGuardStyleInstalled = true; } function revealFeed() { feedVisibilityRevealed = true; document.documentElement?.classList.remove( AUTO_IMAGE_NOTE_FILTER.pendingClass, ); if (feedRevealTimeoutId !== null) { clearTimeout(feedRevealTimeoutId); feedRevealTimeoutId = null; } } function completeAutomaticFilter() { if (automaticFilterCompleted) { return; } automaticFilterCompleted = true; revealFeed(); filterObserver?.disconnect(); filterObserver = null; if (filterCheckIntervalId !== null) { clearInterval(filterCheckIntervalId); filterCheckIntervalId = null; } if (automaticAttemptTimeoutId !== null) { clearTimeout(automaticAttemptTimeoutId); automaticAttemptTimeoutId = null; } window.removeEventListener('pageshow', prepareImageNoteFilter); window.removeEventListener('popstate', prepareImageNoteFilter); } function hideFeedUntilFilterReady() { if (automaticFilterCompleted || feedVisibilityRevealed) { return; } installFeedGuardStyle(); document.documentElement.classList.add( AUTO_IMAGE_NOTE_FILTER.pendingClass, ); if (feedRevealTimeoutId === null) { feedRevealTimeoutId = setTimeout( revealFeed, AUTO_IMAGE_NOTE_FILTER.revealTimeoutMs, ); } } function getIconName(control) { const iconUse = control.querySelector('use'); const href = iconUse?.getAttribute('xlink:href') ?? iconUse?.getAttribute('href'); return href?.split('#').pop() ?? ''; } function isImageNoteFilterInactive(control) { const label = control .querySelector(AUTO_IMAGE_NOTE_FILTER.labelSelector) ?.textContent?.trim() ?? ''; const iconName = getIconName(control); if ( iconName === 'loading' || iconName === 'imgNoteSelect' || label === '取消只看图文' ) { return false; } return iconName === 'imgNote' || label === '只看图文'; } function isImageNoteFilterActive(control) { const label = control .querySelector(AUTO_IMAGE_NOTE_FILTER.labelSelector) ?.textContent?.trim() ?? ''; const iconName = getIconName(control); return iconName === 'imgNoteSelect' || label === '取消只看图文'; } function enableImageNoteFilter() { if (automaticFilterCompleted) { return false; } const control = document.querySelector( AUTO_IMAGE_NOTE_FILTER.controlSelector, ); if (!control) { return false; } if (isImageNoteFilterActive(control)) { completeAutomaticFilter(); return false; } const iconName = getIconName(control); if (automaticClickAttempted) { if (iconName === 'loading') { automaticLoadingObserved = true; } else if ( automaticLoadingObserved && isImageNoteFilterInactive(control) ) { completeAutomaticFilter(); } return false; } if (!isImageNoteFilterInactive(control)) { return false; } const clickTarget = control.querySelector( AUTO_IMAGE_NOTE_FILTER.clickTargetSelector, ); if (!clickTarget) { return false; } automaticClickAttempted = true; clickTarget.click(); return true; } function scheduleImageNoteFilterCheck() { if (automaticFilterCompleted || checkScheduled) { return; } checkScheduled = true; setTimeout(() => { checkScheduled = false; if (automaticFilterCompleted) { return; } enableImageNoteFilter(); }, 0); } function prepareImageNoteFilter() { if (automaticFilterCompleted) { revealFeed(); return; } hideFeedUntilFilterReady(); scheduleImageNoteFilterCheck(); } function start() { try { bootstrapFeedTitleKeywordFilter(); } catch (error) { // 标题过滤必须 fail-open,不能阻断独立的“自动只看图文”生命周期。 console.error('[小红书 Web 增强] 启动标题屏蔽失败', error); } hideFeedUntilFilterReady(); filterObserver = new MutationObserver(scheduleImageNoteFilterCheck); filterObserver.observe(document.documentElement, { attributes: true, characterData: true, childList: true, subtree: true, }); window.addEventListener('pageshow', prepareImageNoteFilter); window.addEventListener('popstate', prepareImageNoteFilter); filterCheckIntervalId = setInterval( scheduleImageNoteFilterCheck, AUTO_IMAGE_NOTE_FILTER.checkIntervalMs, ); automaticAttemptTimeoutId = setTimeout( completeAutomaticFilter, AUTO_IMAGE_NOTE_FILTER.automaticAttemptTimeoutMs, ); scheduleImageNoteFilterCheck(); } const testHook = globalThis.__XHS_ENHANCER_TEST_HOOK__; if (typeof testHook === 'function') { // 测试直接穿过纯逻辑 seam,正式页面未定义该 hook。 testHook( Object.freeze({ compileBlockKeywords, bootstrapFeedTitleKeywordFilter, collectAffectedCards, commitRootLayoutTransaction, computeColumnCompaction, createFeedTitleKeywordFilterController, createRootLayoutTransaction, getCardTitle, isExploreRoute, matchesAnyBlockKeyword, measureNativeLayout, normalizeBlockKeywordText, restoreCommittedLayout, titleFilterDom: TITLE_FILTER_DOM, titleFilterLifecycle: TITLE_FILTER_LIFECYCLE, validateNativeMasonryModel, }), ); } if (document.documentElement) { start(); } else { const rootObserver = new MutationObserver(() => { if (!document.documentElement) { return; } rootObserver.disconnect(); start(); }); rootObserver.observe(document, { childList: true }); } })();