getsocialapi-facebook 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.
- getsocialapi_facebook-0.1.0/LICENSE +21 -0
- getsocialapi_facebook-0.1.0/PKG-INFO +157 -0
- getsocialapi_facebook-0.1.0/README.md +137 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook/__init__.py +23 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook/client.py +28 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook/exceptions.py +58 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook/facebook.py +152 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook/http.py +77 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook.egg-info/PKG-INFO +157 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook.egg-info/SOURCES.txt +14 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook.egg-info/dependency_links.txt +1 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook.egg-info/requires.txt +1 -0
- getsocialapi_facebook-0.1.0/getsocialapi_facebook.egg-info/top_level.txt +1 -0
- getsocialapi_facebook-0.1.0/pyproject.toml +28 -0
- getsocialapi_facebook-0.1.0/setup.cfg +4 -0
- getsocialapi_facebook-0.1.0/tests/test_sdk.py +191 -0
|
@@ -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,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getsocialapi-facebook
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GetSocialAPI — Python client for the Facebook Scraper API (groups, profiles, connections)
|
|
5
|
+
Author: GetSocialAPI
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://getsocialapi.com
|
|
8
|
+
Project-URL: Documentation, https://getsocialapi.com/docs/
|
|
9
|
+
Keywords: facebook,api,scraping,sdk,groups,members
|
|
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
|
+
# Facebook Scraper API
|
|
22
|
+
|
|
23
|
+
[](https://github.com/GetSocialAPI/Facebook-Scraper-API/actions/workflows/tests.yml)
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
[](https://www.python.org)
|
|
26
|
+
|
|
27
|
+
Python client for the **[GetSocialAPI](https://getsocialapi.com/)** Facebook Scraper API.
|
|
28
|
+
|
|
29
|
+
- Groups: resolve links, overview, paginated members
|
|
30
|
+
- Profiles: resolve links, About/contacts in raw GraphQL
|
|
31
|
+
- Connections: followers, followings, friends
|
|
32
|
+
- Raw responses — no transformations
|
|
33
|
+
- Automatic retries on 429/502/503, typed exceptions
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/GetSocialAPI/Facebook-Scraper-API.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from getsocialapi_facebook import Client
|
|
45
|
+
|
|
46
|
+
# Get your API key at https://getsocialapi.com/dashboard/
|
|
47
|
+
client = Client(api_key="gsa_live_...") # or set GETSOCIALAPI_FB_API_KEY
|
|
48
|
+
|
|
49
|
+
# Resolve any group link (slug, m.facebook, share/g) to a group_id
|
|
50
|
+
group = client.fb.resolve("https://www.facebook.com/groups/freegarden/")
|
|
51
|
+
group_id = group["group_id"]
|
|
52
|
+
|
|
53
|
+
# Profile links resolve the same way
|
|
54
|
+
user = client.fb.resolve_profile("https://www.facebook.com/nasa")
|
|
55
|
+
user_id = user["user_id"]
|
|
56
|
+
|
|
57
|
+
# Group card
|
|
58
|
+
client.fb.overview(group_id)
|
|
59
|
+
|
|
60
|
+
# Members: raw GraphQL pages (1st -> data.group, 2nd+ -> data.fetch__Group)
|
|
61
|
+
page = client.fb.members(group_id) # fixed page of 15 members
|
|
62
|
+
page["data"]["group"]["group_member_profiles"]["page_info"]["end_cursor"]
|
|
63
|
+
|
|
64
|
+
# Next page: pass page_info.end_cursor as `after`
|
|
65
|
+
client.fb.members(group_id, after="AQHTm5Xq…") # end_cursor from page_info
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Base URL: `https://api.getsocialapi.com/fb` — authenticate with the API key
|
|
69
|
+
from your [dashboard](https://getsocialapi.com/dashboard/).
|
|
70
|
+
|
|
71
|
+
## Client
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
Client(api_key=None, base_url="https://api.getsocialapi.com/fb", timeout=30, max_retries=3)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `api_key` — your API key (env var `GETSOCIALAPI_FB_API_KEY` works too)
|
|
78
|
+
|
|
79
|
+
## Methods
|
|
80
|
+
|
|
81
|
+
| Method | Route | Description |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `resolve(url)` | `GET /resolve` | group link → `group_id` (numeric id, slug, m.facebook, `/share/g/…`) |
|
|
84
|
+
| `resolve_profile(url)` | `GET /resolve/profile` | profile link → `user_id` |
|
|
85
|
+
| `overview(group_id)` | `GET /overview` | group card: name, visibility |
|
|
86
|
+
| `members(group_id, after=None)` | `GET /api/v1/members` | raw GraphQL page of 15 (1st — `data.group`, 2nd+ — `data.fetch__Group`) |
|
|
87
|
+
| `group_posts(group_id, after=None)` | `GET /api/v1/group_posts` | group feed: page of 2–3 `Story` posts, cursor from `group_feed.page_info.end_cursor` |
|
|
88
|
+
| `contacts(user_id)` | `GET /api/v1/contacts` | profile About: raw GraphQL |
|
|
89
|
+
| `posts(user_id, after=None)` | `GET /api/v1/posts` | user timeline: page of ~100 posts (the fixed service maximum), cursor from `timeline_feed_units.page_info.end_cursor` |
|
|
90
|
+
| `feed(user_id=None, group_id=None, per_page=None, after=None)` | `GET /api/v1/feed` | unified user/group feed, small pages: `per_page` 3–20 (default 10) |
|
|
91
|
+
| `followers(user_id, after=None)` | `GET /api/v1/followers` | raw GraphQL `full_profile_list` |
|
|
92
|
+
| `followings(user_id, after=None)` | `GET /api/v1/followings` | same shape |
|
|
93
|
+
| `friends(user_id, after=None)` | `GET /api/v1/friends` | same shape |
|
|
94
|
+
|
|
95
|
+
Full request/response reference with live examples:
|
|
96
|
+
[getsocialapi.com/docs — API Reference / Facebook](https://getsocialapi.com/docs/api/facebook/resolve/).
|
|
97
|
+
|
|
98
|
+
### Pagination
|
|
99
|
+
|
|
100
|
+
Every method returns one raw page. Pass the connection's
|
|
101
|
+
`page_info.end_cursor` as `after` while `has_next_page` is true.
|
|
102
|
+
|
|
103
|
+
| List | Page size (fixed) | Cursor source |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `posts` | ~100 posts — the service maximum, there is no `count` parameter | `data.profile.timeline_feed_units.page_info.end_cursor` |
|
|
106
|
+
| `feed` | `per_page` 3–20 (default 10); a page may come back short — follow `has_next_page` | same connection as the target: `timeline_feed_units` / `group_feed` `page_info.end_cursor` |
|
|
107
|
+
| `group_posts` | 2–3 `Story` posts (skip `GroupsSectionHeaderUnit` edges, dedupe by `cache_id`) | `data.group_address.group_feed.page_info.end_cursor` |
|
|
108
|
+
| `members` | 15 | `data.(group / fetch__Group).group_member_profiles.page_info.end_cursor` |
|
|
109
|
+
| `followers` / `followings` / `friends` | 15 | `…list_items.page_info.end_cursor` |
|
|
110
|
+
|
|
111
|
+
Cursors are reusable and not bound to a session; a broken or foreign cursor
|
|
112
|
+
returns `400 Invalid Cursor` — take `end_cursor` only from the connection's
|
|
113
|
+
`page_info`, never from node fields.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
posts, after = [], None
|
|
117
|
+
while True:
|
|
118
|
+
page = client.fb.posts(user_id, after=after)
|
|
119
|
+
tfu = page["data"]["profile"]["timeline_feed_units"]
|
|
120
|
+
posts += [e["node"] for e in tfu["edges"]]
|
|
121
|
+
pi = tfu.get("page_info") or {}
|
|
122
|
+
if not pi.get("has_next_page") or not pi.get("end_cursor"):
|
|
123
|
+
break
|
|
124
|
+
after = pi["end_cursor"]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Response notes
|
|
128
|
+
|
|
129
|
+
- members page 1 lives in `data.group`, following pages in `data.fetch__Group`
|
|
130
|
+
- connection lists: `can_see_follower_list: false` means the list is hidden by
|
|
131
|
+
privacy settings (only the count is available)
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
| Exception | HTTP | Notes |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| `GetSocialFacebookError` | — | base class; also network errors |
|
|
138
|
+
| `BadRequestError` | 400 | bad parameters |
|
|
139
|
+
| `APIKeyError` | 401 | invalid key |
|
|
140
|
+
| `RateLimitError` | 429 | `.limit` from the response |
|
|
141
|
+
| `UpstreamError` | 502 | temporary upstream error — retried automatically |
|
|
142
|
+
| `ServiceUnavailableError` | 503 | service overloaded — retried automatically |
|
|
143
|
+
|
|
144
|
+
Only successful responses count against your quota.
|
|
145
|
+
Plans and rate limits: [getsocialapi.com/#pricing](https://getsocialapi.com/#pricing).
|
|
146
|
+
|
|
147
|
+
## Support
|
|
148
|
+
|
|
149
|
+
- Telegram: [@getsocialapi](https://t.me/getsocialapi)
|
|
150
|
+
- Email: [hello@getsocialapi.com](mailto:hello@getsocialapi.com)
|
|
151
|
+
- Website: [getsocialapi.com](https://getsocialapi.com/)
|
|
152
|
+
- Documentation: [getsocialapi.com/docs](https://getsocialapi.com/docs/)
|
|
153
|
+
- Status: [getsocialapi.com/status](https://getsocialapi.com/status/)
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Facebook Scraper API
|
|
2
|
+
|
|
3
|
+
[](https://github.com/GetSocialAPI/Facebook-Scraper-API/actions/workflows/tests.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://www.python.org)
|
|
6
|
+
|
|
7
|
+
Python client for the **[GetSocialAPI](https://getsocialapi.com/)** Facebook Scraper API.
|
|
8
|
+
|
|
9
|
+
- Groups: resolve links, overview, paginated members
|
|
10
|
+
- Profiles: resolve links, About/contacts in raw GraphQL
|
|
11
|
+
- Connections: followers, followings, friends
|
|
12
|
+
- Raw responses — no transformations
|
|
13
|
+
- Automatic retries on 429/502/503, typed exceptions
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install git+https://github.com/GetSocialAPI/Facebook-Scraper-API.git
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from getsocialapi_facebook import Client
|
|
25
|
+
|
|
26
|
+
# Get your API key at https://getsocialapi.com/dashboard/
|
|
27
|
+
client = Client(api_key="gsa_live_...") # or set GETSOCIALAPI_FB_API_KEY
|
|
28
|
+
|
|
29
|
+
# Resolve any group link (slug, m.facebook, share/g) to a group_id
|
|
30
|
+
group = client.fb.resolve("https://www.facebook.com/groups/freegarden/")
|
|
31
|
+
group_id = group["group_id"]
|
|
32
|
+
|
|
33
|
+
# Profile links resolve the same way
|
|
34
|
+
user = client.fb.resolve_profile("https://www.facebook.com/nasa")
|
|
35
|
+
user_id = user["user_id"]
|
|
36
|
+
|
|
37
|
+
# Group card
|
|
38
|
+
client.fb.overview(group_id)
|
|
39
|
+
|
|
40
|
+
# Members: raw GraphQL pages (1st -> data.group, 2nd+ -> data.fetch__Group)
|
|
41
|
+
page = client.fb.members(group_id) # fixed page of 15 members
|
|
42
|
+
page["data"]["group"]["group_member_profiles"]["page_info"]["end_cursor"]
|
|
43
|
+
|
|
44
|
+
# Next page: pass page_info.end_cursor as `after`
|
|
45
|
+
client.fb.members(group_id, after="AQHTm5Xq…") # end_cursor from page_info
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Base URL: `https://api.getsocialapi.com/fb` — authenticate with the API key
|
|
49
|
+
from your [dashboard](https://getsocialapi.com/dashboard/).
|
|
50
|
+
|
|
51
|
+
## Client
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
Client(api_key=None, base_url="https://api.getsocialapi.com/fb", timeout=30, max_retries=3)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `api_key` — your API key (env var `GETSOCIALAPI_FB_API_KEY` works too)
|
|
58
|
+
|
|
59
|
+
## Methods
|
|
60
|
+
|
|
61
|
+
| Method | Route | Description |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `resolve(url)` | `GET /resolve` | group link → `group_id` (numeric id, slug, m.facebook, `/share/g/…`) |
|
|
64
|
+
| `resolve_profile(url)` | `GET /resolve/profile` | profile link → `user_id` |
|
|
65
|
+
| `overview(group_id)` | `GET /overview` | group card: name, visibility |
|
|
66
|
+
| `members(group_id, after=None)` | `GET /api/v1/members` | raw GraphQL page of 15 (1st — `data.group`, 2nd+ — `data.fetch__Group`) |
|
|
67
|
+
| `group_posts(group_id, after=None)` | `GET /api/v1/group_posts` | group feed: page of 2–3 `Story` posts, cursor from `group_feed.page_info.end_cursor` |
|
|
68
|
+
| `contacts(user_id)` | `GET /api/v1/contacts` | profile About: raw GraphQL |
|
|
69
|
+
| `posts(user_id, after=None)` | `GET /api/v1/posts` | user timeline: page of ~100 posts (the fixed service maximum), cursor from `timeline_feed_units.page_info.end_cursor` |
|
|
70
|
+
| `feed(user_id=None, group_id=None, per_page=None, after=None)` | `GET /api/v1/feed` | unified user/group feed, small pages: `per_page` 3–20 (default 10) |
|
|
71
|
+
| `followers(user_id, after=None)` | `GET /api/v1/followers` | raw GraphQL `full_profile_list` |
|
|
72
|
+
| `followings(user_id, after=None)` | `GET /api/v1/followings` | same shape |
|
|
73
|
+
| `friends(user_id, after=None)` | `GET /api/v1/friends` | same shape |
|
|
74
|
+
|
|
75
|
+
Full request/response reference with live examples:
|
|
76
|
+
[getsocialapi.com/docs — API Reference / Facebook](https://getsocialapi.com/docs/api/facebook/resolve/).
|
|
77
|
+
|
|
78
|
+
### Pagination
|
|
79
|
+
|
|
80
|
+
Every method returns one raw page. Pass the connection's
|
|
81
|
+
`page_info.end_cursor` as `after` while `has_next_page` is true.
|
|
82
|
+
|
|
83
|
+
| List | Page size (fixed) | Cursor source |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `posts` | ~100 posts — the service maximum, there is no `count` parameter | `data.profile.timeline_feed_units.page_info.end_cursor` |
|
|
86
|
+
| `feed` | `per_page` 3–20 (default 10); a page may come back short — follow `has_next_page` | same connection as the target: `timeline_feed_units` / `group_feed` `page_info.end_cursor` |
|
|
87
|
+
| `group_posts` | 2–3 `Story` posts (skip `GroupsSectionHeaderUnit` edges, dedupe by `cache_id`) | `data.group_address.group_feed.page_info.end_cursor` |
|
|
88
|
+
| `members` | 15 | `data.(group / fetch__Group).group_member_profiles.page_info.end_cursor` |
|
|
89
|
+
| `followers` / `followings` / `friends` | 15 | `…list_items.page_info.end_cursor` |
|
|
90
|
+
|
|
91
|
+
Cursors are reusable and not bound to a session; a broken or foreign cursor
|
|
92
|
+
returns `400 Invalid Cursor` — take `end_cursor` only from the connection's
|
|
93
|
+
`page_info`, never from node fields.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
posts, after = [], None
|
|
97
|
+
while True:
|
|
98
|
+
page = client.fb.posts(user_id, after=after)
|
|
99
|
+
tfu = page["data"]["profile"]["timeline_feed_units"]
|
|
100
|
+
posts += [e["node"] for e in tfu["edges"]]
|
|
101
|
+
pi = tfu.get("page_info") or {}
|
|
102
|
+
if not pi.get("has_next_page") or not pi.get("end_cursor"):
|
|
103
|
+
break
|
|
104
|
+
after = pi["end_cursor"]
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Response notes
|
|
108
|
+
|
|
109
|
+
- members page 1 lives in `data.group`, following pages in `data.fetch__Group`
|
|
110
|
+
- connection lists: `can_see_follower_list: false` means the list is hidden by
|
|
111
|
+
privacy settings (only the count is available)
|
|
112
|
+
|
|
113
|
+
## Errors
|
|
114
|
+
|
|
115
|
+
| Exception | HTTP | Notes |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `GetSocialFacebookError` | — | base class; also network errors |
|
|
118
|
+
| `BadRequestError` | 400 | bad parameters |
|
|
119
|
+
| `APIKeyError` | 401 | invalid key |
|
|
120
|
+
| `RateLimitError` | 429 | `.limit` from the response |
|
|
121
|
+
| `UpstreamError` | 502 | temporary upstream error — retried automatically |
|
|
122
|
+
| `ServiceUnavailableError` | 503 | service overloaded — retried automatically |
|
|
123
|
+
|
|
124
|
+
Only successful responses count against your quota.
|
|
125
|
+
Plans and rate limits: [getsocialapi.com/#pricing](https://getsocialapi.com/#pricing).
|
|
126
|
+
|
|
127
|
+
## Support
|
|
128
|
+
|
|
129
|
+
- Telegram: [@getsocialapi](https://t.me/getsocialapi)
|
|
130
|
+
- Email: [hello@getsocialapi.com](mailto:hello@getsocialapi.com)
|
|
131
|
+
- Website: [getsocialapi.com](https://getsocialapi.com/)
|
|
132
|
+
- Documentation: [getsocialapi.com/docs](https://getsocialapi.com/docs/)
|
|
133
|
+
- Status: [getsocialapi.com/status](https://getsocialapi.com/status/)
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .client import Client
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
APIKeyError,
|
|
4
|
+
BadRequestError,
|
|
5
|
+
GetSocialFacebookError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
ServiceUnavailableError,
|
|
8
|
+
UpstreamError,
|
|
9
|
+
)
|
|
10
|
+
from .facebook import FacebookAPI
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Client",
|
|
16
|
+
"FacebookAPI",
|
|
17
|
+
"GetSocialFacebookError",
|
|
18
|
+
"BadRequestError",
|
|
19
|
+
"APIKeyError",
|
|
20
|
+
"RateLimitError",
|
|
21
|
+
"UpstreamError",
|
|
22
|
+
"ServiceUnavailableError",
|
|
23
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from .facebook import FacebookAPI
|
|
4
|
+
from .http import DEFAULT_BASE_URL, Transport
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Client:
|
|
8
|
+
"""GetSocialAPI Facebook client.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from getsocialapi_facebook import Client
|
|
12
|
+
client = Client(api_key="gsa_live_...")
|
|
13
|
+
group = client.resolve("https://www.facebook.com/groups/freegarden/")
|
|
14
|
+
client.fb.members(group["group_id"], first=15)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, api_key=None, base_url=DEFAULT_BASE_URL, timeout=30,
|
|
18
|
+
max_retries=3, session=None):
|
|
19
|
+
api_key = api_key or os.environ.get("GETSOCIALAPI_FB_API_KEY")
|
|
20
|
+
if not api_key:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"api_key is required (pass api_key=... or set GETSOCIALAPI_FB_API_KEY)"
|
|
23
|
+
)
|
|
24
|
+
self._transport = Transport(
|
|
25
|
+
base_url=base_url, api_key=api_key, timeout=timeout,
|
|
26
|
+
max_retries=max_retries, session=session,
|
|
27
|
+
)
|
|
28
|
+
self.fb = self.facebook = FacebookAPI(self._transport)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
class GetSocialFacebookError(Exception):
|
|
2
|
+
"""Base error for all GetSocialAPI Facebook 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(GetSocialFacebookError):
|
|
24
|
+
"""400 — bad parameters."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, response=None, body=None):
|
|
27
|
+
body = body or {}
|
|
28
|
+
super().__init__(_reason(body, "Bad request"), response, body)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIKeyError(GetSocialFacebookError):
|
|
32
|
+
"""401 — invalid API key."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, response=None, body=None):
|
|
35
|
+
super().__init__("Invalid API key", response, body)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RateLimitError(GetSocialFacebookError):
|
|
39
|
+
"""429 — rate limit (body: {"error": "rate limit", "limit": N})."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, response=None, body=None, limit=None):
|
|
42
|
+
super().__init__(f"Rate limit exceeded (limit: {limit})", response, body)
|
|
43
|
+
self.limit = limit
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UpstreamError(GetSocialFacebookError):
|
|
47
|
+
"""502 — temporary upstream error. Automatically retried."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, response=None, body=None):
|
|
50
|
+
body = body or {}
|
|
51
|
+
super().__init__(_reason(body, "Temporary upstream error"), response, body)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ServiceUnavailableError(GetSocialFacebookError):
|
|
55
|
+
"""503 — service overloaded."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, response=None, body=None):
|
|
58
|
+
super().__init__("Service overloaded", response, body)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from urllib.parse import quote
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _quote(value):
|
|
5
|
+
return quote(str(value), safe="")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _digits(value, name, max_len=20):
|
|
9
|
+
s = str(value)
|
|
10
|
+
if not s.isdigit() or not 1 <= len(s) <= max_len:
|
|
11
|
+
raise ValueError(f"{name} must be 1-{max_len} digits, got: {value!r}")
|
|
12
|
+
return s
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FacebookAPI:
|
|
16
|
+
"""Facebook endpoints of GetSocialAPI (groups, profiles, connections)."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, transport):
|
|
19
|
+
self._t = transport
|
|
20
|
+
|
|
21
|
+
# ---------- Groups ----------
|
|
22
|
+
|
|
23
|
+
def resolve(self, url):
|
|
24
|
+
"""Resolve a group link (numeric id, slug, m.facebook, share/g) to group_id."""
|
|
25
|
+
s = str(url).strip()
|
|
26
|
+
if not s:
|
|
27
|
+
raise ValueError("url must not be empty")
|
|
28
|
+
return self._t.request("GET", "/resolve", params={"url": s})
|
|
29
|
+
|
|
30
|
+
def resolve_profile(self, url):
|
|
31
|
+
"""Resolve a profile link to a numeric user_id."""
|
|
32
|
+
s = str(url).strip()
|
|
33
|
+
if not s:
|
|
34
|
+
raise ValueError("url must not be empty")
|
|
35
|
+
return self._t.request("GET", "/resolve/profile", params={"url": s})
|
|
36
|
+
|
|
37
|
+
def overview(self, group_id):
|
|
38
|
+
"""Group card: name, visibility, admins count. Upstream: FetchGroupMemberList."""
|
|
39
|
+
return self._t.request("GET", "/overview", params={"group_id": _digits(group_id, "group_id")})
|
|
40
|
+
|
|
41
|
+
def members(self, group_id, after=None):
|
|
42
|
+
"""Group members, raw Facebook GraphQL (one page of 15).
|
|
43
|
+
|
|
44
|
+
Page 1 -> data.group, page 2+ -> data.fetch__Group. The page size is
|
|
45
|
+
fixed by the service (15 edges); pass page_info.end_cursor as `after`
|
|
46
|
+
while has_next_page is true to walk the list.
|
|
47
|
+
"""
|
|
48
|
+
params = {"group_id": _digits(group_id, "group_id")}
|
|
49
|
+
if after is not None:
|
|
50
|
+
params["after"] = after
|
|
51
|
+
return self._t.request("GET", "/api/v1/members", params=params)
|
|
52
|
+
|
|
53
|
+
def group_posts(self, group_id, after=None):
|
|
54
|
+
"""Group feed posts, raw GroupFeedCsrQuery payload (2-3 posts/page).
|
|
55
|
+
|
|
56
|
+
Story-shaped posts (creation_time, message.text, actors,
|
|
57
|
+
attachments) — same node schema as the user timeline; skip
|
|
58
|
+
GroupsSectionHeaderUnit edges. Take the cursor from
|
|
59
|
+
data.group_address.group_feed.page_info.end_cursor and pass it
|
|
60
|
+
back as `after` while has_next_page is true. Post visibility in
|
|
61
|
+
closed groups depends on the scraper token's membership.
|
|
62
|
+
"""
|
|
63
|
+
params = {"group_id": _digits(group_id, "group_id")}
|
|
64
|
+
if after is not None:
|
|
65
|
+
params["after"] = after
|
|
66
|
+
return self._t.request("GET", "/api/v1/group_posts", params=params)
|
|
67
|
+
|
|
68
|
+
# ---------- Profile ----------
|
|
69
|
+
|
|
70
|
+
def contacts(self, user_id):
|
|
71
|
+
"""Profile About (contacts) — the untouched Facebook GraphQL payload.
|
|
72
|
+
|
|
73
|
+
Walk data.user.profile_intro_card: each About section is a connection
|
|
74
|
+
with edges[].node; `field_type` describes the entry kind (email,
|
|
75
|
+
phone, gender, employer, ...), linked entities are full node objects.
|
|
76
|
+
"""
|
|
77
|
+
return self._t.request("GET", "/api/v1/contacts",
|
|
78
|
+
params={"user_id": _digits(user_id, "user_id")})
|
|
79
|
+
|
|
80
|
+
def posts(self, user_id, after=None):
|
|
81
|
+
"""User timeline posts, raw Facebook GraphQL (one page of ~100).
|
|
82
|
+
|
|
83
|
+
data.profile.timeline_feed_units.edges[] holds Story nodes;
|
|
84
|
+
take the cursor from timeline_feed_units.page_info.end_cursor
|
|
85
|
+
and pass it back as `after` while has_next_page is true. The
|
|
86
|
+
cursor is a ~888-char base64 string — pass it verbatim (the
|
|
87
|
+
transport URL-encodes it). Cursors are reusable and not bound
|
|
88
|
+
to a session. Use only the page_info cursor — end_cursor values
|
|
89
|
+
inside node attachments are impostors and yield Invalid Cursor.
|
|
90
|
+
"""
|
|
91
|
+
return self._t.request("GET", "/api/v1/posts", params=_conn(user_id, after))
|
|
92
|
+
|
|
93
|
+
def feed(self, user_id=None, group_id=None, per_page=None, after=None):
|
|
94
|
+
"""GET /api/v1/feed — unified user/group feed, small pages.
|
|
95
|
+
|
|
96
|
+
Pass exactly one of `user_id` / `group_id`. Page size is
|
|
97
|
+
`per_page` (integer 3-20, default 10); a page may come back
|
|
98
|
+
short — follow has_next_page instead of counting posts.
|
|
99
|
+
|
|
100
|
+
user: `data.profile.timeline_feed_units` (~10 Story nodes;
|
|
101
|
+
Facebook may return slightly more — the service does not trim,
|
|
102
|
+
trimming would lose posts at page seams).
|
|
103
|
+
group: `data.group_address.group_feed` (Facebook caps its own
|
|
104
|
+
page at ~2 posts; the service stitches several FB pages up to
|
|
105
|
+
per_page — up to 2 duplicate edges are possible at the seam,
|
|
106
|
+
dedupe by node.cache_id).
|
|
107
|
+
|
|
108
|
+
Cursor: page_info.end_cursor of the respective connection,
|
|
109
|
+
passed back as `after` while has_next_page is true. A broken
|
|
110
|
+
cursor raises BadRequestError (400 Invalid Cursor).
|
|
111
|
+
"""
|
|
112
|
+
if (user_id is None) == (group_id is None):
|
|
113
|
+
raise ValueError("pass exactly one of user_id or group_id")
|
|
114
|
+
params = {}
|
|
115
|
+
if user_id is not None:
|
|
116
|
+
params["user_id"] = _digits(user_id, "user_id")
|
|
117
|
+
else:
|
|
118
|
+
params["group_id"] = _digits(group_id, "group_id")
|
|
119
|
+
if per_page is not None:
|
|
120
|
+
if isinstance(per_page, bool) or not isinstance(per_page, int) \
|
|
121
|
+
or not 3 <= per_page <= 20:
|
|
122
|
+
raise ValueError(f"per_page must be an integer 3-20, "
|
|
123
|
+
f"got: {per_page!r}")
|
|
124
|
+
params["per_page"] = per_page
|
|
125
|
+
if after is not None:
|
|
126
|
+
params["after"] = after
|
|
127
|
+
return self._t.request("GET", "/api/v1/feed", params=params)
|
|
128
|
+
|
|
129
|
+
# ---------- Connections ----------
|
|
130
|
+
|
|
131
|
+
def followers(self, user_id, after=None):
|
|
132
|
+
"""User followers. Raw GraphQL: data.full_profile_list.list_items."""
|
|
133
|
+
return self._t.request("GET", "/api/v1/followers",
|
|
134
|
+
params=_conn(user_id, after))
|
|
135
|
+
|
|
136
|
+
def followings(self, user_id, after=None):
|
|
137
|
+
"""User followings."""
|
|
138
|
+
return self._t.request("GET", "/api/v1/followings",
|
|
139
|
+
params=_conn(user_id, after))
|
|
140
|
+
|
|
141
|
+
def friends(self, user_id, after=None):
|
|
142
|
+
"""User friends."""
|
|
143
|
+
return self._t.request("GET", "/api/v1/friends",
|
|
144
|
+
params=_conn(user_id, after))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _conn(user_id, after):
|
|
148
|
+
params = {"user_id": _digits(user_id, "user_id")}
|
|
149
|
+
if after is not None:
|
|
150
|
+
params["after"] = after
|
|
151
|
+
return params
|
|
152
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
APIKeyError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
GetSocialFacebookError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
UpstreamError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.getsocialapi.com/fb"
|
|
15
|
+
|
|
16
|
+
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Transport:
|
|
20
|
+
"""HTTP transport: X-API-Key auth, JSON parsing, retries."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_url=DEFAULT_BASE_URL, api_key=None, timeout=30,
|
|
23
|
+
max_retries=3, session=None):
|
|
24
|
+
self.base_url = base_url.rstrip("/")
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
self.max_retries = max_retries
|
|
28
|
+
self.session = session or requests.Session()
|
|
29
|
+
|
|
30
|
+
def request(self, method, path, params=None):
|
|
31
|
+
url = f"{self.base_url}{path}"
|
|
32
|
+
headers = {"X-API-Key": self.api_key}
|
|
33
|
+
attempts = self.max_retries + 1
|
|
34
|
+
for attempt in range(attempts):
|
|
35
|
+
try:
|
|
36
|
+
resp = self.session.request(
|
|
37
|
+
method, url, params=params, headers=headers, timeout=self.timeout,
|
|
38
|
+
)
|
|
39
|
+
except (requests.ConnectionError, requests.Timeout) as e:
|
|
40
|
+
if attempt < attempts - 1:
|
|
41
|
+
time.sleep(min(2 ** attempt, 10))
|
|
42
|
+
continue
|
|
43
|
+
raise GetSocialFacebookError(f"Network error: {e}") from e
|
|
44
|
+
|
|
45
|
+
if resp.status_code in RETRYABLE_STATUS and attempt < attempts - 1:
|
|
46
|
+
time.sleep(self._retry_after(resp))
|
|
47
|
+
continue
|
|
48
|
+
return self._parse(resp)
|
|
49
|
+
raise GetSocialFacebookError("Request failed after retries") # pragma: no cover
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _retry_after(resp):
|
|
53
|
+
try:
|
|
54
|
+
return min(max(int(float(resp.headers.get("Retry-After", 1))), 0), 30)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _parse(resp):
|
|
60
|
+
try:
|
|
61
|
+
body = resp.json()
|
|
62
|
+
except ValueError:
|
|
63
|
+
body = {"raw": resp.text}
|
|
64
|
+
|
|
65
|
+
if resp.status_code == 400:
|
|
66
|
+
raise BadRequestError(resp, body)
|
|
67
|
+
if resp.status_code == 401:
|
|
68
|
+
raise APIKeyError(resp, body)
|
|
69
|
+
if resp.status_code == 429:
|
|
70
|
+
raise RateLimitError(resp, body, limit=(body or {}).get("limit"))
|
|
71
|
+
if resp.status_code == 502:
|
|
72
|
+
raise UpstreamError(resp, body)
|
|
73
|
+
if resp.status_code == 503:
|
|
74
|
+
raise ServiceUnavailableError(resp, body)
|
|
75
|
+
if resp.status_code >= 400:
|
|
76
|
+
raise GetSocialFacebookError(f"HTTP {resp.status_code}: {body}", resp, body)
|
|
77
|
+
return body
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: getsocialapi-facebook
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GetSocialAPI — Python client for the Facebook Scraper API (groups, profiles, connections)
|
|
5
|
+
Author: GetSocialAPI
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://getsocialapi.com
|
|
8
|
+
Project-URL: Documentation, https://getsocialapi.com/docs/
|
|
9
|
+
Keywords: facebook,api,scraping,sdk,groups,members
|
|
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
|
+
# Facebook Scraper API
|
|
22
|
+
|
|
23
|
+
[](https://github.com/GetSocialAPI/Facebook-Scraper-API/actions/workflows/tests.yml)
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
[](https://www.python.org)
|
|
26
|
+
|
|
27
|
+
Python client for the **[GetSocialAPI](https://getsocialapi.com/)** Facebook Scraper API.
|
|
28
|
+
|
|
29
|
+
- Groups: resolve links, overview, paginated members
|
|
30
|
+
- Profiles: resolve links, About/contacts in raw GraphQL
|
|
31
|
+
- Connections: followers, followings, friends
|
|
32
|
+
- Raw responses — no transformations
|
|
33
|
+
- Automatic retries on 429/502/503, typed exceptions
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install git+https://github.com/GetSocialAPI/Facebook-Scraper-API.git
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from getsocialapi_facebook import Client
|
|
45
|
+
|
|
46
|
+
# Get your API key at https://getsocialapi.com/dashboard/
|
|
47
|
+
client = Client(api_key="gsa_live_...") # or set GETSOCIALAPI_FB_API_KEY
|
|
48
|
+
|
|
49
|
+
# Resolve any group link (slug, m.facebook, share/g) to a group_id
|
|
50
|
+
group = client.fb.resolve("https://www.facebook.com/groups/freegarden/")
|
|
51
|
+
group_id = group["group_id"]
|
|
52
|
+
|
|
53
|
+
# Profile links resolve the same way
|
|
54
|
+
user = client.fb.resolve_profile("https://www.facebook.com/nasa")
|
|
55
|
+
user_id = user["user_id"]
|
|
56
|
+
|
|
57
|
+
# Group card
|
|
58
|
+
client.fb.overview(group_id)
|
|
59
|
+
|
|
60
|
+
# Members: raw GraphQL pages (1st -> data.group, 2nd+ -> data.fetch__Group)
|
|
61
|
+
page = client.fb.members(group_id) # fixed page of 15 members
|
|
62
|
+
page["data"]["group"]["group_member_profiles"]["page_info"]["end_cursor"]
|
|
63
|
+
|
|
64
|
+
# Next page: pass page_info.end_cursor as `after`
|
|
65
|
+
client.fb.members(group_id, after="AQHTm5Xq…") # end_cursor from page_info
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Base URL: `https://api.getsocialapi.com/fb` — authenticate with the API key
|
|
69
|
+
from your [dashboard](https://getsocialapi.com/dashboard/).
|
|
70
|
+
|
|
71
|
+
## Client
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
Client(api_key=None, base_url="https://api.getsocialapi.com/fb", timeout=30, max_retries=3)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `api_key` — your API key (env var `GETSOCIALAPI_FB_API_KEY` works too)
|
|
78
|
+
|
|
79
|
+
## Methods
|
|
80
|
+
|
|
81
|
+
| Method | Route | Description |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `resolve(url)` | `GET /resolve` | group link → `group_id` (numeric id, slug, m.facebook, `/share/g/…`) |
|
|
84
|
+
| `resolve_profile(url)` | `GET /resolve/profile` | profile link → `user_id` |
|
|
85
|
+
| `overview(group_id)` | `GET /overview` | group card: name, visibility |
|
|
86
|
+
| `members(group_id, after=None)` | `GET /api/v1/members` | raw GraphQL page of 15 (1st — `data.group`, 2nd+ — `data.fetch__Group`) |
|
|
87
|
+
| `group_posts(group_id, after=None)` | `GET /api/v1/group_posts` | group feed: page of 2–3 `Story` posts, cursor from `group_feed.page_info.end_cursor` |
|
|
88
|
+
| `contacts(user_id)` | `GET /api/v1/contacts` | profile About: raw GraphQL |
|
|
89
|
+
| `posts(user_id, after=None)` | `GET /api/v1/posts` | user timeline: page of ~100 posts (the fixed service maximum), cursor from `timeline_feed_units.page_info.end_cursor` |
|
|
90
|
+
| `feed(user_id=None, group_id=None, per_page=None, after=None)` | `GET /api/v1/feed` | unified user/group feed, small pages: `per_page` 3–20 (default 10) |
|
|
91
|
+
| `followers(user_id, after=None)` | `GET /api/v1/followers` | raw GraphQL `full_profile_list` |
|
|
92
|
+
| `followings(user_id, after=None)` | `GET /api/v1/followings` | same shape |
|
|
93
|
+
| `friends(user_id, after=None)` | `GET /api/v1/friends` | same shape |
|
|
94
|
+
|
|
95
|
+
Full request/response reference with live examples:
|
|
96
|
+
[getsocialapi.com/docs — API Reference / Facebook](https://getsocialapi.com/docs/api/facebook/resolve/).
|
|
97
|
+
|
|
98
|
+
### Pagination
|
|
99
|
+
|
|
100
|
+
Every method returns one raw page. Pass the connection's
|
|
101
|
+
`page_info.end_cursor` as `after` while `has_next_page` is true.
|
|
102
|
+
|
|
103
|
+
| List | Page size (fixed) | Cursor source |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `posts` | ~100 posts — the service maximum, there is no `count` parameter | `data.profile.timeline_feed_units.page_info.end_cursor` |
|
|
106
|
+
| `feed` | `per_page` 3–20 (default 10); a page may come back short — follow `has_next_page` | same connection as the target: `timeline_feed_units` / `group_feed` `page_info.end_cursor` |
|
|
107
|
+
| `group_posts` | 2–3 `Story` posts (skip `GroupsSectionHeaderUnit` edges, dedupe by `cache_id`) | `data.group_address.group_feed.page_info.end_cursor` |
|
|
108
|
+
| `members` | 15 | `data.(group / fetch__Group).group_member_profiles.page_info.end_cursor` |
|
|
109
|
+
| `followers` / `followings` / `friends` | 15 | `…list_items.page_info.end_cursor` |
|
|
110
|
+
|
|
111
|
+
Cursors are reusable and not bound to a session; a broken or foreign cursor
|
|
112
|
+
returns `400 Invalid Cursor` — take `end_cursor` only from the connection's
|
|
113
|
+
`page_info`, never from node fields.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
posts, after = [], None
|
|
117
|
+
while True:
|
|
118
|
+
page = client.fb.posts(user_id, after=after)
|
|
119
|
+
tfu = page["data"]["profile"]["timeline_feed_units"]
|
|
120
|
+
posts += [e["node"] for e in tfu["edges"]]
|
|
121
|
+
pi = tfu.get("page_info") or {}
|
|
122
|
+
if not pi.get("has_next_page") or not pi.get("end_cursor"):
|
|
123
|
+
break
|
|
124
|
+
after = pi["end_cursor"]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Response notes
|
|
128
|
+
|
|
129
|
+
- members page 1 lives in `data.group`, following pages in `data.fetch__Group`
|
|
130
|
+
- connection lists: `can_see_follower_list: false` means the list is hidden by
|
|
131
|
+
privacy settings (only the count is available)
|
|
132
|
+
|
|
133
|
+
## Errors
|
|
134
|
+
|
|
135
|
+
| Exception | HTTP | Notes |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| `GetSocialFacebookError` | — | base class; also network errors |
|
|
138
|
+
| `BadRequestError` | 400 | bad parameters |
|
|
139
|
+
| `APIKeyError` | 401 | invalid key |
|
|
140
|
+
| `RateLimitError` | 429 | `.limit` from the response |
|
|
141
|
+
| `UpstreamError` | 502 | temporary upstream error — retried automatically |
|
|
142
|
+
| `ServiceUnavailableError` | 503 | service overloaded — retried automatically |
|
|
143
|
+
|
|
144
|
+
Only successful responses count against your quota.
|
|
145
|
+
Plans and rate limits: [getsocialapi.com/#pricing](https://getsocialapi.com/#pricing).
|
|
146
|
+
|
|
147
|
+
## Support
|
|
148
|
+
|
|
149
|
+
- Telegram: [@getsocialapi](https://t.me/getsocialapi)
|
|
150
|
+
- Email: [hello@getsocialapi.com](mailto:hello@getsocialapi.com)
|
|
151
|
+
- Website: [getsocialapi.com](https://getsocialapi.com/)
|
|
152
|
+
- Documentation: [getsocialapi.com/docs](https://getsocialapi.com/docs/)
|
|
153
|
+
- Status: [getsocialapi.com/status](https://getsocialapi.com/status/)
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
getsocialapi_facebook/__init__.py
|
|
5
|
+
getsocialapi_facebook/client.py
|
|
6
|
+
getsocialapi_facebook/exceptions.py
|
|
7
|
+
getsocialapi_facebook/facebook.py
|
|
8
|
+
getsocialapi_facebook/http.py
|
|
9
|
+
getsocialapi_facebook.egg-info/PKG-INFO
|
|
10
|
+
getsocialapi_facebook.egg-info/SOURCES.txt
|
|
11
|
+
getsocialapi_facebook.egg-info/dependency_links.txt
|
|
12
|
+
getsocialapi_facebook.egg-info/requires.txt
|
|
13
|
+
getsocialapi_facebook.egg-info/top_level.txt
|
|
14
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.25
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
getsocialapi_facebook
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "getsocialapi-facebook"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "GetSocialAPI — Python client for the Facebook Scraper API (groups, profiles, connections)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "GetSocialAPI" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["requests>=2.25"]
|
|
14
|
+
keywords = ["facebook", "api", "scraping", "sdk", "groups", "members"]
|
|
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_facebook*"]
|
|
@@ -0,0 +1,191 @@
|
|
|
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_facebook import Client, FacebookAPI # noqa: E402
|
|
10
|
+
from getsocialapi_facebook.exceptions import ( # noqa: E402
|
|
11
|
+
APIKeyError,
|
|
12
|
+
BadRequestError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServiceUnavailableError,
|
|
15
|
+
UpstreamError,
|
|
16
|
+
)
|
|
17
|
+
from getsocialapi_facebook.http import Transport # noqa: E402
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fake_response(status, body=None, headers=None):
|
|
21
|
+
r = MagicMock()
|
|
22
|
+
r.status_code = status
|
|
23
|
+
r.headers = headers or {}
|
|
24
|
+
r.text = json.dumps(body) if body is not None else ""
|
|
25
|
+
r.json = (lambda b=body: b) if body is not None else (lambda: (_ for _ in ()).throw(ValueError()))
|
|
26
|
+
return r
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_client(session):
|
|
30
|
+
return Client(api_key="test_key", session=session, max_retries=0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestErrorMessages(unittest.TestCase):
|
|
34
|
+
def test_reason_shapes(self):
|
|
35
|
+
from getsocialapi_facebook.exceptions import BadRequestError
|
|
36
|
+
self.assertEqual(str(BadRequestError(body={"message": "bad thing", "status": "fail"})), "bad thing")
|
|
37
|
+
self.assertEqual(str(BadRequestError(body={"error": "invalid id"})), "invalid id")
|
|
38
|
+
self.assertEqual(str(BadRequestError(body={"error": {"message": "nested"}})), "nested")
|
|
39
|
+
self.assertEqual(str(BadRequestError(body={})), "Bad request")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestClient(unittest.TestCase):
|
|
43
|
+
def test_requires_key(self):
|
|
44
|
+
with self.assertRaises(ValueError):
|
|
45
|
+
Client(api_key=None)
|
|
46
|
+
|
|
47
|
+
def test_sends_api_key_header(self):
|
|
48
|
+
s = MagicMock()
|
|
49
|
+
s.request.return_value = fake_response(200, {"group_id": "1"})
|
|
50
|
+
make_client(s).fb.overview("1")
|
|
51
|
+
headers = s.request.call_args.kwargs["headers"]
|
|
52
|
+
self.assertEqual(headers, {"X-API-Key": "test_key"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestErrors(unittest.TestCase):
|
|
56
|
+
def _transport(self, resp):
|
|
57
|
+
t = Transport(api_key="k", max_retries=0)
|
|
58
|
+
t.session = MagicMock()
|
|
59
|
+
t.session.request.return_value = resp
|
|
60
|
+
return t
|
|
61
|
+
|
|
62
|
+
def test_400(self):
|
|
63
|
+
with self.assertRaises(BadRequestError) as ctx:
|
|
64
|
+
self._transport(fake_response(400, {"error": "bad params"})).request("GET", "/x")
|
|
65
|
+
self.assertEqual(str(ctx.exception), "bad params")
|
|
66
|
+
|
|
67
|
+
def test_401(self):
|
|
68
|
+
with self.assertRaises(APIKeyError):
|
|
69
|
+
self._transport(fake_response(401, {"error": "invalid key"})).request("GET", "/x")
|
|
70
|
+
|
|
71
|
+
def test_429_limit(self):
|
|
72
|
+
with self.assertRaises(RateLimitError) as ctx:
|
|
73
|
+
self._transport(fake_response(429, {"error": "rate limit", "limit": 32})).request("GET", "/x")
|
|
74
|
+
self.assertEqual(ctx.exception.limit, 32)
|
|
75
|
+
|
|
76
|
+
def test_502(self):
|
|
77
|
+
with self.assertRaises(UpstreamError):
|
|
78
|
+
self._transport(fake_response(502, {"error": "could not resolve group id from x"})).request("GET", "/x")
|
|
79
|
+
|
|
80
|
+
def test_503(self):
|
|
81
|
+
with self.assertRaises(ServiceUnavailableError):
|
|
82
|
+
self._transport(fake_response(503, {})).request("GET", "/x")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TestFacebookAPI(unittest.TestCase):
|
|
86
|
+
def api(self):
|
|
87
|
+
t = MagicMock()
|
|
88
|
+
return FacebookAPI(t), t
|
|
89
|
+
|
|
90
|
+
def check(self, method, args, expected_path, expected_params=None, kwargs=None):
|
|
91
|
+
a, t = self.api()
|
|
92
|
+
getattr(a, method)(*args, **(kwargs or {}))
|
|
93
|
+
call = t.request.call_args
|
|
94
|
+
self.assertEqual(call.args, ("GET", expected_path))
|
|
95
|
+
if expected_params is not None:
|
|
96
|
+
self.assertEqual(call.kwargs.get("params"), expected_params)
|
|
97
|
+
|
|
98
|
+
def test_resolve(self):
|
|
99
|
+
url = "https://www.facebook.com/groups/freegarden/"
|
|
100
|
+
self.check("resolve", (url,), "/resolve", {"url": url})
|
|
101
|
+
|
|
102
|
+
def test_overview(self):
|
|
103
|
+
self.check("overview", ("800246542990860",), "/overview",
|
|
104
|
+
{"group_id": "800246542990860"})
|
|
105
|
+
|
|
106
|
+
def test_members(self):
|
|
107
|
+
self.check("members", ("800246542990860",), "/api/v1/members",
|
|
108
|
+
{"group_id": "800246542990860"})
|
|
109
|
+
|
|
110
|
+
def test_members_after(self):
|
|
111
|
+
self.check("members", ("800246542990860",), "/api/v1/members",
|
|
112
|
+
{"group_id": "800246542990860", "after": "AQH"}, {"after": "AQH"})
|
|
113
|
+
|
|
114
|
+
def test_contacts(self):
|
|
115
|
+
self.check("contacts", ("100001797717646",), "/api/v1/contacts",
|
|
116
|
+
{"user_id": "100001797717646"})
|
|
117
|
+
|
|
118
|
+
def test_resolve_profile(self):
|
|
119
|
+
self.check("resolve_profile", ("https://www.facebook.com/zuck",), "/resolve/profile",
|
|
120
|
+
{"url": "https://www.facebook.com/zuck"})
|
|
121
|
+
|
|
122
|
+
def test_followers(self):
|
|
123
|
+
self.check("followers", ("61595500131774",), "/api/v1/followers",
|
|
124
|
+
{"user_id": "61595500131774"})
|
|
125
|
+
|
|
126
|
+
def test_followings_after(self):
|
|
127
|
+
self.check("followings", ("61595500131774",), "/api/v1/followings",
|
|
128
|
+
{"user_id": "61595500131774", "after": "C"}, {"after": "C"})
|
|
129
|
+
|
|
130
|
+
def test_friends(self):
|
|
131
|
+
self.check("friends", ("61595500131774",), "/api/v1/friends",
|
|
132
|
+
{"user_id": "61595500131774"})
|
|
133
|
+
|
|
134
|
+
def test_posts(self):
|
|
135
|
+
self.check("posts", ("100053723115450",), "/api/v1/posts",
|
|
136
|
+
{"user_id": "100053723115450"})
|
|
137
|
+
|
|
138
|
+
def test_posts_after(self):
|
|
139
|
+
cursor = "AQ" * 444 # ~888-char base64 end_cursor
|
|
140
|
+
self.check("posts", ("100053723115450",), "/api/v1/posts",
|
|
141
|
+
{"user_id": "100053723115450", "after": cursor},
|
|
142
|
+
{"after": cursor})
|
|
143
|
+
|
|
144
|
+
def test_group_posts(self):
|
|
145
|
+
self.check("group_posts", ("800246542990860",), "/api/v1/group_posts",
|
|
146
|
+
{"group_id": "800246542990860"})
|
|
147
|
+
|
|
148
|
+
def test_group_posts_after(self):
|
|
149
|
+
cur = "Cg8TZXhpc3RpbmdfdW5pdF9jb3VudA"
|
|
150
|
+
self.check("group_posts", ("800246542990860",), "/api/v1/group_posts",
|
|
151
|
+
{"group_id": "800246542990860", "after": cur},
|
|
152
|
+
{"after": cur})
|
|
153
|
+
|
|
154
|
+
def test_feed_user(self):
|
|
155
|
+
self.check("feed", ("100053723115450",), "/api/v1/feed",
|
|
156
|
+
{"user_id": "100053723115450"})
|
|
157
|
+
|
|
158
|
+
def test_feed_group_per_page(self):
|
|
159
|
+
self.check("feed", (), "/api/v1/feed",
|
|
160
|
+
{"group_id": "800246542990860", "per_page": 10},
|
|
161
|
+
{"group_id": "800246542990860", "per_page": 10})
|
|
162
|
+
|
|
163
|
+
def test_feed_after(self):
|
|
164
|
+
cur = "AQ" * 444
|
|
165
|
+
self.check("feed", ("100053723115450",), "/api/v1/feed",
|
|
166
|
+
{"user_id": "100053723115450", "after": cur},
|
|
167
|
+
{"after": cur})
|
|
168
|
+
|
|
169
|
+
def test_feed_validation(self):
|
|
170
|
+
a, _ = self.api()
|
|
171
|
+
with self.assertRaises(ValueError):
|
|
172
|
+
a.feed() # ни одного id
|
|
173
|
+
with self.assertRaises(ValueError):
|
|
174
|
+
a.feed("100053723115450", "800246542990860") # оба
|
|
175
|
+
with self.assertRaises(ValueError):
|
|
176
|
+
a.feed(user_id="100053723115450", per_page=21)
|
|
177
|
+
with self.assertRaises(ValueError):
|
|
178
|
+
a.feed(user_id="100053723115450", per_page=2)
|
|
179
|
+
|
|
180
|
+
def test_validation(self):
|
|
181
|
+
a, _ = self.api()
|
|
182
|
+
with self.assertRaises(ValueError):
|
|
183
|
+
a.overview("not-digits")
|
|
184
|
+
with self.assertRaises(ValueError):
|
|
185
|
+
a.members("12x")
|
|
186
|
+
with self.assertRaises(ValueError):
|
|
187
|
+
a.resolve(" ")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == "__main__":
|
|
191
|
+
unittest.main(verbosity=2)
|