Files

1172 lines
42 KiB
JavaScript

"use strict";
const REDGIFS_API = "https://api.redgifs.com";
const TOKEN_TTL_MS = 50 * 60 * 1000;
const DOWNLOAD_HISTORY_KEY = "downloadHistoryV1";
const DOWNLOAD_HISTORY_LIMIT = 5000;
const DIAGNOSTIC_LOG_KEY = "diagnosticLogV1";
const DIAGNOSTICS_SETTING_KEY = "diagnosticsEnabled";
const DIAGNOSTIC_LOG_LIMIT = 500;
const DIAGNOSTIC_REPORT_MAX_CHARS = 6 * 1024 * 1024;
const tokenCache = { value: null, expiresAt: 0 };
const jobs = new Map();
const downloadJobIds = new Map();
let creatingOffscreen = null;
let downloadHistoryCache = null;
let downloadHistoryWrite = Promise.resolve();
let diagnosticLogCache = null;
let diagnosticLogWrite = Promise.resolve();
let diagnosticsEnabled = false;
function applyDiagnosticsState(enabled) {
diagnosticsEnabled = Boolean(enabled);
if (diagnosticsEnabled) return;
diagnosticLogWrite = diagnosticLogWrite.catch(() => {}).then(async () => {
diagnosticLogCache = [];
try {
await chrome.storage.session.remove(DIAGNOSTIC_LOG_KEY);
} catch {
// Diagnostics are already disabled in memory.
}
});
}
try {
chrome.storage.local.get(DIAGNOSTICS_SETTING_KEY).then((stored) => {
applyDiagnosticsState(Boolean(stored && stored[DIAGNOSTICS_SETTING_KEY]));
}).catch(() => {});
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === "local" && changes[DIAGNOSTICS_SETTING_KEY]) {
applyDiagnosticsState(Boolean(changes[DIAGNOSTICS_SETTING_KEY].newValue));
}
});
} catch {
applyDiagnosticsState(false);
}
function diagnosticUrl(value) {
try {
const parsed = new URL(String(value || ""));
if (parsed.protocol === "blob:") return "blob:" + parsed.origin + "/[local-object]";
parsed.username = "";
parsed.password = "";
parsed.search = "";
parsed.hash = "";
return parsed.toString();
} catch {
return String(value || "").slice(0, 300);
}
}
function diagnosticValue(value, key, depth) {
if (depth > 5) return "[max-depth]";
const name = String(key || "").toLowerCase();
if (/authorization|cookie|token|credential|password|secret/.test(name)) return "[redacted]";
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
if (value instanceof Error) {
return {
name: String(value.name || "Error").slice(0, 100),
message: String(value.message || value).slice(0, 1000)
};
}
if (typeof value === "string") {
if (/url|uri|source|playlist|media/i.test(name) || /^https?:|^blob:/i.test(value)) return diagnosticUrl(value);
return value.slice(0, 1000);
}
if (Array.isArray(value)) return value.slice(0, 50).map((item) => diagnosticValue(item, key, depth + 1));
if (typeof value === "object") {
const result = {};
for (const [childKey, childValue] of Object.entries(value).slice(0, 80)) {
result[childKey] = diagnosticValue(childValue, childKey, depth + 1);
}
return result;
}
return String(value).slice(0, 500);
}
async function loadDiagnosticLog() {
if (!diagnosticsEnabled) return [];
if (diagnosticLogCache) return diagnosticLogCache;
try {
const stored = await chrome.storage.session.get(DIAGNOSTIC_LOG_KEY);
diagnosticLogCache = Array.isArray(stored && stored[DIAGNOSTIC_LOG_KEY])
? stored[DIAGNOSTIC_LOG_KEY].slice(-DIAGNOSTIC_LOG_LIMIT)
: [];
} catch {
diagnosticLogCache = [];
}
return diagnosticLogCache;
}
function recordDiagnostic(level, area, event, details) {
if (!diagnosticsEnabled) return null;
const entry = {
at: new Date().toISOString(),
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
area: String(area || "runtime").slice(0, 80),
event: String(event || "event").slice(0, 120),
details: diagnosticValue(details || {}, "details", 0)
};
diagnosticLogWrite = diagnosticLogWrite.catch(() => {}).then(async () => {
const log = await loadDiagnosticLog();
log.push(entry);
if (log.length > DIAGNOSTIC_LOG_LIMIT) log.splice(0, log.length - DIAGNOSTIC_LOG_LIMIT);
try {
await chrome.storage.session.set({ [DIAGNOSTIC_LOG_KEY]: log });
} catch {
// In-memory diagnostics remain available until the worker is stopped.
}
});
return entry;
}
async function diagnosticRuntimeBundle() {
if (!diagnosticsEnabled) {
return {
generatedAt: new Date().toISOString(),
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null,
diagnosticsEnabled: false,
events: []
};
}
await diagnosticLogWrite.catch(() => {});
const log = await loadDiagnosticLog();
const settings = await getDownloadSettings();
return {
generatedAt: new Date().toISOString(),
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null,
diagnosticsEnabled: true,
platform: typeof navigator !== "undefined" ? {
userAgent: navigator.userAgent || null,
language: navigator.language || null,
online: navigator.onLine
} : null,
tokenCache: { hasToken: Boolean(tokenCache.value), expiresAt: tokenCache.expiresAt || 0 },
downloadSettings: diagnosticValue(settings, "downloadSettings", 0),
activeJobs: Array.from(jobs.values()).slice(-50).map((job) => diagnosticValue(job, "job", 0)),
events: log.slice()
};
}
function diagnosticReportJson(report) {
const json = JSON.stringify(report, null, 2);
if (json.length > DIAGNOSTIC_REPORT_MAX_CHARS) {
throw new Error("Диагностический лог слишком большой для сохранения.");
}
return json;
}
function diagnosticReportDataUrl(report) {
const json = diagnosticReportJson(report);
const bytes = new TextEncoder().encode(json);
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return "data:application/json;base64," + btoa(binary);
}
async function downloadDiagnosticReport(report) {
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = "redgifs-diagnostic-log-" + stamp + ".json";
const text = diagnosticReportJson(report);
try {
await ensureOffscreenDocument();
const response = await chrome.runtime.sendMessage({
target: "offscreen",
action: "downloadTextFile",
text,
filename,
mimeType: "application/json;charset=utf-8",
saveAs: true
});
if (!response || !response.ok || !response.result) {
throw new Error(response && response.error ? response.error : "Offscreen-процесс не сохранил лог.");
}
recordDiagnostic("info", "diagnostic", "report-download-started", {
downloadId: response.result.downloadId,
filename,
mode: "offscreen-blob",
textLength: text.length
});
return Object.assign({ filename, mode: "offscreen-blob" }, response.result);
} catch (offscreenError) {
recordDiagnostic("warn", "diagnostic", "offscreen-report-download-failed", {
filename,
error: offscreenError
});
const url = diagnosticReportDataUrl(report);
const downloadId = await chrome.downloads.download({
url,
filename,
saveAs: true,
conflictAction: "uniquify"
});
recordDiagnostic("info", "diagnostic", "report-download-started", {
downloadId,
filename,
mode: "data-url-fallback",
encodedBytes: url.length
});
return { downloadId, filename, mode: "data-url-fallback" };
}
}
function isRedgifsHost(hostname) {
const host = String(hostname || "").toLowerCase();
return host === "redgifs.com" || host.endsWith(".redgifs.com");
}
function parseTrustedUrl(value) {
let parsed;
try {
parsed = new URL(String(value || "").trim());
} catch {
throw new Error("Введите корректную ссылку RedGifs.");
}
if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) {
throw new Error("Поддерживаются только HTTPS-ссылки RedGifs.");
}
return parsed;
}
function extractId(parsedUrl) {
const match = parsedUrl.pathname.match(/\/(?:watch|ifr)\/([a-z0-9_-]+)/i);
return match && match[1] ? match[1] : null;
}
function normalizeMediaId(value) {
const id = String(value || "").trim().toLowerCase();
return /^[a-z0-9_-]{2,160}$/i.test(id) ? id : null;
}
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/gi, "&")
.replace(/&#x2F;/gi, "/")
.replace(/&quot;/gi, "\"")
.replace(/&#39;/gi, "'");
}
function extensionFromUrl(url, fallback) {
try {
const match = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
return match ? match[1].toLowerCase() : fallback;
} catch {
return fallback;
}
}
function sanitizeFilename(value) {
const cleaned = String(value || "redgifs-video")
.replace(/^Poster for\s+/i, "")
.replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "")
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/\s+/g, " ")
.replace(/[. ]+$/g, "")
.trim();
return (cleaned || "redgifs-video").slice(0, 120);
}
const DEFAULT_DOWNLOAD_SETTINGS = {
baseFolder: "RedGifs",
groupByProfile: true,
saveAs: false
};
function sanitizeFolderPath(value) {
return String(value || "")
.replace(/\\/g, "/")
.split("/")
.map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim())
.filter((part) => part && part !== "." && part !== "..")
.slice(0, 8)
.map((part) => part.slice(0, 80))
.join("/");
}
function profileFromUrl(value) {
try {
const parsed = parseTrustedUrl(value);
const match = parsed.pathname.match(/\/users\/([^/?#]+)/i);
return match && match[1] ? decodeURIComponent(match[1]) : null;
} catch {
return null;
}
}
function profileFromGif(gif) {
if (!gif || typeof gif !== "object") return null;
const nestedUser = gif.user && typeof gif.user === "object" ? gif.user : null;
const value = gif.userName
|| gif.username
|| gif.user_name
|| (nestedUser && (nestedUser.userName || nestedUser.username || nestedUser.name));
return sanitizeFolderPath(String(value || "").trim().replace(/^@+/, "")) || null;
}
async function getDownloadSettings() {
try {
const stored = await chrome.storage.local.get("downloadSettings");
return Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, stored.downloadSettings || {});
} catch {
return Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS);
}
}
async function readDownloadHistory() {
if (downloadHistoryCache) return downloadHistoryCache;
try {
const stored = await chrome.storage.local.get(DOWNLOAD_HISTORY_KEY);
const value = stored && stored[DOWNLOAD_HISTORY_KEY];
downloadHistoryCache = value && typeof value === "object" ? value : {};
} catch {
downloadHistoryCache = {};
}
return downloadHistoryCache;
}
async function getDownloadedRecords(ids) {
await downloadHistoryWrite.catch(() => {});
const history = await readDownloadHistory();
const result = {};
for (const value of Array.isArray(ids) ? ids.slice(0, 500) : []) {
const id = normalizeMediaId(value);
if (id && history[id]) result[id] = history[id];
}
return result;
}
function rememberDownloaded(job) {
const mediaId = normalizeMediaId(job && job.mediaId);
if (!mediaId) return Promise.resolve();
downloadHistoryWrite = downloadHistoryWrite.catch(() => {}).then(async () => {
const history = Object.assign({}, await readDownloadHistory());
history[mediaId] = {
downloadedAt: Date.now(),
filename: sanitizeFilename(job.filename || job.title || mediaId),
profileSlug: sanitizeFolderPath(job.profileSlug || "") || null
};
const keys = Object.keys(history);
if (keys.length > DOWNLOAD_HISTORY_LIMIT) {
keys.sort((left, right) => {
return Number(history[left] && history[left].downloadedAt || 0)
- Number(history[right] && history[right].downloadedAt || 0);
});
for (const key of keys.slice(0, keys.length - DOWNLOAD_HISTORY_LIMIT)) delete history[key];
}
downloadHistoryCache = history;
try {
await chrome.storage.local.set({ [DOWNLOAD_HISTORY_KEY]: history });
} catch {
// The current session still remembers the completed download.
}
});
return downloadHistoryWrite;
}
function buildOutputFolder(settings, profileSlug) {
const parts = [];
const base = sanitizeFolderPath(settings.baseFolder);
if (base) parts.push(base);
if (settings.groupByProfile) {
const profile = sanitizeFolderPath(profileSlug);
if (profile) parts.push(profile);
}
return parts.join("/");
}
function addFormat(list, seen, label, url, kind, quality, extension) {
if (!url) return;
let parsed;
try {
parsed = parseTrustedUrl(url);
} catch {
return;
}
const normalized = parsed.toString();
if (seen.has(normalized)) return;
seen.add(normalized);
list.push({
label,
url: normalized,
kind,
quality: quality || null,
extension: extension || extensionFromUrl(normalized, kind === "hls" ? "m3u8" : "mp4")
});
}
function formatsFromGif(gif) {
const formats = [];
const seen = new Set();
const urls = gif && gif.urls ? gif.urls : {};
const height = Number(gif && gif.height) || null;
const sdHeight = height && height >= 720 ? 480 : 360;
addFormat(formats, seen, height ? "HD MP4 · " + height + "p" : "HD MP4", urls.hd, "direct", height, "mp4");
addFormat(formats, seen, "SD MP4 · " + sdHeight + "p", urls.sd, "direct", sdHeight, "mp4");
addFormat(formats, seen, "HLS · локальная сборка", urls.hls, "hls", height, "m3u8");
addFormat(formats, seen, "Без звука", urls.silent, String(urls.silent || "").includes(".m3u8") ? "hls" : "direct", height, extensionFromUrl(urls.silent, "mp4"));
addFormat(formats, seen, "GIF", urls.gif, "direct", null, extensionFromUrl(urls.gif, "gif"));
return formats;
}
async function getTemporaryToken(forceRefresh) {
if (!forceRefresh && tokenCache.value && tokenCache.expiresAt > Date.now()) {
recordDiagnostic("debug", "network.auth", "token-cache-hit", { expiresAt: tokenCache.expiresAt });
return tokenCache.value;
}
let lastError = null;
for (const method of ["GET", "POST"]) {
const startedAt = Date.now();
try {
recordDiagnostic("info", "network.auth", "request", { method, url: REDGIFS_API + "/v2/auth/temporary" });
const response = await fetch(REDGIFS_API + "/v2/auth/temporary", {
method,
headers: { Accept: "application/json, text/plain, */*" },
cache: "no-store"
});
recordDiagnostic(response.ok ? "info" : "warn", "network.auth", "response", {
method,
url: REDGIFS_API + "/v2/auth/temporary",
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt
});
if (!response.ok) throw new Error("HTTP " + response.status);
const payload = await response.json();
if (!payload || !payload.token) throw new Error("RedGifs не вернул временный токен.");
tokenCache.value = payload.token;
tokenCache.expiresAt = Date.now() + TOKEN_TTL_MS;
return tokenCache.value;
} catch (error) {
lastError = error;
recordDiagnostic("error", "network.auth", "request-failed", {
method,
url: REDGIFS_API + "/v2/auth/temporary",
durationMs: Date.now() - startedAt,
error
});
}
}
throw new Error("Не удалось получить временный токен RedGifs: " + (lastError && lastError.message ? lastError.message : "unknown error"));
}
async function fetchGifMetadata(id, forceTokenRefresh) {
const token = await getTemporaryToken(Boolean(forceTokenRefresh));
const watchUrl = "https://www.redgifs.com/watch/" + encodeURIComponent(id);
const apiUrl = REDGIFS_API + "/v2/gifs/" + encodeURIComponent(id);
const startedAt = Date.now();
let response;
try {
recordDiagnostic("info", "network.metadata", "request", { id, url: apiUrl, forceTokenRefresh: Boolean(forceTokenRefresh) });
response = await fetch(apiUrl, {
headers: {
Accept: "application/json, text/plain, */*",
Authorization: "Bearer " + token,
"x-customheader": watchUrl
},
cache: "no-store",
referrer: watchUrl,
referrerPolicy: "no-referrer-when-downgrade"
});
recordDiagnostic(response.ok ? "info" : "warn", "network.metadata", "response", {
id,
url: apiUrl,
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt
});
} catch (error) {
recordDiagnostic("error", "network.metadata", "request-failed", {
id,
url: apiUrl,
durationMs: Date.now() - startedAt,
error
});
throw error;
}
if (response.status === 401 && !forceTokenRefresh) {
tokenCache.value = null;
tokenCache.expiresAt = 0;
return fetchGifMetadata(id, true);
}
if (!response.ok) throw new Error("RedGifs API: HTTP " + response.status);
const payload = await response.json();
if (!payload || !payload.gif) throw new Error("RedGifs API не вернул данные видео.");
return payload.gif;
}
function metaContent(html, key) {
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
const first = new RegExp("<meta[^>]+(?:property|name)=[\"']" + escaped + "[\"'][^>]+content=[\"']([^\"']+)[\"']", "i");
const second = new RegExp("<meta[^>]+content=[\"']([^\"']+)[\"'][^>]+(?:property|name)=[\"']" + escaped + "[\"']", "i");
const match = html.match(first) || html.match(second);
return match && match[1] ? decodeHtml(match[1]) : null;
}
async function pageFallback(parsedUrl, knownId) {
const startedAt = Date.now();
let response;
try {
recordDiagnostic("info", "network.page", "request", { url: parsedUrl.toString(), knownId });
response = await fetch(parsedUrl.toString(), {
headers: { Accept: "text/html,application/xhtml+xml" },
cache: "no-store"
});
recordDiagnostic(response.ok ? "info" : "warn", "network.page", "response", {
url: parsedUrl.toString(),
status: response.status,
ok: response.ok,
durationMs: Date.now() - startedAt
});
} catch (error) {
recordDiagnostic("error", "network.page", "request-failed", {
url: parsedUrl.toString(),
durationMs: Date.now() - startedAt,
error
});
throw error;
}
if (!response.ok) throw new Error("Страница RedGifs недоступна: HTTP " + response.status);
const html = await response.text();
const idMatch = html.match(/https:\/\/(?:www\.)?redgifs\.com\/(?:watch|ifr)\/([a-z0-9_-]+)/i);
const id = knownId || (idMatch && idMatch[1] ? idMatch[1] : null);
const title = metaContent(html, "og:title") || metaContent(html, "twitter:title") || id || "RedGifs video";
const thumbnail = metaContent(html, "og:image") || metaContent(html, "twitter:image");
const candidates = [
metaContent(html, "og:video"),
metaContent(html, "og:video:url"),
metaContent(html, "twitter:player:stream")
];
const embedded = html.match(/https:\/\/media\.redgifs\.com\/[^"'\\\s<>]+?\.(?:mp4|m3u8|gif|webm)(?:\?[^"'\\\s<>]*)?/gi) || [];
candidates.push.apply(candidates, embedded);
const formats = [];
const seen = new Set();
for (const candidate of candidates) {
if (!candidate) continue;
const value = decodeHtml(candidate);
const hls = /\.m3u8(?:$|\?)/i.test(value);
addFormat(formats, seen, hls ? "HLS · найдено на странице" : "Видео · найдено на странице", value, hls ? "hls" : "direct", null, hls ? "m3u8" : extensionFromUrl(value, "mp4"));
}
return { id, title, thumbnail, formats };
}
async function getRedgifsTab(tabId) {
let tab = null;
if (Number.isInteger(tabId)) {
try {
tab = await chrome.tabs.get(tabId);
} catch {
tab = null;
}
}
if (!tab) {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
tab = tabs && tabs[0] ? tabs[0] : null;
}
if (!tab || !tab.id || !tab.url) throw new Error("Не найдена открытая вкладка RedGifs.");
const parsed = parseTrustedUrl(tab.url);
return { tab, parsed };
}
function collectionTitleNeedsMetadata(item) {
const id = normalizeMediaId(item && item.id);
const title = String(item && item.title || "").trim();
if (!title) return true;
const withoutPrefix = title.replace(/^redgifs\s*[·:—-]?\s*/i, "").trim();
return Boolean(id && normalizeMediaId(withoutPrefix) === id);
}
function mergeCollectionItemMetadata(item, gif) {
if (!item || !gif) return item;
const tags = Array.isArray(gif.tags) ? gif.tags.slice(0, 8).join(" ") : "";
const metadataTitle = String(gif.title || gif.description || tags || "").trim();
const rawThumbnail = gif.urls && (gif.urls.poster || gif.urls.thumbnail) || null;
let thumbnail = item.thumbnail || null;
if (rawThumbnail) {
try {
thumbnail = parseTrustedUrl(rawThumbnail).toString();
} catch {
// Keep the thumbnail captured from the page.
}
}
return Object.assign({}, item, {
title: collectionTitleNeedsMetadata(item) && metadataTitle
? metadataTitle.slice(0, 160)
: item.title,
thumbnail,
profileSlug: profileFromGif(gif) || item.profileSlug || null
});
}
async function hydrateCollectionItems(items) {
const result = Array.isArray(items) ? items.slice() : [];
const pendingIndexes = [];
for (let index = 0; index < result.length; index += 1) {
const item = result[index];
if (normalizeMediaId(item && item.id)
&& (!item.thumbnail || !item.profileSlug || collectionTitleNeedsMetadata(item))) {
pendingIndexes.push(index);
}
}
if (!pendingIndexes.length) return result;
try {
await getTemporaryToken(false);
} catch (error) {
recordDiagnostic("warn", "content.scan", "metadata-hydration-auth-failed", {
itemCount: pendingIndexes.length,
error
});
return result;
}
let cursor = 0;
let hydratedCount = 0;
let failedCount = 0;
async function worker() {
while (cursor < pendingIndexes.length) {
const pendingIndex = pendingIndexes[cursor];
cursor += 1;
const item = result[pendingIndex];
try {
const gif = await fetchGifMetadata(item.id, false);
result[pendingIndex] = mergeCollectionItemMetadata(item, gif);
hydratedCount += 1;
} catch (error) {
failedCount += 1;
recordDiagnostic("warn", "content.scan", "metadata-hydration-item-failed", {
id: item.id,
error
});
}
}
}
const concurrency = Math.min(6, pendingIndexes.length);
await Promise.all(Array.from({ length: concurrency }, () => worker()));
recordDiagnostic("info", "content.scan", "metadata-hydration-complete", {
requestedCount: pendingIndexes.length,
hydratedCount,
failedCount
});
return result;
}
async function scanOpenRedgifsPage(tabId, requestedUrl) {
const current = await getRedgifsTab(tabId);
if (requestedUrl && current.parsed.origin + current.parsed.pathname !== requestedUrl.origin + requestedUrl.pathname) {
throw new Error("Откройте указанный профиль RedGifs в текущей вкладке, затем нажмите «Сканировать».");
}
let response = null;
try {
response = await chrome.tabs.sendMessage(current.tab.id, { action: "scanRedgifsPage" });
} catch (error) {
recordDiagnostic("warn", "content.scan", "content-script-unavailable", { tabId: current.tab.id, error });
await chrome.scripting.executeScript({
target: { tabId: current.tab.id },
files: ["content.js"]
});
response = await chrome.tabs.sendMessage(current.tab.id, { action: "scanRedgifsPage" });
}
if (!response || !response.ok) {
recordDiagnostic("error", "content.scan", "scan-failed", { tabId: current.tab.id, response });
throw new Error(response && response.error ? response.error : "Не удалось просканировать страницу RedGifs.");
}
const rawItems = response.result && Array.isArray(response.result.items) ? response.result.items : [];
recordDiagnostic("info", "content.scan", "scan-complete", {
tabId: current.tab.id,
pageUrl: response.result && response.result.pageUrl,
rawItemCount: rawItems.length
});
const items = [];
const seen = new Set();
for (const raw of rawItems) {
if (items.length >= 60) break;
const id = raw && raw.id ? String(raw.id) : null;
let watchUrl = null;
let directVideoUrl = null;
let thumbnail = null;
try {
if (raw && raw.watchUrl) watchUrl = parseTrustedUrl(raw.watchUrl).toString();
} catch {
watchUrl = null;
}
try {
if (raw && raw.directVideoUrl) directVideoUrl = parseTrustedUrl(raw.directVideoUrl).toString();
} catch {
directVideoUrl = null;
}
try {
if (raw && raw.thumbnail) thumbnail = parseTrustedUrl(raw.thumbnail).toString();
} catch {
thumbnail = null;
}
if (!id && !directVideoUrl) continue;
const key = id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl;
if (seen.has(key)) continue;
seen.add(key);
items.push({
id,
watchUrl: watchUrl || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null),
directVideoUrl,
thumbnail,
title: String(raw.title || (id ? "RedGifs · " + id : "RedGifs video")).slice(0, 160),
profileSlug: sanitizeFolderPath(raw.profileSlug || profileFromUrl(current.parsed.toString()))
});
}
const hydratedItems = await hydrateCollectionItems(items);
return {
type: "collection",
title: response.result && response.result.pageTitle || "Видимые ролики RedGifs",
sourceUrl: current.parsed.toString(),
items: hydratedItems
};
}
async function resolveMedia(input, tabId) {
const parsedUrl = parseTrustedUrl(input);
const id = extractId(parsedUrl);
const directExtension = extensionFromUrl(parsedUrl.toString(), "");
if (["mp4", "webm", "gif", "m3u8"].includes(directExtension)) {
return {
id: null,
title: sanitizeFilename(parsedUrl.pathname.split("/").pop() || "redgifs-video"),
thumbnail: null,
sourceUrl: parsedUrl.toString(),
formats: [{
label: directExtension === "m3u8" ? "HLS · прямая ссылка" : directExtension.toUpperCase() + " · прямая ссылка",
url: parsedUrl.toString(),
kind: directExtension === "m3u8" ? "hls" : "direct",
quality: null,
extension: directExtension
}]
};
}
if (!id) {
const collection = await scanOpenRedgifsPage(tabId, parsedUrl);
if (collection.items.length) return collection;
throw new Error("На открытой странице не найдено видимых роликов. Прокрутите профиль до плиток и снова нажмите «Сканировать».");
}
let apiError = null;
if (id) {
try {
const gif = await fetchGifMetadata(id, false);
const formats = formatsFromGif(gif);
if (formats.length) {
const tagTitle = Array.isArray(gif.tags) ? gif.tags.slice(0, 8).join(" ") : "";
return {
id,
title: gif.title || gif.description || tagTitle || id,
profileSlug: profileFromGif(gif),
thumbnail: gif.urls && (gif.urls.poster || gif.urls.thumbnail) || null,
sourceUrl: parsedUrl.toString(),
formats
};
}
} catch (error) {
apiError = error;
}
}
try {
const fallback = await pageFallback(parsedUrl, id);
if (fallback.formats.length) {
return {
id: fallback.id,
title: fallback.title,
thumbnail: fallback.thumbnail,
sourceUrl: parsedUrl.toString(),
formats: fallback.formats
};
}
} catch (fallbackError) {
if (!apiError) apiError = fallbackError;
}
if (!id) throw new Error("Не найден ID видео. Вставьте ссылку вида redgifs.com/watch/...");
throw new Error("Не удалось найти адрес видео. " + (apiError && apiError.message ? apiError.message : ""));
}
async function hasOffscreenDocument() {
const offscreenUrl = chrome.runtime.getURL("offscreen.html");
if (chrome.runtime.getContexts) {
const contexts = await chrome.runtime.getContexts({
contextTypes: ["OFFSCREEN_DOCUMENT"],
documentUrls: [offscreenUrl]
});
return contexts.length > 0;
}
const clients = await self.clients.matchAll();
return clients.some((client) => client.url === offscreenUrl);
}
async function ensureOffscreenDocument() {
if (await hasOffscreenDocument()) return;
if (!creatingOffscreen) {
creatingOffscreen = chrome.offscreen.createDocument({
url: "offscreen.html",
reasons: ["BLOBS"],
justification: "Download HLS segments and assemble a local media Blob."
}).finally(() => {
creatingOffscreen = null;
});
}
await creatingOffscreen;
}
async function persistJob(job) {
jobs.set(job.id, job);
try {
await chrome.storage.session.set({ ["job:" + job.id]: job });
} catch {
// In-memory state is enough while the service worker remains alive.
}
}
async function readJob(jobId) {
if (jobs.has(jobId)) return jobs.get(jobId);
try {
const key = "job:" + jobId;
const stored = await chrome.storage.session.get(key);
if (stored && stored[key]) {
jobs.set(jobId, stored[key]);
return stored[key];
}
} catch {
// Ignore unavailable session storage.
}
return null;
}
async function startDownload(message) {
const format = message && message.format;
if (!format || !format.url) throw new Error("Формат не выбран.");
const url = parseTrustedUrl(format.url).toString();
const filenameBase = sanitizeFilename(message.title || message.id || "redgifs-video");
const settings = await getDownloadSettings();
const profileSlug = sanitizeFolderPath(message.profileSlug || profileFromUrl(message.sourceUrl));
const outputFolder = buildOutputFolder(settings, profileSlug);
const saveAs = message.saveAs == null ? Boolean(settings.saveAs) : Boolean(message.saveAs);
await ensureOffscreenDocument();
const jobId = crypto.randomUUID();
const isHls = format.kind === "hls" || /\.m3u8(?:$|\?)/i.test(url);
const extension = String(format.extension || extensionFromUrl(url, "mp4")).replace(/[^a-z0-9]/gi, "") || "mp4";
let mediaId = normalizeMediaId(message.id);
if (!mediaId && message.sourceUrl) {
try {
mediaId = normalizeMediaId(extractId(parseTrustedUrl(message.sourceUrl)));
} catch {
mediaId = null;
}
}
const job = {
id: jobId,
state: "queued",
progress: 0,
status: isHls ? "Подготовка HLS…" : "Подготовка загрузки…",
filename: filenameBase,
title: filenameBase,
mediaId,
clientRequestId: String(message.clientRequestId || "").slice(0, 180) || null,
tabId: Number.isInteger(message.tabId) ? message.tabId : null,
profileSlug: profileSlug || null,
outputFolder,
createdAt: Date.now()
};
recordDiagnostic("info", "download", "job-created", {
jobId,
mediaId,
mode: isHls ? "hls" : "direct-local",
mediaUrl: url,
extension,
outputFolder,
saveAs
});
await persistJob(job);
await notifyJob(job);
const accepted = await chrome.runtime.sendMessage({
target: "offscreen",
action: isHls ? "downloadHls" : "downloadDirect",
jobId,
playlistUrl: isHls ? url : null,
mediaUrl: isHls ? null : url,
sourceUrl: message.sourceUrl || "https://www.redgifs.com/",
filenameBase,
outputFolder,
extension,
saveAs
});
if (!accepted || !accepted.ok) {
recordDiagnostic("error", "offscreen", "job-rejected", { jobId, accepted });
throw new Error(accepted && accepted.error ? accepted.error : "Offscreen-процесс не принял загрузку.");
}
recordDiagnostic("info", "offscreen", "job-accepted", { jobId, mode: isHls ? "hls" : "direct-local" });
return { mode: isHls ? "hls" : "direct-local", jobId };
}
function chooseBestFormat(formats) {
const list = Array.isArray(formats) ? formats.filter((format) => format && format.url) : [];
return list.find((format) => format.kind !== "hls" && /hd|1080|720/i.test(String(format.label || "")))
|| list.find((format) => format.kind !== "hls" && String(format.extension || "").toLowerCase() === "mp4")
|| list.find((format) => format.kind !== "hls")
|| list[0]
|| null;
}
function chooseDownloadTitle(item, media) {
const mediaId = normalizeMediaId(media && media.id || item && item.id);
const resolvedTitle = String(media && media.title || "").trim();
const normalizedResolved = resolvedTitle.toLowerCase()
.replace(/^redgifs\s*[·:-]?\s*/i, "")
.trim();
const resolvedIsFallback = !resolvedTitle
|| (mediaId && normalizeMediaId(normalizedResolved) === mediaId);
if (!resolvedIsFallback) return resolvedTitle;
let fallbackTitle = sanitizeFilename(item && item.title || resolvedTitle || mediaId || "RedGifs video");
if (mediaId) {
const escapedId = mediaId.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
fallbackTitle = fallbackTitle.replace(new RegExp("\\s+" + escapedId + "$", "i"), "").trim();
}
return fallbackTitle || mediaId || "RedGifs video";
}
async function downloadCapturedItem(item, tabId) {
if (!item || typeof item !== "object") throw new Error("Данные плитки не получены.");
const target = item.watchUrl || item.directVideoUrl;
if (!target) throw new Error("У плитки не найден ID или адрес видео.");
const media = await resolveMedia(target, tabId);
if (!media || media.type === "collection") throw new Error("Не удалось определить отдельный ролик.");
const format = chooseBestFormat(media.formats);
if (!format) throw new Error("RedGifs не вернул доступных форматов.");
return startDownload({
format,
title: chooseDownloadTitle(item, media),
id: item.id || media.id,
clientRequestId: item.clientRequestId || null,
sourceUrl: media.sourceUrl || target,
profileSlug: media.profileSlug || item.profileSlug || null,
tabId
});
}
async function notifyJob(job) {
if (!job || !Number.isInteger(job.tabId)) return;
try {
await chrome.tabs.sendMessage(job.tabId, {
action: "downloadProgress",
job
});
} catch {
// The originating tab may have been closed or navigated away.
}
}
async function registerDownloadJob(downloadId, jobId) {
if (!Number.isInteger(downloadId) || !jobId) return;
downloadJobIds.set(downloadId, jobId);
try {
await chrome.storage.session.set({ ["downloadJob:" + downloadId]: jobId });
} catch {
// The in-memory mapping normally remains available.
}
}
async function findJobIdForDownload(downloadId) {
if (downloadJobIds.has(downloadId)) return downloadJobIds.get(downloadId);
try {
const key = "downloadJob:" + downloadId;
const stored = await chrome.storage.session.get(key);
if (stored && stored[key]) {
downloadJobIds.set(downloadId, stored[key]);
return stored[key];
}
} catch {
// Ignore unavailable session storage.
}
return null;
}
async function updateJob(message) {
const previous = await readJob(message.jobId) || { id: message.jobId, createdAt: Date.now() };
const next = Object.assign({}, previous, {
state: message.state || previous.state || "running",
progress: Number.isFinite(message.progress) ? message.progress : previous.progress || 0,
status: message.status || previous.status || "",
filename: message.filename || previous.filename || null,
downloadId: message.downloadId == null ? previous.downloadId : message.downloadId,
error: message.error || null,
updatedAt: Date.now()
});
if (next.state !== previous.state || next.state === "error") {
recordDiagnostic(next.state === "error" ? "error" : "info", "download", "job-state", {
jobId: next.id,
previousState: previous.state || null,
state: next.state,
progress: next.progress,
status: next.status,
error: next.error,
downloadId: next.downloadId
});
}
await persistJob(next);
if (Number.isInteger(next.downloadId)) {
await registerDownloadJob(next.downloadId, next.id);
try {
const matches = await chrome.downloads.search({ id: next.downloadId });
const current = matches && matches[0];
if (current && current.state === "complete") {
await applyDownloadState(next.downloadId, { state: { current: "complete" } });
return await readJob(next.id);
}
if (current && current.state === "interrupted") {
await applyDownloadState(next.downloadId, {
state: { current: "interrupted" },
error: { current: current.error || "DOWNLOAD_INTERRUPTED" }
});
return await readJob(next.id);
}
} catch {
// The normal downloads.onChanged listener will report the result.
}
}
await notifyJob(next);
return next;
}
async function applyDownloadState(downloadId, change) {
const jobId = await findJobIdForDownload(downloadId);
if (!jobId) return;
const previous = await readJob(jobId);
if (!previous) return;
const incomingState = change && change.state && change.state.current;
const isTerminal = previous.state === "complete" || previous.state === "error";
if (isTerminal) return;
const hasByteUpdate = Boolean(change && (change.bytesReceived || change.totalBytes));
const hasProgressState = incomingState === "in_progress";
if (!incomingState && !hasByteUpdate) return;
const bytesReceived = change.bytesReceived && Number(change.bytesReceived.current);
const totalBytes = change.totalBytes && Number(change.totalBytes.current);
const next = Object.assign({}, previous);
if (Number.isFinite(bytesReceived)) next.bytesReceived = bytesReceived;
if (Number.isFinite(totalBytes)) next.totalBytes = totalBytes;
if (incomingState === "complete") {
next.state = "complete";
next.progress = 100;
next.status = "Скачивание завершено";
next.error = null;
} else if (incomingState === "interrupted") {
next.state = "error";
next.status = "Загрузка прервана Chrome";
next.error = change.error && change.error.current || "DOWNLOAD_INTERRUPTED";
} else if (hasByteUpdate || hasProgressState) {
next.state = "saving";
if (next.totalBytes > 0 && next.bytesReceived >= 0) {
next.progress = Math.min(99, 98 + Math.round((next.bytesReceived / next.totalBytes) * 1));
} else {
next.progress = Math.max(98, Number(next.progress) || 0);
}
next.status = "Сохранение файла…";
} else {
return;
}
next.updatedAt = Date.now();
if (next.state === "complete" || next.state === "error") {
recordDiagnostic(next.state === "error" ? "error" : "info", "chrome.downloads", "terminal-state", {
jobId: next.id,
downloadId,
state: next.state,
error: next.error,
filename: next.filename,
bytesReceived: next.bytesReceived,
totalBytes: next.totalBytes
});
}
await persistJob(next);
if (next.state === "complete") await rememberDownloaded(next);
await notifyJob(next);
}
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.target === "offscreen") return false;
(async () => {
switch (message && message.action) {
case "resolve":
return { ok: true, media: await resolveMedia(message.url, message.tabId) };
case "download":
return {
ok: true,
result: await startDownload(Object.assign({}, message, {
tabId: Number.isInteger(message.tabId)
? message.tabId
: (sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null)
}))
};
case "downloadCapturedItem":
return { ok: true, result: await downloadCapturedItem(message.item, sender && sender.tab ? sender.tab.id : null) };
case "getJob":
return { ok: true, job: await readJob(message.jobId) };
case "getDownloadedMedia":
return { ok: true, downloaded: await getDownloadedRecords(message.ids) };
case "diagnosticEvent":
recordDiagnostic(message.level, message.area, message.event, Object.assign({}, message.details || {}, {
tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null,
source: sender && sender.tab ? "content" : "extension-page"
}));
return { ok: true, ignored: !diagnosticsEnabled };
case "getDiagnosticLog":
if (!diagnosticsEnabled) return { ok: false, error: "Включите диагностику в настройках расширения." };
return { ok: true, diagnostics: await diagnosticRuntimeBundle() };
case "downloadDiagnosticReport":
if (!diagnosticsEnabled) return { ok: false, error: "Включите диагностику в настройках расширения." };
return { ok: true, result: await downloadDiagnosticReport(message.report) };
case "hlsProgress":
return { ok: true, job: await updateJob(message) };
case "downloadBlobUrl": {
const prefix = "blob:chrome-extension://" + chrome.runtime.id + "/";
if (!String(message.blobUrl || "").startsWith(prefix)) {
throw new Error("Отклонён недоверенный Blob URL.");
}
const downloadId = await chrome.downloads.download({
url: message.blobUrl,
filename: message.filename,
saveAs: Boolean(message.saveAs)
});
return { ok: true, downloadId };
}
default:
return { ok: false, error: "Unknown action" };
}
})().then(sendResponse).catch((error) => {
recordDiagnostic("error", "runtime.message", "action-failed", {
action: message && message.action,
error,
tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null
});
sendResponse({ ok: false, error: error && error.message ? error.message : String(error) });
});
return true;
});
chrome.downloads.onChanged.addListener((delta) => {
if (!delta || !Number.isInteger(delta.id)) return;
applyDownloadState(delta.id, delta).catch((error) => {
recordDiagnostic("error", "chrome.downloads", "state-handler-failed", {
downloadId: delta.id,
error
});
});
});
recordDiagnostic("info", "runtime", "service-worker-active", {
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null
});