diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..055bc1e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.zip binary +*.crx binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99d63b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Local release artifacts +*.zip +*.crx +*.pem + +# OS and editor files +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# Local machine files +.claude/ +.agents/ +.codex/ +.runtime/ +node_modules/ +.env +.env.* +*.key +*.pfx +*.p12 +*.log diff --git a/CNAME b/CNAME deleted file mode 100644 index cc43a53..0000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -redgifs.extension.lonarpgproxy.space diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b91e71 --- /dev/null +++ b/LICENSE @@ -0,0 +1,61 @@ +REDGIFS VIDEO DOWNLOADER PROPRIETARY LICENSE +Version 1.0, 14 August 2026 + +Copyright (c) 2026 OwnerLink. All rights reserved. + +1. LIMITED END-USER LICENSE + +Subject to this License, the copyright holder grants an individual end user a +limited, revocable, non-exclusive, non-transferable license to install and run +an unmodified official release of RedGifs Video Downloader solely for personal, +non-commercial use and solely to save content that the end user owns or has +explicit permission from the applicable rights holder to save. + +2. NO OTHER RIGHTS GRANTED + +Except for the limited permission in Section 1, no permission is granted to: + +(a) copy, reproduce, modify, adapt, translate, merge, or create derivative works; +(b) publish, distribute, sublicense, sell, rent, lease, host, mirror, or otherwise + make the Software or any part of it available to another person; +(c) fork, clone for redistribution, or maintain a public or private derivative; +(d) reverse engineer, decompile, disassemble, or attempt to derive or reuse any + component, except only to the extent such a restriction is prohibited by + applicable law; +(e) remove or alter copyright, license, attribution, or proprietary notices; +(f) use the Software for commercial activity, bulk republication, infringement, + unauthorized access, circumvention of DRM, or violation of third-party terms. + +Possession of source code or a release archive does not grant any right beyond +Section 1. No rights are granted by implication, estoppel, or otherwise. + +3. THIRD-PARTY CONTENT AND SERVICES + +The Software is unofficial and is not affiliated with, endorsed by, or sponsored +by RedGIFS, Google, GitHub, or any content creator. This License grants no rights +to third-party content, names, trademarks, services, or APIs. The end user is +solely responsible for obtaining all necessary permissions and complying with +applicable law and third-party terms. + +4. TERMINATION + +This License terminates automatically upon breach. On termination, the end user +must stop using the Software and delete all copies in their possession or control. + +5. NO WARRANTY + +THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. USE IS AT THE END USER'S SOLE RISK. + +6. LIMITATION OF LIABILITY + +TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE COPYRIGHT HOLDER SHALL NOT +BE LIABLE FOR ANY CLAIM, LOSS, DAMAGES, OR OTHER LIABILITY ARISING FROM OR RELATED +TO THE SOFTWARE OR ITS USE, WHETHER IN CONTRACT, TORT, OR OTHERWISE. + +7. WRITTEN PERMISSION + +Any permission outside Section 1 must be granted in a separate written agreement +signed by the copyright holder. Requests may be submitted through the official +Gitea repository maintained by OwnerLink. diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..3b5a21b --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,42 @@ +# Privacy Policy + +Effective date: 14 August 2026 + +RedGifs Video Downloader is designed to work locally in the user's Chromium-based browser. + +## Data handled by the extension + +The extension may process the following data when the user visits RedGIFS: + +- the current RedGIFS page URL; +- visible page elements and media metadata needed to identify a video; +- RedGIFS media URLs and temporary API authorization required by the site; +- download settings and the stable IDs of successfully downloaded videos; +- diagnostic events, DOM geometry, HTTP statuses and error messages. + +## How data is used + +This data is used only to find requested media, assemble it locally, save it through the browser download manager, remember completed downloads, and generate a diagnostic log when requested. + +## Storage and sharing + +- The project has no developer-operated server. +- No analytics or telemetry is collected. +- No data is sold or shared by the developer. +- Settings and downloaded-video IDs remain in browser extension storage. +- Temporary diagnostic events remain in session storage and are exported only when the user clicks the diagnostic-log button. +- Media is not uploaded to a third-party service by this extension. + +Normal communication with RedGIFS and its content-delivery infrastructure remains subject to RedGIFS' own privacy policy and terms. + +## Diagnostic redaction + +The diagnostic exporter removes URL query strings and fragments and replaces known token, authorization, cookie, credential, password and secret fields with `[redacted]`. Video bodies and API response bodies are not included. Users should still review a diagnostic file before sharing it. + +## Removal + +Users can erase extension settings and history by removing the extension or clearing its stored data in the browser. + +## Contact + +Privacy questions may be submitted through the official Gitea repository maintained by OwnerLink. diff --git a/README.md b/README.md index 6f1abba..95f5e29 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,122 @@ -# RedGifs Video Downloader — сайт-витрина +# RedGifs Video Downloader -Статическая страница-презентация расширения **RedGifs Video Downloader**. -Без сборки, без зависимостей и без бэкенда: HTML, CSS и один файл JavaScript. +![RedGifs Video Downloader preview](docs/release-preview.png) -**Сайт:** https://redgifs.extension.lonarpgproxy.space/ -**Само расширение:** https://github.com/OwnerLink/redgifs-video-downloader -**Скачать сборку:** https://github.com/OwnerLink/redgifs-video-downloader/releases/latest +Локальное расширение Chromium для сохранения разрешённых пользователю видео с RedGIFs. Расширение добавляет кнопку загрузки непосредственно на карточки, показывает ход выполнения и собирает HLS-потоки на устройстве пользователя. -## Лицензия +> [!IMPORTANT] +> Это неофициальный проект, не связанный с RedGIFS, Google или GitHub. Он предназначен только для совершеннолетних пользователей и только для материалов, которые пользователь создал сам либо получил явное разрешение сохранить. Условия RedGIFS могут ограничивать скачивание контента; ответственность за соблюдение условий сайта и прав авторов несёт пользователь. -Материалы сайта относятся к проекту RedGifs Video Downloader, -© 2026 RedGifs Video Downloader, все права защищены. Условия — в -[LICENSE расширения](https://github.com/OwnerLink/redgifs-video-downloader/blob/main/LICENSE). +## Возможности + +- кнопка загрузки на обнаруженных карточках, в ленте и полноэкранном просмотре; +- автоматический выбор HD MP4 с резервными вариантами; +- локальная обработка MP4, GIF и HLS без стороннего сервера; +- наглядное уведомление с ходом загрузки и пятисекундным обратным отсчётом; +- отметка уже скачанных роликов по стабильному RedGIFS ID; +- повторная загрузка через оранжевую кнопку при наведении на зелёную галочку; +- понятные имена файлов из метаданных ролика; +- пользовательская подпапка внутри `Загрузок`; +- необязательная группировка по имени профиля; +- резервный сканер видимых роликов в popup; +- диагностический журнал с удалением токенов, cookie и других секретов; +- без регистрации, платного счётчика, аналитики и телеметрии. + +## Установка + +### Из Gitea Release + +1. Скачайте архив `RedGifs-Video-Downloader-v0.6.23.zip` из последнего Release. +2. Распакуйте архив в постоянную папку. Не удаляйте её после установки. +3. Откройте `chrome://extensions` в Chrome, Chromium, Brave или другом совместимом браузере. +4. Включите **Режим разработчика**. +5. Нажмите **Загрузить распакованное расширение**. +6. Выберите распакованную папку, внутри которой находится `manifest.json`. + +Расширение распространяется вручную: его публикация в Chrome Web Store не планируется из-за правил магазина для расширений, основная функция которых относится к сайтам с контентом для взрослых. + +## Использование + +1. Откройте страницу ролика, профиль, нишу или ленту RedGIFS. +2. На распознанной видеокарточке появится круглая розовая кнопка загрузки. +3. Нажмите её и дождитесь завершения. Страница покажет текущий этап и ход загрузки. +4. После успешного сохранения кнопка станет зелёной. При наведении появится действие повторной загрузки. + +Если кнопка не появилась, откройте popup расширения и запустите резервное сканирование видимых роликов. При бесконечной ленте новые карточки обрабатываются по мере прокрутки. + +## Настройки + +Настройки находятся под шестерёнкой в popup и по умолчанию свёрнуты. + +- **Подпапка внутри Загрузок** — относительный путь, например `Видео/RedGifs`. +- **Группировать по профилям** — добавляет к пути имя профиля из URL или метаданных карточки. +- **Всегда спрашивать место сохранения** — включает системный диалог браузера. + +Пример итогового пути: + +```text +Загрузки/RedGifs/example-profile/Название ролика.mp4 +``` + +Фактическая корневая папка определяется настройками загрузок браузера. Расширения Chromium не могут произвольно сохранять файлы за пределами разрешённого браузером каталога. + +## Приватность и безопасность + +- видео обрабатывается локально в браузере; +- сторонние серверы проекта отсутствуют; +- аккаунт и email не требуются; +- аналитика и телеметрия отсутствуют; +- сетевой доступ ограничен `redgifs.com` и его поддоменами; +- настройки и история скачанных ID хранятся в локальном хранилище расширения; +- диагностический журнал создаётся только по команде пользователя; +- чувствительные заголовки, токены, cookie, query-параметры и hash удаляются или заменяются на `[redacted]`. + +Подробнее: [PRIVACY.md](PRIVACY.md). + +## Разрешения браузера + +| Разрешение | Назначение | +| --- | --- | +| `activeTab`, `scripting` | обнаружение роликов и отображение интерфейса на открытой вкладке RedGIFS | +| `downloads` | передача готового файла менеджеру загрузок браузера | +| `offscreen` | локальная сборка Blob/HLS вне жизненного цикла popup | +| `storage` | настройки, история скачанных ID и временная диагностика | +| `tabs` | связь popup и service worker с открытой вкладкой | +| `https://redgifs.com/*`, `https://*.redgifs.com/*` | получение разрешённых медиа и метаданных только с RedGIFS | + +## Поддерживаемые форматы + +- прямой MP4; +- GIF; +- HLS с выбором потока наибольшего доступного разрешения; +- AES-128 HLS. + +SAMPLE-AES и DRM не поддерживаются и не обходятся. Очень большие HLS-ролики могут потребовать заметный объём оперативной памяти, поскольку сегменты собираются локально. + +## Диагностика + +Диагностика по умолчанию выключена. Для поиска проблемы откройте настройки popup, включите переключатель **Включить диагностику**, воспроизведите проблему и нажмите **Скачать диагностический лог**. После отключения накопленные диагностические события и история позиционирования очищаются. Журнал не содержит тело видео и намеренно очищается от известных секретов. + +Перед публикацией диагностического файла всё равно рекомендуется просмотреть его вручную. + +## Правовой статус + +Исходный код и релизные сборки предоставляются по проприетарной лицензии **All Rights Reserved**. Разрешена только установка и запуск неизменённого официального релиза для личного некоммерческого использования. Копирование, изменение, форки, публикация зеркал, перераспространение, продажа и создание производных работ запрещены без предварительного письменного разрешения правообладателя. + +Полный текст: [LICENSE](LICENSE). + +Публичная доступность файлов в Gitea сама по себе не предоставляет дополнительных прав. Технически Gitea позволяет создать fork любого публичного репозитория, однако такой fork или иное копирование может нарушать условия этой лицензии. + +## Версия + +Текущая версия: **0.6.23**. + +История релиза: [RELEASE_NOTES.md](RELEASE_NOTES.md). + +## Отказ от гарантий + +Программа предоставляется «как есть», без гарантий работоспособности, доступности RedGIFS, соответствия сторонним условиям или сохранности данных. Структура сайта и API могут измениться без предупреждения. + +## Сайт проекта + +[Сайт расширения](https://git.lonarpgproxy.space/extention/redgifs/). Его исходники находятся в каталоге [website](website/). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..89d03bd --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,56 @@ +# RedGifs Video Downloader 0.6.23 + +![RedGifs Video Downloader preview](https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/raw/branch/main/docs/release-preview.png?raw=true) + +## Исправления 0.6.23 + +- Выбор тега в профиле больше не запускает отдельный глубокий обход для каждого фрагмента перестраиваемой страницы. +- Серии крупных DOM-изменений объединяются в один отложенный пересмотр после стабилизации страницы. +- Отключённые и удалённые карточки освобождаются из реестра наблюдения одним проходом. +- В кнопки и диагностический интерфейс добавлен номер фактически работающего content script, чтобы отличать свежую версию от оставшейся в старой вкладке. + +## Исправления 0.6.22 + +- Устранён полный перескан страницы каждые 160 мс при прокрутке и изменениях DOM. +- Карточки обрабатываются небольшими порциями только рядом с видимой областью. +- Кнопки полноэкранного просмотра больше не забираются у исходной карточки профиля. +- Удалённые карточки, кнопки и цели наблюдения освобождаются после фильтрации по тегам. +- Диагностика получила отдельный переключатель и по умолчанию выключена. +- При выключении диагностики очищаются накопленные события и история позиционирования. + +Первый релиз независимого локального загрузчика для Chromium. + +## Главное + +- Кнопки загрузки в карточках, лентах и полноэкранном режиме. +- Устойчивое позиционирование кнопок при виртуализации и бесконечной прокрутке. +- Автоматическое обнаружение новых карточек без накопления дубликатов. +- Локальное сохранение MP4, GIF и HLS без внешнего сервера. +- Единое уведомление с ходом загрузки, зелёной полосой прогресса и обратным отсчётом после завершения. +- Синхронная зелёная отметка только после фактического завершения записи файла. +- История скачанных RedGIFS ID и оранжевая кнопка повторной загрузки. +- Имена файлов из настоящего названия/описания ролика. +- Группировка файлов по профилям, включая карточки в общей ленте. +- Сворачиваемые настройки в popup с плавной анимацией. +- Резервное сканирование видимых роликов с превью. +- Экспорт диагностического журнала с очисткой известных секретов. +- Новый комплект иконок и название RedGifs Video Downloader. + +## Установка + +1. Скачайте `RedGifs-Video-Downloader-v0.6.23.zip`. +2. Распакуйте архив. +3. Откройте `chrome://extensions`. +4. Включите режим разработчика. +5. Нажмите «Загрузить распакованное расширение» и выберите распакованную папку. + +## Важное ограничение + +Используйте расширение только для собственных материалов или контента, на сохранение которого у вас есть явное разрешение правообладателя. Проект не обходит DRM и не предоставляет прав на чужой контент. + +## Контрольная сумма + +```text +RedGifs-Video-Downloader-v0.6.23.zip +SHA-256: см. описание Gitea Release +``` diff --git a/background.js b/background.js new file mode 100644 index 0000000..21cc0e4 --- /dev/null +++ b/background.js @@ -0,0 +1,1171 @@ +"use strict"; + +const REDGIFS_API = "https://api.redgifs.com"; +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 }; +const jobs = new Map(); +const downloadJobIds = new Map(); +let creatingOffscreen = null; +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 { + const parsed = new URL(String(value || "")); + if (parsed.protocol === "blob:") return "blob:" + parsed.origin + "/[local-object]"; + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return String(value || "").slice(0, 300); + } +} + +function diagnosticValue(value, key, depth) { + if (depth > 5) return "[max-depth]"; + const name = String(key || "").toLowerCase(); + if (/authorization|cookie|token|credential|password|secret/.test(name)) return "[redacted]"; + if (value == null || typeof value === "boolean" || typeof value === "number") return value; + if (value instanceof Error) { + return { + name: String(value.name || "Error").slice(0, 100), + message: String(value.message || value).slice(0, 1000) + }; + } + if (typeof value === "string") { + if (/url|uri|source|playlist|media/i.test(name) || /^https?:|^blob:/i.test(value)) return diagnosticUrl(value); + return value.slice(0, 1000); + } + if (Array.isArray(value)) return value.slice(0, 50).map((item) => diagnosticValue(item, key, depth + 1)); + if (typeof value === "object") { + const result = {}; + for (const [childKey, childValue] of Object.entries(value).slice(0, 80)) { + result[childKey] = diagnosticValue(childValue, childKey, depth + 1); + } + return result; + } + return String(value).slice(0, 500); +} + +async function loadDiagnosticLog() { + if (!diagnosticsEnabled) return []; + if (diagnosticLogCache) return diagnosticLogCache; + try { + const stored = await chrome.storage.session.get(DIAGNOSTIC_LOG_KEY); + diagnosticLogCache = Array.isArray(stored && stored[DIAGNOSTIC_LOG_KEY]) + ? stored[DIAGNOSTIC_LOG_KEY].slice(-DIAGNOSTIC_LOG_LIMIT) + : []; + } catch { + diagnosticLogCache = []; + } + return diagnosticLogCache; +} + +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", + area: String(area || "runtime").slice(0, 80), + event: String(event || "event").slice(0, 120), + details: diagnosticValue(details || {}, "details", 0) + }; + diagnosticLogWrite = diagnosticLogWrite.catch(() => {}).then(async () => { + const log = await loadDiagnosticLog(); + log.push(entry); + if (log.length > DIAGNOSTIC_LOG_LIMIT) log.splice(0, log.length - DIAGNOSTIC_LOG_LIMIT); + try { + await chrome.storage.session.set({ [DIAGNOSTIC_LOG_KEY]: log }); + } catch { + // In-memory diagnostics remain available until the worker is stopped. + } + }); + return entry; +} + +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, + online: navigator.onLine + } : null, + tokenCache: { hasToken: Boolean(tokenCache.value), expiresAt: tokenCache.expiresAt || 0 }, + downloadSettings: diagnosticValue(settings, "downloadSettings", 0), + activeJobs: Array.from(jobs.values()).slice(-50).map((job) => diagnosticValue(job, "job", 0)), + events: log.slice() + }; +} + +function diagnosticReportJson(report) { + const json = JSON.stringify(report, null, 2); + if (json.length > DIAGNOSTIC_REPORT_MAX_CHARS) { + throw new Error("Диагностический лог слишком большой для сохранения."); + } + return json; +} + +function diagnosticReportDataUrl(report) { + const json = diagnosticReportJson(report); + const bytes = new TextEncoder().encode(json); + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return "data:application/json;base64," + btoa(binary); +} + +async function downloadDiagnosticReport(report) { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = "redgifs-diagnostic-log-" + stamp + ".json"; + const text = diagnosticReportJson(report); + try { + await ensureOffscreenDocument(); + const response = await chrome.runtime.sendMessage({ + target: "offscreen", + action: "downloadTextFile", + text, + filename, + mimeType: "application/json;charset=utf-8", + saveAs: true + }); + if (!response || !response.ok || !response.result) { + throw new Error(response && response.error ? response.error : "Offscreen-процесс не сохранил лог."); + } + recordDiagnostic("info", "diagnostic", "report-download-started", { + downloadId: response.result.downloadId, + filename, + mode: "offscreen-blob", + textLength: text.length + }); + return Object.assign({ filename, mode: "offscreen-blob" }, response.result); + } catch (offscreenError) { + recordDiagnostic("warn", "diagnostic", "offscreen-report-download-failed", { + filename, + error: offscreenError + }); + const url = diagnosticReportDataUrl(report); + const downloadId = await chrome.downloads.download({ + url, + filename, + saveAs: true, + conflictAction: "uniquify" + }); + recordDiagnostic("info", "diagnostic", "report-download-started", { + downloadId, + filename, + mode: "data-url-fallback", + encodedBytes: url.length + }); + return { downloadId, filename, mode: "data-url-fallback" }; + } +} + +function isRedgifsHost(hostname) { + const host = String(hostname || "").toLowerCase(); + return host === "redgifs.com" || host.endsWith(".redgifs.com"); +} + +function parseTrustedUrl(value) { + let parsed; + try { + parsed = new URL(String(value || "").trim()); + } catch { + throw new Error("Введите корректную ссылку RedGifs."); + } + if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) { + throw new Error("Поддерживаются только HTTPS-ссылки RedGifs."); + } + return parsed; +} + +function extractId(parsedUrl) { + const match = parsedUrl.pathname.match(/\/(?:watch|ifr)\/([a-z0-9_-]+)/i); + return match && match[1] ? match[1] : null; +} + +function normalizeMediaId(value) { + const id = String(value || "").trim().toLowerCase(); + return /^[a-z0-9_-]{2,160}$/i.test(id) ? id : null; +} + +function decodeHtml(value) { + return String(value || "") + .replace(/&/gi, "&") + .replace(///gi, "/") + .replace(/"/gi, "\"") + .replace(/'/gi, "'"); +} + +function extensionFromUrl(url, fallback) { + try { + const match = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i); + return match ? match[1].toLowerCase() : fallback; + } catch { + return fallback; + } +} + +function sanitizeFilename(value) { + const cleaned = String(value || "redgifs-video") + .replace(/^Poster for\s+/i, "") + .replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "") + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + return (cleaned || "redgifs-video").slice(0, 120); +} + +const DEFAULT_DOWNLOAD_SETTINGS = { + baseFolder: "RedGifs", + groupByProfile: true, + saveAs: false +}; + +function sanitizeFolderPath(value) { + return String(value || "") + .replace(/\\/g, "/") + .split("/") + .map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim()) + .filter((part) => part && part !== "." && part !== "..") + .slice(0, 8) + .map((part) => part.slice(0, 80)) + .join("/"); +} + +function profileFromUrl(value) { + try { + const parsed = parseTrustedUrl(value); + const match = parsed.pathname.match(/\/users\/([^/?#]+)/i); + return match && match[1] ? decodeURIComponent(match[1]) : null; + } catch { + return null; + } +} + +function profileFromGif(gif) { + if (!gif || typeof gif !== "object") return null; + const nestedUser = gif.user && typeof gif.user === "object" ? gif.user : null; + const value = gif.userName + || gif.username + || gif.user_name + || (nestedUser && (nestedUser.userName || nestedUser.username || nestedUser.name)); + return sanitizeFolderPath(String(value || "").trim().replace(/^@+/, "")) || null; +} + +async function getDownloadSettings() { + try { + const stored = await chrome.storage.local.get("downloadSettings"); + return Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, stored.downloadSettings || {}); + } catch { + return Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS); + } +} + +async function readDownloadHistory() { + if (downloadHistoryCache) return downloadHistoryCache; + try { + const stored = await chrome.storage.local.get(DOWNLOAD_HISTORY_KEY); + const value = stored && stored[DOWNLOAD_HISTORY_KEY]; + downloadHistoryCache = value && typeof value === "object" ? value : {}; + } catch { + downloadHistoryCache = {}; + } + return downloadHistoryCache; +} + +async function getDownloadedRecords(ids) { + await downloadHistoryWrite.catch(() => {}); + const history = await readDownloadHistory(); + const result = {}; + for (const value of Array.isArray(ids) ? ids.slice(0, 500) : []) { + const id = normalizeMediaId(value); + if (id && history[id]) result[id] = history[id]; + } + return result; +} + +function rememberDownloaded(job) { + const mediaId = normalizeMediaId(job && job.mediaId); + if (!mediaId) return Promise.resolve(); + downloadHistoryWrite = downloadHistoryWrite.catch(() => {}).then(async () => { + const history = Object.assign({}, await readDownloadHistory()); + history[mediaId] = { + downloadedAt: Date.now(), + filename: sanitizeFilename(job.filename || job.title || mediaId), + profileSlug: sanitizeFolderPath(job.profileSlug || "") || null + }; + const keys = Object.keys(history); + if (keys.length > DOWNLOAD_HISTORY_LIMIT) { + keys.sort((left, right) => { + return Number(history[left] && history[left].downloadedAt || 0) + - Number(history[right] && history[right].downloadedAt || 0); + }); + for (const key of keys.slice(0, keys.length - DOWNLOAD_HISTORY_LIMIT)) delete history[key]; + } + downloadHistoryCache = history; + try { + await chrome.storage.local.set({ [DOWNLOAD_HISTORY_KEY]: history }); + } catch { + // The current session still remembers the completed download. + } + }); + return downloadHistoryWrite; +} + +function buildOutputFolder(settings, profileSlug) { + const parts = []; + const base = sanitizeFolderPath(settings.baseFolder); + if (base) parts.push(base); + if (settings.groupByProfile) { + const profile = sanitizeFolderPath(profileSlug); + if (profile) parts.push(profile); + } + return parts.join("/"); +} + +function addFormat(list, seen, label, url, kind, quality, extension) { + if (!url) return; + let parsed; + try { + parsed = parseTrustedUrl(url); + } catch { + return; + } + const normalized = parsed.toString(); + if (seen.has(normalized)) return; + seen.add(normalized); + list.push({ + label, + url: normalized, + kind, + quality: quality || null, + extension: extension || extensionFromUrl(normalized, kind === "hls" ? "m3u8" : "mp4") + }); +} + +function formatsFromGif(gif) { + const formats = []; + const seen = new Set(); + const urls = gif && gif.urls ? gif.urls : {}; + const height = Number(gif && gif.height) || null; + const sdHeight = height && height >= 720 ? 480 : 360; + + addFormat(formats, seen, height ? "HD MP4 · " + height + "p" : "HD MP4", urls.hd, "direct", height, "mp4"); + addFormat(formats, seen, "SD MP4 · " + sdHeight + "p", urls.sd, "direct", sdHeight, "mp4"); + addFormat(formats, seen, "HLS · локальная сборка", urls.hls, "hls", height, "m3u8"); + addFormat(formats, seen, "Без звука", urls.silent, String(urls.silent || "").includes(".m3u8") ? "hls" : "direct", height, extensionFromUrl(urls.silent, "mp4")); + addFormat(formats, seen, "GIF", urls.gif, "direct", null, extensionFromUrl(urls.gif, "gif")); + return formats; +} + +async function getTemporaryToken(forceRefresh) { + if (!forceRefresh && tokenCache.value && tokenCache.expiresAt > Date.now()) { + recordDiagnostic("debug", "network.auth", "token-cache-hit", { expiresAt: tokenCache.expiresAt }); + return tokenCache.value; + } + let lastError = null; + for (const method of ["GET", "POST"]) { + const startedAt = Date.now(); + try { + recordDiagnostic("info", "network.auth", "request", { method, url: REDGIFS_API + "/v2/auth/temporary" }); + const response = await fetch(REDGIFS_API + "/v2/auth/temporary", { + method, + headers: { Accept: "application/json, text/plain, */*" }, + cache: "no-store" + }); + recordDiagnostic(response.ok ? "info" : "warn", "network.auth", "response", { + method, + url: REDGIFS_API + "/v2/auth/temporary", + status: response.status, + ok: response.ok, + durationMs: Date.now() - startedAt + }); + if (!response.ok) throw new Error("HTTP " + response.status); + const payload = await response.json(); + if (!payload || !payload.token) throw new Error("RedGifs не вернул временный токен."); + tokenCache.value = payload.token; + tokenCache.expiresAt = Date.now() + TOKEN_TTL_MS; + return tokenCache.value; + } catch (error) { + lastError = error; + recordDiagnostic("error", "network.auth", "request-failed", { + method, + url: REDGIFS_API + "/v2/auth/temporary", + durationMs: Date.now() - startedAt, + error + }); + } + } + throw new Error("Не удалось получить временный токен RedGifs: " + (lastError && lastError.message ? lastError.message : "unknown error")); +} + +async function fetchGifMetadata(id, forceTokenRefresh) { + const token = await getTemporaryToken(Boolean(forceTokenRefresh)); + const watchUrl = "https://www.redgifs.com/watch/" + encodeURIComponent(id); + const apiUrl = REDGIFS_API + "/v2/gifs/" + encodeURIComponent(id); + const startedAt = Date.now(); + let response; + try { + recordDiagnostic("info", "network.metadata", "request", { id, url: apiUrl, forceTokenRefresh: Boolean(forceTokenRefresh) }); + response = await fetch(apiUrl, { + headers: { + Accept: "application/json, text/plain, */*", + Authorization: "Bearer " + token, + "x-customheader": watchUrl + }, + cache: "no-store", + referrer: watchUrl, + referrerPolicy: "no-referrer-when-downgrade" + }); + recordDiagnostic(response.ok ? "info" : "warn", "network.metadata", "response", { + id, + url: apiUrl, + status: response.status, + ok: response.ok, + durationMs: Date.now() - startedAt + }); + } catch (error) { + recordDiagnostic("error", "network.metadata", "request-failed", { + id, + url: apiUrl, + durationMs: Date.now() - startedAt, + error + }); + throw error; + } + if (response.status === 401 && !forceTokenRefresh) { + tokenCache.value = null; + tokenCache.expiresAt = 0; + return fetchGifMetadata(id, true); + } + if (!response.ok) throw new Error("RedGifs API: HTTP " + response.status); + const payload = await response.json(); + if (!payload || !payload.gif) throw new Error("RedGifs API не вернул данные видео."); + return payload.gif; +} + +function metaContent(html, key) { + const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + const first = new RegExp("]+(?:property|name)=[\"']" + escaped + "[\"'][^>]+content=[\"']([^\"']+)[\"']", "i"); + const second = new RegExp("]+content=[\"']([^\"']+)[\"'][^>]+(?:property|name)=[\"']" + escaped + "[\"']", "i"); + const match = html.match(first) || html.match(second); + return match && match[1] ? decodeHtml(match[1]) : null; +} + +async function pageFallback(parsedUrl, knownId) { + const startedAt = Date.now(); + let response; + try { + recordDiagnostic("info", "network.page", "request", { url: parsedUrl.toString(), knownId }); + response = await fetch(parsedUrl.toString(), { + headers: { Accept: "text/html,application/xhtml+xml" }, + cache: "no-store" + }); + recordDiagnostic(response.ok ? "info" : "warn", "network.page", "response", { + url: parsedUrl.toString(), + status: response.status, + ok: response.ok, + durationMs: Date.now() - startedAt + }); + } catch (error) { + recordDiagnostic("error", "network.page", "request-failed", { + url: parsedUrl.toString(), + durationMs: Date.now() - startedAt, + error + }); + throw error; + } + if (!response.ok) throw new Error("Страница RedGifs недоступна: HTTP " + response.status); + const html = await response.text(); + const idMatch = html.match(/https:\/\/(?:www\.)?redgifs\.com\/(?:watch|ifr)\/([a-z0-9_-]+)/i); + const id = knownId || (idMatch && idMatch[1] ? idMatch[1] : null); + const title = metaContent(html, "og:title") || metaContent(html, "twitter:title") || id || "RedGifs video"; + const thumbnail = metaContent(html, "og:image") || metaContent(html, "twitter:image"); + const candidates = [ + metaContent(html, "og:video"), + metaContent(html, "og:video:url"), + metaContent(html, "twitter:player:stream") + ]; + const embedded = html.match(/https:\/\/media\.redgifs\.com\/[^"'\\\s<>]+?\.(?:mp4|m3u8|gif|webm)(?:\?[^"'\\\s<>]*)?/gi) || []; + candidates.push.apply(candidates, embedded); + const formats = []; + const seen = new Set(); + for (const candidate of candidates) { + if (!candidate) continue; + const value = decodeHtml(candidate); + const hls = /\.m3u8(?:$|\?)/i.test(value); + addFormat(formats, seen, hls ? "HLS · найдено на странице" : "Видео · найдено на странице", value, hls ? "hls" : "direct", null, hls ? "m3u8" : extensionFromUrl(value, "mp4")); + } + return { id, title, thumbnail, formats }; +} + +async function getRedgifsTab(tabId) { + let tab = null; + if (Number.isInteger(tabId)) { + try { + tab = await chrome.tabs.get(tabId); + } catch { + tab = null; + } + } + if (!tab) { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + tab = tabs && tabs[0] ? tabs[0] : null; + } + if (!tab || !tab.id || !tab.url) throw new Error("Не найдена открытая вкладка RedGifs."); + const parsed = parseTrustedUrl(tab.url); + return { tab, parsed }; +} + +function collectionTitleNeedsMetadata(item) { + const id = normalizeMediaId(item && item.id); + const title = String(item && item.title || "").trim(); + if (!title) return true; + const withoutPrefix = title.replace(/^redgifs\s*[·:—-]?\s*/i, "").trim(); + return Boolean(id && normalizeMediaId(withoutPrefix) === id); +} + +function mergeCollectionItemMetadata(item, gif) { + if (!item || !gif) return item; + const tags = Array.isArray(gif.tags) ? gif.tags.slice(0, 8).join(" ") : ""; + const metadataTitle = String(gif.title || gif.description || tags || "").trim(); + const rawThumbnail = gif.urls && (gif.urls.poster || gif.urls.thumbnail) || null; + let thumbnail = item.thumbnail || null; + if (rawThumbnail) { + try { + thumbnail = parseTrustedUrl(rawThumbnail).toString(); + } catch { + // Keep the thumbnail captured from the page. + } + } + return Object.assign({}, item, { + title: collectionTitleNeedsMetadata(item) && metadataTitle + ? metadataTitle.slice(0, 160) + : item.title, + thumbnail, + profileSlug: profileFromGif(gif) || item.profileSlug || null + }); +} + +async function hydrateCollectionItems(items) { + const result = Array.isArray(items) ? items.slice() : []; + const pendingIndexes = []; + for (let index = 0; index < result.length; index += 1) { + const item = result[index]; + if (normalizeMediaId(item && item.id) + && (!item.thumbnail || !item.profileSlug || collectionTitleNeedsMetadata(item))) { + pendingIndexes.push(index); + } + } + if (!pendingIndexes.length) return result; + + try { + await getTemporaryToken(false); + } catch (error) { + recordDiagnostic("warn", "content.scan", "metadata-hydration-auth-failed", { + itemCount: pendingIndexes.length, + error + }); + return result; + } + + let cursor = 0; + let hydratedCount = 0; + let failedCount = 0; + async function worker() { + while (cursor < pendingIndexes.length) { + const pendingIndex = pendingIndexes[cursor]; + cursor += 1; + const item = result[pendingIndex]; + try { + const gif = await fetchGifMetadata(item.id, false); + result[pendingIndex] = mergeCollectionItemMetadata(item, gif); + hydratedCount += 1; + } catch (error) { + failedCount += 1; + recordDiagnostic("warn", "content.scan", "metadata-hydration-item-failed", { + id: item.id, + error + }); + } + } + } + const concurrency = Math.min(6, pendingIndexes.length); + await Promise.all(Array.from({ length: concurrency }, () => worker())); + recordDiagnostic("info", "content.scan", "metadata-hydration-complete", { + requestedCount: pendingIndexes.length, + hydratedCount, + failedCount + }); + return result; +} + +async function scanOpenRedgifsPage(tabId, requestedUrl) { + const current = await getRedgifsTab(tabId); + if (requestedUrl && current.parsed.origin + current.parsed.pathname !== requestedUrl.origin + requestedUrl.pathname) { + throw new Error("Откройте указанный профиль RedGifs в текущей вкладке, затем нажмите «Сканировать»."); + } + + let response = null; + try { + response = await chrome.tabs.sendMessage(current.tab.id, { action: "scanRedgifsPage" }); + } catch (error) { + recordDiagnostic("warn", "content.scan", "content-script-unavailable", { tabId: current.tab.id, error }); + await chrome.scripting.executeScript({ + target: { tabId: current.tab.id }, + files: ["content.js"] + }); + response = await chrome.tabs.sendMessage(current.tab.id, { action: "scanRedgifsPage" }); + } + if (!response || !response.ok) { + recordDiagnostic("error", "content.scan", "scan-failed", { tabId: current.tab.id, response }); + throw new Error(response && response.error ? response.error : "Не удалось просканировать страницу RedGifs."); + } + + const rawItems = response.result && Array.isArray(response.result.items) ? response.result.items : []; + recordDiagnostic("info", "content.scan", "scan-complete", { + tabId: current.tab.id, + pageUrl: response.result && response.result.pageUrl, + rawItemCount: rawItems.length + }); + const items = []; + const seen = new Set(); + for (const raw of rawItems) { + if (items.length >= 60) break; + const id = raw && raw.id ? String(raw.id) : null; + let watchUrl = null; + let directVideoUrl = null; + let thumbnail = null; + try { + if (raw && raw.watchUrl) watchUrl = parseTrustedUrl(raw.watchUrl).toString(); + } catch { + watchUrl = null; + } + try { + if (raw && raw.directVideoUrl) directVideoUrl = parseTrustedUrl(raw.directVideoUrl).toString(); + } catch { + directVideoUrl = null; + } + try { + if (raw && raw.thumbnail) thumbnail = parseTrustedUrl(raw.thumbnail).toString(); + } catch { + thumbnail = null; + } + if (!id && !directVideoUrl) continue; + const key = id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl; + if (seen.has(key)) continue; + seen.add(key); + items.push({ + id, + watchUrl: watchUrl || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null), + directVideoUrl, + thumbnail, + title: String(raw.title || (id ? "RedGifs · " + id : "RedGifs video")).slice(0, 160), + profileSlug: sanitizeFolderPath(raw.profileSlug || profileFromUrl(current.parsed.toString())) + }); + } + const hydratedItems = await hydrateCollectionItems(items); + return { + type: "collection", + title: response.result && response.result.pageTitle || "Видимые ролики RedGifs", + sourceUrl: current.parsed.toString(), + items: hydratedItems + }; +} + +async function resolveMedia(input, tabId) { + const parsedUrl = parseTrustedUrl(input); + const id = extractId(parsedUrl); + const directExtension = extensionFromUrl(parsedUrl.toString(), ""); + if (["mp4", "webm", "gif", "m3u8"].includes(directExtension)) { + return { + id: null, + title: sanitizeFilename(parsedUrl.pathname.split("/").pop() || "redgifs-video"), + thumbnail: null, + sourceUrl: parsedUrl.toString(), + formats: [{ + label: directExtension === "m3u8" ? "HLS · прямая ссылка" : directExtension.toUpperCase() + " · прямая ссылка", + url: parsedUrl.toString(), + kind: directExtension === "m3u8" ? "hls" : "direct", + quality: null, + extension: directExtension + }] + }; + } + + if (!id) { + const collection = await scanOpenRedgifsPage(tabId, parsedUrl); + if (collection.items.length) return collection; + throw new Error("На открытой странице не найдено видимых роликов. Прокрутите профиль до плиток и снова нажмите «Сканировать»."); + } + + let apiError = null; + if (id) { + try { + const gif = await fetchGifMetadata(id, false); + const formats = formatsFromGif(gif); + if (formats.length) { + const tagTitle = Array.isArray(gif.tags) ? gif.tags.slice(0, 8).join(" ") : ""; + return { + id, + title: gif.title || gif.description || tagTitle || id, + profileSlug: profileFromGif(gif), + thumbnail: gif.urls && (gif.urls.poster || gif.urls.thumbnail) || null, + sourceUrl: parsedUrl.toString(), + formats + }; + } + } catch (error) { + apiError = error; + } + } + + try { + const fallback = await pageFallback(parsedUrl, id); + if (fallback.formats.length) { + return { + id: fallback.id, + title: fallback.title, + thumbnail: fallback.thumbnail, + sourceUrl: parsedUrl.toString(), + formats: fallback.formats + }; + } + } catch (fallbackError) { + if (!apiError) apiError = fallbackError; + } + + if (!id) throw new Error("Не найден ID видео. Вставьте ссылку вида redgifs.com/watch/..."); + throw new Error("Не удалось найти адрес видео. " + (apiError && apiError.message ? apiError.message : "")); +} + +async function hasOffscreenDocument() { + const offscreenUrl = chrome.runtime.getURL("offscreen.html"); + if (chrome.runtime.getContexts) { + const contexts = await chrome.runtime.getContexts({ + contextTypes: ["OFFSCREEN_DOCUMENT"], + documentUrls: [offscreenUrl] + }); + return contexts.length > 0; + } + const clients = await self.clients.matchAll(); + return clients.some((client) => client.url === offscreenUrl); +} + +async function ensureOffscreenDocument() { + if (await hasOffscreenDocument()) return; + if (!creatingOffscreen) { + creatingOffscreen = chrome.offscreen.createDocument({ + url: "offscreen.html", + reasons: ["BLOBS"], + justification: "Download HLS segments and assemble a local media Blob." + }).finally(() => { + creatingOffscreen = null; + }); + } + await creatingOffscreen; +} + +async function persistJob(job) { + jobs.set(job.id, job); + try { + await chrome.storage.session.set({ ["job:" + job.id]: job }); + } catch { + // In-memory state is enough while the service worker remains alive. + } +} + +async function readJob(jobId) { + if (jobs.has(jobId)) return jobs.get(jobId); + try { + const key = "job:" + jobId; + const stored = await chrome.storage.session.get(key); + if (stored && stored[key]) { + jobs.set(jobId, stored[key]); + return stored[key]; + } + } catch { + // Ignore unavailable session storage. + } + return null; +} + +async function startDownload(message) { + const format = message && message.format; + if (!format || !format.url) throw new Error("Формат не выбран."); + const url = parseTrustedUrl(format.url).toString(); + const filenameBase = sanitizeFilename(message.title || message.id || "redgifs-video"); + const settings = await getDownloadSettings(); + const profileSlug = sanitizeFolderPath(message.profileSlug || profileFromUrl(message.sourceUrl)); + const outputFolder = buildOutputFolder(settings, profileSlug); + const saveAs = message.saveAs == null ? Boolean(settings.saveAs) : Boolean(message.saveAs); + await ensureOffscreenDocument(); + const jobId = crypto.randomUUID(); + const isHls = format.kind === "hls" || /\.m3u8(?:$|\?)/i.test(url); + const extension = String(format.extension || extensionFromUrl(url, "mp4")).replace(/[^a-z0-9]/gi, "") || "mp4"; + let mediaId = normalizeMediaId(message.id); + if (!mediaId && message.sourceUrl) { + try { + mediaId = normalizeMediaId(extractId(parseTrustedUrl(message.sourceUrl))); + } catch { + mediaId = null; + } + } + const job = { + id: jobId, + state: "queued", + progress: 0, + status: isHls ? "Подготовка HLS…" : "Подготовка загрузки…", + filename: filenameBase, + title: filenameBase, + mediaId, + clientRequestId: String(message.clientRequestId || "").slice(0, 180) || null, + tabId: Number.isInteger(message.tabId) ? message.tabId : null, + profileSlug: profileSlug || null, + outputFolder, + createdAt: Date.now() + }; + recordDiagnostic("info", "download", "job-created", { + jobId, + mediaId, + mode: isHls ? "hls" : "direct-local", + mediaUrl: url, + extension, + outputFolder, + saveAs + }); + await persistJob(job); + await notifyJob(job); + const accepted = await chrome.runtime.sendMessage({ + target: "offscreen", + action: isHls ? "downloadHls" : "downloadDirect", + jobId, + playlistUrl: isHls ? url : null, + mediaUrl: isHls ? null : url, + sourceUrl: message.sourceUrl || "https://www.redgifs.com/", + filenameBase, + outputFolder, + extension, + saveAs + }); + if (!accepted || !accepted.ok) { + recordDiagnostic("error", "offscreen", "job-rejected", { jobId, accepted }); + throw new Error(accepted && accepted.error ? accepted.error : "Offscreen-процесс не принял загрузку."); + } + recordDiagnostic("info", "offscreen", "job-accepted", { jobId, mode: isHls ? "hls" : "direct-local" }); + return { mode: isHls ? "hls" : "direct-local", jobId }; +} + +function chooseBestFormat(formats) { + const list = Array.isArray(formats) ? formats.filter((format) => format && format.url) : []; + return list.find((format) => format.kind !== "hls" && /hd|1080|720/i.test(String(format.label || ""))) + || list.find((format) => format.kind !== "hls" && String(format.extension || "").toLowerCase() === "mp4") + || list.find((format) => format.kind !== "hls") + || list[0] + || null; +} + +function chooseDownloadTitle(item, media) { + const mediaId = normalizeMediaId(media && media.id || item && item.id); + const resolvedTitle = String(media && media.title || "").trim(); + const normalizedResolved = resolvedTitle.toLowerCase() + .replace(/^redgifs\s*[·:-]?\s*/i, "") + .trim(); + const resolvedIsFallback = !resolvedTitle + || (mediaId && normalizeMediaId(normalizedResolved) === mediaId); + if (!resolvedIsFallback) return resolvedTitle; + let fallbackTitle = sanitizeFilename(item && item.title || resolvedTitle || mediaId || "RedGifs video"); + if (mediaId) { + const escapedId = mediaId.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + fallbackTitle = fallbackTitle.replace(new RegExp("\\s+" + escapedId + "$", "i"), "").trim(); + } + return fallbackTitle || mediaId || "RedGifs video"; +} + +async function downloadCapturedItem(item, tabId) { + if (!item || typeof item !== "object") throw new Error("Данные плитки не получены."); + const target = item.watchUrl || item.directVideoUrl; + if (!target) throw new Error("У плитки не найден ID или адрес видео."); + const media = await resolveMedia(target, tabId); + if (!media || media.type === "collection") throw new Error("Не удалось определить отдельный ролик."); + const format = chooseBestFormat(media.formats); + if (!format) throw new Error("RedGifs не вернул доступных форматов."); + return startDownload({ + format, + title: chooseDownloadTitle(item, media), + id: item.id || media.id, + clientRequestId: item.clientRequestId || null, + sourceUrl: media.sourceUrl || target, + profileSlug: media.profileSlug || item.profileSlug || null, + tabId + }); +} + +async function notifyJob(job) { + if (!job || !Number.isInteger(job.tabId)) return; + try { + await chrome.tabs.sendMessage(job.tabId, { + action: "downloadProgress", + job + }); + } catch { + // The originating tab may have been closed or navigated away. + } +} + +async function registerDownloadJob(downloadId, jobId) { + if (!Number.isInteger(downloadId) || !jobId) return; + downloadJobIds.set(downloadId, jobId); + try { + await chrome.storage.session.set({ ["downloadJob:" + downloadId]: jobId }); + } catch { + // The in-memory mapping normally remains available. + } +} + +async function findJobIdForDownload(downloadId) { + if (downloadJobIds.has(downloadId)) return downloadJobIds.get(downloadId); + try { + const key = "downloadJob:" + downloadId; + const stored = await chrome.storage.session.get(key); + if (stored && stored[key]) { + downloadJobIds.set(downloadId, stored[key]); + return stored[key]; + } + } catch { + // Ignore unavailable session storage. + } + return null; +} + +async function updateJob(message) { + const previous = await readJob(message.jobId) || { id: message.jobId, createdAt: Date.now() }; + const next = Object.assign({}, previous, { + state: message.state || previous.state || "running", + progress: Number.isFinite(message.progress) ? message.progress : previous.progress || 0, + status: message.status || previous.status || "", + filename: message.filename || previous.filename || null, + downloadId: message.downloadId == null ? previous.downloadId : message.downloadId, + error: message.error || null, + updatedAt: Date.now() + }); + if (next.state !== previous.state || next.state === "error") { + recordDiagnostic(next.state === "error" ? "error" : "info", "download", "job-state", { + jobId: next.id, + previousState: previous.state || null, + state: next.state, + progress: next.progress, + status: next.status, + error: next.error, + downloadId: next.downloadId + }); + } + await persistJob(next); + if (Number.isInteger(next.downloadId)) { + await registerDownloadJob(next.downloadId, next.id); + try { + const matches = await chrome.downloads.search({ id: next.downloadId }); + const current = matches && matches[0]; + if (current && current.state === "complete") { + await applyDownloadState(next.downloadId, { state: { current: "complete" } }); + return await readJob(next.id); + } + if (current && current.state === "interrupted") { + await applyDownloadState(next.downloadId, { + state: { current: "interrupted" }, + error: { current: current.error || "DOWNLOAD_INTERRUPTED" } + }); + return await readJob(next.id); + } + } catch { + // The normal downloads.onChanged listener will report the result. + } + } + await notifyJob(next); + return next; +} + +async function applyDownloadState(downloadId, change) { + const jobId = await findJobIdForDownload(downloadId); + if (!jobId) return; + const previous = await readJob(jobId); + if (!previous) return; + + const incomingState = change && change.state && change.state.current; + const isTerminal = previous.state === "complete" || previous.state === "error"; + if (isTerminal) return; + + const hasByteUpdate = Boolean(change && (change.bytesReceived || change.totalBytes)); + const hasProgressState = incomingState === "in_progress"; + if (!incomingState && !hasByteUpdate) return; + + const bytesReceived = change.bytesReceived && Number(change.bytesReceived.current); + const totalBytes = change.totalBytes && Number(change.totalBytes.current); + const next = Object.assign({}, previous); + if (Number.isFinite(bytesReceived)) next.bytesReceived = bytesReceived; + if (Number.isFinite(totalBytes)) next.totalBytes = totalBytes; + + if (incomingState === "complete") { + next.state = "complete"; + next.progress = 100; + next.status = "Скачивание завершено"; + next.error = null; + } else if (incomingState === "interrupted") { + next.state = "error"; + next.status = "Загрузка прервана Chrome"; + next.error = change.error && change.error.current || "DOWNLOAD_INTERRUPTED"; + } else if (hasByteUpdate || hasProgressState) { + next.state = "saving"; + if (next.totalBytes > 0 && next.bytesReceived >= 0) { + next.progress = Math.min(99, 98 + Math.round((next.bytesReceived / next.totalBytes) * 1)); + } else { + next.progress = Math.max(98, Number(next.progress) || 0); + } + next.status = "Сохранение файла…"; + } else { + return; + } + next.updatedAt = Date.now(); + if (next.state === "complete" || next.state === "error") { + recordDiagnostic(next.state === "error" ? "error" : "info", "chrome.downloads", "terminal-state", { + jobId: next.id, + downloadId, + state: next.state, + error: next.error, + filename: next.filename, + bytesReceived: next.bytesReceived, + totalBytes: next.totalBytes + }); + } + await persistJob(next); + if (next.state === "complete") await rememberDownloaded(next); + await notifyJob(next); +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message && message.target === "offscreen") return false; + (async () => { + switch (message && message.action) { + case "resolve": + return { ok: true, media: await resolveMedia(message.url, message.tabId) }; + case "download": + return { + ok: true, + result: await startDownload(Object.assign({}, message, { + tabId: Number.isInteger(message.tabId) + ? message.tabId + : (sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null) + })) + }; + case "downloadCapturedItem": + return { ok: true, result: await downloadCapturedItem(message.item, sender && sender.tab ? sender.tab.id : null) }; + case "getJob": + return { ok: true, job: await readJob(message.jobId) }; + case "getDownloadedMedia": + return { ok: true, downloaded: await getDownloadedRecords(message.ids) }; + case "diagnosticEvent": + recordDiagnostic(message.level, message.area, message.event, Object.assign({}, message.details || {}, { + tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null, + source: sender && sender.tab ? "content" : "extension-page" + })); + 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) }; + case "downloadBlobUrl": { + const prefix = "blob:chrome-extension://" + chrome.runtime.id + "/"; + if (!String(message.blobUrl || "").startsWith(prefix)) { + throw new Error("Отклонён недоверенный Blob URL."); + } + const downloadId = await chrome.downloads.download({ + url: message.blobUrl, + filename: message.filename, + saveAs: Boolean(message.saveAs) + }); + return { ok: true, downloadId }; + } + default: + return { ok: false, error: "Unknown action" }; + } + })().then(sendResponse).catch((error) => { + recordDiagnostic("error", "runtime.message", "action-failed", { + action: message && message.action, + error, + tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null + }); + sendResponse({ ok: false, error: error && error.message ? error.message : String(error) }); + }); + return true; +}); + +chrome.downloads.onChanged.addListener((delta) => { + if (!delta || !Number.isInteger(delta.id)) return; + applyDownloadState(delta.id, delta).catch((error) => { + recordDiagnostic("error", "chrome.downloads", "state-handler-failed", { + downloadId: delta.id, + error + }); + }); +}); + +recordDiagnostic("info", "runtime", "service-worker-active", { + extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..f80cd6e --- /dev/null +++ b/content.js @@ -0,0 +1,2032 @@ +"use strict"; + +if (!globalThis.__localRedgifsScannerInstalled) { + globalThis.__localRedgifsScannerInstalled = true; + const CONTENT_SCRIPT_VERSION = "0.6.23"; + + function isRedgifsHost(hostname) { + const host = String(hostname || "").toLowerCase(); + return host === "redgifs.com" || host.endsWith(".redgifs.com"); + } + + function normalizeUrl(value) { + if (!value || /^data:|^blob:/i.test(String(value))) return null; + try { + const parsed = new URL(String(value), document.baseURI); + if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) return null; + return parsed.toString(); + } catch { + return null; + } + } + + function idFromUrl(value) { + const normalized = normalizeUrl(value); + if (!normalized) return null; + try { + const parsed = new URL(normalized); + const pageMatch = parsed.pathname.match(/\/(?:watch|ifr)\/([a-z0-9_-]+)/i); + if (pageMatch && pageMatch[1]) return pageMatch[1]; + const filename = (parsed.pathname.split("/").pop() || "") + .replace(/\.(?:mp4|webm|m3u8|m4s|gif|jpe?g|png)$/i, "") + .replace(/-(?:mobile|poster|thumbnail|thumb|sd|hd)$/i, ""); + return filename && /^[a-z0-9_-]+$/i.test(filename) ? filename : null; + } catch { + return null; + } + } + + function idFromPosterUrl(value) { + const normalized = normalizeUrl(value); + if (!normalized) return null; + try { + const parsed = new URL(normalized); + if (parsed.hostname.toLowerCase() !== "media.redgifs.com") return null; + const filename = parsed.pathname.split("/").pop() || ""; + const match = filename.match(/^([a-z0-9_-]+)-(?:poster|thumbnail|thumb|mobile)\.(?:avif|webp|jpe?g|png)$/i); + return match && match[1] ? match[1] : null; + } catch { + return null; + } + } + + function mediaPosterImage(root) { + if (!root) return null; + const images = []; + if (root.tagName === "IMG") images.push(root); + images.push.apply(images, Array.from(root.querySelectorAll + ? root.querySelectorAll("img[src], img[data-src], img[data-lazy-src]") + : [])); + for (const image of images) { + const candidates = [ + image.currentSrc, + image.getAttribute && image.getAttribute("src"), + image.getAttribute && image.getAttribute("data-src"), + image.getAttribute && image.getAttribute("data-lazy-src") + ]; + if (candidates.some((candidate) => Boolean(idFromPosterUrl(candidate)))) return image; + } + return null; + } + + function cleanText(value, fallback) { + const cleaned = String(value || "") + .replace(/^Poster for\s+/i, "") + .replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "") + .replace(/\s+/g, " ") + .replace(/\b(?:like|share|sound|more|follow|views?)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return (cleaned || fallback || "RedGifs video").slice(0, 160); + } + + function videoSource(video) { + if (!video) return null; + const candidates = [ + video.currentSrc, + video.src, + video.getAttribute("src"), + video.querySelector("source[src]") && video.querySelector("source[src]").getAttribute("src") + ]; + for (const candidate of candidates) { + const normalized = normalizeUrl(candidate); + if (normalized && !/\.m4s(?:$|\?)/i.test(normalized)) return normalized; + } + return null; + } + + function posterFromRoot(root, video) { + const candidates = []; + if (video) { + candidates.push(video.poster, video.getAttribute("poster")); + } + const images = []; + if (root && root.tagName === "IMG") images.push(root); + images.push.apply(images, Array.from(root.querySelectorAll ? root.querySelectorAll("img[src], img[data-src], img[data-lazy-src]") : [])); + images.sort((left, right) => Number(Boolean(idFromPosterUrl( + right.currentSrc || right.getAttribute("src") || right.getAttribute("data-src") || right.getAttribute("data-lazy-src") + ))) - Number(Boolean(idFromPosterUrl( + left.currentSrc || left.getAttribute("src") || left.getAttribute("data-src") || left.getAttribute("data-lazy-src") + )))); + for (const image of images.slice(0, 6)) { + candidates.push( + image.getAttribute("src"), + image.getAttribute("data-src"), + image.getAttribute("data-lazy-src") + ); + } + for (const candidate of candidates) { + const normalized = normalizeUrl(candidate); + if (normalized) return normalized; + } + return null; + } + + function titleFromRoot(root, id) { + const selectors = [ + ".descriptionText", + ".description", + "[data-testid*='caption']", + "[data-testid*='description']", + "figcaption", + ".userName", + ".username" + ]; + const candidates = []; + for (const selector of selectors) { + const node = root.querySelector && root.querySelector(selector); + if (node && node.textContent) candidates.push(node.textContent); + } + candidates.push( + root.getAttribute && root.getAttribute("aria-label"), + root.getAttribute && root.getAttribute("title") + ); + const image = root.querySelector && root.querySelector("img[alt]"); + if (image) candidates.push(String(image.getAttribute("alt") || "")); + for (const candidate of candidates) { + const cleaned = cleanText(candidate, ""); + if (cleaned && cleaned !== "RedGifs video" && cleaned.length > 2) return cleaned; + } + return id ? "RedGifs · " + id : "RedGifs video"; + } + + function currentProfileSlug() { + const match = String(location.pathname || "").match(/\/users\/([^/?#]+)/i); + if (!match || !match[1]) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return match[1]; + } + } + + function normalizeProfileSlug(value) { + let slug = String(value || "").trim().replace(/^@+/, ""); + try { + slug = decodeURIComponent(slug); + } catch { + // Keep the original visible username. + } + return /^[a-z0-9._-]{1,80}$/i.test(slug) ? slug : null; + } + + function profileSlugFromRoot(root) { + if (!root) return null; + const profileLinks = Array.from(root.querySelectorAll + ? root.querySelectorAll("a[href*='/users/']") + : []); + for (const link of profileLinks) { + const href = normalizeUrl(link.getAttribute && (link.getAttribute("href") || link.href)); + if (!href) continue; + try { + const match = new URL(href).pathname.match(/\/users\/([^/?#]+)/i); + const slug = normalizeProfileSlug(match && match[1]); + if (slug) return slug; + } catch { + // Try the next profile hint. + } + } + + const attributeNames = ["data-username", "data-user-name", "data-uploader", "data-owner"]; + for (const name of attributeNames) { + const slug = normalizeProfileSlug(root.getAttribute && root.getAttribute(name)); + if (slug) return slug; + } + + const selectors = [ + ".userName", + ".username", + "[data-testid='username']", + "[data-testid*='user-name']", + "[data-testid*='username']" + ]; + for (const selector of selectors) { + const node = root.querySelector && root.querySelector(selector); + const slug = normalizeProfileSlug(node && node.textContent); + if (slug) return slug; + } + return null; + } + + function closestCard(node) { + if (!node || !node.closest) return node; + return node.closest("[data-feed-item-id], .GifPreview, .tileItem, article, figure") + || node.closest("a[href*='/watch/'], a[href*='/ifr/']") + || (node.tagName === "VIDEO" && node.parentElement ? node.parentElement : node); + } + + function isExplicitMediaCard(root) { + if (!root || typeof root.matches !== "function") return false; + return root.matches([ + "[data-feed-item-id]", + ".GifPreview", + ".tileItem", + "[data-testid*='feed-item']", + "[data-testid*='gif-preview']", + "[class*='GifPreview']", + "[class*='tileItem']" + ].join(", ")); + } + + function ensureButtonStyles() { + if (document.getElementById("local-redgifs-download-styles")) return; + const style = document.createElement("style"); + style.id = "local-redgifs-download-styles"; + style.textContent = [ + ".local-rg-download-host{position:relative!important}", + ".local-rg-scroll-host{position:relative!important}", + ".local-rg-download-button{all:initial!important;box-sizing:border-box!important;position:absolute!important;right:10px!important;bottom:10px!important;left:auto!important;z-index:2147483646!important;display:flex!important;align-items:center!important;justify-content:center!important;width:42px!important;height:42px!important;border:1px solid rgba(255,255,255,.25)!important;border-radius:50%!important;background:linear-gradient(135deg,#f04468,#d72d57)!important;color:#fff!important;box-shadow:0 8px 24px rgba(0,0,0,.42)!important;font:900 23px/1 system-ui,-apple-system,'Segoe UI',sans-serif!important;cursor:pointer!important;opacity:.96!important;transform:translateY(0)!important;transition:transform .15s ease,filter .15s ease,opacity .15s ease!important;user-select:none!important;-webkit-user-select:none!important}", + ".local-rg-download-button:hover{filter:brightness(1.12)!important;transform:translateY(-1px)!important;opacity:1!important}", + ".local-rg-download-button:active{transform:translateY(1px)!important}", + ".local-rg-download-button:disabled{cursor:wait!important;filter:saturate(.55)!important;opacity:.88!important}", + ".local-rg-download-button[data-state='working']{animation:localRgButtonWorking 1.05s ease-in-out infinite!important}", + ".local-rg-download-button[data-state='done'],.local-rg-download-button[data-state='downloaded']{background:#159665!important}", + ".local-rg-download-button[data-state='downloaded']{border-color:rgba(185,255,225,.72)!important;box-shadow:0 8px 24px rgba(0,0,0,.42),0 0 0 2px rgba(32,184,121,.22)!important}", + ".local-rg-download-button[data-state='downloaded']:hover{background:linear-gradient(135deg,#ff9c45,#e86b21)!important;border-color:rgba(255,224,184,.82)!important;box-shadow:0 10px 28px rgba(0,0,0,.44),0 0 0 3px rgba(244,130,42,.25)!important}", + ".local-rg-download-button[data-state='error']{background:#9e2941!important}", + ".local-rg-button-glyph{all:initial!important;box-sizing:border-box!important;position:absolute!important;inset:0!important;display:grid!important;place-items:center!important;color:inherit!important;pointer-events:none!important;transition:opacity .18s ease,transform .24s cubic-bezier(.2,.8,.2,1)!important}", + ".local-rg-button-glyph svg{display:block!important;width:25px!important;height:25px!important;overflow:visible!important;color:inherit!important;fill:none!important;stroke:currentColor!important;stroke-width:2.35!important;stroke-linecap:round!important;stroke-linejoin:round!important}", + ".local-rg-icon-redownload{opacity:0!important;transform:rotate(-75deg) scale(.62)!important}", + ".local-rg-download-button[data-state='downloaded']:hover .local-rg-icon-check{opacity:0!important;transform:rotate(35deg) scale(.62)!important}", + ".local-rg-download-button[data-state='downloaded']:hover .local-rg-icon-redownload{opacity:1!important;transform:rotate(0) scale(1)!important}", + ".local-rg-download-button[data-layout='feed-external']{position:absolute!important;right:auto!important;bottom:auto!important;left:var(--local-rg-left,8px)!important;top:var(--local-rg-top,8px)!important;z-index:2147483645!important;display:none!important}", + ".local-rg-download-button[data-layout='modal-external']{position:fixed!important;right:auto!important;bottom:auto!important;left:var(--local-rg-left,8px)!important;top:var(--local-rg-top,8px)!important;z-index:2147483646!important;display:none!important;width:38px!important;height:38px!important;box-shadow:0 6px 18px rgba(0,0,0,.36)!important}", + ".local-rg-download-button[data-layout='modal-external'] .local-rg-button-glyph svg{width:22px!important;height:22px!important}", + ".local-rg-diagnostic-anchor{outline:3px solid #31d7ff!important;outline-offset:-3px!important}", + ".local-rg-diagnostic-root{outline:3px solid #ffd43b!important;outline-offset:-6px!important}", + ".local-rg-diagnostic-host{outline:3px dashed #a66cff!important;outline-offset:-9px!important}", + ".local-rg-diagnostic-button{outline:3px solid #ff7a33!important;outline-offset:3px!important}", + ".local-rg-toast-stack{all:initial!important;position:fixed!important;top:18px!important;left:50%!important;z-index:2147483647!important;display:flex!important;width:min(440px,calc(100vw - 28px))!important;flex-direction:column!important;gap:9px!important;transform:translateX(-50%)!important;pointer-events:none!important}", + ".local-rg-toast{all:initial;box-sizing:border-box!important;display:block!important;overflow:hidden!important;border:1px solid rgba(255,255,255,.14)!important;border-radius:14px!important;background:rgba(12,16,23,.96)!important;color:#f5f7fb!important;box-shadow:0 18px 55px rgba(0,0,0,.48)!important;font-family:system-ui,-apple-system,'Segoe UI',sans-serif!important;backdrop-filter:blur(12px)!important;pointer-events:none!important;will-change:transform,opacity,filter!important;transform-origin:50% 0!important}", + ".local-rg-toast.is-entering{animation:localRgToastIn .46s cubic-bezier(.16,1,.3,1)!important}", + ".local-rg-toast.is-leaving{animation:localRgToastOut .3s cubic-bezier(.55,0,1,.45) forwards!important}", + ".local-rg-toast-main{display:flex!important;align-items:center!important;gap:11px!important;padding:12px 13px 10px!important}", + ".local-rg-toast-icon{display:grid!important;width:34px!important;height:34px!important;flex:0 0 34px!important;place-items:center!important;border-radius:10px!important;background:#f04468!important;color:#fff!important;font:900 18px/1 system-ui!important}", + ".local-rg-toast-copy{display:block!important;min-width:0!important;flex:1!important}", + ".local-rg-toast-title,.local-rg-toast-status{display:block!important;overflow:hidden!important;text-overflow:ellipsis!important;white-space:nowrap!important}", + ".local-rg-toast-title{color:#f4f7fb!important;font:800 12px/1.3 system-ui!important}", + ".local-rg-toast-status{margin-top:4px!important;color:#95a1b5!important;font:500 11px/1.3 system-ui!important}", + ".local-rg-toast-value{color:#cbd3df!important;font:800 11px/1 system-ui!important}", + ".local-rg-toast-track{height:5px!important;overflow:hidden!important;background:#242d3c!important}", + ".local-rg-toast-bar{height:100%!important;width:0!important;background:linear-gradient(90deg,#159665,#35d596)!important;box-shadow:0 0 10px rgba(53,213,150,.38)!important;transition:width .18s ease!important}", + ".local-rg-toast[data-state='complete'] .local-rg-toast-bar{transition:width .25s linear!important}", + ".local-rg-toast[data-state='complete'] .local-rg-toast-icon,.local-rg-toast[data-state='complete'] .local-rg-toast-bar{background:#20b879!important}", + ".local-rg-toast[data-state='error'] .local-rg-toast-icon,.local-rg-toast[data-state='error'] .local-rg-toast-bar{background:#d53c5d!important}", + "@keyframes localRgButtonWorking{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-2px) scale(1.06)}}", + "@keyframes localRgToastIn{0%{opacity:0;filter:blur(7px);transform:translateY(-20px) scale(.94)}65%{opacity:1;filter:blur(0);transform:translateY(2px) scale(1.01)}100%{opacity:1;filter:blur(0);transform:translateY(0) scale(1)}}", + "@keyframes localRgToastOut{0%{opacity:1;filter:blur(0);transform:translateY(0) scale(1)}100%{opacity:0;filter:blur(6px);transform:translateY(-18px) scale(.96)}}", + "@media(max-width:700px){.local-rg-download-button{right:6px!important;bottom:6px!important;left:auto!important;width:38px!important;height:38px!important;font-size:21px!important}.local-rg-toast-stack{top:10px!important}}" + ].join(""); + (document.head || document.documentElement).appendChild(style); + } + + const BUTTON_ICON_DOWNLOAD = [ + '' + ].join(""); + const BUTTON_ICON_CHECK = [ + '' + ].join(""); + const BUTTON_ICON_REDOWNLOAD = [ + '' + ].join(""); + const BUTTON_ICON_ERROR = [ + '' + ].join(""); + + function setButtonState(button, state) { + button.dataset.state = state; + const markup = state === "downloaded" + ? BUTTON_ICON_CHECK + BUTTON_ICON_REDOWNLOAD + : state === "done" + ? BUTTON_ICON_CHECK + : state === "error" + ? BUTTON_ICON_ERROR + : BUTTON_ICON_DOWNLOAD; + if (button.__localRgIconMarkup !== markup) { + button.innerHTML = markup; + button.__localRgIconMarkup = markup; + } + const labels = { + downloaded: "Скачать повторно", + done: "Скачивание завершено", + error: "Ошибка загрузки", + working: "Скачивание", + idle: "Скачать видео" + }; + button.setAttribute("aria-label", labels[state] || "Скачать видео"); + } + + const TOAST_COMPLETE_MS = 5000; + const TOAST_EXIT_MS = 300; + const toastEntries = new Map(); + const jobButtons = new Map(); + const pendingJobButtons = new Map(); + const mediaButtons = new Map(); + const downloadedRecords = new Map(); + 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", + event: String(event || "event").slice(0, 120), + details: details || {} + }; + contentDiagnosticEvents.push(entry); + if (contentDiagnosticEvents.length > 100) contentDiagnosticEvents.splice(0, 20); + try { + const pending = chrome.runtime.sendMessage({ + action: "diagnosticEvent", + level: entry.level, + area: "content", + event: entry.event, + details: entry.details + }); + if (pending && typeof pending.catch === "function") pending.catch(() => {}); + } catch { + // Local page diagnostics remain available. + } + } + + function diagnosticSafeUrl(value) { + try { + const parsed = new URL(String(value || ""), document.baseURI); + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return String(value || "").slice(0, 300); + } + } + + 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() + }, details || {}); + button.__localRgLayoutReason = next; + if (previous && previous.code === code) return; + if (!button.__localRgDecisionHistory) button.__localRgDecisionHistory = []; + const anchor = button.__localRgAnchor; + const root = button.__localRgRoot; + const host = button.__localRgScrollHost; + button.__localRgDecisionHistory.push({ + decision: next, + previousCode: previous && previous.code || null, + inlineDisplay: button.style && button.style.display || null, + anchorRect: diagnosticRect(anchor), + anchorConnected: !anchor || !("isConnected" in anchor) || Boolean(anchor.isConnected), + rootConnected: !root || !("isConnected" in root) || Boolean(root.isConnected), + hostScroll: host ? { + top: Number(host.scrollTop) || 0, + left: Number(host.scrollLeft) || 0 + } : null + }); + if (button.__localRgDecisionHistory.length > 12) { + button.__localRgDecisionHistory.splice(0, button.__localRgDecisionHistory.length - 12); + } + } + + function isGeneralFeedPage() { + return !/\/(?:users|watch|ifr)\//i.test(String(location.pathname || "")); + } + + function isPageChromeRoot(root) { + if (!root || typeof root.closest !== "function") return false; + return Boolean(root.closest([ + "header", + "nav", + "aside", + "[role='banner']", + "[role='navigation']", + "[role='complementary']", + "[class*='sidebar' i]", + "[class*='side-menu' i]" + ].join(", "))); + } + + const MODAL_SELECTOR = [ + "[aria-modal='true']", + "[role='dialog']", + "[data-testid*='fullscreen' i]", + "[data-testid*='full-screen' i]", + "[class*='fullscreen' i]", + "[class*='full-screen' i]" + ].join(", "); + + function modalContainerFor(element) { + if (!element) return null; + if (document.fullscreenElement) { + let current = element; + while (current) { + if (current === document.fullscreenElement) return document.fullscreenElement; + current = current.parentElement; + } + } + if (typeof element.closest === "function") { + try { + const matched = element.closest(MODAL_SELECTOR); + if (matched) return matched; + } catch { + // Continue with the geometric fallback below. + } + } + if (typeof globalThis.getComputedStyle !== "function") return null; + const viewportWidth = Number(globalThis.innerWidth) || 0; + const viewportHeight = Number(globalThis.innerHeight) || 0; + let current = element; + while (current && current !== document.body && current !== document.documentElement) { + if (typeof current.getBoundingClientRect === "function") { + const rect = current.getBoundingClientRect(); + const style = globalThis.getComputedStyle(current); + if (style && style.position === "fixed" + && rect.width >= viewportWidth * 0.6 + && rect.height >= viewportHeight * 0.6) { + return current; + } + } + current = current.parentElement; + } + return null; + } + + function visibleAreaFor(element) { + if (!element || ("isConnected" in element && !element.isConnected)) return 0; + if (typeof element.getBoundingClientRect !== "function") return 0; + const rect = element.getBoundingClientRect(); + const viewportWidth = Number(globalThis.innerWidth) || 0; + const viewportHeight = Number(globalThis.innerHeight) || 0; + if (!rect || rect.width <= 0 || rect.height <= 0) return 0; + return Math.max(0, Math.min(viewportWidth, rect.right) - Math.max(0, rect.left)) + * Math.max(0, Math.min(viewportHeight, rect.bottom) - Math.max(0, rect.top)); + } + + function usableLayoutRect(rect) { + return Boolean(rect && rect.width > 0 && rect.height > 0); + } + + function rectOverlapRatio(first, second) { + if (!usableLayoutRect(first) || !usableLayoutRect(second)) return 0; + const width = Math.max(0, Math.min(first.right, second.right) - Math.max(first.left, second.left)); + const height = Math.max(0, Math.min(first.bottom, second.bottom) - Math.max(first.top, second.top)); + const smallerArea = Math.min(first.width * first.height, second.width * second.height); + return smallerArea > 0 ? (width * height) / smallerArea : 0; + } + + function rememberAnchorGeometry(button, anchorRect, rootRect) { + if (!button || !usableLayoutRect(anchorRect) || !usableLayoutRect(rootRect)) return; + button.__localRgAnchorGeometry = { + left: anchorRect.left - rootRect.left, + top: anchorRect.top - rootRect.top, + width: anchorRect.width, + height: anchorRect.height + }; + } + + function anchorRectFromRoot(button, rootRect) { + const geometry = button && button.__localRgAnchorGeometry; + if (!geometry || !usableLayoutRect(rootRect)) return null; + const left = rootRect.left + geometry.left; + const top = rootRect.top + geometry.top; + return { + left, + right: left + geometry.width, + top, + bottom: top + geometry.height, + width: geometry.width, + height: geometry.height + }; + } + + function setStyleProperty(element, name, value) { + if (element && element.style && typeof element.style.setProperty === "function") { + element.style.setProperty(name, value, "important"); + } else if (element && element.style) { + element.style[name] = value; + } + } + + function pageTopBoundary(viewportWidth, viewportHeight) { + let boundary = 0; + if (!document.querySelectorAll) return 8; + const selectors = [ + "header", + "[role='banner']", + "[class*='site-header' i]", + "[class*='top-header' i]", + "[class*='topbar' i]" + ].join(", "); + for (const element of Array.from(document.querySelectorAll(selectors))) { + if (!element || typeof element.getBoundingClientRect !== "function") continue; + const rect = element.getBoundingClientRect(); + if (!rect || rect.width < viewportWidth * 0.45 || rect.height <= 0 || rect.height > viewportHeight * 0.35) continue; + if (rect.top <= 8 && rect.bottom > 0) boundary = Math.max(boundary, rect.bottom); + } + return Math.max(8, Math.min(boundary + (boundary ? 8 : 0), viewportHeight)); + } + + function isDocumentScrollHost(element) { + return !element + || element === document.body + || element === document.documentElement + || element === document.scrollingElement; + } + + function scrollHostFor(element) { + if (!element || typeof globalThis.getComputedStyle !== "function") { + return document.scrollingElement || document.body || document.documentElement; + } + let current = element.parentElement; + while (current && current !== document.body && current !== document.documentElement) { + try { + const style = globalThis.getComputedStyle(current); + const overflow = String(style && (style.overflowY || style.overflow) || ""); + const scrollableOverflow = /auto|scroll|overlay/i.test(overflow); + const hasScrollRange = Number(current.scrollHeight) > Number(current.clientHeight) + 1; + if ((scrollableOverflow && hasScrollRange) || Number(current.scrollTop) > 0) return current; + } catch { + // Continue towards the document scroll host. + } + current = current.parentElement; + } + return document.scrollingElement || document.body || document.documentElement; + } + + function feedPositionInHost(button, left, top) { + const host = button.__localRgScrollHost; + if (isDocumentScrollHost(host)) { + return { + left: left + (Number(globalThis.scrollX) || Number(globalThis.pageXOffset) || 0), + top: top + (Number(globalThis.scrollY) || Number(globalThis.pageYOffset) || 0) + }; + } + if (!host || typeof host.getBoundingClientRect !== "function") return { left, top }; + const hostRect = host.getBoundingClientRect(); + return { + left: left - hostRect.left + Number(host.scrollLeft || 0) - Number(host.clientLeft || 0), + top: top - hostRect.top + Number(host.scrollTop || 0) - Number(host.clientTop || 0) + }; + } + + function modalButtonPosition(overlay, mediaRect, size, viewportWidth, viewportHeight) { + const fallback = { + left: Math.max(8, Math.min(mediaRect.right - size - 18, viewportWidth - size - 8)), + top: Math.max(8, Math.min(mediaRect.bottom - size - 64, viewportHeight - size - 8)) + }; + if (!overlay || typeof overlay.querySelectorAll !== "function") return fallback; + const controls = []; + for (const element of Array.from(overlay.querySelectorAll("button, [role='button']"))) { + if (!element || element.classList && element.classList.contains("local-rg-download-button")) continue; + if (typeof element.getBoundingClientRect !== "function") continue; + const rect = element.getBoundingClientRect(); + if (!rect || rect.width < 18 || rect.height < 18 || rect.width > 110 || rect.height > 110) continue; + const centerX = (rect.left + rect.right) / 2; + if (centerX < mediaRect.right - 190 || centerX > mediaRect.right + 24) continue; + if (rect.bottom <= mediaRect.top || rect.top >= mediaRect.bottom - 44) continue; + controls.push({ rect, centerX }); + } + if (!controls.length) return fallback; + const clusters = []; + for (const control of controls) { + let cluster = clusters.find((item) => Math.abs(item.centerX - control.centerX) <= 26); + if (!cluster) { + cluster = { centerX: control.centerX, controls: [] }; + clusters.push(cluster); + } + cluster.controls.push(control); + cluster.centerX = cluster.controls.reduce((sum, item) => sum + item.centerX, 0) / cluster.controls.length; + } + clusters.sort((left, right) => right.controls.length - left.controls.length || right.centerX - left.centerX); + const column = clusters[0]; + if (!column || column.controls.length < 2) return fallback; + const lastBottom = Math.max.apply(null, column.controls.map((item) => item.rect.bottom)); + const safeBottom = Math.min(mediaRect.bottom - 58, viewportHeight - 8); + let top = lastBottom + 12; + if (top + size > safeBottom) top = safeBottom - size; + if (top < mediaRect.top + 8) return fallback; + return { + left: Math.max(8, Math.min(column.centerX - size / 2, viewportWidth - size - 8)), + top: Math.max(8, Math.min(top, viewportHeight - size - 8)) + }; + } + + function scheduleReconnectCheck() { + if (externalReconnectTimer != null) return; + externalReconnectTimer = setTimeout(() => { + externalReconnectTimer = null; + scheduleExternalLayout(); + }, ANCHOR_RECONNECT_GRACE_MS + 40); + } + + function preserveWhileReconnecting(button, timestamp) { + if (!button.__localRgAnchorMissingSince) { + button.__localRgAnchorMissingSince = timestamp; + scheduleReconnectCheck(); + } + return timestamp - button.__localRgAnchorMissingSince < ANCHOR_RECONNECT_GRACE_MS; + } + + function layoutExternalButtons() { + externalLayoutFrame = null; + const viewportWidth = Number(globalThis.innerWidth) || (document.documentElement && document.documentElement.clientWidth) || 0; + const viewportHeight = Number(globalThis.innerHeight) || (document.documentElement && document.documentElement.clientHeight) || 0; + const topBoundary = pageTopBoundary(viewportWidth, viewportHeight); + const size = viewportWidth <= 700 ? 38 : 42; + const gap = 10; + const candidates = []; + const timestamp = Date.now(); + for (const button of Array.from(externalButtons)) { + const root = button.__localRgRoot; + if (!root || ("isConnected" in root && !root.isConnected)) { + if (preserveWhileReconnecting(button, timestamp)) { + setButtonLayoutReason(button, "root-reconnecting"); + continue; + } + disposeButton(button, "root-disconnected"); + continue; + } + const anchor = button.__localRgAnchor || root; + const overlay = modalContainerFor(anchor) || modalContainerFor(root); + const rootRect = typeof root.getBoundingClientRect === "function" ? root.getBoundingClientRect() : null; + const anchorConnected = anchor && (!("isConnected" in anchor) || anchor.isConnected); + const anchorRect = anchorConnected && typeof anchor.getBoundingClientRect === "function" + ? anchor.getBoundingClientRect() + : null; + const anchorMatchesRoot = overlay + || !usableLayoutRect(rootRect) + || (usableLayoutRect(anchorRect) && rectOverlapRatio(anchorRect, rootRect) >= 0.35); + let rect = anchorMatchesRoot && usableLayoutRect(anchorRect) ? anchorRect : null; + 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 + ? "visible-from-root-anchor-disconnected" + : usableLayoutRect(anchorRect) + ? "visible-from-root-anchor-outside-card" + : "visible-from-root-anchor-zero-size"; + } + if (!usableLayoutRect(rect)) { + if (preserveWhileReconnecting(button, timestamp)) { + setButtonLayoutReason(button, anchorConnected ? "anchor-zero-size-reconnecting" : "anchor-reconnecting"); + continue; + } + setStyleProperty(button, "display", "none"); + setButtonLayoutReason(button, anchorConnected ? "anchor-zero-size" : "anchor-disconnected", { + width: anchorRect && anchorRect.width, + height: anchorRect && anchorRect.height + }); + continue; + } + button.__localRgAnchorMissingSince = 0; + if (rect.bottom < 0 || rect.top > viewportHeight) { + setStyleProperty(button, "display", "none"); + setButtonLayoutReason(button, "anchor-outside-viewport", { top: rect.top, bottom: rect.bottom }); + continue; + } + const visibleLeft = Math.max(0, rect.left); + const visibleRight = Math.min(viewportWidth, rect.right); + const visibleTop = Math.max(0, rect.top); + const visibleBottom = Math.min(viewportHeight, rect.bottom); + const visibleWidth = visibleRight - visibleLeft; + const visibleHeight = visibleBottom - visibleTop; + if (visibleWidth <= 0 || visibleHeight <= 0) { + setStyleProperty(button, "display", "none"); + setButtonLayoutReason(button, "anchor-no-visible-area", { visibleWidth, visibleHeight }); + continue; + } + candidates.push({ button, rect, visibleTop, visibleBottom, overlay, fallbackReason }); + } + + const overlayCandidates = candidates.filter((candidate) => candidate.overlay); + let layoutCandidates = candidates; + if (overlayCandidates.length) { + overlayCandidates.sort((left, right) => { + return visibleAreaFor(right.overlay) + - visibleAreaFor(left.overlay) + || (right.visibleBottom - right.visibleTop) - (left.visibleBottom - left.visibleTop); + }); + layoutCandidates = [overlayCandidates[0]]; + const activeButton = layoutCandidates[0].button; + for (const button of externalButtons) { + if (button !== activeButton) { + setStyleProperty(button, "display", "none"); + setButtonLayoutReason(button, "suppressed-by-fullscreen"); + } + } + } + + for (const candidate of layoutCandidates) { + const button = candidate.button; + const rect = candidate.rect; + if (candidate.overlay) { + const modalSize = viewportWidth <= 700 ? 36 : 38; + const position = modalButtonPosition(candidate.overlay, rect, modalSize, viewportWidth, viewportHeight); + setStyleProperty(button, "display", "flex"); + setStyleProperty(button, "--local-rg-left", Math.round(position.left) + "px"); + setStyleProperty(button, "--local-rg-top", Math.round(position.top) + "px"); + setButtonLayoutReason(button, "visible-fullscreen", { left: position.left, top: position.top }); + continue; + } + let left = rect.right + gap; + let top = Math.min(rect.bottom - size - gap, viewportHeight - size - 8); + if (left + size > viewportWidth - 8) { + left = Math.max(8, Math.min(rect.left + gap, viewportWidth - size - 8)); + top = Math.min(rect.top + gap, viewportHeight - size - 8); + } + if (top < topBoundary) { + setStyleProperty(button, "display", "none"); + setButtonLayoutReason(button, "hidden-under-page-header", { intendedTop: top, topBoundary }); + continue; + } + setStyleProperty(button, "display", "flex"); + const position = feedPositionInHost(button, left, top); + setStyleProperty(button, "--local-rg-left", Math.round(position.left) + "px"); + setStyleProperty(button, "--local-rg-top", Math.round(position.top) + "px"); + setButtonLayoutReason(button, candidate.fallbackReason || "visible-feed", { + left: position.left, + top: position.top, + topBoundary + }); + } + } + + function scheduleExternalLayout() { + if (externalLayoutFrame != null) return; + const schedule = typeof globalThis.requestAnimationFrame === "function" + ? globalThis.requestAnimationFrame.bind(globalThis) + : (callback) => setTimeout(callback, 16); + let ranSynchronously = false; + const frame = schedule(() => { + ranSynchronously = true; + externalLayoutFrame = null; + layoutExternalButtons(); + }); + if (!ranSynchronously) externalLayoutFrame = frame; + } + + function scheduleFeedRefresh() { + scheduleExternalLayout(); + } + + function configureButtonLayout(root, button, anchor) { + const overlay = modalContainerFor(anchor || root) || modalContainerFor(root); + const external = isGeneralFeedPage() || Boolean(overlay); + const wasExternal = externalButtons.has(button); + button.__localRgRoot = root; + button.__localRgAnchor = anchor || root; + button.__localRgOverlay = overlay; + button.__localRgAnchorMissingSince = 0; + button.dataset.layout = overlay ? "modal-external" : (external ? "feed-external" : "tile"); + if (external) { + externalButtons.add(button); + if (!wasExternal) setStyleProperty(button, "display", "none"); + const host = overlay + ? (document.body || document.documentElement) + : scrollHostFor(anchor || root); + button.__localRgScrollHost = host; + if (!overlay && host && !isDocumentScrollHost(host)) { + host.classList.add("local-rg-scroll-host"); + if (!host.__localRgScrollListener && typeof host.addEventListener === "function") { + host.__localRgScrollListener = true; + host.addEventListener("scroll", scheduleFeedRefresh, { passive: true }); + } + } + if (button.parentElement !== host) host.appendChild(button); + scheduleExternalLayout(); + } else { + externalButtons.delete(button); + button.__localRgScrollHost = null; + if (button.parentElement !== root) root.appendChild(button); + } + } + + function normalizeMediaKey(value) { + const key = String(value || "").trim().toLowerCase(); + return /^[a-z0-9_-]{2,160}$/i.test(key) ? key : null; + } + + function diagnosticRect(element) { + if (!element || typeof element.getBoundingClientRect !== "function") return null; + try { + const rect = element.getBoundingClientRect(); + return { + left: Math.round(rect.left * 10) / 10, + right: Math.round(rect.right * 10) / 10, + top: Math.round(rect.top * 10) / 10, + bottom: Math.round(rect.bottom * 10) / 10, + width: Math.round(rect.width * 10) / 10, + height: Math.round(rect.height * 10) / 10 + }; + } catch { + return null; + } + } + + function diagnosticElement(element) { + if (!element) return null; + let style = null; + try { + const computed = typeof globalThis.getComputedStyle === "function" + ? globalThis.getComputedStyle(element) + : null; + if (computed) { + style = { + display: computed.display || null, + visibility: computed.visibility || null, + opacity: computed.opacity || null, + position: computed.position || null, + overflow: computed.overflow || null, + overflowX: computed.overflowX || null, + overflowY: computed.overflowY || null, + zIndex: computed.zIndex || null, + transform: computed.transform || null + }; + } + } catch { + // The remaining element data is still useful. + } + const get = (name) => { + try { + return element.getAttribute && element.getAttribute(name); + } catch { + return null; + } + }; + return { + tag: String(element.tagName || "").toLowerCase() || null, + id: String(element.id || get("id") || "").slice(0, 160) || null, + className: String(get("class") || element.className || "").slice(0, 500) || null, + feedItemId: get("data-feed-item-id"), + role: get("role"), + ariaModal: get("aria-modal"), + isConnected: !("isConnected" in element) || Boolean(element.isConnected), + rect: diagnosticRect(element), + scroll: { + top: Number(element.scrollTop) || 0, + left: Number(element.scrollLeft) || 0, + height: Number(element.scrollHeight) || 0, + width: Number(element.scrollWidth) || 0, + clientHeight: Number(element.clientHeight) || 0, + clientWidth: Number(element.clientWidth) || 0 + }, + style + }; + } + + function diagnosticAncestors(element) { + const ancestors = []; + let current = element; + for (let depth = 0; current && depth < 14; depth += 1) { + ancestors.push(diagnosticElement(current)); + current = current.parentElement; + } + return ancestors; + } + + function diagnosticButton(button) { + const root = button && button.__localRgRoot; + const anchor = button && button.__localRgAnchor; + const host = button && button.__localRgScrollHost; + const overlay = button && (button.__localRgOverlay || modalContainerFor(anchor) || modalContainerFor(root)); + return { + mediaId: button && button.__localRgMediaKey || null, + title: button && button.__localRedgifsItem && button.__localRedgifsItem.title || null, + layout: button && button.dataset && button.dataset.layout || null, + state: button && button.dataset && button.dataset.state || null, + inlineDisplay: button && button.style && button.style.display || null, + cssLeft: button && button.style && button.style["--local-rg-left"] || null, + cssTop: button && button.style && button.style["--local-rg-top"] || null, + lastLayoutDecision: button && button.__localRgLayoutReason || null, + decisionHistory: button && button.__localRgDecisionHistory || [], + button: diagnosticElement(button), + root: diagnosticElement(root), + anchor: diagnosticElement(anchor), + scrollHost: diagnosticElement(host), + parent: diagnosticElement(button && button.parentElement), + overlay: diagnosticElement(overlay), + anchorAncestors: diagnosticAncestors(anchor) + }; + } + + function capturePageDiagnostics() { + ensureButtonStyles(); + const buttonSet = new Set(externalButtons); + for (const buttonsForMedia of mediaButtons.values()) { + for (const button of buttonsForMedia) buttonSet.add(button); + } + const buttons = Array.from(buttonSet); + const media = Array.from(document.querySelectorAll + ? document.querySelectorAll("video, img[src], img[data-src], img[data-lazy-src]") + : []).slice(0, 300).map((element) => { + const root = closestCard(element); + const host = scrollHostFor(element); + return { + element: diagnosticElement(element), + posterId: element.tagName === "IMG" + ? idFromPosterUrl(element.currentSrc || element.getAttribute("src") || element.getAttribute("data-src") || element.getAttribute("data-lazy-src")) + : idFromPosterUrl(element.poster || element.getAttribute && element.getAttribute("poster")), + root: diagnosticElement(root), + scrollHost: diagnosticElement(host), + overlay: diagnosticElement(modalContainerFor(element) || modalContainerFor(root)), + ancestors: diagnosticAncestors(element) + }; + }); + for (const button of buttons) { + const root = button.__localRgRoot; + const anchor = button.__localRgAnchor; + const host = button.__localRgScrollHost; + if (button.classList) button.classList.add("local-rg-diagnostic-button"); + if (root && root.classList) root.classList.add("local-rg-diagnostic-root"); + if (anchor && anchor.classList) anchor.classList.add("local-rg-diagnostic-anchor"); + if (host && host.classList && !isDocumentScrollHost(host)) host.classList.add("local-rg-diagnostic-host"); + } + setTimeout(() => { + for (const element of Array.from(document.querySelectorAll ? document.querySelectorAll( + ".local-rg-diagnostic-button,.local-rg-diagnostic-root,.local-rg-diagnostic-anchor,.local-rg-diagnostic-host" + ) : [])) { + if (!element.classList) continue; + element.classList.remove("local-rg-diagnostic-button"); + element.classList.remove("local-rg-diagnostic-root"); + element.classList.remove("local-rg-diagnostic-anchor"); + element.classList.remove("local-rg-diagnostic-host"); + } + }, 30000); + return { + generatedAt: new Date().toISOString(), + extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null, + page: { + url: diagnosticSafeUrl(location.href), + title: document.title || null, + readyState: document.readyState || null, + visibilityState: document.visibilityState || null, + online: typeof navigator !== "undefined" ? navigator.onLine : null, + viewport: { width: Number(globalThis.innerWidth) || 0, height: Number(globalThis.innerHeight) || 0 }, + windowScroll: { + x: Number(globalThis.scrollX) || Number(globalThis.pageXOffset) || 0, + y: Number(globalThis.scrollY) || Number(globalThis.pageYOffset) || 0 + }, + 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", + purpleDashed: "scroll host", + orange: "download button" + }, + buttons: buttons.map(diagnosticButton), + media, + lastScanDiagnostics, + contentEvents: contentDiagnosticEvents.slice(), + resourceTiming: typeof performance !== "undefined" && performance.getEntriesByType + ? performance.getEntriesByType("resource").slice(-150).map((entry) => ({ + url: diagnosticSafeUrl(entry.name), + initiatorType: entry.initiatorType || null, + durationMs: Math.round(Number(entry.duration) * 10) / 10, + transferSize: Number(entry.transferSize) || 0, + encodedBodySize: Number(entry.encodedBodySize) || 0, + decodedBodySize: Number(entry.decodedBodySize) || 0 + })) + : [] + }; + } + + function findReusableExternalButton(root, item, anchor) { + const key = normalizeMediaKey(item && item.id); + const buttons = key && mediaButtons.get(key); + if (!buttons) return null; + for (const button of Array.from(buttons)) { + if (!button) { + buttons.delete(button); + continue; + } + const previousRoot = button.__localRgRoot; + if (!previousRoot) { + disposeButton(button, "reusable-without-root"); + continue; + } + const sameRoot = previousRoot === root; + const previousConnected = previousRoot + && (!("isConnected" in previousRoot) || previousRoot.isConnected); + const nextVisible = Math.max( + visibleAreaFor(root), + visibleAreaFor(anchor || root) + ) > 0; + const adopt = sameRoot + || (!previousConnected && nextVisible); + if (!adopt) continue; + if (previousRoot && previousRoot !== root && previousRoot.__localRgDownloadButton === button) { + previousRoot.__localRgDownloadButton = null; + } + root.__localRgDownloadButton = button; + return { button, adopt: true }; + } + return null; + } + + function setButtonReadyState(button) { + if (!button) return; + if (button.__localRgDownloadedRecord) { + button.title = "Уже скачано · нажмите, чтобы скачать ещё раз"; + setButtonState(button, "downloaded", "✓"); + } else { + button.title = "Скачать видео в лучшем доступном качестве"; + setButtonState(button, "idle", "⇩"); + } + } + + function markButtonDownloaded(button, record) { + if (!button) return; + button.__localRgDownloadedRecord = record || { downloadedAt: Date.now() }; + if (!button.disabled) setButtonReadyState(button); + } + + function markMediaDownloaded(mediaId, record) { + const key = normalizeMediaKey(mediaId); + if (!key) return; + const value = record || { downloadedAt: Date.now() }; + downloadedRecords.set(key, value); + checkedMediaIds.add(key); + const buttons = mediaButtons.get(key); + if (!buttons) return; + for (const button of buttons) { + if (button.__localRgMediaKey === key) markButtonDownloaded(button, value); + } + } + + async function flushHistoryChecks() { + historyCheckTimer = null; + const ids = Array.from(pendingHistoryIds).slice(0, 500); + for (const id of ids) pendingHistoryIds.delete(id); + if (!ids.length) return; + try { + const response = await chrome.runtime.sendMessage({ action: "getDownloadedMedia", ids }); + if (!response || !response.ok) throw new Error("История загрузок недоступна"); + const records = response.downloaded || {}; + for (const id of ids) { + checkedMediaIds.add(id); + if (records[id]) markMediaDownloaded(id, records[id]); + } + } catch (error) { + recordContentDiagnostic("warn", "download-history-check-failed", { + idCount: ids.length, + name: error && error.name, + message: error && error.message + }); + for (const id of ids) pendingHistoryIds.add(id); + } + } + + function queueHistoryCheck(mediaId) { + const key = normalizeMediaKey(mediaId); + if (!key || checkedMediaIds.has(key) || downloadedRecords.has(key)) return; + pendingHistoryIds.add(key); + clearTimeout(historyCheckTimer); + historyCheckTimer = setTimeout(flushHistoryChecks, 80); + } + + function registerMediaButton(button, item) { + const key = normalizeMediaKey(item && item.id); + const previousKey = button.__localRgMediaKey || null; + if (previousKey && previousKey !== key) { + const previousButtons = mediaButtons.get(previousKey); + if (previousButtons) previousButtons.delete(button); + button.__localRgDownloadedRecord = null; + } + button.__localRgMediaKey = key; + if (!key) { + if (!button.disabled) setButtonReadyState(button); + return; + } + if (!mediaButtons.has(key)) mediaButtons.set(key, new Set()); + mediaButtons.get(key).add(button); + if (downloadedRecords.has(key)) { + markButtonDownloaded(button, downloadedRecords.get(key)); + } else { + if (previousKey !== key && !button.disabled) setButtonReadyState(button); + queueHistoryCheck(key); + } + } + + 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") { + try { + root.animate([ + { opacity: 0, transform: "translateY(-28px) scale(.9)", filter: "blur(9px)" }, + { offset: 0.72, opacity: 1, transform: "translateY(4px) scale(1.015)", filter: "blur(0)" }, + { opacity: 1, transform: "translateY(0) scale(1)", filter: "blur(0)" } + ], { + duration: 460, + easing: "cubic-bezier(.16,1,.3,1)" + }); + return; + } catch { + // CSS fallback below. + } + } + root.classList.add("is-entering"); + setTimeout(() => root.classList.remove("is-entering"), 470); + } + + function ensureToastStack() { + ensureButtonStyles(); + let stack = document.getElementById("local-rg-toast-stack"); + if (stack) return stack; + stack = document.createElement("div"); + stack.id = "local-rg-toast-stack"; + stack.className = "local-rg-toast-stack"; + (document.body || document.documentElement).appendChild(stack); + return stack; + } + + function finishToastRemoval(jobId, entry) { + if (toastEntries.get(jobId) !== entry) return; + if (entry.timer) clearTimeout(entry.timer); + if (entry.removeTimer) clearTimeout(entry.removeTimer); + if (entry.countdownInterval) clearInterval(entry.countdownInterval); + if (entry.root && entry.root.remove) entry.root.remove(); + toastEntries.delete(jobId); + } + + function removeToast(jobId, immediate) { + const entry = toastEntries.get(jobId); + if (!entry) return; + if (entry.timer) clearTimeout(entry.timer); + if (entry.countdownInterval) clearInterval(entry.countdownInterval); + if (immediate || !entry.root || !entry.root.classList) { + finishToastRemoval(jobId, entry); + return; + } + if (entry.leaving) return; + entry.leaving = true; + if (typeof entry.root.animate === "function") { + try { + const animation = entry.root.animate([ + { opacity: 1, transform: "translateY(0) scale(1)", filter: "blur(0)" }, + { opacity: 0, transform: "translateY(-24px) scale(.92)", filter: "blur(8px)" } + ], { + duration: TOAST_EXIT_MS, + easing: "cubic-bezier(.55,0,1,.45)", + fill: "forwards" + }); + if (animation && animation.finished && typeof animation.finished.then === "function") { + animation.finished.then( + () => finishToastRemoval(jobId, entry), + () => finishToastRemoval(jobId, entry) + ); + } + } catch { + entry.root.classList.add("is-leaving"); + } + } else { + entry.root.classList.add("is-leaving"); + } + entry.removeTimer = setTimeout(() => finishToastRemoval(jobId, entry), TOAST_EXIT_MS + 80); + } + + function transferToast(fromJobId, toJobId) { + if (!fromJobId || !toJobId || fromJobId === toJobId) return toastEntries.get(fromJobId) || null; + const entry = toastEntries.get(fromJobId); + if (!entry) return null; + const existing = toastEntries.get(toJobId); + if (existing && existing !== entry) finishToastRemoval(toJobId, existing); + toastEntries.delete(fromJobId); + toastEntries.set(toJobId, entry); + return entry; + } + + function adoptPendingJob(job) { + const requestId = String(job && job.clientRequestId || ""); + if (!requestId || !job.id) return; + const button = pendingJobButtons.get(requestId); + if (!button) return; + transferToast(requestId, job.id); + button.__localRgJobId = job.id; + jobButtons.set(job.id, button); + pendingJobButtons.delete(requestId); + } + + function resetButtonForJob(jobId, button) { + if (!button || button.__localRgJobId !== jobId) return; + button.disabled = false; + button.title = "Скачать видео в лучшем доступном качестве"; + button.__localRgJobId = null; + button.__localRgTerminalJobId = null; + button.__localRgResetTimer = null; + jobButtons.delete(jobId); + setButtonReadyState(button); + } + + function syncButtonWithJob(job) { + const button = jobButtons.get(job.id); + if (!button || button.__localRgJobId !== job.id) return; + if (button.__localRgTerminalJobId === job.id) return; + if (job.state === "complete") { + button.disabled = true; + button.__localRgTerminalJobId = job.id; + button.title = "Скачивание завершено"; + setButtonState(button, "done", "✓"); + if (!button.__localRgResetTimer) { + button.__localRgResetTimer = setTimeout( + () => resetButtonForJob(job.id, button), + TOAST_COMPLETE_MS + TOAST_EXIT_MS + ); + } + return; + } + if (job.state === "error") { + button.disabled = true; + button.__localRgTerminalJobId = job.id; + button.title = job.error || job.status || "Ошибка загрузки"; + setButtonState(button, "error", "!"); + if (!button.__localRgResetTimer) { + button.__localRgResetTimer = setTimeout(() => resetButtonForJob(job.id, button), 7500); + } + return; + } + button.disabled = true; + button.title = job.status || "Скачивание…"; + setButtonState(button, "working", "⇩"); + } + + function showDownloadToast(job) { + if (!job || !job.id) return; + adoptPendingJob(job); + if (job.state === "complete" && job.mediaId) { + markMediaDownloaded(job.mediaId, { + downloadedAt: Date.now(), + filename: job.filename || job.title || null, + profileSlug: job.profileSlug || null + }); + } + syncButtonWithJob(job); + const stack = ensureToastStack(); + let entry = toastEntries.get(job.id); + if (!entry) { + const root = document.createElement("div"); + root.className = "local-rg-toast"; + const main = document.createElement("div"); + main.className = "local-rg-toast-main"; + const icon = document.createElement("span"); + icon.className = "local-rg-toast-icon"; + const copy = document.createElement("span"); + copy.className = "local-rg-toast-copy"; + const title = document.createElement("strong"); + title.className = "local-rg-toast-title"; + const status = document.createElement("span"); + status.className = "local-rg-toast-status"; + const value = document.createElement("span"); + value.className = "local-rg-toast-value"; + const track = document.createElement("div"); + track.className = "local-rg-toast-track"; + const bar = document.createElement("div"); + bar.className = "local-rg-toast-bar"; + copy.appendChild(title); + copy.appendChild(status); + main.appendChild(icon); + main.appendChild(copy); + main.appendChild(value); + track.appendChild(bar); + root.appendChild(main); + root.appendChild(track); + stack.appendChild(root); + animateToastEntrance(root); + entry = { + root, + icon, + title, + status, + value, + bar, + timer: null, + removeTimer: null, + countdownInterval: null, + completionStartedAt: 0, + terminalState: null, + leaving: false + }; + toastEntries.set(job.id, entry); + } + + if (entry.terminalState || entry.leaving) return; + + const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress) || 0))); + entry.root.dataset.state = job.state || "running"; + entry.title.textContent = job.title || job.filename || "RedGifs video"; + entry.status.textContent = job.error || job.status || "Подготовка загрузки…"; + entry.value.textContent = progress + "%"; + entry.bar.style.setProperty("width", progress + "%", "important"); + entry.icon.textContent = job.state === "complete" ? "✓" : job.state === "error" ? "!" : "⇩"; + + if (job.state === "complete") { + entry.terminalState = "complete"; + entry.completionStartedAt = Date.now(); + const completeStatus = job.status || "Скачивание завершено"; + const updateCountdown = () => { + const remaining = Math.max(0, TOAST_COMPLETE_MS - (Date.now() - entry.completionStartedAt)); + const seconds = Math.ceil(remaining / 1000); + entry.status.textContent = remaining > 0 + ? completeStatus + " · закрытие через " + seconds + " с" + : completeStatus + " · закрытие…"; + entry.value.textContent = seconds + " с"; + entry.bar.style.setProperty("width", (remaining / TOAST_COMPLETE_MS * 100) + "%", "important"); + }; + updateCountdown(); + entry.countdownInterval = setInterval(updateCountdown, 250); + entry.timer = setTimeout(() => removeToast(job.id), TOAST_COMPLETE_MS); + } else if (job.state === "error") { + entry.terminalState = "error"; + entry.timer = setTimeout(() => removeToast(job.id), 7500); + } + } + + function ensureDownloadButton(root, item, anchor) { + if (!root || !root.appendChild || !item) return; + ensureButtonStyles(); + root.classList.add("local-rg-download-host"); + let button = root.__localRgDownloadButton + || Array.from(root.children || []).find((child) => child.classList && child.classList.contains("local-rg-download-button")); + if (!button) { + const reusable = findReusableExternalButton(root, item, anchor); + if (reusable && !reusable.adopt) return; + button = reusable && reusable.button; + } + if (button) { + button.__localRedgifsItem = item; + configureButtonLayout(root, button, anchor); + registerMediaButton(button, item); + return; + } + + 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 = "Скачать видео в лучшем доступном качестве"; + button.setAttribute("aria-label", "Скачать видео"); + setButtonState(button, "idle", "⇩"); + button.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (button.disabled) return; + button.disabled = true; + const pendingId = "pending:" + + (button.__localRedgifsItem.id || "video") + + ":" + Date.now() + + ":" + Math.random().toString(36).slice(2, 8); + pendingJobButtons.set(pendingId, button); + setButtonState(button, "working", "⇩"); + showDownloadToast({ + id: pendingId, + state: "running", + progress: 0, + title: button.__localRedgifsItem.title || "RedGifs video", + status: "Поиск лучшего качества…" + }); + try { + button.title = "Скачать видео в лучшем доступном качестве"; + const response = await chrome.runtime.sendMessage({ + action: "downloadCapturedItem", + item: Object.assign({}, button.__localRedgifsItem, { clientRequestId: pendingId }) + }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Загрузка не запущена"); + } + const jobId = response.result && response.result.jobId; + if (!jobId) throw new Error("Загрузка не вернула идентификатор задания"); + pendingJobButtons.delete(pendingId); + transferToast(pendingId, jobId); + button.__localRgJobId = jobId; + jobButtons.set(jobId, button); + showDownloadToast({ + id: jobId, + state: "queued", + progress: 0, + title: button.__localRedgifsItem.title || "RedGifs video", + status: "Загрузка запущена…" + }); + try { + const latest = await chrome.runtime.sendMessage({ action: "getJob", jobId }); + if (latest && latest.ok && latest.job) showDownloadToast(latest.job); + } catch { + // Subsequent progress messages keep the button and toast synchronized. + } + } catch (error) { + recordContentDiagnostic("error", "download-request-failed", { + mediaId: button.__localRedgifsItem && button.__localRedgifsItem.id, + name: error && error.name, + message: error && error.message + }); + pendingJobButtons.delete(pendingId); + const errorId = "error:" + Date.now(); + transferToast(pendingId, errorId); + showDownloadToast({ + id: errorId, + state: "error", + progress: 0, + title: button.__localRedgifsItem.title || "RedGifs video", + status: error && error.message ? error.message : String(error), + error: error && error.message ? error.message : String(error) + }); + button.title = error && error.message ? error.message : String(error); + setButtonState(button, "error", "!"); + setTimeout(() => { + button.disabled = false; + setButtonReadyState(button); + }, 2600); + } + }, true); + configureButtonLayout(root, button, anchor); + 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 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 { + pageUrl: location.href, + pageTitle: document.title || "RedGifs", + items + }; + } + + 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 { + sendResponse({ ok: true }); + } catch { + // No response channel is required. + } + 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) { + recordContentDiagnostic("error", "diagnostic-capture-failed", { + name: error && error.name, + message: error && error.message + }); + sendResponse({ ok: false, error: error && error.message ? error.message : String(error) }); + } + return false; + } + if (message.action !== "scanRedgifsPage") return false; + try { + sendResponse({ ok: true, result: scanPage() }); + } catch (error) { + recordContentDiagnostic("error", "manual-scan-failed", { + name: error && error.name, + message: error && error.message + }); + sendResponse({ ok: false, error: error && error.message ? error.message : String(error) }); + } + return false; + }); + + 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 { + 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", "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); + }); + } + 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/assets/img/release-preview.png b/docs/release-preview.png similarity index 100% rename from assets/img/release-preview.png rename to docs/release-preview.png diff --git a/assets/img/icon-128.png b/icons/icon-128.png similarity index 100% rename from assets/img/icon-128.png rename to icons/icon-128.png diff --git a/icons/icon-16.png b/icons/icon-16.png new file mode 100644 index 0000000..68a9434 Binary files /dev/null and b/icons/icon-16.png differ diff --git a/assets/img/favicon-32.png b/icons/icon-32.png similarity index 100% rename from assets/img/favicon-32.png rename to icons/icon-32.png diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000..10d2adb Binary files /dev/null and b/icons/icon-48.png differ diff --git a/assets/img/icon.svg b/icons/icon.svg similarity index 100% rename from assets/img/icon.svg rename to icons/icon.svg diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..b1b7f3a --- /dev/null +++ b/manifest.json @@ -0,0 +1,47 @@ +{ + "manifest_version": 3, + "name": "RedGifs Video Downloader", + "version": "0.6.23", + "description": "Downloads RedGifs media directly and processes HLS locally. No account or telemetry.", + "permissions": [ + "activeTab", + "downloads", + "offscreen", + "scripting", + "storage", + "tabs" + ], + "host_permissions": [ + "https://redgifs.com/*", + "https://*.redgifs.com/*" + ], + "background": { + "service_worker": "background.js" + }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "content_scripts": [ + { + "matches": [ + "https://redgifs.com/*", + "https://*.redgifs.com/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_title": "RedGifs Video Downloader", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png" + } + } +} diff --git a/offscreen.html b/offscreen.html new file mode 100644 index 0000000..ce239f0 --- /dev/null +++ b/offscreen.html @@ -0,0 +1,10 @@ + + + + + Local HLS worker + + + + + diff --git a/offscreen.js b/offscreen.js new file mode 100644 index 0000000..b8e5838 --- /dev/null +++ b/offscreen.js @@ -0,0 +1,559 @@ +"use strict"; + +const activeJobs = new Set(); + +function reportDiagnostic(level, area, event, details) { + try { + const pending = chrome.runtime.sendMessage({ + target: "background", + action: "diagnosticEvent", + level, + area, + event, + details: details || {} + }); + if (pending && typeof pending.catch === "function") pending.catch(() => {}); + } catch { + // Diagnostics must never interrupt a download. + } +} + +function shouldLogNetworkResponse(url, response) { + if (!response || !response.ok) return true; + try { + return /\.(?:m3u8|mp4|webm|gif)$/i.test(new URL(url).pathname); + } catch { + return false; + } +} + +function isRedgifsHost(hostname) { + const host = String(hostname || "").toLowerCase(); + return host === "redgifs.com" || host.endsWith(".redgifs.com"); +} + +function trustedUrl(value, baseUrl) { + const parsed = new URL(String(value || "").trim(), baseUrl); + if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) { + throw new Error("HLS содержит ссылку за пределами RedGifs: " + parsed.hostname); + } + return parsed.toString(); +} + +function safeFilename(value) { + const cleaned = String(value || "redgifs-video") + .replace(/^Poster for\s+/i, "") + .replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "") + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") + .replace(/\s+/g, " ") + .replace(/[. ]+$/g, "") + .trim(); + return (cleaned || "redgifs-video").slice(0, 120); +} + +function safeFolderPath(value) { + return String(value || "") + .replace(/\\/g, "/") + .split("/") + .map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim()) + .filter((part) => part && part !== "." && part !== "..") + .slice(0, 8) + .map((part) => part.slice(0, 80)) + .join("/"); +} + +function outputFilename(folder, base, extension) { + const filename = safeFilename(base) + "." + extension; + const safeFolder = safeFolderPath(folder); + return safeFolder ? safeFolder + "/" + filename : filename; +} + +async function report(jobId, state, progress, status, extra) { + try { + await chrome.runtime.sendMessage(Object.assign({ + target: "background", + action: "hlsProgress", + jobId, + state, + progress, + status + }, extra || {})); + } catch { + // The service worker will be woken by the next progress update. + } +} + +function validReferrer(value) { + try { + const parsed = new URL(value); + return parsed.protocol === "https:" && isRedgifsHost(parsed.hostname) ? parsed.toString() : undefined; + } catch { + return undefined; + } +} + +async function fetchMedia(url, init, sourceUrl) { + const options = Object.assign({ + cache: "no-store", + credentials: "omit" + }, init || {}); + const referrer = validReferrer(sourceUrl); + if (referrer) { + options.referrer = referrer; + options.referrerPolicy = "no-referrer-when-downgrade"; + } + const startedAt = Date.now(); + try { + const response = await fetch(url, options); + if (shouldLogNetworkResponse(url, response)) { + reportDiagnostic(response.ok ? "info" : "warn", "network.media", "response", { + url, + status: response.status, + ok: response.ok, + usedReferrer: Boolean(referrer), + durationMs: Date.now() - startedAt + }); + } + return response; + } catch (firstError) { + reportDiagnostic(referrer ? "warn" : "error", "network.media", "request-failed", { + url, + usedReferrer: Boolean(referrer), + durationMs: Date.now() - startedAt, + willRetryWithoutReferrer: Boolean(referrer), + error: { name: firstError && firstError.name, message: firstError && firstError.message } + }); + if (!referrer) throw firstError; + delete options.referrer; + delete options.referrerPolicy; + const retryStartedAt = Date.now(); + try { + const response = await fetch(url, options); + if (shouldLogNetworkResponse(url, response)) { + reportDiagnostic(response.ok ? "info" : "warn", "network.media", "retry-response", { + url, + status: response.status, + ok: response.ok, + durationMs: Date.now() - retryStartedAt + }); + } + return response; + } catch (retryError) { + reportDiagnostic("error", "network.media", "retry-failed", { + url, + durationMs: Date.now() - retryStartedAt, + error: { name: retryError && retryError.name, message: retryError && retryError.message } + }); + throw retryError; + } + } +} + +async function fetchText(url, sourceUrl) { + const response = await fetchMedia(url, { + headers: { Accept: "application/vnd.apple.mpegurl, application/x-mpegURL, text/plain, */*" } + }, sourceUrl); + if (!response.ok) throw new Error("Не удалось получить HLS playlist: HTTP " + response.status); + return response.text(); +} + +function parseAttributes(value) { + const result = {}; + const matcher = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/gi; + let match; + while ((match = matcher.exec(value)) !== null) { + let item = match[2] || ""; + if (item.startsWith("\"") && item.endsWith("\"")) item = item.slice(1, -1); + result[match[1].toUpperCase()] = item; + } + return result; +} + +function playlistLines(text) { + return String(text || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function masterVariants(text, baseUrl) { + const lines = playlistLines(text); + const variants = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (!line.startsWith("#EXT-X-STREAM-INF:")) continue; + const attributes = parseAttributes(line.slice("#EXT-X-STREAM-INF:".length)); + let next = index + 1; + while (next < lines.length && lines[next].startsWith("#")) next += 1; + if (next >= lines.length) continue; + const resolution = String(attributes.RESOLUTION || "").match(/(\d+)x(\d+)/i); + variants.push({ + url: trustedUrl(lines[next], baseUrl), + bandwidth: Number(attributes.BANDWIDTH) || 0, + height: resolution ? Number(resolution[2]) : 0 + }); + } + return variants.sort((left, right) => { + if (right.height !== left.height) return right.height - left.height; + return right.bandwidth - left.bandwidth; + }); +} + +function parseByteRange(specification, url, offsets) { + if (!specification) return null; + const match = String(specification).match(/^(\d+)(?:@(\d+))?$/); + if (!match) throw new Error("Неподдерживаемый EXT-X-BYTERANGE: " + specification); + const length = Number(match[1]); + const start = match[2] == null ? (offsets.get(url) || 0) : Number(match[2]); + const end = start + length - 1; + offsets.set(url, end + 1); + return { start, end }; +} + +function parseMediaPlaylist(text, baseUrl) { + const lines = playlistLines(text); + const segments = []; + const offsets = new Map(); + let mediaSequence = 0; + let pendingDuration = null; + let pendingRange = null; + let currentKey = null; + let currentMap = null; + + for (const line of lines) { + if (line.startsWith("#EXT-X-MEDIA-SEQUENCE:")) { + mediaSequence = Number(line.slice("#EXT-X-MEDIA-SEQUENCE:".length)) || 0; + continue; + } + if (line.startsWith("#EXTINF:")) { + const match = line.match(/^#EXTINF:([\d.]+)/); + pendingDuration = match ? Number(match[1]) : null; + continue; + } + if (line.startsWith("#EXT-X-BYTERANGE:")) { + pendingRange = line.slice("#EXT-X-BYTERANGE:".length).trim(); + continue; + } + if (line.startsWith("#EXT-X-KEY:")) { + const attributes = parseAttributes(line.slice("#EXT-X-KEY:".length)); + if (String(attributes.METHOD || "").toUpperCase() === "NONE") { + currentKey = null; + } else { + currentKey = { + method: String(attributes.METHOD || "").toUpperCase(), + url: attributes.URI ? trustedUrl(attributes.URI, baseUrl) : null, + iv: attributes.IV || null + }; + } + continue; + } + if (line.startsWith("#EXT-X-MAP:")) { + const attributes = parseAttributes(line.slice("#EXT-X-MAP:".length)); + const mapUrl = trustedUrl(attributes.URI, baseUrl); + currentMap = { + url: mapUrl, + range: parseByteRange(attributes.BYTERANGE, mapUrl, offsets) + }; + continue; + } + if (line.startsWith("#")) continue; + + const segmentUrl = trustedUrl(line, baseUrl); + segments.push({ + url: segmentUrl, + duration: pendingDuration, + range: parseByteRange(pendingRange, segmentUrl, offsets), + sequence: mediaSequence + segments.length, + key: currentKey ? Object.assign({}, currentKey) : null, + map: currentMap ? { + url: currentMap.url, + range: currentMap.range ? Object.assign({}, currentMap.range) : null + } : null + }); + pendingDuration = null; + pendingRange = null; + } + return segments; +} + +async function fetchBytes(url, range, sourceUrl) { + const headers = { Accept: "*/*" }; + if (range) headers.Range = "bytes=" + range.start + "-" + range.end; + const response = await fetchMedia(url, { headers }, sourceUrl); + if (!response.ok) throw new Error("Сегмент HLS: HTTP " + response.status); + let bytes = new Uint8Array(await response.arrayBuffer()); + if (range && response.status !== 206) { + const expected = range.end - range.start + 1; + if (bytes.byteLength >= range.end + 1) { + bytes = bytes.slice(range.start, range.end + 1); + } else if (bytes.byteLength !== expected) { + throw new Error("Сервер проигнорировал byte-range сегмента."); + } + } + return bytes; +} + +function makeIv(explicitIv, sequence) { + const iv = new Uint8Array(16); + if (explicitIv) { + let hex = String(explicitIv).replace(/^0x/i, "").replace(/[^0-9a-f]/gi, ""); + hex = hex.padStart(32, "0").slice(-32); + for (let index = 0; index < 16; index += 1) { + iv[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return iv; + } + let value = BigInt(sequence); + for (let index = 15; index >= 0; index -= 1) { + iv[index] = Number(value & 255n); + value >>= 8n; + } + return iv; +} + +async function decryptSegment(bytes, keyDescriptor, sequence, sourceUrl, keyCache) { + if (!keyDescriptor) return bytes; + if (keyDescriptor.method !== "AES-128" || !keyDescriptor.url) { + throw new Error("Неподдерживаемое шифрование HLS: " + (keyDescriptor.method || "unknown")); + } + let cryptoKey = keyCache.get(keyDescriptor.url); + if (!cryptoKey) { + const rawKey = await fetchBytes(keyDescriptor.url, null, sourceUrl); + if (rawKey.byteLength !== 16) throw new Error("Некорректный AES-128 ключ HLS."); + cryptoKey = await crypto.subtle.importKey("raw", rawKey, { name: "AES-CBC" }, false, ["decrypt"]); + keyCache.set(keyDescriptor.url, cryptoKey); + } + const decrypted = await crypto.subtle.decrypt({ + name: "AES-CBC", + iv: makeIv(keyDescriptor.iv, sequence) + }, cryptoKey, bytes); + return new Uint8Array(decrypted); +} + +function mapIdentity(map, key) { + if (!map) return ""; + const range = map.range ? map.range.start + "-" + map.range.end : "all"; + const keyPart = key ? key.url + ":" + (key.iv || "") : "plain"; + return map.url + "|" + range + "|" + keyPart; +} + +function looksLikeFragmentedMp4(chunks, segments) { + if (segments.some((segment) => Boolean(segment.map))) return true; + if (segments.some((segment) => /\.(?:m4s|mp4)(?:$|\?)/i.test(segment.url))) return true; + const first = chunks[0]; + if (!first || first.length < 8) return false; + const box = String.fromCharCode(first[4], first[5], first[6], first[7]); + return ["ftyp", "styp", "moof"].includes(box); +} + +async function downloadBlob(blobUrl, filename, saveAs) { + try { + if (chrome.downloads && chrome.downloads.download) { + return await chrome.downloads.download({ url: blobUrl, filename, saveAs }); + } + } catch { + // Fall back to the service worker, which has the downloads permission. + } + const response = await chrome.runtime.sendMessage({ + target: "background", + action: "downloadBlobUrl", + blobUrl, + filename, + saveAs + }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Chrome не принял локальный Blob."); + } + return response.downloadId; +} + +async function downloadTextFile(message) { + const text = String(message && message.text || ""); + if (!text || text.length > 6 * 1024 * 1024) { + throw new Error(text ? "Диагностический лог слишком большой." : "Диагностический лог пуст."); + } + const filename = String(message.filename || "redgifs-diagnostic-log.json") + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") + .replace(/[. ]+$/g, "") + .slice(0, 180) || "redgifs-diagnostic-log.json"; + const blob = new Blob([text], { type: message.mimeType || "application/json;charset=utf-8" }); + const blobUrl = URL.createObjectURL(blob); + try { + const downloadId = await downloadBlob(blobUrl, filename, message.saveAs !== false); + return { downloadId, filename }; + } finally { + setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); + } +} + +async function runDirect(message) { + const jobId = message.jobId; + activeJobs.add(jobId); + try { + const mediaUrl = trustedUrl(message.mediaUrl); + await report(jobId, "running", 1, "Подключение к CDN RedGifs…"); + const response = await fetchMedia(mediaUrl, { + headers: { + Accept: "video/webm,video/ogg,video/*;q=0.9,image/gif;q=0.8,*/*;q=0.5" + } + }, message.sourceUrl); + if (!response.ok) throw new Error("Загрузка видео: HTTP " + response.status); + + const total = Number(response.headers.get("content-length")) || 0; + const chunks = []; + let received = 0; + let lastReportedProgress = -1; + if (response.body && response.body.getReader) { + const reader = response.body.getReader(); + for (;;) { + const part = await reader.read(); + if (part.done) break; + chunks.push(part.value); + received += part.value.byteLength; + const progress = total ? Math.min(94, Math.round((received / total) * 94)) : Math.min(90, 2 + Math.round(chunks.length / 2)); + if (progress !== lastReportedProgress) { + lastReportedProgress = progress; + await report(jobId, "running", progress, total ? "Загрузка · " + Math.round(received / 1048576) + "/" + Math.round(total / 1048576) + " МБ" : "Загружено · " + Math.round(received / 1048576) + " МБ"); + } + } + } else { + const bytes = new Uint8Array(await response.arrayBuffer()); + chunks.push(bytes); + received = bytes.byteLength; + } + + if (!received) throw new Error("RedGifs вернул пустой файл."); + await report(jobId, "running", 96, "Подготовка локального файла…"); + const extension = String(message.extension || "mp4").replace(/[^a-z0-9]/gi, "") || "mp4"; + const filename = outputFilename(message.outputFolder, message.filenameBase, extension); + const mimeType = response.headers.get("content-type") || (extension === "gif" ? "image/gif" : "video/mp4"); + const blob = new Blob(chunks, { type: mimeType }); + const blobUrl = URL.createObjectURL(blob); + try { + const downloadId = await downloadBlob(blobUrl, filename, Boolean(message.saveAs)); + await report(jobId, "saving", 98, "Сохранение файла…", { + filename, + downloadId + }); + } finally { + setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); + } + } catch (error) { + reportDiagnostic("error", "offscreen.direct", "job-failed", { + jobId, + mediaUrl: message.mediaUrl, + error: { name: error && error.name, message: error && error.message } + }); + await report(jobId, "error", 0, "Ошибка загрузки", { + error: error && error.message ? error.message : String(error) + }); + } finally { + activeJobs.delete(jobId); + } +} + +async function runHls(message) { + const jobId = message.jobId; + activeJobs.add(jobId); + try { + let playlistUrl = trustedUrl(message.playlistUrl); + let playlistText = null; + let variants = []; + + await report(jobId, "running", 1, "Получение HLS playlist…"); + for (let depth = 0; depth < 3; depth += 1) { + playlistText = await fetchText(playlistUrl, message.sourceUrl); + variants = masterVariants(playlistText, playlistUrl); + if (!variants.length) break; + playlistUrl = variants[0].url; + await report(jobId, "running", 3, "Выбран лучший HLS-поток" + (variants[0].height ? " · " + variants[0].height + "p" : "")); + } + + const segments = parseMediaPlaylist(playlistText, playlistUrl); + if (!segments.length) throw new Error("В HLS playlist не найдено сегментов."); + await report(jobId, "running", 5, "Сегментов: " + segments.length); + + const chunks = []; + const downloadedMaps = new Set(); + const keyCache = new Map(); + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (segment.map) { + const identity = mapIdentity(segment.map, segment.key); + if (!downloadedMaps.has(identity)) { + let mapBytes = await fetchBytes(segment.map.url, segment.map.range, message.sourceUrl); + mapBytes = await decryptSegment(mapBytes, segment.key, segment.sequence, message.sourceUrl, keyCache); + chunks.push(mapBytes); + downloadedMaps.add(identity); + } + } + + let segmentBytes = await fetchBytes(segment.url, segment.range, message.sourceUrl); + segmentBytes = await decryptSegment(segmentBytes, segment.key, segment.sequence, message.sourceUrl, keyCache); + chunks.push(segmentBytes); + const progress = 5 + Math.round(((index + 1) / segments.length) * 86); + await report(jobId, "running", progress, "Загрузка сегментов · " + (index + 1) + "/" + segments.length); + } + + await report(jobId, "running", 94, "Сборка локального файла…"); + const fragmentedMp4 = looksLikeFragmentedMp4(chunks, segments); + const extension = fragmentedMp4 ? "mp4" : "ts"; + const mimeType = fragmentedMp4 ? "video/mp4" : "video/mp2t"; + const filename = outputFilename(message.outputFolder, message.filenameBase, extension); + const blob = new Blob(chunks, { type: mimeType }); + if (!blob.size) throw new Error("Собранный файл пуст."); + const blobUrl = URL.createObjectURL(blob); + + try { + const downloadId = await downloadBlob(blobUrl, filename, Boolean(message.saveAs)); + await report(jobId, "saving", 98, "Сохранение файла…", { + filename, + downloadId + }); + } finally { + setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); + } + } catch (error) { + reportDiagnostic("error", "offscreen.hls", "job-failed", { + jobId, + playlistUrl: message.playlistUrl, + error: { name: error && error.name, message: error && error.message } + }); + await report(jobId, "error", 0, "Ошибка HLS", { + error: error && error.message ? error.message : String(error) + }); + } finally { + activeJobs.delete(jobId); + } +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || message.target !== "offscreen" + || !["downloadHls", "downloadDirect", "downloadTextFile"].includes(message.action)) return false; + if (message.action === "downloadTextFile") { + downloadTextFile(message).then((result) => { + sendResponse({ ok: true, result }); + }).catch((error) => { + reportDiagnostic("error", "offscreen.diagnostic", "download-failed", { + filename: message.filename, + error: { name: error && error.name, message: error && error.message } + }); + sendResponse({ ok: false, error: error && error.message ? error.message : String(error) }); + }); + return true; + } + if (activeJobs.has(message.jobId)) { + sendResponse({ ok: false, error: "Эта задача уже выполняется." }); + return false; + } + sendResponse({ ok: true }); + setTimeout(() => { + if (message.action === "downloadHls") { + runHls(message); + } else { + runDirect(message); + } + }, 0); + return false; +}); diff --git a/options.css b/options.css new file mode 100644 index 0000000..ec646c9 --- /dev/null +++ b/options.css @@ -0,0 +1,195 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0b0f16; + color: #f4f7fb; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at 92% 0, rgba(240, 68, 104, 0.18), transparent 34%), + linear-gradient(150deg, #111722, #080b10 70%); +} + +button, +input { + font: inherit; +} + +.settings-shell { + width: min(680px, calc(100% - 32px)); + margin: 0 auto; + padding: 52px 0; +} + +header { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 24px; +} + +.settings-mark { + display: grid; + width: 52px; + height: 52px; + place-items: center; + border-radius: 16px; + background: linear-gradient(135deg, #ff5277, #cf2851); + box-shadow: 0 14px 38px rgba(240, 68, 104, 0.25); + font-weight: 900; +} + +h1, +p { + margin: 0; +} + +h1 { + font-size: 22px; +} + +header p { + margin-top: 4px; + color: #8995a9; + font-size: 13px; +} + +.settings-card { + padding: 25px; + border: 1px solid #263143; + border-radius: 18px; + background: rgba(15, 21, 31, 0.92); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28); +} + +.settings-card > label:first-child { + display: block; + margin-bottom: 8px; + color: #d9e0ea; + font-size: 13px; + font-weight: 800; +} + +input[type="text"] { + width: 100%; + padding: 12px 13px; + border: 1px solid #344157; + border-radius: 11px; + outline: none; + background: #0c1119; + color: #f4f7fb; +} + +input[type="text"]:focus { + border-color: #f04468; + box-shadow: 0 0 0 3px rgba(240, 68, 104, 0.13); +} + +.help { + margin: 9px 0 23px; + color: #7f8a9e; + font-size: 11px; + line-height: 1.55; +} + +code { + color: #d9e0ea; +} + +.check-row { + display: flex; + align-items: flex-start; + gap: 11px; + padding: 13px 0; + border-top: 1px solid #232d3d; + cursor: pointer; +} + +.check-row input { + width: 17px; + height: 17px; + margin-top: 2px; + accent-color: #f04468; +} + +.check-row strong, +.check-row small { + display: block; +} + +.check-row strong { + font-size: 13px; +} + +.check-row small { + margin-top: 4px; + color: #7f8a9e; + font-size: 11px; +} + +.preview { + margin-top: 18px; + padding: 13px; + border: 1px solid #2b374a; + border-radius: 11px; + background: #0c1119; +} + +.preview span, +.preview strong { + display: block; +} + +.preview span { + margin-bottom: 5px; + color: #748096; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.preview strong { + overflow-wrap: anywhere; + color: #cdd5e2; + font-size: 12px; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 22px; +} + +button { + padding: 10px 15px; + border: 0; + border-radius: 10px; + cursor: pointer; + font-weight: 800; +} + +.primary { + background: #f04468; + color: #fff; +} + +.secondary { + background: #202a39; + color: #cbd3df; +} + +.status { + min-height: 18px; + margin-top: 12px; + color: #69dda9; + font-size: 11px; + text-align: right; +} diff --git a/options.html b/options.html new file mode 100644 index 0000000..a4a3dca --- /dev/null +++ b/options.html @@ -0,0 +1,54 @@ + + + + + + Настройки RedGifs Video Downloader + + + +
+
+
RG
+
+

