From 0d828c06d23cf4e36aacaaed824499db2f9b3537 Mon Sep 17 00:00:00 2001 From: themattberman Date: Wed, 10 Jun 2026 12:20:02 -0500 Subject: [PATCH 1/2] fix(live): add Graph API insights adapter so reports work outside mock mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report layer's jq targets an internal schema (account_summary, campaign_insights[], ad_insights[], ad_daily[], today_campaign_spend[]) that only the mock fixtures produced. The non-mock path piped raw responses straight through, so every report (pacing, overview, winners, bleeders, fatigue) errored against the live API — the kit effectively only ran in mock. - scripts/lib/live-adapter.sh: pure transform (mk_assemble_insights) from raw Graph responses -> internal schema, plus a thin fetch layer (mk_build_live_insights) and campaign-list normalizer. Uses the Marketing API for level=campaign/level=ad breakdowns because the official Ads CLI `insights get` is account-level only. Memoized per process; rounds ctr/cpc/freq to 2dp; follows paging. - scripts/lib/meta-cli.sh: route insights ops through the adapter; normalize the CLI campaign list into {data:[{id,name,status}]}. - scripts/lib/config.sh: .env no longer clobbers variables already set on the command line, so `META_KIT_MODE=mock ./run.sh` reliably forces mock. - scripts/test/: offline unit tests for the transform (23 assertions, synthetic raw-shape fixtures — no client data, no network). - docs: README/SETUP/.env.example document the Graph API insights path, GRAPH_API_VERSION, the env-precedence rule, and the multi-brand patterns. Verified live (read-only) against two real accounts; mock mode unchanged. Co-Authored-By: Claude Opus 4.8 --- .env.example | 5 + README.md | 2 +- SETUP.md | 38 ++++ scripts/lib/config.sh | 26 ++- scripts/lib/live-adapter.sh | 181 ++++++++++++++++++++ scripts/lib/meta-cli.sh | 28 ++- scripts/meta-kit.sh | 7 + scripts/test/fixtures/raw-graph-bundle.json | 31 ++++ scripts/test/test_live_adapter.sh | 79 +++++++++ 9 files changed, 389 insertions(+), 8 deletions(-) create mode 100644 scripts/lib/live-adapter.sh create mode 100644 scripts/test/fixtures/raw-graph-bundle.json create mode 100755 scripts/test/test_live_adapter.sh diff --git a/.env.example b/.env.example index 2890df2..56acfe1 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,12 @@ META_BUSINESS_ID= META_APP_ID= META_APP_SECRET= +# Graph API (used for per-campaign/per-ad insights breakdowns the CLI can't do) +GRAPH_API_VERSION=v21.0 + # Kit behavior +# NOTE: values here do NOT override variables already set on the command line, +# so `META_KIT_MODE=mock ./run.sh ...` always wins over this file. META_KIT_MODE=mock META_KIT_OUTPUT=json META_KIT_DEFAULT_PRESET=last_7d diff --git a/README.md b/README.md index f58a4a1..c3c9fa2 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ No Ads Manager required at any step. ## Running With OpenClaw -This kit is built for [OpenClaw](https://openclaw.ai), an open-source AI agent framework. Under the hood, reports route through `./run.sh` → `scripts/meta-kit.sh` → Meta's official Ads CLI (`meta`). +This kit is built for [OpenClaw](https://openclaw.ai), an open-source AI agent framework. Under the hood, reports route through `./run.sh` → `scripts/meta-kit.sh`. Campaign lists use Meta's official Ads CLI (`meta`); per-campaign/per-ad insight breakdowns (winners, bleeders, pacing, fatigue) use the Meta Marketing API with the same token, since the official CLI's `insights get` is account-level only. See `scripts/lib/live-adapter.sh`. ```bash # Install OpenClaw diff --git a/SETUP.md b/SETUP.md index 16d6c6a..3a73625 100644 --- a/SETUP.md +++ b/SETUP.md @@ -110,6 +110,44 @@ META_KIT_MODE=read-only ./run.sh daily-check The adapter writes sanitized read-only snapshots under `local/outputs/read-only/`. +### How live reports are sourced + +- **Campaign list** comes from the official Ads CLI (`meta ads campaign list`). +- **Insights breakdowns** (per-campaign, per-ad, pacing, fatigue) come from the + Meta Marketing API (Graph) using the same `ACCESS_TOKEN`. The official Ads CLI + `insights get` is account-level only (no `--level`), so it cannot produce + per-campaign/per-ad rows in one call; the Graph API can. See + `scripts/lib/live-adapter.sh`. Override the version with `GRAPH_API_VERSION` + (default `v21.0`). + +`.env` values do **not** override variables already set on the command line, so +`META_KIT_MODE=mock ./run.sh daily-check` always forces mock regardless of `.env`. + +## Multi-brand / multi-client + +Each report reads `AD_ACCOUNT_ID` (and `ACCESS_TOKEN`) at call time, so you can +run multiple brands without editing files. Two patterns: + +```bash +# A) Inline per-run (same token, different account): +AD_ACCOUNT_ID=act_1111111111 ./run.sh daily-check +AD_ACCOUNT_ID=act_2222222222 ./run.sh daily-check + +# B) Per-client env files (different tokens per client): +# .env.clienta.local -> ACCESS_TOKEN + AD_ACCOUNT_ID for client A +# .env.clientb.local -> ACCESS_TOKEN + AD_ACCOUNT_ID for client B +META_KIT_ENV_FILE=.env.clienta.local ./run.sh daily-check +META_KIT_ENV_FILE=.env.clientb.local ./run.sh daily-check +``` + +All `.env` and `.env.*.local` files are gitignored — tokens never get committed. + +## Tests + +```bash +./scripts/test/test_live_adapter.sh # offline unit tests for the live adapter transform +``` + --- ## Mutations: approval-only diff --git a/scripts/lib/config.sh b/scripts/lib/config.sh index 023e68a..4fec212 100755 --- a/scripts/lib/config.sh +++ b/scripts/lib/config.sh @@ -20,8 +20,30 @@ mk_load_env() { fi if [[ -f "$env_file" ]]; then - # shellcheck disable=SC1090 - set -a; source "$env_file"; set +a + # Load .env WITHOUT clobbering variables already set in the environment, so a + # command-line prefix wins over the file. This is what makes the documented + # overrides work, e.g. `META_KIT_MODE=mock ./run.sh ...` even when .env pins a + # mode. (A plain `set -a; source` would let the file overwrite the prefix.) + local _line _key _val + while IFS= read -r _line || [[ -n "$_line" ]]; do + _line="${_line%%$'\r'}" + _line="${_line#"${_line%%[![:space:]]*}"}" # ltrim + [[ -z "$_line" || "$_line" == \#* ]] && continue + _line="${_line#export }" + [[ "$_line" != *=* ]] && continue + _key="${_line%%=*}" + _val="${_line#*=}" + _key="${_key//[[:space:]]/}" + [[ -z "$_key" ]] && continue + # Strip one layer of surrounding quotes from the value, if present. + if [[ "$_val" == \"*\" || "$_val" == \'*\' ]]; then + _val="${_val:1:${#_val}-2}" + fi + # Only set if not already provided by the caller's environment. + if [[ -z "${!_key+x}" ]]; then + export "$_key=$_val" + fi + done < "$env_file" fi # Official Ads CLI env vars are ACCESS_TOKEN, AD_ACCOUNT_ID, and BUSINESS_ID. diff --git a/scripts/lib/live-adapter.sh b/scripts/lib/live-adapter.sh new file mode 100644 index 0000000..257baa9 --- /dev/null +++ b/scripts/lib/live-adapter.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash + +# Live adapter: turns raw Meta Marketing API (Graph) responses into the kit's +# internal report schema (the same shape as scripts/fixtures/insights.last_7d.json). +# +# Why the Graph API and not the official `meta` CLI here: +# the official Ads CLI exposes only account-level `insights get` (no --level), +# so it cannot return per-campaign or per-ad breakdowns in one call. Winners, +# bleeders, pacing-by-campaign and fatigue are all level-based. The Graph API +# (same ACCESS_TOKEN the CLI uses) returns level=campaign / level=ad in a single +# call, which is the only thing that scales past a handful of ads. +# +# The transform (mk_assemble_insights) is a PURE jq function over recorded raw +# responses, so it is unit-testable offline. The fetch layer (mk_build_live_insights) +# is the only part that touches the network. + +GRAPH_API_VERSION="${GRAPH_API_VERSION:-v21.0}" +GRAPH_API_BASE="${GRAPH_API_BASE:-https://graph.facebook.com}" + +# mk_graph_get ... +# Returns the raw JSON body for GET {base}/{version}/{account}/{edge}?... +# Follows .paging.next up to a bounded number of pages and merges .data arrays. +mk_graph_get() { + local edge="$1"; shift + local account token body merged pages next + account="$(mk_normalize_account "${AD_ACCOUNT_ID:-${META_AD_ACCOUNT:-}}")" + token="${ACCESS_TOKEN:-${META_SYSTEM_USER_ACCESS_TOKEN:-}}" + + local curl_args=(-sG "${GRAPH_API_BASE}/${GRAPH_API_VERSION}/${account}/${edge}" + --data-urlencode "access_token=${token}") + local kv + for kv in "$@"; do + curl_args+=(--data-urlencode "$kv") + done + + body="$(curl "${curl_args[@]}" 2>/dev/null)" + + # If there is no pagination, return the body unchanged so single-object + # responses (e.g. the account node) pass through untouched. + next="$(jq -r '.paging.next // empty' <<<"$body" 2>/dev/null)" + if [[ -z "$next" ]]; then + printf '%s\n' "$body" + return 0 + fi + + merged="$body" + pages=1 + while [[ -n "$next" && "$pages" -lt 25 ]]; do + body="$(curl -s "$next" 2>/dev/null)" + merged="$(jq -s '{data: ((.[0].data // []) + (.[1].data // []))}' \ + <(printf '%s' "$merged") <(printf '%s' "$body") 2>/dev/null)" + next="$(jq -r '.paging.next // empty' <<<"$body" 2>/dev/null)" + pages=$((pages + 1)) + done + printf '%s\n' "$merged" +} + +# mk_assemble_insights (reads a combined raw bundle on stdin) +# Input: one JSON object keyed by raw Graph responses: +# {acct_meta, acct_7d, acct_today, camp_7d, camp_today, ad_7d, ad_daily, campaigns, adsets} +# Output: the kit's internal insights schema. +# PURE: jq only, no network — this is the unit-tested seam. +mk_assemble_insights() { + jq ' + def num: (try (tostring|tonumber) catch 0); + def r2: (. as $n | ($n|num) * 100 | round / 100); + def cents_to_dollars: (num / 100); + + .acct_meta as $meta + | .acct_7d as $a7 + | .acct_today as $at + | .camp_7d as $c7 + | .camp_today as $ct + | .ad_7d as $a + | .ad_daily as $ad + | .campaigns as $camps + | .adsets as $adsets + + | ( [ ($camps.data // [])[] | select(.effective_status == "ACTIVE") | (.daily_budget // "0" | cents_to_dollars) ] | add // 0 ) as $camp_budget + | ( [ ($adsets.data // [])[] | select(.effective_status == "ACTIVE") | (.daily_budget // "0" | cents_to_dollars) ] | add // 0 ) as $adset_budget + + | { + account_summary: { + account_id: ($meta.id // ""), + currency: ($meta.currency // ($a7.data[0].account_currency // "USD")), + spend_7d: (($a7.data[0].spend // "0") | num | tostring), + spend_today: (($at.data[0].spend // "0") | num | tostring), + daily_budget_target: (($camp_budget + $adset_budget) | tostring), + active_campaigns: ( [ ($camps.data // [])[] | select(.effective_status == "ACTIVE") ] | length ), + active_ads: ( ($a.data // []) | length ) + }, + campaign_insights: [ + ($c7.data // [])[] | { + campaign_id: (.campaign_id // ""), + campaign_name: (.campaign_name // "(unknown)"), + spend: ((.spend // "0") | num | tostring), + ctr: ((.ctr // "0") | r2 | tostring), + cpc: ((.cpc // "0") | r2 | tostring) + } + ], + ad_insights: [ + ($a.data // [])[] | { + ad_id: (.ad_id // ""), + ad_name: (.ad_name // "(unknown)"), + spend: ((.spend // "0") | num | tostring), + ctr: ((.ctr // "0") | r2 | tostring), + cpc: ((.cpc // "0") | r2 | tostring), + frequency: ((.frequency // "0") | r2 | tostring) + } + ], + ad_daily: [ + ($ad.data // [])[] | { + ad_id: (.ad_id // ""), + ad_name: (.ad_name // "(unknown)"), + date_start: (.date_start // ""), + ctr: ((.ctr // "0") | r2 | tostring), + frequency: ((.frequency // "0") | r2 | tostring) + } + ], + today_campaign_spend: [ + ($ct.data // [])[] | { + campaign_id: (.campaign_id // ""), + campaign_name: (.campaign_name // "(unknown)"), + spend_today: ((.spend // "0") | num | tostring) + } + ] + } + ' +} + +# mk_normalize_campaigns (reads raw `meta ads campaign list` array on stdin) +# Output: { data: [ {id, name, status} ] } — the shape report_campaigns expects. +mk_normalize_campaigns() { + jq '{ data: [ .[] | { id: .id, name: .name, status: (.status // .effective_status // "UNKNOWN") } ] }' +} + +# mk_build_live_insights (network) +# Fetches the raw Graph responses for the account and assembles the internal +# schema. Memoized per process+account so daily-check does not refetch 5x. +mk_build_live_insights() { + local account cache + account="$(mk_normalize_account "${AD_ACCOUNT_ID:-${META_AD_ACCOUNT:-}}")" + cache="${TMPDIR:-/tmp}/meta-kit-insights-${account}-$$.json" + + if [[ -f "$cache" ]]; then + cat "$cache" + return 0 + fi + + local acct_meta acct_7d acct_today camp_7d camp_today ad_7d ad_daily campaigns adsets bundle + # Account node (currency); not an edge, so call it directly rather than via mk_graph_get. + acct_meta="$(curl -sG "${GRAPH_API_BASE}/${GRAPH_API_VERSION}/${account}" \ + --data-urlencode "access_token=${ACCESS_TOKEN:-${META_SYSTEM_USER_ACCESS_TOKEN:-}}" \ + --data-urlencode "fields=currency,name" 2>/dev/null)" + acct_7d="$(mk_graph_get insights "date_preset=last_7d" "fields=spend,account_currency")" + acct_today="$(mk_graph_get insights "date_preset=today" "fields=spend")" + camp_7d="$(mk_graph_get insights "level=campaign" "date_preset=last_7d" "fields=campaign_id,campaign_name,spend,ctr,cpc" "limit=200")" + camp_today="$(mk_graph_get insights "level=campaign" "date_preset=today" "fields=campaign_id,campaign_name,spend" "limit=200")" + ad_7d="$(mk_graph_get insights "level=ad" "date_preset=last_7d" "fields=ad_id,ad_name,spend,ctr,cpc,frequency" "limit=500")" + ad_daily="$(mk_graph_get insights "level=ad" "date_preset=last_7d" "time_increment=1" "fields=ad_id,ad_name,ctr,frequency" "limit=500")" + campaigns="$(mk_graph_get campaigns "fields=effective_status,daily_budget" "limit=500")" + adsets="$(mk_graph_get adsets "fields=effective_status,daily_budget" "limit=500")" + + # NB: do not use "${var:-{}}" as a default here — bash parses the first "}" + # as the end of the expansion and appends the trailing "}" literally, + # corrupting otherwise-valid JSON. Use a named empty-object default. + local empty='{}' + bundle="$(jq -n \ + --argjson acct_meta "${acct_meta:-$empty}" \ + --argjson acct_7d "${acct_7d:-$empty}" \ + --argjson acct_today "${acct_today:-$empty}" \ + --argjson camp_7d "${camp_7d:-$empty}" \ + --argjson camp_today "${camp_today:-$empty}" \ + --argjson ad_7d "${ad_7d:-$empty}" \ + --argjson ad_daily "${ad_daily:-$empty}" \ + --argjson campaigns "${campaigns:-$empty}" \ + --argjson adsets "${adsets:-$empty}" \ + '{acct_meta:$acct_meta, acct_7d:$acct_7d, acct_today:$acct_today, camp_7d:$camp_7d, camp_today:$camp_today, ad_7d:$ad_7d, ad_daily:$ad_daily, campaigns:$campaigns, adsets:$adsets}')" + + printf '%s' "$bundle" | mk_assemble_insights | tee "$cache" +} diff --git a/scripts/lib/meta-cli.sh b/scripts/lib/meta-cli.sh index fc8de96..69c49bf 100755 --- a/scripts/lib/meta-cli.sh +++ b/scripts/lib/meta-cli.sh @@ -163,16 +163,34 @@ mk_meta_cli_read_json() { return 0 fi - mk_meta_base_cmd || return 1 - - mk_meta_cli_command_for "$op" - if [[ -z "$account" ]]; then echo "ERROR: META_AD_ACCOUNT is required outside mock mode." >&2 return 1 fi - export AD_ACCOUNT_ID="$account" + + # Insights ops need level=campaign / level=ad breakdowns. The official Ads CLI + # cannot do those (no --level), so the live adapter assembles them from the + # Graph API into the kit's internal schema. See scripts/lib/live-adapter.sh. + case "$op" in + insights_campaign_last_7d|insights_ad_last_7d|insights_ad_daily_last_7d) + json="$(mk_build_live_insights)" + mk_snapshot_json "$label" "$json" >/dev/null + printf '%s\n' "$json" + return 0 + ;; + campaigns_list) + # The official CLI lists campaigns fine; normalize its array into {data:[...]}. + mk_meta_base_cmd || return 1 + json="$("${META_BASE_CMD[@]}" --output "$(mk_output_format)" --no-input ads campaign list 2>/dev/null | mk_normalize_campaigns)" + mk_snapshot_json "$label" "$json" >/dev/null + printf '%s\n' "$json" + return 0 + ;; + esac + + mk_meta_base_cmd || return 1 + mk_meta_cli_command_for "$op" local cmd=("${META_BASE_CMD[@]}" --output "$(mk_output_format)" --no-input "${META_CMD[@]:1}") json="$("${cmd[@]}" 2>/dev/null)" mk_snapshot_json "$label" "$json" >/dev/null diff --git a/scripts/meta-kit.sh b/scripts/meta-kit.sh index fb4ac00..496098e 100755 --- a/scripts/meta-kit.sh +++ b/scripts/meta-kit.sh @@ -11,10 +11,17 @@ source "$SCRIPT_DIR/lib/mock.sh" source "$SCRIPT_DIR/lib/safety.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/meta-cli.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/live-adapter.sh" mk_load_env mk_require_jq +# The live adapter memoizes assembled insights to a per-process temp file so a +# single daily-check does not refetch the Graph API once per report. Clean it up +# when this process exits. +trap 'rm -f "${TMPDIR:-/tmp}"/meta-kit-insights-*-$$.json 2>/dev/null || true' EXIT + MODE="${1:-daily-check}" shift || true diff --git a/scripts/test/fixtures/raw-graph-bundle.json b/scripts/test/fixtures/raw-graph-bundle.json new file mode 100644 index 0000000..bb1a200 --- /dev/null +++ b/scripts/test/fixtures/raw-graph-bundle.json @@ -0,0 +1,31 @@ +{ + "acct_meta": { "id": "act_999000111", "currency": "USD", "name": "Test Brand" }, + "acct_7d": { "data": [ { "spend": "1640.25", "account_currency": "USD", "date_start": "2026-06-03", "date_stop": "2026-06-09" } ] }, + "acct_today": { "data": [ { "spend": "241.10", "date_start": "2026-06-10", "date_stop": "2026-06-10" } ] }, + "camp_7d": { "data": [ + { "campaign_id": "1201001", "campaign_name": "Prospecting - Broad", "spend": "1120.40", "ctr": "1.434221", "cpc": "0.521988", "date_start": "2026-06-03", "date_stop": "2026-06-09" }, + { "campaign_id": "1201002", "campaign_name": "Retargeting - 30d", "spend": "519.85", "ctr": "2.370115", "cpc": "0.499003", "date_start": "2026-06-03", "date_stop": "2026-06-09" } + ] }, + "camp_today": { "data": [ + { "campaign_id": "1201001", "campaign_name": "Prospecting - Broad", "spend": "162.30" }, + { "campaign_id": "1201002", "campaign_name": "Retargeting - 30d", "spend": "78.80" } + ] }, + "ad_7d": { "data": [ + { "ad_id": "3303003", "ad_name": "Carousel - Pain Points", "spend": "501.08", "ctr": "0.841552", "cpc": "0.954381", "frequency": "3.901221" }, + { "ad_id": "3303004", "ad_name": "UGC Retargeting - Testimonial", "spend": "301.20", "ctr": "2.981004", "cpc": "0.557777", "frequency": "2.201991" }, + { "ad_id": "3303005", "ad_name": "Discount Reminder", "spend": "218.65", "ctr": "1.389004", "cpc": "0.601233", "frequency": "4.099812" } + ] }, + "ad_daily": { "data": [ + { "ad_id": "3303003", "ad_name": "Carousel - Pain Points", "date_start": "2026-06-03", "ctr": "1.220004", "frequency": "3.200111" }, + { "ad_id": "3303003", "ad_name": "Carousel - Pain Points", "date_start": "2026-06-06", "ctr": "0.970221", "frequency": "3.500004" }, + { "ad_id": "3303003", "ad_name": "Carousel - Pain Points", "date_start": "2026-06-09", "ctr": "0.841552", "frequency": "3.901221" } + ] }, + "campaigns": { "data": [ + { "effective_status": "ACTIVE", "daily_budget": "15000" }, + { "effective_status": "ACTIVE", "daily_budget": "8000" }, + { "effective_status": "PAUSED", "daily_budget": "4000" } + ] }, + "adsets": { "data": [ + { "effective_status": "ACTIVE", "daily_budget": "0" } + ] } +} diff --git a/scripts/test/test_live_adapter.sh b/scripts/test/test_live_adapter.sh new file mode 100755 index 0000000..f9cfafd --- /dev/null +++ b/scripts/test/test_live_adapter.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Offline unit tests for the live adapter's PURE transform (mk_assemble_insights) +# and campaign normalizer (mk_normalize_campaigns). No network. +# +# Run: ./scripts/test/test_live_adapter.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +FIXTURES="$SCRIPT_DIR/fixtures" + +# Minimal helper the adapter expects from config.sh. +mk_normalize_account() { local a="${1:-}"; [[ -z "$a" || "$a" == act_* ]] && echo "$a" || echo "act_${a}"; } + +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/live-adapter.sh" + +PASS=0 +FAIL=0 +check() { # check + if [[ "$2" == "$3" ]]; then + PASS=$((PASS + 1)); printf ' ok %s\n' "$1" + else + FAIL=$((FAIL + 1)); printf ' FAIL %s\n expected [%s] got [%s]\n' "$1" "$3" "$2" + fi +} + +echo "== mk_assemble_insights (pure transform) ==" +OUT="$(mk_assemble_insights < "$FIXTURES/raw-graph-bundle.json")" + +# Schema keys present +for key in account_summary campaign_insights ad_insights ad_daily today_campaign_spend; do + has="$(jq -r --arg k "$key" 'has($k)' <<<"$OUT")" + check "schema has .$key" "$has" "true" +done + +# account_summary fields +check "currency" "$(jq -r '.account_summary.currency' <<<"$OUT")" "USD" +check "spend_7d" "$(jq -r '.account_summary.spend_7d' <<<"$OUT")" "1640.25" +check "spend_today" "$(jq -r '.account_summary.spend_today' <<<"$OUT")" "241.10" +check "active_campaigns" "$(jq -r '.account_summary.active_campaigns' <<<"$OUT")" "2" +check "active_ads" "$(jq -r '.account_summary.active_ads' <<<"$OUT")" "3" +# daily_budget_target = (15000 + 8000)/100 ; paused 4000 excluded ; adset 0 +check "daily_budget_target" "$(jq -r '.account_summary.daily_budget_target' <<<"$OUT")" "230" + +# CTR/CPC rounded to 2dp (raw 1.434221 -> 1.43, 0.954381 -> 0.95) +check "campaign ctr rounded" "$(jq -r '.campaign_insights[0].ctr' <<<"$OUT")" "1.43" +check "campaign cpc rounded" "$(jq -r '.campaign_insights[0].cpc' <<<"$OUT")" "0.52" +check "ad freq rounded" "$(jq -r '.ad_insights[0].frequency' <<<"$OUT")" "3.9" + +# array counts +check "campaign_insights count" "$(jq -r '.campaign_insights|length' <<<"$OUT")" "2" +check "ad_insights count" "$(jq -r '.ad_insights|length' <<<"$OUT")" "3" +check "ad_daily count" "$(jq -r '.ad_daily|length' <<<"$OUT")" "3" +check "today_campaign_spend count" "$(jq -r '.today_campaign_spend|length' <<<"$OUT")" "2" + +echo "== mk_normalize_campaigns ==" +CAMP_RAW='[{"id":"1","name":"A","effective_status":"ACTIVE"},{"id":"2","name":"B","status":"PAUSED","effective_status":"PAUSED"}]' +CN="$(printf '%s' "$CAMP_RAW" | mk_normalize_campaigns)" +check "normalized wraps in .data" "$(jq -r 'has("data")' <<<"$CN")" "true" +check "status from effective_status" "$(jq -r '.data[0].status' <<<"$CN")" "ACTIVE" +check "status prefers .status" "$(jq -r '.data[1].status' <<<"$CN")" "PAUSED" + +echo "== reports render against assembled schema (no jq errors) ==" +# Feed the assembled object through the same jq the reports use; assert no error +# and that a known value appears. +PACING="$(jq -r ' + .account_summary as $a | + "Today spend: $\($a.spend_today) / daily target $\($a.daily_budget_target)" +' <<<"$OUT" 2>&1)" +check "pacing line renders" "$PACING" "Today spend: \$241.10 / daily target \$230" + +WINNERS="$(jq -r '.ad_insights | sort_by(-(.ctr|tonumber)) | .[0].ad_name' <<<"$OUT" 2>&1)" +check "winners sorts by ctr" "$WINNERS" "UGC Retargeting - Testimonial" + +echo +echo "RESULT: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]] From e001f3f3ddbcfaa5c7bc6413af561aeb5e4ff884 Mon Sep 17 00:00:00 2001 From: themattberman Date: Wed, 10 Jun 2026 12:57:25 -0500 Subject: [PATCH 2/2] feat(backend): add selectable META_KIT_BACKEND (graph default | social-cli) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit social-cli (@vishalgojha/social-cli) was the kit's ORIGINAL engine and natively supports `marketing insights --level campaign|ad --time-increment` plus mutations and OAuth. The migration to the official Ads CLI is what dropped --level and broke live reports. This brings the richer engine back as an opt-in, alongside the zero-dependency Graph backend. - scripts/lib/config.sh: mk_backend() reads META_KIT_BACKEND (default graph). - scripts/lib/backend-social.sh: social-cli fetch layer — preamble stripper, --export-based insights (clean JSON), flat-array -> {data:[...]} wrapping so it reuses the SAME pure transform (mk_assemble_insights) as graph. Campaign-list + status + level insights wired; pacing target is campaign-budget-based (social has no account-level adsets edge — documented). - scripts/lib/live-adapter.sh: mk_build_live_insights dispatches by backend; graph fetch extracted to mk_fetch_bundle_graph. - scripts/lib/meta-cli.sh: campaign-list routes by backend; doctor reports backend + social-cli presence/version. - scripts/test/test_social_backend.sh: 10 offline assertions (preamble strip, normalize, social arrays -> shared transform). Graph suite still 23/23. - docs: README/SETUP/.env.example document both backends, the install/auth for social-cli, the speed trade-off, and the pacing-target caveat. Verified live (read-only) on a real ad account: both backends produce identical spend/winners/bleeders/fatigue. Fixed a recurrence of the "${var:-{}}" brace bug in the social account-meta path. Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 +- README.md | 8 +- SETUP.md | 41 +++++-- scripts/lib/backend-social.sh | 108 ++++++++++++++++++ scripts/lib/config.sh | 8 ++ scripts/lib/live-adapter.sh | 36 ++++-- scripts/lib/meta-cli.sh | 17 ++- scripts/meta-kit.sh | 2 + .../social-campaigns-with-preamble.txt | 8 ++ scripts/test/test_social_backend.sh | 62 ++++++++++ 10 files changed, 275 insertions(+), 21 deletions(-) create mode 100644 scripts/lib/backend-social.sh create mode 100644 scripts/test/fixtures/social-campaigns-with-preamble.txt create mode 100755 scripts/test/test_social_backend.sh diff --git a/.env.example b/.env.example index 56acfe1..0958cb4 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,11 @@ META_BUSINESS_ID= META_APP_ID= META_APP_SECRET= -# Graph API (used for per-campaign/per-ad insights breakdowns the CLI can't do) +# Live data backend: graph (default, curl-only) | social-cli (@vishalgojha/social-cli) +# social-cli adds native --level insights, mutations, and OAuth but is slower. +META_KIT_BACKEND=graph + +# Graph API (used by the graph backend for breakdowns the official CLI can't do) GRAPH_API_VERSION=v21.0 # Kit behavior diff --git a/README.md b/README.md index c3c9fa2..2839f07 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,13 @@ No Ads Manager required at any step. ## Running With OpenClaw -This kit is built for [OpenClaw](https://openclaw.ai), an open-source AI agent framework. Under the hood, reports route through `./run.sh` → `scripts/meta-kit.sh`. Campaign lists use Meta's official Ads CLI (`meta`); per-campaign/per-ad insight breakdowns (winners, bleeders, pacing, fatigue) use the Meta Marketing API with the same token, since the official CLI's `insights get` is account-level only. See `scripts/lib/live-adapter.sh`. +This kit is built for [OpenClaw](https://openclaw.ai), an open-source AI agent framework. Under the hood, reports route through `./run.sh` → `scripts/meta-kit.sh`. + +**Live data backends** (`META_KIT_BACKEND`): +- `graph` (default) — direct Meta Marketing API via `curl`. Fast, zero extra deps. Campaign list via the official `meta` CLI. +- `social-cli` — [`@vishalgojha/social-cli`](https://www.npmjs.com/package/@vishalgojha/social-cli), the kit's original engine: native `--level` insights, mutations, OAuth, FB/IG/WhatsApp. `npm i -g @vishalgojha/social-cli`, then `META_KIT_BACKEND=social-cli`. + +Both feed one shared transform, so report output is identical. The official Ads CLI's `insights get` is account-level only (no `--level`), which is why the breakdowns route through the Graph API or social-cli. See `scripts/lib/live-adapter.sh` and `scripts/lib/backend-social.sh`. ```bash # Install OpenClaw diff --git a/SETUP.md b/SETUP.md index 3a73625..8981fef 100644 --- a/SETUP.md +++ b/SETUP.md @@ -110,15 +110,38 @@ META_KIT_MODE=read-only ./run.sh daily-check The adapter writes sanitized read-only snapshots under `local/outputs/read-only/`. -### How live reports are sourced - -- **Campaign list** comes from the official Ads CLI (`meta ads campaign list`). -- **Insights breakdowns** (per-campaign, per-ad, pacing, fatigue) come from the - Meta Marketing API (Graph) using the same `ACCESS_TOKEN`. The official Ads CLI - `insights get` is account-level only (no `--level`), so it cannot produce - per-campaign/per-ad rows in one call; the Graph API can. See - `scripts/lib/live-adapter.sh`. Override the version with `GRAPH_API_VERSION` - (default `v21.0`). +### Live data backends + +The non-mock data engine is selectable with `META_KIT_BACKEND`: + +| Backend | Value | Needs | Notes | +|---------|-------|-------|-------| +| Graph (default) | `graph` | just `curl` | Direct Meta Marketing API. Fast (~2s), zero extra deps. Campaign list via the official `meta` CLI. | +| social-cli | `social-cli` | `npm i -g @vishalgojha/social-cli` | The kit's original engine. Native `--level` insights (async report runs), mutations, OAuth, FB/IG/WhatsApp. Slower (~50s — async jobs). | + +```bash +# Default (Graph): +./run.sh daily-check + +# social-cli backend: +npm i -g @vishalgojha/social-cli +social auth login --token "$ACCESS_TOKEN" --api facebook --no-open # or: social auth login (OAuth) +META_KIT_BACKEND=social-cli ./run.sh daily-check +``` + +Both backends produce identical report output — they feed the same internal +schema through one shared transform (`mk_assemble_insights`). The official Ads +CLI `insights get` is account-level only (no `--level`), which is why neither the +reports nor a "meta-cli backend" can do per-ad breakdowns alone; both real +backends solve that (Graph via `level=` params, social-cli via `--level`). + +Override the Graph API version with `GRAPH_API_VERSION` (default `v21.0`). + +**Pacing-target caveat:** the Graph backend sums active campaign + active ad-set +daily budgets (it has an account-level ad-sets edge). social-cli has no +account-level ad-sets edge, so its target reflects active *campaign* (CBO) +budgets only and under-reports accounts whose budgets live at the ad-set level. +Spend, winners, bleeders, and fatigue are identical across backends. `.env` values do **not** override variables already set on the command line, so `META_KIT_MODE=mock ./run.sh daily-check` always forces mock regardless of `.env`. diff --git a/scripts/lib/backend-social.sh b/scripts/lib/backend-social.sh new file mode 100644 index 0000000..88211f1 --- /dev/null +++ b/scripts/lib/backend-social.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +# social-cli backend: routes live data through @vishalgojha/social-cli ("social"). +# +# Why offer it alongside the default Graph backend: social-cli was the kit's +# ORIGINAL engine and natively supports `marketing insights --level campaign|ad +# --time-increment` (via async report runs) plus mutations (pause/resume/ +# set-budget) and OAuth login. The official Ads CLI dropped --level, which is why +# the kit broke in live mode. This backend brings the richer surface back as an +# opt-in (META_KIT_BACKEND=social-cli). +# +# Trade-off: social-cli insights use async report jobs (slower, ~10s/poll) but +# are robust. The Graph backend is faster and dependency-free. Same internal +# schema either way — both feed the shared pure transform mk_assemble_insights. + +# Strip social-cli's stdout preamble (banner note / progress lines) and emit JSON +# starting at the first line that begins with [ or {. +mk_social_strip() { + awk 'f || /^[[{]/ { f=1; print }' +} + +mk_social_installed() { + command -v social >/dev/null 2>&1 +} + +# mk_social_list_json -> clean JSON (array/object) on stdout +mk_social_list_json() { + social --no-banner "$@" --json 2>/dev/null | mk_social_strip +} + +# mk_social_insights_data [time_increment] +# Uses --export to a temp file so progress lines never pollute the JSON. +# Returns {data: [...]} to match the bundle shape the transform expects. +mk_social_insights_data() { + local acct="$1" level="$2" preset="$3" inc="${4:-}" + local f out fields + f="$(mktemp "${TMPDIR:-/tmp}/meta-kit-soc-XXXXXX.json")" + + case "$level" in + campaign) fields="campaign_id,campaign_name,spend,ctr,cpc" ;; + ad) if [[ -n "$inc" ]]; then fields="ad_id,ad_name,ctr,frequency"; else fields="ad_id,ad_name,spend,ctr,cpc,frequency"; fi ;; + *) fields="spend,ctr,cpc" ;; + esac + + local args=(--no-banner marketing insights "$acct" --level "$level" --preset "$preset" --fields "$fields" --export "$f" --export-format json) + [[ -n "$inc" ]] && args+=(--time-increment "$inc") + + social "${args[@]}" >/dev/null 2>&1 + + if [[ -s "$f" ]] && jq -e . "$f" >/dev/null 2>&1; then + out="$(jq '{data: .}' "$f")" + else + out='{"data":[]}' + fi + rm -f "$f" + printf '%s' "$out" +} + +# mk_fetch_bundle_social -> the 9-key raw bundle consumed by mk_assemble_insights. +mk_fetch_bundle_social() { + local acct status camps_raw camp7 camp_today ad7 ad_daily + acct="$(mk_normalize_account "${AD_ACCOUNT_ID:-${META_AD_ACCOUNT:-}}")" + + status="$(social --no-banner marketing status "$acct" --json 2>/dev/null | mk_social_strip)" + camps_raw="$(mk_social_list_json marketing campaigns "$acct")" + camp7="$(mk_social_insights_data "$acct" campaign last_7d)" + camp_today="$(mk_social_insights_data "$acct" campaign today)" + ad7="$(mk_social_insights_data "$acct" ad last_7d)" + ad_daily="$(mk_social_insights_data "$acct" ad last_7d 1)" + + # Campaigns: map social's `status` to effective_status; keep daily_budget for the target. + local campaigns + campaigns="$(jq '{data: [ (.[]? // empty) | {effective_status: .status, daily_budget: (.daily_budget // "0"), id: .id, name: .name, status: .status} ]}' <<<"${camps_raw:-[]}")" + + # Ad sets: social-cli only lists ad sets per-campaign (no account-level edge), + # and those calls are rate-limit-prone. Rather than ship a pacing denominator + # that swings between runs, the social backend computes daily_budget_target from + # active CAMPAIGN budgets only (left empty here). This under-reports accounts + # whose budgets live at the ad-set level (non-CBO). The graph backend, which has + # an account-level adsets edge, includes ad-set budgets. Documented in SETUP.md. + local adsets='{"data":[]}' + + # Account spend derived from campaign-level sums (saves two async jobs). + local acct_7d acct_today acct_meta + acct_7d="$(jq '{data:[{spend: (([(.data[]?.spend // "0")|tonumber]|add // 0) * 100 | round / 100 | tostring), account_currency: "USD"}]}' <<<"$camp7")" + acct_today="$(jq '{data:[{spend: (([(.data[]?.spend // "0")|tonumber]|add // 0) * 100 | round / 100 | tostring)}]}' <<<"$camp_today")" + local empty='{}' + acct_meta="$(jq '{id: (.account.id // ""), currency: (.account.currency // "USD")}' <<<"${status:-$empty}")" + + jq -n \ + --argjson acct_meta "$acct_meta" \ + --argjson acct_7d "$acct_7d" \ + --argjson acct_today "$acct_today" \ + --argjson camp_7d "$camp7" \ + --argjson camp_today "$camp_today" \ + --argjson ad_7d "$ad7" \ + --argjson ad_daily "$ad_daily" \ + --argjson campaigns "$campaigns" \ + --argjson adsets "$adsets" \ + '{acct_meta:$acct_meta, acct_7d:$acct_7d, acct_today:$acct_today, camp_7d:$camp_7d, camp_today:$camp_today, ad_7d:$ad_7d, ad_daily:$ad_daily, campaigns:$campaigns, adsets:$adsets}' +} + +# Campaign list for report_campaigns under the social backend. +mk_social_campaigns_list() { + local acct + acct="$(mk_normalize_account "${AD_ACCOUNT_ID:-${META_AD_ACCOUNT:-}}")" + mk_social_list_json marketing campaigns "$acct" | mk_normalize_campaigns +} diff --git a/scripts/lib/config.sh b/scripts/lib/config.sh index 4fec212..b994f79 100755 --- a/scripts/lib/config.sh +++ b/scripts/lib/config.sh @@ -107,6 +107,14 @@ mk_mode() { printf '%s\n' "${META_KIT_MODE:-mock}" } +# Live data backend (non-mock only): +# graph — direct Meta Marketing API via curl (default; zero extra deps) +# social-cli — @vishalgojha/social-cli ("social" binary): native --level +# insights + mutations + OAuth. Requires `npm i -g @vishalgojha/social-cli`. +mk_backend() { + printf '%s\n' "${META_KIT_BACKEND:-graph}" +} + mk_output_format() { printf '%s\n' "${META_KIT_OUTPUT:-json}" } diff --git a/scripts/lib/live-adapter.sh b/scripts/lib/live-adapter.sh index 257baa9..48bf8ff 100644 --- a/scripts/lib/live-adapter.sh +++ b/scripts/lib/live-adapter.sh @@ -135,19 +135,43 @@ mk_normalize_campaigns() { } # mk_build_live_insights (network) -# Fetches the raw Graph responses for the account and assembles the internal +# Fetches the raw backend responses for the account and assembles the internal # schema. Memoized per process+account so daily-check does not refetch 5x. +# Backend is selected by META_KIT_BACKEND (graph | social-cli). mk_build_live_insights() { - local account cache + local account cache backend bundle account="$(mk_normalize_account "${AD_ACCOUNT_ID:-${META_AD_ACCOUNT:-}}")" cache="${TMPDIR:-/tmp}/meta-kit-insights-${account}-$$.json" + backend="$(mk_backend)" if [[ -f "$cache" ]]; then cat "$cache" return 0 fi - local acct_meta acct_7d acct_today camp_7d camp_today ad_7d ad_daily campaigns adsets bundle + case "$backend" in + social-cli) + if ! mk_social_installed; then + echo "ERROR: META_KIT_BACKEND=social-cli but the 'social' CLI is not installed. Run: npm i -g @vishalgojha/social-cli" >&2 + return 1 + fi + bundle="$(mk_fetch_bundle_social)" + ;; + *) + bundle="$(mk_fetch_bundle_graph)" + ;; + esac + + printf '%s' "$bundle" | mk_assemble_insights | tee "$cache" +} + +# mk_fetch_bundle_graph -> the 9-key raw bundle, sourced from the Marketing API +# (Graph) via curl. Default backend; no extra dependencies. +mk_fetch_bundle_graph() { + local account + account="$(mk_normalize_account "${AD_ACCOUNT_ID:-${META_AD_ACCOUNT:-}}")" + + local acct_meta acct_7d acct_today camp_7d camp_today ad_7d ad_daily campaigns adsets # Account node (currency); not an edge, so call it directly rather than via mk_graph_get. acct_meta="$(curl -sG "${GRAPH_API_BASE}/${GRAPH_API_VERSION}/${account}" \ --data-urlencode "access_token=${ACCESS_TOKEN:-${META_SYSTEM_USER_ACCESS_TOKEN:-}}" \ @@ -165,7 +189,7 @@ mk_build_live_insights() { # as the end of the expansion and appends the trailing "}" literally, # corrupting otherwise-valid JSON. Use a named empty-object default. local empty='{}' - bundle="$(jq -n \ + jq -n \ --argjson acct_meta "${acct_meta:-$empty}" \ --argjson acct_7d "${acct_7d:-$empty}" \ --argjson acct_today "${acct_today:-$empty}" \ @@ -175,7 +199,5 @@ mk_build_live_insights() { --argjson ad_daily "${ad_daily:-$empty}" \ --argjson campaigns "${campaigns:-$empty}" \ --argjson adsets "${adsets:-$empty}" \ - '{acct_meta:$acct_meta, acct_7d:$acct_7d, acct_today:$acct_today, camp_7d:$camp_7d, camp_today:$camp_today, ad_7d:$ad_7d, ad_daily:$ad_daily, campaigns:$campaigns, adsets:$adsets}')" - - printf '%s' "$bundle" | mk_assemble_insights | tee "$cache" + '{acct_meta:$acct_meta, acct_7d:$acct_7d, acct_today:$acct_today, camp_7d:$camp_7d, camp_today:$camp_today, ad_7d:$ad_7d, ad_daily:$ad_daily, campaigns:$campaigns, adsets:$adsets}' } diff --git a/scripts/lib/meta-cli.sh b/scripts/lib/meta-cli.sh index 69c49bf..f15b5e5 100755 --- a/scripts/lib/meta-cli.sh +++ b/scripts/lib/meta-cli.sh @@ -54,6 +54,12 @@ mk_meta_cli_doctor() { echo "meta-kit doctor" echo "mode=$mode output=$output" + echo "backend=$(mk_backend)" + if command -v social >/dev/null 2>&1; then + echo "social_cli=found ($(social --version 2>/dev/null | head -1))" + else + echo "social_cli=missing (npm i -g @vishalgojha/social-cli for META_KIT_BACKEND=social-cli)" + fi if command -v meta >/dev/null 2>&1; then echo "meta_binary=found" @@ -180,9 +186,14 @@ mk_meta_cli_read_json() { return 0 ;; campaigns_list) - # The official CLI lists campaigns fine; normalize its array into {data:[...]}. - mk_meta_base_cmd || return 1 - json="$("${META_BASE_CMD[@]}" --output "$(mk_output_format)" --no-input ads campaign list 2>/dev/null | mk_normalize_campaigns)" + # Campaign list: social-cli backend uses `social marketing campaigns`; + # graph backend uses the official Ads CLI. Both normalize to {data:[...]}. + if [[ "$(mk_backend)" == "social-cli" ]]; then + json="$(mk_social_campaigns_list)" + else + mk_meta_base_cmd || return 1 + json="$("${META_BASE_CMD[@]}" --output "$(mk_output_format)" --no-input ads campaign list 2>/dev/null | mk_normalize_campaigns)" + fi mk_snapshot_json "$label" "$json" >/dev/null printf '%s\n' "$json" return 0 diff --git a/scripts/meta-kit.sh b/scripts/meta-kit.sh index 496098e..cd54788 100755 --- a/scripts/meta-kit.sh +++ b/scripts/meta-kit.sh @@ -13,6 +13,8 @@ source "$SCRIPT_DIR/lib/safety.sh" source "$SCRIPT_DIR/lib/meta-cli.sh" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/live-adapter.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/backend-social.sh" mk_load_env mk_require_jq diff --git a/scripts/test/fixtures/social-campaigns-with-preamble.txt b/scripts/test/fixtures/social-campaigns-with-preamble.txt new file mode 100644 index 0000000..aef639b --- /dev/null +++ b/scripts/test/fixtures/social-campaigns-with-preamble.txt @@ -0,0 +1,8 @@ +! Note: Your Graph API version is v21.0. Marketing API guidance here assumes v24.0+. + You can set it via: social utils version set v24.0 + +[ + {"id": "111", "name": "Prospecting - Broad", "objective": "OUTCOME_LEADS", "status": "ACTIVE", "daily_budget": "15000"}, + {"id": "222", "name": "Retargeting - 30d", "objective": "OUTCOME_LEADS", "status": "ACTIVE", "daily_budget": "8000"}, + {"id": "333", "name": "Old Test", "objective": "OUTCOME_TRAFFIC", "status": "PAUSED", "daily_budget": "4000"} +] diff --git a/scripts/test/test_social_backend.sh b/scripts/test/test_social_backend.sh new file mode 100755 index 0000000..b89ee57 --- /dev/null +++ b/scripts/test/test_social_backend.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Offline unit tests for the social-cli backend helpers that are pure +# (preamble stripping + array->bundle wrapping + shared transform reuse). +# Does NOT call the `social` binary or the network. +# +# Run: ./scripts/test/test_social_backend.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +FIXTURES="$SCRIPT_DIR/fixtures" + +mk_normalize_account() { local a="${1:-}"; [[ -z "$a" || "$a" == act_* ]] && echo "$a" || echo "act_${a}"; } +mk_backend() { printf '%s\n' "${META_KIT_BACKEND:-graph}"; } + +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/live-adapter.sh" +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/backend-social.sh" + +PASS=0; FAIL=0 +check() { if [[ "$2" == "$3" ]]; then PASS=$((PASS+1)); printf ' ok %s\n' "$1"; else FAIL=$((FAIL+1)); printf ' FAIL %s\n expected [%s] got [%s]\n' "$1" "$3" "$2"; fi; } + +echo "== mk_social_strip drops the banner/version preamble ==" +STRIPPED="$(mk_social_strip < "$FIXTURES/social-campaigns-with-preamble.txt")" +check "stripped output is valid JSON" "$(jq -e . >/dev/null 2>&1 <<<"$STRIPPED" && echo yes || echo no)" "yes" +check "stripped array length" "$(jq 'length' <<<"$STRIPPED")" "3" +check "no '! Note' leaks through" "$(grep -c '! Note' <<<"$STRIPPED")" "0" + +echo "== mk_normalize_campaigns on social campaign array ==" +CN="$(printf '%s' "$STRIPPED" | mk_normalize_campaigns)" +check "wrapped in .data" "$(jq -r 'has("data")' <<<"$CN")" "true" +check "status carried through" "$(jq -r '.data[0].status' <<<"$CN")" "ACTIVE" + +echo "== social flat arrays wrap into the shared transform bundle ==" +# Simulate social's flat insight arrays -> {data:[...]} -> shared transform. +CAMP7='[{"campaign_id":"111","campaign_name":"Prospecting - Broad","spend":"1120.40","ctr":"1.434221","cpc":"0.521988"}]' +AD7='[{"ad_id":"a1","ad_name":"Winner","spend":"300.00","ctr":"2.981004","cpc":"0.557777","frequency":"2.201991"}]' +CAMPS="$(printf '%s' "$STRIPPED" | jq '{data: [ .[] | {effective_status: .status, daily_budget, id, name, status} ]}')" +BUNDLE="$(jq -n \ + --argjson acct_meta '{"id":"act_999","currency":"USD"}' \ + --argjson acct_7d "{\"data\":[{\"spend\":\"1120.40\"}]}" \ + --argjson acct_today '{"data":[{"spend":"50.00"}]}' \ + --argjson camp_7d "{\"data\":$CAMP7}" \ + --argjson camp_today '{"data":[]}' \ + --argjson ad_7d "{\"data\":$AD7}" \ + --argjson ad_daily '{"data":[]}' \ + --argjson campaigns "$CAMPS" \ + --argjson adsets '{"data":[]}' \ + '{acct_meta:$acct_meta,acct_7d:$acct_7d,acct_today:$acct_today,camp_7d:$camp_7d,camp_today:$camp_today,ad_7d:$ad_7d,ad_daily:$ad_daily,campaigns:$campaigns,adsets:$adsets}')" +OUT="$(printf '%s' "$BUNDLE" | mk_assemble_insights)" +check "currency" "$(jq -r '.account_summary.currency' <<<"$OUT")" "USD" +check "active_campaigns" "$(jq -r '.account_summary.active_campaigns' <<<"$OUT")" "2" +# target = (15000 + 8000)/100 ; paused 4000 excluded +check "daily_budget_target" "$(jq -r '.account_summary.daily_budget_target' <<<"$OUT")" "230" +check "campaign ctr rounded" "$(jq -r '.campaign_insights[0].ctr' <<<"$OUT")" "1.43" +check "ad freq rounded" "$(jq -r '.ad_insights[0].frequency' <<<"$OUT")" "2.2" + +echo +echo "RESULT: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]]