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.
- brightdata_instagram_scraper-0.1.0/.env.example +3 -0
- brightdata_instagram_scraper-0.1.0/.github/banner.png +0 -0
- brightdata_instagram_scraper-0.1.0/.github/scripts/readme_blocks.py +138 -0
- brightdata_instagram_scraper-0.1.0/.github/workflows/ci.yml +130 -0
- brightdata_instagram_scraper-0.1.0/.github/workflows/live.yml +395 -0
- brightdata_instagram_scraper-0.1.0/.github/workflows/publish.yml +38 -0
- brightdata_instagram_scraper-0.1.0/.gitignore +7 -0
- brightdata_instagram_scraper-0.1.0/AGENTS.md +51 -0
- brightdata_instagram_scraper-0.1.0/CHANGELOG.md +11 -0
- brightdata_instagram_scraper-0.1.0/CONTRIBUTING.md +27 -0
- brightdata_instagram_scraper-0.1.0/LICENSE +202 -0
- brightdata_instagram_scraper-0.1.0/PKG-INFO +542 -0
- brightdata_instagram_scraper-0.1.0/README.md +514 -0
- brightdata_instagram_scraper-0.1.0/examples/sample_output.json +195 -0
- brightdata_instagram_scraper-0.1.0/pyproject.toml +52 -0
- brightdata_instagram_scraper-0.1.0/src/instagram_scraper/__init__.py +6 -0
- brightdata_instagram_scraper-0.1.0/src/instagram_scraper/__main__.py +73 -0
- brightdata_instagram_scraper-0.1.0/src/instagram_scraper/scrape.py +170 -0
- brightdata_instagram_scraper-0.1.0/tests/test_scrape.py +264 -0
|
Binary file
|
|
@@ -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="[](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
|