diff --git a/README.md b/README.md index d7141c8..959d110 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,42 @@ repurposed = client.ai.repurpose_url( > `401` to an API key. They are here so the surface is complete once the server > opens them up. +## Analytics + +```python +# How long a post keeps earning, from the repeated readings of each post +decay = client.analytics.decay(days=30) +print(decay.half_life_bucket) # e.g. "1h_3h" + +# Whether posting more earned more +cadence = client.analytics.frequency(days=90) +print(cadence.best.label if cadence.best else None) # e.g. "3-5 a week" + +# Every reading held for one post, with what moved between them +timeline = client.analytics.timeline(post.id) + +# Mirror the metrics into your own store, without refetching everything +cursor = None +while True: + page = client.analytics.changes(since=cursor) + save(page.changes) + if not page.has_more or page.cursor is None: + break + cursor = page.cursor.isoformat() + +# Refresh one post now instead of waiting for the next collection run +client.analytics.collect_post(post.id) + +# Posts on the account that never went out through FoPost +for native in client.analytics.native_posts(accounts[0].id): + print(native.permalink, native.metrics.engagements) +``` + +A post is addressed by its FoPost id or by its permalink, so a post made by +hand on the network works the same way:: + + client.analytics.timeline("https://x.com/acme/status/1") + ## Direct uploads `upload_direct` presigns an upload slot, PUTs the bytes straight to storage, and @@ -216,6 +252,7 @@ except FopostError as err: | `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `add_audience_companies`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `bid_pricing`, `supply_forecast`, `conversion_rules`, `create_conversion_rule`, `get_conversion_rule`, `update_conversion_rule`, `delete_conversion_rule`, `attach_conversion_rule`, `detach_conversion_rule`, `conversion_metrics`, `send_conversion_events` | | `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` | | `validate` | `post`, `length`, `media` | +| `analytics` | `decay`, `frequency`, `timeline`, `changes`, `collect_post`, `native_posts` | | `activity` | `list` | For an endpoint the SDK does not wrap yet, `client.request` sends an diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index b5e8c52..f8b0f81 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -68,6 +68,8 @@ BroadcastRecipient, BulkAdStatusResult, CaptionResult, + CollectPostDelivery, + CollectPostResult, Contact, ContactChannel, ContactConversation, @@ -76,11 +78,13 @@ ContactImportSkip, ContactLabel, ContentBlock, + ContentDecay, ContentSignal, ConversationAnalytics, ConversationAnalyticsRow, ConversionMetrics, ConversionRule, + DecayBand, Delivery, DiscordChannel, DiscordIdentity, @@ -92,6 +96,9 @@ Enrollment, ExternalAd, FeedLead, + FrequencyBand, + FrequencyBest, + FrequencyWeek, GoogleAdScheduleSlot, GoogleAsset, GoogleAssetGroup, @@ -148,6 +155,10 @@ MetaMenuItem, MetaPersistentMenu, MetaPersistentMenuEntry, + MetricChange, + MetricChangePage, + NativePost, + NativePostMetrics, NetworkAd, Page, PageMeta, @@ -157,7 +168,9 @@ PlatformMetricsBlock, Post, PostAccount, + PostingFrequency, PostStatus, + PostTimeline, PresignedUpload, ReachEstimate, RepurposeResult, @@ -181,6 +194,9 @@ TikTokMusic, TikTokPlace, TikTokVideoSource, + TimelineDelivery, + TimelineDelta, + TimelinePoint, ValidateLengthCheck, ValidateLengthResult, ValidateMediaResult, @@ -351,6 +367,22 @@ "ValidateLengthResult", "ValidateMediaResult", "ValidatePlatformCheck", + "CollectPostDelivery", + "CollectPostResult", + "ContentDecay", + "DecayBand", + "FrequencyBand", + "FrequencyBest", + "FrequencyWeek", + "MetricChange", + "MetricChangePage", + "NativePost", + "NativePostMetrics", + "PostTimeline", + "PostingFrequency", + "TimelineDelivery", + "TimelinePoint", + "TimelineDelta", "ValidatePostResult", "Workspace", "__version__", diff --git a/src/fopost/client.py b/src/fopost/client.py index 68f529f..6c97c3d 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -15,6 +15,7 @@ ActivityResource, AdsResource, AiResource, + AnalyticsResource, BroadcastsResource, ContactsResource, GoogleBusinessResource, @@ -82,6 +83,7 @@ def __init__( self.sequences = SequencesResource(self._http) self.ads = AdsResource(self._http) self.validate = ValidateResource(self._http) + self.analytics = AnalyticsResource(self._http) self.knowledge = KnowledgeResource(self._http) self.activity = ActivityResource(self._http) self.google_business = GoogleBusinessResource(self._http) diff --git a/src/fopost/models.py b/src/fopost/models.py index fb42f3f..4f09263 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -35,6 +35,22 @@ "AdSetNode", "AdSource", "AiCreditBalance", + "CollectPostDelivery", + "CollectPostResult", + "ContentDecay", + "DecayBand", + "FrequencyBand", + "FrequencyBest", + "FrequencyWeek", + "MetricChange", + "MetricChangePage", + "NativePost", + "NativePostMetrics", + "PostTimeline", + "PostingFrequency", + "TimelineDelivery", + "TimelinePoint", + "TimelineDelta", "AiCredits", "Audience", "AudiencesResult", @@ -806,6 +822,164 @@ class PageMeta(FopostModel): T = TypeVar("T", bound=FopostModel) +class DecayBand(FopostModel): + bucket: str | None = None + label: str | None = None + posts: int = 0 + avg_engagements: float = 0.0 + avg_impressions: float = 0.0 + #: Mean share of the post's final engagement reached by this age, 0-1. + share_of_final: float | None = None + + +class ContentDecay(FopostModel): + """How engagement accumulates as a post ages.""" + + days: int | None = None + posts_measured: int = 0 + #: First band where the average post had passed half its final engagement. + half_life_bucket: str | None = None + bands: list[DecayBand] = [] + + +class FrequencyWeek(FopostModel): + #: Monday of the week, UTC, as YYYY-MM-DD. + week_start: str | None = None + posts: int = 0 + engagements: int = 0 + avg_engagements_per_post: float = 0.0 + + +class FrequencyBand(FopostModel): + band: str | None = None + label: str | None = None + weeks: int = 0 + posts: int = 0 + avg_posts_per_week: float = 0.0 + avg_engagements_per_post: float = 0.0 + #: Engagements over reach, impressions as the stand-in; None with neither. + engagement_rate: float | None = None + + +class FrequencyBest(FopostModel): + band: str | None = None + label: str | None = None + avg_engagements_per_post: float = 0.0 + + +class PostingFrequency(FopostModel): + """Weekly cadence set against what each cadence earned per post.""" + + days: int | None = None + weeks: list[FrequencyWeek] = [] + bands: list[FrequencyBand] = [] + best: FrequencyBest | None = None + + +class TimelineDelta(FopostModel): + impressions: int = 0 + reach: int = 0 + engagements: int = 0 + likes: int = 0 + comments: int = 0 + shares: int = 0 + + +class TimelinePoint(FopostModel): + at: datetime | None = None + #: Minutes since publication; None when the network never said when. + age_minutes: int | None = None + impressions: int | None = None + reach: int | None = None + engagements: int | None = None + likes: int | None = None + comments: int | None = None + shares: int | None = None + video_views: int | None = None + delta: TimelineDelta = TimelineDelta() + + +class TimelineDelivery(FopostModel): + account_id: str | None = None + platform: str | None = None + username: str | None = None + external_post_id: str | None = None + posted_at: datetime | None = None + points: list[TimelinePoint] = [] + + +class PostTimeline(FopostModel): + """Every reading held for one post, one timeline per delivery.""" + + #: None when the post was made natively on the network. + post_id: str | None = None + deliveries: list[TimelineDelivery] = [] + + +class MetricChange(FopostModel): + account_id: str | None = None + platform: str | None = None + external_post_id: str | None = None + #: None for a post made natively on the network. + post_id: str | None = None + posted_at: datetime | None = None + fetched_at: datetime | None = None + impressions: int | None = None + reach: int | None = None + engagements: int | None = None + likes: int | None = None + comments: int | None = None + shares: int | None = None + + +class MetricChangePage(FopostModel): + """Readings since a cursor, with the cursor to pass next time.""" + + since: datetime | None = None + #: Feed back as ``since`` to continue; None when nothing changed. + cursor: datetime | None = None + has_more: bool = False + changes: list[MetricChange] = [] + + +class CollectPostDelivery(FopostModel): + account_id: str | None = None + platform: str | None = None + external_post_id: str | None = None + collected: bool = False + fetched_at: datetime | None = None + #: Why the refresh did not happen. + message: str | None = None + + +class CollectPostResult(FopostModel): + collected: int = 0 + deliveries: list[CollectPostDelivery] = [] + + +class NativePostMetrics(FopostModel): + impressions: int | None = None + reach: int | None = None + engagements: int | None = None + likes: int | None = None + comments: int | None = None + shares: int | None = None + video_views: int | None = None + + +class NativePost(FopostModel): + """A post on the account that never went out through FoPost.""" + + external_post_id: str | None = None + text: str | None = None + permalink: str | None = None + thumbnail_url: str | None = None + media_type: str | None = None + posted_at: datetime | None = None + fetched_at: datetime | None = None + metrics: NativePostMetrics = NativePostMetrics() + + class Page(FopostModel, Generic[T]): """One page of a list endpoint: its items plus the pagination meta.""" diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index 4e5095f..82fb15e 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -3,6 +3,7 @@ from .activity import ActivityResource from .ads import AdsResource from .ai import AiResource +from .analytics import AnalyticsResource from .broadcasts import BroadcastsResource, SequencesResource from .contacts import ContactsResource from .google_business import GoogleBusinessResource @@ -17,6 +18,7 @@ __all__ = [ "AccountGroupsResource", "AccountsResource", + "AnalyticsResource", "ActivityResource", "AdsResource", "AiResource", diff --git a/src/fopost/resources/analytics.py b/src/fopost/resources/analytics.py new file mode 100644 index 0000000..00fa258 --- /dev/null +++ b/src/fopost/resources/analytics.py @@ -0,0 +1,118 @@ +"""``client.analytics`` — deeper posting analytics. + +Derived from the repeated readings the platform collector takes of every post +as it ages, so nothing here needs a second collection pass to be useful. +Every call needs the ``analytics`` scope. +""" + +from __future__ import annotations + +from urllib.parse import quote + +from .._http import unwrap +from ..models import ( + CollectPostResult, + ContentDecay, + MetricChangePage, + NativePost, + Page, + PageMeta, + PostingFrequency, + PostTimeline, +) +from ._base import Resource, parse_list + +__all__ = ["AnalyticsResource"] + + +class AnalyticsResource(Resource): + def decay( + self, + *, + days: int | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> ContentDecay: + """How engagement accumulates with a post's age, and where the half-life falls. + + ``days`` selects posts by publish time, not reading time. + """ + body = self._http.get( + "/analytics/decay", + {"days": days, "workspace_id": workspace_id, "accountId": account_id}, + ) + return ContentDecay.model_validate(unwrap(body)) + + def frequency( + self, + *, + days: int | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> PostingFrequency: + """Weekly posting cadence set against what each cadence earned per post.""" + body = self._http.get( + "/analytics/frequency", + {"days": days, "workspace_id": workspace_id, "accountId": account_id}, + ) + return PostingFrequency.model_validate(unwrap(body)) + + def timeline(self, id_or_permalink: str) -> PostTimeline: + """Every reading held for one post, oldest first, one timeline per delivery. + + ``id_or_permalink`` is a FoPost post id or the permalink of a post made + natively on the network. + """ + path = f"/analytics/posts/{quote(id_or_permalink, safe='')}/timeline" + return PostTimeline.model_validate(unwrap(self._http.get(path))) + + def changes( + self, + *, + since: str | None = None, + limit: int | None = None, + workspace_id: str | None = None, + account_id: str | None = None, + ) -> MetricChangePage: + """Readings recorded after ``since``, oldest first, with a cursor to continue. + + Poll this to mirror the metrics into your own store. Omitting ``since`` + gives the last seven days. + """ + body = self._http.get( + "/analytics/changes", + { + "since": since, + "limit": limit, + "workspace_id": workspace_id, + "accountId": account_id, + }, + ) + return MetricChangePage.model_validate(unwrap(body)) + + def collect_post(self, id_or_permalink: str) -> CollectPostResult: + """Re-read one post from the network now. + + Spends the same per-user budget as a full collection run, so a burst + answers 429 with ``retryAfter``. + """ + path = f"/posts/{quote(id_or_permalink, safe='')}/analytics/collect" + return CollectPostResult.model_validate(unwrap(self._http.post(path))) + + def native_posts( + self, + account_id: str, + *, + page: int = 1, + per_page: int = 20, + days: int | None = None, + ) -> Page[NativePost]: + """Posts on the account that never went out through FoPost, newest first.""" + body = self._http.get( + f"/accounts/{account_id}/native-posts", + {"page": page, "per_page": per_page, "days": days}, + ) + items = parse_list(NativePost, body.get("data") if isinstance(body, dict) else body) + raw_meta = body.get("meta") if isinstance(body, dict) else None + meta = PageMeta.model_validate(raw_meta) if isinstance(raw_meta, dict) else PageMeta() + return Page[NativePost](items=items, meta=meta) diff --git a/tests/test_analytics.py b/tests/test_analytics.py new file mode 100644 index 0000000..da06a5e --- /dev/null +++ b/tests/test_analytics.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import httpx +import respx + +from fopost import Fopost +from tests.conftest import BASE_URL + + +@respx.mock +def test_decay_parses_the_bands_and_half_life(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/analytics/decay").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "days": 30, + "postsMeasured": 2, + "halfLifeBucket": "1h_3h", + "bands": [ + { + "bucket": "under_1h", + "label": "First hour", + "posts": 2, + "avgEngagements": 25, + "avgImpressions": 300, + "shareOfFinal": 0.3, + }, + { + "bucket": "6h_12h", + "label": "6-12 hours", + "posts": 0, + "avgEngagements": 0, + "avgImpressions": 0, + "shareOfFinal": None, + }, + ], + } + }, + ) + ) + + decay = client.analytics.decay(days=30, account_id="acc_1") + + assert route.call_count == 1 + assert dict(route.calls[0].request.url.params) == {"days": "30", "accountId": "acc_1"} + assert decay.half_life_bucket == "1h_3h" + assert decay.bands[0].share_of_final == 0.3 + # A band nothing was measured in reports no share rather than zero + assert decay.bands[1].share_of_final is None + + +@respx.mock +def test_frequency_parses_weeks_and_the_best_cadence(client: Fopost) -> None: + respx.get(f"{BASE_URL}/analytics/frequency").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "days": 90, + "weeks": [ + { + "weekStart": "2026-03-02", + "posts": 2, + "engagements": 240, + "avgEngagementsPerPost": 120, + } + ], + "bands": [ + { + "band": "under_3", + "label": "1-2 a week", + "weeks": 1, + "posts": 2, + "avgPostsPerWeek": 2, + "avgEngagementsPerPost": 120, + "engagementRate": 0.12, + } + ], + "best": { + "band": "under_3", + "label": "1-2 a week", + "avgEngagementsPerPost": 120, + }, + } + }, + ) + ) + + frequency = client.analytics.frequency(days=90) + + assert frequency.weeks[0].week_start == "2026-03-02" + assert frequency.best is not None + assert frequency.best.label == "1-2 a week" + assert frequency.bands[0].engagement_rate == 0.12 + + +@respx.mock +def test_timeline_escapes_a_permalink_into_the_path(client: Fopost) -> None: + route = respx.get( + f"{BASE_URL}/analytics/posts/https%3A%2F%2Fx.com%2Facme%2Fstatus%2F1/timeline" + ).mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "postId": None, + "deliveries": [ + { + "accountId": "acc_1", + "platform": "twitter", + "username": "acme", + "externalPostId": "1", + "postedAt": "2026-03-02T00:00:00.000Z", + "points": [ + { + "at": "2026-03-02T00:30:00.000Z", + "ageMinutes": 30, + "engagements": 40, + "impressions": 400, + "reach": None, + "likes": 30, + "comments": None, + "shares": None, + "videoViews": None, + "delta": { + "impressions": 400, + "reach": 0, + "engagements": 40, + "likes": 30, + "comments": 0, + "shares": 0, + }, + } + ], + } + ], + } + }, + ) + ) + + timeline = client.analytics.timeline("https://x.com/acme/status/1") + + assert route.call_count == 1 + assert timeline.post_id is None + assert timeline.deliveries[0].points[0].age_minutes == 30 + assert timeline.deliveries[0].points[0].delta.engagements == 40 + + +@respx.mock +def test_changes_carries_the_cursor(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/analytics/changes").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "since": "2026-03-02T00:00:00.000Z", + "cursor": "2026-03-02T06:00:00.000Z", + "hasMore": True, + "changes": [ + { + "accountId": "acc_1", + "platform": "twitter", + "externalPostId": "1", + "postId": "post_1", + "postedAt": "2026-03-02T00:00:00.000Z", + "fetchedAt": "2026-03-02T06:00:00.000Z", + "impressions": 900, + "reach": None, + "engagements": 90, + "likes": 70, + "comments": 10, + "shares": 10, + } + ], + } + }, + ) + ) + + page = client.analytics.changes(since="2026-03-02T00:00:00Z", limit=100) + + assert dict(route.calls[0].request.url.params) == { + "since": "2026-03-02T00:00:00Z", + "limit": "100", + } + assert page.has_more is True + assert page.changes[0].post_id == "post_1" + + +@respx.mock +def test_collect_post_reports_each_delivery(client: Fopost) -> None: + respx.post(f"{BASE_URL}/posts/post_1/analytics/collect").mock( + return_value=httpx.Response( + 200, + json={ + "data": { + "collected": 1, + "deliveries": [ + { + "accountId": "acc_1", + "platform": "twitter", + "externalPostId": "1", + "collected": True, + "fetchedAt": "2026-03-02T00:30:00.000Z", + "message": None, + } + ], + } + }, + ) + ) + + result = client.analytics.collect_post("post_1") + + assert result.collected == 1 + assert result.deliveries[0].collected is True + + +@respx.mock +def test_native_posts_returns_a_page(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/accounts/acc_1/native-posts").mock( + return_value=httpx.Response( + 200, + json={ + "data": [ + { + "externalPostId": "1", + "text": "Posted by hand", + "permalink": "https://x.com/acme/status/1", + "thumbnailUrl": None, + "mediaType": None, + "postedAt": "2026-03-02T00:00:00.000Z", + "fetchedAt": "2026-03-02T06:00:00.000Z", + "metrics": { + "impressions": 900, + "reach": None, + "engagements": 90, + "likes": 70, + "comments": 10, + "shares": 10, + "videoViews": None, + }, + } + ], + "meta": {"page": 1, "perPage": 20, "total": 1}, + }, + ) + ) + + page = client.analytics.native_posts("acc_1", page=1, per_page=20) + + assert dict(route.calls[0].request.url.params) == {"page": "1", "per_page": "20"} + assert len(page) == 1 + assert page[0].permalink == "https://x.com/acme/status/1" + assert page[0].metrics.engagements == 90 + assert page.meta.total == 1