Spaces:
Running
Running
| #!/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() | |