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
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -381,6 +381,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
Expand Down
17 changes: 17 additions & 0 deletions src/main/java/com/fopost/sdk/model/CollectPostResult.java
Original file line number Diff line number Diff line change
@@ -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<Delivery> 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) {}
}
24 changes: 24 additions & 0 deletions src/main/java/com/fopost/sdk/model/ContentDecay.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@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<Band> 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) {}
}
28 changes: 28 additions & 0 deletions src/main/java/com/fopost/sdk/model/MetricChangePage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.fopost.sdk.model;

import java.time.Instant;
import java.util.List;

/**
* Readings recorded after a cursor, oldest first.
*
* <p>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<Change> 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) {}
}
24 changes: 24 additions & 0 deletions src/main/java/com/fopost/sdk/model/NativePost.java
Original file line number Diff line number Diff line change
@@ -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) {}
}
40 changes: 40 additions & 0 deletions src/main/java/com/fopost/sdk/model/PostTimeline.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>{@code postId} is null when the post was made natively on the network.
*/
public record PostTimeline(String postId, List<Delivery> deliveries) {

public record Delivery(
String accountId,
String platform,
String username,
String externalPostId,
Instant postedAt,
List<Point> 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) {}
}
28 changes: 28 additions & 0 deletions src/main/java/com/fopost/sdk/model/PostingFrequency.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.fopost.sdk.model;

import java.util.List;

/**
* Weekly posting cadence set against what each cadence earned per post.
*
* <p>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<Week> weeks, List<Band> 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) {}
}
23 changes: 23 additions & 0 deletions src/main/java/com/fopost/sdk/param/AnalyticsParams.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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<String, Object> toQuery() {
Map<String, Object> query = new LinkedHashMap<>();
Params.put(query, "accountId", accountId);
Expand All @@ -93,6 +114,8 @@ public Map<String, Object> 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;
}
}
86 changes: 86 additions & 0 deletions src/main/java/com/fopost/sdk/resource/AnalyticsResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>{@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.
*
* <p>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<NativePost> 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<NativePost> 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");
}
}
Loading
Loading