Настройки RedGifs Video Downloader

+

Папки сохранения и поведение загрузчика

+
+
+ +
+ + +

Можно использовать вложенный путь, например Видео/RedGifs. Абсолютную папку расширение задавать не может — корневая папка определяется настройками Chrome.

+ + + + + +
+ Пример пути + Загрузки/RedGifs/profile/video.mp4 +
+ +
+ + +
+
+
+
+ + + diff --git a/options.js b/options.js new file mode 100644 index 0000000..725cf11 --- /dev/null +++ b/options.js @@ -0,0 +1,76 @@ +"use strict"; + +const DEFAULT_SETTINGS = { + baseFolder: "RedGifs", + groupByProfile: true, + saveAs: false +}; + +const baseFolderInput = document.getElementById("baseFolder"); +const groupByProfileInput = document.getElementById("groupByProfile"); +const saveAsInput = document.getElementById("saveAs"); +const pathPreview = document.getElementById("pathPreview"); +const saveButton = document.getElementById("saveButton"); +const resetButton = document.getElementById("resetButton"); +const statusBox = document.getElementById("status"); + +function cleanPath(value) { + return String(value || "") + .replace(/\\/g, "/") + .split("/") + .map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim()) + .filter((part) => part && part !== "." && part !== "..") + .slice(0, 8) + .join("/"); +} + +function readForm() { + return { + baseFolder: cleanPath(baseFolderInput.value), + groupByProfile: groupByProfileInput.checked, + saveAs: saveAsInput.checked + }; +} + +function writeForm(settings) { + baseFolderInput.value = settings.baseFolder || ""; + groupByProfileInput.checked = settings.groupByProfile !== false; + saveAsInput.checked = Boolean(settings.saveAs); + updatePreview(); +} + +function updatePreview() { + const settings = readForm(); + const parts = ["Загрузки"]; + if (settings.baseFolder) parts.push(settings.baseFolder); + if (settings.groupByProfile) parts.push("profile"); + parts.push("video.mp4"); + pathPreview.textContent = parts.join("/"); +} + +async function loadSettings() { + const stored = await chrome.storage.local.get("downloadSettings"); + writeForm(Object.assign({}, DEFAULT_SETTINGS, stored.downloadSettings || {})); +} + +async function saveSettings() { + const settings = readForm(); + baseFolderInput.value = settings.baseFolder; + await chrome.storage.local.set({ downloadSettings: settings }); + statusBox.textContent = "Настройки сохранены."; + updatePreview(); + setTimeout(() => { + statusBox.textContent = ""; + }, 2200); +} + +baseFolderInput.addEventListener("input", updatePreview); +groupByProfileInput.addEventListener("change", updatePreview); +saveAsInput.addEventListener("change", updatePreview); +saveButton.addEventListener("click", saveSettings); +resetButton.addEventListener("click", () => { + writeForm(DEFAULT_SETTINGS); + statusBox.textContent = "Восстановлены значения по умолчанию — нажмите «Сохранить»."; +}); + +loadSettings(); diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..3400f0f --- /dev/null +++ b/popup.css @@ -0,0 +1,706 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0c1017; + color: #f4f7fb; +} + +* { + box-sizing: border-box; +} + +body { + width: 410px; + margin: 0; + background: + radial-gradient(circle at 100% 0, rgba(255, 69, 103, 0.2), transparent 38%), + linear-gradient(155deg, #121824 0%, #090c12 72%); +} + +button, +input { + font: inherit; +} + +.shell { + padding: 20px 20px 14px; +} + +header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.header-copy { + min-width: 0; + flex: 1; +} + +.settings-button { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 42px; + place-items: center; + border: 1px solid rgba(255, 111, 142, 0.72); + border-radius: 12px; + background: linear-gradient(135deg, rgba(240, 68, 104, 0.34), rgba(191, 37, 76, 0.24)); + color: #fff; + box-shadow: 0 7px 22px rgba(240, 68, 104, 0.18); + cursor: pointer; + transition: transform 150ms ease, filter 150ms ease, box-shadow 150ms ease; +} + +.settings-button:hover { + filter: brightness(1.18); + transform: translateY(-1px); + box-shadow: 0 9px 26px rgba(240, 68, 104, 0.28); +} + +.settings-button[aria-expanded="true"] { + border-color: #ff7895; + background: linear-gradient(135deg, rgba(240, 68, 104, 0.58), rgba(191, 37, 76, 0.42)); + box-shadow: 0 9px 26px rgba(240, 68, 104, 0.3); +} + +.settings-button:active { + transform: translateY(1px); +} + +.settings-button svg { + width: 21px; + height: 21px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 300ms cubic-bezier(.22, 1, .36, 1); +} + +.settings-button[aria-expanded="true"] svg { + transform: rotate(45deg); +} + +.settings-panel { + margin-bottom: 12px; + padding: 14px; + border: 1px solid rgba(240, 68, 104, 0.42); + border-radius: 13px; + background: rgba(15, 21, 31, 0.96); + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.22); +} + +.settings-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 13px; +} + +.settings-panel-head h2 { + margin: 0; + color: #f4f7fb; + font-size: 13px; + line-height: 1.3; +} + +.settings-panel-head p { + margin-top: 3px; + color: #748096; + font-size: 10px; +} + +.settings-field-label { + display: block; + margin-bottom: 6px; + color: #c7cfdb; + font-size: 11px; + font-weight: 800; +} + +.settings-text-input { + width: 100%; + padding: 9px 10px; + border: 1px solid #344157; + border-radius: 9px; + outline: none; + background: #0c1119; + color: #f4f7fb; + font-size: 11px; +} + +.settings-text-input:focus { + border-color: #f04468; + box-shadow: 0 0 0 3px rgba(240, 68, 104, 0.13); +} + +.settings-help { + margin: 7px 0 12px; + color: #6f7c91; + font-size: 9px; + line-height: 1.45; +} + +.settings-help code { + color: #cbd3df; +} + +.settings-check-row { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 0; + border-top: 1px solid #232d3d; + cursor: pointer; +} + +.settings-check-row input { + width: 16px; + height: 16px; + flex: 0 0 16px; + margin: 1px 0 0; + accent-color: #f04468; +} + +.settings-check-row strong, +.settings-check-row small { + display: block; +} + +.settings-check-row strong { + color: #e8edf5; + font-size: 11px; +} + +.settings-check-row small { + margin-top: 3px; + color: #788499; + font-size: 9px; + line-height: 1.4; +} + +.settings-preview { + margin-top: 8px; + padding: 10px; + border: 1px solid #2b374a; + border-radius: 9px; + background: #0c1119; +} + +.settings-preview span, +.settings-preview strong { + display: block; +} + +.settings-preview span { + margin-bottom: 4px; + color: #748096; + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-preview strong { + overflow-wrap: anywhere; + color: #cdd5e2; + font-size: 10px; +} + +.settings-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.settings-primary, +.settings-secondary { + padding: 8px 11px; + border-radius: 9px; + font-size: 10px; + font-weight: 800; +} + +.settings-primary { + background: #f04468; + color: #fff; +} + +.settings-primary:hover:not(:disabled) { + background: #ff5477; +} + +.settings-secondary { + background: #202a39; + color: #cbd3df; +} + +.settings-secondary:hover:not(:disabled) { + background: #293548; +} + +.settings-status { + min-height: 0; + margin-top: 8px; + color: #69dda9; + font-size: 9px; + text-align: right; +} + +.settings-status:empty { + display: none; +} + +.settings-status.error { + color: #ff7895; +} + +.alternative-toggle { + display: grid; + grid-template-columns: 30px minmax(0, 1fr) 20px; + align-items: center; + width: 100%; + gap: 9px; + padding: 11px 12px; + border: 1px solid #2c3749; + border-radius: 12px; + background: rgba(20, 27, 39, 0.88); + color: #dce2eb; + cursor: pointer; + text-align: left; +} + +.alternative-toggle:hover, +.alternative-toggle[aria-expanded="true"] { + border-color: rgba(240, 68, 104, 0.65); + background: #192230; +} + +.alternative-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border-radius: 9px; + background: rgba(240, 68, 104, 0.16); + color: #ff718e; + font-size: 17px; + font-weight: 900; + transition: transform 300ms cubic-bezier(.22, 1, .36, 1), background 200ms ease, color 200ms ease; +} + +.alternative-toggle[aria-expanded="true"] .alternative-icon { + transform: rotate(180deg); + background: rgba(240, 68, 104, 0.26); + color: #ff9bb0; +} + +.alternative-label { + font-size: 11px; + font-weight: 800; + line-height: 1.35; +} + +.alternative-chevron { + color: #7f8a9e; + font-size: 18px; + text-align: center; + transition: transform 300ms cubic-bezier(.22, 1, .36, 1), color 200ms ease; +} + +.alternative-toggle[aria-expanded="true"] .alternative-chevron { + transform: rotate(180deg); + color: #ff8da4; +} + +.diagnostic-tools { + margin-top: 10px; +} + +.diagnostic-button { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 12px; + border: 1px dashed rgba(120, 173, 255, 0.44); + border-radius: 10px; + background: rgba(22, 34, 52, 0.72); + color: #bcd4ff; + font-size: 11px; + font-weight: 800; +} + +.diagnostic-button:hover:not(:disabled) { + border-color: rgba(120, 173, 255, 0.8); + background: rgba(31, 49, 75, 0.9); + color: #e4eeff; +} + +.diagnostic-button svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.diagnostic-status { + margin: 6px 4px 0; + color: #6f7c91; + font-size: 10px; + line-height: 1.35; + text-align: center; +} + +.diagnostic-status:empty { + display: none; +} + +.diagnostic-status.success { + color: #69dda9; +} + +.diagnostic-status.error { + color: #ff7895; +} + +.alternative-panel { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #222c3b; +} + +.mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 13px; + background: linear-gradient(135deg, #ff496e, #c92550); + box-shadow: 0 10px 30px rgba(255, 73, 110, 0.26); + font-weight: 900; + letter-spacing: -0.04em; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 17px; + letter-spacing: -0.02em; +} + +header p { + margin-top: 3px; + color: #8f9aae; + font-size: 12px; +} + +.field-label { + display: block; + margin-bottom: 7px; + color: #b9c2d0; + font-size: 12px; + font-weight: 700; +} + +.input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +input[type="url"] { + min-width: 0; + padding: 11px 12px; + border: 1px solid #273142; + border-radius: 10px; + outline: none; + background: rgba(10, 14, 21, 0.88); + color: #f6f8fb; + font-size: 12px; +} + +input[type="url"]:focus { + border-color: #e23b61; + box-shadow: 0 0 0 3px rgba(226, 59, 97, 0.13); +} + +button { + border: 0; + cursor: pointer; +} + +button:disabled { + cursor: wait; + opacity: 0.58; +} + +.primary { + padding: 0 15px; + border-radius: 10px; + background: #f04468; + color: white; + font-weight: 800; +} + +.primary:hover:not(:disabled) { + background: #ff5477; +} + +.save-option { + display: flex; + align-items: center; + gap: 7px; + margin: 10px 0 0; + color: #8f9aae; + font-size: 11px; +} + +.save-option input { + accent-color: #f04468; +} + +.status { + margin-top: 14px; + padding: 10px 11px; + border: 1px solid #232c3b; + border-radius: 10px; + background: rgba(18, 24, 35, 0.72); + color: #9da8ba; + font-size: 12px; + line-height: 1.45; +} + +.status.success { + border-color: rgba(42, 190, 126, 0.35); + color: #69dda9; +} + +.status.error { + border-color: rgba(255, 73, 110, 0.38); + color: #ff7895; +} + +.result { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 13px; + margin-top: 14px; + padding: 12px; + border: 1px solid #273142; + border-radius: 13px; + background: rgba(15, 20, 29, 0.92); +} + +.result.no-image { + grid-template-columns: 1fr; +} + +.thumbnail { + width: 112px; + height: 138px; + border-radius: 9px; + object-fit: cover; + background: #181f2a; +} + +.media-copy { + min-width: 0; +} + +h2 { + overflow: hidden; + margin: 2px 0 10px; + font-size: 13px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collection { + margin-top: 14px; + padding: 11px; + border: 1px solid #273142; + border-radius: 13px; + background: rgba(15, 20, 29, 0.92); +} + +.collection-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 9px; +} + +.collection-head h2 { + margin: 0; +} + +.collection-head span { + color: #7e899c; + font-size: 10px; +} + +.collection-items { + display: grid; + max-height: 320px; + gap: 7px; + overflow-y: auto; + padding-right: 3px; +} + +.collection-item { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + align-items: center; + width: 100%; + min-height: 66px; + overflow: hidden; + padding: 0; + border: 1px solid #303b4e; + border-radius: 9px; + background: #18202d; + color: #e8edf5; + text-align: left; +} + +.collection-item.no-image { + grid-template-columns: 1fr; + padding: 10px; +} + +.collection-item:hover { + border-color: #f04468; + background: #202a39; +} + +.collection-thumb { + width: 62px; + height: 66px; + object-fit: cover; + background: #222b39; +} + +.collection-copy { + min-width: 0; + padding: 8px 10px; +} + +.collection-copy strong, +.collection-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collection-copy strong { + font-size: 11px; +} + +.collection-copy small { + margin-top: 4px; + color: #7f8a9e; + font-size: 9px; +} + +.collection-hint { + margin-top: 9px; + color: #667287; + font-size: 9px; + line-height: 1.4; +} + +.formats { + display: grid; + gap: 7px; +} + +.format-button { + width: 100%; + padding: 9px 10px; + border: 1px solid #344055; + border-radius: 9px; + background: #1a2230; + color: #e8edf5; + font-size: 11px; + font-weight: 750; + text-align: left; +} + +.format-button:hover:not(:disabled) { + border-color: #f04468; + background: #222c3c; +} + +.progress-card { + margin-top: 12px; + padding: 11px; + border: 1px solid #273142; + border-radius: 11px; + background: rgba(15, 20, 29, 0.92); +} + +.progress-head { + display: flex; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; + color: #b9c2d0; + font-size: 11px; +} + +.progress-track { + height: 7px; + overflow: hidden; + border-radius: 99px; + background: #232c3b; +} + +.progress-bar { + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #f04468, #ff8ca3); + transition: width 180ms ease; +} + +footer { + margin-top: 12px; + padding: 0 4px; + color: #667287; + font-size: 10px; + line-height: 1.45; + text-align: center; +} + +footer strong { + color: #aeb8c8; + font-weight: 750; +} + +.hidden { + display: none !important; +} + +@media (prefers-reduced-motion: reduce) { + .settings-button, + .settings-button svg, + .alternative-icon, + .alternative-chevron, + .progress-bar { + transition-duration: 0.01ms !important; + } +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..1e4ea0b --- /dev/null +++ b/popup.html @@ -0,0 +1,128 @@ + + + + + + RedGifs Video Downloader + + + +
+
+
RG
+
+

