Skip to content

Instantly share code, notes, and snippets.

@jhyland87
Last active May 16, 2026 05:43
Show Gist options
  • Select an option

  • Save jhyland87/fc613d51837f94cb049b76fab3474ba2 to your computer and use it in GitHub Desktop.

Select an option

Save jhyland87/fc613d51837f94cb049b76fab3474ba2 to your computer and use it in GitHub Desktop.
Tampermonkey/Violentmonkey Scripts
// ==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();
})();
// ==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();
})();
// ==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();
})();
// ==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));
// });
})();
// ==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)
})();
// ==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');
})();
// ==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 });
}
})();
// ==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