// ==UserScript== // @name Onliner Compare Extended // @namespace https://github.com/qFamouse/ // @version 3.0 // @description Widens the Onliner comparison table and lets you copy it as CSV // @description:ru Позволяет расширить таблицу сравнения Onliner и скопировать её в CSV // @author Famouse // @license MIT // @match https://catalog.onliner.by/compare/* // @icon https://www.google.com/s2/favicons?sz=64&domain=onliner.by // @grant GM_setValue // @grant GM_getValue // @homepageURL https://github.com/qFamouse/Userscripts/tree/master/scripts/onliner-compare-extended // @supportURL https://github.com/qFamouse/Userscripts/issues // ==/UserScript== (function () { 'use strict'; // ─── Константы ──────────────────────────────────────────────────────────── const MARKER = 'ch-expand-btn'; const SETTINGS_KEY = 'ch_csv_settings'; const DEFAULT_SETTINGS = { includeName: true, includePrice: true, includeSections: true, onlyDiff: false, separator: ';', includeUrl: true, includeRating: false, boolFormat: 'word', // 'word' = Да/Нет, 'symbol' = ✓/✗, 'bool' = true/false }; let expanded = false; let settingsOpen = false; // ─── Утилиты ────────────────────────────────────────────────────────────── function loadSettings() { try { const raw = GM_getValue(SETTINGS_KEY, '{}'); return Object.assign({}, DEFAULT_SETTINGS, JSON.parse(raw)); } catch { return { ...DEFAULT_SETTINGS }; } } function saveSettings(s) { GM_setValue(SETTINGS_KEY, JSON.stringify(s)); } function escapeCell(val, sep) { if (val == null) return ''; const str = String(val).replace(/\u00a0/g, ' ').trim(); if (str.includes(sep) || str.includes('"') || str.includes('\n')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } // ─── Кнопка «Убрать границы» ────────────────────────────────────────────── function hideClearButtons() { document.querySelectorAll('.product-table__clear').forEach(el => { if (el.textContent.trim() === 'Очистить список') { el.style.display = 'none'; } }); } function toggleBorders(btn) { const middle = document.querySelector('.g-middle-i'); const table = document.querySelector('.product-table-container'); if (!expanded) { if (middle) { middle.dataset.chOld = middle.style.maxWidth || ''; middle.style.maxWidth = 'none'; } if (table) { table.dataset.chOld = table.style.maxWidth || ''; table.style.maxWidth = 'none'; } btn.textContent = 'Вернуть границы'; btn.classList.remove('button_white'); btn.classList.add('button_orange'); } else { if (middle) middle.style.maxWidth = middle.dataset.chOld || ''; if (table) table.style.maxWidth = table.dataset.chOld || ''; btn.textContent = 'Убрать границы'; btn.classList.remove('button_orange'); btn.classList.add('button_white'); } expanded = !expanded; } function addTopButton() { if (document.querySelector('.' + MARKER)) return; const tabs = document.querySelector('.product-table__tabs'); if (!tabs) return; const btn = document.createElement('a'); btn.textContent = 'Убрать границы'; btn.className = `product-table__clear button button_small button_white ${MARKER}`; btn.href = '#'; btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); toggleBorders(btn); }, true); tabs.appendChild(btn); } // ─── Извлечение данных из таблицы ───────────────────────────────────────── function extractTableData(settings) { const rows = []; // Считаем количество товаров (колонок данных) // Первая строка-заголовок: .product-table__row_header const headerRow = document.querySelector('.product-table__row_header'); if (!headerRow) return null; // Ячейки с товарами — все th.product-table__cell (кроме первого с кнопками и duplicate) const productCells = [...headerRow.querySelectorAll('th.product-table__cell')] .filter(th => !th.classList.contains('product-table__cell_duplicate') && !th.classList.contains('product-table__cell_small')); const productCount = productCells.length; if (productCount === 0) return null; // Имена товаров const names = productCells.map(th => { const cap = th.querySelector('.product-summary__caption'); return cap ? cap.textContent.trim() : ''; }); // Цены const prices = productCells.map(th => { const pr = th.querySelector('.product-summary__price_primary'); return pr ? pr.textContent.trim().replace(/\s+/g, ' ') : ''; }); // Рейтинги const ratings = productCells.map(th => { const stars = th.querySelector('.rating'); if (!stars) return ''; const titleAttr = stars.getAttribute('title') || ''; const ratingLink = th.querySelector('.product-summary__rating-link'); const reviewCount = ratingLink ? ratingLink.textContent.replace(/[^\d]/g, '') : ''; return titleAttr + (reviewCount ? ` (${reviewCount} отз.)` : ''); }); // URL товаров const urls = productCells.map(th => { const link = th.querySelector('.product-summary__figure'); return link ? link.href : ''; }); // Заголовок (первая строка) const header = ['Параметр', ...names.map((n, i) => { let label = n || `Товар ${i + 1}`; return label; })]; rows.push(header); // Строка с ценами if (settings.includePrice) { rows.push(['Цена', ...prices]); } // Строка с рейтингом if (settings.includeRating) { rows.push(['Рейтинг', ...ratings]); } // Строка с URL if (settings.includeUrl) { rows.push(['Ссылка', ...urls]); } // Параметры: проходим по группам и строкам параметров const groups = document.querySelectorAll('.product-table__group'); groups.forEach(group => { // Заголовок секции const sectionTitle = group.querySelector('.product-table__title-inner-text'); const sectionName = sectionTitle ? sectionTitle.textContent.trim() : ''; // Строки параметров const paramRows = group.querySelectorAll('.product-table__row_parameter'); if (paramRows.length === 0) return; if (settings.includeSections && sectionName) { // Вставляем строку-заголовок секции (пустые значения для товаров) rows.push([`=== ${sectionName} ===`, ...Array(productCount).fill('')]); } paramRows.forEach(row => { // Если строка скрыта через «Скрыть одинаковые параметры» — не пропускаем её // (экспортируем всё) // Лейбл параметра — первая ячейка (не duplicate, не с иконкой) const labelCell = row.querySelector('td.product-table__cell:not(.product-table__cell_duplicate)'); if (!labelCell) return; const labelWrapper = labelCell.querySelector('.product-table__wrapper'); const label = labelWrapper ? labelWrapper.textContent.trim() : labelCell.textContent.trim(); if (!label) return; // Значения — все td.product-table__cell кроме первого (лейбл) и duplicate const valueCells = [...row.querySelectorAll('td.product-table__cell')] .filter(td => !td.classList.contains('product-table__cell_duplicate')); // valueCells[0] — лейбл, остальные — значения товаров const values = valueCells.slice(1).map(td => { const wrapper = td.querySelector('.product-table__wrapper'); if (!wrapper) return td.textContent.trim().replace(/\s+/g, ' '); const iconTip = wrapper.querySelector('.product-icon_tip'); const iconX = wrapper.querySelector('.product-icon_x'); const vt = wrapper.querySelector('.value__text'); const fmt = settings.boolFormat || 'word'; const yes = fmt === 'symbol' ? '✓' : fmt === 'bool' ? 'true' : 'Да'; const no = fmt === 'symbol' ? '✗' : fmt === 'bool' ? 'false' : 'Нет'; if (iconX) return no + (vt ? ' ' + vt.textContent.trim() : ''); if (iconTip) return yes + (vt ? ' ' + vt.textContent.trim() : ''); if (vt) return vt.textContent.trim().replace(/\u00a0/g, ' '); return wrapper.textContent.trim().replace(/\s+/g, ' '); }); // Дополнить до нужного количества товаров while (values.length < productCount) values.push(''); // Если «только различающиеся» — пропускаем одинаковые if (settings.onlyDiff && values.length > 1) { const unique = new Set(values.map(v => v.toLowerCase())); if (unique.size === 1) return; } rows.push([label, ...values.slice(0, productCount)]); }); }); return rows; } // ─── Генерация CSV ──────────────────────────────────────────────────────── function generateCSV(rows, sep) { return rows.map(row => row.map(cell => escapeCell(cell, sep)).join(sep)).join('\r\n'); } // ─── Копирование в буфер ───────────────────────────────────────────────── function copyToClipboard(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); } // Fallback const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); return Promise.resolve(); } // ─── Toast-уведомление ─────────────────────────────────────────────────── function showToast(msg, isError) { const existing = document.querySelector('.ch-toast'); if (existing) existing.remove(); const toast = document.createElement('div'); toast.className = 'ch-toast'; toast.textContent = msg; toast.style.cssText = ` position: fixed; bottom: 90px; right: 24px; z-index: 99999; background: ${isError ? '#e53935' : '#43a047'}; color: #fff; padding: 10px 18px; border-radius: 6px; font-size: 13px; font-family: "Open Sans", Arial, sans-serif; box-shadow: 0 4px 16px rgba(0,0,0,.25); pointer-events: none; opacity: 0; transition: opacity .2s ease; `; document.body.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = '1'; }); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 250); }, 2400); } // ─── Панель настроек ────────────────────────────────────────────────────── function buildSettingsPanel(settings, onClose) { const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; inset: 0; z-index: 99998; background: rgba(0,0,0,.35); display: flex; align-items: flex-end; justify-content: flex-end; `; const panel = document.createElement('div'); panel.style.cssText = ` background: #fff; border-radius: 12px 12px 0 0; box-shadow: 0 -4px 32px rgba(0,0,0,.18); padding: 24px 28px 28px; min-width: 320px; max-width: 380px; margin-right: 16px; font-family: "Open Sans", Arial, sans-serif; font-size: 14px; color: #1a1a1a; `; // Заголовок const titleRow = document.createElement('div'); titleRow.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:18px;'; const title = document.createElement('span'); title.textContent = '⚙ Настройки экспорта CSV'; title.style.cssText = 'font-weight:600; font-size:15px;'; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = ` background: none; border: none; cursor: pointer; font-size: 18px; color: #888; padding: 0 4px; line-height: 1; `; closeBtn.onclick = () => { overlay.remove(); settingsOpen = false; onClose(settings); }; titleRow.append(title, closeBtn); panel.appendChild(titleRow); // Переключатели const toggles = [ { key: 'includeName', label: 'Название товара', tip: 'Первая строка с именами' }, { key: 'includePrice', label: 'Цена', tip: 'Строка с ценами из заголовка' }, { key: 'includeUrl', label: 'Ссылка на товар', tip: 'URL страницы товара' }, { key: 'includeRating', label: 'Рейтинг и отзывы', tip: 'Оценка и количество отзывов' }, { key: 'includeSections', label: 'Заголовки секций', tip: 'Например «Общая информация»' }, { key: 'onlyDiff', label: 'Только различающиеся', tip: 'Пропустить одинаковые параметры' }, ]; toggles.forEach(({ key, label, tip }) => { const row = document.createElement('label'); row.style.cssText = ` display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; cursor: pointer; `; const left = document.createElement('div'); const lbl = document.createElement('div'); lbl.textContent = label; lbl.style.cssText = 'font-weight:500;'; const desc = document.createElement('div'); desc.textContent = tip; desc.style.cssText = 'font-size:11px; color:#888; margin-top:1px;'; left.append(lbl, desc); // Toggle switch const switchWrap = document.createElement('div'); switchWrap.style.cssText = 'position:relative; flex-shrink:0; margin-left:12px;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!settings[key]; cb.style.cssText = 'position:absolute; opacity:0; width:0; height:0;'; cb.onchange = () => { settings[key] = cb.checked; saveSettings(settings); }; const track = document.createElement('div'); track.style.cssText = ` width: 40px; height: 22px; border-radius: 11px; background: ${cb.checked ? '#ff6520' : '#ccc'}; transition: background .2s; position: relative; cursor: pointer; `; const knob = document.createElement('div'); knob.style.cssText = ` position: absolute; top: 3px; left: ${cb.checked ? '21px' : '3px'}; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: left .2s; `; track.appendChild(knob); cb.onchange = () => { settings[key] = cb.checked; track.style.background = cb.checked ? '#ff6520' : '#ccc'; knob.style.left = cb.checked ? '21px' : '3px'; saveSettings(settings); }; switchWrap.append(cb, track); row.append(left, switchWrap); panel.appendChild(row); }); // Разделитель const sepRow = document.createElement('div'); sepRow.style.cssText = 'margin-top:14px;'; const sepLabel = document.createElement('div'); sepLabel.textContent = 'Разделитель'; sepLabel.style.cssText = 'font-weight:500; margin-bottom:8px;'; const sepGroup = document.createElement('div'); sepGroup.style.cssText = 'display:flex; gap:8px;'; [ { val: ';', display: 'Точка с запятой ;' }, { val: ',', display: 'Запятая ,' }, { val: '\t', display: 'Tab ⇥' }, ].forEach(({ val, display }) => { const btn = document.createElement('button'); btn.textContent = display; const isActive = settings.separator === val; btn.style.cssText = ` padding: 5px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; border: 2px solid ${isActive ? '#ff6520' : '#ddd'}; background: ${isActive ? '#fff4ef' : '#fafafa'}; color: ${isActive ? '#ff6520' : '#555'}; font-family: inherit; transition: all .15s; `; btn.onclick = () => { settings.separator = val; saveSettings(settings); // Обновить стили всех кнопок sepGroup.querySelectorAll('button').forEach(b => { const active = b === btn; b.style.borderColor = active ? '#ff6520' : '#ddd'; b.style.background = active ? '#fff4ef' : '#fafafa'; b.style.color = active ? '#ff6520' : '#555'; }); }; sepGroup.appendChild(btn); }); sepRow.append(sepLabel, sepGroup); panel.appendChild(sepRow); // Формат булевых значений const boolRow = document.createElement("div"); boolRow.style.cssText = "margin-top:14px;"; const boolLabel = document.createElement("div"); boolLabel.textContent = "Формат галочек (есть/нет)"; boolLabel.style.cssText = "font-weight:500; margin-bottom:8px;"; const boolGroup = document.createElement("div"); boolGroup.style.cssText = "display:flex; gap:8px;"; [ { val: "word", display: "Да / Нет" }, { val: "symbol", display: "✓ / ✗" }, { val: "bool", display: "true / false" }, ].forEach(({ val, display }) => { const btn = document.createElement("button"); btn.textContent = display; const isActive = (settings.boolFormat || "word") === val; btn.style.cssText = `padding: 5px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; border: 2px solid ${isActive ? "#ff6520" : "#ddd"}; background: ${isActive ? "#fff4ef" : "#fafafa"}; color: ${isActive ? "#ff6520" : "#555"}; font-family: inherit; transition: all .15s;`; btn.onclick = () => { settings.boolFormat = val; saveSettings(settings); boolGroup.querySelectorAll("button").forEach(b => { const active = b === btn; b.style.borderColor = active ? "#ff6520" : "#ddd"; b.style.background = active ? "#fff4ef" : "#fafafa"; b.style.color = active ? "#ff6520" : "#555"; }); }; boolGroup.appendChild(btn); }); boolRow.append(boolLabel, boolGroup); panel.appendChild(boolRow); overlay.appendChild(panel); overlay.addEventListener('click', e => { if (e.target === overlay) { overlay.remove(); settingsOpen = false; onClose(settings); } }); return overlay; } // ─── FAB-кнопка (правый нижний угол) ───────────────────────────────────── function injectStyles() { if (document.getElementById('ch-fab-style')) return; const style = document.createElement('style'); style.id = 'ch-fab-style'; style.textContent = ` .ch-fab-wrap { position: fixed; bottom: 24px; right: 24px; z-index: 9999; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; } .ch-fab-main { display: flex; align-items: center; gap: 0; border-radius: 28px; box-shadow: 0 4px 18px rgba(0,0,0,.22); overflow: hidden; font-family: "Open Sans", Arial, sans-serif; } .ch-fab-copy { background: #ff6520; color: #fff; border: none; padding: 12px 20px; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .15s, transform .1s; white-space: nowrap; line-height: 1; display: flex; align-items: center; gap: 7px; } .ch-fab-copy:hover { background: #e55a1a; } .ch-fab-copy:active { transform: scale(.97); } .ch-fab-copy svg { width: 15px; height: 15px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; } .ch-fab-settings { background: #ff6520; color: #fff; border: none; border-left: 1px solid rgba(255,255,255,.25); padding: 12px 14px; cursor: pointer; transition: background .15s; line-height: 1; display: flex; align-items: center; } .ch-fab-settings:hover { background: #e55a1a; } .ch-fab-settings svg { width: 15px; height: 15px; fill: none; stroke: #fff; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } `; document.head.appendChild(style); } function addFAB() { if (document.querySelector('.ch-fab-wrap')) return; injectStyles(); let settings = loadSettings(); const wrap = document.createElement('div'); wrap.className = 'ch-fab-wrap'; const main = document.createElement('div'); main.className = 'ch-fab-main'; // Кнопка копирования const copyBtn = document.createElement('button'); copyBtn.className = 'ch-fab-copy'; copyBtn.innerHTML = ` Копировать CSV `; copyBtn.title = 'Скопировать таблицу сравнения в буфер обмена (CSV)'; copyBtn.onclick = () => { settings = loadSettings(); const data = extractTableData(settings); if (!data || data.length < 2) { showToast('Не удалось прочитать таблицу сравнения', true); return; } const csv = generateCSV(data, settings.separator); copyToClipboard(csv).then(() => { const products = data[0].length - 1; const params = data.length - 1; showToast(`✓ Скопировано: ${products} товара, ${params} строк`, false); // Анимация кнопки copyBtn.innerHTML = ` Скопировано! `; copyBtn.style.background = '#43a047'; setTimeout(() => { copyBtn.innerHTML = ` Копировать CSV `; copyBtn.style.background = ''; }, 2000); }).catch(() => { showToast('Ошибка: не удалось скопировать', true); }); }; // Кнопка настроек (шестерёнка) const settingsBtn = document.createElement('button'); settingsBtn.className = 'ch-fab-settings'; settingsBtn.title = 'Настройки экспорта'; settingsBtn.innerHTML = ` `; settingsBtn.onclick = () => { if (settingsOpen) return; settingsOpen = true; settings = loadSettings(); const panel = buildSettingsPanel(settings, (updated) => { settings = updated; }); document.body.appendChild(panel); }; main.append(copyBtn, settingsBtn); wrap.appendChild(main); document.body.appendChild(wrap); } // ─── Инициализация ──────────────────────────────────────────────────────── function init() { hideClearButtons(); addTopButton(); addFAB(); } let attempts = 0; const interval = setInterval(() => { attempts++; init(); if (document.querySelector('.' + MARKER) || attempts >= 50) { clearInterval(interval); } }, 200); const observer = new MutationObserver(() => { hideClearButtons(); if (!document.querySelector('.' + MARKER)) addTopButton(); if (!document.querySelector('.ch-fab-wrap')) addFAB(); }); if (document.body) { observer.observe(document.body, { childList: true, subtree: true }); } })();