// ==UserScript== // @name Kick Raid Blocker Mobile // @name:ja Kick レイドブロッカー モバイル // @namespace https://github.com/AIAIdaisuki/kick-raid-blocker-mobile // @version 0.2.1 // @description Block Kick.com raid/host auto-redirects. Works on iPhone (Userscripts.app), Android (Tampermonkey on Kiwi/Firefox), and PC. Allow/block lists supported. Clean-room implementation. // @description:ja Kick.comのレイド(ホスト)自動リダイレクトをブロックします。iPhone(Userscripts)/ Android(Tampermonkey)/ PC で動作。許可・ブロックリスト対応。クリーンルーム実装。 // @author AIAIdaisuki // @match *://kick.com/* // @match *://*.kick.com/* // @run-at document-start // @grant GM.setValue // @grant GM.getValue // @license MIT // @homepageURL https://github.com/AIAIdaisuki/kick-raid-blocker-mobile // @supportURL https://github.com/AIAIdaisuki/kick-raid-blocker-mobile/issues // @updateURL https://raw.githubusercontent.com/AIAIdaisuki/kick-raid-blocker-mobile/main/kick-raid-blocker-mobile.user.js // @downloadURL https://raw.githubusercontent.com/AIAIdaisuki/kick-raid-blocker-mobile/main/kick-raid-blocker-mobile.user.js // ==/UserScript== (function () { 'use strict'; const SCRIPT_NAME = 'Kick Raid Blocker Mobile'; const STORAGE_KEY = 'krb-mobile-config-v1'; const LOG_MAX = 50; const USER_INPUT_GRACE_MS = 1500; const VALID_MODES = ['block-all', 'allow-list', 'block-list']; const SLUG_REGEX = /^[a-z0-9_\-]{1,32}$/; const LIST_MAX = 500; const DEFAULT_CONFIG = { enabled: true, mode: 'block-all', allow: [], block: [], notify: true, log: [], }; // Strictly validate anything coming back from storage. Anything that doesn't // match the expected shape is silently replaced with the default — the script // never trusts deserialized data implicitly. function sanitizeSlugList(x) { if (!Array.isArray(x)) return []; const seen = new Set(); const out = []; for (const raw of x) { if (typeof raw !== 'string') continue; const s = raw.toLowerCase().trim(); if (!SLUG_REGEX.test(s)) continue; if (seen.has(s)) continue; seen.add(s); out.push(s); if (out.length >= LIST_MAX) break; } return out; } function sanitizeLog(x) { if (!Array.isArray(x)) return []; const out = []; for (const e of x) { if (!e || typeof e !== 'object') continue; const time = Number.isFinite(e.time) ? e.time : Date.now(); const from = (typeof e.from === 'string' && SLUG_REGEX.test(e.from)) ? e.from : ''; const to = (typeof e.to === 'string' && SLUG_REGEX.test(e.to)) ? e.to : ''; const src = typeof e.src === 'string' ? e.src.slice(0, 32) : ''; out.push({ time, from, to, src }); if (out.length >= LOG_MAX) break; } return out; } function sanitizeConfig(raw) { const c = (raw && typeof raw === 'object') ? raw : {}; return { enabled: c.enabled !== false, mode: VALID_MODES.includes(c.mode) ? c.mode : 'block-all', allow: sanitizeSlugList(c.allow), block: sanitizeSlugList(c.block), notify: c.notify !== false, log: sanitizeLog(c.log), }; } const hasGM = typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function'; async function loadConfig() { try { const raw = hasGM ? await GM.getValue(STORAGE_KEY, null) : localStorage.getItem(STORAGE_KEY); if (!raw) return sanitizeConfig({}); const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; return sanitizeConfig(parsed); } catch (e) { console.warn('[KRB] loadConfig', e); return sanitizeConfig({}); } } async function saveConfig(cfg) { try { const payload = JSON.stringify(cfg); if (hasGM) await GM.setValue(STORAGE_KEY, payload); else localStorage.setItem(STORAGE_KEY, payload); } catch (e) { console.warn('[KRB] saveConfig', e); } } let CONFIG = sanitizeConfig({}); loadConfig().then((c) => { CONFIG = c; refreshButton(); }); // Slugs that are NOT channel pages on kick.com. // We add common reserved words; if Kick adds a new section we'll just be slightly conservative. const RESERVED_SLUGS = new Set([ '', 'api', 'browse', 'categories', 'category', 'login', 'signup', 'logout', 'help', 'support', 'dashboard', 'messages', 'settings', 'profile', 'account', 'vods', 'vod', 'clip', 'clips', 'leaderboards', 'shop', 'store', 'gift', 'gifts', 'slots', 'plinko', 'search', 'redirect', 'chat', 'admin', 'upload', 'streams', '_next', 'static', 'img', 'images', 'assets', 'about', 'contact', 'terms', 'privacy', 'tos', 'partners', 'affiliate', 'affiliates', 'creators', 'developer', 'developers', 'blog', 'news', 'community', 'subscriptions', 'subscribe', 'following', 'followers', 'verify', 'reset', 'auth', ]); function parseChannelSlug(pathname) { if (typeof pathname !== 'string') return null; const clean = pathname.split('#')[0].split('?')[0]; const m = clean.match(/^\/([^/]+)\/?$/); if (!m) return null; const slug = m[1].toLowerCase(); if (!SLUG_REGEX.test(slug)) return null; if (RESERVED_SLUGS.has(slug)) return null; return slug; } function isKickHost(hostname) { // Strict check: exact match or proper subdomain. `endsWith('kick.com')` // alone would match `evil-kick.com`, so we require either the full match // or a leading dot before `kick.com`. const h = String(hostname).toLowerCase(); return h === 'kick.com' || h.endsWith('.kick.com'); } function urlToPath(url) { if (typeof url !== 'string') return null; try { if (/^https?:\/\//i.test(url)) { const u = new URL(url); if (!isKickHost(u.hostname)) return null; return u.pathname; } if (url.startsWith('//')) { const u = new URL('https:' + url); if (!isKickHost(u.hostname)) return null; return u.pathname; } return url.startsWith('/') ? url : '/' + url; } catch { return null; } } // Track recent user input so we can distinguish manual clicks from programmatic redirects. let lastInteraction = 0; const trackEvent = () => { lastInteraction = Date.now(); }; ['pointerdown', 'touchstart', 'keydown', 'wheel', 'click'].forEach((t) => { window.addEventListener(t, trackEvent, { capture: true, passive: true }); }); function isUserInitiated() { return Date.now() - lastInteraction < USER_INPUT_GRACE_MS; } function shouldBlock(fromSlug, toSlug, programmatic) { if (!CONFIG.enabled) return false; if (!fromSlug || !toSlug) return false; if (fromSlug === toSlug) return false; if (!programmatic) return false; if (CONFIG.mode === 'allow-list') { return !CONFIG.allow.includes(fromSlug); } if (CONFIG.mode === 'block-list') { return CONFIG.block.includes(fromSlug); } return true; } function logBlock(fromSlug, toSlug, source) { const entry = { time: Date.now(), from: fromSlug, to: toSlug, src: source }; CONFIG.log = [entry, ...(CONFIG.log || [])].slice(0, LOG_MAX); saveConfig(CONFIG); if (CONFIG.notify) showToast(`レイドをブロック: ${fromSlug} → ${toSlug}`); refreshPanel(); } function attemptNavigation(url, source) { const targetPath = urlToPath(url); if (!targetPath) return false; const fromSlug = parseChannelSlug(location.pathname); const toSlug = parseChannelSlug(targetPath); const programmatic = !isUserInitiated(); if (shouldBlock(fromSlug, toSlug, programmatic)) { console.warn(`[KRB] blocked ${source}: ${fromSlug} → ${toSlug}`); logBlock(fromSlug, toSlug, source); return true; } return false; } // --- history API patches (covers SPA / next-router / client-side navigation) --- const origPush = history.pushState.bind(history); history.pushState = function (state, title, url) { if (url != null && attemptNavigation(url, 'pushState')) return; return origPush(state, title, url); }; const origReplace = history.replaceState.bind(history); history.replaceState = function (state, title, url) { if (url != null && attemptNavigation(url, 'replaceState')) return; return origReplace(state, title, url); }; // --- Location.assign / Location.replace --- try { const Loc = Object.getPrototypeOf(location); const origAssign = Loc.assign; const origReplaceLoc = Loc.replace; Loc.assign = function (url) { if (attemptNavigation(url, 'location.assign')) return; return origAssign.call(this, url); }; Loc.replace = function (url) { if (attemptNavigation(url, 'location.replace')) return; return origReplaceLoc.call(this, url); }; } catch (e) { console.warn('[KRB] could not patch Location proto:', e); } // --- location.href setter (best-effort; some engines don't allow this) --- try { const Loc = Object.getPrototypeOf(location); const desc = Object.getOwnPropertyDescriptor(Loc, 'href'); if (desc && desc.set) { Object.defineProperty(Loc, 'href', { configurable: true, enumerable: true, get: desc.get, set(value) { if (attemptNavigation(value, 'location.href=')) return; return desc.set.call(this, value); }, }); } } catch (e) { console.warn('[KRB] could not patch Location.href setter:', e); } // --- synthesized anchor click defense (real user clicks have isTrusted=true and pass through) --- document.addEventListener('click', (e) => { if (!CONFIG.enabled) return; if (e.isTrusted) return; const a = e.target && e.target.closest && e.target.closest('a[href]'); if (!a) return; const targetPath = urlToPath(a.href); if (!targetPath) return; const fromSlug = parseChannelSlug(location.pathname); const toSlug = parseChannelSlug(targetPath); if (!fromSlug || !toSlug || fromSlug === toSlug) return; if (shouldBlock(fromSlug, toSlug, true)) { e.preventDefault(); e.stopImmediatePropagation(); console.warn('[KRB] blocked synthesized click', fromSlug, '->', toSlug); logBlock(fromSlug, toSlug, 'synth-click'); } }, true); // --- toast --- let toastEl = null; function showToast(msg) { try { if (!document.body) return; if (!toastEl) { toastEl = document.createElement('div'); toastEl.style.cssText = [ 'position:fixed', 'left:50%', 'bottom:88px', 'transform:translateX(-50%)', 'background:rgba(20,20,20,0.92)', 'color:#fff', 'padding:10px 16px', 'border-radius:8px', 'font:14px/1.4 system-ui,sans-serif', 'z-index:2147483647', 'pointer-events:none', 'box-shadow:0 4px 12px rgba(0,0,0,0.3)', 'opacity:0', 'transition:opacity .25s', 'max-width:80vw', 'text-align:center', ].join(';'); document.body.appendChild(toastEl); } toastEl.textContent = msg; toastEl.style.opacity = '1'; clearTimeout(toastEl._t); toastEl._t = setTimeout(() => { if (toastEl) toastEl.style.opacity = '0'; }, 3500); } catch (e) { console.warn('[KRB] toast', e); } } // --- Settings UI --- let buttonEl = null; let panelEl = null; function injectUI() { if (buttonEl) return; if (!document.body) { document.addEventListener('DOMContentLoaded', injectUI); return; } buttonEl = document.createElement('button'); buttonEl.type = 'button'; buttonEl.textContent = '🛡'; buttonEl.setAttribute('aria-label', SCRIPT_NAME + ' settings'); buttonEl.style.cssText = [ 'position:fixed', 'right:12px', 'bottom:12px', 'width:44px', 'height:44px', 'border-radius:50%', 'background:#53fc18', 'color:#000', 'border:none', 'font-size:20px', 'cursor:pointer', 'box-shadow:0 2px 8px rgba(0,0,0,0.3)', 'z-index:2147483646', 'padding:0', 'display:flex', 'align-items:center', 'justify-content:center', ].join(';'); buttonEl.addEventListener('click', togglePanel); document.body.appendChild(buttonEl); refreshButton(); } function refreshButton() { if (!buttonEl) return; buttonEl.style.opacity = CONFIG.enabled ? '1' : '0.4'; buttonEl.title = `${SCRIPT_NAME} (${CONFIG.enabled ? 'ON' : 'OFF'})`; } function togglePanel() { if (panelEl && panelEl.parentNode) { panelEl.parentNode.removeChild(panelEl); panelEl = null; return; } panelEl = document.createElement('div'); panelEl.style.cssText = [ 'position:fixed', 'right:12px', 'bottom:64px', 'width:min(340px,92vw)', 'max-height:75vh', 'overflow-y:auto', 'background:#1a1a1a', 'color:#fff', 'border-radius:12px', 'padding:16px', 'font:14px/1.5 system-ui,sans-serif', 'box-shadow:0 8px 24px rgba(0,0,0,0.4)', 'z-index:2147483646', 'box-sizing:border-box', ].join(';'); panelEl.innerHTML = renderPanel(); document.body.appendChild(panelEl); bindPanel(); } function refreshPanel() { if (!panelEl) return; panelEl.innerHTML = renderPanel(); bindPanel(); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } function renderPanel() { const c = CONFIG; const rows = (c.log || []).slice(0, 10).map((e) => `
  • ${new Date(e.time).toLocaleTimeString()} ${escapeHtml(e.from)} → ${escapeHtml(e.to)}
  • ` ).join('') || '
  • 記録なし
  • '; const ta = 'width:100%;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;font:12px monospace;box-sizing:border-box;'; const sel = 'width:100%;padding:6px;background:#2a2a2a;color:#fff;border:1px solid #444;border-radius:4px;box-sizing:border-box;'; return `
    🛡 ${SCRIPT_NAME}
    モード
    許可リスト(カンマ/スペース区切り)
    ブロックリスト(カンマ/スペース区切り)
    最近のブロック履歴
    v0.2.1 · clean-room · MIT · 外部送信なし
    ソース監査
    `; } function bindPanel() { panelEl.querySelector('#krb-close').addEventListener('click', togglePanel); panelEl.querySelector('#krb-save').addEventListener('click', () => { const enabled = panelEl.querySelector('#krb-enabled').checked; const mode = panelEl.querySelector('#krb-mode').value; const notify = panelEl.querySelector('#krb-notify').checked; const parseList = (raw) => sanitizeSlugList(raw.split(/[\s,]+/).filter(Boolean)); const allow = parseList(panelEl.querySelector('#krb-allow').value); const block = parseList(panelEl.querySelector('#krb-block').value); CONFIG = sanitizeConfig({ ...CONFIG, enabled, mode, allow, block, notify }); saveConfig(CONFIG); const dropped = ( panelEl.querySelector('#krb-allow').value.split(/[\s,]+/).filter(Boolean).length - allow.length ) + ( panelEl.querySelector('#krb-block').value.split(/[\s,]+/).filter(Boolean).length - block.length ); showToast(dropped > 0 ? `保存しました(${dropped}件の無効なslugを除外)` : '設定を保存しました'); refreshButton(); togglePanel(); }); panelEl.querySelector('#krb-clear-log').addEventListener('click', () => { CONFIG = { ...CONFIG, log: [] }; saveConfig(CONFIG); refreshPanel(); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', injectUI); } else { injectUI(); } console.log(`[KRB] ${SCRIPT_NAME} loaded`); })();