Skip to content

Instantly share code, notes, and snippets.

@chriskyfung
Last active October 15, 2025 13:38
Show Gist options
  • Select an option

  • Save chriskyfung/6c3e97e31c71493b222e482ed1f15f56 to your computer and use it in GitHub Desktop.

Select an option

Save chriskyfung/6c3e97e31c71493b222e482ed1f15f56 to your computer and use it in GitHub Desktop.
Userscript for batch downloading Facebook photos from facebook with the post link and captions
// ==UserScript==
// @name Facebook photos bulk downloader
// @namespace https://chriskyfung.github.io
// @version 1.1.0
// @description Bulk-download photos from Facebook's photo viewer. Click "Fetch" to start downloading a batch of photos.
// @license AGPL-3.0 License
// @icon https://www.facebook.com/favicon.ico
// @author Chris KY Fung
// @match https://www.facebook.com/*/photos/*
// @match https://www.facebook.com/photo.php?*
// @match https://www.facebook.com/photo?*
// @match https://www.facebook.com/photo/*
// @grant GM_download
// @grant GM_registerMenuCommand
// ==/UserScript==
(() => {
'use strict';
// -- Configurable Defaults --
let defaultBatchSize = 3;
let isDebug = false;
GM_registerMenuCommand('Settings', () => {
// Create dialog
const dialog = document.createElement('div');
dialog.style.cssText = 'position:fixed;top:40%;left:50%;transform:translate(-50%,-50%);background:white;padding:24px 32px;z-index:9999;border-radius:10px;box-shadow:0 2px 16px rgba(0,0,0,0.2);font-size:16px;text-align:center;';
dialog.innerHTML = `
<div style="margin-bottom:16px;">
<label>
Default batch size:
<input id="batch-size-input" type="number" min="1" value="${defaultBatchSize}" style="width:60px;margin-left:8px;">
</label>
</div>
<div style="margin-bottom:16px;">
<span>Debug mode:</span>
<label style="margin-left:12px;">
<input type="radio" name="debug-mode" value="on" ${isDebug ? 'checked' : ''}> On
</label>
<label style="margin-left:12px;">
<input type="radio" name="debug-mode" value="off" ${!isDebug ? 'checked' : ''}> Off
</label>
</div>
<button id="settings-ok" style="padding:6px 18px;margin-right:12px;">OK</button>
<button id="settings-cancel" style="padding:6px 18px;">Cancel</button>
`;
document.body.appendChild(dialog);
dialog.querySelector('#settings-ok').onclick = () => {
const batchInput = dialog.querySelector('#batch-size-input');
const n = parseInt(batchInput.value, 10);
if (!isNaN(n) && n > 0) defaultBatchSize = n;
const debugValue = dialog.querySelector('input[name="debug-mode"]:checked').value;
isDebug = debugValue === 'on';
dialog.remove();
alert('Settings updated.');
};
dialog.querySelector('#settings-cancel').onclick = () => {
dialog.remove();
};
});
// -- Selectors & State --
const MAIN_CONTENT_SELECTOR = '[role="main"]';
const NEXT_BTN_SELECTOR = '[aria-label="下一張相片"]';
const BULK_FETCH_BTN_ID = 'fb-bulk-fetch';
const BULK_CANCEL_BTN_ID = 'fb-bulk-cancel';
const IMG_SELECTOR = 'img[data-visualcompletion]';
let bulkFetchBtn = null;
let bulkCancelBtn = null;
let batchSize = defaultBatchSize;
let count = 0;
let isRunning = false;
let isCancelled = false;
// -- Utility: Wait for an element or timeout --
function waitFor(selector, timeout = 5000) {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeout;
(function poll() {
const el = document.querySelector(selector);
if (el) return resolve(el);
if (Date.now() > deadline) return reject(`Timeout waiting for ${selector}`);
setTimeout(poll, 200);
})();
});
}
// -- Insert "Fetch" button when photo dialog appears --
function initFetchButton() {
const observer = new MutationObserver(() => {
const mainContent = document.querySelector(MAIN_CONTENT_SELECTOR);
if (mainContent) {
injectUI();
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial injection in case the element is already present
injectUI();
}
// -- Create UI: Fetch, Cancel & Progress Indicator --
function injectUI() {
const mainContent = document.querySelector(MAIN_CONTENT_SELECTOR);
if (!mainContent) {
if (isDebug) console.log('Main content not found, waiting...');
return;
}
const toolbar = mainContent.firstChild.lastChild.firstChild;
if (!toolbar) {
if (isDebug) console.log('Toolbar not found, waiting...');
return;
}
// Prevent duplicate button
if (toolbar.querySelector(`#${BULK_FETCH_BTN_ID}`)) {
bulkFetchBtn = toolbar.querySelector(`#${BULK_FETCH_BTN_ID}`);
bulkCancelBtn = toolbar.querySelector(`#${BULK_CANCEL_BTN_ID}`);
return;
}
const fetchBtn = document.createElement("div");
fetchBtn.id = BULK_FETCH_BTN_ID;
fetchBtn.role = 'button';
fetchBtn.ariaLabel = 'Fetch photos';
fetchBtn.textContent = isRunning ? `⬇ ${count + 1}/${batchSize}` : '⬇ Fetch';
fetchBtn.style.cssText = `
margin-left: 4px;
padding: 6px 16px;
background: #1877f2;
opacity: ${isRunning ? '1' : '0.5'};
color: #fff;
border: none;
border-radius: 15px;
align-self:center;
cursor: pointer;
font-size: 14px;
transition: opacity 0.2s;
`;
fetchBtn.disabled = isRunning;
fetchBtn.addEventListener('mouseenter', () => {
fetchBtn.style.opacity = '0.6';
});
fetchBtn.addEventListener('mouseleave', () => {
fetchBtn.style.opacity = '0.5';
});
const cancelBtn = document.createElement('div');
cancelBtn.id = BULK_CANCEL_BTN_ID;
cancelBtn.role = 'button';
cancelBtn.ariaLabel = 'Cancel download';
cancelBtn.textContent = '✕ Cancel';
cancelBtn.style.cssText = `
margin-left: 4px;
padding: 6px 16px;
background: #f04747;
color: #fff;
border: none;
border-radius: 15px;
cursor: pointer;
align-self:center;
font-size: 14px;
display: ${isRunning ? 'block' : 'none'};
`;
fetchBtn.addEventListener("click", () => {
if (isDebug) console.log("Fetch button clicked");
batchSize = parseInt(prompt("How many photos to fetch:", defaultBatchSize), 10) || defaultBatchSize;
startBatch(batchSize);
});
cancelBtn.addEventListener('click', () => {
if (isDebug) console.log("Cancel button clicked");
isCancelled = true;
cancelBtn.style.display = 'none';
fetchBtn.textContent = '⬇ Fetch';
});
toolbar.prepend(fetchBtn);
toolbar.prepend(cancelBtn);
bulkFetchBtn = fetchBtn;
bulkCancelBtn = cancelBtn;
};
// -- Get button by ID with error handling --
async function getButton(id) {
const button = await waitFor(`#${id}`);
if (!button) {
console.error(`${id} not found, cannot continue.`);
alert(`${id} not found. Please reload the page.`);
return;
}
return button;
}
// -- Download Logic --
async function downloadImage(url) {
const filename = extractFilename(url);
await GM_download({ 'url': url, 'name': filename, 'saveAs': false });
}
// -- Helper: Extract filename from URL --
function extractFilename(url) {
// Extract filename via URL API
const path = new URL(url).pathname; // e.g. "/photos/a.123.456.jpg"
return path.substring(path.lastIndexOf('/') + 1);
}
// -- Get current image URL --
async function getImageUrl() {
const img = await waitFor(IMG_SELECTOR);
return img.src;
};
// -- Click "Next" button to navigate to the next photo --
async function clickNextPhoto() {
const nextBtn = await waitFor(NEXT_BTN_SELECTOR);
if (!nextBtn) throw new Error("Next button not found");
nextBtn.click();
}
// -- Batch Download Logic --
async function startBatch(total) {
isRunning = true;
isCancelled = false;
bulkFetchBtn.style.opacity = '1';
count = 0;
try {
for (; count < total; count++) {
if (isCancelled) break;
if (isDebug) console.log(`Processing photo ${count + 1}/${total}`);
// Wait for the image to load
const url = await getImageUrl();
if (!url) throw new Error('Image URL not found');
// Download the image
await downloadImage(url);
// Update UI
bulkFetchBtn.textContent = `⬇ ${count + 1}/${total}`;
// Click "Next" and wait a moment
await clickNextPhoto();
await new Promise(res => setTimeout(res, 600));
}
} catch (error) {
console.error("Error during batch processing:", error);
alert("An error occurred while processing the images. Please try again.");
} finally {
// Reset count after batch completion
isRunning = false;
isCancelled = false;
bulkFetchBtn.disabled = false;
bulkFetchBtn.textContent = '⬇ Fetch';
bulkCancelBtn.style.display = 'none';
}
};
// -- Initialize on page load --
initFetchButton();
})();
@udara86
Copy link

udara86 commented Mar 1, 2025 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment