From be01fbea9e1b8c9a3c7819cc62aa2008e2b82d47 Mon Sep 17 00:00:00 2001 From: OwnerLink <76668238+OwnerLink@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:53:29 +0300 Subject: [PATCH] Release RedGifs Video Downloader 0.6.23 --- README.md | 6 +- RELEASE_NOTES.md | 24 +- background.js | 43 +++- content.js | 608 ++++++++++++++++++++++++++++++++++++++++------- manifest.json | 2 +- popup.html | 8 + popup.js | 56 ++++- 7 files changed, 640 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 657468a..268e13f 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ ### Из GitHub Release -1. Скачайте архив `RedGifs-Video-Downloader-v0.6.21.zip` из последнего Release. +1. Скачайте архив `RedGifs-Video-Downloader-v0.6.23.zip` из последнего Release. 2. Распакуйте архив в постоянную папку. Не удаляйте её после установки. 3. Откройте `chrome://extensions` в Chrome, Chromium, Brave или другом совместимом браузере. 4. Включите **Режим разработчика**. @@ -95,7 +95,7 @@ SAMPLE-AES и DRM не поддерживаются и не обходятся. ## Диагностика -Кнопка **Скачать диагностический лог** находится в popup. Журнал может содержать сведения о DOM-структуре страницы, расположении карточек и кнопок, этапах работы расширения, HTTP-статусах и сетевых ошибках. Он не содержит тело видео и намеренно очищается от известных секретов. +Диагностика по умолчанию выключена. Для поиска проблемы откройте настройки popup, включите переключатель **Включить диагностику**, воспроизведите проблему и нажмите **Скачать диагностический лог**. После отключения накопленные диагностические события и история позиционирования очищаются. Журнал не содержит тело видео и намеренно очищается от известных секретов. Перед публикацией диагностического файла всё равно рекомендуется просмотреть его вручную. @@ -109,7 +109,7 @@ SAMPLE-AES и DRM не поддерживаются и не обходятся. ## Версия -Текущая версия: **0.6.21**. +Текущая версия: **0.6.23**. История релиза: [RELEASE_NOTES.md](RELEASE_NOTES.md). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index cf96458..a3c7fad 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,7 +1,23 @@ -# RedGifs Video Downloader 0.6.21 +# RedGifs Video Downloader 0.6.23  +## Исправления 0.6.23 + +- Выбор тега в профиле больше не запускает отдельный глубокий обход для каждого фрагмента перестраиваемой страницы. +- Серии крупных DOM-изменений объединяются в один отложенный пересмотр после стабилизации страницы. +- Отключённые и удалённые карточки освобождаются из реестра наблюдения одним проходом. +- В кнопки и диагностический интерфейс добавлен номер фактически работающего content script, чтобы отличать свежую версию от оставшейся в старой вкладке. + +## Исправления 0.6.22 + +- Устранён полный перескан страницы каждые 160 мс при прокрутке и изменениях DOM. +- Карточки обрабатываются небольшими порциями только рядом с видимой областью. +- Кнопки полноэкранного просмотра больше не забираются у исходной карточки профиля. +- Удалённые карточки, кнопки и цели наблюдения освобождаются после фильтрации по тегам. +- Диагностика получила отдельный переключатель и по умолчанию выключена. +- При выключении диагностики очищаются накопленные события и история позиционирования. + Первый релиз независимого локального загрузчика для Chromium. ## Главное @@ -22,7 +38,7 @@ ## Установка -1. Скачайте `RedGifs-Video-Downloader-v0.6.21.zip`. +1. Скачайте `RedGifs-Video-Downloader-v0.6.23.zip`. 2. Распакуйте архив. 3. Откройте `chrome://extensions`. 4. Включите режим разработчика. @@ -35,6 +51,6 @@ ## Контрольная сумма ```text -RedGifs-Video-Downloader-v0.6.21.zip -SHA-256: a81ea275639fcbf56f1224c32c02a1db45e7d618f701068b277881aaea7efba7 +RedGifs-Video-Downloader-v0.6.23.zip +SHA-256: см. описание GitHub Release ``` diff --git a/background.js b/background.js index 1e15db5..21cc0e4 100644 --- a/background.js +++ b/background.js @@ -5,6 +5,7 @@ const TOKEN_TTL_MS = 50 * 60 * 1000; const DOWNLOAD_HISTORY_KEY = "downloadHistoryV1"; const DOWNLOAD_HISTORY_LIMIT = 5000; const DIAGNOSTIC_LOG_KEY = "diagnosticLogV1"; +const DIAGNOSTICS_SETTING_KEY = "diagnosticsEnabled"; const DIAGNOSTIC_LOG_LIMIT = 500; const DIAGNOSTIC_REPORT_MAX_CHARS = 6 * 1024 * 1024; const tokenCache = { value: null, expiresAt: 0 }; @@ -15,6 +16,33 @@ let downloadHistoryCache = null; let downloadHistoryWrite = Promise.resolve(); let diagnosticLogCache = null; let diagnosticLogWrite = Promise.resolve(); +let diagnosticsEnabled = false; + +function applyDiagnosticsState(enabled) { + diagnosticsEnabled = Boolean(enabled); + if (diagnosticsEnabled) return; + diagnosticLogWrite = diagnosticLogWrite.catch(() => {}).then(async () => { + diagnosticLogCache = []; + try { + await chrome.storage.session.remove(DIAGNOSTIC_LOG_KEY); + } catch { + // Diagnostics are already disabled in memory. + } + }); +} + +try { + chrome.storage.local.get(DIAGNOSTICS_SETTING_KEY).then((stored) => { + applyDiagnosticsState(Boolean(stored && stored[DIAGNOSTICS_SETTING_KEY])); + }).catch(() => {}); + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === "local" && changes[DIAGNOSTICS_SETTING_KEY]) { + applyDiagnosticsState(Boolean(changes[DIAGNOSTICS_SETTING_KEY].newValue)); + } + }); +} catch { + applyDiagnosticsState(false); +} function diagnosticUrl(value) { try { @@ -57,6 +85,7 @@ function diagnosticValue(value, key, depth) { } async function loadDiagnosticLog() { + if (!diagnosticsEnabled) return []; if (diagnosticLogCache) return diagnosticLogCache; try { const stored = await chrome.storage.session.get(DIAGNOSTIC_LOG_KEY); @@ -70,6 +99,7 @@ async function loadDiagnosticLog() { } function recordDiagnostic(level, area, event, details) { + if (!diagnosticsEnabled) return null; const entry = { at: new Date().toISOString(), level: ["debug", "info", "warn", "error"].includes(level) ? level : "info", @@ -91,12 +121,21 @@ function recordDiagnostic(level, area, event, details) { } async function diagnosticRuntimeBundle() { + if (!diagnosticsEnabled) { + return { + generatedAt: new Date().toISOString(), + extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null, + diagnosticsEnabled: false, + events: [] + }; + } await diagnosticLogWrite.catch(() => {}); const log = await loadDiagnosticLog(); const settings = await getDownloadSettings(); return { generatedAt: new Date().toISOString(), extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null, + diagnosticsEnabled: true, platform: typeof navigator !== "undefined" ? { userAgent: navigator.userAgent || null, language: navigator.language || null, @@ -1082,10 +1121,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null, source: sender && sender.tab ? "content" : "extension-page" })); - return { ok: true }; + return { ok: true, ignored: !diagnosticsEnabled }; case "getDiagnosticLog": + if (!diagnosticsEnabled) return { ok: false, error: "Включите диагностику в настройках расширения." }; return { ok: true, diagnostics: await diagnosticRuntimeBundle() }; case "downloadDiagnosticReport": + if (!diagnosticsEnabled) return { ok: false, error: "Включите диагностику в настройках расширения." }; return { ok: true, result: await downloadDiagnosticReport(message.report) }; case "hlsProgress": return { ok: true, job: await updateJob(message) }; diff --git a/content.js b/content.js index b96b73c..f80cd6e 100644 --- a/content.js +++ b/content.js @@ -2,6 +2,7 @@ if (!globalThis.__localRedgifsScannerInstalled) { globalThis.__localRedgifsScannerInstalled = true; + const CONTENT_SCRIPT_VERSION = "0.6.23"; function isRedgifsHost(hostname) { const host = String(hostname || "").toLowerCase(); @@ -333,14 +334,39 @@ if (!globalThis.__localRedgifsScannerInstalled) { const externalButtons = new Set(); const checkedMediaIds = new Set(); const pendingHistoryIds = new Set(); + const MEDIA_CANDIDATE_SELECTOR = [ + "[data-feed-item-id]", + "a[href*='/watch/']", + "a[href*='/ifr/']", + "video", + "img[src*='media.redgifs.com']", + "img[data-src*='media.redgifs.com']", + "img[data-lazy-src*='media.redgifs.com']" + ].join(", "); const ANCHOR_RECONNECT_GRACE_MS = 500; let historyCheckTimer = null; let externalLayoutFrame = null; let externalReconnectTimer = null; let lastScanDiagnostics = []; const contentDiagnosticEvents = []; + let diagnosticsEnabled = false; + + function applyDiagnosticsState(enabled) { + diagnosticsEnabled = Boolean(enabled); + if (diagnosticsEnabled) return; + contentDiagnosticEvents.length = 0; + lastScanDiagnostics = []; + const buttons = new Set(externalButtons); + for (const buttonsForMedia of mediaButtons.values()) { + for (const button of buttonsForMedia) buttons.add(button); + } + for (const button of buttons) { + if (button) button.__localRgDecisionHistory = []; + } + } function recordContentDiagnostic(level, event, details) { + if (!diagnosticsEnabled) return null; const entry = { at: new Date().toISOString(), level: level || "info", @@ -379,6 +405,11 @@ if (!globalThis.__localRedgifsScannerInstalled) { function setButtonLayoutReason(button, code, details) { if (!button) return; const previous = button.__localRgLayoutReason || null; + if (!diagnosticsEnabled) { + button.__localRgLayoutReason = { code }; + button.__localRgDecisionHistory = []; + return; + } const next = Object.assign({ code, at: new Date().toISOString() @@ -393,9 +424,7 @@ if (!globalThis.__localRedgifsScannerInstalled) { decision: next, previousCode: previous && previous.code || null, inlineDisplay: button.style && button.style.display || null, - buttonRect: diagnosticRect(button), anchorRect: diagnosticRect(anchor), - rootRect: diagnosticRect(root), anchorConnected: !anchor || !("isConnected" in anchor) || Boolean(anchor.isConnected), rootConnected: !root || !("isConnected" in root) || Boolean(root.isConnected), hostScroll: host ? { @@ -403,7 +432,9 @@ if (!globalThis.__localRedgifsScannerInstalled) { left: Number(host.scrollLeft) || 0 } : null }); - if (button.__localRgDecisionHistory.length > 40) button.__localRgDecisionHistory.splice(0, 10); + if (button.__localRgDecisionHistory.length > 12) { + button.__localRgDecisionHistory.splice(0, button.__localRgDecisionHistory.length - 12); + } } function isGeneralFeedPage() { @@ -661,8 +692,7 @@ if (!globalThis.__localRedgifsScannerInstalled) { setButtonLayoutReason(button, "root-reconnecting"); continue; } - setStyleProperty(button, "display", "none"); - setButtonLayoutReason(button, "root-disconnected"); + disposeButton(button, "root-disconnected"); continue; } const anchor = button.__localRgAnchor || root; @@ -679,6 +709,9 @@ if (!globalThis.__localRedgifsScannerInstalled) { let fallbackReason = null; if (rect && usableLayoutRect(rootRect)) { rememberAnchorGeometry(button, rect, rootRect); + } else if (overlay && usableLayoutRect(rootRect)) { + rect = rootRect; + fallbackReason = "visible-from-modal-root"; } else { rect = anchorRectFromRoot(button, rootRect); fallbackReason = !anchorConnected @@ -788,7 +821,6 @@ if (!globalThis.__localRedgifsScannerInstalled) { function scheduleFeedRefresh() { scheduleExternalLayout(); - scheduleDecoration(); } function configureButtonLayout(root, button, anchor) { @@ -991,6 +1023,13 @@ if (!globalThis.__localRedgifsScannerInstalled) { topBoundary: pageTopBoundary(Number(globalThis.innerWidth) || 0, Number(globalThis.innerHeight) || 0), fullscreenElement: diagnosticElement(document.fullscreenElement) }, + scanner: { + diagnosticsEnabled, + trackedButtons: buttons.length, + trackedMediaIds: mediaButtons.size, + externalButtons: externalButtons.size, + pendingDecorationRoots: pendingDecorationRoots.size + }, legend: { cyan: "anchor (video/poster)", yellow: "card root", @@ -1018,28 +1057,26 @@ if (!globalThis.__localRedgifsScannerInstalled) { const key = normalizeMediaKey(item && item.id); const buttons = key && mediaButtons.get(key); if (!buttons) return null; - for (const button of buttons) { - if (!button) continue; + for (const button of Array.from(buttons)) { + if (!button) { + buttons.delete(button); + continue; + } const previousRoot = button.__localRgRoot; - const previousAnchor = button.__localRgAnchor || previousRoot; - const previousOverlay = button.__localRgOverlay || modalContainerFor(previousAnchor) || modalContainerFor(previousRoot); - const nextOverlay = modalContainerFor(anchor || root) || modalContainerFor(root); + if (!previousRoot) { + disposeButton(button, "reusable-without-root"); + continue; + } const sameRoot = previousRoot === root; const previousConnected = previousRoot && (!("isConnected" in previousRoot) || previousRoot.isConnected); - const previousVisible = Math.max( - visibleAreaFor(previousRoot), - visibleAreaFor(previousAnchor) - ) > 0; const nextVisible = Math.max( visibleAreaFor(root), visibleAreaFor(anchor || root) ) > 0; const adopt = sameRoot - || (!previousOverlay && Boolean(nextOverlay)) - || (!previousConnected && nextVisible) - || (!previousVisible && nextVisible); - if (!adopt) return { button, adopt: false }; + || (!previousConnected && nextVisible); + if (!adopt) continue; if (previousRoot && previousRoot !== root && previousRoot.__localRgDownloadButton === button) { previousRoot.__localRgDownloadButton = null; } @@ -1133,6 +1170,64 @@ if (!globalThis.__localRedgifsScannerInstalled) { } } + function disposeButton(button, reason) { + if (!button) return; + const root = button.__localRgRoot; + const mediaKey = button.__localRgMediaKey; + externalButtons.delete(button); + if (mediaKey) { + const buttons = mediaButtons.get(mediaKey); + if (buttons) { + buttons.delete(button); + if (!buttons.size) mediaButtons.delete(mediaKey); + } + } + if (root && root.__localRgDownloadButton === button) { + root.__localRgDownloadButton = null; + } + if (!button.__localRgJobId && button.__localRgResetTimer) { + clearTimeout(button.__localRgResetTimer); + button.__localRgResetTimer = null; + } + if (button.remove) button.remove(); + button.__localRgRoot = null; + button.__localRgAnchor = null; + button.__localRgOverlay = null; + button.__localRgScrollHost = null; + button.__localRgAnchorGeometry = null; + button.__localRgAnchorMissingSince = 0; + button.__localRgMediaKey = null; + button.__localRgDownloadedRecord = null; + button.__localRedgifsItem = null; + button.__localRgLayoutReason = { + code: reason || "disposed", + at: new Date().toISOString() + }; + button.__localRgDecisionHistory = []; + } + + function pruneDetachedButtons() { + for (const buttons of Array.from(mediaButtons.values())) { + for (const button of Array.from(buttons)) { + if (externalButtons.has(button)) continue; + const root = button && button.__localRgRoot; + if (!root || ("isConnected" in root && !root.isConnected)) { + disposeButton(button, "pruned-disconnected-root"); + } + } + } + for (const button of Array.from(externalButtons)) { + if (!button) { + externalButtons.delete(button); + continue; + } + const root = button.__localRgRoot; + if (!root || ("isConnected" in root && !root.isConnected)) { + scheduleExternalLayout(); + } + } + } + function animateToastEntrance(root) { if (!root) return; if (typeof root.animate === "function") { @@ -1387,6 +1482,7 @@ if (!globalThis.__localRedgifsScannerInstalled) { button = document.createElement("button"); button.type = "button"; button.className = "local-rg-download-button"; + button.dataset.runtimeVersion = CONTENT_SCRIPT_VERSION; root.__localRgDownloadButton = button; button.__localRedgifsItem = item; button.title = "Скачать видео в лучшем доступном качестве"; @@ -1468,62 +1564,111 @@ if (!globalThis.__localRedgifsScannerInstalled) { registerMediaButton(button, item); } + function extractCandidate(node) { + if (!node) return null; + const root = closestCard(node); + if (!root || isPageChromeRoot(root)) return null; + const feedNode = (root.matches && root.matches("[data-feed-item-id]")) + ? root + : root.querySelector && root.querySelector("[data-feed-item-id]"); + const feedId = feedNode && feedNode.getAttribute("data-feed-item-id"); + const link = (root.matches && root.matches("a[href*='/watch/'], a[href*='/ifr/']")) + ? root + : root.querySelector && root.querySelector("a[href*='/watch/'], a[href*='/ifr/']"); + const href = normalizeUrl(link && (link.getAttribute("href") || link.href)); + const video = (root.tagName === "VIDEO") ? root : root.querySelector && root.querySelector("video"); + const previewImage = mediaPosterImage(root); + const directVideoUrl = videoSource(video); + const thumbnail = posterFromRoot(root, video); + const posterId = idFromPosterUrl(thumbnail); + if (!feedId && !href && posterId && !isExplicitMediaCard(root)) return null; + const stableId = feedId || idFromUrl(href) || posterId; + if (isGeneralFeedPage() && !stableId) return null; + const id = stableId || idFromUrl(directVideoUrl); + if (!id && !directVideoUrl) return null; + const item = { + id: id || null, + watchUrl: href || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null), + directVideoUrl: directVideoUrl || null, + thumbnail, + title: titleFromRoot(root, id), + profileSlug: profileSlugFromRoot(root) || currentProfileSlug() + }; + return { + root, + item, + mediaAnchor: video || previewImage, + key: id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl + }; + } + + function isNearViewport(element, margin) { + if (!element || typeof element.getBoundingClientRect !== "function") return false; + const rect = element.getBoundingClientRect(); + const padding = Math.max(0, Number(margin) || 0); + const viewportWidth = Number(globalThis.innerWidth) || 0; + const viewportHeight = Number(globalThis.innerHeight) || 0; + return rect.width > 0 + && rect.height > 0 + && rect.bottom >= -padding + && rect.top <= viewportHeight + padding + && rect.right >= -padding + && rect.left <= viewportWidth + padding; + } + + function lightweightElement(element) { + if (!element) return null; + return { + tag: String(element.tagName || "").toLowerCase() || null, + id: element.id || null, + className: typeof element.className === "string" ? element.className.slice(0, 240) : null, + connected: !("isConnected" in element) || Boolean(element.isConnected) + }; + } + + function decorateCandidate(node) { + const extracted = extractCandidate(node); + if (!extracted || !extracted.mediaAnchor) return false; + ensureDownloadButton(extracted.root, extracted.item, extracted.mediaAnchor); + return true; + } + function scanPage() { const items = []; - const seen = new Set(); + const seenItems = new Set(); + const seenRoots = new Set(); lastScanDiagnostics = []; - function add(node) { - if (!node) return; + const nodes = Array.from(document.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)); + for (const node of nodes) { const root = closestCard(node); - if (!root || isPageChromeRoot(root)) return; - const feedNode = (root.matches && root.matches("[data-feed-item-id]")) - ? root - : root.querySelector && root.querySelector("[data-feed-item-id]"); - const feedId = feedNode && feedNode.getAttribute("data-feed-item-id"); - const link = (root.matches && root.matches("a[href*='/watch/'], a[href*='/ifr/']")) - ? root - : root.querySelector && root.querySelector("a[href*='/watch/'], a[href*='/ifr/']"); - const href = normalizeUrl(link && (link.getAttribute("href") || link.href)); - const video = (root.tagName === "VIDEO") ? root : root.querySelector && root.querySelector("video"); - const previewImage = mediaPosterImage(root); - const directVideoUrl = videoSource(video); - const thumbnail = posterFromRoot(root, video); - const posterId = idFromPosterUrl(thumbnail); - if (!feedId && !href && posterId && !isExplicitMediaCard(root)) return; - const stableId = feedId || idFromUrl(href) || posterId; - if (isGeneralFeedPage() && !stableId) return; - const id = stableId || idFromUrl(directVideoUrl); - if (!id && !directVideoUrl) return; - const key = id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl; - const duplicate = seen.has(key); - if (!duplicate) seen.add(key); - const item = { - id: id || null, - watchUrl: href || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null), - directVideoUrl: directVideoUrl || null, - thumbnail, - title: titleFromRoot(root, id), - profileSlug: profileSlugFromRoot(root) || currentProfileSlug() - }; - const mediaAnchor = video || previewImage; - if (mediaAnchor) ensureDownloadButton(root, item, mediaAnchor); - lastScanDiagnostics.push({ - key, - duplicate, - outcome: mediaAnchor ? "decorated" : "no-video-or-redgifs-poster", - item: { id: item.id, title: item.title, watchUrl: item.watchUrl, thumbnail: item.thumbnail }, - root: diagnosticElement(root), - anchor: diagnosticElement(mediaAnchor) - }); - if (!duplicate && items.length < 240) items.push(item); + if (!root || seenRoots.has(root)) continue; + seenRoots.add(root); + const extracted = extractCandidate(root); + if (!extracted) continue; + const duplicate = seenItems.has(extracted.key); + if (!duplicate) seenItems.add(extracted.key); + if (extracted.mediaAnchor && isNearViewport(extracted.mediaAnchor, 900)) { + ensureDownloadButton(extracted.root, extracted.item, extracted.mediaAnchor); + } + if (diagnosticsEnabled && lastScanDiagnostics.length < 80) { + lastScanDiagnostics.push({ + key: extracted.key, + duplicate, + outcome: extracted.mediaAnchor ? "candidate" : "no-video-or-redgifs-poster", + item: { + id: extracted.item.id, + title: extracted.item.title, + watchUrl: extracted.item.watchUrl, + thumbnail: extracted.item.thumbnail + }, + root: lightweightElement(extracted.root), + anchor: lightweightElement(extracted.mediaAnchor) + }); + } + if (!duplicate && items.length < 240) items.push(extracted.item); } - - const nodes = Array.from(document.querySelectorAll( - "[data-feed-item-id], a[href*='/watch/'], a[href*='/ifr/'], video, img[src], img[data-src], img[data-lazy-src]" - )); - nodes.sort((left, right) => Number(Boolean(modalContainerFor(right))) - Number(Boolean(modalContainerFor(left)))); - for (const node of nodes) add(node); + pruneDetachedButtons(); scheduleExternalLayout(); return { @@ -1535,6 +1680,15 @@ if (!globalThis.__localRedgifsScannerInstalled) { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message) return false; + if (message.action === "getContentRuntimeInfo") { + sendResponse({ + ok: true, + version: CONTENT_SCRIPT_VERSION, + diagnosticsEnabled, + buttons: document.querySelectorAll(".local-rg-download-button").length + }); + return false; + } if (message.action === "downloadProgress") { showDownloadToast(message.job); try { @@ -1545,6 +1699,10 @@ if (!globalThis.__localRedgifsScannerInstalled) { return false; } if (message.action === "captureExtensionDiagnostics" || message.action === "captureButtonDiagnostics") { + if (!diagnosticsEnabled) { + sendResponse({ ok: false, error: "Включите диагностику в настройках расширения." }); + return false; + } try { sendResponse({ ok: true, report: capturePageDiagnostics() }); } catch (error) { @@ -1569,36 +1727,306 @@ if (!globalThis.__localRedgifsScannerInstalled) { return false; }); - let decorateTimer = null; - function scheduleDecoration() { - if (decorateTimer != null) return; - let ranSynchronously = false; - const timer = setTimeout(() => { - ranSynchronously = true; - decorateTimer = null; - try { - scanPage(); - } catch (error) { - recordContentDiagnostic("error", "automatic-scan-failed", { - name: error && error.name, - message: error && error.message - }); - // A later DOM mutation will retry. - } - }, 160); - if (!ranSynchronously) decorateTimer = timer; + const observedCandidates = new Set(); + const intersectingCandidates = new WeakSet(); + const pendingDecorationRoots = new Set(); + let decorationTask = null; + let documentSweepTimer = null; + let documentSweepTask = null; + let tagTransitionUntil = 0; + + const TAG_TRANSITION_GUARD_MS = 12000; + const MUTATION_BATCH_LIMIT = 64; + const MUTATION_NODE_LIMIT = 96; + + function isExtensionOwnedNode(node) { + const element = node && node.nodeType === Node.ELEMENT_NODE + ? node + : node && node.parentElement; + if (!element || typeof element.closest !== "function") return false; + return Boolean(element.closest([ + "#local-redgifs-download-styles", + "#local-rg-toast-stack", + ".local-rg-download-button" + ].join(", "))); } - const observer = new MutationObserver(scheduleDecoration); + function requestDecorationFlush() { + if (decorationTask != null) return; + const run = (deadline) => { + decorationTask = null; + const startedAt = typeof performance !== "undefined" ? performance.now() : Date.now(); + let processed = 0; + while (pendingDecorationRoots.size && processed < 16) { + if (processed > 0 && deadline && typeof deadline.timeRemaining === "function" && deadline.timeRemaining() < 2) break; + const root = pendingDecorationRoots.values().next().value; + pendingDecorationRoots.delete(root); + if (root && (!("isConnected" in root) || root.isConnected)) { + try { + decorateCandidate(root); + } catch (error) { + recordContentDiagnostic("error", "automatic-decoration-failed", { + name: error && error.name, + message: error && error.message + }); + } + } + processed += 1; + const now = typeof performance !== "undefined" ? performance.now() : Date.now(); + if (now - startedAt >= 9) break; + } + pruneDetachedButtons(); + if (pendingDecorationRoots.size) requestDecorationFlush(); + }; + if (typeof globalThis.requestIdleCallback === "function") { + decorationTask = globalThis.requestIdleCallback(run, { timeout: 240 }); + } else { + decorationTask = setTimeout(() => run(null), 48); + } + } + + function queueCandidateDecoration(candidate) { + if (!candidate || isExtensionOwnedNode(candidate)) return; + const root = closestCard(candidate); + if (!root || isPageChromeRoot(root)) return; + pendingDecorationRoots.add(root); + requestDecorationFlush(); + } + + const candidateObserver = typeof globalThis.IntersectionObserver === "function" + ? new globalThis.IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + intersectingCandidates.add(entry.target); + queueCandidateDecoration(entry.target); + } else { + intersectingCandidates.delete(entry.target); + } + } + }, { root: null, rootMargin: "800px 240px", threshold: 0 }) + : null; + + function observeCandidate(candidate) { + if (!candidate || candidate.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(candidate)) return; + if (typeof candidate.matches !== "function" || !candidate.matches(MEDIA_CANDIDATE_SELECTOR)) return; + if (observedCandidates.has(candidate)) return; + observedCandidates.add(candidate); + if (candidateObserver) { + candidateObserver.observe(candidate); + } else { + queueCandidateDecoration(candidate); + } + } + + function observeCandidateScope(scope) { + if (!scope || isExtensionOwnedNode(scope)) return false; + let found = false; + if (scope.nodeType === Node.ELEMENT_NODE && typeof scope.matches === "function" && scope.matches(MEDIA_CANDIDATE_SELECTOR)) { + observeCandidate(scope); + found = true; + } + if (typeof scope.querySelectorAll !== "function") return found; + for (const candidate of scope.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)) { + observeCandidate(candidate); + found = true; + } + return found; + } + + function unobserveCandidateScope(scope) { + if (!candidateObserver || !scope || scope.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(scope)) return false; + let found = false; + if (typeof scope.matches === "function" && scope.matches(MEDIA_CANDIDATE_SELECTOR)) { + candidateObserver.unobserve(scope); + observedCandidates.delete(scope); + intersectingCandidates.delete(scope); + found = true; + } + if (typeof scope.querySelectorAll !== "function") return found; + for (const candidate of scope.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)) { + candidateObserver.unobserve(candidate); + observedCandidates.delete(candidate); + intersectingCandidates.delete(candidate); + found = true; + } + return found; + } + + function pruneObservedCandidates() { + for (const candidate of Array.from(observedCandidates)) { + const connected = !("isConnected" in candidate) || candidate.isConnected; + const stillMatches = connected + && typeof candidate.matches === "function" + && candidate.matches(MEDIA_CANDIDATE_SELECTOR); + if (stillMatches) continue; + if (candidateObserver) candidateObserver.unobserve(candidate); + observedCandidates.delete(candidate); + intersectingCandidates.delete(candidate); + } + } + + function rebuildCandidateRegistry() { + pruneObservedCandidates(); + for (const candidate of document.querySelectorAll(MEDIA_CANDIDATE_SELECTOR)) { + observeCandidate(candidate); + if (isNearViewport(candidate, 900)) queueCandidateDecoration(candidate); + } + pruneDetachedButtons(); + scheduleExternalLayout(); + } + + function cancelDocumentSweepTask() { + if (documentSweepTimer != null) { + clearTimeout(documentSweepTimer); + documentSweepTimer = null; + } + if (documentSweepTask == null) return; + if (typeof globalThis.cancelIdleCallback === "function") { + globalThis.cancelIdleCallback(documentSweepTask); + } else { + clearTimeout(documentSweepTask); + } + documentSweepTask = null; + } + + function requestDocumentSweep(delay) { + cancelDocumentSweepTask(); + documentSweepTimer = setTimeout(() => { + documentSweepTimer = null; + const run = () => { + documentSweepTask = null; + try { + rebuildCandidateRegistry(); + } catch (error) { + recordContentDiagnostic("error", "candidate-registry-rebuild-failed", { + name: error && error.name, + message: error && error.message + }); + } + }; + if (typeof globalThis.requestIdleCallback === "function") { + documentSweepTask = globalThis.requestIdleCallback(run, { timeout: 700 }); + } else { + documentSweepTask = setTimeout(run, 0); + } + }, Math.max(0, Number(delay) || 0)); + } + + function isTagNavigationControl(target) { + if (!target || typeof target.closest !== "function") return false; + return Boolean(target.closest([ + ".tagButton", + "button[aria-label$=' tag']", + "[role='button'][aria-label$=' tag']" + ].join(", "))); + } + + function armTagTransitionGuard(event) { + if (!event || !isTagNavigationControl(event.target)) return; + tagTransitionUntil = Date.now() + TAG_TRANSITION_GUARD_MS; + pendingDecorationRoots.clear(); + requestDocumentSweep(260); + recordContentDiagnostic("info", "tag-transition-guard-armed", { + label: event.target && event.target.closest + ? (event.target.closest("[aria-label]") || {}).ariaLabel || null + : null, + guardMs: TAG_TRANSITION_GUARD_MS + }); + } + + function isLargeMutationBatch(mutations) { + if (!mutations || mutations.length >= MUTATION_BATCH_LIMIT) return true; + let changedNodes = 0; + for (const mutation of mutations) { + changedNodes += (mutation.addedNodes && mutation.addedNodes.length) || 0; + changedNodes += (mutation.removedNodes && mutation.removedNodes.length) || 0; + if (changedNodes >= MUTATION_NODE_LIMIT) return true; + } + return false; + } + + function refreshKnownButtonContexts() { + const buttons = new Set(externalButtons); + for (const buttonsForMedia of mediaButtons.values()) { + for (const button of buttonsForMedia) buttons.add(button); + } + for (const button of buttons) { + const root = button && button.__localRgRoot; + const anchor = button && button.__localRgAnchor; + if (!root || ("isConnected" in root && !root.isConnected)) continue; + configureButtonLayout(root, button, anchor || root); + } + pruneDetachedButtons(); + scheduleExternalLayout(); + } + + function handlePageMutations(mutations) { + if (Date.now() < tagTransitionUntil || isLargeMutationBatch(mutations)) { + requestDocumentSweep(260); + return; + } + let pageChanged = false; + for (const mutation of mutations) { + if (!mutation || isExtensionOwnedNode(mutation.target)) continue; + if (mutation.type === "attributes") { + const matchesCandidate = mutation.target.matches && mutation.target.matches(MEDIA_CANDIDATE_SELECTOR); + if (!matchesCandidate) { + if (candidateObserver && observedCandidates.has(mutation.target)) { + candidateObserver.unobserve(mutation.target); + observedCandidates.delete(mutation.target); + intersectingCandidates.delete(mutation.target); + pageChanged = true; + } + continue; + } + observeCandidate(mutation.target); + if (intersectingCandidates.has(mutation.target)) queueCandidateDecoration(mutation.target); + pageChanged = true; + continue; + } + for (const addedNode of mutation.addedNodes || []) { + if (!addedNode || addedNode.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(addedNode)) continue; + if (observeCandidateScope(addedNode)) pageChanged = true; + } + for (const removedNode of mutation.removedNodes || []) { + if (!removedNode || removedNode.nodeType !== Node.ELEMENT_NODE || isExtensionOwnedNode(removedNode)) continue; + if (unobserveCandidateScope(removedNode)) pageChanged = true; + } + } + if (pageChanged) { + pruneDetachedButtons(); + scheduleExternalLayout(); + } + } + + const observer = new MutationObserver(handlePageMutations); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, - attributeFilter: ["src", "poster", "data-src", "data-lazy-src", "data-feed-item-id"] + attributeFilter: ["src", "poster", "data-src", "data-lazy-src", "data-feed-item-id", "href"] }); if (typeof globalThis.addEventListener === "function") { + globalThis.addEventListener("click", armTagTransitionGuard, { capture: true, passive: true }); globalThis.addEventListener("scroll", scheduleFeedRefresh, { passive: true, capture: true }); globalThis.addEventListener("resize", scheduleExternalLayout, { passive: true }); + globalThis.addEventListener("fullscreenchange", () => { + refreshKnownButtonContexts(); + observeCandidateScope(document.fullscreenElement || document); + setTimeout(refreshKnownButtonContexts, 260); + }); } - scheduleDecoration(); + try { + chrome.storage.local.get("diagnosticsEnabled").then((stored) => { + applyDiagnosticsState(Boolean(stored && stored.diagnosticsEnabled)); + }).catch(() => {}); + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName === "local" && changes.diagnosticsEnabled) { + applyDiagnosticsState(Boolean(changes.diagnosticsEnabled.newValue)); + } + }); + } catch { + applyDiagnosticsState(false); + } + observeCandidateScope(document); } diff --git a/manifest.json b/manifest.json index cbc735a..b1b7f3a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "RedGifs Video Downloader", - "version": "0.6.21", + "version": "0.6.23", "description": "Downloads RedGifs media directly and processes HLS locally. No account or telemetry.", "permissions": [ "activeTab", diff --git a/popup.html b/popup.html index 05413ea..1e4ea0b 100644 --- a/popup.html +++ b/popup.html @@ -50,6 +50,14 @@ + +