Last active
June 16, 2026 06:32
-
-
Save LawrenceHwang/5967d5c834869339338fb1f1673a8ce9 to your computer and use it in GitHub Desktop.
instagram-quick-actions
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
| // ==UserScript== | |
| // @name Instagram Quick Actions | |
| // @namespace https://www.instagram.com/ | |
| // @version 0.6.3 | |
| // @description Adds "Not Interested" and "Block Account" shortcut buttons to every Instagram feed post. | |
| // @match https://www.instagram.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| const INJECTED_ATTR = "data-iq-injected"; | |
| const OWN_ATTR = "data-iq-control"; | |
| let actionInProgress = false; | |
| // ── DOM helpers ──────────────────────────────────────────────────────────── | |
| /** | |
| * Find the first interactive element whose trimmed text matches needle. | |
| * caseless defaults to true; partial defaults to false (exact match). | |
| */ | |
| // Broad selector: catches buttons, roleified divs/spans, and bare list items | |
| // Instagram's bottom-sheet items vary across A/B versions. | |
| const INTERACTIVE_SEL = | |
| 'button, [role="button"], [role="menuitem"], [role="option"], [role="listitem"], li'; | |
| const ACTIONABLE_SEL = INTERACTIVE_SEL + ', [tabindex="0"], [tabindex="-1"], a'; | |
| function normalizeText(value, caseless = true) { | |
| const text = (value || "").trim().replace(/\s+/g, " "); | |
| return caseless ? text.toLowerCase() : text; | |
| } | |
| function scoreTextCandidate(el, matchText, wantsPartial) { | |
| const normalized = normalizeText(el.textContent); | |
| if (!normalized) return -1; | |
| const isExact = normalized === matchText; | |
| const isMatch = wantsPartial ? normalized.includes(matchText) : isExact; | |
| if (!isMatch) return -1; | |
| const rect = el.getBoundingClientRect(); | |
| const childCount = el.children.length; | |
| const interactive = el.matches(ACTIONABLE_SEL); | |
| const hasInteractiveChild = !!el.querySelector(ACTIONABLE_SEL); | |
| const sizePenalty = Math.min(normalized.length, 200); | |
| let score = 0; | |
| if (isExact) score += 1000; | |
| if (interactive) score += 200; | |
| if (!hasInteractiveChild) score += 80; | |
| if (childCount === 0) score += 50; | |
| if (childCount <= 2) score += 20; | |
| if (rect.width > 0 && rect.width < window.innerWidth * 0.9) score += 20; | |
| score -= sizePenalty; | |
| score -= childCount * 10; | |
| return score; | |
| } | |
| function findByText(text, { partial = false, caseless = true } = {}, root = document.body) { | |
| const needle = normalizeText(text, caseless); | |
| const candidates = new Map(); | |
| for (const el of root.querySelectorAll(ACTIONABLE_SEL + ', div, span')) { | |
| if (!isVisible(el) || isOwnControl(el)) continue; | |
| const score = scoreTextCandidate(el, needle, partial); | |
| if (score < 0) continue; | |
| const target = getActionTarget(el, root); | |
| let adjustedScore = score; | |
| if (target !== el) adjustedScore += 140; | |
| if (target.matches(ACTIONABLE_SEL)) adjustedScore += 80; | |
| if (target.children.length <= 3) adjustedScore += 15; | |
| const existing = candidates.get(target); | |
| if (!existing || adjustedScore > existing.score) { | |
| candidates.set(target, { el: target, score: adjustedScore }); | |
| } | |
| } | |
| if (!candidates.size) return null; | |
| return [...candidates.values()].sort((a, b) => b.score - a.score)[0].el; | |
| } | |
| /** Resolve with the first matching element, or reject after timeout ms. */ | |
| function waitForText(text, opts = {}, timeout = 6000) { | |
| return new Promise((resolve, reject) => { | |
| const hit = findByText(text, opts); | |
| if (hit) return resolve(hit); | |
| const obs = new MutationObserver(() => { | |
| const el = findByText(text, opts); | |
| if (!el) return; | |
| obs.disconnect(); | |
| clearTimeout(timer); | |
| resolve(el); | |
| }); | |
| obs.observe(document.body, { childList: true, subtree: true }); | |
| const timer = setTimeout(() => { | |
| obs.disconnect(); | |
| reject(new Error(`instagram-quick-actions: waitForText timeout — "${text}"`)); | |
| }, timeout); | |
| }); | |
| } | |
| function pressEscape() { | |
| document.dispatchEvent( | |
| new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }) | |
| ); | |
| } | |
| function delay(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| function isVisible(el) { | |
| if (!el || !(el instanceof Element)) return false; | |
| const rect = el.getBoundingClientRect(); | |
| return rect.width > 0 && rect.height > 0; | |
| } | |
| function isOwnControl(el) { | |
| return !!el?.closest(`[${OWN_ATTR}]`); | |
| } | |
| function getActionTarget(el, root = document.body) { | |
| const target = el.closest(ACTIONABLE_SEL); | |
| if (target && root.contains(target) && !isOwnControl(target) && isVisible(target)) { | |
| return target; | |
| } | |
| return el; | |
| } | |
| /** | |
| * Fire the full pointer+mouse+click event sequence that React expects. | |
| * Plain el.click() is often ignored on React-synthetic-event buttons. | |
| */ | |
| function robustClick(el) { | |
| const opts = { bubbles: true, cancelable: true, composed: true }; | |
| el.dispatchEvent(new PointerEvent("pointerover", opts)); | |
| el.dispatchEvent(new MouseEvent("mouseover", opts)); | |
| el.dispatchEvent(new PointerEvent("pointerenter", { ...opts, bubbles: false })); | |
| el.dispatchEvent(new PointerEvent("pointerdown", opts)); | |
| el.dispatchEvent(new MouseEvent("mousedown", opts)); | |
| el.dispatchEvent(new PointerEvent("pointerup", opts)); | |
| el.dispatchEvent(new MouseEvent("mouseup", opts)); | |
| el.dispatchEvent(new MouseEvent("click", opts)); | |
| } | |
| function getDotsButton(post) { | |
| const allClickables = [ | |
| ...post.querySelectorAll( | |
| 'button, [role="button"], [tabindex="0"], [tabindex="-1"], [aria-label]' | |
| ), | |
| ].filter((el) => !isOwnControl(el)); | |
| for (const label of ["More options", "More", "Additional actions", "Post options"]) { | |
| const hit = allClickables.find( | |
| (el) => (el.getAttribute("aria-label") || "") === label | |
| ); | |
| if (hit) return hit; | |
| } | |
| for (const el of allClickables) { | |
| const lbl = (el.getAttribute("aria-label") || "").toLowerCase(); | |
| if (lbl.includes("more") || lbl.includes("option")) return el; | |
| } | |
| const postRect = post.getBoundingClientRect(); | |
| const probePoints = [ | |
| [postRect.right - 18, postRect.top + 18], | |
| [postRect.right - 30, postRect.top + 18], | |
| [postRect.right - 18, postRect.top + 30], | |
| [postRect.right - 30, postRect.top + 30], | |
| [postRect.right - 42, postRect.top + 24], | |
| ]; | |
| for (const [x, y] of probePoints) { | |
| const stack = document.elementsFromPoint(x, y); | |
| for (const el of stack) { | |
| if (!post.contains(el) || isOwnControl(el)) continue; | |
| for (let cur = el; cur && cur !== post; cur = cur.parentElement) { | |
| if (isOwnControl(cur)) break; | |
| if (!isVisible(cur)) continue; | |
| const rect = cur.getBoundingClientRect(); | |
| const text = cur.textContent.trim(); | |
| const aria = (cur.getAttribute("aria-label") || "").toLowerCase(); | |
| const isTopRight = | |
| rect.top <= postRect.top + postRect.height * 0.25 | |
| && rect.right >= postRect.right - 70; | |
| const looksLikeControl = | |
| cur.matches('button, [role="button"], [tabindex="0"], [tabindex="-1"], svg, path') | |
| || !!cur.querySelector("svg") | |
| || !!cur.closest("svg") | |
| || !!aria; | |
| if (isTopRight && looksLikeControl && text.length <= 2) { | |
| return cur; | |
| } | |
| } | |
| } | |
| } | |
| return null; | |
| } | |
| function collectVisibleActionTexts() { | |
| const roots = [ | |
| ...document.querySelectorAll('[role="dialog"], [role="menu"], [aria-modal="true"]'), | |
| ].filter(isVisible); | |
| const sourceRoots = roots.length ? roots : [document.body]; | |
| const texts = new Set(); | |
| for (const root of sourceRoots) { | |
| for (const el of root.querySelectorAll(INTERACTIVE_SEL + ', div, span')) { | |
| if (!isVisible(el) || isOwnControl(el)) continue; | |
| const text = el.textContent.trim().replace(/\s+/g, " "); | |
| if (text.length < 3 || text.length > 80) continue; | |
| texts.add(text); | |
| } | |
| } | |
| return [...texts]; | |
| } | |
| async function waitForMenuText(text, opts = {}, timeout = 3500) { | |
| try { | |
| return await waitForText(text, opts, timeout); | |
| } catch (err) { | |
| await delay(250); | |
| console.warn("[IQ diag] visible action texts:", collectVisibleActionTexts()); | |
| throw err; | |
| } | |
| } | |
| async function waitForOptionalMenuText(text, opts = {}, timeout = 1600) { | |
| try { | |
| return await waitForText(text, opts, timeout); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| async function waitForAnyMenuText(options, timeout = 2600) { | |
| const deadline = Date.now() + timeout; | |
| while (Date.now() < deadline) { | |
| for (const option of options) { | |
| const el = findByText(option.text, option.opts || {}); | |
| if (el) return { el, option }; | |
| } | |
| await delay(120); | |
| } | |
| await delay(250); | |
| console.warn("[IQ diag] visible action texts:", collectVisibleActionTexts()); | |
| throw new Error( | |
| `instagram-quick-actions: waitForAnyMenuText timeout — ${options.map((option) => `"${option.text}"`).join(", ")}` | |
| ); | |
| } | |
| /** One-time helper: log every clickable inside a post so we can identify the dots button. */ | |
| function diagnosePosts() { | |
| const posts = document.querySelectorAll("article"); | |
| if (!posts.length) { console.warn("[IQ diag] No <article> elements found on page."); return; } | |
| const post = posts[0]; | |
| const els = [ | |
| ...post.querySelectorAll('button, [role="button"], [tabindex="0"], [tabindex="-1"], [aria-label], svg'), | |
| ]; | |
| console.log(`[IQ diag] ${els.length} clickable(s) in first article:`); | |
| els.forEach((el, i) => { | |
| console.log( | |
| ` [${i}] <${el.tagName.toLowerCase()}>` | |
| + ` aria-label="${el.getAttribute("aria-label") ?? ""}"` | |
| + ` role="${el.getAttribute("role") ?? ""}"` | |
| + ` text="${el.textContent.trim().slice(0, 40)}"` | |
| + ` rect=${JSON.stringify(el.getBoundingClientRect().toJSON())}` | |
| ); | |
| }); | |
| } | |
| setTimeout(diagnosePosts, 3000); | |
| // ── Action flows ─────────────────────────────────────────────────────────── | |
| async function doNotInterested(post) { | |
| if (actionInProgress) return; | |
| const dots = getDotsButton(post); | |
| if (!dots) { console.warn("[IQ] dots button not found"); return; } | |
| actionInProgress = true; | |
| try { | |
| robustClick(dots); | |
| robustClick(await waitForMenuText("not interested", { partial: true })); | |
| await delay(300); | |
| const dontSuggestAction = await waitForOptionalMenuText( | |
| "suggest posts from", | |
| { partial: true }, | |
| 2200 | |
| ); | |
| if (dontSuggestAction) { | |
| robustClick(dontSuggestAction); | |
| await delay(200); | |
| } | |
| await delay(200); | |
| } catch (err) { | |
| console.error("[IQ] Not Interested flow failed:", err); | |
| pressEscape(); | |
| } finally { | |
| actionInProgress = false; | |
| } | |
| } | |
| async function doBlock(post) { | |
| if (actionInProgress) return; | |
| const dots = getDotsButton(post); | |
| if (!dots) { console.warn("[IQ] dots button not found"); return; } | |
| actionInProgress = true; | |
| try { | |
| // Three-dot menu → Report | |
| robustClick(dots); | |
| robustClick(await waitForMenuText("report", { partial: true })); | |
| await delay(350); | |
| // Report reason → "I just don't like it" | |
| robustClick(await waitForMenuText("i just don't like it", { partial: true })); | |
| await delay(350); | |
| // Post-report suggestion → "Block @username" (partial on "block ") | |
| const blockAccountAction = await waitForMenuText("block ", { partial: true }); | |
| robustClick(blockAccountAction); | |
| await delay(350); | |
| // Some variants show a standalone "Block" confirmation, while others | |
| // finish with a visible Close action after the account row click. | |
| let confirmBlockAction = await waitForOptionalMenuText("block"); | |
| if (!confirmBlockAction) { | |
| const retryBlockAccountAction = findByText("block ", { partial: true }); | |
| if (retryBlockAccountAction) { | |
| robustClick(retryBlockAccountAction); | |
| await delay(350); | |
| confirmBlockAction = await waitForOptionalMenuText("block"); | |
| } | |
| } | |
| if (confirmBlockAction) { | |
| robustClick(confirmBlockAction); | |
| await delay(250); | |
| } | |
| // Success screen → Dismiss / Close | |
| const exitAction = await waitForAnyMenuText([ | |
| { text: "dismiss", opts: { partial: true } }, | |
| { text: "close" }, | |
| ]); | |
| robustClick(exitAction.el); | |
| } catch (err) { | |
| console.error("[IQ] Block flow failed:", err); | |
| pressEscape(); | |
| } finally { | |
| actionInProgress = false; | |
| } | |
| } | |
| // ── Button factory ───────────────────────────────────────────────────────── | |
| function makeBtn(ariaLabel, svgBody, onClick) { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.setAttribute(OWN_ATTR, ""); | |
| btn.setAttribute("aria-label", ariaLabel); | |
| btn.title = ariaLabel; | |
| Object.assign(btn.style, { | |
| background: "linear-gradient(180deg, rgba(255,255,255,0.72), rgba(242,244,247,0.58))", | |
| border: "1px solid rgba(15,23,42,0.06)", | |
| borderRadius: "999px", | |
| boxShadow: "0 8px 18px rgba(15,23,42,0.08)", | |
| cursor: "pointer", | |
| padding: "5px", | |
| margin: "0", | |
| color: "#0f172a", | |
| opacity: "0.9", | |
| lineHeight: "0", | |
| flexShrink: "0", | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| transition: "transform 140ms ease, box-shadow 140ms ease, background 140ms ease", | |
| backdropFilter: "blur(10px)", | |
| }); | |
| btn.innerHTML = | |
| `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" ` + | |
| `viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ` + | |
| `stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${svgBody}</svg>`; | |
| btn.addEventListener("mouseenter", () => { | |
| btn.style.transform = "translateY(-1px) scale(1.03)"; | |
| btn.style.boxShadow = "0 10px 20px rgba(15,23,42,0.12)"; | |
| btn.style.background = "linear-gradient(180deg, rgba(255,255,255,0.84), rgba(248,250,252,0.7))"; | |
| }); | |
| btn.addEventListener("mouseleave", () => { | |
| btn.style.transform = "translateY(0) scale(1)"; | |
| btn.style.boxShadow = "0 8px 18px rgba(15,23,42,0.08)"; | |
| btn.style.background = "linear-gradient(180deg, rgba(255,255,255,0.72), rgba(242,244,247,0.58))"; | |
| }); | |
| btn.addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| onClick(); | |
| }); | |
| return btn; | |
| } | |
| // Circle-X → "Not Interested" | |
| const SVG_NOT_INTERESTED = | |
| '<circle cx="12" cy="12" r="9"/>' + | |
| '<line x1="8" y1="8" x2="16" y2="16"/>' + | |
| '<line x1="16" y1="8" x2="8" y2="16"/>'; | |
| // Circle with diagonal slash → "Block Account" | |
| const SVG_BLOCK = | |
| '<circle cx="12" cy="12" r="9"/>' + | |
| '<line x1="4.22" y1="4.22" x2="19.78" y2="19.78"/>'; | |
| // ── Injection ────────────────────────────────────────────────────────────── | |
| function injectIntoPost(post) { | |
| if (post.hasAttribute(INJECTED_ATTR)) return; | |
| post.setAttribute(INJECTED_ATTR, ""); | |
| // Use absolute positioning so the wrapper is never clipped by | |
| // overflow:hidden on the native flex containers. | |
| if (getComputedStyle(post).position === "static") { | |
| post.style.position = "relative"; | |
| } | |
| const wrap = document.createElement("div"); | |
| wrap.setAttribute(OWN_ATTR, ""); | |
| Object.assign(wrap.style, { | |
| position: "absolute", | |
| top: "4px", | |
| right: "132px", | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "6px", | |
| zIndex: "10", | |
| padding: "4px", | |
| borderRadius: "999px", | |
| background: "rgba(255,255,255,0.42)", | |
| border: "1px solid rgba(15,23,42,0.06)", | |
| boxShadow: "0 8px 20px rgba(15,23,42,0.08)", | |
| backdropFilter: "blur(14px)", | |
| }); | |
| wrap.appendChild(makeBtn("Not Interested", SVG_NOT_INTERESTED, () => doNotInterested(post))); | |
| wrap.appendChild(makeBtn("Block Account", SVG_BLOCK, () => doBlock(post))); | |
| post.appendChild(wrap); | |
| } | |
| function scan() { | |
| document.querySelectorAll("article").forEach(injectIntoPost); | |
| } | |
| const pageObserver = new MutationObserver(scan); | |
| pageObserver.observe(document.body, { childList: true, subtree: true }); | |
| scan(); | |
| // Periodic fallback for React hydration races | |
| setInterval(scan, 2000); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment