Last active
November 28, 2025 21:08
-
-
Save qFamouse/2c0736f39f6fc6065e8baf229dc86d07 to your computer and use it in GitHub Desktop.
Автоматически показывает цены с shop.by в карточках товаров onliner.by
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Onliner + Shop.by Auto Price Display | |
| // @namespace https://github.com/qFamouse/ | |
| // @version 1.2 | |
| // @description Автоматически показывает цены с shop.by в карточках товаров onliner.by с кешированием | |
| // @author Famouse | |
| // @match https://catalog.onliner.by/* | |
| // @match https://www.onliner.by/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=onliner.by | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @connect shop.by | |
| // @updateURL https://gist.github.com/qFamouse/2c0736f39f6fc6065e8baf229dc86d07/raw/3eb99c3d1eed115380365c6248f5229a0b520c14/onliner-shopby-auto-price.user.js | |
| // @downloadURL https://gist.github.com/qFamouse/2c0736f39f6fc6065e8baf229dc86d07/raw/3eb99c3d1eed115380365c6248f5229a0b520c14/onliner-shopby-auto-price.user.js | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| console.log('[Shop.by] Скрипт запущен'); | |
| // Настройки кеширования | |
| const CACHE_DURATION = 6 * 60 * 60 * 1000; // 6 часов в миллисекундах | |
| const CACHE_KEY_PREFIX = 'shopby_cache_'; | |
| // Функции работы с кешем | |
| function getCacheKey(productName) { | |
| // Используем Base64 для создания уникального ключа | |
| return CACHE_KEY_PREFIX + btoa(encodeURIComponent(productName)).replace(/[^a-zA-Z0-9]/g, '_'); | |
| } | |
| function getCachedData(productName) { | |
| try { | |
| const cacheKey = getCacheKey(productName); | |
| const cached = GM_getValue(cacheKey, null); | |
| if (!cached) return null; | |
| const data = JSON.parse(cached); | |
| const now = Date.now(); | |
| // Проверяем, не истек ли срок действия кеша | |
| if (now - data.timestamp > CACHE_DURATION) { | |
| GM_setValue(cacheKey, null); // Очищаем устаревший кеш | |
| console.log('[Shop.by] Кеш устарел для:', productName); | |
| return null; | |
| } | |
| console.log('[Shop.by] Данные взяты из кеша:', productName); | |
| return data.shopData; | |
| } catch (e) { | |
| console.error('[Shop.by] Ошибка чтения кеша:', e); | |
| return null; | |
| } | |
| } | |
| function setCachedData(productName, shopData) { | |
| try { | |
| const cacheKey = getCacheKey(productName); | |
| const cacheEntry = { | |
| timestamp: Date.now(), | |
| shopData: shopData | |
| }; | |
| GM_setValue(cacheKey, JSON.stringify(cacheEntry)); | |
| console.log('[Shop.by] Данные сохранены в кеш:', productName); | |
| } catch (e) { | |
| console.error('[Shop.by] Ошибка записи в кеш:', e); | |
| } | |
| } | |
| function clearOldCache() { | |
| // Очистка старого кеша при запуске (опционально) | |
| try { | |
| const allKeys = GM_getValue('cache_keys', '').split(',').filter(k => k); | |
| const now = Date.now(); | |
| allKeys.forEach(key => { | |
| if (key.startsWith(CACHE_KEY_PREFIX)) { | |
| const cached = GM_getValue(key, null); | |
| if (cached) { | |
| const data = JSON.parse(cached); | |
| if (now - data.timestamp > CACHE_DURATION) { | |
| GM_setValue(key, null); | |
| } | |
| } | |
| } | |
| }); | |
| } catch (e) { | |
| console.error('[Shop.by] Ошибка очистки кеша:', e); | |
| } | |
| } | |
| function searchOnShopBy(productName) { | |
| const encodedName = encodeURIComponent(productName); | |
| const url = `https://shop.by/find/?findtext=${encodedName}&sort=price--number`; | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: "GET", | |
| url: url, | |
| headers: { | |
| "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", | |
| "accept-language": "ru,en;q=0.9", | |
| "referer": "https://shop.by/", | |
| "user-agent": navigator.userAgent | |
| }, | |
| anonymous: false, | |
| onload: function(response) { | |
| if (response.status === 200) { | |
| try { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(response.responseText, 'text/html'); | |
| // Проверяем наличие результатов | |
| const noResults = doc.querySelector('.PageFind__Noresults'); | |
| if (noResults) { | |
| resolve(null); | |
| return; | |
| } | |
| const modelList = doc.querySelector('.ModelList'); | |
| if (!modelList) { | |
| resolve(null); | |
| return; | |
| } | |
| // Ищем первую карточку (модель или товар магазина) | |
| const modelCard = modelList.querySelector('.ModelList__ModelBlockItem'); | |
| const shopCard = modelList.querySelector('.ShopItemList__BlockItem'); | |
| const firstCard = modelCard || shopCard; | |
| if (!firstCard) { | |
| resolve(null); | |
| return; | |
| } | |
| let price, shopCount; | |
| const productUrl = url; | |
| if (modelCard) { | |
| // Карточка модели товара | |
| const priceEl = modelCard.querySelector('.PriceBlock__PriceValue'); | |
| const countEl = modelCard.querySelector('.ModelList__CountShopsLink'); | |
| price = priceEl ? priceEl.textContent.replace(/\s+/g, ' ').trim() : null; | |
| if (countEl) { | |
| shopCount = countEl.textContent.trim(); | |
| } else { | |
| // Подсчитываем количество карточек на странице | |
| const itemsOnPage = modelList.querySelectorAll('.ModelList__ModelBlockItem, .ShopItemList__BlockItem').length; | |
| // Проверяем наличие пагинации | |
| const pagination = doc.querySelector('.Paging__InnerPages'); | |
| if (pagination) { | |
| const pageLinks = pagination.querySelectorAll('.Paging__PageLink:not(.Paging__DisabledFirstPage):not(.Paging__PageActive):not(.Paging__LastPage)'); | |
| const totalPages = pageLinks.length + 1; // +1 за текущую страницу | |
| const estimatedTotal = itemsOnPage * totalPages; | |
| shopCount = `~${estimatedTotal} предложений`; | |
| } else { | |
| shopCount = `${itemsOnPage} ${itemsOnPage === 1 ? 'предложение' : itemsOnPage < 5 ? 'предложения' : 'предложений'}`; | |
| } | |
| } | |
| } else { | |
| // Карточка товара от магазина | |
| const priceEl = shopCard.querySelector('.PriceBlock__PriceFirst'); | |
| price = priceEl ? priceEl.textContent.replace(/\s+/g, ' ').trim() : null; | |
| // Для товаров магазинов считаем все карточки | |
| const totalItems = modelList.querySelectorAll('.ShopItemList__BlockItem').length; | |
| const pagination = doc.querySelector('.Paging__InnerPages'); | |
| if (pagination) { | |
| const pageLinks = pagination.querySelectorAll('.Paging__PageLink:not(.Paging__DisabledFirstPage):not(.Paging__PageActive):not(.Paging__LastPage)'); | |
| const totalPages = pageLinks.length + 1; | |
| const estimatedTotal = totalItems * totalPages; | |
| shopCount = `~${estimatedTotal} предложений`; | |
| } else { | |
| shopCount = `${totalItems} ${totalItems === 1 ? 'предложение' : totalItems < 5 ? 'предложения' : 'предложений'}`; | |
| } | |
| } | |
| if (price) { | |
| resolve({ | |
| price: price, | |
| url: productUrl, | |
| shopCount: shopCount || '' | |
| }); | |
| } else { | |
| resolve(null); | |
| } | |
| } catch (e) { | |
| console.error('[Shop.by] Ошибка парсинга:', e); | |
| reject(e); | |
| } | |
| } else { | |
| reject(new Error(`HTTP ${response.status}`)); | |
| } | |
| }, | |
| onerror: reject | |
| }); | |
| }); | |
| } | |
| async function getShopByData(productName) { | |
| // Проверяем кеш | |
| const cached = getCachedData(productName); | |
| if (cached) { | |
| return cached; | |
| } | |
| // Если в кеше нет, делаем запрос | |
| console.log('[Shop.by] Запрос к API для:', productName); | |
| const shopData = await searchOnShopBy(productName); | |
| // Сохраняем результат в кеш (даже если null) | |
| setCachedData(productName, shopData); | |
| return shopData; | |
| } | |
| function extractProductName(container) { | |
| // Блок #1: catalog-form__offers-part_data | |
| let nameEl = container.querySelector('.catalog-form__offers-part_data .catalog-form__link'); | |
| if (nameEl) { | |
| return nameEl.textContent.trim(); | |
| } | |
| // Блок #2-3: product-summary__caption | |
| nameEl = container.querySelector('.product-summary__caption'); | |
| if (nameEl) { | |
| return nameEl.textContent.trim(); | |
| } | |
| // Блок #4: catalog-masthead__title | |
| nameEl = container.querySelector('.catalog-masthead__title'); | |
| if (nameEl) { | |
| return nameEl.textContent.trim(); | |
| } | |
| return null; | |
| } | |
| function addShopByPrice(container, shopData) { | |
| if (container.querySelector('.shopby-price-badge')) { | |
| return; | |
| } | |
| let insertTarget = null; | |
| // Блок #1: После цены в catalog-form__link | |
| const priceLink = container.querySelector('.catalog-form__offers-part_control .catalog-form__link'); | |
| if (priceLink) { | |
| insertTarget = priceLink.parentElement; | |
| } | |
| // Блок #2-3: После product-summary__price | |
| if (!insertTarget) { | |
| insertTarget = container.querySelector('.product-summary__price'); | |
| } | |
| // Блок #4: После первой цены product-aside__description | |
| if (!insertTarget) { | |
| const priceElements = document.querySelectorAll('.product-aside__description'); | |
| for (let el of priceElements) { | |
| if (el.textContent.includes('р.') && el.textContent.trim().match(/^\d+[,\s\d]*р\./)) { | |
| insertTarget = el; | |
| break; | |
| } | |
| } | |
| } | |
| if (!insertTarget) { | |
| console.warn('[Shop.by] Не найдено место для вставки'); | |
| return; | |
| } | |
| const badge = document.createElement('div'); | |
| badge.className = 'shopby-price-badge'; | |
| badge.style.cssText = ` | |
| display: block; | |
| margin: 8px 0; | |
| padding: 8px 12px; | |
| background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%); | |
| border-radius: 6px; | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.15); | |
| `; | |
| badge.innerHTML = ` | |
| <a href="${shopData.url}" target="_blank" style="color: white; text-decoration: none; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600;"> | |
| <span style="font-size: 16px;">🛒</span> | |
| <div> | |
| <div>Shop.by: ${shopData.price}</div> | |
| ${shopData.shopCount ? `<div style="font-size: 11px; opacity: 0.85; margin-top: 2px;">${shopData.shopCount}</div>` : ''} | |
| </div> | |
| </a> | |
| `; | |
| insertTarget.insertAdjacentElement('afterend', badge); | |
| console.log('[Shop.by] Цена добавлена:', shopData.price); | |
| } | |
| async function processCard(card) { | |
| if (card.dataset.shopbyProcessed === 'true') return; | |
| card.dataset.shopbyProcessed = 'true'; | |
| const productName = extractProductName(card); | |
| if (!productName) { | |
| console.warn('[Shop.by] Не удалось извлечь название'); | |
| return; | |
| } | |
| console.log('[Shop.by] Обработка:', productName); | |
| try { | |
| const shopData = await getShopByData(productName); | |
| if (shopData) { | |
| addShopByPrice(card, shopData); | |
| } else { | |
| console.log('[Shop.by] Товар не найден на shop.by'); | |
| } | |
| } catch (error) { | |
| console.error('[Shop.by] Ошибка:', error); | |
| } | |
| } | |
| function findCards() { | |
| return [ | |
| ...document.querySelectorAll('.catalog-form__offers-unit'), // Блок #1 | |
| ...document.querySelectorAll('.product-summary'), // Блок #2-3 | |
| ...document.querySelectorAll('.catalog-masthead') // Блок #4 | |
| ]; | |
| } | |
| async function processAllCards() { | |
| const cards = findCards(); | |
| console.log(`[Shop.by] Найдено карточек: ${cards.length}`); | |
| for (const card of cards) { | |
| await processCard(card); | |
| await new Promise(resolve => setTimeout(resolve, 500)); | |
| } | |
| } | |
| const observer = new MutationObserver(() => { | |
| processAllCards(); | |
| }); | |
| function init() { | |
| console.log('[Shop.by] Инициализация'); | |
| clearOldCache(); // Очищаем старый кеш при запуске | |
| processAllCards(); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| setTimeout(init, 2000); // Задержка для загрузки динамического контента | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment