| """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 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _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.""" |
| |
| |
| |
| |
| |
| |
| capable: bool = True |
| cost_per_record: float = 0.0 |
| |
| note: str = "" |
|
|
|
|
| @dataclass |
| class Provider: |
| key: str |
| label: str |
| |
| |
| 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()) |
|
|
|
|
| |
| |
| 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"), |
| |
| "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."), |
| |
| |
| |
| "tt_profile": Capability(True, _COST_BRIGHTDATA, |
| "40 fields incl. bio/engagement rates/region"), |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "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={ |
| |
| |
| "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)"), |
| }), |
| } |
|
|
| |
| DEFAULT_CHAINS: dict[str, tuple] = { |
| "ig_profile": ("brightdata", "apify"), |
| "ig_post_metrics": ("brightdata", "apify"), |
| |
| |
| "ig_post_views": ("apify",), |
| "ig_comments": ("brightdata",), |
| |
| |
| |
| |
| |
| |
| |
| |
| "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: |
| |
| |
| 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 |
| }, |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| CANONICAL_POST_KEYS = ( |
| "shortcode", "url", "influencer_key", "posted_at", "type", "caption", |
| "likes", "comments", "views", "paid_partnership", "partner", "hashtags", |
| "alt_text", "tagged_location", "source_payload", |
| |
| |
| |
| "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 ""), |
| |
| |
| |
| "views": _int_or_none(row.get("videoPlayCount")), |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "plays": _int_or_none(row.get("videoPlayCount")), |
| "likes": _int_or_none(row.get("likesCount")), |
| "comments": _int_or_none(row.get("commentsCount")), |
| |
| |
| "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 |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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], |
| } |
| |
| |
| |
| 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, "")] |
|
|