Skip to content

Commit c532036

Browse files
Your Nameclaude
authored andcommitted
migrate Instagram integration to Graph API v26 with new test account
- switch host to graph.instagram.com (was graph.facebook.com) and bump v21.0 -> v26.0 - use header-based auth for metrics collector, matching the poster - poll media container status instead of a blind sleep before publishing - derive post_url from the authenticated account instead of the old hardcoded handle - add INSTAGRAM_TEST_* secrets so dev CI posts to the test account, not prod - correct .env.example/README var names; add instagram_manual_test.py Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent bf306f8 commit c532036

8 files changed

Lines changed: 198 additions & 23 deletions

File tree

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
CUTEPETSBOSTON_RESCUEGROUPS_API_KEY=
2-
INSTAGRAM_USERNAME=
3-
INSTAGRAM_PASSWORD=
2+
INSTAGRAM_BUSINESS_ACCOUNT_ID=
3+
INSTAGRAM_PAGE_ACCESS_TOKEN=
44
BLUESKY_HANDLE=
55
BLUESKY_PASSWORD=
66
MASTODON_TOKEN=

.github/workflows/dev.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ jobs:
7070
- name: Call RescueGroups API
7171
env:
7272
CUTEPETSBOSTON_RESCUEGROUPS_API_KEY: ${{ secrets.CUTEPETSBOSTON_RESCUEGROUPS_API_KEY }}
73-
INSTAGRAM_BUSINESS_ACCOUNT_ID: ${{ secrets.INSTAGRAM_BUSINESS_ACCOUNT_ID }}
74-
INSTAGRAM_PAGE_ACCESS_TOKEN: ${{ secrets.INSTAGRAM_PAGE_ACCESS_TOKEN }}
73+
INSTAGRAM_BUSINESS_ACCOUNT_ID: ${{ secrets.INSTAGRAM_TEST_BUSINESS_ACCOUNT_ID }}
74+
INSTAGRAM_PAGE_ACCESS_TOKEN: ${{ secrets.INSTAGRAM_TEST_PAGE_ACCESS_TOKEN }}
7575
BLUESKY_HANDLE: ${{ secrets.BLUESKY_TEST_HANDLE }}
7676
BLUESKY_PASSWORD: ${{ secrets.BLUESKY_TEST_PASSWORD }}
7777
MASTODON_TOKEN: ${{ secrets.MASTODON_TEST_TOKEN }}

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Required:
1616
- `CUTEPETSBOSTON_RESCUEGROUPS_API_KEY`
1717

1818
Optional for Instagram posting:
19-
- `INSTAGRAM_HANDLE`
20-
- `INSTAGRAM_PASSWORD`
19+
- `INSTAGRAM_BUSINESS_ACCOUNT_ID` (or `INSTAGRAM_TEST_BUSINESS_ACCOUNT_ID`)
20+
- `INSTAGRAM_PAGE_ACCESS_TOKEN` (or `INSTAGRAM_TEST_PAGE_ACCESS_TOKEN`)
2121

2222
Optional for Bluesky posting:
2323
- `BLUESKY_HANDLE` (or `BLUESKY_TEST_HANDLE`)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import argparse
2+
import os
3+
import sys
4+
5+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
6+
7+
from abstractions import AdoptablePet
8+
from social_posters.instagram import PosterInstagram
9+
10+
11+
def sample_pet():
12+
return AdoptablePet(
13+
name="Brian",
14+
species="dog",
15+
breed="Labrador Retriever",
16+
location="Boston, MA",
17+
description="Brian is a laid-back lab mix who loves a good nap and a good book.",
18+
adoption_url="https://example.org/adopt/brian",
19+
image_url="https://static.wikia.nocookie.net/familyguy/images/c/c2/FamilyGuy_Single_BrianWriter_R7.jpg/revision/latest?cb=20230807152447",
20+
age_string="4 years",
21+
sex="Male",
22+
size_group="Large",
23+
pet_id="manual-test-brian",
24+
)
25+
26+
27+
testing_cases = [sample_pet]
28+
29+
30+
def main():
31+
parser = argparse.ArgumentParser(
32+
description="Manually exercise the Instagram poster against a real account."
33+
)
34+
parser.add_argument(
35+
"--dry-run",
36+
action="store_true",
37+
help="Format the post without authenticating or publishing.",
38+
)
39+
parser.add_argument(
40+
"--image-url",
41+
help="Override the sample pet's image URL with a different publicly accessible image.",
42+
)
43+
args = parser.parse_args()
44+
45+
poster = PosterInstagram()
46+
47+
if not args.dry_run and not poster.authenticate():
48+
print("Authentication failed!")
49+
sys.exit(1)
50+
51+
if not args.dry_run:
52+
print(f"Authenticated to Instagram as @{poster.username}")
53+
54+
for make_pet in testing_cases:
55+
pet = make_pet()
56+
if args.image_url:
57+
pet.image_url = args.image_url
58+
59+
post = poster.format_post(pet)
60+
print(f"\nPost preview:\n{post.text}")
61+
print(f"\nTags: {post.tags}")
62+
print(f"Alt text: {post.alt_text}")
63+
64+
if args.dry_run:
65+
continue
66+
67+
print(
68+
"\nPublishing (this polls Instagram until the image finishes "
69+
"processing, up to 60s)..."
70+
)
71+
result = poster.publish(post)
72+
73+
if result.success:
74+
print(f"\nPosted successfully! Media ID: {result.post_id}, URL: {result.post_url}")
75+
else:
76+
print(f"\nPost failed: {result.error_message}")
77+
sys.exit(1)
78+
79+
80+
if __name__ == "__main__":
81+
main()

