diff --git a/README.md b/README.md index e1100da..41685fa 100644 --- a/README.md +++ b/README.md @@ -675,6 +675,42 @@ $media = $client->validate()->media('https://yourbrand.com/launch.png'); $media->ok; // 200 even when a check fails; read $media->issues ``` +## Analytics + +```php +// How long a post keeps earning, from the repeated readings of each post +$decay = $client->analytics()->decay(days: 30); +echo $decay->halfLifeBucket; // e.g. "1h_3h" + +// Whether posting more earned more +$cadence = $client->analytics()->frequency(days: 90); +echo $cadence->best?->label; // 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 = null; +do { + $page = $client->analytics()->changes(since: $cursor); + save($page->changes); + $cursor = $page->cursor?->format(DATE_ATOM); +} while ($page->hasMore && $cursor !== null); + +// Refresh one post now instead of waiting for the next collection run +$client->analytics()->collectPost($post->id); + +// Posts on the account that never went out through FoPost +foreach ($client->analytics()->nativePosts($accounts[0]->id)->items as $native) { + echo $native->permalink, ' ', $native->metrics->engagements, PHP_EOL; +} +``` + +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: + +```php +$client->analytics()->timeline('https://x.com/acme/status/1'); ## Activity What happened in a workspace, newest first. Needs the `analytics` scope. diff --git a/src/Client.php b/src/Client.php index 232d8b2..2b85d0d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,6 +9,7 @@ use Fopost\Sdk\Resource\AccountGroupsResource; use Fopost\Sdk\Resource\AccountsResource; use Fopost\Sdk\Resource\AdsResource; +use Fopost\Sdk\Resource\AnalyticsResource; use Fopost\Sdk\Resource\AiResource; use Fopost\Sdk\Resource\BroadcastsResource; use Fopost\Sdk\Resource\ContactsResource; @@ -56,6 +57,7 @@ final class Client private readonly AdsResource $ads; private readonly MediaResource $media; private readonly ValidateResource $validate; + private readonly AnalyticsResource $analytics; private readonly GoogleBusinessResource $googleBusiness; public function __construct( @@ -89,6 +91,7 @@ public function __construct( $this->ads = new AdsResource($this->http); $this->media = new MediaResource($this->http); $this->validate = new ValidateResource($this->http); + $this->analytics = new AnalyticsResource($this->http); $this->googleBusiness = new GoogleBusinessResource($this->http); } @@ -167,6 +170,11 @@ public function validate(): ValidateResource return $this->validate; } + public function analytics(): AnalyticsResource + { + return $this->analytics; + } + /** Manage a connected Google Business Profile location. */ public function googleBusiness(): GoogleBusinessResource { diff --git a/src/Model/CollectPostDelivery.php b/src/Model/CollectPostDelivery.php new file mode 100644 index 0000000..80e59e5 --- /dev/null +++ b/src/Model/CollectPostDelivery.php @@ -0,0 +1,39 @@ + $deliveries */ + private function __construct( + array $raw, + public readonly int $collected, + public readonly array $deliveries, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::int($data, 'collected') ?? 0, + CollectPostDelivery::listFrom(self::seq($data, 'deliveries')), + ); + } +} diff --git a/src/Model/ContentDecayReport.php b/src/Model/ContentDecayReport.php new file mode 100644 index 0000000..e590afa --- /dev/null +++ b/src/Model/ContentDecayReport.php @@ -0,0 +1,34 @@ + $bands */ + private function __construct( + array $raw, + public readonly int $days, + public readonly int $postsMeasured, + /** First band where the average post had passed half its final engagement. */ + public readonly ?string $halfLifeBucket, + public readonly array $bands, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::int($data, 'days') ?? 0, + self::int($data, 'posts_measured') ?? 0, + self::str($data, 'half_life_bucket'), + DecayBand::listFrom(self::seq($data, 'bands')), + ); + } +} diff --git a/src/Model/DecayBand.php b/src/Model/DecayBand.php new file mode 100644 index 0000000..e42e97f --- /dev/null +++ b/src/Model/DecayBand.php @@ -0,0 +1,38 @@ + $changes */ + private function __construct( + array $raw, + public readonly ?DateTimeImmutable $since, + /** Feed back as `since` to continue; null when nothing changed. */ + public readonly ?DateTimeImmutable $cursor, + public readonly bool $hasMore, + public readonly array $changes, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::date($data, 'since'), + self::date($data, 'cursor'), + self::bool($data, 'has_more') ?? false, + MetricChange::listFrom(self::seq($data, 'changes')), + ); + } +} diff --git a/src/Model/Model.php b/src/Model/Model.php index 00f6af5..1bfb35d 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -116,6 +116,21 @@ protected static function int(array $data, string $name): ?int return null; } + /** Averages and rates come back as floats; ints on the wire still count. */ + /** @param array $data */ + protected static function num(array $data, string $name): ?float + { + $value = self::field($data, $name); + if (is_float($value) || is_int($value)) { + return (float) $value; + } + if (is_string($value) && is_numeric($value)) { + return (float) $value; + } + + return null; + } + /** @param array $data */ protected static function float(array $data, string $name): ?float { diff --git a/src/Model/NativePost.php b/src/Model/NativePost.php new file mode 100644 index 0000000..d710857 --- /dev/null +++ b/src/Model/NativePost.php @@ -0,0 +1,42 @@ + $deliveries */ + private function __construct( + array $raw, + /** Null when the post was made natively on the network. */ + public readonly ?string $postId, + public readonly array $deliveries, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::str($data, 'post_id'), + TimelineDelivery::listFrom(self::seq($data, 'deliveries')), + ); + } +} diff --git a/src/Model/PostingFrequencyReport.php b/src/Model/PostingFrequencyReport.php new file mode 100644 index 0000000..5f78f30 --- /dev/null +++ b/src/Model/PostingFrequencyReport.php @@ -0,0 +1,38 @@ + $weeks + * @param array $bands + */ + private function __construct( + array $raw, + public readonly int $days, + public readonly array $weeks, + public readonly array $bands, + /** The cadence that earned the most per post; null without posts. */ + public readonly ?FrequencyBand $best, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + $best = self::nested($data, 'best'); + + return new self( + $data, + self::int($data, 'days') ?? 0, + FrequencyWeek::listFrom(self::seq($data, 'weeks')), + FrequencyBand::listFrom(self::seq($data, 'bands')), + $best !== null ? FrequencyBand::fromArray($best) : null, + ); + } +} diff --git a/src/Model/TimelineDelivery.php b/src/Model/TimelineDelivery.php new file mode 100644 index 0000000..47f36ef --- /dev/null +++ b/src/Model/TimelineDelivery.php @@ -0,0 +1,39 @@ + $points */ + private function __construct( + array $raw, + public readonly string $accountId, + public readonly string $platform, + public readonly string $username, + public readonly string $externalPostId, + public readonly ?DateTimeImmutable $postedAt, + public readonly array $points, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'account_id'), + self::requiredStr($data, 'platform'), + self::requiredStr($data, 'username'), + self::requiredStr($data, 'external_post_id'), + self::date($data, 'posted_at'), + TimelinePoint::listFrom(self::seq($data, 'points')), + ); + } +} diff --git a/src/Model/TimelineDelta.php b/src/Model/TimelineDelta.php new file mode 100644 index 0000000..6806438 --- /dev/null +++ b/src/Model/TimelineDelta.php @@ -0,0 +1,36 @@ +analytics(): deeper posting analytics. + * + * Derived from the repeated readings the platform collector takes of every + * post as it ages. Needs the `analytics` scope. + */ +final class AnalyticsResource extends Resource +{ + /** + * How engagement accumulates with a post's age, and where the half-life falls. + * + * `$days` selects posts by publish time, not reading time. + */ + public function decay( + ?int $days = null, + ?string $workspaceId = null, + ?string $accountId = null, + ): ContentDecayReport { + $query = self::compact(['days' => $days, 'workspace_id' => $workspaceId, 'accountId' => $accountId]); + + return ContentDecayReport::fromArray(self::unwrap($this->http->get('/analytics/decay', $query))); + } + + /** Weekly posting cadence set against what each cadence earned per post. */ + public function frequency( + ?int $days = null, + ?string $workspaceId = null, + ?string $accountId = null, + ): PostingFrequencyReport { + $query = self::compact(['days' => $days, 'workspace_id' => $workspaceId, 'accountId' => $accountId]); + + return PostingFrequencyReport::fromArray(self::unwrap($this->http->get('/analytics/frequency', $query))); + } + + /** + * Every reading held for one post, oldest first, one timeline per delivery. + * + * `$idOrPermalink` is a FoPost post id or the permalink of a post made + * natively on the network. + */ + public function timeline(string $idOrPermalink): PostTimeline + { + $path = '/analytics/posts/' . rawurlencode($idOrPermalink) . '/timeline'; + + return PostTimeline::fromArray(self::unwrap($this->http->get($path))); + } + + /** + * 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. + */ + public function changes( + ?string $since = null, + ?int $limit = null, + ?string $workspaceId = null, + ?string $accountId = null, + ): MetricChangePage { + $query = self::compact([ + 'since' => $since, + 'limit' => $limit, + 'workspace_id' => $workspaceId, + 'accountId' => $accountId, + ]); + + return MetricChangePage::fromArray(self::unwrap($this->http->get('/analytics/changes', $query))); + } + + /** + * 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`. + */ + public function collectPost(string $idOrPermalink): CollectPostResult + { + $path = '/posts/' . rawurlencode($idOrPermalink) . '/analytics/collect'; + + return CollectPostResult::fromArray(self::unwrap($this->http->post($path))); + } + + /** + * Posts on the account that never went out through FoPost, newest first. + * + * @return Page + */ + public function nativePosts(string $accountId, int $page = 1, int $perPage = 20, ?int $days = null): Page + { + $query = self::compact(['page' => $page, 'per_page' => $perPage, 'days' => $days]); + + return self::page(NativePost::class, $this->http->get("/accounts/{$accountId}/native-posts", $query)); + } +} diff --git a/tests/AnalyticsTest.php b/tests/AnalyticsTest.php new file mode 100644 index 0000000..5bdff59 --- /dev/null +++ b/tests/AnalyticsTest.php @@ -0,0 +1,211 @@ +transport->push(200, ['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' => null, + ], + ], + ]]); + + $decay = $this->client()->analytics()->decay(30, accountId: 'acc_1'); + + $this->assertSame('GET', $this->transport->last()['method']); + $this->assertSame( + 'https://api.fopost.com/v1/analytics/decay?days=30&accountId=acc_1', + $this->transport->last()['url'], + ); + $this->assertSame('1h_3h', $decay->halfLifeBucket); + $this->assertSame(2, $decay->postsMeasured); + $this->assertSame(0.3, $decay->bands[0]->shareOfFinal); + // A band nothing was measured in reports no share rather than zero + $this->assertNull($decay->bands[1]->shareOfFinal); + } + + public function testFrequencyReadsTheWeeksAndBestCadence(): void + { + $this->transport->push(200, ['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 = $this->client()->analytics()->frequency(90); + + $this->assertSame('2026-03-02', $frequency->weeks[0]->weekStart); + $this->assertSame(120.0, $frequency->weeks[0]->avgEngagementsPerPost); + $this->assertSame(0.12, $frequency->bands[0]->engagementRate); + $this->assertNotNull($frequency->best); + $this->assertSame('1-2 a week', $frequency->best->label); + } + + public function testTimelineEscapesAPermalinkIntoThePath(): void + { + $this->transport->push(200, ['data' => [ + 'postId' => null, + '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' => null, + 'likes' => 30, + 'comments' => null, + 'shares' => null, + 'videoViews' => null, + 'delta' => [ + 'impressions' => 400, + 'reach' => 0, + 'engagements' => 40, + 'likes' => 30, + 'comments' => 0, + 'shares' => 0, + ], + ]], + ]], + ]]); + + $timeline = $this->client()->analytics()->timeline('https://x.com/acme/status/1'); + + $this->assertSame( + 'https://api.fopost.com/v1/analytics/posts/https%3A%2F%2Fx.com%2Facme%2Fstatus%2F1/timeline', + $this->transport->last()['url'], + ); + $this->assertNull($timeline->postId); + $this->assertSame(30, $timeline->deliveries[0]->points[0]->ageMinutes); + $this->assertSame(40, $timeline->deliveries[0]->points[0]->delta->engagements); + } + + public function testChangesCarriesTheCursor(): void + { + $this->transport->push(200, ['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' => null, + 'engagements' => 90, + 'likes' => 70, + 'comments' => 10, + 'shares' => 10, + ]], + ]]); + + $page = $this->client()->analytics()->changes('2026-03-02T00:00:00Z', 100); + + $this->assertStringContainsString('since=2026-03-02T00%3A00%3A00Z', $this->transport->last()['url']); + $this->assertStringContainsString('limit=100', $this->transport->last()['url']); + $this->assertTrue($page->hasMore); + $this->assertSame('post_1', $page->changes[0]->postId); + } + + public function testCollectPostReportsEachDelivery(): void + { + $this->transport->push(200, ['data' => [ + 'collected' => 1, + 'deliveries' => [[ + 'accountId' => 'acc_1', + 'platform' => 'twitter', + 'externalPostId' => '1', + 'collected' => true, + 'fetchedAt' => '2026-03-02T00:30:00.000Z', + 'message' => null, + ]], + ]]); + + $result = $this->client()->analytics()->collectPost('post_1'); + + $this->assertSame('POST', $this->transport->last()['method']); + $this->assertSame( + 'https://api.fopost.com/v1/posts/post_1/analytics/collect', + $this->transport->last()['url'], + ); + $this->assertSame(1, $result->collected); + $this->assertTrue($result->deliveries[0]->collected); + } + + public function testNativePostsReturnsAPage(): void + { + $this->transport->push(200, [ + 'data' => [[ + 'externalPostId' => '1', + 'text' => 'Posted by hand', + 'permalink' => 'https://x.com/acme/status/1', + 'thumbnailUrl' => null, + 'mediaType' => null, + 'postedAt' => '2026-03-02T00:00:00.000Z', + 'fetchedAt' => '2026-03-02T06:00:00.000Z', + 'metrics' => [ + 'impressions' => 900, + 'reach' => null, + 'engagements' => 90, + 'likes' => 70, + 'comments' => 10, + 'shares' => 10, + 'videoViews' => null, + ], + ]], + 'meta' => ['page' => 1, 'perPage' => 20, 'total' => 1], + ]); + + $page = $this->client()->analytics()->nativePosts('acc_1'); + + $this->assertSame( + 'https://api.fopost.com/v1/accounts/acc_1/native-posts?page=1&per_page=20', + $this->transport->last()['url'], + ); + $this->assertCount(1, $page->items); + $this->assertSame('https://x.com/acme/status/1', $page->items[0]->permalink); + $this->assertSame(90, $page->items[0]->metrics->engagements); + } +}