Download character images in Harem Heroes (base character: scenes / avatars / data). Game-themed glossy UI, all English.
// ==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);
})();