-
Notifications
You must be signed in to change notification settings - Fork 0
404 lines (382 loc) · 19.5 KB
/
Copy pathlive.yml
File metadata and controls
404 lines (382 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# The only workflow that calls the real API. Needs the BRIGHTDATA_API_TOKEN
# secret and spends about one credit per post.
#
# Four ways it runs:
# on every push to main: one post from nasa and the README's links, one
# credit and about three minutes, so a broken commit shows up at once.
# A newer push cancels a run still going for an older commit.
# by hand, from the Actions tab, to scrape whatever accounts you choose
# every day at 06:00 UTC except Monday: one post from nasa, the async
# path, and the field table, about two credits
# every Monday at 06:00 UTC: the same, plus every code block in the README,
# about 35 credits and 30 minutes
#
# A slow day at the API shows up as a red run. Rerun it before reading more
# into it.
#
# The daily and Monday runs end by rewriting the "last verified" badge line at
# the top of README.md with today's date, and committing it if it changed. Push
# runs never do this, so your own push is never followed by a bot commit. Only
# the main repository commits; a copy of the repository just runs the checks.
name: Live check
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) }}"
on:
push:
branches: [main]
schedule:
- cron: "0 6 * * 0,2-6" # daily smoke, every day except Monday
- cron: "0 6 * * 1" # Monday: the whole README
workflow_dispatch:
inputs:
handles:
description: Instagram handles, separated by spaces. No @ needed.
default: nasa natgeo
limit:
description: How many recent posts to fetch for each handle
default: "2"
validate:
description: Also run every README block, as the Monday run does
type: boolean
default: false
only:
description: Block numbers to run, comma separated, for a cheap check. Empty means all.
default: ""
permissions:
contents: write # the daily check commits the regenerated field table
concurrency:
group: live-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'push' }} # only the newest commit matters
jobs:
scrape:
name: Fetch the posts and attach them to this run
runs-on: ubuntu-latest
timeout-minutes: 20 # fetch, async path, table; the README blocks have their own jobs
env:
BRIGHTDATA_API_TOKEN: ${{ secrets.BRIGHTDATA_API_TOKEN }}
# On the daily schedule there are no inputs, so fall back to one cheap post.
HANDLES: ${{ inputs.handles || 'nasa' }}
LIMIT: ${{ inputs.limit || '1' }}
# the async path and the field table run on every scheduled run and on request
CHECK: ${{ github.event_name == 'schedule' || inputs.validate }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.12"
- name: Install the package and the Bright Data CLI
run: |
pip install -e .
npm i -g @brightdata/cli@0.3.4 # pinned: this job holds the API secret
- name: Do the README's links still resolve?
run: |
public=$(curl -s -o /dev/null -w '%{http_code}' https://github.com/brightdata/instagram-scraper-python)
failed=0
for url in $(grep -ohE 'https?://[^ )>"`]+' README.md AGENTS.md | sed 's/[.,]$//' | sort -u); do
case "$url" in
*instagram.com/*) continue ;; # rate-limits runners; the weekly README run fetches these posts anyway
*zendesk.com/*) continue ;; # answers 403 to anything that is not a browser
*mcp.brightdata.com/*) continue ;; # carries a placeholder token; nothing to check without a real one
*github.com/brightdata/instagram-scraper-python*) [ "$public" = "200" ] || continue ;; # unreachable on a private fork
esac
code=$(curl -s -o /dev/null -L --retry 3 --max-time 30 -A "Mozilla/5.0" -w '%{http_code}' "$url" || echo 000)
if [ "$code" = "200" ]; then echo "200 $url"; else echo "::error::$code $url"; failed=1; fi
done
[ "$failed" = 0 ] || exit 1
- name: Fetch the posts from Instagram
run: |
test -n "$BRIGHTDATA_API_TOKEN" || {
echo "No API token. Add BRIGHTDATA_API_TOKEN under" >&2
echo "Settings, Secrets and variables, Actions, then start this again." >&2
exit 1
}
# One retry, only on an API timeout. A slow day is not a broken build.
set +e
python -m instagram_scraper $HANDLES --limit "$LIMIT" 2>&1 | tee run.log
code=${PIPESTATUS[0]}
if [ "$code" != "0" ] && grep -q "timeout" run.log; then
echo "::warning::The API timed out. Waiting 30s and trying once more."
sleep 30
python -m instagram_scraper $HANDLES --limit "$LIMIT" 2>&1 | tee run.log
code=${PIPESTATUS[0]}
fi
exit "$code"
- name: Show what was found
if: always()
run: |
set -o pipefail
test -f instagram.json || exit 0
python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
import json
doc = json.load(open("instagram.json"))
print(f"Run at {doc['generated_at']}\n")
print("| handle | posts | fields | newest post |")
print("| --- | --- | --- | --- |")
for entry in doc["handles"]:
posts = entry["posts"]
newest = max(posts, key=lambda p: p.get("date_posted") or "", default=None)
print(
f"| {entry['handle']} | {len(posts)} | {len(posts[0]) if posts else 0} "
f"| {newest.get('url') if newest else '-'} |"
)
for entry in doc["handles"]:
if not entry["posts"]:
continue
print(f"\n### {entry['handle']}\n")
print("| date_posted | url | likes | comments |")
print("| --- | --- | --- | --- |")
for post in entry["posts"]:
print(
f"| {post.get('date_posted')} | {post.get('url')} "
f"| {post.get('likes')} | {post.get('num_comments')} |"
)
first = next((p for e in doc["handles"] for p in e["posts"]), None)
if first:
print("\n<details><summary>First record in full</summary>\n")
print("```json")
print(json.dumps(first, indent=2, ensure_ascii=False))
print("```\n")
print("</details>")
PY
# The two daily checks. Discovery is already covered by the step above.
- name: Does the async trigger, status, fetch path still work?
if: env.CHECK == 'true'
run: |
python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
import time
from brightdata import SyncBrightDataClient
url = "https://www.instagram.com/p/Db_SePSltfz/"
with SyncBrightDataClient(auto_create_zones=False) as c:
job = c.scrape.instagram.posts_trigger(url)
sid = job.snapshot_id
for _ in range(72): # up to 6 minutes
status = c.scrape.instagram.posts_status(sid)
if status in ("ready", "failed", "canceled"):
break
time.sleep(5)
assert status == "ready", f"snapshot {sid} ended as {status}"
rows = c.scrape.instagram.posts_fetch(sid)
rows = rows if isinstance(rows, list) else [rows]
assert rows and rows[0].get("url"), "fetch returned no record"
print(f"\nAsync path ok: trigger, status, fetch returned {len(rows)} record(s).")
PY
- name: Regenerate the README field table from the live schema
if: always() && env.CHECK == 'true' && github.repository == 'brightdata/instagram-scraper-python'
run: |
python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
import json
import pathlib
import re
from brightdata import SyncBrightDataClient
readme = pathlib.Path("README.md")
text = readme.read_text(encoding="utf-8")
start, end = "<!-- fields:start -->", "<!-- fields:end -->"
assert start in text and end in text, "README field-table markers are missing"
with SyncBrightDataClient(auto_create_zones=False) as c:
fields = c.datasets.instagram_posts.get_metadata().fields
sample = json.load(open("examples/sample_output.json"))["handles"][0]["posts"][0]
rows = ["| field | type | description |", "| --- | --- | --- |"]
for name, spec in fields.items():
desc = (spec.description or "").replace(" — ", ": ").replace("—", ":")
desc = " ".join(desc.split()).replace("`", "'").replace("|", "/") # no fences, no table breaks
rows.append(f"| `{name}` | {spec.type} | {desc} |")
block = "\n".join([
start,
"<details>",
f"<summary>All {len(fields)} fields, with type and description</summary>",
"",
"Regenerated every day from the dataset schema, via",
"`client.datasets.instagram_posts.get_metadata()`, so it cannot go stale. A",
f"post carries the fields that apply to it; the sample file has {len(sample)}",
f"of these {len(fields)}.",
"",
*rows,
"",
"</details>",
end,
])
new = re.sub(re.escape(start) + r".*?" + re.escape(end), lambda _: block, text, flags=re.S)
readme.write_text(new, encoding="utf-8")
state = "updated" if new != text else "already current"
print(f"\nSchema: {len(fields)} fields. README table {state}.")
PY
if git diff --quiet README.md; then
echo "Nothing to commit."
exit 0
fi
# Never commit a README the tests would reject.
pip install -q -e ".[dev]" && pytest -q
git config user.name "anil-bd"
git config user.email "210667061+anil-bd@users.noreply.github.com"
git add README.md
git commit -m "Regenerate the field table from the live schema (automated daily check)"
git pull --rebase origin main
git push
echo "Committed the regenerated table."
- name: Attach instagram.json so you can download it
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: instagram-json
path: instagram.json
if-no-files-found: warn
# Every code block in the README, each in its own job, all at once. Mondays,
# and on request. Wall clock is the slowest block, about five minutes,
# instead of the sum of all fourteen.
plan:
name: List the README blocks
if: github.event.schedule == '0 6 * * 1' || inputs.validate == true
runs-on: ubuntu-latest
outputs:
blocks: ${{ steps.list.outputs.blocks }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- id: list
env:
ONLY: ${{ inputs.only }}
run: |
python - <<'PY' >> "$GITHUB_OUTPUT"
import os
import json
import pathlib
import re
readme = pathlib.Path("README.md").read_text(encoding="utf-8")
# never execute anything inside the generated field table: its text comes from
# the API, and a fence in a description must not become a command
readme = re.sub(r"<!-- fields:start -->.*?<!-- fields:end -->", "", readme, flags=re.S)
SKIP = ("pip install", "export ", "npm i", "npm install",
"npx -p @brightdata/cli bdata login", "bdata login")
blocks, heading, seen = [], "README", {}
for m in re.finditer(r"^#{2,3} ([^\n]+)$|```(python|bash)\n(.*?)```", readme, re.M | re.S):
if m.group(1):
heading = m.group(1).strip() # a block is named after its section, not its first line
continue
lang, code = m.group(2), m.group(3)
lines = [l for l in code.splitlines() if l.strip() and not l.strip().startswith("#")]
if lang == "bash":
lines = [l for l in lines if not l.startswith(SKIP)]
if not lines:
continue
# the CLI cannot open a browser here; the secret goes in by flag, and
# only the shell ever sees its value
code = "\n".join(l + ' -k "$BRIGHTDATA_API_TOKEN"' if "bdata pipelines" in l else l for l in lines)
seen[heading] = seen.get(heading, 0) + 1
title = heading if seen[heading] == 1 else f"{heading} ({seen[heading]})"
blocks.append({"lang": lang, "title": title[:50], "code": code})
wanted = {int(n) for n in os.environ.get("ONLY", "").split(",") if n.strip()}
out = [{"n": i + 1, "lang": b["lang"], "title": b["title"]} for i, b in enumerate(blocks) if not wanted or i + 1 in wanted]
print("blocks=" + json.dumps(out))
PY
block:
name: "${{ matrix.n }}. ${{ matrix.title }}"
needs: plan
runs-on: ubuntu-latest
timeout-minutes: 10 # the SDK gives up at 3 minutes, reels at 7; past this is stuck
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.plan.outputs.blocks) }}
env:
BRIGHTDATA_API_TOKEN: ${{ secrets.BRIGHTDATA_API_TOKEN }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.12"
- name: Install the package and, for CLI blocks, the Bright Data CLI
run: |
pip install -e .
if [ "${{ matrix.lang }}" = "bash" ]; then npm i -g @brightdata/cli@0.3.4; fi
- name: Run block ${{ matrix.n }} exactly as the README shows it
env:
N: ${{ matrix.n }}
run: |
set -o pipefail # without this, tee's exit code hides a failing block
python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY"
import os
import subprocess
import sys
import time
import json
import pathlib
import re
readme = pathlib.Path("README.md").read_text(encoding="utf-8")
# never execute anything inside the generated field table: its text comes from
# the API, and a fence in a description must not become a command
readme = re.sub(r"<!-- fields:start -->.*?<!-- fields:end -->", "", readme, flags=re.S)
SKIP = ("pip install", "export ", "npm i", "npm install",
"npx -p @brightdata/cli bdata login", "bdata login")
blocks, heading, seen = [], "README", {}
for m in re.finditer(r"^#{2,3} ([^\n]+)$|```(python|bash)\n(.*?)```", readme, re.M | re.S):
if m.group(1):
heading = m.group(1).strip() # a block is named after its section, not its first line
continue
lang, code = m.group(2), m.group(3)
lines = [l for l in code.splitlines() if l.strip() and not l.strip().startswith("#")]
if lang == "bash":
lines = [l for l in lines if not l.startswith(SKIP)]
if not lines:
continue
# the CLI cannot open a browser here; the secret goes in by flag, and
# only the shell ever sees its value
code = "\n".join(l + ' -k "$BRIGHTDATA_API_TOKEN"' if "bdata pipelines" in l else l for l in lines)
seen[heading] = seen.get(heading, 0) + 1
title = heading if seen[heading] == 1 else f"{heading} ({seen[heading]})"
blocks.append({"lang": lang, "title": title[:50], "code": code})
b = blocks[int(os.environ["N"]) - 1]
argv = ["bash", "-e", "-c", b["code"]] if b["lang"] == "bash" else [sys.executable, "-c", b["code"]]
secret = os.environ.get("BRIGHTDATA_API_TOKEN", "")
for attempt in (1, 2):
started = time.time()
try:
run = subprocess.run(argv, capture_output=True, text=True, timeout=480)
except subprocess.TimeoutExpired:
run = subprocess.CompletedProcess(argv, 1, "", "block timeout: no result within 8 minutes")
secs = round(time.time() - started)
out, err = run.stdout.strip(), run.stderr.strip()
if secret: # summaries are not secret-masked the way logs are
out, err = out.replace(secret, "***"), err.replace(secret, "***")
ok = run.returncode == 0 and bool(out if b["lang"] == "python" else out or err)
if ok or ((out or err) and "timeout" not in (out + err).lower()):
break
time.sleep(30) # the API timed out or answered with nothing at all; one retry
shown = (out.splitlines() or [""])[0] if ok else (err.splitlines() or out.splitlines() or ["no output"])[-1]
print(f"\n`{b['title']}`: {'ok' if ok else 'FAILED'} in {secs}s\n\n {shown[:120]}")
sys.exit(0 if ok else 1)
PY
badge:
name: Put today's date on the README's "last verified" badge
needs: [scrape, plan, block]
# scheduled and manual runs only, and only in the main repository: a push is
# never followed by a bot commit, and a copy never diverges from the original
if: always() && !cancelled() && github.event_name != 'push' && github.repository == 'brightdata/instagram-scraper-python'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Rewrite one badge line, commit only if it changed
env:
SCRAPE: ${{ needs.scrape.result }}
BLOCKS: ${{ needs.block.result }} # skipped on the daily run, and that is fine
run: |
if [ "$SCRAPE" = "success" ] && { [ "$BLOCKS" = "success" ] || [ "$BLOCKS" = "skipped" ]; }; then
label="last verified"; color="brightgreen"
else
label="last check failed"; color="red"
fi
today=$(date -u +'%-d %b %Y')
line="[](https://github.com/brightdata/instagram-scraper-python/actions/workflows/live.yml) <!-- verified: rewritten by the daily run -->"
python3 - "$line" <<'PY'
import pathlib, re, sys
p = pathlib.Path("README.md"); t = p.read_text(encoding="utf-8")
new, n = re.subn(r"^\[!\[last [^\]]*\]\(https://img\.shields\.io/badge/[^)]*\)\]\([^)]*\) <!-- verified:[^\n]*$", sys.argv[1], t, count=1, flags=re.M)
if n != 1:
sys.exit("the badge line is missing from README.md")
p.write_text(new, encoding="utf-8")
PY
if git diff --quiet; then echo "badge already shows $today"; exit 0; fi
git config user.name "anil-bd"
git config user.email "210667061+anil-bd@users.noreply.github.com"
git commit -qam "Last verified $today (automated daily check)"
git pull -q --rebase origin main
git push -q origin main
echo "badge now shows: $label, $today"