Skip to content
8 changes: 2 additions & 6 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Enums\SocialAccount;

use App\Enums\Media\Type as MediaType;
use App\Enums\TikTok\PrivacyLevel;

enum Platform: string
{
Expand Down Expand Up @@ -480,12 +481,7 @@ public function publishConfig(): array
{
return match ($this) {
self::TikTok => [
'privacyLevelOptions' => [
'PUBLIC_TO_EVERYONE',
'MUTUAL_FOLLOW_FRIENDS',
'FOLLOWER_OF_CREATOR',
'SELF_ONLY',
],
'privacyLevelOptions' => PrivacyLevel::values(),
'musicUsageConfirmationUrl' => 'https://www.tiktok.com/legal/page/global/music-usage-confirmation/en',
'brandedContentPolicyUrl' => 'https://www.tiktok.com/legal/page/global/bc-policy/en',
],
Expand Down
26 changes: 26 additions & 0 deletions app/Enums/TikTok/PrivacyLevel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Enums\TikTok;

/**
* TikTok `post_info.privacy_level` values from the Content Posting API.
*
* @see https://developers.tiktok.com/doc/content-posting-api-reference-direct-post
*/
enum PrivacyLevel: string
{
case PublicToEveryone = 'PUBLIC_TO_EVERYONE';
case MutualFollowFriends = 'MUTUAL_FOLLOW_FRIENDS';
case FollowerOfCreator = 'FOLLOWER_OF_CREATOR';
case SelfOnly = 'SELF_ONLY';

/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
'content_type' => $p->string()->description('New content_type for this platform.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
Expand Down
22 changes: 14 additions & 8 deletions app/Services/Post/PostMetricsFetcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\Telegram\TelegramAnalytics;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokAnalytics;
use App\Services\Social\XAnalytics;
use App\Services\Social\YouTubeAnalytics;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -43,14 +44,18 @@ public function forPost(Post $post): Collection
return $post->postPlatforms
->where('enabled', true)
->values()
->map(fn (PostPlatform $pp) => [
'post_platform_id' => $pp->id,
'platform' => $pp->platform->value,
'status' => $pp->status->value,
'platform_post_id' => $pp->platform_post_id,
'platform_url' => $pp->platform_url,
'metrics' => $this->forPlatform($pp),
]);
->map(function (PostPlatform $pp): array {
$metrics = $this->forPlatform($pp);

return [
'post_platform_id' => $pp->id,
'platform' => $pp->platform->value,
'status' => $pp->status->value,
'platform_post_id' => $pp->platform_post_id,
'platform_url' => $pp->platform_url,
'metrics' => $metrics,
];
});
}

/**
Expand All @@ -74,6 +79,7 @@ public function forPlatform(PostPlatform $postPlatform): array
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform),
Platform::TikTok => app(TikTokAnalytics::class)->fetchPostMetrics($postPlatform),
default => ['unsupported' => true, 'reason' => 'platform_not_supported'],
});
}
Expand Down
257 changes: 256 additions & 1 deletion app/Services/Social/TikTokAnalytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,46 @@

namespace App\Services\Social;

use App\Enums\SocialAccount\Platform;
use App\Enums\TikTok\PrivacyLevel;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;

class TikTokAnalytics
{
use HasSocialHttpClient;

private const string VIDEO_METRIC_FIELDS = 'id,like_count,comment_count,share_count,view_count';

private const string VIDEO_LIST_FIELDS = 'id,title,create_time';

private const int VIDEO_LIST_PAGE_SIZE = 20;

private const int VIDEO_LIST_MAX_PAGES = 5;

/**
* `published_at` is stamped after TikTok finishes processing, which can trail
* the video's `create_time` by up to the status-poll window (~1 h). A day of
* slack keeps our own video inside the scan on slow publishes.
*/
private const int PUBLISH_CLOCK_SLACK_SECONDS = 86400;

/**
* @var array<string, string>
*/
private const array POST_METRICS = [
'view_count' => 'analytics.metrics.views',
'like_count' => 'analytics.metrics.likes',
'comment_count' => 'analytics.metrics.comments',
'share_count' => 'analytics.metrics.shares',
];

private string $baseUrl;

private string $accessToken;
Expand All @@ -33,6 +63,231 @@ public function getMetrics(SocialAccount $account): array
});
}

/**
* TikTok has no media insights edge. Per-post numbers live on
* `POST /v2/video/query/` and require the video's `item_id`, not the
* Content Posting `publish_id`. A stored `v_pub_*` / `p_pub_*` is resolved
* via status fetch: after moderation, `publicaly_available_post_id` is the
* id `video/query` accepts. Private posts never get one.
*
* @return array<int, array{label: string, value: int}>|array{unsupported: true, reason: string}
*/
public function fetchPostMetrics(PostPlatform $postPlatform): array
{
$account = $postPlatform->socialAccount;

if (! $account || ! $postPlatform->platform_post_id) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}

$this->prepareAccessToken($account);

$videoId = $this->videoIdFor($postPlatform);

if ($videoId === null) {
return ['unsupported' => true, 'reason' => 'missing_post_id'];
}

$response = $this->getHttpClient()
->post("{$this->baseUrl}/video/query/?fields=".self::VIDEO_METRIC_FIELDS, [
'filters' => ['video_ids' => [$videoId]],
]);

if ($response->failed()) {
Log::warning('TikTok post metrics fetch failed', [
'body' => $this->redactResponseBody($response->body()),
]);

return ['unsupported' => true, 'reason' => 'api_error'];
}

$video = collect(data_get($response->json(), 'data.videos', []))
->first(fn (mixed $item): bool => (string) data_get($item, 'id') === $videoId);

if (! is_array($video)) {
return ['unsupported' => true, 'reason' => 'api_error'];
}

return collect(self::POST_METRICS)
->map(fn (string $label, string $field): array => [
'label' => __($label),
'value' => (int) data_get($video, $field, 0),
])
->values()
->all();
}

