File size: 25,492 Bytes
c25ccce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
c25ccce
e1b3e71
c25ccce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c25ccce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
c25ccce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce0d145
 
 
 
c25ccce
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce0d145
 
 
c25ccce
ce0d145
 
 
 
 
 
 
 
 
 
 
c25ccce
 
ce0d145
 
 
 
c25ccce
 
 
 
 
 
 
 
 
 
 
637946b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e78e601
 
 
 
 
 
 
 
 
 
 
 
637946b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c25ccce
 
 
 
 
 
 
 
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
"""THE PROVIDER LAYER β€” many vendors behind one capability, chosen per FIELD and per cost.

⭐⭐ OWNER RULING 2026-08-08: *"Let's do a combination scraper. Do Bright Data first, and if
anything fails, we use APIfy or vice versa. Make this dynamic somehow. remember to cost optimize
when we scale to thousands+ customer … make sure our code is customizable and modular."*

β›” THE DESIGN DECISION THAT MATTERS, AND IT IS NOT "TRY A, THEN TRY B".
A provider-level failover ("if Bright Data errors, re-run the whole thing on Apify") is the obvious
shape and it is the expensive one. MEASURED: Bright Data answers profile, likes and comments
correctly and CANNOT answer view counts on any route; Apify answers view counts correctly. A
provider-level fallback would notice the missing views and re-buy the profile, the likes and the
comments from Apify as well β€” **paying twice for the 90% that already worked**. At one profile that
is noise; at thousands of customers Γ— twelve posts each it is the whole bill.

So routing is per CAPABILITY, and a capability is the smallest independently-billable thing:

    ig_profile       -> brightdata   (36 fields, works)
    ig_post_metrics  -> brightdata   (likes + comments, works)
    ig_post_views    -> apify        (Bright Data is INCAPABLE β€” see `capable=False` below)
    ig_comments      -> brightdata

Each capability has an ORDERED list of providers. The first one that is configured AND capable AND
succeeds wins; the rest are never called, so the common path costs exactly one vendor record.

⚠ "FAILURE" INCLUDES ANSWERING WITH THE FIELD BLANK. A vendor that returns HTTP 200 and a null in
the one column you asked for has failed for our purposes, and a chain that only catches exceptions
would stop at it forever. `run()` takes a `satisfied` predicate and falls through on an unsatisfying
answer exactly as it would on a 500.

⚠ ORDER IS DATA, NOT CODE. `AIOS_PROVIDER_ORDER` overrides any chain at deploy time
(`ig_post_views=apify,brightdata;ig_profile=apify`), which is what "or vice versa" means and what
lets a tenant be moved off a vendor without a release.
"""
from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass, field
from typing import Callable

#: ⚠ APPROXIMATE, AND DELIBERATELY SO. These are list prices per RECORD in USD, used only to RANK
#: providers and to estimate a run's spend for the operator. They are not billing truth β€” the
#: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
#: why they are named and dated rather than guessed silently.
#: Bright Data Instagram datasets ~ $0.0015/record (2026-08 list). Apify instagram-scraper
#: ~ $0.0027/result (2026-08 list). Both re-checked when a provider is added.
#: ⚠ WAVE 30 Β· T16 β€” APIFY CORRECTED 0.0023 -> 0.0027, and the direction matters: the old number
#: made the fallback look CHEAPER than it is, and `estimate()` is what a person is shown before
#: they authorise a run. An under-stated price is the one rounding error a cost guard cannot catch.
#: ⚠ Both are LIST rates used to rank and to estimate. They are not invoices, and nothing here
#: reads a live price β€” a modelled figure that says so is honest; one that pretends is not.
_COST_BRIGHTDATA = float(os.environ.get("AIOS_COST_BRIGHTDATA") or 0.0015)
_COST_APIFY = float(os.environ.get("AIOS_COST_APIFY") or 0.0027)


