From f66548f3886f4c5801cb841365bf68969e9e6227 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 09:46:14 -0400 Subject: [PATCH] chore: untrack and ignore venv directory --- .gitignore | 1 + config.yaml | 66 ++++++ daemon.py | 521 ++++++++++++++++++++++++++++++++++++++++++++ job-matcher.service | 18 ++ matcher.py | 257 ++++++++++++++++++++++ profile.json | 77 +++++++ requirements.txt | 5 + resume_parser.py | 245 +++++++++++++++++++++ schema.sql | 41 ++++ 9 files changed, 1231 insertions(+) create mode 100644 .gitignore create mode 100644 config.yaml create mode 100644 daemon.py create mode 100644 job-matcher.service create mode 100644 matcher.py create mode 100644 profile.json create mode 100644 requirements.txt create mode 100644 resume_parser.py create mode 100644 schema.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7275bb --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +venv/ diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..8d0eb7b --- /dev/null +++ b/config.yaml @@ -0,0 +1,66 @@ +# ============================================================ +# JOB MATCHER CONFIG +# ============================================================ + +# --- Matching Thresholds --- +min_match_score: 70 # Only alert on jobs scoring >= this +max_detail_fetches_per_run: 50 # Limit detail page requests per run + +# --- Score Weights (must sum to 1.0) --- +weights: + skills: 0.40 + title: 0.25 + experience: 0.15 + keywords: 0.15 + certifications: 0.05 + +# --- Pre-filter: Skip jobs whose title contains these --- +title_blacklist: + - "marketing" + - "sales" + - "recruiter" + - "accounting" + - "graphic design" + - "product manager" + - "data scientist" + - "frontend developer" + - "ui/ux" + +# --- Blacklisted Companies --- +company_blacklist: [] + +# --- Skills you WANT to learn (boost jobs that need these) --- +aspirational_skills: + - "terraform" + - "kubernetes" + - "docker" + - "ci/cd" + +# --- Scraper Settings --- +scraper: + keywords: + - "Network Engineer" + - "Network Analyst" + - "Network Administrator" + - "NOC Engineer" + - "Infrastructure Engineer" + - "Cloud Network Engineer" + - "Systems Engineer" + location: "Toronto, ON" + pages_per_keyword: 3 + delay_between_pages: [3, 6] + delay_between_keywords: [5, 10] + delay_between_details: [4, 8] + +# --- Database --- +database: + host: "localhost" + user: "root" + password: "" + database: "job_matcher_db" + +# --- Telegram --- +# Fill these in with your bot token and chat ID +telegram: + token: "YOUR_BOT_TOKEN_HERE" + chat_id: "YOUR_CHAT_ID_HERE" diff --git a/daemon.py b/daemon.py new file mode 100644 index 0000000..054400e --- /dev/null +++ b/daemon.py @@ -0,0 +1,521 @@ +""" +daemon.py +--------- +Enhanced job scraper daemon with: + 1. Job card scraping + 2. Detail page fetching + 3. Resume-based match scoring + 4. Smart alerts - only 70%+ matches with breakdown + +Usage: + python daemon.py # single run + python daemon.py --loop # run every 2 hours + python daemon.py --loop --interval 3600 # run every hour +""" + +import requests +from bs4 import BeautifulSoup +import time +import random +import re +import json +import yaml +import argparse +import os +import sys +from datetime import datetime +from urllib.parse import quote +import logging +import mysql.connector +from mysql.connector import Error + +from resume_parser import parse_resume_text, parse_resume_file +from matcher import score_job + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger("job-matcher") + + +def load_config(config_path="config.yaml"): + with open(config_path, "r") as f: + return yaml.safe_load(f) + + +def load_profile(profile_path="profile.json", resume_path=None): + if os.path.exists(profile_path): + with open(profile_path, "r") as f: + profile = json.load(f) + if "full_text" not in profile and resume_path and os.path.exists(resume_path): + full_profile = parse_resume_file(resume_path) + profile["full_text"] = full_profile.get("full_text", "") + return profile + elif resume_path and os.path.exists(resume_path): + logger.info(f"No profile.json found. Parsing resume: {resume_path}") + profile = parse_resume_file(resume_path) + from resume_parser import save_profile + save_profile(profile, profile_path) + return profile + else: + logger.error("No profile.json or resume file found. Run resume_parser.py first.") + sys.exit(1) + + +class LinkedInJobScraper: + def __init__(self, config): + self.config = config.get("scraper", {}) + self.session = requests.Session() + self.session.headers.update({ + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + }) + self.detail_delay = self.config.get("delay_between_details", [4, 8]) + + def scrape_jobs(self, keyword, location, pages=3): + jobs = [] + seen_links = set() + encoded_keyword = quote(keyword) + encoded_location = quote(location) + delay_range = self.config.get("delay_between_pages", [3, 6]) + + for page in range(pages): + logger.info(f" Page {page + 1}/{pages} for '{keyword}'") + start_index = page * 25 + url = ( + f"https://www.linkedin.com/jobs/search?" + f"keywords={encoded_keyword}&location={encoded_location}" + f"&start={start_index}" + ) + + try: + response = self.session.get(url, timeout=15) + if response.status_code == 429: + wait = 60 * (page + 1) + logger.warning(f"Rate limited. Sleeping {wait}s...") + time.sleep(wait) + continue + if response.status_code != 200: + logger.error(f"Failed page {page + 1}: HTTP {response.status_code}") + continue + + soup = BeautifulSoup(response.content, "html.parser") + job_cards = soup.find_all("div", class_="base-search-card") + + if not job_cards: + logger.info("No job cards found (possibly blocked).") + break + + for card in job_cards: + job = self._extract_card(card) + if not job or job["link"] in seen_links: + continue + seen_links.add(job["link"]) + jobs.append(job) + + except Exception as e: + logger.error(f"Error scraping page {page + 1}: {e}") + + time.sleep(random.uniform(*delay_range)) + return jobs + + def _extract_card(self, card): + try: + title_elem = card.find("h3", class_="base-search-card__title") + if not title_elem: + return None + title = title_elem.get_text(strip=True) + + link_elem = card.find("a", class_="base-card__full-link") + if not link_elem or not link_elem.get("href"): + return None + link = link_elem["href"].split("?")[0] + + company_elem = card.find("h4", class_="base-search-card__subtitle") + company = company_elem.get_text(strip=True) if company_elem else "Unknown" + + location_elem = card.find("span", class_="job-search-card__location") + location = location_elem.get_text(strip=True) if location_elem else "" + + date_elem = card.find("time") + posted_date = date_elem.get_text(strip=True) if date_elem else "Just posted" + + benefit_elem = card.find("div", class_="job-posting-benefits") + actively_hiring = False + if benefit_elem: + actively_hiring = "actively hiring" in benefit_elem.get_text().lower() + + return { + "title": title, "company": company, "location": location, + "posted_date": posted_date, "link": link, + "actively_hiring": actively_hiring, "description": None, + } + except Exception as e: + logger.debug(f"Card extraction error: {e}") + return None + + def fetch_job_description(self, job_url): + try: + response = self.session.get(job_url, timeout=15) + if response.status_code != 200: + logger.debug(f"Detail fetch failed ({response.status_code}): {job_url}") + return None + + soup = BeautifulSoup(response.content, "html.parser") + + desc_div = soup.find("div", class_="show-more-less-html__markup") + if desc_div: + return desc_div.get_text(separator="\n", strip=True) + + for selector in [ + {"class_": "description__text"}, + {"class_": "job-description"}, + {"class_": "details"}, + ]: + fallback = soup.find("div", **selector) + if fallback: + text = fallback.get_text(separator="\n", strip=True) + if len(text) > 50: + return text + + sections = soup.find_all("section", class_="show-more-less-html") + if sections: + return "\n".join(s.get_text(separator="\n", strip=True) for s in sections) + + logger.debug(f"No description found on detail page: {job_url}") + return None + except Exception as e: + logger.debug(f"Detail fetch exception: {e}") + return None + + def fetch_descriptions_batch(self, jobs, max_fetches=50): + fetched_count = 0 + for job in jobs: + if job.get("description"): + continue + if fetched_count >= max_fetches: + logger.info(f"Hit max detail fetches ({max_fetches}).") + break + + logger.info(f" Fetching details: {job['title']} @ {job['company']}") + description = self.fetch_job_description(job["link"]) + if description: + job["description"] = description + fetched_count += 1 + else: + job["description"] = "" + + time.sleep(random.uniform(*self.detail_delay)) + + logger.info(f"Fetched {fetched_count} job descriptions.") + return jobs + + +def pre_filter_jobs(jobs, config): + title_blacklist = [t.lower() for t in config.get("title_blacklist", [])] + company_blacklist = [c.lower() for c in config.get("company_blacklist", [])] + filtered = [] + for job in jobs: + title_lower = job["title"].lower() + company_lower = job["company"].lower() + if any(bl in title_lower for bl in title_blacklist): + continue + if any(bl in company_lower for bl in company_blacklist): + continue + filtered.append(job) + skipped = len(jobs) - len(filtered) + if skipped: + logger.info(f"Pre-filter: {skipped} jobs skipped, {len(filtered)} remaining.") + return filtered + + +class JobAlertSystem: + def __init__(self, db_config): + self.db_config = db_config + + def is_job_fresh(self, posted_date): + if not posted_date: + return False + text = posted_date.lower() + if any(x in text for x in ["just", "minute", "hour", "now", "today"]): + return True + match = re.search(r"(\d+)\s+day", text) + if match: + return int(match.group(1)) <= 4 + return False + + def send_telegram_alert(self, token, chat_id, job, match_result): + url = f"https://api.telegram.org/bot{token}/sendMessage" + score = match_result["score"] + breakdown = match_result["breakdown"] + matched = match_result.get("matched_skills", []) + missing = match_result.get("missing_skills", []) + + filled = score // 10 + bar = "█" * filled + "░" * (10 - filled) + matched_str = ", ".join(matched[:8]) if matched else "—" + missing_str = ", ".join(missing[:5]) if missing else "None" + + message = ( + f"🎯 *{score}% Match — {match_result['verdict']}*\n" + f"{bar}\n\n" + f"*{job['title']}*\n" + f"🏢 {job['company']}\n" + f"📍 {job['location']}\n\n" + f"📊 *Breakdown:*\n" + f" Skills: {breakdown['skills']['score']}% | " + f"Title: {breakdown['title']['score']}% | " + f"Exp: {breakdown['experience']['score']}%\n" + f" Keywords: {breakdown['keywords']['score']}% | " + f"Certs: {breakdown['certifications']['score']}%\n\n" + f"✅ *Match:* {matched_str}\n" + f"❌ *Gap:* {missing_str}\n\n" + f"[View Job]({job['link']})" + ) + + try: + r = requests.post(url, json={ + "chat_id": chat_id, "text": message, + "parse_mode": "Markdown", "disable_web_page_preview": True, + }, timeout=10) + if r.status_code != 200: + logger.error(f"Telegram error: {r.text}") + return False + return True + except Exception as e: + logger.error(f"Telegram exception: {e}") + return False + + def send_summary_alert(self, token, chat_id, stats): + url = f"https://api.telegram.org/bot{token}/sendMessage" + message = ( + f"✅ *Job Matcher Run Complete*\n" + f"⏰ {datetime.now().strftime('%H:%M %d-%b-%Y')}\n\n" + f"📊 *Stats:*\n" + f" Scanned: {stats['total_scanned']}\n" + f" Passed pre-filter: {stats['after_prefilter']}\n" + f" Descriptions fetched: {stats['descriptions_fetched']}\n" + f" Matches (70%+): {stats['matches']}\n" + f" Alerts sent: {stats['alerts_sent']}\n" + f" Top score: {stats.get('top_score', 'N/A')}%" + ) + try: + requests.post(url, json={ + "chat_id": chat_id, "text": message, "parse_mode": "Markdown", + }, timeout=10) + except Exception as e: + logger.error(f"Summary send error: {e}") + + def process_jobs(self, jobs, profile, config): + conn = None + cursor = None + min_score = config.get("min_match_score", 70) + weights = config.get("weights", None) + aspirational = config.get("aspirational_skills", []) + + stats = { + "total_scanned": len(jobs), "after_prefilter": len(jobs), + "descriptions_fetched": sum(1 for j in jobs if j.get("description")), + "matches": 0, "alerts_sent": 0, "top_score": 0, + } + + try: + conn = mysql.connector.connect(**self.db_config) + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT * FROM users") + users = cursor.fetchall() + + cursor.execute("SELECT user_id, job_id FROM alert_logs") + alerted = {(r["user_id"], r["job_id"]) for r in cursor.fetchall()} + + job_sql = """ + INSERT IGNORE INTO jobs + (title, company, location, posted_date_raw, job_link, + is_actively_hiring, description, match_score, match_breakdown, + matched_skills, missing_skills, verdict, detail_fetched_at, scored_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + + for job in jobs: + if not self.is_job_fresh(job["posted_date"]): + continue + + job_text = job.get("description") or "" + full_text = f"{job['title']}\n{job_text}" + + match_result = score_job( + profile=profile, job_title=job["title"], + job_text=full_text, weights=weights, + aspirational_skills=aspirational, + ) + + score = match_result["score"] + stats["top_score"] = max(stats["top_score"], score) + if score >= min_score: + stats["matches"] += 1 + + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + cursor.execute(job_sql, ( + job["title"], job["company"], job["location"], + job["posted_date"], job["link"], job["actively_hiring"], + job.get("description"), score, + json.dumps(match_result["breakdown"], default=str), + json.dumps(match_result["matched_skills"]), + json.dumps(match_result["missing_skills"]), + match_result["verdict"], + now if job.get("description") else None, now, + )) + + if cursor.rowcount == 1: + job_id = cursor.lastrowid + else: + cursor.execute( + "SELECT job_id FROM jobs WHERE job_link = %s", (job["link"],) + ) + result = cursor.fetchone() + if not result: + continue + job_id = result["job_id"] + cursor.execute(""" + UPDATE jobs SET match_score=%s, match_breakdown=%s, + matched_skills=%s, missing_skills=%s, verdict=%s, + description=%s, scored_at=%s WHERE job_id=%s + """, ( + score, + json.dumps(match_result["breakdown"], default=str), + json.dumps(match_result["matched_skills"]), + json.dumps(match_result["missing_skills"]), + match_result["verdict"], + job.get("description"), now, job_id, + )) + + if score < min_score: + continue + + for user in users: + user_min = user.get("min_match_score", min_score) + if score < user_min: + continue + key = (user["user_id"], job_id) + if key in alerted: + continue + if self.send_telegram_alert( + user["telegram_token"], user["telegram_chat_id"], + job, match_result, + ): + cursor.execute( + "INSERT INTO alert_logs (user_id, job_id) VALUES (%s, %s)", + (user["user_id"], job_id) + ) + alerted.add(key) + stats["alerts_sent"] += 1 + time.sleep(1) + + logger.info( + f" ✅ {score}% | {job['title']} @ {job['company']} " + f"[{', '.join(match_result['matched_skills'][:5])}]" + ) + + conn.commit() + + for user in users: + self.send_summary_alert( + user["telegram_token"], user["telegram_chat_id"], stats, + ) + + except Error as e: + logger.error(f"Database error: {e}") + finally: + if cursor: + cursor.close() + if conn and conn.is_connected(): + conn.close() + + return stats + + +def run_once(config, profile): + scraper_cfg = config.get("scraper", {}) + db_config = config.get("database", {}) + scraper = LinkedInJobScraper(config) + alert_system = JobAlertSystem(db_config) + + keywords = scraper_cfg.get("keywords", ["Network Engineer"]) + location = scraper_cfg.get("location", "Toronto, ON") + pages = scraper_cfg.get("pages_per_keyword", 3) + keyword_delay = scraper_cfg.get("delay_between_keywords", [5, 10]) + max_details = config.get("max_detail_fetches_per_run", 50) + + all_jobs = [] + for keyword in keywords: + logger.info(f"🔍 Scraping: {keyword}") + jobs = scraper.scrape_jobs(keyword=keyword, location=location, pages=pages) + all_jobs.extend(jobs) + time.sleep(random.uniform(*keyword_delay)) + + unique = {} + for job in all_jobs: + unique[job["link"]] = job + jobs = list(unique.values()) + logger.info(f"📋 Total unique jobs: {len(jobs)}") + + jobs = pre_filter_jobs(jobs, config) + + logger.info("📄 Fetching job descriptions...") + jobs = scraper.fetch_descriptions_batch(jobs, max_fetches=max_details) + + desc_count = sum(1 for j in jobs if j.get("description")) + logger.info(f"📄 Jobs with descriptions: {desc_count}/{len(jobs)}") + + logger.info("🎯 Scoring jobs against your profile...") + stats = alert_system.process_jobs(jobs, profile, config) + + logger.info("=" * 50) + logger.info(f"📊 RUN COMPLETE") + logger.info(f" Scanned: {stats['total_scanned']}") + logger.info(f" Matches (70%+): {stats['matches']}") + logger.info(f" Alerts sent: {stats['alerts_sent']}") + logger.info(f" Top score: {stats['top_score']}%") + logger.info("=" * 50) + return stats + + +def main(): + parser = argparse.ArgumentParser(description="Job Matcher Daemon") + parser.add_argument("--config", default="config.yaml", help="Config file path") + parser.add_argument("--profile", default="profile.json", help="Resume profile path") + parser.add_argument("--resume", default=None, help="Resume file (txt/pdf) to parse") + parser.add_argument("--loop", action="store_true", help="Run continuously") + parser.add_argument("--interval", type=int, default=7200, help="Loop interval in seconds (default: 2hrs)") + args = parser.parse_args() + + config = load_config(args.config) + profile = load_profile(args.profile, args.resume) + + logger.info(f"Profile loaded: {len(profile.get('skills', {}))} skills, " + f"{profile.get('years_experience', '?')} years exp, " + f"certs: {profile.get('certifications', [])}") + + if args.loop: + logger.info(f"🔁 Daemon mode: running every {args.interval}s") + while True: + try: + run_once(config, profile) + except Exception as e: + logger.error(f"Run failed: {e}") + logger.info(f"💤 Sleeping {args.interval}s until next run...") + time.sleep(args.interval) + else: + run_once(config, profile) + + +if __name__ == "__main__": + main() diff --git a/job-matcher.service b/job-matcher.service new file mode 100644 index 0000000..148f154 --- /dev/null +++ b/job-matcher.service @@ -0,0 +1,18 @@ +[Unit] +Description=Job Matcher Daemon - LinkedIn Job Scanner with Resume Matching +After=network.target mysql.service + +[Service] +Type=simple +User=root +WorkingDirectory=/root/job-matcher +ExecStart=/root/job-matcher/venv/bin/python daemon.py --loop --interval 7200 +Restart=on-failure +RestartSec=60 +StandardOutput=journal +StandardError=journal +MemoryMax=512M +CPUQuota=25% + +[Install] +WantedBy=multi-user.target diff --git a/matcher.py b/matcher.py new file mode 100644 index 0000000..301e6d9 --- /dev/null +++ b/matcher.py @@ -0,0 +1,257 @@ +""" +matcher.py +---------- +Scores a job posting against a parsed resume profile. +Returns a 0-100 score with detailed breakdown. +""" + +import re +import math +import logging +from collections import Counter + +logger = logging.getLogger(__name__) + +from resume_parser import SKILL_TAXONOMY, TITLE_VARIANTS, CERT_PATTERNS + + +def _tokenize(text): + return re.findall(r'[a-z0-9/\.\+\#]+', text.lower()) + + +def _cosine_similarity(vec_a, vec_b): + common = set(vec_a.keys()) & set(vec_b.keys()) + if not common: + return 0.0 + dot = sum(vec_a[k] * vec_b[k] for k in common) + mag_a = math.sqrt(sum(v ** 2 for v in vec_a.values())) + mag_b = math.sqrt(sum(v ** 2 for v in vec_b.values())) + if mag_a == 0 or mag_b == 0: + return 0.0 + return dot / (mag_a * mag_b) + + +def score_skills(profile, job_text, aspirational_skills=None): + job_lower = job_text.lower() + aspirational_skills = [s.lower() for s in (aspirational_skills or [])] + + job_skills = {} + for skill_name, patterns in SKILL_TAXONOMY.items(): + for pattern in patterns: + if pattern.lower() in job_lower: + count = job_lower.count(pattern.lower()) + job_skills[skill_name] = min(count, 5) + break + + if not job_skills: + return {"score": 50, "matched": [], "missing": [], "aspirational_hits": []} + + profile_skills = profile.get("skills", {}) + matched = [] + missing = [] + aspirational_hits = [] + + for skill in job_skills: + if skill in profile_skills: + matched.append(skill) + elif skill.lower() in aspirational_skills: + aspirational_hits.append(skill) + matched.append(skill) + else: + missing.append(skill) + + total = len(job_skills) + effective_matches = len([s for s in matched if s not in aspirational_hits]) + effective_matches += len(aspirational_hits) * 0.5 + score = (effective_matches / total) * 100 if total > 0 else 50 + + return { + "score": min(round(score), 100), + "matched": matched, + "missing": missing, + "aspirational_hits": aspirational_hits, + "job_requires": list(job_skills.keys()), + } + + +def score_title(profile, job_title): + job_title_lower = job_title.lower().strip() + profile_titles = [t.lower() for t in profile.get("titles", [])] + + for pt in profile_titles: + if pt in job_title_lower or job_title_lower in pt: + return {"score": 100, "reason": f"Direct match: {pt}"} + + job_normalized = set() + for norm, variants in TITLE_VARIANTS.items(): + if any(v in job_title_lower for v in variants): + job_normalized.add(norm) + + profile_normalized = set() + for pt in profile_titles: + for norm, variants in TITLE_VARIANTS.items(): + if any(v in pt for v in variants): + profile_normalized.add(norm) + + if job_normalized & profile_normalized: + return {"score": 95, "reason": f"Normalized match: {job_normalized & profile_normalized}"} + + related_groups = [ + {"network engineer", "network administrator", "infrastructure engineer", "system administrator"}, + {"cloud engineer", "devops engineer", "infrastructure engineer"}, + {"security engineer", "network engineer"}, + ] + + for group in related_groups: + if (job_normalized & group) and (profile_normalized & group): + return {"score": 70, "reason": "Related role family"} + + job_words = set(_tokenize(job_title_lower)) + profile_words = set() + for pt in profile_titles: + profile_words.update(_tokenize(pt)) + + overlap = job_words & profile_words + if overlap: + ratio = len(overlap) / max(len(job_words), 1) + return {"score": round(ratio * 60), "reason": f"Keyword overlap: {overlap}"} + + return {"score": 20, "reason": "No meaningful match"} + + +def score_experience(profile, job_text): + profile_years = profile.get("years_experience", 0) + patterns = [ + r'(\d+)\+?\s*years?\s*(?:of\s+)?(?:experience|exp)', + r'(\d+)\+?\s*years?\s*(?:in|with|of)', + r'minimum\s+(\d+)\s*years?', + r'at\s+least\s+(\d+)\s*years?', + ] + + required_years = [] + for pattern in patterns: + matches = re.findall(pattern, job_text.lower()) + required_years.extend(int(m) for m in matches) + + if not required_years: + return {"score": 70, "reason": "No experience requirement found", "required": None} + + max_required = max(required_years) + + if profile_years >= max_required: + score = 100 + reason = f"Exceeds requirement ({profile_years} >= {max_required})" + elif profile_years >= max_required - 1: + score = 85 + reason = f"Close match ({profile_years} vs {max_required} required)" + elif profile_years >= max_required * 0.7: + score = 60 + reason = f"Slightly under ({profile_years} vs {max_required} required)" + else: + score = max(20, 100 - (max_required - profile_years) * 15) + reason = f"Under-qualified ({profile_years} vs {max_required} required)" + + return {"score": round(score), "reason": reason, "required": max_required} + + +def score_keywords(profile, job_text): + resume_text = profile.get("full_text", "") + if not resume_text or not job_text: + return {"score": 50, "similarity": 0} + + resume_tokens = _tokenize(resume_text) + job_tokens = _tokenize(job_text) + + stopwords = { + "the", "a", "an", "and", "or", "but", "in", "on", "at", "to", + "for", "of", "with", "by", "is", "are", "was", "were", "be", + "been", "being", "have", "has", "had", "do", "does", "did", + "will", "would", "could", "should", "may", "might", "shall", + "can", "this", "that", "these", "those", "it", "its", "we", + "you", "they", "them", "their", "our", "your", "from", "as", + "not", "all", "also", "more", "other", "such", "than", "into", + "about", "over", "between", "through", "during", "before", + "after", "above", "below", "up", "down", "out", "off", "then", + "once", "here", "there", "when", "where", "why", "how", "any", + "both", "each", "few", "most", "some", "no", "nor", "only", + "own", "same", "so", "very", "just", "if", "while", "work", + "working", "experience", "including", "ability", "strong", + "knowledge", "skills", "required", "preferred", "minimum", + "must", "etc", "ensure", "across", "using", "within", + } + + resume_tf = Counter(t for t in resume_tokens if t not in stopwords and len(t) > 1) + job_tf = Counter(t for t in job_tokens if t not in stopwords and len(t) > 1) + similarity = _cosine_similarity(resume_tf, job_tf) + score = min(round(similarity * 300), 100) + return {"score": score, "similarity": round(similarity, 3)} + + +def score_certifications(profile, job_text): + job_lower = job_text.lower() + profile_certs = [c.lower() for c in profile.get("certifications", [])] + + if not profile_certs: + return {"score": 50, "matched": [], "required": []} + + job_certs = [] + for pattern, cert_name in CERT_PATTERNS: + if re.search(pattern, job_lower): + job_certs.append(cert_name) + + if not job_certs: + return {"score": 75, "matched": [], "required": []} + + matched = [c for c in job_certs if c.lower() in profile_certs] + score = (len(matched) / len(job_certs)) * 100 if job_certs else 75 + + return {"score": round(score), "matched": matched, "required": job_certs} + + +def score_job(profile, job_title, job_text, weights=None, aspirational_skills=None): + if weights is None: + weights = { + "skills": 0.40, + "title": 0.25, + "experience": 0.15, + "keywords": 0.15, + "certifications": 0.05, + } + + skill_result = score_skills(profile, job_text, aspirational_skills) + title_result = score_title(profile, job_title) + exp_result = score_experience(profile, job_text) + kw_result = score_keywords(profile, job_text) + cert_result = score_certifications(profile, job_text) + + final_score = ( + skill_result["score"] * weights["skills"] + + title_result["score"] * weights["title"] + + exp_result["score"] * weights["experience"] + + kw_result["score"] * weights["keywords"] + + cert_result["score"] * weights["certifications"] + ) + final_score = round(final_score) + + if final_score >= 80: + verdict = "Strong Match" + elif final_score >= 70: + verdict = "Good Match" + elif final_score >= 55: + verdict = "Weak Match" + else: + verdict = "Poor Match" + + return { + "score": final_score, + "verdict": verdict, + "breakdown": { + "skills": skill_result, + "title": title_result, + "experience": exp_result, + "keywords": kw_result, + "certifications": cert_result, + }, + "matched_skills": skill_result.get("matched", []), + "missing_skills": skill_result.get("missing", []), + } diff --git a/profile.json b/profile.json new file mode 100644 index 0000000..f94bdb5 --- /dev/null +++ b/profile.json @@ -0,0 +1,77 @@ +{ + "skills": { + "bgp": 4, + "ospf": 3, + "eigrp": 1, + "mpls": 1, + "vxlan": 1, + "vrrp": 2, + "vlans": 1, + "stp": 1, + "tcp/ip": 1, + "dns": 2, + "dhcp": 1, + "qos": 1, + "ipv6": 1, + "nat": 2, + "acls": 1, + "load balancing": 1, + "cisco": 5, + "arista": 3, + "juniper": 2, + "mikrotik": 1, + "fortinet": 3, + "pfsense": 1, + "barracuda": 1, + "vyos": 2, + "firewall": 4, + "vpn": 2, + "802.1x": 1, + "2fa": 1, + "aws": 2, + "ibm cloud": 1, + "vmware": 2, + "proxmox": 1, + "lxc": 2, + "docker": 1, + "hybrid cloud": 1, + "python": 2, + "bash": 1, + "ansible": 3, + "git": 1, + "iac": 1, + "zabbix": 1, + "librenms": 1, + "nagios": 1, + "snmp": 1, + "syslog": 1, + "active directory": 1, + "linux": 2, + "voip": 1, + "sql": 1, + "certificate management": 1 + }, + "titles": [ + "system administrator", + "network engineer" + ], + "raw_titles": [ + "Network Engineer", + "Network and System Administrator", + "Network Engineer & System Administrator" + ], + "years_experience": 7, + "certifications": [ + "AWS Cloud Practitioner", + "AWS Certified", + "CCNA", + "CCNP" + ], + "education": [ + { + "level": "Diploma", + "field": "Computer System Networking" + } + ], + "full_text": "Network Infrastructure Engineer with 7+ years of experience designing, deploying, and securing LAN, WAN, and hybrid cloud environments for financial and enterprise platforms. CCNP-certified specialist in BGP/OSPF routing, firewall management (Fortinet, VyOS, Juniper SRX), network automation (Ansible, Python), and high-availability infrastructure across Cisco, Arista, and cloud platforms. Routing & Switching: BGP Peering Route Maps OSPF NX vPCs EIGRP MPLS VXLAN VRRP VLANs STP TCP/IP DNS DHCP QoS IPv6. Platforms & Hardware: Arista Low-Latency Switches Cisco Catalyst Cisco Nexus MikroTik APs Terminal Servers. Firewalls & Security: Fortinet NGFW Barracuda NGFW Pfsense 802.1X NAC IPsec VPN NAT ACLs Network Segmentation 2FA Management. Cloud & Virtualization: AWS VPCs Networking IBM Cloud VMware ESXi Proxmox LXC Hybrid Cloud Deployments. Automation & Scripting: Python Bash Ansible Infrastructure-as-Code IaC Git Event-Driven Automation. Monitoring & Observability: Zabbix LibreNMS Nagios SNMP Syslog SLO/SLI Principles. Infrastructure Services: DNS Management Load Balancing Certificate Management VOIP Windows Active Directory Linux/UNIX Out-of-Band Management SQL servers high availability. CCNP CCNA AWS Certified Cloud Practitioner. Infrastructure Engineer TickTrade Systems Inc Toronto. Managed BGP client onboardings route map configuration and OSPF inter-site connectivity. Administered VyOS and Juniper SRX firewalls. Deployed and managed bare-metal Linux servers and LXC containers. Automated application deployments and network provisioning using Ansible. Network Engineer System Administrator Tradelogiq Markets Toronto. Configured and managed low-latency Arista switches for financial trading infrastructure. Executed BGP peering onboardings and maintained route maps. Administered Fortinet NGFW firewalls implementing security policies NAT rules and VPN configurations. Network Engineer Nextologies Markham Ontario. Engineered high-availability network architectures implementing VRRP gateway redundancy. Designed and deployed out-of-band management networks from scratch with terminal servers. Network and System Administrator Logistics Alliance Brampton Ontario. Managed network infrastructure across multiple company sites. Administered VMware ESXi hypervisors hosting critical business applications. Diploma in Computer System Networking Georgian College Barrie Ontario." +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..32f9f55 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.31.0 +beautifulsoup4>=4.12.0 +pyyaml>=6.0 +mysql-connector-python>=8.0 +pdfplumber>=0.10.0 diff --git a/resume_parser.py b/resume_parser.py new file mode 100644 index 0000000..3364979 --- /dev/null +++ b/resume_parser.py @@ -0,0 +1,245 @@ +""" +resume_parser.py +---------------- +Parses a resume (text or PDF) into a structured JSON profile +used by the matching engine. Run once, outputs profile.json. +""" + +import json +import re +import sys +import os +import logging + +logger = logging.getLogger(__name__) + + +# ---- Known skill taxonomy ---- +SKILL_TAXONOMY = { + "bgp": ["bgp", "border gateway protocol", "bgp peering", "route maps"], + "ospf": ["ospf", "open shortest path first"], + "eigrp": ["eigrp"], + "mpls": ["mpls"], + "vxlan": ["vxlan"], + "vrrp": ["vrrp"], + "vlans": ["vlan", "vlans"], + "stp": ["stp", "spanning tree"], + "tcp/ip": ["tcp/ip", "tcp", "ip", "tcpip"], + "dns": ["dns", "domain name"], + "dhcp": ["dhcp"], + "qos": ["qos", "quality of service"], + "ipv6": ["ipv6"], + "multicast": ["multicast", "pim", "igmp"], + "nat": ["nat", "network address translation"], + "acls": ["acl", "acls", "access control list"], + "load balancing": ["load balanc"], + "cisco": ["cisco", "catalyst", "nexus"], + "arista": ["arista"], + "juniper": ["juniper", "junos", "srx"], + "mikrotik": ["mikrotik"], + "brocade": ["brocade"], + "fortinet": ["fortinet", "fortigate"], + "pfsense": ["pfsense"], + "barracuda": ["barracuda"], + "vyos": ["vyos"], + "firewall": ["firewall", "ngfw"], + "vpn": ["vpn", "ipsec", "site-to-site"], + "802.1x": ["802.1x", "nac", "network access control"], + "2fa": ["2fa", "mfa", "two-factor", "multi-factor"], + "network segmentation": ["network segmentation", "microsegmentation"], + "aws": ["aws", "amazon web services", "vpc"], + "azure": ["azure"], + "gcp": ["gcp", "google cloud"], + "ibm cloud": ["ibm cloud"], + "vmware": ["vmware", "esxi", "vsphere"], + "proxmox": ["proxmox"], + "lxc": ["lxc", "linux container"], + "docker": ["docker", "container"], + "kubernetes": ["kubernetes", "k8s"], + "terraform": ["terraform"], + "hybrid cloud": ["hybrid cloud"], + "python": ["python"], + "bash": ["bash", "shell script"], + "ansible": ["ansible"], + "git": ["git", "github", "gitlab"], + "iac": ["infrastructure as code", "infrastructure-as-code", "iac"], + "ci/cd": ["ci/cd", "cicd", "jenkins", "pipeline"], + "zabbix": ["zabbix"], + "librenms": ["librenms"], + "nagios": ["nagios"], + "snmp": ["snmp"], + "syslog": ["syslog"], + "datadog": ["datadog"], + "grafana": ["grafana"], + "prometheus": ["prometheus"], + "splunk": ["splunk"], + "solarwinds": ["solarwinds"], + "active directory": ["active directory", "ad"], + "linux": ["linux", "unix", "rhel", "ubuntu", "centos", "debian"], + "windows server": ["windows server", "windows ad"], + "voip": ["voip", "sip"], + "sql": ["sql", "mysql", "postgres", "mssql"], + "certificate management": ["certificate", "ssl", "tls", "pki"], +} + +CERT_PATTERNS = [ + (r"ccnp", "CCNP"), + (r"ccna", "CCNA"), + (r"ccie", "CCIE"), + (r"aws\s+certified\s+cloud\s+practitioner", "AWS Cloud Practitioner"), + (r"aws\s+certified\s+solutions?\s+architect", "AWS Solutions Architect"), + (r"aws\s+certified", "AWS Certified"), + (r"comptia\s+security\+", "CompTIA Security+"), + (r"comptia\s+network\+", "CompTIA Network+"), + (r"jncia", "JNCIA"), + (r"jncis", "JNCIS"), + (r"fortinet\s+nse", "Fortinet NSE"), + (r"palo\s+alto\s+pcnse", "PCNSE"), +] + +TITLE_VARIANTS = { + "network engineer": ["network engineer", "network eng", "noc engineer"], + "infrastructure engineer": ["infrastructure engineer", "infra engineer"], + "network administrator": ["network administrator", "network admin"], + "system administrator": ["system administrator", "sysadmin", "systems administrator"], + "cloud engineer": ["cloud engineer", "cloud network engineer"], + "devops engineer": ["devops", "site reliability", "sre"], + "security engineer": ["security engineer", "network security"], +} + + +def extract_skills(text): + text_lower = text.lower() + found = {} + for skill_name, patterns in SKILL_TAXONOMY.items(): + for pattern in patterns: + if pattern.lower() in text_lower: + count = text_lower.count(pattern.lower()) + found[skill_name] = min(count, 5) + break + return found + + +def extract_titles(text): + titles = [] + title_patterns = [ + r"(?:^|\n)\s*([\w\s&/]+(?:Engineer|Administrator|Analyst|Manager|Architect|Specialist|Technician|Director))\s*(?:\(|–|-|\d|$)", + ] + for pattern in title_patterns: + matches = re.findall(pattern, text, re.IGNORECASE | re.MULTILINE) + for m in matches: + clean = m.strip() + if 5 < len(clean) < 60: + titles.append(clean) + return list(set(titles)) + + +def extract_experience_years(text): + explicit = re.search(r"(\d+)\+?\s*years?\s*(?:of\s+)?experience", text, re.IGNORECASE) + if explicit: + return int(explicit.group(1)) + + date_ranges = re.findall(r"(\d{2}/\d{4})\s*[–-]\s*(\d{2}/\d{4}|present|current)", text, re.IGNORECASE) + if date_ranges: + from datetime import datetime + total_months = 0 + for start_str, end_str in date_ranges: + try: + start = datetime.strptime(start_str, "%m/%Y") + if end_str.lower() in ("present", "current"): + end = datetime.now() + else: + end = datetime.strptime(end_str, "%m/%Y") + months = (end.year - start.year) * 12 + (end.month - start.month) + total_months += max(months, 0) + except ValueError: + continue + if total_months > 0: + return total_months // 12 + return 0 + + +def extract_certifications(text): + text_lower = text.lower() + found = [] + for pattern, cert_name in CERT_PATTERNS: + if re.search(pattern, text_lower): + found.append(cert_name) + return list(set(found)) + + +def extract_education(text): + education = [] + edu_patterns = [ + r"(bachelor|master|diploma|degree|b\.?s\.?|m\.?s\.?|ph\.?d|associate)[\w\s,]*(?:in\s+)?([\w\s&]+?)(?:\d{4}|\n|$)", + ] + for pattern in edu_patterns: + matches = re.findall(pattern, text, re.IGNORECASE) + for level, field in matches: + education.append({"level": level.strip(), "field": field.strip()[:50]}) + return education + + +def parse_resume_text(text): + skills = extract_skills(text) + titles = extract_titles(text) + years = extract_experience_years(text) + certs = extract_certifications(text) + education = extract_education(text) + + normalized_titles = set() + for title in titles: + title_lower = title.lower() + for norm, variants in TITLE_VARIANTS.items(): + if any(v in title_lower for v in variants): + normalized_titles.add(norm) + break + else: + normalized_titles.add(title_lower) + + return { + "skills": skills, + "titles": list(normalized_titles), + "raw_titles": titles, + "years_experience": years, + "certifications": certs, + "education": education, + "full_text": text, + } + + +def parse_resume_file(filepath): + ext = os.path.splitext(filepath)[1].lower() + if ext == ".txt": + with open(filepath, "r", encoding="utf-8") as f: + text = f.read() + elif ext == ".pdf": + try: + import pdfplumber + with pdfplumber.open(filepath) as pdf: + text = "\n".join(page.extract_text() or "" for page in pdf.pages) + except ImportError: + logger.error("Install pdfplumber: pip install pdfplumber") + sys.exit(1) + else: + raise ValueError(f"Unsupported file type: {ext}") + return parse_resume_text(text) + + +def save_profile(profile, output_path="profile.json"): + save_data = {k: v for k, v in profile.items() if k != "full_text"} + with open(output_path, "w") as f: + json.dump(save_data, f, indent=2) + logger.info(f"Profile saved to {output_path}") + return save_data + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + if len(sys.argv) < 2: + print("Usage: python resume_parser.py ") + sys.exit(1) + filepath = sys.argv[1] + profile = parse_resume_file(filepath) + saved = save_profile(profile) + print(json.dumps(saved, indent=2)) diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..c7af977 --- /dev/null +++ b/schema.sql @@ -0,0 +1,41 @@ +CREATE DATABASE IF NOT EXISTS job_matcher_db; +USE job_matcher_db; + +CREATE TABLE IF NOT EXISTS jobs ( + job_id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + company VARCHAR(255), + location VARCHAR(255), + posted_date_raw VARCHAR(100), + job_link VARCHAR(512) UNIQUE NOT NULL, + is_actively_hiring BOOLEAN DEFAULT FALSE, + description TEXT DEFAULT NULL, + match_score INT DEFAULT NULL, + match_breakdown JSON DEFAULT NULL, + matched_skills JSON DEFAULT NULL, + missing_skills JSON DEFAULT NULL, + verdict VARCHAR(50) DEFAULT NULL, + detail_fetched_at DATETIME DEFAULT NULL, + scored_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_match_score (match_score), + INDEX idx_verdict (verdict) +); + +CREATE TABLE IF NOT EXISTS users ( + user_id INT AUTO_INCREMENT PRIMARY KEY, + telegram_token VARCHAR(255) NOT NULL, + telegram_chat_id VARCHAR(50) NOT NULL, + min_match_score INT DEFAULT 70, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS alert_logs ( + alert_id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + job_id INT NOT NULL, + alerted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_user_job (user_id, job_id), + FOREIGN KEY (user_id) REFERENCES users(user_id), + FOREIGN KEY (job_id) REFERENCES jobs(job_id) +);