divapply 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- divapply/__init__.py +3 -0
- divapply/__main__.py +5 -0
- divapply/apply/__init__.py +1 -0
- divapply/apply/chrome.py +360 -0
- divapply/apply/dashboard.py +203 -0
- divapply/apply/launcher.py +1404 -0
- divapply/apply/prompt.py +942 -0
- divapply/cli.py +765 -0
- divapply/config/coursework.seed.json +560 -0
- divapply/config/employers.yaml +31 -0
- divapply/config/searches.example.yaml +113 -0
- divapply/config/sites.yaml +107 -0
- divapply/config.py +445 -0
- divapply/database.py +635 -0
- divapply/discovery/__init__.py +0 -0
- divapply/discovery/jobspy.py +557 -0
- divapply/discovery/smartextract.py +1268 -0
- divapply/discovery/workday.py +575 -0
- divapply/enrichment/__init__.py +0 -0
- divapply/enrichment/detail.py +1003 -0
- divapply/llm.py +367 -0
- divapply/pipeline.py +551 -0
- divapply/scoring/__init__.py +1 -0
- divapply/scoring/cover_letter.py +337 -0
- divapply/scoring/pdf.py +789 -0
- divapply/scoring/scorer.py +230 -0
- divapply/scoring/tailor.py +800 -0
- divapply/scoring/ultimate.py +214 -0
- divapply/scoring/validator.py +374 -0
- divapply/social.py +1324 -0
- divapply/view.py +407 -0
- divapply/wizard/__init__.py +0 -0
- divapply/wizard/init.py +396 -0
- divapply-0.4.2.dist-info/METADATA +278 -0
- divapply-0.4.2.dist-info/RECORD +38 -0
- divapply-0.4.2.dist-info/WHEEL +4 -0
- divapply-0.4.2.dist-info/entry_points.txt +2 -0
- divapply-0.4.2.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# DivApply search configuration
|
|
2
|
+
# Copy to ~/.divapply/searches.yaml and customize for your job search.
|
|
3
|
+
# The wizard (divapply init) generates a basic version automatically.
|
|
4
|
+
|
|
5
|
+
# Search queries -- each has a query string and priority tier.
|
|
6
|
+
# Tier 1: High-priority exact matches (searched first, highest precision)
|
|
7
|
+
# Tier 2: Strong matches, broader scope
|
|
8
|
+
# Tier 3: Wide net, high volume, lower precision
|
|
9
|
+
queries:
|
|
10
|
+
- query: "software engineer"
|
|
11
|
+
tier: 1
|
|
12
|
+
- query: "backend engineer"
|
|
13
|
+
tier: 1
|
|
14
|
+
- query: "backend developer"
|
|
15
|
+
tier: 1
|
|
16
|
+
- query: "full stack developer"
|
|
17
|
+
tier: 2
|
|
18
|
+
- query: "full stack engineer"
|
|
19
|
+
tier: 2
|
|
20
|
+
- query: "python developer"
|
|
21
|
+
tier: 2
|
|
22
|
+
- query: "python engineer"
|
|
23
|
+
tier: 2
|
|
24
|
+
- query: "DevOps engineer"
|
|
25
|
+
tier: 2
|
|
26
|
+
- query: "site reliability engineer"
|
|
27
|
+
tier: 2
|
|
28
|
+
- query: "platform engineer"
|
|
29
|
+
tier: 2
|
|
30
|
+
- query: "cloud engineer"
|
|
31
|
+
tier: 2
|
|
32
|
+
- query: "data engineer"
|
|
33
|
+
tier: 2
|
|
34
|
+
- query: "infrastructure engineer"
|
|
35
|
+
tier: 2
|
|
36
|
+
- query: "software developer"
|
|
37
|
+
tier: 3
|
|
38
|
+
- query: "developer"
|
|
39
|
+
tier: 3
|
|
40
|
+
- query: "systems engineer"
|
|
41
|
+
tier: 3
|
|
42
|
+
- query: "automation engineer"
|
|
43
|
+
tier: 3
|
|
44
|
+
- query: "solutions engineer"
|
|
45
|
+
tier: 3
|
|
46
|
+
- query: "integration engineer"
|
|
47
|
+
tier: 3
|
|
48
|
+
- query: "ML engineer"
|
|
49
|
+
tier: 3
|
|
50
|
+
- query: "machine learning engineer"
|
|
51
|
+
tier: 3
|
|
52
|
+
- query: "AI engineer"
|
|
53
|
+
tier: 3
|
|
54
|
+
- query: "security engineer"
|
|
55
|
+
tier: 3
|
|
56
|
+
|
|
57
|
+
# Locations to search
|
|
58
|
+
locations:
|
|
59
|
+
- location: "San Francisco, CA"
|
|
60
|
+
remote: false
|
|
61
|
+
- location: "Remote"
|
|
62
|
+
remote: true
|
|
63
|
+
|
|
64
|
+
# Location filtering -- accept/reject patterns for job location text
|
|
65
|
+
location:
|
|
66
|
+
accept_patterns:
|
|
67
|
+
- "San Francisco"
|
|
68
|
+
- "Bay Area"
|
|
69
|
+
- "SF"
|
|
70
|
+
- "California"
|
|
71
|
+
- "CA"
|
|
72
|
+
- "Remote"
|
|
73
|
+
- "Anywhere"
|
|
74
|
+
- "United States"
|
|
75
|
+
- "US"
|
|
76
|
+
- "USA"
|
|
77
|
+
reject_patterns:
|
|
78
|
+
- "New York only"
|
|
79
|
+
- "Seattle only"
|
|
80
|
+
- "onsite only"
|
|
81
|
+
- "London, UK"
|
|
82
|
+
- "India"
|
|
83
|
+
- "Philippines"
|
|
84
|
+
|
|
85
|
+
# Country for Indeed/LinkedIn/Google Jobs searches
|
|
86
|
+
country: "USA"
|
|
87
|
+
|
|
88
|
+
# JobSpy source boards (all available options shown)
|
|
89
|
+
boards:
|
|
90
|
+
- indeed
|
|
91
|
+
- linkedin
|
|
92
|
+
- glassdoor
|
|
93
|
+
- zip_recruiter
|
|
94
|
+
- google
|
|
95
|
+
|
|
96
|
+
# Search defaults
|
|
97
|
+
defaults:
|
|
98
|
+
results_per_site: 100 # Max results per board per query
|
|
99
|
+
hours_old: 72 # Only jobs posted within this many hours
|
|
100
|
+
|
|
101
|
+
# Negative keywords -- skip jobs with these in the title (case-insensitive)
|
|
102
|
+
exclude_titles:
|
|
103
|
+
- "senior director"
|
|
104
|
+
- "VP "
|
|
105
|
+
- "vice president"
|
|
106
|
+
- "chief"
|
|
107
|
+
- "intern"
|
|
108
|
+
- "internship"
|
|
109
|
+
- "co-op"
|
|
110
|
+
- "clearance required"
|
|
111
|
+
- "TS/SCI"
|
|
112
|
+
- "principal scientist"
|
|
113
|
+
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Direct career site registry for DivApply — Sacramento government focus
|
|
2
|
+
# Dalton De Arrieta: IT support, admin, procurement, fiscal roles
|
|
3
|
+
# All remote developer boards removed — not relevant to this candidate's profile.
|
|
4
|
+
|
|
5
|
+
# ── Manual-Only ATS Domains ──────────────────────────────────────────────
|
|
6
|
+
# These ATS platforms have unsolvable CAPTCHAs. Jobs routing through them
|
|
7
|
+
# are flagged as "manual" during apply — bot skips, user applies by hand.
|
|
8
|
+
manual_ats:
|
|
9
|
+
- "ibegin.tcsapps.com" # TCS iBegin — unsolvable CAPTCHA every time
|
|
10
|
+
|
|
11
|
+
# ── Blocked sites — too problematic for automation ───────────────────────
|
|
12
|
+
blocked:
|
|
13
|
+
sites:
|
|
14
|
+
- "glassdoor"
|
|
15
|
+
- "google"
|
|
16
|
+
url_patterns:
|
|
17
|
+
- "%glassdoor%"
|
|
18
|
+
- "%google.com/about/careers%"
|
|
19
|
+
- "%google.jobs%"
|
|
20
|
+
|
|
21
|
+
# ── Blocked SSO domains — cannot authenticate through these ──────────────
|
|
22
|
+
blocked_sso:
|
|
23
|
+
- "login.microsoftonline.com"
|
|
24
|
+
- "okta.com"
|
|
25
|
+
- "auth0.com"
|
|
26
|
+
|
|
27
|
+
# ── Base URLs for URL resolution ─────────────────────────────────────────
|
|
28
|
+
base_urls:
|
|
29
|
+
"GovernmentJobs.com": "https://www.governmentjobs.com"
|
|
30
|
+
# "City of Roseville": "https://www.governmentjobs.com"
|
|
31
|
+
# "Placer County": "https://www.jobapscloud.com"
|
|
32
|
+
# "Sacramento County": "https://www.governmentjobs.com"
|
|
33
|
+
# "Nevada County": "https://www.governmentjobs.com"
|
|
34
|
+
# "City of Sacramento": "https://www.governmentjobs.com"
|
|
35
|
+
# "City of Folsom": "https://www.governmentjobs.com"
|
|
36
|
+
# "City of Elk Grove": "https://www.governmentjobs.com"
|
|
37
|
+
# "City of Rocklin": "https://www.governmentjobs.com"
|
|
38
|
+
# "City of Citrus Heights": "https://www.governmentjobs.com"
|
|
39
|
+
# "City of Rancho Cordova": "https://www.governmentjobs.com"
|
|
40
|
+
# "City of Auburn": "https://www.governmentjobs.com"
|
|
41
|
+
# "CalCareers": "https://calcareers.ca.gov"
|
|
42
|
+
"SimplyHired": "https://www.simplyhired.com"
|
|
43
|
+
|
|
44
|
+
sites:
|
|
45
|
+
# ── GovernmentJobs.com (NEOGOV) — used by nearly all CA government agencies ──
|
|
46
|
+
# Search type: expands per query × location combo from searches.yaml
|
|
47
|
+
# - name: "GovernmentJobs.com"
|
|
48
|
+
# url: "https://www.governmentjobs.com/jobs?keyword={query_encoded}&location={location_encoded}&radius=30"
|
|
49
|
+
# type: search
|
|
50
|
+
|
|
51
|
+
# ── General job search with location ─────────────────────────────────────
|
|
52
|
+
- name: "SimplyHired"
|
|
53
|
+
url: "https://www.simplyhired.com/search?q={query_encoded}&l={location_encoded}"
|
|
54
|
+
type: search
|
|
55
|
+
|
|
56
|
+
# ── Static agency pages — Sacramento region government portals ────────────
|
|
57
|
+
# These are scraped once (no query/location params) and filtered by keyword.
|
|
58
|
+
|
|
59
|
+
# - name: "City of Roseville"
|
|
60
|
+
# url: "https://www.governmentjobs.com/careers/rosevilleca"
|
|
61
|
+
# type: static
|
|
62
|
+
#
|
|
63
|
+
# - name: "Placer County"
|
|
64
|
+
# url: "https://www.jobapscloud.com/Placer/JobBoard.asp"
|
|
65
|
+
# type: static
|
|
66
|
+
#
|
|
67
|
+
# - name: "Sacramento County"
|
|
68
|
+
# url: "https://www.governmentjobs.com/careers/sacramento"
|
|
69
|
+
# type: static
|
|
70
|
+
#
|
|
71
|
+
# - name: "City of Sacramento"
|
|
72
|
+
# url: "https://www.governmentjobs.com/careers/saccity"
|
|
73
|
+
# type: static
|
|
74
|
+
#
|
|
75
|
+
# - name: "City of Folsom"
|
|
76
|
+
# url: "https://www.governmentjobs.com/careers/folsom"
|
|
77
|
+
# type: static
|
|
78
|
+
#
|
|
79
|
+
# - name: "City of Elk Grove"
|
|
80
|
+
# url: "https://www.governmentjobs.com/careers/elkgrove"
|
|
81
|
+
# type: static
|
|
82
|
+
#
|
|
83
|
+
# - name: "City of Rocklin"
|
|
84
|
+
# url: "https://www.governmentjobs.com/careers/rocklinca"
|
|
85
|
+
# type: static
|
|
86
|
+
#
|
|
87
|
+
# - name: "City of Citrus Heights"
|
|
88
|
+
# url: "https://www.governmentjobs.com/careers/citrusheights"
|
|
89
|
+
# type: static
|
|
90
|
+
#
|
|
91
|
+
# - name: "City of Rancho Cordova"
|
|
92
|
+
# url: "https://www.governmentjobs.com/careers/ranchocordova"
|
|
93
|
+
# type: static
|
|
94
|
+
#
|
|
95
|
+
# - name: "Nevada County"
|
|
96
|
+
# url: "https://www.governmentjobs.com/careers/mynevadacounty"
|
|
97
|
+
# type: static
|
|
98
|
+
#
|
|
99
|
+
# - name: "City of Auburn"
|
|
100
|
+
# url: "https://www.governmentjobs.com/careers/auburn"
|
|
101
|
+
# type: static
|
|
102
|
+
#
|
|
103
|
+
# # City of Lincoln uses CalOpps (not NEOGOV) — not compatible with this scraper
|
|
104
|
+
#
|
|
105
|
+
# - name: "CalCareers"
|
|
106
|
+
# url: "https://calcareers.ca.gov/CalHRPublic/Search/JobSearchResults.aspx"
|
|
107
|
+
# type: static
|
divapply/config.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""DivApply configuration: paths, platform detection, user data."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# User data directory - all user-specific files live here.
|
|
9
|
+
#
|
|
10
|
+
# New installs default to ~/.divapply, but we still read legacy ~/.applypilot
|
|
11
|
+
# files so existing users can migrate without losing data.
|
|
12
|
+
APP_DIR = Path(os.environ.get("DIVAPPLY_DIR", os.environ.get("APPLYPILOT_DIR", Path.home() / ".divapply")))
|
|
13
|
+
LEGACY_APP_DIR = Path(os.environ.get("APPLYPILOT_DIR", Path.home() / ".applypilot"))
|
|
14
|
+
|
|
15
|
+
# Core paths
|
|
16
|
+
DB_PATH = APP_DIR / "divapply.db"
|
|
17
|
+
PROFILE_PATH = APP_DIR / "profile.json"
|
|
18
|
+
RESUME_PATH = APP_DIR / "resume.txt"
|
|
19
|
+
RESUME_PDF_PATH = APP_DIR / "resume.pdf"
|
|
20
|
+
SEARCH_CONFIG_PATH = APP_DIR / "searches.yaml"
|
|
21
|
+
ENV_PATH = APP_DIR / ".env"
|
|
22
|
+
LEGACY_DB_PATH = LEGACY_APP_DIR / "applypilot.db"
|
|
23
|
+
LEGACY_PROFILE_PATH = LEGACY_APP_DIR / "profile.json"
|
|
24
|
+
LEGACY_SEARCH_CONFIG_PATH = LEGACY_APP_DIR / "searches.yaml"
|
|
25
|
+
LEGACY_ENV_PATH = LEGACY_APP_DIR / ".env"
|
|
26
|
+
|
|
27
|
+
# Generated output
|
|
28
|
+
TAILORED_DIR = APP_DIR / "tailored_resumes"
|
|
29
|
+
COVER_LETTER_DIR = APP_DIR / "cover_letters"
|
|
30
|
+
LOG_DIR = APP_DIR / "logs"
|
|
31
|
+
|
|
32
|
+
# Chrome worker isolation
|
|
33
|
+
CHROME_WORKER_DIR = APP_DIR / "chrome-workers"
|
|
34
|
+
APPLY_WORKER_DIR = APP_DIR / "apply-workers"
|
|
35
|
+
|
|
36
|
+
# Package-shipped config (YAML registries)
|
|
37
|
+
PACKAGE_DIR = Path(__file__).parent
|
|
38
|
+
CONFIG_DIR = PACKAGE_DIR / "config"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_chrome_path() -> str:
|
|
42
|
+
"""Auto-detect Chrome/Chromium executable path, cross-platform.
|
|
43
|
+
|
|
44
|
+
Override with CHROME_PATH environment variable.
|
|
45
|
+
"""
|
|
46
|
+
env_path = os.environ.get("CHROME_PATH")
|
|
47
|
+
if env_path and Path(env_path).exists():
|
|
48
|
+
return env_path
|
|
49
|
+
|
|
50
|
+
system = platform.system()
|
|
51
|
+
|
|
52
|
+
if system == "Windows":
|
|
53
|
+
candidates = [
|
|
54
|
+
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Google/Chrome/Application/chrome.exe",
|
|
55
|
+
Path(os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) / "Google/Chrome/Application/chrome.exe",
|
|
56
|
+
Path(os.environ.get("LOCALAPPDATA", "")) / "Google/Chrome/Application/chrome.exe",
|
|
57
|
+
]
|
|
58
|
+
elif system == "Darwin":
|
|
59
|
+
candidates = [
|
|
60
|
+
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
61
|
+
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
|
|
62
|
+
]
|
|
63
|
+
else:
|
|
64
|
+
candidates = []
|
|
65
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium-browser", "chromium"):
|
|
66
|
+
found = shutil.which(name)
|
|
67
|
+
if found:
|
|
68
|
+
candidates.append(Path(found))
|
|
69
|
+
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
if candidate and candidate.exists():
|
|
72
|
+
return str(candidate)
|
|
73
|
+
|
|
74
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "chrome"):
|
|
75
|
+
found = shutil.which(name)
|
|
76
|
+
if found:
|
|
77
|
+
return found
|
|
78
|
+
|
|
79
|
+
raise FileNotFoundError(
|
|
80
|
+
"Chrome/Chromium not found. Install Chrome or set CHROME_PATH environment variable."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_chrome_user_data() -> Path:
|
|
85
|
+
"""Default Chrome user data directory, cross-platform."""
|
|
86
|
+
system = platform.system()
|
|
87
|
+
if system == "Windows":
|
|
88
|
+
return Path(os.environ.get("LOCALAPPDATA", "")) / "Google" / "Chrome" / "User Data"
|
|
89
|
+
if system == "Darwin":
|
|
90
|
+
return Path.home() / "Library" / "Application Support" / "Google" / "Chrome"
|
|
91
|
+
return Path.home() / ".config" / "google-chrome"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ensure_dirs() -> None:
|
|
95
|
+
"""Create all required directories."""
|
|
96
|
+
for directory in [APP_DIR, TAILORED_DIR, COVER_LETTER_DIR, LOG_DIR, CHROME_WORKER_DIR, APPLY_WORKER_DIR]:
|
|
97
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _read_text_with_legacy(current: Path, legacy: Path | None = None, *, encoding: str = "utf-8") -> str | None:
|
|
101
|
+
"""Read current user data, falling back to a legacy file."""
|
|
102
|
+
if current.exists():
|
|
103
|
+
return current.read_text(encoding=encoding)
|
|
104
|
+
if legacy is not None and legacy.exists():
|
|
105
|
+
return legacy.read_text(encoding=encoding)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _copy_if_present(source: Path, target: Path, *, overwrite: bool = False) -> str:
|
|
110
|
+
"""Copy a file when available and report the action taken."""
|
|
111
|
+
if not source.exists():
|
|
112
|
+
return "missing"
|
|
113
|
+
try:
|
|
114
|
+
if source.resolve() == target.resolve():
|
|
115
|
+
return "skipped"
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
if target.exists() and not overwrite:
|
|
119
|
+
return "skipped"
|
|
120
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
shutil.copy2(source, target)
|
|
122
|
+
return "copied"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def migrate_legacy_user_data(
|
|
126
|
+
*,
|
|
127
|
+
source_dir: Path | None = None,
|
|
128
|
+
target_dir: Path | None = None,
|
|
129
|
+
overwrite: bool = False,
|
|
130
|
+
) -> dict[str, str]:
|
|
131
|
+
"""Copy legacy files into the current DivApply layout.
|
|
132
|
+
|
|
133
|
+
This intentionally preserves the current files unless overwrite=True.
|
|
134
|
+
The helper is used by the CLI migrate command and is safe to call on a
|
|
135
|
+
fresh install or repeatedly during upgrade troubleshooting.
|
|
136
|
+
"""
|
|
137
|
+
source_root = Path(source_dir or LEGACY_APP_DIR)
|
|
138
|
+
target_root = Path(target_dir or APP_DIR)
|
|
139
|
+
results = {
|
|
140
|
+
"profile": _copy_if_present(source_root / "profile.json", target_root / "profile.json", overwrite=overwrite),
|
|
141
|
+
"searches": _copy_if_present(source_root / "searches.yaml", target_root / "searches.yaml", overwrite=overwrite),
|
|
142
|
+
"env": _copy_if_present(source_root / ".env", target_root / ".env", overwrite=overwrite),
|
|
143
|
+
"resume_txt": _copy_if_present(source_root / "resume.txt", target_root / "resume.txt", overwrite=overwrite),
|
|
144
|
+
"resume_pdf": _copy_if_present(source_root / "resume.pdf", target_root / "resume.pdf", overwrite=overwrite),
|
|
145
|
+
"database": _copy_if_present(source_root / "applypilot.db", target_root / "divapply.db", overwrite=overwrite),
|
|
146
|
+
}
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_profile() -> dict:
|
|
151
|
+
"""Load user profile from ~/.divapply/profile.json."""
|
|
152
|
+
import json
|
|
153
|
+
|
|
154
|
+
raw = _read_text_with_legacy(PROFILE_PATH, LEGACY_PROFILE_PATH)
|
|
155
|
+
if raw is None:
|
|
156
|
+
raise FileNotFoundError(
|
|
157
|
+
f"Profile not found at {PROFILE_PATH}. Run `divapply init` first."
|
|
158
|
+
)
|
|
159
|
+
profile = json.loads(raw)
|
|
160
|
+
|
|
161
|
+
# Hidden coursework knowledge is stored in SQLite so it can inform
|
|
162
|
+
# scoring/tailoring without being exposed in the generated resume text.
|
|
163
|
+
try:
|
|
164
|
+
from divapply.database import get_coursework, load_coursework_seed
|
|
165
|
+
|
|
166
|
+
coursework = get_coursework()
|
|
167
|
+
if not coursework:
|
|
168
|
+
coursework = load_coursework_seed()
|
|
169
|
+
except Exception:
|
|
170
|
+
coursework = []
|
|
171
|
+
|
|
172
|
+
profile["coursework"] = coursework
|
|
173
|
+
profile["coursework_summary"] = _summarize_coursework(coursework)
|
|
174
|
+
profile["coursework_skills"] = _summarize_coursework_skills(coursework)
|
|
175
|
+
return profile
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _summarize_coursework(coursework: list[dict]) -> list[str]:
|
|
179
|
+
"""Condense coursework rows into a compact internal-only summary."""
|
|
180
|
+
if not coursework:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
grouped: dict[str, list[str]] = {}
|
|
184
|
+
for row in coursework:
|
|
185
|
+
school = (row.get("school") or "Unknown school").strip()
|
|
186
|
+
title = (row.get("course_title") or row.get("course_code") or "").strip()
|
|
187
|
+
subject = (row.get("subject_area") or "").strip()
|
|
188
|
+
if subject:
|
|
189
|
+
item = f"{title} [{subject}]" if title else subject
|
|
190
|
+
else:
|
|
191
|
+
raw = (row.get("raw_text") or "").strip()
|
|
192
|
+
if raw:
|
|
193
|
+
first_line = raw.splitlines()[0].strip()
|
|
194
|
+
item = title or (first_line[:120] + ("..." if len(first_line) > 120 else ""))
|
|
195
|
+
else:
|
|
196
|
+
item = title
|
|
197
|
+
if not item:
|
|
198
|
+
continue
|
|
199
|
+
grouped.setdefault(school, [])
|
|
200
|
+
if item not in grouped[school]:
|
|
201
|
+
grouped[school].append(item)
|
|
202
|
+
|
|
203
|
+
summary: list[str] = []
|
|
204
|
+
for school, items in grouped.items():
|
|
205
|
+
summary.append(f"{school}: {', '.join(items[:12])}")
|
|
206
|
+
return summary
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _summarize_coursework_skills(coursework: list[dict]) -> list[str]:
|
|
210
|
+
"""Aggregate coursework skill tags into a compact internal-only summary."""
|
|
211
|
+
if not coursework:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
grouped: dict[str, set[str]] = {}
|
|
215
|
+
for row in coursework:
|
|
216
|
+
school = (row.get("school") or "Unknown school").strip()
|
|
217
|
+
raw_skills = row.get("skills") or []
|
|
218
|
+
if isinstance(raw_skills, str):
|
|
219
|
+
try:
|
|
220
|
+
import json
|
|
221
|
+
raw_skills = json.loads(raw_skills)
|
|
222
|
+
except Exception:
|
|
223
|
+
raw_skills = [s.strip() for s in raw_skills.split(",") if s.strip()]
|
|
224
|
+
if not isinstance(raw_skills, list):
|
|
225
|
+
continue
|
|
226
|
+
grouped.setdefault(school, set()).update(str(skill).strip() for skill in raw_skills if str(skill).strip())
|
|
227
|
+
|
|
228
|
+
summary: list[str] = []
|
|
229
|
+
for school, skills in grouped.items():
|
|
230
|
+
summary.append(f"{school}: {', '.join(sorted(skills))}")
|
|
231
|
+
return summary
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_search_config() -> dict:
|
|
235
|
+
"""Load search configuration from ~/.divapply/searches.yaml."""
|
|
236
|
+
import yaml
|
|
237
|
+
|
|
238
|
+
raw = _read_text_with_legacy(SEARCH_CONFIG_PATH, LEGACY_SEARCH_CONFIG_PATH)
|
|
239
|
+
if raw is None:
|
|
240
|
+
example = CONFIG_DIR / "searches.example.yaml"
|
|
241
|
+
if example.exists():
|
|
242
|
+
return yaml.safe_load(example.read_text(encoding="utf-8"))
|
|
243
|
+
return {}
|
|
244
|
+
return yaml.safe_load(raw)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def load_sites_config() -> dict:
|
|
248
|
+
"""Load sites.yaml configuration (sites list, manual_ats, blocked, etc.)."""
|
|
249
|
+
import yaml
|
|
250
|
+
|
|
251
|
+
path = CONFIG_DIR / "sites.yaml"
|
|
252
|
+
if not path.exists():
|
|
253
|
+
return {}
|
|
254
|
+
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def is_manual_ats(url: str | None) -> bool:
|
|
258
|
+
"""Check if a URL routes through an ATS that requires manual application."""
|
|
259
|
+
if not url:
|
|
260
|
+
return False
|
|
261
|
+
sites_cfg = load_sites_config()
|
|
262
|
+
domains = sites_cfg.get("manual_ats", [])
|
|
263
|
+
url_lower = url.lower()
|
|
264
|
+
return any(domain in url_lower for domain in domains)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def load_blocked_sites() -> tuple[set[str], list[str]]:
|
|
268
|
+
"""Load blocked sites and URL patterns from sites.yaml."""
|
|
269
|
+
cfg = load_sites_config()
|
|
270
|
+
blocked = cfg.get("blocked", {})
|
|
271
|
+
sites = set(blocked.get("sites", []))
|
|
272
|
+
patterns = blocked.get("url_patterns", [])
|
|
273
|
+
return sites, patterns
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def load_blocked_sso() -> list[str]:
|
|
277
|
+
"""Load blocked SSO domains from sites.yaml."""
|
|
278
|
+
cfg = load_sites_config()
|
|
279
|
+
return cfg.get("blocked_sso", [])
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def load_base_urls() -> dict[str, str | None]:
|
|
283
|
+
"""Load site base URLs for URL resolution from sites.yaml."""
|
|
284
|
+
cfg = load_sites_config()
|
|
285
|
+
return cfg.get("base_urls", {})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
DEFAULTS = {
|
|
289
|
+
"min_score": 7,
|
|
290
|
+
"max_apply_attempts": 3,
|
|
291
|
+
"max_tailor_attempts": 5,
|
|
292
|
+
"poll_interval": 60,
|
|
293
|
+
"apply_timeout": 300,
|
|
294
|
+
"viewport": "1280x900",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_env() -> None:
|
|
299
|
+
"""Load environment variables from ~/.divapply/.env if it exists."""
|
|
300
|
+
from dotenv import load_dotenv
|
|
301
|
+
|
|
302
|
+
if ENV_PATH.exists():
|
|
303
|
+
load_dotenv(ENV_PATH, override=True)
|
|
304
|
+
elif LEGACY_ENV_PATH.exists():
|
|
305
|
+
load_dotenv(LEGACY_ENV_PATH, override=True)
|
|
306
|
+
load_dotenv()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
TIER_LABELS = {
|
|
310
|
+
1: "Discovery",
|
|
311
|
+
2: "AI Scoring & Tailoring",
|
|
312
|
+
3: "Full Auto-Apply",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
TIER_COMMANDS: dict[int, list[str]] = {
|
|
316
|
+
1: ["init", "run discover", "run enrich", "status", "dashboard"],
|
|
317
|
+
2: ["run score", "run tailor", "run cover", "run pdf", "run"],
|
|
318
|
+
3: ["apply"],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
APPLY_AGENT_LABELS = {
|
|
322
|
+
"codex": "Codex CLI",
|
|
323
|
+
"claude": "Claude Code CLI",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
APPLY_BROWSER_LABELS = {
|
|
327
|
+
"firefox": "Firefox",
|
|
328
|
+
"chrome": "Google Chrome",
|
|
329
|
+
"msedge": "Microsoft Edge",
|
|
330
|
+
"webkit": "WebKit",
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def get_available_apply_backends() -> dict[str, str]:
|
|
335
|
+
"""Return detected auto-apply agent CLIs keyed by backend name."""
|
|
336
|
+
backends: dict[str, str] = {}
|
|
337
|
+
for name, binary in (("codex", "codex"), ("claude", "claude")):
|
|
338
|
+
resolved = shutil.which(binary)
|
|
339
|
+
if resolved:
|
|
340
|
+
backends[name] = resolved
|
|
341
|
+
return backends
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def get_apply_backend(preferred: str | None = None) -> str | None:
|
|
345
|
+
"""Resolve which apply backend to use."""
|
|
346
|
+
requested = preferred or os.environ.get("DIVAPPLY_APPLY_BACKEND") or os.environ.get("APPLYPILOT_APPLY_BACKEND")
|
|
347
|
+
available = get_available_apply_backends()
|
|
348
|
+
if requested:
|
|
349
|
+
requested = requested.strip().lower()
|
|
350
|
+
return requested if requested in available else None
|
|
351
|
+
for name in ("codex", "claude"):
|
|
352
|
+
if name in available:
|
|
353
|
+
return name
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def get_apply_backend_label(backend: str | None) -> str:
|
|
358
|
+
"""Return a human-readable label for the apply backend."""
|
|
359
|
+
if not backend:
|
|
360
|
+
return "Apply agent CLI"
|
|
361
|
+
return APPLY_AGENT_LABELS.get(backend, backend)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def get_apply_browser(preferred: str | None = None) -> str:
|
|
365
|
+
"""Resolve which Playwright MCP browser channel to use."""
|
|
366
|
+
requested = preferred or os.environ.get("DIVAPPLY_BROWSER") or os.environ.get("APPLYPILOT_BROWSER") or "firefox"
|
|
367
|
+
browser = requested.strip().lower()
|
|
368
|
+
return browser if browser in APPLY_BROWSER_LABELS else "firefox"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def get_apply_browser_label(browser: str | None) -> str:
|
|
372
|
+
"""Return a human-readable label for the apply browser."""
|
|
373
|
+
if not browser:
|
|
374
|
+
return "Browser"
|
|
375
|
+
return APPLY_BROWSER_LABELS.get(browser, browser)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def get_tier(preferred_backend: str | None = None, preferred_browser: str | None = None) -> int:
|
|
379
|
+
"""Detect the current tier based on available dependencies.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
preferred_backend: Optional backend to evaluate instead of the env/default backend.
|
|
383
|
+
preferred_browser: Optional browser to evaluate instead of the env/default browser.
|
|
384
|
+
"""
|
|
385
|
+
load_env()
|
|
386
|
+
|
|
387
|
+
has_llm = any(os.environ.get(key) for key in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL"))
|
|
388
|
+
if not has_llm:
|
|
389
|
+
return 1
|
|
390
|
+
|
|
391
|
+
has_apply_agent = get_apply_backend(preferred_backend) is not None
|
|
392
|
+
browser = get_apply_browser(preferred_browser)
|
|
393
|
+
has_browser = True
|
|
394
|
+
if browser == "chrome":
|
|
395
|
+
try:
|
|
396
|
+
get_chrome_path()
|
|
397
|
+
except FileNotFoundError:
|
|
398
|
+
has_browser = False
|
|
399
|
+
|
|
400
|
+
if has_apply_agent and has_browser and shutil.which("npx") is not None:
|
|
401
|
+
return 3
|
|
402
|
+
|
|
403
|
+
return 2
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def check_tier(
|
|
407
|
+
required: int,
|
|
408
|
+
feature: str,
|
|
409
|
+
preferred_backend: str | None = None,
|
|
410
|
+
preferred_browser: str | None = None,
|
|
411
|
+
) -> None:
|
|
412
|
+
"""Raise SystemExit with a clear message if the current tier is too low."""
|
|
413
|
+
current = get_tier(preferred_backend=preferred_backend, preferred_browser=preferred_browser)
|
|
414
|
+
if current >= required:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
from rich.console import Console
|
|
418
|
+
|
|
419
|
+
console = Console(stderr=True)
|
|
420
|
+
missing: list[str] = []
|
|
421
|
+
|
|
422
|
+
if required >= 2 and not any(os.environ.get(key) for key in ("GEMINI_API_KEY", "OPENAI_API_KEY", "LLM_URL")):
|
|
423
|
+
missing.append("LLM API key — run [bold]divapply init[/bold] or set GEMINI_API_KEY")
|
|
424
|
+
if required >= 3:
|
|
425
|
+
if get_apply_backend(preferred_backend) is None:
|
|
426
|
+
missing.append("Apply agent CLI — install Codex or Claude Code for auto-apply")
|
|
427
|
+
if shutil.which("npx") is None:
|
|
428
|
+
missing.append("Node.js / npx — install Node.js 18+ for Playwright MCP")
|
|
429
|
+
if get_apply_browser(preferred_browser) == "chrome":
|
|
430
|
+
try:
|
|
431
|
+
get_chrome_path()
|
|
432
|
+
except FileNotFoundError:
|
|
433
|
+
missing.append("Chrome/Chromium — install or set CHROME_PATH")
|
|
434
|
+
|
|
435
|
+
console.print(
|
|
436
|
+
f"\n[red]'{feature}' requires {TIER_LABELS.get(required, f'Tier {required}')} (Tier {required}).[/red]\n"
|
|
437
|
+
f"Current tier: {TIER_LABELS.get(current, f'Tier {current}')} (Tier {current})."
|
|
438
|
+
)
|
|
439
|
+
if missing:
|
|
440
|
+
console.print("\n[yellow]Missing:[/yellow]")
|
|
441
|
+
for item in missing:
|
|
442
|
+
console.print(f" - {item}")
|
|
443
|
+
console.print()
|
|
444
|
+
raise SystemExit(1)
|
|
445
|
+
|