Skip to content

Instantly share code, notes, and snippets.

@20plays
Last active October 18, 2026 22:35
Show Gist options
  • Select an option

  • Save 20plays/fbffa1bf18c1816d552926e8c54aedae to your computer and use it in GitHub Desktop.

Select an option

Save 20plays/fbffa1bf18c1816d552926e8c54aedae to your computer and use it in GitHub Desktop.
Export discord favorite GIFs (Including discord CDN)

Discord favorite GIF export

an improved version of the script from https://gist.github.com/Davr1 important change: GIFs from Discord CDN will be minted so you can access them properly for use with the bulk downloader.

How to use these scripts:

  1. Open a browser and log into your discord account.
  2. Open devtools using Ctrl+Shift+I (or Cmd ⌘ + Option ⌥ + I)
  3. Go to the console tab.
  4. Copy and paste the javascript code into the console and hit enter. You might also need to type "allow pasting" if this is your first time using snippets.
  5. Download and run the python file in the same directory that the json export is in to bulk download your gif files to your computer.
#!/usr/bin/env python3
"""
Discord Favorite GIF Bulk Downloader
Downloads GIFs exported from Discord's favorite GIFs store.
Supports two download modes:
1. 'discord': Downloads only the expiring Discord CDN attachments (cdn.discordapp.com / media.discordapp.net)
2. 'all': Downloads all GIFs (including Tenor, Giphy, and other external providers)
Usage:
python3 download_gifs.py # Interactive prompt
python3 download_gifs.py --mode discord # Download only expiring Discord CDN attachments
python3 download_gifs.py --mode all # Download all favorite GIFs
python3 download_gifs.py -m discord -o ./my_discord_gifs -t 10
"""
import argparse
import concurrent.futures
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# Headers to mimic standard browser request and prevent CDN 403 blocks
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
DEFAULT_INPUT_FILE = "discord-favorite-gifs-fresh.json"
def is_discord_cdn_attachment(url: str) -> bool:
"""Check if URL points to an expiring Discord CDN attachment."""
if not url:
return False
try:
parsed = urllib.parse.urlparse(url)
netloc = parsed.netloc.lower()
if netloc in ("cdn.discordapp.com", "media.discordapp.net"):
path = parsed.path.lower()
return path.startswith("/attachments/") or path.startswith("/ephemeral-attachments/")
return False
except Exception:
return False
def get_clean_download_url(url: str) -> str:
"""
Cleans up Discord CDN URLs to request the original file format
rather than lossy client WebP proxies when available.
"""
try:
parsed = urllib.parse.urlparse(url)
if parsed.netloc.lower() in ("cdn.discordapp.com", "media.discordapp.net"):
qs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
# Filter out client-side thumbnail/webp transformation params but preserve ex, is, hm
filtered_qs = [(k, v) for k, v in qs if k not in ("format", "animated", "width", "height")]
new_query = urllib.parse.urlencode(filtered_qs)
return urllib.parse.urlunparse(parsed._replace(query=new_query))
except Exception:
pass
return url
def sanitize_filename(name: str) -> str:
"""Sanitize filename to prevent directory traversal or invalid characters."""
# Remove any query string if present in filename
name = name.split("?")[0].split("#")[0]
# Replace illegal filesystem characters
name = re.sub(r'[\\/*?:"<>|]', "_", name)
# Strip leading/trailing spaces and dots
name = name.strip(" .")
return name or "unnamed_gif.gif"
def determine_extension(content_type: str, fallback_ext: str = ".gif") -> str:
"""Derive appropriate file extension from Content-Type header."""
if not content_type:
return fallback_ext
ct = content_type.split(";")[0].strip().lower()
mapping = {
"image/gif": ".gif",
"video/mp4": ".mp4",
"image/webp": ".webp",
"image/png": ".png",
"image/jpeg": ".jpg",
"video/webm": ".webm"
}
return mapping.get(ct, fallback_ext)
def extract_filename(url: str, item_data: dict, index: int, prefix_order: bool = False) -> str:
"""Extract and build a reasonable filename for the GIF item."""
parsed = urllib.parse.urlparse(url)
raw_name = urllib.parse.unquote(os.path.basename(parsed.path))
sanitized = sanitize_filename(raw_name)
stem, ext = os.path.splitext(sanitized)
if not ext:
ext = ".gif"
order = item_data.get("order")
if prefix_order and order is not None:
return f"{order:04d}_{stem}{ext}"
return f"{stem}{ext}"
def download_single(item_info: dict, output_dir: Path, max_retries: int = 3) -> dict:
"""Download a single GIF with retry logic."""
url = item_info["url"]
filename = item_info["filename"]
dest_path = output_dir / filename
headers = {"User-Agent": USER_AGENT, "Accept": "*/*"}
download_url = get_clean_download_url(url)
last_error = None
for attempt in range(1, max_retries + 1):
try:
req = urllib.request.Request(download_url, headers=headers)
with urllib.request.urlopen(req, timeout=15) as resp:
data = resp.read()
# Fix extension if needed based on response Content-Type
content_type = resp.headers.get("Content-Type", "")
proper_ext = determine_extension(content_type, dest_path.suffix)
if dest_path.suffix.lower() != proper_ext:
dest_path = dest_path.with_suffix(proper_ext)
with open(dest_path, "wb") as f:
f.write(data)
return {
"success": True,
"filename": dest_path.name,
"bytes": len(data),
"url": url,
"error": None
}
except urllib.error.HTTPError as e:
last_error = f"HTTP {e.code}: {e.reason}"
# If we stripped params and got 404/403, retry with original unstripped URL
if download_url != url and attempt == 1:
download_url = url
continue
if e.code == 429:
time.sleep(1.5 * attempt)
continue
except Exception as e:
last_error = str(e)
time.sleep(1.0 * attempt)
return {
"success": False,
"filename": filename,
"bytes": 0,
"url": url,
"error": last_error
}
def format_bytes(num_bytes: int) -> str:
"""Format bytes into human-readable string."""
for unit in ["B", "KB", "MB", "GB"]:
if abs(num_bytes) < 1024.0:
return f"{num_bytes:3.1f} {unit}"
num_bytes /= 1024.0
return f"{num_bytes:.1f} TB"
def main():
parser = argparse.ArgumentParser(
description="Bulk download favorite Discord GIFs from exported JSON file.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 download_gifs.py # Interactive menu
python3 download_gifs.py -m discord # Download only expiring Discord CDN attachments
python3 download_gifs.py -m all -o ./all_gifs -t 12
"""
)
parser.add_argument(
"-m", "--mode",
choices=["discord", "all"],
help="Download mode: 'discord' (only expiring Discord CDN links) or 'all' (every favorite GIF)"
)
parser.add_argument(
"-i", "--input-file",
default=DEFAULT_INPUT_FILE,
help=f"Path to the exported JSON file (default: {DEFAULT_INPUT_FILE})"
)
parser.add_argument(
"-o", "--output-dir",
help="Target folder for downloaded GIFs (default: ./downloaded_discord_gifs or ./downloaded_all_gifs)"
)
parser.add_argument(
"-t", "--threads",
type=int,
default=8,
help="Number of concurrent download threads (default: 8)"
)
parser.add_argument(
"--prefix-order",
action="store_true",
help="Prepend Discord favorite order index to filenames (e.g. 0001_duck.gif)"
)
args = parser.parse_args()
input_path = Path(args.input_file)
if not input_path.exists():
# Try finding in script's directory
script_dir = Path(__file__).parent
if (script_dir / args.input_file).exists():
input_path = script_dir / args.input_file
else:
print(f"Error: JSON file '{args.input_file}' not found.")
sys.exit(1)
with open(input_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Classify all items
discord_items = []
other_items = []
for key, value in data.items():
src = value.get("src", key)
is_discord = is_discord_cdn_attachment(src) or is_discord_cdn_attachment(key)
item_entry = {
"key": key,
"src": src,
"data": value,
"is_discord": is_discord
}
if is_discord:
discord_items.append(item_entry)
else:
other_items.append(item_entry)
total_count = len(data)
discord_count = len(discord_items)
other_count = len(other_items)
mode = args.mode
# If no mode flag was passed, show interactive menu
if not mode:
print("\n" + "=" * 55)
print(" Discord Favorite GIF Bulk Downloader")
print("=" * 55)
print(f"Loaded: {input_path.name}")
print(f"Total favorite GIFs: {total_count}")
print(f" • Expiring Discord CDN attachments : {discord_count}")
print(f" • External / permanent GIFs : {other_count} (Tenor, Giphy, etc.)")
print("\nChoose download mode:")
print(f" [1] Only expiring Discord CDN attachments ({discord_count} items)")
print(f" [2] Everything ({total_count} items)")
print(" [q] Quit")
if not sys.stdin.isatty():
# Non-interactive fallback
mode = "discord"
print(f"\nNon-interactive shell detected: defaulting to mode '{mode}'.")
else:
choice = input("\nEnter choice [1/2] (default: 1): ").strip()
if choice in ("q", "Q"):
print("Exiting.")
sys.exit(0)
elif choice == "2":
mode = "all"
else:
mode = "discord"
# Select items according to mode
if mode == "discord":
selected_entries = discord_items
default_dir = "./downloaded_discord_gifs"
mode_label = "Expiring Discord CDN attachments only"
else:
selected_entries = discord_items + other_items
default_dir = "./downloaded_all_gifs"
mode_label = "Everything (all favorite GIFs)"
output_dir_str = args.output_dir or default_dir
output_dir = Path(output_dir_str)
output_dir.mkdir(parents=True, exist_ok=True)
print("\n" + "-" * 55)
print(f"Mode : {mode_label}")
print(f"Total to fetch: {len(selected_entries)} files")
print(f"Output folder : {output_dir.resolve()}")
print(f"Concurrency : {args.threads} threads")
print("-" * 55 + "\n")
# Build queue with collision resolution for filenames
download_queue = []
used_names = set()
for i, entry in enumerate(selected_entries, 1):
target_url = entry["src"]
desired_name = extract_filename(
target_url,
entry["data"],
index=i,
prefix_order=args.prefix_order
)
stem, ext = os.path.splitext(desired_name)
candidate = desired_name
counter = 1
# Resolve collisions
while candidate.lower() in used_names:
candidate = f"{stem}_{counter}{ext}"
counter += 1
used_names.add(candidate.lower())
download_queue.append({
"url": target_url,
"filename": candidate,
"data": entry["data"]
})
# Execute downloads with thread pool
successful = 0
failed = []
total_bytes = 0
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=args.threads) as executor:
future_to_item = {
executor.submit(download_single, item, output_dir): item
for item in download_queue
}
for i, future in enumerate(concurrent.futures.as_completed(future_to_item), 1):
res = future.result()
progress = f"[{i:>{len(str(len(download_queue)))}}/{len(download_queue)}]"
if res["success"]:
successful += 1
total_bytes += res["bytes"]
print(f"{progress} ✓ Saved: {res['filename']} ({format_bytes(res['bytes'])})")
else:
failed.append(res)
print(f"{progress} ✗ Failed: {res['filename']} -> {res['error']}")
elapsed = time.time() - start_time
# Final summary report
print("\n" + "=" * 55)
print("Download Summary")
print("=" * 55)
print(f"Total processed : {len(download_queue)}")
print(f"Successfully saved: {successful}")
print(f"Failed : {len(failed)}")
print(f"Total size : {format_bytes(total_bytes)}")
print(f"Elapsed time : {elapsed:.2f}s")
print(f"Destination : {output_dir.resolve()}")
if failed:
print("\nFailed files:")
for f in failed:
print(f" • {f['filename']} ({f['error']}) - URL: {f['url']}")
print("\nDone! 🎉")
if __name__ == "__main__":
main()
/**
* 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);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment