Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.3] — 2026-10-16

### Added

- **Facebook photo albums** (sync and async): `get_page_albums(profile_id)`,
`get_group_albums(group_id)` and `get_album_items(album_id)`, backed by
`/facebook/pages/albums`, `/facebook/groups/albums` and
`/facebook/albums/items`. The SDK now covers all 53 endpoints.

### Fixed

- **`get_group_videos`** sent `group_id`, but `/facebook/groups/videos`
requires `link`, so calls failed. It now takes the group URL, slug or
numeric ID and sends `link` (positional calls keep working; the keyword
argument is renamed from `group_id` to `group`).

## [0.1.2] — 2026-10-05

### Fixed
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ This SDK is the **drop-in successor**:
| **Pagination** | Generator with edge-case bugs | Cursor-based; API decides page size |
| **Error handling** | Generic exceptions | Typed hierarchy (`RateLimitError`, etc.) |
| **CI / tests** | Manual against live FB | Recorded HTTP fixtures, Python 3.10–3.13 |
| **Coverage** | Page posts, group posts only | **50 endpoints** across FB + IG |
| **Coverage** | Page posts, group posts only | **53 endpoints** across FB + IG |

The trade-off: instead of running a scraper yourself, you make a REST call to our hosted
API. **200 calls/month free**, no credit card. Paid plans start at $4.99/mo for 1,500
Expand All @@ -94,12 +94,17 @@ calls.
- `get_page_posts(page)` — recent posts
- `get_page_reels(page)` — short-form videos
- `get_page_videos(page)` — long-form videos
- `get_page_albums(profile_id)` — photo albums (numeric page ID from `get_page_id`)

**Groups**
- `get_group_id(group)`
- `get_group_details(group)` → `GroupInfo` (typed model)
- `get_group_posts(group)`
- `get_group_videos(group_id)`
- `get_group_videos(group)` — URL, slug or numeric ID
- `get_group_albums(group_id)` — photo albums

**Albums**
- `get_album_items(album_id)` — photos and videos in a page or group album

**Posts**
- `get_post_id(post)` — extract numeric ID from URL
Expand Down
2 changes: 1 addition & 1 deletion socialapis/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
# this manually for a release — use `git tag vX.Y.Z` and the CI does the
# rest via hatchling's dynamic-version feature (see pyproject.toml).

__version__ = "0.1.2"
__version__ = "0.1.3"
69 changes: 65 additions & 4 deletions socialapis/facebook/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,19 @@ def get_page_videos(self, page: str, **kwargs: Any) -> dict[str, Any]:
_params(("link", _as_facebook_url(page)), extra=kwargs),
).json()

def get_page_albums(self, profile_id: str, **kwargs: Any) -> dict[str, Any]:
"""Return the photo albums of a Facebook Page.

Backed by ``GET /facebook/pages/albums``. Takes the numeric page ID
(use :meth:`get_page_id` to resolve a URL first). Pass ``limit``
(8-16) or ``end_cursor`` as keyword arguments. Use
:meth:`get_album_items` for the photos in one album.
"""
return self._get(
"/facebook/pages/albums",
_params(("profile_id", profile_id), extra=kwargs),
).json()

# =======================================================================
# GROUPS
# =======================================================================
Expand Down Expand Up @@ -274,17 +287,41 @@ def get_group_posts(self, group: str, **kwargs: Any) -> dict[str, Any]:
_params(("link", _as_facebook_group_url(group)), extra=kwargs),
).json()

def get_group_videos(self, group_id: str, **kwargs: Any) -> dict[str, Any]:
def get_group_videos(self, group: str, **kwargs: Any) -> dict[str, Any]:
"""Return videos posted to a Facebook Group.

Backed by ``GET /facebook/groups/videos``. Takes a numeric
``group_id`` (use :meth:`get_group_id` to resolve a URL first).
Backed by ``GET /facebook/groups/videos``. ``group`` can be the group
URL, slug or numeric ID. Pass ``end_cursor`` for the next page.
"""
return self._get(
"/facebook/groups/videos",
_params(("link", _as_facebook_group_url(group)), extra=kwargs),
).json()

