Skip to content

feat(apify): persist scrape results for the remaining 5 platforms#740

Merged
sweetmantech merged 1 commit into
mainfrom
feat/persist-remaining-platforms
Jul 2, 2026
Merged

feat(apify): persist scrape results for the remaining 5 platforms#740
sweetmantech merged 1 commit into
mainfrom
feat/persist-remaining-platforms

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Part of recoupable/chat#1833 — completes the persistence work from api#738 (registry + LinkedIn). Base: main.

What

TikTok, X/Twitter, YouTube, Threads, and Facebook scrapes now persist to socials instead of being poll-only. Per platform, mirroring lib/apify/linkedin/:

  1. Webhook attachment — each start… module passes { webhooks: getApifyWebhooks() } (root cause of "nothing persists": only IG/LinkedIn runs ever called /api/apify).
  2. Result handlerhandle<Platform>ProfileScraperResults upserts socials (keyed profile_url), mapping only fields observed in a real actor run of each platform (run ids in the code comments: TikTok G4YRI0e…, X ALVMZYX…, YouTube H0ZrIAs…, Threads 9iiG1sD…, Facebook ICZxdJB…).
  3. Registry entry — actor ids resolved live from the Apify API: GdWCkxBtKWOsKjdch (tiktok), nfp1fpt5gUlBwPcor (x), h7sDV53CddomktSi5 (youtube), kJdK90pa2hhYYrCK5 (threads), 4Hv5RhChiaDk6iwad (facebook).

Round-trip hazards handled explicitly

upsertSocials keys on exact profile_url, so each handler must emit a URL that matches the stored row:

  • X/Twitter: the actor echoes display casing (x.com/TheASF) while stored rows are lowercase — the handler lowercases (X handles are case-insensitive). Without this, every scrape would create a duplicate row.
  • YouTube: the actor's channelUrl is the /channel/UC… form, which never matches the stored @handle row — the handler keys on inputChannelUrl (the exact URL this service passed to the actor).

Tests (TDD)

  • 5 handler test files, each asserting the exact upsert from its real trimmed fixture + empty-dataset no-op.
  • Registry test extended to all 5 new actor ids; webhook-handler test unchanged in behavior (new handler mocks only).
  • Full lib/apify suite green (65 tests / 20 files); tsc + eslint clean.

Preview verification (live scrape → webhook → DB diff on real rows) to follow as a comment.

🤖 Generated with Claude Code


Summary by cubic

Persist TikTok, X/Twitter, YouTube, Threads, and Facebook scrape results to socials via Apify webhooks, completing the remaining platform persistence. This satisfies recoupable/chat#1833 and removes the poll-only behavior.

  • New Features

    • Added handle<Platform>ProfileScraperResults for all 5 platforms to upsert into socials keyed by profile_url.
    • Attached getApifyWebhooks() to all start… modules so runs hit /api/apify.
    • Registered each platform’s actor ID in getApifyResultHandler.
    • Tests added for each handler and the registry mapping.
  • Bug Fixes

    • X/Twitter: lowercased profile_url to avoid duplicate rows caused by display casing.
    • YouTube: upsert keyed on inputChannelUrl (matches stored @handle), not /channel/UC… URLs.

Written for commit 31cd246. Summary will update on new commits.

Review in cubic

