#!/usr/bin/env python3 """ Job Automation Agent -- PM Edition Searches Product Manager jobs across LinkedIn, Indeed, Glassdoor. Rates each job using a 10-model parallel AI pool. Generates ATS 95%+ optimized DOCX resumes for all PM jobs. """ import io import os import sys import time import json from datetime import datetime from colorama import Fore, Style, init from tqdm import tqdm # Force UTF-8 output on Windows if hasattr(sys.stdout, "buffer"): sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") if hasattr(sys.stderr, "buffer"): sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") init(autoreset=True) BANNER = ( f"\n{Fore.CYAN}" "==========================================================\n" " JOB AUTOMATION AGENT -- PM Edition\n" " 10-Model AI Pool | LinkedIn / Indeed / Glassdoor\n" " ATS Score: 95%+ guaranteed on all resumes\n" f"=========================================================={Style.RESET_ALL}\n" ) def main(): print(BANNER) from config import JOB_SEARCH, PLATFORMS, ASSESSMENT, OUTPUT, RESUME, ASSESSMENT_MODELS, GOOGLE from src.resume_parser import ResumeParser from src.llm_client import LLMClient from src.model_pool import ModelPool from src.job_assessor import JobAssessor from src.resume_customizer import ResumeCustomizer from src.excel_reporter import ExcelReporter from src.scrapers.base import Job # ── 1. PARSE RESUME ── print(f"\n{Fore.YELLOW}[Step 1/5] Parsing your resume...{Style.RESET_ALL}") parser = ResumeParser(RESUME["pdf_path"]) try: resume_text = parser.parse() print(f"{Fore.GREEN}✓ Resume parsed ({len(resume_text)} chars){Style.RESET_ALL}") except FileNotFoundError as e: print(f"{Fore.RED}✗ {e}{Style.RESET_ALL}") sys.exit(1) except Exception as e: print(f"{Fore.RED}✗ Resume parsing failed: {e}{Style.RESET_ALL}") sys.exit(1) # ── 2. BUILD PROFILE — use fastest available model ── # Try Kimi-K2.6 first (~5s), fall back to GLM 5.1 (~234s) fast_profile_cfg = next( (m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key") and m["name"] in ("Kimi-K2.6", "Step-3.7-Flash", "Qwen3.5-397b")), None ) model_name = fast_profile_cfg["name"] if fast_profile_cfg else "GLM 5.1" print(f"\n{Fore.YELLOW}[Step 2/5] Building profile with {model_name}...{Style.RESET_ALL}") llm = LLMClient() try: if fast_profile_cfg: user_profile_json = llm.extract_profile_summary_fast(fast_profile_cfg, resume_text) else: user_profile_json = llm.extract_profile_summary(resume_text) compact_profile = llm.build_compact_profile(user_profile_json) print(f"{Fore.GREEN}✓ Profile extracted{Style.RESET_ALL}") print(f"\n{Fore.CYAN}--- Your Profile Summary ---{Style.RESET_ALL}") try: pd = json.loads(user_profile_json) print(f" Name: {pd.get('name', 'N/A')}") print(f" Role: {pd.get('current_role', 'N/A')}") print(f" Experience: {pd.get('total_experience_years', 'N/A')} years") print(f" Skills: {', '.join(pd.get('core_skills', [])[:8])}") except Exception: print(compact_profile) except Exception as e: print(f"{Fore.YELLOW}⚠ LLM profile extraction failed, using raw resume.{Style.RESET_ALL}") user_profile_json = resume_text[:1500] compact_profile = resume_text[:300] # ── 3. SCRAPE JOBS ── print(f"\n{Fore.YELLOW}[Step 3/5] Searching jobs across platforms...{Style.RESET_ALL}") # Load dedup store from src.job_history import is_duplicate, bulk_mark_seen, get_stats as dedup_stats, clear_old_entries dedup_days = ASSESSMENT.get("dedup_days", 30) clear_old_entries(days=90) # housekeep old entries dstats = dedup_stats() print(f" Dedup store: {dstats['total_seen']} jobs seen historically " f"({dstats['seen_last_7_days']} in last 7 days) " f"— skipping duplicates from last {dedup_days} days") all_jobs: list[Job] = [] seen_urls: set = set() skipped_dup = 0 scraper_map = {} if PLATFORMS.get("linkedin"): from src.scrapers.linkedin import LinkedInScraper scraper_map["LinkedIn"] = LinkedInScraper() if PLATFORMS.get("naukri"): from src.scrapers.naukri import NaukriScraper scraper_map["Naukri"] = NaukriScraper() if PLATFORMS.get("indeed"): from src.scrapers.indeed import IndeedScraper scraper_map["Indeed"] = IndeedScraper() if PLATFORMS.get("glassdoor"): from src.scrapers.glassdoor import GlassdoorScraper scraper_map["Glassdoor"] = GlassdoorScraper() roles_to_search = JOB_SEARCH["roles"][:3] locations_to_search = [JOB_SEARCH["locations"][0], "Bangalore", "Remote"] for platform_name, scraper in scraper_map.items(): print(f"\n {Fore.CYAN}Scraping {platform_name}...{Style.RESET_ALL}") platform_jobs = [] for role in roles_to_search: for location in locations_to_search: try: jobs = scraper.search(role, location, max_results=10) for job in jobs: if not job.url or job.url in seen_urls: continue # PM-only filter at scrape time if not scraper.is_pm_role(job.title): continue # Dedup check if dedup_days > 0 and is_duplicate(job.url, days=dedup_days): skipped_dup += 1 continue seen_urls.add(job.url) platform_jobs.append(job) except Exception as e: print(f" {Fore.RED}Error ({platform_name}, {role}, {location}): {e}{Style.RESET_ALL}") time.sleep(1) # Fetch descriptions for jobs that don't have them needs_desc = [j for j in platform_jobs if not j.description][:15] if needs_desc: with tqdm(total=len(needs_desc), desc=f" Fetching {platform_name} descriptions", leave=False) as pbar: for job in needs_desc: try: scraper.get_job_details(job) except Exception: pass pbar.update(1) print(f" {Fore.GREEN}✓ {platform_name}: {len(platform_jobs)} new jobs{Style.RESET_ALL}") all_jobs.extend(platform_jobs) if skipped_dup > 0: print(f" {Fore.YELLOW}Skipped {skipped_dup} duplicate jobs (seen in last {dedup_days} days){Style.RESET_ALL}") # Test mode — cap at N jobs for quick validation if ASSESSMENT.get("test_mode") and all_jobs: limit = ASSESSMENT.get("test_jobs_limit", 5) all_jobs = all_jobs[:limit] print(f"\n{Fore.YELLOW}TEST MODE: capped to {len(all_jobs)} jobs{Style.RESET_ALL}") print(f"\n{Fore.GREEN}Total unique jobs collected: {len(all_jobs)}{Style.RESET_ALL}") if not all_jobs: print(f"{Fore.RED}No jobs found. Check internet connection.{Style.RESET_ALL}") sys.exit(1) # ── 4. ASSESS JOBS — 4-model parallel pool ── print(f"\n{Fore.YELLOW}[Step 4/5] Assessing jobs with 4-model AI pool...{Style.RESET_ALL}") print(f"{Fore.CYAN}Models: {', '.join(m['name'] for m in ASSESSMENT_MODELS if m.get('api_key'))}{Style.RESET_ALL}") t_assess = time.time() model_pool = ModelPool(ASSESSMENT_MODELS) assessor = JobAssessor(model_pool, compact_profile) assessed_jobs = assessor.assess_all(all_jobs) t_elapsed = time.time() - t_assess scores = [j.get("relevance_score", 0) for j in assessed_jobs] print(f"\n{Fore.CYAN}Assessment done in {t_elapsed:.0f}s{Style.RESET_ALL}") print(f" High Priority (8-10): {sum(1 for s in scores if s >= 8)}") print(f" Good Match (6-7): {sum(1 for s in scores if 6 <= s <= 7)}") print(f" Lower Match (<6): {sum(1 for s in scores if s < 6)}") if assessed_jobs: top = assessed_jobs[0] print(f" Top Job: {top.get('title')} at {top.get('company')} — Score {top.get('relevance_score')}/10") # ── 5. GENERATE RESUMES (all jobs) + ATS SCORES ── # ── Mark all new jobs as seen (dedup for next run) ── bulk_mark_seen(all_jobs) print(f" {Fore.CYAN}Marked {len(all_jobs)} jobs in dedup store (won't repeat next run){Style.RESET_ALL}") print(f"\n{Fore.YELLOW}[Step 5/6] Generating resumes + ATS scoring...{Style.RESET_ALL}") # Pick fastest reliable model for LLM keyword extraction # (Kimi ~5s or Step-3.7-Flash ~8s — both much faster than GLM's 234s) fast_cfg = next( (m for m in ASSESSMENT_MODELS if m.get("phase2") and m.get("api_key") and m["name"] in ("Kimi-K2.6", "Step-3.7-Flash", "Qwen3.5-397b")), None ) if fast_cfg: print(f" {Fore.CYAN}LLM keyword extraction: {fast_cfg['name']}{Style.RESET_ALL}") customizer = ResumeCustomizer(llm, resume_text, OUTPUT["resumes_dir"], fast_model_cfg=fast_cfg) assessed_jobs = customizer.customize_for_jobs( assessed_jobs, min_score_for_llm=ASSESSMENT["min_score_for_llm_resume"], max_llm_resumes=ASSESSMENT["max_llm_resumes"], generate_all=ASSESSMENT["generate_all_resumes"], ) llm_resumes = sum(1 for j in assessed_jobs if j.get("resume_generated") == "LLM Tailored") tmpl_resumes = sum(1 for j in assessed_jobs if j.get("resume_generated") == "Template") print(f" LLM-tailored: {llm_resumes} | Template: {tmpl_resumes}") # ── 6. WRITE GOOGLE SHEET + EXCEL ── # Resumes are stored locally (date-based folder) — no Drive upload needed. print(f"\n{Fore.YELLOW}[Step 6/6] Writing to Google Sheet + Excel...{Style.RESET_ALL}") batch_label = datetime.now().strftime("%Y-%m-%d %H:%M") sheet_ok = False try: from src.gsheets import write_jobs_to_sheet, get_sheet_url sheet_ok = write_jobs_to_sheet( assessed_jobs, sheet_id=GOOGLE["sheet_id"], tab_name=GOOGLE["sheet_tab"], batch_label=batch_label, ) if sheet_ok: sheet_url = get_sheet_url(GOOGLE["sheet_id"]) print(f" {Fore.GREEN}✓ Google Sheet updated: {sheet_url}{Style.RESET_ALL}") else: print(f" {Fore.YELLOW}⚠ Google Sheet not updated (run connect_google.py to connect){Style.RESET_ALL}") except Exception as e: print(f" {Fore.YELLOW}⚠ Google Sheet skipped: {e}{Style.RESET_ALL}") # Always generate local Excel reporter = ExcelReporter(OUTPUT["excel_path"]) excel_path = reporter.generate(assessed_jobs) # ── DONE ── print(f"\n{Fore.GREEN}{'='*58}") print(f" JOB SEARCH COMPLETE! {datetime.now().strftime('%Y-%m-%d %H:%M')}") print(f"{'='*58}{Style.RESET_ALL}") print(f" Total Jobs: {len(assessed_jobs)}") print(f" Assessed in: {t_elapsed:.0f}s") print(f" LLM Resumes: {llm_resumes}") print(f" Template Resumes: {tmpl_resumes}") print(f" Excel Report: {excel_path}") print(f" Resumes Folder: {OUTPUT['resumes_dir']}{batch_label[:10]}/") if sheet_ok: print(f" Google Sheet: {get_sheet_url(GOOGLE['sheet_id'])}") print(f"\n{Fore.CYAN}All jobs written with direct apply links!{Style.RESET_ALL}\n") try: os.startfile(os.path.abspath(excel_path)) except Exception: pass if __name__ == "__main__": main()