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
49 changes: 48 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,54 @@ 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.0] — Unreleased
## [0.1.1] — 2026-06-22

### Fixed (breaking for the typed-model methods)

- **`Facebook.get_page_info` / `AsyncFacebook.get_page_info`** now correctly
unwrap the API's response envelope. The endpoint returns
`{"0": {...payload}, "message": ..., "meta": ...}` — v0.1.0 passed the
whole envelope to `PageInfo.model_validate()`, which left every typed
attribute as `None`. v0.1.1 extracts the `"0"` key before validation.
- **`Instagram.get_profile_details` / `AsyncInstagram.get_profile_details`**
same fix — the endpoint wraps the payload under `"data"` (with sibling
`"success"`, `"message"`, `"meta"`); v0.1.0 didn't unwrap.
- **`PageInfo`** rewritten with the real field names the API returns
(`ad_page_id`, `user_id`, `title`, `category`, `bio`, `description`,
`followers_count`, `likes_count`, `image`, `rating`, `business_hours`,
`twitter`/`instagram`/`linkedin`/`pinterest`/`telegram`/`youtube`, etc.).
Removed v0.1.0's invented fields (`name`, `likes`, `verified`,
`profile_image_url`) — they never populated against real responses.
- **`GroupInfo`** rewritten with the real field names
(`group_id`, `group_member_count`, `description_text`,
`created_time`, `group_rules`, `number_of_posts_in_last_day`, etc.).
- **`ProfileInfo`** rewritten with the real Instagram field names
(`id`, `pk`, `fbid`, `full_name`, `followers_count`, `following_count`,
`media_count`, `is_verified`, `profile_pic_url`, etc.). Removed v0.1.0's
invented `posts_count`, `follower_count` (singular) aliases.

### Why this is a breaking change

If any code was reading `page.likes`, `page.name`, `profile.followers`,
`profile.posts_count`, those will now raise `AttributeError`. v0.1.0 left
all those attributes as `None` (because the envelope was never unwrapped),
so any code that checked for them was already not getting real data — but
the rename is still technically breaking.

Discovered via a real-API smoke test (added in
`scripts/integration_smoke.py`). Every other endpoint method that returns
`dict[str, Any]` was unaffected — those already pass through the raw
response.

### Unchanged

- Public import paths (`from socialapis import Facebook, Instagram, ...`)
- All wire-level behaviour (URLs, headers, query params)
- The typed exception hierarchy (`AuthenticationError`,
`RateLimitError`, etc.)
- The migration aliases (`FacebookScraper`, `InstagramScraper`)

## [0.1.0] — 2026-06-22

First public release. Full coverage of the SocialAPIs.io public REST surface
in one shot — no v0.2/v0.3 follow-ups required for core endpoints.
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.0"
__version__ = "0.1.1"
17 changes: 15 additions & 2 deletions socialapis/facebook/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,15 @@ def get_page_info(self, page: str, **kwargs: Any) -> PageInfo:
"/facebook/pages/details",
_params(("link", _as_facebook_url(page)), extra=kwargs),
)
return PageInfo.model_validate(response.json())
# This endpoint wraps the page payload under string key "0"
# alongside the "message" and "meta" envelope keys. Unwrap
# before validation; fall back to the raw body if the shape
# ever changes upstream.
body = response.json()
payload = (
body.get("0") if isinstance(body, dict) and isinstance(body.get("0"), dict) else body
)
return PageInfo.model_validate(payload)

def get_page_posts(self, page: str, **kwargs: Any) -> dict[str, Any]:
"""Return recent posts from a Facebook Page.
Expand Down Expand Up @@ -654,7 +662,12 @@ async def get_page_info(self, page: str, **kwargs: Any) -> PageInfo:
"/facebook/pages/details",
_params(("link", _as_facebook_url(page)), extra=kwargs),
)
return PageInfo.model_validate(response.json())
# Same envelope-unwrap as the sync client — see Facebook.get_page_info.
body = response.json()
payload = (
body.get("0") if isinstance(body, dict) and isinstance(body.get("0"), dict) else body
)
return PageInfo.model_validate(payload)

async def get_page_posts(self, page: str, **kwargs: Any) -> dict[str, Any]:
return (
Expand Down
129 changes: 87 additions & 42 deletions socialapis/facebook/_types.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
"""Pydantic v2 response models for the Facebook namespace.

