|
/** |
|
* Discord Favorite GIF Exporter with Minted CDN Links |
|
* |
|
* HOW TO USE: |
|
* 1. Open Discord in your web browser (https://discord.com/app) or Discord Desktop app. |
|
* (If on desktop, ensure Developer Tools are enabled: Ctrl+Shift+I on Windows/Linux, Cmd+Option+I on macOS). |
|
* 2. Open the Developer Tools (F12 or Ctrl+Shift+I / Cmd+Option+I) and select the "Console" tab. |
|
* 3. Copy and paste this entire script into the console and press Enter. |
|
* 4. The script will automatically fetch your favorite GIFs, mint fresh signatures for all Discord CDN |
|
* attachments via Discord's official POST /api/v9/attachments/refresh-urls endpoint, and trigger |
|
* a download of 'discord-favorite-gifs-fresh.json'. |
|
*/ |
|
|
|
(async () => { |
|
// Configuration |
|
const OUTPUT_FILENAME = 'discord-favorite-gifs-fresh.json'; |
|
const BATCH_SIZE = 50; // Discord API allows 1 to 50 URLs per refresh request |
|
const BATCH_DELAY_MS = 250; // Delay between batches to respect rate limits |
|
const DOWNLOAD_DISCORD_FILES = false; // Set to true if you also want local file copies of attachments |
|
const FILE_DOWNLOAD_DELAY_MS = 200; |
|
|
|
console.log('%c[Discord GIF Exporter]%c Initializing...', 'color: #5865F2; font-weight: bold;', 'color: inherit;'); |
|
|
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); |
|
|
|
const root = typeof window !== 'undefined' ? window : globalThis; |
|
|
|
// Step 1: Hook Webpack to access internal Discord modules |
|
let wpRequire; |
|
if (typeof webpackChunkdiscord_app !== 'undefined') { |
|
webpackChunkdiscord_app.push([[Symbol()], {}, (e) => { wpRequire = e; return e; }]); |
|
if (!wpRequire) { |
|
webpackChunkdiscord_app.push([[Symbol()], , (e) => { wpRequire = e; return e; }]); |
|
} |
|
} |
|
|
|
let allExports = []; |
|
if (wpRequire?.c) { |
|
allExports = Object.values(wpRequire.c) |
|
.map(m => m?.exports) |
|
.filter(x => typeof x === 'object' && x && x !== root && (typeof DOMTokenList === 'undefined' || x !== DOMTokenList.prototype)) |
|
.flatMap(x => { |
|
try { |
|
return [x, ...Object.values(x)]; |
|
} catch { |
|
return [x]; |
|
} |
|
}); |
|
} |
|
|
|
// Step 2: Locate FrecencyUserSettings, Token, and HTTP module |
|
const FrecencyUserSettings = root.FrecencyUserSettings |
|
|| allExports.find(x => x?.ProtoClass?.typeName?.endsWith('FrecencyUserSettings')) |
|
|| root.Vencord?.Webpack?.findByProps?.('updateAsync', 'getCurrentValue'); |
|
|
|
if (!FrecencyUserSettings) { |
|
throw new Error('[Discord GIF Exporter] Could not find FrecencyUserSettings store. Make sure you are logged into Discord.'); |
|
} |
|
|
|
// Retrieve user authentication token |
|
let token = null; |
|
const authStore = allExports.find(x => typeof x?.getToken === 'function' && typeof x?.getId === 'function') |
|
|| allExports.find(x => typeof x?.getToken === 'function'); |
|
if (authStore) { |
|
try { token = authStore.getToken(); } catch {} |
|
} |
|
if (!token && root.Vencord?.Webpack) { |
|
try { token = root.Vencord.Webpack.findByProps('getToken')?.getToken?.(); } catch {} |
|
} |
|
if (!token) { |
|
try { |
|
const raw = root.localStorage?.getItem('token'); |
|
if (raw) token = JSON.parse(raw); |
|
} catch {} |
|
} |
|
|
|
// Retrieve internal HTTP module if available |
|
const httpModule = allExports.find(x => |
|
typeof x?.post === 'function' && |
|
typeof x?.get === 'function' && |
|
(typeof x?.put === 'function' || typeof x?.del === 'function') |
|
); |
|
|
|
console.log(`%c[Discord GIF Exporter]%c Stores located (Auth: ${token ? 'Token acquired' : 'Session credentials'}, HTTP helper: ${httpModule ? 'yes' : 'standard fetch'})`, 'color: #5865F2; font-weight: bold;', 'color: inherit;'); |
|
|
|
// Step 3: Load favorite GIFs |
|
if (typeof FrecencyUserSettings.loadIfNecessary === 'function') { |
|
await FrecencyUserSettings.loadIfNecessary(); |
|
} |
|
|
|
const currentValue = FrecencyUserSettings.getCurrentValue?.(); |
|
const sourceGifs = currentValue?.favoriteGifs?.gifs; |
|
|
|
if (!sourceGifs || Object.keys(sourceGifs).length === 0) { |
|
console.warn('[Discord GIF Exporter] No favorite GIFs found in your account.'); |
|
return; |
|
} |
|
|
|
const totalGifs = Object.keys(sourceGifs).length; |
|
console.log(`%c[Discord GIF Exporter]%c Found ${totalGifs} favorite GIFs. Analyzing URLs...`, 'color: #5865F2; font-weight: bold;', 'color: inherit;'); |
|
|
|
// Helper functions for URL analysis |
|
function isDiscordAttachmentUrl(url) { |
|
try { |
|
const u = new URL(url); |
|
if (!/(?:cdn\.discordapp\.com|media\.discordapp\.net)$/i.test(u.hostname)) return false; |
|
return /^\/(?:attachments|ephemeral-attachments)\/\d+\/\d+\//i.test(u.pathname); |
|
} catch { |
|
return false; |
|
} |
|
} |
|
|
|
function getAttachmentPath(url) { |
|
try { |
|
const u = new URL(url); |
|
return u.pathname; |
|
} catch { |
|
return url; |
|
} |
|
} |
|
|
|
// Step 4: Collect all unique Discord attachment URLs needing minting |
|
const discordUrlSet = new Set(); |
|
let externalCount = 0; |
|
|
|
for (const [key, value] of Object.entries(sourceGifs)) { |
|
let hasDiscordUrl = false; |
|
if (isDiscordAttachmentUrl(key)) { |
|
discordUrlSet.add(key); |
|
hasDiscordUrl = true; |
|
} |
|
if (value?.src && isDiscordAttachmentUrl(value.src)) { |
|
discordUrlSet.add(value.src); |
|
hasDiscordUrl = true; |
|
} |
|
if (value?.url && isDiscordAttachmentUrl(value.url)) { |
|
discordUrlSet.add(value.url); |
|
hasDiscordUrl = true; |
|
} |
|
if (!hasDiscordUrl) { |
|
externalCount++; |
|
} |
|
} |
|
|
|
const discordUrlsToRefresh = Array.from(discordUrlSet); |
|
console.log(`%c[Discord GIF Exporter]%c Breakdown: ${externalCount} external GIFs (Tenor/Giphy), ${discordUrlsToRefresh.length} Discord attachment URLs to mint.`, 'color: #5865F2; font-weight: bold;', 'color: inherit;'); |
|
|
|
// Step 5: Refresh Discord CDN URLs via POST /api/v9/attachments/refresh-urls |
|
async function refreshBatch(batchUrls, maxRetries = 3) { |
|
for (let attempt = 1; attempt <= maxRetries; attempt++) { |
|
try { |
|
// Strategy 1: Standard fetch with Authorization header |
|
if (token) { |
|
const res = await fetch('/api/v9/attachments/refresh-urls', { |
|
method: 'POST', |
|
headers: { |
|
'Authorization': token, |
|
'Content-Type': 'application/json' |
|
}, |
|
body: JSON.stringify({ attachment_urls: batchUrls }) |
|
}); |
|
|
|
if (res.status === 429) { |
|
const errData = await res.json().catch(() => ({})); |
|
const delay = (errData.retry_after ? errData.retry_after * 1000 : 1500) + 100; |
|
console.warn(`[Discord GIF Exporter] Rate limited (429). Retrying in ${(delay / 1000).toFixed(1)}s...`); |
|
await sleep(delay); |
|
continue; |
|
} |
|
|
|
if (res.ok) { |
|
return await res.json(); |
|
} |
|
} |
|
|
|
// Strategy 2: Internal HTTP module |
|
if (httpModule?.post) { |
|
try { |
|
const res = await httpModule.post({ |
|
url: '/attachments/refresh-urls', |
|
body: { attachment_urls: batchUrls } |
|
}); |
|
const data = res?.body || res; |
|
if (data?.refreshed_urls) return data; |
|
} catch (httpErr) { |
|
if (httpErr?.status === 429) { |
|
const delay = (httpErr?.body?.retry_after ? httpErr.body.retry_after * 1000 : 1500) + 100; |
|
console.warn(`[Discord GIF Exporter] Rate limited (429). Retrying in ${(delay / 1000).toFixed(1)}s...`); |
|
await sleep(delay); |
|
continue; |
|
} |
|
} |
|
} |
|
|
|
// Strategy 3: Fetch with session credentials |
|
const res = await fetch('/api/v9/attachments/refresh-urls', { |
|
method: 'POST', |
|
credentials: 'include', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ attachment_urls: batchUrls }) |
|
}); |
|
|
|
if (res.status === 429) { |
|
const errData = await res.json().catch(() => ({})); |
|
const delay = (errData.retry_after ? errData.retry_after * 1000 : 1500) + 100; |
|
console.warn(`[Discord GIF Exporter] Rate limited (429). Retrying in ${(delay / 1000).toFixed(1)}s...`); |
|
await sleep(delay); |
|
continue; |
|
} |
|
|
|
if (res.ok) { |
|
return await res.json(); |
|
} |
|
|
|
throw new Error(`HTTP ${res.status}: ${await res.text()}`); |
|
} catch (err) { |
|
if (attempt === maxRetries) { |
|
console.error(`[Discord GIF Exporter] Batch attempt ${attempt} failed:`, err); |
|
return { refreshed_urls: [] }; |
|
} |
|
await sleep(1000 * attempt); |
|
} |
|
} |
|
return { refreshed_urls: [] }; |
|
} |
|
|
|
const exactMap = new Map(); |
|
const pathMap = new Map(); |
|
|
|
if (discordUrlsToRefresh.length > 0) { |
|
const totalBatches = Math.ceil(discordUrlsToRefresh.length / BATCH_SIZE); |
|
console.log(`%c[Discord GIF Exporter]%c Minting keys for ${discordUrlsToRefresh.length} URLs across ${totalBatches} batch(es)...`, 'color: #5865F2; font-weight: bold;', 'color: inherit;'); |
|
|
|
for (let i = 0; i < discordUrlsToRefresh.length; i += BATCH_SIZE) { |
|
const batch = discordUrlsToRefresh.slice(i, i + BATCH_SIZE); |
|
const batchNum = Math.floor(i / BATCH_SIZE) + 1; |
|
console.log(`[Discord GIF Exporter] Processing batch ${batchNum}/${totalBatches} (${batch.length} URLs)...`); |
|
|
|
const result = await refreshBatch(batch); |
|
for (const entry of result?.refreshed_urls || []) { |
|
if (entry?.original && entry?.refreshed) { |
|
exactMap.set(entry.original, entry.refreshed); |
|
pathMap.set(getAttachmentPath(entry.original), entry.refreshed); |
|
} |
|
} |
|
|
|
if (i + BATCH_SIZE < discordUrlsToRefresh.length) { |
|
await sleep(BATCH_DELAY_MS); |
|
} |
|
} |
|
} |
|
|
|
function getRefreshedUrl(originalUrl) { |
|
if (!originalUrl) return originalUrl; |
|
return exactMap.get(originalUrl) || pathMap.get(getAttachmentPath(originalUrl)) || originalUrl; |
|
} |
|
|
|
// Step 6: Construct the updated export dictionary |
|
const outputGifs = {}; |
|
let refreshedCount = 0; |
|
const unrefreshed = []; |
|
|
|
for (const [key, value] of Object.entries(sourceGifs)) { |
|
const item = { ...value }; |
|
let newKey = key; |
|
|
|
if (isDiscordAttachmentUrl(key)) { |
|
const refreshedKey = getRefreshedUrl(key); |
|
if (refreshedKey !== key) { |
|
newKey = refreshedKey; |
|
} |
|
} |
|
|
|
if (item.src && isDiscordAttachmentUrl(item.src)) { |
|
const refreshedSrc = getRefreshedUrl(item.src); |
|
if (refreshedSrc !== item.src) { |
|
item.src = refreshedSrc; |
|
refreshedCount++; |
|
} else { |
|
unrefreshed.push(item.src); |
|
} |
|
} |
|
|
|
if (item.url && isDiscordAttachmentUrl(item.url)) { |
|
item.url = getRefreshedUrl(item.url); |
|
} |
|
|
|
outputGifs[newKey] = item; |
|
} |
|
|
|
// Step 7: Optional local file downloader |
|
async function downloadFile(url, filename) { |
|
const response = await fetch(url, { credentials: 'include' }); |
|
if (!response.ok) throw new Error(`Download failed (HTTP ${response.status})`); |
|
const blob = await response.blob(); |
|
const href = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = href; |
|
a.download = filename; |
|
document.body.appendChild(a); |
|
a.click(); |
|
document.body.removeChild(a); |
|
setTimeout(() => URL.revokeObjectURL(href), 1500); |
|
} |
|
|
|
if (DOWNLOAD_DISCORD_FILES) { |
|
console.log('[Discord GIF Exporter] Downloading media files locally...'); |
|
let downloaded = 0; |
|
for (const [url, item] of Object.entries(outputGifs)) { |
|
const targetUrl = item.src || url; |
|
if (isDiscordAttachmentUrl(targetUrl)) { |
|
try { |
|
const u = new URL(targetUrl); |
|
const filename = decodeURIComponent(u.pathname.split('/').pop() || `gif_${downloaded}.gif`); |
|
await downloadFile(targetUrl, filename); |
|
downloaded++; |
|
await sleep(FILE_DOWNLOAD_DELAY_MS); |
|
} catch (err) { |
|
console.warn(`[Discord GIF Exporter] Failed to download ${targetUrl}:`, err); |
|
} |
|
} |
|
} |
|
console.log(`[Discord GIF Exporter] Downloaded ${downloaded} files.`); |
|
} |
|
|
|
// Step 8: Trigger JSON download |
|
function downloadJSON(content, filename) { |
|
const json = JSON.stringify(content, null, 2); |
|
const blob = new Blob([json], { type: 'application/json' }); |
|
const href = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = href; |
|
a.download = filename; |
|
document.body.appendChild(a); |
|
a.click(); |
|
document.body.removeChild(a); |
|
setTimeout(() => URL.revokeObjectURL(href), 1500); |
|
} |
|
|
|
downloadJSON(outputGifs, OUTPUT_FILENAME); |
|
|
|
// Step 9: Report results |
|
console.log('%c[Discord GIF Exporter] Export Complete! 🎉', 'color: #57F287; font-weight: bold;'); |
|
console.table({ |
|
'Total Favorite GIFs': totalGifs, |
|
'External GIFs (Tenor/Giphy)': externalCount, |
|
'Discord CDN URLs Minted': exactMap.size, |
|
'GIFs Updated with Minted Links': refreshedCount, |
|
'Unresolved / Deleted Attachments': unrefreshed.length, |
|
'Saved Output File': OUTPUT_FILENAME |
|
}); |
|
|
|
if (unrefreshed.length > 0) { |
|
console.warn('[Discord GIF Exporter] Some Discord attachments could not be refreshed (they were likely permanently deleted from Discord):', unrefreshed); |
|
} |
|
})(); |