// ==UserScript==
// @name Social Frontend Switcher
// @namespace via-userscripts.anonymous.social-frontend-switcher
// @version 2.0.1
// @description Open Instagram, Reddit, Threads, and X links through community frontends. Fast instance selection, automatic failover, and one compact menu to switch or return.
// @match https://instagram.com/*
// @match https://www.instagram.com/*
// @match https://m.instagram.com/*
// @match https://reddit.com/*
// @match https://www.reddit.com/*
// @match https://old.reddit.com/*
// @match https://new.reddit.com/*
// @match https://sh.reddit.com/*
// @match https://m.reddit.com/*
// @match https://redd.it/*
// @match https://threads.com/*
// @match https://www.threads.com/*
// @match https://threads.net/*
// @match https://www.threads.net/*
// @match https://x.com/*
// @match https://www.x.com/*
// @match https://twitter.com/*
// @match https://www.twitter.com/*
// @match https://mobile.twitter.com/*
// @match https://m.twitter.com/*
// @match https://twiiit.com/*
// @match https://kittygr.am/*
// @match https://kittygram.irelephant.net/*
// @match https://kittygram.nexussfan.cz/*
// @match https://kittygram.kareem.one/*
// @match https://kg.lus.lu/*
// @match https://kg.meowing.de/*
// @match https://kittygram.nadeko.net/*
// @match https://kittygram.pussthecat.org/*
// @match https://redlib.catsarch.com/*
// @match https://redlib.r4fo.com/*
// @match https://red.artemislena.eu/*
// @match https://redlib.cow.rip/*
// @match https://redlib.nadeko.net/*
// @match https://redlib.privadency.com/*
// @match https://shoelace.mint.lgbt/*
// @match https://threads.reallyaweso.me/*
// @match https://shoelace.kareem.one/*
// @match https://nitter.net/*
// @match https://xcancel.com/*
// @match https://nitter.poast.org/*
// @match https://nitter.privacyredirect.com/*
// @match https://nitter.tiekoetter.com/*
// @match https://nuku.trabun.org/*
// @match https://nitter.catsarch.com/*
// @match https://nitter.kareem.one/*
// @match https://nitter.meowing.monster/*
// @noframes
// @run-at document-start
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect codeberg.org
// @connect foundry.fsky.io
// @connect raw.githubusercontent.com
// @connect twiiit.com
// @connect kittygr.am
// @connect kittygram.irelephant.net
// @connect kittygram.nexussfan.cz
// @connect kittygram.kareem.one
// @connect kg.lus.lu
// @connect kg.meowing.de
// @connect kittygram.nadeko.net
// @connect kittygram.pussthecat.org
// @connect redlib.catsarch.com
// @connect redlib.r4fo.com
// @connect red.artemislena.eu
// @connect redlib.cow.rip
// @connect redlib.nadeko.net
// @connect redlib.privadency.com
// @connect shoelace.mint.lgbt
// @connect threads.reallyaweso.me
// @connect shoelace.kareem.one
// @connect nitter.net
// @connect xcancel.com
// @connect nitter.poast.org
// @connect nitter.privacyredirect.com
// @connect nitter.tiekoetter.com
// @connect nuku.trabun.org
// @connect nitter.catsarch.com
// @connect nitter.kareem.one
// @connect nitter.meowing.monster
// ==/UserScript==
(() => {
'use strict';
if (window.top !== window.self) return;
// Hosts must also appear in @match and @connect above.
const SITES = {
instagram: {
source: /^(?:www\.|m\.)?instagram\.com$/, home: 'https://www.instagram.com',
hosts: ['kittygr.am', 'kittygram.irelephant.net', 'kittygram.nexussfan.cz', 'kittygram.kareem.one',
'kg.lus.lu', 'kg.meowing.de', 'kittygram.nadeko.net', 'kittygram.pussthecat.org'],
brand: /kittygram/i, trackers: ['igsh', 'igshid'],
registries: ['https://codeberg.org/irelephant/kittygram/raw/branch/main/instances.json',
'https://foundry.fsky.io/mirrors/kittygram/raw/branch/main/instances.json'],
route(url) {
const path = url.pathname;
if (/^\/(?:p|reel|tv)\/[^/]+\/?$/.test(path) || /^\/explore\/tags\/[^/]+\/?$/.test(path)) return path;
return /^\/[\w.]+\/?$/.test(path) &&
!/^\/(?:accounts|about|developer|direct|explore|reels?|stories|web|legal)\/?$/i.test(path) ? path : null;
}
},
reddit: {
source: /^(?:(?:www|old|new|sh|m)\.)?reddit\.com$|^redd\.it$/, home: 'https://www.reddit.com',
hosts: ['redlib.catsarch.com', 'redlib.r4fo.com', 'red.artemislena.eu', 'redlib.cow.rip',
'redlib.nadeko.net', 'redlib.privadency.com'],
brand: /redlib/i, trackers: ['ref', 'ref_source'], probe: '/r/popular',
registries: ['https://raw.githubusercontent.com/redlib-org/redlib-instances/main/instances.json'],
route(url) {
const path = url.pathname.replace(/\/$/, '');
if (url.hostname === 'redd.it') return /^\/[a-z\d]+$/i.test(path) ? '/comments' + path : null;
if (/^\/gallery\/[a-z\d]+$/i.test(path)) return path.replace('/gallery/', '/comments/');
if (/^\/(?:popular|all)$/.test(path)) return '/r' + path;
if (/^\/(?:hot|new|rising|controversial|top|best)$/.test(path)) return path;
if (/^\/domain\/[^/]+$/.test(path)) {
url.searchParams.set('q', 'site:' + path.slice(8));
return '/search';
}
if (/^\/(?:r\/[^/]+\/)?search$/.test(path)) return url.searchParams.get('q') ? path : null;
if (/^\/r\/[^/]+(?:\/(?:hot|new|rising|controversial|top|best|about\/sidebar|(?:w|wiki)(?:\/.*)?|s\/[a-z\d]+))?$/i.test(path)) return path;
if (/^\/(?:(?:r|u|user)\/[^/]+\/)?comments\/[a-z\d]+(?:\/[^/]+){0,2}$/i.test(path)) return path;
if (/^\/(?:u|user)\/[^/]+(?:\/(?:overview|submitted|comments))?$/i.test(path)) return path.replace(/^\/u\//, '/user/');
return null;
}
},
threads: {
source: /^(?:www\.)?threads\.(?:net|com)$/, home: 'https://www.threads.com',
hosts: ['shoelace.mint.lgbt', 'threads.reallyaweso.me', 'shoelace.kareem.one'],
brand: /shoelace/i, trackers: ['igshid', 'xmt'], registries: [],
route(url) {
const post = url.pathname.match(/^\/(?:@[^/]+\/post|t)\/([^/]+)\/?$/);
return post ? '/t/' + post[1] : /^\/@[\w.]+\/?$/.test(url.pathname) ? url.pathname : null;
}
},
x: {
source: /^(?:www\.)?x\.com$|^(?:(?:www|mobile|m)\.)?twitter\.com$/, home: 'https://x.com',
hosts: ['nitter.net', 'xcancel.com', 'nitter.poast.org', 'nitter.privacyredirect.com',
'nitter.tiekoetter.com', 'nuku.trabun.org', 'nitter.catsarch.com', 'nitter.kareem.one', 'nitter.meowing.monster'],
brand: /nitter|xcancel/i, trackers: ['s', 't', 'ref_src', 'ref_url'], registries: ['https://twiiit.com/jack'],
route(url) {
let path = url.pathname;
if (/^\/(?:[\w]+\/status|i\/(?:web\/)?status)\/\d+(?:\/(?:photo|video)\/\d+)?\/?$/.test(path)) {
return path.replace(/^\/i\/web\//, '/i/').replace(/\/(?:photo|video)\/\d+\/?$/, '');
}
const tag = path.match(/^\/hashtag\/([^/]+)\/?$/);
if (tag) {
let name = tag[1];
try { name = decodeURIComponent(name); } catch {}
url.search = '';
url.searchParams.set('f', 'tweets');
url.searchParams.set('q', '#' + name);
return '/search';
}
if (/^\/search\/?$/.test(path)) return url.searchParams.get('q') ? path : null;
const profile = path.match(/^\/([\w]{1,15})(?:\/(?:media|with_replies))?\/?$/);
return profile && !/^(?:about|account|compose|explore|home|i|intent|login|logout|messages|notifications|search|settings|share|tos|web)$/i.test(profile[1]) ? path : null;
}
}
};
const bridge = location.hostname === 'twiiit.com';
const siteId = bridge ? 'x' : Object.keys(SITES).find(id => SITES[id].source.test(location.hostname) || SITES[id].hosts.includes(location.hostname));
if (!siteId) return;
const site = SITES[siteId], frontend = bridge || site.hosts.includes(location.hostname);
const MINUTE = 60000, FRESH = 2 * MINUTE, COOLDOWN = 5 * MINUTE, REGISTRY_AGE = 12 * 60 * MINUTE;
const PREFIX = 'sfs.', key = url => url.pathname + url.search;
let trip = null, job = null, background = null, phase = 'idle', message = '', lastPage = '', examined = false, ui;
function read(name, fallback = null) {
try {
const value = GM_getValue(PREFIX + name, null);
return value == null ? fallback : typeof value === 'string' ? JSON.parse(value) : value;
} catch { return fallback; }
}
function write(name, value) {
try { GM_setValue(PREFIX + name, JSON.stringify(value)); return true; } catch { return false; }
}
function tab(name, value) {
try {
if (arguments.length === 2) { sessionStorage.setItem(PREFIX + name, JSON.stringify(value)); return true; }
return JSON.parse(sessionStorage.getItem(PREFIX + name));
} catch { return null; }
}
const enabled = () => read('enabled.' + siteId, true) !== false;
const stayKey = () => (route(location.href) || key(location)).split('#')[0];
const origin = host => 'https://' + host;
function supported(value) {
try {
const url = new URL(value);
return url.protocol === 'https:' && !url.username && !url.password && !url.port && site.hosts.includes(url.hostname) ? url.origin : null;
} catch { return null; }
}
function route(value) {
try {
const url = new URL(value);
if (url.protocol !== 'https:' || !site.source.test(url.hostname)) return null;
for (const name of [...url.searchParams.keys()]) {
if (/^utm_/i.test(name) || name === 'fbclid' || site.trackers.includes(name)) url.searchParams.delete(name);
}
const path = site.route(url);
return path ? path + url.search + url.hash : null;
} catch { return null; }
}
const restricted = () => read('restricted.' + siteId, []);
const allowed = base => supported(base) && !restricted().includes(base);
const fresh = (time, age) => Number.isFinite(time) && Date.now() >= time && Date.now() - time < age;
function task() { return { cancelled: false, requests: new Set() }; }
function abortRequests(task) { for (const abort of [...task.requests]) abort(); }
function cancel(task) { if (task) { task.cancelled = true; abortRequests(task); } }
// An independent timer also covers managers that fail to call ontimeout.
function request(url, task, timeout = 5000) {
return new Promise((resolve, reject) => {
let handle, done = false;
const finish = (error, response) => {
if (done) return;
done = true;
clearTimeout(timer);
task.requests.delete(abort);
error ? reject(new Error(error)) : resolve(response);
};
const abort = () => { finish('cancelled'); try { handle?.abort(); } catch {} };
const timer = setTimeout(() => { finish('timeout'); try { handle?.abort(); } catch {} }, timeout);
task.requests.add(abort);
if (task.cancelled) return abort();
if (typeof GM_xmlhttpRequest !== 'function') return finish('unsupported');
try {
handle = GM_xmlhttpRequest({ method: 'GET', url, anonymous: true, timeout,
onload: response => finish(null, response),
onerror: response => response?.status ? finish(null, response) : finish('network'),
ontimeout: () => finish('timeout'), onabort: () => finish('cancelled') });
} catch { finish('network'); }
});
}
// Inspect frontend chrome and error containers, never ordinary post text.
function pageState(doc) {
const title = doc.title || '';
const brand = site.brand.test(title + ' ' + [...doc.querySelectorAll('meta[name="description"],meta[property="og:site_name"],nav .site-name')]
.map(el => el.content || el.textContent).join(' ')) || (siteId === 'reddit' && !!doc.querySelector('a#redlib'));
const gate = doc.querySelector('#nsfw_landing');
if (brand && gate) return /\bsfw[ -]only\b/i.test(gate.textContent) ? 'restricted' : 'gate';
if (brand && doc.querySelector('#wall form')) return 'gate';
if ((!brand && /just a moment|making sure you.?re not a bot|checking (?:that )?you are not a bot|verify (?:you are|you're) human|attention required|captcha/i.test(title)) ||
doc.querySelector('#challenge-form,#anubis_challenge')) return 'challenge';
if (!brand && /access denied|too many requests|rate limit|403 forbidden/i.test(title)) return 'blocked';
if (bridge && /couldn.t find a Nitter instance/i.test(doc.body?.textContent || '')) return 'down';
const error = doc.querySelector('#error h1,.error-panel');
const text = (error?.textContent || '') + ' ' + (/^error[:\s]/i.test(title) ? title : '');
if (brand && /not found|nothing here|does not exist|doesn't exist|deleted|suspended|private (?:community|account)|banned|protected|post id is invalid/i.test(text)) return 'missing';
const heading = doc.querySelector('h1')?.textContent || '';
if (error || (!brand && /^(?:error[:\s]|50[0234]\b|bad gateway\b|service unavailable\b|gateway time.?out\b|too many requests\b)/i.test((title || heading).trim()))) return 'down';
return brand ? 'ok' : 'unknown';
}
async function probe(base, path, task) {
const response = await request(base + (site.probe || path.split('#')[0]), task, siteId === 'reddit' ? 8000 : 5000);
const final = response.finalUrl || response.responseURL;
if (final && supported(final) !== base) throw new Error('redirect');
const status = Number(response.status);
const state = pageState(new DOMParser().parseFromString(String(response.responseText || ''), 'text/html'));
if (state === 'restricted') write('restricted.' + siteId, [...new Set([...restricted(), base])]);
if (state === 'challenge') throw new Error('challenge');
if (state === 'blocked' || status === 403 || status === 429) throw new Error('blocked');
if ((status >= 200 && status < 300 && ['ok', 'gate'].includes(state)) ||
(!site.probe && state === 'missing' && [200, 404].includes(status))) return true;
throw new Error(status >= 500 || state === 'down' ? 'down' : 'unavailable');
}
async function discover(task) {
const cached = read('registry.' + siteId);
if (siteId === 'x' && task.twiiitChecked) return cached?.urls || [];
if (cached && fresh(cached.savedAt, REGISTRY_AGE)) return cached.urls || [];
for (const url of site.registries) {
try {
const response = await request(url, task);
if (task.cancelled) return [];
let urls = [];
if (siteId === 'x') {
const header = String(response.responseHeaders || '').match(/^location:\s*(.+)$/im);
urls = [response.finalUrl || response.responseURL || (header ? new URL(header[1].trim(), url).href : '')];
} else {
if (Number(response.status) !== 200) continue;
const data = JSON.parse(response.responseText);
const entries = siteId === 'reddit' ? data.instances?.slice().sort((a, b) => Number(!!a.cloudflare) - Number(!!b.cloudflare)) : data;
if (!Array.isArray(entries)) continue;
const blocked = entry => /\bsfw(?:[ -]+only)?\b|\bno\s+nsfw\b|\bnsfw(?:\s+content)?\s+(?:is\s+)?(?:disabled|filtered|blocked|unavailable)\b/i.test(entry.description || '');
if (siteId === 'reddit') write('restricted.' + siteId, [...new Set([...restricted(),
...entries.filter(blocked).map(entry => supported(entry.url)).filter(Boolean)])]);
urls = entries.filter(entry => siteId !== 'reddit' || !blocked(entry)).map(entry => entry.url);
}
urls = [...new Set(urls.map(supported).filter(allowed))];
if (urls.length) { write('registry.' + siteId, { savedAt: Date.now(), urls }); return urls; }
} catch { if (task.cancelled) break; }
}
return [];
}
async function choose(task, record, retry) {
const preferred = read('preferred.' + siteId, {});
const eligible = base => allowed(base) && !record.tried.includes(base) &&
(retry || !fresh(read('failed.' + base), COOLDOWN));
if (eligible(preferred.url) && fresh(preferred.checkedAt, FRESH)) return preferred.url;
// Trust Twiiit's selected supported host without requiring its browser
// check to pass in an anonymous XHR. If Twiiit is unreachable, use the pool.
if (siteId === 'x' && !frontend) {
task.twiiitChecked = true;
try {
const response = await request('https://twiiit.com/jack', task, 3000);
const header = String(response.responseHeaders || '').match(/^location:\s*(.+)$/im);
const resolved = supported(response.finalUrl || response.responseURL || (header ? new URL(header[1].trim(), 'https://twiiit.com').href : ''));
if (eligible(resolved)) {
write('registry.x', { savedAt: Date.now(), urls: [resolved] });
return resolved;
}
} catch {}
if (task.cancelled) throw new Error('cancelled');
}
const cached = read('registry.' + siteId, {}).urls || [];
const pool = site.hosts.map(origin);
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
let candidates = [...new Set([preferred.url, ...cached, ...pool])].filter(eligible);
let queue = candidates.slice();
return new Promise((resolve, reject) => {
const attempted = new Set(), results = new Map();
let active = 0, discovering = true, done = false;
const finish = (base, error) => {
if (done) return;
done = true;
clearTimeout(deadline);
task.requests.delete(stopSelection);
abortRequests(task);
base ? resolve(base) : reject(error || new Error('No other instance left. Retry or open the original site.'));
};
const stopSelection = () => finish(null, new Error('cancelled'));
// A blocked anonymous probe is not proof that browser navigation fails.
// Prefer a browser-check response over an unresponsive or broken host.
const fallback = () => {
const rank = base => results.get(base) === 'challenge' ? 0 : results.get(base) === 'down' ? 2 : 1;
finish(candidates.filter(eligible).sort((a, b) => rank(a) - rank(b))[0]);
};
const deadline = setTimeout(fallback, siteId === 'reddit' ? 6000 : 5000);
task.requests.add(stopSelection);
const pump = () => {
if (done) return;
if (task.cancelled) { stopSelection(); return; }
queue = queue.filter(base => eligible(base) && !attempted.has(base));
while (active < 3 && queue.length) {
const base = queue.shift();
attempted.add(base);
active++;
probe(base, record.path, task).then(() => {
if (done || task.cancelled || !eligible(base)) return;
write('preferred.' + siteId, { url: base, checkedAt: Date.now() });
finish(base);
}).catch(error => {
if (done || task.cancelled || error.message === 'cancelled') return;
results.set(base, error.message);
}).finally(() => { active--; pump(); });
}
if (!active && !queue.length && !discovering) {
fallback();
}
};
discover(task).then(urls => {
candidates = [...new Set([preferred.url, ...urls, ...candidates])].filter(eligible);
queue = [...new Set([...urls, ...queue])];
})
.finally(() => { discovering = false; pump(); });
pump();
});
}
function saveTrip() { if (trip && frontend) tab('trip', trip); }
function validTrip(value) {
return value && typeof value.original === 'string' && route(value.original) && Array.isArray(value.tried) &&
typeof value.path === 'string' && value.path.startsWith('/') && !value.path.startsWith('//') && fresh(value.at, 24 * 60 * MINUTE);
}
function handoff(record, base) {
const records = read('trips', {});
const recent = Object.entries(records).filter(([, value]) => fresh(value.at, 24 * 60 * MINUTE)).slice(-19);
const bytes = crypto.getRandomValues(new Uint32Array(3));
const id = [...bytes].map(value => value.toString(36)).join('');
const next = { ...record, tried: [...new Set([...record.tried, base])], at: Date.now(), stopped: false };
if (!write('trips', { ...Object.fromEntries(recent), [id]: next })) throw new Error('Allow storage for this script, then retry.');
const target = new URL(base + record.path);
target.hash = '_sfs_return=' + id;
return target.href;
}
function stop() {
cancel(job); job = null; phase = 'idle'; message = '';
}
async function go(retry = false) {
if (!trip) return;
stop();
cancel(background); background = null;
if (frontend && !location.hash.startsWith('#_sfs_return=')) trip.path = trip.path.split('#')[0] + location.hash;
const taskRun = job = task(), page = key(location);
const record = { ...trip, stopped: false, tried: retry ? (frontend ? [location.origin] : []) : trip.tried.slice() };
trip.stopped = false; saveTrip();
if (!frontend) tab('stay', null);
phase = 'loading'; render();
try {
const base = await choose(taskRun, record, retry);
if (job !== taskRun || taskRun.cancelled || key(location) !== page) return;
location.replace(handoff(record, base));
} catch (error) {
if (job !== taskRun || taskRun.cancelled || key(location) !== page) return;
phase = 'error'; message = error.message; job = null;
trip.stopped = true; saveTrip(); render();
}
}
function original() {
stop();
if (!frontend) { tab('stay', stayKey()); render(); return; }
const url = new URL(trip.original);
url.hash = '_sfs_skip=' + encodeURIComponent(url.hash);
location.replace(url.href);
}
function render() {
if (!document.documentElement) return;
if (!ui) {
const host = document.createElement('div');
host.id = 'sfs-menu';
host.style.cssText = 'all:initial;position:fixed;right:12px;bottom:max(14px,env(safe-area-inset-bottom));z-index:2147483647';
ui = host.attachShadow({ mode: 'open' });
ui.innerHTML = `<style>
details{font:13px/1.4 system-ui,sans-serif;color:#fff;color-scheme:dark}
*{box-sizing:border-box} [hidden]{display:none!important}
summary,button{font:inherit;color:inherit;cursor:pointer;border:0}
summary{display:flex;align-items:center;gap:7px;list-style:none;border-radius:22px;padding:10px 13px;background:#252629;opacity:.5;box-shadow:0 2px 10px #0005}
summary::-webkit-details-marker{display:none} summary:hover,summary:focus-visible,details[open] summary{opacity:1}
section{position:absolute;right:0;bottom:48px;width:190px;max-width:85vw;padding:5px;border:1px solid #ffffff26;border-radius:12px;background:#252629;box-shadow:0 4px 18px #0005}
button{display:block;width:100%;min-height:40px;padding:9px 10px;text-align:left;border-radius:7px;background:transparent}
button:hover{background:#ffffff14} :focus-visible{outline:2px solid #8ab4f8;outline-offset:2px}
p{margin:6px 10px;font-size:12px;color:#ddd} i{width:12px;height:12px;border:2px solid #ffffff55;border-top-color:#fff;border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}} @media(prefers-reduced-motion:reduce){i{animation:none}}
</style><details><summary aria-label="Social Frontend Switcher"><i hidden aria-hidden="true"></i><span>Switcher</span></summary>
<section aria-label="Redirect controls"><p role="status" aria-live="polite" hidden></p>
<button data-action="original">Original site</button><button data-action="next">Next instance</button>
<button data-action="toggle">Auto redirect: On</button><button data-action="cancel" hidden>Cancel</button></section></details>`;
ui.addEventListener('click', event => {
const action = event.target.closest('button')?.dataset.action;
if (!action || !trip) return;
ui.querySelector('details').open = false;
if (action === 'original') original();
if (action === 'next') go(phase === 'error');
if (action === 'cancel') { stop(); trip.stopped = true; saveTrip(); if (!frontend) tab('stay', stayKey()); render(); }
if (action === 'toggle') {
const on = !enabled();
write('enabled.' + siteId, on); stop();
if (on && !frontend) go(); else render();
}
});
document.addEventListener('pointerdown', event => { if (!event.composedPath().includes(host)) ui.querySelector('details').open = false; });
document.addEventListener('keydown', event => { if (event.key === 'Escape' && ui.querySelector('details').open) { ui.querySelector('details').open = false; ui.querySelector('summary').focus(); } });
}
if (!ui.host.isConnected) document.documentElement.appendChild(ui.host);
ui.host.hidden = !trip;
ui.host.style.display = trip ? 'block' : 'none';
const busy = phase === 'loading', button = action => ui.querySelector(`[data-action="${action}"]`);
ui.querySelector('i').hidden = !busy && phase !== 'checking';
ui.querySelector('summary span').textContent = busy ? 'Finding instance…' : phase === 'checking' ? 'Checking access…' : 'Switcher';
ui.querySelector('p').textContent = message;
ui.querySelector('p').hidden = !message;
button('original').hidden = !frontend;
button('next').hidden = busy;
button('next').textContent = phase === 'error' ? 'Retry' : frontend ? 'Next instance' : 'Use alternative';
button('toggle').hidden = busy;
button('toggle').textContent = 'Auto redirect: ' + (enabled() ? 'On' : 'Off');
button('toggle').setAttribute('aria-pressed', String(enabled()));
button('cancel').hidden = !busy;
}
function inspectDestination() {
if (!frontend || !trip || examined || document.readyState === 'loading') return;
const state = pageState(document);
if (state === 'unknown') return;
if (state === 'challenge') {
// Let the frontend's own browser check run, including interactive
// checks. Keep Next instance and Original site available throughout.
if (phase === 'idle' || phase === 'checking') { phase = 'checking'; render(); }
return;
}
if (phase === 'checking') phase = 'idle';
examined = true;
if (['down', 'blocked', 'restricted'].includes(state)) {
write('failed.' + location.origin, Date.now());
if (state === 'restricted') write('restricted.' + siteId, [...new Set([...restricted(), location.origin])]);
if (enabled() && !trip.stopped && phase === 'idle') go();
} else {
write('preferred.' + siteId, { url: location.origin, checkedAt: Date.now() });
write('failed.' + location.origin, null);
}
render();
}
function update() {
if (key(location) === lastPage) { render(); return; }
stop(); examined = false;
let url = new URL(location.href);
const marker = url.hash.match(/^#_sfs_return=([a-z\d]+)$/i);
const returned = url.hash.match(/^#_sfs_skip=(.*)$/);
if (frontend) {
const records = read('trips', {}), saved = marker ? records[marker[1]] : tab('trip');
if (validTrip(saved) && (marker || key(new URL(location.origin + saved.path)) === key(url))) {
trip = { ...saved, tried: [...new Set([...saved.tried.filter(allowed), location.origin])] };
if (marker) url.hash = new URL(location.origin + saved.path).hash;
if (route(site.home + key(url))) trip.path = key(url) + url.hash;
} else {
if (marker) url.hash = '';
const original = site.home + key(url) + (marker ? '' : url.hash);
trip = route(original) ? { original, path: key(url) + (marker ? '' : url.hash), tried: [location.origin], at: Date.now() } : null;
}
if (tab('trip', trip) && marker) history.replaceState(history.state, '', url.href);
if (!background && trip) { background = task(); discover(background).catch(() => {}); }
} else {
if (returned) {
try { url.hash = decodeURIComponent(returned[1]); } catch { url.hash = ''; }
if (tab('stay', (route(url.href) || key(url)).split('#')[0])) history.replaceState(history.state, '', url.href);
}
const path = route(url.href);
trip = path ? { original: url.href, path, tried: [], at: Date.now() } : null;
}
lastPage = key(location);
render();
if (frontend) inspectDestination();
else if (trip && enabled() && !returned && tab('stay') !== stayKey()) go();
}
document.addEventListener('DOMContentLoaded', () => { update(); inspectDestination(); }, { once: true });
window.addEventListener('load', inspectDestination, { once: true });
window.addEventListener('popstate', update);
window.addEventListener('pageshow', event => { if (event.persisted) { lastPage = ''; update(); } });
window.addEventListener('pagehide', () => { cancel(job); cancel(background); });
// URL-only polling also catches history changes in isolated userscript worlds.
setInterval(() => {
if (document.hidden) return;
if (key(location) !== lastPage) update();
else if (phase === 'checking') inspectDestination();
}, 400);
update();
})();