@dataclass(frozen=True)
class Capability:
    """What ONE provider can do for ONE capability, and what it costs to ask."""
    #: β›” `False` means MEASURED INCAPABLE, not "untried". A provider declared incapable is never
    #: called for this capability at all β€” it cannot be reached by a fallback, cannot be put first
    #: by an env override, and cannot silently start being billed because somebody reordered a
    #: list. Bright Data's `ig_post_views` is False on the strength of Β§4e-Β§4h: every route
    #: (Posts x /p/, Posts x /reel/, Reels x /p/, Reels x /reel/, discover-by-profile) on four
    #: accounts from 13K to 268M followers returned an account-grain constant or a null.
    capable: bool = True
    cost_per_record: float = 0.0
    #: Free-text, shown to an operator deciding where their money went.
    note: str = ""


@dataclass
class Provider:
    key: str
    label: str
    #: The env var holding this provider's credential. NEVER the credential itself β€” this module
    #: is imported by surfaces that serialise their config.
    key_env: str = ""
    caps: dict = field(default_factory=dict)

    def configured(self) -> bool:
        return bool((os.environ.get(self.key_env) or "").strip()) if self.key_env else True

    def cap(self, capability: str) -> Capability | None:
        return self.caps.get(capability)

    def can(self, capability: str) -> bool:
        c = self.cap(capability)
        return bool(c and c.capable and self.configured())


#: ⚠ ADDING A PROVIDER IS A REGISTRY ENTRY PLUS A RUNNER β€” no change to any caller. That is the
#: "modular/customizable" half of the ruling, and the reason the chains below name STRINGS.
PROVIDERS: dict[str, Provider] = {
    "brightdata": Provider(
        key="brightdata", label="Bright Data", key_env="AIOS_BRIGHTDATA_KEY",
        caps={
            "ig_profile": Capability(True, _COST_BRIGHTDATA, "36 fields incl. bio/email/category"),
            "ig_post_metrics": Capability(True, _COST_BRIGHTDATA, "likes + comments + captions"),
            "ig_comments": Capability(True, _COST_BRIGHTDATA, "separate paid dataset, opt-in"),
            # β›” THE ONE THAT COSTS US NOTHING TO GET RIGHT AND EVERYTHING TO GET WRONG.
            "ig_post_views": Capability(
                False, _COST_BRIGHTDATA,
                "MEASURED INCAPABLE: returns one account-grain number for every reel of a "
                "creator (identical for two shortcodes in one call, ticking upward between "
                "calls) and never populates video_play_count. Their own documented example "
                "account no longer reproduces it."),
            # ⭐⭐ WAVE 29 (item 7 / D-9 / R1) β€” TIKTOK. Three datasets, all reachable with our
            # existing key, all schema-probed for $0.00 (40/43/17 fields with the vendor's own
            # types): `gd_l1villgoiiidt09ci` Β· `gd_lu702nij2f790tmv9h` Β· `gd_lkf2st302ap89utw5k`.
            "tt_profile": Capability(True, _COST_BRIGHTDATA,
                                     "40 fields incl. bio/engagement rates/region"),
            # β›” THERE IS NO `tt_post_views` CAPABILITY, AND ITS ABSENCE IS THE CLAIM.
            # `TikTok - Posts` declares `play_count: number` and the vendor's own sample says
            # "no empty values or zeros" β€” so on TikTok the view count arrives INSIDE the post
            # record and a separate view rung would be a second bill for a number we already have.
            # ⚠ DECLARED, NOT MEASURED, and that distinction is the whole scar tissue of §4e:
            # Bright Data's Instagram Reels also DECLARES `views: number` and delivers an
            # account-grain wrong one. One live pull settles it (W29-T06, spend-gated). If it
            # fails, the honest repair is a `tt_post_views` capability routed elsewhere β€” never a
            # quiet fallback bolted onto this one.
            "tt_post_metrics": Capability(
                True, _COST_BRIGHTDATA,
                "likes + comments + shares + saves, and play_count inline (DECLARED "
                "no-empties-or-zeros, UNPROVEN until one live pull)"),
            "tt_comments": Capability(True, _COST_BRIGHTDATA,
                                      "separate paid dataset, opt-in β€” 17 fields"),
        }),
    "apify": Provider(
        key="apify", label="Apify", key_env="AIOS_APIFY_KEY",
        caps={
            # ⭐ MEASURED 2026-08-08 against the public Reels grid: `videoPlayCount` 137,684 and
            # 299,493 vs a browser-read ground truth of 134K-137K and 299K. Exact.
            "ig_post_views": Capability(True, _COST_APIFY,
                                        "videoPlayCount β€” matches Instagram's displayed views"),
            "ig_post_metrics": Capability(True, _COST_APIFY, "likes + comments (fallback)"),
            "ig_profile": Capability(True, _COST_APIFY, "profile fields (fallback)"),
        }),
}

