Skip to content

Instantly share code, notes, and snippets.

@tyhallcsu
Last active July 31, 2026 07:55
Show Gist options
  • Select an option

  • Save tyhallcsu/aac9769038b639d11cae000ff1dab55d to your computer and use it in GitHub Desktop.

Select an option

Save tyhallcsu/aac9769038b639d11cae000ff1dab55d to your computer and use it in GitHub Desktop.
1337x - Combined Enhancements 2025 (v18) - Configurable | Published: https://sleazyfork.org/en/scripts/483602-1337x-combined-enhancements-2025-v18-configurable
// ==UserScript==
// @name 1337x - Combined Enhancements 2025 (v18) - Configurable
// @namespace http://tampermonkey.net/
// @version 2025.18
// @description Adds a column with torrent and magnet links, extends titles, adds images, full width site with configurable settings. Uses native fetch for Cloudflare compatibility.
// @author sharmanhall
// @contributor darkred, NotNeo, barn852, French Bond
// @match *://1337x.to/*
// @match *://1337x.st/*
// @match *://x1337x.cc/*
// @match *://x1337x.ws/*
// @match *://x1337x.eu/*
// @match *://l337xdarkkaqfwzntnfk5bmoaroivtl6xsbatabvlb52umg6v3ch44yd.onion/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-end
// @noframes
// @license MIT
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAB8UlEQVQ4jcWTsWsUYRDF38z37WbvAkJEBIVDREEEETsbG1GwMJXapLSxUEvTRSxEUsVeFALaWIighZg/QRBMEEFEBEE9Qkji5e72dm935lmEmMilS+E0M8384M17IySxl9I9bQOIW8PPqZNX030TdylKB5Kq25lnOZijRmg6dj47eHgWIWZQzer29xeHni7O/AOoB/kHaAyh0TxFUYR07JZX5fParR0b4zdF9SxEwKr0apC/H5HgZt+qvDstdVUqHSFJjyLEK6J6TrPG5Natqu7v+1B9PSIhxAgBFqwsXoroFElo1pxWDesQySCKKu99qnsbjyDiIwDZbKyL/mwI8RKSdH9MsxZDbEmI8HJQD398nbNy0IZs3/7vRDPQDDD7aEX+OIQIiEBjgHfXUXxZelcs/3pW93uouxujLtR5DyChjXFNsmYLIrC8By6vwTqr8Gp4QEQmUFUru9oIcFOHVRe837lmq22wyAEzaJIgJOkJjbxd1/W9nYBtMcMCGJZjWvbv2NpyyrwLd1t0kTc70nqdwDHuBnAzkH6ZxEXRAIrCzB+62Yy59+kOiLQA3CAZRgHkcYg8AKAkQfIzybcUWXLnAkTgZlDVaQEmR3MQwukQQkFyEUDi7k9EZGXTYs67+xGSIcaYDd3PAHgFAPLfv/EPJs4IQHNr/+kAAAAASUVORK5CYII=
// @downloadURL https://update.sleazyfork.org/scripts/483602/1337x%20-%20Combined%20Enhancements%202025%20%28v18%29%20-%20Configurable.user.js
// @updateURL https://update.sleazyfork.org/scripts/483602/1337x%20-%20Combined%20Enhancements%202025%20%28v18%29%20-%20Configurable.meta.js
// ==/UserScript==
// v18 Changes (issue refs: tyhallcsu/1337x-enhancements):
// - #33 Challenge detection no longer misfires on ordinary pages. The
// `script[src*="challenge-platform"]` rule is GONE: Cloudflare injects its jsd
// (JavaScript Detections) beacon from that path into perfectly normal HTTP 200
// responses, so it proved nothing and quietly turned every instrumented page into
// a "block". `[class^="cf-error"]` is replaced with token-aware matching — it
// anchored on the whole class ATTRIBUTE, so it MISSED `class="foo cf-error-details"`
// while MATCHING an innocent `class="cf-errorish"`. And because CHALLENGE_TITLE_RE
// is ^-anchored and ran against the LIVE document, a search whose query began with
// "Access denied" disabled the entire script; init() now also requires the site's
// own chrome (.page-content / .table-list / .box-info) to be absent before it
// bails out. A real interstitial is still detected and still gets nothing.
// - #34 HTTP 429 is a terminal block, not a transient failure. v17 retried it, which is
// exactly the wrong response to rate limiting: the request was re-sent up to
// maxRetries times and it reset the consecutive-block counter, so a host answering
// 403/429/403 never tripped the breaker. 429 now classifies as blocked, counts
// towards both breaker thresholds, and is never re-sent. Cloudflare's rate-limit
// marker `error code: 1015` joins the 4xx/5xx text markers, and `Retry-After` is
// honoured in both RFC 9110 forms (delta-seconds and HTTP-date), clamped to 300 s,
// falling back to the normal queue delay when absent or unparseable. It delays the
// REST of the queue — the rate-limited task itself is never retried.
// - #35 The live-settings reconciler handles showExtraColumn false->true. Re-enabling the
// ml/dl column rebuilt the cells but left every button `unresolved`, because the
// transition was missing from reapplyConsumersToProcessedRows. Already-processed
// rows now replay their CACHED detail document, so the buttons come back as live
// anchors for zero additional network requests.
// - #37 http->https upgrades are same-origin aware. findTorrentHref upgraded every
// torrent-file href unconditionally, so on the .onion gateway and the http-only
// mirrors a same-origin /files/x.torrent link was rewritten to a https:// URL the
// host does not serve — a dead download button. The upgrade rule resolveThumbUrl
// already used (upgrade cross-origin, leave same-origin alone) is now shared by
// both, in safeHttpUrlPreferSameOrigin(). The scheme allowlist is unchanged.
// - #38 A task can never be settled twice. runFetchTask's terminal .catch also covers the
// DOM work in handleBlocked -> tripBreaker -> showRetryBar/showPopup; if any of it
// threw, the already-rejected task was handed to handleTransientFailure, which
// pushed it back onto the queue and reset the consecutive-block counter. Every
// terminal resolve/reject now sets task.settled first, and the catch logs and
// returns instead of requeueing.
// - #36 Test infrastructure: the harness gained a third mode
// (`node test/headless-check.mjs --v18`) covering addDownloadButtons() in the name
// column, the retry/backoff branch against a flaky HTTP 500 route, and the
// same-origin guard against a genuine second HTTP origin. `npm test` runs all three
// modes and CI runs them on every push and pull request.
//
// v17 Changes (issue refs: tyhallcsu/1337x-enhancements):
// - #3 Every fetch has a configurable AbortController timeout (fetchTimeoutMs, default
// 15 s, clamped 1000-60000); one hung request can no longer wedge the serial queue.
// - #2 Responses are classified BEFORE throwing: HTTP 403/429/503, or a *structural*
// Cloudflare challenge signal in the parsed document (challenge-form, cf-error /
// cf-wrapper containers, a "Just a moment" / "Attention Required" / "Access denied"
// <title>) is "blocked" and is NEVER retried. Bare body text is no longer enough —
// a 200 page that merely mentions "Just a moment" or "Access denied" in its
// description is processed normally (#22). Retries are reserved for transient
// network errors/timeouts.
// - #2 Circuit breaker: 3 consecutive blocked results clear the queue, release the
// in-flight/dedupe state (so rows stay manually retryable) and show exactly one
// actionable popup. Fetching then stops until the user asks for a retry via the
// persistent #x1337-retry-bar or the settings-panel "Retry paused fetches"
// button, both of which purge the failed dedupe entries and re-run the row pass.
// - #5 Rows are enqueued exactly once: init() no longer runs the row pass twice and
// rows are marked (data-x1337-queued) synchronously at enqueue time.
// - #4 New `extendTitles` setting. A detail page is fetched only when at least one
// consumer (extendTitles / showThumbnails / showButtonsInNameColumn) is enabled.
// The extra ml/dl column no longer requires any background fetch.
// - #6 Every numeric setting is sanitized on load AND on save (finite check + clamp);
// poisoned/null stored values self-heal back to defaults.
// - #7 Render callbacks run in their own try/catch outside the fetch chain, so a DOM
// exception can never be misreported as a network error or trigger a retry.
// - #13 One shared per-URL promise cache; the parsed document is kept together with the
// final response URL, which is used as the base for resolving relative hrefs.
// - #8 URL scheme allowlist (http/https for files, magnet for magnets) applied at every
// point a fetched-document href reaches a live href or window.location.
// - #14 http->https upgrades go through the URL API, so magnet URIs (and their unencoded
// http trackers) are never corrupted.
// - #16 Thumbnails are rebuilt as fresh <img> elements (no cloneNode of remote nodes),
// hidden images carry no src until revealed, and all get lazy/async/no-referrer.
// - #17 Thumbnail strip is a single non-wrapping row with horizontal scroll: list rows
// keep a constant height.
// - #15 ml/dl buttons keep a data-state (unresolved/resolving/ready/failed) and never
// rewrite themselves into a dead href. Any consumer path that already has the
// detail document populates the row's buttons immediately (assignResolvedHrefs),
// so with the default config they are plain <a> elements before the first click.
// The click-only path never navigates after an await: it resolves, flips the
// button to `ready`, and asks the user to click again (a real anchor navigation).
// - #11 One owned <style id="x1337-style"> element; full-width is a real
// body.full-width-site rule, so the toggle works live in both directions.
// - #12 Live-apply reconciler for thumbnails/buttons/column, with an honest save popup.
// false->true transitions of showThumbnails / extendTitles / showButtonsInNameColumn
// re-run the affected consumer against the CACHED detail document for rows that
// are already data-processed (zero extra network); only rows whose document was
// never cached fall back to the "reload the page" popup.
// - #22 Torrent-file extraction validates candidates with the URL API: the host must be
// itorrents.org (or a subdomain) with a /torrent/ path, and the dropdown fallback
// only accepts .torrent paths or known torrent-cache hosts.
// - #23 Startup popup only fires when the stored version differs from this one.
// - #9 #10 MutationObserver handles nodes that CONTAIN rows, gives new rows their extra
// column cell, and is scoped to the content area.
// - #21 Same-origin guard before every queued fetch.
// - #18 #22 Duplicate #DLT/#DLM ids replaced with classes; positional selectors replaced
// with attribute/class selectors; title replacement guards empty results and links
// that contain markup.
// - #27 The breaker counts CONSECUTIVE blocks: any non-blocked response, network error
// or timeout ends the run, so an alternating 403/404 or 403/timeout pattern no
// longer trips it. A separate absolute ceiling (5 blocked responses per page)
// still stops a host that blocks intermittently from being fetched forever.
// - #29 The script no longer decorates a Cloudflare interstitial. v16 injected its
// settings gear and popup on top of "Just a moment…"; init() now detects a
// challenge document and injects nothing at all.
// - #18 Dead x1337x.se mirror dropped, the .onion entry is a proper @match, @noframes
// added, and the icon is inlined so no favicon request goes to a third party.
//
// Thanks to:
// - French Bond, darkred, NotNeo, barn852 for original scripts
(function() {
'use strict';
const SCRIPT_VERSION = '2025.18';
const VERSION_LABEL = 'v18';
// ==================== CONFIGURATION ====================
const DEFAULTS = {
showThumbnails: true,
showExtraColumn: true,
showButtonsInNameColumn: false,
fullWidthSite: true,
extendTitles: true,
visibleImages: 4,
queueFetchDelay: 300,
maxRetries: 3,
fetchTimeoutMs: 15000
};
// Bounds for every numeric setting. Anything outside is clamped; anything that is not a
// finite number (NaN, null, '', undefined) falls back to the default and is rewritten.
const BOUNDS = {
visibleImages: { min: 1, max: 10 },
queueFetchDelay: { min: 50, max: 5000 },
maxRetries: { min: 0, max: 10 },
fetchTimeoutMs: { min: 1000, max: 60000 }
};
const BOOL_KEYS = ['showThumbnails', 'showExtraColumn', 'showButtonsInNameColumn', 'fullWidthSite', 'extendTitles'];
const NUM_KEYS = ['visibleImages', 'queueFetchDelay', 'maxRetries', 'fetchTimeoutMs'];
let config = Object.assign({}, DEFAULTS);
function sanitizeNumber(key, value) {
const bounds = BOUNDS[key];
const n = typeof value === 'number' ? value : Number.parseInt(value, 10);
if (!Number.isFinite(n)) return DEFAULTS[key];
return Math.min(bounds.max, Math.max(bounds.min, Math.trunc(n)));
}
function sanitizeConfig(raw) {
const out = {};
BOOL_KEYS.forEach(key => {
out[key] = typeof raw[key] === 'boolean' ? raw[key] : DEFAULTS[key];
});
NUM_KEYS.forEach(key => {
out[key] = sanitizeNumber(key, raw[key]);
});
return out;
}
// Reads stored values, sanitizes them, and rewrites anything that was invalid so a
// poisoned profile heals itself instead of staying broken forever (#6).
function loadConfig() {
const raw = {};
BOOL_KEYS.concat(NUM_KEYS).forEach(key => {
raw[key] = GM_getValue(key, undefined);
});
config = sanitizeConfig(raw);
BOOL_KEYS.concat(NUM_KEYS).forEach(key => {
if (raw[key] !== undefined && raw[key] !== config[key]) {
console.warn(`[1337x] Invalid stored setting ${key}=${JSON.stringify(raw[key])}, reset to ${config[key]}`);
GM_setValue(key, config[key]);
}
});
}
function saveConfig(next) {
config = sanitizeConfig(next);
BOOL_KEYS.concat(NUM_KEYS).forEach(key => GM_setValue(key, config[key]));
}
// A detail page is only worth fetching when something actually consumes it (#4).
function needsDetailFetch() {
return config.extendTitles || config.showThumbnails || config.showButtonsInNameColumn;
}
// ==================== STYLES ====================
const STATIC_CSS = `
.list-button-magnet > i.flaticon-magnet {
font-size: 13px;
color: #da3a04
}
.list-button-dl > i.flaticon-torrent-download {
font-size: 13px;
color: #89ad19;
}
table.table-list td.dl-buttons {
border-left: 1px solid #f6f6f6;
border-right: 1px solid #c0c0c0;
padding-left: 2.5px;
padding-right: 2.5px;
text-align: center !important;
position: relative;
display: table-cell !important;
width: 6%;
}
td.dl-buttons > a,
td.dl-buttons > a:hover,
td.dl-buttons > a:visited,
td.dl-buttons > a:link,
td.dl-buttons > a:active {
color: inherit;
text-decoration: none;
cursor: pointer;
display: inline-block !important;
margin: 0 2px;
}
td.dl-buttons > a[data-state="resolving"] {
opacity: 0.45;
cursor: progress;
}
td.dl-buttons > a[data-state="failed"] {
opacity: 0.7;
}
table.table-list td.coll-1b {
border-right: 1px solid silver;
}
.table-list > thead > tr > th:nth-child(2),
.table-list > thead > tr > td:nth-child(2) {
text-align: center;
}
/* Thumbnails: single non-wrapping strip so list rows keep a constant height (#17) */
.thumbnail-container {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 10px;
margin-top: 10px;
max-height: 96px;
overflow-x: auto;
overflow-y: hidden;
}
.thumbnail-container img.x1337-thumb {
max-height: 90px;
max-width: 160px;
width: auto;
height: auto;
flex: 0 0 auto;
margin: 0 !important;
cursor: zoom-in;
}
.thumbnail-container img.x1337-thumb-hidden {
display: none;
}
body.x1337-no-thumbs .thumbnail-container {
display: none !important;
}
body.full-width-site .container {
max-width: 1450px !important;
}
#x1337-settings-wrapper {
font-family: 'Open Sans', sans-serif;
background-color: #1d1d1d;
color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(241, 78, 19, 0.5);
position: fixed;
top: 20px;
right: -300px;
width: 300px;
transition: right 0.3s ease;
z-index: 9999;
}
#x1337-settings-toggle {
position: absolute;
left: -30px;
top: 0;
width: 30px;
height: 30px;
background-color: #F14E13;
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
box-shadow: -2px 0 5px rgba(0,0,0,0.2);
cursor: pointer;
text-align: center;
line-height: 30px;
}
#x1337-settings-content {
padding: 15px;
}
#x1337-settings-content h3 {
color: #F14E13;
border-bottom: 1px solid #F14E13;
padding-bottom: 10px;
margin-bottom: 15px;
}
.x1337-option {
margin-bottom: 15px;
color: #fff;
}
.x1337-option label {
display: flex;
align-items: center;
cursor: pointer;
color: #fff;
}
.x1337-option input[type="checkbox"] {
margin-right: 10px;
}
.x1337-option input[type="number"],
.x1337-option input[type="text"] {
background-color: #2d2d2d;
border: 1px solid #F14E13;
color: #fff;
padding: 5px;
border-radius: 3px;
width: 80px;
}
#x1337-save-settings {
background-color: #F14E13;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.3s;
box-shadow: 0 0 10px rgba(241, 78, 19, 0.5);
}
#x1337-save-settings:hover {
background-color: #ff6a3c;
}
#x1337-settings-wrapper,
#x1337-settings-wrapper * {
color: #fff;
}
#x1337-popup {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background-color: #F14E13;
color: #fff;
padding: 10px 20px;
border-radius: 5px;
z-index: 10000;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
/* Persistent breaker bar: the ONLY way back from a tripped breaker (#2) */
#x1337-retry-bar {
position: fixed;
left: 50%;
bottom: 20px;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 10px;
background-color: #1d1d1d;
border: 1px solid #F14E13;
color: #fff;
font-family: 'Open Sans', sans-serif;
font-size: 13px;
padding: 8px 12px;
border-radius: 5px;
box-shadow: 0 0 10px rgba(241, 78, 19, 0.5);
z-index: 10001;
}
#x1337-retry-bar button {
background-color: #F14E13;
color: #fff;
border: none;
padding: 5px 12px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
}
#x1337-retry-bar button:hover {
background-color: #ff6a3c;
}
#x1337-retry-fetching {
background-color: #2d2d2d;
color: #fff;
border: 1px solid #F14E13;
padding: 6px 12px;
border-radius: 3px;
cursor: pointer;
margin-top: 8px;
font-size: 12px;
}
#x1337-retry-fetching:hover {
background-color: #3d3d3d;
}
.x1337-sub-option {
margin-left: 25px;
margin-top: 10px;
}
.x1337-sub-option label {
display: block;
margin-bottom: 8px;
font-size: 12px;
}
.x1337-group-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.7;
margin-bottom: 6px;
}
`;
// Exactly one owned <style> element; repeated saves rewrite it instead of stacking
// new sheets on top of each other (#11).
let styleElement = null;
function ensureStyleElement() {
if (styleElement && styleElement.isConnected) return styleElement;
let el = document.getElementById('x1337-style');
if (!el) {
const created = (typeof GM_addStyle === 'function') ? GM_addStyle('') : null;
el = (created && created.nodeType === 1) ? created : document.createElement('style');
el.id = 'x1337-style';
if (!el.isConnected) {
(document.head || document.documentElement).appendChild(el);
}
}
styleElement = el;
return el;
}
function applyStyles() {
ensureStyleElement().textContent = STATIC_CSS;
}
function applyBodyClasses() {
document.body.classList.toggle('full-width-site', !!config.fullWidthSite);
document.body.classList.toggle('x1337-no-thumbs', !config.showThumbnails);
}
// ==================== URL SAFETY ====================
// Every href that comes out of a fetched (remote, partly uploader-authored) document
// goes through these helpers before it can become a live href or a navigation (#8).
function resolveUrl(raw, base) {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (!trimmed) return null;
try {
return new URL(trimmed, base || window.location.href);
} catch (e) {
return null;
}
}
// Scheme allowlist only: http(s) in, absolute href out, scheme untouched. It never
// upgrades — #37 removed the `upgrade` flag so that no caller can rewrite a scheme
// without going through the same-origin rule below.
function safeHttpUrl(raw, base) {
const url = resolveUrl(raw, base);
if (!url) return null;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.href;
}
// http(s) only, returned as a URL object, with the ONE upgrade rule the script uses
// everywhere: a cross-origin http URL becomes https (mixed content would be blocked by
// the browser anyway), while a same-origin URL keeps the page's own scheme. v17 upgraded
// torrent-file hrefs unconditionally, which rewrote the http-only mirrors and the .onion
// gateway into a scheme they do not serve, producing a dead download button (#37).
// The upgrade goes through the URL API, never a string replace — that is what used to
// mangle magnet URIs and their unencoded http trackers (#14).
function safeHttpUrlPreferSameOrigin(raw, base) {
const absolute = safeHttpUrl(raw, base);
if (!absolute) return null;
const url = resolveUrl(absolute, base);
if (!url) return null;
if (url.protocol === 'http:' && url.origin !== window.location.origin) {
url.protocol = 'https:';
}
return url;
}
// Magnet URIs are passed through completely untouched.
function safeMagnetUrl(raw) {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return /^magnet:\?/i.test(trimmed) ? trimmed : null;
}
function isSameOrigin(raw) {
const url = resolveUrl(raw, window.location.href);
return !!url && url.origin === window.location.origin;
}
function findMagnetHref(doc) {
const anchor = doc.querySelector("a[href^='magnet:']");
return anchor ? safeMagnetUrl(anchor.getAttribute('href')) : null;
}
// Hosts that legitimately serve a .torrent file for a 1337x detail page. Matching is
// on the PARSED hostname, never on a substring of the raw href (#22).
const TORRENT_CACHE_HOSTS = ['itorrents.org', 'btcache.me', 'torrage.info'];
function hostMatches(hostname, suffix) {
return hostname === suffix || hostname.endsWith('.' + suffix);
}
function isTorrentCacheHost(hostname) {
for (let i = 0; i < TORRENT_CACHE_HOSTS.length; i++) {
if (hostMatches(hostname, TORRENT_CACHE_HOSTS[i])) return true;
}
return false;
}
// Preferred: a real itorrents.org /torrent/ URL. Fallback: a dropdown entry that either
// points at a .torrent path or lives on a known cache host. Anything else (an uploader
// link, an ad, "https://evil.example/itorrents.org/torrent/x") is rejected outright —
// the magnet link still works without it.
function findTorrentHref(doc, baseUrl) {
const direct = doc.querySelectorAll("a[href*='itorrents.org']");
for (let i = 0; i < direct.length; i++) {
const url = safeHttpUrlPreferSameOrigin(direct[i].getAttribute('href'), baseUrl);
if (!url) continue;
if (hostMatches(url.hostname.toLowerCase(), 'itorrents.org') && url.pathname.indexOf('/torrent/') === 0) {
return url.href;
}
}
const fallback = doc.querySelectorAll('.dropdown-menu li a');
for (let i = 0; i < fallback.length; i++) {
const url = safeHttpUrlPreferSameOrigin(fallback[i].getAttribute('href'), baseUrl);
if (!url) continue;
if (/\.torrent$/i.test(url.pathname) || isTorrentCacheHost(url.hostname.toLowerCase())) {
return url.href;
}
}
return null;
}
// ==================== FETCH QUEUE SYSTEM ====================
const BREAKER_THRESHOLD = 3;
// Consecutive blocks are the primary trip signal, but a host that alternates blocks
// with timeouts/errors would never produce a run of three. This absolute ceiling on
// blocked responses per page keeps that pattern from fetching indefinitely (#27).
const BREAKER_TOTAL_THRESHOLD = 5;
const BREAKER_MESSAGE = "Cloudflare is blocking page fetches — enhancements paused. Solve the site's challenge, then hit Retry.";
const RETRY_BAR_TEXT = '1337x enhancements paused (Cloudflare).';
// Text-only markers. On their own these prove nothing (an uploader description can
// contain any of them); they only count when corroborated by a 4xx/5xx status (#22).
// 1015 is Cloudflare's rate-limit code and travels with HTTP 429 (#34).
const BLOCK_TEXT_MARKERS = ['cf_chl', 'error code: 1020', 'error code: 1015'];
const CHALLENGE_TITLE_RE = /^\s*(just a moment|attention required|access denied|please wait)/i;
// #33 — script[src*="challenge-platform"] is GONE. Cloudflare injects its jsd
// (JavaScript Detections) beacon from that path into ORDINARY 200 responses, so its
// presence proves nothing; keeping it as a "corroborated" signal would add nothing
// either, because the status/container rules already decide every real case — and
// hasStructuralChallenge() also classifies FETCHED documents, where no init() guard
// can undo a false positive.
// Class selectors are token-aware by definition, so `.cf-error-details` correctly
// matches class="foo cf-error-details". The old [class^="cf-error"] was NOT: it anchors
// on the whole class ATTRIBUTE, so it missed that page and matched class="cf-errorish".
// Ids are single tokens, so [id^="cf-error"] is fine as-is.
const CF_CONTAINER_SELECTOR = '#challenge-form, #challenge-running, #challenge-error-title, ' +
'.cf-browser-verification, #cf-wrapper, #cf-error-details, .cf-error-code, ' +
'.cf-error-details, .cf-error-overview, .cf-error-footer, .cf-error-title, ' +
'[id^="cf-error"]';
// Site chrome no Cloudflare interstitial ever carries. Used only as a veto on the
// init() bail-out (#33): a detector false positive must never zero out the script.
const SITE_CHROME_SELECTOR = '.page-content, .table-list, .box-info';
// Cloudflare's own error containers are all cf-error-<something>. Matching per CLASS
// TOKEN rejects lookalikes such as "cf-errorish" while still catching cf-error-1020,
// cf-error-details, and any future cf-error-* container (#33).
function hasCfErrorClassToken(doc) {
const nodes = doc.querySelectorAll('[class*="cf-error"]');
for (let i = 0; i < nodes.length; i++) {
const tokens = nodes[i].classList;
for (let j = 0; j < tokens.length; j++) {
const token = tokens[j];
if (token === 'cf-error' || token.indexOf('cf-error-') === 0) return true;
}
}
return false;
}
const fetchQueue = [];
const docCache = new Map(); // absolute url -> Promise<{ doc, baseUrl }>
const failedUrls = new Set(); // dedupe keys that ended in a rejection (purged on reset)
let isProcessingQueue = false;
let consecutiveBlocked = 0;
let totalBlocked = 0;
let fetchingDisabled = false;
let breakerPopupShown = false;
let crossOriginWarned = false;
function fetchError(message, code) {
const err = new Error(message);
err.code = code;
return err;
}
function fetchTimeoutMs() {
return sanitizeNumber('fetchTimeoutMs', config.fetchTimeoutMs);
}
// Structural Cloudflare signals, read off the PARSED document. A body-text substring
// search over the whole page is far too broad — a torrent description that says
// "Access denied" would otherwise trip the breaker (#22).
function hasStructuralChallenge(doc) {
if (!doc) return false;
try {
const titleEl = doc.querySelector('title');
if (titleEl && CHALLENGE_TITLE_RE.test(titleEl.textContent || '')) return true;
if (doc.querySelector(CF_CONTAINER_SELECTOR)) return true;
return hasCfErrorClassToken(doc);
} catch (e) {
// A hostile/odd document must never turn a render problem into a network error.
return false;
}
}
// The site's own markup. A Cloudflare interstitial replaces the page entirely, so if any
// of this is present the document is a real 1337x page whatever the detector thinks (#33).
function hasSiteChrome(doc) {
if (!doc) return false;
try {
return !!doc.querySelector(SITE_CHROME_SELECTOR);
} catch (e) {
return false;
}
}
// A confirmed block is (403 | 429 | 503) OR a structural challenge document. Blocked
// results are terminal — retrying a Cloudflare challenge or a rate limit from a
// background fetch cannot work, and retrying a 429 actively makes it worse (#34).
function isBlockedResponse(status, text, doc) {
if (status === 403 || status === 429 || status === 503) return true;
if (hasStructuralChallenge(doc)) return true;
if (status >= 400 && typeof text === 'string') {
for (let i = 0; i < BLOCK_TEXT_MARKERS.length; i++) {
if (text.indexOf(BLOCK_TEXT_MARKERS[i]) !== -1) return true;
}
}
return false;
}
// Retry-After, per RFC 9110: either delta-seconds or an HTTP-date. Both forms are
// accepted; the result is clamped so a hostile/absurd header cannot park the queue for
// hours. Anything unparseable returns null, and the caller falls back to the ordinary
// queue delay (#34).
const RETRY_AFTER_MAX_MS = 300000;
function parseRetryAfter(raw) {
if (typeof raw !== 'string') return null;
const value = raw.trim();
if (!value) return null;
if (/^\d+$/.test(value)) {
const seconds = Number.parseInt(value, 10);
if (!Number.isFinite(seconds)) return null;
return Math.min(RETRY_AFTER_MAX_MS, seconds * 1000);
}
const when = Date.parse(value);
if (!Number.isFinite(when)) return null;
const delta = when - Date.now();
if (delta <= 0) return null;
return Math.min(RETRY_AFTER_MAX_MS, delta);
}
// One in-flight/completed fetch per URL; every consumer shares the same parsed result.
function fetchDoc(rawUrl) {
const url = resolveUrl(rawUrl, window.location.href);
const key = url ? url.href : String(rawUrl);
if (docCache.has(key)) return docCache.get(key);
if (fetchingDisabled) {
return Promise.reject(fetchError('Fetching disabled by circuit breaker', 'blocked'));
}
const promise = new Promise((resolve, reject) => {
fetchQueue.push({ url: key, resolve, reject, retries: 0 });
});
// Consumers that ignore the promise must not produce unhandled rejections, and a
// rejected promise must never linger in the dedupe map (it would make every later
// retry fail instantly without a single request).
promise.catch(() => {
failedUrls.add(key);
if (docCache.get(key) === promise) docCache.delete(key);
});
docCache.set(key, promise);
if (!isProcessingQueue) processQueue();
return promise;
}
function releaseUrl(url) {
failedUrls.add(url);
docCache.delete(url);
}
// Returns the shared cache entry for a URL without ever starting a fetch. Used by the
// settings reconciler so a false->true consumer transition costs zero requests (#12).
function cachedDoc(rawUrl) {
const url = resolveUrl(rawUrl, window.location.href);
const key = url ? url.href : String(rawUrl);
return docCache.has(key) ? docCache.get(key) : null;
}
function scheduleNext(delay) {
setTimeout(processQueue, typeof delay === 'number' ? delay : config.queueFetchDelay);
}
function processQueue() {
if (fetchingDisabled) {
clearQueue(fetchError(BREAKER_MESSAGE, 'blocked'));
isProcessingQueue = false;
return;
}
if (fetchQueue.length === 0) {
isProcessingQueue = false;
return;
}
isProcessingQueue = true;
runFetchTask(fetchQueue.shift());
}
function clearQueue(error) {
while (fetchQueue.length) {
const task = fetchQueue.shift();
task.settled = true;
releaseUrl(task.url);
task.reject(error);
}
}
function tripBreaker() {
fetchingDisabled = true;
isProcessingQueue = false;
clearQueue(fetchError(BREAKER_MESSAGE, 'blocked'));
console.error('[1337x] Circuit breaker tripped — page fetching disabled until the user retries.');
showRetryBar();
if (!breakerPopupShown) {
breakerPopupShown = true;
showPopup(BREAKER_MESSAGE, 10000);
}
}
// The popup auto-dismisses, so the breaker also leaves a persistent, clickable way back.
function showRetryBar() {
if (!document.body || document.getElementById('x1337-retry-bar')) return;
const bar = document.createElement('div');
bar.id = 'x1337-retry-bar';
const label = document.createElement('span');
label.textContent = RETRY_BAR_TEXT;
bar.appendChild(label);
const retry = document.createElement('button');
retry.type = 'button';
retry.id = 'x1337-retry-bar-button';
retry.textContent = 'Retry';
retry.addEventListener('click', function(e) {
e.preventDefault();
resetFetching();
}, false);
bar.appendChild(retry);
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.id = 'x1337-retry-bar-dismiss';
dismiss.textContent = '✕';
dismiss.title = 'Dismiss (Retry stays available in the settings panel)';
dismiss.addEventListener('click', function(e) {
e.preventDefault();
removeRetryBar();
}, false);
bar.appendChild(dismiss);
document.body.appendChild(bar);
}
function removeRetryBar() {
const bar = document.getElementById('x1337-retry-bar');
if (bar && bar.parentNode) bar.parentNode.removeChild(bar);
}
// Explicit user-driven reset: clears the breaker, purges the rejected dedupe entries
// (otherwise a cached rejection would make every retry fail without a request), clears
// the per-row queued markers and re-runs the row pass (#2).
function resetFetching() {
fetchingDisabled = false;
consecutiveBlocked = 0;
totalBlocked = 0;
breakerPopupShown = false;
crossOriginWarned = false;
failedUrls.forEach(url => docCache.delete(url));
failedUrls.clear();
document.querySelectorAll('tr[data-x1337-queued]').forEach(row => {
if (row.dataset.processed !== 'true') row.dataset.x1337Queued = '0';
});
removeRetryBar();
console.log('[1337x] Circuit breaker reset — retrying unprocessed rows.');
showPopup('Retrying paused fetches…', 3000);
processAllRows();
}
// `retryAfterMs` comes from a Retry-After header (#34). It delays the REST of the queue;
// the blocked task itself is terminal and is never re-run.
function handleBlocked(task, retryAfterMs) {
// #38 — terminal from this point on, whatever the DOM work below does. runFetchTask's
// .catch also covers tripBreaker/showRetryBar, and a task that has already been
// rejected must never be pushed back onto the queue.
task.settled = true;
consecutiveBlocked++;
totalBlocked++;
releaseUrl(task.url);
console.warn(`[1337x] Blocked response for ${task.url} (consecutive: ${consecutiveBlocked}, total: ${totalBlocked})`);
task.reject(fetchError('Blocked by Cloudflare', 'blocked'));
if (consecutiveBlocked >= BREAKER_THRESHOLD || totalBlocked >= BREAKER_TOTAL_THRESHOLD) {
tripBreaker();
return;
}
scheduleNext(typeof retryAfterMs === 'number' ? retryAfterMs : undefined);
}
function handleTransientFailure(task, error) {
// #27 — a network error or timeout is not a blocked result, so it ends any run of
// consecutive blocks. The absolute totalBlocked ceiling is what stops an
// alternating block/timeout pattern from fetching forever.
consecutiveBlocked = 0;
console.error(`[1337x] Fetch error for ${task.url}:`, error);
if (task.retries < config.maxRetries) {
const delay = config.queueFetchDelay * Math.pow(2, task.retries + 1);
task.retries++;
fetchQueue.push(task);
scheduleNext(delay);
return;
}
task.settled = true;
releaseUrl(task.url);
task.reject(error);
scheduleNext();
}
function runFetchTask(task) {
// #21 — never fetch anything that is not on this origin.
if (!isSameOrigin(task.url)) {
if (!crossOriginWarned) {
crossOriginWarned = true;
console.warn('[1337x] Skipping cross-origin fetch target:', task.url);
}
task.settled = true;
releaseUrl(task.url);
task.reject(fetchError('Cross-origin fetch target', 'cross-origin'));
scheduleNext(0);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs());
let status = 0;
let finalUrl = task.url;
let retryAfterMs = null;
fetch(task.url, {
method: 'GET',
credentials: 'same-origin', // sends cookies including HttpOnly cf_clearance
signal: controller.signal,
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
})
.then(response => {
status = response.status;
finalUrl = response.url || task.url;
try {
retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
} catch (headerError) {
retryAfterMs = null;
}
return response.text(); // classify AFTER reading the body (#2)
})
.then(text => {
clearTimeout(timer);
// Parse first: classification now inspects the DOCUMENT (title/head/CF
// containers) rather than raw body text (#22). A parser failure is not a
// network failure, so it never reaches the transient-retry path.
let doc = null;
try {
doc = new DOMParser().parseFromString(text, 'text/html');
} catch (parseError) {
console.error('[1337x] Parse error for', task.url, parseError);
}
if (isBlockedResponse(status, text, doc)) {
// #34 — a 429 reaches HERE and never handleTransientFailure below, so it is
// not retried and does not reset the consecutive-block run.
handleBlocked(task, retryAfterMs);
return;
}
// #27 — the breaker counts CONSECUTIVE blocks. Any response that classifies
// as non-blocked breaks the run, even a 404 or a retryable 5xx; otherwise an
// alternating sequence like 403,404,403,404,403 would trip it.
consecutiveBlocked = 0;
if (status === 408 || status >= 500) {
handleTransientFailure(task, fetchError(`HTTP ${status}`, 'transient'));
return;
}
if (status >= 400 || !doc) {
task.settled = true;
releaseUrl(task.url);
task.reject(fetchError(doc ? `HTTP ${status}` : 'Unparseable response', doc ? 'http-error' : 'parse-error'));
scheduleNext();
return;
}
// Resolve the promise and advance the queue; consumer code runs in its own
// microtask, so a render exception can never re-enter this chain (#7).
task.settled = true;
task.resolve({ doc: doc, baseUrl: finalUrl });
scheduleNext();
})
.catch(error => {
clearTimeout(timer);
// #38 — this catch also covers the DOM work in handleBlocked -> tripBreaker ->
// showRetryBar/showPopup. If that throws, the task has ALREADY been settled and
// rejected; feeding it to handleTransientFailure would push a dead task back
// onto the queue and refetch a URL the script has given up on.
if (task.settled) {
console.error('[1337x] Error after task settled for', task.url, error);
// The breaker path already cleared the queue; only a failure before the
// breaker took over leaves the pump needing a nudge.
if (!fetchingDisabled) scheduleNext();
return;
}
handleTransientFailure(task, error);
});
}
// ==================== COLUMN AND BUTTON FUNCTIONS ====================
let extraColumnAdded = false;
function firstCellOf(row) {
return row.querySelector(':scope > td.coll-1') || row.querySelector(':scope > td') || null;
}
function makeDlButton(type, targetUrl) {
const anchor = document.createElement('a');
anchor.className = type === 'ml' ? 'list-button-magnet' : 'list-button-dl';
anchor.setAttribute('href', '#');
anchor.dataset.href = targetUrl;
anchor.dataset.type = type;
anchor.dataset.state = 'unresolved';
anchor.title = type === 'ml' ? 'Magnet link' : 'Torrent download';
const icon = document.createElement('i');
icon.className = type === 'ml' ? 'flaticon-magnet' : 'flaticon-torrent-download';
anchor.appendChild(icon);
anchor.addEventListener('click', onDlButtonClick, false);
return anchor;
}
// Exactly one extra cell per row, idempotent, usable from both the initial pass and
// the MutationObserver (#10).
function addColumnCellToRow(row) {
if (!config.showExtraColumn) return;
if (row.classList.contains('blank')) return;
if (row.dataset.x1337Col === '1' || row.querySelector('td.dl-buttons')) return;
const cell = firstCellOf(row);
if (!cell) return;
// #22 — attribute selector instead of the positional querySelectorAll('a')[1].
const titleLink = cell.querySelector('a[href^="/torrent/"]');
if (!titleLink) return;
const td = document.createElement('td');
td.className = 'coll-1b dl-buttons';
td.appendChild(makeDlButton('ml', titleLink.href));
td.appendChild(makeDlButton('dl', titleLink.href));
cell.insertAdjacentElement('afterend', td);
row.dataset.x1337Col = '1';
}
function addColumnHeaderToTable(wrap) {
const table = wrap.querySelector('.table-list') || (wrap.classList.contains('table-list') ? wrap : null);
if (!table) return;
if (table.querySelector('thead > tr > th.coll-1b')) return;
const headerRow = table.querySelector('thead > tr:not(.blank)');
if (!headerRow) return;
const headerCell = headerRow.querySelector(':scope > th.coll-1') || headerRow.querySelector(':scope > th');
if (!headerCell) return;
const th = document.createElement('th');
th.className = 'coll-1b';
th.innerHTML = 'ml&nbsp;dl';
headerCell.insertAdjacentElement('afterend', th);
}
function appendColumn() {
if (!config.showExtraColumn) return;
document.querySelectorAll('.table-list-wrap').forEach(wrap => {
addColumnHeaderToTable(wrap);
wrap.querySelectorAll('.table-list > tbody > tr').forEach(addColumnCellToRow);
});
extraColumnAdded = true;
}
function removeColumn() {
document.querySelectorAll('th.coll-1b').forEach(th => th.remove());
document.querySelectorAll('td.coll-1b.dl-buttons').forEach(td => td.remove());
document.querySelectorAll('tr[data-x1337-col]').forEach(row => { delete row.dataset.x1337Col; });
extraColumnAdded = false;
}
function setButtonState(button, state) {
button.dataset.state = state;
if (state === 'resolving') {
button.title = 'Resolving link…';
} else if (state === 'failed') {
button.title = 'Link not found — click to retry';
} else if (state === 'ready') {
button.title = button.dataset.type === 'ml' ? 'Magnet link' : 'Torrent download';
}
}
// Pulls both hrefs out of a fetched detail document. Extraction failures are contained
// here so they can never look like a network error (#7).
function extractHrefs(result) {
let magnetHref = null;
let torrentHref = null;
try {
magnetHref = findMagnetHref(result.doc);
torrentHref = findTorrentHref(result.doc, result.baseUrl);
} catch (err) {
console.error('[1337x] Link extraction error:', err);
}
return { magnetHref: magnetHref, torrentHref: torrentHref };
}
function assignResolvedHrefs(cell, magnetHref, torrentHref) {
const magnetButton = cell.querySelector('.list-button-magnet');
const dlButton = cell.querySelector('.list-button-dl');
if (magnetButton && magnetHref) {
magnetButton.setAttribute('href', magnetHref);
setButtonState(magnetButton, 'ready');
}
if (dlButton && torrentHref) {
dlButton.setAttribute('href', torrentHref);
setButtonState(dlButton, 'ready');
}
}
// Buttons resolve on click through the shared cache and never rewrite themselves into
// a dead state (#15).
function onDlButtonClick(e) {
const button = this;
const state = button.dataset.state;
if (state === 'ready') return; // plain anchor navigation
e.preventDefault();
if (state === 'resolving') return;
const target = button.dataset.href;
if (!target || !isSameOrigin(target)) {
setButtonState(button, 'failed');
showPopup('Link not found', 3000);
return;
}
const cell = button.closest('td') || button.parentNode;
const type = button.dataset.type;
setButtonState(button, 'resolving');
fetchDoc(target).then(result => {
const hrefs = extractHrefs(result);
assignResolvedHrefs(cell, hrefs.magnetHref, hrefs.torrentHref);
const wanted = type === 'ml' ? hrefs.magnetHref : hrefs.torrentHref;
if (!wanted) {
setButtonState(button, 'failed');
showPopup('Link not found', 3000);
return;
}
// #15 — transient activation is long gone after an await, and a scripted
// window.location.href to magnet:/a download can be blocked. The button is now
// a real anchor: ask for a second click, which navigates natively.
setButtonState(button, 'ready');
showPopup('Link ready — click again', 4000);
}).catch(error => {
setButtonState(button, 'failed');
if (error && error.code === 'blocked') {
if (!breakerPopupShown) {
showPopup('Request blocked — solve the site challenge, then reload.', 5000);
}
} else {
console.error('[1337x] Error fetching link:', error);
showPopup('Error fetching link', 3000);
}
});
}
// ==================== IMAGE/THUMBNAIL FUNCTIONS ====================
function optimizeImageUrl(imgSrc) {
const optimizations = [
{ from: 'https://imgtraffic.com/1s/', to: 'https://imgtraffic.com/1/' },
{ from: /https?:\/\/.*\/images\/.*\.th\.jpg$/, to: (url) => url.replace(/\.th\.jpg$/, '.jpg') },
{ from: 'https://22pixx.xyz/as/', to: 'https://22pixx.xyz/a/' },
{ from: 'http://imgblaze.net/data_server_', to: 'https://www.imgopaleno.site/data_server_' },
{ from: '/small/small_', to: '/big/' }
];
return optimizations.reduce((url, opt) => {
if (typeof opt.from === 'string') {
return url.replace(opt.from, opt.to);
} else if (opt.from instanceof RegExp) {
return opt.from.test(url) ? url.replace(opt.from, opt.to) : url;
}
return url;
}, imgSrc);
}
// Resolves a remote thumbnail reference to an absolute, http(s)-only URL string, through
// the same same-origin-aware upgrade rule that findTorrentHref now uses (#37).
function resolveThumbUrl(raw, baseUrl) {
const absolute = safeHttpUrl(raw, baseUrl);
if (!absolute) return null;
const optimized = safeHttpUrlPreferSameOrigin(optimizeImageUrl(absolute), baseUrl);
return optimized ? optimized.href : null;
}
function revealThumb(img) {
if (!img.getAttribute('src') && img.dataset.src) {
img.setAttribute('src', img.dataset.src);
}
img.classList.remove('x1337-thumb-hidden');
}
// Rebuilds every thumbnail as a fresh <img> from a validated URL string only — nothing
// is cloned out of the untrusted parsed document (#16).
function appendImages(link, doc, baseUrl) {
if (!config.showThumbnails) return;
const host = link.parentNode;
if (!host || host.querySelector('.thumbnail-container')) return;
const nodes = doc.querySelectorAll('#description img');
const urls = [];
nodes.forEach(node => {
const raw = node.getAttribute('data-original') || node.getAttribute('src');
const url = resolveThumbUrl(raw, baseUrl);
if (url) urls.push(url);
});
if (urls.length === 0) return;
const container = document.createElement('div');
container.className = 'thumbnail-container';
const thumbs = [];
urls.forEach((url, index) => {
const img = document.createElement('img');
img.className = 'x1337-thumb';
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
img.dataset.src = url;
// Hidden images get NO src until they are revealed — display:none does not
// stop a download (#16).
if (index < config.visibleImages) {
img.setAttribute('src', url);
} else {
img.classList.add('x1337-thumb-hidden');
}
img.addEventListener('error', function() {
img.classList.add('x1337-thumb-failed');
img.remove();
});
img.addEventListener('mouseover', function(e) { showEnlargedImg(url, e); });
img.addEventListener('mousemove', updateEnlargedImgPosition);
img.addEventListener('mouseout', removeEnlargedImg);
container.appendChild(img);
thumbs.push(img);
});
if (urls.length > config.visibleImages) {
const showMoreButton = document.createElement('button');
showMoreButton.type = 'button';
showMoreButton.className = 'x1337-show-more';
showMoreButton.textContent = 'Show More';
showMoreButton.style.flex = '0 0 auto';
showMoreButton.style.backgroundColor = '#1d1d1d';
showMoreButton.style.border = '1px solid #F14E13';
showMoreButton.style.color = '#fff';
showMoreButton.style.borderRadius = '3px';
showMoreButton.style.padding = '4px 8px';
showMoreButton.style.fontSize = '11px';
showMoreButton.style.cursor = 'pointer';
showMoreButton.addEventListener('click', function(e) {
e.preventDefault();
const showingMore = showMoreButton.textContent === 'Show Less';
thumbs.forEach((img, index) => {
if (index < config.visibleImages) return;
if (showingMore) {
img.classList.add('x1337-thumb-hidden');
} else {
revealThumb(img);
}
});
showMoreButton.textContent = showingMore ? 'Show More' : 'Show Less';
});
container.appendChild(showMoreButton);
}
host.insertBefore(container, link.nextSibling);
}
function showEnlargedImg(imgSrc, event) {
removeEnlargedImg();
const enlargedImg = document.createElement('img');
enlargedImg.src = imgSrc;
enlargedImg.id = 'x1337-enlarged-img';
enlargedImg.referrerPolicy = 'no-referrer';
enlargedImg.style.position = 'fixed';
enlargedImg.style.zIndex = '10000';
enlargedImg.style.border = '2px solid #F14E13';
enlargedImg.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
enlargedImg.style.maxWidth = '500px';
enlargedImg.style.maxHeight = '500px';
enlargedImg.style.width = 'auto';
enlargedImg.style.height = 'auto';
document.body.appendChild(enlargedImg);
updateEnlargedImgPosition(event);
}
function updateEnlargedImgPosition(e) {
const enlargedImg = document.getElementById('x1337-enlarged-img');
if (enlargedImg) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const imgWidth = enlargedImg.offsetWidth;
const imgHeight = enlargedImg.offsetHeight;
let left = e.clientX + 20;
let top = e.clientY + 20;
if (left + imgWidth > viewportWidth) {
left = e.clientX - imgWidth - 20;
}
if (top + imgHeight > viewportHeight) {
top = e.clientY - imgHeight - 20;
}
enlargedImg.style.left = `${left}px`;
enlargedImg.style.top = `${top}px`;
}
}
function removeEnlargedImg() {
const enlargedImg = document.getElementById('x1337-enlarged-img');
if (enlargedImg && enlargedImg.parentNode) {
enlargedImg.parentNode.removeChild(enlargedImg);
}
}
// ==================== UTILITY FUNCTIONS ====================
function cleanTitle(title) {
let out = typeof title === 'string' ? title : '';
if (out.startsWith('Download ')) {
out = out.substring('Download '.length);
}
const pipeIndex = out.indexOf(' Torrent |');
if (pipeIndex !== -1) {
out = out.substring(0, pipeIndex);
}
out = out.trim();
return out || (typeof title === 'string' ? title.trim() : '');
}
function modifyH1ContentOnTorrentPages() {
if (window.location.pathname.startsWith('/torrent/')) {
const h1Element = document.querySelector('.box-info-heading h1');
const cleaned = cleanTitle(document.title);
if (h1Element && cleaned) {
h1Element.textContent = cleaned;
}
}
}
// ==================== LINK PROCESSING ====================
// #15 — whenever a detail document is available for a row (from ANY consumer path),
// the extra-column buttons are populated straight away. No extra fetch: the document is
// already in hand. The buttons become real anchors, so the first click is a plain
// synchronous navigation instead of an async window.location.href.
function populateRowButtons(link, result) {
if (!config.showExtraColumn) return;
const row = (typeof link.closest === 'function') ? link.closest('tr') : null;
const cell = row ? row.querySelector('td.dl-buttons') : null;
if (!cell) return;
const hrefs = extractHrefs(result);
assignResolvedHrefs(cell, hrefs.magnetHref, hrefs.torrentHref);
}
function renderDetail(link, result) {
populateRowButtons(link, result);
updateLinkTitle(link, result.doc);
if (config.showThumbnails) {
appendImages(link, result.doc, result.baseUrl);
}
if (config.showButtonsInNameColumn) {
addDownloadButtons(link, result.doc, result.baseUrl);
}
}
function processLink(row, link) {
if (!needsDetailFetch()) return;
if (row.dataset.processed === 'true' || row.dataset.x1337Queued === '1') return;
// Marked synchronously at enqueue time so no later pass can double-queue it (#5).
row.dataset.x1337Queued = '1';
const url = link.href;
fetchDoc(url).then(result => {
// Consumer code is isolated: a render exception logs once, marks the row
// processed, and never becomes a network retry (#7).
try {
renderDetail(link, result);
} catch (err) {
console.error('[1337x] Render error for', url, err);
} finally {
row.dataset.processed = 'true';
}
}).catch(error => {
// Leave the row unprocessed and release the marker so it stays retryable.
row.dataset.x1337Queued = '0';
if (!error || error.code !== 'blocked') {
console.warn('[1337x] Giving up on', url, error);
}
});
}
function decorateRow(row) {
if (!row || row.nodeType !== 1) return;
if (row.classList.contains('blank')) return;
if (config.showExtraColumn) {
addColumnCellToRow(row);
}
const link = row.querySelector('a[href^="/torrent/"]');
if (link) processLink(row, link);
}
function updateLinkTitle(link, doc) {
if (!config.extendTitles) return;
// Never clobber a link that carries markup (e.g. search-highlight spans) (#22).
if (link.children.length > 0) return;
const titleEl = doc.querySelector('title');
if (!titleEl) return;
const title = cleanTitle(titleEl.textContent || '');
if (!title) return;
link.textContent = title;
}
function addDownloadButtons(link, doc, baseUrl) {
const host = link.parentNode;
if (!host) return;
const torrentHref = findTorrentHref(doc, baseUrl);
const magnetHref = findMagnetHref(doc);
const existingTorrentButton = host.querySelector('.x1337-dlt');
const existingMagnetButton = host.querySelector('.x1337-dlm');
let buttonsContainer = host.querySelector('.buttons-container');
if (!buttonsContainer) {
buttonsContainer = document.createElement('div');
buttonsContainer.classList.add('buttons-container');
buttonsContainer.style.display = 'flex';
buttonsContainer.style.alignItems = 'center';
buttonsContainer.style.gap = '5px';
buttonsContainer.style.marginTop = '10px';
link.after(buttonsContainer);
}
if (torrentHref && !existingTorrentButton) {
const torrentButton = document.createElement('a');
torrentButton.href = torrentHref;
torrentButton.title = 'Download torrent file';
torrentButton.className = 'x1337-dlt';
torrentButton.innerHTML = '<i class="flaticon-torrent-download" style="color: #89ad19; font-size: 16px"></i>';
buttonsContainer.appendChild(torrentButton);
}
if (magnetHref && !existingMagnetButton) {
const magnetButton = document.createElement('a');
magnetButton.setAttribute('href', magnetHref);
magnetButton.title = 'Download via magnet';
magnetButton.className = 'x1337-dlm';
magnetButton.innerHTML = '<i class="flaticon-magnet" style="color: #da3a04; font-size: 16px"></i>';
buttonsContainer.appendChild(magnetButton);
}
}
function processAllRows() {
document.querySelectorAll('.table-list tbody tr').forEach(decorateRow);
}
// ==================== POPUP SYSTEM ====================
let popupQueue = [];
let isShowingPopup = false;
function showPopup(message, duration = 5000) {
popupQueue.push({ message, duration });
if (!isShowingPopup) {
displayNextPopup();
}
}
function displayNextPopup() {
if (popupQueue.length === 0) {
isShowingPopup = false;
return;
}
isShowingPopup = true;
const { message, duration } = popupQueue.shift();
const popup = document.createElement('div');
popup.id = 'x1337-popup';
popup.textContent = message;
document.body.appendChild(popup);
setTimeout(() => {
popup.style.opacity = '1';
}, 10);
setTimeout(() => {
popup.style.opacity = '0';
setTimeout(() => {
if (popup.parentNode) {
popup.parentNode.removeChild(popup);
}
displayNextPopup();
}, 300);
}, duration);
}
// ==================== SETTINGS MENU ====================
function buildSettingsHTML() {
return `
<div id="x1337-settings-wrapper">
<div id="x1337-settings-toggle">⚙️</div>
<div id="x1337-settings-content">
<h3>1337x Enhancements Settings</h3>
<div class="x1337-option">
<label>
<input type="checkbox" id="x1337-extend-titles" ${config.extendTitles ? 'checked' : ''}>
Extend Truncated Titles
</label>
</div>
<div class="x1337-option">
<label>
<input type="checkbox" id="x1337-show-thumbnails" ${config.showThumbnails ? 'checked' : ''}>
Show Thumbnails
</label>
<div class="x1337-sub-option" id="x1337-thumbnail-options" ${config.showThumbnails ? '' : 'style="display:none;"'}>
<label>
Visible Images:
<input type="number" id="x1337-visible-images" value="${config.visibleImages}" min="1" max="10">
</label>
</div>
</div>
<div class="x1337-option">
<label>
<input type="checkbox" id="x1337-show-magnet-column" ${config.showExtraColumn ? 'checked' : ''}>
Show Magnet URL Column
</label>
</div>
<div class="x1337-option">
<label>
<input type="checkbox" id="x1337-show-buttons-in-name" ${config.showButtonsInNameColumn ? 'checked' : ''}>
Show Buttons in Name Column
</label>
</div>
<div class="x1337-option">
<label>
<input type="checkbox" id="x1337-full-width-site" ${config.fullWidthSite ? 'checked' : ''}>
Full Width Site
</label>
</div>
<div class="x1337-option">
<div class="x1337-group-title">Network</div>
<div class="x1337-sub-option">
<label>
Queue Fetch Delay (ms):
<input type="number" id="x1337-queue-fetch-delay" value="${config.queueFetchDelay}" min="50" max="5000">
</label>
<label>
Max Fetch Retries:
<input type="number" id="x1337-max-retries" value="${config.maxRetries}" min="0" max="10">
</label>
<label>
Fetch Timeout (ms):
<input type="number" id="x1337-fetch-timeout" value="${config.fetchTimeoutMs}" min="1000" max="60000">
</label>
<button type="button" id="x1337-retry-fetching">Retry paused fetches</button>
</div>
</div>
<button id="x1337-save-settings">Save Settings</button><br>
<small>v${SCRIPT_VERSION} | by sharmanhall</small>
</div>
</div>
`;
}
function addSettingsMenu() {
document.body.insertAdjacentHTML('beforeend', buildSettingsHTML());
document.getElementById('x1337-settings-toggle').addEventListener('click', function() {
const wrapper = document.getElementById('x1337-settings-wrapper');
wrapper.style.right = wrapper.style.right === '0px' ? '-300px' : '0px';
});
document.getElementById('x1337-show-thumbnails').addEventListener('change', function() {
const thumbnailOptions = document.getElementById('x1337-thumbnail-options');
if (thumbnailOptions) thumbnailOptions.style.display = this.checked ? 'block' : 'none';
});
// Reachable even after the persistent breaker bar has been dismissed (#2).
document.getElementById('x1337-retry-fetching').addEventListener('click', function(e) {
e.preventDefault();
resetFetching();
});
document.getElementById('x1337-save-settings').addEventListener('click', function() {
const previous = Object.assign({}, config);
saveConfig({
showThumbnails: document.getElementById('x1337-show-thumbnails').checked,
showExtraColumn: document.getElementById('x1337-show-magnet-column').checked,
showButtonsInNameColumn: document.getElementById('x1337-show-buttons-in-name').checked,
fullWidthSite: document.getElementById('x1337-full-width-site').checked,
extendTitles: document.getElementById('x1337-extend-titles').checked,
visibleImages: document.getElementById('x1337-visible-images').value,
queueFetchDelay: document.getElementById('x1337-queue-fetch-delay').value,
maxRetries: document.getElementById('x1337-max-retries').value,
fetchTimeoutMs: document.getElementById('x1337-fetch-timeout').value
});
// Reflect any clamped/repaired value straight back into the inputs.
document.getElementById('x1337-visible-images').value = config.visibleImages;
document.getElementById('x1337-queue-fetch-delay').value = config.queueFetchDelay;
document.getElementById('x1337-max-retries').value = config.maxRetries;
document.getElementById('x1337-fetch-timeout').value = config.fetchTimeoutMs;
applySettings(previous);
});
}
// A row that was already data-processed is skipped by processLink forever, so a
// false->true consumer transition would silently do nothing (#12). Re-run the consumers
// against the CACHED document instead — no refetch. Returns false when at least one
// processed row had no cached document, which is the only case that needs a reload.
function reapplyConsumersToProcessedRows(previous) {
// #35 — showExtraColumn belongs here too. appendColumn() re-creates the cells, but
// their buttons come back `unresolved`; only replaying the CACHED document through
// populateRowButtons turns them back into live magnet/torrent anchors.
const enabledNow = (config.showThumbnails && !previous.showThumbnails) ||
(config.extendTitles && !previous.extendTitles) ||
(config.showButtonsInNameColumn && !previous.showButtonsInNameColumn) ||
(config.showExtraColumn && !previous.showExtraColumn);
if (!enabledNow) return true;
let allFromCache = true;
document.querySelectorAll('.table-list tbody tr').forEach(row => {
if (row.dataset.processed !== 'true') return;
const link = row.querySelector('a[href^="/torrent/"]');
if (!link) return;
const cached = cachedDoc(link.href);
if (!cached) {
allFromCache = false;
return;
}
cached.then(result => {
try {
renderDetail(link, result);
} catch (err) {
console.error('[1337x] Re-render error for', link.href, err);
}
}).catch(() => { /* a rejected cache entry cannot be re-rendered */ });
});
return allFromCache;
}
// Reconciler: applies everything that can be applied live and tells the user when a
// change genuinely needs a reload (#12).
function applySettings(previous) {
applyStyles();
applyBodyClasses();
if (config.showExtraColumn) {
appendColumn();
} else if (extraColumnAdded) {
removeColumn();
}
if (!config.showButtonsInNameColumn) {
document.querySelectorAll('.buttons-container').forEach(container => container.remove());
}
// Rows that have not been processed yet pick up newly-enabled consumers now.
processAllRows();
if (!previous) return;
// Rows that ARE processed pick them up from the shared document cache.
const appliedLive = reapplyConsumersToProcessedRows(previous);
const needsReload = !appliedLive ||
(previous.extendTitles && !config.extendTitles) ||
(previous.visibleImages !== config.visibleImages);
showPopup(needsReload
? 'Settings saved — reload the page to apply all of them.'
: 'Settings saved successfully!');
}
// ==================== MUTATION OBSERVER ====================
const pendingRows = new Set();
let flushScheduled = false;
function scheduleRowFlush() {
if (flushScheduled) return;
flushScheduled = true;
const run = () => {
flushScheduled = false;
const rows = Array.from(pendingRows);
pendingRows.clear();
rows.forEach(decorateRow);
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
else setTimeout(run, 0);
}
function collectRowsFrom(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE || typeof node.matches !== 'function') return;
// Cheap guard: ignore everything this script injects itself.
if (node.id === 'x1337-popup' || node.id === 'x1337-enlarged-img' || node.id === 'x1337-settings-wrapper') return;
if (node.classList && (node.classList.contains('thumbnail-container') ||
node.classList.contains('buttons-container') ||
node.classList.contains('dl-buttons'))) return;
if (node.matches('.table-list tbody tr')) {
pendingRows.add(node);
scheduleRowFlush();
return;
}
if (typeof node.querySelectorAll !== 'function') return;
// #9 — also handle nodes that CONTAIN rows (a new tbody/table/wrap).
const rows = node.querySelectorAll('.table-list tbody tr');
if (rows.length === 0) return;
if (config.showExtraColumn) {
if (node.matches('.table-list-wrap')) {
addColumnHeaderToTable(node);
}
node.querySelectorAll('.table-list-wrap').forEach(addColumnHeaderToTable);
}
rows.forEach(row => pendingRows.add(row));
scheduleRowFlush();
}
function addMutationObserver() {
const target = document.querySelector('.page-content') ||
document.querySelector('main.container') ||
document.body;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type !== 'childList' || mutation.addedNodes.length === 0) return;
mutation.addedNodes.forEach(collectRowsFrom);
});
});
observer.observe(target, { childList: true, subtree: true });
}
// ==================== INITIALIZATION ====================
let hasInitialized = false;
function maybeShowStartupPopup() {
// #23 — only announce the script once per installed version.
const lastSeen = GM_getValue('lastSeenVersion', null);
if (lastSeen === SCRIPT_VERSION) return;
GM_setValue('lastSeenVersion', SCRIPT_VERSION);
showPopup(`1337x Enhancements ${VERSION_LABEL} (by sharmanhall)`, 5000);
}
function init() {
if (hasInitialized) return;
// #29 — a Cloudflare interstitial matches our @match patterns just like a real
// page, so v16/v17 decorated it: settings gear and version popup rendered on top
// of "Just a moment…". Bail out entirely — no UI, no observer, no queue. Do NOT
// set hasInitialized: the challenge navigates to the real page, which loads the
// script fresh, and this guard must not survive into that run.
// #33 — defense in depth. CHALLENGE_TITLE_RE is ^-anchored and this is the LIVE
// document, so a search for "Access denied" puts that phrase at the front of the
// site's own <title>. A detector false positive must never be able to zero out the
// whole script, so the bail-out also requires the site's own chrome to be absent —
// no Cloudflare interstitial has ever served .page-content / .table-list / .box-info.
if (hasStructuralChallenge(document) && !hasSiteChrome(document)) {
console.log('[1337x] Cloudflare challenge page — enhancements not injected.');
return;
}
hasInitialized = true;
loadConfig();
console.log(`[1337x] Initializing ${VERSION_LABEL} (${SCRIPT_VERSION})`);
applyStyles();
applyBodyClasses();
addSettingsMenu();
modifyH1ContentOnTorrentPages();
appendColumn();
processAllRows(); // exactly one enqueue pass (#5)
addMutationObserver();
maybeShowStartupPopup();
}
// Run the script
init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment