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
divapply/cli.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"""DivApply CLI — the main entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from divapply import __version__
|
|
17
|
+
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
21
|
+
datefmt="%H:%M:%S",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="divapply",
|
|
26
|
+
help="DivApply, an AI-powered end-to-end job application pipeline.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
console = Console()
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Valid pipeline stages (in execution order)
|
|
33
|
+
VALID_STAGES = ("discover", "enrich", "score", "tailor", "cover", "pdf")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def _bootstrap() -> None:
|
|
41
|
+
"""Common setup: load env, create dirs, init DB."""
|
|
42
|
+
from divapply.config import load_env, ensure_dirs
|
|
43
|
+
from divapply.database import init_db
|
|
44
|
+
|
|
45
|
+
load_env()
|
|
46
|
+
ensure_dirs()
|
|
47
|
+
init_db()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version_callback(value: bool) -> None:
|
|
51
|
+
if value:
|
|
52
|
+
console.print(f"[bold]DivApply[/bold] {__version__}")
|
|
53
|
+
raise typer.Exit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Commands
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@app.callback()
|
|
61
|
+
def main(
|
|
62
|
+
version: bool = typer.Option(
|
|
63
|
+
False, "--version", "-V",
|
|
64
|
+
help="Show version and exit.",
|
|
65
|
+
callback=_version_callback,
|
|
66
|
+
is_eager=True,
|
|
67
|
+
),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""DivApply — AI-powered end-to-end job application pipeline."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command()
|
|
73
|
+
def init() -> None:
|
|
74
|
+
"""Run the first-time setup wizard (profile, resume, search config)."""
|
|
75
|
+
from divapply.wizard.init import run_wizard
|
|
76
|
+
|
|
77
|
+
run_wizard()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def migrate(
|
|
82
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Replace current files with legacy copies when both exist."),
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Copy legacy user files into the current DivApply layout."""
|
|
85
|
+
from divapply.config import ensure_dirs, migrate_legacy_user_data
|
|
86
|
+
|
|
87
|
+
ensure_dirs()
|
|
88
|
+
results = migrate_legacy_user_data(overwrite=overwrite)
|
|
89
|
+
|
|
90
|
+
console.print("\n[bold]Migration summary[/bold]")
|
|
91
|
+
for key, status in results.items():
|
|
92
|
+
label = {
|
|
93
|
+
"profile": "profile.json",
|
|
94
|
+
"searches": "searches.yaml",
|
|
95
|
+
"env": ".env",
|
|
96
|
+
"resume_txt": "resume.txt",
|
|
97
|
+
"resume_pdf": "resume.pdf",
|
|
98
|
+
"database": "divapply.db",
|
|
99
|
+
}.get(key, key)
|
|
100
|
+
color = "green" if status == "copied" else "yellow" if status == "skipped" else "dim"
|
|
101
|
+
console.print(f" [{color}]{label}[/{color}] {status}")
|
|
102
|
+
|
|
103
|
+
copied = sum(1 for status in results.values() if status == "copied")
|
|
104
|
+
skipped = sum(1 for status in results.values() if status == "skipped")
|
|
105
|
+
if copied:
|
|
106
|
+
console.print("[green]Legacy data copied into the DivApply layout.[/green]")
|
|
107
|
+
if skipped:
|
|
108
|
+
console.print("[yellow]Some current files already existed and were left in place.[/yellow]")
|
|
109
|
+
if not copied and not skipped:
|
|
110
|
+
console.print("[dim]No legacy files were found to migrate.[/dim]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("import-coursework")
|
|
114
|
+
def import_coursework(
|
|
115
|
+
path: Path = typer.Argument(..., exists=True, readable=True, resolve_path=True, help="Transcript or coursework file to import."),
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Import coursework knowledge into the hidden SQLite coursework table."""
|
|
118
|
+
_bootstrap()
|
|
119
|
+
|
|
120
|
+
from divapply.database import replace_coursework
|
|
121
|
+
|
|
122
|
+
entries: list[dict] = []
|
|
123
|
+
suffix = path.suffix.lower()
|
|
124
|
+
|
|
125
|
+
if suffix in {".json", ".csv"}:
|
|
126
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
127
|
+
|
|
128
|
+
if suffix == ".json":
|
|
129
|
+
payload = json.loads(text)
|
|
130
|
+
if isinstance(payload, dict):
|
|
131
|
+
payload = payload.get("coursework") or payload.get("courses") or payload.get("entries") or [payload]
|
|
132
|
+
if isinstance(payload, list):
|
|
133
|
+
for item in payload:
|
|
134
|
+
if isinstance(item, dict):
|
|
135
|
+
entries.append({
|
|
136
|
+
"school": item.get("school") or item.get("institution"),
|
|
137
|
+
"course_code": item.get("course_code") or item.get("code"),
|
|
138
|
+
"course_title": item.get("course_title") or item.get("title") or item.get("name"),
|
|
139
|
+
"subject_area": item.get("subject_area") or item.get("subject") or item.get("category"),
|
|
140
|
+
"term": item.get("term") or item.get("semester") or item.get("session"),
|
|
141
|
+
"credits": item.get("credits") or item.get("units"),
|
|
142
|
+
"grade": item.get("grade"),
|
|
143
|
+
"source": item.get("source") or path.name,
|
|
144
|
+
"notes": item.get("notes"),
|
|
145
|
+
"raw_text": item.get("raw_text") or item.get("text") or json.dumps(item, ensure_ascii=True),
|
|
146
|
+
})
|
|
147
|
+
else:
|
|
148
|
+
entries.append({"source": path.name, "raw_text": text})
|
|
149
|
+
elif suffix == ".csv":
|
|
150
|
+
reader = csv.DictReader(text.splitlines())
|
|
151
|
+
for row in reader:
|
|
152
|
+
entries.append({
|
|
153
|
+
"school": row.get("school") or row.get("institution"),
|
|
154
|
+
"course_code": row.get("course_code") or row.get("code"),
|
|
155
|
+
"course_title": row.get("course_title") or row.get("title") or row.get("name"),
|
|
156
|
+
"subject_area": row.get("subject_area") or row.get("subject") or row.get("category"),
|
|
157
|
+
"term": row.get("term") or row.get("semester") or row.get("session"),
|
|
158
|
+
"credits": row.get("credits") or row.get("units"),
|
|
159
|
+
"grade": row.get("grade"),
|
|
160
|
+
"source": path.name,
|
|
161
|
+
"notes": row.get("notes"),
|
|
162
|
+
"raw_text": json.dumps(row, ensure_ascii=True),
|
|
163
|
+
})
|
|
164
|
+
elif suffix == ".pdf":
|
|
165
|
+
try:
|
|
166
|
+
from pypdf import PdfReader
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
console.print(
|
|
169
|
+
"[red]PDF import needs the optional 'pypdf' package.[/red]\n"
|
|
170
|
+
"Install it or convert the transcript to JSON, CSV, or plain text first."
|
|
171
|
+
)
|
|
172
|
+
raise typer.Exit(code=1) from exc
|
|
173
|
+
|
|
174
|
+
reader = PdfReader(str(path))
|
|
175
|
+
raw_text = "\n".join(page.extract_text() or "" for page in reader.pages).strip()
|
|
176
|
+
entries.append({"source": path.name, "raw_text": raw_text, "notes": "Imported from PDF transcript"})
|
|
177
|
+
else:
|
|
178
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
179
|
+
entries.append({"source": path.name, "raw_text": text, "notes": "Imported plain text transcript"})
|
|
180
|
+
|
|
181
|
+
inserted = replace_coursework(entries)
|
|
182
|
+
console.print(f"[green]Imported coursework entries:[/green] {inserted}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
def run(
|
|
187
|
+
stages: Optional[list[str]] = typer.Argument(
|
|
188
|
+
None,
|
|
189
|
+
help=(
|
|
190
|
+
"Pipeline stages to run. "
|
|
191
|
+
f"Valid: {', '.join(VALID_STAGES)}, all. "
|
|
192
|
+
"Defaults to 'all' if omitted."
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for tailor/cover stages."),
|
|
196
|
+
prune_score: int = typer.Option(0, "--prune-score", help="Auto-delete jobs scoring at or below this after scoring (0 = off)."),
|
|
197
|
+
workers: int = typer.Option(1, "--workers", "-w", help="Parallel threads for discovery/enrichment stages."),
|
|
198
|
+
stream: bool = typer.Option(False, "--stream", help="Run stages concurrently (streaming mode)."),
|
|
199
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview stages without executing."),
|
|
200
|
+
validation: str = typer.Option(
|
|
201
|
+
"normal",
|
|
202
|
+
"--validation",
|
|
203
|
+
help=(
|
|
204
|
+
"Validation strictness for tailor/cover stages. "
|
|
205
|
+
"strict: banned words = errors, judge must pass. "
|
|
206
|
+
"normal: banned words = warnings only (default). "
|
|
207
|
+
"lenient: banned words ignored, LLM judge skipped. "
|
|
208
|
+
"none: skip all validation entirely, accept whatever the LLM returns."
|
|
209
|
+
),
|
|
210
|
+
),
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Run pipeline stages: discover, enrich, score, tailor, cover, pdf."""
|
|
213
|
+
_bootstrap()
|
|
214
|
+
|
|
215
|
+
from divapply.pipeline import run_pipeline
|
|
216
|
+
|
|
217
|
+
stage_list = stages if stages else ["all"]
|
|
218
|
+
|
|
219
|
+
# Validate stage names
|
|
220
|
+
for s in stage_list:
|
|
221
|
+
if s != "all" and s not in VALID_STAGES:
|
|
222
|
+
console.print(
|
|
223
|
+
f"[red]Unknown stage:[/red] '{s}'. "
|
|
224
|
+
f"Valid stages: {', '.join(VALID_STAGES)}, all"
|
|
225
|
+
)
|
|
226
|
+
raise typer.Exit(code=1)
|
|
227
|
+
|
|
228
|
+
# Gate AI stages behind Tier 2
|
|
229
|
+
llm_stages = {"score", "tailor", "cover"}
|
|
230
|
+
if any(s in stage_list for s in llm_stages) or "all" in stage_list:
|
|
231
|
+
from divapply.config import check_tier
|
|
232
|
+
check_tier(2, "AI scoring/tailoring")
|
|
233
|
+
|
|
234
|
+
# Validate the --validation flag value
|
|
235
|
+
valid_modes = ("strict", "normal", "lenient", "none")
|
|
236
|
+
if validation not in valid_modes:
|
|
237
|
+
console.print(
|
|
238
|
+
f"[red]Invalid --validation value:[/red] '{validation}'. "
|
|
239
|
+
f"Choose from: {', '.join(valid_modes)}"
|
|
240
|
+
)
|
|
241
|
+
raise typer.Exit(code=1)
|
|
242
|
+
|
|
243
|
+
result = run_pipeline(
|
|
244
|
+
stages=stage_list,
|
|
245
|
+
min_score=min_score,
|
|
246
|
+
dry_run=dry_run,
|
|
247
|
+
stream=stream,
|
|
248
|
+
workers=workers,
|
|
249
|
+
validation_mode=validation,
|
|
250
|
+
prune_below=prune_score,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if result.get("errors"):
|
|
254
|
+
raise typer.Exit(code=1)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@app.command()
|
|
258
|
+
def apply(
|
|
259
|
+
limit: Optional[int] = typer.Option(None, "--limit", "-l", help="Max applications to submit."),
|
|
260
|
+
workers: int = typer.Option(1, "--workers", "-w", help="Number of parallel browser workers."),
|
|
261
|
+
min_score: int = typer.Option(7, "--min-score", help="Minimum fit score for job selection."),
|
|
262
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="Apply agent model name."),
|
|
263
|
+
backend: Optional[str] = typer.Option(None, "--backend", "-b", help="Apply agent backend: codex or claude."),
|
|
264
|
+
browser: str = typer.Option("firefox", "--browser", help="Playwright browser: firefox, chrome, msedge, webkit."),
|
|
265
|
+
continuous: bool = typer.Option(False, "--continuous", "-c", help="Run forever, polling for new jobs."),
|
|
266
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview actions without submitting."),
|
|
267
|
+
headless: bool = typer.Option(False, "--headless", help="Run browsers in headless mode."),
|
|
268
|
+
url: Optional[str] = typer.Option(None, "--url", help="Apply to a specific job URL."),
|
|
269
|
+
gen: bool = typer.Option(False, "--gen", help="Generate prompt file for manual debugging instead of running."),
|
|
270
|
+
mark_applied: Optional[str] = typer.Option(None, "--mark-applied", help="Manually mark a job URL as applied."),
|
|
271
|
+
mark_failed: Optional[str] = typer.Option(None, "--mark-failed", help="Manually mark a job URL as failed (provide URL)."),
|
|
272
|
+
fail_reason: Optional[str] = typer.Option(None, "--fail-reason", help="Reason for --mark-failed."),
|
|
273
|
+
reset_failed: bool = typer.Option(False, "--reset-failed", help="Reset all failed jobs for retry."),
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Launch auto-apply to submit job applications."""
|
|
276
|
+
_bootstrap()
|
|
277
|
+
|
|
278
|
+
from divapply.config import (
|
|
279
|
+
PROFILE_PATH as _profile_path,
|
|
280
|
+
check_tier,
|
|
281
|
+
get_apply_backend,
|
|
282
|
+
get_apply_backend_label,
|
|
283
|
+
get_apply_browser,
|
|
284
|
+
get_apply_browser_label,
|
|
285
|
+
get_chrome_path,
|
|
286
|
+
)
|
|
287
|
+
from divapply.database import get_connection
|
|
288
|
+
|
|
289
|
+
# --- Utility modes (no browser agent needed) ---
|
|
290
|
+
|
|
291
|
+
if mark_applied:
|
|
292
|
+
from divapply.apply.launcher import mark_job
|
|
293
|
+
mark_job(mark_applied, "applied")
|
|
294
|
+
console.print(f"[green]Marked as applied:[/green] {mark_applied}")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
if mark_failed:
|
|
298
|
+
from divapply.apply.launcher import mark_job
|
|
299
|
+
mark_job(mark_failed, "failed", reason=fail_reason)
|
|
300
|
+
console.print(f"[yellow]Marked as failed:[/yellow] {mark_failed} ({fail_reason or 'manual'})")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if reset_failed:
|
|
304
|
+
from divapply.apply.launcher import reset_failed as do_reset
|
|
305
|
+
count = do_reset()
|
|
306
|
+
console.print(f"[green]Reset {count} failed job(s) for retry.[/green]")
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
# --- Full apply mode ---
|
|
310
|
+
|
|
311
|
+
# Check 1: Tier 3 required (apply agent CLI + browser runtime + Node.js)
|
|
312
|
+
resolved_browser = get_apply_browser(browser)
|
|
313
|
+
resolved_backend = get_apply_backend(backend)
|
|
314
|
+
default_model = "sonnet" if resolved_backend == "claude" else "gpt-5.4-mini"
|
|
315
|
+
resolved_model = (
|
|
316
|
+
model
|
|
317
|
+
or os.environ.get("LLM_MODEL_APPLY")
|
|
318
|
+
or os.environ.get("LLM_MODEL")
|
|
319
|
+
or default_model
|
|
320
|
+
)
|
|
321
|
+
check_tier(3, "auto-apply", preferred_backend=backend, preferred_browser=browser)
|
|
322
|
+
if resolved_backend is None:
|
|
323
|
+
console.print(
|
|
324
|
+
"[red]No supported apply backend found.[/red]\n"
|
|
325
|
+
"Install Codex or Claude Code, or pass [bold]--backend[/bold] with an installed option."
|
|
326
|
+
)
|
|
327
|
+
raise typer.Exit(code=1)
|
|
328
|
+
if resolved_browser == "chrome":
|
|
329
|
+
try:
|
|
330
|
+
get_chrome_path()
|
|
331
|
+
except FileNotFoundError:
|
|
332
|
+
console.print("[red]Chrome/Chromium not found.[/red]\nInstall Chrome or set CHROME_PATH.")
|
|
333
|
+
raise typer.Exit(code=1)
|
|
334
|
+
|
|
335
|
+
# Check 2: Profile exists
|
|
336
|
+
if not _profile_path.exists():
|
|
337
|
+
console.print(
|
|
338
|
+
"[red]Profile not found.[/red]\n"
|
|
339
|
+
"Run [bold]divapply init[/bold] to create your profile first."
|
|
340
|
+
)
|
|
341
|
+
raise typer.Exit(code=1)
|
|
342
|
+
|
|
343
|
+
# Check 3: Tailored resumes exist (skip for --gen with --url)
|
|
344
|
+
if not (gen and url):
|
|
345
|
+
conn = get_connection()
|
|
346
|
+
ready = conn.execute(
|
|
347
|
+
"SELECT COUNT(*) FROM jobs WHERE tailored_resume_path IS NOT NULL AND applied_at IS NULL"
|
|
348
|
+
).fetchone()[0]
|
|
349
|
+
if ready == 0:
|
|
350
|
+
console.print(
|
|
351
|
+
"[red]No tailored resumes ready.[/red]\n"
|
|
352
|
+
"Run [bold]divapply run score tailor[/bold] first to prepare applications."
|
|
353
|
+
)
|
|
354
|
+
raise typer.Exit(code=1)
|
|
355
|
+
|
|
356
|
+
if gen:
|
|
357
|
+
from divapply.apply.launcher import gen_prompt, get_manual_command
|
|
358
|
+
target = url or ""
|
|
359
|
+
if not target:
|
|
360
|
+
console.print("[red]--gen requires --url to specify which job.[/red]")
|
|
361
|
+
raise typer.Exit(code=1)
|
|
362
|
+
prompt_file = gen_prompt(
|
|
363
|
+
target,
|
|
364
|
+
min_score=min_score,
|
|
365
|
+
model=resolved_model,
|
|
366
|
+
backend=resolved_backend,
|
|
367
|
+
browser=resolved_browser,
|
|
368
|
+
)
|
|
369
|
+
if not prompt_file:
|
|
370
|
+
console.print("[red]No matching job found for that URL.[/red]")
|
|
371
|
+
raise typer.Exit(code=1)
|
|
372
|
+
mcp_path = _profile_path.parent / ".mcp-apply-0.json"
|
|
373
|
+
console.print(f"[green]Wrote prompt to:[/green] {prompt_file}")
|
|
374
|
+
console.print(f"\n[bold]Run manually:[/bold]")
|
|
375
|
+
console.print(f" {get_manual_command(resolved_backend, resolved_model, prompt_file, mcp_path)}")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
from divapply.apply.launcher import main as apply_main
|
|
379
|
+
|
|
380
|
+
effective_limit = limit if limit is not None else (0 if continuous else 1)
|
|
381
|
+
|
|
382
|
+
console.print("\n[bold blue]Launching Auto-Apply[/bold blue]")
|
|
383
|
+
console.print(f" Limit: {'unlimited' if continuous else effective_limit}")
|
|
384
|
+
console.print(f" Workers: {workers}")
|
|
385
|
+
console.print(f" Backend: {get_apply_backend_label(resolved_backend)}")
|
|
386
|
+
console.print(f" Browser: {get_apply_browser_label(resolved_browser)}")
|
|
387
|
+
console.print(f" Model: {resolved_model}")
|
|
388
|
+
console.print(f" Headless: {headless}")
|
|
389
|
+
console.print(f" Dry run: {dry_run}")
|
|
390
|
+
if url:
|
|
391
|
+
console.print(f" Target: {url}")
|
|
392
|
+
console.print()
|
|
393
|
+
|
|
394
|
+
apply_main(
|
|
395
|
+
limit=effective_limit,
|
|
396
|
+
target_url=url,
|
|
397
|
+
min_score=min_score,
|
|
398
|
+
headless=headless,
|
|
399
|
+
model=resolved_model,
|
|
400
|
+
backend=resolved_backend,
|
|
401
|
+
browser=resolved_browser,
|
|
402
|
+
dry_run=dry_run,
|
|
403
|
+
continuous=continuous,
|
|
404
|
+
workers=workers,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@app.command()
|
|
409
|
+
def status() -> None:
|
|
410
|
+
"""Show pipeline statistics from the database."""
|
|
411
|
+
_bootstrap()
|
|
412
|
+
|
|
413
|
+
from divapply.database import get_stats
|
|
414
|
+
|
|
415
|
+
stats = get_stats()
|
|
416
|
+
|
|
417
|
+
console.print("\n[bold]DivApply Pipeline Status[/bold]\n")
|
|
418
|
+
|
|
419
|
+
# Summary table
|
|
420
|
+
summary = Table(title="Pipeline Overview", show_header=True, header_style="bold cyan")
|
|
421
|
+
summary.add_column("Metric", style="bold")
|
|
422
|
+
summary.add_column("Count", justify="right")
|
|
423
|
+
|
|
424
|
+
summary.add_row("Total jobs discovered", str(stats["total"]))
|
|
425
|
+
summary.add_row("With full description", str(stats["with_description"]))
|
|
426
|
+
summary.add_row("Pending enrichment", str(stats["pending_detail"]))
|
|
427
|
+
summary.add_row("Enrichment errors", str(stats["detail_errors"]))
|
|
428
|
+
summary.add_row("Scored by LLM", str(stats["scored"]))
|
|
429
|
+
summary.add_row("Pending scoring", str(stats["unscored"]))
|
|
430
|
+
summary.add_row("Tailored resumes", str(stats["tailored"]))
|
|
431
|
+
summary.add_row("Pending tailoring (7+)", str(stats["untailored_eligible"]))
|
|
432
|
+
summary.add_row("Cover letters", str(stats["with_cover_letter"]))
|
|
433
|
+
summary.add_row("Ready to apply", str(stats["ready_to_apply"]))
|
|
434
|
+
summary.add_row("Applied", str(stats["applied"]))
|
|
435
|
+
summary.add_row("Apply errors", str(stats["apply_errors"]))
|
|
436
|
+
|
|
437
|
+
console.print(summary)
|
|
438
|
+
|
|
439
|
+
# Score distribution
|
|
440
|
+
if stats["score_distribution"]:
|
|
441
|
+
dist_table = Table(title="\nScore Distribution", show_header=True, header_style="bold yellow")
|
|
442
|
+
dist_table.add_column("Score", justify="center")
|
|
443
|
+
dist_table.add_column("Count", justify="right")
|
|
444
|
+
dist_table.add_column("Bar")
|
|
445
|
+
|
|
446
|
+
max_count = max(count for _, count in stats["score_distribution"]) or 1
|
|
447
|
+
for score, count in stats["score_distribution"]:
|
|
448
|
+
bar_len = int(count / max_count * 30)
|
|
449
|
+
if score >= 7:
|
|
450
|
+
color = "green"
|
|
451
|
+
elif score >= 5:
|
|
452
|
+
color = "yellow"
|
|
453
|
+
else:
|
|
454
|
+
color = "red"
|
|
455
|
+
bar = f"[{color}]{'=' * bar_len}[/{color}]"
|
|
456
|
+
dist_table.add_row(str(score), str(count), bar)
|
|
457
|
+
|
|
458
|
+
console.print(dist_table)
|
|
459
|
+
|
|
460
|
+
# By site
|
|
461
|
+
if stats["by_site"]:
|
|
462
|
+
site_table = Table(title="\nJobs by Source", show_header=True, header_style="bold magenta")
|
|
463
|
+
site_table.add_column("Site")
|
|
464
|
+
site_table.add_column("Count", justify="right")
|
|
465
|
+
|
|
466
|
+
for site, count in stats["by_site"]:
|
|
467
|
+
site_table.add_row(site or "Unknown", str(count))
|
|
468
|
+
|
|
469
|
+
console.print(site_table)
|
|
470
|
+
|
|
471
|
+
console.print()
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@app.command()
|
|
475
|
+
def dashboard() -> None:
|
|
476
|
+
"""Generate and open the HTML dashboard in your browser."""
|
|
477
|
+
_bootstrap()
|
|
478
|
+
|
|
479
|
+
from divapply.view import open_dashboard
|
|
480
|
+
|
|
481
|
+
open_dashboard()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@app.command()
|
|
485
|
+
def doctor() -> None:
|
|
486
|
+
"""Check your setup and diagnose missing requirements."""
|
|
487
|
+
import shutil
|
|
488
|
+
from divapply.config import (
|
|
489
|
+
load_env, PROFILE_PATH, RESUME_PATH, RESUME_PDF_PATH,
|
|
490
|
+
SEARCH_CONFIG_PATH, ENV_PATH, get_chrome_path,
|
|
491
|
+
get_apply_browser, get_apply_browser_label,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
load_env()
|
|
495
|
+
|
|
496
|
+
ok_mark = "[green]OK[/green]"
|
|
497
|
+
fail_mark = "[red]MISSING[/red]"
|
|
498
|
+
warn_mark = "[yellow]WARN[/yellow]"
|
|
499
|
+
|
|
500
|
+
results: list[tuple[str, str, str]] = [] # (check, status, note)
|
|
501
|
+
|
|
502
|
+
# --- Tier 1 checks ---
|
|
503
|
+
# Profile
|
|
504
|
+
if PROFILE_PATH.exists():
|
|
505
|
+
results.append(("profile.json", ok_mark, str(PROFILE_PATH)))
|
|
506
|
+
else:
|
|
507
|
+
results.append(("profile.json", fail_mark, "Run 'divapply init' to create"))
|
|
508
|
+
|
|
509
|
+
# Resume
|
|
510
|
+
if RESUME_PATH.exists():
|
|
511
|
+
results.append(("resume.txt", ok_mark, str(RESUME_PATH)))
|
|
512
|
+
elif RESUME_PDF_PATH.exists():
|
|
513
|
+
results.append(("resume.txt", warn_mark, "Only PDF found — plain-text needed for AI stages"))
|
|
514
|
+
else:
|
|
515
|
+
results.append(("resume.txt", fail_mark, "Run 'divapply init' to add your resume"))
|
|
516
|
+
|
|
517
|
+
# Search config
|
|
518
|
+
if SEARCH_CONFIG_PATH.exists():
|
|
519
|
+
results.append(("searches.yaml", ok_mark, str(SEARCH_CONFIG_PATH)))
|
|
520
|
+
else:
|
|
521
|
+
results.append(("searches.yaml", warn_mark, "Will use example config — run 'divapply init'"))
|
|
522
|
+
|
|
523
|
+
# jobspy (discovery dep installed separately)
|
|
524
|
+
try:
|
|
525
|
+
import jobspy # noqa: F401
|
|
526
|
+
results.append(("python-jobspy", ok_mark, "Job board scraping available"))
|
|
527
|
+
except ImportError:
|
|
528
|
+
results.append(("python-jobspy", warn_mark,
|
|
529
|
+
"pip install --no-deps python-jobspy && pip install pydantic tls-client requests markdownify regex"))
|
|
530
|
+
|
|
531
|
+
# --- Tier 2 checks ---
|
|
532
|
+
import os
|
|
533
|
+
has_gemini = bool(os.environ.get("GEMINI_API_KEY"))
|
|
534
|
+
has_openai = bool(os.environ.get("OPENAI_API_KEY"))
|
|
535
|
+
has_local = bool(os.environ.get("LLM_URL"))
|
|
536
|
+
if has_gemini:
|
|
537
|
+
model = os.environ.get("LLM_MODEL", "gemini-2.0-flash")
|
|
538
|
+
results.append(("LLM API key", ok_mark, f"Gemini ({model})"))
|
|
539
|
+
elif has_openai:
|
|
540
|
+
model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
|
|
541
|
+
results.append(("LLM API key", ok_mark, f"OpenAI ({model})"))
|
|
542
|
+
elif has_local:
|
|
543
|
+
results.append(("LLM API key", ok_mark, f"Local: {os.environ.get('LLM_URL')}"))
|
|
544
|
+
else:
|
|
545
|
+
results.append(("LLM API key", fail_mark,
|
|
546
|
+
"Set GEMINI_API_KEY in ~/.divapply/.env (run 'divapply init')"))
|
|
547
|
+
|
|
548
|
+
# --- Tier 3 checks ---
|
|
549
|
+
from divapply.config import get_apply_backend, get_apply_backend_label, get_available_apply_backends
|
|
550
|
+
detected_backends = get_available_apply_backends()
|
|
551
|
+
selected_backend = get_apply_backend()
|
|
552
|
+
if detected_backends:
|
|
553
|
+
note = ", ".join(
|
|
554
|
+
f"{get_apply_backend_label(name)}: {path}" for name, path in detected_backends.items()
|
|
555
|
+
)
|
|
556
|
+
results.append(("Apply agent CLI", ok_mark, note))
|
|
557
|
+
else:
|
|
558
|
+
results.append(("Apply agent CLI", fail_mark,
|
|
559
|
+
"Install Codex or Claude Code (needed for auto-apply)"))
|
|
560
|
+
|
|
561
|
+
# Browser runtime
|
|
562
|
+
selected_browser = get_apply_browser()
|
|
563
|
+
if selected_browser == "chrome":
|
|
564
|
+
try:
|
|
565
|
+
chrome_path = get_chrome_path()
|
|
566
|
+
results.append(("Browser", ok_mark, chrome_path))
|
|
567
|
+
except FileNotFoundError:
|
|
568
|
+
results.append(("Browser", fail_mark,
|
|
569
|
+
"Install Chrome or set CHROME_PATH env var (needed for Chrome mode)"))
|
|
570
|
+
else:
|
|
571
|
+
results.append(("Browser", ok_mark,
|
|
572
|
+
f"Playwright channel: {get_apply_browser_label(selected_browser)}"))
|
|
573
|
+
|
|
574
|
+
# Node.js / npx (for Playwright MCP)
|
|
575
|
+
npx_bin = shutil.which("npx")
|
|
576
|
+
if npx_bin:
|
|
577
|
+
results.append(("Node.js (npx)", ok_mark, npx_bin))
|
|
578
|
+
else:
|
|
579
|
+
results.append(("Node.js (npx)", fail_mark,
|
|
580
|
+
"Install Node.js 18+ from nodejs.org (needed for auto-apply)"))
|
|
581
|
+
|
|
582
|
+
# CapSolver (optional)
|
|
583
|
+
capsolver = os.environ.get("CAPSOLVER_API_KEY")
|
|
584
|
+
if capsolver:
|
|
585
|
+
results.append(("CapSolver API key", ok_mark, "CAPTCHA solving enabled"))
|
|
586
|
+
else:
|
|
587
|
+
results.append(("CapSolver API key", "[dim]optional[/dim]",
|
|
588
|
+
"Set CAPSOLVER_API_KEY in .env for CAPTCHA solving"))
|
|
589
|
+
|
|
590
|
+
# --- Render results ---
|
|
591
|
+
console.print()
|
|
592
|
+
console.print("[bold]DivApply Doctor[/bold]\n")
|
|
593
|
+
|
|
594
|
+
col_w = max(len(r[0]) for r in results) + 2
|
|
595
|
+
for check, status, note in results:
|
|
596
|
+
pad = " " * (col_w - len(check))
|
|
597
|
+
console.print(f" {check}{pad}{status} [dim]{note}[/dim]")
|
|
598
|
+
|
|
599
|
+
console.print()
|
|
600
|
+
|
|
601
|
+
# Tier summary
|
|
602
|
+
from divapply.config import get_tier, TIER_LABELS
|
|
603
|
+
tier = get_tier()
|
|
604
|
+
if selected_backend:
|
|
605
|
+
console.print(f"[dim] Auto-apply backend: {get_apply_backend_label(selected_backend)}[/dim]")
|
|
606
|
+
console.print(f"[bold]Current tier: Tier {tier} - {TIER_LABELS[tier]}[/bold]")
|
|
607
|
+
|
|
608
|
+
if tier == 1:
|
|
609
|
+
console.print("[dim] -> Tier 2 unlocks: scoring, tailoring, cover letters (needs LLM API key)[/dim]")
|
|
610
|
+
console.print("[dim] -> Tier 3 unlocks: auto-apply (needs an apply backend CLI + Node.js + browser runtime)[/dim]")
|
|
611
|
+
elif tier == 2:
|
|
612
|
+
console.print("[dim] -> Tier 3 unlocks: auto-apply (needs an apply backend CLI + Node.js + browser runtime)[/dim]")
|
|
613
|
+
|
|
614
|
+
console.print()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@app.command()
|
|
618
|
+
def prune(
|
|
619
|
+
max_score: int = typer.Option(4, "--max-score", help="Delete scored jobs at or below this score (default: 4)."),
|
|
620
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Preview without deleting."),
|
|
621
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
|
|
622
|
+
) -> None:
|
|
623
|
+
"""Remove low-scoring jobs from the database to reduce clutter."""
|
|
624
|
+
_bootstrap()
|
|
625
|
+
|
|
626
|
+
from divapply.database import get_connection
|
|
627
|
+
|
|
628
|
+
conn = get_connection()
|
|
629
|
+
|
|
630
|
+
# Show breakdown by score before deleting
|
|
631
|
+
rows = conn.execute(
|
|
632
|
+
"SELECT fit_score, COUNT(*) FROM jobs "
|
|
633
|
+
"WHERE fit_score IS NOT NULL AND fit_score <= ? "
|
|
634
|
+
"GROUP BY fit_score ORDER BY fit_score",
|
|
635
|
+
(max_score,),
|
|
636
|
+
).fetchall()
|
|
637
|
+
|
|
638
|
+
if not rows:
|
|
639
|
+
console.print(f"[green]No scored jobs with fit_score <= {max_score} found.[/green]")
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
total = sum(r[1] for r in rows)
|
|
643
|
+
console.print(f"\n[yellow]Jobs to remove (fit_score <= {max_score}):[/yellow]")
|
|
644
|
+
for score, count in rows:
|
|
645
|
+
bar = "=" * min(count, 40)
|
|
646
|
+
console.print(f" Score {score}: {count:>4} [{bar}]")
|
|
647
|
+
console.print(f" [bold]Total: {total}[/bold]\n")
|
|
648
|
+
|
|
649
|
+
if dry_run:
|
|
650
|
+
console.print("[dim]Dry run — no changes made.[/dim]")
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
if not yes:
|
|
654
|
+
confirmed = typer.confirm(f"Delete {total} jobs permanently?")
|
|
655
|
+
if not confirmed:
|
|
656
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
conn.execute(
|
|
660
|
+
"DELETE FROM jobs WHERE fit_score IS NOT NULL AND fit_score <= ?",
|
|
661
|
+
(max_score,),
|
|
662
|
+
)
|
|
663
|
+
conn.commit()
|
|
664
|
+
console.print(f"[green]Deleted {total} low-scoring jobs (score <= {max_score}).[/green]")
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@app.command()
|
|
668
|
+
def ultimate(
|
|
669
|
+
top: int = typer.Option(10, "--top", "-n", help="Number of top-scoring jobs to draw from."),
|
|
670
|
+
min_score: int = typer.Option(7, "--min-score", help="Minimum fit score to include."),
|
|
671
|
+
out: Optional[str] = typer.Option(None, "--out", "-o", help="Output directory (default: ~/.divapply/)."),
|
|
672
|
+
) -> None:
|
|
673
|
+
"""Generate an ultimate general-purpose resume from your top-scoring jobs."""
|
|
674
|
+
_bootstrap()
|
|
675
|
+
|
|
676
|
+
from pathlib import Path
|
|
677
|
+
from divapply.scoring.ultimate import generate_ultimate_resume
|
|
678
|
+
|
|
679
|
+
output_dir = Path(out) if out else None
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
result = generate_ultimate_resume(
|
|
683
|
+
top_n=top,
|
|
684
|
+
min_score=min_score,
|
|
685
|
+
output_dir=output_dir,
|
|
686
|
+
)
|
|
687
|
+
except RuntimeError as e:
|
|
688
|
+
console.print(f"[red]{e}[/red]")
|
|
689
|
+
raise typer.Exit(code=1)
|
|
690
|
+
|
|
691
|
+
console.print(f"\n[bold green]Ultimate resume generated![/bold green]")
|
|
692
|
+
console.print(f" Jobs used: {result['jobs_used']}")
|
|
693
|
+
console.print(f" Text: {result['text_path']}")
|
|
694
|
+
if result.get("pdf_path"):
|
|
695
|
+
console.print(f" PDF: {result['pdf_path']}")
|
|
696
|
+
console.print(f" Time: {result['elapsed']:.1f}s")
|
|
697
|
+
console.print()
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@app.command()
|
|
701
|
+
def sync(
|
|
702
|
+
platform: Optional[list[str]] = typer.Argument(
|
|
703
|
+
None,
|
|
704
|
+
help="Platforms to sync: github, linkedin, facebook. Defaults to all.",
|
|
705
|
+
),
|
|
706
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Generate content without pushing or automating."),
|
|
707
|
+
headless: bool = typer.Option(False, "--headless", help="Run browser automation in headless mode."),
|
|
708
|
+
) -> None:
|
|
709
|
+
"""Sync your profile across social platforms (GitHub, LinkedIn, Facebook).
|
|
710
|
+
|
|
711
|
+
Uses your Firefox login cookies to automate profile updates.
|
|
712
|
+
GitHub is updated via API (needs GITHUB_TOKEN in .env).
|
|
713
|
+
LinkedIn and Facebook are updated via browser automation.
|
|
714
|
+
"""
|
|
715
|
+
_bootstrap()
|
|
716
|
+
|
|
717
|
+
from divapply.social import sync_profiles
|
|
718
|
+
|
|
719
|
+
targets = platform if platform else None
|
|
720
|
+
|
|
721
|
+
# Validate platform names
|
|
722
|
+
valid = {"github", "linkedin", "facebook"}
|
|
723
|
+
if targets:
|
|
724
|
+
for t in targets:
|
|
725
|
+
if t.lower() not in valid:
|
|
726
|
+
console.print(f"[red]Unknown platform:[/red] '{t}'. Valid: {', '.join(sorted(valid))}")
|
|
727
|
+
raise typer.Exit(code=1)
|
|
728
|
+
|
|
729
|
+
console.print("\n[bold blue]Social Profile Sync[/bold blue]")
|
|
730
|
+
if dry_run:
|
|
731
|
+
console.print("[dim]Dry run — generating content only, no automation.[/dim]")
|
|
732
|
+
else:
|
|
733
|
+
console.print("[dim]Extracting Firefox cookies for login, launching browser...[/dim]")
|
|
734
|
+
console.print()
|
|
735
|
+
|
|
736
|
+
results = sync_profiles(platforms=targets, dry_run=dry_run, headless=headless)
|
|
737
|
+
|
|
738
|
+
for r in results:
|
|
739
|
+
header = f"[bold]{r.platform}[/bold]"
|
|
740
|
+
if r.auto_updated:
|
|
741
|
+
updated = ", ".join(r.sections_updated) if r.sections_updated else "all"
|
|
742
|
+
console.print(f" {header} [green]UPDATED[/green] ({updated})")
|
|
743
|
+
elif r.error:
|
|
744
|
+
console.print(f" {header} [yellow]{r.error}[/yellow]")
|
|
745
|
+
else:
|
|
746
|
+
console.print(f" {header} [dim]done[/dim]")
|
|
747
|
+
|
|
748
|
+
if r.sections_failed:
|
|
749
|
+
console.print(f" [red]failed:[/red] {', '.join(r.sections_failed)}")
|
|
750
|
+
|
|
751
|
+
# Show generated content
|
|
752
|
+
for key, val in r.content.items():
|
|
753
|
+
display = val if len(val) <= 120 else val[:117] + "..."
|
|
754
|
+
console.print(f" {key}: {display}")
|
|
755
|
+
console.print()
|
|
756
|
+
|
|
757
|
+
# Mention the saved snapshot
|
|
758
|
+
from divapply.config import APP_DIR as _app_dir
|
|
759
|
+
console.print(f"[dim]Full content saved to {_app_dir / 'social_sync.json'}[/dim]")
|
|
760
|
+
console.print(f"[dim]Debug screenshots in {_app_dir / 'social_screenshots/'}[/dim]\n")
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
if __name__ == "__main__":
|
|
764
|
+
app()
|
|
765
|
+
|