Release RedGifs Video Downloader 0.6.23

This commit is contained in:
OwnerLink committed 2026-08-17 05:53:29 +03:00
1 parent 9d6149e6bf
commit 3f1d8b9845
7 files changed
+611 -78

No files matched your search

+3 -3
View File
@@ -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).
+20 -4
View File
@@ -1,7 +1,23 @@
# RedGifs Video Downloader 0.6.21
# RedGifs Video Downloader 0.6.23
![RedGifs Video Downloader preview](https://github.com/OwnerLink/redgifs-video-downloader/blob/main/docs/release-preview.png?raw=true)
## Исправления 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
```
+42 -1
View File
@@ -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) };
+492 -64
View File
@@ -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,15 +1564,10 @@ if (!globalThis.__localRedgifsScannerInstalled) {
registerMediaButton(button, item);
}
function scanPage() {
const items = [];
const seen = new Set();
lastScanDiagnostics = [];
function add(node) {
if (!node) return;
function extractCandidate(node) {
if (!node) return null;
const root = closestCard(node);
if (!root || isPageChromeRoot(root)) return;
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]");
@@ -1490,14 +1581,11 @@ if (!globalThis.__localRedgifsScannerInstalled) {
const directVideoUrl = videoSource(video);
const thumbnail = posterFromRoot(root, video);
const posterId = idFromPosterUrl(thumbnail);
if (!feedId && !href && posterId && !isExplicitMediaCard(root)) return;
if (!feedId && !href && posterId && !isExplicitMediaCard(root)) return null;
const stableId = feedId || idFromUrl(href) || posterId;
if (isGeneralFeedPage() && !stableId) return;
if (isGeneralFeedPage() && !stableId) return null;
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);
if (!id && !directVideoUrl) return null;
const item = {
id: id || null,
watchUrl: href || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null),
@@ -1506,24 +1594,81 @@ if (!globalThis.__localRedgifsScannerInstalled) {
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);
return {
root,
item,
mediaAnchor: video || previewImage,
key: id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl
};
}
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);
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 seenItems = new Set();
const seenRoots = new Set();
lastScanDiagnostics = [];
const nodes = Array.from(document.querySelectorAll(MEDIA_CANDIDATE_SELECTOR));
for (const node of nodes) {
const root = closestCard(node);
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);
}
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;
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(", ")));
}
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 {
scanPage();
decorateCandidate(root);
} catch (error) {
recordContentDiagnostic("error", "automatic-scan-failed", {
recordContentDiagnostic("error", "automatic-decoration-failed", {
name: error && error.name,
message: error && error.message
});
// A later DOM mutation will retry.
}
}, 160);
if (!ranSynchronously) decorateTimer = timer;
}
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);
}
}
const observer = new MutationObserver(scheduleDecoration);
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);
}
+1 -1
View File
@@ -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",
+8
View File
@@ -50,6 +50,14 @@
</span>
</label>
<label class="settings-check-row">
<input id="diagnosticsEnabled" type="checkbox">
<span>
<strong>Включить диагностику</strong>
<small>Временно собирать данные для диагностического лога.</small>
</span>
</label>
<div class="settings-preview">
<span>Пример пути</span>
<strong id="settingsPathPreview">Загрузки/RedGifs/profile/video.mp4</strong>
+45 -5
View File
@@ -7,6 +7,7 @@ const settingsPanel = document.getElementById("settingsPanel");
const baseFolderInput = document.getElementById("baseFolder");
const groupByProfileInput = document.getElementById("groupByProfile");
const saveAsInput = document.getElementById("saveAs");
const diagnosticsEnabledInput = document.getElementById("diagnosticsEnabled");
const settingsPathPreview = document.getElementById("settingsPathPreview");
const settingsSaveButton = document.getElementById("settingsSaveButton");
const settingsResetButton = document.getElementById("settingsResetButton");
@@ -42,6 +43,7 @@ const DEFAULT_DOWNLOAD_SETTINGS = Object.freeze({
saveAs: false
});
let downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS);
let diagnosticsEnabled = false;
let settingsStatusTimer = null;
let popupBlurTimer = null;
const panelAnimations = new WeakMap();
@@ -125,6 +127,7 @@ function setPanelExpanded(element, expanded, options) {
}
function reportPopupDiagnostic(level, event, details) {
if (!diagnosticsEnabled) return;
try {
const pending = chrome.runtime.sendMessage({
action: "diagnosticEvent",
@@ -215,9 +218,23 @@ function writeSettingsForm(settings) {
baseFolderInput.value = settings.baseFolder || "";
groupByProfileInput.checked = settings.groupByProfile !== false;
saveAsInput.checked = Boolean(settings.saveAs);
diagnosticsEnabledInput.checked = diagnosticsEnabled;
renderSettingsPathPreview();
}
function renderDiagnosticControls() {
diagnosticButton.disabled = !diagnosticsEnabled;
diagnosticButton.title = diagnosticsEnabled
? "Скачать диагностический лог"
: "Сначала включите диагностику в настройках";
if (!diagnosticsEnabled) {
diagnosticStatus.className = "diagnostic-status";
diagnosticStatus.textContent = "Диагностика выключена.";
} else if (diagnosticStatus.textContent === "Диагностика выключена.") {
diagnosticStatus.textContent = "";
}
}
function showSettingsStatus(text, kind) {
if (settingsStatusTimer) clearTimeout(settingsStatusTimer);
settingsStatusTimer = null;
@@ -233,11 +250,17 @@ function showSettingsStatus(text, kind) {
async function saveDownloadSettings() {
const settings = readSettingsForm();
const nextDiagnosticsEnabled = Boolean(diagnosticsEnabledInput.checked);
settingsSaveButton.disabled = true;
try {
await chrome.storage.local.set({ downloadSettings: settings });
await chrome.storage.local.set({
downloadSettings: settings,
diagnosticsEnabled: nextDiagnosticsEnabled
});
downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, settings);
diagnosticsEnabled = nextDiagnosticsEnabled;
writeSettingsForm(downloadSettings);
renderDiagnosticControls();
renderSaveDestination();
showSettingsStatus("Настройки сохранены.");
} catch (error) {
@@ -487,13 +510,20 @@ groupByProfileInput.addEventListener("change", renderSettingsPathPreview);
saveAsInput.addEventListener("change", renderSettingsPathPreview);
settingsSaveButton.addEventListener("click", saveDownloadSettings);
settingsResetButton.addEventListener("click", () => {
diagnosticsEnabled = false;
writeSettingsForm(DEFAULT_DOWNLOAD_SETTINGS);
diagnosticsEnabledInput.checked = false;
renderDiagnosticControls();
showSettingsStatus("Значения сброшены — нажмите «Сохранить».");
});
diagnosticButton.addEventListener("click", async () => {
if (!diagnosticsEnabled) {
renderDiagnosticControls();
return;
}
diagnosticButton.disabled = true;
diagnosticStatus.className = "diagnostic-status";
diagnosticStatus.textContent = "Собираю DOM, события и сетевые ошибки…";
diagnosticStatus.textContent = "Собираю диагностический лог…";
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs && tabs[0];
@@ -553,7 +583,7 @@ diagnosticButton.addEventListener("click", async () => {
diagnosticStatus.className = "diagnostic-status error";
diagnosticStatus.textContent = error && error.message ? error.message : String(error);
} finally {
diagnosticButton.disabled = false;
diagnosticButton.disabled = !diagnosticsEnabled;
}
});
alternativeToggle.addEventListener("click", async () => {
@@ -573,10 +603,13 @@ alternativeToggle.addEventListener("click", async () => {
(async () => {
writeSettingsForm(downloadSettings);
renderDiagnosticControls();
try {
const stored = await chrome.storage.local.get("downloadSettings");
const stored = await chrome.storage.local.get(["downloadSettings", "diagnosticsEnabled"]);
downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, stored.downloadSettings || {});
diagnosticsEnabled = Boolean(stored.diagnosticsEnabled);
writeSettingsForm(downloadSettings);
renderDiagnosticControls();
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs && tabs[0];
if (tab && /^https:\/\/(?:[^/]+\.)?redgifs\.com\//i.test(tab.url || "")) {
@@ -593,8 +626,15 @@ alternativeToggle.addEventListener("click", async () => {
})();
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local" || !changes.downloadSettings) return;
if (areaName !== "local") return;
if (changes.downloadSettings) {
downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, changes.downloadSettings.newValue || {});
writeSettingsForm(downloadSettings);
renderSaveDestination();
}
if (changes.diagnosticsEnabled) {
diagnosticsEnabled = Boolean(changes.diagnosticsEnabled.newValue);
diagnosticsEnabledInput.checked = diagnosticsEnabled;
renderDiagnosticControls();
}
});