Publish RedGifs 0.6.23 and migrate project website to Gitea

This commit is contained in:
DepressedCat committed 2026-10-11 01:03:40 +03:00
1 parent 01e0debd76
commit 580fd4f5b9
38 files changed
+5987 -42

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.zip binary
*.crx binary
+23
View File
@@ -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
-1
View File
@@ -1 +0,0 @@
redgifs.extension.lonarpgproxy.space
+61
View File
@@ -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
View File
@@ -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.
+118 -10
View File
@@ -1,14 +1,122 @@
# RedGifs Video Downloader — сайт-витрина
# RedGifs Video Downloader
Статическая страница-презентация расширения **RedGifs Video Downloader**.
Без сборки, без зависимостей и без бэкенда: HTML, CSS и один файл JavaScript.
![RedGifs Video Downloader preview](docs/release-preview.png)
**Сайт:** https://redgifs.extension.lonarpgproxy.space/
**Само расширение:** https://github.com/OwnerLink/redgifs-video-downloader
**Скачать сборку:** https://github.com/OwnerLink/redgifs-video-downloader/releases/latest
Локальное расширение Chromium для сохранения разрешённых пользователю видео с RedGIFs. Расширение добавляет кнопку загрузки непосредственно на карточки, показывает ход выполнения и собирает HLS-потоки на устройстве пользователя.
## Лицензия
> [!IMPORTANT]
> Это неофициальный проект, не связанный с RedGIFS, Google или GitHub. Он предназначен только для совершеннолетних пользователей и только для материалов, которые пользователь создал сам либо получил явное разрешение сохранить. Условия RedGIFS могут ограничивать скачивание контента; ответственность за соблюдение условий сайта и прав авторов несёт пользователь.
Материалы сайта относятся к проекту RedGifs Video Downloader,
© 2026 RedGifs Video Downloader, все права защищены. Условия — в
[LICENSE расширения](https://github.com/OwnerLink/redgifs-video-downloader/blob/main/LICENSE).
## Возможности
- кнопка загрузки на обнаруженных карточках, в ленте и полноэкранном просмотре;
- автоматический выбор HD MP4 с резервными вариантами;
- локальная обработка MP4, GIF и HLS без стороннего сервера;
- наглядное уведомление с ходом загрузки и пятисекундным обратным отсчётом;
- отметка уже скачанных роликов по стабильному RedGIFS ID;
- повторная загрузка через оранжевую кнопку при наведении на зелёную галочку;
- понятные имена файлов из метаданных ролика;
- пользовательская подпапка внутри `Загрузок`;
- необязательная группировка по имени профиля;
- резервный сканер видимых роликов в popup;
- диагностический журнал с удалением токенов, cookie и других секретов;
- без регистрации, платного счётчика, аналитики и телеметрии.
## Установка
### Из Gitea Release
1. Скачайте архив `RedGifs-Video-Downloader-v0.6.23.zip` из последнего Release.
2. Распакуйте архив в постоянную папку. Не удаляйте её после установки.
3. Откройте `chrome://extensions` в Chrome, Chromium, Brave или другом совместимом браузере.
4. Включите **Режим разработчика**.
5. Нажмите **Загрузить распакованное расширение**.
6. Выберите распакованную папку, внутри которой находится `manifest.json`.
Расширение распространяется вручную: его публикация в Chrome Web Store не планируется из-за правил магазина для расширений, основная функция которых относится к сайтам с контентом для взрослых.
## Использование
1. Откройте страницу ролика, профиль, нишу или ленту RedGIFS.
2. На распознанной видеокарточке появится круглая розовая кнопка загрузки.
3. Нажмите её и дождитесь завершения. Страница покажет текущий этап и ход загрузки.
4. После успешного сохранения кнопка станет зелёной. При наведении появится действие повторной загрузки.
Если кнопка не появилась, откройте popup расширения и запустите резервное сканирование видимых роликов. При бесконечной ленте новые карточки обрабатываются по мере прокрутки.
## Настройки
Настройки находятся под шестерёнкой в popup и по умолчанию свёрнуты.
- **Подпапка внутри Загрузок** — относительный путь, например `Видео/RedGifs`.
- **Группировать по профилям** — добавляет к пути имя профиля из URL или метаданных карточки.
- **Всегда спрашивать место сохранения** — включает системный диалог браузера.
Пример итогового пути:
```text
Загрузки/RedGifs/example-profile/Название ролика.mp4
```
Фактическая корневая папка определяется настройками загрузок браузера. Расширения Chromium не могут произвольно сохранять файлы за пределами разрешённого браузером каталога.
## Приватность и безопасность
- видео обрабатывается локально в браузере;
- сторонние серверы проекта отсутствуют;
- аккаунт и email не требуются;
- аналитика и телеметрия отсутствуют;
- сетевой доступ ограничен `redgifs.com` и его поддоменами;
- настройки и история скачанных ID хранятся в локальном хранилище расширения;
- диагностический журнал создаётся только по команде пользователя;
- чувствительные заголовки, токены, cookie, query-параметры и hash удаляются или заменяются на `[redacted]`.
Подробнее: [PRIVACY.md](PRIVACY.md).
## Разрешения браузера
| Разрешение | Назначение |
| --- | --- |
| `activeTab`, `scripting` | обнаружение роликов и отображение интерфейса на открытой вкладке RedGIFS |
| `downloads` | передача готового файла менеджеру загрузок браузера |
| `offscreen` | локальная сборка Blob/HLS вне жизненного цикла popup |
| `storage` | настройки, история скачанных ID и временная диагностика |
| `tabs` | связь popup и service worker с открытой вкладкой |
| `https://redgifs.com/*`, `https://*.redgifs.com/*` | получение разрешённых медиа и метаданных только с RedGIFS |
## Поддерживаемые форматы
- прямой MP4;
- GIF;
- HLS с выбором потока наибольшего доступного разрешения;
- AES-128 HLS.
SAMPLE-AES и DRM не поддерживаются и не обходятся. Очень большие HLS-ролики могут потребовать заметный объём оперативной памяти, поскольку сегменты собираются локально.
## Диагностика
Диагностика по умолчанию выключена. Для поиска проблемы откройте настройки popup, включите переключатель **Включить диагностику**, воспроизведите проблему и нажмите **Скачать диагностический лог**. После отключения накопленные диагностические события и история позиционирования очищаются. Журнал не содержит тело видео и намеренно очищается от известных секретов.
Перед публикацией диагностического файла всё равно рекомендуется просмотреть его вручную.
## Правовой статус
Исходный код и релизные сборки предоставляются по проприетарной лицензии **All Rights Reserved**. Разрешена только установка и запуск неизменённого официального релиза для личного некоммерческого использования. Копирование, изменение, форки, публикация зеркал, перераспространение, продажа и создание производных работ запрещены без предварительного письменного разрешения правообладателя.
Полный текст: [LICENSE](LICENSE).
Публичная доступность файлов в Gitea сама по себе не предоставляет дополнительных прав. Технически Gitea позволяет создать fork любого публичного репозитория, однако такой fork или иное копирование может нарушать условия этой лицензии.
## Версия
Текущая версия: **0.6.23**.
История релиза: [RELEASE_NOTES.md](RELEASE_NOTES.md).
## Отказ от гарантий
Программа предоставляется «как есть», без гарантий работоспособности, доступности RedGIFS, соответствия сторонним условиям или сохранности данных. Структура сайта и API могут измениться без предупреждения.
## Сайт проекта
[Сайт расширения](https://git.lonarpgproxy.space/extention/redgifs/). Его исходники находятся в каталоге [website](website/).
+56
View File
@@ -0,0 +1,56 @@
# RedGifs Video Downloader 0.6.23
![RedGifs Video Downloader preview](https://git.lonarpgproxy.space/OwnerLink/redgifs-video-downloader/raw/branch/main/docs/release-preview.png?raw=true)
## Исправления 0.6.23
- Выбор тега в профиле больше не запускает отдельный глубокий обход для каждого фрагмента перестраиваемой страницы.
- Серии крупных DOM-изменений объединяются в один отложенный пересмотр после стабилизации страницы.
- Отключённые и удалённые карточки освобождаются из реестра наблюдения одним проходом.
- В кнопки и диагностический интерфейс добавлен номер фактически работающего content script, чтобы отличать свежую версию от оставшейся в старой вкладке.
## Исправления 0.6.22
- Устранён полный перескан страницы каждые 160 мс при прокрутке и изменениях DOM.
- Карточки обрабатываются небольшими порциями только рядом с видимой областью.
- Кнопки полноэкранного просмотра больше не забираются у исходной карточки профиля.
- Удалённые карточки, кнопки и цели наблюдения освобождаются после фильтрации по тегам.
- Диагностика получила отдельный переключатель и по умолчанию выключена.
- При выключении диагностики очищаются накопленные события и история позиционирования.
Первый релиз независимого локального загрузчика для Chromium.
## Главное
- Кнопки загрузки в карточках, лентах и полноэкранном режиме.
- Устойчивое позиционирование кнопок при виртуализации и бесконечной прокрутке.
- Автоматическое обнаружение новых карточек без накопления дубликатов.
- Локальное сохранение MP4, GIF и HLS без внешнего сервера.
- Единое уведомление с ходом загрузки, зелёной полосой прогресса и обратным отсчётом после завершения.
- Синхронная зелёная отметка только после фактического завершения записи файла.
- История скачанных RedGIFS ID и оранжевая кнопка повторной загрузки.
- Имена файлов из настоящего названия/описания ролика.
- Группировка файлов по профилям, включая карточки в общей ленте.
- Сворачиваемые настройки в popup с плавной анимацией.
- Резервное сканирование видимых роликов с превью.
- Экспорт диагностического журнала с очисткой известных секретов.
- Новый комплект иконок и название RedGifs Video Downloader.
## Установка
1. Скачайте `RedGifs-Video-Downloader-v0.6.23.zip`.
2. Распакуйте архив.
3. Откройте `chrome://extensions`.
4. Включите режим разработчика.
5. Нажмите «Загрузить распакованное расширение» и выберите распакованную папку.
## Важное ограничение
Используйте расширение только для собственных материалов или контента, на сохранение которого у вас есть явное разрешение правообладателя. Проект не обходит DRM и не предоставляет прав на чужой контент.
## Контрольная сумма
```text
RedGifs-Video-Downloader-v0.6.23.zip
SHA-256: см. описание Gitea Release
```
+1171
View File
File diff suppressed because it is too large. Load diff
+2032
View File
File diff suppressed because it is too large. Load diff
File renamed without changes.
File renamed without changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

File renamed without changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

File renamed without changes.
+47
View File
@@ -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"
}
}
}
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+54
View File
@@ -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
View File
@@ -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();
+706
View File
@@ -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
View File
@@ -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>
+640
View File
@@ -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();
}
});
View File
File renamed without changes.
+2 -6
View File
@@ -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;
+14
View File
@@ -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

+24
View File
@@ -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]');
+13 -13
View File
@@ -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>
+6 -6
View File
@@ -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.
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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>