| """connectors_ig.py β THE INSTAGRAM CONNECTOR. Every wire that reaches a vendor for IG data. |
| |
| Wave 27 item 23, and the split is along one line: **this module knows what Instagram and its |
| vendors look like; `automation_engine.py` knows what an AUTOMATION is.** The engine used to know |
| both, which is how a 10,500-line file came to carry Bright Data's snapshot namespaces, Apify's |
| actor id and the shape of an `og:description` string. Nothing about the automation runtime needed |
| any of that, and nothing here needs to know what a trigger is. |
| |
| WHAT LIVES HERE |
| * the ANONYMOUS ladder's readers β Instagram's own public surfaces, $0, no key of any kind; |
| * BRIGHT DATA β the paid rung: the scraper route, the pre-collected CORPUS route, their two |
| mutually-404ing snapshot namespaces, and the row mappers that turn either into our shape; |
| * APIFY β the second provider, wired for the one capability Bright Data is measured incapable of; |
| * the CORPUS FILTER wire β the discovery query's shape guards (the vendor's 4-rule group cap and |
| its 3-level nesting ceiling) and the start/status/download calls; |
| * `pull_profile` β the orchestrator that walks paid rung then anonymous rungs for one profile. |
| |
| WHAT DELIBERATELY DOES NOT |
| * **Table schema.** `SNAPSHOT_FIELDS`, `POST_FIELDS`, `COMMENT_FIELDS`, `POST_SNAPSHOT_FIELDS` |
| and the `ut_ig_*` presets stay in the engine. They declare COLUMNS A TENANT'S VIEWS BIND TO β |
| product schema that must not move when a vendor does. That is the same boundary |
| `providers.CANONICAL_POST_KEYS` draws one level down, and drawing it twice is the point. |
| * **Routing between vendors.** `providers.py` owns which vendor answers which capability, in |
| what order, and at what cost. This module supplies the RUNNERS that routing calls. |
| * **Discovery vocabulary.** Which fields a person may filter on, what the operators are called, |
| which ones actually narrow a 620M-row corpus β that is a product surface and it stays in the |
| engine beside the runner that spends money on it. |
| |
| ββ THE IMPORT DIRECTION, WHICH IS THE ONE DECISION IN THIS FILE THAT IS NOT ABOUT INSTAGRAM. |
| `automation_engine` imports THIS module at its top level (it needs `bd_ready`, `pull_profile` and |
| the mappers, and so do `routes_automation` and `routes_connectors` through it). So a module-level |
| `import automation_engine` HERE would close a cycle β and a cycle's failure depends on which side |
| a caller reached first. The server imports the engine first and would have worked; a gate or a |
| probe doing `import connectors_ig` would have got a half-built engine and an AttributeError from |
| inside a vendor call. **An intermittent-by-import-order failure is the worst shape available**, so |
| every reach-back is a `from automation_engine import β¦` INSIDE the function that needs it, where |
| the module is always fully built. There are exactly seven β `_bd_why`, `bd_call`, |
| `apify_posts`, `apify_reels`, `apify_profile`, `_tag_metric_deferrals`, `pull_profile_bd`, |
| `pull_profile` β each |
| marked `# lazy β see the module header`, and they reach for four kinds of thing: |
| |
| fetch / fetch_json / Refused the SSRF-guarded HTTP rail (the scrape runner shares it) |
| PACE_SECONDS the pacing floor β β read at CALL time ON PURPOSE: the gate |
| sets `engine.PACE_SECONDS = 0` to run in seconds instead of |
| minutes, and a copy imported at module load would silently |
| stop honouring that while every test still passed |
| DEFAULT_POSTS_PER_PULL `clean_max_posts`' default lives with the config that validates |
| _s / _iso / clean_tier small shared helpers with one definition each, deliberately |
| |
| β A CREDENTIAL NEVER LEAVES THIS FILE, AND NEVER APPEARS IN A RETURN VALUE. Keys are read lazily |
| from the environment inside the call that uses them (`bd_key`, `apify_key`), travel in a header or |
| a query string, and every error path below quotes a STATUS or an exception TYPE NAME β never the |
| request, never `str(e)`. The readiness booleans (`bd_ready`, `apify_ready`) are what a surface is |
| allowed to know. |
| """ |
| from __future__ import annotations |
|
|
| import hashlib |
| import json |
| import os |
| import re |
| import time |
| from urllib.parse import urlparse |
|
|
| import requests |
|
|
| import providers |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| from connectors_bd import ( |
| BD_MAX_KB, |
| BD_METRIC_SCRAPE_WAIT, |
| BD_PATH_FILTER, |
| BD_PATH_FILTER_SNAPSHOT, |
| BD_PATH_SNAPSHOT, |
| BD_PATH_TRIGGER, |
| BD_SCRAPE_POLL, |
| BD_SCRAPE_WAIT, |
| _bd_deferral, |
| _bd_first_url, |
| _bd_flag, |
| _bd_list, |
| _bd_rows, |
| _bd_source_payload, |
| _first, |
| _ig_int, |
| bd_call, |
| bd_scrape, |
| bd_snapshot_progress |
| ) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| def ig_handle(url): |
| """The handle out of a profile URL (or a bare handle). '' when it is not one.""" |
| raw = str(url or "").strip() |
| if not raw: |
| return "" |
| if raw.startswith("@"): |
| return re.sub(r"[^A-Za-z0-9._]", "", raw[1:])[:40] |
| if "instagram.com" not in raw: |
| return re.sub(r"[^A-Za-z0-9._]", "", raw)[:40] if "/" not in raw else "" |
| path = urlparse(raw if "//" in raw else "https://" + raw).path.strip("/") |
| first = (path.split("/") or [""])[0] |
| if first in ("p", "reel", "reels", "explore", "accounts", "stories", ""): |
| return "" |
| return re.sub(r"[^A-Za-z0-9._]", "", first)[:40] |
|
|
|
|
|
|
|
|
| def _ig_zero_is_blank(v): |
| """Like `_ig_int`, but a vendor `0` reads as NOT MEASURED rather than as a measurement. |
| |
| β FOR THE PAID VIEW/PLAY RUNG ONLY. `POST_FIELDS` states the rule β *"BLANK, NEVER ZERO β¦ a 0 |
| here would claim a post nobody watched"* β but `_ig_int(0)` returns `0`, so the rule was |
| prose with nothing enforcing it. MEASURED 2026-08-08: `sriyynntt` came back with `views: 0` on |
| all twelve reels and those zeros were published as measurements; they were removed by the |
| REPEAT rule, not the zero rule, i.e. by luck. A single vendor `0` on one reel still landed. |
| β DO NOT reach for this on `likes` or `comments`. Zero likes is a real, common and useful |
| measurement; blanking it would destroy data to satisfy a rule that was never about it. |
| """ |
| n = _ig_int(v) |
| return None if n == 0 else n |
|
|
|
|
| BD_DS_PROFILES = "gd_l1vikfch901nx3by4" |
| BD_DS_POSTS = "gd_lk5ns7kz21pck8jpis" |
| BD_DS_REELS = "gd_lyclm20il4r5helnj" |
| BD_DS_COMMENTS = "gd_ltppn085pokosxh13" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| |
| |
| _BD_TYPES = {"image": "image", "images": "image", "photo": "image", "photos": "image", |
| "video": "video", "videos": "video", "reel": "video", "reels": "video", |
| "clip": "video", "clips": "video", "igtv": "video", "carousel": "carousel", |
| "carousel_container": "carousel", "sidecar": "carousel", "posts": "image"} |
|
|
|
|
| def _bd_type(*raw): |
| """The first recognisable content type among `raw`, defaulting to `image`.""" |
| for v in raw: |
| hit = _BD_TYPES.get(str(v or "").strip().lower()) |
| if hit: |
| return hit |
| return "image" |
|
|
|
|
| def ig_shortcode(url): |
| """The shortcode out of a post/reel permalink. '' when it is not one. |
| |
| The `ut_ig_posts` identity key. The vendor gives a post URL (sometimes without its trailing |
| slash) and a numeric `pk`; the shortcode is what both datasets agree on and what a human can |
| paste into a browser, so it stays the key. |
| """ |
| raw = str(url or "").strip() |
| if not raw: |
| return "" |
| path = urlparse(raw if "//" in raw else "https://" + raw).path.strip("/").split("/") |
| if len(path) >= 2 and path[0] in ("p", "reel", "reels", "tv"): |
| return re.sub(r"[^A-Za-z0-9_-]", "", path[1])[:40] |
| return "" |
|
|
|
|
|
|
|
|
| def _bd_posts_count(node): |
| """`posts_count`, or None β **and a vendor ZERO IS DISCARDED.** |
| |
| β MEASURED 2026-08-05: the Profiles dataset returned `posts_count: 0` for BOTH probe accounts |
| β `inayma` (203,800 followers, 12 posts returned in the same row) and `utopian_events` (20,924 |
| followers, 12 posts). The POSTS dataset carries the true value for the same two accounts |
| (1,215 and 751), so the zero is not the account being empty; it is a measurement failure |
| wearing a number. Writing it would be the invented measurement this module's honest-status |
| contract exists to forbid β and a `_first`-style candidate list cannot help, because the |
| vendor DID answer, with the wrong answer. |
| |
| β THE ASYMMETRY IS DELIBERATE AND IT COSTS SOMETHING: an account that genuinely has zero posts |
| also reads blank. "Not read" is honest about an unknown; "0" is a claim, and on the only |
| evidence we have that claim is wrong 2 times out of 2. |
| β `len(posts)` IS NOT A SUBSTITUTE. The profile row carries the TOP 12 β a cap, not a count. |
| """ |
| v = _ig_int(_first(node, "posts_count", "media_count", "post_count")) |
| return None if v == 0 else v |
|
|
|
|
|
|
|
|
|
|
|
|
| def _bd_tagged_location(node): |
| """Bright Data's optional Post `location` -> one filterable content-context string. |
| |
| The documented Posts response is an ordered array such as |
| `["Capanema", "ParΓ‘", "Brasil"]`; retain the order rather than guessing which component is |
| a city or country. Reels do not promise this field, so a blank means it was not returned. |
| """ |
| raw = _first(node, "location", "location_details") |
| if isinstance(raw, dict): |
| raw = [raw.get(k) for k in ("name", "city", "region", "state", "country", "slug")] |
| if not isinstance(raw, list): |
| raw = [raw] |
| parts = [] |
| for value in raw: |
| if isinstance(value, dict): |
| value = _first(value, "name", "city", "region", "state", "country", "slug") |
| text = str(value or "").strip() |
| if text and text not in parts: |
| parts.append(text) |
| return ", ".join(parts)[:400] or None |
|
|
|
|
|
|
|
|
| def _bd_comment(row, influencer_key="", shortcode="", fallback_key=""): |
| """One Bright Data Comment response -> one linked Comment record with engagement. |
| |
| The dedicated Comments dataset is invoked only by the default-off `commentMetrics` switch. |
| This mapper is also used for comment arrays embedded in an already-paid Post/Reel response, |
| which adds no request. The full provider object is preserved in Source data; Likes and Replies |
| are promoted for the grid and future rollups. |
| """ |
| if not isinstance(row, dict): |
| return None |
| url = str(_first(row, "url", "post_url", default="") or "") |
| code = str(shortcode or "") or ig_shortcode(url) |
| comment_id = str(_first(row, "comment_id", "id", "pk", default="") or fallback_key or "") |
| if not code or not comment_id: |
| return None |
| posted = str(_first(row, "comment_date", "date_of_comment", "created_at", "created_time", |
| default="") or "") |
| return { |
| "comment_key": comment_id, |
| "shortcode": code, |
| "influencer_key": str(influencer_key or _first(row, "post_user", default="") or ""), |
| |
| |
| |
| |
| |
| |
| |
| "text": str(_first(row, "comment", "comment_text", "text", default="") or ""), |
| "commented_at": posted.replace("T", " ")[:16], |
| "likes": _ig_int(_first(row, "likes_number", "likes", "like_count", "likes_count")), |
| "replies": _ig_int(_first(row, "replies_number", "num_replies", "replies", |
| "reply_count", "replies_count")), |
| "source_payload": _bd_source_payload(row), |
| } |
|
|
|
|
| def _bd_embedded_comments(post, influencer_key=""): |
| """Comment rows carried inside a paid Profile/Post/Reel record, with no extra request.""" |
| if not isinstance(post, dict): |
| return [] |
| shortcode = str(post.get("shortcode") or "") or ig_shortcode(post.get("url")) |
| if not shortcode: |
| return [] |
| out, seen = [], set() |
| |
| |
| arrays = [(str(k), v) for k, v in post.items() |
| if isinstance(v, list) and (str(k) in ("latest_comments", "top_comments") |
| or "comment" in str(k).lower())] |
| for source_key, values in arrays: |
| for index, value in enumerate(values): |
| if not isinstance(value, dict): |
| continue |
| try: |
| raw = json.dumps(value, ensure_ascii=False, sort_keys=True, |
| separators=(",", ":")) |
| except (TypeError, ValueError): |
| continue |
| fallback = "embedded:" + hashlib.sha256( |
| f"{shortcode}:{source_key}:{index}:{raw}".encode("utf-8")).hexdigest()[:24] |
| got = _bd_comment(value, influencer_key=influencer_key, shortcode=shortcode, |
| fallback_key=fallback) |
| if got and got["comment_key"] not in seen: |
| seen.add(got["comment_key"]) |
| out.append(got) |
| return out |
|
|
|
|
| def _bd_profile(node, handle): |
| """A Profiles row β our snapshot shape. Every name below is MEASURED off a real row |
| (2026-08-05, 35 keys); the candidates guard renames, not guesses. |
| |
| β Several of these were **null on both probe rows** (`avg_engagement`, `category_name`, |
| `business_category_name`, `is_professional_account`, `bio_hashtags`, `post_hashtags`). They |
| are mapped ANYWAY β see the note on `SNAPSHOT_FIELDS`: n=2 is not evidence enough to drop a |
| column permanently, and history is unbuyable, so a field omitted today is lost for today. |
| β They ARE populated on the CORPUS (`/datasets/filter`) rows, which is a different path with |
| a different population; that is a reason to capture them, not a licence to promise a |
| scrape-side feature before D-25 measures the fill rate. |
| """ |
| out = { |
| "username": str(_first(node, "account", "username", default=handle) or handle), |
| "full_name": str(_first(node, "full_name", "profile_name", default="") or ""), |
| "bio": str(_first(node, "biography", "bio", default="") or ""), |
| "followers": _ig_int(_first(node, "followers", "follower_count")), |
| "following": _ig_int(_first(node, "following", "following_count")), |
| "posts_count": _bd_posts_count(node), |
| "verified": "1" if _first(node, "is_verified", default=False) else "", |
| "external_url": _bd_first_url(_first(node, "external_url", "external_urls", |
| "external_url_title")), |
| |
| |
| |
| "ig_id": str(_first(node, "id", "fbid", default="") or ""), |
| "profile_url": str(_first(node, "profile_url", "url", default="") or ""), |
| "avg_engagement": _first(node, "avg_engagement"), |
| "category": str(_first(node, "category_name", default="") or ""), |
| "business_category": str(_first(node, "business_category_name", default="") or ""), |
| "is_business": _bd_flag(node, "is_business_account"), |
| "is_professional": _bd_flag(node, "is_professional_account"), |
| "is_private": _bd_flag(node, "is_private"), |
| "highlights_count": _ig_int(_first(node, "highlights_count")), |
| "bio_hashtags": _bd_list(node, "bio_hashtags"), |
| "pronouns": str(_first(node, "pronouns", default="") or ""), |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "profile_name": str(_first(node, "profile_name", default="") or ""), |
| "is_joined_recently": _bd_flag(node, "is_joined_recently"), |
| "has_channel": _bd_flag(node, "has_channel"), |
| "partner_id": str(_first(node, "partner_id", default="") or ""), |
| |
| |
| |
| "external_url_title": _bd_first_url(_first(node, "external_url_title")), |
| |
| |
| "fbid": str(_first(node, "fbid", default="") or ""), |
| |
| |
| |
| "related_accounts": _bd_list(node, "related_accounts"), |
| |
| |
| |
| "country_code": str(_first(node, "country_code", default="") or ""), |
| "source_payload": _bd_source_payload(node), |
| } |
| return out |
|
|
|
|
| def _bd_post_identity(p): |
| """One entry of a Profiles row's `posts[]` β our post shape. None when it has no shortcode. |
| |
| β IDENTITY ONLY β AND THAT IS THE FINDING THIS WHOLE RUNG IS SHAPED AROUND. MEASURED |
| 2026-08-05 across 24 posts on two accounts: `caption`, `content_type`, `id`, `image_url`, |
| `url` are populated 24/24, and **`likes`, `comments` and `datetime` are `None` 24/24.** So the |
| profile scrape can fill `ut_ig_posts` and CANNOT fill `ut_ig_post_snapshots` β the engagement |
| time series Β§3's append law exists for needs the POSTS dataset, one record per post. That is |
| why `postMetrics` is a separate, opt-in, ~13Γ-cost rung rather than a free by-product. |
| β `is_pinned` was `True` on all 24 β a constant, not a measurement. Not mapped. |
| β Blank-valued keys are OMITTED rather than written, so a later engagement pull that DOES know |
| `posted_at` fills it in instead of being overwritten by this rung's silence. |
| """ |
| code = ig_shortcode(p.get("url")) if isinstance(p, dict) else "" |
| if not code: |
| return None |
| kind = _bd_type(p.get("content_type"), p.get("type")) |
| |
| |
| |
| route = "reel" if kind == "video" else "p" |
| out = {"shortcode": code, "url": f"https://www.instagram.com/{route}/{code}/", |
| "type": kind} |
| caption = _first(p, "caption", "description", default="") |
| if isinstance(caption, dict): |
| caption = _first(caption, "text", default="") |
| if caption: |
| out["caption"] = str(caption) |
| out["source_payload"] = _bd_source_payload(p) |
| embedded = _bd_embedded_comments(p) |
| if embedded: |
| out["embedded_comments"] = embedded |
| return out |
|
|
|
|
| def _bd_post_metrics(row): |
| """One POSTS-dataset row β the engagement half. None when it has no shortcode. |
| |
| MEASURED 2026-08-05 on three real post rows: `likes` (308 / 3,736), `num_comments` (37 / 134) |
| and `date_posted` (`2026-05-04T17:51:58.000Z`) all populated. |
| β NO VIEW COUNT CAME BACK β `video_play_count` / `video_view_count` are declared in the schema |
| and the KEY WAS ABSENT from every row, including a Reel. So `views` reads None and the column |
| stays blank, which is the honest answer; it is not folded into likes and it is not zeroed. |
| β The candidates below are all COUNTS OF VIEWS in different vendor typings (`video_view_count` |
| is declared `text`, the others `number`). A candidate list may span types only when the |
| MEANING is identical β never the way `like_and_view_counts_disabled`, a boolean, would have |
| turned "likes are hidden" into "1 like". |
| Embedded comment arrays are retained in Source data and converted to linked Comment rows |
| without triggering the separate full Comments-dataset request. |
| """ |
| if not isinstance(row, dict): |
| return None |
| code = str(_first(row, "shortcode", "content_id", default="") or "") or \ |
| ig_shortcode(row.get("url")) |
| if not code: |
| return None |
| posted = str(_first(row, "date_posted", "taken_at", default="") or "") |
| partner = row.get("partnership_details") |
| out = { |
| "shortcode": code, |
| "url": str(_first(row, "url", default=f"https://www.instagram.com/p/{code}/")), |
| "influencer_key": str(_first(row, "user_posted", default="") or ""), |
| "posted_at": posted.replace("T", " ")[:16], |
| "type": _bd_type(row.get("content_type"), row.get("product_type")), |
| "caption": str(_first(row, "description", "caption", default="") or ""), |
| "likes": _ig_int(_first(row, "likes", "like_count")), |
| "comments": _ig_int(_first(row, "num_comments", "comment_count", "comments")), |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| "plays": _ig_zero_is_blank(_first(row, "video_play_count", "play_count")), |
| |
| |
| "paid_partnership": _bd_flag(row, "is_paid_partnership"), |
| "partner": (str(_first(partner, "username", "profile_id", default="") or "") |
| if isinstance(partner, dict) else ""), |
| "hashtags": _bd_list(row, "hashtags"), |
| "alt_text": str(_first(row, "alt_text", default="") or ""), |
| "tagged_location": _bd_tagged_location(row), |
| "source_payload": _bd_source_payload(row), |
| } |
| embedded = _bd_embedded_comments(row, influencer_key=out["influencer_key"]) |
| if embedded: |
| out["embedded_comments"] = embedded |
| return out |
|
|
|
|
| def bd_true_posts_count(rows): |
| """The TRUE `posts_count`, read out of a Posts/Reels row's profile-context block. None if |
| none of them carries one. |
| |
| ββ DEBT D-82, AND THE FIX WAS ALREADY IN A ROW WE HAD PAID FOR. The Profiles dataset returns |
| `posts_count: 0` for accounts with thousands of posts (MEASURED 2026-08-05: `inayma` 203,800 |
| followers and `utopian_events` 20,924, both `0`), so `_bd_posts_count` discards it β correctly, |
| because a fabricated zero is a measurement failure wearing a number. The POSTS dataset carries |
| the truth for those same two accounts (1,215 and 751), and it carries it in the vendor's own |
| `input` echo: |
| |
| "input": {"url": β¦, "posts_count": 386, "followers": 6133, "following": 2850, |
| "video_play_count": null, "views": null, "country_code": null} |
| |
| β THIS IS THE SAME BLOCK THAT PRODUCED THE `views` DEFECT, read the right way round. That |
| field is ACCOUNT-grain, which is exactly why mapping it onto a POST was wrong and why reading |
| `posts_count` from it is right: we are asking an account-level question of an account-level |
| block ([[bd-reels-views-account-level]]). |
| β A ZERO IS STILL DISCARDED HERE. The Profiles dataset's zero is not trusted; the same number |
| from a second endpoint is not evidence, it is the same failure arriving twice. |
| """ |
| for row in rows or []: |
| blk = (row or {}).get("input") if isinstance(row, dict) else None |
| if not isinstance(blk, dict): |
| continue |
| v = _ig_int(_first(blk, "posts_count", "media_count", "post_count")) |
| if v: |
| return v |
| return None |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| PROFILE_BATCH_URLS = max(1, int(os.environ.get("AIOS_BD_PROFILE_BATCH") or 5)) |
|
|
|
|
| |
| |
| |
| |
| MAX_POST_WINDOW = int(os.environ.get("AIOS_IG_MAX_POST_WINDOW") or 60) |
|
|
|
|
| def _bd_mapped_posts(rows, raw_out=None): |
| """Posts-dataset rows -> our canonical post rows, drops what will not map. |
| |
| β `raw_out` IS NOT A CONVENIENCE. `bd_true_posts_count` reads the vendor's profile-context |
| echo, which lives on the RAW row and is destroyed by mapping β so a caller that only kept the |
| mapped rows would lose the FREE `posts_count` read and fall through to the branch that BUYS a |
| record for it. The sink keeps a window route exactly as cheap as the per-permalink one it |
| replaces. Same shape as `deferred=` and `applied=` elsewhere in this module. |
| |
| β `_bd_post_metrics`, the SAME mapper `pull_profile_bd` feeds its per-permalink reads through. |
| A window route with its own mapper would be a second answer to "which vendor key is `views`", |
| and this repo has already paid for one of those ([[one-question-two-normalizers]]). |
| """ |
| out = [] |
| for r in (rows or []): |
| if isinstance(raw_out, list) and isinstance(r, dict): |
| raw_out.append(r) |
| got = _bd_post_metrics(r) if isinstance(r, dict) else None |
| if got: |
| out.append(got) |
| return out |
|
|
|
|
| def bd_profile_posts(handle, count, deferred=None, wait=None, post_type="", raw_out=None): |
| """A profile's LAST `count` posts via discover-by-url. `(rows, note)` β rows already MAPPED. |
| |
| ββ WAVE 30 Β· T16 (carried W29-T10) β `post_type` IS THE WHOLE FIX AND IT IS ONE KEY. |
| MEASURED 2026-08-10 on `@theresalearns`, same window, same hour, so no probability argument is |
| needed: this route WITHOUT the key returned **8 Reels + 3 Carousels** for a 12-post ask; WITH |
| `post_type="reel"` it returned **12/12**. Asking for "the last 12 reels" and being handed eight |
| is not a filter that under-delivers, it is a filter applied to the wrong set β the shortfall |
| scales with a creator's carousel share and nothing in the run says so. |
| β It is HONOURED, not silently ignored (the failure mode that looks like success): the returned |
| rows carry `content_type: "Reel"`. Same dataset, same route, same mapper, same price. |
| β **AND IT DOES NOT REVIVE VIEWS ON BRIGHT DATA.** This measured `product_type`, never `views`; |
| the Reels dataset stays type-capable and views-INCAPABLE ([[bd-reels-views-account-level]]). |
| Views still come from Apify. Conflating the two questions about one dataset cost a day once. |
| |
| β ROWS ARE MAPPED HERE NOW, and that is the second half of why this had no caller. It returned |
| raw `_bd_rows()` β a vendor-shaped list every caller would have had to normalise itself, which |
| is a second mapper waiting to disagree with `_bd_post_metrics`. It is the boundary's job. |
| |
| β A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT. |
| |
| β A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT. The profile record |
| embeds at most 12 posts however many you ask for β measured, not assumed. This is the |
| documented discovery contract (`type=discover_new&discover_by=url`, `num_of_posts: N`), the |
| only route that can answer "the last 30 posts", which is what makes a per-group filter |
| ("last 12 reels AND last 12 images") expressible at all. |
| |
| β IT COSTS PER RECORD. 30 posts is ~2.5x the spend of 12, so no caller should reach for this |
| because wider is nicer β `MAX_POST_WINDOW` bounds it and the automation's own config decides. |
| """ |
| try: |
| n = max(1, min(int(count), MAX_POST_WINDOW)) |
| except (TypeError, ValueError): |
| n = 12 |
| _row = {"url": f"https://www.instagram.com/{ig_handle(handle) or handle}/", |
| "num_of_posts": n} |
| if str(post_type or "").strip(): |
| |
| |
| _row["post_type"] = str(post_type).strip() |
| payload, note = bd_call(BD_PATH_TRIGGER, |
| {"dataset_id": BD_DS_POSTS, "type": "discover_new", |
| "discover_by": "url"}, |
| body=[_row]) |
| if note: |
| return [], note |
| |
| |
| |
| |
| |
| |
| sid = _bd_deferral(payload) or (str(payload.get("snapshot_id") or "") |
| if isinstance(payload, dict) else "") |
| if not sid: |
| return _bd_mapped_posts(_bd_rows(payload), raw_out), "" |
| budget = BD_SCRAPE_WAIT if wait is None else float(wait) |
| waited = 0.0 |
| while True: |
| state, records, empty_note = bd_snapshot_progress(sid) |
| if state in ("done", "failed") and not records: |
| return [], empty_note |
| if state != "running": |
| got, gnote = bd_call(f"{BD_PATH_SNAPSHOT}/{sid}", {"format": "json"}) |
| if not gnote: |
| rows = _bd_rows(got) |
| if rows and not _bd_deferral(got) and not ( |
| len(rows) == 1 and str(rows[0].get("status") or "") in |
| ("running", "building", "collecting")): |
| return _bd_mapped_posts(rows, raw_out), "" |
| if waited >= budget: |
| break |
| time.sleep(BD_SCRAPE_POLL) |
| waited += BD_SCRAPE_POLL |
| if isinstance(deferred, list): |
| deferred.append({"snapshotId": sid, "datasetId": str(BD_DS_POSTS), |
| "urls": [f"https://www.instagram.com/{handle}/"], "kind": "posts"}) |
| return [], (f"the post source deferred this request to {sid} and it was not ready within " |
| f"{int(budget)}s β the records are collected, not lost") |
|
|
|
|
| def group_types(groups): |
| """`{type: limit}` for a stored `postGroups` value, ignoring anything malformed. |
| |
| β ONE READER FOR THE STORED SHAPE. `select_post_groups` built this dict inline and the two |
| routing helpers below need the same answer; three copies of "what did the person ask for" is |
| how a filter and the read that feeds it end up disagreeing about the ask. |
| """ |
| wanted = {} |
| for g in (groups or []): |
| if not isinstance(g, dict): |
| continue |
| t = str(g.get("type") or "").strip().lower() |
| try: |
| lim = int(g.get("limit")) |
| except (TypeError, ValueError): |
| continue |
| if t and lim > 0: |
| wanted[t] = max(wanted.get(t, 0), lim) |
| return wanted |
|
|
|
|
| def reels_only_limit(groups): |
| """The reel limit when the config asks for REELS AND NOTHING ELSE, else 0. |
| |
| β THE TEST IS EXCLUSIVITY, NOT PRESENCE. "12 reels AND 6 images" cannot be served by a |
| single-type route β one request cannot express it β so a `video in wanted` test would send a |
| mixed ask down the reels-only branch and silently drop the images. That is the specialisation |
| trap: the narrow route is right only when the ask is narrow. |
| """ |
| wanted = group_types(groups) |
| return wanted.get("video", 0) if set(wanted) == {"video"} else 0 |
|
|
|
|
| def group_window(groups): |
| """How many posts ONE wide read must return for every group to be fillable. |
| |
| β THE SUM, then bounded by `MAX_POST_WINDOW`. A mixed ask is only satisfiable if the window is |
| at least as large as everything asked for, and the vendor's window is what actually limits it β |
| which is why the caller REPORTS a bound it could not meet rather than quietly returning fewer |
| (a short window reads downstream as "this creator posts less", which is a claim about them). |
| """ |
| return min(sum(group_types(groups).values()), MAX_POST_WINDOW) |
|
|
|
|
| def select_post_groups(posts, groups): |
| """Keep the newest `limit` posts of each named TYPE. Returns the kept posts, newest first. |
| |
| β 2026-08-09 (owner: *"not just last 12 but by group also"*). `groups` is |
| `[{"type": "video", "limit": 12}, {"type": "image", "limit": 6}]`. A type nobody named is |
| DROPPED, which is the point of a filter β asking for reels and getting carousels back is the |
| behaviour this replaces. |
| |
| β NEWEST-FIRST BY `posted_at`, and a post with no date sorts LAST rather than first. A blank |
| date is not "just now"; ranking it as though it were would silently prefer exactly the rows we |
| know least about ([[sentinel-in-a-sort-key]] β partition, never rank a sentinel). |
| """ |
| wanted = group_types(groups) |
| if not wanted: |
| return list(posts or []) |
| dated, undated = [], [] |
| for p in (posts or []): |
| (dated if str((p or {}).get("posted_at") or "").strip() else undated).append(p) |
| dated.sort(key=lambda p: str(p.get("posted_at") or ""), reverse=True) |
| kept, seen = [], {} |
| for p in dated + undated: |
| t = str((p or {}).get("type") or "").strip().lower() |
| if t not in wanted: |
| continue |
| if seen.get(t, 0) >= wanted[t]: |
| continue |
| seen[t] = seen.get(t, 0) + 1 |
| kept.append(p) |
| return kept |
|
|
|
|
| def bd_profiles_batch(handles, deferred=None, wait=None): |
| """Read MANY profiles per vendor call. Returns `({handle: node}, note)`. |
| |
| The batch FAST PATH for `pull_profile_bd`: whatever comes back inline is handed over as a |
| prefetch map so the per-record rung never asks the vendor for it again. Handles this cannot |
| resolve are simply absent from the map, and the per-record path runs for them exactly as it |
| does today β this function can make a run faster, never wronger. |
| |
| β ATTRIBUTION COMES OFF THE ROW, NOT OFF THE REQUEST ORDER. Each Profiles row carries its own |
| `account`/`username` (it is what `_bd_profile` reads first, taking its `handle` argument only |
| as a default), so a 5-URL answer is mapped back by identity. Zipping the response against the |
| request list would silently mis-assign every profile the moment the vendor reorders, dedupes |
| or drops one β and a mis-assigned profile is worse than a missing one, because nothing about |
| it looks wrong. |
| |
| β A DEFERRED CHUNK IS HANDED BACK, NOT DROPPED. `bd_scrape` appends `{snapshotId, datasetId, |
| urls}` to `deferred`, and the caller fans it out into one task per DESTINATION. Discarding it |
| would re-create the exact defect fixed this morning β a paid snapshot whose id nothing kept β |
| and would then bill a second time when the per-record rung asked again. |
| """ |
| want, seen = [], set() |
| for h in (handles or []): |
| key = str(h or "").strip().lstrip("@").lower() |
| if key and key not in seen: |
| seen.add(key) |
| want.append(key) |
| if not want: |
| return {}, "" |
|
|
| out, notes = {}, [] |
| for i in range(0, len(want), PROFILE_BATCH_URLS): |
| chunk = want[i:i + PROFILE_BATCH_URLS] |
| rows, note = bd_scrape(BD_DS_PROFILES, |
| [f"https://www.instagram.com/{h}/" for h in chunk], |
| wait=wait, deferred=deferred) |
| if note: |
| notes.append(note) |
| for node in rows or []: |
| if not isinstance(node, dict): |
| continue |
| got = str(_first(node, "account", "username", default="") or "").strip() |
| got = got.lstrip("@").lower() |
| |
| |
| if got and got in seen: |
| out[got] = node |
| return out, "; ".join(n for n in notes if n) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| APIFY_BASE = "https://api.apify.com/v2" |
| |
| APIFY_ACTOR_POSTS = os.environ.get("AIOS_APIFY_ACTOR") or "apify~instagram-scraper" |
| |
| |
| APIFY_WAIT = float(os.environ.get("AIOS_APIFY_WAIT") or 240) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| ACCOUNT_GONE_NOTE = ("Instagram has no account with that handle β the source reports it does " |
| "not exist (it may have been deleted, renamed, or suspended)") |
|
|
|
|
| def apify_key(): |
| """The key, or ''. Read fresh every call, exactly as `bd_key` is.""" |
| return (os.environ.get("AIOS_APIFY_KEY") or "").strip() |
|
|
|
|
| def apify_ready(): |
| return bool(apify_key()) |
|
|
|
|
| def apify_posts(urls): |
| """Post/reel permalinks -> (canonical rows, note). A note means it did NOT answer. |
| |
| Returns rows already normalised through `providers.normalize_post_apify`, so nothing |
| downstream ever sees an Apify-shaped key. That is the owner's schema ruling enforced at the |
| only place it can be: the boundary. |
| """ |
| from automation_engine import Refused, fetch_json |
| want = [str(u) for u in (urls or []) if u] |
| if not want: |
| return [], "" |
| key = apify_key() |
| if not key: |
| return [], "AIOS_APIFY_KEY is not configured β the Apify rung is closed" |
| url = (f"{APIFY_BASE}/acts/{APIFY_ACTOR_POSTS}/run-sync-get-dataset-items" |
| f"?token={requests.utils.quote(key)}&timeout={int(APIFY_WAIT)}") |
| body = {"directUrls": want, "resultsType": "posts", "resultsLimit": max(1, len(want)), |
| "addParentData": False} |
| try: |
| status, raw = fetch_json(url, body, timeout=APIFY_WAIT + 60, max_kb=BD_MAX_KB) |
| except Refused as e: |
| return [], f"Apify's address was refused by the URL rail: {e}" |
| except Exception as e: |
| |
| |
| return [], f"Apify did not answer ({type(e).__name__})" |
| if status == 402: |
| return [], "the Apify account is out of credit" |
| if status in (401, 403): |
| return [], f"Apify refused our key ({status})" |
| if status >= 400: |
| return [], f"Apify answered {status}" |
| try: |
| items = json.loads(raw.decode("utf-8", "replace")) |
| except Exception: |
| return [], "Apify's answer was not readable JSON" |
| if not isinstance(items, list): |
| return [], "Apify answered with something other than a list of items" |
| rows = [providers.normalize_post_apify(x) for x in items] |
| return [r for r in rows if r], "" |
|
|
|
|
| def apify_reels(handle, limit): |
| """A profile's LAST `limit` REELS, with view counts INLINE. `(rows, note)`. |
| |
| ββ WAVE 30 Β· T16 (carried W29-T11) β ONE CALL FOR WHAT CURRENTLY TAKES THREE. |
| MEASURED 2026-08-10 (`@theresalearns`): `resultsType: "reels"` + `resultsLimit: 12` returned |
| **12/12 `productType: clips`, every one carrying a distinct `videoPlayCount`** (2,113 β |
| 418,232). The shipped path for the same answer is a Bright Data profile read, a client-side |
| type filter, and a SEPARATE per-permalink Apify `top_up_views` β three steps, two vendors, and |
| one request's worth of data. |
| |
| β A NEW BRANCH, NEVER A RE-POINTING OF THE DEFAULT CHAIN. `ig_post_metrics` does not move and |
| Bright Data stays first for it; this is the specialisation for a single-type ask, and it is |
| reached only when the config's groups name reels and nothing else. |
| β ITS CALLER MUST NOT THEN RUN `top_up_views`. The views are already here; asking Apify again |
| per permalink would buy the same numbers twice, which is the shape a "top-up" is meant to |
| avoid rather than cause. |
| β THE ACTOR PAGE CONTRADICTS ITS OWN INPUT SCHEMA β the page says `resultsType` has no |
| `"reels"` value. It does; the response is the evidence ([[measure-the-real-call]]). |
| """ |
| from automation_engine import Refused, fetch_json |
| h = ig_handle(handle) or str(handle or "").strip().lstrip("@") |
| if not h: |
| return [], "no handle to read reels for" |
| key = apify_key() |
| if not key: |
| return [], "AIOS_APIFY_KEY is not configured β the Apify rung is closed" |
| try: |
| n = max(1, min(int(limit), MAX_POST_WINDOW)) |
| except (TypeError, ValueError): |
| n = 12 |
| url = (f"{APIFY_BASE}/acts/{APIFY_ACTOR_POSTS}/run-sync-get-dataset-items" |
| f"?token={requests.utils.quote(key)}&timeout={int(APIFY_WAIT)}") |
| body = {"directUrls": [f"https://www.instagram.com/{h}/"], "resultsType": "reels", |
| "resultsLimit": n, "addParentData": False} |
| try: |
| status, raw = fetch_json(url, body, timeout=APIFY_WAIT + 60, max_kb=BD_MAX_KB) |
| except Refused as e: |
| return [], f"Apify's address was refused by the URL rail: {e}" |
| except Exception as e: |
| |
| return [], f"Apify did not answer ({type(e).__name__})" |
| if status == 402: |
| return [], "the Apify account is out of credit" |
| if status in (401, 403): |
| return [], f"Apify refused our key ({status})" |
| if status >= 400: |
| return [], f"Apify answered {status}" |
| try: |
| items = json.loads(raw.decode("utf-8", "replace")) |
| except Exception: |
| return [], "Apify's answer was not readable JSON" |
| if not isinstance(items, list): |
| return [], "Apify answered with something other than a list of items" |
| rows = [r for r in (providers.normalize_post_apify(x) for x in items) if r] |
| if not rows: |
| return [], "Apify answered with no reels for this profile" |
| return rows, "" |
|
|
|
|
| def apify_profile(handle): |
| """One handle -> (canonical profile | None, note). A note means it did NOT answer. |
| |
| β THE SECOND PROVIDER FOR `ig_profile`, and the rung that was MISSING (owner report |
| 2026-08-09). `providers.py` has declared Apify capable of `ig_profile` and the chain |
| `("brightdata", "apify")` since 2026-08-08, but no runner existed and nothing walked that |
| chain β so a profile Bright Data could not scrape fell straight past a configured provider |
| to the anonymous rungs, and reported `blocked` when those failed too. |
| |
| β `resultsType: "details"`, NOT `"posts"` β the same actor, a different question. Asking for |
| posts and reading the parent profile off `addParentData` would buy a page of media we did not |
| want on every profile read, which is the expensive mistake the capability split exists to |
| prevent (see the Apify header note above). |
| """ |
| from automation_engine import Refused, fetch_json |
| h = str(handle or "").strip().lstrip("@") |
| if not h: |
| return None, "no handle" |
| key = apify_key() |
| if not key: |
| return None, "AIOS_APIFY_KEY is not configured β the Apify rung is closed" |
| url = (f"{APIFY_BASE}/acts/{APIFY_ACTOR_POSTS}/run-sync-get-dataset-items" |
| f"?token={requests.utils.quote(key)}&timeout={int(APIFY_WAIT)}") |
| body = {"directUrls": [f"https://www.instagram.com/{h}/"], |
| "resultsType": "details", "resultsLimit": 1, "addParentData": False} |
| try: |
| status, raw = fetch_json(url, body, timeout=APIFY_WAIT + 60, max_kb=BD_MAX_KB) |
| except Refused as e: |
| return None, f"Apify's address was refused by the URL rail: {e}" |
| except Exception as e: |
| |
| return None, f"Apify did not answer ({type(e).__name__})" |
| if status == 402: |
| return None, "the Apify account is out of credit" |
| if status in (401, 403): |
| return None, f"Apify refused our key ({status})" |
| if status >= 400: |
| return None, f"Apify answered {status}" |
| try: |
| items = json.loads(raw.decode("utf-8", "replace")) |
| except Exception: |
| return None, "Apify's answer was not readable JSON" |
| if not isinstance(items, list) or not items: |
| return None, "Apify answered with no profile for that handle" |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| err = str((items[0] or {}).get("error") or "").strip() |
| if err: |
| desc = str((items[0] or {}).get("errorDescription") or "").strip() |
| if err in ("not_found", "not-found", "notfound"): |
| return None, ACCOUNT_GONE_NOTE |
| return None, f"the source could not read that account ({desc or err})" |
| prof = providers.normalize_profile_apify(items[0]) |
| if not prof: |
| return None, "Apify's profile row was not readable" |
| return prof, "" |
|
|
|
|
| |
| |
| |
| |
| BD_CORPUS_WAIT = float(os.environ.get("AIOS_BD_CORPUS_WAIT") or 420) |
| BD_CORPUS_POLL = float(os.environ.get("AIOS_BD_CORPUS_POLL") or 15) |
|
|
|
|
| def bd_corpus_profile(handle, log=print): |
| """One profile out of the PRE-COLLECTED corpus. `(node|None, note)`. |
| |
| ββ WHY THIS RUNG EXISTS, and it is measured rather than defensive. On 2026-08-07 the SCRAPE |
| path answered `Parse error: Invalid URL` for `inayma` on **four consecutive attempts and |
| through both input shapes** (a URL with and without its trailing slash, and the `user_name` |
| discovery input, which the vendor converts to the same URL internally) β while a control |
| profile in the SAME batch came back with 36 keys. So the URL form was not the problem and |
| neither was the account: the anonymous ladder read it fine. The vendor simply could not |
| scrape that profile. |
| β **The same dataset answered instantly by the OTHER route.** `POST /datasets/filter` with |
| `account = inayma` returned the full 33-key row β exact followers (203,818 against the |
| anonymous rung's rounded 204,000), biography, `is_verified`, `avg_engagement`, `fbid`, |
| `highlights_count`. One dataset, two routes, and only one of them was broken. |
| |
| β THE ROWS ARE PRE-COLLECTED, so this is not a live read β it is the vendor's own last |
| capture, and its population differs from the scrape path's (Β§2d: `avg_engagement` 88% |
| populated on the corpus vs 0% on scrape). That is why it is a FALLBACK and not the default: |
| fresher-but-thinner beats staler-but-fuller when both work. |
| β THE SNAPSHOT NAMESPACES ARE NOT INTERCHANGEABLE. A corpus query mints `snap_β¦` and is read |
| at `/datasets/snapshot/{id}`; the scraper mints `sd_β¦` and is read at |
| `/datasets/v3/snapshot/{id}`. Crossing them returns a flat 404 about a snapshot that is alive |
| and building β the trap Β§2c records, and the reason these paths are spelled out here. |
| """ |
| h = str(handle or "").strip().lstrip("@").lower() |
| if not h: |
| return None, "no handle to look up" |
| payload, note = bd_call(BD_PATH_FILTER, None, |
| {"dataset_id": BD_DS_PROFILES, "records_limit": 1, |
| "filter": {"name": "account", "operator": "=", "value": h}}) |
| if note: |
| return None, note |
| snap = str((payload or {}).get("snapshot_id") or "").strip() |
| if not snap: |
| return None, "the profile corpus accepted the query but named no snapshot" |
| deadline = time.time() + BD_CORPUS_WAIT |
| while time.time() < deadline: |
| time.sleep(BD_CORPUS_POLL) |
| got, perr = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snap}", None, None) |
| status = str((got or {}).get("status") or "").strip() |
| if perr or status in ("failed", "error"): |
| return None, (perr or f"the corpus query failed ({(got or {}).get('error') or status})") |
| if status == "ready": |
| |
| |
| |
| while time.time() < deadline: |
| rows, derr = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snap}/download", |
| {"format": "json"}, None) |
| if derr: |
| time.sleep(BD_CORPUS_POLL) |
| continue |
| if isinstance(rows, dict): |
| rows = [rows] |
| if isinstance(rows, list) and rows: |
| return rows[0], "" |
| time.sleep(BD_CORPUS_POLL) |
| break |
| return None, (f"the profile corpus did not deliver {h!r} within " |
| f"{int(BD_CORPUS_WAIT)}s (snapshot {snap} is still building β it is not lost)") |
|
|
|
|
| def _tag_metric_deferrals(items, start, kind, influencer_key): |
| """Attach only the context a later snapshot collector needs to preserve the IG graph.""" |
| from automation_engine import _iso |
| for item in items[start:]: |
| if isinstance(item, dict): |
| item["kind"] = kind |
| item["influencer"] = str(influencer_key or "").strip().lstrip("@").lower() |
| item["requestedAt"] = _iso() |
|
|
|
|
| def top_up_views(posts, log=print): |
| """Fill `views` on video posts from the `ig_post_views` capability. MUTATES `posts`; returns |
| a note for the run (`""` when there was nothing to do). |
| |
| ββ CAPABILITY ROUTING, NOT PROVIDER FAILOVER (owner ruling 2026-08-08, `providers.py`). |
| Bright Data answers profile + likes + comments and is declared INCAPABLE of `ig_post_views`, |
| so the chain for that ONE capability resolves to Apify and only the video permalinks are |
| re-bought. Re-running the whole profile on the second vendor would pay twice for the 90% that |
| already worked, which at thousands of tenants x twelve posts is the entire bill. |
| |
| ββ WHY THIS IS A FUNCTION AND NOT INLINE, which is the whole point of the 2026-08-09 fix. |
| It used to live inside `pull_profile_bd`'s post-metrics block, so it ran ONLY when the Posts |
| scrape answered within the wait budget. When that batch DEFERRED β routine, and the normal |
| outcome on a busy account β the posts came back through |
| `automation_engine.collect_pending_metric_snapshots` instead, which never called it. MEASURED |
| on nurilab's `theresalearns`: profile, 12 posts and 154 comments all landed, and **every |
| Views cell was blank**, because the deferred path never asked Apify at all. One capability, |
| two collection paths, one of them wired: the same shape as the `ig_profile` rung that was |
| declared and never called ([[flag-shipped-without-its-writer]]). |
| β ONE implementation, TWO callers. A copy in the collector would have been a second thing to |
| keep in step, and the two would drift on the next ruling. |
| |
| β VIDEO ONLY. A carousel or image has no view count, so sending it would buy a record that |
| can only come back blank. |
| """ |
| video_urls = [p["url"] for p in (posts or []) |
| if p.get("type") == "video" and p.get("url")] |
| if not video_urls: |
| return "" |
|
|
| def _views_work(prov, _urls=tuple(video_urls)): |
| if prov.key == "apify": |
| return apify_posts(list(_urls)) |
| |
| |
| |
| return None, f"no {prov.key} runner is wired for ig_post_views" |
|
|
| got_views, attempts = providers.run( |
| "ig_post_views", _views_work, |
| |
| |
| |
| |
| satisfied=lambda rows: bool(rows) and any(r.get("views") for r in rows), |
| log=log) |
| for row in (got_views or []): |
| target = next((p for p in posts |
| if p.get("shortcode") == row.get("shortcode")), None) |
| |
| |
| |
| |
| if target and row.get("views") not in (None, ""): |
| target["views"] = row["views"] |
| |
| |
| |
| |
| |
| |
| |
| |
| for _extra in ("plays", "video_duration", "comments_disabled"): |
| if target and row.get(_extra) not in (None, ""): |
| target[_extra] = row[_extra] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if target and not str(target.get("tagged_location") or "").strip() \ |
| and str(row.get("tagged_location") or "").strip(): |
| target["tagged_location"] = row["tagged_location"] |
| used = next((a.provider for a in attempts if a.ok), "") |
| if used: |
| |
| |
| |
| |
| return "view counts collected" |
| if attempts: |
| |
| |
| reasons = "; ".join(a.note for a in attempts if a.note) |
| return f"view counts could not be collected{': ' + reasons if reasons else ''}" |
| return "" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| PUBLIC_SOURCE = { |
| "brightdata": "Primary", |
| "brightdata:deferred": "Primary (deferred)", |
| |
| |
| |
| |
| |
| |
| |
| "brightdata:discovery": "Discovery", |
| "apify": "Backup", |
| } |
|
|
|
|
| def public_source(via): |
| """A vendor key -> the word an operator reads. Never returns the key itself.""" |
| key = str(via or "").strip().lower() |
| if not key: |
| return "" |
| if key in PUBLIC_SOURCE: |
| return PUBLIC_SOURCE[key] |
| |
| base = key.split(":", 1)[0] |
| if base in PUBLIC_SOURCE: |
| suffix = key.split(":", 1)[1] if ":" in key else "" |
| return f"{PUBLIC_SOURCE[base]} ({suffix})" if suffix else PUBLIC_SOURCE[base] |
| return "Automated" |
|
|
|
|
| |
| |
| |
| |
| DEFERRED_MARK = "__aios_deferred_snapshot__" |
|
|
|
|
| def pull_profile_bd(url, max_posts=None, post_metrics=False, |
| comment_metrics=False, log=print, pending_metrics=None, |
| pending_profile=None, prefetch=None, post_groups=None): |
| """The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides. |
| |
| Same return contract as `pull_profile` (`{state, profile, posts, via, note}`) so the runner |
| treats every rung identically. `blocked` here means the VENDOR rung did not answer β the |
| caller decides whether to drop to the anonymous ladder. |
| |
| `post_metrics=True` adds the paid Post/Reel calls. `comment_metrics=True` independently adds |
| the separate paid Comments-dataset call. Both default off; the latter is never inferred from |
| an enabled post-metrics switch. Embedded comment previews returned by either paid record are |
| retained without that extra Comments request. |
| |
| β `max_posts=None` MEANS THE DEFAULT, and the `None` is a consequence of the module split |
| rather than a new option. A default argument is evaluated when this `def` runs β i.e. at |
| import β so naming `DEFAULT_POSTS_PER_PULL` in the signature would be exactly the |
| module-level reach into `automation_engine` the header forbids. Every caller in the engine |
| already writes `cfg.get("maxPosts") or DEFAULT_POSTS_PER_PULL`, so the value is unchanged. |
| """ |
| from automation_engine import (DEFAULT_POSTS_PER_PULL, PACE_SECONDS, |
| _s) |
| max_posts = DEFAULT_POSTS_PER_PULL if max_posts is None else max_posts |
| handle = ig_handle(url) |
| if not handle: |
| return {"state": "error", "note": f"{url!r} is not an Instagram profile URL", |
| "profile": {}, "posts": [], "comments": [], "via": ""} |
| |
| |
| |
| |
| |
| |
| |
| |
| _deferred = [] |
| |
| |
| |
| cached = prefetch.get(handle) if isinstance(prefetch, dict) else None |
| if isinstance(cached, dict) and cached.get(DEFERRED_MARK): |
| |
| |
| |
| sid = str(cached.get(DEFERRED_MARK)) |
| return {"state": "blocked", "profile": {}, "posts": [], "comments": [], |
| "via": "brightdata", "deferredProfile": [sid], |
| "note": (f"the profile source deferred this batch to {sid} β the record is " |
| f"collected, not lost; the next run picks it up")} |
| if isinstance(cached, dict) and cached: |
| rows, note = [cached], "" |
| else: |
| rows, note = bd_scrape(BD_DS_PROFILES, [f"https://www.instagram.com/{handle}/"], |
| deferred=_deferred) |
| node = rows[0] if rows else {} |
| profile = _bd_profile(node, handle) if node else {} |
| unreadable = profile.get("followers") is None and profile.get("following") is None |
| via = "brightdata" |
| if note or unreadable: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| scrape_note = note or ("the scrape answered, but no follower/following counts were " |
| "readable in it (the field names may have moved β see " |
| "instagram-capture.md)") |
| cnode, cnote = bd_corpus_profile(handle, log=log) |
| if cnode: |
| node = cnode |
| profile = _bd_profile(node, handle) |
| via = "brightdata:corpus" |
| |
| |
| |
| note = (f"the live scrape did not answer ({_s(scrape_note, 120)}), so this came from " |
| f"the vendor's pre-collected profile corpus β the values are its last " |
| f"capture, not a read taken just now") |
| else: |
| |
| |
| |
| |
| |
| if isinstance(pending_profile, list): |
| for d in _deferred: |
| pending_profile.append({**d, "kind": "profile", "influencer": handle}) |
| return {"state": "blocked", "profile": {}, "posts": [], "comments": [], "via": "brightdata", |
| "deferredProfile": [d.get("snapshotId") for d in _deferred], |
| "note": f"{scrape_note}; the profile corpus did not answer either ({cnote})"} |
| if profile.get("followers") is None and profile.get("following") is None: |
| |
| |
| |
| return {"state": "blocked", "profile": {}, "posts": [], "comments": [], "via": "brightdata", |
| "note": "the profile source answered, but no follower/following counts were readable in " |
| "it (the field names may have moved β see instagram-capture.md)"} |
| posts = [] |
| for p in (node.get("posts") or [])[:max_posts]: |
| got = _bd_post_identity(p) |
| if got: |
| posts.append(got) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| rows2 = [] |
| views_inline, window_bought, window_note = False, False, "" |
| if post_groups and not post_metrics: |
| window_note = ("post groups were configured but post data is switched off, so they filtered " |
| "the profile's own preview (at most 12 mixed posts) rather than a window " |
| "bought for them") |
| elif post_groups: |
| _reels_n = reels_only_limit(post_groups) |
| if _reels_n and apify_ready(): |
| |
| |
| wide, wnote = apify_reels(handle, _reels_n) |
| if wide: |
| posts, views_inline, window_bought = wide, True, True |
| else: |
| window_note = f"the reels route did not answer ({_s(wnote, 90)})" |
| else: |
| |
| |
| |
| |
| wide, wnote = bd_profile_posts(handle, group_window(post_groups), |
| post_type=("reel" if _reels_n else ""), |
| deferred=pending_metrics, raw_out=rows2) |
| if wide: |
| posts, window_bought = wide, True |
| else: |
| window_note = f"the post window was not readable ({_s(wnote, 90)})" |
|
|
| |
| |
| |
| |
| if post_metrics and posts and not window_bought: |
| time.sleep(min(PACE_SECONDS, 1.0)) |
| |
| |
| |
| |
| ordinary = [p for p in posts if p.get("type") != "video"] |
| videos = [p for p in posts if p.get("type") == "video"] |
| metric_notes, ordinary_rows = [], [] |
| |
| |
| |
| metric_wait = BD_METRIC_SCRAPE_WAIT if isinstance(pending_metrics, list) else None |
| if ordinary: |
| at = len(pending_metrics) if isinstance(pending_metrics, list) else 0 |
| got_rows, got_note = bd_scrape(BD_DS_POSTS, [p["url"] for p in ordinary], |
| wait=metric_wait, deferred=pending_metrics) |
| if isinstance(pending_metrics, list): |
| _tag_metric_deferrals(pending_metrics, at, "posts", profile.get("username") or handle) |
| rows2.extend(got_rows) |
| ordinary_rows.extend(got_rows) |
| if got_note: |
| metric_notes.append(f"posts: {got_note}") |
| if videos: |
| at = len(pending_metrics) if isinstance(pending_metrics, list) else 0 |
| got_rows, got_note = bd_scrape(BD_DS_REELS, [p["url"] for p in videos], |
| wait=metric_wait, deferred=pending_metrics) |
| if isinstance(pending_metrics, list): |
| _tag_metric_deferrals(pending_metrics, at, "posts", profile.get("username") or handle) |
| rows2.extend(got_rows) |
| if got_note: |
| metric_notes.append(f"reels: {got_note}") |
| |
| |
| |
| |
| |
| repair_reels = [] |
| for row in ordinary_rows: |
| mapped = _bd_post_metrics(row) |
| if mapped and mapped.get("type") == "video": |
| repair_reels.append(f"https://www.instagram.com/reel/{mapped['shortcode']}/") |
| if repair_reels: |
| at = len(pending_metrics) if isinstance(pending_metrics, list) else 0 |
| got_rows, got_note = bd_scrape(BD_DS_REELS, list(dict.fromkeys(repair_reels)), |
| wait=metric_wait, deferred=pending_metrics) |
| if isinstance(pending_metrics, list): |
| _tag_metric_deferrals(pending_metrics, at, "posts", profile.get("username") or handle) |
| rows2.extend(got_rows) |
| if got_note: |
| metric_notes.append(f"reel recovery: {got_note}") |
| if metric_notes and not rows2: |
| |
| |
| note = f"post metrics unavailable: {'; '.join(metric_notes)}" |
| else: |
| by_code = {} |
| for r in rows2: |
| got = _bd_post_metrics(r) |
| if got: |
| by_code[got["shortcode"]] = got |
| for p in posts: |
| extra = by_code.get(p["shortcode"]) |
| if extra: |
| |
| |
| p.update({k: v for k, v in extra.items() if v not in (None, "")}) |
| if not by_code: |
| note = "post metrics were requested but the Posts dataset returned no rows" |
| elif metric_notes: |
| note = f"some post metrics were unavailable: {'; '.join(metric_notes)}" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if post_metrics and posts and not views_inline: |
| note = "; ".join(x for x in (note, top_up_views(posts, log=log)) if x) |
| if window_note: |
| note = "; ".join(x for x in (note, window_note) if x) |
|
|
| |
| |
| |
| |
| |
| |
| |
| if profile.get("posts_count") is None and post_metrics: |
| true_count = bd_true_posts_count(rows2 if post_metrics else []) |
| if true_count: |
| profile["posts_count"] = true_count |
| log(" posts_count read from the Posts rows already bought - no extra spend") |
| |
| |
| |
| |
| |
| elif profile.get("posts_count") is None and posts: |
| quote = providers.estimate("ig_post_metrics", 1) |
| log(f" buying 1 {quote.get('provider') or 'provider'} post record for the true " |
| f"posts_count (~${quote.get('usd', 0)}; the Profiles dataset's 0 is false)") |
| extra_rows, extra_note = bd_scrape(BD_DS_POSTS, [posts[0]["url"]], |
| wait=BD_METRIC_SCRAPE_WAIT |
| if isinstance(pending_metrics, list) else None, |
| deferred=pending_metrics) |
| true_count = bd_true_posts_count(extra_rows) |
| if true_count: |
| profile["posts_count"] = true_count |
| |
| |
| |
| |
| |
| note = "; ".join(x for x in ( |
| note, "bought 1 extra post record for the true post count") if x) |
| elif extra_note: |
| |
| |
| note = "; ".join(x for x in (note, f"post count unavailable: {extra_note}") if x) |
|
|
| comments = [] |
| if comment_metrics and posts: |
| |
| |
| |
| time.sleep(min(PACE_SECONDS, 1.0)) |
| comment_wait = BD_METRIC_SCRAPE_WAIT if isinstance(pending_metrics, list) else None |
| at = len(pending_metrics) if isinstance(pending_metrics, list) else 0 |
| comment_source, comment_note = bd_scrape(BD_DS_COMMENTS, [p["url"] for p in posts], |
| wait=comment_wait, deferred=pending_metrics) |
| if isinstance(pending_metrics, list): |
| _tag_metric_deferrals(pending_metrics, at, "comments", profile.get("username") or handle) |
| for row in comment_source: |
| got = _bd_comment(row, influencer_key=profile.get("username") or handle) |
| if got: |
| comments.append(got) |
| if comment_note: |
| note = "; ".join(x for x in (note, f"comment engagement unavailable: {comment_note}") if x) |
| elif not comments: |
| note = "; ".join(x for x in (note, |
| "comment engagement was requested but the Comments dataset returned no rows") if x) |
|
|
| |
| |
| |
| |
| |
| |
| |
| return {"state": "ok" if posts else "partial", "profile": profile, "posts": posts, |
| "comments": comments, |
| "via": via, |
| "note": note or ("" if posts else "profile read; no posts were returned")} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def pull_profile(url, max_posts=None, log=print, |
| post_metrics=False, comment_metrics=False, pending_metrics=None, |
| pending_profile=None, prefetch=None, post_groups=None): |
| """Everything readable about one public profile β EXACT NUMBERS OR NOTHING. |
| |
| Returns `{state, profile, posts, via, note}` with state β ok | partial | blocked | error. |
| `partial` means the identity was read but the media was not. |
| |
| ββ WAVE 28 / OWNER RULING R5 β THE FREE ANONYMOUS LADDER IS RETIRED, AND THE REASON IS WHAT |
| IT PUT IN THE COLUMN, not what it cost. Two rungs used to sit under the paid one: Instagram's |
| own `web_profile_info` endpoint, and the profile HTML's `og:description`, which carries |
| "204K Followers, 5,245 Following, 750 Posts". That last one is the page's OWN ROUNDING, and it |
| landed in the same `followers` column as a measured 204,318, stamped `approx: "1"` β a flag |
| that tells you the number is an abbreviation only AFTER you have already averaged it, sorted |
| on it, or filtered a shortlist by it. A table where some rows are measurements and some are |
| the page's shorthand is a table you cannot do arithmetic on. |
| β A vendor refusal now returns `blocked` and the row KEEPS WHAT IT LAST KNEW, instead of being |
| overwritten with a rounder number. The user-facing tier/fallback choice is gone with the |
| rungs; there is one behaviour and it is this one. |
| |
| β THIS IS STILL CAPABILITY ROUTING, NOT A SINGLE VENDOR. `ig_profile` is declared on Bright |
| Data AND Apify (`providers.py`), so a profile Bright Data cannot read is asked of Apify before |
| anything reports blocked. What was retired is the FREE approximate rung, not the second |
| opinion β the fallback that costs money and answers exactly is the one worth having. |
| |
| β `max_posts=None` means the default β see `pull_profile_bd` for why the constant cannot be |
| named in the signature any more. |
| """ |
| from automation_engine import (DEFAULT_POSTS_PER_PULL, PACE_SECONDS, |
| _s) |
| max_posts = DEFAULT_POSTS_PER_PULL if max_posts is None else max_posts |
| handle = ig_handle(url) |
| if not handle: |
| return {"state": "error", "note": f"{url!r} is not an Instagram profile URL", |
| "profile": {}, "posts": [], "comments": [], "via": ""} |
| attempts = [] |
| |
| |
| |
| gone = "" |
|
|
| paid = pull_profile_bd(url, max_posts=max_posts, post_metrics=post_metrics, |
| comment_metrics=comment_metrics, log=log, |
| pending_metrics=pending_metrics, |
| pending_profile=pending_profile, prefetch=prefetch, |
| post_groups=post_groups) |
| if paid["state"] in ("ok", "partial"): |
| return paid |
| |
| |
| |
| |
| attempts.append(f"brightdata:{_s(paid.get('note'), 200)}") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if providers.PROVIDERS["apify"].can("ig_profile"): |
| a_prof, a_note = apify_profile(handle) |
| if a_prof and a_prof.get("followers") is not None: |
| log(f" apify: profile ok ({a_prof.get('followers')} followers)") |
| return {"state": "ok", "profile": a_prof, "posts": [], "comments": [], |
| "via": "apify", |
| "note": ("the primary source could not read this profile " |
| f"({_s(paid.get('note'), 90)}), so a backup source was used")} |
| |
| |
| |
| if a_note == ACCOUNT_GONE_NOTE: |
| gone = a_note |
| attempts.append(f"apify:{_s(a_note or 'answered without follower counts', 200)}") |
| time.sleep(PACE_SECONDS) |
|
|
| |
| |
| |
| |
| |
| |
| return {"state": "blocked", "profile": {}, "posts": [], "comments": [], |
| "via": "brightdata", "gone": bool(gone), |
| "note": (gone if gone else |
| "no provider could read this profile " |
| f"({'; '.join(attempts) or 'blocked'})")} |
|
|
|
|
|
|