chore: untrack and ignore venv directory

This commit is contained in:
root
2026-05-14 09:46:14 -04:00
commit f66548f388
9 changed files with 1231 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
venv/
+66
View File
@@ -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"
+521
View File
@@ -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()
+18
View File
@@ -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
+257
View File
@@ -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", []),
}
+77
View File
@@ -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."
}
+5
View File
@@ -0,0 +1,5 @@
requests>=2.31.0
beautifulsoup4>=4.12.0
pyyaml>=6.0
mysql-connector-python>=8.0
pdfplumber>=0.10.0
+245
View File
@@ -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 <resume.txt|resume.pdf>")
sys.exit(1)
filepath = sys.argv[1]
profile = parse_resume_file(filepath)
saved = save_profile(profile)
print(json.dumps(saved, indent=2))
+41
View File
@@ -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)
);