Technology & Society

Taming LinkedIn Job Alerts

Building an Automated Job Tracker with Claude Code

By Samuel S. Kim
January 9, 2026
How I built a serverless tool to automatically process LinkedIn job alert emails and maintain a deduplicated list in Google Sheets—all with the help of Claude Code.
Taming LinkedIn Job Alerts

Taming LinkedIn Job Alerts

If you're actively job hunting or just keeping an eye on the market, you've probably subscribed to LinkedIn's job alerts. What starts as a helpful feature quickly becomes overwhelming—several emails per day depending on the searches you have subscribed to, many containing jobs you've already seen, reviewed, and ruled out.

This is the story of how I went from email chaos to an organized job tracking system, built entirely with the help of Claude Code.

The Problem: Death by Duplicate Job Alerts

LinkedIn sends job alert emails from multiple addresses:

  • jobalerts-noreply@linkedin.com — standard job alerts
  • jobs-noreply@linkedin.com — job recommendations
  • jobs-listings@linkedin.com — "You may be a fit" suggestions

Each email contains 5-10 job listings, and here's the frustrating part: the same jobs appear across multiple emails. That Senior Engineering Manager role at Company X? I've seen it in at least six different emails over the past two weeks.

Even worse, LinkedIn Premium's "match level" indicator often showed medium to low matches for many suggestions—jobs I'd never apply to but had to mentally process anyway.

The result? I became reluctant to check these emails. They piled up. Potentially interesting opportunities got buried under a mountain of duplicates and irrelevant listings.

I needed a system.

The Solution: Automated Processing with Google Sheets

My requirements were simple:

  1. Automatically process all LinkedIn job alert emails
  2. Deduplicate jobs across all emails using LinkedIn's job ID
  3. Store everything in a Google Sheet I could review at my leisure
  4. Track status of each job (New, Interested, Applied, etc.)
  5. Run on a schedule without any manual intervention

Why Cloud Functions Instead of a Local Script?

My first instinct was to write a simple script and run it on my laptop before checking the job listings. That would work—but it meant storing OAuth tokens with Gmail access on my local machine.

That's a security concern I wasn't comfortable with.

Gmail access tokens are powerful. They can read, modify, and delete emails. If my laptop were ever compromised—malware, theft, or even a misconfigured backup—those credentials could be exposed. I didn't want tokens with access to my personal email sitting in a config file on my filesystem.

Google Cloud Functions with Secret Manager solved this:

ConcernLocal ScriptCloud Functions
Credential storagePlain files on diskSecret Manager (encrypted, access-controlled)
Exposure riskLaptop compromise, backups, theftIsolated in Google's infrastructure
Access auditNoneCloud audit logs

The credentials never touch my local machine. They're created once during OAuth setup, immediately stored in Secret Manager, and only accessed by the Cloud Function at runtime. Even I can't view the raw tokens after setup—I'd have to generate new ones.

As a bonus, Cloud Scheduler lets the function run automatically every few hours, so the job list stays current without me remembering to run anything.

The Tech Stack

  • Google Cloud Functions (Gen2) — serverless execution
  • Gmail API — fetch and process emails
  • Google Sheets API — store and update job listings
  • Cloud Scheduler — trigger every 4 hours
  • Secret Manager — secure credential storage
  • TypeScript — type safety for parsing logic

And my development partner: Claude Code.

Building with Claude Code

I used Claude Code throughout the entire development process. Here's how it helped at each stage:

Project Setup and Architecture

I started by describing what I wanted to build. Claude Code helped establish a clean project structure following SOLID principles:

src/
├── index.ts           # Cloud Function entry point
├── gmail/
│   ├── client.ts      # Gmail API client
│   └── parser.ts      # LinkedIn email HTML parser
├── sheets/
│   ├── client.ts      # Sheets API client
│   ├── schema.ts      # Column definitions
│   └── writer.ts      # Write logic with deduplication
└── types/
    └── job.ts         # Job interface with Zod validation

Parsing LinkedIn Emails

The trickiest part was parsing LinkedIn's HTML emails. The structure isn't documented and can change without notice. Claude Code helped build a defensive parser with multiple fallback strategies:

// Extract job title - try multiple selectors for different email formats
let title = '';

// Strategy 1: font-bold link (jobalerts-noreply format)
const fontBoldLink = card.find('a.font-bold[href*="jobs/view"]');
if (fontBoldLink.length > 0) {
  title = fontBoldLink.text().trim();
}

// Strategy 2: Any link to jobs/view with text
if (!title) {
  card.find('a[href*="jobs/view"]').each((_, el) => {
    const linkText = $(el).text().trim();
    if (linkText && linkText.length > 2) {
      title = linkText;
      return false; // break
    }
  });
}

// Strategy 3: Common heading elements
if (!title) {
  const headingSelectors = ['h3', 'h4', '[data-test-id="job-title"]'];
  // ... fallback logic
}

