From fabbd0aa099df82b492c1a02045a48d3ceb29661 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 2 Jul 2026 13:49:09 -0500 Subject: [PATCH] feat(apify): LinkedIn scrape returns posts when a posts depth is requested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A requested posts depth switches the LinkedIn scrape from harvestapi/linkedin-profile-scraper (whose dataset has zero post fields) to harvestapi/linkedin-profile-posts with {targetUrls, maxPosts} — post items with engagement counts (likes, comments, shares, reaction breakdown). posts omitted keeps the profile actor with byte-identical input. Same handle guards, URL normalization, webhooks, and error paths. Matches X/YouTube semantics (depth → post items; omitted → snapshot). Actor verified live 2026-07-02: run aKsHPucJ3thxuosSd, 5 posts @ $0.01005 ($0.002/post) — the shipped 5 + posts credit rule covers it at ~5x margin, no billing change. Implements recoupable/chat#1838; contract in recoupable/docs#260. Co-Authored-By: Claude Fable 5 --- .../startLinkedinProfileScraping.test.ts | 28 +++++++++++++++++++ .../linkedin/startLinkedinProfileScraping.ts | 25 +++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/lib/apify/__tests__/startLinkedinProfileScraping.test.ts b/lib/apify/__tests__/startLinkedinProfileScraping.test.ts index dcd48700..07c05dc5 100644 --- a/lib/apify/__tests__/startLinkedinProfileScraping.test.ts +++ b/lib/apify/__tests__/startLinkedinProfileScraping.test.ts @@ -35,4 +35,32 @@ describe("startLinkedinProfileScraping", () => { ); expect(start).not.toHaveBeenCalled(); }); + + it("runs the posts actor with maxPosts when a posts depth is requested", async () => { + const r = await startLinkedinProfileScraping("sweetmaneth", 20); + expect(apifyClient.actor).toHaveBeenCalledWith("harvestapi/linkedin-profile-posts"); + expect(start).toHaveBeenCalledWith( + { targetUrls: ["https://www.linkedin.com/in/sweetmaneth"], maxPosts: 20 }, + { webhooks: expect.any(Array) }, + ); + expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" }); + }); + + it("posts depth keeps the full-URL passthrough and the handle guards", async () => { + await startLinkedinProfileScraping("https://www.linkedin.com/in/drew-thurlow", 5); + expect(start).toHaveBeenCalledWith( + { targetUrls: ["https://www.linkedin.com/in/drew-thurlow"], maxPosts: 5 }, + { webhooks: expect.any(Array) }, + ); + await expect(startLinkedinProfileScraping("in", 5)).rejects.toThrow(/Invalid LinkedIn handle/); + }); + + it("posts omitted keeps the legacy profile actor and input byte-identical", async () => { + await startLinkedinProfileScraping("sweetmaneth", undefined); + expect(apifyClient.actor).toHaveBeenCalledWith("harvestapi/linkedin-profile-scraper"); + expect(start).toHaveBeenCalledWith( + { urls: ["https://www.linkedin.com/in/sweetmaneth"] }, + { webhooks: expect.any(Array) }, + ); + }); }); diff --git a/lib/apify/linkedin/startLinkedinProfileScraping.ts b/lib/apify/linkedin/startLinkedinProfileScraping.ts index ff323194..d9752d0d 100644 --- a/lib/apify/linkedin/startLinkedinProfileScraping.ts +++ b/lib/apify/linkedin/startLinkedinProfileScraping.ts @@ -4,14 +4,24 @@ import { OUTSTANDING_ERROR } from "@/lib/apify/errors"; import { ApifyRunInfo } from "@/lib/apify/types"; /** - * Starts a LinkedIn profile scrape via the harvestapi actor. + * Starts a LinkedIn scrape via the harvestapi actors. * Mirrors the other `startProfileScraping` modules. * + * The profile actor's dataset has no post fields, so a requested `posts` + * depth switches to the posts actor (post items with engagement counts) — + * the same semantics as X/YouTube: depth requested → post items in the + * dataset; omitted → the legacy profile snapshot. + * * @param handle - A LinkedIn vanity slug (e.g. `drew-thurlow`) or full profile URL. + * @param posts - Max recent posts to scrape; omitted → profile snapshot. * @returns Apify run info, or null if the run failed to start. * @see https://apify.com/harvestapi/linkedin-profile-scraper + * @see https://apify.com/harvestapi/linkedin-profile-posts */ -const startLinkedinProfileScraping = async (handle: string): Promise => { +const startLinkedinProfileScraping = async ( + handle: string, + posts?: number, +): 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. @@ -23,12 +33,15 @@ const startLinkedinProfileScraping = async (handle: string): Promise