metric_collectors/instagram.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,8 @@ def fetch_metrics(
3232
try:
3333
response = requests.get(
3434
f"{GRAPH_API_BASE}/{post_id}",
35-
params={
36-
"fields": "like_count,comments_count",
37-
"access_token": self.access_token,
38-
},
35+
params={"fields": "like_count,comments_count"},
36+
headers={"Authorization": f"Bearer {self.access_token}"},
3937
timeout=20,
4038
)
4139
response.raise_for_status()

social_posters/instagram.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@
66
from abstractions import Post, PostResult, SocialPoster
77

88

9-
GRAPH_API_VERSION = "v21.0"
10-
GRAPH_API_BASE = f"https://graph.facebook.com/{GRAPH_API_VERSION}"
9+
GRAPH_API_VERSION = "v26.0"
10+
GRAPH_API_BASE = f"https://graph.instagram.com/{GRAPH_API_VERSION}"
11+
12+
# Images typically finish container processing in seconds;
13+
# video would need Meta's suggested ~1-minute cadence
14+
CONTAINER_POLL_INTERVAL_SECONDS = 5
15+
CONTAINER_POLL_TIMEOUT_SECONDS = 60
1116

1217

1318
class PosterInstagram(SocialPoster):
@@ -16,6 +21,7 @@ def __init__(self):
1621
self.access_token = os.environ.get("INSTAGRAM_PAGE_ACCESS_TOKEN")
1722
self._is_available = bool(self.account_id and self.access_token)
1823
self._authenticated = False
24+
self.username = None
1925

2026
@property
2127
def platform_name(self) -> str:
@@ -37,6 +43,7 @@ def authenticate(self) -> bool:
3743
timeout=10,
3844
)
3945
response.raise_for_status()
46+
self.username = response.json().get("username")
4047
self._authenticated = True
4148
return True
4249
except requests.exceptions.HTTPError as exc:
@@ -64,15 +71,16 @@ def publish(self, post: Post) -> PostResult:
6471

6572
try:
6673
container_id = self._create_media_container(post)
67-
# Instagram needs time to process the uploaded image before publishing.
68-
# Publishing immediately returns "Media ID is not available" (error 9007).
69-
time.sleep(10)
70-
74+
self._wait_for_container_ready(container_id)
75+
7176
media_id = self._publish_media(container_id)
77+
post_url = (
78+
f"https://www.instagram.com/{self.username}/" if self.username else None
79+
)
7280
return PostResult(
7381
success=True,
7482
post_id=media_id,
75-
post_url="https://www.instagram.com/cute.pets.boston/",
83+
post_url=post_url,
7684
)
7785
except requests.exceptions.HTTPError as exc:
7886
body = exc.response.text if exc.response is not None else "no response body"
@@ -99,7 +107,37 @@ def _create_media_container(self, post: Post) -> str:
99107
response.raise_for_status()
100108
return response.json()["id"]
101109

