Вставляет раздел «Автозаполнение формы» и заполняет поля через VNDB/Steam API
// ==UserScript==
// @name Pornolab — Form autocomplete
// @namespace https://github.com/yourname
// @version 1.1.1
// @description Вставляет раздел «Автозаполнение формы» и заполняет поля через VNDB/Steam API
// @author claude.ai
// @match https://pornolab.net/forum/posting.php?mode=new_rel&f=1756
// @match https://pornolab.net/forum/posting.php?mode=new_rel&f=1869
// @match https://pornolab.net/forum/posting.php?mode=new_rel&f=1750
// @match https://pornolab.net/forum/posting.php?mode=new_rel&f=1785
// @match https://pornolab.net/forum/posting.php?mode=new_rel&f=1790
// @match https://pornolab.net/forum/posting.php?mode=new_rel&f=1827
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @connect api.vndb.org
// @connect store.steampowered.com
// @connect steamspy.com
// @connect translate.googleapis.com
// @connect new.fastpic.org
// @connect static.pornolab.net
// @connect api.igdb.com
// @connect id.twitch.tv
// @connect images.igdb.com
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ─── Конфигурация ──────────────────────────────────────────────────────────
const FORUM_ID = new URL(location.href).searchParams.get('f') || '';
const DEFAULT_STEAM_FORUMS = ['1750', '1785', '1790', '1827'];
const API_BASE = 'https://api.vndb.org/kana';
const FIELDS = {
poster: 'poster',
hieroglyphs: '7cd5187f9e6f07f61f4ba4b07c559644',
titleEng: 'title_eng',
titleRu: '92bc57d69021863813152bd3afe11942',
year: 'g_year',
date: 'g_data',
tags: 'genre_g',
cens: 'cens_game',
developer: '6952199379228e5408178e7c18b27360',
platform: 'platform',
releaseType: 'release_type',
medicine: 'tablet',
version: '88311f8a842b3a42b3f4d079846ef9ab',
langStory: 'lang_g',
langInterface: 'menulang_g',
langVoice: 'language_g',
sysReq: '8baf6af65599d806043e079219e49003',
description: 'description',
moreInfo: 'moreinfo',
stepInstall: 'stepinstall',
screenshots: 'screenshots_g',
};
const FIELD_LABELS = {
hieroglyphs: 'иероглифы',
titleEng: 'Оригинальное название',
titleRu: 'Название на русском',
developer: 'Разработчик',
version: 'Версия',
sysReq: 'Системные требования',
};
const EXTRA_TAGS_GROUPS = [
{ title: 'Ориентация', tags: [
{ key: 'xt_Straight', label: 'Straight' },
{ key: 'xt_Gay_Yaoi', label: 'Gay/Yaoi' },
{ key: 'xt_Trans_Trap', label: 'Trans/Trap' },
{ key: 'xt_Lesbian_Yuri', label: 'Lesbian/Yuri' },
{ key: 'xt_Bisexual', label: 'Bisexual' },
]},
{ title: 'OS', tags: [
{ key: 'xt_Win', label: 'Win' },
{ key: 'xt_Lin', label: 'Lin' },
{ key: 'xt_Mac', label: 'Mac' },
{ key: 'xt_Apk', label: 'Apk' },
]},
{ title: 'Разработчик', tags: [
{ key: 'xt_Indie', label: 'Indie' },
{ key: 'xt_Pro', label: 'Pro' },
]},
{ title: 'Движок', tags: [
{ key: 'xt_HTML', label: 'HTML' },
{ key: 'xt_Flash', label: 'Flash' },
{ key: 'xt_GameMaker', label: 'GameMaker' },
{ key: 'xt_Godot', label: 'Godot' },
{ key: 'xt_NW_js', label: 'NW.js' },
{ key: 'xt_Unity', label: 'Unity' },
{ key: 'xt_Unreal', label: 'Unreal' },
{ key: 'xt_RenPy', label: "Ren'Py" },
{ key: 'xt_Kirikiri', label: 'Kirikiri' },
{ key: 'xt_Tyranobuilder', label: 'Tyranobuilder' },
{ key: 'xt_QSP', label: 'QSP' },
{ key: 'xt_RPGMaker', label: 'RPG Maker' },
{ key: 'xt_WolfRPG', label: 'Wolf RPG Editor' },
]},
];
const LANG_G_ABR = [
'', // » Выбрать
'rus', // Русский
'rus(auto)', // Русский(авто)
'eng', // Английский
'eng(auto)', // Английский(авто)
'rus+eng', // Русский+Английский
'rus+eng+multi', // Русский+Английский и др.
'rus+eng(auto)+multi', // Русский+Английский(авто) и др.
'jap', // Японский
'chi', // Китайский
'jap+eng', // Японский+Английский
'jap+eng(auto)', // Японский+Английский(авто)
'jap+rus', // Японский+Русский
'jap+rus(auto)', // Японский+Русский(авто)
'chi+eng', // Китайский+Английский
'chi+eng(auto)', // Китайский+Английский(авто)
'chi+rus', // Китайский+Русский
'chi+rus(auto)', // Китайский+Русский(авто)
'jap+eng+rus', // Яп.+Англ.+Рус.
'jap+eng+rus(auto)', // Яп.+Англ.+Рус.(авто)
'chi+eng+rus', // Кит.+Англ.+Рус.
'chi+eng+rus(auto)', // Кит.+Англ.+Рус.(авто)
'jap+chi+eng+rus', // Яп.+Кит.+Англ.+Рус.
'jap+chi+eng+rus(auto)', // Яп.+Кит.+Англ.+Рус.(авто)
'jap+chi+eng+rus+multi', // Яп.+Кит.+Англ.+Рус.и др.
'jap+chi+eng+rus(auto)+multi', // Яп.+Кит.+Англ.+Рус.(авто)и др.
'', // Неизвестен
];
const VOICE_ABR_TO_LABEL = {
'jap': 'Японский',
'eng': 'Английский',
'rus': 'Русский',
'chi': 'Китайский',
'jap+eng': 'Японский+Английский',
'jap+eng(auto)': 'Японский+Английский(авто)',
'jap+rus': 'Японский+Русский',
'jap+rus(auto)': 'Японский+Русский(авто)',
'chi+eng': 'Китайский+Английский',
'chi+eng(auto)': 'Китайский+Английский(авто)',
'chi+rus': 'Китайский+Русский',
'chi+rus(auto)': 'Китайский+Русский(авто)',
'jap+eng+rus': 'Яп.+Англ.+Рус.',
'jap+eng+rus(auto)': 'Яп.+Англ.+Рус.(авто)',
'chi+eng+rus': 'Кит.+Англ.+Рус.',
'chi+eng+rus(auto)': 'Кит.+Англ.+Рус.(авто)',
'jap+chi+eng+rus': 'Яп.+Кит.+Англ.+Рус.',
'jap+chi+eng+rus(auto)': 'Яп.+Кит.+Англ.+Рус.(авто)',
'jap+chi+eng+rus+multi': 'Яп.+Кит.+Англ.+Рус.и др.',
'jap+chi+eng+rus(auto)+multi': 'Яп.+Кит.+Англ.+Рус.(авто)и др.',
};
const STORE_ICON_MAP = new Map([
['ci-en.dlsite.com', 'ci-en.png'],
['dl.getchu.com', 'getchu.png'],
['dmm.co.jp', 'fanza.png'],
['store.steampowered.com', 'steam.png'],
['gog.com', 'gogcom.png'],
['itch.io', 'itch_io.png'],
['nutaku.net', 'nutaku.png'],
['jastusa.com', 'jast-usa.png'],
['jaststore.com', 'jast-usa.png'],
['saikeystudios.com', 'saikey.png'],
['dlsite.com', 'dlsite.png'],
['getchu.com', 'getchu.png'],
['dmm.com', 'dmm.png'],
['fantia.jp', 'fantia.png'],
['boosty.to', 'boosty.png'],
['patreon.com', 'patreon.png'],
['subscribestar.com', 'subscribestar.png'],
['x.com', 'x.png'],
]);
const ICON_BASE = 'https://static.pornolab.net/icons_new/r/';
// ─── Настройки ─────────────────────────────────────────────────────────────
const SETTINGS_KEY = 'af_settings_v1';
const SOURCE_KEY = 'af_source_v1';
// Поля, общие для обоих источников (дублируются с префиксом stm_)
const _STM_MIRROR = {
fieldPoster: true,
fieldHieroglyphs: true, fieldTitleEng: true, fieldTitleRu: true,
fieldYear: true, fieldDate: true, fieldTags: true,
fieldDeveloper: true, fieldPlatform: true,
fieldLanguage: true, fieldVoice: true,
fieldSysReq: true, formatSysReqTemplate: true,
fieldLinks: true, fieldScreenshots: true,
};
const DEFAULT_SETTINGS = {
..._STM_MIRROR,
adaptTagEditor: true, addIconLinks: true,
...Object.fromEntries(EXTRA_TAGS_GROUPS.flatMap(g => g.tags.map(t => [t.key, false]))),
fieldCensorship: true, fieldReleaseType: true, fieldDescription: true,
enableSteamPoster: false, enableSteamTags: false, enableSteamScreenshots: false,
enableSteamSpy: false, enableGoogleTranslate: false, enableFastpicUpload: true,
...Object.fromEntries(Object.entries(_STM_MIRROR).map(([k, v]) => [`stm_${k}`, v])),
stm_fieldDescShort: true, stm_fieldDescLong: true, stm_enableGoogleTranslate: false,
};
function loadSettings() {
try {
const raw = GM_getValue(SETTINGS_KEY, null);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch { return { ...DEFAULT_SETTINGS }; }
}
function saveSettings(s) {
GM_setValue(SETTINGS_KEY, JSON.stringify(s));
}
let settings = loadSettings();
// ─── Toast-уведомления ─────────────────────────────────────────────────────
let toastContainer = null;
function ensureToastContainer() {
if (toastContainer) return;
toastContainer = document.createElement('div');
toastContainer.id = 'af-toast-container';
document.body.appendChild(toastContainer);
}
function toast(msg, type = 'info', duration = 3500) {
ensureToastContainer();
const el = document.createElement('div');
el.className = `af-toast af-toast-${type}`;
el.textContent = msg;
el.addEventListener('click', () => el.remove());
toastContainer.appendChild(el);
if (duration > 0) setTimeout(() => el.remove(), duration);
return el;
}
async function withToast(loadMsg, fn, { successMsg = '', failMsg = '', failDuration = 5000 } = {}) {
const t = toast(loadMsg, 'info', 0);
try {
const result = await fn();
t.remove();
if (successMsg) toast(successMsg, 'success');
return result;
} catch (err) {
t.remove();
if (failMsg) toast(`${failMsg}: ${err.message}`, 'warning', failDuration);
return null;
}
}
// ─── Вспомогательные функции ───────────────────────────────────────────────
function getHostname(url) {
try { return new URL(url).hostname; } catch { return null; }
}
function _matchStore(hostname) {
if (!hostname) return null;
for (const [d, icon] of STORE_ICON_MAP)
if (hostname === d || hostname.endsWith('.' + d)) return { domain: d, icon };
return null;
}
function getCanonicalDomain(url) {
return _matchStore(getHostname(url))?.domain ?? null;
}
function getStoreIcon(url) {
return _matchStore(getHostname(url))?.icon ?? null;
}
function urlToBBIcon(url, iconFile) {
if (iconFile) return `[url=${url}][img]${ICON_BASE}${iconFile}[/img][/url]`;
const domain = getCanonicalDomain(url) || getHostname(url) || url;
return `[url=${url}]${domain}[/url]`;
}
function setVal(el, value) {
if (!el) return;
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function setLangSelect(id, abrValue) {
const sel = getField(id);
if (!sel) return;
const idx = LANG_G_ABR.indexOf(abrValue);
if (idx >= 0) {
sel.selectedIndex = idx;
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
}
function getField(id, label = null) {
let el = document.getElementById(id);
if (!el && label) {
for (const td of document.querySelectorAll('td.rel-title')) {
if (td.textContent.includes(label)) {
el = td.nextElementSibling?.querySelector('input.rel-input, textarea.rel-input, select.rel-input');
if (el) break;
}
}
}
return el || null;
}
function getIconsField() {
const tagsField = getField(FIELDS.tags);
if (!tagsField) return null;
const container = tagsField.closest('td') || tagsField.parentElement;
if (!container) return null;
const hasEditor = Array.from(container.querySelectorAll('input[type="button"]'))
.some(b => b.value?.includes('Редактор'));
if (!hasEditor) return null;
const inputs = Array.from(container.querySelectorAll('input[type="text"], textarea'));
const tagIdx = inputs.indexOf(tagsField);
if (tagIdx >= 0 && tagIdx + 1 < inputs.length) return inputs[tagIdx + 1];
return null;
}
function setAnnotation(targetEl, id, html, before = false) {
if (!targetEl) return;
let ann = document.getElementById(id);
if (!ann) {
ann = document.createElement(before ? 'div' : 'span');
ann.id = id;
ann.style.cssText = before
? 'color:#cc0000;margin-bottom:4px;font-size:12px;'
: 'color:#cc0000;margin-left:8px;font-size:12px;';
targetEl.parentNode.insertBefore(ann, before ? targetEl : targetEl.nextSibling);
}
ann.innerHTML = html;
}
function fillIf(settingKey, fieldId, value, label = null) {
if (!settings[settingKey] || !value) return;
const f = getField(fieldId, label);
if (f) setVal(f, value);
}
// ─── FastPic — аннотации ──────────────────────────────────────────────────
const _FP_HREF = '<a href="https://new.fastpic.org/viaurl/" target="_blank" style="color:#345da4;font-weight:bold;">fastpic</a>';
const ANN_POSTER_MANUAL = `Перезалейте ссылку на ${_FP_HREF} вручную`;
const ANN_POSTER_COVER = `Перезалейте ссылку на ${_FP_HREF}, выбрав режим Постер (Cover)`;
const ANN_SCREENS = `Перезалейте все ссылки на ${_FP_HREF} и получите ссылки для вставки миниатюр 350px`;
const TAG_EDITOR_ANN = 'Скопируйте эти теги в <a href="https://static.pornolab.net/tag_editor/?conf=game" ' +
'target="_blank" style="color:#345da4;font-weight:bold;">редактор тегов</a> для проверки';
// ─── Парсинг системных требований ─────────────────────────────────────────
function parseSteamMinReqs(html) {
if (!html) return null;
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<strong>Minimum:<\/strong>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/OS \*:/g, 'OS:')
.replace(/\s*\b(or higher|or better|or equivalent)\b/gi, '')
.split('\n').map(l => l.trim()).filter(Boolean).join('\n');
}
function formatSysReqToTemplate(text) {
if (!text) return text;
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
const getVal = key => {
for (const line of lines)
if (line.toLowerCase().startsWith(key.toLowerCase() + ':'))
return line.slice(line.indexOf(':') + 1).trim();
return null;
};
const squishUnits = s => s.replace(/\s+(GHz|MHz|GB|MB)/gi, '$1');
const os = getVal('OS');
const processor = getVal('Processor');
const memory = getVal('Memory');
const graphics = getVal('Graphics');
const storage = getVal('Storage');
const parts = [];
if (os) parts.push(`OS: ${os}`);
if (processor && /intel|amd|ghz|mhz/i.test(processor)) {
const cpu = processor
.replace(/\b(single-core|quad[\s-]?core|dual-core)\b/gi, '')
.replace(/\s{2,}/g, ' ').trim();
parts.push(`CPU: ${squishUnits(cpu)}`);
}
if (graphics && /intel|nvidia|geforce|amd|radeon/i.test(graphics))
parts.push(`GPU: ${graphics}`);
if (memory && /gb|mb/i.test(memory)) {
const ram = memory.replace(/\bRAM\b/gi, '').replace(/\s{2,}/g, ' ').trim();
parts.push(`RAM: ${squishUnits(ram)}`);
}
if (graphics && /gb|mb/i.test(graphics)) {
const vram = graphics.replace(/\bVRAM\b/gi, '').replace(/\s{2,}/g, ' ').trim();
parts.push(`VRAM: ${squishUnits(vram)}`);
}
if (storage && /gb|mb/i.test(storage)) {
const hdd = storage.replace(/\bavailable\s+space\b/gi, '').replace(/\s{2,}/g, ' ').trim();
parts.push(`HDD: ${squishUnits(hdd)}`);
}
return parts.length > 0 ? parts.join(' | ') : text;
}
/** Параметрическая замена getSteamSysReq + дублирующего кода из fillFormSteam */
function parseSysReq(steamData, enabled, format) {
if (!enabled) return null;
const raw = parseSteamMinReqs(steamData?.pc_requirements?.minimum);
if (!raw) return null;
return format ? formatSysReqToTemplate(raw) : raw;
}
// ─── API-функции ───────────────────────────────────────────────────────────
function apiPost(endpoint, payload) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${API_BASE}/${endpoint}`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(payload),
timeout: 15000,
onload: r => {
if (r.status === 200) resolve(JSON.parse(r.responseText));
else reject(new Error(`VNDB HTTP ${r.status}: ${r.responseText.slice(0, 200)}`));
},
onerror: () => reject(new Error('VNDB: ошибка сети')),
ontimeout: () => reject(new Error('VNDB: таймаут запроса')),
});
});
}
function fetchVNData(id) {
return apiPost('vn', {
filters: ['id', '=', `v${id}`],
fields: 'title, released, description, olang, image { url }, titles { lang, main, title }, tags { name, lie }, languages, developers { name }, platforms, screenshots { url }',
results: 1,
}).then(r => r.results?.[0]);
}
function fetchReleases(id) {
return apiPost('release', {
filters: ['vn', '=', ['id', '=', `v${id}`]],
fields: 'uncensored, official, patch, voiced, platforms, languages { lang }, vns { id, rtype }, producers { developer, publisher, name }, extlinks { url }, released',
results: 100,
}).then(r => r.results || []).catch(() => []);
}
function fetchReleaseById(releaseId) {
return apiPost('release', {
filters: ['id', '=', `r${releaseId}`],
fields: 'title, uncensored, official, patch, voiced, platforms, vns { id, rtype }, producers { developer, publisher, name }, extlinks { url }, released, languages { lang, title }',
results: 1,
}).then(r => r.results?.[0] || null).catch(() => null);
}
function gmFetch(url, { throwOnError = false, timeout = 12000 } = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url, timeout,
onload: r => {
if (throwOnError && r.status >= 400) { reject(new Error(`HTTP ${r.status}`)); return; }
try { resolve(JSON.parse(r.responseText)); }
catch { throwOnError ? reject(new Error('Ошибка парсинга JSON')) : resolve(null); }
},
onerror: () => throwOnError ? reject(new Error('Ошибка сети')) : resolve(null),
ontimeout: () => throwOnError ? reject(new Error('Таймаут запроса')) : resolve(null),
});
});
}
function fetchSteamDetails(appId, locale = 'english') {
return gmFetch(`https://store.steampowered.com/api/appdetails?appids=${appId}&l=${locale}`)
.then(json => { const e = json?.[String(appId)]; return e?.success ? e.data : null; });
}
function fetchSteamSpy(appId) {
return gmFetch(`https://steamspy.com/api.php?request=appdetails&appid=${appId}`)
.then(json => json?.tags ?? null);
}
async function translateToRussian(text) {
if (!text || /[а-яА-ЯёЁ]/.test(text)) return text;
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=ru&dt=t&q=${encodeURIComponent(text)}`;
const data = await gmFetch(url, { timeout: 15000 });
if (!data?.[0]) throw new Error('Неверный формат ответа');
return data[0].map(i => i[0]).join('');
}
// ─── Steam-специфичные вспомогательные функции ────────────────────────────
function parseSteamSupportedLanguages(html) {
if (!html) return { langs: [], audioLangs: [] };
const noFootnote = html.replace(/<br>[\s\S]*/i, '');
const langs = [], audioLangs = [];
noFootnote.split(',').forEach(entry => {
const hasAudio = /<strong>\*<\/strong>/i.test(entry);
const clean = entry.replace(/<[^>]+>/g, '').replace(/\*/g, '').trim().toLowerCase();
if (clean) {
langs.push(clean);
if (hasAudio) audioLangs.push(clean);
}
});
return { langs, audioLangs };
}
function cleanSteamHtml(html) {
if (!html) return '';
return html
.replace(/<span[^>]*class=["'][^"']*bb_img_ctn[^"']*["'][^>]*>[\s\S]*?<\/span>/gi, '')
.replace(/<\/h2>/gi, '\n').replace(/<h2[^>]*>/gi, '\n')
.replace(/<\/p>/gi, '\n').replace(/<p[^>]*>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/'/g, "'").replace(/'/g, "'")
.replace(/ /g, ' ')
.replace(/\n{3,}/g, '\n\n').trim();
}
// ─── Маппинг языков ────────────────────────────────────────────────────────
/** Общая логика маппинга булевых флагов → строку lang_g_abr */
function _langsAbrFromFlags(rus, eng, jap, chi, multi) {
if (jap && chi && eng && rus) return multi ? 'jap+chi+eng+rus+multi' : 'jap+chi+eng+rus';
if (jap && eng && rus) return 'jap+eng+rus';
if (chi && eng && rus) return 'chi+eng+rus';
if (jap && rus) return 'jap+rus';
if (jap && eng) return 'jap+eng';
if (chi && rus) return 'chi+rus';
if (chi && eng) return 'chi+eng';
if (rus && eng) return multi ? 'rus+eng+multi' : 'rus+eng';
if (jap) return 'jap';
if (chi) return 'chi';
if (rus) return 'rus';
if (eng) return 'eng';
return '';
}
/** VNDB-коды языков (['ru', 'en', 'ja', 'zh-Hans', ...]) → lang_g_abr */
function langsToAbr(langArray) {
if (!langArray?.length) return '';
const lc = langArray.map(l => l.toLowerCase());
return _langsAbrFromFlags(
lc.some(l => l === 'ru'),
lc.some(l => l === 'en'),
lc.some(l => l === 'ja'),
lc.some(l => l === 'zh' || l.startsWith('zh-')),
lc.some(l => l !== 'ru' && l !== 'en' && l !== 'ja' && !l.startsWith('zh'))
);
}
/** Steam-имена языков (['russian', 'english', ...]) → lang_g_abr */
function steamLangsToAbr(langs) {
const hasChi = langs.includes('simplified chinese') || langs.includes('traditional chinese');
return _langsAbrFromFlags(
langs.includes('russian'),
langs.includes('english'),
langs.includes('japanese'),
hasChi,
langs.some(l => l !== 'russian' && l !== 'english' && l !== 'japanese' &&
l !== 'simplified chinese' && l !== 'traditional chinese')
);
}
/**
* Универсальный маппинг языка озвучки → lang_g_abr.
* Принимает строку VNDB-кода ('ja') ИЛИ массив Steam-имён (['japanese']).
*/
function audioLangToAbr(input) {
let jap = false, eng = false, rus = false, chi = false;
if (typeof input === 'string') {
const l = input.toLowerCase();
jap = l === 'ja'; eng = l === 'en'; rus = l === 'ru';
chi = l === 'zh' || l.startsWith('zh-');
} else if (Array.isArray(input)) {
jap = input.includes('japanese');
eng = input.includes('english');
rus = input.includes('russian');
chi = input.includes('simplified chinese') || input.includes('traditional chinese');
}
return _langsAbrFromFlags(rus, eng, jap, chi, false);
}
// ─── Общие хелперы заполнения полей ───────────────────────────────────────
/** Заполняет поля языка игры и озвучки */
function applyLanguages(langEnabled, langAbr, voiceEnabled, voiceAbr) {
if (langEnabled && langAbr) {
setLangSelect(FIELDS.langStory, langAbr);
setLangSelect(FIELDS.langInterface, langAbr);
}
if (voiceEnabled && voiceAbr) {
const sel = getField(FIELDS.langVoice);
if (sel) {
const label = VOICE_ABR_TO_LABEL[voiceAbr] || '';
if (label) { sel.value = label; sel.dispatchEvent(new Event('change', { bubbles: true })); }
}
}
}
/** Заполняет поле системных требований (или ставит аннотацию «Заполнить самим») */
function applySysReq(sysReq, enabled) {
if (!enabled) return;
const f = getField(FIELDS.sysReq, FIELD_LABELS.sysReq);
if (!f) return;
if (sysReq) {
document.getElementById('af-ann-sysreq')?.remove();
setVal(f, sysReq);
} else {
setAnnotation(f, 'af-ann-sysreq', '<b>Заполнить самим</b>', true);
}
}
/** Заполняет поле описания (+ ссылки под ним) */
function applyDescription(hasDesc, description, linksEnabled, linksText) {
const f = getField(FIELDS.description);
if (!f) return;
const suffix = linksEnabled && linksText ? '\n\n' + linksText : '';
if (hasDesc) setVal(f, '\n' + description + suffix);
else if (linksEnabled && linksText) setVal(f, '\n\n' + linksText);
}
// ─── FastPic: загрузка картинок по URL ────────────────────────────────────
async function uploadToFastpic(urls, onProgress = null) {
const BATCH_SIZE = 30;
const batches = [];
for (let i = 0; i < urls.length; i += BATCH_SIZE)
batches.push(urls.slice(i, i + BATCH_SIZE));
function uploadBatch(batchUrls) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://new.fastpic.org/upload_from_url',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': 'https://new.fastpic.org/viaurl/',
},
data: 'files=' + encodeURIComponent(batchUrls.join('\n')) + '&uploading=1',
timeout: 60000,
onload(r) {
if (r.status >= 400) { reject(new Error(`FastPic HTTP ${r.status}`)); return; }
const doc = new DOMParser().parseFromString(r.responseText, 'text/html');
const results = [];
doc.querySelectorAll('img[data-links]').forEach(img => {
try {
const links = JSON.parse(img.getAttribute('data-links').replace(/"/g, '"'));
if (links.big) results.push(links);
} catch (e) {
console.warn('[AutoFill] FastPic: ошибка парсинга data-links:', e);
}
});
resolve(results);
},
onerror() { reject(new Error('Сетевая ошибка при обращении к FastPic')); },
ontimeout(){ reject(new Error('Таймаут FastPic (60 с)')); },
});
});
}
const results = [];
for (const batch of batches) {
const batchResults = await uploadBatch(batch);
results.push(...batchResults);
onProgress?.(results.length, urls.length);
}
return results;
}
// ─── Маппинг платформы ─────────────────────────────────────────────────────
function mapPlatform(platformsArr) {
const s = new Set(platformsArr);
const w = s.has('win'), l = s.has('lin'), m = s.has('mac');
if (w && l && m) return 'Windows+Linux+MacOS';
if (w && l) return 'Windows+Linux';
if (w && m) return 'Windows+MacOS';
if (l && m) return 'Linux+MacOS';
if (w) return 'Windows';
if (l) return 'Linux';
if (m) return 'MacOS';
return '';
}
// ─── Подготовка данных ─────────────────────────────────────────────────────
function buildUniqueUrls(releases, priorityRelease) {
const domainBestLink = new Map();
if (priorityRelease) {
(priorityRelease.extlinks || []).forEach(({ url }) => {
if (!url) return;
const domain = getCanonicalDomain(url);
if (domain) domainBestLink.set(domain, { url, date: '9999-99-99' });
});
}
releases.filter(r => r.official && !r.patch).forEach(r => {
const date = (r.released && r.released !== 'TBA') ? r.released : '';
(r.extlinks || []).forEach(({ url }) => {
if (!url) return;
const domain = getCanonicalDomain(url);
if (!domain) return;
const cur = domainBestLink.get(domain);
if (!cur || date > cur.date) domainBestLink.set(domain, { url, date });
});
});
return [...domainBestLink.values()].map(v => v.url);
}
function resolveFormData(vnData, releases, priorityRelease, vnId) {
const vid = `v${vnId}`;
const vnTitles = vnData.titles || [];
const jaMain = vnTitles.find(t => t.lang === 'ja' && t.main);
const enTitle = vnTitles.find(t => t.lang === 'en');
const ruTitle = vnTitles.find(t => t.lang === 'ru');
const relLangs = priorityRelease?.languages || [];
const titleJa = priorityRelease
? (relLangs.find(l => l.lang === 'ja')?.title ?? jaMain?.title ?? null)
: (jaMain?.title ?? null);
const titleEn = priorityRelease
? (relLangs.find(l => l.lang === 'en')?.title ?? priorityRelease.title ?? enTitle?.title ?? vnData.title)
: (enTitle?.title ?? vnData.title);
const titleRu = priorityRelease
? (relLangs.find(l => l.lang === 'ru')?.title ?? ruTitle?.title ?? null)
: (ruTitle?.title ?? null);
const hasUncensored = priorityRelease
? priorityRelease.uncensored !== false
: releases.some(r => r.uncensored !== false);
const hasNonTrial = priorityRelease
? (priorityRelease.vns || []).some(v => v.id === vid && v.rtype !== 'trial')
: releases.some(r => (r.vns || []).some(v => v.id === vid && v.rtype !== 'trial'));
const platformsArr = priorityRelease
? [...new Set(priorityRelease.platforms || [])]
: [...new Set(releases.filter(r => r.official).flatMap(r => r.platforms || []))];
let creators;
if (priorityRelease) {
const prPublishers = (priorityRelease.producers || []).filter(p => p.publisher).map(p => p.name);
const allDevelopers = [...new Set(
releases.filter(r => r.official).flatMap(r => (r.producers || []).filter(p => p.developer).map(p => p.name))
)];
creators = (allDevelopers.length > 0 || prPublishers.length > 0)
? [...new Set([...allDevelopers, ...prPublishers])]
: [...new Set([
...(vnData.developers || []).map(d => d.name),
...releases.filter(r => r.official).flatMap(r => (r.producers || []).map(p => p.name)),
])];
} else {
creators = [...new Set([
...(vnData.developers || []).map(d => d.name),
...releases.filter(r => r.official).flatMap(r => (r.producers || []).map(p => p.name)),
])];
}
const langs = priorityRelease
? (priorityRelease.languages || []).map(l => l.lang)
: (vnData.languages?.length
? vnData.languages
: [...new Set(
releases
.filter(r => r.official)
.flatMap(r => (r.languages || []).map(l => l.lang))
)]);
const hasVoiced = priorityRelease
? priorityRelease.voiced >= 3
: releases.some(r => r.official && (r.vns || []).some(v => v.id === vid) && r.voiced >= 3);
const voiceLang = hasVoiced ? (vnData.olang || '') : '';
const released = (priorityRelease?.released && priorityRelease.released !== 'TBA')
? priorityRelease.released
: (vnData.released || '');
return { titleJa, titleEn, titleRu, hasUncensored, hasNonTrial, platformsArr, creators, langs, voiceLang, released };
}
/**
* Строит массив тегов из VNDB тегов, платформ и опциональных данных Steam/SteamSpy.
*/
function buildAllTags({ vnTags = [], platforms = [], steamData = null, steamSpyTags = null } = {}) {
const seen = new Set(), allTags = [];
const addTag = t => {
t = t.trim(); if (!t) return;
const k = t.toLowerCase();
if (!seen.has(k)) { seen.add(k); allTags.push(t); }
};
vnTags.filter(t => !t.lie).forEach(t => addTag(t.name));
const PLAT_WL = new Set(['win', 'lin', 'mac']);
platforms.filter(p => PLAT_WL.has(p)).forEach(addTag);
if (steamData) {
const STEAM_PLAT = { windows: 'win', mac: 'mac', linux: 'lin' };
Object.entries(steamData.platforms || {}).filter(([, v]) => v)
.forEach(([k]) => { const m = STEAM_PLAT[k]; if (m) addTag(m); });
(steamData.genres || []).forEach(g => g.description && addTag(g.description));
}
if (steamSpyTags) Object.keys(steamSpyTags).forEach(addTag);
return allTags;
}
function buildLinksText(releases, priorityRelease, uniqueUrls, vid) {
if (!settings.fieldLinks) return '';
let sourceUrls = uniqueUrls;
if (priorityRelease) {
const releaseUrls = (priorityRelease.extlinks || [])
.map(e => e.url)
.filter(url => url && getCanonicalDomain(url));
if (releaseUrls.length > 0) sourceUrls = releaseUrls;
}
return [...sourceUrls, `https://vndb.org/${vid}`].map(url => {
const h = getHostname(url);
const icon = h === 'vndb.org' ? 'vndb.png' : getStoreIcon(url);
return urlToBBIcon(url, icon);
}).join(' ');
}
// ─── Заполнение отдельных полей ────────────────────────────────────────────
async function fillPosterField(posterUrl) {
const fPoster = getField(FIELDS.poster);
if (!fPoster || !posterUrl) return;
setVal(fPoster, posterUrl);
if (!settings.enableFastpicUpload) {
setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_COVER, true);
return;
}
const tPoster = toast('⏳ Загрузка постера на FastPic...', 'info', 0);
try {
const result = await uploadToFastpic([posterUrl]);
tPoster.remove();
if (result.length > 0) {
setVal(fPoster, result[0].big);
toast('✅ Постер загружен на FastPic', 'success');
} else {
toast('⚠️ FastPic не вернул ссылку для постера. Проверьте авторизацию.', 'warning', 6000);
setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_MANUAL, true);
}
} catch (err) {
tPoster.remove();
toast(`⚠️ Ошибка FastPic (постер): ${err.message}`, 'warning', 6000);
setAnnotation(fPoster, 'af-ann-poster', ANN_POSTER_COVER, true);
}
}
async function fillTagsField(allTags) {
const f = getField(FIELDS.tags);
if (!f || allTags.length === 0) return;
if (!settings.adaptTagEditor) {
setVal(f, allTags.join(', '));
setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
return;
}
const tAdapt = toast('⏳ Адаптация тегов под редактор pornolab...', 'info', 0);
try {
const mergedTags = [...allTags];
const mergedLower = new Set(mergedTags.map(t => t.toLowerCase()));
EXTRA_TAGS_GROUPS.forEach(group => {
group.tags.forEach(t => {
if (settings[t.key] && !mergedLower.has(t.label.toLowerCase())) {
mergedTags.push(t.label);
mergedLower.add(t.label.toLowerCase());
}
});
});
const gameJson = await gmFetch('https://static.pornolab.net/tag_editor/game.json', { throwOnError: true, timeout: 10000 });
const tagIndex = new Map();
const allCatTags = (gameJson.categories || []).flatMap(c => c.tags || []);
allCatTags.forEach(entry => {
tagIndex.set(entry.name.toLowerCase(), entry);
(entry.knownAs || []).forEach(alias => tagIndex.set(alias.toLowerCase(), entry));
});
const validTags = [], validIcons = [], seenValid = new Set();
mergedTags.forEach(tag => {
const entry = tagIndex.get(tag.toLowerCase());
if (entry && !seenValid.has(entry.name.toLowerCase())) {
seenValid.add(entry.name.toLowerCase());
validTags.push(entry.name);
if (settings.addIconLinks && entry.alternative)
validIcons.push(entry.alternative);
}
});
setVal(f, validTags.join(', '));
setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
if (settings.addIconLinks && validIcons.length > 0) {
const fIcons = getIconsField();
if (fIcons) setVal(fIcons, validIcons.join(''));
}
tAdapt.remove();
toast(
`✅ Теги адаптированы: ${validTags.length} из ${mergedTags.length}` +
(mergedTags.length - validTags.length > 0
? ` (${mergedTags.length - validTags.length} не найдено в game.json)` : ''),
'success'
);
} catch (err) {
tAdapt.remove();
toast(`❌ Ошибка адаптации тегов: ${err.message}`, 'error', 6000);
console.error('[AutoFill] Ошибка адаптации тегов:', err);
setVal(f, allTags.join(', '));
setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true);
}
}
async function fillScreenshotsField(allScreens) {
const f = getField(FIELDS.screenshots);
if (!f || allScreens.length === 0) return;
setVal(f, allScreens.join('\n'));
if (!settings.enableFastpicUpload) {
setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
return;
}
const tScreens = toast(`⏳ Загрузка ${allScreens.length} скриншотов на FastPic...`, 'info', 0);
try {
const result = await uploadToFastpic(allScreens, (done, total) => {
tScreens.textContent = `⏳ FastPic: обработано ${done} из ${total} скриншотов...`;
});
tScreens.remove();
if (result.length > 0) {
setVal(f, result.map(links => `[URL=${links.view}][IMG]${links.thumb}[/IMG][/URL]`).join(' '));
toast(`✅ Скриншоты загружены на FastPic: ${result.length} из ${allScreens.length}`, 'success');
} else {
toast('⚠️ FastPic не вернул ссылки для скриншотов. Проверьте авторизацию.', 'warning', 6000);
setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
}
} catch (err) {
tScreens.remove();
toast(`⚠️ Ошибка FastPic (скриншоты): ${err.message}`, 'warning', 6000);
setAnnotation(f, 'af-ann-screenshots', ANN_SCREENS, true);
}
}
// ─── IGDB / Обложки ────────────────────────────────────────────────────────
// Учётные данные Twitch для IGDB API
// При необходимости замените на собственные: https://dev.twitch.tv/console
const TWITCH_CLIENT_ID = 'f4nguxprv5p5p94uouph5m8u3qi3a9';
const TWITCH_CLIENT_SECRET = 'c5yg7qvwmmn5y5qadmmrrpihievuu7';
const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token';
const IGDB_BASE = 'https://api.igdb.com/v4';
function igdbGmRequest({ method = 'GET', url, headers = {}, data = null, responseType = 'json' }) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method, url, headers, data, responseType,
timeout: 30000,
onload(res) {
const body = responseType === 'json' ? res.response : res.responseText;
if (res.status >= 200 && res.status < 300) resolve(body);
else reject(new Error(`HTTP ${res.status} для ${url}`));
},
onerror: () => reject(new Error(`Ошибка запроса: ${url}`)),
ontimeout: () => reject(new Error(`Таймаут: ${url}`)),
});
});
}
async function getTwitchToken() {
const params = new URLSearchParams({
client_id: TWITCH_CLIENT_ID,
client_secret: TWITCH_CLIENT_SECRET,
grant_type: 'client_credentials',
});
const data = await igdbGmRequest({
method: 'POST',
url: TWITCH_TOKEN_URL,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
data: params.toString(),
responseType: 'json',
});
if (!data?.access_token) throw new Error('Twitch: нет access_token');
return data.access_token;
}
function igdbPost(endpoint, token, body) {
return igdbGmRequest({
method: 'POST',
url: `${IGDB_BASE}/${endpoint}`,
headers: {
'Client-ID': TWITCH_CLIENT_ID,
'Authorization': `Bearer ${token}`,
'Content-Type': 'text/plain',
},
data: body,
responseType: 'json',
});
}
function buildIgdbImageUrl(imageId, size = 't_1080p') {
return `https://images.igdb.com/igdb/image/upload/${size}/${imageId}.jpg`;
}
async function fetchIgdbGameByAppId(token, appId) {
const rows = await igdbPost(
'external_games', token,
`fields id,game,uid,external_game_source; where external_game_source = 1 & uid = "${appId}"; limit 1;`
);
if (!rows?.length) return null;
const gameId = rows[0].game;
if (!gameId) return null;
const games = await igdbPost(
'games', token,
`fields id,name,cover,artworks; where id = ${gameId}; limit 1;`
);
return games?.[0] || null;
}
async function fetchIgdbCovers(token, gameId) {
return igdbPost(
'covers', token,
`fields id,game,image_id,width,height; where game = ${gameId}; limit 500;`
).catch(() => []);
}
async function fetchIgdbArtworks(token, gameId) {
return igdbPost(
'artworks', token,
`fields id,game,image_id,width,height; where game = ${gameId}; limit 500;`
).catch(() => []);
}
/**
* Собирает список обложек для выбора.
* @param {string} appId - Steam App ID
* @param {object|null} steamData - уже полученные данные Steam API (или null)
* @returns {Promise<Array<{label:string, url:string, kind:string}>>}
*/
async function buildCoverUrls(appId, steamData) {
const urls = [], seen = new Set();
// Steam header image из уже полученных данных
if (steamData?.header_image) {
const url = steamData.header_image;
seen.add(url);
urls.push({ label: 'Steam: header', url, kind: 'steam' });
}
// IGDB обложки и артворки
try {
const token = await getTwitchToken();
const game = await fetchIgdbGameByAppId(token, appId);
if (game) {
const [covers, artworks] = await Promise.all([
fetchIgdbCovers(token, game.id),
fetchIgdbArtworks(token, game.id),
]);
for (const row of [...(covers || []), ...(artworks || [])]) {
if (!row?.image_id) continue;
const url = buildIgdbImageUrl(row.image_id, 't_1080p');
if (seen.has(url)) continue;
seen.add(url);
const isArtwork = 'artwork_type' in row;
urls.push({
label: isArtwork ? `IGDB artwork #${row.id}` : `IGDB cover #${row.id}`,
url,
kind: isArtwork ? 'artwork' : 'cover',
});
}
}
} catch (err) {
console.warn('[AutoFill] IGDB недоступен:', err.message);
}
return urls;
}
// ─── Выбор постера ─────────────────────────────────────────────────────────
/**
* Показывает панель выбора постера.
* @param {Array<{label:string, url:string}>} coverUrls
* @returns {Promise<string|null>} выбранная ссылка или null
*/
function showCoverSelector(coverUrls) {
return new Promise(resolve => {
document.getElementById('af-cover-panel')?.remove();
let selectedUrl = coverUrls[0]?.url || null;
const panel = document.createElement('div');
panel.id = 'af-cover-panel';
// Заголовок
const header = document.createElement('div');
header.id = 'af-cover-header';
header.innerHTML =
'<span>Выберите постер (показаны миниатюры, но будет взят исходник)</span>' +
'<button id="af-cover-close" title="Закрыть">×</button>';
// Тело со скроллом
const body = document.createElement('div');
body.id = 'af-cover-body';
if (coverUrls.length === 0) {
body.innerHTML = '<div style="color:#888;padding:8px;">Обложки не найдены.</div>';
} else {
coverUrls.forEach((item, i) => {
const div = document.createElement('div');
div.className = 'af-cover-item';
const safeLabel = item.label.replace(/</g, '<').replace(/>/g, '>');
const safeUrl = item.url.replace(/"/g, '"');
div.innerHTML = `
<label class="af-cover-label">
<input type="radio" name="af-cover-pick" value="${safeUrl}" ${i === 0 ? 'checked' : ''}>
<span>${safeLabel}</span>
</label>
<img class="af-cover-thumb" src="${safeUrl}" alt="${safeLabel}" loading="lazy">`;
body.appendChild(div);
});
body.querySelectorAll('input[type="radio"]').forEach(r => {
r.addEventListener('change', () => { selectedUrl = r.value; });
});
}
// Кнопки (прилеплены к низу)
const footer = document.createElement('div');
footer.id = 'af-cover-footer';
footer.innerHTML =
'<button id="af-cover-select" class="af-cover-btn-primary">Выбрал</button>' +
'<button id="af-cover-cancel" class="af-cover-btn-secondary">Закрыть</button>';
panel.appendChild(header);
panel.appendChild(body);
panel.appendChild(footer);
document.body.appendChild(panel);
const done = url => { panel.remove(); resolve(url); };
panel.querySelector('#af-cover-close').addEventListener('click', () => done(null));
panel.querySelector('#af-cover-cancel').addEventListener('click', () => done(null));
panel.querySelector('#af-cover-select').addEventListener('click', () => done(selectedUrl));
// Перетаскивание
let drag = null;
header.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
drag = { x: e.clientX, y: e.clientY };
const onMove = e => {
panel.style.left = (panel.offsetLeft - (drag.x - e.clientX)) + 'px';
panel.style.top = (panel.offsetTop - (drag.y - e.clientY)) + 'px';
drag = { x: e.clientX, y: e.clientY };
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
}
// ─── Заполнение формы по VNDB-ссылке ──────────────────────────────────────
async function fillForm(vndbUrl) {
const matchVN = vndbUrl.match(/\/v(\d+)/);
const matchRel = vndbUrl.match(/\/r(\d+)/);
if (!matchVN && !matchRel) {
toast('Неверный формат ссылки VNDB. Пример: https://vndb.org/v97 или https://vndb.org/r64727', 'error');
return;
}
const tVndb = toast('⏳ Запрос к VNDB API...', 'info', 0);
let vnData, releases, priorityRelease = null, vnId;
try {
if (matchRel) {
priorityRelease = await fetchReleaseById(matchRel[1]);
if (!priorityRelease) { tVndb.remove(); toast('❌ Релиз не найден в VNDB', 'error'); return; }
const vnIdFull = priorityRelease.vns?.[0]?.id;
if (!vnIdFull) { tVndb.remove(); toast('❌ Не удалось определить VN по данному релизу', 'error'); return; }
vnId = vnIdFull.replace(/^v/, '');
} else {
vnId = matchVN[1];
}
[vnData, releases] = await Promise.all([fetchVNData(vnId), fetchReleases(vnId)]);
tVndb.remove();
toast('✅ Данные VNDB получены', 'success');
} catch (err) {
tVndb.remove();
toast(`❌ Ошибка VNDB: ${err.message}`, 'error', 6000);
console.error('[AutoFill] Ошибка VNDB:', err);
return;
}
if (!vnData) { toast('❌ VNDB не вернул данные для этого ID', 'error'); return; }
const vid = `v${vnId}`;
const uniqueUrls = buildUniqueUrls(releases, priorityRelease);
const steamUrl = uniqueUrls.find(u => getHostname(u) === 'store.steampowered.com');
const steamAppId = steamUrl ? (steamUrl.match(/\/app\/(\d+)/)?.[1] ?? null) : null;
let steamData = null, steamSpyTags = null;
const needSteam = steamAppId && (
settings.enableSteamPoster || settings.enableSteamTags ||
settings.enableSteamScreenshots || settings.fieldSysReq
);
if (needSteam)
steamData = await withToast('⏳ Запрос к Steam API...', () => fetchSteamDetails(steamAppId),
{ successMsg: '✅ Данные Steam получены', failMsg: '⚠️ Steam API недоступен', failDuration: 5000 });
if (steamAppId && settings.enableSteamTags)
steamSpyTags = await withToast('⏳ Запрос к SteamSpy API...', () => fetchSteamSpy(steamAppId),
{ successMsg: '✅ Данные SteamSpy получены', failMsg: '⚠️ SteamSpy недоступен', failDuration: 5000 });
const { titleJa, titleEn, titleRu, hasUncensored, hasNonTrial,
platformsArr, creators, langs, voiceLang, released } =
resolveFormData(vnData, releases, priorityRelease, vnId);
// Если VNDB не дал разработчиков/издателей — пробуем Steam как запасной источник
if (creators.length === 0 && steamAppId && !steamData) {
steamData = await withToast(
'⏳ Запрос к Steam API (разработчик)...',
() => fetchSteamDetails(steamAppId),
{ successMsg: '✅ Данные Steam получены', failMsg: '⚠️ Steam API недоступен', failDuration: 5000 }
);
}
const developerStr = creators.length > 0
? creators.join(' / ')
: [...new Set([...(steamData?.developers || []), ...(steamData?.publishers || [])])].join(' / ');
const platformVal = mapPlatform(platformsArr);
const allTags = buildAllTags({
vnTags: vnData.tags || [],
platforms: platformsArr,
steamData: settings.enableSteamTags ? steamData : null,
steamSpyTags: settings.enableSteamTags ? steamSpyTags : null,
});
const sysReq = parseSysReq(steamData, settings.fieldSysReq, settings.formatSysReqTemplate);
const linksText = buildLinksText(releases, priorityRelease, uniqueUrls, vid);
const allScreens = [
...(vnData.screenshots || []).map(s => s.url),
...(settings.enableSteamScreenshots && steamData
? (steamData.screenshots || []).map(s => s.path_full).filter(Boolean) : []),
];
const vnPosterUrl = vnData.image?.url || '';
let description = '';
if (settings.fieldDescription) {
const rawDesc = (vnData.description || '')
.replace(/\[(?:[^\[\]]|\[[^\[\]]*\])*\]\s*$/, '').trimEnd();
if (settings.enableGoogleTranslate && rawDesc) {
description = await withToast(
'⏳ Перевод описания через Google Translate...',
() => translateToRussian(rawDesc),
{ successMsg: '✅ Перевод получен', failMsg: '⚠️ Ошибка перевода', failDuration: 4000 }
) ?? rawDesc;
} else {
description = rawDesc;
}
}
console.log('[AutoFill] Поля для заполнения:', {
приоритетРелиз: priorityRelease?.id || null,
постер: settings.enableSteamPoster && steamAppId ? '(выбор из IGDB/Steam)' : vnPosterUrl,
иероглифы: titleJa || '',
titleEng: titleEn || '', titleRu: titleRu || '',
год: released.split('-')[0] || '', дата: released.replace(/-/g, '/') || '',
теги: allTags, цензура: hasUncensored ? 'Нет' : 'Есть',
разработчик: creators.join(' / '), платформа: platformVal,
типИздания: hasNonTrial ? 'Релиз' : (releases.length ? 'Демо-версия' : ''),
языки: langs, озвучка: voiceLang, sysReq,
описание: description, ссылки: linksText, скриншоты: allScreens,
});
// Постер
if (settings.fieldPoster) {
if (settings.enableSteamPoster && steamAppId) {
const tCovers = toast('⏳ Загрузка обложек из Steam/IGDB...', 'info', 0);
const coverUrls = await buildCoverUrls(steamAppId, steamData).catch(() => []);
tCovers.remove();
if (coverUrls.length > 0) {
const chosen = await showCoverSelector(coverUrls);
await fillPosterField(chosen || vnPosterUrl);
} else {
toast('⚠️ Обложки не найдены, используется постер VNDB', 'warning', 4000);
await fillPosterField(vnPosterUrl);
}
} else {
await fillPosterField(vnPosterUrl);
}
}
fillIf('fieldHieroglyphs', FIELDS.hieroglyphs, titleJa, FIELD_LABELS.hieroglyphs);
fillIf('fieldTitleEng', FIELDS.titleEng, titleEn, FIELD_LABELS.titleEng);
fillIf('fieldTitleRu', FIELDS.titleRu, titleRu, FIELD_LABELS.titleRu);
fillIf('fieldYear', FIELDS.year, released.split('-')[0]);
fillIf('fieldDate', FIELDS.date, released.replace(/-/g, '/'));
fillIf('fieldCensorship', FIELDS.cens, hasUncensored ? 'Нет' : 'Есть');
fillIf('fieldDeveloper', FIELDS.developer, developerStr, FIELD_LABELS.developer);
fillIf('fieldPlatform', FIELDS.platform, platformVal);
if (settings.fieldTags) await fillTagsField(allTags);
if (settings.fieldReleaseType) {
const f = getField(FIELDS.releaseType);
if (f) setVal(f, hasNonTrial ? '' : (releases.length ? 'Демо-версия' : ''));
}
const fMed = getField(FIELDS.medicine);
if (fMed) setAnnotation(fMed, 'af-ann-medicine', 'Заполнить самим');
const fVer = getField(FIELDS.version, FIELD_LABELS.version);
if (fVer) setAnnotation(fVer, 'af-ann-version', 'Заполнить самим');
applyLanguages(settings.fieldLanguage, langsToAbr(langs), settings.fieldVoice, audioLangToAbr(voiceLang));
applySysReq(sysReq, settings.fieldSysReq);
applyDescription(settings.fieldDescription, description, settings.fieldLinks, linksText);
if (settings.fieldScreenshots) await fillScreenshotsField(allScreens);
toast('✅ Форма заполнена!', 'success', 4000);
}
// ─── Заполнение формы по Steam-ссылке ─────────────────────────────────────
async function fillFormSteam(steamUrl) {
const matchApp = steamUrl.match(/\/app\/(\d+)/);
if (!matchApp) {
toast('Неверный формат ссылки Steam. Пример: https://store.steampowered.com/app/3014080', 'error');
return;
}
const appId = matchApp[1];
const tSteam = toast('⏳ Запрос к Steam API...', 'info', 0);
let steamData;
try {
steamData = await fetchSteamDetails(appId, 'english');
tSteam.remove();
if (!steamData) { toast('❌ Steam не вернул данные для этого App ID', 'error'); return; }
toast('✅ Данные Steam получены', 'success');
} catch (err) {
tSteam.remove();
toast(`❌ Ошибка Steam API: ${err.message}`, 'error', 6000);
return;
}
let titleEng = null, titleRu = null, titleHieroglyphs = null;
if (settings.stm_fieldTitleEng) {
const name = steamData.name || '';
if (!/[\u3040-\u30ff\u4e00-\u9fff\uac00-\ud7af\u0400-\u04ff]/.test(name))
titleEng = name;
}
if (settings.stm_fieldTitleRu) {
const tRu = toast('⏳ Steam API (Russian)...', 'info', 0);
const ruData = await fetchSteamDetails(appId, 'russian');
tRu.remove();
const name = ruData?.name || '';
if (/[а-яА-ЯёЁ]/.test(name)) titleRu = name;
}
if (settings.stm_fieldHieroglyphs) {
const locales = [
{ locale: 'japanese', test: /[\u3040-\u30ff\u4e00-\u9fff]/ },
{ locale: 'schinese', test: /[\u4e00-\u9fff]/ },
{ locale: 'koreana', test: /[\uac00-\ud7af]/ },
];
for (const { locale, test } of locales) {
const tLoc = toast(`⏳ Steam API (${locale})...`, 'info', 0);
const locData = await fetchSteamDetails(appId, locale);
tLoc.remove();
const name = locData?.name || '';
if (test.test(name)) { titleHieroglyphs = name; break; }
}
}
let steamSpyTags = null;
if (settings.stm_fieldTags)
steamSpyTags = await withToast(
'⏳ Запрос к SteamSpy API...',
() => fetchSteamSpy(appId),
{ successMsg: '✅ Данные SteamSpy получены', failMsg: '⚠️ SteamSpy недоступен', failDuration: 5000 }
);
let releaseYear = '', releaseDate = '';
const relDateStr = steamData.release_date?.date || '';
if (relDateStr && !steamData.release_date?.coming_soon) {
try {
const d = new Date(relDateStr);
if (!isNaN(d.getTime())) {
releaseYear = String(d.getFullYear());
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
releaseDate = `${releaseYear}/${mm}/${dd}`;
}
} catch {}
}
const STEAM_PLAT_MAP = { windows: 'win', mac: 'mac', linux: 'lin' };
const platformsArr = Object.entries(steamData.platforms || {})
.filter(([, v]) => v).map(([k]) => STEAM_PLAT_MAP[k] || k);
const platformVal = mapPlatform(platformsArr);
const developers = steamData.developers || [];
const publishers = steamData.publishers || [];
const developerStr = [...new Set([...developers, ...publishers])].join(' / ');
const { langs: steamLangs, audioLangs: steamAudioLangs } =
parseSteamSupportedLanguages(steamData.supported_languages || '');
const sysReq = parseSysReq(steamData, settings.stm_fieldSysReq, settings.stm_formatSysReqTemplate);
let description = '';
if (settings.stm_fieldDescShort || settings.stm_fieldDescLong) {
const parts = [];
if (settings.stm_fieldDescShort && steamData.short_description)
parts.push(steamData.short_description);
if (settings.stm_fieldDescLong && steamData.about_the_game)
parts.push(cleanSteamHtml(steamData.about_the_game));
description = parts.join('\n\n');
if (settings.stm_enableGoogleTranslate && description)
description = await withToast(
'⏳ Перевод описания через Google Translate...',
() => translateToRussian(description),
{ successMsg: '✅ Перевод получен', failMsg: '⚠️ Ошибка перевода', failDuration: 4000 }
) ?? description;
}
const allTags = settings.stm_fieldTags
? buildAllTags({ platforms: platformsArr, steamData, steamSpyTags })
: [];
const linksText = settings.stm_fieldLinks
? urlToBBIcon(`https://store.steampowered.com/app/${appId}`, 'steam.png')
: '';
const allScreens = settings.stm_fieldScreenshots
? (steamData.screenshots || []).map(s => s.path_full).filter(Boolean)
: [];
console.log('[AutoFill Steam] Поля для заполнения:', {
appId,
постер: settings.stm_fieldPoster ? '(выбор из IGDB/Steam)' : '',
titleHieroglyphs, titleEng, titleRu,
год: releaseYear, дата: releaseDate, платформа: platformVal,
разработчик: developerStr, теги: allTags,
langAbr: steamLangsToAbr(steamLangs), voiceAbr: audioLangToAbr(steamAudioLangs),
sysReq, описание: description, ссылки: linksText, скриншоты: allScreens,
});
// Постер — показываем выбор обложки
if (settings.stm_fieldPoster) {
const tCovers = toast('⏳ Загрузка обложек из Steam/IGDB...', 'info', 0);
const coverUrls = await buildCoverUrls(appId, steamData).catch(() => []);
tCovers.remove();
if (coverUrls.length > 0) {
const chosen = await showCoverSelector(coverUrls);
if (chosen) await fillPosterField(chosen);
} else {
toast('⚠️ Обложки не найдены', 'warning', 4000);
}
}
fillIf('stm_fieldHieroglyphs', FIELDS.hieroglyphs, titleHieroglyphs, FIELD_LABELS.hieroglyphs);
fillIf('stm_fieldTitleEng', FIELDS.titleEng, titleEng, FIELD_LABELS.titleEng);
fillIf('stm_fieldTitleRu', FIELDS.titleRu, titleRu, FIELD_LABELS.titleRu);
fillIf('stm_fieldYear', FIELDS.year, releaseYear);
fillIf('stm_fieldDate', FIELDS.date, releaseDate);
fillIf('stm_fieldDeveloper', FIELDS.developer, developerStr, FIELD_LABELS.developer);
fillIf('stm_fieldPlatform', FIELDS.platform, platformVal);
if (settings.stm_fieldTags && allTags.length > 0) {
const f = getField(FIELDS.tags);
if (f) { setVal(f, allTags.join(', ')); setAnnotation(f, 'af-ann-tags', TAG_EDITOR_ANN, true); }
}
applyLanguages(settings.stm_fieldLanguage, steamLangsToAbr(steamLangs),
settings.stm_fieldVoice, audioLangToAbr(steamAudioLangs));
applySysReq(sysReq, settings.stm_fieldSysReq);
applyDescription(settings.stm_fieldDescShort || settings.stm_fieldDescLong,
description, settings.stm_fieldLinks, linksText);
if (settings.stm_fieldScreenshots) await fillScreenshotsField(allScreens);
// Аннотации «Заполнить самим»
const _fCens = getField(FIELDS.cens);
setAnnotation(_fCens?.nextElementSibling ?? _fCens, 'af-ann-cens', 'Заполнить самим');
setAnnotation(getField(FIELDS.releaseType), 'af-ann-reltype', 'Заполнить самим');
setAnnotation(getField(FIELDS.medicine), 'af-ann-medicine', 'Заполнить самим');
setAnnotation(getField(FIELDS.version, FIELD_LABELS.version), 'af-ann-version', 'Заполнить самим');
const _fLang = getField(FIELDS.langStory);
const _tdLang = _fLang?.closest('td') ?? _fLang?.parentElement;
setAnnotation(_tdLang?.firstChild ?? _fLang, 'af-ann-lang-warn','Проверьте эту информацию', true);
toast('✅ Форма заполнена!', 'success', 4000);
}
// ─── Панель настроек ───────────────────────────────────────────────────────
function buildExtraTagsHTML() {
return EXTRA_TAGS_GROUPS.map(group => {
const half = Math.ceil(group.tags.length / 2);
const renderCol = col => col.map(t => {
const checked = settings[t.key] ? 'checked' : '';
return `<label class="af-sp-label" style="display:block;">
<input type="checkbox" data-key="${t.key}" ${checked}> ${t.label}
</label>`;
}).join('');
return `
<div style="margin-top:5px;font-size:11px;color:#777;font-style:italic;">${group.title}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 8px;margin-left:4px;">
<div>${renderCol(group.tags.slice(0, half))}</div>
<div>${renderCol(group.tags.slice(half))}</div>
</div>`;
}).join('');
}
function buildSettingsHTML(source) {
const isSteam = source === 'steam';
const P = isSteam ? 'stm_' : '';
const cb = (suffix, label, extraStyle = '') => {
const key = P + suffix;
const checked = settings[key] ? 'checked' : '';
return `<label class="af-sp-label"${extraStyle ? ` style="${extraStyle}"` : ''}>
<input type="checkbox" data-key="${key}" ${checked}> ${label}
</label>`;
};
const sysReqFmtId = `af-sp-${isSteam ? 'stm-' : ''}sysreq-fmt`;
const showSysReq = settings[P + 'fieldSysReq'];
const showDesc = isSteam
? (settings.stm_fieldDescShort || settings.stm_fieldDescLong)
: settings.fieldDescription;
return `
<div class="af-sp-section">
<div class="af-sp-section-title">Поля ${isSteam ? 'Steam' : 'VNDB'}</div>
${cb('fieldPoster', 'Постер')}
${cb('fieldHieroglyphs', 'Оригинальное название (иероглифы)')}
${cb('fieldTitleEng', 'Оригинальное название (английское)')}
${cb('fieldTitleRu', 'Название на русском')}
${cb('fieldYear', 'Год выпуска')}
${cb('fieldDate', 'Дата выпуска')}
${cb('fieldTags', 'Теги')}
${!isSteam ? `
<div id="af-sp-tags-options" style="display:${settings.fieldTags ? 'block' : 'none'};margin-left:18px;">
${cb('adaptTagEditor', 'Адаптировать под редактор тегов pornolab')}
<div id="af-sp-adapt-options" style="display:${settings.fieldTags && settings.adaptTagEditor ? 'block' : 'none'};margin-left:18px;">
${cb('addIconLinks', 'Добавлять ссылки на иконки')}
<div style="margin-top:6px;font-size:12px;font-weight:bold;">Дописать теги:</div>
${buildExtraTagsHTML()}
</div>
</div>
${cb('fieldCensorship', 'Цензура')}` : ''}
${cb('fieldDeveloper', 'Разработчик/Издатель')}
${cb('fieldPlatform', 'Платформа')}
${!isSteam ? cb('fieldReleaseType', 'Тип издания') : ''}
${cb('fieldLanguage', 'Язык игры (сюжет/интерфейс)')}
${cb('fieldVoice', 'Озвучка')}
${cb('fieldSysReq', 'Системные требования' + (!isSteam ? ' (из Steam)' : ''))}
<div id="${sysReqFmtId}" style="display:${showSysReq ? 'block' : 'none'}">
${cb('formatSysReqTemplate', 'Форматировать под шаблон pornolab', 'margin-left:20px;')}
</div>
${isSteam ? `
${cb('fieldDescShort', 'Описание (короткое)')}
${cb('fieldDescLong', 'Описание (длинное)')}
<div id="af-sp-stm-desc-options" style="display:${showDesc ? 'block' : 'none'};margin-left:18px;">
${cb('enableGoogleTranslate', 'Перевести через Google Translate')}
</div>` : `
${cb('fieldDescription', 'Описание')}
<div id="af-sp-desc-options" style="display:${showDesc ? 'block' : 'none'};margin-left:18px;">
${cb('enableGoogleTranslate', 'Перевести через Google Translate')}
</div>`}
${cb('fieldLinks', 'Ссылки на источник (под описанием игры)')}
${cb('fieldScreenshots', 'Скриншоты/Примеры')}
</div>
<div class="af-sp-section">
<div class="af-sp-section-title">FastPic</div>
<label class="af-sp-label">
<input type="checkbox" data-key="enableFastpicUpload" ${settings.enableFastpicUpload ? 'checked' : ''}>
Перезалить картинки на FastPic
</label>
</div>
${!isSteam ? `
<div class="af-sp-section">
<div class="af-sp-section-title">Steam</div>
<label class="af-sp-label"><input type="checkbox" data-key="enableSteamPoster" ${settings.enableSteamPoster ? 'checked' : ''}> Использовать постер из Steam (с выбором обложки)</label>
<label class="af-sp-label"><input type="checkbox" data-key="enableSteamTags" ${settings.enableSteamTags ? 'checked' : ''}> Добавлять Steam теги к текущим</label>
<label class="af-sp-label"><input type="checkbox" data-key="enableSteamScreenshots" ${settings.enableSteamScreenshots ? 'checked' : ''}> Добавлять скриншоты из Steam к текущим</label>
</div>` : ''}`;
}
function buildSettingsPanel(source) {
const panel = document.createElement('div');
panel.id = 'af-settings-panel';
panel.innerHTML = `
<div id="af-sp-header">
<span>Настройки ${source}</span>
<button id="af-sp-close" title="Закрыть">×</button>
</div>
<div id="af-sp-body">${buildSettingsHTML(source)}</div>`;
document.body.appendChild(panel);
makeSettingsPanelDraggable(panel);
panel.style.top = '120px';
panel.style.left = '50px';
try {
const pos = JSON.parse(GM_getValue('af_settings_pos', '{}'));
if (pos.top) { panel.style.top = pos.top; panel.style.transform = 'none'; }
if (pos.left) { panel.style.left = pos.left; }
} catch {}
panel.querySelector('#af-sp-close').onclick = () => panel.remove();
bindSettingsEvents(panel, source);
return panel;
}
function bindSettingsEvents(panel, source) {
const isSteam = source === 'steam';
const P = isSteam ? 'stm_' : '';
const TOGGLE_MAP = isSteam ? {
[`${P}fieldSysReq`]: '#af-sp-stm-sysreq-fmt',
} : {
fieldSysReq: '#af-sp-sysreq-fmt',
fieldTags: '#af-sp-tags-options',
adaptTagEditor: '#af-sp-adapt-options',
fieldDescription: '#af-sp-desc-options',
};
panel.querySelectorAll('input[type="checkbox"][data-key]').forEach(cb => {
cb.addEventListener('change', () => {
settings[cb.dataset.key] = cb.checked;
saveSettings(settings);
const sel = TOGGLE_MAP[cb.dataset.key];
if (sel) { const el = panel.querySelector(sel); if (el) el.style.display = cb.checked ? 'block' : 'none'; }
if (isSteam && (cb.dataset.key === 'stm_fieldDescShort' || cb.dataset.key === 'stm_fieldDescLong')) {
const el = panel.querySelector('#af-sp-stm-desc-options');
if (el) el.style.display = (settings.stm_fieldDescShort || settings.stm_fieldDescLong) ? 'block' : 'none';
}
});
});
}
function makeSettingsPanelDraggable(panel) {
const header = panel.querySelector('#af-sp-header');
let drag = null;
const savePos = () => GM_setValue('af_settings_pos', JSON.stringify({
top: panel.style.top, left: panel.style.left
}));
header.onmousedown = e => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
drag = { x: e.clientX, y: e.clientY };
document.onmousemove = e => {
panel.style.top = (panel.offsetTop - (drag.y - e.clientY)) + 'px';
panel.style.left = (panel.offsetLeft - (drag.x - e.clientX)) + 'px';
panel.style.transform = 'none';
drag = { x: e.clientX, y: e.clientY };
};
document.onmouseup = () => {
document.onmousemove = document.onmouseup = null;
savePos();
};
};
}
// ─── Вставка секции «Автозаполнение» в форму ──────────────────────────────
function injectAutofillSection() {
const relForm = document.getElementById('rel-form');
if (!relForm) {
console.warn('[AutoFill] #rel-form не найден, повтор через 500мс...');
setTimeout(injectAutofillSection, 500);
return;
}
const forumDefault = DEFAULT_STEAM_FORUMS.includes(FORUM_ID) ? 'steam' : 'vndb';
const defaultSource = GM_getValue(SOURCE_KEY, forumDefault);
const defaultPlaceholder = defaultSource === 'steam'
? 'https://store.steampowered.com/app/702050/'
: 'https://vndb.org/v97 или https://vndb.org/r64727';
const afSection = document.createElement('div');
afSection.id = 'af-section';
afSection.innerHTML = `
<table class="forumline">
<colgroup>
<col class="row1" width="20%">
<col class="row2" width="80%">
</colgroup>
<tbody>
<tr><th colspan="2">Автозаполнение формы</th></tr>
<tr>
<td class="rel-title">Источник:</td>
<td class="rel-inputs" id="af-controls-td">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<select id="af-source-select" class="rel-el rel-input">
<option value="vndb" ${defaultSource === 'vndb' ? 'selected' : ''}>vndb</option>
<option value="steam" ${defaultSource === 'steam' ? 'selected' : ''}>steam</option>
</select>
<input type="button" id="af-settings-btn" value="Настройки ${defaultSource}">
<span id="af-vndb-hint"
style="display:${defaultSource === 'vndb' ? 'inline' : 'none'};cursor:default;font-size:13px;line-height:1;vertical-align:middle;user-select:none;">❓</span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:4px;flex-wrap:wrap;">
<input id="af-url-input" type="text" class="rel-el rel-input"
style="width:410px;"
placeholder="${defaultPlaceholder}"
value="">
<input type="button" id="af-fill-btn" value="Заполнить форму">
</div>
</td>
</tr>
</tbody>
</table>`;
const spacer = document.createElement('div');
spacer.className = 'spacer_12';
relForm.parentNode.insertBefore(afSection, relForm);
relForm.parentNode.insertBefore(spacer, relForm);
const sourceSelect = document.getElementById('af-source-select');
const urlInput = document.getElementById('af-url-input');
const settingsBtn = document.getElementById('af-settings-btn');
const fillBtn = document.getElementById('af-fill-btn');
function applySource(src, clearUrl = true) {
sourceSelect.value = src;
settingsBtn.value = `Настройки ${src}`;
urlInput.placeholder = src === 'steam'
? 'https://store.steampowered.com/app/702050/'
: 'https://vndb.org/v97 или https://vndb.org/r64727';
if (clearUrl) urlInput.value = '';
document.getElementById('af-settings-panel')?.remove();
const hintEl = document.getElementById('af-vndb-hint');
if (hintEl) hintEl.style.display = src === 'vndb' ? 'inline' : 'none';
GM_setValue(SOURCE_KEY, src);
}
sourceSelect.addEventListener('change', () => applySource(sourceSelect.value));
urlInput.addEventListener('paste', e => {
const pasted = (e.clipboardData || window.clipboardData).getData('text').trim();
try {
const hostname = new URL(pasted).hostname;
if (hostname === 'vndb.org' || hostname.endsWith('.vndb.org'))
applySource('vndb', false);
else if (hostname === 'store.steampowered.com')
applySource('steam', false);
} catch {}
});
settingsBtn.addEventListener('click', () => {
const existing = document.getElementById('af-settings-panel');
if (existing) { existing.remove(); return; }
buildSettingsPanel(sourceSelect.value);
});
fillBtn.addEventListener('click', async () => {
const src = sourceSelect.value;
const url = urlInput.value.trim();
if (!url) { toast('Введите ссылку', 'warning'); return; }
fillBtn.disabled = true;
fillBtn.value = '⏳ Заполняю...';
try {
if (src === 'steam') await fillFormSteam(url);
else await fillForm(url);
} finally {
fillBtn.disabled = false;
fillBtn.value = 'Заполнить форму';
}
});
const hintEl = document.getElementById('af-vndb-hint');
let hintTimer = null;
hintEl.addEventListener('mouseenter', () => {
hintTimer = setTimeout(() => {
let tip = document.getElementById('af-vndb-tooltip');
if (!tip) {
tip = document.createElement('div');
tip.id = 'af-vndb-tooltip';
tip.innerHTML =
'Если вставите ссылку на vn страницу, то дата релиза будет взята из первого официального релиза. ' +
'Это нормально для новых игр.<br><br>' +
'Если vn с большой историей переизданий, то вставляйте ссылку на конкретный релиз, ' +
'его данные будут в приоритете на заполнение формы.<br><br>' +
'В настройках можно подключить добавление данных из Steam. ' +
'Это будет работать, если на vn странице есть хоть один релиз с ссылкой на Steam.<br><br>' +
'Если на один магазин будет несколько разных ссылок, то берётся последняя по дате релиза.';
document.body.appendChild(tip);
}
const r = hintEl.getBoundingClientRect();
tip.style.left = r.left + 'px';
tip.style.top = (r.bottom + 6) + 'px';
tip.style.display = 'block';
}, 50);
});
hintEl.addEventListener('mouseleave', () => {
clearTimeout(hintTimer);
const tip = document.getElementById('af-vndb-tooltip');
if (tip) tip.style.display = 'none';
});
}
// ─── Стили ─────────────────────────────────────────────────────────────────
GM_addStyle(`
#af-toast-container { position:fixed; top:16px; left:50%; transform:translateX(-50%); z-index:999999; display:flex; flex-direction:column; gap:8px; max-width:340px; pointer-events:none; }
.af-toast { padding:10px 14px; border-radius:6px; font:13px/1.4 Verdana,Arial,sans-serif; box-shadow:0 3px 12px rgba(0,0,0,.35); pointer-events:auto; cursor:pointer; animation:af-fadein .25s ease; word-break:break-word; }
@keyframes af-fadein { from { opacity:0; transform:translateX(20px); } to { opacity:1; } }
.af-toast-info { background:#1a6ebf; color:#fff; }
.af-toast-success { background:#2a8a3e; color:#fff; }
.af-toast-warning { background:#b07c00; color:#fff; }
.af-toast-error { background:#b02020; color:#fff; }
/* ─── Настройки ─── */
#af-settings-panel { width:max-content; min-width:340px; max-width:min(560px,90vw); position:fixed; max-height:80vh; overflow:hidden; display:flex; flex-direction:column; background:#fafafa; border:1px solid #bbb; border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.25); z-index:99998; font:12px/1.5 Verdana,Arial,sans-serif; }
#af-sp-header { background:#2a6ea0; color:#fff; padding:8px 12px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; cursor:move; user-select:none; font-weight:bold; flex-shrink:0; }
#af-sp-body { padding:10px; overflow-y:auto; flex:1; }
#af-sp-close { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1; padding:0 2px; }
#af-sp-close:hover { color:#ffc; }
.af-sp-section { margin-bottom:12px; border:1px solid #ddd; border-radius:5px; padding:8px 10px; }
.af-sp-section-title { font-weight:bold; color:#2a6ea0; margin-bottom:6px; font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
.af-sp-label { display:block; padding:2px 0; cursor:pointer; color:#333; }
.af-sp-label:hover { color:#000; }
.af-sp-label input[type="checkbox"] { margin-right:6px; cursor:pointer; }
.af-sp-radio { cursor:pointer; color:#333; }
.af-sp-radio:hover { color:#000; }
.af-sp-radio input { margin-right:5px; cursor:pointer; }
#af-vndb-tooltip { display:none; position:fixed; z-index:999999; max-width:340px; background:#2c2c2c; color:#f0f0f0; font:12px/1.6 Verdana,Arial,sans-serif; padding:10px 13px; border-radius:6px; box-shadow:0 4px 16px rgba(0,0,0,.45); pointer-events:none; word-break:break-word; }
/* ─── Выбор постера ─── */
#af-cover-panel { position:fixed; top:80px; left:80px; width:max-content; min-width:340px; max-width:90vw; max-height:82vh; display:flex; flex-direction:column; background:#fafafa; border:1px solid #bbb; border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.3); z-index:99999; font:12px/1.5 Verdana,Arial,sans-serif; }
#af-cover-header { background:#2a6ea0; color:#fff; padding:8px 12px; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; cursor:move; user-select:none; font-weight:bold; flex-shrink:0; }
#af-cover-header button { background:none; border:none; color:#fff; font-size:20px; cursor:pointer; line-height:1; padding:0 2px; }
#af-cover-header button:hover { color:#ffc; }
#af-cover-body { overflow-y:auto; flex:1; padding:10px 12px; }
#af-cover-footer { flex-shrink:0; border-top:1px solid #ddd; background:#fafafa; padding:10px 12px; display:flex; gap:8px; border-radius:0 0 8px 8px; }
.af-cover-item { padding:8px 0; border-top:1px solid #eee; }
.af-cover-item:first-child { border-top:none; }
.af-cover-label { display:flex; align-items:center; gap:6px; cursor:pointer; font-weight:bold; color:#333; }
.af-cover-label:hover { color:#000; }
.af-cover-label input[type="radio"] { cursor:pointer; flex-shrink:0; }
.af-cover-thumb { display:block; max-height:320px; width:auto; height:auto; margin-top:6px; border-radius:4px; border:1px solid #ddd; }
.af-cover-btn-primary { background:#2a6ea0; color:#fff; border:none; border-radius:5px; padding:7px 18px; font:12px Verdana,Arial,sans-serif; cursor:pointer; }
.af-cover-btn-primary:hover { background:#1a5e90; }
.af-cover-btn-secondary { background:#fff; color:#333; border:1px solid #bbb; border-radius:5px; padding:7px 14px; font:12px Verdana,Arial,sans-serif; cursor:pointer; }
.af-cover-btn-secondary:hover { background:#f0f0f0; }
`);
// ─── Инициализация ─────────────────────────────────────────────────────────
function init() {
if (!document.getElementById('rel-form')) return;
injectAutofillSection();
}
if (document.readyState !== 'loading') {
setTimeout(init, 300);
} else {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 300));
}
})();