Design decision: we hand-craft typed models for a small set of "headline"
endpoints (PageInfo, GroupInfo, PostDetails, ProfileDetails) where IDE
autocomplete is most valuable. The niche endpoints (Ads Library archive
details, Marketplace city coordinates, etc.) return plain `dict[str, Any]`
to keep the SDK shipping fast — callers who care can build typed wrappers
themselves.
All models use `extra="allow"` so the API can ADD fields without breaking
us. Field names below mirror the EXACT field names the live API returns
(verified against a real token, 2026-06-22). Anything else lands in
`model_extra` and is accessible via `.model_dump()` for forward-compat.

Every typed model uses `extra="allow"` so the API can ADD fields without
breaking existing integrations. Old fields can be removed; the attribute
just goes None.
The niche endpoints (Ads Library archive details, Marketplace city
coordinates, etc.) return plain `dict[str, Any]` to keep the SDK
shipping fast — callers who want type safety can build wrappers
themselves.
"""

from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field
from typing import Any

from pydantic import BaseModel, ConfigDict


class _Model(BaseModel):
Expand All @@ -35,41 +36,85 @@ class _Model(BaseModel):
class PageInfo(_Model):
"""Public metadata returned by `Facebook.get_page_info()`.

Backed by `GET /facebook/pages/details`. Common fields typed for
autocomplete; anything else the API returns is preserved in
`model_extra`.
Backed by `GET /facebook/pages/details`. The API wraps this payload
under a string key `"0"` in the response envelope — the SDK
unwraps that before validation.

Field names match the live API exactly. Most fields are optional
because the API populates them only when the page provides them
(e.g. a personal page won't have business_hours).
"""

id: str | None = Field(default=None, description="Facebook's internal page identifier.")
name: str | None = Field(default=None, description="Display name of the page.")
url: str | None = Field(default=None, description="Canonical Facebook URL.")
category: str | None = Field(default=None, description="Page category, e.g. 'Public figure'.")
likes: int | None = Field(default=None, description="Cumulative like count, when available.")
followers: int | None = Field(default=None, description="Follower count.")
verified: bool | None = Field(
default=None, description="Whether the page has a blue checkmark."
)
about: str | None = Field(default=None, description="Free-text 'About' description.")
website: str | None = Field(default=None, description="Linked external website, when present.")
profile_image_url: str | None = Field(
default=None,
description="URL to the page's profile image.",
alias="profileImageUrl",
)
cover_image_url: str | None = Field(
default=None,
description="URL to the page's cover image.",
alias="coverImageUrl",
)
# Identifiers
ad_page_id: str | None = None
user_id: str | None = None

# Display
title: str | None = None
url: str | None = None
category: list[str] | str | None = None
status: str | None = None

# Content
bio: str | None = None
description: str | None = None

# Contact
address: str | None = None
phone: str | None = None
email: str | None = None
website: str | None = None
maps_address: str | None = None

# Engagement (the API exposes both `_count` (int) and `_display` (str))
followers_count: int | None = None
followers_display: str | None = None
likes_count: int | None = None
likes_display: str | None = None

# Media
image: str | None = None
image_alt: str | None = None

# Ratings
rating: str | None = None
rating_count: int | None = None
rating_overall: str | None = None

# Business hours / pricing (often None for non-business pages)
business_hours: str | None = None
business_price: str | None = None
business_services: str | None = None
is_business_page_active: bool | None = None
confirmed_owner_label: str | None = None

# Linked social accounts
twitter: str | None = None
instagram: str | None = None
linkedin: str | None = None
pinterest: str | None = None
telegram: str | None = None
youtube: str | None = None


class GroupInfo(_Model):
"""Public metadata for a Facebook Group. Backed by `GET /facebook/groups/details`."""
"""Public metadata for a Facebook Group. Backed by `GET /facebook/groups/details`.

id: str | None = None
name: str | None = None
url: str | None = None
description: str | None = None
member_count: int | None = Field(default=None, alias="memberCount")
privacy: str | None = None
is_public: bool | None = Field(default=None, alias="isPublic")
NOTE: this endpoint has NO envelope — the payload sits at the top level
of the response (alongside `message` and `meta`). The SDK passes the
raw body straight to this model.
"""

group_id: str | None = None
group_member_count: str | None = None
group_total_members_info_text: str | None = None
group_new_members_info_text: str | None = None
description_text: str | None = None
privacy_info_text: dict[str, Any] | None = None
created_time: int | None = None
group_rules: list[Any] | None = None
group_history: dict[str, Any] | None = None
admin_tags: list[Any] | None = None
group_locations: list[Any] | None = None
number_of_posts_in_last_day: int | None = None
number_of_posts_in_last_month: int | None = None
22 changes: 19 additions & 3 deletions socialapis/instagram/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,22 @@ def get_user_id(self, profile: str, **kwargs: Any) -> dict[str, Any]:
def get_profile_details(self, username: str, **kwargs: Any) -> ProfileInfo:
"""Return public Instagram profile metadata.

Backed by ``GET /instagram/profile/details``.
Backed by ``GET /instagram/profile/details``. The API wraps the
payload under ``"data"`` in the response envelope (alongside
``success``, ``message``, ``meta``); the SDK unwraps before
validation.
"""
response = self._get(
"/instagram/profile/details",
_params(("username", username), extra=kwargs),
)
return ProfileInfo.model_validate(response.json())
body = response.json()
payload = (
body.get("data")
if isinstance(body, dict) and isinstance(body.get("data"), dict)
else body
)
return ProfileInfo.model_validate(payload)

def get_profile_posts(self, username: str, **kwargs: Any) -> dict[str, Any]:
"""Return recent posts from an Instagram profile.
Expand Down Expand Up @@ -307,7 +316,14 @@ async def get_profile_details(self, username: str, **kwargs: Any) -> ProfileInfo
"/instagram/profile/details",
_params(("username", username), extra=kwargs),
)
return ProfileInfo.model_validate(response.json())
# Same envelope-unwrap as the sync client.
body = response.json()
payload = (
body.get("data")
if isinstance(body, dict) and isinstance(body.get("data"), dict)
else body
)
return ProfileInfo.model_validate(payload)

