• úvod
  • témata
  • události
  • tržiště
  • přihlásit
  • diskuze
  • nástěnka
  • přihlásit
    přihlásit se
    registrace
    ztracené heslo?
    TOMTampermonkey 🐒 - máte užitečný skript? a mohli bychom ho vidět?
    TOM
    TOM --- ---
    Export JSON rodokmenu z největší DB na světě MyHeritage.com
    články o rodokmenech here:
    [TOM @ Za Paywallem? 🔐🗝️ Odemčeno. 🔑🔓 klub se 4 pravidly v záhlaví]

    // ==UserScript==
    // @name         MyHeritage Family Tree Exporter
    // @version      1.1.0
    // @description  Exports the complete MyHeritage family tree (people, families, lineage codes, photos) into a ZIP with a JSON file and a MyHeritage_attachments folder.
    // @match        https://www.myheritage.com/family-trees/*
    // @match        https://www.myheritage.cz/family-trees/*
    // @include      /^https:\/\/www\.myheritage\.[a-z.]+\/family-trees\//
    // @grant        none
    // @run-at       document-idle
    // ==/UserScript==
    
    /*
     * HOW IT WORKS (findings from reverse-engineering the "family view" page)
     * ----------------------------------------------------------------------
     * The family-view page keeps its data model in `window.newTree`:
     *   newTree.genealogy._individualsCollection._allIndividuals  -> {id: Individual}
     *   newTree.genealogy._familiesCollection._allFamilies        -> {id: Family}
     * Only a pruned neighbourhood (~3 generations) around the *pivot* person is loaded.
     * The "View family branch" button on a card runs
     *   newTree.dataAcquisition.setRootIndividualID(id);
     *   newTree.dataAcquisition.requestTreeData(false);
     * which reloads the model around a new pivot WITHOUT a page reload.
     * The exporter drives that call over every person until the whole tree
     * (newTree.genealogy.getNumberOfIndividualsInTree()) has been collected.
     *
     * Photos: card data only carries a 96 px signed thumbnail. The full-size
     * profile photo comes from
     *   POST /FP/API/Profile/get-profile-photo.php  body: individualID=<id>&s=<siteID>
     * which returns XML with <profilePhotoFullUrl>. Those URLs are signed and expire,
     * so images are downloaded at export time into MyHeritage_attachments/<id>.<ext>.
     *
     * Lineage code (same convention MyHeritage uses for its colour coding):
     *   ""  = home person, "f"/"m" = father/mother, "ff","fm","mf","mm" = grandparents,
     *   "c" = descendant of the home person, "" also for spouses/in-laws/siblings.
     * Colours are NOT written into the JSON; the post-processing tool maps lineage -> colour.
     * MyHeritage's own palette is included as `referencePalette` for convenience.
     *
     * Output: one ZIP (Downloads folder) containing
     *   <name>.json
     *   MyHeritage_attachments/<individualId>.jpg
     * The ZIP is written by a small built-in "store" writer (no compression, no external
     * library) so the script has no @require and is not affected by the site's CSP.
     */
    
    (function () {
        'use strict';
    
        const LOG_PREFIX = '[MH-Export]';
        const ATTACH_DIR = 'MyHeritage_attachments';
        const JUMP_TIMEOUT_MS = 15000;      // a normal pivot loads in ~1 s; longer = the server refused it
        // The site rate-limits tree requests (HTTP 429, shown as "An error occurred. Please try again later.").
        // The pause between pivots is adaptive: it doubles after every refusal and slowly relaxes after successes.
        const JUMP_DELAY_MIN_MS = 2000, JUMP_DELAY_MAX_MS = 20000;
        const RETRY_WAITS_MS = [30000, 60000, 120000, 240000];   // extra back-off before retrying a refused pivot
        const SETTLE_MS = 350;
    
        // ------------------------------------------------------------------ utils
        const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
        const log = (...a) => console.log(LOG_PREFIX, ...a);
    
        async function waitFor(cond, timeoutMs, stepMs = 100) {
            const t0 = Date.now();
            while (Date.now() - t0 < timeoutMs) {
                try { if (cond()) return true; } catch (e) { /* not ready */ }
                await sleep(stepMs);
            }
            return false;
        }
    
        function str(v) { return v === undefined || v === null ? '' : String(v); }
        function numOrNull(v) { const n = parseInt(v, 10); return isNaN(n) ? null : n; }
        function safeFileName(s) { return str(s).replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim(); }
        function timestamp() {
            const d = new Date(), p = (n) => String(n).padStart(2, '0');
            return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '_' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
        }
    
        // ------------------------------------------------------------ zip writer
        // Minimal ZIP (method 0 = store). JPEGs do not compress anyway; JSON is small.
        const CRC_TABLE = (() => {
            const t = new Uint32Array(256);
            for (let n = 0; n < 256; n++) {
                let c = n;
                for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
                t[n] = c >>> 0;
            }
            return t;
        })();
        function crc32(bytes) {
            let c = 0xFFFFFFFF;
            for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
            return (c ^ 0xFFFFFFFF) >>> 0;
        }
        class SimpleZip {
            constructor() { this.entries = []; }
            file(name, data) { this.entries.push({ name, data }); }
            async generateAsync() {
                const enc = new TextEncoder();
                const now = new Date();
                const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xFFFF;
                const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xFFFF;
                const parts = [], central = [];
                let offset = 0;
                const u16 = (v) => [v & 0xFF, (v >>> 8) & 0xFF];
                const u32 = (v) => [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
                for (const e of this.entries) {
                    const bytes = typeof e.data === 'string' ? enc.encode(e.data) : new Uint8Array(await e.data.arrayBuffer());
                    const nameBytes = enc.encode(e.name);
                    const crc = crc32(bytes);
                    const common = [...u16(20), ...u16(0x0800), ...u16(0), ...u16(dosTime), ...u16(dosDate), ...u32(crc), ...u32(bytes.length), ...u32(bytes.length), ...u16(nameBytes.length)];
                    const local = new Uint8Array([...u32(0x04034B50), ...common, ...u16(0), ...nameBytes]);
                    parts.push(local, bytes);
                    central.push(new Uint8Array([...u32(0x02014B50), ...u16(20), ...common, ...u16(0), ...u16(0), ...u16(0), ...u16(0), ...u32(0), ...u32(offset), ...nameBytes]));
                    offset += local.length + bytes.length;
                }
                const centralSize = central.reduce((s, c) => s + c.length, 0);
                const eocd = new Uint8Array([...u32(0x06054B50), ...u16(0), ...u16(0), ...u16(this.entries.length), ...u16(this.entries.length), ...u32(centralSize), ...u32(offset), ...u16(0)]);
                return new Blob([...parts, ...central, eocd], { type: 'application/zip' });
            }
        }
    
        function treeReady() {
            const nt = window.newTree;
            return !!(nt && nt.genealogy && nt.dataAcquisition && nt.canvas &&
                nt.genealogy._individualsCollection && nt.genealogy._individualsCollection.getCount() > 0);
        }
    
        // ------------------------------------------------------------- collector
        class TreeCollector {
            constructor() {
                this.individuals = new Map();   // id -> record
                this.families = new Map();      // id -> record
                this.pivoted = new Set();
                this.pruned = new Set();        // ids whose branches were cut in some view
                this.complete = new Set();      // ids seen as a NON-pruned card: all their immediate relatives were in that view
                this.failed = new Set();        // pivots the server kept refusing
                this.siteId = str(window.newTree.genealogy.getSiteID());
                this.treeId = str(window.newTree.genealogy.getTreeID());
                this.homeId = str(window.newTree.dataAcquisition.getHomeIndividualID());
                this.treeSize = numOrNull(window.newTree.genealogy.getNumberOfIndividualsInTree()) || 0;
            }
    
            // real ids are positive integers; the page uses "0" for a missing spouse and "addme_N" for placeholders
            static isRealId(id) { return /^\d+$/.test(str(id)) && parseInt(id, 10) > 0; }
    
            mergeCurrentView() {
                const g = window.newTree.genealogy;
                const inds = Object.values(g._individualsCollection._allIndividuals || {}).filter(Boolean);
                for (const i of inds) {
                    const id = str(i._ID);
                    if (!TreeCollector.isRealId(id)) continue;
                    // "addme_N" ids are the page's "add parents / add partner" placeholders, not real families
                    const parentFamilies = (i._familiesIAmAChildIn || [])
                        .map((f) => typeof f === 'object' ? { id: str(f.id), relation: str(f.relation) } : { id: str(f), relation: '' })
                        .filter((f) => TreeCollector.isRealId(f.id));
                    const spouseFamilies = (i._familiesIAmAParentIn || [])
                        .map((f) => str(typeof f === 'object' ? f.id : f))
                        .filter(TreeCollector.isRealId);
                    let profileUrl = '';
                    try { profileUrl = new URL(i.getProfilePageURL(), location.origin).href; } catch (e) { /* ignore */ }
    
                    const prev = this.individuals.get(id);
                    const rec = {
                        id,
                        name: str(i._displayName),
                        firstName: str(i._firstName),
                        lastName: str(i._lastName),
                        gender: str(i._gender),          // "M" / "F" / ""
                        birthDate: str(i._birthDate),     // localized text as shown on the card
                        birthYear: numOrNull(i._birthYear),
                        deathDate: str(i._deathDate),
                        deathYear: numOrNull(i._deathYear),
                        isAlive: !!i._isAlive,
                        parentFamilies,
                        spouseFamilies,
                        thumbUrl: str(i._smallThumbURL),
                        hasPhoto: !!i._smallThumbURL,
                        profileUrl,
                        lineage: '',
                        generation: null,
                        photo: null
                    };
                    if (prev) {
                        // union of family links (a pruned view may hide some of them)
                        const pf = new Map(prev.parentFamilies.map((x) => [x.id, x]));
                        for (const x of parentFamilies) pf.set(x.id, x);
                        rec.parentFamilies = [...pf.values()];
                        rec.spouseFamilies = [...new Set([...prev.spouseFamilies, ...spouseFamilies])];
                        if (!rec.thumbUrl && prev.thumbUrl) { rec.thumbUrl = prev.thumbUrl; rec.hasPhoto = true; }
                    }
                    this.individuals.set(id, rec);
                }
    
                const fams = Object.values(g._familiesCollection._allFamilies || {}).filter(Boolean);
                for (const f of fams) {
                    const id = str(f._ID);
                    if (!TreeCollector.isRealId(id)) continue;   // skip "addme_N" placeholders
                    const prev = this.families.get(id);
                    const children = (f._childrenIDs || []).map(str).filter(TreeCollector.isRealId);
                    const rec = {
                        id,
                        husband: TreeCollector.isRealId(f._husbandID) ? str(f._husbandID) : '',
                        wife: TreeCollector.isRealId(f._wifeID) ? str(f._wifeID) : '',
                        status: str(f._status),
                        children
                    };
                    if (prev) {
                        rec.husband = rec.husband || prev.husband;
                        rec.wife = rec.wife || prev.wife;
                        rec.status = rec.status || prev.status;
                        rec.children = [...new Set([...prev.children, ...children])];
                    }
                    this.families.set(id, rec);
                }
    
                // cards whose branch is cut in this view (ridge icon) vs. cards shown with all their relatives
                try {
                    const cards = Object.values(window.newTree.canvas._personCardsCollection._allPersonCards || {}).filter(Boolean);
                    for (const c of cards) {
                        if (typeof c.getIsPruned !== 'function' || !c.getTargetPerson) continue;
                        const p = c.getTargetPerson();
                        if (!p || !TreeCollector.isRealId(p._ID)) continue;
                        if (c.getIsPruned()) this.pruned.add(str(p._ID)); else this.complete.add(str(p._ID));
                    }
                } catch (e) { log('pruned-card scan failed', e); }
                const root = str(g.getRootIndividualID());
                if (TreeCollector.isRealId(root)) this.complete.add(root);
            }
    
            remainingToCheck() {
                let n = 0;
                for (const id of this.individuals.keys()) if (!this.pivoted.has(id) && !this.complete.has(id)) n++;
                return n;
            }
    
            nextPivot(thorough) {
                // 1) cut branches first (that is where unknown people hide)
                for (const id of this.pruned) if (!this.pivoted.has(id) && !this.complete.has(id)) return id;
                if (!thorough && this.treeSize && this.individuals.size >= this.treeSize) return null;
                // 2) then only people never shown with their full neighbourhood - NOT everyone (each pivot is a server request)
                for (const id of this.individuals.keys()) if (!this.pivoted.has(id) && !this.complete.has(id)) return id;
                return null;
            }
    
            // The page calls dataAcquisition.treeLayoutLoaded(...) when a pivot's data has been
            // applied to the model. Hooking it gives an event-driven completion signal, which also
            // works in a background tab where setTimeout polling would be throttled to ~1/min.
            static installLoadedHook() {
                const da = window.newTree.dataAcquisition;
                if (da.__mhHooked) return;
                const orig = da.treeLayoutLoaded;
                da.__mhWaiters = [];
                const fire = (ok) => { const w = da.__mhWaiters; da.__mhWaiters = []; w.forEach((f) => { try { f(ok); } catch (e) { /* ignore */ } }); };
                da.treeLayoutLoaded = function () {
                    const r = orig.apply(this, arguments);
                    fire(true);
                    return r;
                };
                // a refused layout request (HTTP 429 with an empty body when the rate limit hits, or any other
                // HTTP error) should fail fast instead of waiting for the timeout
                try {
                    const oOpen = XMLHttpRequest.prototype.open, oSend = XMLHttpRequest.prototype.send;
                    XMLHttpRequest.prototype.open = function (m, u) { this.__mhUrl = String(u || ''); return oOpen.apply(this, arguments); };
                    XMLHttpRequest.prototype.send = function () {
                        if (/get-tree-layout/.test(this.__mhUrl)) {
                            this.addEventListener('loadend', () => { if (this.status >= 400 || this.status === 0) fire(false); });
                        }
                        return oSend.apply(this, arguments);
                    };
                } catch (e) { log('xhr hook failed', e); }
                da.__mhHooked = true;
            }
    
            async jumpTo(id) {
                const da = window.newTree.dataAcquisition, g = window.newTree.genealogy;
                TreeCollector.installLoadedHook();
                try { da.setOpenSpiderAfterPruneJump(false); } catch (e) { /* ignore */ }
                const loaded = new Promise((res) => {
                    da.__mhWaiters.push((ok) => res(ok));
                    setTimeout(() => res(false), JUMP_TIMEOUT_MS);
                });
                da.setRootIndividualID(id);
                da.requestTreeData(false);
                // success = treeLayoutLoaded fired. There is deliberately NO fallback on the root id: the page
                // sets genealogy's root id BEFORE the request, so after a 429 it already "matches" while the
                // model still holds the previous pivot's people.
                const ok = await loaded;
                await sleep(SETTLE_MS);   // let the canvas rebuild its card collection
                return ok;
            }
    
            // ------------------------------------------------ derived fields
            computeLineageAndGeneration() {
                const byId = this.individuals;
                const fam = this.families;
                const parentsOf = (id) => {
                    const out = [];
                    const p = byId.get(id);
                    if (!p) return out;
                    for (const pf of p.parentFamilies) {
                        const f = fam.get(pf.id);
                        if (!f) continue;
                        if (f.husband) out.push({ id: f.husband, code: 'f' });
                        if (f.wife) out.push({ id: f.wife, code: 'm' });
                    }
                    return out;
                };
                const childrenOf = (id) => {
                    const out = [];
                    const p = byId.get(id);
                    if (!p) return out;
                    for (const fid of p.spouseFamilies) {
                        const f = fam.get(fid);
                        if (f) out.push(...f.children);
                    }
                    return out;
                };
                const spousesOf = (id) => {
                    const out = [];
                    const p = byId.get(id);
                    if (!p) return out;
                    for (const fid of p.spouseFamilies) {
                        const f = fam.get(fid);
                        if (!f) continue;
                        if (f.husband && f.husband !== id) out.push(f.husband);
                        if (f.wife && f.wife !== id) out.push(f.wife);
                    }
                    return out;
                };
    
                // lineage: ancestors of home get f/m path, descendants get "c"
                const home = this.homeId;
                if (byId.has(home)) {
                    byId.get(home).lineage = '';
                    const q = [{ id: home, code: '' }];
                    const seen = new Set([home]);
                    while (q.length) {
                        const { id, code } = q.shift();
                        for (const par of parentsOf(id)) {
                            if (seen.has(par.id) || !byId.has(par.id)) continue;
                            seen.add(par.id);
                            const c = code + par.code;
                            byId.get(par.id).lineage = c;
                            q.push({ id: par.id, code: c });
                        }
                    }
                    const dq = [home];
                    const dseen = new Set([home]);
                    while (dq.length) {
                        const id = dq.shift();
                        for (const ch of childrenOf(id)) {
                            if (dseen.has(ch) || !byId.has(ch)) continue;
                            dseen.add(ch);
                            byId.get(ch).lineage = 'c';
                            dq.push(ch);
                        }
                    }
                }
    
                // generation relative to home (ancestors positive, descendants negative, spouses equal)
                if (byId.has(home)) {
                    byId.get(home).generation = 0;
                    const q = [home];
                    while (q.length) {
                        const id = q.shift();
                        const gen = byId.get(id).generation;
                        const visit = (nid, ngen) => {
                            const n = byId.get(nid);
                            if (!n || n.generation !== null) return;
                            n.generation = ngen;
                            q.push(nid);
                        };
                        for (const par of parentsOf(id)) visit(par.id, gen + 1);
                        for (const ch of childrenOf(id)) visit(ch, gen - 1);
                        for (const sp of spousesOf(id)) visit(sp, gen);
                    }
                }
            }
    
            // ------------------------------------------------------ photos
            async fetchFullPhotoUrl(id) {
                try {
                    const r = await fetch('/FP/API/Profile/get-profile-photo.php', {
                        method: 'POST', credentials: 'include',
                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                        body: 'individualID=' + encodeURIComponent(id) + '&s=' + encodeURIComponent(this.siteId)
                    });
                    const xml = new DOMParser().parseFromString(await r.text(), 'text/xml');
                    const full = xml.querySelector('profilePhotoFullUrl');
                    const norm = xml.querySelector('profilePhotoUrl');
                    return (full && full.textContent) || (norm && norm.textContent) || '';
                } catch (e) {
                    log('get-profile-photo failed for', id, e);
                    return '';
                }
            }
    
            async downloadPhoto(rec, zip, useFullSize) {
                let url = useFullSize ? await this.fetchFullPhotoUrl(rec.id) : '';
                let source = url ? 'profile-full' : '';
                if (!url) { url = rec.thumbUrl; source = 'card-thumbnail'; }
                if (!url) return null;
                const r = await fetch(url);
                if (!r.ok) throw new Error('HTTP ' + r.status);
                const blob = await r.blob();
                const ext = blob.type === 'image/png' ? 'png' : blob.type === 'image/gif' ? 'gif' : blob.type === 'image/webp' ? 'webp' : 'jpg';
                let width = null, height = null;
                try { const bm = await createImageBitmap(blob); width = bm.width; height = bm.height; if (bm.close) bm.close(); } catch (e) { /* ignore */ }
                const file = ATTACH_DIR + '/' + rec.id + '.' + ext;
                if (zip) zip.file(file, blob);
                return { file, width, height, bytes: blob.size, source, sourceUrl: url };
            }
    
            // ------------------------------------------------------ output
            toJson() {
                const palette = (window.treeColorCodingService && window.treeColorCodingService._colors) || null;
                return {
                    format: 'myheritage-tree-export/1',
                    exportedAt: new Date().toISOString(),
                    source: {
                        url: location.href.split('?')[0],
                        siteId: this.siteId,
                        treeId: this.treeId,
                        title: str(window.familyTreeTitle || document.title),
                        treeSizeReported: this.treeSize
                    },
                    homeIndividualId: this.homeId,
                    // note: no empty-string keys anywhere in this file - PowerShell 5.1's ConvertFrom-Json rejects them
                    lineageLegend: {
                        'none': 'empty string = home person, spouses, siblings and in-laws (no lineage)',
                        'f/m...': 'ancestor path from home person: f = father, m = mother, e.g. "fm" = father\'s mother',
                        'c': 'descendant of the home person'
                    },
                    referencePalette: palette,   // MyHeritage colour coding: lineage -> [fill, border]
                    attachmentsDir: ATTACH_DIR,
                    individuals: [...this.individuals.values()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })),
                    families: [...this.families.values()].sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
                };
            }
        }
    
        // ----------------------------------------------------------------- UI
        function buildUi() {
            const css = `
            #mhx_btn{position:fixed;right:18px;bottom:18px;z-index:99999;background:#d14900;color:#fff;border:0;border-radius:24px;
                padding:10px 18px;font:600 14px/1 Arial,sans-serif;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.35)}
            #mhx_btn:hover{background:#b33f00}
            #mhx_panel{position:fixed;right:18px;bottom:64px;z-index:99999;width:360px;background:#fff;border:1px solid #ccc;border-radius:8px;
                box-shadow:0 4px 16px rgba(0,0,0,.3);font:13px/1.4 Arial,sans-serif;color:#222;display:none}
            #mhx_panel h3{margin:0;padding:10px 14px;background:#f4f4f4;border-bottom:1px solid #ddd;font-size:14px}
            #mhx_panel .body{padding:10px 14px}
            #mhx_panel label{display:block;margin:4px 0}
            #mhx_panel .row{display:flex;gap:8px;margin-top:8px}
            #mhx_panel button{background:#d14900;color:#fff;border:0;border-radius:16px;padding:6px 14px;cursor:pointer}
            #mhx_panel button.sec{background:#888}
            #mhx_panel button:disabled{background:#bbb;cursor:default}
            #mhx_log{margin-top:8px;height:140px;overflow:auto;background:#fafafa;border:1px solid #e5e5e5;padding:6px;font:12px/1.3 Consolas,monospace;white-space:pre-wrap}
            #mhx_bar{height:6px;background:#eee;border-radius:3px;margin-top:8px;overflow:hidden}
            #mhx_bar i{display:block;height:100%;width:0;background:#2d7559;transition:width .2s}`;
            const style = document.createElement('style');
            style.textContent = css;
            document.head.appendChild(style);
    
            const btn = document.createElement('button');
            btn.id = 'mhx_btn';
            btn.textContent = 'Export tree';
            document.body.appendChild(btn);
    
            const panel = document.createElement('div');
            panel.id = 'mhx_panel';
            panel.innerHTML = `
                <h3>MyHeritage tree export</h3>
                <div class="body">
                    <label><input type="checkbox" id="mhx_photos" checked> Download photos (full size) into ${ATTACH_DIR}/</label>
                    <label><input type="checkbox" id="mhx_thorough" checked> Thorough crawl (also visit people whose branches were always cut off)</label>
                    <div class="row">
                        <button id="mhx_run">Start export</button>
                        <button id="mhx_close" class="sec">Close</button>
                    </div>
                    <div id="mhx_bar"><i></i></div>
                    <div id="mhx_log"></div>
                </div>`;
            document.body.appendChild(panel);
    
            const logBox = panel.querySelector('#mhx_log');
            const bar = panel.querySelector('#mhx_bar i');
            let lastWasStatus = false;
            const ui = {
                log(msg) { logBox.textContent += msg + '\n'; logBox.scrollTop = logBox.scrollHeight; lastWasStatus = false; log(msg); },
                // status() rewrites the previous status line instead of adding a new one
                status(msg) {
                    if (lastWasStatus) {
                        const lines = logBox.textContent.split('\n');
                        lines.splice(-2, 1);               // drop the previous status line (last element is '' after the trailing \n)
                        logBox.textContent = lines.join('\n');
                    }
                    logBox.textContent += msg + '\n';
                    logBox.scrollTop = logBox.scrollHeight;
                    lastWasStatus = true;
                },
                progress(p) { bar.style.width = Math.max(0, Math.min(100, p)) + '%'; },
                options() { return { photos: panel.querySelector('#mhx_photos').checked, thorough: panel.querySelector('#mhx_thorough').checked }; },
                busy(b) { panel.querySelector('#mhx_run').disabled = b; }
            };
            btn.addEventListener('click', () => { panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; });
            panel.querySelector('#mhx_close').addEventListener('click', () => { panel.style.display = 'none'; });
            panel.querySelector('#mhx_run').addEventListener('click', async () => {
                ui.busy(true);
                try { await runExport(ui.options(), ui); }
                catch (e) { ui.log('ERROR: ' + (e && e.message ? e.message : e)); console.error(e); }
                finally { ui.busy(false); }
            });
            return ui;
        }
    
        // --------------------------------------------------------------- main
        async function runExport(opts, ui, testOnly) {
            if (!treeReady()) throw new Error('Tree model not ready (newTree missing). Open the family view first.');
            const col = new TreeCollector();
            ui.log(`Site ${col.siteId}, tree ${col.treeId}, home person ${col.homeId}, ${col.treeSize} people reported.`);
    
            // 1) crawl
            //    phase 1: discover people (one log line per pivot while the count still grows)
            //    phase 2: every remaining person is visited once so that no family link is missed;
            //             the count no longer changes, so this phase is one status line updated in place
            col.mergeCurrentView();
            col.pivoted.add(str(window.newTree.genealogy.getRootIndividualID()));
            let jumps = 0, phase = 1;
            const allFound = () => col.treeSize > 0 && col.individuals.size >= col.treeSize;
            let phase2Total = 0, delay = JUMP_DELAY_MIN_MS, refused = 0;
            for (;;) {
                const next = col.nextPivot(opts.thorough);
                if (!next) break;
                let ok = false;
                for (let attempt = 0; attempt <= RETRY_WAITS_MS.length; attempt++) {
                    ok = await col.jumpTo(next);
                    jumps++;
                    if (ok) { delay = Math.max(JUMP_DELAY_MIN_MS, Math.round(delay * 0.9)); break; }
                    refused++;
                    delay = Math.min(JUMP_DELAY_MAX_MS, delay * 2);
                    if (attempt < RETRY_WAITS_MS.length) {
                        const w = RETRY_WAITS_MS[attempt];
                        ui.log(`  server refused pivot ${next} (rate limit) - pausing ${w / 1000} s, retry ${attempt + 1}/${RETRY_WAITS_MS.length}; spacing now ${delay / 1000} s`);
                        await sleep(w);
                    }
                }
                col.pivoted.add(next);
                if (!ok) { col.failed.add(next); ui.log(`  warning: pivot ${next} skipped after ${RETRY_WAITS_MS.length} retries`); }
                else col.mergeCurrentView();
                if (phase === 1) {
                    ui.progress(col.treeSize ? (col.individuals.size / col.treeSize) * 40 : 0);
                    ui.log(`  pivot ${next}: ${col.individuals.size}/${col.treeSize} people, ${col.families.size} families`);
                    if (allFound()) {
                        phase = 2;
                        phase2Total = col.remainingToCheck();
                        ui.log(`All ${col.individuals.size} people found after ${jumps} jumps.`);
                        if (opts.thorough && phase2Total) ui.log(`Checking ${phase2Total} people whose branches were cut off in every view so far ...`);
                    }
                } else {
                    const left = col.remainingToCheck();
                    ui.progress(40 + (phase2Total ? (1 - left / phase2Total) : 1) * 20);
                    ui.status(`  ${left} people left to check, ${col.families.size} families`);
                }
                await sleep(delay);
            }
            ui.log(`Crawl done: ${jumps} requests (${refused} refused), ${col.individuals.size} people, ${col.families.size} families.`);
            if (col.treeSize && col.individuals.size < col.treeSize) {
                ui.log(`  note: ${col.treeSize - col.individuals.size} people were not reachable through family links.`);
            }
            if (col.failed.size) ui.log(`  note: ${col.failed.size} pivots failed - some family links may be missing: ${[...col.failed].join(', ')}`);
    
            // 2) derived fields
            col.computeLineageAndGeneration();
    
            // 3) photos
            const zip = testOnly ? null : new SimpleZip();
            const withPhoto = [...col.individuals.values()].filter((r) => r.hasPhoto);
            if (opts.photos) {
                ui.log(`Downloading ${withPhoto.length} photos ...`);
                let n = 0;
                for (const rec of withPhoto) {
                    try { rec.photo = await col.downloadPhoto(rec, zip, true); }
                    catch (e) { ui.log(`  photo ${rec.id} (${rec.name}) failed: ${e.message}`); }
                    n++;
                    ui.progress(60 + (n / withPhoto.length) * 35);
                }
            }
    
            // 4) back to the home person so the page looks as before
            try { await col.jumpTo(col.homeId); } catch (e) { /* ignore */ }
    
            const json = col.toJson();
            if (testOnly) return json;
    
            // 5) zip + download
            const base = 'MyHeritage_' + safeFileName(json.source.title).replace(/ /g, '_') + '_' + timestamp();
            zip.file(base + '.json', JSON.stringify(json, null, 2));
            ui.log('Packing ZIP ...');
            const blob = await zip.generateAsync();
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = base + '.zip';
            document.body.appendChild(a);
            a.click();
            setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
            ui.progress(100);
            ui.log(`Done: ${a.download} (${(blob.size / 1024).toFixed(0)} KB).`);
            return json;
        }
    
        // expose for testing from the console: mhExport.run({photos:true, thorough:true}, null, true)
        window.mhExport = {
            run: (opts, ui, testOnly) => runExport(opts || { photos: true, thorough: true }, ui || { log, status: log, progress() {} }, testOnly),
            TreeCollector
        };
    
        waitFor(treeReady, 60000, 250).then((ok) => {
            if (!ok) { log('tree model never became ready; exporter button not shown'); return; }
            buildUi();
            log('exporter ready');
        });
    })();
    
    TOM
    TOM --- ---
    otravné heureka "Ověřeno zákazníky" panely, co překrývají obsah stránky (a CTRE si s nimi neporadí)? say no more!

    // ==UserScript==
    // @name         Heureka Sliding Widget Remover
    // @version      1.0
    // @description  Hides the heureka.cz "shopping comparison" sliding side tab on any site that injects it. Extensible: add more ID prefixes to ID_PREFIXES.
    // @author       Claude Opus 4.7
    // @match        *://*/*
    // @run-at       document-start
    // @grant        none
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // ----- CONFIG -------------------------------------------------------------
        // ID prefixes to hide. Add more entries for other widgets you spot.
        // Tip: open DevTools, find the offending element, copy its id attribute,
        //      and use a long-enough prefix to avoid false positives.
        const ID_PREFIXES = [
            'heurekaTab',     // visible tab handle (e.g. heurekaTabright / heurekaTableft)
            'heurekaIfrBox',  // sliding iframe container
            'iframe-widget'   // the iframe itself (belt-and-suspenders; parent is already hidden)
        ];
        // --------------------------------------------------------------------------
    
        const selector = ID_PREFIXES.map(p => `[id^="${p}"]`).join(',');
    
        // Inject a single <style> rule. Because this runs at document-start,
        // the rule is in place BEFORE the widget script injects its DOM,
        // so the elements are invisible the moment they appear. No observer,
        // no polling, no removal loop required.
        const style = document.createElement('style');
        style.textContent = `${selector} { display: none !important; }`;
        (document.head || document.documentElement).appendChild(style);
    })();
    TOM
    TOM --- ---
    PDFko na scribd.com chce údaje kreditky pro "free" download?
    snadná pomoc ;)
    vlevo nahoře se zobrazí poloprůhledné tlačítko PDF download
    // ==UserScript==
    // @name         PDF Grabber (Direct + Image-Viewer Fallback)
    // @namespace    userscripts
    // @version      1.1.0
    // @description  Floating button. Tries to find a direct PDF on the page; if none, falls back to harvesting a paged image viewer (Scribd-style). Two output modes: 'print' (browser Save-as-PDF; preserves text layer; default) or 'pdf' (image-only PDF via jsPDF).
    // @author       whoever
    // @match        *://*.scribd.com/*
    // @grant        GM_xmlhttpRequest
    // @grant        GM_download
    // @grant        unsafeWindow
    // @connect      *
    // @require      https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
    // @run-at       document-idle
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // ============================================================
        // CONFIG
        // ============================================================
        const CONFIG = {
            // 'print'  = build a clean iframe of all pages and call window.print()
            //            => preserves text layer (selectable text in resulting PDF)
            //            => requires user to pick "Save as PDF" in the print dialog
            // 'pdf'    = stitch image_layer images into a single PDF via jsPDF
            //            => fully automatic, no dialog
            //            => image-only, NO text in result
            // 'images' = save each image as a separate file
            OUTPUT_MODE: 'print',
    
            // Selectors for the paged image viewer.
            IMAGE_VIEWER_PAGE_SELECTOR: '.outer_page',
            INNER_PAGE_SELECTOR       : '.newpage',
            IMAGE_VIEWER_IMG_SELECTOR : '.absimg, .image_layer img',
    
            // CSS selectors of in-page promo / ad blocks to strip before printing.
            PRINT_HIDE_SELECTORS: [
                // Scribd's between-page promo div uses an obfuscated class name.
                '[class*="vxxhmpejhe"]',
                // Generic ad slots
                '[id*="ad-"]', '[id*="ad_"]', '[class*="advert"]',
            ],
    
            PAGE_LOAD_WAIT_MS: 800,
            FINAL_SETTLE_MS: 1500,
    
            BUTTON_OPACITY_IDLE: 0.25,
            BUTTON_OPACITY_HOVER: 1.0,
            BUTTON_POSITION: { top: '8px', left: '8px' },
    
            FILENAME: '',
            DEBUG: true,
        };
        // ============================================================
    
        const log = (...a) => CONFIG.DEBUG && console.log('[PDF-Grabber]', ...a);
        const warn = (...a) => console.warn('[PDF-Grabber]', ...a);
    
        // ---------- 1. UI ----------
        function buildButton() {
            if (document.getElementById('__pdf_grabber_btn__')) return;
            const btn = document.createElement('button');
            btn.id = '__pdf_grabber_btn__';
            btn.textContent = '⤓ PDF';
            btn.title = 'PDF Grabber — click to download';
            Object.assign(btn.style, {
                position: 'fixed',
                top: CONFIG.BUTTON_POSITION.top,
                left: CONFIG.BUTTON_POSITION.left,
                zIndex: 2147483647,
                padding: '6px 10px',
                fontSize: '12px',
                fontFamily: 'sans-serif',
                background: 'rgba(0,0,0,0.6)',
                color: '#fff',
                border: '1px solid rgba(255,255,255,0.4)',
                borderRadius: '4px',
                cursor: 'pointer',
                opacity: String(CONFIG.BUTTON_OPACITY_IDLE),
                transition: 'opacity 0.2s',
            });
            btn.addEventListener('mouseenter', () => btn.style.opacity = String(CONFIG.BUTTON_OPACITY_HOVER));
            btn.addEventListener('mouseleave', () => btn.style.opacity = String(CONFIG.BUTTON_OPACITY_IDLE));
            btn.addEventListener('click', onClick);
            document.body.appendChild(btn);
        }
    
        function setBtn(text, disabled) {
            const btn = document.getElementById('__pdf_grabber_btn__');
            if (!btn) return;
            btn.textContent = text;
            btn.disabled = !!disabled;
            btn.style.opacity = disabled ? '0.6' : String(CONFIG.BUTTON_OPACITY_IDLE);
        }
        function resetBtnLater() {
            setTimeout(() => setBtn('⤓ PDF', false), 3000);
        }
    
        // ---------- 2. Click handler ----------
        async function onClick() {
            try {
                setBtn('… searching', true);
    
                // (A) Direct PDF on the page — the genuinely universal case.
                const direct = findDirectPdf();
                if (direct) {
                    log('Direct PDF found:', direct);
                    setBtn('… downloading');
                    downloadDirect(direct);
                    setBtn('✓ done');
                    return resetBtnLater();
                }
    
                // (B) Image-viewer fallback (Scribd-shaped).
                log('No direct PDF. Trying image-viewer fallback.');
                if (!hasImageViewer()) {
                    alert('PDF Grabber: no PDF and no recognised image viewer on this page.');
                    setBtn('⤓ PDF', false);
                    return;
                }
    
                // Prevent Scribd from evicting off-screen pages while we scroll.
                disablePageEviction();
    
                setBtn('… loading pages', true);
                await scrollAllPages();
    
                if (CONFIG.OUTPUT_MODE === 'print') {
                    setBtn('… preparing print');
                    await openPrintIframe();
                    // Print dialog blocks until user closes it.
                    setBtn('✓ done');
                    return resetBtnLater();
                }
    
                // 'pdf' or 'images' modes — image-only path
                setBtn('… collecting');
                const urls = collectImageUrls();
                log(`Collected ${urls.length} image URLs.`);
                if (!urls.length) {
                    alert('PDF Grabber: image viewer detected but 0 images collected. Page selectors may need adjusting.');
                    setBtn('⤓ PDF', false);
                    return;
                }
    
                if (CONFIG.OUTPUT_MODE === 'images') {
                    setBtn('… downloading');
                    await downloadEachImage(urls);
                } else {
                    setBtn(`… fetching 0/${urls.length}`);
                    const blobs = await fetchAllAsDataURLs(urls, (i, n) => setBtn(`… fetching ${i}/${n}`));
                    setBtn('… building PDF');
                    await buildPdf(blobs);
                }
                setBtn('✓ done');
                resetBtnLater();
            } catch (err) {
                console.error('[PDF-Grabber] fatal:', err);
                alert('PDF Grabber error: ' + (err.message || err));
                setBtn('⚠ error', false);
                resetBtnLater();
            }
        }
    
        // ---------- 3. Direct PDF detection ----------
        function findDirectPdf() {
            if (/\.pdf(\?|#|$)/i.test(location.pathname + location.search)) return location.href;
            const sels = [
                'embed[src*=".pdf" i]', 'embed[type="application/pdf"]',
                'object[data*=".pdf" i]', 'object[type="application/pdf"]',
                'iframe[src*=".pdf" i]',
            ];
            for (const s of sels) {
                const el = document.querySelector(s);
                if (el) {
                    const u = el.getAttribute('src') || el.getAttribute('data');
                    if (u) return new URL(u, location.href).href;
                }
            }
            const a = document.querySelector('a[href*=".pdf" i]');
            if (a) return new URL(a.getAttribute('href'), location.href).href;
            return null;
        }
    
        function downloadDirect(url) {
            const a = document.createElement('a');
            a.href = url; a.download = filename('.pdf');
            a.target = '_blank'; a.rel = 'noopener';
            document.body.appendChild(a); a.click(); a.remove();
        }
    
        // ---------- 4. Image-viewer detection & loading ----------
        function hasImageViewer() {
            return document.querySelector(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR) != null;
        }
    
        function disablePageEviction() {
            const dm = unsafeWindow.DocumentManager || window.DocumentManager;
            if (!dm) return;
            try {
                if (typeof dm._removeUnusedPages === 'function') dm._removeUnusedPages = function () {};
                const orig = dm._loadAdjacentPages;
                if (typeof orig === 'function') {
                    dm._loadAdjacentPages = function () {
                        const prev = this.mobile;
                        this.mobile = false;
                        try { return orig.apply(this, arguments); }
                        finally { this.mobile = prev; }
                    };
                }
                log('Page eviction disabled.');
            } catch (e) { warn('Could not disable page eviction:', e); }
        }
    
        async function scrollAllPages() {
            const dm = unsafeWindow.DocumentManager || window.DocumentManager;
            const total = countPages();
            log('Page count:', total);
    
            if (dm && typeof dm.gotoPage === 'function') {
                log('Using DocumentManager.gotoPage()');
                for (let i = 1; i <= total; i++) {
                    try { dm.gotoPage(i); } catch (e) { warn('gotoPage failed:', e); }
                    await sleep(CONFIG.PAGE_LOAD_WAIT_MS);
                }
                try { dm.gotoPage(1); } catch (_) {}
            } else {
                log('No DocumentManager — scrolling manually.');
                const pages = document.querySelectorAll(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR);
                for (const p of pages) {
                    p.scrollIntoView({ block: 'center', behavior: 'instant' });
                    await sleep(CONFIG.PAGE_LOAD_WAIT_MS);
                }
                window.scrollTo(0, 0);
            }
            await sleep(CONFIG.FINAL_SETTLE_MS);
        }
    
        function countPages() {
            const ids = [...document.querySelectorAll('[id^="outer_page_"]')]
                .map(e => parseInt(e.id.replace(/\D+/g, ''), 10))
                .filter(n => !isNaN(n));
            if (ids.length) return Math.max(...ids);
            return document.querySelectorAll(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR).length;
        }
    
        function collectImageUrls() {
            const imgs = [...document.querySelectorAll(CONFIG.IMAGE_VIEWER_IMG_SELECTOR)];
            const urls = [], seen = new Set();
            for (const img of imgs) {
                const u = img.currentSrc || img.src || img.getAttribute('orig');
                if (!u || u.startsWith('data:') || seen.has(u)) continue;
                seen.add(u); urls.push(u);
            }
            return urls;
        }
    
        // ---------- 5. PRINT MODE: build clean iframe, then print ----------
        async function openPrintIframe() {
            const pages = [...document.querySelectorAll(CONFIG.IMAGE_VIEWER_PAGE_SELECTOR)];
            if (!pages.length) throw new Error('No outer_page elements found.');
    
            // Determine print page size from the inner .newpage of the first page.
            const firstInner = pages[0].querySelector(CONFIG.INNER_PAGE_SELECTOR);
            const pageW = parsePx(firstInner && firstInner.style.width)  || 902;
            const pageH = parsePx(firstInner && firstInner.style.height) || 1275;
            log(`Print page size: ${pageW} × ${pageH}px`);
    
            // Create offscreen iframe.
            const iframe = document.createElement('iframe');
            iframe.id = '__pdf_grabber_print_iframe__';
            Object.assign(iframe.style, {
                position: 'fixed', top: '0', left: '0',
                width: '0', height: '0', border: 'none', opacity: '0',
                pointerEvents: 'none', zIndex: '-1',
            });
            document.body.appendChild(iframe);
    
            const doc = iframe.contentDocument;
            doc.open();
            doc.write('<!DOCTYPE html><html><head><meta charset="utf-8"><title>');
            doc.write(escapeHtml(filename('')));
            doc.write('</title>');
    
            // Copy ALL stylesheets and inline styles from parent so Scribd's
            // positioning rules (.text_layer transform, .absimg, fonts, etc.)
            // are preserved.
            document.querySelectorAll('head style, head link[rel="stylesheet"]').forEach(el => {
                doc.write(el.outerHTML);
            });
    
            // Print-specific CSS: page size, page breaks, hide promos.
            const hideSelectors = CONFIG.PRINT_HIDE_SELECTORS.join(',\n');
            doc.write(`
                <style id="__pdf_grabber_print_css__">
                    @page { size: ${pageW}px ${pageH}px; margin: 0; }
                    html, body { margin: 0 !important; padding: 0 !important; background: #fff !important; }
                    /* Strip Scribd's between-page promo blocks and ads */
                    ${hideSelectors} { display: none !important; }
                    /* But keep the page containers visible even if they share a class pattern */
                    .outer_page {
                        display: block !important;
                        width: ${pageW}px !important;
                        height: ${pageH}px !important;
                        margin: 0 !important;
                        padding: 0 !important;
                        border: none !important;
                        box-shadow: none !important;
                        page-break-after: always !important;
                        break-after: page !important;
                        overflow: hidden !important;
                        position: relative !important;
                    }
                    .outer_page:last-child {
                        page-break-after: auto !important;
                        break-after: auto !important;
                    }
                    .newpage {
                        display: block !important;
                        transform: none !important;
                        transform-origin: top left !important;
                        width: ${pageW}px !important;
                        height: ${pageH}px !important;
                        position: relative !important;
                    }
                    .absimg { display: block !important; }
                </style>
            `);
    
            doc.write('</head><body>');
    
            // Clone every page into the iframe.
            for (const p of pages) {
                const clone = p.cloneNode(true);
                clone.classList.remove('not_visible');
                clone.classList.remove('blurred_page');
                const inner = clone.querySelector(CONFIG.INNER_PAGE_SELECTOR);
                if (inner) inner.style.display = 'block';
                doc.write(clone.outerHTML);
            }
    
            doc.write('</body></html>');
            doc.close();
    
            // Wait for images to actually load in the iframe.
            await waitForIframeReady(iframe, 30000);
    
            // Print.
            try {
                iframe.contentWindow.focus();
                iframe.contentWindow.print();
            } catch (e) {
                warn('iframe.print() threw, falling back to window.print():', e);
                window.print();
            }
    
            // Clean up after a short delay.
            await sleep(1500);
            try { iframe.remove(); } catch (_) {}
        }
    
        function waitForIframeReady(iframe, timeoutMs) {
            return new Promise(resolve => {
                const start = Date.now();
                const tick = () => {
                    const doc = iframe.contentDocument;
                    let ready = false;
                    if (doc && doc.readyState === 'complete') {
                        const imgs = [...doc.querySelectorAll('img')];
                        ready = imgs.every(img => img.complete && img.naturalWidth > 0);
                    }
                    if (ready) return resolve();
                    if (Date.now() - start > timeoutMs) {
                        warn('Iframe not fully loaded within timeout, printing anyway.');
                        return resolve();
                    }
                    setTimeout(tick, 200);
                };
                tick();
            });
        }
    
        // ---------- 6. IMAGE-ONLY modes (unchanged from v1.0) ----------
        async function downloadEachImage(urls) {
            for (let i = 0; i < urls.length; i++) {
                const url = urls[i];
                const ext = (url.match(/\.(jpg|jpeg|png|webp)(\?|$)/i) || [, 'jpg'])[1];
                const name = `${filename('')}_page_${String(i + 1).padStart(3, '0')}.${ext}`;
                try {
                    if (typeof GM_download === 'function') {
                        GM_download({ url, name, saveAs: false });
                    } else {
                        const a = document.createElement('a');
                        a.href = url; a.download = name;
                        document.body.appendChild(a); a.click(); a.remove();
                    }
                } catch (e) { warn('download failed:', url, e); }
                await sleep(150);
            }
        }
    
        async function fetchAllAsDataURLs(urls, onProgress) {
            const out = [];
            for (let i = 0; i < urls.length; i++) {
                try { out.push(await fetchAsDataURL(urls[i])); }
                catch (e) { warn('fetch failed for', urls[i], e); out.push(null); }
                onProgress && onProgress(i + 1, urls.length);
            }
            return out;
        }
    
        function fetchAsDataURL(url) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET', url, responseType: 'blob',
                    onload: (resp) => {
                        if (resp.status >= 400) return reject(new Error('HTTP ' + resp.status));
                        const reader = new FileReader();
                        reader.onloadend = () => resolve({ dataUrl: reader.result, blob: resp.response });
                        reader.onerror = () => reject(reader.error);
                        reader.readAsDataURL(resp.response);
                    },
                    onerror: (e) => reject(new Error('network error: ' + (e && e.error))),
                    ontimeout: () => reject(new Error('timeout')),
                });
            });
        }
    
        async function buildPdf(items) {
            const jsPDF = (window.jspdf && window.jspdf.jsPDF) ||
                          (unsafeWindow.jspdf && unsafeWindow.jspdf.jsPDF);
            if (!jsPDF) throw new Error('jsPDF not loaded — check @require URL.');
            let pdf = null;
            for (let i = 0; i < items.length; i++) {
                const item = items[i];
                if (!item) continue;
                const { w, h } = await imageSize(item.dataUrl);
                if (!pdf) pdf = new jsPDF({ orientation: w > h ? 'l' : 'p', unit: 'px', format: [w, h] });
                else pdf.addPage([w, h], w > h ? 'l' : 'p');
                const fmt = /^data:image\/png/i.test(item.dataUrl) ? 'PNG' : 'JPEG';
                pdf.addImage(item.dataUrl, fmt, 0, 0, w, h, undefined, 'FAST');
            }
            if (!pdf) throw new Error('No images successfully fetched.');
            pdf.save(filename('.pdf'));
        }
    
        function imageSize(dataUrl) {
            return new Promise((resolve, reject) => {
                const img = new Image();
                img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight });
                img.onerror = reject;
                img.src = dataUrl;
            });
        }
    
        // ---------- 7. Helpers ----------
        function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
        function parsePx(v) { return v ? parseFloat(String(v).replace('px', '')) : 0; }
        function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
        function filename(suffix) {
            let base = CONFIG.FILENAME || document.title || 'document';
            base = base.replace(/[\\/:*?"<>|]+/g, '_').replace(/\s+/g, '_').slice(0, 120);
            return base + suffix;
        }
    
        // ---------- 8. Boot ----------
        if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', buildButton);
        else buildButton();
    })();
    
    TOM
    TOM --- ---
    odstraní Shorts
    vhodné nenápadně instalovat na PC vašich dětí, pokud na nějaký mají přístup ;)
    // ==UserScript==
    // @name         Remove YouTube Shorts
    // @namespace    https://gist.github.com/sjoller/68a53ecd76f8cb6ea74e47b7a407f3f7
    // @updateURL    https://gist.github.com/sjoller/68a53ecd76f8cb6ea74e47b7a407f3f7/raw/87e1c0ce81325514f43d6666eee46de4b08e60f4/hideYTShorts.user.js
    // @downloadURL  https://gist.github.com/sjoller/68a53ecd76f8cb6ea74e47b7a407f3f7/raw/87e1c0ce81325514f43d6666eee46de4b08e60f4/hideYTShorts.user.js
    // @version      1.5
    // @description  Remove all YouTube Shorts from YouTube, including autoloaded content, and remove Shorts menu items from both the sidebar and top menu.
    // @match        *://www.youtube.com/*
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // If the current URL is a Shorts page, redirect to the homepage.
        if (window.location.pathname.startsWith('/shorts')) {
            window.location.replace("https://www.youtube.com");
        }
    
        // Function to remove Shorts video items.
        function removeShorts() {
            // Look for any link that contains "/shorts" in its href.
            const shortsLinks = document.querySelectorAll('a[href*="/shorts"]');
            shortsLinks.forEach(link => {
                // Attempt to locate a suitable container element for the Shorts item.
                let container = link.closest('ytd-reel-shelf-renderer') ||
                                link.closest('ytd-rich-item-renderer') ||
                                link.closest('ytd-video-renderer') ||
                                link.closest('div');
                if (container) {
                    container.remove();
                }
            });
        }
    
        // Function to remove Shorts menu items.
        function removeShortsMenuItems() {
            // Remove left sidebar Shorts element.
            // Select the guide entry whose anchor has a title attribute of "Shorts".
            const leftMenuShorts = document.querySelectorAll('ytd-guide-entry-renderer a[title="Shorts"]');
            leftMenuShorts.forEach(link => {
                let menuItem = link.closest('ytd-guide-entry-renderer');
                if (menuItem) {
                    menuItem.remove();
                }
            });
    
            // Remove left mini sidebar Shorts element.
            // Select the guide entry whose anchor has a title attribute of "Shorts".
            const leftMiniMenuShorts = document.querySelectorAll('ytd-mini-guide-entry-renderer a[title="Shorts"]');
            leftMiniMenuShorts.forEach(link => {
                let menuItem = link.closest('ytd-mini-guide-entry-renderer');
                if (menuItem) {
                    menuItem.remove();
                }
            });
    
            // Remove top menu Shorts element that is an anchor.
            const topMenuShorts = document.querySelectorAll('a[href="/shorts"]');
            topMenuShorts.forEach(link => {
                let menuItem = link.closest('tp-yt-paper-item') || link;
                if (menuItem) {
                    menuItem.remove();
                }
            });
    
            // Remove top menu Shorts chip element.
            // This targets the chip element by finding a yt-formatted-string with a title of "Shorts".
            const chipShorts = document.querySelectorAll('yt-chip-cloud-chip-renderer yt-formatted-string[title="Shorts"]');
            chipShorts.forEach(el => {
                let chip = el.closest('yt-chip-cloud-chip-renderer');
                if (chip) {
                    chip.remove();
                }
            });
        }
    
        // Run functions initially.
        removeShorts();
        removeShortsMenuItems();
    
        // Set up a MutationObserver to catch dynamically loaded content.
        const observer = new MutationObserver(() => {
            removeShorts();
            removeShortsMenuItems();
        });
        observer.observe(document.body, { childList: true, subtree: true });
    
        // Listen for scroll events to catch additional autoloaded content.
        let scrollTimeout;
        window.addEventListener("scroll", function() {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(() => {
                removeShorts();
                removeShortsMenuItems();
            }, 300);
        });
    })();
    MRTVY_KENNY
    MRTVY_KENNY --- ---
    schovavani uz ohodnocenych filmu v zebriccich csfd

    // ==UserScript==
    // @name         ČSFD: Hide Rated Movies
    // @namespace    http://tampermonkey.net/
    // @version      1.2
    // @description  Hides movies you have already rated on ČSFD charts.
    // @author       Gemini
    // @match        https://www.csfd.cz/zebricky/*
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        const hideRated = () => {
            const articles = document.querySelectorAll('article');
    
            articles.forEach(article => {
                // If the article contains an element with the 'rating-mine' class, hide it
                if (article.querySelector('.rating-mine')) {
                    article.style.display = 'none';
                }
            });
        };
    
        // Initial execution
        hideRated();
    
        // Handle infinite scroll and dynamic loading
        const observer = new MutationObserver(hideRated);
        observer.observe(document.body, { childList: true, subtree: true });
    })();
    STEVIE
    STEVIE --- ---
    Strašně me sralo, že se všechny taby jmenují Gemini a nevidím co v nich je v Chrome:

    // ==UserScript==
    // @name Gemini Chat Title Renamer
    // @namespace http://tampermonkey.net/
    // @version 1.1
    // @description Automatically renames the tab to the current Gemini conversation title
    // @author You
    // @match https://gemini.google.com/*
    // @grant none
    // ==/UserScript==

    (function() {
    'use strict';

    function updateTitle() {
    // 1. Attempt to find the specific title element at the top of the chat
    // Google sometimes changes these class names, so we try a specific one and a generic fallback
    const titleElement = document.querySelector('.conversation-title') ||
    document.querySelector('h1[data-test-id="conversation-title"]');

    // 2. If we found the element, get the text
    if (titleElement) {
    const chatTitle = titleElement.innerText.trim();

    // 3. Update the tab name only if it's different and not empty
    if (chatTitle && chatTitle.length > 0 && document.title !== chatTitle) {
    document.title = chatTitle;
    }
    }
    }

    // 4. Run this check every 1 second (1000ms) to handle switching between chats
    setInterval(updateTitle, 1000);
    })();
    DRAGON
    DRAGON --- ---
    SUCZKER: skoda ze tam nemaj STL file :))
    SUCZKER
    SUCZKER --- ---
    Stahování 3D modelů nábytku ze stránek IKEA:

    GitHub - apinanaivot/IKEA-3D-Model-Download-Button: Adds ability to download models from IKEA website
    https://github.com/apinanaivot/IKEA-3D-Model-Download-Button

    PICAZSOO
    PICAZSOO --- ---
    PICAZSOO: Nedalo mi to a skript jsem si otestoval i v Edge Beta s Tampermonkey. Funguje tam taky. Ty user scripty jsou fakt skvělá věc.
    PICAZSOO
    PICAZSOO --- ---
    A možná prosím opravte info v záhlaví, že jediný android browser, co umí user scripty je Edge Beta? Když Firefox for Android (teda přinejmenším Fennec z F-Droidu) podporuje jak Tampermonkey, tak Violentmonkey.

    Za mě je to nejdospělejší prohlížeč na telefon. Možná pomalejší než Chrome nebo Edge, ale na Galaxy S24 rozdíl v praxi nevidím.
    PICAZSOO
    PICAZSOO --- ---
    Velmi dlouho jsem na svém android telefonu používal Operu Mobile. Důvod byl, že jako jediný prohlížeč podporovala text reflow při změně zoomu ( typicky situace, kdy nějaká mobile-unfriendly stránka má moc široký odstavce a malý text a člověk je nucenej po zoomu scrolovat doprava a doleva. Např. některý klasický diskuzní fóra.) Tohle opera vždycky řešila úplně perfektně.

    Na PC ale používám Firefox a už mě nebavilo, jak to nefunguje dohromady z hlediska nějakýho sdílení záložek a historie atd. Takže jsem začal používat Fennec [1], který má slušnou podporu rozšíření a používal jsem tam rozšíření Text reflow on zoom (text wrap) [2]. Fungovalo to skoro stejně dobře jako ta funkcionalita v Opeře, ale mělo to pár much - některý textový prvky to newrapuje, vůbec to nesahá na dynamicky načtený nový prvky (typicky načtení dalších komentářů v diskuzi na redditu atd.) a nepodporovalo to double-tap-to-zoom (jenom pinch-to-zoom).

    Naštěstí je ten plugin open source a MIT licensed a funguje i jako user script. Tak jsem s pomocí Gemini odstranil mouchy, co mě trápily a teď ho používám skrz Violentmonkey [3] a jsem extrémně spokojenej.

    [1] https://f-droid.org/packages/org.mozilla.fennec_fdroid/
    [2] https://addons.mozilla.org/en-US/android/addon/text-reflow-on-zoom-mobile/
    [3] https://addons.mozilla.org/cs/firefox/addon/violentmonkey/

    Skript zde (permanentně zapnutý):
    // ==UserScript==
    // @name         Text reflow on zoom for mobile (text wrap) - Mixed Content Fix
    // @name:ru      Text reflow on zoom for mobile (text wrap) - Mixed Content Fix
    // @description  Fits all text to the screen width after a pinch or double-tap zoom gesture (fixes mixed text/quote blocks)
    // @description:ru Подгонка текста под ширину экрана после жеста увеличения на телефоне
    // @version      1.0.12
    // @author       emvaized
    // @license      MIT
    // @homepageURL  https://github.com/emvaized/text-reflow-on-zoom-mobile/
    // @namespace    text_reflow_on_pinch_zoom
    // @match        *://*/*
    // @grant        none
    // @run-at       document-start
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // UPDATED SELECTOR: Uses text()[normalize-space()] to find ANY valid text node, not just the first one.
        const xpathSelector = `
        //p |
        //li |
        //h1 | //h2 | //h3 | //h4 | //h5 | //h6 |
        //pre |
        //a[text()[normalize-space()]] |
        //div[b or em or i] |
        //div[text()[normalize-space()]] |
        //div[span[text()[normalize-space()]]]`;
    
        let isCssInjected = false;
        let isPinching = false;
        let zoomTarget, targetDyOffsetRatio;
        let resizeDebounceTimer;
        let mutationDebounceTimer;
        
        // Track viewport width to ignore height-only changes (keyboard)
        let lastViewportWidth = window.visualViewport ? window.visualViewport.width : window.innerWidth;
    
        // Track all text elements queried by the selector
        const allTextElements = new Set();
    
        /**
         * Reflows text to fit screen.
         * @param {boolean} shouldRestoreScroll - If true, scrolls back to the zoom target. False for passive updates (infinite scroll).
         */
        function reflowText(shouldRestoreScroll = false) {
            if (!isCssInjected) {
                const styleContent = `.text-reflow-userscript { word-wrap: break-word !important; overflow-wrap:break-word !important; max-width:var(--text-reflow-max-width) !important; }
                .text-reflow-scroll-padding {scroll-margin-left: 1vw !important;}`;
                const styleElement = document.createElement('style');
                styleElement.textContent = styleContent;
                document.head.appendChild(styleElement);
                isCssInjected = true;
            }
    
            // Calculate new width based on the visual viewport (zoomed area)
            const maxAllowedWidth = Math.round(window.visualViewport.width * 0.96);
            document.documentElement.style.setProperty('--text-reflow-max-width', `${maxAllowedWidth}px`);
    
            // Update our width tracker
            lastViewportWidth = window.visualViewport.width;
    
            // Select elements likely to contain text
            const xpathResult = document.evaluate(xpathSelector, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
    
            // Refresh text element set
            allTextElements.clear();
    
            for (let i = 0, n = xpathResult.snapshotLength, el; i < n; i++) {
                el = xpathResult.snapshotItem(i);
                if (!el.offsetParent) continue;
                // Double check text content to be safe
                if (!el.textContent.trim()) continue;
    
                // Process only top-level text elements
                let isTopLevel = true;
                let parent = el.parentElement;
                while (parent) {
                    if (elementIsTextElement(parent)) {
                        isTopLevel = false;
                        break;
                    }
                    parent = parent.parentElement;
                }
    
                if (isTopLevel) {
                    el.classList.add('text-reflow-userscript');
                    allTextElements.add(el);
                }
             }
    
            /// Scroll initial target element into view (ONLY if this is a zoom-triggered reflow)
            if (shouldRestoreScroll && zoomTarget && targetDyOffsetRatio) {
                try {
                    // Scroll to element vertically, according to new page layout
                    const targetOffset = targetDyOffsetRatio * window.innerHeight;
                    const rect = zoomTarget.getBoundingClientRect();
                    const targetTop = rect.top + window.pageYOffset;
                    const scrollToPosition = targetTop - targetOffset;
    
                    window.scrollTo({
                        top: scrollToPosition,
                        behavior: 'instant'
                    });
    
                    // Scroll element into view horizontally if it's text
                    if (zoomTarget.nodeName !== 'IMG' && zoomTarget.nodeName !== 'VIDEO' && zoomTarget.nodeName !== 'IFRAME'){
                        zoomTarget.classList.add('text-reflow-scroll-padding');
                        zoomTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
                        zoomTarget.classList.remove('text-reflow-scroll-padding');
                    }
                } catch (e) {
                    // Fail silently if target is detached from DOM
                }
                
                zoomTarget = null;
                targetDyOffsetRatio = null;
            }
        }
    
        function elementIsTextElement(element) {
            return allTextElements.has(element);
        }
    
        // --- Interaction Handlers ---
    
        function handleTouchStart(event) {
            if (!event.touches) return;
    
            // Logic for tracking the target element
            let midpointX, midpointY;
    
            if (event.touches.length >= 2) {
                isPinching = true;
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                midpointX = (touch1.clientX + touch2.clientX) / 2;
                midpointY = (touch1.clientY + touch2.clientY) / 2;
            } else {
                midpointX = event.touches[0].clientX;
                midpointY = event.touches[0].clientY;
            }
    
            let possibleZoomTarget;
            const elementsFromPoint = document.elementsFromPoint(midpointX, midpointY);
    
            for (let i = 0, n = elementsFromPoint.length, element; i < n; i++) {
                element = elementsFromPoint[i];
                if (elementIsTextElement(element)) {
                    possibleZoomTarget = element;
                    break;
                }
            }
    
            if (!possibleZoomTarget) possibleZoomTarget = elementsFromPoint[0];
    
            if (possibleZoomTarget) {
                zoomTarget = possibleZoomTarget;
                const targetRect = zoomTarget.getBoundingClientRect();
                targetDyOffsetRatio = targetRect.top / window.innerHeight;
            }
        }
    
        function handleTouchEnd(event) {
            if (isPinching && (event.touches && event.touches.length === 0)) {
                isPinching = false;
                reflowText(true);
            }
        }
    
        function handleViewportResize() {
            if (isPinching) return;
            
            const currentWidth = window.visualViewport.width;
            if (Math.abs(currentWidth - lastViewportWidth) < 1) {
                return;
            }
    
            clearTimeout(resizeDebounceTimer);
            resizeDebounceTimer = setTimeout(() => {
                reflowText(true);
            }, 400); 
        }
    
        // --- Dynamic Content Handling ---
        function initObserver() {
            if (!document.body) return;
    
            const observer = new MutationObserver((mutations) => {
                if (isPinching) return;
    
                let hasAddedNodes = false;
                for (const mutation of mutations) {
                    if (mutation.addedNodes.length > 0) {
                        hasAddedNodes = true;
                        break;
                    }
                }
    
                if (hasAddedNodes) {
                    clearTimeout(mutationDebounceTimer);
                    mutationDebounceTimer = setTimeout(() => {
                        reflowText(false);
                    }, 500);
                }
            });
    
            observer.observe(document.body, { childList: true, subtree: true });
        }
    
        // --- Listeners ---
        window.addEventListener('touchstart', handleTouchStart, { passive: true });
        window.addEventListener('touchend', handleTouchEnd);
        
        if (window.visualViewport) {
            window.visualViewport.addEventListener('resize', handleViewportResize);
        }
    
        if (document.body) {
            initObserver();
        } else {
            window.addEventListener('DOMContentLoaded', initObserver);
        }
    
    })();


    a tady je ještě novější verze, u které lze podržením tří prstů na displeji text-wrap globálně vypnout a zapnout podle potřeby (když třeba zlobí na některých stránkách).

    // ==UserScript==
    // @name         Text reflow on zoom for mobile (text wrap) - Sync Fix
    // @name:ru      Text reflow on zoom for mobile (text wrap) - Sync Fix
    // @description  Fits all text to the screen width after a pinch or double-tap zoom gesture (Global 3-finger toggle)
    // @description:ru Подгонка текста под ширину экрана после жеста увеличения на телефоне (Глобальный Вкл/Выкл 3 пальцами)
    // @version      1.0.20
    // @author       emvaized
    // @license      MIT
    // @homepageURL  https://github.com/emvaized/text-reflow-on-zoom-mobile/
    // @namespace    text_reflow_on_pinch_zoom
    // @match        *://*/*
    // @grant        GM_setValue
    // @grant        GM_getValue
    // @grant        GM_addValueChangeListener
    // @run-at       document-start
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        const ENABLED_CLASS = 'userscript-mobile-text-reflow-enabled';
    
        // v1.0.12 Selector
        const xpathSelector = `
        //p |
        //a[normalize-space(text())] |
        //h1 | //h2 | //h3 | //h4 | //h5 | //h6 |
        //li |
        //pre |
        //div[b or em or i] |
        //div[text()[normalize-space()]] |
        //div[span[text()[normalize-space()]]]`;
    
        let isReflowEnabled = GM_getValue('reflowEnabled', true);
        
        let isCssInjected = false;
        let isPinching = false;
        
        let zoomTarget, targetDyOffsetRatio;
        let resizeDebounceTimer;
        let mutationDebounceTimer;
        
        let longPressTimer;
        const LONG_PRESS_DURATION = 800; 
        const MOVE_THRESHOLD = 15;
        let startTouches = [];
        
        let lastViewportWidth = window.visualViewport ? window.visualViewport.width : window.innerWidth;
    
        const allTextElements = new Set();
        let notificationEl = null;
    
        // Apply initial state
        updateHtmlClass();
    
        // 1. Listen for background changes (if browser allows)
        if (typeof GM_addValueChangeListener !== 'undefined') {
            GM_addValueChangeListener('reflowEnabled', (name, oldVal, newVal, remote) => {
                if (isReflowEnabled !== newVal) {
                    isReflowEnabled = newVal;
                    updateHtmlClass();
                    if (isReflowEnabled) reflowText(false); 
                }
            });
        }
    
        // 2. FORCE SYNC on tab switch (Fixes frozen background tabs)
        document.addEventListener("visibilitychange", () => {
            if (document.visibilityState === 'visible') {
                const globalState = GM_getValue('reflowEnabled', true);
                if (isReflowEnabled !== globalState) {
                    isReflowEnabled = globalState;
                    updateHtmlClass();
                    if (isReflowEnabled) reflowText(false);
                }
            }
        });
    
        function updateHtmlClass() {
            if (isReflowEnabled) {
                document.documentElement.classList.add(ENABLED_CLASS);
            } else {
                document.documentElement.classList.remove(ENABLED_CLASS);
                document.documentElement.style.setProperty('--text-reflow-max-width', 'unset');
            }
        }
    
        function showNotification(text) {
            if (!notificationEl) {
                notificationEl = document.createElement('div');
                Object.assign(notificationEl.style, {
                    position: 'fixed',
                    top: '50%',
                    left: '50%',
                    backgroundColor: 'rgba(0, 0, 0, 0.9)',
                    color: 'white',
                    padding: '16px 24px',
                    borderRadius: '12px',
                    zIndex: '2147483647',
                    fontFamily: 'sans-serif',
                    fontSize: '18px',
                    fontWeight: 'bold',
                    pointerEvents: 'none',
                    opacity: '0',
                    transition: 'opacity 0.2s ease',
                    textAlign: 'center',
                    boxShadow: '0 4px 15px rgba(0,0,0,0.5)',
                    userSelect: 'none' 
                });
                document.body.appendChild(notificationEl);
            }
    
            const scale = window.visualViewport ? (1 / window.visualViewport.scale) : 1;
            notificationEl.style.transform = `translate(-50%, -50%) scale(${scale})`;
            
            notificationEl.textContent = text;
            notificationEl.style.opacity = '1';
    
            setTimeout(() => {
                notificationEl.style.opacity = '0';
            }, 2000);
        }
    
        function reflowText(shouldRestoreScroll = false) {
            if (!isReflowEnabled) return;
    
            if (!isCssInjected) {
                const styleContent = `html.${ENABLED_CLASS} .text-reflow-userscript { word-wrap: break-word !important; overflow-wrap:break-word !important; max-width:var(--text-reflow-max-width) !important; }
                .text-reflow-scroll-padding {scroll-margin-left: 1vw !important;}`;
                const styleElement = document.createElement('style');
                styleElement.textContent = styleContent;
                document.head.appendChild(styleElement);
                isCssInjected = true;
            }
    
            const maxAllowedWidth = Math.round(window.visualViewport.width * 0.96);
            document.documentElement.style.setProperty('--text-reflow-max-width', `${maxAllowedWidth}px`);
    
            lastViewportWidth = window.visualViewport.width;
    
            const xpathResult = document.evaluate(xpathSelector, document, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null);
    
            allTextElements.clear();
    
            for (let i = 0, n = xpathResult.snapshotLength, el; i < n; i++) {
                el = xpathResult.snapshotItem(i);
                if (!el.offsetParent) continue;
                if (!el.textContent.trim()) continue;
    
                let isTopLevel = true;
                let parent = el.parentElement;
                while (parent) {
                    if (elementIsTextElement(parent)) {
                        isTopLevel = false;
                        break;
                    }
                    parent = parent.parentElement;
                }
    
                if (isTopLevel) {
                    el.classList.add('text-reflow-userscript');
                    allTextElements.add(el);
                }
             }
    
            if (shouldRestoreScroll && zoomTarget && targetDyOffsetRatio) {
                try {
                    const targetOffset = targetDyOffsetRatio * window.innerHeight;
                    const rect = zoomTarget.getBoundingClientRect();
                    const targetTop = rect.top + window.pageYOffset;
                    const scrollToPosition = targetTop - targetOffset;
    
                    window.scrollTo({
                        top: scrollToPosition,
                        behavior: 'instant'
                    });
    
                    if (zoomTarget.nodeName !== 'IMG' && zoomTarget.nodeName !== 'VIDEO' && zoomTarget.nodeName !== 'IFRAME'){
                        zoomTarget.classList.add('text-reflow-scroll-padding');
                        zoomTarget.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' });
                        zoomTarget.classList.remove('text-reflow-scroll-padding');
                    }
                } catch (e) {
                    // Fail silently
                }
                zoomTarget = null;
                targetDyOffsetRatio = null;
            }
        }
    
        function elementIsTextElement(element) {
            return allTextElements.has(element);
        }
    
        // --- Interaction Handlers ---
    
        function handleTouchStart(event) {
            if (!event.touches) return;
    
            // Toggle Gesture (3 Fingers)
            if (event.touches.length === 3) {
                startTouches = [
                    {x: event.touches[0].clientX, y: event.touches[0].clientY},
                    {x: event.touches[1].clientX, y: event.touches[1].clientY},
                    {x: event.touches[2].clientX, y: event.touches[2].clientY}
                ];
    
                clearTimeout(longPressTimer);
                longPressTimer = setTimeout(() => {
                    isReflowEnabled = !isReflowEnabled;
                    GM_setValue('reflowEnabled', isReflowEnabled);
                    updateHtmlClass();
                    showNotification(isReflowEnabled ? "Reflow: ON" : "Reflow: OFF");
                    if (isReflowEnabled) reflowText(true);
                    longPressTimer = null;
                }, LONG_PRESS_DURATION);
                return; 
            }
    
            let midpointX, midpointY;
    
            if (event.touches.length >= 2) {
                isPinching = true;
                const touch1 = event.touches[0];
                const touch2 = event.touches[1];
                midpointX = (touch1.clientX + touch2.clientX) / 2;
                midpointY = (touch1.clientY + touch2.clientY) / 2;
            } else {
                midpointX = event.touches[0].clientX;
                midpointY = event.touches[0].clientY;
            }
    
            if (isReflowEnabled) {
                let possibleZoomTarget;
                const elementsFromPoint = document.elementsFromPoint(midpointX, midpointY);
    
                for (let i = 0, n = elementsFromPoint.length, element; i < n; i++) {
                    element = elementsFromPoint[i];
                    if (elementIsTextElement(element)) {
                        possibleZoomTarget = element;
                        break;
                    }
                }
    
                if (!possibleZoomTarget) possibleZoomTarget = elementsFromPoint[0];
    
                if (possibleZoomTarget) {
                    zoomTarget = possibleZoomTarget;
                    const targetRect = zoomTarget.getBoundingClientRect();
                    targetDyOffsetRatio = targetRect.top / window.innerHeight;
                }
            }
        }
    
        function handleTouchMove(event) {
            if (longPressTimer && event.touches.length === 3 && startTouches.length === 3) {
                for (let i = 0; i < 3; i++) {
                    const dx = event.touches[i].clientX - startTouches[i].x;
                    const dy = event.touches[i].clientY - startTouches[i].y;
                    if (Math.abs(dx) > MOVE_THRESHOLD || Math.abs(dy) > MOVE_THRESHOLD) {
                        clearTimeout(longPressTimer);
                        longPressTimer = null;
                        return;
                    }
                }
                return;
            }
            
            if (longPressTimer && event.touches.length !== 3) {
                 clearTimeout(longPressTimer);
                 longPressTimer = null;
            }
        }
    
        function handleTouchEnd(event) {
            if (longPressTimer) {
                 clearTimeout(longPressTimer);
                 longPressTimer = null;
            }
    
            if (isPinching && (event.touches && event.touches.length === 0)) {
                isPinching = false;
                if (isReflowEnabled) reflowText(true);
            }
        }
    
        function handleViewportResize() {
            if (isPinching || !isReflowEnabled) return;
            
            const currentWidth = window.visualViewport.width;
            if (Math.abs(currentWidth - lastViewportWidth) < 1) {
                return;
            }
    
            clearTimeout(resizeDebounceTimer);
            resizeDebounceTimer = setTimeout(() => {
                reflowText(true);
            }, 400); 
        }
    
        function initObserver() {
            if (!document.body) return;
    
            const observer = new MutationObserver((mutations) => {
                if (isPinching || !isReflowEnabled) return;
    
                let hasAddedNodes = false;
                for (const mutation of mutations) {
                    if (mutation.addedNodes.length > 0) {
                        hasAddedNodes = true;
                        break;
                    }
                }
    
                if (hasAddedNodes) {
                    clearTimeout(mutationDebounceTimer);
                    mutationDebounceTimer = setTimeout(() => {
                        reflowText(false);
                    }, 500);
                }
            });
    
            observer.observe(document.body, { childList: true, subtree: true });
        }
    
        window.addEventListener('touchstart', handleTouchStart, { passive: true });
        window.addEventListener('touchmove', handleTouchMove, { passive: true });
        window.addEventListener('touchend', handleTouchEnd);
        
        if (window.visualViewport) {
            window.visualViewport.addEventListener('resize', handleViewportResize);
        }
    
        if (document.body) {
            initObserver();
        } else {
            window.addEventListener('DOMContentLoaded', initObserver);
        }
    
    })();

    Třeba se to bude někomu hodit. Obecně jsem u těhle user scriptů s tím kóděním přes gemini a chatGpt udělal dobrou zkušenost. Programovat umím, ale na tyhle věci mi nezbývá čas.
    MUXX
    MUXX --- ---
    Na reddit jsem nedavno objevil bajecnou vec - https://gosinkit.com/
    Neni to tampermonkey, ale stoji to za to.
    STARF
    STARF --- ---
    pokud pouzivate Reddit, posty s vice obrazkama je standardne nutny proklikat mysi... tenhle script prida carousel scroll pomoci sipek na klavesnici
    prevzato z: https://github.com/TheFantasticLoki/Tampermonkey-Scripts/blob/master/Reddit%20Image%20Gallery%20Arrow%20Navigation.user.js

    // ==UserScript==
    // @name         Reddit Image Gallery Arrow Navigation
    // @namespace    https://reddit.com/
    // @version      1.9.2
    // @description  Navigate Reddit image galleries using arrow keys, made with the help of ChatGPT, Thanks ChatGPT!
    // @author       TheFantasticLoki
    // @match        https://*.reddit.com/*
    // @grant        none
    // @license      MIT
    // @homepage     https://github.com/TheFantasticLoki/Tampermonkey-Scripts
    // @homepageURL  https://github.com/TheFantasticLoki/Tampermonkey-Scripts
    // @supportURL   https://github.com/TheFantasticLoki/Tampermonkey-Scripts/issues
    // @downloadURL  https://github.com/TheFantasticLoki/Tampermonkey-Scripts/raw/refs/heads/master/Reddit%20Image%20Gallery%20Arrow%20Navigation.user.js
    // @updateURL    https://github.com/TheFantasticLoki/Tampermonkey-Scripts/raw/refs/heads/master/Reddit%20Image%20Gallery%20Arrow%20Navigation.user.js
    // ==/UserScript==
    
    (function () {
        'use strict';
    
        // Set the debug variable to control logging
        const debug = false;
    
        let activeGallery = null;
    
        const simulateClick = (button) => {
            if (button) {
                try {
                    const event = new MouseEvent('click', {
                        bubbles: true,
                        cancelable: true,
                        view: window,
                    });
                    button.dispatchEvent(event);
                    if (debug) console.log('Dispatched click event successfully');
                } catch (err) {
                    if (debug) console.error('Error dispatching click event:', err);
                }
            } else {
                if (debug) console.log('Button not interactable or not found.');
            }
        };
    
        const handleKeyDown = (e) => {
            if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
                return;
            }
    
            if (!activeGallery) {
                if (debug) console.log('No active gallery to navigate.');
                return;
            }
    
            if (e.key === 'ArrowLeft') {
                if (debug) console.log('Left arrow pressed');
                const prevButton = activeGallery.querySelector('button[aria-label="Previous page"]');
                if (debug) console.log('Previous Button:', prevButton);
                simulateClick(prevButton);
            }
    
            if (e.key === 'ArrowRight') {
                if (debug) console.log('Right arrow pressed');
                const nextButton = activeGallery.querySelector('button[aria-label="Next page"]');
                if (debug) console.log('Next Button:', nextButton);
                simulateClick(nextButton);
            }
        };
    
        const handleMouseEnter = (e) => {
            const galleryCarousel = e.currentTarget;
            if (galleryCarousel && galleryCarousel.shadowRoot) {
                activeGallery = galleryCarousel.shadowRoot;
                if (debug) console.log('Mouse entered gallery-carousel. Active gallery set:', activeGallery);
            }
        };
    
        const handleMouseLeave = () => {
            activeGallery = null;
            if (debug) console.log('Mouse left gallery-carousel. Active gallery cleared.');
        };
    
        const detectPopup = () => {
            const lightbox = document.querySelector('#shreddit-media-lightbox');
            if (lightbox) {
                const gallery = lightbox.querySelector('gallery-carousel');
                if (gallery && gallery.shadowRoot) {
                    activeGallery = gallery.shadowRoot;
                    if (debug) console.log('Gallery popup detected. Active gallery set:', activeGallery);
                }
            }
        };
    
        const observeDOMChanges = () => {
            const observer = new MutationObserver((mutations) => {
                mutations.forEach((mutation) => {
                    if (mutation.addedNodes) {
                        mutation.addedNodes.forEach((node) => {
                            if (node.id === 'shreddit-media-lightbox') {
                                if (debug) console.log('Lightbox detected:', node);
                                detectPopup();
                            }
                        });
                    }
                });
            });
    
            observer.observe(document.body, { childList: true, subtree: true });
        };
    
        const attachHoverListeners = () => {
            document.querySelectorAll('gallery-carousel').forEach((carousel) => {
                carousel.addEventListener('mouseenter', handleMouseEnter);
                carousel.addEventListener('mouseleave', handleMouseLeave);
            });
        };
    
        const observeFeed = () => {
            const feedObserver = new MutationObserver(() => {
                attachHoverListeners(); // Reattach listeners as new galleries load
            });
    
            feedObserver.observe(document.body, { childList: true, subtree: true });
        };
    
        // Initial setup
        document.addEventListener('keydown', handleKeyDown);
        observeDOMChanges();
        observeFeed();
    
        // Attach listeners for initial galleries
        attachHoverListeners();
    
        if (debug) console.log('Script initialized.');
    })();
    TOM
    TOM --- ---
    TOM: zapomněl jsem dodat, že nemá GUI - ovládá se jenom klávesovou zkratkou Ctrl+Shift+mezerník
    TOM
    TOM --- ---
    pauzne/pustí video na všech tabech/stránkách prohlížeče, kde je skript aktivní
    asterisk (*://*/*) případně změňte na youtube apod.
    hodí se např. pokud potřebujete dabing z nekvalitního videa a video obraz z jiného (kombinace youtube + netflix)

    // ==UserScript==
    // @name Pause/Play an all tabs
    // @namespace http://tampermonkey.net/
    // @version 1.0
    // @description Pause/play all videos across all tabs with Ctrl+Shift+Space
    // @match *://*/*
    // @grant GM_setValue
    // @grant GM_getValue
    // @grant GM_addValueChangeListener
    // @run-at document-idle
    // ==/UserScript==
    
    (function() {
    'use strict';
    
    // ============ CONFIGURATION ============
    const CONFIG = {
    debug: true, // Console logging
    storageKey: 'globalVideoToggle'
    };
    
    const log = (...args) => CONFIG.debug && console.log('[VideoToggle]', ...args);
    
    // ============ VIDEO CONTROL ============
    function toggleVideos(action) {
    const videos = document.querySelectorAll('video');
    let affected = 0;
    
    videos.forEach(video => {
    try {
    if (action === 'pause') {
    if (!video.paused) {
    video.pause();
    affected++;
    }
    } else {
    if (video.paused) {
    video.play().catch(() => {}); // Ignore autoplay restrictions
    affected++;
    }
    }
    } catch (e) {
    log('Error controlling video:', e);
    }
    });
    
    log(`${action.toUpperCase()}: ${affected}/${videos.length} videos`);
    }
    
    // ============ KEYBOARD SHORTCUT ============
    document.addEventListener('keydown', (e) => {
    // Ctrl+Shift+Space
    if (e.ctrlKey && e.shiftKey && e.code === 'Space') {
    e.preventDefault();
    e.stopPropagation();
    
    // Toggle based on previous state
    const current = GM_getValue(CONFIG.storageKey, { action: 'play' });
    const newAction = current.action === 'pause' ? 'play' : 'pause';
    
    log(`Shortcut triggered -> ${newAction}`);
    
    // Broadcast to all tabs (including this one via the listener)
    GM_setValue(CONFIG.storageKey, {
    action: newAction,
    timestamp: Date.now() // Ensures value always changes
    });
    
    // Apply locally (listener may not fire for local changes in some versions)
    toggleVideos(newAction);
    }
    }, true); // Capture phase - catches event before websites can intercept
    
    // ============ CROSS-TAB LISTENER ============
    GM_addValueChangeListener(CONFIG.storageKey, (name, oldValue, newValue, remote) => {
    if (remote) { // Only react to changes from OTHER tabs
    log(`Remote signal received -> ${newValue.action}`);
    toggleVideos(newValue.action);
    }
    });
    
    // ============ INIT ============
    log('Loaded. Press Ctrl+Shift+Space to pause/play all videos across tabs.');
    })();
    TOM
    TOM --- ---
    brute force scraper titulků ze streamu
    běží minimálně na stránce arte.tv (konkrétní tip zde), jinde jsem to nepotřeboval/netestoval
    skript nemá GUI, přehrávané titulky se stáhnou po skončení videa automaticky do nastavené dwl složky prohlížeče

    // ==UserScript==
    // @name         Arte.tv Auto Subtitle Scraper
    // @namespace    http://tampermonkey.net/
    // @version      3.0
    // @description  Automatically scrape and save subtitles on tab close/navigation
    // @match        https://www.arte.tv/*
    // @match        https://*.arte.tv/*
    // @grant        none
    // @run-at       document-idle
    // ==/UserScript==
    (function() {
        'use strict';
        // ============ CONFIGURATION ============
        const CONFIG = {
            // Console logging
            debug: true,
            // Start scraping automatically
            autoStart: true,
            // Auto-save when leaving page
            saveOnExit: true,
            // Auto-save on URL/navigation change
            saveOnUrlChange: true,
            // Minimum subtitles before auto-saving
            minSubtitles: 10,
            // Manual save shortcut
            keyboardShortcut: 'ctrl+shift+s',
            // Filename prefix
            filenamePrefix: 'arte_subtitles_',
        };
        // Note: Download folder is controlled by browser settings, not this script.
        // Files will save to your browser's default download location.
        // ============ STATE ============
        const log = (...args) => CONFIG.debug && console.log('[SubScraper]', ...args);
        let capturedSubs = [];
        let isScanning = false;
        let hasAutoSaved = false;
        // ============ FILTERS ============
        const UI_GARBAGE = [
            'Pause', 'Play', 'Mute', 'Unmute',
            'Forward 10 seconds', 'Back 10 seconds',
            'Rewind 10 seconds', 'Skip 10 seconds',
            'Settings', 'Fullscreen', 'Exit fullscreen',
            'Subtitles', 'Audio', 'Quality', 'Captions',
            'Volume', 'Speed', 'Next', 'Previous',
            'Share', 'Download', 'More', 'Less'
        ];
        function isUIGarbage(text) {
            if (!text || text.length < 2) return true;
            // Percentages like "1.36%"
            if (/^\d+\.?\d*%$/.test(text)) return true;
            // Timestamps like "12:34" or "1:23:45"
            if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(text)) return true;
            // Known UI text
            if (UI_GARBAGE.some(ui => text.includes(ui))) return true;
            // Very short or pure numbers
            if (text.length < 3 || /^\d+$/.test(text)) return true;
            return false;
        }
        // ============ SCRAPING ============
        function startScraping() {
            if (isScanning) {
                log('Already scanning');
                return;
            }
            isScanning = true;
            log('Auto-scraping started');
            const observer = new MutationObserver((mutations) => {
                if (!isScanning) return;
                mutations.forEach(mutation => {
                    mutation.addedNodes.forEach(node => {
                        let text = '';
                        if (node.nodeType === Node.TEXT_NODE) {
                            text = node.textContent.trim();
                        } else if (node.nodeType === Node.ELEMENT_NODE) {
                            text = node.textContent.trim();
                        }
                        if (text && !isUIGarbage(text)) {
                            captureSubtitle(text);
                        }
                    });
                });
            });
            // Monitor subtitle areas
            const targets = document.querySelectorAll('.avp, video, [class*="player"], [class*="subtitle"], [class*="caption"]');
            targets.forEach(target => {
                observer.observe(target, {
                    childList: true,
                    subtree: true,
                    characterData: true
                });
            });
            log(`Monitoring ${targets.length} elements`);
        }
        function captureSubtitle(text) {
            // Avoid duplicates
            if (capturedSubs.some(s => s.text === text)) return;
            const video = document.querySelector('video');
            const timestamp = video ? video.currentTime : capturedSubs.length * 2;
            capturedSubs.push({
                start: timestamp,
                end: timestamp + 2, // Default 2 second duration
                text: text
            });
            log(`[${capturedSubs.length}] ${text.substring(0, 50)}`);
        }
        // ============ OVERLAP CORRECTION ============
        function fixOverlaps(subs) {
            // Sort by start time
            subs.sort((a, b) => a.start - b.start);
            // Fix overlaps: if current subtitle ends after next starts,
            // adjust current end to match next start
            for (let i = 0; i < subs.length - 1; i++) {
                if (subs[i].end > subs[i + 1].start) {
                    log(`Overlap fixed: [${i}] ${formatTime(subs[i].end)} -> ${formatTime(subs[i + 1].start)}`);
                    subs[i].end = subs[i + 1].start;
                }
            }
            return subs;
        }
        // ============ DEDUPLICATION ============
        function removeDuplicates(subs) {
            const unique = [];
            const seen = new Set();
            subs.forEach(sub => {
                const key = `${sub.start.toFixed(1)}_${sub.text}`;
                if (!seen.has(key)) {
                    seen.add(key);
                    unique.push(sub);
                }
            });
            return unique;
        }
        // ============ SRT GENERATION ============
        function generateSRT() {
            if (capturedSubs.length === 0) {
                log('No subtitles to save');
                return null;
            }
            // Process subtitles
            let processed = removeDuplicates(capturedSubs);
            processed = fixOverlaps(processed);
            log(`Generating SRT: ${capturedSubs.length} raw -> ${processed.length} final`);
            // Convert to SRT format
            let srt = '';
            processed.forEach((sub, idx) => {
                srt += `${idx + 1}\n`;
                srt += `${formatTime(sub.start)} --> ${formatTime(sub.end)}\n`;
                srt += `${sub.text}\n\n`;
            });
            return srt;
        }
        function formatTime(seconds) {
            const h = Math.floor(seconds / 3600);
            const m = Math.floor((seconds % 3600) / 60);
            const s = Math.floor(seconds % 60);
            const ms = Math.floor((seconds % 1) * 1000);
            return `${pad(h)}:${pad(m)}:${pad(s)},${pad(ms, 3)}`;
        }
        function pad(num, size = 2) {
            let s = num + '';
            while (s.length < size) s = '0' + s;
            return s;
        }
        // ============ SAVE ============
        function saveSubtitles(reason = 'manual') {
            if (capturedSubs.length < CONFIG.minSubtitles) {
                log(`Not saving: only ${capturedSubs.length} subtitles (min: ${CONFIG.minSubtitles})`);
                return false;
            }
            const srt = generateSRT();
            if (!srt) return false;
            const filename = `${CONFIG.filenamePrefix}${Date.now()}.srt`;
            try {
                const blob = new Blob([srt], { type: 'text/plain;charset=utf-8' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                // For auto-save on exit, we need to click synchronously
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                // Clean up after a delay
                setTimeout(() => URL.revokeObjectURL(url), 100);
                log(`✓ Saved: ${filename} (${capturedSubs.length} lines, reason: ${reason})`);
                return true;
            } catch (e) {
                console.error('[SubScraper] Save failed:', e);
                return false;
            }
        }
        // ============ AUTO-SAVE TRIGGERS ============
        // Tab close / page refresh / browser close
        window.addEventListener('beforeunload', (e) => {
            if (CONFIG.saveOnExit && !hasAutoSaved) {
                hasAutoSaved = true;
                saveSubtitles('page-exit');
            }
        });
        // URL change (SPA navigation)
        if (CONFIG.saveOnUrlChange) {
            let currentUrl = window.location.href;
            setInterval(() => {
                if (window.location.href !== currentUrl) {
                    log('URL changed detected');
                    currentUrl = window.location.href;
                    if (!hasAutoSaved) {
                        hasAutoSaved = true;
                        saveSubtitles('url-change');
                    }
                }
            }, 1000);
        }
        // ============ KEYBOARD SHORTCUT ============
        document.addEventListener('keydown', (e) => {
            // Ctrl+Shift+S (or Cmd+Shift+S on Mac)
            if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 's') {
                e.preventDefault();
                log('Manual save triggered');
                saveSubtitles('keyboard');
            }
        });
        // ============ INITIALIZATION ============
        function init() {
            if (!document.body) {
                setTimeout(init, 100);
                return;
            }
            log('Arte.tv Auto Subtitle Scraper v3.0');
            log(`Config: autoStart=${CONFIG.autoStart}, saveOnExit=${CONFIG.saveOnExit}`);
            log(`Keyboard shortcut: ${CONFIG.keyboardShortcut.toUpperCase()} for manual save`);
            if (CONFIG.autoStart) {
                // Wait for video player to load
                setTimeout(() => {
                    const video = document.querySelector('video');
                    if (video) {
                        log('Video detected, starting scraper');
                        startScraping();
                    } else {
                        log('No video found, waiting...');
                        setTimeout(init, 2000);
                    }
                }, 2000);
            }
        }
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init);
        } else {
            init();
        }
        // ============ DEBUG INFO ============
        // Expose stats for console inspection
        window.subScraperStats = () => {
            return {
                captured: capturedSubs.length,
                autoSaved: hasAutoSaved,
                config: CONFIG
            };
        };
        log('Tip: Run window.subScraperStats() in console to see current status');
    })();
    TOM
    TOM --- ---
    POSEIDON: ¯\_(°-°)_/¯ napsal jsem podle aktuální situace, nevím jestli/kdy/proč se může gcp zobrazit
    tvoje verze bude tím pádem určitě spolehlivější
    POSEIDON
    POSEIDON --- ---
    TOM: /gcp/ uz neni potreba?
    // ==UserScript==
    // @name         AliExpress Redirect Script
    // @namespace    http://tampermonkey.net/
    // @version      1.0
    // @description  Redirect from AliExpress /gcp/ URLs to /item/ URLs using productIds param
    // @author       Poseidon
    // @match        https://www.aliexpress.com/*
    // @grant        none
    // ==/UserScript==
    
    (function() {
        'use strict';
    
        // Get current URL
        const url = new URL(window.location.href);
    
        if (url.pathname.includes("/gcp/") || url.pathname.includes("/ssr/")) {
            if (url.searchParams.has("productIds")){
                // Get the value of productIds param
                const productIds = url.searchParams.get("productIds");
                // Split by colon and take the first ID
                const productId = productIds.split(':')[0];
    
                // Create the new URL
                const newUrl = `https://www.aliexpress.com/item/${productId}.html`;
    
                // Redirect to the new URL
                window.location.replace(newUrl);
            }
        }
    })();
    TOM
    TOM --- ---
    Pokud vás obtěžují bundle deals na Aliexpressu, tak tohle je vypne (respektive vezme první ProductID z toho bundle, a přepíše URL adresu)
    // ==UserScript==
    // @name         AliExpress Bundle to Single Item
    // @namespace    http://tampermonkey.net/
    // @version      1.0
    // @description  Redirect bundle deals to first item
    // @match        https://www.aliexpress.com/*
    // @match        https://aliexpress.com/*
    // @grant        none
    // @run-at       document-start
    // ==/UserScript==
    
    (function() {
        'use strict';
        const url = window.location.href;
        // Check for bundle deal URL
        if (url.includes('aliexpress.com/ssr/')) {
            const urlParams = new URLSearchParams(window.location.search);
            const productIds = urlParams.get('productIds');
            if (productIds) {
                // Extract first product ID (digits before colon/separator)
                const match = productIds.match(/^(\d+)/);
                if (match) {
                    window.location.replace(`https://www.aliexpress.com/item/${match[1]}.html`);
                }
            }
        }
    })();
    SHASHA_KRASTY
    SHASHA_KRASTY --- ---
    pro ty co pouzivají vymeny nesmyslu z inventare ve sluzbe Steam, tak zde jeden, skoro bych rekl, nepostradatelny:
    https://www.steamtradematcher.com/res/userscript/stm.user.js

    Kliknutím sem můžete změnit nastavení reklam