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
15 changes: 15 additions & 0 deletions lib/apify/__tests__/apifyWebhookHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ vi.mock("../instagram/handleInstagramCommentsScraper", () => ({
vi.mock("../linkedin/handleLinkedinProfileScraperResults", () => ({
handleLinkedinProfileScraperResults: vi.fn(),
}));
vi.mock("../tiktok/handleTiktokProfileScraperResults", () => ({
handleTiktokProfileScraperResults: vi.fn(),
}));
vi.mock("../twitter/handleTwitterProfileScraperResults", () => ({
handleTwitterProfileScraperResults: vi.fn(),
}));
vi.mock("../youtube/handleYoutubeProfileScraperResults", () => ({
handleYoutubeProfileScraperResults: vi.fn(),
}));
vi.mock("../threads/handleThreadsProfileScraperResults", () => ({
handleThreadsProfileScraperResults: vi.fn(),
}));
vi.mock("../facebook/handleFacebookProfileScraperResults", () => ({
handleFacebookProfileScraperResults: vi.fn(),
}));

function makeRequest(body: unknown, raw?: string) {
return new NextRequest("http://localhost/api/apify", {
Expand Down
27 changes: 27 additions & 0 deletions lib/apify/__tests__/getApifyResultHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { getApifyResultHandler } from "@/lib/apify/getApifyResultHandler";
import { handleInstagramProfileScraperResults } from "@/lib/apify/instagram/handleInstagramProfileScraperResults";
import { handleInstagramCommentsScraper } from "@/lib/apify/instagram/handleInstagramCommentsScraper";
import { handleLinkedinProfileScraperResults } from "@/lib/apify/linkedin/handleLinkedinProfileScraperResults";
import { handleTiktokProfileScraperResults } from "@/lib/apify/tiktok/handleTiktokProfileScraperResults";
import { handleTwitterProfileScraperResults } from "@/lib/apify/twitter/handleTwitterProfileScraperResults";
import { handleYoutubeProfileScraperResults } from "@/lib/apify/youtube/handleYoutubeProfileScraperResults";
import { handleThreadsProfileScraperResults } from "@/lib/apify/threads/handleThreadsProfileScraperResults";
import { handleFacebookProfileScraperResults } from "@/lib/apify/facebook/handleFacebookProfileScraperResults";

vi.mock("@/lib/apify/instagram/handleInstagramProfileScraperResults", () => ({
handleInstagramProfileScraperResults: vi.fn(),
Expand All @@ -13,6 +18,21 @@ vi.mock("@/lib/apify/instagram/handleInstagramCommentsScraper", () => ({
vi.mock("@/lib/apify/linkedin/handleLinkedinProfileScraperResults", () => ({
handleLinkedinProfileScraperResults: vi.fn(),
}));
vi.mock("@/lib/apify/tiktok/handleTiktokProfileScraperResults", () => ({
handleTiktokProfileScraperResults: vi.fn(),
}));
vi.mock("@/lib/apify/twitter/handleTwitterProfileScraperResults", () => ({
handleTwitterProfileScraperResults: vi.fn(),
}));
vi.mock("@/lib/apify/youtube/handleYoutubeProfileScraperResults", () => ({
handleYoutubeProfileScraperResults: vi.fn(),
}));
vi.mock("@/lib/apify/threads/handleThreadsProfileScraperResults", () => ({
handleThreadsProfileScraperResults: vi.fn(),
}));
vi.mock("@/lib/apify/facebook/handleFacebookProfileScraperResults", () => ({
handleFacebookProfileScraperResults: vi.fn(),
}));

describe("getApifyResultHandler", () => {
it("maps the Instagram actor ids to their result handlers", () => {
Expand All @@ -22,6 +42,13 @@ describe("getApifyResultHandler", () => {
it("maps the harvestapi LinkedIn actor id to its results handler", () => {
expect(getApifyResultHandler("LpVuK3Zozwuipa5bp")).toBe(handleLinkedinProfileScraperResults);
});
it("maps every platform's resolved actor id to its results handler", () => {
expect(getApifyResultHandler("GdWCkxBtKWOsKjdch")).toBe(handleTiktokProfileScraperResults);
expect(getApifyResultHandler("nfp1fpt5gUlBwPcor")).toBe(handleTwitterProfileScraperResults);
expect(getApifyResultHandler("h7sDV53CddomktSi5")).toBe(handleYoutubeProfileScraperResults);
expect(getApifyResultHandler("kJdK90pa2hhYYrCK5")).toBe(handleThreadsProfileScraperResults);
expect(getApifyResultHandler("4Hv5RhChiaDk6iwad")).toBe(handleFacebookProfileScraperResults);
});
it("returns undefined for an unregistered actor id", () => {
expect(getApifyResultHandler("unknown_actor")).toBeUndefined();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleFacebookProfileScraperResults } from "@/lib/apify/facebook/handleFacebookProfileScraperResults";
const listItems = vi.fn();
vi.mock("@/lib/apify/client", () => ({ default: { dataset: vi.fn(() => ({ listItems })) } }));
const upsertSocials = vi.fn();
vi.mock("@/lib/supabase/socials/upsertSocials", () => ({
upsertSocials: (...a: unknown[]) => upsertSocials(...a),
}));

const payload = {
eventData: { actorId: "4Hv5RhChiaDk6iwad" },
resource: { defaultDatasetId: "ds-1" },
} as never;
// Trimmed from real run ICZxdJBrtP1jkPITS (2026-07-01).
const REAL_ITEM = {
pageUrl: "https://www.facebook.com/facebook",
facebookUrl: "https://www.facebook.com/facebook",
pageName: "facebook",
title: "Facebook",
profilePictureUrl: "https://scontent.cdn/pic.jpg",
followers: 154000000,
};
beforeEach(() => {
vi.clearAllMocks();
upsertSocials.mockResolvedValue([]);
});

describe("handleFacebookProfileScraperResults", () => {
it("upserts the page from a real item (keyed on profile_url)", async () => {
listItems.mockResolvedValue({ items: [REAL_ITEM] });
await handleFacebookProfileScraperResults(payload);
expect(upsertSocials).toHaveBeenCalledWith([
{
profile_url: "facebook.com/facebook",
username: "facebook",
avatar: "https://scontent.cdn/pic.jpg",
followerCount: 154000000,
},
]);
});
it("no-ops on an empty dataset", async () => {
listItems.mockResolvedValue({ items: [] });
expect(await handleFacebookProfileScraperResults(payload)).toEqual({ social: null });
expect(upsertSocials).not.toHaveBeenCalled();
});
});
30 changes: 30 additions & 0 deletions lib/apify/facebook/handleFacebookProfileScraperResults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import apifyClient from "@/lib/apify/client";
import { upsertSocials } from "@/lib/supabase/socials/upsertSocials";
import { normalizeProfileUrl } from "@/lib/socials/normalizeProfileUrl";
import type { ApifyWebhookPayload } from "@/lib/apify/validateApifyWebhookRequest";

/** Page item from apify~facebook-pages-scraper (real shape, run ICZxdJBrtP1jkPITS). */
type FacebookPageItem = {
pageUrl?: string;
facebookUrl?: string;
pageName?: string;
profilePictureUrl?: string;
followers?: number;
};

/** Persists a Facebook page scrape back to `socials` (upsert on `profile_url`). */
export async function handleFacebookProfileScraperResults(parsed: ApifyWebhookPayload) {
const { items } = await apifyClient.dataset(parsed.resource.defaultDatasetId).listItems();
const first = items[0] as FacebookPageItem | undefined;
const url = first?.pageUrl ?? first?.facebookUrl;
if (!url) return { social: null };

const social = {
profile_url: normalizeProfileUrl(url),
username: first?.pageName,
avatar: first?.profilePictureUrl ?? null,
followerCount: first?.followers ?? null,
};
await upsertSocials([social]);
return { social };
}
18 changes: 11 additions & 7 deletions lib/apify/facebook/startFacebookProfileScraping.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApifyRunInfo } from "@/lib/apify/types";
import apifyClient from "@/lib/apify/client";
import { getApifyWebhooks } from "@/lib/apify/getApifyWebhooks";
import { OUTSTANDING_ERROR } from "@/lib/apify/errors";

const startFacebookProfileScraping = async (handle: string): Promise<ApifyRunInfo | null> => {
Expand All @@ -11,13 +12,16 @@ const startFacebookProfileScraping = async (handle: string): Promise<ApifyRunInf

const targetUrl = `https://www.facebook.com/${cleanHandle}`;

const run = await apifyClient.actor("apify~facebook-pages-scraper").start({
startUrls: [
{
url: targetUrl,
},
],
});
const run = await apifyClient.actor("apify~facebook-pages-scraper").start(
{
startUrls: [
{
url: targetUrl,
},
],
},
{ webhooks: getApifyWebhooks() },
);

if (!run?.id || !run?.defaultDatasetId) {
console.error("Failed to start Facebook profile scraping for handle:", handle);
Expand Down
15 changes: 12 additions & 3 deletions lib/apify/getApifyResultHandler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { handleInstagramProfileScraperResults } from "@/lib/apify/instagram/handleInstagramProfileScraperResults";
import { handleInstagramCommentsScraper } from "@/lib/apify/instagram/handleInstagramCommentsScraper";
import { handleLinkedinProfileScraperResults } from "@/lib/apify/linkedin/handleLinkedinProfileScraperResults";
import { handleTiktokProfileScraperResults } from "@/lib/apify/tiktok/handleTiktokProfileScraperResults";
import { handleTwitterProfileScraperResults } from "@/lib/apify/twitter/handleTwitterProfileScraperResults";
import { handleYoutubeProfileScraperResults } from "@/lib/apify/youtube/handleYoutubeProfileScraperResults";
import { handleThreadsProfileScraperResults } from "@/lib/apify/threads/handleThreadsProfileScraperResults";
import { handleFacebookProfileScraperResults } from "@/lib/apify/facebook/handleFacebookProfileScraperResults";
import type { ApifyWebhookPayload } from "@/lib/apify/validateApifyWebhookRequest";

/** Persists one Apify actor run's results (posts/socials/etc.). */
Expand All @@ -11,16 +16,20 @@ export type ApifyResultHandler = (parsed: ApifyWebhookPayload) => Promise<unknow
* persists that actor's results. Registry, so adding a platform is a single
* entry rather than another `switch` arm.
*
* Instagram and LinkedIn are wired; TikTok/X/YouTube/Threads/Facebook scrapes
* run and return data on the poll endpoint but are NOT persisted yet — their
* start modules must also attach `getApifyWebhooks()` (only IG + LinkedIn do). Each needs a `handle<Platform>ProfileScraperResults` (mirror
* All supported platforms are wired: each start module attaches
* `getApifyWebhooks()` to its run and registers its resolved actor id here. Each needs a `handle<Platform>ProfileScraperResults` (mirror
* the Instagram one) registered here under its resolved actor id. See
* recoupable/chat#1833 ("persist non-Instagram scrape results").
*/
const HANDLERS_BY_ACTOR_ID: Record<string, ApifyResultHandler> = {
dSCLg0C3YEZ83HzYX: handleInstagramProfileScraperResults, // instagram profile
SbK00X0JYCPblD2wp: handleInstagramCommentsScraper, // instagram comments
LpVuK3Zozwuipa5bp: handleLinkedinProfileScraperResults, // linkedin profile (harvestapi)
GdWCkxBtKWOsKjdch: handleTiktokProfileScraperResults, // tiktok (clockworks~tiktok-scraper)
nfp1fpt5gUlBwPcor: handleTwitterProfileScraperResults, // x/twitter (apidojo~twitter-scraper-lite)
h7sDV53CddomktSi5: handleYoutubeProfileScraperResults, // youtube (streamers~youtube-scraper)
kJdK90pa2hhYYrCK5: handleThreadsProfileScraperResults, // threads (apify~threads-profile-api-scraper)
"4Hv5RhChiaDk6iwad": handleFacebookProfileScraperResults, // facebook (apify~facebook-pages-scraper)
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleThreadsProfileScraperResults } from "@/lib/apify/threads/handleThreadsProfileScraperResults";
const listItems = vi.fn();
vi.mock("@/lib/apify/client", () => ({ default: { dataset: vi.fn(() => ({ listItems })) } }));
const upsertSocials = vi.fn();
vi.mock("@/lib/supabase/socials/upsertSocials", () => ({
upsertSocials: (...a: unknown[]) => upsertSocials(...a),
}));

const payload = {
eventData: { actorId: "kJdK90pa2hhYYrCK5" },
resource: { defaultDatasetId: "ds-1" },
} as never;
// Trimmed from real run 9iiG1sDkpaeWPCWHl (2026-07-01).
const REAL_ITEM = {
username: "zuck",
url: "https://www.threads.net/@zuck",
profile_pic_url: "https://instagram.cdn/pic.jpg",
biography: "Mostly superintelligence and MMA takes",
follower_count: 5642746,
};
beforeEach(() => {
vi.clearAllMocks();
upsertSocials.mockResolvedValue([]);
});

describe("handleThreadsProfileScraperResults", () => {
it("upserts the profile from a real item (keyed on profile_url)", async () => {
listItems.mockResolvedValue({ items: [REAL_ITEM] });
await handleThreadsProfileScraperResults(payload);
expect(upsertSocials).toHaveBeenCalledWith([
{
profile_url: "threads.net/@zuck",
username: "zuck",
avatar: "https://instagram.cdn/pic.jpg",
bio: "Mostly superintelligence and MMA takes",
followerCount: 5642746,
},
]);
});
it("no-ops on an empty dataset", async () => {
listItems.mockResolvedValue({ items: [] });
expect(await handleThreadsProfileScraperResults(payload)).toEqual({ social: null });
expect(upsertSocials).not.toHaveBeenCalled();
});
});
30 changes: 30 additions & 0 deletions lib/apify/threads/handleThreadsProfileScraperResults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import apifyClient from "@/lib/apify/client";
import { upsertSocials } from "@/lib/supabase/socials/upsertSocials";
import { normalizeProfileUrl } from "@/lib/socials/normalizeProfileUrl";
import type { ApifyWebhookPayload } from "@/lib/apify/validateApifyWebhookRequest";

/** Profile item from apify~threads-profile-api-scraper (real shape, run 9iiG1sDkpaeWPCWHl). */
type ThreadsProfileItem = {
username?: string;
url?: string;
profile_pic_url?: string;
biography?: string;
follower_count?: number;
};

/** Persists a Threads profile scrape back to `socials` (upsert on `profile_url`). */
export async function handleThreadsProfileScraperResults(parsed: ApifyWebhookPayload) {
const { items } = await apifyClient.dataset(parsed.resource.defaultDatasetId).listItems();
const first = items[0] as ThreadsProfileItem | undefined;
if (!first?.url) return { social: null };

const social = {
profile_url: normalizeProfileUrl(first.url),
username: first.username,
avatar: first.profile_pic_url ?? null,
bio: first.biography || null,
followerCount: first.follower_count ?? null,
};
await upsertSocials([social]);
return { social };
}
10 changes: 7 additions & 3 deletions lib/apify/threads/startThreadsProfileScraping.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ApifyRunInfo } from "@/lib/apify/types";
import apifyClient from "@/lib/apify/client";
import { getApifyWebhooks } from "@/lib/apify/getApifyWebhooks";
import { OUTSTANDING_ERROR } from "@/lib/apify/errors";

const startThreadsProfileScraping = async (handle: string): Promise<ApifyRunInfo | null> => {
Expand All @@ -9,9 +10,12 @@ const startThreadsProfileScraping = async (handle: string): Promise<ApifyRunInfo
throw new Error("Invalid Threads handle");
}

const run = await apifyClient.actor("apify~threads-profile-api-scraper").start({
usernames: [cleanHandle],
});
const run = await apifyClient.actor("apify~threads-profile-api-scraper").start(
{
usernames: [cleanHandle],
},
{ webhooks: getApifyWebhooks() },
);

if (!run?.id || !run?.defaultDatasetId) {
console.error("Failed to start Threads profile scraping for handle:", handle);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { handleTiktokProfileScraperResults } from "@/lib/apify/tiktok/handleTiktokProfileScraperResults";
const listItems = vi.fn();
vi.mock("@/lib/apify/client", () => ({ default: { dataset: vi.fn(() => ({ listItems })) } }));
const upsertSocials = vi.fn();
vi.mock("@/lib/supabase/socials/upsertSocials", () => ({
upsertSocials: (...a: unknown[]) => upsertSocials(...a),
}));

const payload = {
eventData: { actorId: "GdWCkxBtKWOsKjdch" },
resource: { defaultDatasetId: "ds-1" },
} as never;
// Trimmed from real run G4YRI0eUI0d5IidDN (2026-07-01).
const REAL_ITEM = {
text: "In welcher Stadt tanzen wir?",
authorMeta: {
name: "apache_207",
profileUrl: "https://www.tiktok.com/@apache_207",
avatar: "https://p16-common-sign.tiktokcdn-us.com/avatar.jpeg",
signature: "",
fans: 917500,
following: 0,
},
};
beforeEach(() => {
vi.clearAllMocks();
upsertSocials.mockResolvedValue([]);
});

describe("handleTiktokProfileScraperResults", () => {
it("upserts author profile stats from a real post item (keyed on profile_url)", async () => {
listItems.mockResolvedValue({ items: [REAL_ITEM] });
await handleTiktokProfileScraperResults(payload);
expect(upsertSocials).toHaveBeenCalledWith([
{
profile_url: "tiktok.com/@apache_207",
username: "apache_207",
avatar: "https://p16-common-sign.tiktokcdn-us.com/avatar.jpeg",
bio: null,
followerCount: 917500,
followingCount: 0,
},
]);
});
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>

listItems.mockResolvedValue({ items: [] });
expect(await handleTiktokProfileScraperResults(payload)).toEqual({ social: null });
expect(upsertSocials).not.toHaveBeenCalled();
});
});
Loading
Loading