102-
110+
def _wait_for_container_ready(self, container_id: str) -> None:
111+
"""Poll the container until Instagram finishes processing the image.
112+
113+
Publishing before the container is FINISHED returns "Media ID is not
114+
available" (error 9007).
115+
"""
116+
deadline = time.monotonic() + CONTAINER_POLL_TIMEOUT_SECONDS
117+
while True:
118+
response = requests.get(
119+
f"{GRAPH_API_BASE}/{container_id}",
120+
params={"fields": "status_code"},
121+
headers=self._authorization_headers,
122+
timeout=10,
123+
)
124+
response.raise_for_status()
125+
status = response.json().get("status_code")
126+
127+
if status == "FINISHED":
128+
return
129+
if status in ("ERROR", "EXPIRED"):
130+
raise RuntimeError(
131+
f"Instagram media container {container_id} failed with status {status}"
132+
)
133+
if time.monotonic() >= deadline:
134+
raise RuntimeError(
135+
f"Instagram media container {container_id} did not finish "
136+
f"processing within {CONTAINER_POLL_TIMEOUT_SECONDS}s "
137+
f"(last status: {status})"
138+
)
139+
time.sleep(CONTAINER_POLL_INTERVAL_SECONDS)
140+
103141
def _publish_media(self, container_id: str) -> str:
104142
response = requests.post(
105143
f"{GRAPH_API_BASE}/{self.account_id}/media_publish",

tests/test_instagram.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def build_poster(monkeypatch) -> PosterInstagram:
1717
def test_authenticate_keeps_access_token_out_of_query_params(monkeypatch):
1818
poster = build_poster(monkeypatch)
1919
response = Mock()
20+
response.json.return_value = {"id": ACCOUNT_ID, "username": "cutepetsboston2026_test"}
2021

2122
with patch(
2223
"social_posters.instagram.requests.get",
@@ -31,6 +32,7 @@ def test_authenticate_keeps_access_token_out_of_query_params(monkeypatch):
3132
timeout=10,
3233
)
3334
response.raise_for_status.assert_called_once_with()
35+
assert poster.username == "cutepetsboston2026_test"
3436

3537

3638
def test_create_media_container_uses_authorization_header(monkeypatch):
@@ -77,3 +79,60 @@ def test_publish_media_uses_authorization_header(monkeypatch):
7779
timeout=30,
7880
)
7981
response.raise_for_status.assert_called_once_with()
82+
83+
84+
def test_wait_for_container_ready_returns_once_finished(monkeypatch):
85+
poster = build_poster(monkeypatch)
86+
in_progress = Mock()
87+
in_progress.json.return_value = {"status_code": "IN_PROGRESS"}
88+
finished = Mock()
89+
finished.json.return_value = {"status_code": "FINISHED"}
90+
91+
with (
92+
patch(
93+
"social_posters.instagram.requests.get",
94+
side_effect=[in_progress, finished],
95+
) as request_get,
96+
patch("social_posters.instagram.time.sleep") as mock_sleep,
97+
):
98+
poster._wait_for_container_ready("container-id")
99+
100+
assert request_get.call_count == 2
101+
request_get.assert_called_with(
102+
f"{GRAPH_API_BASE}/container-id",
103+
params={"fields": "status_code"},
104+
headers={"Authorization": f"Bearer {ACCESS_TOKEN}"},
105+
timeout=10,
106+
)
107+
mock_sleep.assert_called_once()
108+
109+
110+
def test_wait_for_container_ready_raises_on_error_status(monkeypatch):
111+
poster = build_poster(monkeypatch)
112+
response = Mock()
113+
response.json.return_value = {"status_code": "ERROR"}
114+
115+
with patch("social_posters.instagram.requests.get", return_value=response):
116+
try:
117+
poster._wait_for_container_ready("container-id")
118+
assert False, "expected RuntimeError"
119+
except RuntimeError as exc:
120+
assert "ERROR" in str(exc)
121+
122+
123+
def test_publish_builds_post_url_from_authenticated_username(monkeypatch):
124+
poster = build_poster(monkeypatch)
125+
poster._authenticated = True
126+
poster.username = "cutepetsboston2026_test"
127+
post = Post(text="Meet Poppy!", image_url="https://example.com/poppy.jpg")
128+
129+
with (
130+
patch.object(poster, "_create_media_container", return_value="container-id"),
131+
patch.object(poster, "_wait_for_container_ready"),
132+
patch.object(poster, "_publish_media", return_value="media-id"),
133+
):
134+
result = poster.publish(post)
135+
136+
assert result.success is True
137+
assert result.post_id == "media-id"
138+
assert result.post_url == "https://www.instagram.com/cutepetsboston2026_test/"

tests/test_metric_collector_instagram.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,8 @@ def test_maps_media_counts_and_marks_reposts_not_applicable(self, mock_get):
2020
assert metrics.comments == 5
2121
mock_get.assert_called_once_with(
2222
f"{GRAPH_API_BASE}/media-123",
23-
params={
24-
"fields": "like_count,comments_count",
25-
"access_token": "token",
26-
},
23+
params={"fields": "like_count,comments_count"},
24+
headers={"Authorization": "Bearer token"},
2725
timeout=20,
2826
)
2927
response.raise_for_status.assert_called_once_with()
@@ -41,7 +39,8 @@ def test_returns_none_on_http_error(self, mock_get):
4139
assert metrics is None
4240

4341
@patch("metric_collectors.instagram.requests.get")
44-
def test_returns_none_without_access_token(self, mock_get):
42+
def test_returns_none_without_access_token(self, mock_get, monkeypatch):
43+
monkeypatch.delenv("INSTAGRAM_PAGE_ACCESS_TOKEN", raising=False)
4544
metrics = CollectorInstagram(access_token="").fetch_metrics("media-123")
4645

4746
assert metrics is None

0 commit comments

Comments
 (0)