Completes the persistence work started in #738 (registry + LinkedIn):
TikTok, X/Twitter, YouTube, Threads, and Facebook scrapes now write back to
socials instead of being poll-only (recoupable/chat#1833).

Per platform (mirroring lib/apify/linkedin/):
- start module attaches getApifyWebhooks() to the run (the root cause of
  "nothing persists" — only IG/LinkedIn runs ever called /api/apify).
- handle<Platform>ProfileScraperResults upserts socials (keyed profile_url),
  with the field mapping taken from a REAL actor run of each platform:
  tiktok authorMeta (run G4YRI0e…), twitter author (ALVMZYX…), youtube
  channel fields (H0ZrIAs…), threads profile (9iiG1sD…), facebook page
  (ICZxdJB…).
- registry entry under the actor id resolved from the Apify API.

Round-trip hazards handled explicitly: twitter URLs are lowercased (actor
echoes display casing, stored rows are lowercase — upsert would duplicate);
youtube keys on inputChannelUrl (the URL we passed) because the actor's own
channelUrl is the /channel/UC… form which never matches the stored @handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 2, 2026 12:05am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 93d64ead-3d03-46cf-b48b-f4d150b69069

📥 Commits

Reviewing files that changed from the base of the PR and between bd1958c and 31cd246.

⛔ Files ignored due to path filters (7)
  • lib/apify/__tests__/apifyWebhookHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/__tests__/getApifyResultHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/facebook/__tests__/handleFacebookProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/threads/__tests__/handleThreadsProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/tiktok/__tests__/handleTiktokProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/youtube/__tests__/handleYoutubeProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (11)
  • lib/apify/facebook/handleFacebookProfileScraperResults.ts
  • lib/apify/facebook/startFacebookProfileScraping.ts
  • lib/apify/getApifyResultHandler.ts
  • lib/apify/threads/handleThreadsProfileScraperResults.ts
  • lib/apify/threads/startThreadsProfileScraping.ts
  • lib/apify/tiktok/handleTiktokProfileScraperResults.ts
  • lib/apify/tiktok/startTiktokProfileScraping.ts
  • lib/apify/twitter/handleTwitterProfileScraperResults.ts
  • lib/apify/twitter/startTwitterProfileScraping.ts
  • lib/apify/youtube/handleYoutubeProfileScraperResults.ts
  • lib/apify/youtube/startYoutubeProfileScraping.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/persist-remaining-platforms

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 18 files

Confidence score: 4/5

  • In lib/apify/twitter/handleTwitterProfileScraperResults.ts, mapping author.location || null and author.description || null can prevent cleared profile fields from being persisted because upsertSocials drops nullish values, leaving stale socials.region/socials.bio in place after users remove them on X — switch these assignments to preserve empty strings (or otherwise explicitly clear stored values) before merging.
  • lib/apify/tiktok/__tests__/handleTiktokProfileScraperResults.test.ts claims coverage for both empty datasets and missing authorMeta, but only validates the empty-dataset path; a missing-authorMeta regression could slip through untested — add the items[0] without authorMeta case to de-risk future parser changes.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/apify/tiktok/__tests__/handleTiktokProfileScraperResults.test.ts">

<violation number="1" location="lib/apify/tiktok/__tests__/handleTiktokProfileScraperResults.test.ts:46">
P3: Test description claims to cover two scenarios (empty dataset OR no authorMeta) but only tests the empty-dataset path. The 'no authorMeta' case — where `items[0]` exists but lacks `authorMeta` — is untested. Since the handler guards on `!author?.profileUrl`, this path is exercised differently and should either be tested or removed from the description to avoid misleading future readers.</violation>
</file>

<file name="lib/apify/twitter/handleTwitterProfileScraperResults.ts">

<violation number="1" location="lib/apify/twitter/handleTwitterProfileScraperResults.ts:35">
P2: X profiles that remove their bio will keep stale `socials.bio`, because `author.description || null` converts the actor's empty string to `null` and `upsertSocials` drops nullish fields before upsert. Using nullish coalescing preserves an observed empty string so the scrape can clear stale text.</violation>

<violation number="2" location="lib/apify/twitter/handleTwitterProfileScraperResults.ts:38">
P2: X profiles that clear their location will keep stale `socials.region`, because `author.location || null` turns the empty string into a dropped nullish field. Preserving empty strings lets the upsert reflect the latest scrape result.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as External Scheduler
    participant API as API Route (/api/apify)
    participant Handler as ApifyWebhookHandler
    participant Registry as getApifyResultHandler
    participant TikTok as handleTiktokProfileScraperResults
    participant Twitter as handleTwitterProfileScraperResults
    participant YouTube as handleYoutubeProfileScraperResults
    participant Threads as handleThreadsProfileScraperResults
    participant Facebook as handleFacebookProfileScraperResults
    participant ApifyClient as Apify Client (apifyClient)
    participant DB as Database (socials)

    Note over Client,DB: NEW: Webhook persistence flow for all 5 platforms

    Client->>API: POST /api/apify (webhook payload)
    API->>Handler: parse & validate request
    Handler->>Registry: getApifyResultHandler(actorId)
    
    alt TikTok actor (GdWCkxBtKWOsKjdch)
        Registry-->>Handler: handleTiktokProfileScraperResults
        Handler->>TikTok: handle(payload)
        TikTok->>ApifyClient: listItems(datasetId)
        ApifyClient-->>TikTok: items (post items)
        TikTok->>TikTok: extract authorMeta (name, profileUrl, avatar, fans)
        TikTok->>DB: upsertSocials([{ profile_url, username, avatar, followerCount }])
    else Twitter actor (nfp1fpt5gUlBwPcor)
        Registry-->>Handler: handleTwitterProfileScraperResults
        Handler->>Twitter: handle(payload)
        Twitter->>ApifyClient: listItems(datasetId)
        ApifyClient-->>Twitter: items (tweet items)
        Twitter->>Twitter: extract author (userName, url, followers)
        Note over Twitter: NEW: lowercase profile_url to match stored rows (x.com/theasf)
        Twitter->>DB: upsertSocials([{ profile_url, username, avatar, followerCount, region }])
    else YouTube actor (h7sDV53CddomktSi5)
        Registry-->>Handler: handleYoutubeProfileScraperResults
        Handler->>YouTube: handle(payload)
        YouTube->>ApifyClient: listItems(datasetId)
        ApifyClient-->>YouTube: items (video items)
        YouTube->>YouTube: extract inputChannelUrl, channelUsername, subscribers
        Note over YouTube: NEW: key on inputChannelUrl, NOT channelUrl (/channel/UC…)
        YouTube->>DB: upsertSocials([{ profile_url, username, avatar, followerCount, region }])
    else Threads actor (kJdK90pa2hhYYrCK5)
        Registry-->>Handler: handleThreadsProfileScraperResults
        Handler->>Threads: handle(payload)
        Threads->>ApifyClient: listItems(datasetId)
        ApifyClient-->>Threads: items (profile items)
        Threads->>Threads: extract username, url, follower_count
        Threads->>DB: upsertSocials([{ profile_url, username, avatar, followerCount, bio }])
    else Facebook actor (4Hv5RhChiaDk6iwad)
        Registry-->>Handler: handleFacebookProfileScraperResults
        Handler->>Facebook: handle(payload)
        Facebook->>ApifyClient: listItems(datasetId)
        ApifyClient-->>Facebook: items (page items)
        Facebook->>Facebook: extract pageUrl, pageName, followers
        Facebook->>DB: upsertSocials([{ profile_url, username, avatar, followerCount }])
    else Unknown actor
        Registry-->>Handler: undefined
        Note over Handler: No-op, return early
    end

    Note over Client,DB: NEW: Each start module now attaches webhooks
    Note over Client,DB: startTiktokProfileScraping -> actor.start(input, { webhooks: getApifyWebhooks() })
    Note over Client,DB: startTwitterProfileScraping -> same pattern
    Note over Client,DB: startYoutubeProfileScraping -> same pattern
    Note over Client,DB: startThreadsProfileScraping -> same pattern
    Note over Client,DB: startFacebookProfileScraping -> same pattern

    DB-->>Handler: upsert result
    Handler-->>API: { ok: true }
    API-->>Client: 200 OK

    Note over TikTok,Facebook: empty dataset → no-op, return { social: null }
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

bio: author.description || null,
followerCount: author.followers ?? null,
followingCount: author.following ?? null,
region: author.location || null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: X profiles that clear their location will keep stale socials.region, because author.location || null turns the empty string into a dropped nullish field. Preserving empty strings lets the upsert reflect the latest scrape result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/twitter/handleTwitterProfileScraperResults.ts, line 38:

<comment>X profiles that clear their location will keep stale `socials.region`, because `author.location || null` turns the empty string into a dropped nullish field. Preserving empty strings lets the upsert reflect the latest scrape result.</comment>

<file context>
@@ -0,0 +1,42 @@
+    bio: author.description || null,
+    followerCount: author.followers ?? null,
+    followingCount: author.following ?? null,
+    region: author.location || null,
+  };
+  await upsertSocials([social]);
</file context>
Suggested change
region: author.location || null,
region: author.location ?? null,