#: The DEFAULT chain per capability, cheapest-capable-first. Overridable β€” see `chain()`.
DEFAULT_CHAINS: dict[str, tuple] = {
    "ig_profile": ("brightdata", "apify"),
    "ig_post_metrics": ("brightdata", "apify"),
    # Only one entry, and that is the point: Bright Data is declared incapable, so listing it here
    # would be a lie that costs a wasted call on every single post.
    "ig_post_views": ("apify",),
    "ig_comments": ("brightdata",),
    # ⭐ WAVE 29 β€” TikTok, and every chain is deliberately SINGLE-PROVIDER.
    # β›” A MULTI-PROVIDER CHAIN IS A PROMISE SOMETHING WALKS IT. `ig_post_metrics` has declared a
    # two-provider fallback since wave 28 and NOTHING reaches the second name: if Bright Data
    # answers with the likes blank, Apify is never asked. `verify_automation`'s E2b pins that gap
    # BY NAME so a third unwalked chain turns it red β€” which is exactly what a second name here
    # would be today. Apify does sell TikTok (`clockworks/tiktok-profile-scraper`, $0.003/result,
    # 0.7% 30-day failure rate β€” measured from its own store record), so the fallback is buildable;
    # it is not declared until a runner walks it.
    "tt_profile": ("brightdata",),
    "tt_post_metrics": ("brightdata",),
    "tt_comments": ("brightdata",),
}


def chain(capability: str) -> list:
    """The provider order for `capability` β€” env override first, then the default.

    `AIOS_PROVIDER_ORDER` is a `;`-separated list of `capability=p1,p2` clauses. This is how the
    owner's "or vice versa" is expressed WITHOUT a release, and how one tenant can be moved off a
    vendor that is having a bad day.
    β›” An override may REORDER and may DROP, but it can never make an incapable provider capable β€”
    `can()` still gates every name. A chain that reads `ig_post_views=brightdata` therefore
    resolves to EMPTY rather than to a provider that would return a wrong number, because a
    plausible wrong number is worse than an honest refusal.
    """
    raw = (os.environ.get("AIOS_PROVIDER_ORDER") or "").strip()
    names = None
    for clause in raw.split(";"):
        if "=" in clause:
            cap_name, _, order = clause.partition("=")
            if cap_name.strip() == capability:
                names = [x.strip() for x in order.split(",") if x.strip()]
    if names is None:
        names = list(DEFAULT_CHAINS.get(capability) or ())
    return [PROVIDERS[n] for n in names if n in PROVIDERS and PROVIDERS[n].can(capability)]


def estimate(capability: str, records: int) -> dict:
    """What the FIRST capable provider would cost for `records` β€” the number an operator plans on.

    Reported per capability rather than per run because that is the unit that scales: at a thousand
    tenants the question is never "what did this run cost", it is "what does adding view counts to
    every post cost per month".
    """
    ch = chain(capability)
    if not ch:
        return {"capability": capability, "records": records, "provider": None, "usd": 0.0,
                "note": "no configured, capable provider"}
    p = ch[0]
    c = p.cap(capability)
    return {"capability": capability, "records": records, "provider": p.key,
            "usd": round((c.cost_per_record or 0.0) * max(0, int(records)), 4),
            "note": c.note}


@dataclass
class Attempt:
    provider: str
    ok: bool
    note: str = ""
    records: int = 0
    seconds: float = 0.0


