Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/automation_engine.py +109 -5
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v14",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "b859a84",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v14",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
b859a84
|
api/automation_engine.py
CHANGED
|
@@ -2695,6 +2695,76 @@ def bd_scrape(dataset_id, urls, wait=None):
|
|
| 2695 |
f"or collect {sid} from the control panel")
|
| 2696 |
|
| 2697 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2698 |
def pull_profile_bd(url, max_posts=DEFAULT_POSTS_PER_PULL, post_metrics=False, log=print):
|
| 2699 |
"""The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides.
|
| 2700 |
|
|
@@ -2711,11 +2781,41 @@ def pull_profile_bd(url, max_posts=DEFAULT_POSTS_PER_PULL, post_metrics=False, l
|
|
| 2711 |
return {"state": "error", "note": f"{url!r} is not an Instagram profile URL",
|
| 2712 |
"profile": {}, "posts": [], "via": ""}
|
| 2713 |
rows, note = bd_scrape(BD_DS_PROFILES, [f"https://www.instagram.com/{handle}/"])
|
| 2714 |
-
if note:
|
| 2715 |
-
return {"state": "blocked", "profile": {}, "posts": [], "via": "brightdata",
|
| 2716 |
-
"note": note}
|
| 2717 |
node = rows[0] if rows else {}
|
| 2718 |
-
profile = _bd_profile(node, handle)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2719 |
if profile.get("followers") is None and profile.get("following") is None:
|
| 2720 |
# It answered 200 with something we could not read. Say THAT β not "0 followers".
|
| 2721 |
# β `posts_count` is deliberately NOT part of this test: it arrives as a fabricated 0 and
|
|
@@ -2754,8 +2854,12 @@ def pull_profile_bd(url, max_posts=DEFAULT_POSTS_PER_PULL, post_metrics=False, l
|
|
| 2754 |
# IDENTITY WITHOUT MEDIA IS STILL `partial`, on the paid rung too. The rule does not soften
|
| 2755 |
# because we are paying: a run that wrote a follower count and no posts must not paint green
|
| 2756 |
# over a posts table that did not grow.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2757 |
return {"state": "ok" if posts else "partial", "profile": profile, "posts": posts,
|
| 2758 |
-
"via":
|
| 2759 |
"note": note or ("" if posts else "profile read; no posts were returned")}
|
| 2760 |
|
| 2761 |
|
|
|
|
| 2695 |
f"or collect {sid} from the control panel")
|
| 2696 |
|
| 2697 |
|
| 2698 |
+
#: ββ 2026-08-07 β HOW LONG THE CORPUS RUNG MAY WAIT. A corpus query is a scan over 620M rows
|
| 2699 |
+
#: and takes MINUTES, not seconds (Β§2d MEASURED ~4 min for a narrow one). It is a FALLBACK behind
|
| 2700 |
+
#: a scrape that already failed, so the budget is generous β but bounded, because an enrich step
|
| 2701 |
+
#: that never returns is an automation that never finishes.
|
| 2702 |
+
BD_CORPUS_WAIT = float(os.environ.get("AIOS_BD_CORPUS_WAIT") or 420)
|
| 2703 |
+
BD_CORPUS_POLL = float(os.environ.get("AIOS_BD_CORPUS_POLL") or 15)
|
| 2704 |
+
|
| 2705 |
+
|
| 2706 |
+
def bd_corpus_profile(handle, log=print):
|
| 2707 |
+
"""One profile out of the PRE-COLLECTED corpus. `(node|None, note)`.
|
| 2708 |
+
|
| 2709 |
+
ββ WHY THIS RUNG EXISTS, and it is measured rather than defensive. On 2026-08-07 the SCRAPE
|
| 2710 |
+
path answered `Parse error: Invalid URL` for `inayma` on **four consecutive attempts and
|
| 2711 |
+
through both input shapes** (a URL with and without its trailing slash, and the `user_name`
|
| 2712 |
+
discovery input, which the vendor converts to the same URL internally) β while a control
|
| 2713 |
+
profile in the SAME batch came back with 36 keys. So the URL form was not the problem and
|
| 2714 |
+
neither was the account: the anonymous ladder read it fine. The vendor simply could not
|
| 2715 |
+
scrape that profile.
|
| 2716 |
+
β **The same dataset answered instantly by the OTHER route.** `POST /datasets/filter` with
|
| 2717 |
+
`account = inayma` returned the full 33-key row β exact followers (203,818 against the
|
| 2718 |
+
anonymous rung's rounded 204,000), biography, `is_verified`, `avg_engagement`, `fbid`,
|
| 2719 |
+
`highlights_count`. One dataset, two routes, and only one of them was broken.
|
| 2720 |
+
|
| 2721 |
+
β THE ROWS ARE PRE-COLLECTED, so this is not a live read β it is the vendor's own last
|
| 2722 |
+
capture, and its population differs from the scrape path's (Β§2d: `avg_engagement` 88%
|
| 2723 |
+
populated on the corpus vs 0% on scrape). That is why it is a FALLBACK and not the default:
|
| 2724 |
+
fresher-but-thinner beats staler-but-fuller when both work.
|
| 2725 |
+
β THE SNAPSHOT NAMESPACES ARE NOT INTERCHANGEABLE. A corpus query mints `snap_β¦` and is read
|
| 2726 |
+
at `/datasets/snapshot/{id}`; the scraper mints `sd_β¦` and is read at
|
| 2727 |
+
`/datasets/v3/snapshot/{id}`. Crossing them returns a flat 404 about a snapshot that is alive
|
| 2728 |
+
and building β the trap Β§2c records, and the reason these paths are spelled out here.
|
| 2729 |
+
"""
|
| 2730 |
+
h = str(handle or "").strip().lstrip("@").lower()
|
| 2731 |
+
if not h:
|
| 2732 |
+
return None, "no handle to look up"
|
| 2733 |
+
payload, note = bd_call(BD_PATH_FILTER, None,
|
| 2734 |
+
{"dataset_id": BD_DS_PROFILES, "records_limit": 1,
|
| 2735 |
+
"filter": {"name": "account", "operator": "=", "value": h}})
|
| 2736 |
+
if note:
|
| 2737 |
+
return None, note
|
| 2738 |
+
snap = str((payload or {}).get("snapshot_id") or "").strip()
|
| 2739 |
+
if not snap:
|
| 2740 |
+
return None, "the profile corpus accepted the query but named no snapshot"
|
| 2741 |
+
deadline = time.time() + BD_CORPUS_WAIT
|
| 2742 |
+
while time.time() < deadline:
|
| 2743 |
+
time.sleep(BD_CORPUS_POLL)
|
| 2744 |
+
got, perr = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snap}", None, None)
|
| 2745 |
+
status = str((got or {}).get("status") or "").strip()
|
| 2746 |
+
if perr or status in ("failed", "error"):
|
| 2747 |
+
return None, (perr or f"the corpus query failed ({(got or {}).get('error') or status})")
|
| 2748 |
+
if status == "ready":
|
| 2749 |
+
# β READY IS NOT DELIVERABLE. Β§2d MEASURED `/download` answering "Snapshot is
|
| 2750 |
+
# building. Try again in a few minutes" while status already said `ready` β delivery
|
| 2751 |
+
# lags readiness. So the download is polled too, inside the same budget.
|
| 2752 |
+
while time.time() < deadline:
|
| 2753 |
+
rows, derr = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snap}/download",
|
| 2754 |
+
{"format": "json"}, None)
|
| 2755 |
+
if derr:
|
| 2756 |
+
time.sleep(BD_CORPUS_POLL)
|
| 2757 |
+
continue
|
| 2758 |
+
if isinstance(rows, dict):
|
| 2759 |
+
rows = [rows]
|
| 2760 |
+
if isinstance(rows, list) and rows:
|
| 2761 |
+
return rows[0], ""
|
| 2762 |
+
time.sleep(BD_CORPUS_POLL)
|
| 2763 |
+
break
|
| 2764 |
+
return None, (f"the profile corpus did not deliver {h!r} within "
|
| 2765 |
+
f"{int(BD_CORPUS_WAIT)}s (snapshot {snap} is still building β it is not lost)")
|
| 2766 |
+
|
| 2767 |
+
|
| 2768 |
def pull_profile_bd(url, max_posts=DEFAULT_POSTS_PER_PULL, post_metrics=False, log=print):
|
| 2769 |
"""The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides.
|
| 2770 |
|
|
|
|
| 2781 |
return {"state": "error", "note": f"{url!r} is not an Instagram profile URL",
|
| 2782 |
"profile": {}, "posts": [], "via": ""}
|
| 2783 |
rows, note = bd_scrape(BD_DS_PROFILES, [f"https://www.instagram.com/{handle}/"])
|
|
|
|
|
|
|
|
|
|
| 2784 |
node = rows[0] if rows else {}
|
| 2785 |
+
profile = _bd_profile(node, handle) if node else {}
|
| 2786 |
+
unreadable = profile.get("followers") is None and profile.get("following") is None
|
| 2787 |
+
via = "brightdata"
|
| 2788 |
+
if note or unreadable:
|
| 2789 |
+
# ββ 2026-08-07 β THE CORPUS IS TRIED BEFORE THIS RUNG GIVES UP, and it is the difference
|
| 2790 |
+
# between a blank row and a full one. MEASURED: the scraper answered `Parse error: Invalid
|
| 2791 |
+
# URL` for `inayma` four times running and through both input shapes, while a control
|
| 2792 |
+
# profile in the same batch returned 36 keys β and `POST /datasets/filter` on the SAME
|
| 2793 |
+
# dataset returned that profile's complete 33-key row. A rung that reports `blocked`
|
| 2794 |
+
# while the vendor is holding the answer on another route is a rung that gave up early.
|
| 2795 |
+
# β The FIRST failure is kept in the note either way: if the corpus also misses, the
|
| 2796 |
+
# person needs to know the scrape was tried and what it said, not only the fallback.
|
| 2797 |
+
# β THE UNREADABLE-ROW DIAGNOSTIC IS CARRIED, NOT DROPPED. "It answered 200 with something
|
| 2798 |
+
# we could not parse" and "it refused" are different problems with different fixes, and
|
| 2799 |
+
# the field-names hint is the one that tells the next reader to go and diff a real row
|
| 2800 |
+
# against the schema. Losing it behind the corpus fallback would make a mapping
|
| 2801 |
+
# regression look like a vendor outage.
|
| 2802 |
+
scrape_note = note or ("the scrape answered, but no follower/following counts were "
|
| 2803 |
+
"readable in it (the field names may have moved β see "
|
| 2804 |
+
"instagram-capture.md)")
|
| 2805 |
+
cnode, cnote = bd_corpus_profile(handle, log=log)
|
| 2806 |
+
if cnode:
|
| 2807 |
+
node = cnode
|
| 2808 |
+
profile = _bd_profile(node, handle)
|
| 2809 |
+
via = "brightdata:corpus"
|
| 2810 |
+
# β The corpus is PRE-COLLECTED, so the row is the vendor's last capture rather than
|
| 2811 |
+
# a read taken now. Said out loud on the attempt, because "when was this true" is the
|
| 2812 |
+
# one question a stored measurement must always be able to answer.
|
| 2813 |
+
note = (f"the live scrape did not answer ({_s(scrape_note, 120)}), so this came from "
|
| 2814 |
+
f"the vendor's pre-collected profile corpus β the values are its last "
|
| 2815 |
+
f"capture, not a read taken just now")
|
| 2816 |
+
else:
|
| 2817 |
+
return {"state": "blocked", "profile": {}, "posts": [], "via": "brightdata",
|
| 2818 |
+
"note": f"{scrape_note}; the profile corpus did not answer either ({cnote})"}
|
| 2819 |
if profile.get("followers") is None and profile.get("following") is None:
|
| 2820 |
# It answered 200 with something we could not read. Say THAT β not "0 followers".
|
| 2821 |
# β `posts_count` is deliberately NOT part of this test: it arrives as a fabricated 0 and
|
|
|
|
| 2854 |
# IDENTITY WITHOUT MEDIA IS STILL `partial`, on the paid rung too. The rule does not soften
|
| 2855 |
# because we are paying: a run that wrote a follower count and no posts must not paint green
|
| 2856 |
# over a posts table that did not grow.
|
| 2857 |
+
# β `via` CARRIES WHICH ROUTE ANSWERED β `brightdata` or `brightdata:corpus`. It lands in the
|
| 2858 |
+
# snapshot row's `source` column, so a stored measurement can always say whether it was read
|
| 2859 |
+
# live or taken from the vendor's pre-collected corpus. Two routes writing one indisting-
|
| 2860 |
+
# uishable `source` would make the series unauditable exactly where it matters most.
|
| 2861 |
return {"state": "ok" if posts else "partial", "profile": profile, "posts": posts,
|
| 2862 |
+
"via": via,
|
| 2863 |
"note": note or ("" if posts else "profile read; no posts were returned")}
|
| 2864 |
|
| 2865 |
|