async def get_profile_posts(self, username: str, **kwargs: Any) -> dict[str, Any]:
return (
Expand Down
77 changes: 63 additions & 14 deletions socialapis/instagram/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
endpoint (ProfileInfo from `get_profile_details`), `dict[str, Any]`
returns for the niche endpoints, every model uses `extra="allow"` so
new API fields don't break old callers.

Field names match the live API exactly (verified against a real token,
2026-06-22).
"""

from __future__ import annotations

from pydantic import BaseModel, ConfigDict, Field
from typing import Any

from pydantic import BaseModel, ConfigDict


class _Model(BaseModel):
Expand All @@ -22,21 +27,65 @@ class _Model(BaseModel):
class ProfileInfo(_Model):
"""Public Instagram profile metadata.

Backed by ``GET /instagram/profile/details``. The fields below are the
common ones; anything else the API returns is preserved on
``model_extra``.
Backed by `GET /instagram/profile/details`. The API wraps the payload
under the key `"data"` in the envelope (alongside `success`, `message`,
`meta`) — the SDK unwraps that before validation.

Fields below match the live API's field names exactly.
"""

# Identifiers
id: str | None = None
pk: str | None = None
fbid: str | None = None

# Display
username: str | None = None
full_name: str | None = Field(default=None, alias="fullName")
full_name: str | None = None
biography: str | None = None
followers: int | None = Field(default=None, alias="followerCount")
following: int | None = Field(default=None, alias="followingCount")
posts_count: int | None = Field(default=None, alias="postsCount")
is_verified: bool | None = Field(default=None, alias="isVerified")
is_private: bool | None = Field(default=None, alias="isPrivate")
is_business: bool | None = Field(default=None, alias="isBusiness")
profile_picture_url: str | None = Field(default=None, alias="profilePictureUrl")
external_url: str | None = Field(default=None, alias="externalUrl")
category: str | None = None
category_name: str | None = None

# Media URLs
profile_pic_url: str | None = None
profile_pic_url_hd: str | None = None
external_url: str | None = None
external_url_linkshimmed: str | None = None

# Counts
followers_count: int | None = None
following_count: int | None = None
media_count: int | None = None
total_clips_count: int | None = None
highlight_reel_count: int | None = None
mutual_followers_count: int | None = None

# Flags
is_private: bool | None = None
is_verified: bool | None = None
is_business_account: bool | None = None
is_professional_account: bool | None = None
is_memorialized: bool | None = None
is_unpublished: bool | None = None
is_embeds_disabled: bool | None = None
is_joined_recently: bool | None = None
is_regulated_c18: bool | None = None
account_type: int | None = None

# Features the profile has enabled
has_clips: bool | None = None
has_guides: bool | None = None
has_channel: bool | None = None
has_ar_effects: bool | None = None

# Business contact (often None for personal accounts)
business_category_name: str | None = None
business_email: str | None = None
business_phone_number: str | None = None
business_contact_method: str | None = None
address_street: str | None = None
city_name: str | None = None
zip: str | None = None

# Misc
pronouns: list[Any] | None = None
account_badges: list[Any] | None = None
Loading