| """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
|
|
|
| GRAPH_VERSION = os.environ.get("META_GRAPH_VERSION") or "v21.0"
|
| GRAPH = f"https://graph.facebook.com/{GRAPH_VERSION}"
|
|
|
|
|
|
|
| _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"}
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
| 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"),
|
| }
|
|
|
|
|
|
|
| INSIGHTS_TABLE = "meta_insights_daily"
|
| INSIGHTS_LEVEL = os.environ.get("META_INSIGHTS_LEVEL") or "ad"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| _TOO_MUCH = "reduce the amount of data"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _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"
|
| 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"
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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}")
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
| 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())
|
|
|