From db509d54af145ca0ae8918935094256ccfb52df2 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 01:55:38 +0200 Subject: [PATCH] feat: read content decay, posting cadence, post timelines and native posts --- README.md | 49 +++++- .../fopost/sdk/model/CollectPostResult.java | 17 +++ .../com/fopost/sdk/model/ContentDecay.java | 24 +++ .../fopost/sdk/model/MetricChangePage.java | 28 ++++ .../java/com/fopost/sdk/model/NativePost.java | 24 +++ .../com/fopost/sdk/model/PostTimeline.java | 40 +++++ .../fopost/sdk/model/PostingFrequency.java | 28 ++++ .../com/fopost/sdk/param/AnalyticsParams.java | 23 +++ .../sdk/resource/AnalyticsResource.java | 86 +++++++++++ .../com/fopost/sdk/AnalyticsDeeperTest.java | 144 ++++++++++++++++++ 10 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fopost/sdk/model/CollectPostResult.java create mode 100644 src/main/java/com/fopost/sdk/model/ContentDecay.java create mode 100644 src/main/java/com/fopost/sdk/model/MetricChangePage.java create mode 100644 src/main/java/com/fopost/sdk/model/NativePost.java create mode 100644 src/main/java/com/fopost/sdk/model/PostTimeline.java create mode 100644 src/main/java/com/fopost/sdk/model/PostingFrequency.java create mode 100644 src/test/java/com/fopost/sdk/AnalyticsDeeperTest.java diff --git a/README.md b/README.md index 343931c..cede274 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` | | `labels()` | `list`, `get`, `create`, `update`, `delete` | | `webhooks()` | `list`, `create`, `update`, `delete`, `test` | -| `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect` | +| `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts` | | `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats` | | `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | @@ -260,6 +260,53 @@ MediaValidation file = client.validate().media("https://cdn.example.test/chart.p Nothing is stored. `media` answers `200` with `ok` false when the file fails a check; an unreachable url throws `ValidationException`. All three need the `posts` scope. +## Analytics + +```java +// How long a post keeps earning, from the repeated readings of each post +var decay = client.analytics().decay(AnalyticsParams.create().days(30)); +System.out.println(decay.halfLifeBucket()); // e.g. "1h_3h" + +// Whether posting more earned more +var cadence = client.analytics().frequency(AnalyticsParams.create().days(90)); +if (cadence.best() != null) { + System.out.println(cadence.best().label()); // e.g. "3-5 a week" +} + +// Every reading held for one post, with what moved between them +var timeline = client.analytics().timeline(post.id()); + +// Mirror the metrics into your own store, without refetching everything +Instant cursor = null; +while (true) { + var params = AnalyticsParams.create(); + if (cursor != null) { + params.since(cursor); + } + var page = client.analytics().changes(params); + save(page.changes()); + if (!Boolean.TRUE.equals(page.hasMore()) || page.cursor() == null) { + break; + } + cursor = page.cursor(); +} + +// 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 +for (var native : client.analytics().nativePosts(accounts.get(0).id())) { + System.out.println(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: + +```java +client.analytics().timeline("https://x.com/acme/status/1"); +``` + ## Configuration ```java diff --git a/src/main/java/com/fopost/sdk/model/CollectPostResult.java b/src/main/java/com/fopost/sdk/model/CollectPostResult.java new file mode 100644 index 0000000..2ad2335 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/CollectPostResult.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** What the on-demand refresh of one post managed, per delivery. */ +public record CollectPostResult(Integer collected, List deliveries) { + + /** {@code message} says why a refresh did not happen. */ + public record Delivery( + String accountId, + String platform, + String externalPostId, + Boolean collected, + Instant fetchedAt, + String message) {} +} diff --git a/src/main/java/com/fopost/sdk/model/ContentDecay.java b/src/main/java/com/fopost/sdk/model/ContentDecay.java new file mode 100644 index 0000000..1b30588 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ContentDecay.java @@ -0,0 +1,24 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * How engagement accumulates as a post ages, from the repeated readings taken of every post. + * + *

{@code halfLifeBucket} names the first band where the average post had passed half its final + * engagement, and is null when nothing was measured. + */ +public record ContentDecay(Integer days, Integer postsMeasured, String halfLifeBucket, List bands) { + + /** + * One age band. {@code posts} counts the posts with at least one reading in it, and + * {@code shareOfFinal} is null when nothing in the band had earned anything yet. + */ + public record Band( + String bucket, + String label, + Integer posts, + Double avgEngagements, + Double avgImpressions, + Double shareOfFinal) {} +} diff --git a/src/main/java/com/fopost/sdk/model/MetricChangePage.java b/src/main/java/com/fopost/sdk/model/MetricChangePage.java new file mode 100644 index 0000000..6f13dda --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/MetricChangePage.java @@ -0,0 +1,28 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * Readings recorded after a cursor, oldest first. + * + *

Feed {@code cursor} back as the next {@code since} to continue; it is null when nothing + * changed. + */ +public record MetricChangePage(Instant since, Instant cursor, Boolean hasMore, List changes) { + + /** One reading. {@code postId} is null for a post made natively on the network. */ + public record Change( + String accountId, + String platform, + String externalPostId, + String postId, + Instant postedAt, + Instant fetchedAt, + Integer impressions, + Integer reach, + Integer engagements, + Integer likes, + Integer comments, + Integer shares) {} +} diff --git a/src/main/java/com/fopost/sdk/model/NativePost.java b/src/main/java/com/fopost/sdk/model/NativePost.java new file mode 100644 index 0000000..6ff9340 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/NativePost.java @@ -0,0 +1,24 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** A post on the account that never went out through FoPost, with the freshest reading held. */ +public record NativePost( + String externalPostId, + String text, + String permalink, + String thumbnailUrl, + String mediaType, + Instant postedAt, + Instant fetchedAt, + Metrics metrics) { + + public record Metrics( + Integer impressions, + Integer reach, + Integer engagements, + Integer likes, + Integer comments, + Integer shares, + Integer videoViews) {} +} diff --git a/src/main/java/com/fopost/sdk/model/PostTimeline.java b/src/main/java/com/fopost/sdk/model/PostTimeline.java new file mode 100644 index 0000000..67553d4 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/PostTimeline.java @@ -0,0 +1,40 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * Every reading held for one post, oldest first, one timeline per delivery, because the same post + * on two networks decays differently. + * + *

{@code postId} is null when the post was made natively on the network. + */ +public record PostTimeline(String postId, List deliveries) { + + public record Delivery( + String accountId, + String platform, + String username, + String externalPostId, + Instant postedAt, + List points) {} + + /** + * One reading. {@code ageMinutes} is null when the network never said when the post went out, + * and {@code delta} is what moved since the reading before this one. + */ + public record Point( + Instant at, + Integer ageMinutes, + Integer impressions, + Integer reach, + Integer engagements, + Integer likes, + Integer comments, + Integer shares, + Integer videoViews, + Delta delta) {} + + public record Delta( + Integer impressions, Integer reach, Integer engagements, Integer likes, Integer comments, Integer shares) {} +} diff --git a/src/main/java/com/fopost/sdk/model/PostingFrequency.java b/src/main/java/com/fopost/sdk/model/PostingFrequency.java new file mode 100644 index 0000000..22d73a6 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/PostingFrequency.java @@ -0,0 +1,28 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * Weekly posting cadence set against what each cadence earned per post. + * + *

Weeks run Monday to Sunday in UTC and are grouped into bands by their own post count, so a + * four-post week is compared against other four-post weeks. {@code best} is null without posts. + */ +public record PostingFrequency(Integer days, List weeks, List bands, Band best) { + + /** One week of posting. {@code weekStart} is the Monday, UTC, as YYYY-MM-DD. */ + public record Week(String weekStart, Integer posts, Integer engagements, Double avgEngagementsPerPost) {} + + /** + * The weeks that shared a cadence, folded together. {@code engagementRate} is engagements over + * reach, impressions as the stand-in, and null with neither. + */ + public record Band( + String band, + String label, + Integer weeks, + Integer posts, + Double avgPostsPerWeek, + Double avgEngagementsPerPost, + Double engagementRate) {} +} diff --git a/src/main/java/com/fopost/sdk/param/AnalyticsParams.java b/src/main/java/com/fopost/sdk/param/AnalyticsParams.java index bf1f163..15ce6c9 100644 --- a/src/main/java/com/fopost/sdk/param/AnalyticsParams.java +++ b/src/main/java/com/fopost/sdk/param/AnalyticsParams.java @@ -1,5 +1,6 @@ package com.fopost.sdk.param; +import java.time.Instant; import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.Map; @@ -22,6 +23,8 @@ public final class AnalyticsParams { private String sort; private String label; private String audience; + private String since; + private Integer perPage; public static AnalyticsParams create() { return new AnalyticsParams(); @@ -81,6 +84,24 @@ public AnalyticsParams audience(String audience) { return this; } + /** Changes feed only: return readings recorded after this instant, as ISO 8601. */ + public AnalyticsParams since(String since) { + this.since = since; + return this; + } + + /** Changes feed only, as an instant. */ + public AnalyticsParams since(Instant since) { + this.since = since == null ? null : since.toString(); + return this; + } + + /** Native posts only: page size, since that endpoint reads {@code per_page}. */ + public AnalyticsParams perPage(int perPage) { + this.perPage = perPage; + return this; + } + public Map toQuery() { Map query = new LinkedHashMap<>(); Params.put(query, "accountId", accountId); @@ -93,6 +114,8 @@ public Map toQuery() { Params.put(query, "sort", sort); Params.put(query, "label", label); Params.put(query, "audience", audience); + Params.put(query, "since", since); + Params.put(query, "per_page", perPage); return query; } } diff --git a/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java b/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java index 8453489..dcfa2a5 100644 --- a/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AnalyticsResource.java @@ -2,14 +2,25 @@ import com.fopost.sdk.internal.ApiClient; import com.fopost.sdk.model.AnalyticsOverview; +import com.fopost.sdk.model.CollectPostResult; import com.fopost.sdk.model.CollectSummary; +import com.fopost.sdk.model.ContentDecay; import com.fopost.sdk.model.Demographics; import com.fopost.sdk.model.LabelAnalytics; +import com.fopost.sdk.model.MetricChangePage; +import com.fopost.sdk.model.NativePost; +import com.fopost.sdk.model.Page; +import com.fopost.sdk.model.PageMeta; +import com.fopost.sdk.model.PostTimeline; +import com.fopost.sdk.model.PostingFrequency; import com.fopost.sdk.model.PostingStreak; import com.fopost.sdk.model.PostsTable; import com.fopost.sdk.model.TimeSeries; import com.fopost.sdk.model.TopPost; import com.fopost.sdk.param.AnalyticsParams; +import com.fasterxml.jackson.databind.JsonNode; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -79,4 +90,79 @@ public CollectSummary collect(String accountId) { } return http.convert(ApiClient.unwrap(http.post("/v1/analytics/collect", null, query)), CollectSummary.class); } + + /** + * How long a post keeps earning: engagement grouped by the post's age at each reading. + * + *

{@code days} selects posts by publish time, not reading time. + */ + public ContentDecay decay(AnalyticsParams params) { + return http.convert(ApiClient.unwrap(http.get("/v1/analytics/decay", params.toQuery())), ContentDecay.class); + } + + public ContentDecay decay() { + return decay(AnalyticsParams.create()); + } + + /** Whether posting more earned more: weekly cadence against what each cadence earned per post. */ + public PostingFrequency frequency(AnalyticsParams params) { + return http.convert( + ApiClient.unwrap(http.get("/v1/analytics/frequency", params.toQuery())), PostingFrequency.class); + } + + public PostingFrequency frequency() { + return frequency(AnalyticsParams.create()); + } + + /** + * Every reading held for one post, oldest first, with what moved between them. + * + * @param idOrPermalink a FoPost post id, or the permalink of a post made natively on the network + */ + public PostTimeline timeline(String idOrPermalink) { + String path = "/v1/analytics/posts/" + encodeSegment(idOrPermalink) + "/timeline"; + return http.convert(ApiClient.unwrap(http.get(path, null)), PostTimeline.class); + } + + /** + * Readings recorded after {@code since}, oldest first, with a cursor to continue. + * + *

Poll it to mirror the metrics into your own store instead of refetching the whole history. + * Without a {@code since} it answers with the last seven days. + */ + public MetricChangePage changes(AnalyticsParams params) { + return http.convert( + ApiClient.unwrap(http.get("/v1/analytics/changes", params.toQuery())), MetricChangePage.class); + } + + public MetricChangePage changes() { + return changes(AnalyticsParams.create()); + } + + /** + * Re-read one post from the network now. Spends the same per-user budget as {@link #collect()}, + * so a burst answers 429. + * + * @param idOrPermalink a FoPost post id, or the permalink of a post made natively on the network + */ + public CollectPostResult collectPost(String idOrPermalink) { + String path = "/v1/posts/" + encodeSegment(idOrPermalink) + "/analytics/collect"; + return http.convert(ApiClient.unwrap(http.post(path, null, null)), CollectPostResult.class); + } + + /** Posts on the account that never went out through FoPost, newest first. */ + public Page nativePosts(String accountId, AnalyticsParams params) { + JsonNode body = http.get("/v1/accounts/" + accountId + "/native-posts", params.toQuery()); + return new Page<>( + http.convertList(body.path("data"), NativePost.class), http.convert(body.path("meta"), PageMeta.class)); + } + + public Page nativePosts(String accountId) { + return nativePosts(accountId, AnalyticsParams.create()); + } + + /** A post can be addressed by permalink, whose slashes would otherwise split the path. */ + private static String encodeSegment(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } } diff --git a/src/test/java/com/fopost/sdk/AnalyticsDeeperTest.java b/src/test/java/com/fopost/sdk/AnalyticsDeeperTest.java new file mode 100644 index 0000000..f64af00 --- /dev/null +++ b/src/test/java/com/fopost/sdk/AnalyticsDeeperTest.java @@ -0,0 +1,144 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.param.AnalyticsParams; +import org.junit.jupiter.api.Test; + +/** The deeper analytics endpoints: decay, cadence, per-post timelines, changes and native posts. */ +class AnalyticsDeeperTest { + + @Test + void decayReadsTheBandsAndTheHalfLife() { + FakeTransport transport = new FakeTransport() + .enqueue( + 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}]}}"); + + var decay = TestSupport.client(transport) + .analytics() + .decay(AnalyticsParams.create().days(30).accountId("a1")); + + assertEquals("https://api.fopost.test/v1/analytics/decay?accountId=a1&days=30", transport.last().url()); + assertEquals("1h_3h", decay.halfLifeBucket()); + assertEquals(2, decay.postsMeasured()); + assertEquals(0.3, decay.bands().get(0).shareOfFinal()); + // A band nothing was measured in reports no share rather than zero + assertNull(decay.bands().get(1).shareOfFinal()); + } + + @Test + void frequencyReadsTheWeeksAndTheBestCadence() { + FakeTransport transport = new FakeTransport() + .enqueue( + 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}}}"); + + var frequency = + TestSupport.client(transport).analytics().frequency(AnalyticsParams.create().days(90)); + + assertEquals("2026-03-02", frequency.weeks().get(0).weekStart()); + assertEquals(0.12, frequency.bands().get(0).engagementRate()); + assertEquals("1-2 a week", frequency.best().label()); + } + + @Test + void aTimelineCanBeAddressedByPermalink() { + FakeTransport transport = new FakeTransport() + .enqueue( + 200, + "{\"data\":{\"postId\":null,\"deliveries\":[{\"accountId\":\"a1\"," + + "\"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}}]}]}}"); + + var timeline = TestSupport.client(transport).analytics().timeline("https://x.com/acme/status/1"); + + assertEquals( + "https://api.fopost.test/v1/analytics/posts/https%3A%2F%2Fx.com%2Facme%2Fstatus%2F1/timeline", + transport.last().url()); + // A post made on the network has no FoPost id + assertNull(timeline.postId()); + var point = timeline.deliveries().get(0).points().get(0); + assertEquals(30, point.ageMinutes()); + assertEquals(40, point.delta().engagements()); + } + + @Test + void changesCarriesTheCursor() { + FakeTransport transport = new FakeTransport() + .enqueue( + 200, + "{\"data\":{\"since\":\"2026-03-02T00:00:00.000Z\"," + + "\"cursor\":\"2026-03-02T06:00:00.000Z\",\"hasMore\":true,\"changes\":[" + + "{\"accountId\":\"a1\",\"platform\":\"twitter\",\"externalPostId\":\"1\"," + + "\"postId\":\"p1\",\"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}]}}"); + + var page = TestSupport.client(transport) + .analytics() + .changes(AnalyticsParams.create().since("2026-03-02T00:00:00Z").limit(100)); + + assertTrue(transport.last().url().contains("since=2026-03-02T00%3A00%3A00Z")); + assertTrue(page.hasMore()); + assertEquals("p1", page.changes().get(0).postId()); + } + + @Test + void collectPostReportsEachDelivery() { + FakeTransport transport = new FakeTransport() + .enqueue( + 200, + "{\"data\":{\"collected\":1,\"deliveries\":[{\"accountId\":\"a1\"," + + "\"platform\":\"twitter\",\"externalPostId\":\"1\",\"collected\":true," + + "\"fetchedAt\":\"2026-03-02T00:30:00.000Z\",\"message\":null}]}}"); + + var result = TestSupport.client(transport).analytics().collectPost("p1"); + + assertEquals("https://api.fopost.test/v1/posts/p1/analytics/collect", transport.last().url()); + assertEquals(1, result.collected()); + assertTrue(result.deliveries().get(0).collected()); + } + + @Test + void nativePostsKeepsTheMetaEnvelope() { + FakeTransport transport = new FakeTransport() + .enqueue( + 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}}"); + + var page = TestSupport.client(transport) + .analytics() + .nativePosts("a1", AnalyticsParams.create().page(1).perPage(20)); + + assertEquals("https://api.fopost.test/v1/accounts/a1/native-posts?page=1&per_page=20", transport.last().url()); + assertEquals(1, page.size()); + assertEquals("https://x.com/acme/status/1", page.data().get(0).permalink()); + assertEquals(90, page.data().get(0).metrics().engagements()); + assertEquals(20, page.meta().perPage()); + } +}