/** * ADK - ScalperA — Google Apps Script (Sheets API) * ================================================= * Облачная "база данных" расширения на Google Таблицах. * * Быстрый старт: * 1. Откройте https://script.google.com → "Новый проект". * 2. Вставьте этот файл целиком (замените содержимое Code.gs). * 3. Разверните: "Развернуть" → "Новое развёртывание" → тип "Веб-приложение". * • Запуск от имени: Я * • Доступ: Любой * 4. Скопируйте URL веб-приложения и вставьте его в расширении: * вкладка "База данных" → поле URL → "Подключить". * * Скрипт умеет работать автономно (без привязки к таблице): при первом * обращении он сам создаст таблицу "ScalperA DB" с нужными колонками и * запомнит её ID. Если скрипт привязан к таблице — используется она. * * Действие createDb создаёт НОВУЮ таблицу и делает её текущей. */ var DEFAULT_SPREADSHEET_NAME = "ScalperA DB"; var DEFAULT_SHEET_NAME = "Данные"; var HEADERS = ["Заголовок", "Цена", "Адрес", "Ссылка", "Дата"]; var PROP_SPREADSHEET_ID = "SPREADSHEET_ID"; var PROP_SPREADSHEET_URL = "SPREADSHEET_URL"; var COLUMNS = HEADERS.length; /* ─────────────────────────── helpers ─────────────────────────── */ function jsonOut_(data) { return ContentService.createTextOutput(JSON.stringify(data)) .setMimeType(ContentService.MimeType.JSON); } function getSpreadsheet_() { var active = null; try { active = SpreadsheetApp.getActiveSpreadsheet(); } catch (err) { active = null; } if (active) return active; var props = PropertiesService.getScriptProperties(); var id = props.getProperty(PROP_SPREADSHEET_ID); if (id) { try { return SpreadsheetApp.openById(id); } catch (err) { /* таблица удалена/недоступна — создадим новую ниже */ } } var ss = SpreadsheetApp.create(DEFAULT_SPREADSHEET_NAME); props.setProperty(PROP_SPREADSHEET_ID, ss.getId()); props.setProperty(PROP_SPREADSHEET_URL, ss.getUrl()); initSheet_(ss.getSheets()[0]); return ss; } function initSheet_(sheet) { if (!sheet) return sheet; var name = sheet.getName(); if (name === "Sheet1" || name === "Лист1") sheet.setName(DEFAULT_SHEET_NAME); if (sheet.getLastRow() === 0) sheet.appendRow(HEADERS); try { sheet.setFrozenRows(1); } catch (err) {} return sheet; } function ensureHeader_(sheet) { if (sheet.getLastRow() === 0) { sheet.appendRow(HEADERS); try { sheet.setFrozenRows(1); } catch (err) {} } } function getSheet_(ss, name) { var sheet = name ? ss.getSheetByName(name) : ss.getSheets()[0]; if (!sheet) sheet = ss.getSheets()[0]; if (!sheet) sheet = ss.insertSheet(DEFAULT_SHEET_NAME); ensureHeader_(sheet); return sheet; } function listSheets_(ss) { return ss.getSheets().map(function (s) { return s.getName(); }); } function readItems_(sheet) { var lastRow = sheet.getLastRow(); if (lastRow < 2) return []; var values = sheet.getRange(2, 1, lastRow - 1, COLUMNS).getValues(); return values .map(function (r) { var dateVal = r[4]; if (dateVal instanceof Date) { dateVal = Utilities.formatDate(dateVal, Session.getScriptTimeZone(), "dd.MM.yyyy HH:mm"); } return { title: String(r[0] || ""), price: String(r[1] || ""), address: String(r[2] || ""), url: String(r[3] || ""), dateAdded: String(dateVal || "") }; }) .filter(function (item) { return item.title || item.url; }); } function saveItems_(sheet, items) { if (!items || !items.length) return { success: true, added: 0 }; var lock = LockService.getScriptLock(); lock.waitLock(30000); try { // Читаем только колонку "Ссылка" — быстрее, чем весь диапазон. var lastRow = sheet.getLastRow(); var existing = {}; if (lastRow > 1) { var urls = sheet.getRange(2, 4, lastRow - 1, 1).getValues(); for (var i = 0; i < urls.length; i++) { var key = String(urls[i][0] || ""); if (key) existing[key] = true; } } var rows = []; items.forEach(function (item) { var url = String(item.url || ""); var dedupKey = url || (String(item.title || "") + "|" + String(item.price || "")); if (existing[dedupKey]) return; existing[dedupKey] = true; rows.push([ item.title || "", item.price || "", item.address || "", url, item.dateAdded || "" ]); }); if (rows.length > 0) { sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, COLUMNS).setValues(rows); } return { success: true, added: rows.length }; } finally { lock.releaseLock(); } } function clearSheet_(sheet) { var lastRow = sheet.getLastRow(); var lastCol = Math.max(sheet.getLastColumn(), COLUMNS); if (lastRow > 1) sheet.getRange(2, 1, lastRow - 1, lastCol).clearContent(); return { success: true }; } // Расширение карточек: для каждой строки (по URL) дополняет столбцы-параметры и проставляет значения. // Порядок столбцов: базовые (Заголовок, Цена, Адрес, Ссылка, Дата) → "Описание" → остальные параметры. function extendItems_(sheet, items) { if (!items || !items.length) return { success: true, updated: 0, notFound: 0, columns: 0 }; var lock = LockService.getScriptLock(); lock.waitLock(30000); try { var lastRow = sheet.getLastRow(); var lastCol = Math.max(sheet.getLastColumn(), 1); var data = sheet.getRange(1, 1, Math.max(lastRow, 1), lastCol).getValues(); var oldHeader = (data[0] || []).map(function (h) { return String(h); }); if (!oldHeader.join("").replace(/\s/g, "")) oldHeader = HEADERS.slice(); // порядок дополнительных столбцов: сохраняем существующий, затем новые var extraOrder = []; oldHeader.forEach(function (h) { if (h && HEADERS.indexOf(h) === -1 && h !== "Описание" && extraOrder.indexOf(h) === -1) extraOrder.push(h); }); items.forEach(function (it) { Object.keys(it.params || {}).forEach(function (k) { if (k && k !== "Описание" && HEADERS.indexOf(k) === -1 && extraOrder.indexOf(k) === -1) extraOrder.push(k); }); }); // "Описание" — всегда первый дополнительный столбец var newHeader = HEADERS.concat(["Описание"]).concat(extraOrder); var totalCols = newHeader.length; var oldIndex = {}; oldHeader.forEach(function (h, i) { if (oldIndex[h] === undefined) oldIndex[h] = i; }); var oldUrlCol = oldHeader.indexOf("Ссылка"); if (oldUrlCol === -1) oldUrlCol = 3; var out = [newHeader]; var rowByUrl = {}; for (var r = 1; r < data.length; r++) { var oldRow = data[r]; var newRow = newHeader.map(function (h) { var oi = oldIndex[h]; return oi === undefined ? "" : (oldRow[oi] !== undefined ? oldRow[oi] : ""); }); var u = String(oldRow[oldUrlCol] || ""); if (u) rowByUrl[u] = out.length; out.push(newRow); } // защита от дубликатов: пишем только изменившиеся поля; если ничего не поменялось — строку скипаем var updated = 0, unchanged = 0, notFound = 0, changedFields = 0; items.forEach(function (it) { var idx = rowByUrl[String(it.url || "")]; if (idx === undefined) { notFound++; return; } var changed = false; Object.keys(it.params || {}).forEach(function (k) { var col = newHeader.indexOf(k); if (col === -1) return; var newVal = it.params[k]; if (String(out[idx][col]) !== String(newVal)) { out[idx][col] = newVal; changed = true; changedFields++; } }); if (changed) updated++; else unchanged++; }); sheet.getRange(1, 1, out.length, totalCols).setValues(out); // ── визуал: влево, перенос строк, автоподбор ширины (описание — фиксированная ширина) ── // выравнивание: всё по центру (и по горизонтали, и по вертикали), кроме «Описание» — оно слева и сверху var rows = out.length; var descCol = newHeader.indexOf("Описание"); var allRng = sheet.getRange(1, 1, rows, totalCols); allRng.setHorizontalAlignment("center"); allRng.setVerticalAlignment("middle"); if (descCol !== -1) { sheet.getRange(1, descCol + 1, rows, 1).setHorizontalAlignment("left").setVerticalAlignment("top"); } allRng.setWrap(true); sheet.autoResizeColumns(1, totalCols); if (descCol !== -1) sheet.setColumnWidth(descCol + 1, 420); return { success: true, updated: updated, unchanged: unchanged, notFound: notFound, changedFields: changedFields, columns: totalCols }; } finally { lock.releaseLock(); } } function createSheet_(ss, body) { var name = (body.name && String(body.name).trim()) || "Лист " + (ss.getSheets().length + 1); if (ss.getSheetByName(name)) return { success: false, error: "already_exists" }; var sheet = ss.insertSheet(name); sheet.appendRow(HEADERS); try { sheet.setFrozenRows(1); } catch (err) {} return { success: true, name: name }; } function deleteSheet_(ss, name) { var sheet = ss.getSheetByName(name); if (!sheet) return { success: false, error: "not_found" }; if (ss.getSheets().length <= 1) return { success: false, error: "last_sheet" }; ss.deleteSheet(sheet); return { success: true }; } function renameSheet_(ss, oldName, newName) { var sheet = ss.getSheetByName(oldName); if (!sheet) return { success: false, error: "not_found" }; newName = (newName || "").trim(); if (!newName) return { success: false, error: "empty_name" }; if (ss.getSheetByName(newName)) return { success: false, error: "already_exists" }; sheet.setName(newName); return { success: true, name: newName }; } function createDb_(name) { var ss = SpreadsheetApp.create((name && String(name).trim()) || DEFAULT_SPREADSHEET_NAME); initSheet_(ss.getSheets()[0]); var props = PropertiesService.getScriptProperties(); props.setProperty(PROP_SPREADSHEET_ID, ss.getId()); props.setProperty(PROP_SPREADSHEET_URL, ss.getUrl()); return { success: true, id: ss.getId(), url: ss.getUrl(), name: ss.getName() }; } function dbInfo_(ss) { var sheets = ss.getSheets().map(function (s) { return { name: s.getName(), gid: s.getSheetId() }; }); return { success: true, id: ss.getId(), url: ss.getUrl(), name: ss.getName(), sheets: sheets }; } /* ─────────────────────────── роутинг ─────────────────────────── */ function handle_(e, method) { try { var params = (e && e.parameter) || {}; var body = {}; if (method === "POST" && e && e.postData && e.postData.contents) { try { body = JSON.parse(e.postData.contents) || {}; } catch (parseErr) { body = {}; } } var action = body.action || params.action || ""; if (action === "createDb") { return jsonOut_(createDb_(body.name || params.name)); } var ss = getSpreadsheet_(); if (action === "getSheets") return jsonOut_(listSheets_(ss)); if (action === "dbInfo") return jsonOut_(dbInfo_(ss)); if (action === "createSheet") return jsonOut_(createSheet_(ss, body)); if (action === "deleteSheet") return jsonOut_(deleteSheet_(ss, body.name || params.name)); if (action === "renameSheet") { return jsonOut_(renameSheet_(ss, body.oldName || params.oldName, body.newName || params.newName)); } var sheet = getSheet_(ss, body.sheet || params.sheet); if (action === "get") return jsonOut_(readItems_(sheet)); if (action === "save") return jsonOut_(saveItems_(sheet, body.items || [])); if (action === "clear") return jsonOut_(clearSheet_(sheet)); if (action === "extendItems") return jsonOut_(extendItems_(sheet, body.items || [])); return jsonOut_({ success: true, count: sheet.getLastRow() }); } catch (err) { return jsonOut_({ success: false, error: err && err.message ? err.message : String(err) }); } } function doGet(e) { return handle_(e, "GET"); } function doPost(e) { return handle_(e, "POST"); }