def run(capability, work, satisfied=None, log=None):
    """Walk the chain for `capability` until one provider gives a SATISFYING answer.

    `work(provider) -> (result, note)`; a non-empty note means it did not answer.
    `satisfied(result) -> bool` decides whether an answer is good enough to stop. Default: any
    truthy result.

    Returns `(result, attempts)`. `attempts` is the audit trail β€” every provider tried, whether it
    satisfied, and how long it took β€” because "where did this number come from and what did it
    cost" is a question somebody asks about a bill, not about a stack trace.

    β›” THE `satisfied` HOOK IS THE WHOLE POINT. Without it this is an error-handler, and the
    failure it must catch is not an error: a vendor answering 200 with the one field you needed
    left blank. That is exactly how Bright Data behaves on view counts, and a chain that only
    caught exceptions would have stopped there forever and never reached Apify.
    """
    log = log or (lambda *_a: None)
    ok = satisfied or (lambda r: bool(r))
    attempts, last = [], None
    for provider in chain(capability):
        started = time.time()
        try:
            result, note = work(provider)
        except Exception as e:                                    # noqa: BLE001
            # ⚠ TYPE NAME ONLY. A vendor client's str() can carry the URL, and the credential is
            # one refactor away from being a query param; this line must not be what leaks it.
            result, note = None, f"{type(e).__name__}"
        secs = round(time.time() - started, 2)
        if note:
            attempts.append(Attempt(provider.key, False, note, 0, secs))
            log(f"  {provider.label}: {note} β€” falling through")
            continue
        if not ok(result):
            attempts.append(Attempt(provider.key, False, "answered without the field asked for",
                                    0, secs))
            log(f"  {provider.label}: answered, but not with what was asked for β€” falling through")
            last = result if last is None else last
            continue
        attempts.append(Attempt(provider.key, True, "", _count(result), secs))
        log(f"  {provider.label}: ok ({_count(result)} records, {secs}s)")
        return result, attempts
    return last, attempts


def _count(result):
    if isinstance(result, (list, tuple, set)):
        return len(result)
    if isinstance(result, dict):
        return len(result)
    return 1 if result else 0


def wire():
    """What the Settings surface renders. Booleans and labels β€” NEVER a credential.

    Mirrors the `hikerReady` rule the rest of the product follows: a surface is told WHETHER a
    provider is usable, never what the key is.
    """
    return {
        "providers": [
            {"key": p.key, "label": p.label, "configured": p.configured(),
             "capabilities": sorted(k for k, c in p.caps.items() if c.capable)}
            for p in PROVIDERS.values()
        ],
        "chains": {cap: [p.key for p in chain(cap)] for cap in DEFAULT_CHAINS},
        "incapable": {
            f"{p.key}:{cap}": c.note
            for p in PROVIDERS.values() for cap, c in p.caps.items() if not c.capable
        },
    }


# =============================================================================================
# THE CANONICAL SCHEMA β€” owner ruling 2026-08-08:
#   *"standardize the schema between Bright Data and APIfy so we keep using the same pre-set
#    database even if the underlying engine changes"*
#
# β›” THE PRESET TABLES ARE THE CONTRACT; A VENDOR IS AN IMPLEMENTATION DETAIL. `ut_ig_posts` and
# `ut_ig_post_snapshots` must not gain, lose or rename a column because a chain was reordered β€” a
# tenant's saved views, filters, rollups and forms all bind to these keys, and a schema that moves
# with the vendor turns a routing change into a data migration.
#
# So every provider normalises INTO the keys below and nothing reads a vendor row downstream.
# `verify_automation` asserts both normalisers emit exactly `CANONICAL_POST_KEYS` on a fixture, so
# a third provider cannot ship with a near-miss key like `viewCount` and silently write a column
# nobody declared.
#
# ⚠ ONE NAME PER MEASUREMENT, AND `views` IS THE MEASUREMENT INSTAGRAM DISPLAYS. Meta folded
# Impressions/Plays/Video Views into a single **Views** metric on 2025-04-10, so carrying both a
# `views` and a `plays` column would be modelling a distinction the platform deleted β€” and it is
# exactly the distinction that let an account-grain number wear the "Views" label for a month.
# β›” THE VENDOR KEY THAT LOOKS RIGHT IS THE WRONG ONE, ON BOTH VENDORS. Apify ships BOTH
# `videoViewCount` (10,678) and `videoPlayCount` (137,684) for one reel whose true displayed count
# is ~137K β€” and Bright Data's useless `views` is 10,638 for that same reel. The two vendors' junk
# fields AGREE with each other, which is precisely what makes picking by name so dangerous.
#   canonical `views`  <-  apify `videoPlayCount`      βœ… matches the grid
#   canonical `views`  <-  apify `videoViewCount`      β›” off by 13x
#   canonical `views`  <-  brightdata `views`          β›” off by 13x AND account-grain
# =============================================================================================

