From 580fd4f5b909be42ef2fcf5d5c95e22bb7132934 Mon Sep 17 00:00:00 2001 From: OwnerLink Date: Sun, 11 Oct 2026 01:03:40 +0300 Subject: [PATCH] Publish RedGifs 0.6.23 and migrate project website to Gitea --- .gitattributes | 4 + .gitignore | 23 + CNAME | 1 - LICENSE | 61 + PRIVACY.md | 42 + README.md | 128 +- RELEASE_NOTES.md | 56 + background.js | 1171 ++++++++++ content.js | 2032 +++++++++++++++++ {assets/img => docs}/release-preview.png | Bin {assets/img => icons}/icon-128.png | Bin icons/icon-16.png | Bin 0 -> 752 bytes .../img/favicon-32.png => icons/icon-32.png | Bin icons/icon-48.png | Bin 0 -> 3009 bytes {assets/img => icons}/icon.svg | 0 manifest.json | 47 + offscreen.html | 10 + offscreen.js | 559 +++++ options.css | 195 ++ options.html | 54 + options.js | 76 + popup.css | 706 ++++++ popup.html | 128 ++ popup.js | 640 ++++++ .nojekyll => website/.nojekyll | 0 404.html => website/404.html | 8 +- website/README.md | 14 + {assets => website/assets}/css/style.css | 0 website/assets/img/favicon-32.png | Bin 0 -> 1810 bytes website/assets/img/icon-128.png | Bin 0 -> 9419 bytes website/assets/img/icon.svg | 24 + website/assets/img/release-preview.png | Bin 0 -> 1490314 bytes {assets => website/assets}/js/main.js | 4 +- index.html => website/index.html | 26 +- license.html => website/license.html | 12 +- privacy.html => website/privacy.html | 0 robots.txt => website/robots.txt | 2 +- sitemap.xml => website/sitemap.xml | 6 +- 38 files changed, 5987 insertions(+), 42 deletions(-) create mode 100644 .gitattributes create mode 100644 .gitignore delete mode 100644 CNAME create mode 100644 LICENSE create mode 100644 PRIVACY.md create mode 100644 RELEASE_NOTES.md create mode 100644 background.js create mode 100644 content.js rename {assets/img => docs}/release-preview.png (100%) rename {assets/img => icons}/icon-128.png (100%) create mode 100644 icons/icon-16.png rename assets/img/favicon-32.png => icons/icon-32.png (100%) create mode 100644 icons/icon-48.png rename {assets/img => icons}/icon.svg (100%) create mode 100644 manifest.json create mode 100644 offscreen.html create mode 100644 offscreen.js create mode 100644 options.css create mode 100644 options.html create mode 100644 options.js create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js rename .nojekyll => website/.nojekyll (100%) rename 404.html => website/404.html (91%) create mode 100644 website/README.md rename {assets => website/assets}/css/style.css (100%) create mode 100644 website/assets/img/favicon-32.png create mode 100644 website/assets/img/icon-128.png create mode 100644 website/assets/img/icon.svg create mode 100644 website/assets/img/release-preview.png rename {assets => website/assets}/js/main.js (98%) rename index.html => website/index.html (96%) rename license.html => website/license.html (94%) rename privacy.html => website/privacy.html (100%) rename robots.txt => website/robots.txt (65%) rename sitemap.xml => website/sitemap.xml (67%) 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 0000000000000000000000000000000000000000..68a94348ca84f6e900f0041d97071e82a7bd1048 GIT binary patch literal 752 zcmVmqDe$SR5;6RlTV11WfaDL@B4k<_5SIOrfCRbiy=ydATra9Z9>Qv z79j{NTD1&ZL=Y*0y3kFyiV7kWN@^kgK_v*$!kwETkSKy-9Beclo7=tD?|$EV&eP)F z8C-PWY|i0#&ck^)fhVFm_}TWXneB727J&n}t@J&y=)>WK&R2(SE!P5o_V3SZ^RDm1 zzoU2P4WisotBD#p3K5y1s>X*EDxzz3`|49)e!4G!MS%LbonORzOYibeFRF-W(+0GD z4Qe-0BSU#aMS+?*N)~nZ;amL=j(!dh^!kxz+S*x+4xi>=Y9<#JfJ8r6=H;fqVeHC=+V0=xNSh_G_hx&P|h zTl2~vcbf{RQ2-HDHFj?i4NH(Cz}=96+>t@@not{MU)?;#7?d3H0w`U26_MRQ|PM*QcNG8l2xs!k#61cG509isVs>?T#IjxW01E6>E4E>8Y zFcW5m;b0CxP9I#Xz{@t3mu}*#3S6Rp;re9r7`BuMX`)~uTbiHFx{_OPgW`=HWZS04 zM{^MoQy;&NB!)A;@#y?@EQzcUb@$H)bMd8bPR)(7Fz?GYi4|CkkLVKa6Bm+5$x)}6 zeRJmYd=A*}3k%1$Y+QfJ?mTD>ul#~t`kAyW*My}+Vho#!_Vk9wxBvd@Fp$FJsoLMY za$xW77p6bS%WAV*%G@18coJ3%M<~crGoQUSGk@a4xpS9+GCc9kjqR|h!Shp&NB$=w i(JTJw-GVzn4e%ddrtnUG(>p-`0000vb4f%&RA}DSnrnOf2d^Cq zVPY(-fGpcFk}a9YACedu1BL`_CHW8w+Xyg$fGk-;!3ZoF*$63#)}RDiA_hNkEF0E5 zb_Zr=t$pB~XZQ4@y6!#sP`934GrJ3XNsmUV?y9c(oqPW0f6uLI_<#OVZ{$mI(@izp z-Fwj8p`!i$(>X~ebv~ur|U;R7>aKmZ~6# zWWp-k1hU6vzl1~w3y2?dP0xQd=yVPofBogV&wupamH=bVC}$FY0-R^|-7>WQW3zXb z8nw?7n5wKaq1geyhxh@Ay}X4I1WXPJ2V8|X4j6+N0j;60dM|yRCsK zxm~@o{@{l5Ha8fNT&x~AL;txetUj*a+K1p?GWhs`J=}#-}~^_766?O zLKVRu`|E$&ch>CePn@pbVEzQ6O$cI4l6Xl4;d!X>5oEX;#$h3oZ8HcjfH9flWut>> zwIK+?@pc``Z=Z#s%G%Mzh4=4z`kq~X{H)XIPx0FEFy+|P=i$8) zYH|!UHI9_sL_T?Vdk!=R1@QJbJQp=oL6nOGeb9#B*b?q&y*e^6@rn12yWjrqE3f$J z0Gzbu0q?4r_kPDQ>|o(UCZ$mwg`Wvhuc2n9k$NqJ(h|aJL5v_qhq5uo!b^CZF`$h- zMnH7<9uvw@8&su)8n1&_0-*ci0!r9^*?Av)2=FYcBDE1p=X3vXk=LkySbqO{eBrd- zB`Vg*QPlJ{L|K7bXtczm9!kZ2rmcju3A1EDrQ1Q)+L-kgvf9Edts`qKq}f86-LQsg z6$muFmli3H*FW;|)nB;CDn|jTl*&YD+r;*-$$Hymqgz<40J5!tYK)|G9F3$6Q0s8J z=25!rVgw=h?_WT(9TsZ4i}d7BO$AGfZgOObpo zg|b_#?&s*z=I~>6)Mz8ov9`yFq>43kD(}-*;Jj;JsL!zxdSCr^%CXY2JT-iDT>t12 z-ku%ryA;Y#LjagaLhgfq^W{Hv%HD`HTZJ|$6%RE&8jifN5*UCNkWlYxW;%8UXV=+p z8eTL6ORG4Qa^qWn@we}aWE2N5;f-D-ejoZKahiCd0<~={c9=--F|~G3KrQl5pUPlfU#-D~43ojT#Af=eOR6>G+gA&WjAl0o0Y?DyYZKk!}kV(5PEi zA$AwBQV}r_7|e-fWPJm~6!J@iV4}9ha7Y*d3nL=`n;s$R1; znSzfp(Dp<4s1+z~M$fod!w^k8>QT-_hg(KOX*-dX(*01R!{n3-yz-z;ih)i7pNuSt z(-bsXrvopkk{TLco@n9cNMd`gJ&a&WUYuKt&P!1+&!e{vz(oG^#Po@Qt4eFJ~f|M0y{D7a}R^ zO#+DEqcrzT-20f7^_)$baN>&WpkAYP`*k?`cE(ni55c`)mik>cqUyEea%-&|Y`%D- zZG*Azbx{T~ja%HS*i}=j4Sq1-G~GKNMp~Ur5JR_LkGpSn0KW)cT5Z<8^1vXxPT&m& zqoY}2D+%e;0Z~C}9Tsl0opl=PB<7_>I=9{*?~jAH>-u;#1%Owge#iAW_ztT#-%I~@ z3j^?WeHKn;I^AHHt&5o4EG*qVbbEHmH`tH~f? ze-HliuV!r0Soj!LOAg*xGca2fanf{&-^a8%@mhAXhD;wi!p4`rnUNDJH4i%~Bb(Km z?xy$roJC3(Zyufuyaa=HxSYbiF>)He4?%wukIyz_`o(!RZn-ZTME=aJHY+#%3%wU! z%Sd7XHuv6Oq7~>CH4v??PGf?Sf^JfP;H7z5xBP2i5D~op!fRXGWL&&N=&6wp>(3Mg zU{VE=b{aXurC)Gxf#w(Q4ez)`=j-~c+$S` z{a5B$`@&uHUznr+!W=8t-GTr7+<;A*luhP~RRU9>|0Fw^UZ@mFO{r`{md=1Ssowt7 z4O#!?c~-8yJ=2hhb((Ck4%%cE9wbQI38g@^DcMmgMZ?!K<`1gmz9s9{d^c%Mvslwk zV^Z+x0G+Z&uje0+#tRVkiQp?~rZ%LmThwd3)+eO#b&DKv!L z+n>DkhNn2WHsQ}7d&BG2q&}nqmCe0bbn;`d{47{N>Juo0KhWGWje#zB)P1+y3mG zyn1-0m_82;K~|!e&#OlmJvoQJb0MZTsIzb!x2hzt0`K|Hdc&>wyk) zD-Bq)qaQ?5s>(g&E7^jsFTvOlyT{%=G(P#|tFO3WYI^DeZnZKLEU%-xJxtdRdl-|H zd9+S2Xpb6{*w2)5aB5Xb!xgF%qxjuk`}mRL&)o6kkMBFwUS4W*cy$}BJ`F9P zX9JL;$E;4|O8a5B2_p^8Z}iZ01J&F9{E~|=+&=SxO0_&ua!aEks3OaZ8?L}4Gy35C zo7`zrBEI(5dh7nlgU60Md&e)H`<098lyNrJnOh%$=Fefn!s~QDS2UeQL3kg6+8PY? zU}%{0hc#6C=(2<33aEZK5x(h{hcXJpV6=zpmr&gnhualsk3##0;V&!tdHAhDje}euMC-C+z}I0aFEYAROiNzuD|}5&-`PwEm0C>2JOL00000NkvXXu0mjf D + + + + 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 @@