/**
* Public posts often stay on a Content Posting `publish_id` because TikTok
* omits `publicaly_available_post_id` even after PUBLISH_COMPLETE. The video
* still shows up on `video/list` with the caption we sent — match that so
* the show-page link stops pointing at the profile. SELF_ONLY posts never
* appear on the list, so they are not looked up.
*/
public function findVideoIdByCaption(PostPlatform $postPlatform): ?string
{
$account = $postPlatform->socialAccount;

if (! $account) {
return null;
}

$this->prepareAccessToken($account);

return $this->matchVideoFromRecentList($postPlatform);
}

private function videoIdFor(PostPlatform $postPlatform): ?string
{
$stored = (string) $postPlatform->platform_post_id;

if (ctype_digit($stored)) {
return $stored;
}

$videoId = $this->publicVideoIdFromStatus($stored) ?? $this->matchVideoFromRecentList($postPlatform);

if ($videoId === null) {
return null;
}

$username = $postPlatform->socialAccount?->username;

$postPlatform->update([
'platform_post_id' => $videoId,
'platform_url' => filled($username)
? "https://www.tiktok.com/@{$username}/video/{$videoId}"
: $postPlatform->platform_url,
]);

return $videoId;
}

private function publicVideoIdFromStatus(string $publishId): ?string
{
$response = $this->getHttpClient()
->post("{$this->baseUrl}/post/publish/status/fetch/", [
'publish_id' => $publishId,
]);

if ($response->failed()) {
Log::warning('TikTok publish status fetch for metrics failed', [
'body' => $this->redactResponseBody($response->body()),
]);

return null;
}

return $this->digitsOrNull($response->json('data.publicaly_available_post_id.0'));
}

/**
* `video/list` is sorted by `create_time` desc, so scanning stops at the
* first video older than the publish — anything past it cannot be ours, and
* an older repost with the same caption must never be claimed.
*/
private function matchVideoFromRecentList(PostPlatform $postPlatform): ?string
{
if (PrivacyLevel::tryFrom((string) data_get($postPlatform->meta, 'privacy_level')) === PrivacyLevel::SelfOnly) {
return null;
}

$postPlatform->loadMissing('post');

$caption = $this->normalizeCaption((string) $postPlatform->post?->content);

if ($caption === '') {
return null;
}

$notBefore = ($postPlatform->published_at ?? now())->getTimestamp() - self::PUBLISH_CLOCK_SLACK_SECONDS;
$cursor = null;

for ($page = 0; $page < self::VIDEO_LIST_MAX_PAGES; $page++) {
$payload = ['max_count' => self::VIDEO_LIST_PAGE_SIZE];

if (filled($cursor)) {
$payload['cursor'] = $cursor;
}

$response = $this->getHttpClient()
->post("{$this->baseUrl}/video/list/?fields=".self::VIDEO_LIST_FIELDS, $payload);

if ($response->failed()) {
Log::warning('TikTok video list match failed', [
'body' => $this->redactResponseBody($response->body()),
]);

return null;
}

$data = $response->json('data', []);

foreach (data_get($data, 'videos', []) as $video) {
if ((int) data_get($video, 'create_time', 0) < $notBefore) {
return null;
}

$videoId = $this->digitsOrNull(data_get($video, 'id'));
$title = $this->normalizeCaption((string) data_get($video, 'title', ''));

if ($videoId !== null && $this->captionsMatch($caption, $title)) {
return $videoId;
}
}

$cursor = data_get($data, 'cursor');

if (! data_get($data, 'has_more') || blank($cursor)) {
return null;
}
}

return null;
}

/**
* `video/list` titles may be a truncated form of the caption we posted, so a
* prefix match in either direction counts. An empty title never matches:
* `str_starts_with($x, '')` is true and would claim any untitled video.
*/
private function captionsMatch(string $posted, string $title): bool
{
return $title !== ''
&& (str_starts_with($posted, $title) || str_starts_with($title, $posted));
}

private function digitsOrNull(mixed $value): ?string
{
$value = is_scalar($value) ? (string) $value : '';

return ctype_digit($value) ? $value : null;
}

private function normalizeCaption(string $text): string
{
return (string) Str::of(app(ContentSanitizer::class)->displayText($text, Platform::TikTok))
->squish()
->lower();
}

private function prepareAccessToken(SocialAccount $account): void
{
if ($account->needsProactiveTokenRefresh()) {
try {
app(ConnectionVerifier::class)->refreshToken($account);
$account->refresh();
} catch (Throwable $e) {
Log::warning('TikTok token refresh before post metrics failed', [
'account_id' => $account->id,
'error' => $e->getMessage(),
]);
}
}

$this->accessToken = $account->access_token;
}

private function fetchMetricsFromApi(SocialAccount $account): array
{
if ($account->needsProactiveTokenRefresh()) {
Expand Down Expand Up @@ -114,7 +369,7 @@ private function fetchVideoMetrics(): array
$videoIds = array_map(fn ($v) => $v['id'], $videos);

$queryResponse = $this->getHttpClient()
->post("{$this->baseUrl}/video/query/?fields=id,like_count,comment_count,share_count,view_count", [
->post("{$this->baseUrl}/video/query/?fields=".self::VIDEO_METRIC_FIELDS, [
'filters' => ['video_ids' => $videoIds],
]);

Expand Down
Loading
Loading