getsocialapi-instagram 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GetSocialAPI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: getsocialapi-instagram
3
+ Version: 0.1.0
4
+ Summary: GetSocialAPI — client library for the Instagram API (no cookies, no accounts required)
5
+ Author: GetSocialAPI
6
+ License: MIT
7
+ Project-URL: Homepage, https://getsocialapi.com
8
+ Project-URL: Documentation, https://getsocialapi.com/docs/
9
+ Keywords: instagram,api,scraping,sdk,getsocialapi
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.25
19
+ Dynamic: license-file
20
+
21
+ # Instagram Scraper API
22
+
23
+ [![tests](https://github.com/GetSocialAPI/Instagram-Scraper-API/actions/workflows/tests.yml/badge.svg)](https://github.com/GetSocialAPI/Instagram-Scraper-API/actions/workflows/tests.yml)
24
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
25
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org)
26
+
27
+ Python client for the **[GetSocialAPI](https://getsocialapi.com/)** Instagram Scraper API.
28
+
29
+ - **No cookies required** — you never provide Instagram session cookies
30
+ - **No accounts needed** — zero ban risk for your own profiles
31
+ - Raw Instagram JSON responses, REST endpoints — no transformations
32
+ - Automatic retries with `Retry-After`, typed exceptions
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install getsocialapi-instagram
38
+ ```
39
+
40
+ ## Migration from `getsocialapi` (git installs)
41
+
42
+ Before the PyPI release this SDK was installed from git as `getsocialapi`.
43
+ Old imports keep working via a deprecated shim, but please migrate:
44
+
45
+ ```bash
46
+ pip uninstall getsocialapi
47
+ pip install getsocialapi-instagram
48
+ ```
49
+
50
+ ```python
51
+ # before
52
+ from getsocialapi import Client
53
+ # after
54
+ from getsocialapi_instagram import Client
55
+ ```
56
+
57
+ ## Quickstart
58
+
59
+ ```python
60
+ from getsocialapi_instagram import Client
61
+
62
+ # Get your API key at https://getsocialapi.com/dashboard/
63
+ client = Client(api_key="gsa_live_...") # or set GETSOCIALAPI_API_KEY
64
+
65
+ # Profile by numeric id (or user_by_username for usernames)
66
+ user = client.instagram.user_info(25025320)
67
+ print(user["user"]["follower_count"])
68
+
69
+ # Posts (grid)
70
+ medias = client.instagram.user_medias(25025320, count=12)
71
+
72
+ # Followers / following — paginated on unverified accounts: pass `next_max_id`
73
+ # back as `max_id`. Verified accounts (blue checkmark) expose ~50 followers max.
74
+ page = client.instagram.followers(43586162288, count=25)
75
+ page = client.instagram.followers(43586162288, max_id=page["next_max_id"])
76
+
77
+ # All comments of a post — cursor is next_min_id -> min_id (sort_order="recent" by default)
78
+ comments, cursor = [], None
79
+ while True:
80
+ page = client.instagram.media_comments(4012386322294878415, count=50, min_id=cursor)
81
+ comments += page["comments"]
82
+ cursor = page.get("next_min_id")
83
+ if not cursor:
84
+ break
85
+ ```
86
+
87
+ Base URL: `https://api.getsocialapi.com/v1` — authenticate with the API key
88
+ from your [dashboard](https://getsocialapi.com/dashboard/).
89
+
90
+ ## Client
91
+
92
+ ```python
93
+ Client(api_key=None, base_url="https://api.getsocialapi.com/v1", timeout=30, max_retries=3)
94
+ ```
95
+
96
+ | Parameter | Default | Description |
97
+ | --- | --- | --- |
98
+ | `api_key` | env `GETSOCIALAPI_API_KEY` | your API key |
99
+ | `base_url` | API endpoint | override only for testing |
100
+ | `timeout` | `30` | per-request timeout, seconds |
101
+ | `max_retries` | `3` | retries on 429/503/5xx and network errors |
102
+
103
+ ## Methods
104
+
105
+ | Method | Route | Description |
106
+ | --- | --- | --- |
107
+ | `profile(user_id)` | `/profile/<id>` | profile by numeric id |
108
+ | `user_info(user_id)` | `/ig/user/info/<id>` | profile by numeric id |
109
+ | `user_by_username(username)` | `/ig/user/username_info/<username>` | profile by username |
110
+ | `user_about(user_id)` | `/user/about/<id>` | About this account: country, joined, former usernames, shared-followers count, verified |
111
+ | `search_username(query, count=10)` | `/search/username/<q>` | search users, resolve username → pk |
112
+ | `similar_users(user_id)` | `/ig/user/similar/<id>` | suggested accounts |
113
+ | `user_medias(user_id, count=12, max_id=None)` | `/ig/user/media/<id>` | profile grid posts |
114
+ | `user_tags(user_id, count=12, max_id=None)` | `/ig/user/tags/<id>` | posts with the user tagged |
115
+ | `user_stories(user_id)` | `/ig/user/stories/<id>` | active stories |
116
+ | `user_highlights(user_id)` | `/ig/user/highlights/<id>` | highlights tray |
117
+ | `highlight_stories(highlight_id)` | `/ig/highlight/stories/<id>` | media of a highlight |
118
+ | `followers(user_id, count=50, max_id=None, query=None)` | `/followers/<id>` | followers list |
119
+ | `following(user_id, count=25, max_id=None, query=None)` | `/following/<id>` | following list |
120
+ | `media_info(media)` | `/ig/media/info/<pk\|shortcode>` | full media card; accepts numeric pk or shortcode |
121
+ | `media_likers(media)` | `/ig/media/likers/<pk\|shortcode>` | users who liked (up to ~100, no pagination) |
122
+ | `media_comments(media, count=None, sort_order="recent", min_id=None)` | `/ig/media/comments/<pk\|shortcode>` | media comments; cursor pagination `next_min_id` → `min_id` |
123
+ | `search_clips(keyword)` | `/ig/media/search_clips/<kw>` | search reels by keyword |
124
+ | `hashtag_info(tag)` | `/ig/hashtag/info/<tag>` | hashtag card |
125
+ | `hashtag_search(query)` | `/ig/hashtag/search/<q>` | search hashtags |
126
+ | `hashtag_posts(tag, max_id=None)` | `/v2/hashtag/posts/<tag>` | posts by hashtag |
127
+ | `location_info(pk)` | `/ig/location/info/<pk>` | location card |
128
+ | `location_media(pk, max_id=None)` | `/ig/location/media/<pk>` | media by location |
129
+
130
+ Every method returns the raw JSON of the API route as received — nothing is
131
+ unwrapped, filtered or renamed. Where the API supports pagination (`followers`*, `following`, `user_medias`,
132
+ `user_tags`, `hashtag_posts`, `location_media`), pass the cursor from the
133
+ previous response (`next_max_id`) as `max_id`.
134
+ `media_comments` paginates with a different cursor: always request with
135
+ `sort_order="recent"` (the default) and pass `next_min_id` back as `min_id`
136
+ until it comes back null — pages are chronological and disjoint. Instagram
137
+ never returns `next_max_id` for comments, so `max_id` must not be used there.
138
+ *`followers` works on unverified accounts (nearly the full list, plus the
139
+ `query` filter); verified accounts (blue checkmark) expose no more than ~50
140
+ followers — one page, no cursor, `query` ignored. For lists without cursors
141
+ (`search_clips`, `hashtag_search`) each call simply returns the service page.
142
+
143
+ Full request/response reference with live examples:
144
+ [docs.getsocialapi → API Reference](https://getsocialapi.com/docs/api/instagram/user/get_info_by_id/)
145
+ (see the Documentation section on [getsocialapi.com](https://getsocialapi.com/)).
146
+
147
+ ## Errors
148
+
149
+ | Exception | HTTP | Notes |
150
+ | --- | --- | --- |
151
+ | `GetSocialAPIError` | — | base class; also network errors |
152
+ | `APIKeyError` | 401 | invalid or missing key |
153
+ | `BadRequestError` | 400 | `.field` points to the bad parameter |
154
+ | `NotFoundError` | 404 | object does not exist — never retried |
155
+ | `RateLimitError` | 429 | `.retry_after` seconds |
156
+ | `ServiceUnavailableError` | 503 | no live accounts, `.retry_after` |
157
+ | `InstagramError` | any | upstream Instagram error, `.status` |
158
+
159
+ ```python
160
+ from getsocialapi_instagram import NotFoundError, RateLimitError
161
+ import time
162
+
163
+ try:
164
+ client.instagram.user_info(25025320)
165
+ except NotFoundError:
166
+ ... # object does not exist
167
+ except RateLimitError as e:
168
+ time.sleep(e.retry_after)
169
+ ```
170
+
171
+ Only successful responses (HTTP `200` and `404`) count against your quota.
172
+ Rate limits and plans: [pricing](https://getsocialapi.com/#pricing).
173
+
174
+ ## Support
175
+
176
+ - Telegram: [@getsocialapi](https://t.me/getsocialapi)
177
+ - Email: [hello@getsocialapi.com](mailto:hello@getsocialapi.com)
178
+ - Website: [getsocialapi.com](https://getsocialapi.com/)
179
+ - Documentation: [getsocialapi.com/docs](https://getsocialapi.com/docs/)
180
+ - Status: [getsocialapi.com/status](https://getsocialapi.com/status/)
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,164 @@
1
+ # Instagram Scraper API
2
+
3
+ [![tests](https://github.com/GetSocialAPI/Instagram-Scraper-API/actions/workflows/tests.yml/badge.svg)](https://github.com/GetSocialAPI/Instagram-Scraper-API/actions/workflows/tests.yml)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org)
6
+
7
+ Python client for the **[GetSocialAPI](https://getsocialapi.com/)** Instagram Scraper API.
8
+
9
+ - **No cookies required** — you never provide Instagram session cookies
10
+ - **No accounts needed** — zero ban risk for your own profiles
11
+ - Raw Instagram JSON responses, REST endpoints — no transformations
12
+ - Automatic retries with `Retry-After`, typed exceptions
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install getsocialapi-instagram
18
+ ```
19
+
20
+ ## Migration from `getsocialapi` (git installs)
21
+
22
+ Before the PyPI release this SDK was installed from git as `getsocialapi`.
23
+ Old imports keep working via a deprecated shim, but please migrate:
24
+
25
+ ```bash
26
+ pip uninstall getsocialapi
27
+ pip install getsocialapi-instagram
28
+ ```
29
+
30
+ ```python
31
+ # before
32
+ from getsocialapi import Client
33
+ # after
34
+ from getsocialapi_instagram import Client
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ ```python
40
+ from getsocialapi_instagram import Client
41
+
42
+ # Get your API key at https://getsocialapi.com/dashboard/
43
+ client = Client(api_key="gsa_live_...") # or set GETSOCIALAPI_API_KEY
44
+
45
+ # Profile by numeric id (or user_by_username for usernames)
46
+ user = client.instagram.user_info(25025320)
47
+ print(user["user"]["follower_count"])
48
+
49
+ # Posts (grid)
50
+ medias = client.instagram.user_medias(25025320, count=12)
51
+
52
+ # Followers / following — paginated on unverified accounts: pass `next_max_id`
53
+ # back as `max_id`. Verified accounts (blue checkmark) expose ~50 followers max.
54
+ page = client.instagram.followers(43586162288, count=25)
55
+ page = client.instagram.followers(43586162288, max_id=page["next_max_id"])
56
+
57
+ # All comments of a post — cursor is next_min_id -> min_id (sort_order="recent" by default)
58
+ comments, cursor = [], None
59
+ while True:
60
+ page = client.instagram.media_comments(4012386322294878415, count=50, min_id=cursor)
61
+ comments += page["comments"]
62
+ cursor = page.get("next_min_id")
63
+ if not cursor:
64
+ break
65
+ ```
66
+
67
+ Base URL: `https://api.getsocialapi.com/v1` — authenticate with the API key
68
+ from your [dashboard](https://getsocialapi.com/dashboard/).
69
+
70
+ ## Client
71
+
72
+ ```python
73
+ Client(api_key=None, base_url="https://api.getsocialapi.com/v1", timeout=30, max_retries=3)
74
+ ```
75
+
76
+ | Parameter | Default | Description |
77
+ | --- | --- | --- |
78
+ | `api_key` | env `GETSOCIALAPI_API_KEY` | your API key |
79
+ | `base_url` | API endpoint | override only for testing |
80
+ | `timeout` | `30` | per-request timeout, seconds |
81
+ | `max_retries` | `3` | retries on 429/503/5xx and network errors |
82
+
83
+ ## Methods
84
+
85
+ | Method | Route | Description |
86
+ | --- | --- | --- |
87
+ | `profile(user_id)` | `/profile/<id>` | profile by numeric id |
88
+ | `user_info(user_id)` | `/ig/user/info/<id>` | profile by numeric id |
89
+ | `user_by_username(username)` | `/ig/user/username_info/<username>` | profile by username |
90
+ | `user_about(user_id)` | `/user/about/<id>` | About this account: country, joined, former usernames, shared-followers count, verified |
91
+ | `search_username(query, count=10)` | `/search/username/<q>` | search users, resolve username → pk |
92
+ | `similar_users(user_id)` | `/ig/user/similar/<id>` | suggested accounts |
93
+ | `user_medias(user_id, count=12, max_id=None)` | `/ig/user/media/<id>` | profile grid posts |
94
+ | `user_tags(user_id, count=12, max_id=None)` | `/ig/user/tags/<id>` | posts with the user tagged |
95
+ | `user_stories(user_id)` | `/ig/user/stories/<id>` | active stories |
96
+ | `user_highlights(user_id)` | `/ig/user/highlights/<id>` | highlights tray |
97
+ | `highlight_stories(highlight_id)` | `/ig/highlight/stories/<id>` | media of a highlight |
98
+ | `followers(user_id, count=50, max_id=None, query=None)` | `/followers/<id>` | followers list |
99
+ | `following(user_id, count=25, max_id=None, query=None)` | `/following/<id>` | following list |
100
+ | `media_info(media)` | `/ig/media/info/<pk\|shortcode>` | full media card; accepts numeric pk or shortcode |
101
+ | `media_likers(media)` | `/ig/media/likers/<pk\|shortcode>` | users who liked (up to ~100, no pagination) |
102
+ | `media_comments(media, count=None, sort_order="recent", min_id=None)` | `/ig/media/comments/<pk\|shortcode>` | media comments; cursor pagination `next_min_id` → `min_id` |
103
+ | `search_clips(keyword)` | `/ig/media/search_clips/<kw>` | search reels by keyword |
104
+ | `hashtag_info(tag)` | `/ig/hashtag/info/<tag>` | hashtag card |
105
+ | `hashtag_search(query)` | `/ig/hashtag/search/<q>` | search hashtags |
106
+ | `hashtag_posts(tag, max_id=None)` | `/v2/hashtag/posts/<tag>` | posts by hashtag |
107
+ | `location_info(pk)` | `/ig/location/info/<pk>` | location card |
108
+ | `location_media(pk, max_id=None)` | `/ig/location/media/<pk>` | media by location |
109
+
110
+ Every method returns the raw JSON of the API route as received — nothing is
111
+ unwrapped, filtered or renamed. Where the API supports pagination (`followers`*, `following`, `user_medias`,
112
+ `user_tags`, `hashtag_posts`, `location_media`), pass the cursor from the
113
+ previous response (`next_max_id`) as `max_id`.
114
+ `media_comments` paginates with a different cursor: always request with
115
+ `sort_order="recent"` (the default) and pass `next_min_id` back as `min_id`
116
+ until it comes back null — pages are chronological and disjoint. Instagram
117
+ never returns `next_max_id` for comments, so `max_id` must not be used there.
118
+ *`followers` works on unverified accounts (nearly the full list, plus the
119
+ `query` filter); verified accounts (blue checkmark) expose no more than ~50
120
+ followers — one page, no cursor, `query` ignored. For lists without cursors
121
+ (`search_clips`, `hashtag_search`) each call simply returns the service page.
122
+
123
+ Full request/response reference with live examples:
124
+ [docs.getsocialapi → API Reference](https://getsocialapi.com/docs/api/instagram/user/get_info_by_id/)
125
+ (see the Documentation section on [getsocialapi.com](https://getsocialapi.com/)).
126
+
127
+ ## Errors
128
+
129
+ | Exception | HTTP | Notes |
130
+ | --- | --- | --- |
131
+ | `GetSocialAPIError` | — | base class; also network errors |
132
+ | `APIKeyError` | 401 | invalid or missing key |
133
+ | `BadRequestError` | 400 | `.field` points to the bad parameter |
134
+ | `NotFoundError` | 404 | object does not exist — never retried |
135
+ | `RateLimitError` | 429 | `.retry_after` seconds |
136
+ | `ServiceUnavailableError` | 503 | no live accounts, `.retry_after` |
137
+ | `InstagramError` | any | upstream Instagram error, `.status` |
138
+
139
+ ```python
140
+ from getsocialapi_instagram import NotFoundError, RateLimitError
141
+ import time
142
+
143
+ try:
144
+ client.instagram.user_info(25025320)
145
+ except NotFoundError:
146
+ ... # object does not exist
147
+ except RateLimitError as e:
148
+ time.sleep(e.retry_after)
149
+ ```
150
+
151
+ Only successful responses (HTTP `200` and `404`) count against your quota.
152
+ Rate limits and plans: [pricing](https://getsocialapi.com/#pricing).
153
+
154
+ ## Support
155
+
156
+ - Telegram: [@getsocialapi](https://t.me/getsocialapi)
157
+ - Email: [hello@getsocialapi.com](mailto:hello@getsocialapi.com)
158
+ - Website: [getsocialapi.com](https://getsocialapi.com/)
159
+ - Documentation: [getsocialapi.com/docs](https://getsocialapi.com/docs/)
160
+ - Status: [getsocialapi.com/status](https://getsocialapi.com/status/)
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,25 @@
1
+ from .client import Client
2
+ from .exceptions import (
3
+ APIKeyError,
4
+ BadRequestError,
5
+ GetSocialAPIError,
6
+ InstagramError,
7
+ NotFoundError,
8
+ RateLimitError,
9
+ ServiceUnavailableError,
10
+ )
11
+ from .instagram import InstagramAPI
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ __all__ = [
16
+ "Client",
17
+ "InstagramAPI",
18
+ "GetSocialAPIError",
19
+ "BadRequestError",
20
+ "APIKeyError",
21
+ "NotFoundError",
22
+ "RateLimitError",
23
+ "ServiceUnavailableError",
24
+ "InstagramError",
25
+ ]
@@ -0,0 +1,31 @@
1
+ import os
2
+
3
+ from .http import DEFAULT_BASE_URL, Transport
4
+ from .instagram import InstagramAPI
5
+
6
+
7
+ class Client:
8
+ """GetSocialAPI client.
9
+
10
+ Usage:
11
+ from getsocialapi_instagram import Client
12
+ client = Client(api_key="gsa_live_...")
13
+ client.instagram.user_by_username("durov")
14
+ """
15
+
16
+ def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, timeout=30,
17
+ max_retries=3, session=None):
18
+ api_key = api_key or os.environ.get("GETSOCIALAPI_API_KEY")
19
+ if not api_key:
20
+ raise ValueError(
21
+ "api_key is required (pass api_key=... or set GETSOCIALAPI_API_KEY)"
22
+ )
23
+ self._transport = Transport(
24
+ base_url=base_url, api_key=api_key, timeout=timeout,
25
+ max_retries=max_retries, session=session,
26
+ )
27
+ self.instagram = InstagramAPI(self._transport)
28
+
29
+ def health(self):
30
+ """Upstream liveness probe: {"ok": true, "state": "ready"}."""
31
+ return self._transport.request("GET", "/health", auth=False)
@@ -0,0 +1,80 @@
1
+ class GetSocialAPIError(Exception):
2
+ """Base error for all GetSocialAPI SDK failures."""
3
+
4
+ def __init__(self, message, response=None, body=None):
5
+ super().__init__(message)
6
+ self.response = response
7
+ self.body = body if body is not None else {}
8
+
9
+
10
+ def _reason(body, fallback):
11
+ """Extract a human-readable reason: {"error": "..."} | {"error": {"message"}} | {"message": ..."}."""
12
+ err = body.get("error")
13
+ if isinstance(err, dict):
14
+ err = err.get("message")
15
+ if isinstance(err, str) and err:
16
+ return err
17
+ msg = body.get("message")
18
+ if isinstance(msg, str) and msg:
19
+ return msg
20
+ return fallback
21
+
22
+
23
+ class BadRequestError(GetSocialAPIError):
24
+ """400 — invalid parameters (body: {"error", "field"})."""
25
+
26
+ def __init__(self, response=None, body=None):
27
+ body = body or {}
28
+ field = body.get("field")
29
+ msg = _reason(body, "Bad request")
30
+ if field:
31
+ msg = f"{msg} (field: {field})"
32
+ super().__init__(msg, response, body)
33
+ self.field = field
34
+
35
+
36
+ class UnauthorizedError(GetSocialAPIError):
37
+ """401 — invalid or missing API key."""
38
+
39
+ def __init__(self, response=None, body=None):
40
+ super().__init__("Invalid API key", response, body)
41
+
42
+
43
+ class NotFoundError(GetSocialAPIError):
44
+ """404 — target object does not exist. Do not retry."""
45
+
46
+ def __init__(self, response=None, body=None):
47
+ body = body or {}
48
+ msg = _reason(body, "Not found")
49
+ super().__init__(msg, response, body)
50
+
51
+
52
+ class RateLimitError(GetSocialAPIError):
53
+ """429 — key rate limit exceeded. Honors Retry-After."""
54
+
55
+ def __init__(self, response=None, body=None, retry_after=1):
56
+ super().__init__(f"Rate limit exceeded, retry after {retry_after}s", response, body)
57
+ self.retry_after = retry_after
58
+
59
+
60
+ class ServiceUnavailableError(GetSocialAPIError):
61
+ """503 — no live accounts or the service is stopped."""
62
+
63
+ def __init__(self, response=None, body=None, retry_after=None):
64
+ msg = "Service unavailable"
65
+ if retry_after:
66
+ msg += f", retry after {retry_after}s"
67
+ super().__init__(msg, response, body)
68
+ self.retry_after = retry_after
69
+
70
+
71
+ class InstagramError(GetSocialAPIError):
72
+ """Non-404 error returned by the Instagram private API (raw mode)."""
73
+
74
+ def __init__(self, status, message, response=None, body=None):
75
+ super().__init__(f"Instagram API error {status}: {message}", response, body)
76
+ self.status = status
77
+
78
+
79
+ # Convenience alias
80
+ APIKeyError = UnauthorizedError
@@ -0,0 +1,82 @@
1
+ import time
2
+
3
+ import requests
4
+
5
+ from .exceptions import (
6
+ BadRequestError,
7
+ GetSocialAPIError,
8
+ InstagramError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ServiceUnavailableError,
12
+ UnauthorizedError,
13
+ )
14
+
15
+ DEFAULT_BASE_URL = "https://api.getsocialapi.com/v1"
16
+
17
+ RETRYABLE_STATUS = {429, 500, 502, 503, 504}
18
+
19
+
20
+ class Transport:
21
+ """HTTP transport: auth, JSON parsing, retries with Retry-After."""
22
+
23
+ def __init__(self, base_url=DEFAULT_BASE_URL, api_key=None, timeout=30,
24
+ max_retries=3, session=None):
25
+ self.base_url = base_url.rstrip("/")
26
+ self.api_key = api_key
27
+ self.timeout = timeout
28
+ self.max_retries = max_retries
29
+ self.session = session or requests.Session()
30
+
31
+ def request(self, method, path, params=None, auth=True):
32
+ url = f"{self.base_url}{path}"
33
+ headers = {"X-API-Key": self.api_key} if auth else {}
34
+ attempts = self.max_retries + 1
35
+ last_exc = None
36
+ for attempt in range(attempts):
37
+ try:
38
+ resp = self.session.request(
39
+ method, url, params=params, headers=headers, timeout=self.timeout,
40
+ )
41
+ except (requests.ConnectionError, requests.Timeout) as e:
42
+ last_exc = e
43
+ if attempt < attempts - 1:
44
+ time.sleep(min(2 ** attempt, 10))
45
+ continue
46
+ raise GetSocialAPIError(f"Network error: {e}") from e
47
+
48
+ if resp.status_code in RETRYABLE_STATUS and attempt < attempts - 1:
49
+ time.sleep(self._retry_after(resp))
50
+ continue
51
+ return self._parse(resp)
52
+ raise GetSocialAPIError("Request failed after retries") # pragma: no cover
53
+
54
+ @staticmethod
55
+ def _retry_after(resp):
56
+ try:
57
+ return min(max(int(float(resp.headers.get("Retry-After", 1))), 0), 30)
58
+ except (TypeError, ValueError):
59
+ return 1
60
+
61
+ def _parse(self, resp):
62
+ try:
63
+ body = resp.json()
64
+ except ValueError:
65
+ body = {"raw": resp.text}
66
+
67
+ if resp.status_code == 401:
68
+ raise UnauthorizedError(resp, body)
69
+ if resp.status_code == 400:
70
+ raise BadRequestError(resp, body)
71
+ if resp.status_code == 404:
72
+ raise NotFoundError(resp, body)
73
+ if resp.status_code == 429:
74
+ raise RateLimitError(resp, body, retry_after=self._retry_after(resp))
75
+ if resp.status_code == 503:
76
+ raise ServiceUnavailableError(resp, body, retry_after=self._retry_after(resp))
77
+ if resp.status_code >= 400:
78
+ # Instagram errors in raw mode: {"message": "...", "status": "fail"}
79
+ if isinstance(body, dict) and body.get("status") == "fail":
80
+ raise InstagramError(resp.status_code, body.get("message", "unknown"), resp, body)
81
+ raise GetSocialAPIError(f"HTTP {resp.status_code}: {body}", resp, body)
82
+ return body
@@ -0,0 +1,257 @@
1
+ from urllib.parse import quote
2
+ import re
3
+
4
+
5
+ def _quote(value):
6
+ return quote(str(value), safe="")
7
+
8
+
9
+ def _media_arg(value, name="media id"):
10
+ """Numeric media pk or shortcode (e.g. DehVsgBi3sH)."""
11
+ s = str(value)
12
+ if s.isdigit():
13
+ return _digits(s, name)
14
+ if re.fullmatch(r"[A-Za-z0-9_-]{5,64}", s):
15
+ return s
16
+ raise ValueError(f"{name} must be a numeric pk or a shortcode, "
17
+ f"got: {value!r}")
18
+
19
+
20
+ def _digits(value, name, max_len=20):
21
+ s = str(value)
22
+ if not s.isdigit() or not 1 <= len(s) <= max_len:
23
+ raise ValueError(f"{name} must be 1-{max_len} digits, got: {value!r}")
24
+ return s
25
+
26
+
27
+ def _length(value, name, lo, hi):
28
+ s = str(value)
29
+ if not lo <= len(s) <= hi:
30
+ raise ValueError(f"{name} must be {lo}-{hi} characters, got: {value!r}")
31
+ return s
32
+
33
+
34
+ def _tag(value):
35
+ return _quote(_length(str(value).lstrip("#"), "tag", 1, 64))
36
+
37
+
38
+ def _count(value, lo=1, hi=50, default=None):
39
+ if value is None:
40
+ return default
41
+ if isinstance(value, bool) or not isinstance(value, int) or not lo <= value <= hi:
42
+ raise ValueError(f"count must be an integer {lo}-{hi}, got: {value!r}")
43
+ return value
44
+
45
+
46
+ class InstagramAPI:
47
+ """Instagram endpoints of GetSocialAPI.
48
+
49
+ Methods map 1:1 to API routes and return the raw JSON responses as received.
50
+ """
51
+
52
+ def __init__(self, transport):
53
+ self._t = transport
54
+
55
+ # ---------- Profile ----------
56
+
57
+ def user_info(self, user_id):
58
+ """GET /ig/user/info/{user_id} — profile by numeric id."""
59
+ return self._t.request("GET", f"/ig/user/info/{_digits(user_id, 'user_id')}")
60
+
61
+ def user_by_username(self, username):
62
+ """GET /ig/user/username_info/{username} — profile by username."""
63
+ return self._t.request("GET", f"/ig/user/username_info/{_length(username, 'username', 1, 64)}")
64
+
65
+ def profile(self, user_id):
66
+ """GET /profile/{user_id} — profile by numeric id."""
67
+ return self._t.request("GET", f"/profile/{_digits(user_id, 'user_id')}")
68
+
69
+ def user_about(self, user_id):
70
+ """GET /user/about/{user_id} — About this account.
71
+
72
+ The "About this account" screen: country, registration month,
73
+ former usernames count, accounts-with-shared-followers count,
74
+ verification. Sparse accounts return null fields — check
75
+ `has_about_data` / `unavailable` before using them.
76
+ """
77
+ return self._t.request("GET", f"/user/about/{_digits(user_id, 'user_id')}")
78
+
79
+ def search_username(self, query, count=10):
80
+ """GET /search/username/{query} — search users.
81
+
82
+ `count` is forwarded to the service, but the response may include
83
+ one extra exact-match entry beyond it.
84
+ """
85
+ return self._t.request("GET", f"/search/username/{_quote(query)}",
86
+ params={"count": _count(count, default=10)})
87
+
88
+ def similar_users(self, user_id):
89
+ """GET /ig/user/similar/{user_id} — suggested accounts."""
90
+ return self._t.request("GET", f"/ig/user/similar/{_digits(user_id, 'user_id')}")
91
+
92
+ # ---------- User content ----------
93
+
94
+ def user_medias(self, user_id, count=12, max_id=None):
95
+ """GET /ig/user/media/{user_id} — profile grid posts."""
96
+ return self._t.request("GET", f"/ig/user/media/{_digits(user_id, 'user_id')}",
97
+ params=_page(count, max_id, default=12))
98
+
99
+ def user_tags(self, user_id, count=12, max_id=None):
100
+ """GET /ig/user/tags/{user_id} — posts where the user is tagged."""
101
+ return self._t.request("GET", f"/ig/user/tags/{_digits(user_id, 'user_id')}",
102
+ params=_page(count, max_id, default=12))
103
+
104
+ def user_stories(self, user_id):
105
+ """GET /ig/user/stories/{user_id} — active stories."""
106
+ return self._t.request("GET", f"/ig/user/stories/{_digits(user_id, 'user_id')}")
107
+
108
+ def user_highlights(self, user_id):
109
+ """GET /ig/user/highlights/{user_id} — highlights tray."""
110
+ return self._t.request("GET", f"/ig/user/highlights/{_digits(user_id, 'user_id')}")
111
+
112
+ def highlight_stories(self, highlight_id):
113
+ """GET /ig/highlight/stories/{highlight_id} — media of a highlight.
114
+
115
+ Accepts the tray id as is — the leading `highlight:` prefix
116
+ (as returned by `user_highlights`) is trimmed automatically.
117
+ """
118
+ hid = str(highlight_id)
119
+ if hid.startswith("highlight:"):
120
+ hid = hid[len("highlight:"):]
121
+ return self._t.request("GET", f"/ig/highlight/stories/{_quote(_length(hid, 'highlight_id', 1, 64))}")
122
+
123
+ # ---------- Followers / following ----------
124
+
125
+ def followers(self, user_id, count=50, max_id=None, query=None):
126
+ """GET /followers/{user_id} — followers list.
127
+
128
+ On unverified accounts `count`, the `next_max_id` cursor and the
129
+ `query` filter work — nearly the full list is accessible and
130
+ searchable. On verified accounts (blue checkmark) Instagram exposes
131
+ no more than ~50 followers: one page, no cursor, `query` ignored.
132
+ """
133
+ params = _page(count, max_id, default=50)
134
+ if query is not None:
135
+ params["query"] = query
136
+ return self._t.request("GET", f"/followers/{_digits(user_id, 'user_id')}",
137
+ params=params)
138
+
139
+ def following(self, user_id, count=25, max_id=None, query=None):
140
+ """GET /following/{user_id} — following list.
141
+
142
+ Page size: the service returns the default 25 accounts per page and
143
+ at most 25 (values above 50 are rejected). The response carries
144
+ `next_max_id` — pass it as `max_id` for the next page. `query`
145
+ filters the list by name.
146
+ """
147
+ params = _page(count, max_id, default=25, hi=50)
148
+ if query is not None:
149
+ params["query"] = query
150
+ return self._t.request("GET", f"/following/{_digits(user_id, 'user_id')}",
151
+ params=params)
152
+
153
+ # ---------- Media ----------
154
+
155
+ def media_info(self, media):
156
+ """GET /ig/media/info/{pk|shortcode} — full media card.
157
+
158
+ Accepts a numeric pk or a post shortcode (e.g. "Deu3BoCBbDP"):
159
+ items[0] carries pk, code, media_type (1=photo, 2=video,
160
+ 8=carousel), caption, like/comment/view counts, taken_at, owner.
161
+ """
162
+ return self._t.request("GET", f"/ig/media/info/{_media_arg(media)}")
163
+
164
+ def media_likers(self, media):
165
+ """GET /ig/media/likers/{pk|shortcode} — users who liked the media.
166
+
167
+ One shot of up to ~100 users; the route has no pagination.
168
+ """
169
+ return self._t.request("GET", f"/ig/media/likers/{_media_arg(media)}")
170
+
171
+ def media_comments(self, media, count=None, sort_order="recent", min_id=None):
172
+ """GET /ig/media/comments/{pk|shortcode} — media comments.
173
+
174
+ Accepts a numeric pk or a post shortcode (e.g. "Deu3BoCBbDP").
175
+
176
+ Full pagination works only with `sort_order="recent"` (default):
177
+ pages are strictly chronological (newer → older) and disjoint.
178
+ The cursor is `next_min_id` from the previous response — pass it
179
+ back as `min_id` verbatim (it is a JSON string with special
180
+ characters; the transport URL-encodes it). Collect until
181
+ `next_min_id` is null/empty or `comments` is an empty list.
182
+
183
+ Instagram never returns `next_max_id` for this route — `max_id`
184
+ pagination must not be used. `count` (1-50) is accepted by the
185
+ validator but the real page size is ~12-20 comments. Without
186
+ `sort_order` (ranked) the service returns a ~15 comment preview
187
+ whose pages overlap between pool accounts.
188
+ """
189
+ params = {}
190
+ if count is not None:
191
+ params["count"] = _count(count)
192
+ if sort_order is not None:
193
+ if sort_order not in ("recent", "ranked"):
194
+ raise ValueError('sort_order must be "recent" or "ranked", '
195
+ f"got: {sort_order!r}")
196
+ params["sort_order"] = sort_order
197
+ if min_id is not None:
198
+ params["min_id"] = min_id
199
+ return self._t.request("GET", f"/ig/media/comments/{_media_arg(media)}",
200
+ params=params)
201
+
202
+ def search_clips(self, keyword):
203
+ """GET /ig/media/search_clips/{keyword} — search reels by keyword.
204
+
205
+ The service returns its native page (~12 clips); a page-size
206
+ parameter is not supported by the API for this route.
207
+ """
208
+ kw = _length(keyword, "keyword", 1, 100)
209
+ return self._t.request("GET", f"/ig/media/search_clips/{_quote(kw)}")
210
+
211
+ # ---------- Hashtags ----------
212
+
213
+ def hashtag_info(self, tag):
214
+ """GET /ig/hashtag/info/{tag} — hashtag card. A leading # is trimmed."""
215
+ return self._t.request("GET", f"/ig/hashtag/info/{_tag(tag)}")
216
+
217
+ def hashtag_search(self, query):
218
+ """GET /ig/hashtag/search/{query} — search hashtags.
219
+
220
+ Returns the full first page of tag matches; a page-size
221
+ parameter is not supported by the API for this route.
222
+ """
223
+ return self._t.request("GET", f"/ig/hashtag/search/{_quote(query)}")
224
+
225
+ def hashtag_posts(self, tag, max_id=None):
226
+ """GET /v2/hashtag/posts/{tag} — posts by hashtag. A leading # is trimmed.
227
+
228
+ The service returns its native page (~15-18 posts) plus `next_max_id`
229
+ for pagination via `max_id`; a page-size parameter is not supported.
230
+ """
231
+ params = {"max_id": max_id} if max_id is not None else None
232
+ return self._t.request("GET", f"/v2/hashtag/posts/{_tag(tag)}",
233
+ params=params)
234
+
235
+ # ---------- Locations ----------
236
+
237
+ def location_info(self, pk):
238
+ """GET /ig/location/info/{pk} — location card."""
239
+ return self._t.request("GET", f"/ig/location/info/{_digits(pk, 'pk')}")
240
+
241
+ def location_media(self, pk, max_id=None):
242
+ """GET /ig/location/media/{pk} — media by location.
243
+
244
+ The service returns its native page; pass `next_max_id` from a
245
+ previous response as `max_id` to request the next page. The
246
+ response also carries `next_page` — a page counter, not a cursor.
247
+ """
248
+ params = {"max_id": max_id} if max_id is not None else None
249
+ return self._t.request("GET", f"/ig/location/media/{_digits(pk, 'pk')}",
250
+ params=params)
251
+
252
+
253
+ def _page(count, max_id, default=12, hi=50):
254
+ params = {"count": _count(count, default=default, hi=hi)}
255
+ if max_id is not None:
256
+ params["max_id"] = max_id
257
+ return params
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: getsocialapi-instagram
3
+ Version: 0.1.0
4
+ Summary: GetSocialAPI — client library for the Instagram API (no cookies, no accounts required)
5
+ Author: GetSocialAPI
6
+ License: MIT
7
+ Project-URL: Homepage, https://getsocialapi.com
8
+ Project-URL: Documentation, https://getsocialapi.com/docs/
9
+ Keywords: instagram,api,scraping,sdk,getsocialapi
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.25
19
+ Dynamic: license-file
20
+
21
+ # Instagram Scraper API
22
+
23
+ [![tests](https://github.com/GetSocialAPI/Instagram-Scraper-API/actions/workflows/tests.yml/badge.svg)](https://github.com/GetSocialAPI/Instagram-Scraper-API/actions/workflows/tests.yml)
24
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
25
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org)
26
+
27
+ Python client for the **[GetSocialAPI](https://getsocialapi.com/)** Instagram Scraper API.
28
+
29
+ - **No cookies required** — you never provide Instagram session cookies
30
+ - **No accounts needed** — zero ban risk for your own profiles
31
+ - Raw Instagram JSON responses, REST endpoints — no transformations
32
+ - Automatic retries with `Retry-After`, typed exceptions
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install getsocialapi-instagram
38
+ ```
39
+
40
+ ## Migration from `getsocialapi` (git installs)
41
+
42
+ Before the PyPI release this SDK was installed from git as `getsocialapi`.
43
+ Old imports keep working via a deprecated shim, but please migrate:
44
+
45
+ ```bash
46
+ pip uninstall getsocialapi
47
+ pip install getsocialapi-instagram
48
+ ```
49
+
50
+ ```python
51
+ # before
52
+ from getsocialapi import Client
53
+ # after
54
+ from getsocialapi_instagram import Client
55
+ ```
56
+
57
+ ## Quickstart
58
+
59
+ ```python
60
+ from getsocialapi_instagram import Client
61
+
62
+ # Get your API key at https://getsocialapi.com/dashboard/
63
+ client = Client(api_key="gsa_live_...") # or set GETSOCIALAPI_API_KEY
64
+
65
+ # Profile by numeric id (or user_by_username for usernames)
66
+ user = client.instagram.user_info(25025320)
67
+ print(user["user"]["follower_count"])
68
+
69
+ # Posts (grid)
70
+ medias = client.instagram.user_medias(25025320, count=12)
71
+
72
+ # Followers / following — paginated on unverified accounts: pass `next_max_id`
73
+ # back as `max_id`. Verified accounts (blue checkmark) expose ~50 followers max.
74
+ page = client.instagram.followers(43586162288, count=25)
75
+ page = client.instagram.followers(43586162288, max_id=page["next_max_id"])
76
+
77
+ # All comments of a post — cursor is next_min_id -> min_id (sort_order="recent" by default)
78
+ comments, cursor = [], None
79
+ while True:
80
+ page = client.instagram.media_comments(4012386322294878415, count=50, min_id=cursor)
81
+ comments += page["comments"]
82
+ cursor = page.get("next_min_id")
83
+ if not cursor:
84
+ break
85
+ ```
86
+
87
+ Base URL: `https://api.getsocialapi.com/v1` — authenticate with the API key
88
+ from your [dashboard](https://getsocialapi.com/dashboard/).
89
+
90
+ ## Client
91
+
92
+ ```python
93
+ Client(api_key=None, base_url="https://api.getsocialapi.com/v1", timeout=30, max_retries=3)
94
+ ```
95
+
96
+ | Parameter | Default | Description |
97
+ | --- | --- | --- |
98
+ | `api_key` | env `GETSOCIALAPI_API_KEY` | your API key |
99
+ | `base_url` | API endpoint | override only for testing |
100
+ | `timeout` | `30` | per-request timeout, seconds |
101
+ | `max_retries` | `3` | retries on 429/503/5xx and network errors |
102
+
103
+ ## Methods
104
+
105
+ | Method | Route | Description |
106
+ | --- | --- | --- |
107
+ | `profile(user_id)` | `/profile/<id>` | profile by numeric id |
108
+ | `user_info(user_id)` | `/ig/user/info/<id>` | profile by numeric id |
109
+ | `user_by_username(username)` | `/ig/user/username_info/<username>` | profile by username |
110
+ | `user_about(user_id)` | `/user/about/<id>` | About this account: country, joined, former usernames, shared-followers count, verified |
111
+ | `search_username(query, count=10)` | `/search/username/<q>` | search users, resolve username → pk |
112
+ | `similar_users(user_id)` | `/ig/user/similar/<id>` | suggested accounts |
113
+ | `user_medias(user_id, count=12, max_id=None)` | `/ig/user/media/<id>` | profile grid posts |
114
+ | `user_tags(user_id, count=12, max_id=None)` | `/ig/user/tags/<id>` | posts with the user tagged |
115
+ | `user_stories(user_id)` | `/ig/user/stories/<id>` | active stories |
116
+ | `user_highlights(user_id)` | `/ig/user/highlights/<id>` | highlights tray |
117
+ | `highlight_stories(highlight_id)` | `/ig/highlight/stories/<id>` | media of a highlight |
118
+ | `followers(user_id, count=50, max_id=None, query=None)` | `/followers/<id>` | followers list |
119
+ | `following(user_id, count=25, max_id=None, query=None)` | `/following/<id>` | following list |
120
+ | `media_info(media)` | `/ig/media/info/<pk\|shortcode>` | full media card; accepts numeric pk or shortcode |
121
+ | `media_likers(media)` | `/ig/media/likers/<pk\|shortcode>` | users who liked (up to ~100, no pagination) |
122
+ | `media_comments(media, count=None, sort_order="recent", min_id=None)` | `/ig/media/comments/<pk\|shortcode>` | media comments; cursor pagination `next_min_id` → `min_id` |
123
+ | `search_clips(keyword)` | `/ig/media/search_clips/<kw>` | search reels by keyword |
124
+ | `hashtag_info(tag)` | `/ig/hashtag/info/<tag>` | hashtag card |
125
+ | `hashtag_search(query)` | `/ig/hashtag/search/<q>` | search hashtags |
126
+ | `hashtag_posts(tag, max_id=None)` | `/v2/hashtag/posts/<tag>` | posts by hashtag |
127
+ | `location_info(pk)` | `/ig/location/info/<pk>` | location card |
128
+ | `location_media(pk, max_id=None)` | `/ig/location/media/<pk>` | media by location |
129
+
130
+ Every method returns the raw JSON of the API route as received — nothing is
131
+ unwrapped, filtered or renamed. Where the API supports pagination (`followers`*, `following`, `user_medias`,
132
+ `user_tags`, `hashtag_posts`, `location_media`), pass the cursor from the
133
+ previous response (`next_max_id`) as `max_id`.
134
+ `media_comments` paginates with a different cursor: always request with
135
+ `sort_order="recent"` (the default) and pass `next_min_id` back as `min_id`
136
+ until it comes back null — pages are chronological and disjoint. Instagram
137
+ never returns `next_max_id` for comments, so `max_id` must not be used there.
138
+ *`followers` works on unverified accounts (nearly the full list, plus the
139
+ `query` filter); verified accounts (blue checkmark) expose no more than ~50
140
+ followers — one page, no cursor, `query` ignored. For lists without cursors
141
+ (`search_clips`, `hashtag_search`) each call simply returns the service page.
142
+
143
+ Full request/response reference with live examples:
144
+ [docs.getsocialapi → API Reference](https://getsocialapi.com/docs/api/instagram/user/get_info_by_id/)
145
+ (see the Documentation section on [getsocialapi.com](https://getsocialapi.com/)).
146
+
147
+ ## Errors
148
+
149
+ | Exception | HTTP | Notes |
150
+ | --- | --- | --- |
151
+ | `GetSocialAPIError` | — | base class; also network errors |
152
+ | `APIKeyError` | 401 | invalid or missing key |
153
+ | `BadRequestError` | 400 | `.field` points to the bad parameter |
154
+ | `NotFoundError` | 404 | object does not exist — never retried |
155
+ | `RateLimitError` | 429 | `.retry_after` seconds |
156
+ | `ServiceUnavailableError` | 503 | no live accounts, `.retry_after` |
157
+ | `InstagramError` | any | upstream Instagram error, `.status` |
158
+
159
+ ```python
160
+ from getsocialapi_instagram import NotFoundError, RateLimitError
161
+ import time
162
+
163
+ try:
164
+ client.instagram.user_info(25025320)
165
+ except NotFoundError:
166
+ ... # object does not exist
167
+ except RateLimitError as e:
168
+ time.sleep(e.retry_after)
169
+ ```
170
+
171
+ Only successful responses (HTTP `200` and `404`) count against your quota.
172
+ Rate limits and plans: [pricing](https://getsocialapi.com/#pricing).
173
+
174
+ ## Support
175
+
176
+ - Telegram: [@getsocialapi](https://t.me/getsocialapi)
177
+ - Email: [hello@getsocialapi.com](mailto:hello@getsocialapi.com)
178
+ - Website: [getsocialapi.com](https://getsocialapi.com/)
179
+ - Documentation: [getsocialapi.com/docs](https://getsocialapi.com/docs/)
180
+ - Status: [getsocialapi.com/status](https://getsocialapi.com/status/)
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ getsocialapi_instagram/__init__.py
5
+ getsocialapi_instagram/client.py
6
+ getsocialapi_instagram/exceptions.py
7
+ getsocialapi_instagram/http.py
8
+ getsocialapi_instagram/instagram.py
9
+ getsocialapi_instagram.egg-info/PKG-INFO
10
+ getsocialapi_instagram.egg-info/SOURCES.txt
11
+ getsocialapi_instagram.egg-info/dependency_links.txt
12
+ getsocialapi_instagram.egg-info/requires.txt
13
+ getsocialapi_instagram.egg-info/top_level.txt
14
+ tests/test_sdk.py
@@ -0,0 +1 @@
1
+ getsocialapi_instagram
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "getsocialapi-instagram"
7
+ version = "0.1.0"
8
+ description = "GetSocialAPI — client library for the Instagram API (no cookies, no accounts required)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "GetSocialAPI" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = ["requests>=2.25"]
14
+ keywords = ["instagram", "api", "scraping", "sdk", "getsocialapi"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://getsocialapi.com"
25
+ Documentation = "https://getsocialapi.com/docs/"
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["getsocialapi_instagram*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,229 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import unittest
5
+ from unittest.mock import MagicMock
6
+
7
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8
+
9
+ from getsocialapi_instagram import Client, InstagramAPI # noqa: E402
10
+ from getsocialapi_instagram.exceptions import ( # noqa: E402
11
+ APIKeyError,
12
+ BadRequestError,
13
+ InstagramError,
14
+ NotFoundError,
15
+ RateLimitError,
16
+ ServiceUnavailableError,
17
+ )
18
+ from getsocialapi_instagram.http import Transport # noqa: E402
19
+
20
+
21
+ def fake_response(status, body=None, headers=None):
22
+ r = MagicMock()
23
+ r.status_code = status
24
+ r.headers = headers or {}
25
+ r.text = json.dumps(body) if body is not None else ""
26
+ r.json = (lambda b=body: b) if body is not None else (lambda: (_ for _ in ()).throw(ValueError()))
27
+ return r
28
+
29
+
30
+ def make_client(session):
31
+ return Client(api_key="test_key", session=session, max_retries=0)
32
+
33
+
34
+ class TestClient(unittest.TestCase):
35
+ def test_requires_key(self):
36
+ with self.assertRaises(ValueError):
37
+ Client(api_key=None)
38
+
39
+ def test_health_no_auth_header(self):
40
+ s = MagicMock()
41
+ s.request.return_value = fake_response(200, {"ok": True, "state": "ready"})
42
+ c = make_client(s)
43
+ self.assertEqual(c.health(), {"ok": True, "state": "ready"})
44
+ kwargs = s.request.call_args.kwargs
45
+ self.assertEqual(kwargs["headers"], {})
46
+
47
+
48
+ class TestErrors(unittest.TestCase):
49
+ def _transport(self, resp):
50
+ t = Transport(api_key="k", max_retries=0)
51
+ t.session = MagicMock()
52
+ t.session.request.return_value = resp
53
+ return t
54
+
55
+ def test_401(self):
56
+ with self.assertRaises(APIKeyError):
57
+ self._transport(fake_response(401, {"error": "bad key"})).request("GET", "/x")
58
+
59
+ def test_400_message_field(self):
60
+ from getsocialapi_instagram.exceptions import BadRequestError, NotFoundError
61
+ e = BadRequestError(body={"message": "Location does not exist.", "status": "fail"})
62
+ self.assertEqual(str(e), "Location does not exist.")
63
+ n = NotFoundError(body={"message": "Location does not exist.", "status": "fail"})
64
+ self.assertEqual(str(n), "Location does not exist.")
65
+ g = NotFoundError(body={"error": {"code": 404, "message": "Unknown endpoint"}})
66
+ self.assertEqual(str(g), "Unknown endpoint")
67
+
68
+ def test_400_field(self):
69
+ with self.assertRaises(BadRequestError) as ctx:
70
+ self._transport(fake_response(400, {"error": "invalid id", "field": "user_id"})).request("GET", "/x")
71
+ self.assertEqual(ctx.exception.field, "user_id")
72
+
73
+ def test_404_not_found(self):
74
+ with self.assertRaises(NotFoundError):
75
+ self._transport(fake_response(404, {"message": "user does not exist", "status": "fail"})).request("GET", "/x")
76
+
77
+ def test_429_retry_after(self):
78
+ with self.assertRaises(RateLimitError) as ctx:
79
+ self._transport(fake_response(429, {}, {"Retry-After": "7"})).request("GET", "/x")
80
+ self.assertEqual(ctx.exception.retry_after, 7)
81
+
82
+ def test_503(self):
83
+ with self.assertRaises(ServiceUnavailableError):
84
+ self._transport(fake_response(503, {})).request("GET", "/x")
85
+
86
+ def test_ig_error(self):
87
+ with self.assertRaises(InstagramError):
88
+ self._transport(fake_response(500, {"message": "oops", "status": "fail"})).request("GET", "/x")
89
+
90
+
91
+ class TestInstagramAPI(unittest.TestCase):
92
+ def api(self):
93
+ t = MagicMock()
94
+ return InstagramAPI(t), t
95
+
96
+ def check(self, method, args, expected_path, expected_params=None, kwargs=None):
97
+ a, t = self.api()
98
+ getattr(a, method)(*args, **(kwargs or {}))
99
+ call = t.request.call_args
100
+ self.assertEqual(call.args, ("GET", expected_path))
101
+ if expected_params is not None:
102
+ self.assertEqual(call.kwargs.get("params"), expected_params)
103
+
104
+ def test_profile(self):
105
+ self.check("profile", ("25025320",), "/profile/25025320")
106
+
107
+ def test_user_info(self):
108
+ self.check("user_info", (25025320,), "/ig/user/info/25025320")
109
+
110
+ def test_user_about(self):
111
+ self.check("user_about", (25025320,), "/user/about/25025320")
112
+
113
+ def test_hashtag_info_trims_hash(self):
114
+ self.check("hashtag_info", ("#gymgirl",), "/ig/hashtag/info/gymgirl")
115
+
116
+ def test_hashtag_posts_trims_hash(self):
117
+ self.check("hashtag_posts", ("#gymgirl",), "/v2/hashtag/posts/gymgirl")
118
+ self.check("hashtag_posts", ("##gym girl",), "/v2/hashtag/posts/gym%20girl")
119
+
120
+ def test_username_info(self):
121
+ self.check("user_by_username", ("instagram",), "/ig/user/username_info/instagram")
122
+
123
+ def test_search_username(self):
124
+ self.check("search_username", ("du",), "/search/username/du", {"count": 10})
125
+
126
+ def test_user_medias(self):
127
+ self.check("user_medias", (25025320,), "/ig/user/media/25025320", {"count": 12})
128
+
129
+ def test_user_medias_max_id(self):
130
+ self.check("user_medias", (25025320,), "/ig/user/media/25025320",
131
+ {"count": 50, "max_id": "CUR"}, {"count": 50, "max_id": "CUR"})
132
+
133
+ def test_followers_default(self):
134
+ self.check("followers", (25025320,), "/followers/25025320", {"count": 50})
135
+
136
+ def test_followers_page_and_filter(self):
137
+ self.check("followers", (25025320,), "/followers/25025320",
138
+ {"count": 25, "max_id": "CUR", "query": "an"},
139
+ {"count": 25, "max_id": "CUR", "query": "an"})
140
+
141
+ def test_following_default(self):
142
+ self.check("following", (25025320,), "/following/25025320", {"count": 25})
143
+
144
+ def test_following_page_and_filter(self):
145
+ self.check("following", (25025320,), "/following/25025320",
146
+ {"count": 25, "max_id": "CUR", "query": "bo"},
147
+ {"count": 25, "max_id": "CUR", "query": "bo"})
148
+
149
+ def test_media_comments_default(self):
150
+ self.check("media_comments", (3141085633626004499,),
151
+ "/ig/media/comments/3141085633626004499",
152
+ {"sort_order": "recent"})
153
+
154
+ def test_media_shortcode_args(self):
155
+ self.check("media_info", ("Deu3BoCBbDP",), "/ig/media/info/Deu3BoCBbDP")
156
+ self.check("media_likers", ("DehVsgBi3sH",), "/ig/media/likers/DehVsgBi3sH")
157
+ self.check("media_comments", ("Deu3BoCBbDP",),
158
+ "/ig/media/comments/Deu3BoCBbDP",
159
+ {"sort_order": "recent"})
160
+
161
+ def test_media_bad_arg(self):
162
+ a, _ = self.api()
163
+ for bad in ("", "abc!", "#tag", "way too long to be a shortcode at all"):
164
+ with self.assertRaises(ValueError):
165
+ a.media_info(bad)
166
+
167
+ def test_media_comments_page(self):
168
+ cur = '{"cached_comments_cursor":"123","bifilter_token":"abc+/=|"}'
169
+ self.check("media_comments", (3141085633626004499,),
170
+ "/ig/media/comments/3141085633626004499",
171
+ {"count": 50, "sort_order": "recent", "min_id": cur},
172
+ {"count": 50, "min_id": cur})
173
+
174
+ def test_media_comments_ranked(self):
175
+ self.check("media_comments", (3141085633626004499,),
176
+ "/ig/media/comments/3141085633626004499",
177
+ {"sort_order": "ranked"}, {"sort_order": "ranked"})
178
+
179
+ def test_media_comments_bad_sort_order(self):
180
+ a, _ = self.api()
181
+ with self.assertRaises(ValueError):
182
+ a.media_comments(3141085633626004499, sort_order="top")
183
+
184
+ def test_media_comments_bad_count(self):
185
+ a, _ = self.api()
186
+ with self.assertRaises(ValueError):
187
+ a.media_comments(3141085633626004499, count=51)
188
+
189
+ def test_hashtag_posts_default(self):
190
+ self.check("hashtag_posts", ("gymgirl",), "/v2/hashtag/posts/gymgirl")
191
+
192
+ def test_hashtag_posts_max_id(self):
193
+ self.check("hashtag_posts", ("gymgirl",), "/v2/hashtag/posts/gymgirl",
194
+ {"max_id": "CUR"}, {"max_id": "CUR"})
195
+
196
+ def test_location_media(self):
197
+ self.check("location_media", (213069402,), "/ig/location/media/213069402")
198
+
199
+ def test_location_media_max_id(self):
200
+ self.check("location_media", (213069402,), "/ig/location/media/213069402",
201
+ {"max_id": "NEXT"}, {"max_id": "NEXT"})
202
+
203
+ def test_search_clips(self):
204
+ self.check("search_clips", ("hello world",), "/ig/media/search_clips/hello%20world")
205
+
206
+ def test_hashtag_search_no_count(self):
207
+ self.check("hashtag_search", ("gym girl",), "/ig/hashtag/search/gym%20girl")
208
+
209
+ def test_highlight_stories_trims_prefix(self):
210
+ self.check("highlight_stories", ("highlight:18223279177302854",),
211
+ "/ig/highlight/stories/18223279177302854")
212
+ self.check("highlight_stories", ("18223279177302854",),
213
+ "/ig/highlight/stories/18223279177302854")
214
+
215
+ def test_validations(self):
216
+ a, _ = self.api()
217
+ for fn, bad in [
218
+ (a.user_info, "12x"),
219
+ (a.user_by_username, ""),
220
+ (a.user_medias, 0),
221
+ ]:
222
+ with self.assertRaises(ValueError):
223
+ fn(bad) if fn not in (a.user_medias,) else fn(1, count=0)
224
+ with self.assertRaises(ValueError):
225
+ a.search_clips("")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ unittest.main(verbosity=2)