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 ADDED
@@ -0,0 +1,3 @@
1
+ """DivApply — AI-powered end-to-end job application pipeline."""
2
+
3
+ __version__ = "0.4.2"
divapply/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m divapply`."""
2
+
3
+ from divapply.cli import app
4
+
5
+ app()
@@ -0,0 +1 @@
1
+ """Apply pipeline: Chrome management, prompt building, orchestration, and dashboard."""
@@ -0,0 +1,360 @@
1
+ """Chrome lifecycle management for apply workers.
2
+
3
+ Handles launching an isolated Chrome instance with remote debugging,
4
+ worker profile setup/cloning, and cross-platform process cleanup.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import platform
10
+ import shutil
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ import urllib.request
15
+ import urllib.error
16
+ from pathlib import Path
17
+
18
+ from divapply import config
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # CDP port base — each worker uses BASE_CDP_PORT + worker_id
23
+ BASE_CDP_PORT = 9222
24
+
25
+ # Track Chrome processes per worker for cleanup
26
+ _chrome_procs: dict[int, subprocess.Popen] = {}
27
+ _chrome_lock = threading.Lock()
28
+
29
+
30
+ def get_worker_browser_profile_dir(worker_id: int, browser: str = "chrome") -> Path:
31
+ """Return the persistent browser profile directory for a worker."""
32
+ safe_browser = browser.lower()
33
+ if safe_browser == "chrome":
34
+ return config.CHROME_WORKER_DIR / f"worker-{worker_id}"
35
+ return config.APPLY_WORKER_DIR / f"{safe_browser}-profile-{worker_id}"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Cross-platform process helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def _kill_process_tree(pid: int) -> None:
43
+ """Kill a process and all its children.
44
+
45
+ On Windows, Chrome spawns 10+ child processes (GPU, renderer, etc.),
46
+ so taskkill /T is needed to kill the entire tree. On Unix, os.killpg
47
+ handles the process group.
48
+ """
49
+ import signal as _signal
50
+
51
+ try:
52
+ if platform.system() == "Windows":
53
+ subprocess.run(
54
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
55
+ stdout=subprocess.DEVNULL,
56
+ stderr=subprocess.DEVNULL,
57
+ timeout=10,
58
+ )
59
+ else:
60
+ # Unix: kill entire process group
61
+ import os
62
+ try:
63
+ os.killpg(os.getpgid(pid), _signal.SIGKILL)
64
+ except (ProcessLookupError, PermissionError):
65
+ # Process already gone or owned by another user
66
+ try:
67
+ os.kill(pid, _signal.SIGKILL)
68
+ except (ProcessLookupError, PermissionError):
69
+ pass
70
+ except Exception:
71
+ logger.debug("Failed to kill process tree for PID %d", pid, exc_info=True)
72
+
73
+
74
+ def _kill_on_port(port: int) -> None:
75
+ """Kill any process listening on a specific port (zombie cleanup).
76
+
77
+ Uses netstat on Windows, lsof on macOS/Linux.
78
+ """
79
+ try:
80
+ if platform.system() == "Windows":
81
+ result = subprocess.run(
82
+ ["netstat", "-ano", "-p", "TCP"],
83
+ capture_output=True, text=True, timeout=10,
84
+ )
85
+ for line in result.stdout.splitlines():
86
+ if f":{port}" in line and "LISTENING" in line:
87
+ pid = line.strip().split()[-1]
88
+ if pid.isdigit():
89
+ _kill_process_tree(int(pid))
90
+ else:
91
+ # macOS / Linux
92
+ result = subprocess.run(
93
+ ["lsof", "-ti", f":{port}"],
94
+ capture_output=True, text=True, timeout=10,
95
+ )
96
+ for pid_str in result.stdout.strip().splitlines():
97
+ pid_str = pid_str.strip()
98
+ if pid_str.isdigit():
99
+ _kill_process_tree(int(pid_str))
100
+ except FileNotFoundError:
101
+ logger.debug("Port-kill tool not found (netstat/lsof) for port %d", port)
102
+ except Exception:
103
+ logger.debug("Failed to kill process on port %d", port, exc_info=True)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Worker profile management
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def setup_worker_profile(worker_id: int, browser: str = "chrome") -> Path:
111
+ """Create an isolated browser profile for a worker.
112
+
113
+ Chrome profiles are cloned from an existing worker or the user's local
114
+ Chrome profile to preserve cookies. Other browsers use a dedicated
115
+ persistent Playwright profile directory per worker.
116
+
117
+ Args:
118
+ worker_id: Numeric worker identifier.
119
+
120
+ Returns:
121
+ Path to the worker's browser user-data directory.
122
+ """
123
+ profile_dir = get_worker_browser_profile_dir(worker_id, browser)
124
+ if browser.lower() != "chrome":
125
+ profile_dir.mkdir(parents=True, exist_ok=True)
126
+ return profile_dir
127
+
128
+ if (profile_dir / "Default").exists():
129
+ return profile_dir # Already initialized
130
+
131
+ # Find a source: prefer existing worker (has session cookies), else user profile
132
+ source: Path | None = None
133
+ for wid in range(10):
134
+ if wid == worker_id:
135
+ continue
136
+ candidate = config.CHROME_WORKER_DIR / f"worker-{wid}"
137
+ if (candidate / "Default").exists():
138
+ source = candidate
139
+ break
140
+ if source is None:
141
+ source = config.get_chrome_user_data()
142
+
143
+ logger.info("[worker-%d] Copying Chrome profile from %s (first time setup)...",
144
+ worker_id, source.name)
145
+ profile_dir.mkdir(parents=True, exist_ok=True)
146
+
147
+ # Copy essential profile dirs -- skip caches and heavy transient data
148
+ skip = {
149
+ "ShaderCache", "GrShaderCache", "Service Worker", "Cache",
150
+ "Code Cache", "GPUCache", "CacheStorage", "Crashpad",
151
+ "BrowserMetrics", "SafeBrowsing", "Crowd Deny",
152
+ "MEIPreload", "SSLErrorAssistant", "recovery", "Temp",
153
+ "SingletonLock", "SingletonSocket", "SingletonCookie",
154
+ }
155
+
156
+ for item in source.iterdir():
157
+ if item.name in skip:
158
+ continue
159
+ dst = profile_dir / item.name
160
+ try:
161
+ if item.is_dir():
162
+ shutil.copytree(
163
+ str(item), str(dst), dirs_exist_ok=True,
164
+ ignore=shutil.ignore_patterns(
165
+ "Cache", "Code Cache", "GPUCache", "Service Worker",
166
+ ),
167
+ )
168
+ else:
169
+ shutil.copy2(str(item), str(dst))
170
+ except (PermissionError, OSError):
171
+ pass # skip locked files
172
+
173
+ return profile_dir
174
+
175
+
176
+ def _suppress_restore_nag(profile_dir: Path) -> None:
177
+ """Clear Chrome's 'restore pages' nag by fixing Preferences.
178
+
179
+ Chrome writes exit_type=Crashed when killed, which triggers a
180
+ 'Restore pages?' prompt on next launch. This patches it out.
181
+ """
182
+ prefs_file = profile_dir / "Default" / "Preferences"
183
+ if not prefs_file.exists():
184
+ return
185
+
186
+ try:
187
+ prefs = json.loads(prefs_file.read_text(encoding="utf-8"))
188
+ prefs.setdefault("profile", {})["exit_type"] = "Normal"
189
+ prefs.setdefault("session", {})["restore_on_startup"] = 4 # 4 = open blank
190
+ prefs.setdefault("session", {}).pop("startup_urls", None)
191
+ prefs["credentials_enable_service"] = False
192
+ prefs.setdefault("password_manager", {})["saving_enabled"] = False
193
+ prefs.setdefault("autofill", {})["profile_enabled"] = False
194
+ prefs_file.write_text(json.dumps(prefs), encoding="utf-8")
195
+ except Exception:
196
+ logger.debug("Could not patch Chrome preferences", exc_info=True)
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Chrome launch / kill
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def launch_chrome(worker_id: int, port: int | None = None,
204
+ headless: bool = False) -> subprocess.Popen:
205
+ """Launch a Chrome instance with remote debugging for a worker.
206
+
207
+ Args:
208
+ worker_id: Numeric worker identifier.
209
+ port: CDP port. Defaults to BASE_CDP_PORT + worker_id.
210
+ headless: Run Chrome in headless mode (no visible window).
211
+
212
+ Returns:
213
+ subprocess.Popen handle for the Chrome process.
214
+ """
215
+ if port is None:
216
+ port = BASE_CDP_PORT + worker_id
217
+
218
+ profile_dir = setup_worker_profile(worker_id)
219
+
220
+ # Kill any zombie Chrome from a previous run on this port
221
+ _kill_on_port(port)
222
+
223
+ # Patch preferences to suppress restore nag
224
+ _suppress_restore_nag(profile_dir)
225
+
226
+ chrome_exe = config.get_chrome_path()
227
+
228
+ cmd = [
229
+ chrome_exe,
230
+ f"--remote-debugging-port={port}",
231
+ f"--user-data-dir={profile_dir}",
232
+ "--profile-directory=Default",
233
+ "--no-first-run",
234
+ "--no-default-browser-check",
235
+ "--window-size=1024,768",
236
+ "--disable-session-crashed-bubble",
237
+ "--disable-features=InfiniteSessionRestore,PasswordManagerOnboarding",
238
+ "--hide-crash-restore-bubble",
239
+ "--noerrdialogs",
240
+ "--password-store=basic",
241
+ "--disable-save-password-bubble",
242
+ "--disable-popup-blocking",
243
+ # Block dangerous permissions at browser level
244
+ "--use-fake-device-for-media-stream",
245
+ "--use-fake-ui-for-media-stream",
246
+ "--deny-permission-prompts",
247
+ "--disable-notifications",
248
+ ]
249
+ if headless:
250
+ cmd.append("--headless=new")
251
+
252
+ # On Unix, start in a new process group so we can kill the whole tree
253
+ kwargs: dict = dict(stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
254
+ if platform.system() != "Windows":
255
+ import os
256
+ kwargs["preexec_fn"] = os.setsid
257
+
258
+ proc = subprocess.Popen(cmd, **kwargs)
259
+ with _chrome_lock:
260
+ _chrome_procs[worker_id] = proc
261
+
262
+ # Wait for Chrome to open the CDP debug port (up to 15s)
263
+ _wait_for_cdp(port, timeout=15, worker_id=worker_id)
264
+ logger.info("[worker-%d] Chrome started on port %d (pid %d)",
265
+ worker_id, port, proc.pid)
266
+ return proc
267
+
268
+
269
+ def _wait_for_cdp(port: int, timeout: int = 15, worker_id: int = 0) -> None:
270
+ """Poll http://localhost:{port}/json/version until Chrome is ready.
271
+
272
+ Raises RuntimeError if Chrome doesn't respond within timeout seconds.
273
+ This prevents @playwright/mcp from falling back to headless Chromium
274
+ (which has no cookies) when Chrome is slow to start.
275
+ """
276
+ url = f"http://localhost:{port}/json/version"
277
+ deadline = time.time() + timeout
278
+ last_err = None
279
+ while time.time() < deadline:
280
+ try:
281
+ with urllib.request.urlopen(url, timeout=2) as resp:
282
+ if resp.status == 200:
283
+ logger.info("[worker-%d] CDP ready on port %d", worker_id, port)
284
+ return
285
+ except Exception as e:
286
+ last_err = e
287
+ time.sleep(0.5)
288
+ raise RuntimeError(
289
+ f"[worker-{worker_id}] Chrome CDP port {port} not ready after {timeout}s: {last_err}"
290
+ )
291
+
292
+
293
+ def cleanup_worker(worker_id: int, process: subprocess.Popen | None) -> None:
294
+ """Kill a worker's Chrome instance and remove it from tracking.
295
+
296
+ Args:
297
+ worker_id: Numeric worker identifier.
298
+ process: The Popen handle returned by launch_chrome.
299
+ """
300
+ if process and process.poll() is None:
301
+ _kill_process_tree(process.pid)
302
+ with _chrome_lock:
303
+ _chrome_procs.pop(worker_id, None)
304
+ logger.info("[worker-%d] Chrome cleaned up", worker_id)
305
+
306
+
307
+ def kill_all_chrome() -> None:
308
+ """Kill all Chrome instances and any port zombies.
309
+
310
+ Called during graceful shutdown to ensure no orphan Chrome processes.
311
+ """
312
+ with _chrome_lock:
313
+ procs = dict(_chrome_procs)
314
+ _chrome_procs.clear()
315
+
316
+ for wid, proc in procs.items():
317
+ if proc.poll() is None:
318
+ _kill_process_tree(proc.pid)
319
+ _kill_on_port(BASE_CDP_PORT + wid)
320
+
321
+ # Sweep base port in case of zombies
322
+ _kill_on_port(BASE_CDP_PORT)
323
+
324
+
325
+ def reset_worker_dir(worker_id: int) -> Path:
326
+ """Wipe and recreate a worker's isolated working directory.
327
+
328
+ Each job gets a fresh working directory so that file conflicts
329
+ (resume PDFs, MCP configs) don't bleed between jobs.
330
+
331
+ Args:
332
+ worker_id: Numeric worker identifier.
333
+
334
+ Returns:
335
+ Path to the clean worker directory.
336
+ """
337
+ worker_dir = config.APPLY_WORKER_DIR / f"worker-{worker_id}"
338
+ if worker_dir.exists():
339
+ shutil.rmtree(str(worker_dir), ignore_errors=True)
340
+ worker_dir.mkdir(parents=True, exist_ok=True)
341
+ return worker_dir
342
+
343
+
344
+ def cleanup_on_exit() -> None:
345
+ """Atexit handler: kill all Chrome processes and sweep CDP ports.
346
+
347
+ Register this with atexit.register() at application startup.
348
+ """
349
+ with _chrome_lock:
350
+ procs = dict(_chrome_procs)
351
+ _chrome_procs.clear()
352
+
353
+ for wid, proc in procs.items():
354
+ if proc.poll() is None:
355
+ _kill_process_tree(proc.pid)
356
+ _kill_on_port(BASE_CDP_PORT + wid)
357
+
358
+ # Sweep base port for any orphan
359
+ _kill_on_port(BASE_CDP_PORT)
360
+
@@ -0,0 +1,203 @@
1
+ """Rich live dashboard for the apply pipeline.
2
+
3
+ Displays real-time worker status, job progress, and recent events
4
+ in a terminal dashboard using the Rich library.
5
+ """
6
+
7
+ import logging
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from rich.console import Group
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class WorkerState:
24
+ """Tracks the current state of the apply worker."""
25
+
26
+ worker_id: int = 0
27
+ status: str = "starting" # starting, applying, applied, failed, expired, captcha, idle, done
28
+ job_title: str = ""
29
+ company: str = ""
30
+ score: int = 0
31
+ start_time: float = 0.0
32
+ actions: int = 0
33
+ last_action: str = ""
34
+ jobs_applied: int = 0
35
+ jobs_failed: int = 0
36
+ jobs_done: int = 0
37
+ total_cost: float = 0.0
38
+ log_file: Path | None = None
39
+
40
+
41
+ # Module-level state (thread-safe via _lock)
42
+ _worker_states: dict[int, WorkerState] = {}
43
+ _events: list[str] = []
44
+ _lock = threading.Lock()
45
+ MAX_EVENTS = 8
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # State mutation helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def init_worker(worker_id: int = 0) -> None:
53
+ """Register the worker in the dashboard state."""
54
+ with _lock:
55
+ _worker_states[worker_id] = WorkerState(worker_id=worker_id)
56
+
57
+
58
+ def update_state(worker_id: int = 0, **kwargs) -> None:
59
+ """Update the worker's state fields.
60
+
61
+ Args:
62
+ worker_id: Which worker to update.
63
+ **kwargs: Field names and values to set on WorkerState.
64
+ """
65
+ with _lock:
66
+ state = _worker_states.get(worker_id)
67
+ if state is not None:
68
+ for key, value in kwargs.items():
69
+ setattr(state, key, value)
70
+
71
+
72
+ def get_state(worker_id: int = 0) -> WorkerState | None:
73
+ """Read the worker's current state."""
74
+ with _lock:
75
+ return _worker_states.get(worker_id)
76
+
77
+
78
+ def add_event(msg: str) -> None:
79
+ """Add a timestamped event to the scrolling event log.
80
+
81
+ Args:
82
+ msg: Rich markup string describing the event.
83
+ """
84
+ ts = datetime.now().strftime("%H:%M:%S")
85
+ with _lock:
86
+ _events.append(f"[dim]{ts}[/dim] {msg}")
87
+ if len(_events) > MAX_EVENTS:
88
+ _events.pop(0)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Rendering
93
+ # ---------------------------------------------------------------------------
94
+
95
+ # Status -> Rich style mapping
96
+ _STATUS_STYLES: dict[str, str] = {
97
+ "starting": "dim",
98
+ "idle": "dim",
99
+ "applying": "yellow",
100
+ "applied": "bold green",
101
+ "failed": "red",
102
+ "expired": "dim red",
103
+ "captcha": "magenta",
104
+ "login_issue": "red",
105
+ "done": "bold",
106
+ }
107
+
108
+
109
+ def render_dashboard() -> Table:
110
+ """Build the Rich table showing all worker statuses.
111
+
112
+ Returns:
113
+ A Rich Table object ready for display.
114
+ """
115
+ table = Table(title="DivApply Dashboard", expand=True, show_lines=False)
116
+ table.add_column("W", style="bold", width=3, justify="center")
117
+ table.add_column("Job", min_width=30, max_width=50, no_wrap=True)
118
+ table.add_column("Status", width=12, justify="center")
119
+ table.add_column("Time", width=6, justify="right")
120
+ table.add_column("Acts", width=5, justify="right")
121
+ table.add_column("Last Action", min_width=20, max_width=35, no_wrap=True)
122
+ table.add_column("OK", width=4, justify="right", style="green")
123
+ table.add_column("Fail", width=4, justify="right", style="red")
124
+ table.add_column("Cost", width=8, justify="right")
125
+
126
+ with _lock:
127
+ states = sorted(_worker_states.values(), key=lambda s: s.worker_id)
128
+
129
+ total_applied = 0
130
+ total_failed = 0
131
+ total_cost = 0.0
132
+
133
+ for s in states:
134
+ elapsed = ""
135
+ if s.start_time and s.status == "applying":
136
+ elapsed = f"{int(time.time() - s.start_time)}s"
137
+
138
+ style = _STATUS_STYLES.get(s.status, "")
139
+ status_text = Text(s.status.upper(), style=style)
140
+
141
+ job_text = f"{s.job_title[:28]} @ {s.company[:16]}" if s.job_title else ""
142
+
143
+ table.add_row(
144
+ str(s.worker_id),
145
+ job_text,
146
+ status_text,
147
+ elapsed,
148
+ str(s.actions) if s.actions else "",
149
+ s.last_action[:35] if s.last_action else "",
150
+ str(s.jobs_applied),
151
+ str(s.jobs_failed),
152
+ f"${s.total_cost:.3f}" if s.total_cost else "",
153
+ )
154
+ total_applied += s.jobs_applied
155
+ total_failed += s.jobs_failed
156
+ total_cost += s.total_cost
157
+
158
+ # Totals row
159
+ table.add_section()
160
+ table.add_row(
161
+ "", "", "", "", "", "TOTAL",
162
+ str(total_applied), str(total_failed), f"${total_cost:.3f}",
163
+ style="bold",
164
+ )
165
+
166
+ return table
167
+
168
+
169
+ def render_full() -> Table | Group:
170
+ """Render the dashboard table plus the recent events panel.
171
+
172
+ Returns:
173
+ A Rich Group (table + events panel) or just the table if no events.
174
+ """
175
+ table = render_dashboard()
176
+
177
+ with _lock:
178
+ event_lines = list(_events)
179
+
180
+ if event_lines:
181
+ event_text = Text.from_markup("\n".join(event_lines))
182
+ events_panel = Panel(
183
+ event_text,
184
+ title="Recent Events",
185
+ border_style="dim",
186
+ height=min(MAX_EVENTS + 2, len(event_lines) + 2),
187
+ )
188
+ return Group(table, events_panel)
189
+
190
+ return table
191
+
192
+
193
+ def get_totals() -> dict[str, int | float]:
194
+ """Compute aggregate totals across all workers.
195
+
196
+ Returns:
197
+ Dict with keys: applied, failed, cost.
198
+ """
199
+ with _lock:
200
+ applied = sum(s.jobs_applied for s in _worker_states.values())
201
+ failed = sum(s.jobs_failed for s in _worker_states.values())
202
+ cost = sum(s.total_cost for s in _worker_states.values())
203
+ return {"applied": applied, "failed": failed, "cost": cost}