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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
BroadcastRecipient,
BulkAdStatusResult,
CaptionResult,
CollectPostDelivery,
CollectPostResult,
Contact,
ContactChannel,
ContactConversation,
Expand All @@ -76,11 +78,13 @@
ContactImportSkip,
ContactLabel,
ContentBlock,
ContentDecay,
ContentSignal,
ConversationAnalytics,
ConversationAnalyticsRow,
ConversionMetrics,
ConversionRule,
DecayBand,
Delivery,
DiscordChannel,
DiscordIdentity,
Expand All @@ -92,6 +96,9 @@
Enrollment,
ExternalAd,
FeedLead,
FrequencyBand,
FrequencyBest,
FrequencyWeek,
GoogleAdScheduleSlot,
GoogleAsset,
GoogleAssetGroup,
Expand Down Expand Up @@ -148,6 +155,10 @@
MetaMenuItem,
MetaPersistentMenu,
MetaPersistentMenuEntry,
MetricChange,
MetricChangePage,
NativePost,
NativePostMetrics,
NetworkAd,
Page,
PageMeta,
Expand All @@ -157,7 +168,9 @@
PlatformMetricsBlock,
Post,
PostAccount,
PostingFrequency,
PostStatus,
PostTimeline,
PresignedUpload,
ReachEstimate,
RepurposeResult,
Expand All @@ -181,6 +194,9 @@
TikTokMusic,
TikTokPlace,
TikTokVideoSource,
TimelineDelivery,
TimelineDelta,
TimelinePoint,
ValidateLengthCheck,
ValidateLengthResult,
ValidateMediaResult,
Expand Down Expand Up @@ -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__",
Expand Down
2 changes: 2 additions & 0 deletions src/fopost/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ActivityResource,
AdsResource,
AiResource,
AnalyticsResource,
BroadcastsResource,
ContactsResource,
GoogleBusinessResource,
Expand Down Expand Up @@ -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)
Expand Down
174 changes: 174 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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."""

Expand Down
2 changes: 2 additions & 0 deletions src/fopost/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +18,7 @@
__all__ = [
"AccountGroupsResource",
"AccountsResource",
"AnalyticsResource",
"ActivityResource",
"AdsResource",
"AiResource",
Expand Down
Loading
Loading