#: Every key a normalised POST row may carry. Absent > blank: a key is omitted when the provider
#: did not answer, because `upsert_rows` merges and an empty string would ERASE what an earlier
#: paid run learned.
CANONICAL_POST_KEYS = (
    "shortcode", "url", "influencer_key", "posted_at", "type", "caption",
    "likes", "comments", "views", "paid_partnership", "partner", "hashtags",
    "alt_text", "tagged_location", "source_payload",
    # ⭐ 2026-08-09 β€” the three the SECOND provider answers and the first does not. Each has a
    # matching `field_def` in the engine's POST_FIELDS; a key here without a column there is a
    # value that normalises cleanly and is then dropped by the write door, silently.
    "plays", "video_duration", "comments_disabled",
)


def _int_or_none(v):
    """`-1` IS NOT A COUNT. Apify returns `likesCount: -1` when the creator HIDES their like
    count β€” a real state that is not a measurement. Writing -1 would render as a negative like
    count; writing 0 would claim nobody liked it. Both are lies, so the key is omitted."""
    try:
        n = int(v)
    except (TypeError, ValueError):
        return None
    return None if n < 0 else n


def normalize_post_apify(row):
    """One Apify `instagram-scraper` item -> the canonical post row."""
    if not isinstance(row, dict):
        return None
    code = str(row.get("shortCode") or "").strip()
    if not code:
        return None
    out = {
        "shortcode": code,
        "url": str(row.get("url") or f"https://www.instagram.com/reel/{code}/"),
        "influencer_key": str(row.get("ownerUsername") or ""),
        "posted_at": str(row.get("timestamp") or "").replace("T", " ")[:16],
        "type": "video" if str(row.get("type") or "").lower() == "video" else
                ("carousel" if row.get("childPosts") else "image"),
        "caption": str(row.get("caption") or ""),
        # ⭐ THE FIELD THIS WHOLE PROVIDER EXISTS FOR. `videoPlayCount`, never `videoViewCount`:
        # the wrong one agreed with Bright Data's junk (10,678 vs 10,638) on a reel whose true
        # count was ~137K, and `videoPlayCount` matched a browser read to the digit.
        "views": _int_or_none(row.get("videoPlayCount")),
        # ⭐⭐ 2026-08-09 (owner: *"Video plays (# Plays) field isn't in APIfy? i believe it is"*).
        # They were right, and the `plays` COLUMN had been empty on all 816 rows because nothing
        # ever wrote it β€” the field existed with no writer. MEASURED on two of their own reels:
        # `videoPlayCount` 216,904 / 95,331 and `videoViewCount` 115,929 / 30,871. Two different
        # real numbers, and we were storing only one of them.
        # ⚠ `plays` TAKES THE PLAY COUNT, which is also what `views` carries today β€” so the two
        # columns will agree until somebody decides otherwise, and that decision is the OWNER'S:
        # re-sourcing `views` to `videoViewCount` would change what the ~480 rows already captured
        # mean, and a column whose meaning changes halfway down is the one thing worse than a
        # column with no data. Flagged rather than done.
        "plays": _int_or_none(row.get("videoPlayCount")),
        "likes": _int_or_none(row.get("likesCount")),
        "comments": _int_or_none(row.get("commentsCount")),
        # ⭐ FIELDS BRIGHT DATA DOES NOT RETURN AT ALL, kept because they are already paid for in
        # this same response (owner: *"whatever APIfy has more than BD pls use it"*).
        "video_duration": _int_or_none(row.get("videoDuration")),
        "comments_disabled": "1" if row.get("isCommentsDisabled") else "",
        "hashtags": ", ".join(str(h) for h in (row.get("hashtags") or []) if h),
        "alt_text": str(row.get("alt") or ""),
        "paid_partnership": "1" if row.get("paidPartnership") else "",
        "partner": ", ".join(str((s or {}).get("username") or "")
                             for s in (row.get("sponsors") or []) if isinstance(s, dict)),
        "tagged_location": str((row.get("locationName") or "")),
        "source_payload": json.dumps(row, default=str)[:32_000_000],
    }
    return {k: v for k, v in out.items() if v not in (None, "")}