RedGifs Video Downloader

+

Прямая загрузка без аккаунта и телеметрии

+
+ +
+ + + + + +
+ +

+
+ + + +
Определяю путь сохранения…
+
+ + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..8a0f669 --- /dev/null +++ b/popup.js @@ -0,0 +1,640 @@ +"use strict"; + +const urlInput = document.getElementById("urlInput"); +const resolveButton = document.getElementById("resolveButton"); +const settingsButton = document.getElementById("settingsButton"); +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"); +const settingsStatus = document.getElementById("settingsStatus"); +const alternativeToggle = document.getElementById("alternativeToggle"); +const alternativePanel = document.getElementById("alternativePanel"); +const diagnosticButton = document.getElementById("diagnosticButton"); +const diagnosticStatus = document.getElementById("diagnosticStatus"); +const saveDestination = document.getElementById("saveDestination"); +const statusBox = document.getElementById("status"); +const collectionBox = document.getElementById("collection"); +const collectionTitle = document.getElementById("collectionTitle"); +const collectionCount = document.getElementById("collectionCount"); +const collectionItems = document.getElementById("collectionItems"); +const resultBox = document.getElementById("result"); +const thumbnail = document.getElementById("thumbnail"); +const mediaTitle = document.getElementById("mediaTitle"); +const formatsBox = document.getElementById("formats"); +const progressCard = document.getElementById("progressCard"); +const progressLabel = document.getElementById("progressLabel"); +const progressValue = document.getElementById("progressValue"); +const progressBar = document.getElementById("progressBar"); + +let currentMedia = null; +let pollingTimer = null; +let activeTabId = null; +let selectedProfileSlug = null; +let activeTabUrl = ""; +let alternativeScanned = false; +const DEFAULT_DOWNLOAD_SETTINGS = Object.freeze({ + baseFolder: "RedGifs", + groupByProfile: true, + saveAs: false +}); +let downloadSettings = Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS); +let diagnosticsEnabled = false; +let settingsStatusTimer = null; +let popupBlurTimer = null; +const panelAnimations = new WeakMap(); + +function panelIsExpanded(element) { + if (element.dataset.popupExpanded === "true") return true; + if (element.dataset.popupExpanded === "false") return false; + return !element.classList.contains("hidden"); +} + +function clearPanelMotionStyles(element) { + element.style.removeProperty("overflow"); + element.style.removeProperty("will-change"); + element.style.removeProperty("transform-origin"); +} + +function setPanelExpanded(element, expanded, options) { + const config = Object.assign({ duration: 260, offset: 8, scale: 0.985 }, options || {}); + const previous = panelAnimations.get(element); + if (previous) previous.cancel(); + clearPanelMotionStyles(element); + element.dataset.popupExpanded = String(expanded); + + const reduceMotion = typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (reduceMotion || typeof element.animate !== "function") { + element.classList.toggle("hidden", !expanded); + panelAnimations.delete(element); + return Promise.resolve(); + } + + if (expanded) element.classList.remove("hidden"); + if (!expanded && element.classList.contains("hidden")) return Promise.resolve(); + + const fullHeight = Math.max(1, Math.ceil(element.getBoundingClientRect().height)); + const computedStyle = window.getComputedStyle(element); + const expandedBox = { + marginTop: computedStyle.marginTop, + marginBottom: computedStyle.marginBottom, + paddingTop: computedStyle.paddingTop, + paddingBottom: computedStyle.paddingBottom, + borderTopWidth: computedStyle.borderTopWidth, + borderBottomWidth: computedStyle.borderBottomWidth + }; + element.style.overflow = "hidden"; + element.style.willChange = "height, margin, padding, border-width, opacity, transform"; + element.style.transformOrigin = "top center"; + + const collapsedFrame = { + height: "0px", + marginTop: "0px", + marginBottom: "0px", + paddingTop: "0px", + paddingBottom: "0px", + borderTopWidth: "0px", + borderBottomWidth: "0px", + opacity: 0, + transform: `translateY(-${config.offset}px) scale(${config.scale})` + }; + const expandedFrame = { + height: fullHeight + "px", + ...expandedBox, + opacity: 1, + transform: "translateY(0) scale(1)" + }; + const animation = element.animate( + expanded ? [collapsedFrame, expandedFrame] : [expandedFrame, collapsedFrame], + { + duration: config.duration, + easing: expanded ? "cubic-bezier(.22, 1, .36, 1)" : "cubic-bezier(.4, 0, .2, 1)" + } + ); + panelAnimations.set(element, animation); + + return animation.finished.catch(() => {}).then(() => { + if (panelAnimations.get(element) !== animation) return; + if (!expanded) element.classList.add("hidden"); + clearPanelMotionStyles(element); + panelAnimations.delete(element); + }); +} + +function reportPopupDiagnostic(level, event, details) { + if (!diagnosticsEnabled) return; + try { + const pending = chrome.runtime.sendMessage({ + action: "diagnosticEvent", + level, + area: "popup", + event, + details: details || {} + }); + if (pending && typeof pending.catch === "function") pending.catch(() => {}); + } catch { + // A failed logger must not replace the original UI error. + } +} + +function cleanFolderPath(value) { + return String(value || "") + .replace(/\\/g, "/") + .split("/") + .map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim()) + .filter((part) => part && part !== "." && part !== "..") + .slice(0, 8) + .join("/"); +} + +function profileFromUrl(value) { + try { + const match = new URL(value).pathname.match(/\/users\/([^/?#]+)/i); + return match && match[1] ? decodeURIComponent(match[1]) : null; + } catch { + return null; + } +} + +function diagnosticPageUrl(value) { + try { + const parsed = new URL(value); + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return String(value || "").slice(0, 300); + } +} + +function renderSaveDestination() { + saveDestination.textContent = ""; + if (downloadSettings.saveAs) { + saveDestination.textContent = "Для каждого видео Chrome спросит место сохранения."; + return; + } + + const parts = ["Загрузки"]; + const baseFolder = cleanFolderPath(downloadSettings.baseFolder); + if (baseFolder) parts.push(baseFolder); + const profile = profileFromUrl(activeTabUrl); + if (downloadSettings.groupByProfile && profile) parts.push(cleanFolderPath(profile)); + + saveDestination.appendChild(document.createTextNode("Видео сохраняются в: ")); + const strong = document.createElement("strong"); + strong.textContent = parts.join("/"); + saveDestination.appendChild(strong); +} + +function readSettingsForm() { + return { + baseFolder: cleanFolderPath(baseFolderInput.value), + groupByProfile: groupByProfileInput.checked, + saveAs: saveAsInput.checked + }; +} + +function renderSettingsPathPreview() { + const settings = readSettingsForm(); + if (settings.saveAs) { + settingsPathPreview.textContent = "Chrome спросит место сохранения"; + return; + } + const parts = ["Загрузки"]; + if (settings.baseFolder) parts.push(settings.baseFolder); + if (settings.groupByProfile) parts.push(profileFromUrl(activeTabUrl) || "profile"); + parts.push("video.mp4"); + settingsPathPreview.textContent = parts.join("/"); +} + +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; + settingsStatus.textContent = text || ""; + settingsStatus.className = "settings-status" + (kind ? " " + kind : ""); + if (text) { + settingsStatusTimer = setTimeout(() => { + settingsStatus.textContent = ""; + settingsStatusTimer = null; + }, 2600); + } +} + +async function saveDownloadSettings() { + const settings = readSettingsForm(); + const nextDiagnosticsEnabled = Boolean(diagnosticsEnabledInput.checked); + settingsSaveButton.disabled = true; + try { + 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) { + reportPopupDiagnostic("error", "settings-save-failed", { + name: error && error.name, + message: error && error.message + }); + showSettingsStatus(error && error.message ? error.message : "Не удалось сохранить настройки.", "error"); + } finally { + settingsSaveButton.disabled = false; + } +} + +function setStatus(text, kind) { + statusBox.textContent = text; + statusBox.className = "status" + (kind ? " " + kind : ""); +} + +function setBusy(busy) { + resolveButton.disabled = busy; + resolveButton.textContent = busy ? "Сканирую…" : "Сканировать"; +} + +function stopPolling() { + if (pollingTimer) { + clearTimeout(pollingTimer); + pollingTimer = null; + } +} + +function renderMedia(media) { + setPanelExpanded(collectionBox, false, { duration: 210, offset: 5 }); + currentMedia = media; + if (media.profileSlug) selectedProfileSlug = media.profileSlug; + renderSaveDestination(); + mediaTitle.textContent = media.title || media.id || "RedGifs video"; + formatsBox.textContent = ""; + + if (media.thumbnail) { + thumbnail.src = media.thumbnail; + thumbnail.classList.remove("hidden"); + resultBox.classList.remove("no-image"); + } else { + thumbnail.removeAttribute("src"); + thumbnail.classList.add("hidden"); + resultBox.classList.add("no-image"); + } + + media.formats.forEach((format, index) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "format-button"; + button.textContent = "Скачать · " + format.label; + button.addEventListener("click", () => downloadFormat(index, button)); + formatsBox.appendChild(button); + }); + setPanelExpanded(resultBox, true, { duration: 240, offset: 6 }); +} + +function renderCollection(collection) { + currentMedia = null; + setPanelExpanded(resultBox, false, { duration: 210, offset: 5 }); + collectionItems.textContent = ""; + collectionTitle.textContent = collection.title || "Видимые ролики"; + collectionCount.textContent = collection.items.length + " шт."; + + collection.items.forEach((item) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "collection-item"; + + if (item.thumbnail) { + const image = document.createElement("img"); + image.className = "collection-thumb"; + image.src = item.thumbnail; + image.alt = ""; + button.appendChild(image); + } else { + button.classList.add("no-image"); + } + + const copy = document.createElement("span"); + copy.className = "collection-copy"; + const title = document.createElement("strong"); + title.textContent = item.title || item.id || "RedGifs video"; + const id = document.createElement("small"); + id.textContent = item.id ? "ID: " + item.id : "Прямой источник"; + copy.appendChild(title); + copy.appendChild(id); + button.appendChild(copy); + button.addEventListener("click", async () => { + const target = item.watchUrl || item.directVideoUrl; + if (!target) return; + selectedProfileSlug = item.profileSlug || null; + urlInput.value = target; + await resolveCurrentUrl(); + }); + collectionItems.appendChild(button); + }); + setPanelExpanded(collectionBox, true, { duration: 240, offset: 6 }); +} + +async function resolveCurrentUrl() { + const value = urlInput.value.trim(); + if (!value) { + setStatus("Вставьте ссылку на страницу ролика.", "error"); + return; + } + stopPolling(); + setPanelExpanded(progressCard, false, { duration: 190, offset: 4 }); + setPanelExpanded(resultBox, false, { duration: 210, offset: 5 }); + setPanelExpanded(collectionBox, false, { duration: 210, offset: 5 }); + currentMedia = null; + setBusy(true); + setStatus("Получаю временный токен и адреса видео…"); + try { + const response = await chrome.runtime.sendMessage({ action: "resolve", url: value, tabId: activeTabId }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Нет ответа от расширения."); + } + if (response.media.type === "collection") { + renderCollection(response.media); + setStatus("Найдены видимые ролики. Выберите нужный.", "success"); + } else { + renderMedia(response.media); + setStatus("Найдено форматов: " + response.media.formats.length, "success"); + } + } catch (error) { + reportPopupDiagnostic("error", "resolve-failed", { + url: value, + name: error && error.name, + message: error && error.message + }); + setStatus(error && error.message ? error.message : String(error), "error"); + } finally { + setBusy(false); + } +} + +async function downloadFormat(index, button) { + if (!currentMedia || !currentMedia.formats[index]) return; + const format = currentMedia.formats[index]; + button.disabled = true; + setStatus(format.kind === "hls" ? "Запускаю локальную сборку HLS…" : "Запускаю локальную загрузку…"); + try { + const response = await chrome.runtime.sendMessage({ + action: "download", + format, + title: currentMedia.title, + id: currentMedia.id, + sourceUrl: currentMedia.sourceUrl, + profileSlug: selectedProfileSlug, + tabId: activeTabId + }); + if (!response || !response.ok) { + throw new Error(response && response.error ? response.error : "Загрузка не запущена."); + } + if (response.result.jobId) { + setPanelExpanded(progressCard, true, { duration: 220, offset: 5 }); + setStatus(response.result.mode === "hls" ? "HLS загружается и собирается локально." : "Видео загружается локально.", "success"); + pollJob(response.result.jobId); + } else { + setStatus("Загрузка передана Chrome: " + response.result.filename, "success"); + } + } catch (error) { + reportPopupDiagnostic("error", "download-start-failed", { + mediaId: currentMedia && currentMedia.id, + format: format && format.label, + name: error && error.name, + message: error && error.message + }); + setStatus(error && error.message ? error.message : String(error), "error"); + } finally { + button.disabled = false; + } +} + +async function pollJob(jobId) { + stopPolling(); + try { + const response = await chrome.runtime.sendMessage({ action: "getJob", jobId }); + if (!response || !response.ok || !response.job) { + throw new Error("Состояние HLS-задачи потеряно."); + } + const job = response.job; + const progress = Math.max(0, Math.min(100, Math.round(Number(job.progress) || 0))); + progressLabel.textContent = job.status || "Обработка HLS…"; + progressValue.textContent = progress + "%"; + progressBar.style.width = progress + "%"; + if (job.state === "complete") { + setStatus("Готово: " + (job.filename || "файл передан Chrome"), "success"); + return; + } + if (job.state === "error") throw new Error(job.error || "Ошибка обработки HLS."); + pollingTimer = setTimeout(() => pollJob(jobId), 500); + } catch (error) { + reportPopupDiagnostic("error", "job-poll-failed", { + jobId, + name: error && error.name, + message: error && error.message + }); + setStatus(error && error.message ? error.message : String(error), "error"); + } +} + +resolveButton.addEventListener("click", () => { + selectedProfileSlug = null; + resolveCurrentUrl(); +}); +urlInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + selectedProfileSlug = null; + resolveCurrentUrl(); + } +}); +settingsButton.addEventListener("click", () => { + const opening = !panelIsExpanded(settingsPanel); + setPanelExpanded(settingsPanel, opening, { duration: 300, offset: 10, scale: 0.98 }); + settingsButton.setAttribute("aria-expanded", String(opening)); + settingsButton.setAttribute("aria-label", opening ? "Скрыть настройки" : "Открыть настройки"); + settingsButton.title = opening ? "Скрыть настройки" : "Настройки"; + if (opening) renderSettingsPathPreview(); +}); +window.addEventListener("focus", () => { + if (popupBlurTimer) clearTimeout(popupBlurTimer); + popupBlurTimer = null; +}); +window.addEventListener("blur", () => { + if (popupBlurTimer) clearTimeout(popupBlurTimer); + popupBlurTimer = setTimeout(() => { + popupBlurTimer = null; + if (!document.hasFocus()) window.close(); + }, 140); +}); +requestAnimationFrame(() => { + try { + window.focus(); + } catch { + // Browser-native popup focus remains the fallback. + } +}); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") window.close(); +}); +baseFolderInput.addEventListener("input", renderSettingsPathPreview); +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 = "Собираю диагностический лог…"; + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = tabs && tabs[0]; + if (!tab || !/^https:\/\/(?:[^/]+\.)?redgifs\.com\//i.test(tab.url || "")) { + throw new Error("Откройте вкладку RedGifs и повторите."); + } + let pageDiagnostics = null; + let pageError = null; + let runtimeDiagnostics = null; + let runtimeError = null; + try { + const response = await chrome.tabs.sendMessage(tab.id, { action: "captureExtensionDiagnostics" }); + if (!response || !response.ok || !response.report) { + throw new Error(response && response.error ? response.error : "Снимок страницы не получен."); + } + pageDiagnostics = response.report; + } catch (error) { + pageError = error && error.message ? error.message : String(error); + } + try { + const response = await chrome.runtime.sendMessage({ action: "getDiagnosticLog" }); + if (!response || !response.ok || !response.diagnostics) { + throw new Error(response && response.error ? response.error : "Журнал фонового процесса не получен."); + } + runtimeDiagnostics = response.diagnostics; + } catch (error) { + runtimeError = error && error.message ? error.message : String(error); + } + if (!pageDiagnostics && !runtimeDiagnostics) { + throw new Error("Диагностический лог не получен. Обновите вкладку RedGifs."); + } + const report = { + schemaVersion: 2, + generatedAt: new Date().toISOString(), + extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null, + activeTab: { id: tab.id, url: diagnosticPageUrl(tab.url) }, + collectionErrors: { page: pageError, runtime: runtimeError }, + pageDiagnostics, + runtimeDiagnostics + }; + const saveResponse = await chrome.runtime.sendMessage({ + action: "downloadDiagnosticReport", + report + }); + if (!saveResponse || !saveResponse.ok || !saveResponse.result) { + throw new Error(saveResponse && saveResponse.error + ? saveResponse.error + : "Фоновый процесс не смог сохранить диагностический лог."); + } + diagnosticStatus.className = "diagnostic-status success"; + const runtimeEvents = runtimeDiagnostics && Array.isArray(runtimeDiagnostics.events) + ? runtimeDiagnostics.events + : []; + const errorCount = runtimeEvents.filter((entry) => entry.level === "error").length; + diagnosticStatus.textContent = "Лог сохранён: событий — " + runtimeEvents.length + ", ошибок — " + errorCount + "."; + } catch (error) { + diagnosticStatus.className = "diagnostic-status error"; + diagnosticStatus.textContent = error && error.message ? error.message : String(error); + } finally { + diagnosticButton.disabled = !diagnosticsEnabled; + } +}); +alternativeToggle.addEventListener("click", async () => { + const opening = !panelIsExpanded(alternativePanel); + alternativeToggle.setAttribute("aria-expanded", String(opening)); + if (!opening) { + stopPolling(); + await setPanelExpanded(alternativePanel, false, { duration: 260, offset: 8, scale: 0.985 }); + return; + } + await setPanelExpanded(alternativePanel, true, { duration: 300, offset: 10, scale: 0.98 }); + if (!alternativeScanned && urlInput.value.trim()) { + alternativeScanned = true; + await resolveCurrentUrl(); + } +}); + +(async () => { + writeSettingsForm(downloadSettings); + renderDiagnosticControls(); + try { + 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 || "")) { + activeTabId = tab.id; + activeTabUrl = tab.url; + urlInput.value = tab.url; + } + renderSaveDestination(); + renderSettingsPathPreview(); + } catch { + writeSettingsForm(downloadSettings); + renderSaveDestination(); + } +})(); + +chrome.storage.onChanged.addListener((changes, areaName) => { + 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(); + } +}); diff --git a/.nojekyll b/website/.nojekyll similarity index 100% rename from .nojekyll rename to website/.nojekyll diff --git a/404.html b/website/404.html similarity index 91% rename from 404.html rename to website/404.html index 2a748af..385d468 100644 --- a/404.html +++ b/website/404.html @@ -69,7 +69,7 @@ 404

Страница не найдена

Ссылка не работает или страница переехала.

- + Вернуться на главную @@ -77,11 +77,7 @@