diff --git a/CHANGELOG.md b/CHANGELOG.md index 9246408..c2a3ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/socialapis/_version.py b/socialapis/_version.py index bed78ed..1cd93ce 100644 --- a/socialapis/_version.py +++ b/socialapis/_version.py @@ -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" diff --git a/socialapis/facebook/_client.py b/socialapis/facebook/_client.py index 59dcf03..3a014e6 100644 --- a/socialapis/facebook/_client.py +++ b/socialapis/facebook/_client.py @@ -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. @@ -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 ( diff --git a/socialapis/facebook/_types.py b/socialapis/facebook/_types.py index b426273..1955384 100644 --- a/socialapis/facebook/_types.py +++ b/socialapis/facebook/_types.py @@ -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): @@ -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 diff --git a/socialapis/instagram/_client.py b/socialapis/instagram/_client.py index ef97776..6623a7e 100644 --- a/socialapis/instagram/_client.py +++ b/socialapis/instagram/_client.py @@ -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. @@ -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 ( diff --git a/socialapis/instagram/_types.py b/socialapis/instagram/_types.py index 9b93aa5..dce1196 100644 --- a/socialapis/instagram/_types.py +++ b/socialapis/instagram/_types.py @@ -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): @@ -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 diff --git a/tests/test_facebook.py b/tests/test_facebook.py index 231541b..1825cd0 100644 --- a/tests/test_facebook.py +++ b/tests/test_facebook.py @@ -27,17 +27,36 @@ RateLimitError, ) -SAMPLE_PAGE_INFO = { - "id": "143568085655519", - "name": "Engen SA", +# Mirrors the real API's envelope shape (verified 2026-06-22): +# the page payload sits under string key "0" alongside "message" +# and "meta" envelope keys. +SAMPLE_PAGE_PAYLOAD = { + "ad_page_id": "206441436112629", + "user_id": "100064888920170", + "title": "Engen SA | Cape Town", "url": "https://www.facebook.com/EngenSA", - "category": "Petroleum Service", - "likes": 1_234_567, - "followers": 1_200_000, - "verified": True, - "about": "Energy that drives Africa forward.", - "profileImageUrl": "https://scontent.fbcdn.net/profile.jpg", - "coverImageUrl": "https://scontent.fbcdn.net/cover.jpg", + "category": ["Petroleum Service"], + "bio": "Energy that drives Africa forward.", + "description": "Engen SA, Cape Town. 119,213 likes.", + "address": "Cape Town, South Africa", + "phone": "+27 860 036 436", + "email": "1call@engenoil.com", + "website": "engen.co.za", + "followers_count": 119000, + "followers_display": "119K followers", + "likes_count": 1_234_567, + "likes_display": "1.2M likes", + "image": "https://scontent.fbcdn.net/profile.jpg", + "image_alt": "Engen SA | Cape Town", + "rating": "66% recommend (327 reviews)", + "rating_overall": "327", + "status": "public", + "is_business_page_active": False, +} +SAMPLE_PAGE_INFO = { + "0": SAMPLE_PAGE_PAYLOAD, + "message": "Request completed successfully with status: OK (200)", + "meta": {"statusCode": 200, "duration": 1143, "creditsCharged": 1}, } @@ -56,12 +75,12 @@ def test_get_page_info_returns_typed_model() -> None: page = fb.get_page_info("EngenSA") assert isinstance(page, PageInfo) - assert page.id == "143568085655519" - assert page.name == "Engen SA" - assert page.likes == 1_234_567 - assert page.verified is True - # Camel-case API fields populate the snake-case attributes - assert page.profile_image_url == "https://scontent.fbcdn.net/profile.jpg" + assert page.ad_page_id == "206441436112629" + assert page.title == "Engen SA | Cape Town" + assert page.likes_count == 1_234_567 + assert page.followers_count == 119000 + assert page.image == "https://scontent.fbcdn.net/profile.jpg" + assert page.is_business_page_active is False @respx.mock @@ -253,7 +272,8 @@ async def test_async_get_page_info_works() -> None: ) async with AsyncFacebook(api_token="t") as fb: page = await fb.get_page_info("EngenSA") - assert page.name == "Engen SA" + assert page.title == "Engen SA | Cape Town" + assert page.followers_count == 119000 @pytest.mark.asyncio diff --git a/tests/test_instagram.py b/tests/test_instagram.py index 281c831..1fbe625 100644 --- a/tests/test_instagram.py +++ b/tests/test_instagram.py @@ -11,18 +11,35 @@ from socialapis import AsyncInstagram, Instagram, ProfileInfo -SAMPLE_PROFILE = { +# Mirrors the real API's envelope shape (verified 2026-06-22): +# the profile payload sits under "data" alongside "success", +# "message", and "meta". +SAMPLE_PROFILE_PAYLOAD = { "id": "25025320", + "pk": "25025320", + "fbid": "17841400039600391", "username": "instagram", - "fullName": "Instagram", - "biography": "Discover what's new on Instagram 🌟", - "followerCount": 670_000_000, - "followingCount": 50, - "postsCount": 7_900, - "isVerified": True, - "isPrivate": False, - "isBusiness": True, - "profilePictureUrl": "https://scontent.cdninstagram.com/profile.jpg", + "full_name": "Instagram", + "biography": "Discover what's new on Instagram", + "profile_pic_url": "https://scontent.cdninstagram.com/profile.jpg", + "profile_pic_url_hd": "https://scontent.cdninstagram.com/profile_hd.jpg", + "external_url": "https://youtu.be/sample", + "followers_count": 685_000_000, + "following_count": 229, + "media_count": 7_900, + "is_verified": True, + "is_private": False, + "is_business_account": False, + "is_professional_account": True, + "account_type": 3, + "has_clips": True, + "category_name": "", +} +SAMPLE_PROFILE = { + "success": True, + "data": SAMPLE_PROFILE_PAYLOAD, + "message": "Request completed successfully with status: OK (200)", + "meta": {"statusCode": 200, "creditsCharged": 1}, } @@ -39,7 +56,8 @@ def test_get_profile_details_returns_typed_model() -> None: assert profile.id == "25025320" assert profile.username == "instagram" assert profile.full_name == "Instagram" - assert profile.followers == 670_000_000 + assert profile.followers_count == 685_000_000 + assert profile.media_count == 7_900 assert profile.is_verified is True