diff --git a/README.md b/README.md index 85d58f1..52682d8 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ except FopostError as err: | Namespace | Methods | | ------------ | -------------------------------------------------------------------------------------- | | `posts` | `list`, `iter`, `iter_pages`, `get`, `create`, `update`, `delete`, `publish`, `cancel`, `retry`, `preflight`, `deliveries` | -| `accounts` | `list`, `get`, `health`, `update`, `move`, `create_telegram_connect_code`, `get_telegram_connect_status`, `get_telegram_bot_commands`, `set_telegram_bot_commands`, `delete_telegram_bot_commands`, `list_slack_channels`, `list_slack_members`, `get_slack_identity`, `update_slack_identity`, `get_ice_breakers`, `set_ice_breakers`, `delete_ice_breakers`, `get_persistent_menu`, `set_persistent_menu`, `delete_persistent_menu`, `get_greeting`, `set_greeting`, `delete_greeting`, `get_webhook_subscription`, `resubscribe_webhook`, `list_discord_channels`, `switch_discord_channel`, `get_discord_identity`, `update_discord_identity`, `list_discord_pins`, `delete_discord_message`, `pin_discord_message`, `unpin_discord_message`, `crosspost_discord_message`, `create_discord_thread`, `send_discord_dm`, `list_discord_events`, `get_discord_event`, `create_discord_event`, `update_discord_event`, `delete_discord_event`, `list_discord_members`, `get_discord_member`, `list_discord_roles`, `create_discord_role`, `update_discord_role`, `delete_discord_role`, `add_discord_member_role`, `remove_discord_member_role` | +| `accounts` | `list`, `get`, `health`, `update`, `move`, `create_telegram_connect_code`, `get_telegram_connect_status`, `get_telegram_bot_commands`, `set_telegram_bot_commands`, `delete_telegram_bot_commands`, `list_slack_channels`, `list_slack_members`, `get_slack_identity`, `update_slack_identity`, `get_ice_breakers`, `set_ice_breakers`, `delete_ice_breakers`, `get_persistent_menu`, `set_persistent_menu`, `delete_persistent_menu`, `get_greeting`, `set_greeting`, `delete_greeting`, `get_webhook_subscription`, `resubscribe_webhook`, `list_discord_channels`, `switch_discord_channel`, `get_discord_identity`, `update_discord_identity`, `list_discord_pins`, `delete_discord_message`, `pin_discord_message`, `unpin_discord_message`, `crosspost_discord_message`, `create_discord_thread`, `send_discord_dm`, `list_discord_events`, `get_discord_event`, `create_discord_event`, `update_discord_event`, `delete_discord_event`, `list_discord_members`, `get_discord_member`, `list_discord_roles`, `create_discord_role`, `update_discord_role`, `delete_discord_role`, `add_discord_member_role`, `remove_discord_member_role`, `platform_metrics` | | `account_groups` | `list`, `create`, `get`, `update`, `delete`, `set_members` | | `workspaces` | `list`, `get` | | `labels` | `list` | diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 54e9b70..4cde9c1 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -33,6 +33,7 @@ POST_STATUSES, AccountGroup, AccountMove, + AccountPlatformMetrics, AccountRename, ActivityActor, ActivityEvent, @@ -139,6 +140,8 @@ PageMeta, PinterestBoard, Platform, + PlatformMetricRow, + PlatformMetricsBlock, Post, PostAccount, PostStatus, @@ -184,18 +187,23 @@ FoPost = Fopost __all__ = [ - "DEFAULT_BASE_URL", - "PLATFORMS", - "POST_STATUSES", "AccountGroup", "AccountMove", + "AccountPlatformMetrics", "AccountRename", "ActivityActor", "ActivityEvent", "ActivityPage", "Ad", + "AdAccountTree", + "AdCampaign", + "AdCampaignNode", "AdConnection", + "AdCreative", "AdInsights", + "AdInsightsReport", + "AdSet", + "AdSetNode", "AdSource", "AiCreditBalance", "AiCredits", @@ -203,10 +211,14 @@ "AudiencesResult", "AuthenticationError", "BoostablePost", + "BulkAdStatusResult", "CaptionResult", "ContentBlock", + "ContentSignal", + "DEFAULT_BASE_URL", "Delivery", "ExternalAd", + "FeedLead", "FoPost", "Fopost", "FopostError", @@ -242,23 +254,35 @@ "InboxReplyResult", "InboxStartConversationResult", "InboxThread", + "InsightsMetrics", + "InsightsRow", "Label", "Lead", "LeadForm", + "LeadFormDetail", "LeadFormSource", + "LeadPage", + "LeadPageSubscription", + "LeadsFeedPage", "LeadsPage", "MediaItem", + "NetworkAd", "NotFoundError", + "PLATFORMS", + "POST_STATUSES", "Page", "PageMeta", "PaymentRequiredError", "PermissionDeniedError", "Platform", + "PlatformMetricRow", + "PlatformMetricsBlock", "Post", "PostAccount", "PostStatus", "PresignedUpload", "RateLimitError", + "ReachEstimate", "RepurposeResult", "RewriteResult", "RewriteVariant", @@ -306,7 +330,6 @@ "ValidateMediaResult", "ValidatePlatformCheck", "ValidatePostResult", - "ContentSignal", "Workspace", "__version__", "AdAccountTree", @@ -318,14 +341,6 @@ "AdSetNode", "BulkAdStatusResult", "FeedLead", - "InsightsMetrics", - "InsightsRow", - "LeadFormDetail", - "LeadPage", - "LeadPageSubscription", - "LeadsFeedPage", - "NetworkAd", - "ReachEstimate", "GoogleAdScheduleSlot", "GoogleAsset", "GoogleAssetGroup", @@ -338,4 +353,12 @@ "GoogleLocalServicesLead", "GoogleSearchTerm", "GoogleSharedSet", + "InsightsMetrics", + "InsightsRow", + "LeadFormDetail", + "LeadPage", + "LeadPageSubscription", + "LeadsFeedPage", + "NetworkAd", + "ReachEstimate", ] diff --git a/src/fopost/models.py b/src/fopost/models.py index 14f36fa..ec3c22c 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -14,31 +14,39 @@ from pydantic import AliasChoices, AliasGenerator, BaseModel, ConfigDict __all__ = [ - "PLATFORMS", - "POST_STATUSES", - "Platform", - "PostStatus", "AccountGroup", "AccountMove", + "AccountPlatformMetrics", "ACTIVITY_KINDS", - "ActivityKind", "ActivityActor", "ActivityEvent", + "ActivityKind", "ActivityPage", "AccountRename", "Ad", + "AdAccountTree", + "AdCampaign", + "AdCampaignNode", "AdConnection", + "AdCreative", "AdInsights", + "AdInsightsReport", + "AdSet", + "AdSetNode", "AdSource", "AiCreditBalance", "AiCredits", "Audience", "AudiencesResult", "BoostablePost", + "BulkAdStatusResult", "CaptionResult", "ContentBlock", + "ContentSignal", "Delivery", "ExternalAd", + "FeedLead", + "FopostModel", "InboxAccount", "InboxAccountRef", "InboxApproval", @@ -51,18 +59,31 @@ "InboxReplyResult", "InboxStartConversationResult", "InboxThread", + "InsightsMetrics", + "InsightsRow", "Label", "Lead", "LeadForm", + "LeadFormDetail", "LeadFormSource", + "LeadPage", + "LeadPageSubscription", + "LeadsFeedPage", "LeadsPage", "MediaItem", - "FopostModel", + "NetworkAd", + "PLATFORMS", + "POST_STATUSES", "Page", "PageMeta", + "Platform", + "PlatformMetricRow", + "PlatformMetricsBlock", "Post", "PostAccount", + "PostStatus", "PresignedUpload", + "ReachEstimate", "RepurposeResult", "RewriteResult", "RewriteVariant", @@ -87,45 +108,44 @@ "ValidateMediaResult", "ValidatePlatformCheck", "ValidatePostResult", - "ContentSignal", "Workspace", "AdAccountTree", + "AdActivity", + "AdActivityResult", "AdCampaign", "AdCampaignNode", "AdCreative", "AdInsightsReport", + "AdLabel", + "AdLibraryEntry", + "AdLibraryPage", "AdSet", "AdSetNode", + "AdStudy", "BulkAdStatusResult", + "CatalogBatchResult", + "CatalogProduct", + "CatalogProductsPage", "FeedLead", + "HighDemandPeriod", "InsightsMetrics", "InsightsRow", + "IosCampaignLimits", "LeadFormDetail", "LeadPage", "LeadPageSubscription", "LeadsFeedPage", "NetworkAd", - "AdActivity", - "AdActivityResult", - "AdLabel", - "AdLibraryEntry", - "AdLibraryPage", - "AdStudy", - "CatalogBatchResult", - "CatalogProduct", - "CatalogProductsPage", - "HighDemandPeriod", - "IosCampaignLimits", "PartnershipCreator", "ProductCatalog", "ProductCatalogsResult", "ProductFeed", "ProductFeedUpload", "ProductSet", + "ReachEstimate", "ReachFrequencyPrediction", "ReachFrequencyResult", "ValueRuleSet", - "ReachEstimate", ] #: Every platform the API can publish to. Model fields stay plain `str`, so a @@ -693,6 +713,34 @@ class DiscordRole(FopostModel): permissions: str = "0" +class PlatformMetricRow(FopostModel): + """One metric a network reports under its own name.""" + + #: The platform's own metric name. Stable — read this, not ``label``. + key: str + #: Ours, and subject to rewording. + label: str + kind: Literal["count", "duration_ms", "currency_usd", "ratio", "series"] | str + #: A number for every kind but ``series``, which is a list of points. + value: Any = None + + +class PlatformMetricsBlock(FopostModel): + """One side of the set: the account itself, or its newest measured post.""" + + fetched_at: str | None = None + external_post_id: str | None = None + metrics: list[PlatformMetricRow] = [] + + +class AccountPlatformMetrics(FopostModel): + """What only this network reports, in its own vocabulary.""" + + platform: str + account: PlatformMetricsBlock + post: PlatformMetricsBlock + + class Workspace(FopostModel): id: str name: str diff --git a/src/fopost/resources/accounts.py b/src/fopost/resources/accounts.py index 9f5ca18..141ef56 100644 --- a/src/fopost/resources/accounts.py +++ b/src/fopost/resources/accounts.py @@ -9,6 +9,7 @@ from .._http import unwrap from ..models import ( AccountMove, + AccountPlatformMetrics, AccountRename, BlueskyLanguages, DiscordChannel, @@ -81,6 +82,20 @@ def health(self, account_id: str) -> dict[str, Any]: body = unwrap(self._http.get(f"/accounts/{account_id}/health")) return body if isinstance(body, dict) else {"data": body} + def platform_metrics(self, account_id: str) -> AccountPlatformMetrics: + """The numbers only this account's network reports, in its own vocabulary. + + Ad-break earnings, story taps, a retention curve, the search terms behind a + listing — keyed by the platform's own metric names, read from the newest + collected snapshot rather than fetched live. Needs the ``analytics`` scope. + + A network whose metric access has not been granted yet answers ``503`` + (``platform_metrics_unavailable``) rather than an empty set. + """ + return AccountPlatformMetrics.model_validate( + unwrap(self._http.get(f"/accounts/{account_id}/insights", {"raw": "true"})) + ) + def update(self, account_id: str, *, display_name: str | None) -> AccountRename: """Rename the account; ``None`` or an empty string restores the platform name.""" return AccountRename.model_validate( diff --git a/tests/test_platform_metrics.py b/tests/test_platform_metrics.py new file mode 100644 index 0000000..ddfae91 --- /dev/null +++ b/tests/test_platform_metrics.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import httpx +import pytest +import respx + +from fopost import Fopost, FopostError +from tests.conftest import BASE_URL + +FACEBOOK_SET = { + "platform": "facebook", + "account": { + "fetched_at": "2026-09-20T02:00:00.000Z", + "metrics": [ + { + "key": "page_daily_video_ad_break_earnings", + "label": "Ad Break Earnings", + "kind": "currency_usd", + "value": 42.15, + }, + { + "key": "page_impressions_paid", + "label": "Paid Impressions", + "kind": "count", + "value": 1500, + }, + ], + }, + "post": { + "external_post_id": "123_456", + "fetched_at": "2026-09-20T02:00:00.000Z", + "metrics": [], + }, +} + + +@respx.mock +def test_platform_metrics_asks_for_raw_and_parses_the_set(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/accounts/acc_1/insights").mock( + return_value=httpx.Response(200, json={"data": FACEBOOK_SET}) + ) + + metrics = client.accounts.platform_metrics("acc_1") + + assert dict(route.calls.last.request.url.params) == {"raw": "true"} + assert metrics.platform == "facebook" + assert metrics.account.fetched_at == "2026-09-20T02:00:00.000Z" + assert [m.key for m in metrics.account.metrics] == [ + "page_daily_video_ad_break_earnings", + "page_impressions_paid", + ] + assert metrics.account.metrics[0].value == 42.15 + assert metrics.post.external_post_id == "123_456" + assert metrics.post.metrics == [] + + +@respx.mock +def test_a_series_value_survives_as_a_list(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/insights").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "platform": "youtube", + "account": { + "fetched_at": None, + "metrics": [ + { + "key": "daily_views", + "label": "Views by Day", + "kind": "series", + "value": [{"day": "2026-09-19", "views": 600}], + } + ], + }, + "post": {"external_post_id": None, "fetched_at": None, "metrics": []}, + } + }, + ) + ) + + metrics = client.accounts.platform_metrics("acc_1") + + assert metrics.account.metrics[0].value == [{"day": "2026-09-19", "views": 600}] + assert metrics.account.fetched_at is None + + +@respx.mock +def test_a_pending_metric_grant_raises(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/insights").mock( + return_value=httpx.Response( + 503, + json={ + "error": "platform_metrics_unavailable", + "message": "google-business metrics are not available on this deployment yet.", + }, + ) + ) + + with pytest.raises(FopostError) as err: + client.accounts.platform_metrics("acc_1") + + assert err.value.status == 503 + assert err.value.code == "platform_metrics_unavailable"