def get_group_albums(self, group_id: str, **kwargs: Any) -> dict[str, Any]:
"""Return the photo albums of a public Facebook Group.

Backed by ``GET /facebook/groups/albums``. Takes a numeric
``group_id`` (use :meth:`get_group_id` to resolve a URL first).
Pass ``limit`` (8-16) as a keyword argument.
"""
return self._get(
"/facebook/groups/albums",
_params(("group_id", group_id), extra=kwargs),
).json()

def get_album_items(self, album_id: str, **kwargs: Any) -> dict[str, Any]:
"""Return the photos and videos inside a Facebook album.

Backed by ``GET /facebook/albums/items``. Works for page and group
albums; ``album_id`` comes from :meth:`get_page_albums` or
:meth:`get_group_albums`. Pass ``end_cursor`` for the next page.
"""
return self._get(
"/facebook/albums/items",
_params(("album_id", album_id), extra=kwargs),
).json()

# =======================================================================
# POSTS
# =======================================================================
Expand Down Expand Up @@ -700,6 +737,14 @@ async def get_page_videos(self, page: str, **kwargs: Any) -> dict[str, Any]:
)
).json()

async def get_page_albums(self, profile_id: str, **kwargs: Any) -> dict[str, Any]:
return (
await self._get(
"/facebook/pages/albums",
_params(("profile_id", profile_id), extra=kwargs),
)
).json()

# =======================================================================
# GROUPS
# =======================================================================
Expand Down Expand Up @@ -741,14 +786,30 @@ async def get_group_posts(self, group: str, **kwargs: Any) -> dict[str, Any]:
)
).json()

async def get_group_videos(self, group_id: str, **kwargs: Any) -> dict[str, Any]:
async def get_group_videos(self, group: str, **kwargs: Any) -> dict[str, Any]:
return (
await self._get(
"/facebook/groups/videos",
_params(("link", _as_facebook_group_url(group)), extra=kwargs),
)
).json()

async def get_group_albums(self, group_id: str, **kwargs: Any) -> dict[str, Any]:
return (
await self._get(
"/facebook/groups/albums",
_params(("group_id", group_id), extra=kwargs),
)
).json()

async def get_album_items(self, album_id: str, **kwargs: Any) -> dict[str, Any]:
return (
await self._get(
"/facebook/albums/items",
_params(("album_id", album_id), extra=kwargs),
)
).json()

# =======================================================================
# POSTS
# =======================================================================
Expand Down
46 changes: 46 additions & 0 deletions tests/test_facebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,49 @@ async def test_async_search_marketplace_works() -> None:
async with AsyncFacebook(api_token="t") as fb:
result = await fb.search_marketplace("cars")
assert result == {"listings": []}


@respx.mock
def test_get_page_albums_passes_profile_id_and_limit() -> None:
route = respx.get("https://api.socialapis.io/facebook/pages/albums").mock(
return_value=httpx.Response(200, json={"albums": []})
)
with Facebook(api_token="t") as fb:
fb.get_page_albums("100044395330646", limit=16)
params = route.calls.last.request.url.params
assert params["profile_id"] == "100044395330646"
assert params["limit"] == "16"


@respx.mock
def test_get_group_albums_passes_group_id() -> None:
route = respx.get("https://api.socialapis.io/facebook/groups/albums").mock(
return_value=httpx.Response(200, json={"albums": []})
)
with Facebook(api_token="t") as fb:
fb.get_group_albums("187988788687356")
assert route.calls.last.request.url.params["group_id"] == "187988788687356"


@respx.mock
async def test_async_get_album_items_passes_album_id_and_cursor() -> None:
route = respx.get("https://api.socialapis.io/facebook/albums/items").mock(
return_value=httpx.Response(200, json={"items": []})
)
async with AsyncFacebook(api_token="t") as fb:
await fb.get_album_items("826298618859975", end_cursor="abc")
params = route.calls.last.request.url.params
assert params["album_id"] == "826298618859975"
assert params["end_cursor"] == "abc"


@respx.mock
def test_get_group_videos_sends_link() -> None:
route = respx.get("https://api.socialapis.io/facebook/groups/videos").mock(
return_value=httpx.Response(200, json={"videos": []})
)
with Facebook(api_token="t") as fb:
fb.get_group_videos("187988788687356")
params = route.calls.last.request.url.params
assert params["link"] == "https://www.facebook.com/groups/187988788687356"
assert "group_id" not in params
Loading