brightdata-instagram-scraper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # Copy to .env in the project root. The SDK finds it when the virtualenv is inside the project.
2
+ # Token: https://brightdata.com/cp/setting/users
3
+ BRIGHTDATA_API_TOKEN=
@@ -0,0 +1,138 @@
1
+ """The README's code blocks, as the live check runs them.
2
+
3
+ python readme_blocks.py list # JSON matrix for the block jobs (ONLY filters)
4
+ python readme_blocks.py run N # run block N with the real token, one retry
5
+ python readme_blocks.py cli-syntax # every README CLI line with a dummy key
6
+
7
+ One parser for all three, so the list and the runner can never disagree on
8
+ what block N is.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import pathlib
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import time
18
+
19
+ README = pathlib.Path(__file__).resolve().parents[2] / "README.md"
20
+ SKIP = ("pip install", "export ", "npm i", "npm install",
21
+ "npx -p @brightdata/cli bdata login", "bdata login")
22
+ CLI = os.environ.get("BDATA_CLI", "") # the pinned CLI version; empty means whatever is installed
23
+
24
+
25
+ def blocks():
26
+ readme = README.read_text(encoding="utf-8")
27
+ # never execute anything inside the generated field table: its text comes from
28
+ # the API, and a fence in a description must not become a command
29
+ readme = re.sub(r"<!-- fields:start -->.*?<!-- fields:end -->", "", readme, flags=re.S)
30
+ found, heading, seen = [], "README", {}
31
+ for m in re.finditer(r"^#{2,3} ([^\n]+)$|```(python|bash)\n(.*?)```", readme, re.M | re.S):
32
+ if m.group(1):
33
+ heading = m.group(1).strip() # a block is named after its section, not its first line
34
+ continue
35
+ lang, code = m.group(2), m.group(3)
36
+ lines = [line for line in code.splitlines()
37
+ if line.strip() and not line.strip().startswith("#")]
38
+ if lang == "bash":
39
+ lines = [line for line in lines if not line.startswith(SKIP)]
40
+ if not lines:
41
+ continue
42
+ code = "\n".join(lines)
43
+ seen[heading] = seen.get(heading, 0) + 1
44
+ title = heading if seen[heading] == 1 else f"{heading} ({seen[heading]})"
45
+ found.append({"lang": lang, "title": title[:50], "code": code})
46
+ return found
47
+
48
+
49
+ def cli_lines(code, key):
50
+ """The CLI cannot open a browser here, so the key goes in by flag, and an
51
+ unpinned npx is held to the version the job installed."""
52
+ out = []
53
+ for line in code.splitlines():
54
+ if CLI:
55
+ line = line.replace("npx -p @brightdata/cli ", f"npx -p @brightdata/cli@{CLI} ")
56
+ if "bdata pipelines" in line:
57
+ line += f" -k {key}"
58
+ out.append(line)
59
+ return "\n".join(out)
60
+
61
+
62
+ def list_blocks():
63
+ wanted = {int(n) for n in os.environ.get("ONLY", "").split(",") if n.strip()}
64
+ out = [{"n": i + 1, "lang": b["lang"], "title": b["title"]}
65
+ for i, b in enumerate(blocks()) if not wanted or i + 1 in wanted]
66
+ print("blocks=" + json.dumps(out))
67
+
68
+
69
+ def run_block(n):
70
+ b = blocks()[n - 1]
71
+ if b["lang"] == "bash":
72
+ # only the shell ever sees the key's value
73
+ argv = ["bash", "-e", "-c", cli_lines(b["code"], '"$BRIGHTDATA_API_TOKEN"')]
74
+ else:
75
+ argv = [sys.executable, "-c", b["code"]]
76
+ secret = os.environ.get("BRIGHTDATA_API_TOKEN", "")
77
+ for _attempt in (1, 2):
78
+ started = time.time()
79
+ try:
80
+ run = subprocess.run(argv, capture_output=True, text=True, timeout=480)
81
+ except subprocess.TimeoutExpired:
82
+ run = subprocess.CompletedProcess(
83
+ argv, 1, "", "block timeout: no result within 8 minutes")
84
+ secs = round(time.time() - started)
85
+ out, err = run.stdout.strip(), run.stderr.strip()
86
+ if secret: # summaries are not secret-masked the way logs are
87
+ out, err = out.replace(secret, "***"), err.replace(secret, "***")
88
+ ok = run.returncode == 0 and bool(out if b["lang"] == "python" else out or err)
89
+ if ok or ((out or err) and "timeout" not in (out + err).lower()):
90
+ break
91
+ time.sleep(30) # the API timed out or answered with nothing at all; one retry
92
+ if ok:
93
+ shown = (out.splitlines() or [""])[:1]
94
+ else:
95
+ # the last few lines: a CLI error often puts the cause above its hint
96
+ shown = (err.splitlines() or out.splitlines() or ["no output"])[-6:]
97
+ body = "\n".join(" " + line[:160] for line in shown)
98
+ state = "ok" if ok else "FAILED"
99
+ print(f"\n`{b['title']}`: {state} in {secs}s (exit {run.returncode})\n\n{body}")
100
+ sys.exit(0 if ok else 1)
101
+
102
+
103
+ def cli_syntax():
104
+ """Run every README CLI line with a key the API rejects. A line the CLI
105
+ still understands reaches the API and gets a 401; a line it no longer
106
+ understands fails before that. Free, and needs no secret."""
107
+ failed = 0
108
+ print("| block | CLI line | result |\n| --- | --- | --- |")
109
+ for i, b in enumerate(blocks(), 1):
110
+ if b["lang"] != "bash":
111
+ continue
112
+ for line in b["code"].splitlines():
113
+ if "bdata pipelines" not in line:
114
+ continue
115
+ run = subprocess.run(["bash", "-c", cli_lines(line, "invalid-key-for-syntax-check")],
116
+ capture_output=True, text=True, timeout=120)
117
+ text = run.stdout + run.stderr
118
+ ok = "401" in text or "Invalid credentials" in text
119
+ last = (run.stderr.strip().splitlines() or run.stdout.strip().splitlines()
120
+ or ["no output"])
121
+ why = "reached the API" if ok else " / ".join(x.strip() for x in last[-3:])[:200]
122
+ print(f"| {i} | `{line[:90]}` | {'ok' if ok else 'FAILED'}: {why.replace('|', '/')} |")
123
+ if not ok:
124
+ failed = 1
125
+ print(f"::error::README block {i}: the CLI rejects `{line}`", file=sys.stderr)
126
+ sys.exit(failed)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ mode = sys.argv[1] if len(sys.argv) > 1 else ""
131
+ if mode == "list":
132
+ list_blocks()
133
+ elif mode == "run":
134
+ run_block(int(sys.argv[2]))
135
+ elif mode == "cli-syntax":
136
+ cli_syntax()
137
+ else:
138
+ sys.exit(__doc__)
@@ -0,0 +1,130 @@
1
+ # Runs by itself on every push and pull request.
2
+ # Never calls the Instagram API, so it needs no token and costs nothing.
3
+ name: Tests (automatic, no API calls, free)
4
+
5
+ on:
6
+ push:
7
+ pull_request:
8
+ workflow_dispatch: # a Run workflow button, to re-run the tests without a commit
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency: # a newer push to the same branch or PR replaces the run still going
14
+ group: ci-${{ github.event_name }}-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ tests:
19
+ name: Run the tests and the code style check
20
+ runs-on: ubuntu-latest
21
+ timeout-minutes: 10
22
+ steps:
23
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
24
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
25
+ with:
26
+ python-version: "3.12"
27
+ cache: pip
28
+ cache-dependency-path: pyproject.toml
29
+ - name: Install the package and its test tools
30
+ run: pip install -e ".[dev]"
31
+ - name: Check code style with ruff
32
+ run: ruff check .
33
+ - name: Run the tests
34
+ run: pytest
35
+ - name: README headings are well formed
36
+ # A section rewrite once produced "## The data## The data" and shipped;
37
+ # a heading with a second run of hashes mid-line is never intended.
38
+ run: |
39
+ if grep -nE '^#+ .*#{2,} ' README.md; then
40
+ echo "::error::malformed heading in README.md"; exit 1
41
+ fi
42
+
43
+ # These install steps shipped broken once, because they were only ever run
44
+ # from a working copy that already had the package. This job follows the
45
+ # README on an empty machine and stops where the tool asks for a token, which
46
+ # proves the clone, the install and the command without spending money.
47
+ coldstart:
48
+ name: Follow the README from scratch on an empty machine
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 10
51
+ steps:
52
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
53
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
54
+ with:
55
+ python-version: "3.12"
56
+ - name: Copy the install steps out of the README and run them
57
+ run: |
58
+ set -o pipefail
59
+ python - <<'PY' > "$RUNNER_TEMP/quickstart.sh"
60
+ import os
61
+ import pathlib
62
+ import re
63
+ import sys
64
+
65
+ readme = pathlib.Path("README.md").read_text(encoding="utf-8")
66
+
67
+ def section(title):
68
+ return readme.split(f"## {title}", 1)[1].split("\n## ", 1)[0]
69
+
70
+ def block(text, lang):
71
+ return re.search(r"```" + lang + r"\n(.*?)```", text, re.S).group(1)
72
+
73
+ quick = section("How to scrape Instagram data with Python")
74
+ install, snippet = block(quick, "bash"), block(quick, "python")
75
+ command = block(section("Bulk Instagram scraper CLI"), "bash")
76
+ for required, where in (("pip install brightdata-sdk", install),
77
+ ("SyncBrightDataClient", snippet),
78
+ ("instagram-scraper", command)):
79
+ if required not in where:
80
+ sys.exit(f"the README no longer contains {required!r} where the cold start expects it")
81
+
82
+ steps = ["# --- Quickstart: the SDK path ---"]
83
+ steps += [l for l in install.splitlines() if l.startswith("pip install")]
84
+ pathlib.Path(os.environ["RUNNER_TEMP"], "quickstart_snippet.py").write_text(snippet, encoding="utf-8")
85
+ steps += [
86
+ "set +e",
87
+ "python quickstart_snippet.py > out.txt 2> err.txt",
88
+ "code=$?",
89
+ "set -e",
90
+ 'test "$code" != "0" || { echo "the snippet ran without a token?"; cat out.txt; exit 1; }',
91
+ 'grep -qi token err.txt || { echo "no token message"; cat err.txt; exit 1; }',
92
+ 'echo "Quickstart: installs, and stops at the token, as documented."',
93
+ "# --- Or run it as a command: the CLI path ---",
94
+ ]
95
+ for line in command.splitlines():
96
+ if line.startswith("instagram-scraper"):
97
+ continue # run below, so the exit code can be checked
98
+ if line.startswith("pip install"):
99
+ # The repository is public, so this installs the exact line a
100
+ # reader pastes. On a private fork the URL is unreachable, and
101
+ # the check falls back to the checkout on its own.
102
+ url = line.split("git+", 1)[1].strip() if "git+" in line else ""
103
+ line = "\n".join([
104
+ f'repo="{url}"',
105
+ 'code=$(curl -s -o /dev/null -w "%{http_code}" "$repo" || echo 000)',
106
+ 'if [ "$code" = "200" ]; then',
107
+ f' echo "$repo is public: installing the exact README line"',
108
+ f' {line}',
109
+ "else",
110
+ ' echo "NOTE: $repo returns HTTP $code, so it is not public yet."',
111
+ ' echo "Installing from this checkout instead."',
112
+ ' pip install "$GITHUB_WORKSPACE"',
113
+ "fi",
114
+ ])
115
+ steps.append(line)
116
+ steps += [
117
+ "set +e",
118
+ "instagram-scraper nasa --limit 1 > out.txt 2> err.txt",
119
+ "code=$?",
120
+ "set -e",
121
+ 'test "$code" = "2" || { echo "expected exit 2, got $code"; cat err.txt; exit 1; }',
122
+ 'grep -qi token err.txt || { echo "no token message"; cat err.txt; exit 1; }',
123
+ 'echo "Success: a new user can follow both README paths from scratch."',
124
+ ]
125
+ print("\n".join(steps))
126
+ PY
127
+ echo "--- these commands came out of README.md ---"
128
+ cat "$RUNNER_TEMP/quickstart.sh"
129
+ echo "--- running them on this empty machine ---"
130
+ cd "$RUNNER_TEMP" && bash -e "$RUNNER_TEMP/quickstart.sh"
@@ -0,0 +1,395 @@
1
+ # The only workflow that calls the real API. Needs the BRIGHTDATA_API_TOKEN
2
+ # secret and spends about one credit per post.
3
+ #
4
+ # Four ways it runs:
5
+ # on every push to main: one post from nasa and the README's links, one
6
+ # credit and about three minutes, so a broken commit shows up at once.
7
+ # A newer push cancels a run still going for an older commit.
8
+ # by hand, from the Actions tab, to scrape whatever accounts you choose
9
+ # every day at 06:00 UTC except Monday: one post from nasa, the async
10
+ # path, and the field table, about two credits
11
+ # every Monday at 06:00 UTC: the same, plus every code block in the README,
12
+ # about 35 credits and 30 minutes
13
+ #
14
+ # Every run also checks, for free and with no secret, that the newest Bright
15
+ # Data CLI still accepts every CLI line in the README. Readers run `npx -p
16
+ # @brightdata/cli`, which is always the newest release, while the jobs that hold
17
+ # the secret run the version pinned in BDATA_CLI below. CLI 0.4.0 removed the
18
+ # README's commands and only the Monday run noticed; this check sees it the
19
+ # same day. When it warns about a new release, bump BDATA_CLI.
20
+ #
21
+ # A slow day at the API shows up as a red run. Rerun it before reading more
22
+ # into it.
23
+ #
24
+ # The daily and Monday runs end by rewriting the "last verified" badge line at
25
+ # the top of README.md with today's date, and committing it if it changed. Push
26
+ # runs never do this, so your own push is never followed by a bot commit. Only
27
+ # the main repository commits; a copy of the repository just runs the checks.
28
+ name: Live check
29
+
30
+ run-name: "${{ github.event.schedule == '0 6 * * 1' && 'Weekly check: does every README block still work?' || github.event_name == 'schedule' && 'Daily check: is anything broken?' || github.event_name == 'push' && format('Push check: {0}', github.event.head_commit.message) || format('Scrape {0}, {1} posts each', inputs.handles, inputs.limit) }}"
31
+
32
+ on:
33
+ push:
34
+ branches: [main]
35
+ schedule:
36
+ - cron: "0 6 * * 0,2-6" # daily smoke, every day except Monday
37
+ - cron: "0 6 * * 1" # Monday: the whole README
38
+ workflow_dispatch:
39
+ inputs:
40
+ handles:
41
+ description: Instagram handles, separated by spaces. No @ needed.
42
+ default: nasa natgeo
43
+ limit:
44
+ description: How many recent posts to fetch for each handle
45
+ default: "2"
46
+ validate:
47
+ description: Also run every README block, as the Monday run does
48
+ type: boolean
49
+ default: false
50
+ only:
51
+ description: Block numbers to run, comma separated, for a cheap check. Empty means all.
52
+ default: ""
53
+
54
+ permissions:
55
+ contents: write # the daily check commits the regenerated field table
56
+
57
+ env:
58
+ BDATA_CLI: "0.4.0" # pinned for the jobs that hold the API secret; bump after cli-syntax passes
59
+
60
+ concurrency:
61
+ group: live-${{ github.event_name }}-${{ github.ref }}
62
+ cancel-in-progress: ${{ github.event_name == 'push' }} # only the newest commit matters
63
+
64
+ jobs:
65
+ scrape:
66
+ name: Fetch the posts and attach them to this run
67
+ runs-on: ubuntu-latest
68
+ timeout-minutes: 20 # fetch, async path, table; the README blocks have their own jobs
69
+ env:
70
+ BRIGHTDATA_API_TOKEN: ${{ secrets.BRIGHTDATA_API_TOKEN }}
71
+ # On the daily schedule there are no inputs, so fall back to one cheap post.
72
+ HANDLES: ${{ inputs.handles || 'nasa' }}
73
+ LIMIT: ${{ inputs.limit || '1' }}
74
+ # the async path and the field table run on every scheduled run and on request
75
+ CHECK: ${{ github.event_name == 'schedule' || inputs.validate }}
76
+ steps:
77
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
78
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
79
+ with:
80
+ python-version: "3.12"
81
+ cache: pip
82
+ cache-dependency-path: pyproject.toml
83
+ - name: Install the package
84
+ run: pip install -e .
85
+
86
+ - name: Do the README's links still resolve?
87
+ run: |
88
+ public=$(curl -s -o /dev/null -w '%{http_code}' https://github.com/brightdata/instagram-scraper)
89
+ failed=0
90
+ for url in $(grep -ohE 'https?://[^ )>"`]+' README.md AGENTS.md | sed 's/[.,]$//' | sort -u); do
91
+ case "$url" in
92
+ *instagram.com/*) continue ;; # rate-limits runners; the weekly README run fetches these posts anyway
93
+ *zendesk.com/*) continue ;; # answers 403 to anything that is not a browser
94
+ *mcp.brightdata.com/*) continue ;; # carries a placeholder token; nothing to check without a real one
95
+ *github.com/brightdata/instagram-scraper*) [ "$public" = "200" ] || continue ;; # unreachable on a private fork
96
+ esac
97
+ code=$(curl -s -o /dev/null -L --retry 3 --max-time 30 -A "Mozilla/5.0" -w '%{http_code}' "$url" || echo 000)
98
+ case "$code" in
99
+ 200) echo "200 $url" ;;
100
+ # A 5xx after curl's retries is the server having a bad minute,
101
+ # not a dead link: GitHub answered 502 for an issue page once.
102
+ 5??) echo "::warning::$code $url (server error, link not judged)" ;;
103
+ *) echo "::error::$code $url"; failed=1 ;;
104
+ esac
105
+ done
106
+ [ "$failed" = 0 ] || exit 1
107
+ - name: Fetch the posts from Instagram
108
+ run: |
109
+ test -n "$BRIGHTDATA_API_TOKEN" || {
110
+ echo "No API token. Add BRIGHTDATA_API_TOKEN under" >&2
111
+ echo "Settings, Secrets and variables, Actions, then start this again." >&2
112
+ exit 1
113
+ }
114
+ # One retry, only on an API timeout. A slow day is not a broken build.
115
+ set +e
116
+ python -m instagram_scraper $HANDLES --limit "$LIMIT" 2>&1 | tee run.log
117
+ code=${PIPESTATUS[0]}
118
+ if [ "$code" != "0" ] && grep -q "timeout" run.log; then
119
+ echo "::warning::The API timed out. Waiting 30s and trying once more."
120
+ sleep 30
121
+ python -m instagram_scraper $HANDLES --limit "$LIMIT" 2>&1 | tee run.log
122
+ code=${PIPESTATUS[0]}
123
+ fi
124
+ exit "$code"
125
+
126
+ - name: Show what was found
127
+ if: always()
128
+ run: |
129
+ set -o pipefail
130
+ test -f instagram.json || exit 0
131
+ python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
132
+ import json
133
+
134
+ doc = json.load(open("instagram.json"))
135
+ print(f"Run at {doc['generated_at']}\n")
136
+ print("| handle | posts | fields | newest post |")
137
+ print("| --- | --- | --- | --- |")
138
+ for entry in doc["handles"]:
139
+ posts = entry["posts"]
140
+ newest = max(posts, key=lambda p: p.get("date_posted") or "", default=None)
141
+ print(
142
+ f"| {entry['handle']} | {len(posts)} | {len(posts[0]) if posts else 0} "
143
+ f"| {newest.get('url') if newest else '-'} |"
144
+ )
145
+ for entry in doc["handles"]:
146
+ if not entry["posts"]:
147
+ continue
148
+ print(f"\n### {entry['handle']}\n")
149
+ print("| date_posted | url | likes | comments |")
150
+ print("| --- | --- | --- | --- |")
151
+ for post in entry["posts"]:
152
+ print(
153
+ f"| {post.get('date_posted')} | {post.get('url')} "
154
+ f"| {post.get('likes')} | {post.get('num_comments')} |"
155
+ )
156
+ first = next((p for e in doc["handles"] for p in e["posts"]), None)
157
+ if first:
158
+ print("\n<details><summary>First record in full</summary>\n")
159
+ print("```json")
160
+ print(json.dumps(first, indent=2, ensure_ascii=False))
161
+ print("```\n")
162
+ print("</details>")
163
+ PY
164
+
165
+ # The two daily checks. Discovery is already covered by the step above.
166
+ - name: Does the async trigger, status, fetch path still work?
167
+ if: env.CHECK == 'true'
168
+ run: |
169
+ python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
170
+ import time
171
+
172
+ from brightdata import SyncBrightDataClient
173
+
174
+ url = "https://www.instagram.com/p/Db_SePSltfz/"
175
+ with SyncBrightDataClient(auto_create_zones=False) as c:
176
+ job = c.scrape.instagram.posts_trigger(url)
177
+ sid = job.snapshot_id
178
+ for _ in range(72): # up to 6 minutes
179
+ status = c.scrape.instagram.posts_status(sid)
180
+ if status in ("ready", "failed", "canceled"):
181
+ break
182
+ time.sleep(5)
183
+ assert status == "ready", f"snapshot {sid} ended as {status}"
184
+ rows = c.scrape.instagram.posts_fetch(sid)
185
+ rows = rows if isinstance(rows, list) else [rows]
186
+ assert rows and rows[0].get("url"), "fetch returned no record"
187
+ print(f"\nAsync path ok: trigger, status, fetch returned {len(rows)} record(s).")
188
+ PY
189
+
190
+ - name: Regenerate the README field table from the live schema
191
+ if: always() && env.CHECK == 'true' && github.repository == 'brightdata/instagram-scraper' && github.ref == 'refs/heads/main'
192
+ run: |
193
+ python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
194
+ import json
195
+ import pathlib
196
+ import re
197
+
198
+ from brightdata import SyncBrightDataClient
199
+
200
+ readme = pathlib.Path("README.md")
201
+ text = readme.read_text(encoding="utf-8")
202
+ start, end = "<!-- fields:start -->", "<!-- fields:end -->"
203
+ assert start in text and end in text, "README field-table markers are missing"
204
+
205
+ with SyncBrightDataClient(auto_create_zones=False) as c:
206
+ fields = c.datasets.instagram_posts.get_metadata().fields
207
+ sample = json.load(open("examples/sample_output.json"))["handles"][0]["posts"][0]
208
+
209
+ rows = ["| field | type | description |", "| --- | --- | --- |"]
210
+ for name, spec in fields.items():
211
+ desc = (spec.description or "").replace(" — ", ": ").replace("—", ":")
212
+ desc = " ".join(desc.split()).replace("`", "'").replace("|", "/") # no fences, no table breaks
213
+ rows.append(f"| `{name}` | {spec.type} | {desc} |")
214
+ block = "\n".join([
215
+ start,
216
+ "<details>",
217
+ f"<summary>All {len(fields)} fields, with type and description</summary>",
218
+ "",
219
+ "Regenerated every day from the dataset schema, via",
220
+ "`client.datasets.instagram_posts.get_metadata()`, so it cannot go stale. A",
221
+ f"post carries the fields that apply to it; the sample file has {len(sample)}",
222
+ f"of these {len(fields)}.",
223
+ "",
224
+ *rows,
225
+ "",
226
+ "</details>",
227
+ end,
228
+ ])
229
+ new = re.sub(re.escape(start) + r".*?" + re.escape(end), lambda _: block, text, flags=re.S)
230
+ readme.write_text(new, encoding="utf-8")
231
+ state = "updated" if new != text else "already current"
232
+ print(f"\nSchema: {len(fields)} fields. README table {state}.")
233
+ PY
234
+ if git diff --quiet README.md; then
235
+ echo "Nothing to commit."
236
+ exit 0
237
+ fi
238
+ # Never commit a README the tests would reject.
239
+ pip install -q -e ".[dev]" && pytest -q
240
+ git config user.name "anil-bd"
241
+ git config user.email "210667061+anil-bd@users.noreply.github.com"
242
+ git add README.md
243
+ git commit -m "Regenerate the field table from the live schema (automated daily check)"
244
+ git pull --rebase origin main
245
+ git push
246
+ echo "Committed the regenerated table."
247
+
248
+ - name: Attach instagram.json so you can download it
249
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
250
+ if: always()
251
+ with:
252
+ name: instagram-json
253
+ path: instagram.json
254
+ if-no-files-found: warn
255
+
256
+ # Every code block in the README, each in its own job, all at once. Mondays,
257
+ # and on request. Wall clock is the slowest block, about five minutes,
258
+ # instead of the sum of all fourteen.
259
+ plan:
260
+ name: List the README blocks
261
+ if: github.event.schedule == '0 6 * * 1' || inputs.validate == true
262
+ runs-on: ubuntu-latest
263
+ outputs:
264
+ blocks: ${{ steps.list.outputs.blocks }}
265
+ steps:
266
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
267
+ - id: list
268
+ env:
269
+ ONLY: ${{ inputs.only }}
270
+ run: python .github/scripts/readme_blocks.py list >> "$GITHUB_OUTPUT"
271
+
272
+ block:
273
+ name: "${{ matrix.n }}. ${{ matrix.title }}"
274
+ needs: plan
275
+ runs-on: ubuntu-latest
276
+ timeout-minutes: 10 # the SDK gives up at 3 minutes, reels at 7; past this is stuck
277
+ strategy:
278
+ fail-fast: false
279
+ matrix:
280
+ include: ${{ fromJson(needs.plan.outputs.blocks) }}
281
+ env:
282
+ BRIGHTDATA_API_TOKEN: ${{ secrets.BRIGHTDATA_API_TOKEN }}
283
+ steps:
284
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
285
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
286
+ with:
287
+ python-version: "3.12"
288
+ cache: pip
289
+ cache-dependency-path: pyproject.toml
290
+ - name: Install the package and, for CLI blocks, the Bright Data CLI
291
+ env:
292
+ LANG_: ${{ matrix.lang }}
293
+ run: |
294
+ pip install -e .
295
+ if [ "$LANG_" = "bash" ]; then npm i -g "@brightdata/cli@$BDATA_CLI"; fi
296
+ - name: Run block ${{ matrix.n }} exactly as the README shows it
297
+ env:
298
+ N: ${{ matrix.n }}
299
+ run: |
300
+ set -o pipefail # without this, tee's exit code hides a failing block
301
+ python .github/scripts/readme_blocks.py run "$N" | tee -a "$GITHUB_STEP_SUMMARY"
302
+
303
+ cli-syntax:
304
+ name: Does the newest CLI still accept the README's commands?
305
+ runs-on: ubuntu-latest
306
+ timeout-minutes: 10
307
+ # no secret here on purpose: this job runs whatever npm calls latest
308
+ steps:
309
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
310
+ - name: Install the newest CLI, as a reader's npx would
311
+ run: |
312
+ npm i -g @brightdata/cli@latest
313
+ latest=$(bdata --version)
314
+ echo "Newest CLI: $latest. Pinned for the live jobs: $BDATA_CLI."
315
+ if [ "$latest" != "$BDATA_CLI" ]; then
316
+ echo "::warning::@brightdata/cli $latest is out; the live jobs still pin $BDATA_CLI. Bump BDATA_CLI in live.yml once this job passes."
317
+ fi
318
+ - name: Run each README CLI line with a key the API rejects
319
+ env:
320
+ BDATA_CLI: "" # use the newest, not the pin
321
+ run: |
322
+ set -o pipefail
323
+ python3 .github/scripts/readme_blocks.py cli-syntax | tee -a "$GITHUB_STEP_SUMMARY"
324
+
325
+ badge:
326
+ name: Put today's date on the README's "last verified" badge
327
+ needs: [scrape, plan, block, cli-syntax]
328
+ # scheduled and manual runs on main only, and only in the main repository: a
329
+ # push is never followed by a bot commit, a copy never diverges from the
330
+ # original, and a manual run on a branch never tries to push main
331
+ if: always() && !cancelled() && github.event_name != 'push' && github.repository == 'brightdata/instagram-scraper' && github.ref == 'refs/heads/main'
332
+ permissions:
333
+ contents: write # commits the badge line
334
+ actions: read # counts this run's job results
335
+ runs-on: ubuntu-latest
336
+ timeout-minutes: 5
337
+ steps:
338
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
339
+ - name: Rewrite one badge line, commit only if it changed
340
+ env:
341
+ GH_TOKEN: ${{ github.token }}
342
+ run: |
343
+ # Two states: red only when every check in this run failed. One passing
344
+ # check keeps the badge green; the run itself still shows which failed.
345
+ read -r passed failed < <(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT/jobs?per_page=100" \
346
+ --jq '[.jobs[] | select((.name | startswith("Put today") or startswith("Open or update") or . == "List the README blocks") | not)] as $j | "\([$j[] | select(.conclusion == "success")] | length) \([$j[] | select(.conclusion == "failure")] | length)"') || true
347
+ echo "checks passed: ${passed:-0}, failed: ${failed:-0}"
348
+ if [ "${passed:-0}" -gt 0 ]; then
349
+ label="last verified"; color="brightgreen"
350
+ else
351
+ label="last check failed"; color="red"
352
+ fi
353
+ today=$(date -u +'%-d %b %Y')
354
+ line="[![${label}](https://img.shields.io/badge/${label// /%20}-${today// /%20}-${color})](https://github.com/brightdata/instagram-scraper/actions/workflows/live.yml) <!-- verified: rewritten by the daily run -->"
355
+ python3 - "$line" <<'PY'
356
+ import pathlib, re, sys
357
+ p = pathlib.Path("README.md"); t = p.read_text(encoding="utf-8")
358
+ new, n = re.subn(r"^\[!\[last [^\]]*\]\(https://img\.shields\.io/badge/[^)]*\)\]\([^)]*\) <!-- verified:[^\n]*$", sys.argv[1], t, count=1, flags=re.M)
359
+ if n != 1:
360
+ sys.exit("the badge line is missing from README.md")
361
+ p.write_text(new, encoding="utf-8")
362
+ PY
363
+ if git diff --quiet; then echo "badge already shows $today"; exit 0; fi
364
+ git config user.name "anil-bd"
365
+ git config user.email "210667061+anil-bd@users.noreply.github.com"
366
+ git commit -qam "${label^} $today (automated daily check)"
367
+ git pull -q --rebase origin main
368
+ git push -q origin main
369
+ echo "badge now shows: $label, $today"
370
+
371
+ # A red scheduled run used to show only as a red badge. Open one issue and keep
372
+ # adding to it, so a failure reaches the people watching the repository.
373
+ alert:
374
+ name: Open or update the live-check issue
375
+ needs: [scrape, block, cli-syntax]
376
+ if: failure() && github.event_name == 'schedule' && github.repository == 'brightdata/instagram-scraper'
377
+ runs-on: ubuntu-latest
378
+ timeout-minutes: 5
379
+ permissions:
380
+ issues: write
381
+ steps:
382
+ - env:
383
+ GH_TOKEN: ${{ github.token }}
384
+ GH_REPO: ${{ github.repository }}
385
+ RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
386
+ RESULTS: "scrape ${{ needs.scrape.result }}, README blocks ${{ needs.block.result }}, CLI syntax ${{ needs.cli-syntax.result }}"
387
+ run: |
388
+ title="Live check is failing"
389
+ body="$(date -u +'%-d %b %Y'): $RESULTS. $RUN"
390
+ n=$(gh issue list --state open --search "\"$title\" in:title" --json number --jq '.[0].number')
391
+ if [ -n "$n" ]; then
392
+ gh issue comment "$n" --body "$body"
393
+ else
394
+ gh issue create --title "$title" --body "$body"
395
+ fi