Last active
October 31, 2025 09:22
-
-
Save saas-coder/8f552de30e04f5a5321690628459fe51 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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