{"slug": "automate-talent-sourcing-with-apify-openai-and-zapier", "title": "Automate talent sourcing with Apify, OpenAI, and Zapier", "summary": "Apify, OpenAI, and Zapier have been integrated into a workflow that automates talent sourcing by scraping LinkedIn profiles, evaluating candidates against job requirements, and sending alerts only for new matches. The system uses four Zaps to monitor Google Sheets, match candidates via OpenAI, filter duplicates, and re-scrape positions daily, reducing reliance on inbound applications.", "body_md": "A talent sourcing strategy that relies solely on inbound applications is largely a bet on luck.\n\nWaiting for candidates to come to you means you only surface the people who happen to see your ad and act on it, leaving you with a thin slice of the professionals who can actually do the job.\n\nThe best fit for your role might be actively searching on another platform, or they may be employed elsewhere with quiet plans to leave. If they never cross paths with your job ad, they are automatically disqualified. The solution, then, isn’t a wider ad campaign strategy, but a more proactive one.\n\nThe workflow you’ll build in this tutorial solves this problem by using Apify to collect public LinkedIn profile data for eligible candidates in the specific niches you're hiring for, while OpenAI processes that data and evaluates it against the job requirements, returning the strongest matches for the role.\n\nZapier connects the workflow together and hosts an extensive catalog of integrations that further extend the project’s capabilities.\n\n## Prerequisites\n\n## Overview of the Zap workflow\n\nThis system uses four specialized Zaps to automate talent sourcing. Zap A monitors Google Sheets to scrape relevant LinkedIn profiles via Apify, while Zap B matches the candidates using OpenAI and filters out duplicates before creating new candidate cards. Zap C then emails you an alert only when fresh candidates are discovered. Finally, Zap D runs daily to re-scrape open positions, relying on Zap B's filtering to ensure you only see new talent.\n\n## Phase 1: Accounts and credentials setup\n\nZapier connects to Google, OpenAI, and Airtable through its own in-app sign-in, which you’ll use as you build each step. What you do need upfront are three API tokens and a base ID.\n\nTo avoid confusion, you can use a single project name across all configurations unless specific sections of this tutorial state otherwise.\n\n### Step 1: Grab your Apify API token\n\n- Sign up at\n[apify.com](https://console.apify.com/sign-up)if you don't already have an account. - Go to Apify Console.\n- On the left-hand sidebar menu, click on\n**Settings**, then navigate to the** API & Integrations**tab. - Under \"Personal API tokens,\" copy your default token or click\n**Add new token** to create a new one, then give it a name. - Click the copy icon to copy the token and save it somewhere temporary.\n\n[Get web data](https://console.apify.com/sign-up)\n\n### Step 2: Generate an OpenAI API key\n\n- Go to\n[platform.openai.com](https://platform.openai.com/)and log in or sign up. - On the left sidebar, navigate to\n**API keys**. - Click\n**Create new secret key**, name it, and leave the permissions as they are. - Copy the key immediately and save it alongside your Apify token.\n- OpenAI API operates on a prepaid system, so make sure you have at least $5 in your credit balance.\n\n### Step 3: Create your Airtable base and generate a token\n\n- Go to\n[airtable.com](https://airtable.com/), log in or sign up. - Click\n**Create** and select**Build an app on your own**, then give it a name. You will add its columns in Phase 3; for now, the base just needs to exist so the token has something to point at. - While that base is open, copy the segment of the browser URL that starts with\n`app`\n\nand ends before a forward slash (`/`\n\n). That’s your**base ID**, save it (looks like this: appbxxxQbvxxWSsx). - Now go to\n[airtable.com/create/tokens](https://airtable.com/create/tokens), click**Create new token**, and name it. - Under Scopes, add\n`data.records:read`\n\n. - Under Access, select the base you just created.\n- Click \"Create token\", copy it immediately, and save it alongside your Apify and OpenAI keys.\n\n### Step 4: Create a Zapier account\n\n- Go to\n[zapier.com](https://zapier.com/)and sign up. - The free trial period is enough to build this project. However, if you wish to subscribe to Zapier, just click\n**Upgrade** in the top-right corner and follow the steps.\n\n*Zapier’s free tier caps you at two-step Zaps and hides the Apify, Airtable, and Code apps, so while it’s enough to build, your project won’t function after the trial window closes.*## Phase 2: Build the data stores\n\nGoogle Sheets stores the job details and serves as the trigger point, while the project results appear in Airtable. You’ll build both before the Zaps, so the fields exist to map against.\n\n### Step 1: The Jobs sheet\n\n- In your\n[Google Sheets](https://docs.google.com/spreadsheets/), create and name a new**Sheet**. - Rename the first tab to\n**Jobs**. In row 1, add these headers, left to right:`job_id`\n\n|`job_ad`\n\n|`search_query`\n\n|`search_run_id`\n\n|`status`\n\nThis sheet is the control panel. You only ever touch three columns: `job_id`\n\n(input number), `job_ad`\n\n(a real job ad), and `search_query`\n\n(what gets scraped). `search_run_id`\n\nis how a finished scrape gets tied back to its job, and `status`\n\nis the on/off switch that the daily rerun reads.\n\n### Step 2: The Airtable base\n\nYou already created and named this base in Phase 1. Now you’ll attach two tables and fields within it. Click the + icons within the blank base to create new items, then hover over the edges of each column and click the dropdown to edit them.\n\n- Open the\n**Talent Sourcing** base from Phase 1, rename its default table to**Candidates**, delete the sample rows, and set up these fields:- Name (Single line text)\n- Match (Number)\n- Tier (Single select, options spelled exactly\n`strong_match`\n\n,`high_potential`\n\n,`partial`\n\n,`weak`\n\n, each with a color) - Meets, Misses, High potential reason, Outliers (Long text)\n- Email (Email)\n- LinkedIn (URL),\n- Job (Single line text),\n- Job ID (Single line text),\n- Added (Created time), Airtable fills this automatically.\n\n- Add a second table, name it\n**Runs,** with these fields:- Summary (Single line text)\n- Job title (Single line text)\n- New candidates (Number)\n- Total scored (Number)\n- Link (URL)\n- Logged (Created time), automatic.\n\n- In the Candidates table, sort the grid by\n**Added**, newest first. - Copy the URL of the Candidates grid view from your browser and save it.\n\nThe Candidates table is where you’ll read results. The two fields doing invisible work are LinkedIn, which the pipeline uses to recognize someone it has already logged, and Job ID, which scopes that recognition to a single role. The Runs table is not for reading; it exists solely so that each new row corresponds to one alert, and writes a summary of the event that fires Zap C.\n\n## Phase 3: Zap A - Intake and scrape\n\nThe three steps below trigger on a new job, run the Actor, and stash the run ID.\n\n### Step 1: Trigger - new row in the Jobs sheet\n\n- In Zapier, click\n**Create**, then** Zaps**, and rename the Zap draft to “** A - Intake + Scrape”**. - Click the\n**Trigger** card, search**Google Sheets**, and select it. **Event:****New Spreadsheet Row**, click Continue.** Account:**click** Sign in**, complete the Google pop-up, allow access, and click Continue.** Configure:****Spreadsheet**= Talent Sourcing Sheet,** Worksheet**= Jobs. Continue.** Test:**click** Test trigger**. Zapier pulls your JOB-001 row. Continue with selected record.\n\nThis node is the entry point. Every time you add a job row, it fires once and hands the rest of the Zap that row's `job_ad`\n\nand `search_query`\n\n.\n\n### Step 2: Action - launch the scrape\n\n- Click\n**+**, search** Apify**, and select it. **Event:****Run Actor**.** Account:**connect your Apify account (paste** APIFY_TOKEN**if asked), click Continue.** Configure.**In the** Actor**field, search[LinkedIn Profile Search Scraper](https://apify.com/harvestapi/linkedin-profile-search)by harvestapi, or paste the ID`M2FMdjRVeF1HPGFcc`\n\n. Zapier then renders the Actor's input as form fields:**Search Query:** insert the trigger's**Search Query** field.**Profile Scraper Mode:****Full + email search**.** Max Items:**`25`\n\n.**Run synchronously:****No**(Zap B catches completion through its own trigger).- Location: Either leave this alone if you’re targeting remote, global, or input the specific region you’re scouting for.\n\n- Continue, then\n**Test step**. This launches a real run. The output contains a run** ID**.\n\n### Step 3: Action - save the run id on the row\n\n- Click\n**+**, search** Google Sheets**, select it. **Event:****Update Spreadsheet Row(s)**.** Configure:****Spreadsheet**= Talent Sourcing Sheet,** Worksheet**= Jobs.** Row Number:**insert the trigger's** Row Number**field. This targets the row that fired.** search_run_id:**map the** ID**from the Run Actor step, not the** Actor ID**. The Actor ID is the same on every run; the** ID**is unique per run.** status:**type`searching`\n\n.- Leave\n`job_id`\n\n,`job_ad`\n\n, and`search_query`\n\nblank.\n\n- Continue, then\n**Test step**. Confirm JOB-001 now shows a`search_run_id`\n\n. - Click\n**Publish** to make this Zap go live**.**\n\nApify's Finished Actor Run in the next Zap (Zap B) below triggers when a run finishes, but not which job it was for. By writing the run ID to the job row here, Zap B can match the finished run's ID to the sheet and pull the correct job ad.\n\n## Phase 4: Zap B - Rank, deduplicate, write the board, log the run\n\nThis Zap reacts to the finished scrape from the previous one (Zap A), ranks it, drops any candidate already on the board for that job, writes the new people, and logs a run.\n\n### Step 1: Trigger - Finished Actor Run\n\n- Create a new Zap, rename it “\n**B - Rank + Output”**. - Click the\n**Trigger** card, search**Apify**, and select it. **Event:****Finished Actor Run**.** Account:**pick your Apify connection.** Configure:**in the** Actor**field, select the harvestapi LinkedIn Actor (ID`M2FMdjRVeF1HPGFcc`\n\n). If there is a status option, set it to**Succeeded**.** Test:**click** Test trigger**.\n\n### Step 2: Action - fetch and trim the profiles\n\nYour[ large language model (LLM)](https://en.wikipedia.org/wiki/Large_language_model) analyzing 25 raw profiles, scraped repeatedly, can easily blow past token limits. This particular action helps trim each profile to the fields that matter.\n\n- Click\n**+**, search for** Code by Zapier**, and select it. **Event:****Run JavaScript**.** Input Data:**add two items.`datasetId`\n\n= map the trigger's**Default Dataset ID**.`token`\n\n= paste your**APIFY_TOKEN**.\n\n**Code:** paste this.\n\n``` js\nconst url = `https://api.apify.com/v2/datasets/${inputData.datasetId}/items?clean=true&format=json`;\nconst res = await fetch(url, { headers: { Authorization: `Bearer ${inputData.token}` } });\nconst items = await res.json();\n\n// harvestapi returns \"emails\" as an array (strings or objects); pull the first usable one\nfunction firstEmail(e) {\n  if (!Array.isArray(e) || !e.length) return null;\n  const f = e[0];\n  return typeof f === \"string\" ? f : (f.email || f.value || f.address || null);\n}\n\n// keep the rich signal, and drop the noise (image/logo URLs, ids, _meta)\nconst slim = items.map(p => ({\n  name: [p.firstName, p.lastName].filter(Boolean).join(\" \"),\n  url: p.linkedinUrl,\n  headline: p.headline,\n  location: (p.location && (p.location.parsed?.text || p.location.linkedinText)) || null,\n  openToWork: p.openToWork || false,\n  hiring: p.hiring || false,\n  verified: p.verified || false,\n  connections: p.connectionsCount || null,\n  followers: p.followerCount || null,\n  about: (p.about || \"\").slice(0, 1200),\n  topSkills: p.topSkills || null,\n  email: firstEmail(p.emails),\n  experience: (p.experience || []).slice(0, 8).map(e => ({\n    title: e.position,\n    company: e.companyName,\n    type: e.employmentType || null,\n    from: e.startDate?.text || null,\n    to: e.endDate?.text || null,\n    duration: e.duration || null,\n    desc: (e.description || \"\").slice(0, 700),\n    skills: (e.skills || []).slice(0, 12)\n  })),\n  education: (p.education || []).map(ed => ({\n    school: ed.schoolName,\n    degree: ed.degree,\n    field: ed.fieldOfStudy,\n    period: ed.period || null\n  })),\n  skills: (p.skills || []).map(s => s.name).slice(0, 40),\n  certifications: (p.certifications || []).map(c => ({ title: c.title, by: c.issuedBy || null })).slice(0, 15),\n  projects: (p.projects || []).map(pr => ({ title: pr.title, desc: (pr.description || \"\").slice(0, 300) })).slice(0, 8),\n  honorsAndAwards: (p.honorsAndAwards || []).map(h => h.title).slice(0, 8),\n  volunteering: (p.volunteering || []).map(v => ({ role: v.role, org: v.organizationName })).slice(0, 6),\n  languages: (p.languages || []).map(l => l.name + (l.proficiency ? ` (${l.proficiency})` : \"\")),\n  publications: (p.publications || []).map(pub => pub.title).slice(0, 6)\n}));\n\nreturn { profilesJson: JSON.stringify(slim), count: slim.length };\n```\n\n- Continue, then\n**Test step**.\n\n### Step 3: Action - look up the job ad\n\n- Click\n**+**, search** Google Sheets**, select it. **Event:****Lookup Spreadsheet Row**.** Configure:****Spreadsheet**= Talent Sourcing Sheet,** Worksheet**= Jobs.** Lookup Column:**`search_run_id`\n\n.**Lookup Value:** map the trigger's**ID**(the run** ID**).\n\n- Continue, then\n**Test step**. This is the other half of the trigger sheets node. The finished run carries an ID; this step finds the job row whose search_run_id matches that ID and returns the`job_ad`\n\nand`job_id`\n\n.\n\n### Step 4: Action - ask OpenAI to score and rank\n\n- Click\n**+**, search** ChatGPT (OpenAI)**, select it. **Event:****Conversation**.** Account:**click** Sign in**, paste your** OPENAI_KEY.****Configure:****Model:**`gpt-4o`\n\n**Max Tokens:** at least`4000`\n\n.**User Message:** paste the block below, then replace`PASTE_JOB_AD`\n\nwith the Lookup step's`job_ad`\n\nand`PASTE_PROFILES_JSON`\n\nwith the Code step's`profilesJson`\n\n.\n\n```\nYou are an expert technical recruiter. From the job description, infer the must-have requirements, the nice-to-haves, and any dealbreakers, then score every candidate against them using only data present in their profile. Invent nothing.\n\nFor each candidate, return:\n- match_percentage: 0-100 fit, weighting must-haves far above nice-to-haves.\n- tier: strong_match (80+, no must-have missed), high_potential (strong but misses a must-have), partial (40-79), weak (below 40).\n- matched_criteria / missing_criteria: requirements they clearly meet / clearly miss.\n- high_potential_flag: true only when strong overall, yet missing at least one must-have. In high_potential_reason, name the strength and the exact must-have missed; otherwise use \"\".\n- unique_outliers: 1-4 traits distinctive to this specific person and grounded in their data, never generic. If they are openToWork, you may note they are actively looking.\n- verdict: one blunt sentence.\n- email: copy it exactly from the profile, or null. Never guess or construct one.\n\nSort candidates by match_percentage, highest first. Return ONLY this JSON, no prose and no markdown fences:\n{\"job_title\": string, \"candidates\": [{\"name\": string, \"linkedin_url\": string, \"current_title\": string, \"match_percentage\": number, \"tier\": string, \"matched_criteria\": [string], \"missing_criteria\": [string], \"high_potential_flag\": boolean, \"high_potential_reason\": string, \"unique_outliers\": [string], \"verdict\": string, \"email\": string or null}]}\n\nJOB DESCRIPTION:\nPASTE_JOB_AD\n\nCANDIDATES (JSON array):\nPASTE_PROFILES_JSON\n```\n\n- Continue, then\n**Test step**.\n\nThe model infers what the role actually needs from the ad, then scores each person against it, separates true strong matches from high-potential people who miss a few hard requirements, and pulls out the genuinely distinctive details from each profile. It is told to use only what is in the data and to return null for a missing email, so it never fabricates contact details.\n\n### Step 5: Action - parse the answer and drop anyone already on the board\n\nThis step ensures that alerts are only for new candidates for the role. It parses the model's JSON, reads the LinkedIn URLs already logged for this job, and keeps only candidates you have not written under this job before.\n\n- Click\n**+**, search** Code by Zapier**, select it. **Event:****Run JavaScript.****Input Data:**`content`\n\n= the ranking step's**Output Text**(the JSON reply).`airtableToken`\n\n= paste your**AIRTABLE_TOKEN**.`baseId`\n\n= paste your**BASE_ID**.`candidatesTable`\n\n= type`Candidates`\n\n.`jobId`\n\n= map the Lookup step's**job_id**. This scopes the dedupe to the current job.\n\n**Code:** paste this.\n\n``` js\nconst data = JSON.parse((inputData.content || \"\").replace(/``` json|```/g, \"\").trim());\nconst jobTitle = data.job_title || \"the role\";\nconst cands = (data.candidates || []).sort((a, b) => (b.match_percentage || 0) - (a.match_percentage || 0));\nconst norm = u => String(u || \"\").trim().toLowerCase().replace(/\\\\/+$/, \"\");\n\n// LinkedIn URLs already logged under THIS job (paginated), so re-running a job stays silent\nconst headers = { Authorization: `Bearer ${inputData.airtableToken}` };\nconst table = encodeURIComponent(inputData.candidatesTable || \"Candidates\");\nconst filter = encodeURIComponent(`{Job ID} = \"${String(inputData.jobId || \"\").replace(/\"/g, '\\\\\\\\\"')}\"`);\nconst seen = new Set();\nlet offset = \"\", guard = 0;\ndo {\n  const u = `https://api.airtable.com/v0/${inputData.baseId}/${table}?fields%5B%5D=LinkedIn&filterByFormula=${filter}&pageSize=100${offset ? `&offset=${encodeURIComponent(offset)}` : \"\"}`;\n  const j = await (await fetch(u, { headers })).json();\n  if (j.error) throw new Error(\"Airtable read failed: \" + JSON.stringify(j.error));\n  (j.records || []).forEach(r => { const l = r.fields?.LinkedIn; if (l) seen.add(norm(l)); });\n  offset = j.offset || \"\";\n} while (offset && ++guard < 50);\n\nconst rows = cands.filter(c => c.linkedin_url && !seen.has(norm(c.linkedin_url))).map(c => ({\n  name: c.name,\n  match: c.match_percentage,\n  tier: c.tier,\n  matched: (c.matched_criteria || []).join(\"; \"),\n  missing: (c.missing_criteria || []).join(\"; \"),\n  high_potential_reason: c.high_potential_flag ? c.high_potential_reason : \"\",\n  outliers: (c.unique_outliers || []).join(\"; \"),\n  email: c.email || \"\",\n  url: c.linkedin_url,\n  job: jobTitle\n}));\n\nconst newCount = rows.length, totalCount = cands.length;\nconst summary = newCount\n  ? `${newCount} new candidate${newCount === 1 ? \"\" : \"s\"} for ${jobTitle}, out of ${totalCount} scored this run. Top new match: ${rows[0].name} at ${rows[0].match}%.`\n  : `No new candidates for ${jobTitle}. All ${totalCount} scored were already logged under this job.`;\n\nreturn { rows, newCount, totalCount, jobTitle, summary };\n```\n\n- Continue, then\n**Test step**.\n\n### Step 6: Filter - stop unless this run added someone new\n\nThis action filters to ensure that if the run produced no new people, the Zap stops immediately.\n\n- Click\n**+**, search for** Filter by Zapier**, and select it. **Only continue if:** the dedupe step's**New Count**(`newCount`\n\n), condition**(Number) Greater than**, value`0`\n\n.- Click Continue to add the next action.\n\n### Step 7: Action - log the run\n\n- Click\n**+**, search** Airtable**, select it. **Event:****Create Record**.** Account:**click** Sign in**and connect Airtable through Zapier's OAuth popup (separate from the read token).** Configure:****Base**= Talent Sourcing,** Table**= Runs.** Summary**= dedupe step`summary`\n\n.**Job title**= dedupe step`jobTitle`\n\n.**New candidates**= dedupe step`newCount`\n\n.**Total scored**= dedupe step`totalCount`\n\n.**Link**= paste your** BOARD_LINK**.\n\n- Continue, then\n**Test step**.\n\nThis action writes one summary row per run that brought in new people, and that single write is the event that the next Zap (Zap C) watches out for.\n\n### Step 8: Loop over the new candidates\n\n- Click\n**+**, search** Looping by Zapier**, and select it. **Event:****Create Loop From Line Items**.** Values to Loop:**add one entry per field, typing the key on the left and inserting the matching list from the “Filter by Zapier” step on the right:`name`\n\n=`rows name`\n\n,`match`\n\n=`rows match`\n\n,`tier`\n\n=`rows tier`\n\n,`meets`\n\n=`rows matched`\n\n,`misses`\n\n=`rows missing`\n\n,`reason`\n\n=`rows high_potential_reason`\n\n,`outliers`\n\n=`rows outliers`\n\n,`email`\n\n=`rows email`\n\n,`url`\n\n=`rows url`\n\n,`job`\n\n=`rows job`\n\n- Continue, then\n**Test step**.\n\nThe loop here turns the lists from Step 5, the second Code by Zapier action, into one iteration per new candidate, so every step after it runs once per person.\n\n### Step 9: Write each candidate to the board\n\n- Click\n**+**, search** Airtable**, select it. **Event:****Create Record**.** Configure:****Base**= Talent Sourcing,** Table**= Candidates.- Map\n**Name**,** Match**,** Tier**,** Meets**,** Misses**,** High potential reason**,** Outliers**, and** Email**from the matching loop values. **LinkedIn**= loop`url`\n\n. This must be filled; it is the dedupe key for the next run.**Job**= loop`job`\n\n.**Job ID**= the Lookup step's`job_id`\n\n(constant for the run).\n\n- Continue, then\n**Test step**. - Click\n**Publish,** and this Zap B will go live.\n\nThis step creates one card per candidate. The LinkedIn URL and Job ID are the most important fields to map. The LinkedIn URL prevents duplicates by letting future runs recognize candidates already on your board. The Job ID ensures that this check applies only to the specific role you are hiring for.\n\n## Phase 5: Zap C - New-profile alert\n\nZap C triggers when a new “Runs” row appears, which also means new candidates. Once the run is concluded, it sends you an email pointing to the Airtable board.\n\n### Step 1: Trigger - New Record in the Runs table\n\n- Create a new Zap, rename it “\n**C - New-profile alert”**. - Click the\n**Trigger** card, search**Airtable**, select it. **Event:****New Record**.** Account:**pick your Airtable connection.** Configure:****Base**= Talent Sourcing,** Table**= Runs.** Test:**click** Test trigger**.\n\nThis entire sequence ensures the workflow is event-driven, not just scheduled. The “Runs” table only ever gets a row when Zap B's filter passed, meaning real new people landed.\n\n### Step 2: Send the alert\n\n- Click\n**+**, search** Email by Zapier**, select it. **Event:****Send Outbound Email**.** Configure:****To:** your email address.**Subject:** type`New candidates to review:`\n\nthen insert the trigger's**Job title**.** Body (Plain text here):**insert the trigger's** Summary**, then on a new line type`Open the board (newest at the top):`\n\nand insert the Airtable URL link pointing at the Candidates table.\n\n- Click Continue, then\n**Test step**. Check your inbox. - Click\n**Publish**,and Zap C is live.\n\n## Phase 6: Zap D - Scheduled daily re-run\n\nZaps A through C only discover new candidates when a scrape runs, and so far, a scrape only runs when you add a job by hand. Zap D is what keeps the pipeline hunting on its own. It runs daily, reads every open job from the sheet, and re-launches the scrape for each one. The schedule triggers the scrape, not the alert, because the deduplication code already guarantees you only hear about genuinely new people.\n\n### Step 1: Trigger - daily schedule\n\n- Create a new Zap, rename it “\n**D - Scheduled rerun”**. - Click the\n**Trigger** card, search**Schedule by Zapier**, and select it. **Event:****Every Day**. Set a time (8 or 9 am works). Continue and test.\n\n### Step 2: Action - pull every job row\n\n- Click\n**+**, search** Google Sheets**, select it.** Event:****Get Many Spreadsheet Rows (Advanced)**. **Configure:****Spreadsheet**= Talent Sourcing Sheet,** Worksheet**= Jobs.** Columns**=`A:Z`\n\n.**First row**=`2`\n\n(row 1 is your headers).**Row count**=`10`\n\n(raise it if you ever keep more than 10 jobs).**Output format**=** All**(this is what populates the Raw Rows JSON that the next step reads).\n\n- Continue and test. Confirm it returns all your job rows.\n\nA single-row lookup won't work here because the rerun has to walk through every open job at once. This action returns all of them. The piece you will use is **Raw Rows**, the JSON version of the data, which the Code step parses into clean lists.\n\n### Step 3: Action - flatten the rows into clean lists\n\n- Click\n**+**, search** Code by Zapier**, select it. **Event:****Run JavaScript**.** Input Data:**one item,`rawRows`\n\n= map the Get Many step's**Raw Rows**.** Code:**paste this.\n\n``` js\nlet data = inputData.rawRows;\nif (typeof data === \"string\") {\n  data = JSON.parse(data);\n}\n\nconst jobIds = [];\nconst queries = [];\nconst statuses = [];\nconst rowNumbers = [];\n\ndata.forEach((row, i) => {\n  const cells = Array.isArray(row) ? row : Object.values(row);\n  jobIds.push(cells[0]);     // A = job_id\n  queries.push(cells[2]);    // C = search_query\n  statuses.push(cells[4]);   // E = status\n  rowNumbers.push(i + 2);    // sheet row, headers in row 1, data starts row 2\n});\n\nreturn { jobIds, queries, statuses, rowNumbers };\n```\n\n- Continue and test.\n\nWithout this step, the loop receives every column of a row mashed into a single text string, making it impossible to isolate the search query or job status. Flattening the data into aligned lists keeps the loop clean and usable.\n\n### Step 4: Loop over the jobs\n\n- Click\n**+**, search** Looping by Zapier**, select it. **Event:****Create Loop From Line Items**.** Values to Loop:**add four, each mapped to a list from the Code step:`jobId`\n\n=`Job Ids`\n\n,`query`\n\n=`Queries`\n\n,`status`\n\n=`Statuses`\n\n,`rowNumber`\n\n=`Row Numbers`\n\n- Continue and test.\n\nThis combines the four lists so the system processes one job per iteration. Inside each loop, you have direct access to the jobId, query, status, and rowNumber for that specific job. Everything after this runs exactly once per job.\n\n### Step 5: Filter - only rerun open jobs with a query\n\n- Click\n**+**, search for** Filter by Zapier**, and select it. **Only continue if**(two conditions joined with** And**):`status`\n\n(from the loop)**(Text) Exactly matches**`searching`\n\n`query`\n\n(from the loop)**(Text) Exists**\n\n- Continue.\n\n### Step 6: Action - re-launch the scrape\n\n- Click\n**+**, search** Apify**, select it. **Event:****Run Actor**.** Configure it identically to Zap A's Run Actor:**same Actor (`harvestapi/linkedin-profile-search`\n\n),**Profile Scraper Mode** Full + email search,**Max Items** 25, run asynchronously. The one difference: map**Search Query** to the loop's`query`\n\n.- Continue and test.\n\nThis is the same scrape Zap A runs, just driven by the loop instead of a new intake row. It’s not a second Actor; it’s the same one called again, once per open job, on the schedule. Each finished run flows into Zap B exactly as an intake scrape would.\n\n### Step 7: Action - stamp the new run ID back\n\n- Click\n**+**, search** Google Sheets**, select it. **Event:****Update Spreadsheet Row(s)**.** Configure:****Spreadsheet**= Talent Sourcing Sheet,** Worksheet**= Jobs.** Row:**map the loop's** Row Number**. This targets the right job's row.** search_run_id:**map the** ID**from the Run Actor step (Step 6), not the Actor ID.- Leave every other column blank.\n\n- Continue, then test.\n- Click\n**Publish**. Zap D is live, and the whole pipeline now runs on its own.\n\n## Phase 7: Test the pipeline end-to-end\n\nAdd a real job to your Jobs sheet and let the published Zaps run. Confirm that the candidates land on your Airtable board and you receive an email alert. If you want to test the deduplication logic, rerun the exact same job. You’ll notice that the system recognizes existing profiles and prevents duplicate entries.\n\n## Summary\n\nThe next time your team has an opening, you don’t have to post it and hope. You now have a system that finds qualified people, ranks them against your exact requirements, and surfaces what sets each one apart.\n\n[LinkedIn Profile Search Scraper](https://apify.com/harvestapi/linkedin-profile-scraper) is exclusive to [Apify Store](https://apify.com/store), and the free tier gives you $5 in monthly credits to run the whole build at no cost. For a wider reach, there are other Actors on Apify Store that scrape platforms, such as Twitter, Upwork, and Medium.\n\nAnd, if you have an idea for an Actor of your own, in this space or any other, you can [build, share, and monetize](https://apify.com/partners/actor-developers) it on **Apify, the largest marketplace of tools for web data and AI**.", "url": "https://wpnews.pro/news/automate-talent-sourcing-with-apify-openai-and-zapier", "canonical_source": "https://blog.apify.com/talent-sourcing-strategy/", "published_at": "2026-06-12 10:10:23+00:00", "updated_at": "2026-06-20 16:45:03.601837+00:00", "lang": "en", "topics": ["ai-tools", "ai-agents", "large-language-models"], "entities": ["Apify", "OpenAI", "Zapier", "LinkedIn", "Google Sheets", "Airtable"], "alternates": {"html": "https://wpnews.pro/news/automate-talent-sourcing-with-apify-openai-and-zapier", "markdown": "https://wpnews.pro/news/automate-talent-sourcing-with-apify-openai-and-zapier.md", "text": "https://wpnews.pro/news/automate-talent-sourcing-with-apify-openai-and-zapier.txt", "jsonld": "https://wpnews.pro/news/automate-talent-sourcing-with-apify-openai-and-zapier.jsonld"}}