Facebook Qubix Bridge
Facebook Qubix Bridge — это SDK, через который пользовательский скрипт обращается к Facebook напрямую: скрипт отправляет собственный запрос — в Graph API или на любой другой адрес *.facebook.com, — а Qubix проводит его через браузерный профиль, который вы уже подключили: с ключом доступа этого профиля, его куками, его прокси и подписью его браузера. Ответ Facebook возвращается скрипту как есть — вплоть до двоичного тела. Всё, что профилю позволено делать в рекламном кабинете руками, скрипт теперь может делать по расписанию: читать настройки и статистику, менять бюджеты, останавливать и запускать, выкачивать креативы, чистить комментарии.
Настраивать ничего не нужно: профиль подключён к Qubix — мост уже работает. Что запросу позволено — решают права этого профиля внутри Facebook: если профиль не может править кампанию руками, Facebook откажет и скрипту, и скрипт увидит этот отказ слово в слово.
Это труба, и только труба: адрес, способ, доводы и заголовки пишете вы, а ключ, куки, прокси и подпись браузера подставляет Qubix. Готовых функций управления кампаниями здесь нет намеренно — они вмораживали бы сегодняшнюю форму Facebook в поставку и ломались бы при первой же его правке. Высокоуровневое берите из образцов и правьте под себя: мост о форме запроса не знает ничего, поэтому не устаревает.
Как идёт запрос
Команда fb доступна в пользовательских скриптах — и по расписанию, и по кнопке ▶ Запустить. В правилах Britva её нет.
Два способа выбрать профиль-исполнитель
Каждый запрос отправляет какой-то браузерный профиль — исполнитель. Вы либо называете его сами, либо доверяете выбор Qubix.
// а) выбор за Qubix: любой профиль, который видит эту кампанию
const res = fb.campaign(campaignId).request({
method: 'GET',
url: `https://graph.facebook.com/${campaignId}`,
params: { fields: 'name,daily_budget' },
})
// б) названный профиль: без подбора и без запасных
const res2 = fb.profile(profileId).request({
method: 'GET',
url: 'https://graph.facebook.com/me',
params: { fields: 'id,name' },
})
fb.campaign(campaignId)— кампания должна входить в набор данных вашего скрипта (то, что видно вашей роли). Исполнителя Qubix подбирает среди профилей, которые видят эту кампанию, предпочитая профили владельца объявления. Если кандидат отпал на уровне доставки — умер прокси, оборвалась связь, — пробуется следующий, но только для чтения: изменение (POST,DELETE) через другой профиль не повторяется никогда, поэтому не может примениться дважды. Как только Facebook ответил — пусть даже отказом, — перебор останавливается. У цели есть иgetProfiles()— тот же перечень профилей, что у кампании из выборки.fb.profile(profileId)— запрос идёт ровно через этот профиль, без запасных. Цель несёт и поля самого профиля (name,tokenAlive,onCheckpoint, …) — так цели легко отличать друг от друга, когда вы перебираете несколько.
Какие профили видят объект
Прежде чем что-то отправлять, скрипт может спросить, какие профили могут работать с кампанией или объявлением, — со всем, что Qubix знает об их состоянии:
const profiles = fb.campaign(campaignId).getProfiles()
for (const p of profiles) {
console.log(p.name, 'ключ жив:', p.tokenAlive, 'на проверке:', p.onCheckpoint)
}
Возвращается массив: одну кампанию часто видят несколько профилей, уровни доступа у них в Facebook разные, и выбор — за вами. В каждой записи:
| Поле | Значение |
|---|---|
id | идентификатор профиля — его передают в fb.profile(...) |
name | имя профиля |
group | группа профиля |
ownerBuyerId | закупщик — владелец профиля |
tokenAlive | жив ли ключ доступа |
onCheckpoint | не стоит ли профиль на проверочной странице Facebook |
hasProxy | настроен ли у профиля прокси |
Поля «уровень доступа» здесь нет намеренно: Qubix нигде его не хранит. Что профиль может сделать с объектом, отвечает сам Facebook — пошлите запрос и прочитайте ответ. tokenAlive, onCheckpoint и hasProxy — состояние, а не разрешение: профиль с мёртвым ключом в перечне остаётся, чтобы вы это увидели и решили сами. Объект вне вашего набора данных возвращает пустой массив, а не ошибку.
Запрос
Есть и плоская форма — fb.request({ ...настройки, profileId }) либо campaignId прямо в настройках: тот же запрос без отдельной цели.
request(options) принимает один объект:
| Параметр | Значение |
|---|---|
url | абсолютный адрес запроса: `https://graph.facebook.com/${campaignId}`, https://graph.facebook.com/me/adaccounts. Годится любой хост *.facebook.com (https) — куки и ключ профиля на чужой хост не уходят, а ответные куки с такого адреса в сессию профиля не поглощаются |
method | GET (по умолчанию), POST или DELETE |
params | доводы запроса объектом; POST несёт их телом, остальные способы — в адресе |
headers | свои заголовки; идут поверх наших и вправе затереть любой |
timeoutMs | сколько ждать ответа, в миллисекундах; пусто — срок оператора, выше его потолка не подняться |
Ключ доступа, куки, прокси и подпись браузера подставляет транспорт профиля сам.
Ответ — и два вида отказа
Ответ Facebook приходит как есть, Qubix его не разбирает — разбираете вы:
const res = fb.campaign(campaignId).request({
method: 'GET',
url: `https://graph.facebook.com/${campaignId}`,
params: { fields: 'name,daily_budget' },
timeoutMs: 5000,
})
const data = JSON.parse(res.text)
if (res.status >= 400) { // отказ Facebook — обычный ответ с причиной в теле
console.log('Facebook отказал:', data.error.message, 'код', data.error.code)
return
}
console.log(data.name, 'дневной бюджет:', data.daily_budget)
| Поле | Значение |
|---|---|
status | код ответа HTTP от Facebook |
contentType | тип содержимого ответа |
headers | заголовки ответа — все, кроме Set-Cookie; здесь живёт то, чего в теле нет: остаток лимитов, опознаватель обращения для разбора с поддержкой Facebook |
text | тело как есть, строкой — JSON.parse(res.text) |
bytes | оно же двоично — для картинок и выгрузок |
profileId | каким браузерным профилем ушёл запрос |
Поля ok нет намеренно — оно могло быть только истиной и молча прятало бы отказы. Исход судите по status.
Отказов два вида, и они приходят по-разному:
- Qubix не смог доставить — мёртвый прокси, оборванная связь, кампания вне вашего набора данных — бросается ошибкой: ловите
try/catch, если хотите продолжить цикл. Для чтений черезfb.campaign(...)к этому моменту уже перепробованы запасные профили. - Facebook ответил отказом — нет прав, неверное поле, протухшая сессия — возвращается обычным ответом с
res.status >= 400и причиной в теле:JSON.parse(res.text).errorнесётmessageиcode, а когда естьerror_user_titleиerror_user_msg— это готовая человеческая формулировка.
Помощник на три строки, чтобы не повторять разбор в каждом рецепте:
function graph(res) {
if (res.status >= 400) throw new Error('Facebook отказал: ' + res.status)
return res.text ? JSON.parse(res.text) : {}
}
Проверка status стоит до разбора тела намеренно: Facebook не всегда отвечает JSON —
страница проверки безопасности или заглушка посредника приходят разметкой, и JSON.parse на ней
дал бы невнятную ошибку разбора вместо понятного отказа. Причина отказа словами — в теле:
JSON.parse(res.text).error.message.
Чтение Facebook отдаёт с задержкой: значение, прочитанное сразу после удачного изменения, может оказаться прежним. Не считайте это неудачей записи. Проверяйте новое значение следующим прогоном либо запоминайте, что поставили, в ctx.state.
Пачка: много запросов одним обменом
Очередь по браузерному профилю пропускает один запрос в промежуток — сотня одиночных чтений в предел прогона не уложится. Пачка платит очередь один раз:
const rows = fb.batch(fb.profile(profileId), ids.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=name,daily_budget` })))
for (const r of rows) {
if (r.code >= 400) { console.log('код', r.code); continue }
console.log(r.data.name, r.data.daily_budget)
}
fb.batch(цель, подзапросы, { timeoutMs })(третий довод необязателен) — отправляет подзапросы одним обменом; режется по 50 сама — это предел Facebook. Порядок сохраняется; у каждого ряда свойcode(отказ —code >= 400), сырое тело вbodyи разобранный ответ вdata(null, если тело не JSON).- Подзапрос —
{ method, relativeUrl, body }; тело строкой:'daily_budget=2000'. Отказ всей пачки бросается. - Изменения в пачке класть можно, но пачка никогда не переходит к запасному профилю: оборванная связь оставит вас в неведении, применилось ли изменение, — перечитайте объект отдельным вызовом.
- Двоичного у подответов не бывает:
bytesесть только у одиночного запроса — картинки и выгрузки берите отдельным вызовом.
Пределы и журнал
- Время прогона скрипта режет вызовы
fbнаравне с остальным кодом — команда синхронна, какsqlиctx.fetch. - Ожидание одного вызова задаёт администратор в Система → JavaScript, блок Facebook (свой запрос через fb.*): умолчание и потолок. Ваш
timeoutMsдействует в границах потолка. - Одна очередь на профиль. Запросы к одному браузерному профилю разводятся по времени на весь сервер сразу — Britva, сбор статистики и скрипты делят одну очередь, поэтому скрипт не может сжечь профиль частыми обращениями. Нужно много чтений — берите пачку.
- Пачка журналируется ОДНОЙ записью на обмен — обмен с Facebook у неё один; в колонке пути лежит перечень подзапросов, в теле ответа — все подответы.
- Каждое обращение записывается в журнал запросов на вашем сервере — включая чтения: время, скрипт, профиль-исполнитель, способ, путь, исход и ответ Facebook. Жалоба «скрипт снёс мне кампании» разбирается по журналу за минуту. Ключи доступа в журнал не попадают никогда — из сохраняемых текстов они вычищаются.
Каталог кирпичиков
Каждый запрос ниже Qubix отправлял в живой работе и получал ответ (сверено 27.08.2026) — на них можно опираться как на базовый набор. Всё за его пределами тоже проходит: мост принимает любой адрес *.facebook.com — Graph API и не только, — а дальше решают права вашего профиля. AI-ассистент в редакторе скриптов знает эти кирпичики и соберёт скрипт по задаче, описанной словами.
Готовые скрипты, собранные из этих кирпичиков, — в разделе Примеры скриптов.
Кто я под этим профилем
const res = fb.profile(profileId).request({ url: 'https://graph.facebook.com/me', params: { fields: 'id,name,email' } })
Отказ здесь означает, что сессия профиля мертва. Это самый дешёвый способ проверять живость сессий по расписанию — раньше, чем это заметит залив.
Рекламные кабинеты — включая деньги
const res = fb.profile(profileId).request({
url: 'https://graph.facebook.com/me/adaccounts',
params: {
limit: 50,
fields: 'name,account_id,account_status,disable_reason,currency,' +
'adspaymentcycle,adtrust_dsl,amount_spent,timezone_name',
},
})
Деньги живут здесь — отдельного запроса «баланс» у Graph API нет:
| Поле | Что это |
|---|---|
amount_spent | сколько кабинет потратил всего, в минорных единицах валюты кабинета |
adtrust_dsl | суточный предел расхода кабинета |
adspaymentcycle | порог списания; сумма лежит в .data[0].threshold_amount и делится на 100 |
account_status | состояние кабинета числом |
disable_reason | причина отключения числом |
currency | валюта кабинета |
timezone_name | часовой пояс кабинета; «сегодня» считается по нему |
Карты оплаты кабинетов
Тот же адрес, другой набор полей — в display_string приходит маска привязанной карты:
const res = fb.profile(profileId).request({
url: 'https://graph.facebook.com/me/adaccounts',
params: { limit: 100, fields: 'account_id,all_payment_methods{pm_credit_card{display_string}}' },
})
Состояние и бюджет кампании
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${campaignId}`,
params: { fields: 'id,name,status,effective_status,daily_budget' },
})
daily_budget возвращается в минорных единицах валюты кабинета, строкой. Если бюджет задан на группах объявлений, а не на кампании, поля просто нет — это обычное состояние, а не ошибка.
Изменение бюджета
const res = fb.campaign(campaignId).request({
method: 'POST',
url: `https://graph.facebook.com/${campaignId}`,
params: { daily_budget: '5000' }, // минорные единицы: 5000 = 50.00 в валюте кабинета
})
На успех Facebook отвечает {"success":true}. Полный сценарий с порогами защиты — рецепт «Повышение бюджета прибыльных кампаний» в примерах.
Настройки группы объявлений: таргетинг, бюджеты, оптимизация
У группы объявлений читается то, чего нет в наших отчётах вовсе:
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${adsetId}`,
params: { fields: 'name,targeting,daily_budget,lifetime_budget,optimization_goal,billing_event' },
})
Тем же способом у объявления одним запросом достаются вложенные объекты — кампания, группа и креатив сразу:
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${adId}`,
params: { fields: 'name,status,effective_status,campaign{objective,daily_budget},adset{targeting,optimization_goal},creative{id}' },
})
Пауза и запуск
fb.campaign(campaignId).request({ method: 'POST', url: `https://graph.facebook.com/${someId}`, params: { status: 'PAUSED' } })
fb.campaign(campaignId).request({ method: 'POST', url: `https://graph.facebook.com/${someId}`, params: { status: 'ACTIVE' } })
Одна и та же форма работает для объявления, группы и кампании — Facebook различает их по идентификатору. Пауза кампании действует каскадом: её объявления показывают effective_status: 'CAMPAIGN_PAUSED', а их собственный status не меняется. Поэтому о том, крутится ли объявление на самом деле, судите по effective_status, а не по status.
Останавливать и возвращать объявления, группы и кампании удобнее встроенными .pause() / .activate(): они идут через ту же очередь, что и Britva, и ведут полный учёт пауз. fb берите для того, на что у SDK нет своей команды.
Почему объявление не крутится
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/${adId}`,
params: { fields: 'effective_status,issues_info{error_summary,error_message,level},adset{effective_status,end_time}' },
})
issues_info несёт формулировку проблемы словами самого Facebook; adset.end_time ловит истёкшую группу — обычный ответ на «объявление активно, а расхода нет».
Статистика напрямую из кабинета
Когда нужен срез, которого нет в отчётах Qubix, статистику можно спросить у самого Facebook:
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/act_${accountId}/insights`,
params: {
level: 'ad',
fields: 'ad_id,ad_name,spend,impressions,clicks',
time_range: JSON.stringify({ since: '2026-08-20', until: '2026-08-27' }),
time_increment: 1, // по дням
limit: 500,
},
})
Два подводных камня, о которые ломаются все:
outbound_clicksиunique_outbound_clicksприходят массивом объектовaction_type/value, а не числом, — остальные клик-поля приходят числом строкой;- поля
landing_page_viewsна верхнем уровне нет вовсе — запроситеactionsи ищите в нёмaction_type: 'landing_page_view'.
Картинки креативов — вплоть до двоичного тела
const res = fb.campaign(campaignId).request({
url: `https://graph.facebook.com/act_${accountId}/adimages`,
params: { hashes: JSON.stringify([imageHash]), fields: 'url,permalink_url,hash' },
})
Ответ fb умеет быть и двоичным: res.bytes несёт тело как есть — так забирают сами файлы, а не только ссылки на них.
Исходник видео и картинка поста
// исходный файл видео креатива
fb.campaign(campaignId).request({ url: `https://graph.facebook.com/${videoId}`, params: { fields: 'source' } })
Цепочка «объявление → его картинка» — три зависимых шага: каждому нужен ответ предыдущего, поэтому в один запрос она не складывается. Зато она складывается в пачки по шагам — по целому списку объявлений разом: три обмена на всех, а не три на каждое:
const ads = fb.batch(fb.campaign(adIds[0]), adIds.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=creative{id}` })))
const creativeIds = ads.filter((r) => r.code < 400).map((r) => r.data.creative.id)
const creatives = fb.batch(fb.campaign(adIds[0]), creativeIds.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=effective_object_story_id` })))
const postIds = creatives.filter((r) => r.code < 400).map((r) => r.data.effective_object_story_id)
const posts = fb.batch(fb.campaign(adIds[0]), postIds.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=full_picture` })))
for (const r of posts) if (r.code < 400) console.log(r.data.full_picture)
Страницы, бизнес-менеджеры, посты и комментарии
// фан-страницы и бизнес-менеджеры — независимые чтения, один обмен
const rows = fb.batch(fb.profile(profileId), [
{ method: 'GET', relativeUrl: 'me/accounts?limit=25' },
{ method: 'GET', relativeUrl: 'me/businesses' },
])
// посты страницы
const posts = graph(fb.profile(profileId).request({ url: `https://graph.facebook.com/${pageId}/published_posts`, params: { limit: 5 } }))
// комментарии ВСЕХ свежих постов — одной пачкой на весь список
const coms = fb.batch(fb.profile(profileId),
(posts.data || []).map((po) => ({ method: 'GET', relativeUrl: `${po.id}/comments?fields=id,message,from,created_time&limit=100` })))
// удалить комментарий — изменение идёт одиночным запросом
fb.profile(profileId).request({ method: 'DELETE', url: `https://graph.facebook.com/${commentId}` })
Автоматическая чистка подсадных ссылок с журналом уже встроена — раздел Чистка комментариев. Через fb стройте собственную логику поверх: свои списки слов, свои исключения, свой разбор авторов.
Постраничный обход
Длинные списки Facebook отдаёт курсорами: пока в ответе есть paging.next, берите paging.cursors.after и повторяйте запрос с after в params. Нет paging — значит конец.
let after = null
do {
const params = { limit: 50, fields: 'name,account_id' }
if (after) params.after = after
const data = JSON.parse(fb.profile(profileId).request({ url: 'https://graph.facebook.com/me/adaccounts', params: params }).text)
if (data.error) break
for (const acc of data.data || []) console.log(acc.name)
after = data.paging && data.paging.next ? data.paging.cursors.after : null
} while (after)
Если Facebook отвечает «Please reduce the amount of data you're asking for» — это просьба, а не отказ: уменьшите limit и начните заново.
Сила — в связках с остальным SDK
Мост становится по-настоящему мощным вместе с остальными командами скрипта: условия withCondition отбирают объекты по вашей статистике, sql достаёт любой срез из базы, fb сверяет и меняет кабинет, ctx.state держит память между прогонами, ctx.fetch уводит итог в ваш мессенджер.
function main() {
// 1. Своя статистика — прямой ClickHouse-запрос: окупаемость кампаний по доходу ВОРОНКИ
const rows = sql`
SELECT campaign_id, sum(spend_24h) AS spend, sum(revenue_24h) AS revenue
FROM v_ads_stats
GROUP BY campaign_id
HAVING spend > 0 AND revenue / spend >= 1.5
ORDER BY revenue / spend DESC`
// 2. Живые бюджеты всех кандидатов — одним обменом с Facebook
const ids = rows.map((r) => r.campaign_id)
if (!ids.length) return
const budgets = fb.batch(fb.campaign(ids[0]), ids.map((id) => ({ method: 'GET', relativeUrl: `${id}?fields=name,daily_budget` })))
for (let i = 0; i < ids.length; i++) {
if (budgets[i].code >= 400) continue
console.log(budgets[i].data.name,
'ROAS воронки:', (rows[i].revenue / rows[i].spend).toFixed(2),
'бюджет в кабинете:', budgets[i].data.daily_budget)
}
}
Отбирать объекты можно и без SQL — условием withCondition: это выражение уровня SQL по всем показателям объекта, со скобками, AND/OR/NOT, арифметикой и сравнением полей между собой — spend_24h > 2 * geo_avg_payout, roas_24h < 0.5 * prev_roas_24h. Полный перечень полей — вкладка макросов в редакторе и раздел Метрики.
Так собираются сценарии, до которых у поставщика никогда не дошли бы руки: ваша воронка решает, ваш профиль исполняет.