After getting zero responses from 200+ auto-applied job applications, I decided to build something different. Here's how I created an AI-powered job matching system that helps you find relevant opportunities while keeping you in control.
The Problem with Auto-Apply
We've all seen them - tools that promise to "apply to 100 jobs while you sleep." But here's what actually happens:
- Generic applications that don't stand out
- No customization for specific roles
- Recruiters can smell spray-and-pray a mile away
- Zero personal touch = zero interviews
I tracked my own job search and found:
- Auto-applied: 200+ applications → 0 interviews
- Manual (targeted): 15 applications → 4 interviews
Quality beats quantity. Every time.
The Solution: AI Discovery, Human Decision
I built a job portal with a different philosophy:
AI handles: Finding and scoring relevant jobs
Human handles: Reviewing and applying
Let's break down how I built it.
Tech Stack
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS 4
- Backend: Nitro server + SQLite (better-sqlite3)
- AI: Claude Haiku via CLI
- Scraping: Playwright + Chromium
Architecture Overview
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Job Sites │────▶│ Scrapers │────▶│ SQLite DB │
│ (Naukri/LinkedIn)│ │ (Playwright) │ │ │
└─────────────────┘ └─────────────────┘ └────────┬────────┘
│
┌─────────────────┐ ┌─────────────────┐ │
│ Vue Frontend │◀────│ AI Matcher │◀─────────────┘
│ (Nuxt 4) │ │ (Claude Haiku) │
└─────────────────┘ └─────────────────┘
Step 1: Scraping Jobs with Playwright
First, I created a base scraper class:
// server/scraper/sites/base.ts
export abstract class BaseScraper {
protected browser: Browser | null = null;
abstract getJobListSelector(): string;
abstract buildSearchUrl(keywords: string[]): string;
abstract parseJobCard(element: ElementHandle): Promise<ParsedJob>;
async scrape(keywords: string[]): Promise<Job[]> {
await this.initBrowser();
const url = this.buildSearchUrl(keywords);
// Navigate, wait for selector, parse cards...
}
}
Each site extends this base:
// server/scraper/sites/naukri.ts
export class NaukriScraper extends BaseScraper {
getJobListSelector() {
return '.srp-jobtuple-wrapper';
}
buildSearchUrl(keywords: string[]) {
return `https://www.naukri.com/${keywords.join('-')}-jobs`;
}
async parseJobCard(element: ElementHandle) {
// Extract title, company, location, etc.
}
}
Step 2: The Matching Algorithm
Here's where it gets interesting. I use a weighted scoring system:
const WEIGHTS = {
techStack: 0.40, // 40% - Does the job need your skills?
roleAlignment: 0.25, // 25% - Is this the type of role you want?
experienceLevel: 0.20, // 20% - Are you over/under qualified?
domainRelevance: 0.15 // 15% - Have you worked in this industry?
};
The AI (Claude Haiku) evaluates each job:
// Simplified matching logic
async function matchJob(job: Job, profile: UserProfile): Promise<number> {
const prompt = `
Score this job match (0-100):
Job: ${job.title} at ${job.company}
Requirements: ${job.requirements}
Candidate Profile:
- Experience: ${profile.yearsOfExperience} years
- Tech Stack: ${profile.techStack.join(', ')}
- Target Roles: ${profile.targetRoles.join(', ')}
Scoring weights:
- Tech Stack Match: 40%
- Role Alignment: 25%
- Experience Level: 20%
- Domain Relevance: 15%
Return only the score as a number.
`;
const score = await callClaude(prompt);
return parseInt(score);
}
Jobs scoring ≥50 are marked as "matched" (worth reviewing).
Jobs scoring <50 are marked as "ignored" (not a good fit).
Step 3: The User Flow
new → matched → interested → applied → archived
↓
ignored
- Upload Resume: AI extracts your profile (experience, skills, target roles)
- Scrape Jobs: Playwright fetches listings from job sites
- Match: AI scores each job against your profile
- Review: You see matched jobs sorted by score
- Apply: Click "View Original" → Apply manually on the company site
- Track: Mark as "applied" to track your pipeline
Step 4: Keeping Costs Low
Claude Haiku is incredibly cheap:
| Activity | Tokens | Cost |
|---|---|---|
| Resume upload | ~15K | One-time |
| Match 20 jobs | ~25K | ~$0.0075 |
| Monthly total | ~750K | $0.22 |
That's essentially free if you have Claude Pro.
The Nuxt 4 Experience
Some things I loved about Nuxt 4:
Server Routes are magic:
// server/api/match.post.ts
export default defineEventHandler(async (event) => {
const jobs = await getUnmatchedJobs();
const scores = await matchJobs(jobs);
return { matched: scores.filter(s => s >= 50).length };
});
Composables for state:
// composables/useJobs.ts
export const useJobs = () => {
const jobs = useState<Job[]>('jobs', () => []);
const fetchJobs = async (status?: string) => {
jobs.value = await $fetch('/api/jobs', {
query: { status }
});
};
return { jobs, fetchJobs };
};
What I Deliberately Didn't Build
This is important. These features are out of scope by design:
- ❌ Auto-apply to jobs
- ❌ Form auto-fill
- ❌ Automated emails to recruiters
- ❌ Batch applications
Why? Because the best job applications are personal. AI should augment your job search, not replace your judgment.
Try It Yourself
The project is open source:
GitHub: https://github.com/1291pravin/job-hunt-ai
Setup:
git clone https://github.com/1291pravin/job-hunt-ai
cd job-portal-app
npm install
npx playwright install chromium
npm run dev
What's Next?
Looking for contributors to help with:
- More job sources (Indeed, Glassdoor, AngelList)
- Better matching with ML models
- Resume tailoring suggestions
- Interview prep notes feature
Conclusion
Sometimes the best feature is the one you don't build. By keeping humans in the loop, this tool has helped me land more interviews with fewer applications.
The code is MIT licensed. Fork it, improve it, and let me know what you think!
Top comments (0)