/** * Example adapter: Google Sheets -> BotLIKE Operations import API v1. * * Script Properties required: * BOTLIKE_OPERATIONS_BASE_URL HTTPS URL of the BotLIKE reverse proxy. * Plain HTTP is accepted only for localhost. * BOTLIKE_INBOUND_API_SECRET HMAC-SHA256 secret. * BOTLIKE_SOURCE_INSTANCE Stable logical spreadsheet/deployment name. * * No URL or secret is committed in this file. FBO rows use stable IDs stored * in the source sheet. Article-library identity is derived from the trimmed * article in column B; a physical row number and a row UUID are never used. */ var BOTLIKE_IMPORT_SCHEMA_VERSION_ = '1.0'; var BOTLIKE_ARTICLE_IMPORT_SCHEMA_VERSION_ = '2.0'; var BOTLIKE_IMPORT_PAGE_SIZE_ = 300; var BOTLIKE_IMPORT_MAX_ATTEMPTS_ = 4; var BOTLIKE_ARTICLE_SHEET_NAMES_ = ['Готовые']; var BOTLIKE_FBO_SHEET_NAMES_ = ['ФБО', 'Фбо']; var BOTLIKE_ARTICLE_SOURCE_FIELDS_ = [ 'article', 'name', 'category', 'sale_status', 'packaging', 'color_group', 'sheet_stock_total', 'material', 'legacy_article', 'sale_started_on', 'calibrated_c', 'calibrated_m', 'calibrated_y', 'calibrated_k', 'exact_ink_ml', 'final_profile', 'old_ink_ml', 'mimaki_ink_ml', 'stretch_g', 'tape_cm', 'width_source', 'height_source', 'area_m2', 'ink_ml_m2', 'print_cost', 'printer_wage_cost', 'packaging_cost', 'wb_logistics_cost', 'ozon_to_warehouse_cost', 'ozon_logistics_cost', 'frozen_total', 'wb_weekly_sales' ]; function pushArticleLibraryToBotLIKE() { return withBotlikeImportLock_(function () { var records = readArticleLibraryRecords_(BOTLIKE_ARTICLE_SHEET_NAMES_, { article: {aliases: ['Артикул'], required: true, fixedColumn: 2}, name: {aliases: ['Сюжет'], required: true}, category: {aliases: ['Категория']}, sale_status: {aliases: ['Статус продажи']}, packaging: {aliases: ['Размер упаковки']}, color_group: {aliases: ['Группировка по цвету']}, sheet_stock_total: {aliases: ['Остаток']}, material: {aliases: ['Материал'], required: true}, legacy_article: { aliases: ['Старое название артикула'] }, sale_started_on: {aliases: ['Дата начала продажи']}, calibrated_c: {aliases: ['Откалиброванный C']}, calibrated_m: {aliases: ['M'], fixedColumn: 21}, calibrated_y: {aliases: ['Y'], fixedColumn: 22}, calibrated_k: {aliases: ['K'], fixedColumn: 23}, exact_ink_ml: {aliases: ['мл точно']}, final_profile: {aliases: ['итоговый профиль']}, old_ink_ml: {aliases: ['старый расход']}, mimaki_ink_ml: {aliases: ['итог расход мимаки']}, stretch_g: {aliases: ['Расход стрейч, г']}, tape_cm: {aliases: ['Расход скотч, см']}, width_source: {aliases: ['Ш'], required: true}, height_source: {aliases: ['В'], required: true}, area_m2: {aliases: ['S']}, ink_ml_m2: {aliases: ['мл/кв.м.', 'мл/кв.м', 'мл/м2']}, print_cost: {aliases: ['Стоимость печати']}, printer_wage_cost: {aliases: ['ЗП Печатника']}, packaging_cost: {aliases: ['Упаковка']}, wb_logistics_cost: {aliases: ['Логистика ВБ']}, ozon_to_warehouse_cost: {aliases: ['доставка до склада ОЗОН']}, ozon_logistics_cost: {aliases: ['Логистика ОЗОН']}, frozen_total: {aliases: ['Итого заморожено']}, wb_weekly_sales: {aliases: ['Продажи WB в неделю']} }); var normalizedRecords = records.map(function (record) { return { row: record.row, value: normalizeReadyArticle_( record.value, record.errors, record.row ) }; }); var rows = deduplicateArticleLibraryRows_(normalizedRecords); return pushSnapshot_( 'article-library', 'gas.article_library', rows, BOTLIKE_ARTICLE_IMPORT_SCHEMA_VERSION_ ); }); } function pushFboPlanningToBotLIKE() { return withBotlikeImportLock_(function () { ensureStableIdColumn_(BOTLIKE_FBO_SHEET_NAMES_); var records = readMappedRecords_(BOTLIKE_FBO_SHEET_NAMES_, { fbo_item_id: { aliases: ['_botlike_row_uuid', '_botlike_record_id'], required: true }, wb_article: {aliases: ['Артикул ВБ']}, barcode: {aliases: ['Баркод']}, article: {aliases: ['Артикул'], required: true}, marketplace: {aliases: ['МП'], required: true}, is_needed: {aliases: ['Что нужно печатать']}, ml_m2: {aliases: ['мл/м2', 'мл/кв.м.', 'мл/кв.м']}, printer: {aliases: ['Принтер']}, need_to_print: {aliases: ['Нужно допечатать']}, week_need: {aliases: ['week need']}, transit: {aliases: ['transit']}, priority: {aliases: ['Приоритет']}, in_production: {aliases: ['В производстве']}, local_stock: {aliases: ['local stock']}, stock_plus_transit: {aliases: ['stock+transit']}, stock_total: {aliases: ['stock']}, avg_sales: {aliases: ['avg sales']}, days: {aliases: ['days']}, weeks: {aliases: ['weeks']}, fbo_fbs_weeks: {aliases: ['ФБО+ФБС недели', 'fbo+fbs weeks']} }); var rows = records.map(function (record) { return normalizeFboPlanning_(record.value, record.row); }); return pushSnapshot_('fbo-planning', 'gas.fbo_planning', rows); }); } /** * Backwards-compatible initializer. FBO export still requires the hidden UUID. * The article UUID column may remain in existing sheets, but article export * ignores it and derives identity from the trimmed article in column B. */ function initializeBotlikeRecordIds() { return withBotlikeImportLock_(function () { return [ ensureStableIdColumn_(BOTLIKE_ARTICLE_SHEET_NAMES_), ensureStableIdColumn_(BOTLIKE_FBO_SHEET_NAMES_) ]; }); } function resolveSheet_(sheetNames) { var candidates = Array.isArray(sheetNames) ? sheetNames : [sheetNames]; var spreadsheet = SpreadsheetApp.getActive(); for (var i = 0; i < candidates.length; i += 1) { var exact = spreadsheet.getSheetByName(candidates[i]); if (exact) return exact; } var expected = candidates.map(normalizeHeader_); var sheets = spreadsheet.getSheets(); for (var s = 0; s < sheets.length; s += 1) { if (expected.indexOf(normalizeHeader_(sheets[s].getName())) >= 0) { return sheets[s]; } } throw new Error('Лист не найден: ' + candidates.join(' / ')); } function ensureStableIdColumn_(sheetNames) { var sheet = resolveSheet_(sheetNames); var lastColumn = Math.max(1, sheet.getLastColumn()); var headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0] .map(normalizeHeader_); var canonicalHeader = '_botlike_row_uuid'; var index = headers.indexOf(normalizeHeader_(canonicalHeader)); var legacyIndex = headers.indexOf(normalizeHeader_('_botlike_record_id')); if (index < 0 && legacyIndex >= 0) { index = legacyIndex; sheet.getRange(1, index + 1).setValue(canonicalHeader); } if (index < 0) { index = lastColumn; sheet.getRange(1, index + 1).setValue(canonicalHeader); } var lastRow = sheet.getLastRow(); if (lastRow > 1) { var range = sheet.getRange(2, index + 1, lastRow - 1, 1); var values = range.getValues(); var legacyValues = ( legacyIndex >= 0 && legacyIndex !== index ? sheet.getRange(2, legacyIndex + 1, lastRow - 1, 1).getValues() : null ); var changed = false; values.forEach(function (row, rowIndex) { if (!String(row[0] || '').trim()) { var migrated = legacyValues ? String(legacyValues[rowIndex][0] || '').trim() : ''; row[0] = migrated || String(Utilities.getUuid()).toLowerCase(); changed = true; } }); if (changed) range.setValues(values); } sheet.hideColumns(index + 1); if (legacyIndex >= 0 && legacyIndex !== index) { sheet.hideColumns(legacyIndex + 1); } return { sheet: sheet.getName(), id_column: index + 1, rows: Math.max(0, lastRow - 1) }; } function withBotlikeImportLock_(callback) { var lock = LockService.getScriptLock(); if (!lock.tryLock(30000)) { throw new Error('Другой экспорт BotLIKE уже выполняется'); } try { return callback(); } finally { lock.releaseLock(); } } function normalizeHeader_(value) { return String(value || '') .replace(/[\u00A0\u202F]/g, ' ') .replace(/ё/g, 'е') .replace(/Ё/g, 'Е') .trim() .toLowerCase() .replace(/\s+/g, ' '); } function readMappedRecords_(sheetNames, fieldMap) { var sheet = resolveSheet_(sheetNames); var values = sheet.getDataRange().getValues(); if (values.length < 1) return []; var headers = values[0].map(normalizeHeader_); var indexes = {}; Object.keys(fieldMap).forEach(function (field) { var spec = fieldMap[field] || {}; var aliases = spec.aliases || [field]; var index = -1; for (var i = 0; i < aliases.length; i += 1) { index = headers.indexOf(normalizeHeader_(aliases[i])); if (index >= 0) break; } indexes[field] = index; if (spec.required && index < 0) { throw new Error( 'На листе ' + sheet.getName() + ' не найдена колонка: ' + aliases.join(' / ') ); } }); return values.slice(1).map(function (row, offset) { var result = {}; Object.keys(fieldMap).forEach(function (field) { var index = indexes[field]; if (index >= 0) result[field] = normalizeCell_(row[index]); }); return {row: offset + 2, value: result}; }).filter(function (record) { return Object.keys(record.value).some(function (field) { return field !== 'source_item_id' && field !== 'fbo_item_id' && String(record.value[field] || '').trim() !== ''; }); }); } function findUniqueHeaderIndex_(headers, aliases, sheetName, field) { for (var aliasIndex = 0; aliasIndex < aliases.length; aliasIndex += 1) { var wanted = normalizeHeader_(aliases[aliasIndex]); var matches = []; headers.forEach(function (header, index) { if (header === wanted) matches.push(index); }); if (matches.length > 1) { throw new Error( 'На листе ' + sheetName + ' заголовок "' + aliases[aliasIndex] + '" повторяется; поле ' + field + ' неоднозначно' ); } if (matches.length === 1) return matches[0]; } return -1; } function resolveArticleFieldIndexes_(headers, fieldMap, sheetName) { var indexes = {}; Object.keys(fieldMap).forEach(function (field) { var spec = fieldMap[field] || {}; var aliases = spec.aliases || [field]; var index = -1; if (spec.fixedColumn) { index = Number(spec.fixedColumn) - 1; var expected = aliases.map(normalizeHeader_); if ( index < 0 || index >= headers.length || expected.indexOf(headers[index]) < 0 ) { throw new Error( 'Схема листа ' + sheetName + ' изменилась: в колонке ' + spec.fixedColumn + ' для поля ' + field + ' ожидался заголовок ' + aliases.join(' / ') ); } } else { index = findUniqueHeaderIndex_(headers, aliases, sheetName, field); } indexes[field] = index; if (index < 0) { throw new Error( 'На листе ' + sheetName + ' не найдена колонка: ' + aliases.join(' / ') ); } }); return indexes; } function isDisplayedCellError_(displayValue) { return String(displayValue == null ? '' : displayValue).trim().charAt(0) === '#'; } function articleCellValue_(value, displayValue, field, errors) { var display = String(displayValue == null ? '' : displayValue).trim(); if (isDisplayedCellError_(display)) { errors[field] = display; return null; } if (value == null || (value === '' && display === '')) return null; return normalizeCell_(value); } function readArticleLibraryRecords_(sheetNames, fieldMap) { var sheet = resolveSheet_(sheetNames); var range = sheet.getDataRange(); var values = range.getValues(); var displayValues = range.getDisplayValues(); if (values.length < 1) return []; var headers = values[0].map(normalizeHeader_); var indexes = resolveArticleFieldIndexes_( headers, fieldMap, sheet.getName() ); return values.slice(1).map(function (row, offset) { var result = {}; var display = {}; var errors = {}; Object.keys(fieldMap).forEach(function (field) { var index = indexes[field]; if (index < 0) { result[field] = null; display[field] = ''; return; } display[field] = String(displayValues[offset + 1][index] || ''); result[field] = articleCellValue_( row[index], display[field], field, errors ); }); return {row: offset + 2, value: result, errors: errors}; }).filter(function (record) { return String(record.value.article || '').trim() !== '' || Object.prototype.hasOwnProperty.call(record.errors, 'article'); }); } function normalizeCell_(value) { if (value instanceof Date) return value.toISOString(); return value; } function requireStableId_(row, key, sheetRow) { if (!String(row[key] || '').trim()) { throw new Error(key + ' обязателен, строка ' + sheetRow); } } function requireCell_(value, label, sheetRow) { var normalized = String(value || '').trim(); if (!normalized) { throw new Error(label + ' обязателен, строка ' + sheetRow); } return normalized; } function articleSourceItemId_(article, sheetRow) { var normalized = requireCell_(article, 'Артикул', sheetRow); var identity = normalized.toLowerCase(); if (identity.length > 256) { throw new Error( 'Артикул слишком длинный для source_item_id, строка ' + sheetRow ); } return identity; } function numberCell_(value, fallback) { if (typeof value === 'number' && isFinite(value)) return value; var normalized = String(value == null ? '' : value) .replace(/[\u00A0\u202F\s]/g, '') .replace(',', '.') .replace(/[^0-9.\-]/g, ''); var parsed = Number(normalized); return isFinite(parsed) ? parsed : (fallback || 0); } function nullableNumberCell_(value, field, sheetRow, errors) { if (value == null || value === '') return null; if (typeof value === 'number') { if (isFinite(value)) return value; errors[field] = 'Неконечное число'; return null; } var normalized = String(value) .replace(/[\u00A0\u202F\s]/g, '') .replace(',', '.'); if (!normalized || normalized === '-') return null; if (!/^-?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) { errors[field] = 'Некорректное число: ' + String(value); return null; } var parsed = Number(normalized); if (!isFinite(parsed)) { errors[field] = 'Неконечное число'; return null; } return parsed; } function dimensionToMm_(value) { var number = numberCell_(value, 0); if (number <= 0) return 0; if (number <= 20) return Math.round(number * 1000); if (number < 1000) return Math.round(number * 10); return Math.round(number); } function saleStatus_(value) { var status = normalizeHeader_(value); return status === 'стоп' ? 'inactive' : 'active'; } function marketplace_(value) { var marketplace = normalizeHeader_(value); if (marketplace === 'вб' || marketplace === 'wildberries') return 'wb'; if (marketplace === 'озон') return 'ozon'; return marketplace; } function flagCell_(value) { if (value === true || value === 1) return true; var normalized = normalizeHeader_(value); return ( normalized === 'да' || normalized === 'yes' || normalized === 'true' || normalized === '1' || normalized === 'нужно' ); } function normalizeReadyArticle_(raw, sourceErrors, sheetRow) { var article = requireCell_(raw.article, 'Артикул', sheetRow); var sourceItemId = articleSourceItemId_(article, sheetRow); var errors = {}; Object.keys(sourceErrors || {}).forEach(function (field) { errors[field] = sourceErrors[field]; }); var numericFields = [ 'sheet_stock_total', 'calibrated_c', 'calibrated_m', 'calibrated_y', 'calibrated_k', 'exact_ink_ml', 'old_ink_ml', 'mimaki_ink_ml', 'stretch_g', 'tape_cm', 'area_m2', 'ink_ml_m2', 'print_cost', 'printer_wage_cost', 'packaging_cost', 'wb_logistics_cost', 'ozon_to_warehouse_cost', 'ozon_logistics_cost', 'frozen_total', 'wb_weekly_sales' ]; var numbers = {}; numericFields.forEach(function (field) { numbers[field] = nullableNumberCell_( raw[field], field, sheetRow, errors ); }); var widthSource = nullableNumberCell_( raw.width_source, 'width_source', sheetRow, errors ); var heightSource = nullableNumberCell_( raw.height_source, 'height_source', sheetRow, errors ); var widthMm = dimensionToMm_(widthSource); var heightMm = dimensionToMm_(heightSource); if (widthMm <= 0 || heightMm <= 0) { throw new Error('Ш и В должны быть положительными, строка ' + sheetRow); } var stripCount = Math.max(1, Math.round(widthMm / 1040)); var nominalLengthM = Number( ((heightMm / 1000) * stripCount).toFixed(3) ); var sourceValues = {}; BOTLIKE_ARTICLE_SOURCE_FIELDS_.forEach(function (field) { sourceValues[field] = raw[field] == null ? null : normalizeCell_(raw[field]); }); numericFields.forEach(function (field) { sourceValues[field] = numbers[field]; }); sourceValues.article = article; sourceValues.width_source = widthSource; sourceValues.height_source = heightSource; return { source_item_id: sourceItemId, article: article, name: String(raw.name || article).trim(), category: raw.category == null ? null : String(raw.category).trim(), material: requireCell_(raw.material, 'Материал', sheetRow), width_mm: widthMm, height_mm: heightMm, strip_count: stripCount, nominal_length_m: nominalLengthM, packaging: raw.packaging == null ? null : String(raw.packaging).trim(), sale_status: saleStatus_(raw.sale_status), legacy_article: ( raw.legacy_article == null ? null : String(raw.legacy_article).trim() ), area_m2: numbers.area_m2, ink_ml_m2: numbers.ink_ml_m2, color_group: ( raw.color_group == null ? null : String(raw.color_group).trim() ), sheet_stock_total: numbers.sheet_stock_total, sale_started_on: ( raw.sale_started_on == null ? null : String(raw.sale_started_on).trim() ), calibrated_c: numbers.calibrated_c, calibrated_m: numbers.calibrated_m, calibrated_y: numbers.calibrated_y, calibrated_k: numbers.calibrated_k, exact_ink_ml: numbers.exact_ink_ml, final_profile: ( raw.final_profile == null ? null : String(raw.final_profile).trim() ), old_ink_ml: numbers.old_ink_ml, mimaki_ink_ml: numbers.mimaki_ink_ml, stretch_g: numbers.stretch_g, tape_cm: numbers.tape_cm, print_cost: numbers.print_cost, printer_wage_cost: numbers.printer_wage_cost, packaging_cost: numbers.packaging_cost, wb_logistics_cost: numbers.wb_logistics_cost, ozon_to_warehouse_cost: numbers.ozon_to_warehouse_cost, ozon_logistics_cost: numbers.ozon_logistics_cost, frozen_total: numbers.frozen_total, wb_weekly_sales: numbers.wb_weekly_sales, source_values: sourceValues, cell_errors: errors, listings: [] }; } function deduplicateArticleLibraryRows_(records) { var byIdentity = Object.create(null); var rows = []; records.forEach(function (record) { var sheetRow = Number(record.row); var row = record.value; var identity = articleSourceItemId_(row.article, sheetRow); if (row.source_item_id !== identity) { throw new Error( 'Некорректный source_item_id для артикула "' + row.article + '", строка ' + sheetRow ); } var fingerprint = stableJson_(row); var existing = byIdentity[identity]; if (!existing) { byIdentity[identity] = { fingerprint: fingerprint, rows: [sheetRow], article: row.article }; rows.push(row); return; } if (existing.fingerprint === fingerprint) { existing.rows.push(sheetRow); return; } throw new Error( 'Конфликтующие дубли артикула "' + existing.article + '": строки ' + existing.rows.concat([sheetRow]).join(', ') ); }); return rows; } function normalizeFboPlanning_(raw, sheetRow) { requireStableId_(raw, 'fbo_item_id', sheetRow); var marketplace = marketplace_(requireCell_(raw.marketplace, 'МП', sheetRow)); return { fbo_item_id: String(raw.fbo_item_id).trim().toLowerCase(), article: requireCell_(raw.article, 'Артикул', sheetRow), marketplace: marketplace, wb_article: String(raw.wb_article || '').trim(), barcode: String(raw.barcode || '').trim(), is_needed: flagCell_(raw.is_needed), ml_m2: Math.max(0, numberCell_(raw.ml_m2, 0)), printer: String(raw.printer || '').trim(), need_to_print: Math.max(0, Math.floor(numberCell_(raw.need_to_print, 0))), week_need: Math.max(0, Math.floor(numberCell_(raw.week_need, 0))), transit: Math.max(0, Math.floor(numberCell_(raw.transit, 0))), priority: Math.max(0, Math.floor(numberCell_(raw.priority, 0))), in_production: Math.max(0, Math.floor(numberCell_(raw.in_production, 0))), local_stock: Math.max(0, Math.floor(numberCell_(raw.local_stock, 0))), stock_plus_transit: Math.max( 0, Math.floor(numberCell_(raw.stock_plus_transit, 0)) ), stock_total: Math.max(0, Math.floor(numberCell_(raw.stock_total, 0))), avg_sales: Math.max(0, numberCell_(raw.avg_sales, 0)), days: Math.max(0, numberCell_(raw.days, 0)), weeks: Math.max(0, numberCell_(raw.weeks, 0)), fbo_fbs_weeks: String(raw.fbo_fbs_weeks || '').trim() }; } function pushSnapshot_(entityKind, source, rows, schemaVersion) { var config = botlikeImportConfig_(); var batchId = String(Utilities.getUuid()).toLowerCase(); var generatedAt = new Date().toISOString(); var totalRows = rows.length; var pageCount = Math.max(1, Math.ceil(totalRows / BOTLIKE_IMPORT_PAGE_SIZE_)); var pageResults = []; for (var page = 1; page <= pageCount; page += 1) { var pageRows = rows.slice( (page - 1) * BOTLIKE_IMPORT_PAGE_SIZE_, page * BOTLIKE_IMPORT_PAGE_SIZE_ ); var envelope = { schema_version: schemaVersion || BOTLIKE_IMPORT_SCHEMA_VERSION_, source: source, source_instance: config.sourceInstance, batch_id: batchId, mode: 'snapshot', generated_at: generatedAt, page: page, page_count: pageCount, row_count: pageRows.length, payload_sha256: sha256Hex_(stableJson_(pageRows)), rows: pageRows }; pageResults.push(urlFetchSignedJson_( config, '/api/operations/v1/imports/' + encodeURIComponent(entityKind) + '/batches', envelope, batchId + ':' + page )); } var committed = urlFetchSignedJson_( config, '/api/operations/v1/imports/' + encodeURIComponent(entityKind) + '/batches/' + encodeURIComponent(batchId) + '/commit', { batch_id: batchId, source: source, source_instance: config.sourceInstance }, batchId + ':commit' ); return { batch_id: batchId, page_count: pageCount, total_rows: totalRows, pages: pageResults, commit: committed }; } function botlikeImportConfig_() { var properties = PropertiesService.getScriptProperties(); var baseUrl = String(properties.getProperty('BOTLIKE_OPERATIONS_BASE_URL') || '') .trim().replace(/\/+$/, ''); var secret = String(properties.getProperty('BOTLIKE_INBOUND_API_SECRET') || ''); var sourceInstance = String( properties.getProperty('BOTLIKE_SOURCE_INSTANCE') || '' ).trim(); if (!baseUrl || !secret || !sourceInstance) { throw new Error( 'Задайте BOTLIKE_OPERATIONS_BASE_URL, BOTLIKE_INBOUND_API_SECRET ' + 'и BOTLIKE_SOURCE_INSTANCE в Script Properties' ); } var https = /^https:\/\//i.test(baseUrl); var loopback = /^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/i.test(baseUrl); if (!https && !loopback) { throw new Error('Production BotLIKE import API должен быть доступен через HTTPS'); } return {baseUrl: baseUrl, secret: secret, sourceInstance: sourceInstance}; } function urlFetchSignedJson_(config, path, payload, idempotencyKey) { var body = JSON.stringify(payload); var lastError = null; for (var attempt = 1; attempt <= BOTLIKE_IMPORT_MAX_ATTEMPTS_; attempt += 1) { var timestamp = String(Math.floor(Date.now() / 1000)); var nonce = String(Utilities.getUuid()).toLowerCase(); var bodyHash = sha256Hex_(body); var signature = 'v1=' + hmacSha256Hex_( timestamp + '\n' + nonce + '\n' + bodyHash, config.secret ); var response; try { response = UrlFetchApp.fetch(config.baseUrl + path, { method: 'post', contentType: 'application/json; charset=utf-8', payload: body, muteHttpExceptions: true, followRedirects: false, headers: { 'X-BotLIKE-Timestamp': timestamp, 'X-BotLIKE-Nonce': nonce, 'X-BotLIKE-Signature': signature, 'Idempotency-Key': idempotencyKey } }); } catch (error) { lastError = error; if (attempt === BOTLIKE_IMPORT_MAX_ATTEMPTS_) throw error; retryDelay_(attempt); continue; } var status = response.getResponseCode(); var text = response.getContentText(); if (status >= 200 && status < 300) { return text ? JSON.parse(text) : {}; } var retryable = status === 408 || status === 429 || status >= 500; if (!retryable || attempt === BOTLIKE_IMPORT_MAX_ATTEMPTS_) { throw new Error('BotLIKE HTTP ' + status + ': ' + text.slice(0, 1000)); } lastError = new Error('BotLIKE HTTP ' + status); retryDelay_(attempt); } throw lastError || new Error('BotLIKE import failed'); } function retryDelay_(attempt) { var base = Math.min(8000, 500 * Math.pow(2, attempt - 1)); Utilities.sleep(base + Math.floor(Math.random() * 350)); } function stableJson_(value) { if (value === null || typeof value !== 'object') return JSON.stringify(value); if (Array.isArray(value)) { return '[' + value.map(stableJson_).join(',') + ']'; } return '{' + Object.keys(value).sort().map(function (key) { return JSON.stringify(key) + ':' + stableJson_(value[key]); }).join(',') + '}'; } function sha256Hex_(text) { return bytesToHex_(Utilities.computeDigest( Utilities.DigestAlgorithm.SHA_256, text, Utilities.Charset.UTF_8 )); } function hmacSha256Hex_(text, secret) { return bytesToHex_(Utilities.computeHmacSha256Signature( text, secret, Utilities.Charset.UTF_8 )); } function bytesToHex_(bytes) { return bytes.map(function (value) { var normalized = value < 0 ? value + 256 : value; return ('0' + normalized.toString(16)).slice(-2); }).join(''); }