Skip to content

Instantly share code, notes, and snippets.

@saas-coder
Last active October 31, 2025 09:22
Show Gist options
  • Save saas-coder/8f552de30e04f5a5321690628459fe51 to your computer and use it in GitHub Desktop.
Save saas-coder/8f552de30e04f5a5321690628459fe51 to your computer and use it in GitHub Desktop.
function initFacebookAdDownloader() {
// Queue to store pending GraphQL responses
const processingQueue = {
items: [],
isProcessing: false,
processDelay: 500, // Delay between processing items
async add(response) {
this.items.push(response);
if (!this.isProcessing) {
await this.processQueue();
}
},
async processQueue() {
if (this.items.length === 0) {
this.isProcessing = false;
return;
}
this.isProcessing = true;
const response = this.items.shift();
try {
await processGraphQLResponse(response);
// Add delay between processing items
await new Promise(resolve => setTimeout(resolve, this.processDelay));
} catch (error) {
console.error('Error processing queue item:', error);
}
// Process next item
await this.processQueue();
}
};
// Listen for GraphQL data from injected script
window.addEventListener('message', function (event) {
if (event.data.type === 'FB_AD_DATA') {
processingQueue.add(event.data.data);
}
});
function formatDuration(startTime, endTime) {
// Convert timestamps to Date objects
const start = new Date(startTime);
const end = new Date(endTime);
// Calculate the difference in milliseconds
const diff = end - start;
// Convert milliseconds to days, hours, minutes, and seconds
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
// If the duration is exactly 24 hours, return "1 day"
if (days === 1 && hours === 0 && minutes === 0 && seconds === 0) {
return "1 day";
}
// If only days are present, return immediately
if (days > 0 && hours === 0 && minutes === 0 && seconds === 0) {
return `${days} day${days !== 1 ? "s" : ""}`;
}
// Calculate total hours and round to nearest hour
const totalHours = Math.round(days * 24 + hours + minutes / 60 + seconds / 3600);
if (totalHours >= 24) {
const roundedDays = Math.round(totalHours / 24);
return `${roundedDays} day${roundedDays !== 1 ? "s" : ""}`;
} else if (totalHours > 0) {
return `${totalHours} hour${totalHours !== 1 ? "s" : ""}`;
} else {
return "1 day";
}
}
// Extract data from page scripts
function extractPageData() {
const scripts = document.querySelectorAll('script[type="application/json"][data-sjs]');
let adData = [];
scripts.forEach(script => {
try {
const jsonData = JSON.parse(script.textContent);
const edges = jsonData?.require?.[0]?.[3]?.[0]?.
__bbox?.require?.[0]?.[3]?.[1]?.
__bbox?.result?.data?.
ad_library_main?.search_results_connection?.edges;
if (edges) {
edges.forEach(edge => {
if (edge?.node?.collated_results) {
adData = adData.concat(edge.node.collated_results);
}
});
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
});
return adData;
}
// Wait for DOM to be fully loaded
function waitForDOM() {
return new Promise(resolve => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
}
// Process ads that are already on the page
async function processAdsOnPage() {
const rawData = extractPageData();
if (!rawData.length) {
return;
}
const processedAds = await processAdData(rawData);
await updateDOM(processedAds);
}
// Helper Functions
function createButtonContainer() {
const container = document.createElement('div');
container.className = 'ad-download-buttons';
container.style.cssText = `
position: relative;
display: flex;
flex-direction: row;
gap: 8px;
margin-top:-6px;
margin-right: 16px;
margin-left:16px;
`;
return container;
}
function createButton(text, onClick) {
const button = document.createElement('button');
button.textContent = text;
button.style.cssText = `
padding: 8px 12px;
background-color: rgb(156, 238, 105);
color: rgb(26, 66, 0);
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
white-space: nowrap;
text-align: center;
font-weight: 700;
width: 100%;
font-style: italic;
text-transform: uppercase;
font-family: Inter, Arial, sans-serif !important;
`;
button.addEventListener('click', onClick);
return button;
}
async function downloadFile(url, filename, button) {
const originalText = button.textContent;
button.textContent = 'Downloading...';
try {
const response = await fetch(url);
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
} catch (error) {
console.error('Download failed:', error);
alert('Download failed. The media might be protected or unavailable.');
} finally {
button.textContent = originalText;
}
}
function getFileExtension(url, fallbackType) {
const urlExtension = url.split(/[#?]/)[0].split('.').pop().trim().toLowerCase();
if (urlExtension && urlExtension.length <= 4) return urlExtension;
const typeMapping = {
'video/mp4': 'mp4',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/gif': 'gif'
};
return typeMapping[fallbackType] || 'jpg';
}
// Ad Processing Functions
async function processAdData(ads) {
return ads.map(ad => {
const processedAd = {
ad_archive_id: ad.ad_archive_id,
page_id: ad.page_id,
display_format: ad.snapshot.display_format,
start_date: new Date(ad.start_date * 1000).toISOString(),
end_date: new Date(ad.end_date * 1000).toISOString(),
};
if (ad.snapshot.display_format === 'IMAGE') {
processedAd.original_image_url = ad.snapshot.images[0]?.original_image_url || null;
processedAd.media_type = 'IMAGE';
} else if (ad.snapshot.display_format === 'VIDEO') {
const video = ad.snapshot.videos[0];
processedAd.video_hd_url = video?.video_hd_url || video?.video_sd_url || null;
processedAd.media_type = 'VIDEO';
} else if (ad.snapshot.display_format === 'DCO') {
const cardWithMedia = ad.snapshot.cards.find(
card => card.original_image_url || card.video_hd_url
);
if (cardWithMedia) {
if (cardWithMedia.original_image_url) {
processedAd.original_image_url = cardWithMedia.original_image_url;
processedAd.media_type = 'IMAGE';
} else {
processedAd.video_hd_url = cardWithMedia.video_hd_url || cardWithMedia.video_sd_url;
processedAd.media_type = 'VIDEO';
}
}
}
return processedAd;
});
}
async function processGraphQLResponse(response, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
try {
let jsonObjects = [];
if (typeof response === 'string') {
const lines = response.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
jsonObjects.push(JSON.parse(line));
} catch (e) {
console.warn('Failed to parse JSON line:', e);
}
}
} else if (typeof response === 'object') {
jsonObjects = [response];
}
for (const jsonData of jsonObjects) {
const edges = extractEdges(jsonData);
if (!edges) continue;
const newAds = extractAds(edges);
if (newAds.length === 0) continue;
const processedAds = await processAdData(newAds);
await updateDOM(processedAds);
}
} catch (error) {
console.error('Error in processGraphQLResponse:', error);
if (retryCount < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
return processGraphQLResponse(response, retryCount + 1);
}
}
}
function extractEdges(jsonData) {
if (jsonData?.label?.includes('AdLibraryMobileFocusedStateProvider')) {
return jsonData?.data?.ad_library_main?.search_results_connection?.edges;
}
return jsonData?.data?.ad_library_main?.search_results_connection?.edges;
}
function extractAds(edges) {
let ads = [];
if (!Array.isArray(edges)) return ads;
edges.forEach(edge => {
if (edge?.node?.collated_results) {
ads = ads.concat(edge.node.collated_results);
}
});
return ads;
}
// Enhanced DOM update function with retry mechanism
async function updateDOM(processedAds, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
try {
for (const adData of processedAds) {
if (!adData.ad_archive_id) {
console.warn('Ad missing archive ID:', adData);
continue;
}
const containers = findAdContainers(adData.ad_archive_id);
if (containers.length === 0 && retryCount < MAX_RETRIES) {
// If no containers found, retry after delay
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
return updateDOM(processedAds, retryCount + 1);
}
containers.forEach(container => {
if (!container.querySelector('.ad-download-buttons')) {
addDownloadButtonsToContainer(container, adData);
addDurationToContainer(container, adData);
}
});
}
} catch (error) {
console.error('Error in updateDOM:', error);
if (retryCount < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
return updateDOM(processedAds, retryCount + 1);
}
}
}
function findAdContainers(adArchiveId) {
const containers = Array.from(document.querySelectorAll('div._7jvw.x2izyaf'));
return containers.filter(container =>
container.textContent.includes(adArchiveId)
);
}
function addDurationToContainer(container, adData) {
// Check if duration or Adspo attribution already exists in this container
if (container.querySelector('.duration-added') || container.querySelector('.adspo-attribution')) {
return;
}
// Find all info divs in this specific container
const infoDivs = container.querySelectorAll('.x3nfvp2.x1e56ztr');
// Find the div containing "Started running on"
let startDateDiv = null;
for (const div of infoDivs) {
const text = div.textContent;
if (text.includes('Started running on') || text.match(/\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+-\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}/)) {
startDateDiv = div;
break;
}
}
if (!startDateDiv) return;
// Calculate duration
const duration = formatDuration(adData.start_date, adData.end_date);
// Update the start date text to include duration
const startDateSpan = startDateDiv.querySelector('span');
if (startDateSpan && !startDateDiv.classList.contains('duration-added')) {
const originalText = startDateSpan.textContent;
startDateSpan.textContent = `${originalText} (${duration})`;
startDateDiv.classList.add('duration-added'); // Mark as processed
}
// Create Adspo attribution div
const adspoDiv = document.createElement('div');
adspoDiv.className = 'x3nfvp2 x1e56ztr adspo-attribution';
adspoDiv.innerHTML = `
<span class="x8t9es0 xw23nyj xo1l8bm x63nzvj x108nfp6 xq9mrsl x1h4wwuj xeuugli" style="color:#6e6e6e; font-size:11px !important;">
Insights enhanced by <a href="https://adspo.co?ref=extension" target="_blank" rel="noopener noreferrer" style="color: #1A4200; text-decoration: underline;">Adspo</a>
</span>
`;
// Insert Adspo attribution after all the info divs
const lastInfoDiv = Array.from(infoDivs).pop();
if (lastInfoDiv) {
lastInfoDiv.after(adspoDiv);
}
}
// Modified function to ensure proper button placement
function addDownloadButtonsToContainer(adContainer, adData) {
// Early return if buttons already exist
if (adContainer.querySelector('.ad-download-buttons')) {
return;
}
// Try multiple possible selectors for the "See Details" div
const seeDetailsDiv =
adContainer.querySelector('div[role="none"]') ||
adContainer.querySelector('div._8n_0') ||
adContainer.lastElementChild;
if (!seeDetailsDiv) {
console.warn('Could not find appropriate insertion point for buttons');
return;
}
const buttonContainer = createButtonContainer();
if (adData.media_type === 'IMAGE' && adData.original_image_url) {
const button = createButton('Download', async (e) => {
e.preventDefault();
e.stopPropagation();
const extension = getFileExtension(adData.original_image_url, 'image/jpeg');
const filename = `${adData.ad_archive_id}.${extension}`;
await downloadFile(adData.original_image_url, filename, e.target);
});
buttonContainer.appendChild(button);
}
if (adData.media_type === 'VIDEO' && adData.video_hd_url) {
const button = createButton('Download', async (e) => {
e.preventDefault();
e.stopPropagation();
const extension = getFileExtension(adData.video_hd_url, 'video/mp4');
const filename = `${adData.ad_archive_id}.${extension}`;
await downloadFile(adData.video_hd_url, filename, e.target);
});
buttonContainer.appendChild(button);
}
if (buttonContainer.children.length > 0) {
// Try to insert after the "See Details" div, or as last child if that fails
try {
seeDetailsDiv.insertAdjacentElement('afterend', buttonContainer);
} catch (error) {
console.warn('Failed to insert after seeDetailsDiv, attempting fallback insertion');
adContainer.appendChild(buttonContainer);
}
}
}
// Main initialization function
async function main() {
if (!window.location.href.includes('facebook.com/ads/library')) {
console.log("This script only works on Facebook Ad Library pages.");
return;
}
console.log("Facebook Ad Library Downloader initialized!");
// Wait for DOM to be loaded before processing initial ads
await waitForDOM();
// Process initial ads from page scripts
await processAdsOnPage();
// Create observer for dynamically loaded content
const observer = new MutationObserver((mutations) => {
let shouldProcess = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length) {
const hasRelevantNodes = Array.from(mutation.addedNodes).some(node => {
return node.nodeType === 1 && (
node.classList?.contains('_7jvw') ||
node.querySelector?.('div._7jvw.x2izyaf')
);
});
if (hasRelevantNodes) {
shouldProcess = true;
break;
}
}
}
if (shouldProcess) {
processAdsOnPage();
}
});
// Start observing the document for changes
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Start the main function
main().catch(error => {
console.error("Error initializing Facebook Ad Library Downloader:", error);
});
}
// Execute the function
initFacebookAdDownloader();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment