☰

HH++ Download Images

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 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);
})();