def normalize_profile_apify(row):
    """One Apify `instagram-scraper` PROFILE item (`resultsType: "details"`) -> our snapshot shape.

    ⭐ WHY THIS EXISTS (owner report 2026-08-09: *"I need Bright Data and Apify to work correctly
    in tandem"*). `PROVIDERS["apify"]` has declared `ig_profile` capable since 2026-08-08 and
    `DEFAULT_CHAINS["ig_profile"]` has read `("brightdata", "apify")` β€” but **nothing ever ran
    that chain.** `connectors_ig.pull_profile` went Bright Data -> anonymous HTML rungs and Apify
    was never asked, so a profile Bright Data cannot scrape came back `blocked` while a
    configured, declared-capable provider sat unused. A registry entry with no runner is a
    promise the product does not keep ([[flag-shipped-without-its-writer]]).

    ⚠ THE KEYS ARE APIFY'S camelCase, and mapping them HERE is the boundary rule this module
    already enforces for posts: nothing downstream may ever see a vendor-shaped key, so the
    snapshot writer cannot tell which vendor answered β€” which is what makes the fallback
    invisible to every consumer instead of a second schema.

    β›” OMIT, NEVER ZERO. A field Apify did not send is dropped, exactly as `normalize_post_apify`
    drops a missing count: writing 0 followers would claim we measured an empty account, and the
    caller's `satisfied` hook reads absence as "did not answer" and falls through. A zero would
    stop the chain on a lie.
    """
    if not isinstance(row, dict):
        return None
    # β›” AN ERROR ENVELOPE IS NOT A PROFILE (measured 2026-08-09). Apify answers a dead handle
    # with a 200 and a well-formed item β€” `{"username": …, "error": "not_found",
    # "errorDescription": "Post does not exist"}` β€” and this function used to build a "profile"
    # out of it: a username, a url and a source_payload, with every measured field absent. It
    # then read to the caller as a vendor that answered, so the reason was replaced by a shrug.
    # Refused HERE as well as in `connectors_ig.apify_profile` on purpose: the boundary rule this
    # module exists for is that nothing downstream ever sees a vendor-shaped key, and a vendor's
    # ERROR shape is the one that must never become a row.
    # ⚠ TRUTHINESS, NOT KEY PRESENCE β€” a successful item carries `error: null` (measured on
    # `sriyynntt`), so `"error" in row` would refuse every good profile.
    if row.get("error"):
        return None
    handle = str(row.get("username") or "").strip()
    if not handle:
        return None
    out = {
        "username": handle,
        "full_name": str(row.get("fullName") or ""),
        "bio": str(row.get("biography") or ""),
        "followers": _int_or_none(row.get("followersCount")),
        "following": _int_or_none(row.get("followsCount")),
        "posts_count": _int_or_none(row.get("postsCount")),
        "verified": "1" if row.get("verified") else "",
        "external_url": str(row.get("externalUrl") or ""),
        "ig_id": str(row.get("id") or ""),
        "profile_url": str(row.get("url") or f"https://www.instagram.com/{handle}/"),
        "business_category": str(row.get("businessCategoryName") or ""),
        "is_business": "1" if row.get("isBusinessAccount") else "",
        "is_private": "1" if row.get("private") else "",
        "highlights_count": _int_or_none(row.get("highlightReelCount")),
        "source_payload": json.dumps(row, default=str)[:32_000_000],
    }
    # ⚠ `followers`/`following` are ints and 0 is a LEGITIMATE value for them, so the filter
    # below must not treat 0 as absent the way the post normaliser can β€” an account really can
    # have zero followers. Only None and "" are dropped.
    return {k: v for k, v in out.items() if v is not None and v != ""}


def canonical_gaps(row, want=("views",)):
    """Which requested canonical fields this row does NOT carry β€” the `satisfied` input.

    Named rather than inlined because "did the vendor actually answer the question" is the whole
    fallback trigger, and a chain that asks it differently in two places will drift.
    """
    row = row if isinstance(row, dict) else {}
    return [k for k in want if row.get(k) in (None, "")]