
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 alertsjobs-noreply@linkedin.com— job recommendationsjobs-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:
- Automatically process all LinkedIn job alert emails
- Deduplicate jobs across all emails using LinkedIn's job ID
- Store everything in a Google Sheet I could review at my leisure
- Track status of each job (New, Interested, Applied, etc.)
- 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:
| Concern | Local Script | Cloud Functions |
|---|---|---|
| Credential storage | Plain files on disk | Secret Manager (encrypted, access-controlled) |
| Exposure risk | Laptop compromise, backups, theft | Isolated in Google's infrastructure |
| Access audit | None | Cloud 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_id | status | job_title | company | location | notes |
|---|---|---|---|---|---|
| 1234567890 | NEW | Senior Engineer | Acme Corp | Remote | |
| 2345678901 | INTERESTED | Engineering Manager | Globex Inc | NYC | Great team |
| 3456789012 | APPLIED | Tech Lead | Initech | Austin, TX | Followed 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:
- Open the Google Sheet once a day
- Review new jobs (status = NEW)
- Mark as INTERESTED or NOT INTERESTED
- 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:
- A Google Cloud project with Gmail, Sheets, and Secret Manager APIs enabled
- OAuth 2.0 credentials with
gmail.modifyandspreadsheetsscopes - 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).