File size: 28,779 Bytes
aef86ea | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 | """meta_store.py β the REST loader for a mirror whose only other loader is XML-RPC (W31-T48).
python platform/harness/meta_store.py --sync pull into tenant #0's mirror
python platform/harness/meta_store.py --sync --tenant gtmlab
python platform/harness/meta_store.py --status what is in the mirror now
β WHY THIS IS A SIBLING AND NOT A ROW IN `datastore.ENTITIES`. `datastore.sync_entity` is
hardwired to XML-RPC β it does `import core.odoo as O` and speaks `search_read` β so no amount of
spec data makes it fetch over HTTPS. What IS reusable is everything *below* the fetch: the
per-tenant file (`path_for`), the process-singleton connection (`connect`), and the
delete-then-insert upsert. This module borrows those and brings its own reader. **The mirror is one
store with two loaders, not two stores.**
β THE SHAPE OF THE WHOLE THING, because it is the owner's actual request: *"we just need to pull
the data into our template database for Meta"*, working *"just like Odoo"*. Odoo's path is
`XML-RPC -> DuckDB mirror -> odoo_relational -> ut_odoo_* locked grids`. Meta's is
`Graph -> DuckDB mirror -> meta_relational -> ut_meta_* locked grids`. Same middle, same end, one
different first hop.
β EVERY COLUMN NAME BELOW WAS MEASURED, NOT READ OFF A DOC (R8). `proto/meta-entity-fields.json`
carries the run: each level's list is what the API ACCEPTED when asked, on a real ad account.
Ad Account 45 Β· Campaign 35 Β· Ad Set 54 Β· Ad 36 Β· Creative 58 Β· Insights 57
β AND `ACCEPTED` IS NOT `RETURNED`. Graph omits null fields from a response, so a 20-field ask
came back with 14 keys. Building a schema from what came back would drop ~30% of the columns with
nothing going red β which is exactly the "do not drop any column" instruction, broken silently.
The lists below are therefore the ASKED-AND-ACCEPTED set, and a column with no value is NULL.
β `owner` IS ABSENT FROM THE AD ACCOUNT LIST AND THAT IS A REPORTED LIMIT, NOT AN OMISSION: it
answers `403 (#200) Requires business_management permission`, which this token does not carry. One
column of 285. R6's second sentence β a limit that cannot be removed gets stated with its cause.
β IDS ARE TEXT, ALWAYS. A Meta object id is a 17-digit decimal string; `120273975028650555` is
larger than 2^53, so any float or JS-number path silently corrupts it. Same ruling as `ig_id`
(W26/R3), for the same reason, and it is why every `id` column here is VARCHAR.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
_HERE = Path(__file__).resolve().parent
if str(_HERE.parent) not in sys.path:
sys.path.insert(0, str(_HERE.parent))
from harness import datastore # noqa: E402
GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
#: Numeric columns, by name. Everything else is VARCHAR β including ids (see the header) and
#: including anything Graph returns as a nested object, which is stored as compact JSON text.
_INT = {"impressions", "reach", "clicks", "unique_clicks", "inline_link_clicks",
"inline_post_engagement", "full_view_impressions", "estimated_ad_recallers",
"account_status", "age", "timezone_id", "timezone_offset_hours_utc", "io_number",
"min_daily_budget", "min_campaign_group_spend_cap"}
_DBL = {"spend", "social_spend", "ctr", "unique_ctr", "cpc", "cpm", "cpp", "frequency",
"inline_link_click_ctr", "outbound_clicks_ctr", "amount_spent", "balance", "spend_cap",
"daily_budget", "lifetime_budget", "budget_remaining", "bid_amount", "canvas_avg_view_time",
"daily_min_spend_target", "daily_spend_cap", "lifetime_min_spend_target",
"lifetime_spend_cap", "lifetime_imps"}
#: β THE MEASURED CATALOG. `edge` is the connection on the ad account; `None` means the account
#: itself. `parent` names the column that ties a row to its parent, used by nothing here and by
#: the grid links in `meta_relational`.
ACCOUNT_FIELDS = (
"account_id account_status age amount_spent balance business_city business_country_code "
"business_name business_state business_street business_street2 business_zip capabilities "
"created_time currency disable_reason end_advertiser end_advertiser_name funding_source "
"has_migrated_permissions id io_number is_attribution_spec_system_default "
"is_direct_deals_enabled is_notifications_enabled is_personal is_prepay_account "
"is_tax_id_required line_numbers media_agency min_campaign_group_spend_cap min_daily_budget "
"name offsite_pixels_tos_accepted partner spend_cap tax_id tax_id_status tax_id_type "
"timezone_id timezone_name timezone_offset_hours_utc tos_accepted user_tasks user_tos_accepted"
).split()
CAMPAIGN_FIELDS = (
"account_id bid_strategy boosted_object_id budget_rebalance_flag budget_remaining buying_type "
"campaign_group_active_time can_create_brand_lift_study can_use_spend_cap configured_status "
"created_time daily_budget effective_status id is_skadnetwork_attribution issues_info "
"last_budget_toggling_time lifetime_budget name objective pacing_type primary_attribution "
"promoted_object smart_promotion_type source_campaign source_campaign_id special_ad_categories "
"special_ad_category special_ad_category_country spend_cap start_time status stop_time "
"topline_id updated_time"
).split()
ADSET_FIELDS = (
"account_id adlabels adset_schedule asset_feed_id attribution_spec bid_adjustments bid_amount "
"bid_constraints bid_info bid_strategy billing_event budget_remaining campaign "
"campaign_active_time campaign_attribution campaign_id configured_status created_time "
"creative_sequence daily_budget daily_min_spend_target daily_spend_cap destination_type "
"effective_status end_time frequency_control_specs id instagram_actor_id is_dynamic_creative "
"issues_info learning_stage_info lifetime_budget lifetime_imps lifetime_min_spend_target "
"lifetime_spend_cap multi_optimization_goal_weight name optimization_goal "
"optimization_sub_event pacing_type promoted_object recurring_budget_semantics review_feedback "
"rf_prediction_id source_adset source_adset_id start_time status targeting "
"targeting_optimization_types time_based_ad_rotation_id_blocks "
"time_based_ad_rotation_intervals updated_time use_new_app_click"
).split()
AD_FIELDS = (
"account_id ad_active_time ad_review_feedback ad_schedule_end_time ad_schedule_start_time "
"adlabels adset adset_id bid_amount bid_info bid_type campaign campaign_id configured_status "
"conversion_domain created_time creative demolink_hash display_sequence effective_status "
"engagement_audience failed_delivery_checks id issues_info last_updated_by_app_id name "
"preview_shareable_link priority recommendations source_ad source_ad_id status targeting "
"tracking_and_conversion_with_defaults tracking_specs updated_time"
).split()
CREATIVE_FIELDS = (
"account_id actor_id adlabels applink_treatment asset_feed_spec authorization_category body "
"branded_content_sponsor_page_id bundle_folder_id call_to_action_type categorization_criteria "
"category_media_source collaborative_ads_lsb_image_bank_id degrees_of_freedom_spec "
"destination_set_id dynamic_ad_voice effective_authorization_category "
"effective_instagram_media_id effective_object_story_id enable_direct_install "
"enable_launch_instant_app id image_crops image_hash image_url instagram_actor_id "
"instagram_permalink_url instagram_story_id instagram_user_id interactive_components_spec "
"link_deep_link_url link_destination_display_url link_og_id link_url "
"messenger_sponsored_message name object_id object_store_url object_story_id object_story_spec "
"object_type object_url place_page_set_id platform_customizations playable_asset_id "
"portrait_customizations product_set_id recommender_settings source_instagram_media_id status "
"template_url template_url_spec thumbnail_id thumbnail_url title url_tags "
"use_page_actor_override video_id"
).split()
INSIGHT_FIELDS = (
"account_currency account_id account_name action_values actions ad_id ad_name adset_id "
"adset_name attribution_setting buying_type campaign_id campaign_name canvas_avg_view_time "
"clicks conversion_rate_ranking conversion_values conversions cost_per_action_type "
"cost_per_inline_link_click cost_per_thruplay cost_per_unique_click cpc cpm cpp ctr date_start "
"date_stop engagement_rate_ranking estimated_ad_recallers frequency full_view_impressions "
"impressions inline_link_click_ctr inline_link_clicks inline_post_engagement objective "
"optimization_goal outbound_clicks outbound_clicks_ctr purchase_roas quality_ranking reach "
"social_spend spend unique_clicks unique_ctr unique_outbound_clicks "
"video_avg_time_watched_actions video_p100_watched_actions video_p25_watched_actions "
"video_p50_watched_actions video_p75_watched_actions video_p95_watched_actions "
"video_play_actions video_thruplay_watched_actions website_purchase_roas"
).split()
#: table -> (graph edge on the account | None, measured fields, parent column)
SPECS = {
"meta_ad_accounts": (None, ACCOUNT_FIELDS, None),
"meta_campaigns": ("campaigns", CAMPAIGN_FIELDS, "account_id"),
"meta_adsets": ("adsets", ADSET_FIELDS, "campaign_id"),
"meta_ads": ("ads", AD_FIELDS, "adset_id"),
"meta_creatives": ("adcreatives", CREATIVE_FIELDS, "account_id"),
}
#: The daily Insights grain (R2). One row per (ad, day) β the id is synthesised because Insights
#: has no id of its own, and it must be STABLE so a re-run updates instead of appending.
INSIGHTS_TABLE = "meta_insights_daily"
INSIGHTS_LEVEL = os.environ.get("META_INSIGHTS_LEVEL") or "ad"
#: β INSIGHTS IS FETCHED IN TIME SLICES, AND THE REASON IS MEASURED, NOT DEFENSIVE. All 57 fields
#: at ad level over `last_90d` with a 100-row page answers **HTTP 500 "An unknown error occurred"**
#: β Graph's way of saying the synchronous query is too heavy (the async report-run API is the
#: other answer, and it costs a poll loop this does not need). The SAME 57 fields over 7 days at
#: page 25 answer 200. So the window is walked in slices with every column intact:
#: 57 fields Β· ad level Β· 7d Β· limit 25 -> 200, 25 rows
#: 57 fields Β· account level Β· 7d -> 200, 7 rows
#: β Narrowing the FIELD list would also have "fixed" it, and that is the wrong fix twice over β
#: it drops columns R2 requires, and it does so invisibly.
INSIGHTS_DAYS = int(os.environ.get("META_INSIGHTS_DAYS") or 90)
INSIGHTS_SLICE = int(os.environ.get("META_INSIGHTS_SLICE_DAYS") or 7)
INSIGHTS_PAGE = int(os.environ.get("META_INSIGHTS_PAGE") or 25)
def _slices(days, size):
"""[(since, until)] covering the last `days`, oldest first, in `size`-day windows."""
from datetime import date, timedelta
end = date.today()
start = end - timedelta(days=max(1, days) - 1)
out, cur = [], start
while cur <= end:
stop = min(cur + timedelta(days=max(1, size) - 1), end)
out.append((cur.isoformat(), stop.isoformat()))
cur = stop + timedelta(days=1)
return out
#: β A REAL CEILING, AND R6's SECOND SENTENCE APPLIES TO IT. Graph pages at 25-100 rows; this is
#: the number of PAGES a single edge may walk before the loader stops and SAYS it stopped. It is
#: not a row cap on a connected source (R6 forbids that) β it is a runaway guard, and reaching it
#: is reported as a problem, never absorbed.
MAX_PAGES = int(os.environ.get("META_MAX_PAGES") or 200)
PAGE = int(os.environ.get("META_PAGE_SIZE") or 100)
class MetaError(RuntimeError):
"""A Graph refusal carrying Meta's own words. Safe to print: no token ever reaches it."""
def token():
"""The Meta token from the environment or gitignored `platform/.env`; "" when absent.
β Same resolver `aios-web/api/connectors_meta.py` uses. Duplicated deliberately and minimally:
`platform/` must not import from `aios-web/api/`, which is the layering rule this repo keeps
(`core` never imports up). Fifteen lines is the price of that boundary.
"""
tok = os.environ.get("META_ADS_ACCESS_TOKEN") or ""
if tok:
return tok.strip()
env = _HERE.parent / ".env"
if env.exists():
for line in env.read_text(encoding="utf-8", errors="replace").splitlines():
if line.strip().startswith("META_ADS_ACCESS_TOKEN"):
_, _, v = line.partition("=")
return v.strip().strip('"').strip("'")
return ""
def _get(path, tok, **params):
params["access_token"] = tok
url = f"{GRAPH}/{path.lstrip('/')}?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": "aios-meta-store/1"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode("utf-8", "replace"))
except urllib.error.HTTPError as e:
try:
msg = (json.loads(e.read().decode("utf-8", "replace")).get("error") or {}
).get("message") or ""
except Exception:
msg = ""
raise MetaError(f"HTTP {e.code} on /{path.lstrip('/')}: {msg[:200]}") from None
except Exception as e:
raise MetaError(f"{type(e).__name__} on /{path.lstrip('/')}") from None
#: Graph's own words when a page is too heavy. It arrives as an HTTP **500**, not a 4xx, which is
#: why it cannot be treated as "the server is broken, give up".
_TOO_MUCH = "reduce the amount of data"
#: β THE ADS-MANAGEMENT RATE LIMIT, WHICH IS PER AD ACCOUNT AND NOT PER TOKEN. Measured: after a
#: heavy backfill Graph answers **HTTP 400 "There have been too many calls to this ad-account.
#: Wait a bit and try again."** It is a 4xx, so nothing about the status code says "retry" β the
#: MESSAGE is the only signal, which is why it is matched here rather than inferred from a code.
#: β It persists for minutes, so the backoff is measured in minutes and BOUNDED: after
#: `_RATE_TRIES` waits the loader STOPS and reports what it got, rather than sitting in a retry
#: loop nobody can see. A partial mirror that says it is partial beats a hung sync.
_RATE_LIMITED = "too many calls"
_RATE_TRIES = int(os.environ.get("META_RATE_TRIES") or 3)
_RATE_WAIT = int(os.environ.get("META_RATE_WAIT_S") or 90)
def _walk(path, tok, log, **params):
"""Every page of an edge, paged by CURSOR under our own parameters.
β IT DOES NOT FOLLOW GRAPH'S `paging.next` URL, AND THAT IS THE WHOLE POINT. Measured: the
first call to `/adcreatives` at limit=100 answers **HTTP 500 "Please reduce the amount of data
you're asking for"**, the retry at limit=25 succeeds β and then `next` carries the ORIGINAL
limit=100 and fails again on page 2. A backoff that cannot reach every page is not a backoff.
Re-issuing each page ourselves with `after=<cursor>` keeps the reduced limit for the whole walk.
β It shrinks the PAGE, never the FIELD LIST. Dropping columns to make a request fit is the
silent omission R2 forbids, and nothing downstream could see it. Fewer rows per call, always
every column per row.
-> (rows, hit_page_cap)
"""
out, pages = [], 0
limit = int(params.pop("limit", None) or PAGE)
after, waited = None, 0
while True:
call = dict(params, limit=limit)
if after:
call["after"] = after
try:
body = _get(path, tok, **call)
except MetaError as e:
if _TOO_MUCH in str(e) and limit > 5:
limit = max(5, limit // 4)
log(f" page too heavy for /{path} - retrying at limit={limit} "
f"(a payload ceiling, not a row cap; every column is still asked for)")
continue
if _RATE_LIMITED in str(e).lower() and waited < _RATE_TRIES:
waited += 1
log(f" rate-limited on /{path} (per AD ACCOUNT, not per token) - waiting "
f"{_RATE_WAIT}s, attempt {waited}/{_RATE_TRIES}")
time.sleep(_RATE_WAIT)
continue
if _RATE_LIMITED in str(e).lower():
log(f" !! GIVING UP on /{path} after {waited} waits: still rate-limited. "
f"{len(out)} row(s) collected so far are kept. Cause: the ads-management "
f"limit is per ad account and persists for minutes. Fix: re-run "
f"`--sync --only <table>` later; the upsert is idempotent.")
return out, True
raise
out.extend(body.get("data") or [])
pages += 1
after = ((body.get("paging") or {}).get("cursors") or {}).get("after")
has_next = bool((body.get("paging") or {}).get("next")) and bool(after)
if not has_next:
return out, False
if pages >= MAX_PAGES:
log(f" !! STOPPED at MAX_PAGES={MAX_PAGES} on /{path} with more pages left. "
f"Cause: a runaway guard, not a row cap. Fix: raise META_MAX_PAGES, or narrow the "
f"window with META_INSIGHTS_DAYS.")
return out, True
def _cell(value):
"""One Graph value -> one DuckDB cell. Nested structures become compact JSON TEXT rather than
being dropped: R2 says every field the API returns, and `targeting` is a field."""
if value is None or isinstance(value, (str, int, float, bool)):
return value
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
def _coltype(name, rows=None):
"""The DuckDB type for one column β decided by the DATA when there is data, by the name only
as a fallback.
β THE NAME LIST WAS WRONG AND ONLY THE API COULD SAY SO. `cost_per_unique_click`,
`cost_per_action_type`, `purchase_roas` and friends READ like money and are **arrays of
action-type objects** on the Insights edge:
[{"action_type":"outbound_click","value":"1.459854"}]
Typed DOUBLE from `_DBL`, the insert died with `Conversion Error: Could not convert string
'[{...}]' to DOUBLE` β after five entity tables had already been written, so the sync looked
like it worked and then blew up on the last table.
β Same principle the field CATALOG is built on, applied one layer down: **ask the response,
do not assert from a list.** A value that ever arrives as a list or dict is JSON TEXT, because
that is what `_cell` stores; anything else falls back to the measured name hints.
"""
if rows:
seen, kinds = 0, set()
for r in rows:
v = r.get(name)
if v is None or v == "":
continue
kinds.add("json" if isinstance(v, (list, dict)) else
"bool" if isinstance(v, bool) else
"int" if isinstance(v, int) else
"float" if isinstance(v, float) else "str")
seen += 1
if seen >= 200:
break
if kinds:
if "json" in kinds or "str" in kinds:
return "VARCHAR" # a JSON blob, or a numeric STRING
if kinds <= {"int", "bool"}:
return "BIGINT" if name in _INT else ("VARCHAR" if "bool" in kinds else "BIGINT")
return "DOUBLE"
if name in _INT:
return "BIGINT"
if name in _DBL:
return "DOUBLE"
return "VARCHAR" # ids included β see the header
def _ensure(con, table, fields, rows=None):
"""Create or widen the table, with every column typed from `rows` when they are available.
β AN EXISTING COLUMN WHOSE TYPE IS NOW WRONG IS REBUILT, NOT PATCHED. DuckDB cannot retype a
column in place, and this table is DERIVED data that can be re-pulled in minutes β so a type
disagreement drops and recreates rather than limping on with a column that refuses every
insert. The alternative is a mirror that is permanently unwritable for one bad guess.
"""
want = {f: _coltype(f, rows) for f in fields}
have = {r[1]: str(r[2]).upper() for r in con.execute(f"PRAGMA table_info('{table}')").fetchall()}
if have:
clash = [f for f, ty in want.items() if f in have and have[f] != ty
and not (have[f].startswith("VARCHAR") and ty == "VARCHAR")]
if clash:
con.execute(f"DROP TABLE {table}")
have = {}
if not have:
cols = ", ".join(f"{f} {want[f]}" for f in fields)
con.execute(f"CREATE TABLE IF NOT EXISTS {table} (id VARCHAR PRIMARY KEY, {cols})"
if "id" not in fields else
f"CREATE TABLE IF NOT EXISTS {table} ({cols})")
return
for f in fields:
if f not in have:
con.execute(f"ALTER TABLE {table} ADD COLUMN {f} {want[f]}")
def _upsert(con, table, fields, rows):
"""Delete-then-insert by id β the same idempotence `datastore._upsert` gives the Odoo half, so
a re-sync updates in place and can never append a second copy of the same object."""
if not rows:
return 0
cols = list(fields)
ids = [str(r.get("id") or "") for r in rows]
q = ",".join("?" for _ in ids)
con.execute(f"DELETE FROM {table} WHERE id IN ({q})", ids)
con.executemany(
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({', '.join('?' for _ in cols)})",
[[_cell(r.get(c)) for c in cols] for r in rows])
return len(rows)
def sync(tenant_key="royal-imports", tok=None, log=print, insights=True, insights_days=None):
"""Pull every level into the tenant's mirror. -> a report dict; raises only on a bad token.
Idempotent: re-running updates rows in place. Safe to call at boot and on the resync loop,
exactly as `odoo_relational.refresh` is.
"""
tok = tok or token()
# β THE WINDOW IS A PARAMETER, NOT AN ENVIRONMENT READ AT CALL TIME β and the difference cost a
# live deploy. `INSIGHTS_DAYS` binds at IMPORT (module scope), so `main._pull_meta`'s
# `os.environ.setdefault("META_INSIGHTS_DAYS", "7")` executed AFTER this module was already
# imported and changed nothing: every boot pulled **90 days**, not 7, which is the slow path
# that trips the per-ad-account rate limit and never finishes. The comment beside that call
# claimed a short window the code could not deliver.
# β The shape: **a knob read at import cannot be turned by a caller at runtime.** Passing it
# makes the caller's intent effective instead of aspirational; the env var stays the DEFAULT.
days = int(insights_days or INSIGHTS_DAYS)
report = {"tenant": tenant_key, "tables": {}, "problems": [], "accounts": []}
if not tok:
report["problems"].append(
"META_ADS_ACCESS_TOKEN is not set in the environment or in platform/.env, so nothing "
"was pulled. This is a missing CREDENTIAL, not a missing capability.")
return report
path = datastore.path_for(tenant_key)
if Path(datastore.DB_PATH) != Path(path):
datastore.use_path(path)
con = datastore.connect()
log(f" mirror: {Path(path).name}")
accts, _ = _walk("me/adaccounts", tok, log, fields="id,name", limit=PAGE)
report["accounts"] = [a.get("id") for a in accts]
if not accts:
report["problems"].append("the token reaches no ad accounts")
return report
# ββ the five entity levels ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
for table, (edge, fields, _parent) in SPECS.items():
rows, capped = [], False
for acct in accts:
aid = acct["id"]
if edge is None:
rows.append(_get(aid, tok, fields=",".join(fields)))
else:
got, hit = _walk(f"{aid}/{edge}", tok, log, fields=",".join(fields), limit=PAGE)
rows.extend(got)
capped = capped or hit
_ensure(con, table, fields, rows)
n = _upsert(con, table, fields, rows)
total = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
report["tables"][table] = {"pulled": n, "in_mirror": total, "capped": capped}
log(f" {table:<20} pulled {n:>6} mirror total {total:>6}"
+ (" !! PAGE CAP HIT" if capped else ""))
if capped:
report["problems"].append(f"{table}: stopped at MAX_PAGES={MAX_PAGES}")
# ββ the daily Insights grain ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if insights:
rows, capped = [], False
for acct in accts:
for since, until in _slices(days, INSIGHTS_SLICE):
got, hit = _walk(f"{acct['id']}/insights", tok, log,
fields=",".join(INSIGHT_FIELDS), level=INSIGHTS_LEVEL,
time_increment="1", limit=INSIGHTS_PAGE,
time_range=json.dumps({"since": since, "until": until}))
rows.extend(got)
capped = capped or hit
# β Insights rows carry no id. The key must be STABLE across runs or every re-sync
# appends a second copy of the same day β so it is composed from the grain itself.
for r in rows:
r["id"] = ":".join(str(r.get(k) or "") for k in
(f"{INSIGHTS_LEVEL}_id", "date_start", "date_stop"))
fields = ["id"] + INSIGHT_FIELDS
_ensure(con, INSIGHTS_TABLE, fields, rows)
n = _upsert(con, INSIGHTS_TABLE, fields, rows)
total = con.execute(f"SELECT count(*) FROM {INSIGHTS_TABLE}").fetchone()[0]
report["tables"][INSIGHTS_TABLE] = {"pulled": n, "in_mirror": total, "capped": capped}
log(f" {INSIGHTS_TABLE:<20} pulled {n:>6} mirror total {total:>6}"
+ (" !! PAGE CAP HIT" if capped else ""))
con.execute("INSERT OR REPLACE INTO _sync_state VALUES (?,?,?,?,?,?)",
["meta", "done", 0, f"{days}d/{INSIGHTS_SLICE}d@{INSIGHTS_LEVEL}",
sum(t["in_mirror"] for t in report["tables"].values()),
time.strftime("%Y-%m-%d %H:%M:%S")])
return report
def status(tenant_key="royal-imports"):
"""What is in the mirror right now, per table. Never fetches."""
path = datastore.path_for(tenant_key)
if Path(datastore.DB_PATH) != Path(path):
datastore.use_path(path)
out = {}
con = datastore.connect()
for table in list(SPECS) + [INSIGHTS_TABLE]:
try:
out[table] = con.execute(f"SELECT count(*) FROM {table}").fetchone()[0]
except Exception:
out[table] = None # table absent = never synced
return out
def main(argv=None):
ap = argparse.ArgumentParser(description="Pull Meta Ads into the tenant's DuckDB mirror.")
ap.add_argument("--sync", action="store_true")
ap.add_argument("--status", action="store_true")
ap.add_argument("--tenant", default="royal-imports")
ap.add_argument("--no-insights", action="store_true")
ap.add_argument("--insights-days", type=int, default=None,
help="override the Insights window for THIS run (default INSIGHTS_DAYS); the env var is the default, this is the caller's say")
a = ap.parse_args(argv)
if a.status:
for k, v in status(a.tenant).items():
print(f" {k:<20} {'(never synced)' if v is None else v}")
return 0
if not a.sync:
ap.print_help()
return 2
rep = sync(a.tenant, insights=not a.no_insights, insights_days=a.insights_days)
for p in rep["problems"]:
print(" PROBLEM:", p)
print(f" accounts: {len(rep['accounts'])} tables: {len(rep['tables'])}")
return 1 if rep["problems"] else 0
if __name__ == "__main__":
sys.exit(main())
|