Publish RedGifs 0.6.23 and migrate project website to Gitea
This commit is contained in:
1 parent
01e0debd76
commit
580fd4f5b9
38 files changed
+5987
-42
No files matched your search
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.zip binary
|
||||
*.crx binary
|
||||
+23
@@ -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
|
||||
@@ -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.
|
||||
+42
@@ -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.
|
||||
@@ -1,14 +1,122 @@
|
||||
# RedGifs Video Downloader — сайт-витрина
|
||||
# RedGifs Video Downloader
|
||||
|
||||
Статическая страница-презентация расширения **RedGifs Video Downloader**.
|
||||
Без сборки, без зависимостей и без бэкенда: HTML, CSS и один файл JavaScript.
|
||||

|
||||
|
||||
**Сайт:** 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/).
|
||||
@@ -0,0 +1,56 @@
|
||||
# RedGifs Video Downloader 0.6.23
|
||||
|
||||

|
||||
|
||||
## Исправления 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
|
||||
```
|
||||
+1171
File diff suppressed because it is too large.
Load diff
+2032
File diff suppressed because it is too large.
Load diff
File renamed without changes.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 752 B |
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
File renamed without changes.
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "RedGifs Video Downloader",
|
||||
"version": "0.6.23",
|
||||
"description": "Downloads RedGifs media directly and processes HLS locally. No account or telemetry.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"downloads",
|
||||
"offscreen",
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabs"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://redgifs.com/*",
|
||||
"https://*.redgifs.com/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://redgifs.com/*",
|
||||
"https://*.redgifs.com/*"
|
||||
],
|
||||
"js": [
|
||||
"content.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
"default_title": "RedGifs Video Downloader",
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"32": "icons/icon-32.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Local HLS worker</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="offscreen.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+559
@@ -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;
|
||||
});
|
||||
+195
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Настройки RedGifs Video Downloader</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="settings-shell">
|
||||
<header>
|
||||
<div class="settings-mark">RG</div>
|
||||
<div>
|
||||
<h1>Настройки RedGifs Video Downloader</h1>
|
||||
<p>Папки сохранения и поведение загрузчика</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="settings-card">
|
||||
<label for="baseFolder">Подпапка внутри «Загрузок»</label>
|
||||
<input id="baseFolder" type="text" maxlength="100" placeholder="RedGifs" autocomplete="off">
|
||||
<p class="help">Можно использовать вложенный путь, например <code>Видео/RedGifs</code>. Абсолютную папку расширение задавать не может — корневая папка определяется настройками Chrome.</p>
|
||||
|
||||
<label class="check-row">
|
||||
<input id="groupByProfile" type="checkbox">
|
||||
<span>
|
||||
<strong>Группировать по профилям</strong>
|
||||
<small>Для страницы <code>/users/name</code> файл попадёт в подпапку <code>name</code>.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="check-row">
|
||||
<input id="saveAs" type="checkbox">
|
||||
<span>
|
||||
<strong>Всегда спрашивать место сохранения</strong>
|
||||
<small>Chrome покажет системный диалог для каждого файла.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="preview">
|
||||
<span>Пример пути</span>
|
||||
<strong id="pathPreview">Загрузки/RedGifs/profile/video.mp4</strong>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="resetButton" class="secondary" type="button">По умолчанию</button>
|
||||
<button id="saveButton" class="primary" type="button">Сохранить</button>
|
||||
</div>
|
||||
<div id="status" class="status" aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+76
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RedGifs Video Downloader</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header>
|
||||
<div class="mark">RG</div>
|
||||
<div class="header-copy">
|
||||
<h1>RedGifs Video Downloader</h1>
|
||||
<p>Прямая загрузка без аккаунта и телеметрии</p>
|
||||
</div>
|
||||
<button id="settingsButton" class="settings-button" type="button" title="Настройки" aria-label="Открыть настройки" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section id="settingsPanel" class="settings-panel hidden" aria-label="Настройки сохранения">
|
||||
<div class="settings-panel-head">
|
||||
<div>
|
||||
<h2>Настройки сохранения</h2>
|
||||
<p>Применяются ко всем новым загрузкам</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="settings-field-label" for="baseFolder">Подпапка внутри «Загрузок»</label>
|
||||
<input id="baseFolder" class="settings-text-input" type="text" maxlength="100" placeholder="RedGifs" autocomplete="off">
|
||||
<p class="settings-help">Можно использовать вложенный путь, например <code>Видео/RedGifs</code>. Корневая папка задаётся в Chrome.</p>
|
||||
|
||||
<label class="settings-check-row">
|
||||
<input id="groupByProfile" type="checkbox">
|
||||
<span>
|
||||
<strong>Группировать по профилям</strong>
|
||||
<small>Создавать подпапку с именем автора ролика.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="settings-check-row">
|
||||
<input id="saveAs" type="checkbox">
|
||||
<span>
|
||||
<strong>Всегда спрашивать место сохранения</strong>
|
||||
<small>Chrome покажет системный диалог для каждого файла.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="settings-check-row">
|
||||
<input id="diagnosticsEnabled" type="checkbox">
|
||||
<span>
|
||||
<strong>Включить диагностику</strong>
|
||||
<small>Временно собирать данные для диагностического лога.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="settings-preview">
|
||||
<span>Пример пути</span>
|
||||
<strong id="settingsPathPreview">Загрузки/RedGifs/profile/video.mp4</strong>
|
||||
</div>
|
||||
|
||||
<div class="settings-form-actions">
|
||||
<button id="settingsResetButton" class="settings-secondary" type="button">По умолчанию</button>
|
||||
<button id="settingsSaveButton" class="settings-primary" type="button">Сохранить</button>
|
||||
</div>
|
||||
<div id="settingsStatus" class="settings-status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<button id="alternativeToggle" class="alternative-toggle" type="button" aria-expanded="false">
|
||||
<span class="alternative-icon">⇄</span>
|
||||
<span class="alternative-label">Активировать альтернативный способ загрузки</span>
|
||||
<span class="alternative-chevron">⌄</span>
|
||||
</button>
|
||||
|
||||
<section class="diagnostic-tools">
|
||||
<button id="diagnosticButton" class="diagnostic-button" type="button">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3h8l1 3h3v14H4V6h3l1-3Z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
<span>Скачать диагностический лог</span>
|
||||
</button>
|
||||
<p id="diagnosticStatus" class="diagnostic-status"></p>
|
||||
</section>
|
||||
|
||||
<section id="alternativePanel" class="alternative-panel hidden">
|
||||
<label class="field-label" for="urlInput">Открытая страница RedGifs</label>
|
||||
<div class="input-row">
|
||||
<input id="urlInput" type="url" placeholder="https://www.redgifs.com/users/..." autocomplete="off">
|
||||
<button id="resolveButton" class="primary" type="button">Сканировать</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status" aria-live="polite">Откройте профиль или ленту RedGifs — расширение найдёт видимые ролики.</div>
|
||||
|
||||
<section id="collection" class="collection hidden">
|
||||
<div class="collection-head">
|
||||
<h2 id="collectionTitle">Видимые ролики</h2>
|
||||
<span id="collectionCount"></span>
|
||||
</div>
|
||||
<div id="collectionItems" class="collection-items"></div>
|
||||
<p class="collection-hint">Нужного ролика нет? Закройте popup, прокрутите профиль ниже и откройте расширение снова.</p>
|
||||
</section>
|
||||
|
||||
<section id="result" class="result hidden">
|
||||
<img id="thumbnail" class="thumbnail hidden" alt="">
|
||||
<div class="media-copy">
|
||||
<h2 id="mediaTitle"></h2>
|
||||
<div id="formats" class="formats"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="progressCard" class="progress-card hidden">
|
||||
<div class="progress-head">
|
||||
<span id="progressLabel">Подготовка…</span>
|
||||
<span id="progressValue">0%</span>
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div id="progressBar" class="progress-bar"></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer id="saveDestination">Определяю путь сохранения…</footer>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
File renamed without changes.
@@ -69,7 +69,7 @@
|
||||
<b>404</b>
|
||||
<h1 id="title">Страница не найдена</h1>
|
||||
<p id="text">Ссылка не работает или страница переехала.</p>
|
||||
<a id="home" href="/">
|
||||
<a id="home" href="/extention/redgifs/">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5"/><path d="m11 6-6 6 6 6"/></svg>
|
||||
<span id="cta">Вернуться на главную</span>
|
||||
</a>
|
||||
@@ -77,11 +77,7 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// On a github.io project page the site root is /<repo>/, not /.
|
||||
var host = location.hostname;
|
||||
var seg = location.pathname.split('/').filter(Boolean);
|
||||
var isProjectPage = /\.github\.io$/i.test(host) && seg.length > 0;
|
||||
document.getElementById('home').setAttribute('href', isProjectPage ? '/' + seg[0] + '/' : '/');
|
||||
document.getElementById('home').setAttribute('href', '/extention/redgifs/');
|
||||
|
||||
// Match the language preference chosen on the rest of the site.
|
||||
var lang = null;
|
||||
@@ -0,0 +1,14 @@
|
||||
# RedGifs Video Downloader — сайт-витрина
|
||||
|
||||
Статическая страница-презентация расширения **RedGifs Video Downloader**.
|
||||
Без сборки, без зависимостей и без бэкенда: HTML, CSS и один файл JavaScript.
|
||||
|
||||
**Сайт:** https://git.lonarpgproxy.space/extention/redgifs/
|
||||
**Само расширение:** https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader
|
||||
**Скачать сборку:** https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/releases/latest
|
||||
|
||||
## Лицензия
|
||||
|
||||
Материалы сайта относятся к проекту RedGifs Video Downloader,
|
||||
© 2026 RedGifs Video Downloader, все права защищены. Условия — в
|
||||
[LICENSE расширения](https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/src/branch/main/LICENSE).
|
||||
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="RedGifs Video Downloader">
|
||||
<defs>
|
||||
<linearGradient id="surface" x1="20" y1="15" x2="108" y2="116" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#ff5a7c"/>
|
||||
<stop offset="0.55" stop-color="#ee3e67"/>
|
||||
<stop offset="1" stop-color="#c9234d"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shine" x1="31" y1="17" x2="81" y2="86" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#ffffff" stop-opacity="0.24"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="5" stdDeviation="5" flood-color="#350814" flood-opacity="0.36"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="7" y="5" width="114" height="114" rx="32" fill="url(#surface)" filter="url(#shadow)"/>
|
||||
<rect x="8.5" y="6.5" width="111" height="111" rx="30.5" fill="none" stroke="#ff9caf" stroke-width="3" stroke-opacity="0.72"/>
|
||||
<path d="M22 44C30 21 49 10 72 10c15 0 28 5 38 14C91 19 64 24 43 43c-10 9-17 20-22 32-3-10-3-21 1-31Z" fill="url(#shine)"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="10" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M64 30v48"/>
|
||||
<path d="m45 61 19 19 19-19"/>
|
||||
<path d="M39 96h50"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -20,13 +20,13 @@
|
||||
JavaScript disabled; this only rewrites them when REPO_URL differs.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
var REPO_URL = 'https://github.com/OwnerLink/redgifs-video-downloader';
|
||||
var REPO_URL = 'https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader';
|
||||
|
||||
function initLinks() {
|
||||
var targets = {
|
||||
repo: REPO_URL,
|
||||
release: REPO_URL + '/releases/latest',
|
||||
license: REPO_URL + '/blob/main/LICENSE'
|
||||
license: REPO_URL + '/src/branch/main/LICENSE'
|
||||
};
|
||||
|
||||
var nodes = document.querySelectorAll('[data-link]');
|
||||
@@ -11,7 +11,7 @@
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<link rel="canonical" href="https://redgifs.extension.lonarpgproxy.space/">
|
||||
<link rel="canonical" href="https://git.lonarpgproxy.space/extention/redgifs/">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/img/favicon-32.png">
|
||||
<link rel="icon" type="image/svg+xml" href="assets/img/icon.svg">
|
||||
<link rel="apple-touch-icon" href="assets/img/icon-128.png">
|
||||
@@ -20,12 +20,12 @@
|
||||
<meta property="og:site_name" content="RedGifs Video Downloader">
|
||||
<meta property="og:title" content="RedGifs Video Downloader — локальный загрузчик для Chromium">
|
||||
<meta property="og:description" content="Кнопка загрузки прямо на карточках RedGIFS, локальная сборка MP4, GIF и HLS. Без аккаунта, аналитики и телеметрии.">
|
||||
<meta property="og:url" content="https://redgifs.extension.lonarpgproxy.space/">
|
||||
<meta property="og:image" content="https://redgifs.extension.lonarpgproxy.space/assets/img/release-preview.png">
|
||||
<meta property="og:url" content="https://git.lonarpgproxy.space/extention/redgifs/">
|
||||
<meta property="og:image" content="https://git.lonarpgproxy.space/extention/redgifs/assets/img/release-preview.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="RedGifs Video Downloader">
|
||||
<meta name="twitter:description" content="Локальный загрузчик видео для Chromium. Без аккаунта, аналитики и телеметрии.">
|
||||
<meta name="twitter:image" content="https://redgifs.extension.lonarpgproxy.space/assets/img/release-preview.png">
|
||||
<meta name="twitter:image" content="https://git.lonarpgproxy.space/extention/redgifs/assets/img/release-preview.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
@@ -60,7 +60,7 @@
|
||||
<button type="button" data-set-lang="ru" aria-pressed="true">RU</button>
|
||||
<button type="button" data-set-lang="en" aria-pressed="false">EN</button>
|
||||
</div>
|
||||
<a class="btn btn-primary btn-sm" href="https://github.com/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener">
|
||||
<a class="btn btn-primary btn-sm" href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M4 20h16"/></svg>
|
||||
<span data-en="Download">Скачать</span>
|
||||
</a>
|
||||
@@ -102,7 +102,7 @@
|
||||
</p>
|
||||
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary" href="https://github.com/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener">
|
||||
<a class="btn btn-primary" href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M4 20h16"/></svg>
|
||||
<span data-en="Download the release">Скачать релиз</span>
|
||||
</a>
|
||||
@@ -113,7 +113,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hero-meta">
|
||||
<span data-en="Manual install from GitHub Release">Ручная установка из GitHub Release</span>
|
||||
<span data-en="Manual install from Gitea Release">Ручная установка из Gitea Release</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span><strong data-en="Not in the Chrome Web Store">Не в Chrome Web Store</strong></span>
|
||||
<span aria-hidden="true">·</span>
|
||||
@@ -389,8 +389,8 @@
|
||||
<article class="step reveal">
|
||||
<div class="step-num">1</div>
|
||||
<h3 data-en="Download and unpack">Скачайте и распакуйте</h3>
|
||||
<p data-en="Take the ZIP from the latest GitHub Release and unpack it into a permanent folder — do not delete it afterwards.">
|
||||
Возьмите ZIP из последнего GitHub Release и распакуйте его в постоянную папку —
|
||||
<p data-en="Take the ZIP from the latest Gitea Release and unpack it into a permanent folder — do not delete it afterwards.">
|
||||
Возьмите ZIP из последнего Gitea Release и распакуйте его в постоянную папку —
|
||||
удалять её после установки нельзя.
|
||||
</p>
|
||||
</article>
|
||||
@@ -641,11 +641,11 @@
|
||||
открытой странице RedGIFS.
|
||||
</p>
|
||||
<div class="cta-actions">
|
||||
<a class="btn btn-primary" href="https://github.com/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener">
|
||||
<a class="btn btn-primary" href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M4 20h16"/></svg>
|
||||
<span data-en="Download v0.6.23">Скачать v0.6.23</span>
|
||||
</a>
|
||||
<a class="btn btn-ghost" href="https://github.com/OwnerLink/redgifs-video-downloader" data-link="repo" rel="noopener">
|
||||
<a class="btn btn-ghost" href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader" data-link="repo" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48l-.01-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.36 1.09 2.94.83.09-.65.35-1.09.63-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.6 9.6 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85l-.01 2.75c0 .26.18.58.69.48A10 10 0 0 0 12 2Z"/></svg>
|
||||
<span data-en="View the repository">Открыть репозиторий</span>
|
||||
</a>
|
||||
@@ -690,8 +690,8 @@
|
||||
<div class="footer-col">
|
||||
<h5 data-en="Resources">Материалы</h5>
|
||||
<ul>
|
||||
<li><a href="https://github.com/OwnerLink/redgifs-video-downloader" data-link="repo" rel="noopener" data-en="GitHub repository">Репозиторий GitHub</a></li>
|
||||
<li><a href="https://github.com/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener" data-en="Latest release">Последний релиз</a></li>
|
||||
<li><a href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader" data-link="repo" rel="noopener" data-en="Gitea repository">Репозиторий Gitea</a></li>
|
||||
<li><a href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/releases/latest" data-link="release" rel="noopener" data-en="Latest release">Последний релиз</a></li>
|
||||
<li><a href="privacy.html" data-en="Privacy policy">Политика приватности</a></li>
|
||||
<li><a href="license.html" data-en="License">Лицензия</a></li>
|
||||
</ul>
|
||||
@@ -85,10 +85,10 @@
|
||||
<li data-en="removing or altering copyright, license and attribution notices;">удаление или изменение уведомлений об авторских правах, лицензии и атрибуции;</li>
|
||||
<li data-en="commercial activity, bulk republication, infringement, unauthorized access, DRM circumvention or violation of third-party terms.">коммерческая деятельность, массовая перепубликация, нарушение прав, несанкционированный доступ, обход DRM и нарушение условий третьих сторон.</li>
|
||||
</ul>
|
||||
<p data-en="Possession of the source code or a release archive grants no right beyond the limited end-user license above. Public availability of files on GitHub does not by itself grant additional rights — GitHub technically allows forking any public repository, but such a fork may breach this license.">
|
||||
<p data-en="Possession of the source code or a release archive grants no right beyond the limited end-user license above. Public availability of files on Gitea does not by itself grant additional rights — Gitea technically allows forking any public repository, but such a fork may breach this license.">
|
||||
Обладание исходным кодом или релизным архивом не даёт прав сверх ограниченной пользовательской
|
||||
лицензии выше. Публичная доступность файлов на GitHub сама по себе не предоставляет
|
||||
дополнительных прав: технически GitHub позволяет форкнуть любой публичный репозиторий, однако
|
||||
лицензии выше. Публичная доступность файлов в Gitea сама по себе не предоставляет
|
||||
дополнительных прав: технически Gitea позволяет форкнуть любой публичный репозиторий, однако
|
||||
такой форк может нарушать эту лицензию.
|
||||
</p>
|
||||
|
||||
@@ -122,14 +122,14 @@
|
||||
</p>
|
||||
|
||||
<h2 data-en="Written permission">Письменное разрешение</h2>
|
||||
<p data-en="Any permission beyond the limited end-user license must be granted in a separate written agreement signed by the copyright holder. Requests may be submitted through the official GitHub repository.">
|
||||
<p data-en="Any permission beyond the limited end-user license must be granted in a separate written agreement signed by the copyright holder. Requests may be submitted through the official Gitea repository.">
|
||||
Любое разрешение сверх ограниченной пользовательской лицензии должно предоставляться отдельным
|
||||
письменным соглашением, подписанным правообладателем. Запросы можно направить через
|
||||
<a href="https://github.com/OwnerLink/redgifs-video-downloader" data-link="repo" rel="noopener">официальный репозиторий GitHub</a>.
|
||||
<a href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader" data-link="repo" rel="noopener">официальный репозиторий Gitea</a>.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:34px">
|
||||
<a class="btn btn-ghost" href="https://github.com/OwnerLink/redgifs-video-downloader/blob/main/LICENSE" data-link="license" rel="noopener">
|
||||
<a class="btn btn-ghost" href="https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/src/branch/main/LICENSE" data-link="license" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/></svg>
|
||||
<span data-en="Full LICENSE text">Полный текст LICENSE</span>
|
||||
</a>
|
||||
File renamed without changes.
@@ -3,4 +3,4 @@ Allow: /
|
||||
|
||||
# Replace the host below if the site is published under a custom domain
|
||||
# or a different repository name.
|
||||
Sitemap: https://redgifs.extension.lonarpgproxy.space/sitemap.xml
|
||||
Sitemap: https://git.lonarpgproxy.space/extention/redgifs/sitemap.xml
|
||||
@@ -5,17 +5,17 @@
|
||||
-->
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://redgifs.extension.lonarpgproxy.space/</loc>
|
||||
<loc>https://git.lonarpgproxy.space/extention/redgifs/</loc>
|
||||
<lastmod>2026-08-22</lastmod>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://redgifs.extension.lonarpgproxy.space/privacy.html</loc>
|
||||
<loc>https://git.lonarpgproxy.space/extention/redgifs/privacy.html</loc>
|
||||
<lastmod>2026-08-22</lastmod>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://redgifs.extension.lonarpgproxy.space/license.html</loc>
|
||||
<loc>https://git.lonarpgproxy.space/extention/redgifs/license.html</loc>
|
||||
<lastmod>2026-08-22</lastmod>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
Reference in new issue
Block a user