☰

HH++ Download Images

Download character images in Harem Heroes (base character: scenes / avatars / data). Game-themed glossy UI, all English.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==UserScript==
// @name         HH++ Download Images
// @namespace    http://tampermonkey.net/
// @version      0.9
// @description  Download character images in Harem Heroes (base character: scenes / avatars / data). Game-themed glossy UI, all English.
// @author       demandehhapi
// @match           https://*.hentaiheroes.com/*
// @match           https://*.haremheroes.com/*
// @match           https://*.gayharem.com/*
// @match           https://nutaku.comixharem.com/*
// @match        https://www.hentaiheroes.com/characters/*
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @license MIT
// ==/UserScript==


(function () {
    'use strict';

    console.log('[HH++] v0.9 script loaded');

    // Log global pour export
    let exportLog = [];

    // ============================================================
    // UI — thème du jeu (glossy Harem Heroes, esprit « ++ » de
    // hh-plus-plus). Tout le texte affiché est en anglais.
    // ============================================================
    const HHPP_CSS = `
        .hhpp-fab, .hhpp-panel { font-family:'Trebuchet MS','Segoe UI',Tahoma,Arial,sans-serif; }

        /* Bouton flottant (pastille glossy) */
        .hhpp-fab {
            position: fixed;
            width: 46px; height: 46px;
            border-radius: 50%;
            display: flex; align-items: center; justify-content: center;
            cursor: pointer;
            border: 3px solid #ffc93d;
            background: radial-gradient(circle at 32% 28%, #ff9cc4 0%, #ff4f9a 48%, #c2175f 100%);
            box-shadow: inset 0 2px 3px rgba(255,255,255,.65),
                        inset 0 -4px 6px rgba(0,0,0,.30),
                        0 4px 14px rgba(0,0,0,.55),
                        0 0 12px rgba(255,79,154,.35);
            z-index: 2147483647;
            transition: transform .12s ease, filter .12s ease;
        }
        .hhpp-fab:hover { transform: scale(1.08); filter: brightness(1.07); }
        .hhpp-fab:active { transform: scale(.95); }
        .hhpp-fab svg { filter: drop-shadow(0 1px 1px rgba(0,0,0,.45)); }
        .hhpp-fab--gold {
            border-color: #ff85b5;
            background: radial-gradient(circle at 32% 28%, #ffe9a8 0%, #ffc93d 48%, #d98d0e 100%);
            box-shadow: inset 0 2px 3px rgba(255,255,255,.7),
                        inset 0 -4px 6px rgba(0,0,0,.30),
                        0 4px 14px rgba(0,0,0,.55),
                        0 0 12px rgba(255,201,61,.4);
        }

        /* Panneau */
        .hhpp-panel {
            position: fixed;
            width: 272px;
            padding: 13px 14px 12px;
            background: linear-gradient(180deg, #4d1626 0%, #380a1b 55%, #2a060f 100%);
            border: 2px solid #ffc93d;
            border-radius: 14px;
            box-shadow: 0 10px 30px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.14);
            color: #ffeecf;
            font-size: 14px;
            z-index: 2147483646;
        }
        .hhpp-title {
            display: flex; align-items: center; gap: 8px;
            margin-bottom: 10px;
            font-size: 15px; font-weight: 800; letter-spacing: .02em;
            color: #ffd97d;
            text-shadow: 0 1px 2px rgba(0,0,0,.75);
        }
        .hhpp-badge {
            display: inline-flex; align-items: center; justify-content: center;
            padding: 2px 8px;
            border-radius: 8px;
            border: 1px solid rgba(255,201,61,.8);
            background: linear-gradient(180deg, #6b2135, #4a1220);
            color: #ffe9a8;
            font-size: 12px; font-weight: 800;
            text-shadow: 0 1px 1px rgba(0,0,0,.6);
            box-shadow: inset 0 1px 0 rgba(255,255,255,.2);
        }
        .hhpp-info { margin-bottom: 12px; font-size: 13px; line-height: 1.55; color: #ffeecf; }
        .hhpp-info p { margin: 0 0 2px; }
        .hhpp-info strong { color: #ffd97d; }
        .hhpp-info .hhpp-error { color: #ff6b6b; font-weight: 700; }

        /* Boutons glossy façon jeu */
        .hhpp-btn {
            padding: 7px 12px;
            border-radius: 10px;
            border: 2px solid #5e0f26;
            background: linear-gradient(180deg, #ff85b5 0%, #ff4f9a 48%, #d61f6e 100%);
            color: #fff;
            font-family: inherit;
            font-size: 13px; font-weight: 800; letter-spacing: .02em;
            text-shadow: 0 1px 1px rgba(0,0,0,.55);
            cursor: pointer;
            box-shadow: inset 0 2px 0 rgba(255,255,255,.5),
                        inset 0 -3px 0 rgba(0,0,0,.22),
                        0 3px 8px rgba(0,0,0,.45);
            transition: transform .08s ease, filter .12s ease, box-shadow .12s ease;
        }
        .hhpp-btn:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); }
        .hhpp-btn:active:not(:disabled) {
            transform: translateY(1px);
            box-shadow: inset 0 2px 0 rgba(255,255,255,.35),
                        inset 0 -1px 0 rgba(0,0,0,.2),
                        0 1px 3px rgba(0,0,0,.45);
        }
        .hhpp-btn:disabled { filter: saturate(.45) brightness(.75); cursor: default; }
        .hhpp-btn--gold  { border-color: #7a4a08; background: linear-gradient(180deg, #ffe27a 0%, #ffc93d 48%, #df9a12 100%); }
        .hhpp-btn--green { border-color: #2b5e10; background: linear-gradient(180deg, #9fe06a 0%, #5cb92e 48%, #3d8f1a 100%); }
        .hhpp-btn--ghost {
            border-color: #a86a1a;
            background: linear-gradient(180deg, #5a1c2c 0%, #40101d 100%);
            color: #ffe9c4;
            box-shadow: inset 0 1px 0 rgba(255,255,255,.18),
                        inset 0 -2px 0 rgba(0,0,0,.3),
                        0 2px 6px rgba(0,0,0,.4);
        }
        .hhpp-btn--wide  { width: 100%; font-size: 14px; padding: 9px 10px; }
        .hhpp-btn--small { padding: 5px 12px; font-size: 12px; border-radius: 8px; }

        /* Champs */
        .hhpp-input {
            flex: 1; min-width: 0;
            padding: 6px 9px;
            border: 1px solid rgba(255,201,61,.6);
            border-radius: 8px;
            background: #1f040b;
            color: #ffd97d;
            font-family: inherit;
            font-size: 12.5px;
            outline: none;
            box-shadow: inset 0 2px 4px rgba(0,0,0,.45);
            transition: border .12s ease, box-shadow .12s ease;
        }
        .hhpp-input:focus { border-color: #ffc93d; box-shadow: inset 0 2px 4px rgba(0,0,0,.45), 0 0 6px rgba(255,201,61,.4); }
        .hhpp-input::placeholder { color: rgba(255,217,125,.45); }

        /* Cases à cocher */
        .hhpp-check { display: flex; align-items: center; gap: 7px; font-size: 13px; color: #ffeecf; cursor: pointer; user-select: none; }
        .hhpp-check input { width: 15px; height: 15px; margin: 0; accent-color: #ffc93d; cursor: pointer; }
        .hhpp-check:has(input:disabled) { opacity: .45; cursor: default; }

        /* Zones de log / liste */
        .hhpp-log {
            height: 92px; overflow-y: auto;
            padding: 7px 9px;
            background: #1f040b;
            border: 1px solid rgba(255,201,61,.55);
            border-radius: 8px;
            box-shadow: inset 0 2px 6px rgba(0,0,0,.5);
            font: 11.5px Consolas, 'JetBrains Mono', 'Fira Mono', monospace;
            color: #ffd97d;
        }
        .hhpp-list {
            height: 92px; overflow-y: auto;
            padding: 7px 9px;
            background: #1f040b;
            border: 1px solid rgba(255,201,61,.55);
            border-radius: 8px;
            box-shadow: inset 0 2px 6px rgba(0,0,0,.5);
            font: 12px 'Trebuchet MS','Segoe UI',Tahoma,sans-serif;
            color: #ffd97d;
        }

        /* Progression */
        .hhpp-progress { height: 12px; border: 1px solid rgba(255,201,61,.6); border-radius: 7px; background: #1f040b; overflow: hidden; box-shadow: inset 0 2px 4px rgba(0,0,0,.5); }
        .hhpp-progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #ffc93d 0%, #ff4f9a 100%); box-shadow: 0 0 8px rgba(255,79,154,.55); transition: width .2s; }
        .hhpp-progress-label { margin-top: 3px; text-align: center; font-size: 11px; color: #ffeecf; }

        /* Confirmation */
        .hhpp-confirm {
            display: flex; align-items: center; justify-content: space-between; gap: 8px;
            padding: 8px;
            border: 1px solid rgba(255,201,61,.7);
            border-radius: 9px;
            background: linear-gradient(180deg, #5a1c2c, #3a0c18);
            box-shadow: inset 0 1px 0 rgba(255,255,255,.12);
        }
        .hhpp-confirm span { font-size: 12px; color: #ffeecf; }

        /* Rappel */
        .hhpp-hint { margin-bottom: 8px; font-size: 12px; color: #ffeecf; text-align: center; }
    `;

    function injectStyles() {
        if (document.getElementById('hhpp-style')) return;
        const style = document.createElement('style');
        style.id = 'hhpp-style';
        style.textContent = HHPP_CSS;
        document.head.appendChild(style);
    }

    const HHPP_DOWNLOAD_SVG = `
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path d="M12 3v10m0 0l-4-4m4 4l4-4" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
            <path d="M5 17.5v1.5a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1.5" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
        </svg>
    `;

    // Bouton flottant solo (pastille rose glossy, flèche de téléchargement)
    function createFloatingIcon() {
        const icon = document.createElement('div');
        icon.id = 'hh-download-icon';
        icon.className = 'hhpp-fab';
        icon.title = 'Download this girl (scenes / avatars / data)';
        icon.innerHTML = HHPP_DOWNLOAD_SVG;
        icon.style.cssText = 'display:none;';   // positionné par positionDownloadUi
        icon.addEventListener('click', function () {
            const panel = document.getElementById('hh-download-panel');
            if (!panel) return;
            const opening = panel.style.display === 'none';
            panel.style.display = opening ? 'block' : 'none';
            positionDownloadUi();
        });
        document.body.appendChild(icon);
    }

    // Panneau de contrôle solo (thème jeu)
    function createControlPanel() {
        const panel = document.createElement('div');
        panel.id = 'hh-download-panel';
        panel.className = 'hhpp-panel';
        panel.style.cssText = `
            position: fixed;
            top: 114px;
            right: 16px;
            display: none;
        `;

        // Titre du panneau + badge
        const title = document.createElement('div');
        title.className = 'hhpp-title';
        title.innerHTML = '<span class="hhpp-badge">HH++</span>Character Download';
        panel.appendChild(title);

        // Informations du personnage
        const infoDiv = document.createElement('div');
        infoDiv.id = 'character-info';
        infoDiv.className = 'hhpp-info';
        panel.appendChild(infoDiv);

        // Contrôles
        const controlsDiv = document.createElement('div');
        controlsDiv.style.cssText = 'display:flex;flex-direction:column;gap:9px;margin-bottom:10px;';

        function makeCheckbox(id, label, checked = false) {
            const container = document.createElement('label');
            container.className = 'hhpp-check';
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.id = id;
            checkbox.checked = checked;
            const labelSpan = document.createElement('span');
            labelSpan.textContent = label;
            container.appendChild(checkbox);
            container.appendChild(labelSpan);
            return container;
        }

        // Rangée 1 : cases à cocher
        const topRow = document.createElement('div');
        topRow.style.cssText = 'display:flex;gap:12px;align-items:center;';
        topRow.appendChild(makeCheckbox('dl-scenes', 'Scenes', true));
        const avatarCheckbox = makeCheckbox('dl-avatars', 'Avatars', !window.location.hostname.includes('comixharem.com'));
        if (window.location.hostname.includes('comixharem.com')) {
            avatarCheckbox.querySelector('input').disabled = true;
        }
        topRow.appendChild(avatarCheckbox);
        topRow.appendChild(makeCheckbox('dl-data', 'Data'));
        controlsDiv.appendChild(topRow);

        // Rangée 2 : préfixe + boutons
        const bottomRow = document.createElement('div');
        bottomRow.style.cssText = 'display:flex;gap:7px;align-items:center;';

        const prefixInput = document.createElement('input');
        prefixInput.type = 'text';
        prefixInput.id = 'start-number';
        prefixInput.className = 'hhpp-input';
        prefixInput.placeholder = 'Prefix (e.g. 011)';
        bottomRow.appendChild(prefixInput);

        function makeButton(label, extraClass, onClick) {
            const btn = document.createElement('button');
            btn.textContent = label;
            btn.className = 'hhpp-btn' + (extraClass ? ' ' + extraClass : '');
            btn.onclick = onClick;
            return btn;
        }
        bottomRow.appendChild(makeButton('↻', 'hhpp-btn--ghost', refreshInfo));
        bottomRow.appendChild(makeButton('Download', 'hhpp-btn--gold', downloadSelected));
        bottomRow.appendChild(makeButton('Log', 'hhpp-btn--ghost', downloadLogFile));
        controlsDiv.appendChild(bottomRow);
        panel.appendChild(controlsDiv);

        // Log
        const logDiv = document.createElement('div');
        logDiv.id = 'download-log';
        logDiv.className = 'hhpp-log';
        panel.appendChild(logDiv);

        document.body.appendChild(panel);
        refreshInfo();
    }

    // Fonction pour ajouter un message au log
    function addLogMessage(message, isError = false, raw = null) {
        const logDiv = document.getElementById('download-log');
        if (!logDiv) return;
        const now = new Date().toLocaleTimeString();
        const messageDiv = document.createElement('div');
        messageDiv.textContent = `[${now}] ${message}`;
        messageDiv.style.color = isError ? '#ff6b6b' : '#ffd97d';
        logDiv.appendChild(messageDiv);
        while (logDiv.children.length > 400) logDiv.removeChild(logDiv.firstChild);
        logDiv.scrollTop = logDiv.scrollHeight;
        // Ajout au log exportable
        exportLog.push(`[${now}] ${message}` + (raw ? `\n${raw}` : ''));
    }

    // Improved getCharacterInfo for all platforms
    function getCharacterInfo() {
        // Step 1: Find harem_right or fallback for HH
        let haremRight = document.querySelector('#harem_right');
        if (haremRight) addLogMessage('Found #harem_right');
        if (!haremRight) {
            haremRight = document.querySelector('.harem_right, [id*=harem_right]');
            if (haremRight) addLogMessage('Found .harem_right or [id*=harem_right]');
        }
        // NEW: Fallback for hentaiheroes.com (no #harem_right)
        if (!haremRight && window.location.hostname.includes('hentaiheroes.com')) {
            // Try to find the opened girl panel in #harem_whole or .global-container
            let openedDiv = document.querySelector('#harem_whole .opened[girl], .global-container .opened[girl], #harem_whole [girl].opened, .global-container [girl].opened');
            if (openedDiv) {
                addLogMessage('Found .opened[girl] in #harem_whole or .global-container (HH fallback)');
                haremRight = { children: [openedDiv] }; // fake haremRight for compatibility
            }
        }
        if (!haremRight) {
            addLogMessage('Could not find harem_right panel.', true);
            return null;
        }

        // Step 2: Find opened girl div
        let openedDiv = haremRight.querySelector ? haremRight.querySelector('.opened') : haremRight.children[0];
        if (openedDiv) addLogMessage('Found .opened');
        if (!openedDiv && haremRight.querySelector) {
            openedDiv = haremRight.querySelector('[girl]');
            if (openedDiv) addLogMessage('Found [girl] attribute');
        }
        // Step 3: Fallback: first child with name and scenes
        if (!openedDiv) {
            addLogMessage('Trying fallback: first child with name and scenes');
            const candidates = Array.from(haremRight.children);
            for (const cand of candidates) {
                if (
                    (cand.querySelector('h3') || cand.querySelector('h2') || cand.querySelector('.name')) &&
                    cand.querySelector('.girl_quests a, [class*=girl_quests] a')
                ) {
                    openedDiv = cand;
                    addLogMessage('Fallback: found candidate with name and scenes');
                    break;
                }
            }
        }
        if (!openedDiv) {
            addLogMessage('Could not find any opened/selected girl block.', true);
            return null;
        }

        // Step 4: Get girl id
        const girlId = openedDiv.getAttribute('girl') || openedDiv.getAttribute('data-girl-id') || '';
        addLogMessage('Girl ID: ' + girlId);
        // Step 5: Get name
        let name = '';
        if (openedDiv.querySelector('h3')) name = openedDiv.querySelector('h3').textContent;
        else if (openedDiv.querySelector('h2')) name = openedDiv.querySelector('h2').textContent;
        else if (openedDiv.querySelector('.name')) name = openedDiv.querySelector('.name').textContent;
        name = name.trim();
        addLogMessage('Girl name: ' + name);
        const folderName = name.replace(/\s+/g, '-').replace(/\./g, '').toLowerCase();

        // Step 6: Get scene links
        let quests = [];
        if (window.location.hostname.includes('hentaiheroes.com')) {
            quests = Array.from(openedDiv.querySelectorAll('.girl_quests a, [class*=girl_quests] a'));
            addLogMessage('Found ' + quests.length + ' scene links (HH)');
        } else {
            let questsDiv = openedDiv.querySelector('.girl_quests');
            if (!questsDiv) questsDiv = openedDiv.querySelector('[class*=girl_quests]');
            if (!questsDiv) {
                addLogMessage('Could not find girl_quests block.', true);
                return null;
            }
            quests = Array.from(questsDiv.querySelectorAll('a'));
            addLogMessage('Found ' + quests.length + ' scene links');
        }
        const nbQuests = quests.length;

        // Step 7: Get quest number and session token
        let questNumber = '';
        let sessToken = '';
        if (quests.length > 0) {
            const firstQuest = quests[0].getAttribute('href');
            const questMatch = firstQuest.match(/quest\/(\d+)/);
            if (questMatch) questNumber = questMatch[1];
            const sessMatch = firstQuest.match(/sess=([^&]+)/);
            if (sessMatch) sessToken = sessMatch[1];
            addLogMessage('First quest: ' + questNumber + ', sess: ' + sessToken);
        }

        return {
            girlId,
            name,
            folderName,
            nbQuests,
            firstQuest: questNumber,
            sessToken,
            quests
        };
    }

    // English refreshInfo
    function refreshInfo() {
        const info = getCharacterInfo();
        const infoDiv = document.getElementById('character-info');
        if (!infoDiv) return;

        if (!info) {
            infoDiv.innerHTML = '<p class="hhpp-error">No character detected. Please select a character in your harem.</p>';
            addLogMessage('No character detected.', true);
            return;
        }

        infoDiv.innerHTML = `
            <p><strong>Name:</strong> ${info.name}</p>
            <p><strong>ID:</strong> ${info.girlId}</p>
            <p><strong>Scenes:</strong> ${info.nbQuests}</p>
            <p><strong>First quest:</strong> ${info.firstQuest}</p>
        `;

        addLogMessage('Character info refreshed.');
    }

    // Fonction pour télécharger selon les options sélectionnées
    function downloadSelected() {
        const info = getCharacterInfo();
        if (!info) {
            addLogMessage('No character detected. Please refresh and select a character.', true);
            return;
        }

        const dlScenes = document.getElementById('dl-scenes').checked;
        const dlAvatars = document.getElementById('dl-avatars').checked;
        const dlData = document.getElementById('dl-data').checked;

        if (dlScenes) {
            // Télécharger les images des scènes
            let prefix = document.getElementById('start-number').value || '';
            prefix = prefix.trim();
            if (prefix) prefix = `(${prefix}) `;
            let displayName = info.name.replace(/\s+/g, ' ').replace(/\./g, '').trim();
            let questLinks = [];
            if (window.location.hostname.includes('hentaiheroes.com')) {
                questLinks = info.quests;
            } else {
                const questsDiv = document.querySelector('#harem_right .girl_quests, .harem_right .girl_quests, [id*=harem_right] .girl_quests, [id*=harem_right] [class*=girl_quests]');
                questLinks = questsDiv ? questsDiv.querySelectorAll('a') : [];
            }
            addLogMessage(`Starting download for ${displayName}`);
            if (questLinks.length === 0) {
                addLogMessage('No scene links found for this character.', true);
            } else {
                questLinks.forEach((a, idx) => {
                    const href = a.getAttribute('href');
                    const match = href.match(/quest\/(\d+)/);
                    const questNum = match ? match[1] : null;
                    let url = '';
                    if (window.location.hostname.includes('hentaiheroes.com')) {
                        if (!questNum) {
                            addLogMessage(`Invalid quest link: ${href}`, true);
                            return;
                        }
                        url = `https://www.hentaiheroes.com/img/quests/${questNum}/1/800x450cut/${questNum}-1.jpg`;
                    } else if (window.location.hostname.includes('nutaku.haremheroes.com')) {
                        const sessMatch = href.match(/sess=([^&]+)/);
                        const sessToken = sessMatch ? sessMatch[1] : info.sessToken;
                        if (!questNum || !sessToken) {
                            addLogMessage(`Invalid quest link: ${href}`, true);
                            return;
                        }
                        url = `https://nutaku.haremheroes.com/img/quests/${questNum}/1/1600x900cut/${questNum}.jpg?sess=${sessToken}`;
                    } else if (window.location.hostname.includes('comixharem.com')) {
                        const sessMatch = href.match(/sess=([^&]+)/);
                        const sessToken = sessMatch ? sessMatch[1] : info.sessToken;
                        if (!questNum || !sessToken) {
                            addLogMessage(`Invalid quest link: ${href}`, true);
                            return;
                        }
                        url = `https://nutaku.comixharem.com/img/quests/${questNum}/1/1600x900cut/${questNum}.jpg?sess=${sessToken}`;
                    } else {
                        addLogMessage(`Unsupported site: ${window.location.hostname}`, true);
                        return;
                    }
                    const filename = `${prefix}${displayName} ${idx + 1}.jpg`;
                    addLogMessage(`Trying: ${url} => ${filename}`);
                    GM_download({
                        url: url,
                        name: filename,
                        onload: function (resp) {
                            addLogMessage(`Downloaded: ${filename} (success)`, false, `URL: ${url}`);
                        },
                        onerror: function (error) {
                            addLogMessage(`Error downloading ${filename}: ${error}`, true, `URL: ${url}`);
                        }
                    });
                });
            }
        }

        if (dlAvatars && !window.location.hostname.includes('comixharem.com')) {
            // Télécharger les images ava
            for (let i = 0; i <= info.nbQuests; i++) {
                const url = `https://hh.hh-content.com/pictures/girls/${info.girlId}/ava${i}-1200x.webp`;
                const filename = `star${i}.png`;
                addLogMessage(`Trying: ${url} => ${filename}`);
                GM_download({
                    url: url,
                    name: filename,
                    onload: function (resp) {
                        addLogMessage(`Downloaded: ${filename} (success)`, false, `URL: ${url}`);
                    },
                    onerror: function (error) {
                        addLogMessage(`Error downloading ${filename}: ${error}`, true, `URL: ${url}`);
                    }
                });
            }
            // Télécharger l'icône
            const iconUrl = `https://hh.hh-content.com/pictures/girls/${info.girlId}/ico0-300x.webp?v=2`;
            const iconFilename = `icon.png`;
            addLogMessage(`Trying: ${iconUrl} => ${iconFilename}`);
            GM_download({
                url: iconUrl,
                name: iconFilename,
                onload: function () {
                    addLogMessage(`Downloaded: ${iconFilename} (success)`);
                },
                onerror: function (error) {
                    addLogMessage(`Error downloading ${iconFilename}: ` + error, true);
                }
            });
        }

        if (dlData) {
            // Extraire et télécharger la bio
            const bioDiv = document.querySelector('div.girl_line[data-block="bio"]');
            if (!bioDiv) {
                addLogMessage('Bio div not found.', true);
            } else {
                const jsonStr = buildBioJson(bioDiv);
                if (!jsonStr) {
                    addLogMessage('Bio ul not found.', true);
                } else {
                    const blob = new Blob([jsonStr], { type: 'application/json' });
                    const jsonUrl = URL.createObjectURL(blob);
                    GM_download({
                        url: jsonUrl,
                        name: 'data.json',
                        onload: function () {
                            addLogMessage('Downloaded: data.json (success)');
                        },
                        onerror: function (error) {
                            addLogMessage('Error downloading data.json: ' + error, true);
                        }
                    });
                }
            }
        }
    }

    // Fonction pour télécharger le log
    function downloadLogFile() {
        const blob = new Blob([exportLog.join('\n')], { type: 'text/plain' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'hh_download_debug.log';
        document.body.appendChild(a);
        a.click();
        setTimeout(() => {
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        }, 100);
    }

    // ============================================================
    // Fonctions partagées (solo)
    // ============================================================

    // Extraction de la bio → JSON ou null
    function buildBioJson(bioDiv) {
        const ul = bioDiv.querySelector('ul');
        if (!ul) return null;
        const lis = Array.from(ul.querySelectorAll('li'));
        let bio = {};
        let about = '';
        for (const li of lis) {
            const charSpan = li.querySelector('span.characteristic');
            const valueSpan = li.querySelector('span:not(.characteristic)');
            if (charSpan && valueSpan) {
                let key = charSpan.textContent.trim().replace(/:$/, '');
                const value = valueSpan.textContent.trim();
                if (key === '') {
                    about = value.replace(/^About her:/, '');
                } else {
                    // Normalize key to match example format
                    key = key.toLowerCase().replace(/\b\w/g, l => l.toUpperCase());
                    bio[key] = value;
                }
            } else {
                const descSpan = li.querySelector('span.desc');
                if (descSpan) {
                    about = descSpan.textContent.trim().replace(/^About her:/, '');
                }
            }
        }
        const data = {
            parody: '',
            bio: {
                "About her": about,
                ...bio
            }
        };
        return JSON.stringify(data, null, 4);
    }

    // « En haut à gauche de la fille » : le plus grand <img> du bloc de la
    // fille OUVERTE, sinon du conteneur du harem, sinon de la page.
    function findMainGirlImage() {
        const vw = window.innerWidth, vh = window.innerHeight;
        const usable = (r) =>
            r.width >= 150 && r.height >= 180 &&
            !(r.width > vw * 0.92 && r.height > vh * 0.92);           // ni icône, ni fond plein écran
        const bestIn = (root) => {
            let best = null, bestS = 0;
            for (const img of root.querySelectorAll('img')) {
                if (!img.complete) continue;
                const r = img.getBoundingClientRect();
                if (!usable(r)) continue;
                const s = r.width * r.height;
                if (s > bestS) { bestS = s; best = img; }
            }
            return best;
        };

        // 1) le portrait vit (en général) dans le bloc de la fille sélectionnée
        const opened = document.querySelector(
            '#harem_right .opened[girl], [id*=harem_right] .opened[girl],' +
            '#harem_whole .opened[girl],' +
            '#harem_right .opened, #harem_whole .opened,' +
            '#waifu-page .girl-display, .waifu-page-container .girl-display'
        );
        if (opened) {
            const img = bestIn(opened);
            if (img) return img;
        }

        // 2) à défaut, le plus grand <img> du conteneur du harem
        const har = document.querySelector(
            '#harem_right, .harem_right, [id*=harem_right], #harem_whole, #harem_left, #waifu-page, .waifu-page-container'
        );
        if (har) {
            const img = bestIn(har);
            if (img) return img;
        }

        // 3) dernier recours : plus grand <img> de la page (garanti harem via inHarem)
        return bestIn(document);
    }

    // Détection « sommes-nous sur l'écran du harem ? » — multi-signaux stricts
    let _lastHaremDiag = 0;
    function isOnHaremPage() {
        const PW = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

        const path = window.location.pathname.toLowerCase();
        const haremKeywords = ['harem', 'waifu', 'characters'];

        // Signal 2 — <body page="...">
        const bodyPage = (document.body ? (document.body.getAttribute('page') || '') : '').toLowerCase();

        // Signal 3 — current_page (set by the game's JS)
        let cp = '';
        try {
            cp = String(PW.current_page || window.current_page || '').toLowerCase();
        } catch (_) { /* sandboxed */ }

        // 1. REJET STRICT : exclure immédiatement toute page hors-harem explicite
        // (page d'équipe edit-team, combat, menu principal, ligues, boutique, etc.)
        if (bodyPage && !haremKeywords.includes(bodyPage)) {
            return false;
        }
        if (cp && !haremKeywords.includes(cp)) {
            return false;
        }
        if (document.body) {
            const nonHaremClasses = [
                'page-home', 'page-edit-team', 'page-battle', 'page-arena',
                'page-clubs', 'page-shop', 'page-activities', 'page-pachinko',
                'page-season', 'page-champions', 'page-profile', 'page-quest',
                'page-adventures', 'page-side-quests', 'page-messenger', 'page-settings'
            ];
            if (nonHaremClasses.some(c => document.body.classList.contains(c))) {
                return false;
            }
        }
        const nonHaremPaths = [
            '/home', '/edit-team', '/battle', '/arena', '/clubs',
            '/shop', '/activities', '/pachinko', '/season', '/champions',
            '/god-path', '/messenger', '/settings', '/hero', '/adventures'
        ];
        if (nonHaremPaths.some(p => path.includes(p)) && !haremKeywords.some(k => path.includes(k))) {
            return false;
        }

        // 2. SIGNAUX POSITIFS d'écran Harem
        const urlOk = haremKeywords.some(k => path.includes('/' + k) || path.includes(k + '.html'));
        const bodyOk = haremKeywords.includes(bodyPage) ||
            (document.body && haremKeywords.some(k => document.body.classList.contains('page-' + k)));
        const cpOk = haremKeywords.includes(cp);

        // Conteneurs DOM STRICTEMENT exclusifs au harem
        // ATTENTION : ne JAMAIS inclure [girl], .girls_list ou .harem-girl-container
        // car ils existent sur la page d'équipe (edit-team), combat, etc.
        const domOk = !!(
            document.querySelector('#harem_right') ||
            document.querySelector('.harem_right') ||
            document.querySelector('[id*=harem_right]') ||
            document.querySelector('#harem_whole') ||
            document.querySelector('#harem_left') ||
            document.querySelector('#waifu-page') ||
            document.querySelector('.waifu-page-container')
        );

        const result = urlOk || bodyOk || cpOk || domOk;

        // Log diagnostique toutes les 10 s (pas de spam)
        const now = Date.now();
        if (now - _lastHaremDiag > 10000) {
            _lastHaremDiag = now;
            console.log('[HH++] isOnHaremPage?', result,
                '{ url:', urlOk, '(' + path + '), body:', bodyOk, '(' + bodyPage + '),',
                'cp:', cpOk, '(' + cp + '), dom:', domOk, '}');
        }

        return result;
    }

    // (solo) Positionne le bouton + le panneau, visibles uniquement sur
    // l'écran du harem. Ancre le bouton près du portrait de la fille.
    function positionDownloadUi() {
        const soloIcon = document.getElementById('hh-download-icon');
        if (!soloIcon) return;
        const soloPanel = document.getElementById('hh-download-panel');

        const inHarem = isOnHaremPage();
        if (!inHarem) {
            soloIcon.style.display = 'none';
            if (soloPanel) soloPanel.style.display = 'none';
            return;
        }
        soloIcon.style.display = 'flex';

        const vw = window.innerWidth;
        const img = findMainGirlImage();
        const r = img ? img.getBoundingClientRect() : null;
        const x = r ? Math.max(8, r.left - 56) : Math.max(8, vw / 2 - 250);
        const y = r ? Math.max(225, r.top + 155) : 225;

        soloIcon.style.zIndex = 2147483647;
        soloIcon.style.left = x + 'px';
        soloIcon.style.top = y + 'px';

        // Le panneau s'ouvre à DROITE du bouton.
        if (soloPanel) {
            const w = soloPanel.offsetWidth || 280;
            soloPanel.style.right = 'auto';
            soloPanel.style.left = Math.min(x + 46 + 10, vw - w - 8) + 'px';
            soloPanel.style.top = Math.max(8, y) + 'px';
            soloPanel.style.zIndex = 2147483646;
        }
    }

    let bulkPosRaf = 0;
    function scheduleBulkIconPosition() {
        if (bulkPosRaf) return;
        bulkPosRaf = requestAnimationFrame(() => {
            bulkPosRaf = 0;
            positionDownloadUi();
        });
    }

    // ============================================================
    // Initialisation robuste
    // - marche même si l'événement « load » a déjà eu lieu
    //   (réinjection Tampermonkey après update, navigation dans
    //   le jeu sans rechargement complet)
    // - idempotent : ne crée jamais deux fois les icônes/panneaux
    // - try/catch + boîte d'erreur VISIBLE sur la page
    // ============================================================
    let booted = false;

    function showScriptError(err) {
        let box = document.getElementById('hh-script-error');
        if (!box) {
            box = document.createElement('div');
            box.id = 'hh-script-error';
            box.style.cssText = 'position:fixed;top:8px;left:8px;z-index:2147483647;max-width:340px;padding:8px 10px;background:#fff5f5;color:#b30000;border:1px solid #b30000;border-radius:8px;font:12px Consolas,monospace;white-space:pre-wrap;';
            document.body.appendChild(box);
        }
        box.textContent = 'HH++ script error:\n' + ((err && err.message) || err);
    }

    function bootUi() {
        if (booted) return;
        if (!document.body) return;
        // Nettoyage avant recréation (sécurité réinjection)
        for (const id of ['hh-download-icon', 'hh-download-panel']) {
            const el = document.getElementById(id);
            if (el) el.remove();
        }
        injectStyles();
        try {
            createFloatingIcon();
            createControlPanel();
            positionDownloadUi();
            booted = true;
            setInterval(() => positionDownloadUi(), 1000);
            window.addEventListener('resize', scheduleBulkIconPosition);
            window.addEventListener('scroll', scheduleBulkIconPosition, { passive: true });
            window.addEventListener('popstate', scheduleBulkIconPosition);

            // Détection instantanée des navigations SPA (changement de page sur body)
            try {
                const bodyObs = new MutationObserver(() => positionDownloadUi());
                bodyObs.observe(document.body, { attributes: true, attributeFilter: ['page', 'class'] });
            } catch (_) {}

            console.log('[HH++] UI ready (solo)');
        } catch (e) { console.error('[HH++] init solo:', e); showScriptError(e); }
    }

    if (document.readyState === 'complete') {
        setTimeout(bootUi, 2000);
    } else {
        window.addEventListener('load', function () { setTimeout(bootUi, 2000); });
    }
    // Filet de sécurité : si l'UI n'a toujours pas été créée
    // (load déjà passé / réinjection tardive), on relance le boot.
    setInterval(() => {
        if (!booted && !document.getElementById('hh-download-icon')) bootUi();
    }, 5000);
})();