Last active
July 31, 2026 07:55
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name 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 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