// ==UserScript==
// @name Facebook Marketplace: Auto Renew/Relist ALL Listings
// @namespace http://tampermonkey.net/
// @version 3.14
// @description Uses the power of heuristic processing to auto renew + relist ALL your FB Marketplace listings.
// @author Prismaris
// @match https://www.facebook.com/marketplace/you/selling*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// --- Configuration Constants ---
const ENABLE_DELETE_BUTTON = false; // Set to 'true' to show the Delete All button, 'false' to hide it.
// --- Global State ---
let isMacroRunning = false;
// Debugging report object, reset before each debug run
let debugReport = {
deleteRelistButtonsProcessed: 0,
overallThreeDotsButtonsFound: 0,
threeDotsMenusOpened: 0,
markAsSoldHeuristicApplied: 0,
renewMenuItemsDetected: 0,
deleteMenuItemsDetected: 0
};
// --- Utility Functions ---
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
/** Scrolls the window to the top of the page smoothly. */
async function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
await sleep(500); // Allow animation to start/complete
}
/**
* Scrolls the page down repeatedly to ensure all listings are loaded into the DOM.
* @param {Function} updateLoadingStatus - Callback to update the UI button text.
* @returns {Promise<void>} A promise that resolves when all listings are presumed loaded.
*/
async function loadAllListings(updateLoadingStatus) {
console.log("Starting to load all listings by scrolling...");
let lastScrollHeight = document.documentElement.scrollHeight;
let attempts = 0;
const MAX_SCROLL_ATTEMPTS = 50;
const SCROLL_PAUSE_MS = 600;
while (attempts < MAX_SCROLL_ATTEMPTS) {
updateLoadingStatus(`Loading listings (${attempts + 1}/${MAX_SCROLL_ATTEMPTS})...`);
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth'
});
await sleep(SCROLL_PAUSE_MS); // Give time for new content to load and animation
const newScrollHeight = document.documentElement.scrollHeight;
if (newScrollHeight === lastScrollHeight) {
console.log(`All listings appear to be loaded after ${attempts + 1} scrolls.`);
updateLoadingStatus("All listings loaded.");
return;
}
lastScrollHeight = newScrollHeight;
attempts++;
}
console.warn(`Stopped loading listings after ${MAX_SCROLL_ATTEMPTS} attempts. Not all listings may be loaded.`);
updateLoadingStatus("Loading timed out. May not have all listings.");
}
// --- Tampermonkey Menu Command for Debugging ---
/** Toggles the debugging mode on/off. */
function toggleDebugMode() {
let currentDebugState = GM_getValue('fbm_debug_mode', false);
let newDebugState = !currentDebugState;
GM_setValue('fbm_debug_mode', newDebugState);
alert(`Facebook Marketplace Renew/Relist Debug Mode is now ${newDebugState ? 'ON' : 'OFF'}.`);
registerDebugMenuCommand(); // Re-register to update menu text
}
/** Registers the Tampermonkey menu command for toggling debug mode. */
function registerDebugMenuCommand() {
let currentDebugState = GM_getValue('fbm_debug_mode', false);
GM_registerMenuCommand(currentDebugState ? "Disable FBMP Debug Mode" : "Enable FBMP Debug Mode", toggleDebugMode);
}
// --- Button Identification ---
/** Checks if element is a "More options" (three dots) button. */
function isThreeDotsButton(btn) {
return btn.matches('div[role="button"][aria-label*="More options"]');
}
/** Checks if element is a "Renew listing" menu item. */
function isRenewListingButton(btn) {
return btn.matches('div[role="menuitem"]') && (btn.textContent || '').includes('Renew');
}
/** Checks if element is a "Delete & Relist" button. */
function isDeleteRelistButton(btn) {
return btn.matches('div[role="button"]') && (btn.textContent || '').includes('Delete & Relist');
}
/** Checks if element is a "Delete listing" menu item. */
function isDeleteListingMenuItem(btn) {
return btn.matches('div[role="menuitem"]') && (btn.textContent || '').includes('Delete listing');
}
// --- Helper for Heuristic Identification ---
/** Extracts product title from various aria-label patterns. */
function extractTitleFromAriaLabel(ariaLabel) {
let match = ariaLabel.match(/(?:for\s|sold\s|listing\s|Share\s|options for\s)(.*)/i);
if (match && match[1]) {
return match[1].trim();
}
if (ariaLabel && ariaLabel.length > 10) { // Fallback for direct titles
return ariaLabel.trim();
}
return null;
}
// --- Core Macro Logic Functions ---
/** Clicks/detects "Delete & Relist" buttons based on debug mode. */
function clickDeleteRelistAll(currentDebugMode) {
const buttons = Array.from(document.querySelectorAll('div[role="button"][tabindex="0"]')).filter(isDeleteRelistButton);
if (currentDebugMode) {
debugReport.deleteRelistButtonsProcessed = buttons.length;
console.log(`[DEBUG] Detected ${buttons.length} "Delete & Relist" button(s). (NOT CLICKED)`);
} else {
buttons.forEach(btn => btn.click());
console.log(`Clicked ${buttons.length} "Delete & Relist" button(s).`);
}
}
/** Identifies and opens "More options" menus using a robust heuristic. */
function openAllThreeDotsMenus(currentDebugMode) {
let countOpenedMenus = 0;
const processedListingElements = new Set();
const allMoreOptionButtons = Array.from(document.querySelectorAll('div[role="button"][aria-label*="More options"]'));
if (currentDebugMode) {
debugReport.overallThreeDotsButtonsFound = allMoreOptionButtons.length;
console.log(`[DEBUG] Found ${allMoreOptionButtons.length} potential "More options" buttons across the page.`);
}
allMoreOptionButtons.forEach(moreBtn => {
const moreBtnAriaLabel = moreBtn.getAttribute('aria-label');
if (!moreBtnAriaLabel) return;
const expectedProductTitle = extractTitleFromAriaLabel(moreBtnAriaLabel);
if (!expectedProductTitle) return;
// Escape product title for use in CSS selector
const escapedProductTitle = CSS.escape(expectedProductTitle);
let currentElement = moreBtn;
let foundListingCard = null;
const MAX_TRAVERSAL_DEPTH = 10;
for (let i = 0; i < MAX_TRAVERSAL_DEPTH && currentElement && currentElement !== document.body; i++) {
const hasMatchingTitleElement = currentElement.querySelector(`[aria-label*="${escapedProductTitle}"]`);
// Check for BOTH "Mark as sold" OR "Mark out of stock" buttons
const hasMarkAsSoldOrOutOfStockButton = currentElement.querySelector(
'div[role="button"][aria-label*="Mark as sold"], ' +
'div[role="button"][aria-label*="Mark out of stock"]'
);
const hasPriceText = (currentElement.textContent || '').match(/(\$|£|€|CA\$|US\$|C\$)\s*\d+(?:[.,]\d{1,2})?/);
if (hasMatchingTitleElement && hasPriceText && hasMarkAsSoldOrOutOfStockButton) {
foundListingCard = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
if (foundListingCard && !processedListingElements.has(foundListingCard)) {
const actualMoreBtnForThisCard = foundListingCard.querySelector('div[role="button"][aria-label*="More options"]');
if (actualMoreBtnForThisCard) {
actualMoreBtnForThisCard.click();
countOpenedMenus++;
processedListingElements.add(foundListingCard);
if (currentDebugMode) {
debugReport.markAsSoldHeuristicApplied++;
console.log(`[DEBUG] Applied heuristic and opened menu for: "${expectedProductTitle}"`);
}
} else {
if (currentDebugMode) console.warn(`[DEBUG] Could not re-find moreBtn inside identified listing card for "${expectedProductTitle}".`);
}
} else {
if (currentDebugMode && !processedListingElements.has(foundListingCard)) {
let skipReasons = [];
if (!foundListingCard) {
skipReasons.push("Heuristic failed to find a valid listing card parent.");
let heuristicStatus = { title: false, soldOrOutOfStock: false, price: false };
let tempCurrent = moreBtn;
for (let k = 0; k < MAX_TRAVERSAL_DEPTH && tempCurrent && tempCurrent !== document.body; k++) {
if (!heuristicStatus.title && tempCurrent.querySelector(`[aria-label*="${escapedProductTitle}"]`)) heuristicStatus.title = true;
if (!heuristicStatus.soldOrOutOfStock && tempCurrent.querySelector('div[role="button"][aria-label*="Mark as sold"], div[role="button"][aria-label*="Mark out of stock"]')) heuristicStatus.soldOrOutOfStock = true;
if (!heuristicStatus.price && (tempCurrent.textContent || '').match(/(\$|£|€|CA\$|US\$|C\$)\s*\d+(?:[.,]\d{1,2})?/)) heuristicStatus.price = true;
if (heuristicStatus.title && heuristicStatus.soldOrOutOfStock && heuristicStatus.price) break;
tempCurrent = tempCurrent.parentElement;
}
console.log(`[DEBUG] For "${moreBtnAriaLabel}", heuristic status (Title: ${heuristicStatus.title}, Sold/OutOfStock: ${heuristicStatus.soldOrOutOfStock}, Price: ${heuristicStatus.price}).`);
}
if (foundListingCard && processedListingElements.has(foundListingCard)) { // This check is actually redundant due to outer if condition
skipReasons.push("Listing card already processed.");
}
console.log(`[DEBUG] More options button for "${moreBtnAriaLabel}" skipped. Reasons: ${skipReasons.join(" | ")}`);
}
}
});
debugReport.threeDotsMenusOpened = countOpenedMenus;
console.log(`Identified and opened ${countOpenedMenus} "More Options" (three dots) menus for listings.`);
}
/** Detects/clicks "Renew listing" options, displays debug report if in debug mode. */
function performRenewDetectionAndClicks(currentDebugMode) {
return new Promise(resolve => {
setTimeout(() => {
const buttons = Array.from(document.querySelectorAll('div[role="menuitem"][tabindex="0"]')).filter(isRenewListingButton);
if (currentDebugMode) {
debugReport.renewMenuItemsDetected = buttons.length;
console.log(`[DEBUG] Detected ${buttons.length} "Renew listing" button(s). (NOT CLICKED)`);
let message = "Facebook Marketplace Macro Debug Report (Renew/Relist):\n\n";
message += `1. "Delete & Relist" buttons detected: ${debugReport.deleteRelistButtonsProcessed}\n`;
message += `2. Total "More options" buttons found: ${debugReport.overallThreeDotsButtonsFound}\n`;
message += `3. Listing "More options" menus opened: ${debugReport.threeDotsMenusOpened}\n`;
message += `4. Heuristic successfully applied: ${debugReport.markAsSoldHeuristicApplied} times.\n`;
message += `5. "Renew listing" buttons detected: ${debugReport.renewMenuItemsDetected}\n\n`;
message += "NOTE: In Debug Mode, NO actions (renew/delete) are performed.\n" +
"Please inspect browser console for detailed logs (F12).";
alert(message); // Show popup
} else {
buttons.forEach(btn => btn.click());
console.log(`Clicked ${buttons.length} "Renew listing" option(s).`);
}
resolve();
}, 600); // Delay for menu items to render
});
}
/** Detects/clicks "Delete listing" options, displays debug report if in debug mode. */
function performDeleteDetectionAndClicks(currentDebugMode) {
return new Promise(resolve => {
setTimeout(() => {
const buttons = Array.from(document.querySelectorAll('div[role="menuitem"][tabindex="0"]')).filter(isDeleteListingMenuItem);
if (currentDebugMode) {
debugReport.deleteMenuItemsDetected = buttons.length;
console.log(`[DEBUG] Detected ${buttons.length} "Delete listing" menu item(s). (NOT CLICKED)`);
let message = "Facebook Marketplace Macro Debug Report (Delete All):\n\n";
message += `1. Total "More options" buttons found: ${debugReport.overallThreeDotsButtonsFound}\n`;
message += `2. Listing "More options" menus opened: ${debugReport.threeDotsMenusOpened}\n`;
message += `3. Heuristic successfully applied: ${debugReport.markAsSoldHeuristicApplied} times.\n`;
message += `4. "Delete listing" menu items detected: ${debugReport.deleteMenuItemsDetected}\n\n`;
message += "NOTE: In Debug Mode, NO actions (renew/delete) are performed.\n" +
"Please inspect browser console for detailed logs (F12).";
alert(message);
} else {
buttons.forEach(btn => btn.click());
console.log(`Clicked ${buttons.length} "Delete listing" menu item(s).`);
}
resolve();
}, 600); // Delay for menu items to render
});
}
/** Orchestrates normal Renew/Relist macro flow. */
async function macroFlowRenew(currentDebugMode) {
clickDeleteRelistAll(currentDebugMode);
await sleep(800);
openAllThreeDotsMenus(currentDebugMode);
await performRenewDetectionAndClicks(currentDebugMode);
await scrollToTop();
}
/** Orchestrates normal Delete All macro flow. */
async function macroFlowDelete(currentDebugMode) {
openAllThreeDotsMenus(currentDebugMode); // Open menus for deletion
await performDeleteDetectionAndClicks(currentDebugMode);
await scrollToTop();
}
/** Orchestrates the debug specific macro flow for Renew/Relist. */
async function startDebugProcessRenew() {
debugReport = { // Reset for this run
deleteRelistButtonsProcessed: 0, overallThreeDotsButtonsFound: 0, threeDotsMenusOpened: 0,
markAsSoldHeuristicApplied: 0, renewMenuItemsDetected: 0, deleteMenuItemsDetected: 0
};
console.log("Running Facebook Marketplace Debug Process (Renew/Relist)...");
const currentDebugMode = true;
clickDeleteRelistAll(currentDebugMode);
await sleep(800);
openAllThreeDotsMenus(currentDebugMode);
await performRenewDetectionAndClicks(currentDebugMode);
await scrollToTop();
}
/** Orchestrates the debug specific macro flow for Delete All. */
async function startDebugProcessDelete() {
debugReport = { // Reset for this run
deleteRelistButtonsProcessed: 0, overallThreeDotsButtonsFound: 0, threeDotsMenusOpened: 0,
markAsSoldHeuristicApplied: 0, renewMenuItemsDetected: 0, deleteMenuItemsDetected: 0
};
console.log("Running Facebook Marketplace Debug Process (Delete All)...");
const currentDebugMode = true;
openAllThreeDotsMenus(currentDebugMode);
await performDeleteDetectionAndClicks(currentDebugMode);
await scrollToTop();
}
// --- Custom Confirmation Modal (for Delete All) ---
async function showConfirmationPopup() {
return new Promise(resolve => {
const modalId = 'fbm-confirm-modal';
const existingModal = document.getElementById(modalId);
if (existingModal) existingModal.remove();
const modal = document.createElement('div');
modal.id = modalId;
Object.assign(modal.style, {
position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
backgroundColor: 'rgba(0,0,0,0.7)', display: 'flex', justifyContent: 'center',
alignItems: 'center', zIndex: '10000'
});
const content = document.createElement('div');
Object.assign(content.style, {
backgroundColor: '#fff', padding: '20px', borderRadius: '8px',
boxShadow: '0 4px 8px rgba(0,0,0,0.2)', textAlign: 'center', maxWidth: '400px',
color: '#333'
});
content.innerHTML = `
<h3 style="margin-top:0;">Are you absolutely sure you want to DELETE ALL listings?</h3>
<p style="margin-bottom:20px; font-weight:bold; color:red;">This action cannot be undone!</p>
<div style="display:flex; justify-content:space-around; gap:10px;">
<button id="fbm-confirm-no" style="padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; background-color: #f0f2f5; color: #333; font-weight: bold;">No, Cancel</button>
<button id="fbm-confirm-yes" style="padding: 10px 20px; border: none; border-radius: 4px; cursor: not-allowed; background-color: #ccc; color: #fff; font-weight: bold;">Yes, Delete (3)</button>
</div>
`;
modal.appendChild(content);
document.body.appendChild(modal);
const yesBtn = document.getElementById('fbm-confirm-yes');
const noBtn = document.getElementById('fbm-confirm-no');
noBtn.onclick = () => {
modal.remove();
resolve(false); // User chose No
};
let countdown = 3;
yesBtn.textContent = `Yes, Delete (${countdown})`;
yesBtn.disabled = true; // Ensure disabled initially
const countdownInterval = setInterval(() => {
countdown--;
if (countdown > 0) {
yesBtn.textContent = `Yes, Delete (${countdown})`;
} else {
clearInterval(countdownInterval);
yesBtn.textContent = 'Yes, Delete';
yesBtn.style.backgroundColor = '#fa3e3e'; // Red for delete
yesBtn.style.cursor = 'pointer';
yesBtn.disabled = false;
}
}, 1000);
yesBtn.onclick = () => {
if (yesBtn.disabled) return; // Prevent click before countdown ends
clearInterval(countdownInterval);
modal.remove();
resolve(true); // User chose Yes
};
});
}
// --- Macro Execution Orchestration ---
/** Prepares and runs the macro, updating UI state. */
async function prepareAndRunMacro(actionType) {
const currentDebugMode = GM_getValue('fbm_debug_mode', false);
console.log(`prepareAndRunMacro called for ${actionType}. Current debugMode: ${currentDebugMode}`);
const btn = document.getElementById(`fbm-${actionType}-all-btn`);
if (isMacroRunning) {
console.log("Macro already in progress. Please wait.");
return;
}
isMacroRunning = true;
if (btn) {
btn.textContent = 'Loading listings...';
btn.style.background = '#a9a9a9';
btn.disabled = true;
}
// --- Confirmation for Delete All (always shown) ---
if (actionType === 'delete') {
const confirmed = await showConfirmationPopup();
if (!confirmed) {
console.log("Delete All cancelled by user.");
if (btn) {
btn.textContent = 'Delete All';
btn.style.background = '#fa3e3e';
btn.disabled = false;
}
isMacroRunning = false;
return;
}
}
// --- Load all listings ---
try {
await loadAllListings((status) => {
if (btn) btn.textContent = status;
});
await sleep(2500); // Post-load sleep for DOM stability
console.log("Post-load sleep complete. Proceeding with macro.");
} catch (error) {
console.error("Error loading all listings:", error);
alert("Failed to load all listings. See console for details.");
if (btn) {
btn.textContent = 'Error Loading!';
btn.style.background = 'red';
}
isMacroRunning = false;
if (btn) btn.disabled = false;
return;
}
// --- Run the macro/debug process ---
if (btn) {
btn.textContent = currentDebugMode ? 'Running Debug...' : `Running ${actionType === 'delete' ? 'Deletion' : 'Macro'}...`;
btn.style.background = '#a9a9a9';
}
try {
if (currentDebugMode) {
if (actionType === 'delete') {
await startDebugProcessDelete();
} else {
await startDebugProcessRenew();
}
} else {
if (actionType === 'delete') {
await macroFlowDelete(currentDebugMode);
} else {
await macroFlowRenew(currentDebugMode);
}
}
if (!currentDebugMode && btn) btn.textContent = 'Done!';
} catch (error) {
console.error(`Macro execution for ${actionType} failed:`, error.message);
alert(`Macro execution for ${actionType} failed: ` + error.message);
if (btn) {
btn.textContent = 'Error!';
btn.style.background = 'red';
}
} finally {
setTimeout(() => {
if (btn) {
btn.textContent = actionType === 'delete' ? 'Delete All' : 'Renew/Relist All';
btn.style.background = actionType === 'delete' ? '#fa3e3e' : '#1877f2';
btn.disabled = false;
}
isMacroRunning = false;
}, currentDebugMode ? 5000 : 3000); // Longer delay in debug mode for popup
}
}
// --- UI Injection ---
/** Injects the UI elements (buttons and auto-run toggle) into the page. */
function addMacroUI() {
const header = Array.from(document.querySelectorAll('h1')).find(h => h.textContent.trim() === 'Your listings');
if (!header) return;
const parentSpan = header.closest('span');
if (!parentSpan || document.getElementById('fbm-renew-relist-all-btn')) return;
const container = document.createElement('div');
Object.assign(container.style, {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginTop: '8px',
marginBottom: '16px'
});
// Renew/Relist All Button
const renewBtn = document.createElement('button');
renewBtn.id = 'fbm-renew-relist-all-btn';
renewBtn.textContent = 'Renew/Relist All';
Object.assign(renewBtn.style, {
background: '#1877f2',
color: '#fff',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
fontWeight: 'bold',
cursor: 'pointer',
fontSize: '15px',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
transition: 'background 0.2s'
});
renewBtn.onmouseenter = () => { if(renewBtn.textContent === 'Renew/Relist All') renewBtn.style.background = '#165ecb'; };
renewBtn.onmouseleave = () => { if(renewBtn.textContent === 'Renew/Relist All') renewBtn.style.background = '#1877f2'; };
renewBtn.onclick = () => prepareAndRunMacro('renew');
// Delete All Button (conditional visibility)
if (ENABLE_DELETE_BUTTON) {
const deleteBtn = document.createElement('button');
deleteBtn.id = 'fbm-delete-all-btn';
deleteBtn.textContent = 'Delete All';
Object.assign(deleteBtn.style, {
background: '#fa3e3e',
color: '#fff',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
fontWeight: 'bold',
cursor: 'pointer',
fontSize: '15px',
boxShadow: '0 1px 2px rgba(0,0,0,0.04)',
transition: 'background 0.2s'
});
deleteBtn.onmouseenter = () => { if(deleteBtn.textContent === 'Delete All') deleteBtn.style.background = '#e03737'; };
deleteBtn.onmouseleave = () => { if(deleteBtn.textContent === 'Delete All') deleteBtn.style.background = '#fa3e3e'; };
deleteBtn.onclick = () => prepareAndRunMacro('delete');
container.appendChild(deleteBtn); // Only append if enabled
}
const autoRenewEnabled = GM_getValue('fbm_auto_renew_enabled', false);
const toggleLabel = document.createElement('label');
Object.assign(toggleLabel.style, {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '14px',
color: '#65676b',
cursor: 'pointer'
});
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.id = 'fbm-auto-renew-toggle';
toggle.style.accentColor = '#1877f2';
toggle.checked = autoRenewEnabled;
toggleLabel.appendChild(toggle);
toggleLabel.appendChild(document.createTextNode('Auto Run on Load'));
container.appendChild(renewBtn);
// Delete button inserted here conditionally by the 'if (ENABLE_DELETE_BUTTON)' block above
container.appendChild(toggleLabel);
parentSpan.parentNode.insertBefore(container, parentSpan.nextSibling);
toggle.addEventListener('change', function () {
GM_setValue('fbm_auto_renew_enabled', this.checked);
console.log(`Auto Run/Renew is now ${this.checked ? 'ON' : 'OFF'}.`);
});
if (autoRenewEnabled) {
console.log("Auto Run is enabled. Executing on page load...");
setTimeout(() => prepareAndRunMacro('renew'), 2000);
}
}
// --- Initialization ---
/** Waits for "Your listings" header to inject UI. */
function waitForHeaderAndInjectUI() {
const tryInject = () => addMacroUI();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', tryInject);
} else {
tryInject();
}
new MutationObserver(tryInject).observe(document.body, { childList: true, subtree: true });
}
// Start everything
registerDebugMenuCommand();
waitForHeaderAndInjectUI();
})();