Синхронизируй спенд через расширение FB Spend Sync, затем нажми «Сегодня»
КАБИНЕТЫ И КИНГИ
Кинг
Прошлые кинги
ID кабинета
Статус
Гео
Пиксели
Акт. кампаний
Действия
Google Sheets
Не настроено
URL Apps Script
Активный SUB3 (тег байера) из листа «Тег Баера», столбец A
Кастомный SUB2 если задан — sub2 = кастом+номер, иначе тег байера
База казино — ID/ссылка Google Таблицы откуда тянуть id casino
Если пусто — используется база по умолчанию. Лист должен называться «Id Казино». После вставки жми «↻ Обновить базу казино».
ПредупрежденияНе предупреждать о несовпадении гео
Убирает ⚠ и вопрос при добавлении, если гео аккаунта ≠ гео кампании
АвтосабыПроставлять sub20 = стратегия ставок
sub20 = cc (Cost Cap) / bc (Bid Cap) / hv (High Volume), для всех платформ
✓ При открытии трекера данные автоматически загружаются из таблицы
✓ При добавлении кампании она сразу сохраняется в таблицу
✓ Работает с любого компьютера/браузера
1Google Таблица → Расширения → Apps Script
2Вставь код, разверни: Веб-приложение, Доступ: Все
const SHEET_CAMPS = 'Запуски';
const SHEET_ACCS = 'Аккаунты';
const HEADERS = [
'Нейминг кампании','Профиль','Айди Кабинета','Нейминг адсета','Название крео',
'Страна','Казино','ID Казино','Ссылка ПВА','Отображающаяся ссылка',
'Текст','Заголовок','Описание','Параметры','Пометки',
'Проверка кабов','Вкл/Выкл','Цена лида','Связка',
'CBO/ABO','Топ оффер','Итог','Сервис пва',
'Формат крео','Бюджет $','Стратегия','Bid Amount','Аудитория','Тип крео','Поток','Сетап','Дата',
'Возраст от','Возраст до','Тип кампании','Бонус','Дата акции','Шаблон №'
].concat(Array(21).fill('')).concat(['Доп. поля (JSON)']);
// колонки 1-38 — основные, 60-я — JSON с доп-полями (отложка, вариативность ставок и т.д.)
function getSheet(name) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
return ss.getSheetByName(name) || ss.insertSheet(name);
}
function ensureHeaders(sh) {
if (sh.getLastRow() === 0) {
sh.appendRow(HEADERS);
} else {
// Всегда записываем заголовки целиком в первую строку
// Расширяем диапазон если нужно
var currentCols = sh.getLastColumn();
var neededCols = HEADERS.length;
sh.getRange(1, 1, 1, neededCols).setValues([HEADERS]);
}
// Форматируем заголовки
sh.getRange(1, 1, 1, HEADERS.length)
.setFontWeight('bold')
.setBackground('#e8eaf6')
.setFontColor('#1a1a2e');
sh.setFrozenRows(1);
}
const ACCOUNT_HEADERS = ['Кинги','Активный кинг','ID кабинета','Статус','Трепей','ГЕО','Пиксели','Часовой пояс','Фанпейджи JSON'];
function ensureAccountHeaders(sh) {
sh.getRange(1,1,1,ACCOUNT_HEADERS.length).setValues([ACCOUNT_HEADERS]);
sh.getRange(1,3).setNumberFormat('@');
sh.setFrozenRows(1);
}
function normalizeSheetAccountId(value) {
return String(value == null ? '' : value).trim().replace(/^act_/i,'').replace(/\.0+$/,'').replace(/\s+/g,'');
}
function mergeSheetAccountRows(base,incoming) {
const merged=(base||Array(9).fill('')).slice(0,9);
while(merged.length<9)merged.push('');
for(let col=0;col<9;col++){
const next=incoming[col];
if(col===4){ if(!merged[col]&&next!==''&&next!=null)merged[col]=next; continue; }
if(next!==''&&next!=null)merged[col]=next;
}
merged[2]=normalizeSheetAccountId(merged[2]);
return merged;
}
function uniqueSheetAccountRows(rows) {
const byCab=new Map();
const order=[];
let duplicates=0;
(rows||[]).forEach(function(row){
const cab=normalizeSheetAccountId(row[2]);
if(!cab)return;
const normalized=row.slice(0,9);while(normalized.length<9)normalized.push('');normalized[2]=cab;
if(!byCab.has(cab)){byCab.set(cab,normalized);order.push(cab);}
else{byCab.set(cab,mergeSheetAccountRows(byCab.get(cab),normalized));duplicates++;}
});
return {rows:order.map(function(cab){return byCab.get(cab);}),duplicates:duplicates};
}
function doGet(e) {
const action = e && e.parameter && e.parameter.action;
if (action === 'ping') return json({ok:true, msg:'pong'});
// Обновить заголовки во всех листах (добавить новые колонки)
if (action === 'get_campaign_names') {
const sh = getSheet(SHEET_CAMPS);
const last = sh.getLastRow();
if (last < 2) return json({ok:true, names:[]});
const names = sh.getRange(2,1,last-1,1).getValues().flat().map(String).filter(Boolean);
return json({ok:true, names});
}
if (action === 'update_headers') {
const sh = getSheet(SHEET_CAMPS);
ensureHeaders(sh);
return json({ok:true, msg:'Headers updated to '+HEADERS.length+' columns'});
}
if (action === 'read') {
const sh = getSheet(SHEET_CAMPS);
const last = sh.getLastRow();
if (last < 2) return json({ok:true, rows:[]});
const lastCol = Math.max(sh.getLastColumn(), HEADERS.length);
const range = sh.getRange(2,1,last-1,lastCol);
const rows = range.getValues();
const displayed = range.getDisplayValues();
// Даты кампании и акции — календарные значения, а не моменты времени.
// Date из getValues() JSON превращает в UTC и сдвигает день назад
// (15.09 00:00 МСК -> 14.09 21:00Z), поэтому отдаём видимый текст ячейки.
rows.forEach(function(row,i){
if (row.length > 31) row[31] = displayed[i][31];
if (row.length > 36) row[36] = displayed[i][36];
});
return json({ok:true, rows:rows});
}
if (action === 'read_buyer_tag') {
const sh = getSheet('Тег Баера');
if (!sh) return json({ok:true, tag:'', tags:[]});
const last = sh.getLastRow();
if (last < 1) return json({ok:true, tag:'', tags:[]});
// Читаем весь столбец A — все доступные sub3
const vals = sh.getRange(1,1,last,1).getValues().flat()
.map(function(v){ return String(v).trim(); }).filter(Boolean);
return json({ok:true, tag:(vals[0]||''), tags:vals});
}
if (action === 'read_accounts') {
const sh = getSheet(SHEET_ACCS);
const last = sh.getLastRow();
if (last < 2) return json({ok:true, rows:[]});
const compact=uniqueSheetAccountRows(sh.getRange(2,1,last-1,9).getDisplayValues());
return json({ok:true, rows:compact.rows, duplicates:compact.duplicates});
}
if (action === 'read_templates') {
const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Шаблоны');
if (!sh) return json({ok:true, rows:[]});
const last = sh.getLastRow();
if (last < 2) return json({ok:true, rows:[]});
const lastCol = sh.getLastColumn();
return json({ok:true, rows: sh.getRange(2,1,last-1,lastCol).getValues(), headers: sh.getRange(1,1,1,lastCol).getValues()[0]});
}
if (action === 'read_casino_db') {
const sh = getSheet('Id Казино');
if (!sh) return json({ok:true, rows:[]});
const last = sh.getLastRow();
if (last < 2) return json({ok:true, rows:[]});
const lastCol = sh.getLastColumn();
return json({ok:true, rows: sh.getRange(2,1,last-1,lastCol).getValues(), headers: sh.getRange(1,1,1,lastCol).getValues()[0]});
}
if (action === 'read_streams') {
const sh = getSheet('Потоки');
if (!sh) return json({ok:true, rows:[]});
const last = sh.getLastRow();
if (last < 1) return json({ok:true, rows:[]});
// Колонка A = код потока, B = подсказка
return json({ok:true, rows: sh.getRange(1,1,last,2).getValues()});
}
return json({ok:true});
}
function doPost(e) {
const d = JSON.parse(e.postData.contents);
const sh = getSheet(SHEET_CAMPS);
ensureHeaders(sh);
if (d.action === 'append' || d.action === 'append_new') {
const name = d.row && d.row[0];
if (!name) return json({ok:false, error:'no name'});
// Код запуска из начала названия (DN2115_... -> DN2115). При изменении
// гео/казино/даты/потока строка обновляется, но DN2115 и SV2115 остаются
// разными кампаниями.
function campKey(n) {
const m = String(n||'').trim().match(/^([A-Za-zА-Яа-я]+)[_\s-]*(\d+)(?:[_\s-]|$)/);
return m ? m[1].toUpperCase()+m[2] : null;
}
const incomingKey = campKey(name);
const lock=LockService.getScriptLock();
lock.waitLock(30000);
try{
const last = sh.getLastRow();
if (last > 1) {
const names = sh.getRange(2,1,last-1,1).getValues().flat();
let idx = names.indexOf(name);
if (idx < 0 && incomingKey) idx = names.findIndex(n => campKey(n) === incomingKey);
if (idx >= 0) {
if (d.action === 'append_new') return json({ok:true, action:'skipped'});
sh.getRange(idx+2, 1, 1, d.row.length).setValues([d.row]);
return json({ok:true, action:'updated'});
}
}
sh.appendRow(d.row);
return json({ok:true, action:'added'});
}finally{lock.releaseLock();}
}
if (d.action === 'delete' && d.name) {
const last = sh.getLastRow();
if (last > 1) {
const names = sh.getRange(2,1,last-1,1).getValues().flat();
// удаляем снизу вверх на случай нескольких совпадений
for (let i = names.length - 1; i >= 0; i--) {
if (String(names[i]) === String(d.name)) sh.deleteRow(i + 2);
}
}
return json({ok:true, action:'deleted'});
}
if (d.action === 'sync_all' && d.rows) {
sh.clearContents(); sh.clearFormats();
ensureHeaders(sh);
d.rows.forEach(r => sh.appendRow(r));
return json({ok:true});
}
if (d.action === 'clear_formats') {
const last = sh.getLastRow();
if (last > 1) sh.getRange(2,1,last-1,HEADERS.length).setBackground(null);
return json({ok:true});
}
// Полная безопасная синхронизация листа Аккаунты. Один ID кабинета = одна
// строка. Неизвестные трекеру кабинеты сохраняются, колонка «Трепей» тоже.
if (d.action === 'sync_accounts' && Array.isArray(d.rows)) {
const lock=LockService.getScriptLock();
lock.waitLock(30000);
try{
const sh2=getSheet(SHEET_ACCS);ensureAccountHeaders(sh2);
const last2=sh2.getLastRow();
const existing=last2>1?sh2.getRange(2,1,last2-1,9).getDisplayValues():[];
const compactExisting=uniqueSheetAccountRows(existing);
const incoming=uniqueSheetAccountRows(d.rows);
const byCab=new Map();
const order=[];
compactExisting.rows.forEach(function(row){const cab=normalizeSheetAccountId(row[2]);byCab.set(cab,row);order.push(cab);});
incoming.rows.forEach(function(row){
const cab=normalizeSheetAccountId(row[2]);
const previous=byCab.get(cab)||Array(9).fill('');
const merged=mergeSheetAccountRows(previous,row);
// «Трепей» управляется вручную в таблице — входящая пустая колонка его не стирает.
merged[4]=previous[4]||row[4]||'';
if(!byCab.has(cab))order.push(cab);
byCab.set(cab,merged);
});
const cleanRows=order.map(function(cab){return byCab.get(cab);});
if(last2>1)sh2.getRange(2,1,last2-1,9).clearContent();
if(cleanRows.length){
sh2.getRange(2,1,cleanRows.length,9).setValues(cleanRows);
sh2.getRange(2,3,cleanRows.length,1).setNumberFormat('@');
}
return json({ok:true,action:'accounts_synced',rows:cleanRows.length,duplicates_removed:compactExisting.duplicates+incoming.duplicates});
}finally{lock.releaseLock();}
}
// Точечная запись аккаунта также удаляет все старые дубли этого ID. Lock
// не даёт двум вкладкам одновременно создать две строки одного кабинета.
if (d.action === 'write_account' && d.row) {
const lock=LockService.getScriptLock();
lock.waitLock(30000);
try{
const sh2=getSheet(SHEET_ACCS);ensureAccountHeaders(sh2);
const cab=normalizeSheetAccountId(d.row[2]);
if(!cab)return json({ok:false,error:'no cab'});
const last2=sh2.getLastRow();
const matches=[];
if(last2>1){
const ids=sh2.getRange(2,3,last2-1,1).getDisplayValues();
ids.forEach(function(row,index){if(normalizeSheetAccountId(row[0])===cab)matches.push(index+2);});
}
const rowData=[d.row[0]||'',d.row[1]||'',cab,d.row[3]||'',d.row[4]||'',d.row[5]||'',d.row[6]||'',d.row[7]||'',d.row[8]||''];
let rowNum=matches.length?matches[0]:sh2.getLastRow()+1;
const tripay=matches.length?sh2.getRange(rowNum,5).getValue():(d.row[4]||'');
rowData[4]=tripay;
sh2.getRange(rowNum,1,1,9).setValues([rowData]);
sh2.getRange(rowNum,3).setNumberFormat('@').setValue(cab);
for(let i=matches.length-1;i>=1;i--)sh2.deleteRow(matches[i]);
return json({ok:true,action:matches.length?'updated':'added',duplicates_removed:Math.max(0,matches.length-1)});
}finally{lock.releaseLock();}
}
// Обновить спенд и метрики кампаний (из расширения FB CSV)
if (d.action === 'update_spend' && d.campaigns) {
const last = sh.getLastRow();
if (last < 2) return json({ok:true, updated:0});
const names = sh.getRange(2,1,last-1,1).getValues().flat().map(String);
const SPEND_COL=39, CPC_COL=40, CPL_COL=41, CPR_COL=42, CPD_COL=43;
let updated=0;
d.campaigns.forEach(function(camp) {
const idx = names.findIndex(function(n){ return n===camp.name; });
if (idx < 0) return;
const rowNum = idx + 2;
if (camp.spend!==undefined) sh.getRange(rowNum, SPEND_COL).setValue(camp.spend);
if (camp.cpc!==undefined) sh.getRange(rowNum, CPC_COL).setValue(camp.cpc);
if (camp.cpl!==undefined) sh.getRange(rowNum, CPL_COL).setValue(camp.cpl);
if (camp.cpr!==undefined) sh.getRange(rowNum, CPR_COL).setValue(camp.cpr);
if (camp.cpd!==undefined) sh.getRange(rowNum, CPD_COL).setValue(camp.cpd);
updated++;
});
return json({ok:true, updated});
}
return json({ok:true});
}
function json(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
3Вставь URL выше
⚡ Binom — подключение и ID кампаний
URL и API-ключ Binom — у каждого свои. ID кампаний по платформам нужны для загрузки статистики (EPC, конверсии, профит) по sub2.
URL Binom (без слеша в конце)
API Key (из URL панели: ?auth=...)
ID КАМПАНИЙ ПО ПЛАТФОРМАМ
OUR PWA
PWApartners
SKAK
IOS
ID кампании можно найти в URL панели Binom: /panel/report/campaign/171
Данные
СТАРТОВЫЙ НОМЕР ЗАПУСКА
Текущий: — · Авто = макс. из кампаний + 1
Занято: —
Сделает бэкап JSON, удалит ВСЁ из localStorage браузера и перезагрузит страницу. Твои Google Sheets и сервер не трогаются — трекер заново подтянет данные оттуда.
Статистика
Кампаний0
Кабинетов0
Гео0
Активных0
Добавить кабинет
Кинг *
Выбери кинга *
ГЕО
Статус
Часовой пояс кабинета необязательно · нужно для отложки по МСК
ID кабинетов * каждый с новой строки
Сменить Кинг
Новый кинг *
Добавить пиксель
ID пикселя *
Описание необяз.
Добавить подход
Название *
В sub1: название-формат
🎬 Video IDs для крео
Укажи Video ID для каждого видео крео. Если ID указан — файл загружать не нужно, FB возьмёт видео из библиотеки.
Удалить подход
Выбери
Смена статуса с СЕЛФИ
Сменить кинга или оставить прежнего?
Новый кинг оставь пустым — кинг не изменится
Изменить гео аккаунта
Новое гео *
Скопировать для Facebook
Дублировать в кабинет
Кабинет назначения *
Начальный № запуска *
Пиксель назначения *
Стратегия ставок
Дневной бюджет ($) пусто = оставить как в оригинале