profile_url: normalizeProfileUrl(author.url).toLowerCase(),
username: author.userName,
avatar: author.profilePicture ?? null,
bio: author.description || null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: X profiles that remove their bio will keep stale socials.bio, because author.description || null converts the actor's empty string to null and upsertSocials drops nullish fields before upsert. Using nullish coalescing preserves an observed empty string so the scrape can clear stale text.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/twitter/handleTwitterProfileScraperResults.ts, line 35:

<comment>X profiles that remove their bio will keep stale `socials.bio`, because `author.description || null` converts the actor's empty string to `null` and `upsertSocials` drops nullish fields before upsert. Using nullish coalescing preserves an observed empty string so the scrape can clear stale text.</comment>

<file context>
@@ -0,0 +1,42 @@
+    profile_url: normalizeProfileUrl(author.url).toLowerCase(),
+    username: author.userName,
+    avatar: author.profilePicture ?? null,
+    bio: author.description || null,
+    followerCount: author.followers ?? null,
+    followingCount: author.following ?? null,
</file context>
Suggested change
bio: author.description || null,
bio: author.description ?? null,

},
]);
});
it("no-ops when the dataset is empty or has no authorMeta", async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Test description claims to cover two scenarios (empty dataset OR no authorMeta) but only tests the empty-dataset path. The 'no authorMeta' case — where items[0] exists but lacks authorMeta — is untested. Since the handler guards on !author?.profileUrl, this path is exercised differently and should either be tested or removed from the description to avoid misleading future readers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/tiktok/__tests__/handleTiktokProfileScraperResults.test.ts, line 46:

<comment>Test description claims to cover two scenarios (empty dataset OR no authorMeta) but only tests the empty-dataset path. The 'no authorMeta' case — where `items[0]` exists but lacks `authorMeta` — is untested. Since the handler guards on `!author?.profileUrl`, this path is exercised differently and should either be tested or removed from the description to avoid misleading future readers.</comment>

<file context>
@@ -0,0 +1,51 @@
+      },
+    ]);
+  });
+  it("no-ops when the dataset is empty or has no authorMeta", async () => {
+    listItems.mockResolvedValue({ items: [] });
+    expect(await handleTiktokProfileScraperResults(payload)).toEqual({ social: null });
</file context>

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 3 of 5 platforms persisted live (webhook → DB), zero duplicate rows

Tested on this PR's preview (https://api-onek456p0-recoup.vercel.app) against real product rows (Apache's socials). Captured each row before, fired one POST /api/socials/{id}/scrape per platform, and re-read after the runs succeeded — every write below was made by the Apify webhook, not a human.

platform social followerCount before after (webhook-written) run
TikTok tiktok.com/@apache_207 918,100 (stale since 04-30) 917,500 @ 00:09:19 + avatar refreshed 81Fpn5w68qAxxaynn
X/Twitter x.com/theasf 66,112 (stale since 04-30) 66,211 @ 00:08:34 + avatar + bio CTSZfCiH3kSbHqefl
YouTube youtube.com/@apachelasminas 0 1,010,000 @ 00:08:54 + username fixed (@ApachelasminasApachelasminas) + avatar + bio XGbHmjOIgaUkFblo3

The round-trip hazards — verified handled

  • X lowercase: the actor returned x.com/TheASF; the handler lowercased → the existing x.com/theasf row was updated, and the DB shows exactly 1 theasf row (a naive upsert would have created a duplicate).
  • YouTube inputChannelUrl: the actor's own URL is /channel/UC…; the handler keyed on inputChannelUrl → the stored @apachelasminas row was updated, and 0 new /channel/… rows exist post-test.
  • Full duplicate sweep: only pre-existing unrelated rows matched (TikTok fan accounts from 05-28, an old twitter.com/apachelasminas); no new rows created.

Threads + Facebook

No product socials rows exist for these platforms to scrape live, so they're covered by unit tests built from real actor runs executed for this PR (Threads 9iiG1sDkpaeWPCWHl@zuck, 5.64M followers; Facebook ICZxdJBrtP1jkPITSfacebook.com/facebook, 154M followers) — same mechanism (webhook attach + registry + handler) as the three verified live above and LinkedIn in api#738.

Net: with this PR, all 7 supported platforms (IG, LinkedIn, TikTok, X, YouTube, Threads, Facebook) persist scrape results to socials. The "socials data goes stale for non-IG platforms" gap (chat#1833) is closed.

@sweetmantech sweetmantech merged commit 7b8c4b0 into main Jul 2, 2026
6 checks passed
sweetmantech added a commit that referenced this pull request Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant