diff --git a/lib/apify/__tests__/scrapeProfileUrl.test.ts b/lib/apify/__tests__/scrapeProfileUrl.test.ts new file mode 100644 index 000000000..8ea6e96a8 --- /dev/null +++ b/lib/apify/__tests__/scrapeProfileUrl.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { scrapeProfileUrl } from "@/lib/apify/scrapeProfileUrl"; + +const li = vi.fn(); +vi.mock("@/lib/apify/linkedin/startLinkedinProfileScraping", () => ({ + default: (...a: unknown[]) => li(...a), +})); +vi.mock("@/lib/apify/tiktok/startTiktokProfileScraping", () => ({ default: vi.fn() })); +vi.mock("@/lib/apify/instagram/startInstagramProfileScraping", () => ({ + startInstagramProfileScraping: vi.fn(), +})); +vi.mock("@/lib/apify/twitter/startTwitterProfileScraping", () => ({ default: vi.fn() })); +vi.mock("@/lib/apify/threads/startThreadsProfileScraping", () => ({ default: vi.fn() })); +vi.mock("@/lib/apify/youtube/startYoutubeProfileScraping", () => ({ default: vi.fn() })); +vi.mock("@/lib/apify/facebook/startFacebookProfileScraping", () => ({ default: vi.fn() })); +vi.mock("@/lib/socials/getUsernameFromProfileUrl", () => ({ + getUsernameFromProfileUrl: () => "drew-thurlow", +})); + +beforeEach(() => { + vi.clearAllMocks(); + li.mockResolvedValue({ runId: "li-run", datasetId: "li-ds" }); +}); + +describe("scrapeProfileUrl", () => { + it("dispatches linkedin.com to the LinkedIn scraper", async () => { + const r = await scrapeProfileUrl("https://www.linkedin.com/in/drew-thurlow", "drew-thurlow"); + expect(li).toHaveBeenCalledWith("drew-thurlow"); + expect(r).toMatchObject({ supported: true, runId: "li-run", datasetId: "li-ds" }); + }); + it("returns null for an unsupported platform (spotify)", async () => { + const r = await scrapeProfileUrl("https://open.spotify.com/artist/x", "x"); + expect(r).toBeNull(); + expect(li).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/__tests__/startLinkedinProfileScraping.test.ts b/lib/apify/__tests__/startLinkedinProfileScraping.test.ts new file mode 100644 index 000000000..e9dc4a73b --- /dev/null +++ b/lib/apify/__tests__/startLinkedinProfileScraping.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import apifyClient from "@/lib/apify/client"; +import startLinkedinProfileScraping from "@/lib/apify/linkedin/startLinkedinProfileScraping"; + +const start = vi.fn(); +vi.mock("@/lib/apify/client", () => ({ default: { actor: vi.fn(() => ({ start })) } })); + +beforeEach(() => { + vi.clearAllMocks(); + start.mockResolvedValue({ id: "run-1", defaultDatasetId: "ds-1", status: "RUNNING" }); +}); + +describe("startLinkedinProfileScraping", () => { + it("starts the harvestapi actor with `urls` (the actor's real input key), building the /in/ URL from a handle", async () => { + const r = await startLinkedinProfileScraping("sweetmaneth"); + expect(apifyClient.actor).toHaveBeenCalledWith("harvestapi/linkedin-profile-scraper"); + expect(start).toHaveBeenCalledWith({ urls: ["https://www.linkedin.com/in/sweetmaneth"] }); + expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" }); + }); + + it("passes a full profile URL through unchanged", async () => { + await startLinkedinProfileScraping("https://www.linkedin.com/in/drew-thurlow"); + expect(start).toHaveBeenCalledWith({ urls: ["https://www.linkedin.com/in/drew-thurlow"] }); + }); + it("rejects LinkedIn path prefixes as handles (legacy rows stored 'in') instead of scraping the wrong profile", async () => { + await expect(startLinkedinProfileScraping("in")).rejects.toThrow(/Invalid LinkedIn handle/); + await expect(startLinkedinProfileScraping("company")).rejects.toThrow( + /Invalid LinkedIn handle/, + ); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/linkedin/startLinkedinProfileScraping.ts b/lib/apify/linkedin/startLinkedinProfileScraping.ts new file mode 100644 index 000000000..6038eaa89 --- /dev/null +++ b/lib/apify/linkedin/startLinkedinProfileScraping.ts @@ -0,0 +1,46 @@ +import apifyClient from "@/lib/apify/client"; +import { OUTSTANDING_ERROR } from "@/lib/apify/errors"; +import { ApifyRunInfo } from "@/lib/apify/types"; + +/** + * Starts a LinkedIn profile scrape via the harvestapi actor. + * Mirrors the other `startProfileScraping` modules. + * + * @param handle - A LinkedIn vanity slug (e.g. `drew-thurlow`) or full profile URL. + * @returns Apify run info, or null if the run failed to start. + * @see https://apify.com/harvestapi/linkedin-profile-scraper + */ +const startLinkedinProfileScraping = async (handle: string): Promise => { + const cleanHandle = handle.trim().replace(/^@/, ""); + // Legacy rows stored the LinkedIn path prefix ("in") as the username — never + // scrape those (linkedin.com/in/in is a real, wrong profile URL); fail loudly. + if (!cleanHandle || ["in", "company", "school"].includes(cleanHandle.toLowerCase())) { + throw new Error(`Invalid LinkedIn handle: "${handle}"`); + } + + const targetUrl = cleanHandle.startsWith("http") + ? cleanHandle + : `https://www.linkedin.com/in/${cleanHandle}`; + + const input = { urls: [targetUrl] }; + + try { + const run = await apifyClient.actor("harvestapi/linkedin-profile-scraper").start(input); + + if (!run?.id || !run?.defaultDatasetId) { + console.error("Failed to start LinkedIn profile scraping for handle:", handle); + return null; + } + + if (run.status === "FAILED" || run.status === "ABORTED") { + throw new Error(OUTSTANDING_ERROR); + } + + return { runId: run.id, datasetId: run.defaultDatasetId }; + } catch (error) { + console.error("Error in startLinkedinProfileScraping:", error); + throw error; + } +}; + +export default startLinkedinProfileScraping; diff --git a/lib/apify/scrapeProfileUrl.ts b/lib/apify/scrapeProfileUrl.ts index 3453d0556..6d30a930a 100644 --- a/lib/apify/scrapeProfileUrl.ts +++ b/lib/apify/scrapeProfileUrl.ts @@ -4,6 +4,7 @@ import startTwitterProfileScraping from "@/lib/apify/twitter/startTwitterProfile import startThreadsProfileScraping from "@/lib/apify/threads/startThreadsProfileScraping"; import startYoutubeProfileScraping from "@/lib/apify/youtube/startYoutubeProfileScraping"; import startFacebookProfileScraping from "@/lib/apify/facebook/startFacebookProfileScraping"; +import startLinkedinProfileScraping from "@/lib/apify/linkedin/startLinkedinProfileScraping"; import { getUsernameFromProfileUrl } from "@/lib/socials/getUsernameFromProfileUrl"; type ScrapeRunner = (handle: string) => Promise<{ @@ -51,6 +52,10 @@ const PLATFORM_SCRAPERS: Array<{ match: (url: string) => url.includes("facebook.com"), scraper: startFacebookProfileScraping, }, + { + match: (url: string) => url.includes("linkedin.com"), + scraper: startLinkedinProfileScraping, + }, ]; export const scrapeProfileUrl = async ( diff --git a/lib/artists/__tests__/getSocialPlatformByLink.test.ts b/lib/artists/__tests__/getSocialPlatformByLink.test.ts new file mode 100644 index 000000000..967d74aaf --- /dev/null +++ b/lib/artists/__tests__/getSocialPlatformByLink.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { getSocialPlatformByLink } from "@/lib/artists/getSocialPlatformByLink"; + +describe("getSocialPlatformByLink", () => { + it("maps linkedin.com to LINKEDIN", () => { + expect(getSocialPlatformByLink("https://linkedin.com/in/drew-thurlow")).toBe("LINKEDIN"); + expect(getSocialPlatformByLink("linkedin.com/company/recoup")).toBe("LINKEDIN"); + }); + it("still maps the existing platforms", () => { + expect(getSocialPlatformByLink("x.com/theasf")).toBe("TWITTER"); + expect(getSocialPlatformByLink("tiktok.com/@apache_207")).toBe("TIKTOK"); + expect(getSocialPlatformByLink("")).toBe("NONE"); + }); +}); diff --git a/lib/artists/getSocialPlatformByLink.ts b/lib/artists/getSocialPlatformByLink.ts index 744ad626e..d76b53710 100644 --- a/lib/artists/getSocialPlatformByLink.ts +++ b/lib/artists/getSocialPlatformByLink.ts @@ -10,6 +10,7 @@ export function getSocialPlatformByLink(link: string): string { if (link.includes("instagram.com")) return "INSTAGRAM"; if (link.includes("spotify.com")) return "SPOTIFY"; if (link.includes("tiktok.com")) return "TIKTOK"; + if (link.includes("linkedin.com")) return "LINKEDIN"; if (link.includes("apple.com")) return "APPLE"; if (link.includes("youtube.")) return "YOUTUBE"; if (link.includes("facebook.com")) return "FACEBOOK"; diff --git a/lib/socials/__tests__/getUsernameFromProfileUrl.test.ts b/lib/socials/__tests__/getUsernameFromProfileUrl.test.ts new file mode 100644 index 000000000..153638b99 --- /dev/null +++ b/lib/socials/__tests__/getUsernameFromProfileUrl.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { getUsernameFromProfileUrl } from "@/lib/socials/getUsernameFromProfileUrl"; + +describe("getUsernameFromProfileUrl", () => { + it("extracts the first path segment for standard platforms", () => { + expect(getUsernameFromProfileUrl("https://instagram.com/apacheoficial")).toBe("apacheoficial"); + expect(getUsernameFromProfileUrl("x.com/theasf")).toBe("theasf"); + expect(getUsernameFromProfileUrl("")).toBe(""); + }); + + it("extracts the handle from LinkedIn /in/, /company/, /school/ URLs (not the path prefix)", () => { + expect(getUsernameFromProfileUrl("https://www.linkedin.com/in/sweetmaneth/")).toBe( + "sweetmaneth", + ); + expect(getUsernameFromProfileUrl("linkedin.com/in/drew-thurlow-3a354311")).toBe( + "drew-thurlow-3a354311", + ); + expect(getUsernameFromProfileUrl("https://linkedin.com/company/recoupable")).toBe("recoupable"); + }); +}); diff --git a/lib/socials/getUsernameFromProfileUrl.ts b/lib/socials/getUsernameFromProfileUrl.ts index 385b8d280..b1dae71ac 100644 --- a/lib/socials/getUsernameFromProfileUrl.ts +++ b/lib/socials/getUsernameFromProfileUrl.ts @@ -11,6 +11,12 @@ export const getUsernameFromProfileUrl = (profileUrl: string | null | undefined) try { const normalizedUrl = profileUrl.toLowerCase().trim(); + // LinkedIn nests the handle under a path prefix (/in/, /company/, /school/) — + // the generic first-segment rule would return the prefix itself (e.g. "in"). + const linkedin = normalizedUrl.match(/linkedin\.com\/(?:in|company|school)\/([^/?]+)/); + if (linkedin) { + return linkedin[1]; + } const match = normalizedUrl.match(/(?:\.com|\.net)\/([^/?]+)/); return match ? match[1] : ""; } catch (error) {