When LinkedIn changed their email format mid-project (a new sender address with slightly different HTML), I simply uploaded a sample email to Claude Code and it updated the parser accordingly.

Deduplication Logic

The core of the system is deduplication. Each LinkedIn job has a unique ID embedded in its URL:

https://www.linkedin.com/jobs/view/4351774805/
                                   ^^^^^^^^^^
                                   Job ID

The system maintains a map of existing jobs in the sheet. When processing new emails:

for (const job of jobs) {
  const existing = this.existingJobs.get(job.jobId);

  if (!existing) {
    // New job - add to sheet
    newJobs.push(job);
  } else if (this.hasJobChanged(job, existing)) {
    // Existing job with updated info - update row
    jobsToUpdate.push({ job, rowNumber: existing.rowNumber });
  } else {
    // Duplicate with no changes - skip
    skippedCount++;
  }
}

This ensures I never see the same job twice while still capturing any updates (like salary information being added).

Security Considerations

Claude Code was particularly helpful with security. It identified several concerns I hadn't considered:

Formula Injection Prevention — Malicious job titles could contain formulas:

function sanitizeCellValue(value: string): string {
  const FORMULA_PREFIXES = ['=', '+', '-', '@'];
  if (FORMULA_PREFIXES.includes(value.charAt(0))) {
    return `'${value}`; // Prefix with single quote
  }
  return value;
}

Sender Validation — Defense in depth beyond the Gmail query:

const LINKEDIN_SENDER_EMAILS = new Set([
  'jobalerts-noreply@linkedin.com',
  'jobs-noreply@linkedin.com',
  'jobs-listings@linkedin.com',
]);

// Validate even after Gmail query filters
if (!LINKEDIN_SENDER_EMAILS.has(senderEmail)) {
  logger.warn('Message not from LinkedIn, skipping');
  return null;
}

URL Validation — Only accept legitimate LinkedIn job URLs:

const LINKEDIN_JOB_URL_PATTERN =
  /^https:\/\/(?:www\.)?linkedin\.com\/jobs\/view\/\d+$/;

The Google Sheet

The output is a clean, organized spreadsheet:

job_idstatusjob_titlecompanylocationnotes
1234567890NEWSenior EngineerAcme CorpRemote
2345678901INTERESTEDEngineering ManagerGlobex IncNYCGreat team
3456789012APPLIEDTech LeadInitechAustin, TXFollowed up

Features:

  • Status dropdown with data validation (NEW, READ, INTERESTED, APPLIED, etc.)
  • Conditional formatting — each status has a distinct color
  • Frozen header row for easy scrolling
  • User-editable notes column — the system never overwrites this

Deployment

The entire system runs on Google Cloud's free tier:

# Deploy the function
npm run deploy

# Set up the scheduler (every 4 hours)
gcloud scheduler jobs create http linkedin-job-sync \
  --schedule="0 */4 * * *" \
  --uri="$FUNCTION_URL" \
  --http-method=POST

OAuth credentials are stored securely in Google Secret Manager, and the function has minimal IAM permissions—just enough to read emails and write to one specific spreadsheet.

The Result

After running for a week, here's what the system processed:

{
  "success": true,
  "emailsProcessed": 47,
  "jobsFound": 312,
  "jobsAdded": 89,
  "jobsUpdated": 12,
  "jobsSkipped": 211
}

211 duplicates eliminated. That's 211 times I would have seen a job I'd already reviewed.

Now my workflow is simple:

  1. Open the Google Sheet once a day
  2. Review new jobs (status = NEW)
  3. Mark as INTERESTED or NOT INTERESTED
  4. Apply to the good ones

No more email overwhelm. No more duplicate fatigue. Just a clean list of opportunities.

Lessons Learned

1. Claude Code excels at defensive programming. When I mentioned LinkedIn might change their email format, it automatically built in multiple parsing strategies and fallbacks.

2. Start with the data model. Defining the Job interface with Zod validation upfront caught numerous edge cases early.

3. Idempotency is key. The system can safely re-run without creating duplicates or corrupting data. This made debugging and testing much easier.

4. Free tier is enough. Google Cloud's free tier comfortably handles this workload—approximately 120 function invocations per month, 3 secrets, and one scheduler job.

Try It Yourself

The full source code is available on GitHub: sk102/linkedin-job-alerts

To set it up, you'll need:

  1. A Google Cloud project with Gmail, Sheets, and Secret Manager APIs enabled
  2. OAuth 2.0 credentials with gmail.modify and spreadsheets scopes
  3. A Google Sheet to store the jobs

The hardest part is the initial OAuth setup—after that, it's fully automated. See the repository README for detailed instructions.


Built with Claude Code, deployed on Google Cloud Functions, and finally achieving inbox zero (for job alerts, at least).

Tags

Claude CodeAutomationGoogle CloudTypeScriptProductivityAIDeveloper Tools

Let's Continue the Conversation

Have thoughts on this article? I'd love to hear from you.

Get in Touch