// ==UserScript== // @name Threads 阻擋自動載媒體 (Desktop v1.0) // @namespace https://github.com/mark1688288/digital-detox // @version 1.0.0 // @description 攔截 Threads 圖片/影片自動下載,改為手動載入;阻止誤觸 media gallery。hostname CDN 規則、可串接 prototype hook、fetch/XHR 雙擋,並與 threads_antitrackers 共存。 // @author mark1688288 // @homepageURL https://github.com/mark1688288/digital-detox // @downloadURL https://raw.githubusercontent.com/mark1688288/digital-detox/main/threads-detox.js // @updateURL https://raw.githubusercontent.com/mark1688288/digital-detox/main/threads-detox.js // @match https://www.threads.net/* // @match https://threads.net/* // @match https://*.threads.net/* // @match https://www.threads.com/* // @match https://threads.com/* // @match https://*.threads.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=threads.com // @run-at document-start // @grant none // @sandbox raw // @license MIT // ==/UserScript== /** * - 要喺「頁面真身」patch img/video prototype 同 fetch,必須注入 MAIN_WORLD: * 1) `@grant none`(傳統 page context),或 * 2) `@sandbox raw` / `@sandbox MAIN_WORLD` * - 若改用 GM_* API,腳本會入 sandbox,必須改 patch `unsafeWindow`。 * - 本腳本無需 GM_*,故保持 `@grant none` + `@sandbox raw`。 */ (function () { 'use strict'; if (window.__threadsStopMediaActive) return; window.__threadsStopMediaActive = true; const VERSION = '1.0.0'; const PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const STYLE_ID = 'threads-stop-media-style'; const STATE_LOCKED = 'locked'; const STATE_UNLOCKED = 'unlocked'; const STATE_IGNORED = 'ignored'; const DS = { state: 'tSmState', processed: 'tSmProcessed', src: 'tSmSrc', srcset: 'tSmSrcset', poster: 'tSmPoster', unlockable: 'tSmUnlockable', managed: 'tSmManaged' }; // ========================================== // 1. 設定 // ========================================== const CONFIG = { /** * Meta 媒體 CDN hostname(阻擋 fetch/XHR 用)。 * 以 hostname 精確比對(含子網域),唔用 url.includes。 */ mediaHosts: [ 'cdninstagram.com', 'fbcdn.net', 'facebook.com', 'instagram.com' ], /** * 視為「細圖/icon」可自動放行嘅最大邊長(attribute 或 natural*)。 * avatar 多數 < 150;內容圖通常更大。 */ smallMediaMaxPx: 150, /** true → dialog / lightbox 內媒體亦要手動解鎖;false → dialog 內放行 */ blockDialogMedia: false, /** 解鎖後吞掉後續 click 以防開 gallery 嘅最長時間(ms) */ swallowTimeoutMs: 500, /** 暫停 pause 攔截(解鎖後短暫防 React 即刻 pause) */ pauseBlockMs: 400, /** localStorage key `threads_stopMediaLoader_debug` = "1" 亦可開啟 */ debug: false }; try { if (localStorage.getItem('threads_stopMediaLoader_debug') === '1') { CONFIG.debug = true; } } catch (_) { /* private mode 等 */ } const stats = { blockedFetch: 0, blockedXhr: 0, lockedImg: 0, lockedVideo: 0, unlockedImg: 0, unlockedVideo: 0, ignored: 0, preloadDropped: 0 }; function log(...args) { if (!CONFIG.debug) return; console.debug( `%c[Threads StopMedia ${VERSION}]`, 'color:#8ab4f8;font-weight:bold;', ...args ); } // ========================================== // 2. URL / hostname 工具 // ========================================== function isDataUrl(url) { return typeof url === 'string' && /^data:/i.test(url); } function isBlobUrl(url) { return typeof url === 'string' && /^blob:/i.test(url); } function isPlaceholder(url) { return typeof url === 'string' && ( url === PLACEHOLDER || url === '' || url === 'about:blank' ); } /** 解析任意 fetch/XHR 參數 → URL 物件;失敗回 null */ function parseUrl(value) { if (value == null) return null; let candidate = value; if (typeof Request !== 'undefined' && value instanceof Request) { candidate = value.url; } else if (typeof URL !== 'undefined' && value instanceof URL) { candidate = value.href; } else if (typeof value === 'object' && typeof value.url === 'string') { candidate = value.url; } try { return new URL(String(candidate), location.href); } catch (_) { return null; } } function hostMatches(hostname, domain) { const h = String(hostname || '').toLowerCase(); const d = String(domain || '').toLowerCase(); if (!h || !d) return false; return h === d || h.endsWith('.' + d); } function isMetaMediaHost(hostname) { const h = String(hostname || '').toLowerCase(); if (!h) return false; // scontent* 幾乎一定係 IG/Threads 媒體 CDN if (h.includes('scontent')) return true; return CONFIG.mediaHosts.some((d) => hostMatches(h, d)); } /** path/query 像媒體資源(副檔名或常見 Meta media path) */ function looksLikeMediaPath(urlObj) { if (!urlObj) return false; const path = (urlObj.pathname || '') + (urlObj.search || ''); if (/favicon/i.test(path)) return false; // 常見副檔名(query 前或整段) if (/\.(?:jpe?g|png|gif|webp|avif|heic|heif|bmp|mp4|m4v|mov|webm|mkv)(?:$|[?#])/i.test(path)) { return true; } // Meta CDN 常見無副檔名路徑 if (/\/(?:v\/t\d+|o1\/v\/t\d+|t\d+\.\d+-\d+)\//i.test(urlObj.pathname || '')) { return true; } // stp= / _nc_ 等 media 查詢常見於 scontent if (/[?&](?:stp|oh|oe|_nc_)/i.test(urlObj.search || '')) { return true; } return false; } /** 細尺寸 CDN 片段 s48x48 / s150x150 → 當 icon 放行 */ function isTinyCdnSize(urlObj) { if (!urlObj) return false; const m = (urlObj.pathname + urlObj.search).match(/s(\d+)x(\d+)/i); if (!m) return false; const w = parseInt(m[1], 10); const h = parseInt(m[2], 10); return w <= CONFIG.smallMediaMaxPx && h <= CONFIG.smallMediaMaxPx; } function shouldBlockNetworkMedia(value) { const urlObj = parseUrl(value); if (!urlObj) return false; if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') return false; if (!isMetaMediaHost(urlObj.hostname)) return false; if (isTinyCdnSize(urlObj)) return false; if (/favicon/i.test(urlObj.pathname + urlObj.search)) return false; return looksLikeMediaPath(urlObj); } function toStoredUrl(raw) { if (raw == null) return ''; const s = String(raw).trim(); if (!s || isPlaceholder(s) || isDataUrl(s)) return ''; return s; } // ========================================== // 3. CSS(DOM-less 佔位 + a11y focus) // ========================================== const cssRule = ` video[data-t-sm-state="locked"] { background-color: #1a1a1a !important; background-image: url('data:image/svg+xml;utf8,') !important; background-position: center !important; background-repeat: no-repeat !important; background-size: 48px !important; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1) !important; transition: background-size 0.2s ease, background-color 0.2s ease !important; width: 100% !important; height: 100% !important; min-height: 200px !important; cursor: pointer !important; } video[data-t-sm-state="locked"]:hover, video[data-t-sm-state="locked"]:focus-visible { background-color: #2a2a2a !important; background-size: 56px !important; } img[data-t-sm-state="locked"] { content: url('${PLACEHOLDER}') !important; background-color: #1a1a1a !important; background-image: url('data:image/svg+xml;utf8,') !important; background-position: center !important; background-repeat: no-repeat !important; background-size: 32px !important; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1) !important; transition: background-size 0.2s ease, background-color 0.2s ease !important; cursor: pointer !important; min-width: 48px !important; min-height: 48px !important; } img[data-t-sm-state="locked"]:hover, img[data-t-sm-state="locked"]:focus-visible { background-color: #2a2a2a !important; background-size: 40px !important; } img[data-t-sm-state="locked"]:focus-visible, video[data-t-sm-state="locked"]:focus-visible { outline: 2px solid #8ab4f8 !important; outline-offset: 2px !important; } /* dialog 預設唔套 locked 視覺(blockDialogMedia=false 時 process 會直接 ignored) */ [role="dialog"] video[data-t-sm-state="locked"], [role="dialog"] img[data-t-sm-state="locked"] { filter: none !important; background: none !important; content: none !important; cursor: default !important; min-height: 0 !important; } #threads-stop-media-load-all { position: fixed; z-index: 2147483646; right: 16px; bottom: 16px; padding: 10px 14px; border-radius: 20px; border: 1px solid rgba(255,255,255,0.2); background: rgba(26,26,26,0.92); color: #f5f5f5; font: 600 13px/1.3 system-ui, -apple-system, sans-serif; cursor: pointer; box-shadow: 0 4px 16px rgba(0,0,0,0.35); backdrop-filter: blur(8px); } #threads-stop-media-load-all:hover, #threads-stop-media-load-all:focus-visible { background: rgba(42,42,42,0.96); outline: 2px solid #8ab4f8; outline-offset: 2px; } #threads-stop-media-load-all[hidden] { display: none !important; } `; function injectStyle() { if (document.getElementById(STYLE_ID)) return; const style = document.createElement('style'); style.id = STYLE_ID; style.textContent = cssRule; (document.head || document.documentElement).appendChild(style); } if (document.head || document.documentElement) injectStyle(); else document.addEventListener('DOMContentLoaded', injectStyle, { once: true }); // ========================================== // 4. 可串接 hook 工具 // ========================================== /** * 讀「而家」嘅 descriptor 再 wrap,保留之前腳本(例如 antitrack)嘅 setter 鏈。 * configurable:true 方便之後腳本再 wrap 我哋。 */ function wrapSetter(proto, prop, makeSetter, makeGetter) { if (!proto) return false; let desc = Object.getOwnPropertyDescriptor(proto, prop); let owner = proto; if (!desc || !desc.set) { let curr = Object.getPrototypeOf(proto); while (curr && (!desc || !desc.set)) { desc = Object.getOwnPropertyDescriptor(curr, prop); if (desc && desc.set) { owner = curr; break; } curr = Object.getPrototypeOf(curr); } } if (!desc || typeof desc.set !== 'function') { log('skip wrap', prop, 'no setter'); return false; } // 若 descriptor 喺祖先上,仍然 define 喺目標 proto(shadow 祖先) const prevSet = desc.set; const prevGet = desc.get; try { Object.defineProperty(proto, prop, { configurable: true, enumerable: !!desc.enumerable, get: makeGetter ? makeGetter(prevGet) : (prevGet ? function () { return prevGet.call(this); } : undefined), set: makeSetter(prevSet) }); return true; } catch (err) { log('wrapSetter failed', prop, err); return false; } } function wrapMethod(proto, prop, createReplacement) { if (!proto) return false; const desc = Object.getOwnPropertyDescriptor(proto, prop); if (!desc || typeof desc.value !== 'function') { // fallback:直接讀 function const fn = proto[prop]; if (typeof fn !== 'function') return false; try { proto[prop] = createReplacement(fn); return true; } catch (err) { log('wrapMethod failed', prop, err); return false; } } try { Object.defineProperty(proto, prop, { ...desc, configurable: true, value: createReplacement(desc.value) }); return true; } catch (err) { log('wrapMethod define failed', prop, err); return false; } } function isUnlocking(el) { return !!(el && el._tSmUnlocking); } function withUnlocking(el, fn) { if (!el) return; el._tSmUnlocking = true; try { fn(); } finally { el._tSmUnlocking = false; } } function getState(el) { return el && el.dataset ? el.dataset[DS.state] : ''; } function setState(el, state) { if (el && el.dataset) el.dataset[DS.state] = state; } function isLocked(el) { return getState(el) === STATE_LOCKED; } function isUnlocked(el) { return getState(el) === STATE_UNLOCKED; } function markProcessed(el, value) { if (el && el.dataset) el.dataset[DS.processed] = value ? 'true' : 'false'; } function isProcessed(el) { return el && el.dataset && el.dataset[DS.processed] === 'true'; } function storeMediaUrl(el, key, value) { const s = toStoredUrl(value); if (s && el && el.dataset) el.dataset[key] = s; } function inDialog(el) { return !!(el && el.closest && el.closest('[role="dialog"]')); } /** src/srcset 經 setter 變更時,排隊俾 process* 補上 observe / a11y */ const pending = new Set(); let scheduled = false; function queueElement(el) { if (!el || el.nodeType !== 1) return; pending.add(el); scheduleFlush(); } function scheduleFlush() { if (scheduled) return; scheduled = true; requestAnimationFrame(flush); } // flush / handleElement 喺後面定義;呢度先宣告避免 TDZ(function 聲明會 hoist) function flush() { scheduled = false; if (pending.size === 0) return; const batch = Array.from(pending); pending.clear(); for (let i = 0; i < batch.length; i++) { try { handleElement(batch[i]); } catch (err) { log('handleElement error', err); } } refreshLoadAllButton(); } // ========================================== // 5. 網絡層:fetch + XHR(可串接) // ========================================== function emptyMediaResponse() { return new Response(new Blob([]), { status: 204, statusText: 'No Content', headers: { 'Cache-Control': 'no-store' } }); } wrapMethod(window, 'fetch', (nativeFetch) => function (...args) { try { if (shouldBlockNetworkMedia(args[0])) { stats.blockedFetch++; log('block fetch', parseUrl(args[0]) && parseUrl(args[0]).href); return Promise.resolve(emptyMediaResponse()); } } catch (err) { log('fetch gate error', err); } return nativeFetch.apply(this, args); }); (function patchXHR() { if (typeof XMLHttpRequest === 'undefined') return; const xhrProto = XMLHttpRequest.prototype; const nativeOpen = xhrProto.open; const nativeSend = xhrProto.send; xhrProto.open = function (method, url, ...rest) { try { this._tSmUrl = url; this._tSmBlock = shouldBlockNetworkMedia(url); } catch (_) { this._tSmBlock = false; } return nativeOpen.call(this, method, url, ...rest); }; xhrProto.send = function (...args) { if (this._tSmBlock) { stats.blockedXhr++; log('block xhr', this._tSmUrl); // 模擬成功但空 body,減少頁面 exception const xhr = this; queueMicrotask(() => { try { Object.defineProperty(xhr, 'readyState', { configurable: true, get: () => 4 }); Object.defineProperty(xhr, 'status', { configurable: true, get: () => 204 }); Object.defineProperty(xhr, 'response', { configurable: true, get: () => '' }); Object.defineProperty(xhr, 'responseText', { configurable: true, get: () => '' }); xhr.dispatchEvent(new Event('readystatechange')); xhr.dispatchEvent(new Event('load')); xhr.dispatchEvent(new Event('loadend')); } catch (err) { log('xhr fake response error', err); } }); return; } return nativeSend.apply(this, args); }; })(); // 靜音 locked 媒體嘅資源錯誤,減少 console 噪音 window.addEventListener('error', (e) => { const t = e.target; if (!t || !t.tagName) return; if ((t.tagName === 'IMG' || t.tagName === 'VIDEO' || t.tagName === 'SOURCE') && isLocked(t)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } }, true); // ========================================== // 6. Preload 攔截(唔 patch appendChild) // ========================================== function isImagePreloadLink(el) { if (!el || el.tagName !== 'LINK') return false; const rel = String(el.getAttribute('rel') || el.rel || '').toLowerCase(); const asAttr = String(el.getAttribute('as') || el.as || '').toLowerCase(); return rel.split(/\s+/).includes('preload') && asAttr === 'image'; } function neutralizePreloadLink(link) { if (!link || !isImagePreloadLink(link)) return false; try { link.removeAttribute('href'); link.removeAttribute('imagesrcset'); link.removeAttribute('imagesizes'); if (link.href) { try { link.href = ''; } catch (_) { /* ignore */ } } stats.preloadDropped++; log('drop preload link'); return true; } catch (err) { log('neutralizePreload error', err); return false; } } // ========================================== // 7. Element 狀態:gate media sources // ========================================== function gateImageSrc(img, value, prevSet) { if (isUnlocking(img) || isUnlocked(img) || getState(img) === STATE_IGNORED) { return prevSet.call(img, value); } if (isDataUrl(value) || isPlaceholder(value)) { return prevSet.call(img, value); } storeMediaUrl(img, DS.src, value); markProcessed(img, false); setState(img, STATE_LOCKED); queueElement(img); return prevSet.call(img, PLACEHOLDER); } function gateImageSrcset(img, value, prevSet) { if (isUnlocking(img) || isUnlocked(img) || getState(img) === STATE_IGNORED) { return prevSet.call(img, value); } if (value) storeMediaUrl(img, DS.srcset, value); markProcessed(img, false); setState(img, STATE_LOCKED); queueElement(img); return prevSet.call(img, ''); } function gateSourceAttr(source, prop, value, prevSet) { if (isUnlocking(source)) { return prevSet ? prevSet.call(source, value) : undefined; } const key = prop === 'srcset' ? DS.srcset : DS.src; if (value) storeMediaUrl(source, key, value); // 同步 picture > img try { const pic = source.closest && source.closest('picture'); if (pic) { const img = pic.querySelector('img'); if (img && !isUnlocked(img) && getState(img) !== STATE_IGNORED) { setState(img, STATE_LOCKED); markProcessed(img, false); if (prop === 'srcset' && value) storeMediaUrl(img, DS.srcset, value); if (prop === 'src' && value) storeMediaUrl(img, DS.src, value); queueElement(img); } } } catch (_) { /* ignore */ } // source:唔寫真實 URL;srcset 清走,src 唔 set if (prop === 'srcset' && prevSet) return prevSet.call(source, ''); if (prop === 'src') return; // 唔呼叫 prevSet,避免觸發載入 return prevSet ? prevSet.call(source, value) : undefined; } function gateVideoSrc(video, value, prevSet) { if (isUnlocking(video) || isUnlocked(video) || getState(video) === STATE_IGNORED) { return prevSet.call(video, value); } if (isDataUrl(value) || isPlaceholder(value) || isBlobUrl(value)) { // blob 多數係已解鎖後嘅本地解碼;若仍 locked 就擋 if (isBlobUrl(value) && !isUnlocked(video)) { storeMediaUrl(video, DS.src, value); setState(video, STATE_LOCKED); markProcessed(video, false); return; } return prevSet.call(video, value); } storeMediaUrl(video, DS.src, value); setState(video, STATE_LOCKED); markProcessed(video, false); queueElement(video); // 唔寫 src,避免 media element 開始 fetch return; } function gateVideoPoster(video, value, prevSet) { if (isUnlocking(video) || isUnlocked(video) || getState(video) === STATE_IGNORED) { return prevSet.call(video, value); } if (isDataUrl(value) || isPlaceholder(value)) { return prevSet.call(video, value); } storeMediaUrl(video, DS.poster, value); queueElement(video); return prevSet.call(video, PLACEHOLDER); } // --- property hooks --- wrapSetter( HTMLImageElement.prototype, 'src', (prevSet) => function (val) { try { return gateImageSrc(this, val, prevSet); } catch (err) { log('img.src error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.src] && isLocked(this)) { return this.dataset[DS.src]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( HTMLImageElement.prototype, 'srcset', (prevSet) => function (val) { try { return gateImageSrcset(this, val, prevSet); } catch (err) { log('img.srcset error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.srcset] && isLocked(this)) { return this.dataset[DS.srcset]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( HTMLSourceElement.prototype, 'src', (prevSet) => function (val) { try { return gateSourceAttr(this, 'src', val, prevSet); } catch (err) { log('source.src error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.src]) { return this.dataset[DS.src]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( HTMLSourceElement.prototype, 'srcset', (prevSet) => function (val) { try { return gateSourceAttr(this, 'srcset', val, prevSet); } catch (err) { log('source.srcset error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.srcset]) { return this.dataset[DS.srcset]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( HTMLMediaElement.prototype, 'src', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO') return gateVideoSrc(this, val, prevSet); return prevSet.call(this, val); } catch (err) { log('media.src error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (this.tagName === 'VIDEO' && !isUnlocking(this) && this.dataset && this.dataset[DS.src] && isLocked(this)) { return this.dataset[DS.src]; } return prevGet ? prevGet.call(this) : ''; } ); wrapSetter( HTMLMediaElement.prototype, 'autoplay', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO' && !isUnlocking(this) && !isUnlocked(this) && getState(this) !== STATE_IGNORED) { return prevSet.call(this, false); } return prevSet.call(this, val); } catch (err) { return prevSet.call(this, val); } } ); wrapSetter( HTMLMediaElement.prototype, 'preload', (prevSet) => function (val) { try { if (this.tagName === 'VIDEO' && !isUnlocking(this) && !isUnlocked(this) && getState(this) !== STATE_IGNORED) { return prevSet.call(this, 'none'); } return prevSet.call(this, val); } catch (err) { return prevSet.call(this, val); } } ); wrapSetter( HTMLVideoElement.prototype, 'poster', (prevSet) => function (val) { try { return gateVideoPoster(this, val, prevSet); } catch (err) { log('video.poster error', err); return prevSet.call(this, val); } }, (prevGet) => function () { if (!isUnlocking(this) && this.dataset && this.dataset[DS.poster] && isLocked(this)) { return this.dataset[DS.poster]; } return prevGet ? prevGet.call(this) : ''; } ); // setAttribute 串接 (function patchSetAttribute() { const prev = Element.prototype.setAttribute; Element.prototype.setAttribute = function (name, value) { try { if (!this || typeof name !== 'string') { return prev.call(this, name, value); } const attr = name.toLowerCase(); if (this.tagName === 'LINK') { if (attr === 'rel' || attr === 'as' || attr === 'href' || attr === 'imagesrcset' || attr === 'imagesizes') { const result = prev.call(this, name, value); if (isImagePreloadLink(this)) neutralizePreloadLink(this); return result; } } if (isUnlocking(this)) { return prev.call(this, name, value); } if (this.tagName === 'IMG') { if (attr === 'src') { if (isUnlocked(this) || getState(this) === STATE_IGNORED || isDataUrl(value) || isPlaceholder(value)) { return prev.call(this, name, value); } storeMediaUrl(this, DS.src, value); markProcessed(this, false); setState(this, STATE_LOCKED); queueElement(this); return prev.call(this, name, PLACEHOLDER); } if (attr === 'srcset') { if (isUnlocked(this) || getState(this) === STATE_IGNORED) { return prev.call(this, name, value); } if (value) storeMediaUrl(this, DS.srcset, value); markProcessed(this, false); setState(this, STATE_LOCKED); queueElement(this); return prev.call(this, name, ''); } } if (this.tagName === 'SOURCE' && (attr === 'src' || attr === 'srcset')) { const key = attr === 'srcset' ? DS.srcset : DS.src; if (value) storeMediaUrl(this, key, value); try { const pic = this.closest('picture'); const img = pic && pic.querySelector('img'); if (img && !isUnlocked(img) && getState(img) !== STATE_IGNORED) { setState(img, STATE_LOCKED); markProcessed(img, false); queueElement(img); } } catch (_) { /* ignore */ } if (attr === 'srcset') return prev.call(this, name, ''); return; // 唔寫 src } if (this.tagName === 'VIDEO') { if (attr === 'src') { if (isUnlocked(this) || getState(this) === STATE_IGNORED) { return prev.call(this, name, value); } storeMediaUrl(this, DS.src, value); setState(this, STATE_LOCKED); markProcessed(this, false); queueElement(this); return; // 唔寫真實 src } if (attr === 'poster') { if (isUnlocked(this) || getState(this) === STATE_IGNORED || isDataUrl(value) || isPlaceholder(value)) { return prev.call(this, name, value); } storeMediaUrl(this, DS.poster, value); queueElement(this); return prev.call(this, name, PLACEHOLDER); } if (attr === 'autoplay') return; if (attr === 'preload') return prev.call(this, name, 'none'); } } catch (err) { log('setAttribute error', err); } return prev.call(this, name, value); }; })(); // play():未解鎖唔畀 autoplay const originalPlay = HTMLVideoElement.prototype.play; wrapMethod(HTMLVideoElement.prototype, 'play', (nativePlay) => function (...args) { if (isUnlocked(this) || getState(this) === STATE_IGNORED) { return nativePlay.apply(this, args); } if (!CONFIG.blockDialogMedia && inDialog(this)) { return nativePlay.apply(this, args); } setState(this, STATE_LOCKED); return Promise.reject(new DOMException('Autoplay blocked by Threads Stop Media', 'NotAllowedError')); }); // ========================================== // 8. IntersectionObserver + 可見集合 // ========================================== const visibleLocked = new Set(); const io = new IntersectionObserver((entries) => { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const el = entry.target; if (entry.isIntersecting && isLocked(el) && el.isConnected) { visibleLocked.add(el); } else { visibleLocked.delete(el); } } refreshLoadAllButton(); }, { root: null, rootMargin: '0px', threshold: 0.05 }); function observeLocked(el) { if (!el) return; try { io.observe(el); } catch (_) { /* ignore */ } } function unobserve(el) { if (!el) return; visibleLocked.delete(el); try { io.unobserve(el); } catch (_) { /* ignore */ } } function pruneVisible() { for (const el of Array.from(visibleLocked)) { if (!el.isConnected || !isLocked(el)) visibleLocked.delete(el); } } // ========================================== // 9. 分類:ignore / lock // ========================================== function parsePxAttr(val) { if (val == null || val === '') return null; const s = String(val).trim(); if (!s || s.includes('%')) return null; const n = parseInt(s, 10); return Number.isFinite(n) ? n : null; } function isSmallByAttributes(img) { const w = parsePxAttr(img.getAttribute('width')); const h = parsePxAttr(img.getAttribute('height')); if (w != null && w > 0 && w < 50) return true; if (h != null && h > 0 && h < 50) return true; return false; } /** * 結構啟發式:avatar / 細 icon,唔靠單一 alt 語言。 * - alt 含 profile / avatar / 頭像(多語言) * - 父層係細尺寸 link(常見頭像) * - favicon / 極細 attribute */ function shouldIgnoreImage(img) { if (!img || img.tagName !== 'IMG') return true; if (img.closest('svg')) return true; if (!CONFIG.blockDialogMedia && inDialog(img)) return true; const alt = String(img.getAttribute('alt') || img.alt || '').toLowerCase(); if ( alt.includes('profile') || alt.includes('avatar') || alt.includes('頭像') || alt.includes('プロフィール') || alt.includes('프로필') ) { return true; } if (isSmallByAttributes(img)) return true; const stored = img.dataset[DS.src] || ''; const attrSrc = img.getAttribute('src') || ''; const check = (stored || attrSrc).toLowerCase(); if (check.includes('favicon')) return true; // 細圓形頭像:父 link 好細 try { const link = img.closest('a[role="link"], a[href*="/@"], a[href*="/user"]'); if (link) { const r = link.getBoundingClientRect(); if (r.width > 0 && r.height > 0 && r.width <= 64 && r.height <= 64) { return true; } } } catch (_) { /* ignore */ } return false; } function isNearVideoPoster(img) { let curr = img.parentElement; for (let i = 0; i < 2 && curr; i++) { if (curr.querySelector && curr.querySelector('video')) return true; curr = curr.parentElement; } return false; } function silenceAsIgnored(el) { setState(el, STATE_IGNORED); markProcessed(el, true); unobserve(el); el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); stats.ignored++; } function makeUnlockableA11y(el, kind) { if (!el || el.dataset[DS.unlockable] === '1') return; el.dataset[DS.unlockable] = '1'; if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0'); el.setAttribute('role', 'button'); el.setAttribute( 'aria-label', kind === 'video' ? '載入影片(Enter 或 點擊)' : '載入圖片(Enter 或 點擊)' ); } function stripA11y(el) { if (!el) return; delete el.dataset[DS.unlockable]; el.removeAttribute('tabindex'); el.removeAttribute('role'); el.removeAttribute('aria-label'); } /** * 強制把已存在嘅真實 src 換成 placeholder(補 parser 唔經 JS setter 嘅洞)。 */ function forceStripImageNetwork(img) { const attrSrc = img.getAttribute('src'); const attrSrcset = img.getAttribute('srcset'); if (attrSrc && !isPlaceholder(attrSrc) && !isDataUrl(attrSrc)) { storeMediaUrl(img, DS.src, attrSrc); } if (attrSrcset && attrSrcset.trim()) { storeMediaUrl(img, DS.srcset, attrSrcset); } // 用 removeAttribute 避開部分 hook 迴圈,再寫 placeholder withUnlocking(img, () => { try { if (img.getAttribute('srcset')) img.removeAttribute('srcset'); if (!isPlaceholder(img.getAttribute('src') || '')) { img.setAttribute('src', PLACEHOLDER); } } catch (err) { log('forceStripImage error', err); } }); } function forceStripVideoNetwork(video) { const attrSrc = video.getAttribute('src'); const attrPoster = video.getAttribute('poster'); if (attrSrc && !isPlaceholder(attrSrc) && !isDataUrl(attrSrc)) { storeMediaUrl(video, DS.src, attrSrc); } if (attrPoster && !isPlaceholder(attrPoster) && !isDataUrl(attrPoster)) { storeMediaUrl(video, DS.poster, attrPoster); } withUnlocking(video, () => { try { video.removeAttribute('autoplay'); video.autoplay = false; video.preload = 'none'; if (video.getAttribute('src')) { video.removeAttribute('src'); } if (attrPoster && !isPlaceholder(attrPoster)) { video.setAttribute('poster', PLACEHOLDER); } // 清 source children const sources = video.querySelectorAll('source'); for (let i = 0; i < sources.length; i++) { const s = sources[i]; const ss = s.getAttribute('src'); const sset = s.getAttribute('srcset'); if (ss) storeMediaUrl(s, DS.src, ss); if (sset) storeMediaUrl(s, DS.srcset, sset); s._tSmUnlocking = true; try { if (ss) s.removeAttribute('src'); if (sset) s.removeAttribute('srcset'); } finally { s._tSmUnlocking = false; } } // 取消已開始嘅緩衝 try { video.load(); } catch (_) { /* ignore */ } } catch (err) { log('forceStripVideo error', err); } }); } function processVideo(video) { if (!video || video.tagName !== 'VIDEO') return; if (isUnlocked(video)) return; if (isProcessed(video) && isLocked(video)) { observeLocked(video); return; } if (!CONFIG.blockDialogMedia && inDialog(video)) { silenceAsIgnored(video); return; } const firstManage = video.dataset[DS.managed] !== '1'; video.dataset[DS.managed] = '1'; markProcessed(video, true); setState(video, STATE_LOCKED); forceStripVideoNetwork(video); makeUnlockableA11y(video, 'video'); observeLocked(video); if (firstManage) { stats.lockedVideo++; log('locked video'); } // 附近 locked 圖當 poster 靜音(唔提供解鎖) let curr = video.parentElement; for (let i = 0; i < 2 && curr; i++) { const imgs = curr.querySelectorAll('img'); for (let j = 0; j < imgs.length; j++) { const img = imgs[j]; if (isLocked(img) || !isProcessed(img)) { silenceAsIgnored(img); stripA11y(img); } } curr = curr.parentElement; } } function processImage(img) { if (!img || img.tagName !== 'IMG') return; if (isUnlocked(img)) return; if (isProcessed(img) && isLocked(img)) { // 遲啲先有 profile alt 時可放行 if (shouldIgnoreImage(img)) { unlockImage(img, true); } else { observeLocked(img); } return; } if (shouldIgnoreImage(img)) { markProcessed(img, true); unlockImage(img, true); return; } if (isNearVideoPoster(img)) { markProcessed(img, true); silenceAsIgnored(img); forceStripImageNetwork(img); // 唔載 poster return; } const firstManage = img.dataset[DS.managed] !== '1'; img.dataset[DS.managed] = '1'; markProcessed(img, true); setState(img, STATE_LOCKED); forceStripImageNetwork(img); makeUnlockableA11y(img, 'image'); observeLocked(img); if (firstManage) { stats.lockedImg++; log('locked image'); } } function handleElement(el) { if (!el || el.nodeType !== 1) return; if (el.tagName === 'VIDEO') processVideo(el); else if (el.tagName === 'IMG') processImage(el); else if (el.tagName === 'LINK' && isImagePreloadLink(el)) neutralizePreloadLink(el); } // ========================================== // 10. 解鎖 // ========================================== function unlockImage(img, silent) { if (!img || img.tagName !== 'IMG') return; setState(img, silent ? STATE_IGNORED : STATE_UNLOCKED); markProcessed(img, true); delete img.dataset[DS.managed]; unobserve(img); stripA11y(img); withUnlocking(img, () => { try { const picture = img.closest('picture'); if (picture) { const sources = picture.querySelectorAll('source'); for (let i = 0; i < sources.length; i++) { const source = sources[i]; source._tSmUnlocking = true; try { if (source.dataset[DS.srcset]) source.srcset = source.dataset[DS.srcset]; if (source.dataset[DS.src]) source.src = source.dataset[DS.src]; } finally { source._tSmUnlocking = false; } } } if (img.dataset[DS.src]) img.src = img.dataset[DS.src]; if (img.dataset[DS.srcset]) img.srcset = img.dataset[DS.srcset]; } catch (err) { log('unlockImage error', err); } }); if (!silent) { stats.unlockedImg++; log('unlocked image'); } refreshLoadAllButton(); } function unlockVideo(video) { if (!video || video.tagName !== 'VIDEO') return; setState(video, STATE_UNLOCKED); markProcessed(video, true); delete video.dataset[DS.managed]; unobserve(video); stripA11y(video); withUnlocking(video, () => { try { const sources = video.querySelectorAll('source'); for (let i = 0; i < sources.length; i++) { const source = sources[i]; source._tSmUnlocking = true; try { if (source.dataset[DS.srcset]) source.srcset = source.dataset[DS.srcset]; if (source.dataset[DS.src]) source.src = source.dataset[DS.src]; } finally { source._tSmUnlocking = false; } } if (video.dataset[DS.src]) video.src = video.dataset[DS.src]; if (video.dataset[DS.poster]) video.poster = video.dataset[DS.poster]; if (video.dataset[DS.src] || video.querySelector('source')) { try { video.load(); } catch (_) { /* ignore */ } } } catch (err) { log('unlockVideo error', err); } }); // 短暫擋 pause,避免 React 一解鎖即 pause video._tSmBlockPause = true; if (!video._tSmPauseWrapped) { video._tSmPauseWrapped = true; const nativePause = video.pause.bind(video); video.pause = function (...args) { if (this._tSmBlockPause) return; return nativePause(...args); }; } try { const p = originalPlay.call(video); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch (_) { /* autoplay policy */ } setTimeout(() => { if (video) video._tSmBlockPause = false; }, CONFIG.pauseBlockMs); stats.unlockedVideo++; log('unlocked video'); refreshLoadAllButton(); } function unlockElement(el) { if (!el) return; if (el.tagName === 'VIDEO') unlockVideo(el); else if (el.tagName === 'IMG') unlockImage(el, false); } function unlockAllVisible() { pruneVisible(); const list = Array.from(visibleLocked); for (let i = 0; i < list.length; i++) { unlockElement(list[i]); } log('unlock all visible', list.length); } // ========================================== // 11. 「載入可見媒體」浮動掣 // ========================================== let loadAllBtn = null; function ensureLoadAllButton() { if (loadAllBtn || !document.documentElement) return; loadAllBtn = document.createElement('button'); loadAllBtn.type = 'button'; loadAllBtn.id = 'threads-stop-media-load-all'; loadAllBtn.hidden = true; loadAllBtn.setAttribute('aria-label', '載入目前可見嘅圖片同影片'); loadAllBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); unlockAllVisible(); }); (document.body || document.documentElement).appendChild(loadAllBtn); } function refreshLoadAllButton() { pruneVisible(); if (!loadAllBtn) { if (document.body || document.documentElement) ensureLoadAllButton(); } if (!loadAllBtn) return; const n = visibleLocked.size; if (n === 0) { loadAllBtn.hidden = true; return; } loadAllBtn.hidden = false; loadAllBtn.textContent = `📥 載入可見媒體(${n})`; } // ========================================== // 12. 交互:解鎖 + 吞 gallery 事件 // ========================================== let isSwallowing = false; let swallowTimer = null; function clearSwallow() { isSwallowing = false; if (swallowTimer != null) { clearTimeout(swallowTimer); swallowTimer = null; } } function beginSwallow() { isSwallowing = true; if (swallowTimer != null) clearTimeout(swallowTimer); swallowTimer = setTimeout(clearSwallow, CONFIG.swallowTimeoutMs); } function findLockedFromPoint(x, y) { let stack; try { stack = document.elementsFromPoint(x, y); } catch (_) { stack = []; } for (let i = 0; i < stack.length; i++) { let el = stack[i]; // 向上搵 locked media(點中 child overlay 都算) while (el && el !== document.documentElement) { if ( el.nodeType === 1 && (el.tagName === 'IMG' || el.tagName === 'VIDEO') && isLocked(el) ) { return el; } el = el.parentElement; } } // fallback:visible set + rect(elementsFromPoint 被 overlay 擋時) for (const el of visibleLocked) { if (!el.isConnected || !isLocked(el)) continue; const r = el.getBoundingClientRect(); if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) { return el; } } return null; } function onPointerDown(e) { if (!e.isTrusted) return; if (isSwallowing) { // 新一次 pointerdown:結束上一輪 swallow,允許正常操作 clearSwallow(); } const x = e.clientX; const y = e.clientY; if (x == null || y == null) return; if (visibleLocked.size === 0 && !document.querySelector(`[data-t-sm-state="${STATE_LOCKED}"]`)) { return; } const hit = findLockedFromPoint(x, y); if (!hit) return; unlockElement(hit); beginSwallow(); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } function onSwallowable(e) { if (!e.isTrusted) return; if (!isSwallowing) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (e.type === 'click' || e.type === 'dblclick' || e.type === 'auxclick') { clearSwallow(); } } function onKeyActivate(e) { if (!e.isTrusted) return; if (e.key !== 'Enter' && e.key !== ' ') return; const t = e.target; if (!t || (t.tagName !== 'IMG' && t.tagName !== 'VIDEO')) return; if (!isLocked(t)) return; e.preventDefault(); e.stopPropagation(); unlockElement(t); beginSwallow(); } const SWALLOW_EVENTS = [ 'pointerup', 'mousedown', 'mouseup', 'click', 'dblclick', 'auxclick', 'touchend' ]; window.addEventListener('pointerdown', onPointerDown, { capture: true, passive: false }); SWALLOW_EVENTS.forEach((evt) => { window.addEventListener(evt, onSwallowable, { capture: true, passive: false }); }); window.addEventListener('keydown', onKeyActivate, { capture: true }); // ========================================== // 13. 增量 MutationObserver + rAF // ========================================== function queueNode(node) { if (!node || node.nodeType !== 1) return; if (node.tagName === 'IMG' || node.tagName === 'VIDEO' || node.tagName === 'LINK') { pending.add(node); } if (node.querySelectorAll) { const list = node.querySelectorAll('img, video, link[rel="preload"]'); for (let i = 0; i < list.length; i++) pending.add(list[i]); } if (pending.size) scheduleFlush(); } function scanAll() { try { document.querySelectorAll('img, video, link[rel="preload"]').forEach((el) => pending.add(el)); scheduleFlush(); } catch (err) { log('scanAll error', err); } } const observer = new MutationObserver((mutations) => { for (let i = 0; i < mutations.length; i++) { const m = mutations[i]; if (m.type === 'childList') { const nodes = m.addedNodes; for (let j = 0; j < nodes.length; j++) queueNode(nodes[j]); } } if (pending.size) scheduleFlush(); }); function startObserver() { const root = document.documentElement || document; try { observer.observe(root, { childList: true, subtree: true }); } catch (err) { log('observe failed', err); } injectStyle(); ensureLoadAllButton(); scanAll(); } if (document.documentElement) startObserver(); else document.addEventListener('DOMContentLoaded', startObserver, { once: true }); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { injectStyle(); ensureLoadAllButton(); scanAll(); }, { once: true }); } else { ensureLoadAllButton(); scanAll(); } // 診斷 API(只讀;唔寫入頁面設定) try { Object.defineProperty(window, 'ThreadsStopMedia', { configurable: true, enumerable: false, get() { return { version: VERSION, config: { ...CONFIG }, stats: { ...stats }, visibleLocked: visibleLocked.size }; } }); } catch (_) { /* ignore */ } log('initialized', { mediaHosts: CONFIG.mediaHosts, blockDialogMedia: CONFIG.blockDialogMedia, smallMediaMaxPx: CONFIG.smallMediaMaxPx }); })();