Last active
May 16, 2026 05:43
-
-
Save jhyland87/fc613d51837f94cb049b76fab3474ba2 to your computer and use it in GitHub Desktop.
Tampermonkey/Violentmonkey Scripts
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 Remove all3dp adblocker wall | |
| // @namespace http://tampermonkey.net/ | |
| // @version 0.1 | |
| // @description Remove the irritating modal that All3DP displays until it decides you've disabled addblocker | |
| // @author Justin Hyland | |
| // @match https://all3dp.com/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=all3dp.com | |
| // @require https://gist.githubusercontent.com/jhyland87/b28636effabfc69e316281b004777adf/raw/77699d2539b91a842468b12bf52861f6bf6f4a64/tampermonkey-utilities.js | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| void (async () => { | |
| const elem = await waitForElem({ selector: 'div.fc-ab-dialog.fc-dialog', verbose: true }); | |
| elem.remove(); | |
| })(); | |
| void (async () => { | |
| const elem = await waitForElem({ selector: '.fc-dialog-container', verbose: true }); | |
| elem.remove(); | |
| })(); | |
| void (async () => { | |
| const elem = await waitForElem({ selector: '.root-modal-container-open', verbose: true }); | |
| elem.style.overflow = 'scroll'; | |
| })(); | |
| void (async () => { | |
| const elem = await waitForElem({ selector: '.modal-container--open', verbose: true }); | |
| elem.remove(); | |
| })(); | |
| void (async () => { | |
| const elem = await waitForElem({ selector: '#top', verbose: true }); | |
| elem.style.overflow = 'scroll'; | |
| })(); | |
| /** Fallback polling if the dialog appears outside waitForElem timing. Uncomment startup line below to enable. */ | |
| async function dialogCheckLoop() { | |
| await sleep(1500); | |
| for (let dialogChecks = 0; dialogChecks < 15; dialogChecks++) { | |
| await sleep(250); | |
| const dialog = document.querySelector('div.fc-ab-dialog.fc-dialog'); | |
| if (!dialog?.checkVisibility()) continue; | |
| try { | |
| document.querySelector('.fc-dialog-container')?.remove(); | |
| const rootModal = document.querySelector('.root-modal-container-open'); | |
| if (rootModal) rootModal.style.overflow = 'scroll'; | |
| document.querySelector('.modal-container--open')?.remove(); | |
| const top = document.querySelector('#top'); | |
| if (top) top.style.overflow = 'scroll'; | |
| } catch (err) { | |
| console.error('Encountered an ERROR while trying to delete the dialogs:', err); | |
| } | |
| return; | |
| } | |
| } | |
| // Wait 1.5s, then poll every 250ms up to 15 times (~3.75s total). | |
| // void dialogCheckLoop(); | |
| // All3dp adblocker dialog — manual one-liners if needed: | |
| // document.querySelector("#top > div.fc-ab-root > div").remove(); | |
| // document.querySelector("#top > div.pur-root > div").remove(); | |
| // document.querySelector("#top > pur-modal").remove(); | |
| })(); |
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 Amazon Wishlist Search | |
| // @version 0.2 | |
| // @description Adds a text input field to the top of the wishlist to add a search feature | |
| // @author Justin Hyland | |
| // @match https://www.amazon.com/*/dp/* | |
| // @match https://www.amazon.com/dp/* | |
| // @match https://www.amazon.com/gp/product/* | |
| // @match https://www.amazon.com/*/gp/* | |
| // @match https://www.amazon.com/gp/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=amazon.com | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| console.log('LOADED Amazon Wishlist Search'); | |
| // --------------------------------------------------------------------------- | |
| // Config | |
| // --------------------------------------------------------------------------- | |
| const CONFIG = { | |
| searchDelayMs: 500, // Debounce delay before running a search after keyup | |
| searchFocusDelayMs: 200, // Delay before focusing the input (needs to wait for the popover) | |
| maxSearchResults: 10, // Max items shown; set to non-integer to disable the cap | |
| minSearchInput: 0, // Minimum chars before searching | |
| regexSearches: 'delimiters' // 'enable' | true, 'disable' | false, or 'delimiters' | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Selectors | |
| // | |
| // The wishlist popover is rebuilt by Amazon whenever a variant is selected, | |
| // so we always re-query rather than caching nodes. | |
| // | |
| // We locate the popover via its unique child `#atwl-popover-inner` (not via | |
| // `aria-hidden="false"`) so injection can happen *before* the popover is | |
| // shown. Injecting after Amazon has measured and positioned the popover | |
| // would change its height and trigger repositioning — on narrow viewports | |
| // that causes the popover to flip to a different anchor point. | |
| // --------------------------------------------------------------------------- | |
| const SELECTORS = { | |
| // The popover root — found via its unique inner content. | |
| popover: 'div.a-popover.a-popover-no-header.a-arrow-bottom:has(#atwl-popover-inner)', | |
| // Container for the dropdown list. | |
| popoverInner: '#atwl-popover-inner', | |
| // The <ul> holding wishlist <li> items. | |
| listUl: '#atwl-dd-ul', | |
| // Each wishlist row. | |
| listItem: 'li.a-dropdown-item', | |
| // The list-name span inside a row (used to read the wishlist title). | |
| listItemName: '[id^="atwl-list-name-"]', | |
| // The "Add to List" button — used as the alignment anchor for the popover. | |
| addToListBtn: '#add-to-wishlist-button', | |
| // Injected nodes: | |
| searchInput: '#wishlist-search', | |
| resultCount: '#wishlist-search-result-count' | |
| }; | |
| // Marker attribute used to record which popover we've already injected into. | |
| const INJECTED_ATTR = 'data-wishlist-search-injected'; | |
| // --------------------------------------------------------------------------- | |
| // DOM helpers — always re-resolve against the live popover. | |
| // --------------------------------------------------------------------------- | |
| const getPopover = () => document.querySelector(SELECTORS.popover); | |
| const getPopoverInner = () => getPopover()?.querySelector(SELECTORS.popoverInner) ?? null; | |
| const getListUl = () => getPopover()?.querySelector(SELECTORS.listUl) ?? null; | |
| const getListItems = () => { | |
| const popover = getPopover(); | |
| return popover ? Array.from(popover.querySelectorAll(SELECTORS.listItem)) : []; | |
| }; | |
| const getSearchInput = () => getPopover()?.querySelector(SELECTORS.searchInput) ?? null; | |
| const getResultCount = () => getPopover()?.querySelector(SELECTORS.resultCount) ?? null; | |
| // "Open" means the popover exists *and* Amazon has marked it visible. | |
| // This is used for ESC handling and search execution — not for injection. | |
| const isListOpen = () => { | |
| const popover = getPopover(); | |
| return !!popover && popover.getAttribute('aria-hidden') === 'false'; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Regex helpers | |
| // --------------------------------------------------------------------------- | |
| const escapeRegExp = (text) => text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); | |
| /** | |
| * If `searchStr` looks like `/pattern/flags` (or with one of the alternate | |
| * delimiters), return a RegExp built from it; otherwise return false. | |
| */ | |
| const getRegexpPattern = (searchPatternStr) => { | |
| if (!searchPatternStr || typeof searchPatternStr !== 'string') return false; | |
| const flags = ['i']; | |
| const delimiters = ['\\/', '%', '@', '~', '#']; | |
| const delimClass = `[${delimiters.join('')}]`; | |
| const flagClass = `[${flags.join('')}]`; | |
| const re = new RegExp(`^(?<delim1>${delimClass})(?<pattern>.*)(?<delim2>${delimClass})(?<flags>${flagClass}*)?$`); | |
| const m = searchPatternStr.match(re); | |
| if (!m) return false; | |
| try { | |
| return m.groups.flags | |
| ? new RegExp(m.groups.pattern, m.groups.flags) | |
| : new RegExp(m.groups.pattern); | |
| } catch { | |
| return false; | |
| } | |
| }; | |
| /** Safely convert a plain search string into a case-insensitive RegExp. */ | |
| const str2regex = (searchStr) => { | |
| if (!searchStr || typeof searchStr !== 'string') return false; | |
| try { | |
| return new RegExp(escapeRegExp(searchStr), 'i'); | |
| } catch { | |
| return false; | |
| } | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Result-count notice element | |
| // --------------------------------------------------------------------------- | |
| const createResultCountElement = () => { | |
| if (getResultCount()) return; | |
| const listUl = getListUl(); | |
| if (!listUl) return; | |
| const node = document.createElement('span'); | |
| node.id = 'wishlist-search-result-count'; | |
| node.className = 'a-size-small atwl-hz-vertical-align-middle'; | |
| Object.assign(node.style, { | |
| margin: '5px 0', | |
| fontWeight: '700', | |
| width: '100%', | |
| textAlign: 'center', | |
| display: 'none' | |
| }); | |
| listUl.parentNode.insertBefore(node, listUl); | |
| }; | |
| const updateSearchResultTxt = (html, style) => { | |
| const node = getResultCount(); | |
| if (!node) { | |
| createResultCountElement(); | |
| return updateSearchResultTxt(html, style); | |
| } | |
| if (style && typeof style === 'object') Object.assign(node.style, style); | |
| node.style.display = 'inline-block'; | |
| node.innerHTML = html; | |
| }; | |
| const hideSearchResultTxt = () => { | |
| const node = getResultCount(); | |
| if (node) node.style.display = 'none'; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Search logic | |
| // --------------------------------------------------------------------------- | |
| const showAllListItems = () => { | |
| for (const item of getListItems()) { | |
| item.style.display = 'block'; | |
| // Restore any highlighted innerHTML on the name span. | |
| const nameSpan = item.querySelector(SELECTORS.listItemName); | |
| if (nameSpan) nameSpan.innerHTML = nameSpan.innerText; | |
| } | |
| const input = getSearchInput(); | |
| if (input) input.value = ''; | |
| hideSearchResultTxt(); | |
| }; | |
| const searchList = (searchStr) => { | |
| const input = getSearchInput(); | |
| if (!input) return; | |
| updateSearchResultTxt(''); | |
| // Decide whether/how to build a regex from the input. | |
| let regexPattern = false; | |
| if (CONFIG.regexSearches === 'delimiters') { | |
| const fromDelim = getRegexpPattern(searchStr); | |
| regexPattern = fromDelim !== false ? fromDelim : str2regex(searchStr); | |
| } else if (CONFIG.regexSearches === true || CONFIG.regexSearches === 'enable') { | |
| regexPattern = str2regex(searchStr); | |
| } | |
| let resultCount = 0; | |
| for (const item of getListItems()) { | |
| const nameSpan = item.querySelector(SELECTORS.listItemName); | |
| if (!nameSpan) continue; | |
| const itemName = nameSpan.textContent.trim(); | |
| const match = regexPattern | |
| ? itemName.match(regexPattern) | |
| : (itemName.includes(searchStr) ? [searchStr] : null); | |
| if (!match) { | |
| nameSpan.innerHTML = nameSpan.innerText; | |
| item.style.display = 'none'; | |
| continue; | |
| } | |
| resultCount++; | |
| const overLimit = typeof CONFIG.maxSearchResults === 'number' | |
| && CONFIG.maxSearchResults < resultCount; | |
| if (overLimit) { | |
| item.style.display = 'none'; | |
| } else { | |
| nameSpan.innerHTML = nameSpan.innerText.replace( | |
| match[0], | |
| `<strong><u>${match[0]}</u></strong>` | |
| ); | |
| item.style.display = 'block'; | |
| } | |
| } | |
| if (resultCount === 0) { | |
| updateSearchResultTxt(`0 results for <em>${searchStr}</em>`, { color: '#00000087' }); | |
| input.style.color = '#ff0000'; | |
| } else if (typeof CONFIG.maxSearchResults === 'number' && CONFIG.maxSearchResults < resultCount) { | |
| console.log(`A total of ${resultCount} matches found, but only showing the first ${CONFIG.maxSearchResults}`); | |
| hideSearchResultTxt(); | |
| } else { | |
| hideSearchResultTxt(); | |
| } | |
| }; | |
| // Debounced search trigger fired from the input's keyup event. | |
| let searchDebounce = null; | |
| const searchTrigger = (searchStr) => { | |
| if (searchDebounce) clearTimeout(searchDebounce); | |
| if (!searchStr) { | |
| showAllListItems(); | |
| return; | |
| } | |
| if (searchStr.length <= CONFIG.minSearchInput) return; | |
| searchDebounce = setTimeout(() => searchList(searchStr), CONFIG.searchDelayMs); | |
| }; | |
| // Reset state on keydown (clears 0-results red color, etc). | |
| const resetSearchInput = (elem) => { | |
| hideSearchResultTxt(); | |
| elem.style.color = 'inherit'; | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // Injecting the search input | |
| // | |
| // Hover/focus styling needs real CSS (inline styles can't express pseudo- | |
| // classes), so the stylesheet is injected once on first use. | |
| // --------------------------------------------------------------------------- | |
| const STYLE_ID = 'wishlist-search-style'; | |
| const injectStylesheet = () => { | |
| if (document.getElementById(STYLE_ID)) return; | |
| const style = document.createElement('style'); | |
| style.id = STYLE_ID; | |
| style.textContent = ` | |
| #wishlist-search { | |
| display: block; | |
| box-sizing: border-box; | |
| width: calc(100% - 16px); | |
| margin: 8px; | |
| padding: 6px 10px; | |
| font-size: 13px; | |
| line-height: 19px; | |
| color: #0f1111; | |
| background-color: #fff; | |
| border: 1px solid #d5d9d9; | |
| border-radius: 8px; | |
| box-shadow: 0 1px 2px rgba(15, 17, 17, 0.04) inset; | |
| outline: none; | |
| transition: border-color 0.1s ease-in-out, box-shadow 0.1s ease-in-out; | |
| } | |
| #wishlist-search::placeholder { | |
| color: #898d8d; | |
| } | |
| #wishlist-search:hover { | |
| border-color: #adb1b8; | |
| } | |
| #wishlist-search:focus { | |
| border-color: #adb1b8; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| }; | |
| const searchFocus = () => { | |
| // Only focus if the popover is actually visible — otherwise the focus | |
| // either silently fails or lands invisibly on a hidden input. | |
| setTimeout(() => { | |
| if (isListOpen()) getSearchInput()?.focus(); | |
| }, CONFIG.searchFocusDelayMs); | |
| }; | |
| const addListSearchInput = () => { | |
| const popover = getPopover(); | |
| if (!popover) return; | |
| // Already injected for this popover instance. | |
| if (popover.getAttribute(INJECTED_ATTR) === 'true' && getSearchInput()) { | |
| searchFocus(); | |
| return; | |
| } | |
| const listUl = getListUl(); | |
| if (!listUl) return; | |
| injectStylesheet(); | |
| const input = document.createElement('input'); | |
| Object.assign(input, { | |
| id: 'wishlist-search', | |
| type: 'search', | |
| placeholder: 'Search lists...', | |
| autocomplete: 'off', | |
| autocorrect: 'off', | |
| spellcheck: false | |
| }); | |
| input.addEventListener('keydown', () => resetSearchInput(input)); | |
| input.addEventListener('keyup', () => searchTrigger(input.value)); | |
| listUl.parentNode.insertBefore(input, listUl); | |
| // Align the popover's right edge to the "Add to List" button's right | |
| // edge. The popover is wider than the button, so anchoring on the left | |
| // would push the popover off to the right; right-alignment keeps it | |
| // tucked under the dropdown. | |
| const alignToButton = () => { | |
| const btn = document.querySelector(SELECTORS.addToListBtn); | |
| if (!btn) return; | |
| const btnRect = btn.getBoundingClientRect(); | |
| const targetLeft = `${Math.round(btnRect.right + window.scrollX - popover.offsetWidth)}px`; | |
| if (popover.style.left !== targetLeft) popover.style.left = targetLeft; | |
| }; | |
| alignToButton(); | |
| requestAnimationFrame(() => { | |
| alignToButton(); | |
| requestAnimationFrame(alignToButton); | |
| }); | |
| popover.setAttribute(INJECTED_ATTR, 'true'); | |
| createResultCountElement(); | |
| searchFocus(); | |
| }; | |
| // --------------------------------------------------------------------------- | |
| // ESC key — clear the search without closing the popover. | |
| // --------------------------------------------------------------------------- | |
| document.addEventListener('keydown', (event) => { | |
| if (event.code !== 'Escape') return; | |
| if (!isListOpen()) return; | |
| const input = getSearchInput(); | |
| if (!input || input.value.length === 0) return; | |
| searchTrigger(''); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Persistent observer | |
| // | |
| // The previous version used a one-shot waitForElm() that disconnected its | |
| // observer on first match. When Amazon rebuilt the popover after a variant | |
| // change, the new popover was ignored. This observer stays alive for the | |
| // whole page lifetime and reacts to every new popover that appears. | |
| // --------------------------------------------------------------------------- | |
| const tryInject = () => { | |
| // Only inject after the popover is visible. Injecting earlier (while | |
| // aria-hidden="true") was tried, but it raced with Amazon's measurement | |
| // pass and caused the popover to be repositioned after open. | |
| if (!isListOpen()) return; | |
| const popover = getPopover(); | |
| if (!popover) return; | |
| if (popover.getAttribute(INJECTED_ATTR) !== 'true') { | |
| addListSearchInput(); | |
| } else if (!getSearchInput()) { | |
| // Defensive: the marker is set but the input is gone (e.g. Amazon | |
| // re-rendered the inner content). Clear the marker and re-inject. | |
| popover.removeAttribute(INJECTED_ATTR); | |
| addListSearchInput(); | |
| } else { | |
| // Already injected and the popover just became visible — focus it. | |
| searchFocus(); | |
| } | |
| }; | |
| // Watch for both: | |
| // - childList/subtree changes (new popover nodes being added after a | |
| // variant swap), and | |
| // - aria-hidden flipping from "true" to "false" on an existing popover | |
| // (Amazon often keeps the popover in the DOM and just toggles | |
| // visibility on click). | |
| // This makes the click handler unnecessary — every path that exposes a | |
| // live popover fires a mutation we observe. | |
| const observer = new MutationObserver(tryInject); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| attributes: true, | |
| attributeFilter: ['aria-hidden'] | |
| }); | |
| // Initial attempt in case the popover is somehow already present. | |
| tryInject(); | |
| })(); |
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 Geeks For Geeks - Remove shaming prompt | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2024-07-08 | |
| // @description Geeks For Geeks - Remove shaming prompt | |
| // @author Justin Hyland | |
| // @match https://www.geeksforgeeks.org/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=geeksforgeeks.org | |
| // @require https://gist.githubusercontent.com/jhyland87/b28636effabfc69e316281b004777adf/raw/77699d2539b91a842468b12bf52861f6bf6f4a64/tampermonkey-utilities.js | |
| // @grant none | |
| // ==/UserScript== | |
| void (async () => { | |
| 'use strict'; | |
| const elem = await waitForElem({ | |
| selector: '#ad-blocker-div-continue-btn', | |
| verbose: true | |
| }); | |
| elem.click(); | |
| })(); |
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 - remove login disabler | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2024-07-08 | |
| // @description Instagram - remove login disabler | |
| // @author Justin Hyland | |
| // @match https://www.instagram.com/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=instagram.com | |
| // @grant none | |
| // @require https://gist.githubusercontent.com/jhyland87/b28636effabfc69e316281b004777adf/raw/77699d2539b91a842468b12bf52861f6bf6f4a64/tampermonkey-utilities.js | |
| // ==/UserScript== | |
| function reEnableRegularLinkClicking() { | |
| document.querySelectorAll('article > div > div > div > div a[href]').forEach((link) => { | |
| link.addEventListener( | |
| 'click', | |
| (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (e.metaKey || e.ctrlKey || e.button === 1) { | |
| window.open(link.href, '_blank'); | |
| } else { | |
| window.location.href = link.href; | |
| } | |
| }, | |
| true | |
| ); | |
| }); | |
| } | |
| (() => { | |
| 'use strict'; | |
| console.log('Instagram script loaded'); | |
| const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| // Continuously remove the overlay when it appears (without breaking infinite scroll) | |
| function removeOverlay() { | |
| let removed = false; | |
| const selectors = [ | |
| 'body > div.x1n2onr6.xzkaem6', | |
| 'div.x1n2onr6.xzkaem6', | |
| 'div[role="dialog"]', | |
| 'div[role="dialog"].x1n2onr6' | |
| ]; | |
| for (const selector of selectors) { | |
| const elements = document.querySelectorAll(selector); | |
| elements.forEach((elem) => { | |
| const isOverlay = | |
| elem.getAttribute('role') === 'dialog' || | |
| elem.textContent.includes('Continue watching') || | |
| elem.textContent.includes('Sign up') || | |
| elem.closest('body > div.x1n2onr6.xzkaem6'); | |
| if (isOverlay && elem.isConnected) { | |
| elem.remove(); | |
| removed = true; | |
| console.log('✅ Removed Instagram login overlay:', selector); | |
| } | |
| }); | |
| } | |
| return removed; | |
| } | |
| const observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| if (node.nodeType !== Node.ELEMENT_NODE) continue; | |
| if ( | |
| node.matches?.('div.x1n2onr6.xzkaem6') || | |
| node.matches?.('div[role="dialog"]') || | |
| node.querySelector?.('div[role="dialog"]') || | |
| node.querySelector?.('div.x1n2onr6.xzkaem6') | |
| ) { | |
| queueMicrotask(() => { | |
| if (removeOverlay()) { | |
| console.log('🚨 Overlay detected and removed via MutationObserver'); | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| removeOverlay(); | |
| }); | |
| async function startObserver() { | |
| while (!document.body) { | |
| await sleep(100); | |
| } | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| console.log('✅ Instagram overlay observer started'); | |
| } | |
| void startObserver(); | |
| void (async () => { | |
| const elem = await waitForElem({ | |
| selector: 'body > div.x1n2onr6.xzkaem6', | |
| verbose: true | |
| }); | |
| elem.remove(); | |
| console.log('✅ Removed overlay on initial load'); | |
| })(); | |
| void (async () => { | |
| await waitForElem({ | |
| selector: 'article > div > div > div > div a[href]', | |
| verbose: true | |
| }); | |
| reEnableRegularLinkClicking(); | |
| })(); | |
| setInterval(() => { | |
| removeOverlay(); | |
| }, 100); | |
| void (async () => { | |
| const elem = await waitForElem({ selector: '._aagw', verbose: true }); | |
| elem.remove(); | |
| })(); | |
| // document.querySelector('button:has([aria-label="Close"])').click() | |
| void (async () => { | |
| const elem = await waitForElem({ | |
| selector: 'button:has([aria-label="Close"])', | |
| verbose: true | |
| }); | |
| elem.click(); | |
| })(); | |
| // document.querySelectorAll('a[role="link"]').forEach((elem) => { | |
| // elem.addEventListener('click', () => (location.href = elem.href)); | |
| // }); | |
| })(); |
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 NYT Article Unpaywaller | |
| // @version 1.0.0 | |
| // @description Renders the full sprinkledBody from window.__preloadedData into the article DOM in the site's native style. | |
| // @match https://www.nytimes.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| // ---------- Style classes used by the live page ---------- | |
| // These are the classes pulled from a fully-rendered article; they keep new | |
| // nodes visually consistent with whatever the page already rendered. | |
| const CLS = { | |
| column: 'css-53u6y8', | |
| paragraph: 'css-ac37hb evys1bk0', | |
| link: 'css-yywogo', | |
| h3: 'css-1dg6kl4 e1h9rw200', // common NYT subhead class; falls back gracefully | |
| blockquote: 'css-1iyvfzz ee2lrhn0', | |
| figure: 'css-ozb2v3 ekv1k1y0', | |
| figureImg: 'css-rq4mmj', | |
| figcaption: 'css-1y3vjx0 e1g7ppur0', | |
| credit: 'css-16qhgrr e1g7ppur1', | |
| detail: 'css-1hh4u0i evys1bk0', // address/detail line styling | |
| rule: 'css-1q1n0c2', | |
| }; | |
| // ---------- Helpers ---------- | |
| const el = (tag, className, text) => { | |
| const node = document.createElement(tag); | |
| if (className) node.className = className; | |
| if (text != null) node.textContent = text; | |
| return node; | |
| }; | |
| /** | |
| * Render an array of TextInline nodes into a parent element, applying | |
| * BoldFormat / ItalicFormat / LinkFormat as nested tags. | |
| */ | |
| const renderInlines = (inlines, parent) => { | |
| if (!Array.isArray(inlines)) return; | |
| for (const inline of inlines) { | |
| if (inline.__typename !== 'TextInline') continue; | |
| const text = inline.text ?? ''; | |
| const formats = inline.formats || []; | |
| // Build the innermost node first, then wrap outward. | |
| let node = document.createTextNode(text); | |
| // Find a link format (if any) - the anchor must wrap everything else. | |
| const linkFmt = formats.find(f => f.__typename === 'LinkFormat'); | |
| const hasBold = formats.some(f => f.__typename === 'BoldFormat'); | |
| const hasItalic = formats.some(f => f.__typename === 'ItalicFormat'); | |
| if (hasItalic) { | |
| const i = document.createElement('em'); | |
| i.appendChild(node); | |
| node = i; | |
| } | |
| if (hasBold) { | |
| const b = document.createElement('strong'); | |
| b.appendChild(node); | |
| node = b; | |
| } | |
| if (linkFmt && linkFmt.url) { | |
| const a = el('a', CLS.link); | |
| a.href = linkFmt.url; | |
| if (linkFmt.title) a.title = linkFmt.title; | |
| a.appendChild(node); | |
| node = a; | |
| } | |
| parent.appendChild(node); | |
| } | |
| }; | |
| // ---------- Block renderers ---------- | |
| const renderers = { | |
| ParagraphBlock(block) { | |
| // Skip totally empty paragraphs (e.g. inside empty CapsuleBlocks). | |
| if (!block.content || block.content.length === 0) return null; | |
| const p = el('p', CLS.paragraph); | |
| renderInlines(block.content, p); | |
| return p.childNodes.length ? p : null; | |
| }, | |
| Heading3Block(block) { | |
| const h = el('h3', CLS.h3); | |
| renderInlines(block.content, h); | |
| return h; | |
| }, | |
| BlockquoteBlock(block) { | |
| const bq = el('blockquote', CLS.blockquote); | |
| for (const child of (block.content || [])) { | |
| const rendered = renderers[child.__typename]?.(child); | |
| if (rendered) bq.appendChild(rendered); | |
| } | |
| return bq; | |
| }, | |
| DetailBlock(block) { | |
| // Detail blocks are typically address/location lines. | |
| const p = el('p', CLS.detail); | |
| renderInlines(block.content, p); | |
| return p; | |
| }, | |
| RuleBlock() { | |
| return el('hr', CLS.rule); | |
| }, | |
| ImageBlock(block) { | |
| const media = block.media; | |
| if (!media || !media.crops) return null; | |
| // Pick the best rendition: prefer superJumbo, then jumbo, then articleLarge. | |
| const preferred = ['superJumbo', 'jumbo', 'articleLarge', 'popup', 'thumbLarge']; | |
| let chosen = null; | |
| outer: for (const name of preferred) { | |
| for (const crop of media.crops) { | |
| const rend = (crop.renditions || []).find(r => r.name === name); | |
| if (rend) { chosen = rend; break outer; } | |
| } | |
| } | |
| if (!chosen) { | |
| // Fall back to the first rendition we find. | |
| for (const crop of media.crops) { | |
| if (crop.renditions && crop.renditions.length) { | |
| chosen = crop.renditions[0]; | |
| break; | |
| } | |
| } | |
| } | |
| if (!chosen) return null; | |
| const figure = el('figure', CLS.figure); | |
| const img = el('img', CLS.figureImg); | |
| img.src = chosen.url; | |
| if (chosen.width) img.width = chosen.width; | |
| if (chosen.height) img.height = chosen.height; | |
| img.alt = media.altText || ''; | |
| img.loading = 'lazy'; | |
| figure.appendChild(img); | |
| // Caption (lives inside a TextOnlyDocumentBlock -> ParagraphBlock). | |
| const captionText = media.caption?.text?.trim(); | |
| if (captionText || media.credit) { | |
| const figcap = el('figcaption', CLS.figcaption); | |
| if (captionText && media.caption?.content) { | |
| for (const inner of media.caption.content) { | |
| const rendered = renderers[inner.__typename]?.(inner); | |
| if (rendered) figcap.appendChild(rendered); | |
| } | |
| } | |
| if (media.credit) { | |
| figcap.appendChild(el('span', CLS.credit, ' ' + media.credit)); | |
| } | |
| figure.appendChild(figcap); | |
| } | |
| return figure; | |
| }, | |
| // Blocks we intentionally skip: | |
| Dropzone: () => null, // ad slot placeholders | |
| CapsuleBlock: () => null, // native ads / promo capsules | |
| HeaderBasicBlock: () => null, // headline/byline already rendered above the paywall | |
| }; | |
| const renderBlock = (block) => { | |
| const fn = renderers[block.__typename]; | |
| if (!fn) { | |
| console.warn('[NYT Unpaywall] No renderer for block type:', block.__typename); | |
| return null; | |
| } | |
| return fn(block); | |
| }; | |
| // ---------- Locate the data ---------- | |
| const findSprinkledBody = () => { | |
| // window.__preloadedData is the canonical location, but the path can vary | |
| // slightly between article templates - probe a few. | |
| const root = window.__preloadedData; | |
| if (!root) return null; | |
| const candidates = [ | |
| root?.initialData?.data?.article?.sprinkledBody, | |
| root?.initialState?.data?.article?.sprinkledBody, | |
| root?.data?.article?.sprinkledBody, | |
| ]; | |
| return candidates.find(c => c && Array.isArray(c.content)) || null; | |
| }; | |
| // ---------- Find the insertion point ---------- | |
| const findColumn = () => { | |
| // The first StoryBodyCompanionColumn is where the lede paragraphs live. | |
| // We replace its inner content column with the full body. | |
| const companion = document.querySelector('[data-testid="companionColumn-0"]'); | |
| if (!companion) return null; | |
| return companion.querySelector('.' + CLS.column) || companion; | |
| }; | |
| const clearGateway = () => { | |
| document.getElementById('gateway-content')?.remove() | |
| // Remove position, overflow and height from: | |
| const gatewayContainer = document.querySelector('div[data-testid="vi-gateway-container"]') | |
| if (gatewayContainer) { | |
| gatewayContainer.style.setProperty('position', 'static') | |
| gatewayContainer.style.setProperty('overflow', 'visible') | |
| gatewayContainer.style.setProperty('height', 'auto') | |
| } | |
| document.querySelector('.css-gx5sib')?.style?.setProperty('background', 'none', 'important') | |
| } | |
| // ---------- Main ---------- | |
| const run = () => { | |
| const body = findSprinkledBody(); | |
| if (!body) { | |
| console.warn('[NYT Unpaywall] sprinkledBody not found on window.__preloadedData'); | |
| return; | |
| } | |
| const column = findColumn(); | |
| if (!column) { | |
| console.warn('[NYT Unpaywall] companion column not found in DOM'); | |
| return; | |
| } | |
| // Build the new content in a fragment first so we don't reflow per node. | |
| const frag = document.createDocumentFragment(); | |
| for (const block of body.content) { | |
| const node = renderBlock(block); | |
| if (node) frag.appendChild(node); | |
| } | |
| // Replace the partial preview with the full body. | |
| column.replaceChildren(frag); | |
| // Belt-and-suspenders: kill any paywall/gateway overlays the page may | |
| // have layered on top. | |
| document.querySelectorAll('#gateway-content, [data-testid="gateway-container"]').forEach(n => n.remove()); | |
| document.body.style.overflow = ''; | |
| console.log('[NYT Unpaywall] Rendered', body.content.length, 'blocks.'); | |
| }; | |
| window.clearGateway = clearGateway | |
| // Run once the page is reasonably settled. NYT hydrates client-side, so we | |
| // give it a beat; an event listener on DOMContentLoaded also works but | |
| // @run-at document-idle already covers that. | |
| if (document.readyState === 'complete') { | |
| run(); | |
| } else { | |
| window.addEventListener('load', run, { once: true }); | |
| } | |
| setInterval(() => clearGateway(), 1000) | |
| })(); |
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 Real Python Paywall Remover | |
| // @namespace https://realpython.com/ | |
| // @version 2024-07-15 | |
| // @description Removing the annoying paywalls that disable RealPython. | |
| // @author Justin Hyland | |
| // @match https://realpython.com/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=realpython.com | |
| // @require https://gist.githubusercontent.com/jhyland87/b28636effabfc69e316281b004777adf/raw/77699d2539b91a842468b12bf52861f6bf6f4a64/tampermonkey-utilities.js | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| // Independent waits (same as separate .then() chains — do not Promise.all). | |
| void (async () => { | |
| const elem = await waitForElem({ | |
| selector: '.modal-backdrop', | |
| verbose: true | |
| }); | |
| elem.classList.remove('show'); | |
| })(); | |
| void (async () => { | |
| const elem = await waitForElem({ | |
| selector: 'lesson-player', | |
| verbose: true | |
| }); | |
| elem.remove(); | |
| })(); | |
| void (async () => { | |
| const elem = await waitForElem({ | |
| selector: 'div.show[role="dialog"]:has(div.modal-dialog)', | |
| verbose: true | |
| }); | |
| elem.classList.remove('show'); | |
| })(); | |
| document.body.classList.remove('modal-open'); | |
| })(); |
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 The Atlantic Article Unpaywaller (via __NEXT_DATA__) | |
| // @namespace https://github.com/justin/userscripts | |
| // @version 2.1.0 | |
| // @description Renders the full article body from window.__NEXT_DATA__ urqlState into the DOM, matching the site's native paragraph styling. | |
| // @match https://www.theatlantic.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| // ---------- Style class discovery ---------- | |
| // The Atlantic uses CSS Modules, which produce hashed class names like | |
| // `ArticleParagraph_root__4mszW` that change on every build. Discover the | |
| // current hashes by scanning the live DOM for elements that match a known | |
| // tag + class-prefix pattern, instead of hardcoding the suffix. | |
| // | |
| // Discovery is scoped to `.article-content-body` (and the article body | |
| // section within it) so we don't pick up similarly-named classes that | |
| // might appear in headers, footers, or recirculation modules elsewhere | |
| // on the page. | |
| // | |
| // Each entry pairs a tag selector (narrows the search to the right kind | |
| // of element) with the class prefix that identifies it. | |
| const CLASS_PATTERNS = { | |
| // <section class="ArticleBody_root__..."> — the body wrapper itself. | |
| body: { tag: 'section', prefix: 'ArticleBody_root_' }, | |
| // <p class="ArticleParagraph_root__..."> — body paragraphs. | |
| paragraph: { tag: 'p', prefix: 'ArticleParagraph_root_' }, | |
| // <div class="ArticleLegacyHtml_root__... ArticleLegacyHtml_standard__..."> — | |
| // embed wrappers. We capture both the root and the variant class. | |
| legacyHtml: { tag: 'div', prefix: 'ArticleLegacyHtml_root_' }, | |
| legacyHtmlVariant: { tag: 'div', prefix: 'ArticleLegacyHtml_' }, | |
| // <p class="ArticleRelatedContentLink_root__..."> for in-body | |
| // recirculation links ("Read:" / "Listen:" promos). | |
| relatedLink: { tag: 'p', prefix: 'ArticleRelatedContentLink_root_' }, | |
| }; | |
| // Fallbacks are the hashes that were live when this script was written — | |
| // used only if discovery turns up nothing (e.g. the partial paywalled DOM | |
| // doesn't contain a related-content link yet, so its class can't be found). | |
| const FALLBACK_CLS = { | |
| paragraph: 'ArticleParagraph_root__4mszW', | |
| legacyHtml: 'ArticleLegacyHtml_root__WFd2I ArticleLegacyHtml_standard__kC_zi', | |
| relatedLink: 'ArticleRelatedContentLink_root__VYc9V', | |
| }; | |
| /** | |
| * Within the article content scope, find elements matching each tag+prefix | |
| * pattern, tally how often each matching class appears, and return the | |
| * most common one per pattern. Resilient to hashes shifting per deploy. | |
| */ | |
| const discoverClasses = () => { | |
| const scope = document.querySelector('.article-content-body'); | |
| if (!scope) { | |
| console.warn('[Atlantic Unpaywall] .article-content-body not found — using fallback classes'); | |
| return { ...FALLBACK_CLS }; | |
| } | |
| const counts = {}; // patternKey -> { className: count } | |
| for (const key of Object.keys(CLASS_PATTERNS)) counts[key] = {}; | |
| for (const [key, { tag, prefix }] of Object.entries(CLASS_PATTERNS)) { | |
| for (const node of scope.querySelectorAll(`${tag}[class]`)) { | |
| for (const cls of node.classList) { | |
| if (cls.startsWith(prefix)) { | |
| counts[key][cls] = (counts[key][cls] || 0) + 1; | |
| } | |
| } | |
| } | |
| } | |
| const pickMostCommon = (tally) => { | |
| let best = null, bestCount = 0; | |
| for (const [cls, count] of Object.entries(tally)) { | |
| if (count > bestCount) { best = cls; bestCount = count; } | |
| } | |
| return best; | |
| }; | |
| const paragraph = pickMostCommon(counts.paragraph); | |
| const legacyRoot = pickMostCommon(counts.legacyHtml); | |
| // For the legacy variant, exclude the root we just picked so we end | |
| // up with the *other* ArticleLegacyHtml_* class on the same element | |
| // (typically "_standard_" or similar). | |
| const variantTally = { ...counts.legacyHtmlVariant }; | |
| if (legacyRoot) delete variantTally[legacyRoot]; | |
| for (const c of Object.keys(counts.legacyHtml)) delete variantTally[c]; | |
| const legacyVariant = pickMostCommon(variantTally); | |
| const relatedLink = pickMostCommon(counts.relatedLink); | |
| return { | |
| paragraph: paragraph || FALLBACK_CLS.paragraph, | |
| legacyHtml: legacyRoot | |
| ? (legacyVariant ? `${legacyRoot} ${legacyVariant}` : legacyRoot) | |
| : FALLBACK_CLS.legacyHtml, | |
| relatedLink: relatedLink || FALLBACK_CLS.relatedLink, | |
| }; | |
| }; | |
| let CLS = null; // populated in run() once the DOM is ready | |
| // ---------- Locate the data ---------- | |
| /** | |
| * The urqlState key is a hash that varies per article, so we can't hardcode | |
| * it. Walk all entries, JSON.parse each .data string, and return the first | |
| * one whose shape matches an article.content array. | |
| */ | |
| const findArticleData = () => { | |
| const nextDataEl = document.querySelector('#__NEXT_DATA__'); | |
| if (!nextDataEl) { | |
| console.warn('[Atlantic Unpaywall] #__NEXT_DATA__ not found'); | |
| return null; | |
| } | |
| let nextData; | |
| try { | |
| nextData = JSON.parse(nextDataEl.textContent); | |
| } catch (e) { | |
| console.warn('[Atlantic Unpaywall] Could not parse #__NEXT_DATA__', e); | |
| return null; | |
| } | |
| const urqlState = nextData?.props?.pageProps?.urqlState; | |
| if (!urqlState) { | |
| console.warn('[Atlantic Unpaywall] urqlState not found on pageProps'); | |
| return null; | |
| } | |
| for (const key of Object.keys(urqlState)) { | |
| const entry = urqlState[key]; | |
| if (!entry || typeof entry.data !== 'string') continue; | |
| try { | |
| const parsed = JSON.parse(entry.data); | |
| if (Array.isArray(parsed?.article?.content)) { | |
| return parsed.article; | |
| } | |
| } catch { | |
| // Not JSON, or not the shape we want — keep scanning. | |
| } | |
| } | |
| console.warn('[Atlantic Unpaywall] No urqlState entry with article.content found'); | |
| return null; | |
| }; | |
| // ---------- Find the insertion point ---------- | |
| const findContainer = () => { | |
| // Primary: the body section is identified by data-event-module. | |
| // Fallback: locate the article-content-body wrapper and find the | |
| // <section> within it whose class starts with ArticleBody_root_. | |
| const primary = document.querySelector('section[data-event-module="article body"]'); | |
| if (primary) return primary; | |
| const scope = document.querySelector('.article-content-body'); | |
| if (!scope) return null; | |
| for (const section of scope.querySelectorAll('section[class]')) { | |
| for (const cls of section.classList) { | |
| if (cls.startsWith('ArticleBody_root_')) return section; | |
| } | |
| } | |
| return null; | |
| }; | |
| // ---------- Block renderers ---------- | |
| /** | |
| * Each renderer takes a content block and returns a DOM Node (or null to | |
| * skip). innerHtml strings from the Atlantic's API are already valid HTML, | |
| * so we set .innerHTML directly instead of building it up by hand. | |
| */ | |
| const renderers = { | |
| ArticleParagraphContent(block) { | |
| if (!block.innerHtml) return null; | |
| const p = document.createElement('p'); | |
| p.className = CLS.paragraph; | |
| p.setAttribute('data-flatplan-paragraph', 'true'); | |
| p.innerHTML = block.innerHtml; | |
| return p; | |
| }, | |
| ArticleLegacyHtml(block) { | |
| // Legacy HTML blocks carry pre-rendered embeds (YouTube, tweets, | |
| // etc.) and occasionally standalone elements like <hr/> dividers. | |
| // The block tells us the exact wrapper to build via | |
| // tagName/idAttr/className/style; honor all of them so the result | |
| // matches the server-rendered DOM. | |
| const tag = (block.tagName || 'DIV').toLowerCase(); | |
| // Void elements (hr, br, etc.) have no children — skipping them | |
| // when innerHtml is empty would drop legitimate separators. | |
| const VOID_TAGS = new Set(['hr', 'br', 'img', 'input', 'meta', 'link']); | |
| if (!block.innerHtml && !VOID_TAGS.has(tag)) return null; | |
| const wrap = document.createElement(tag); | |
| if (block.idAttr) wrap.id = block.idAttr; | |
| if (block.className) wrap.className = block.className; | |
| if (block.style) wrap.setAttribute('style', block.style); | |
| // Fall back to the site's standard legacy wrapper class only if | |
| // the block didn't specify one. | |
| if (!block.className) wrap.className = CLS.legacyHtml; | |
| if (block.innerHtml && !VOID_TAGS.has(tag)) { | |
| wrap.innerHTML = block.innerHtml; | |
| // Promote lazy-loaded embed iframes: the site's lazyload script | |
| // may have already finished its initial pass before we injected | |
| // these nodes, leaving data-src unconsumed. | |
| wrap.querySelectorAll('iframe[data-src]:not([src])').forEach(iframe => { | |
| iframe.src = iframe.dataset.src; | |
| }); | |
| } | |
| return wrap; | |
| }, | |
| ArticleRelatedContentLink(block) { | |
| if (!block.innerHtml) return null; | |
| // Rendered as: <p class="ArticleRelatedContentLink_root__..." | |
| // id="injected-recirculation-link-N" | |
| // data-view-action="view link - injected link - item N+1" | |
| // data-event-element="injected link" | |
| // data-event-position="N+1"> | |
| // <a href="...">Read/Listen: ...</a> | |
| // </p> | |
| const p = document.createElement('p'); | |
| p.className = CLS.relatedLink; | |
| if (block.idAttr) p.id = block.idAttr; | |
| // Site uses 1-indexed positions; block.index appears to be 0-indexed. | |
| const position = (typeof block.index === 'number' ? block.index : 0) + 1; | |
| p.setAttribute('data-view-action', `view link - injected link - item ${position}`); | |
| p.setAttribute('data-event-element', 'injected link'); | |
| p.setAttribute('data-event-position', String(position)); | |
| p.innerHTML = block.innerHtml; | |
| return p; | |
| }, | |
| }; | |
| const renderBlock = (block) => { | |
| const fn = renderers[block.__typename]; | |
| if (!fn) { | |
| console.warn('[Atlantic Unpaywall] No renderer for block type:', block.__typename); | |
| return null; | |
| } | |
| return fn(block); | |
| }; | |
| // ---------- Main ---------- | |
| const REPLACEMENT_ID = '__atlantic-unpaywall-body'; | |
| /** | |
| * The Atlantic is a Next.js site, and React keeps managing the original | |
| * <section data-event-module="article body"> long after initial paint. | |
| * If we replaceChildren() on that node, React's next reconciliation pass | |
| * tries to remove/insert children it thinks should be there, fails to | |
| * find them, and throws NotFoundError — which Next.js's error boundary | |
| * catches and converts into a navigation to the _error page. | |
| * | |
| * Workaround: build a sibling <section> next to the original, then | |
| * visually hide the original (display: none). React still "owns" the | |
| * hidden subtree and can reconcile it freely; our sibling is invisible | |
| * to React and won't be touched. | |
| */ | |
| const performInjection = (article, container) => { | |
| if (document.getElementById(REPLACEMENT_ID)) { | |
| // Already injected (e.g. SPA navigation re-fired our entry point). | |
| return; | |
| } | |
| // Discover class names BEFORE we hide the original — its children are | |
| // our source of truth for the current build's hashes. | |
| CLS = discoverClasses(); | |
| console.log('[Atlantic Unpaywall] Using classes:', CLS); | |
| // Build a sibling section that mirrors the original's tag and styling. | |
| const replacement = document.createElement('section'); | |
| replacement.id = REPLACEMENT_ID; | |
| replacement.className = container.className; | |
| // Preserve data-* attributes so any non-React downstream scripts | |
| // (analytics, reading progress, etc.) still find what they expect. | |
| for (const attr of container.attributes) { | |
| if (attr.name.startsWith('data-')) { | |
| replacement.setAttribute(attr.name, attr.value); | |
| } | |
| } | |
| for (const block of article.content) { | |
| const node = renderBlock(block); | |
| if (node) replacement.appendChild(node); | |
| } | |
| // Hide the React-managed original and insert our sibling after it. | |
| // We use the inline style to override any !important rules from the | |
| // site's own CSS. | |
| container.style.setProperty('display', 'none', 'important'); | |
| container.setAttribute('aria-hidden', 'true'); | |
| container.parentNode.insertBefore(replacement, container.nextSibling); | |
| // Belt-and-suspenders: remove paywall overlays Zephr may have layered on. | |
| document.querySelectorAll('[data-zephr-feature-slug="paywall"], [data-zephr-outcome-label*="Paywall"]').forEach(n => n.remove()); | |
| document.body.style.overflow = ''; | |
| document.documentElement.style.overflow = ''; | |
| console.log('[Atlantic Unpaywall] Rendered', article.content.length, 'blocks.'); | |
| }; | |
| /** | |
| * Wait until the article body has stopped mutating for `quietMs` — a | |
| * reasonable proxy for "React has finished hydrating." Falls through to | |
| * a hard timeout so we never hang. | |
| */ | |
| const waitForHydration = (container, quietMs = 600, hardTimeoutMs = 8000) => { | |
| return new Promise(resolve => { | |
| let quietTimer = null; | |
| let hardTimer = null; | |
| const done = () => { | |
| if (quietTimer) clearTimeout(quietTimer); | |
| if (hardTimer) clearTimeout(hardTimer); | |
| observer.disconnect(); | |
| resolve(); | |
| }; | |
| const observer = new MutationObserver(() => { | |
| if (quietTimer) clearTimeout(quietTimer); | |
| quietTimer = setTimeout(done, quietMs); | |
| }); | |
| observer.observe(container, { childList: true, subtree: true, attributes: true }); | |
| // Kick off the quiet timer immediately — if nothing mutates, we're | |
| // already done. | |
| quietTimer = setTimeout(done, quietMs); | |
| hardTimer = setTimeout(() => { | |
| console.warn('[Atlantic Unpaywall] Hydration wait hit hard timeout — proceeding anyway'); | |
| done(); | |
| }, hardTimeoutMs); | |
| }); | |
| }; | |
| const run = async () => { | |
| const article = findArticleData(); | |
| if (!article) return; | |
| const container = findContainer(); | |
| if (!container) { | |
| console.warn('[Atlantic Unpaywall] article body section not found in DOM'); | |
| return; | |
| } | |
| await waitForHydration(container); | |
| performInjection(article, container); | |
| }; | |
| if (document.readyState === 'complete') { | |
| run(); | |
| } else { | |
| window.addEventListener('load', run, { once: true }); | |
| } | |
| })(); |
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 The Wall Street Journal Article Unpaywaller (via __NEXT_DATA__) | |
| // @namespace https://github.com/justin/userscripts | |
| // @version 1.2.1 | |
| // @description Renders the full article body from window.__NEXT_DATA__.props.pageProps.articleData.flattenedBody into the DOM, matching the site's native styling. | |
| // @match https://www.wsj.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| 'use strict'; | |
| // ---------- Style class discovery ---------- | |
| // WSJ uses emotion-style classes (e.g. "css-i0p3hh epyej6v0") which are | |
| // hashed per build. Most don't have a stable semantic prefix, so we can't | |
| // discover them the way we did on the Atlantic. Instead, we sample classes | |
| // from existing nodes in the partial DOM that match a stable selector | |
| // (data-type="paragraph", figcaption, etc.) and reuse them on the new | |
| // nodes we create. | |
| // | |
| // Fallbacks are the hashes that were live when this script was written — | |
| // used only if the discovery scan turns up nothing for that key. | |
| const FALLBACK_CLS = { | |
| paragraph: 'css-i0p3hh epyej6v0', | |
| imageContainer: 'media-layout css-13plya0-Layout-baseCss ertdlv30', | |
| figure: 'css-x5rdl7-Figure ebruzsj0', | |
| picture: 'css-u314cv', | |
| img: 'css-1hzqsjo', | |
| figcaption: 'eihhvrm0 css-1n6fay2-FigcaptionItem e1jubwww21', | |
| captionSpan: 'e1m33gv80 css-426zcb-CaptionSpan e1m33gv81', | |
| credit: 'css-7jz429-Credit eq0esvu0', | |
| companyLink: 'ekxajjj0 css-i0lbhy-OverridedLink', | |
| bodyContainer: 'ef4qpkp0 css-1579sa9-Container e1jubwww0', | |
| }; | |
| /** | |
| * Discover live class names by querying for elements with stable selectors | |
| * (data-type attributes, semantic tags) and copying their className. This | |
| * is more robust than prefix-matching for sites where classes are pure | |
| * hashes with no semantic stem. | |
| */ | |
| const discoverClasses = () => { | |
| const out = { ...FALLBACK_CLS }; | |
| // Body container: parent <section> of the first paragraph or image. | |
| const anchor = document.querySelector('[data-type="paragraph"], [data-type="image"]'); | |
| const section = anchor?.closest('section'); | |
| if (section?.className) out.bodyContainer = section.className; | |
| // Paragraph: <p data-type="paragraph">. | |
| const p = document.querySelector('p[data-type="paragraph"]'); | |
| if (p?.className) out.paragraph = p.className; | |
| // Image container: <div data-type="image">. | |
| const imgDiv = document.querySelector('div[data-type="image"]'); | |
| if (imgDiv?.className) out.imageContainer = imgDiv.className; | |
| // Figure / picture / img within an image container. | |
| const figure = imgDiv?.querySelector('figure'); | |
| if (figure?.className) out.figure = figure.className; | |
| const picture = imgDiv?.querySelector('picture'); | |
| if (picture?.className) out.picture = picture.className; | |
| const img = imgDiv?.querySelector('img'); | |
| if (img?.className) out.img = img.className; | |
| // Figcaption + its caption/credit spans. | |
| const figcaption = imgDiv?.querySelector('figcaption'); | |
| if (figcaption?.className) out.figcaption = figcaption.className; | |
| // The caption is the first span; the credit is a span containing the | |
| // CSS class with "Credit" in its name (the best stable signal we have). | |
| if (figcaption) { | |
| const spans = [...figcaption.querySelectorAll('span')]; | |
| const creditSpan = spans.find(s => /Credit/i.test(s.className || '')); | |
| const captionSpan = spans.find(s => s !== creditSpan && /Caption/i.test(s.className || '')) | |
| || spans.find(s => s !== creditSpan); | |
| if (captionSpan?.className) out.captionSpan = captionSpan.className; | |
| if (creditSpan?.className) out.credit = creditSpan.className; | |
| } | |
| // Inline company-link styling, used for rich-content "company" tokens. | |
| const companyLink = document.querySelector('a[data-type="company"]'); | |
| if (companyLink?.className) out.companyLink = companyLink.className; | |
| return out; | |
| }; | |
| let CLS = null; // populated in run() once the DOM is ready | |
| // ---------- Locate the data ---------- | |
| const findArticleBody = () => { | |
| const nextDataEl = document.querySelector('#__NEXT_DATA__'); | |
| if (!nextDataEl) { | |
| console.warn('[WSJ Unpaywall] #__NEXT_DATA__ not found'); | |
| return null; | |
| } | |
| let nextData; | |
| try { | |
| nextData = JSON.parse(nextDataEl.textContent); | |
| } catch (e) { | |
| console.warn('[WSJ Unpaywall] Could not parse #__NEXT_DATA__', e); | |
| return null; | |
| } | |
| const body = nextData?.props?.pageProps?.articleData?.flattenedBody; | |
| if (!Array.isArray(body)) { | |
| console.warn('[WSJ Unpaywall] flattenedBody not found on pageProps.articleData'); | |
| return null; | |
| } | |
| return body; | |
| }; | |
| // ---------- Find the insertion point ---------- | |
| const findContainer = () => { | |
| // The article body section contains the existing <p data-type="paragraph"> | |
| // and <div data-type="image"> elements. Find it via either anchor and | |
| // walk up to the nearest <section>. | |
| const anchor = document.querySelector('[data-type="paragraph"], [data-type="image"]'); | |
| return anchor?.closest('section') || null; | |
| }; | |
| // ---------- Rich-content rendering for paragraph `content[]` ---------- | |
| /** | |
| * Each ParagraphArticleBody has a content[] of segments. Plain segments | |
| * have just { text }. Rich segments add metadata: | |
| * - phraseType: "company" -> link to /market-data/quotes/<ticker> | |
| * - phraseType: "person" -> usually just text (sometimes wsjTopicUrl) | |
| * - phraseType: other -> render as text + best-effort link if URL | |
| * | |
| * Note: WSJ's live page renders company segments with a full live stock | |
| * chiclet showing the change percentage and an arrow SVG. We can't | |
| * recreate that (no live data), so we render a plain link to the quote | |
| * page — the link itself still works. | |
| */ | |
| const renderParagraphSegment = (seg, parent) => { | |
| if (seg == null) return; | |
| const text = seg.text ?? ''; | |
| if (seg.phraseType === 'company' && seg.instrumentData?.ticker) { | |
| const a = document.createElement('a'); | |
| a.href = `/market-data/quotes/${seg.instrumentData.ticker}`; | |
| a.target = '_blank'; | |
| a.setAttribute('data-type', 'company'); | |
| a.className = CLS.companyLink; | |
| a.textContent = text; | |
| parent.appendChild(a); | |
| return; | |
| } | |
| if (seg.phraseType === 'person' && seg.wsjTopicUrl) { | |
| const a = document.createElement('a'); | |
| a.href = seg.wsjTopicUrl; | |
| a.textContent = text; | |
| parent.appendChild(a); | |
| return; | |
| } | |
| // Fallback: plain text. Other phraseTypes we haven't seen yet end up | |
| // here too, which is safe (no broken markup, just no link). | |
| if (text) parent.appendChild(document.createTextNode(text)); | |
| }; | |
| // ---------- Block renderers ---------- | |
| const renderers = { | |
| ParagraphArticleBody(block) { | |
| const p = document.createElement('p'); | |
| p.setAttribute('data-type', 'paragraph'); | |
| p.className = CLS.paragraph; | |
| for (const seg of (block.content || [])) { | |
| renderParagraphSegment(seg, p); | |
| } | |
| return p.childNodes.length ? p : null; | |
| }, | |
| ImageArticleBody(block) { | |
| // Wrapper div with data-type/data-layout, mirroring the live DOM. | |
| const wrap = document.createElement('div'); | |
| wrap.setAttribute('data-type', 'image'); | |
| wrap.setAttribute('data-inset_type', ''); | |
| wrap.setAttribute('data-sub_type', ''); | |
| const layout = block.properties?.responsive?.layout || 'inline'; | |
| wrap.setAttribute('data-layout', layout); | |
| wrap.className = CLS.imageContainer; | |
| // <figure><picture><img ...></picture></figure> | |
| const figure = document.createElement('figure'); | |
| figure.className = CLS.figure; | |
| const picture = document.createElement('picture'); | |
| picture.className = CLS.picture; | |
| const img = document.createElement('img'); | |
| img.className = CLS.img; | |
| img.alt = block.altText || ''; | |
| if (block.width) img.width = block.width; | |
| if (block.height) img.height = block.height; | |
| if (block.src?.baseUrl && block.src?.imageId) { | |
| // Use the same URL shape WSJ's renderer uses. | |
| const base = `${block.src.baseUrl}${block.src.imageId}`; | |
| const size = block.src.size ? `&size=${block.src.size}` : ''; | |
| img.src = `${base}?width=700&height=460`; | |
| // Build a srcset that covers the responsive breakpoints WSJ | |
| // uses live. The ?width param scales the image; the size | |
| // ratio is preserved. | |
| const widths = [540, 620, 639, 700]; | |
| const pixelRatios = [{w: 700, r: 1.5}, {w: 700, r: 2}, {w: 700, r: 3}]; | |
| const srcset = [ | |
| ...widths.map(w => `${base}?width=${w}${size} ${w}w`), | |
| ...pixelRatios.map(({w, r}) => | |
| `${base}?width=${w}${size}&pixel_ratio=${r} ${Math.round(w * r)}w`), | |
| ].join(', '); | |
| img.srcset = srcset; | |
| img.sizes = '(max-width: 639px) 100vw, (max-width: 979px) 620px, (max-width: 1299px) 540px, 700px'; | |
| } else if (block.properties?.location) { | |
| img.src = block.properties.location; | |
| } | |
| picture.appendChild(img); | |
| figure.appendChild(picture); | |
| wrap.appendChild(figure); | |
| // Figcaption: caption span + credit span (if either is present). | |
| if (block.caption || block.credit) { | |
| const figcap = document.createElement('figcaption'); | |
| figcap.className = CLS.figcaption; | |
| if (block.caption) { | |
| const captionSpan = document.createElement('span'); | |
| captionSpan.className = CLS.captionSpan; | |
| captionSpan.textContent = block.caption; | |
| figcap.appendChild(captionSpan); | |
| } | |
| if (block.caption && block.credit) { | |
| figcap.appendChild(document.createTextNode(' ')); | |
| } | |
| if (block.credit) { | |
| const creditSpan = document.createElement('span'); | |
| creditSpan.className = CLS.credit; | |
| creditSpan.textContent = block.credit; | |
| figcap.appendChild(creditSpan); | |
| } | |
| wrap.appendChild(figcap); | |
| } | |
| return wrap; | |
| }, | |
| }; | |
| const renderBlock = (block) => { | |
| const fn = renderers[block.__typename]; | |
| if (!fn) { | |
| // Best-effort fallback for block types we haven't seen yet: if the | |
| // block has a plain `text` field, show it as a paragraph; otherwise | |
| // skip it. Either way, log so you know to add a real renderer. | |
| console.warn('[WSJ Unpaywall] No renderer for block type:', block.__typename, block); | |
| if (typeof block.text === 'string' && block.text) { | |
| const p = document.createElement('p'); | |
| p.setAttribute('data-type', 'paragraph'); | |
| p.className = CLS.paragraph; | |
| p.textContent = block.text; | |
| return p; | |
| } | |
| return null; | |
| } | |
| return fn(block); | |
| }; | |
| // ---------- Main ---------- | |
| const REPLACEMENT_ID = '__wsj-unpaywall-body'; | |
| /** | |
| * WSJ paints a fade-out gradient over the bottom of the article via a | |
| * `::after` pseudo-element on a hashed class of the form | |
| * `css-<hash>-Container` (e.g. `css-1579sa9-Container`). The simplest | |
| * fix is to strip that class from our sibling so the rule no longer | |
| * applies — we don't need anything else from it. | |
| * | |
| * We match by `css-` prefix + `-Container` suffix so the rule survives | |
| * build-hash changes without catching unrelated classes that happen to | |
| * end in `-Container`. | |
| */ | |
| const stripFadeOutClass = (element) => { | |
| for (const cls of [...element.classList]) { | |
| if (cls.startsWith('css-') && cls.endsWith('-Container')) { | |
| element.classList.remove(cls); | |
| } | |
| } | |
| }; | |
| /** | |
| * WSJ is a Next.js site, so the same React-reconciliation hazard applies | |
| * as on the Atlantic: mutating the live container's children causes the | |
| * next render pass to throw NotFoundError and trip Next.js's error | |
| * boundary. Same workaround: build a sibling, hide the original. | |
| */ | |
| const performInjection = (body, container) => { | |
| if (document.getElementById(REPLACEMENT_ID)) return; | |
| CLS = discoverClasses(); | |
| console.log('[WSJ Unpaywall] Using classes:', CLS); | |
| const replacement = document.createElement('section'); | |
| replacement.id = REPLACEMENT_ID; | |
| replacement.className = container.className; | |
| // Remove the class that carries the paywall fade-out gradient. | |
| stripFadeOutClass(replacement); | |
| for (const attr of container.attributes) { | |
| if (attr.name.startsWith('data-')) { | |
| replacement.setAttribute(attr.name, attr.value); | |
| } | |
| } | |
| for (const block of body) { | |
| const node = renderBlock(block); | |
| if (node) replacement.appendChild(node); | |
| } | |
| container.style.setProperty('display', 'none', 'important'); | |
| container.setAttribute('aria-hidden', 'true'); | |
| container.parentNode.insertBefore(replacement, container.nextSibling); | |
| console.log('[WSJ Unpaywall] Rendered', body.length, 'blocks.'); | |
| }; | |
| const waitForHydration = (container, quietMs = 600, hardTimeoutMs = 8000) => { | |
| return new Promise(resolve => { | |
| let quietTimer = null; | |
| let hardTimer = null; | |
| const done = () => { | |
| if (quietTimer) clearTimeout(quietTimer); | |
| if (hardTimer) clearTimeout(hardTimer); | |
| observer.disconnect(); | |
| resolve(); | |
| }; | |
| const observer = new MutationObserver(() => { | |
| if (quietTimer) clearTimeout(quietTimer); | |
| quietTimer = setTimeout(done, quietMs); | |
| }); | |
| observer.observe(container, { childList: true, subtree: true, attributes: true }); | |
| quietTimer = setTimeout(done, quietMs); | |
| hardTimer = setTimeout(() => { | |
| console.warn('[WSJ Unpaywall] Hydration wait hit hard timeout — proceeding anyway'); | |
| done(); | |
| }, hardTimeoutMs); | |
| }); | |
| }; | |
| const run = async () => { | |
| const body = findArticleBody(); | |
| if (!body) return; | |
| const container = findContainer(); | |
| if (!container) { | |
| console.warn('[WSJ Unpaywall] article body <section> not found in DOM'); | |
| return; | |
| } | |
| await waitForHydration(container); | |
| performInjection(body, container); | |
| document.querySelector('.tp-container-inner')?.remove() | |
| }; | |
| if (document.readyState === 'complete') { | |
| run(); | |
| } else { | |
| window.addEventListener('load', run, { once: true }); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment