621 lines
19 KiB
JavaScript
621 lines
19 KiB
JavaScript
(() => {
|
||
if (window.__fbActivityCleanerLoaded) return;
|
||
window.__fbActivityCleanerLoaded = true;
|
||
|
||
const DEFAULT_SKIP =
|
||
"tesla, ford, pokemon, pokémon, pika, pokejohn, pokemongo, pokemon go";
|
||
const DEFAULT_OFFENSIVE =
|
||
"kill yourself, kys, rape, nigger, nigga, faggot, retard, whore, slut, cunt, " +
|
||
"go die, hope you die, piece of shit, fucking idiot, dumb bitch, dumbass, " +
|
||
"get railed, shitty people, get fucked";
|
||
|
||
const KEEP_POST_RE =
|
||
/profile picture|cover photo|added \d+ new photo|added a new photo|uploaded a photo|uploaded \d+ photos|posted a photo|posted \d+ photos/i;
|
||
|
||
const state = {
|
||
running: false,
|
||
mode: "comments", // comments | posts
|
||
deleted: 0,
|
||
skipped: 0,
|
||
offensiveForced: 0,
|
||
errors: 0,
|
||
lastAction: "Idle",
|
||
delayMs: 2500,
|
||
failStreak: 0,
|
||
};
|
||
|
||
let stopFlag = false;
|
||
const countedSkipLabels = new Set();
|
||
|
||
function sleep(ms) {
|
||
return new Promise((r) => setTimeout(r, ms));
|
||
}
|
||
|
||
function parseList(text) {
|
||
return String(text || "")
|
||
.split(/[\n,]+/)
|
||
.map((s) => s.trim().toLowerCase())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function escapeRegExp(s) {
|
||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function matchesAny(haystack, terms) {
|
||
const h = haystack || "";
|
||
return terms.some((t) => {
|
||
if (!t) return false;
|
||
try {
|
||
const re = new RegExp(`(^|[^a-z0-9])${escapeRegExp(t)}([^a-z0-9]|$)`, "i");
|
||
return re.test(h);
|
||
} catch {
|
||
return h.toLowerCase().includes(t.toLowerCase());
|
||
}
|
||
});
|
||
}
|
||
|
||
function pageText() {
|
||
return document.body?.innerText || "";
|
||
}
|
||
|
||
function isActivityLog() {
|
||
return /\/allactivity/.test(location.pathname + location.search) ||
|
||
pageText().includes("Activity log");
|
||
}
|
||
|
||
function detectMode() {
|
||
const q = location.search;
|
||
if (/MANAGEPOSTSPHOTOSANDVIDEOS/i.test(q)) return "posts";
|
||
if (/COMMENTSCLUSTER/i.test(q)) return "comments";
|
||
const h = pageText();
|
||
if (/Your posts, photos and videos/i.test(h)) return "posts";
|
||
if (/\bComments\b/.test(h) && /Activity log/i.test(h)) return "comments";
|
||
return state.mode;
|
||
}
|
||
|
||
function moreButtons() {
|
||
// Never stamp dataset flags on these nodes — Facebook/React reuses them
|
||
// after a delete, which made us skip every other item.
|
||
return Array.from(
|
||
document.querySelectorAll('[aria-label^="More options"]')
|
||
);
|
||
}
|
||
|
||
/** Tight row text only — walking too far up pulls in the whole list. */
|
||
function rowRoot(btn) {
|
||
return (
|
||
btn.closest('[role="article"]') ||
|
||
btn.closest('[role="listitem"]') ||
|
||
btn.closest("li") ||
|
||
btn.parentElement?.parentElement?.parentElement ||
|
||
btn.parentElement
|
||
);
|
||
}
|
||
|
||
function rowText(btn) {
|
||
const label = btn.getAttribute("aria-label") || "";
|
||
const root = rowRoot(btn);
|
||
const block = (root && root.innerText) || "";
|
||
return `${label}\n${block.slice(0, 500)}`;
|
||
}
|
||
|
||
function robustClick(el) {
|
||
if (!el) return false;
|
||
try {
|
||
// Single click only — double-firing confuses Facebook and triggers errors
|
||
if (typeof el.click === "function") el.click();
|
||
else {
|
||
el.dispatchEvent(
|
||
new MouseEvent("click", { bubbles: true, cancelable: true, view: window })
|
||
);
|
||
}
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function clickText(text, root = document) {
|
||
const els = Array.from(
|
||
root.querySelectorAll('[role="button"], button, [role="menuitem"], span')
|
||
);
|
||
const el = els.find((e) => (e.innerText || "").trim() === text);
|
||
if (el) {
|
||
robustClick(el);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function hasFbError() {
|
||
const t = pageText();
|
||
return (
|
||
/Something went wrong/i.test(t) ||
|
||
/Try again later/i.test(t)
|
||
);
|
||
}
|
||
|
||
function isProcessing() {
|
||
const t = pageText();
|
||
return /still processing your changes/i.test(t) ||
|
||
/We're still processing/i.test(t);
|
||
}
|
||
|
||
function dismissErrorDialogs() {
|
||
let dismissed = false;
|
||
const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'));
|
||
for (const d of dialogs) {
|
||
const txt = d.innerText || "";
|
||
if (
|
||
/Something went wrong|Please try again|Try again later/i.test(txt)
|
||
) {
|
||
const ok =
|
||
Array.from(d.querySelectorAll('[role="button"], button')).find((b) =>
|
||
/^(OK|Close|Got it|Dismiss)$/i.test((b.innerText || "").trim())
|
||
) || d.querySelector('[aria-label="Close"]');
|
||
if (ok) {
|
||
robustClick(ok);
|
||
dismissed = true;
|
||
}
|
||
}
|
||
}
|
||
// Toast / non-dialog banners
|
||
if (/Try again later|Something went wrong/i.test(pageText())) {
|
||
if (clickText("OK")) dismissed = true;
|
||
const closes = Array.from(
|
||
document.querySelectorAll('[aria-label="Close"]')
|
||
);
|
||
closes.slice(0, 3).forEach((c) => robustClick(c));
|
||
if (closes.length) dismissed = true;
|
||
}
|
||
return dismissed;
|
||
}
|
||
|
||
async function waitWhileProcessing(maxMs = 120000) {
|
||
const start = Date.now();
|
||
while (isProcessing() && !stopFlag && Date.now() - start < maxMs) {
|
||
log("Waiting for Facebook to finish processing…");
|
||
await sleep(3000);
|
||
}
|
||
}
|
||
|
||
async function backoffOnError(reason) {
|
||
state.failStreak++;
|
||
state.errors++;
|
||
dismissErrorDialogs();
|
||
const pauseSec = Math.min(300, 60 * state.failStreak); // 1m, 2m, 3m… max 5m
|
||
log(`${reason} — cooling down ${pauseSec}s (streak ${state.failStreak})`);
|
||
// Slow future deletes too
|
||
state.delayMs = Math.min(15000, state.delayMs + 1000);
|
||
const delayInput = document.getElementById("fb-cleaner-delay");
|
||
if (delayInput) delayInput.value = String(state.delayMs);
|
||
const end = Date.now() + pauseSec * 1000;
|
||
while (!stopFlag && Date.now() < end) {
|
||
await sleep(1000);
|
||
}
|
||
dismissErrorDialogs();
|
||
}
|
||
|
||
async function confirmDialogIfAny() {
|
||
await sleep(500);
|
||
const dialog = document.querySelector('[role="dialog"]');
|
||
if (!dialog) return true;
|
||
const txt = dialog.innerText || "";
|
||
if (/Something went wrong|Please try again|Try again later/i.test(txt)) {
|
||
return false;
|
||
}
|
||
const btns = Array.from(dialog.querySelectorAll('[role="button"], button'));
|
||
const conf = btns.find((b) =>
|
||
/^(Move to Trash|Trash|Delete|Remove|Confirm)$/i.test(
|
||
(b.innerText || "").trim()
|
||
)
|
||
);
|
||
if (conf) {
|
||
robustClick(conf);
|
||
await sleep(900);
|
||
return !hasFbError();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function openMenuAndAct(btn, actionLabels) {
|
||
btn.scrollIntoView({ block: "center", behavior: "instant" });
|
||
await sleep(300);
|
||
robustClick(btn);
|
||
await sleep(550);
|
||
|
||
let item = null;
|
||
for (let attempt = 0; attempt < 8 && !item; attempt++) {
|
||
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||
item = items.find((m) => {
|
||
const t = (m.innerText || m.getAttribute("aria-label") || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
return actionLabels.some((a) => t.includes(a.toLowerCase()));
|
||
});
|
||
if (!item) await sleep(180);
|
||
}
|
||
|
||
if (!item) {
|
||
document.body.click();
|
||
await sleep(200);
|
||
return false;
|
||
}
|
||
robustClick(item);
|
||
const confirmed = await confirmDialogIfAny();
|
||
await sleep(1000);
|
||
if (!confirmed || hasFbError()) return false;
|
||
return true;
|
||
}
|
||
|
||
function log(line) {
|
||
state.lastAction = line;
|
||
const el = document.getElementById("fb-cleaner-log");
|
||
if (!el) return;
|
||
const ts = new Date().toLocaleTimeString();
|
||
el.textContent = `[${ts}] ${line}\n` + el.textContent.slice(0, 1800);
|
||
updateStats();
|
||
}
|
||
|
||
function updateStats() {
|
||
const el = document.getElementById("fb-cleaner-stats");
|
||
if (!el) return;
|
||
el.innerHTML =
|
||
`<div>Status: <b>${state.running ? "Running" : "Stopped"}</b> · Mode: <b>${state.mode}</b></div>` +
|
||
`<div>Deleted: <b>${state.deleted}</b> · Skipped: <b>${state.skipped}</b></div>` +
|
||
`<div>Offensive forced: <b>${state.offensiveForced}</b> · Errors: <b>${state.errors}</b></div>` +
|
||
`<div>Last: ${escapeHtml(state.lastAction)}</div>`;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
function classify(btn, skipTerms, offensiveTerms) {
|
||
const label = btn.getAttribute("aria-label") || "";
|
||
const text = rowText(btn);
|
||
// Community skip must use the button label only — row blocks are huge and
|
||
// include neighboring Tesla/Ford/Pokemon items, which false-skipped everything.
|
||
const skipCommunity = matchesAny(label, skipTerms);
|
||
const offensive = matchesAny(text, offensiveTerms);
|
||
const keepPost = KEEP_POST_RE.test(label);
|
||
|
||
if (state.mode === "posts" && keepPost) {
|
||
return { action: "skip", reason: "keep photo/profile", offensive: false };
|
||
}
|
||
|
||
// Always delete offensive comments, even on Tesla/Ford/Pokemon pages
|
||
if (offensive && state.mode === "comments") {
|
||
return { action: "delete", reason: "offensive", offensive: true };
|
||
}
|
||
|
||
if (skipCommunity) {
|
||
return { action: "skip", reason: "skip community", offensive: false };
|
||
}
|
||
|
||
return { action: "delete", reason: "cleanup", offensive: false };
|
||
}
|
||
|
||
async function waitForRowGone(btn, labelBefore, maxMs = 5000) {
|
||
const start = Date.now();
|
||
while (!stopFlag && Date.now() - start < maxMs) {
|
||
if (!btn.isConnected) return true;
|
||
const current = btn.getAttribute("aria-label") || "";
|
||
// React reused the node for a different row
|
||
if (current && current !== labelBefore) return true;
|
||
// Same label still present somewhere is OK if this node is gone;
|
||
// if this node still has the label, keep waiting briefly
|
||
await sleep(200);
|
||
}
|
||
return !btn.isConnected || (btn.getAttribute("aria-label") || "") !== labelBefore;
|
||
}
|
||
|
||
async function processOne(skipTerms, offensiveTerms) {
|
||
if (hasFbError()) {
|
||
await backoffOnError("Facebook error");
|
||
return "rate-limit";
|
||
}
|
||
await waitWhileProcessing();
|
||
if (stopFlag) return "stopped";
|
||
|
||
const btns = moreButtons();
|
||
if (!btns.length) return "empty";
|
||
|
||
let chose = null;
|
||
let classification = null;
|
||
let skippedNow = 0;
|
||
for (const btn of btns) {
|
||
const c = classify(btn, skipTerms, offensiveTerms);
|
||
if (c.action === "skip") {
|
||
const lbl = btn.getAttribute("aria-label") || "";
|
||
if (lbl && !countedSkipLabels.has(lbl)) {
|
||
countedSkipLabels.add(lbl);
|
||
state.skipped++;
|
||
skippedNow++;
|
||
} else if (!lbl) {
|
||
skippedNow++;
|
||
}
|
||
continue;
|
||
}
|
||
chose = btn;
|
||
classification = c;
|
||
break;
|
||
}
|
||
|
||
if (!chose) {
|
||
if (skippedNow) {
|
||
log(`Skipped ${skippedNow} visible (Tesla/Ford/Pokémon/keep) — scrolling`);
|
||
}
|
||
window.scrollBy(0, 2200);
|
||
await sleep(1100);
|
||
const after = moreButtons();
|
||
if (after.length === 0) return "exhausted-visible";
|
||
return "scrolled";
|
||
}
|
||
|
||
if (skippedNow) {
|
||
log(`Passing ${skippedNow} skip-community item(s)`);
|
||
}
|
||
|
||
const labelBefore = chose.getAttribute("aria-label") || "";
|
||
const label = labelBefore.slice(0, 90);
|
||
log(`Deleting: ${label}`);
|
||
const ok = await openMenuAndAct(
|
||
chose,
|
||
state.mode === "posts"
|
||
? ["move to trash", "delete", "trash"]
|
||
: ["delete", "remove"]
|
||
);
|
||
|
||
if (!ok || hasFbError()) {
|
||
await backoffOnError(hasFbError() ? "Something went wrong" : "Delete failed");
|
||
return "rate-limit";
|
||
}
|
||
|
||
await waitForRowGone(chose, labelBefore);
|
||
|
||
if (hasFbError()) {
|
||
await backoffOnError("Something went wrong");
|
||
return "rate-limit";
|
||
}
|
||
|
||
state.deleted++;
|
||
state.failStreak = 0;
|
||
if (classification.offensive) state.offensiveForced++;
|
||
log(
|
||
`${classification.offensive ? "Offensive " : ""}Deleted: ${label}`
|
||
);
|
||
await sleep(state.delayMs);
|
||
return "deleted";
|
||
}
|
||
|
||
async function runLoop() {
|
||
const skipTerms = parseList(
|
||
document.getElementById("fb-cleaner-skip")?.value || DEFAULT_SKIP
|
||
);
|
||
const offensiveTerms = parseList(
|
||
document.getElementById("fb-cleaner-offensive")?.value || DEFAULT_OFFENSIVE
|
||
);
|
||
const delayRaw = Number(
|
||
document.getElementById("fb-cleaner-delay")?.value || 2500
|
||
);
|
||
// Migrate old second-based values (< 100) to ms
|
||
state.delayMs =
|
||
delayRaw > 0 && delayRaw < 100
|
||
? Math.round(delayRaw * 1000)
|
||
: Math.max(0, Math.round(delayRaw || 2500));
|
||
const delayInput = document.getElementById("fb-cleaner-delay");
|
||
if (delayInput) delayInput.value = String(state.delayMs);
|
||
state.failStreak = 0;
|
||
countedSkipLabels.clear();
|
||
|
||
state.mode = detectMode();
|
||
state.running = true;
|
||
stopFlag = false;
|
||
updateStats();
|
||
setButtons(true);
|
||
log(`Started in ${state.mode} mode · ${state.delayMs}ms between deletes`);
|
||
|
||
let emptyStreak = 0;
|
||
while (!stopFlag) {
|
||
if (!isActivityLog()) {
|
||
log("Not on Activity Log — open Comments or Posts manage mode");
|
||
break;
|
||
}
|
||
state.mode = detectMode();
|
||
const result = await processOne(skipTerms, offensiveTerms);
|
||
if (stopFlag) break;
|
||
|
||
if (result === "empty" || result === "exhausted-visible") {
|
||
emptyStreak++;
|
||
window.scrollBy(0, 2800);
|
||
await sleep(1400);
|
||
if (moreButtons().length === 0) {
|
||
if (emptyStreak === 2) {
|
||
window.scrollTo(0, 0);
|
||
await sleep(1000);
|
||
}
|
||
}
|
||
if (emptyStreak >= 6) {
|
||
log("No more deletable items on this page");
|
||
break;
|
||
}
|
||
} else if (result !== "rate-limit") {
|
||
emptyStreak = 0;
|
||
}
|
||
}
|
||
|
||
state.running = false;
|
||
setButtons(false);
|
||
log("Stopped");
|
||
updateStats();
|
||
}
|
||
|
||
function setButtons(running) {
|
||
const start = document.getElementById("fb-cleaner-start");
|
||
const stop = document.getElementById("fb-cleaner-stop");
|
||
if (start) start.disabled = running;
|
||
if (stop) stop.disabled = !running;
|
||
}
|
||
|
||
function ensurePanel() {
|
||
if (document.getElementById("fb-cleaner-root")) return;
|
||
|
||
const root = document.createElement("div");
|
||
root.id = "fb-cleaner-root";
|
||
root.innerHTML = `
|
||
<div id="fb-cleaner-panel">
|
||
<div id="fb-cleaner-header">
|
||
<strong>FB Activity Cleaner</strong>
|
||
<button id="fb-cleaner-minimize" title="Collapse">–</button>
|
||
</div>
|
||
<div id="fb-cleaner-body">
|
||
<label>
|
||
<input type="checkbox" id="fb-cleaner-auto-mode" checked />
|
||
Auto-detect Comments vs Posts page
|
||
</label>
|
||
<div>
|
||
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px">Skip communities (unless offensive)</div>
|
||
<textarea id="fb-cleaner-skip">${DEFAULT_SKIP}</textarea>
|
||
</div>
|
||
<div>
|
||
<div style="font-size:11px;color:#9ca3af;margin-bottom:4px">Offensive phrases — always delete</div>
|
||
<textarea id="fb-cleaner-offensive">${DEFAULT_OFFENSIVE}</textarea>
|
||
</div>
|
||
<label style="align-items:center">
|
||
Delay until next delete (ms)
|
||
<input type="number" id="fb-cleaner-delay" min="0" max="60000" step="50" value="2500" style="width:80px;margin-left:auto;border-radius:6px;border:1px solid #4b5563;background:#1f2937;color:#fff;padding:4px 6px" />
|
||
</label>
|
||
<div id="fb-cleaner-stats"></div>
|
||
<div id="fb-cleaner-log"></div>
|
||
<div id="fb-cleaner-actions">
|
||
<button id="fb-cleaner-start" type="button">Start deletion</button>
|
||
<button id="fb-cleaner-stop" type="button" disabled>Stop</button>
|
||
</div>
|
||
<div style="font-size:11px;color:#9ca3af;line-height:1.35">
|
||
Keeps profile/cover/photo uploads on Posts. Skips Tesla/Ford/Pokémon unless offensive. Slows down automatically if Facebook rate-limits.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.documentElement.appendChild(root);
|
||
|
||
document.getElementById("fb-cleaner-start").onclick = () => {
|
||
if (state.running) return;
|
||
runLoop();
|
||
};
|
||
document.getElementById("fb-cleaner-stop").onclick = () => {
|
||
stopFlag = true;
|
||
log("Stop requested…");
|
||
};
|
||
document.getElementById("fb-cleaner-minimize").onclick = () => {
|
||
root.classList.toggle("collapsed");
|
||
};
|
||
|
||
// drag
|
||
const header = document.getElementById("fb-cleaner-header");
|
||
let dragging = false;
|
||
let ox = 0;
|
||
let oy = 0;
|
||
header.addEventListener("mousedown", (e) => {
|
||
dragging = true;
|
||
const rect = root.getBoundingClientRect();
|
||
ox = e.clientX - rect.left;
|
||
oy = e.clientY - rect.top;
|
||
e.preventDefault();
|
||
});
|
||
window.addEventListener("mousemove", (e) => {
|
||
if (!dragging) return;
|
||
root.style.left = Math.max(0, e.clientX - ox) + "px";
|
||
root.style.top = Math.max(0, e.clientY - oy) + "px";
|
||
root.style.right = "auto";
|
||
root.style.bottom = "auto";
|
||
});
|
||
window.addEventListener("mouseup", () => {
|
||
dragging = false;
|
||
});
|
||
|
||
chrome.storage?.local?.get(["skip", "offensive", "delay"], (data) => {
|
||
if (data?.skip) {
|
||
document.getElementById("fb-cleaner-skip").value = data.skip;
|
||
}
|
||
if (data?.offensive) {
|
||
document.getElementById("fb-cleaner-offensive").value = data.offensive;
|
||
}
|
||
if (data?.delay != null && data.delay !== "") {
|
||
const n = Number(data.delay);
|
||
const ms = n > 0 && n < 100 ? Math.round(n * 1000) : Math.round(n);
|
||
document.getElementById("fb-cleaner-delay").value = String(
|
||
Number.isFinite(ms) ? ms : 2500
|
||
);
|
||
}
|
||
});
|
||
|
||
const persist = () => {
|
||
chrome.storage?.local?.set({
|
||
skip: document.getElementById("fb-cleaner-skip").value,
|
||
offensive: document.getElementById("fb-cleaner-offensive").value,
|
||
delay: document.getElementById("fb-cleaner-delay").value,
|
||
});
|
||
};
|
||
document.getElementById("fb-cleaner-skip").addEventListener("change", persist);
|
||
document
|
||
.getElementById("fb-cleaner-offensive")
|
||
.addEventListener("change", persist);
|
||
document
|
||
.getElementById("fb-cleaner-delay")
|
||
.addEventListener("change", persist);
|
||
|
||
updateStats();
|
||
log("Panel ready — click Start deletion");
|
||
}
|
||
|
||
function startFromExternal() {
|
||
ensurePanel();
|
||
if (state.running) return { ok: true, alreadyRunning: true };
|
||
runLoop();
|
||
return { ok: true };
|
||
}
|
||
|
||
function stopFromExternal() {
|
||
stopFlag = true;
|
||
log("Stop requested…");
|
||
return { ok: true };
|
||
}
|
||
|
||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||
if (!msg || !msg.type) return false;
|
||
if (msg.type === "FB_CLEANER_PING") {
|
||
sendResponse({ ok: true });
|
||
return false;
|
||
}
|
||
if (msg.type === "FB_CLEANER_START") {
|
||
sendResponse(startFromExternal());
|
||
return false;
|
||
}
|
||
if (msg.type === "FB_CLEANER_STOP") {
|
||
sendResponse(stopFromExternal());
|
||
return false;
|
||
}
|
||
if (msg.type === "FB_CLEANER_STATUS") {
|
||
sendResponse({ ok: true, state: { ...state } });
|
||
return false;
|
||
}
|
||
return false;
|
||
});
|
||
|
||
function boot() {
|
||
ensurePanel();
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", boot);
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|