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,557 @@
|
|
|
1
|
+
"""JobSpy-based job discovery: searches Indeed, LinkedIn, Glassdoor, ZipRecruiter.
|
|
2
|
+
|
|
3
|
+
Uses python-jobspy to scrape multiple job boards, deduplicates results,
|
|
4
|
+
parses salary ranges, and stores everything in the DivApply database.
|
|
5
|
+
|
|
6
|
+
Search queries, locations, and filtering rules are loaded from the user's
|
|
7
|
+
search configuration YAML (searches.yaml) rather than being hardcoded.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from jobspy import scrape_jobs
|
|
18
|
+
|
|
19
|
+
from divapply import config
|
|
20
|
+
from divapply.database import get_connection, init_db, store_jobs
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# -- Proxy parsing -----------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def parse_proxy(proxy_str: str) -> dict:
|
|
28
|
+
"""Parse host:port:user:pass into components."""
|
|
29
|
+
parts = proxy_str.split(":")
|
|
30
|
+
if len(parts) == 4:
|
|
31
|
+
host, port, user, passwd = parts
|
|
32
|
+
return {
|
|
33
|
+
"host": host,
|
|
34
|
+
"port": port,
|
|
35
|
+
"user": user,
|
|
36
|
+
"pass": passwd,
|
|
37
|
+
"jobspy": f"{user}:{passwd}@{host}:{port}",
|
|
38
|
+
"playwright": {
|
|
39
|
+
"server": f"http://{host}:{port}",
|
|
40
|
+
"username": user,
|
|
41
|
+
"password": passwd,
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
elif len(parts) == 2:
|
|
45
|
+
host, port = parts
|
|
46
|
+
return {
|
|
47
|
+
"host": host,
|
|
48
|
+
"port": port,
|
|
49
|
+
"user": None,
|
|
50
|
+
"pass": None,
|
|
51
|
+
"jobspy": f"{host}:{port}",
|
|
52
|
+
"playwright": {"server": f"http://{host}:{port}"},
|
|
53
|
+
}
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Proxy format not recognized: {proxy_str}. "
|
|
57
|
+
f"Expected: host:port:user:pass or host:port"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# -- Retry wrapper -----------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _scrape_with_retry(kwargs: dict, max_retries: int = 2, backoff: float = 5.0):
|
|
64
|
+
"""Call scrape_jobs with retry on transient failures."""
|
|
65
|
+
for attempt in range(max_retries + 1):
|
|
66
|
+
try:
|
|
67
|
+
return scrape_jobs(**kwargs)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
err = str(e).lower()
|
|
70
|
+
transient = any(k in err for k in ("timeout", "429", "proxy", "connection", "reset", "refused"))
|
|
71
|
+
if transient and attempt < max_retries:
|
|
72
|
+
wait = backoff * (attempt + 1)
|
|
73
|
+
log.warning("Retry %d/%d in %.0fs: %s", attempt + 1, max_retries, wait, e)
|
|
74
|
+
time.sleep(wait)
|
|
75
|
+
else:
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -- Location filtering ------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _load_location_config(search_cfg: dict) -> tuple[list[str], list[str]]:
|
|
82
|
+
"""Extract accept/reject location lists from search config.
|
|
83
|
+
|
|
84
|
+
Falls back to sensible defaults if not defined in the YAML.
|
|
85
|
+
"""
|
|
86
|
+
accept = search_cfg.get("location_accept", [])
|
|
87
|
+
reject = search_cfg.get("location_reject_non_remote", [])
|
|
88
|
+
return accept, reject
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_title_excludes(search_cfg: dict) -> list[str]:
|
|
92
|
+
"""Load title exclusion patterns from search config (case-insensitive)."""
|
|
93
|
+
return [t.lower() for t in search_cfg.get("exclude_titles", [])]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _title_ok(title: str | None, excludes: list[str]) -> bool:
|
|
97
|
+
"""Return False if title matches any exclude pattern."""
|
|
98
|
+
if not title or not excludes:
|
|
99
|
+
return True
|
|
100
|
+
t = title.lower()
|
|
101
|
+
return not any(ex in t for ex in excludes)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _location_ok(location: str | None, accept: list[str], reject: list[str]) -> bool:
|
|
105
|
+
"""Check if a job location passes the user's location filter.
|
|
106
|
+
|
|
107
|
+
Remote jobs are always accepted. Non-remote jobs must match an accept
|
|
108
|
+
pattern and not match a reject pattern.
|
|
109
|
+
"""
|
|
110
|
+
if not location:
|
|
111
|
+
return True # unknown location -- keep it, let scorer decide
|
|
112
|
+
|
|
113
|
+
loc = location.lower()
|
|
114
|
+
|
|
115
|
+
# Remote jobs always OK
|
|
116
|
+
if any(r in loc for r in ("remote", "anywhere", "work from home", "wfh", "distributed")):
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
# Reject non-remote matches
|
|
120
|
+
for r in reject:
|
|
121
|
+
if r.lower() in loc:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
# Accept matches
|
|
125
|
+
for a in accept:
|
|
126
|
+
if a.lower() in loc:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
# No match -- reject unknown
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# -- DB storage (JobSpy DataFrame -> SQLite) ---------------------------------
|
|
134
|
+
|
|
135
|
+
def store_jobspy_results(conn: sqlite3.Connection, df, source_label: str) -> tuple[int, int]:
|
|
136
|
+
"""Store JobSpy DataFrame results into the DB. Returns (new, existing)."""
|
|
137
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
138
|
+
new = 0
|
|
139
|
+
existing = 0
|
|
140
|
+
|
|
141
|
+
for _, row in df.iterrows():
|
|
142
|
+
url = str(row.get("job_url", ""))
|
|
143
|
+
if not url or url == "nan":
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
title = str(row.get("title", "")) if str(row.get("title", "")) != "nan" else None
|
|
147
|
+
company = str(row.get("company", "")) if str(row.get("company", "")) != "nan" else None
|
|
148
|
+
location_str = str(row.get("location", "")) if str(row.get("location", "")) != "nan" else None
|
|
149
|
+
|
|
150
|
+
# Build salary string from min/max
|
|
151
|
+
salary = None
|
|
152
|
+
min_amt = row.get("min_amount")
|
|
153
|
+
max_amt = row.get("max_amount")
|
|
154
|
+
interval = str(row.get("interval", "")) if str(row.get("interval", "")) != "nan" else ""
|
|
155
|
+
currency = str(row.get("currency", "")) if str(row.get("currency", "")) != "nan" else ""
|
|
156
|
+
if min_amt and str(min_amt) != "nan":
|
|
157
|
+
if max_amt and str(max_amt) != "nan":
|
|
158
|
+
salary = f"{currency}{int(float(min_amt)):,}-{currency}{int(float(max_amt)):,}"
|
|
159
|
+
else:
|
|
160
|
+
salary = f"{currency}{int(float(min_amt)):,}"
|
|
161
|
+
if interval:
|
|
162
|
+
salary += f"/{interval}"
|
|
163
|
+
|
|
164
|
+
description = str(row.get("description", "")) if str(row.get("description", "")) != "nan" else None
|
|
165
|
+
site_name = str(row.get("site", source_label))
|
|
166
|
+
is_remote = row.get("is_remote", False)
|
|
167
|
+
|
|
168
|
+
site_label = f"{site_name}"
|
|
169
|
+
if is_remote:
|
|
170
|
+
location_str = f"{location_str} (Remote)" if location_str else "Remote"
|
|
171
|
+
|
|
172
|
+
strategy = "jobspy"
|
|
173
|
+
|
|
174
|
+
# If JobSpy gave us a full description, promote it directly
|
|
175
|
+
full_description = None
|
|
176
|
+
detail_scraped_at = None
|
|
177
|
+
if description and len(description) > 200:
|
|
178
|
+
full_description = description
|
|
179
|
+
detail_scraped_at = now
|
|
180
|
+
|
|
181
|
+
# Extract apply URL if JobSpy provided it
|
|
182
|
+
apply_url = str(row.get("job_url_direct", "")) if str(row.get("job_url_direct", "")) != "nan" else None
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
conn.execute(
|
|
186
|
+
"INSERT INTO jobs (url, title, salary, description, location, site, strategy, discovered_at, "
|
|
187
|
+
"full_description, application_url, detail_scraped_at) "
|
|
188
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
189
|
+
(url, title, salary, description, location_str, site_label, strategy, now,
|
|
190
|
+
full_description, apply_url, detail_scraped_at),
|
|
191
|
+
)
|
|
192
|
+
new += 1
|
|
193
|
+
except sqlite3.IntegrityError:
|
|
194
|
+
existing += 1
|
|
195
|
+
|
|
196
|
+
conn.commit()
|
|
197
|
+
return new, existing
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# -- Single search execution -------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _run_one_search(
|
|
203
|
+
search: dict,
|
|
204
|
+
sites: list[str],
|
|
205
|
+
results_per_site: int,
|
|
206
|
+
hours_old: int,
|
|
207
|
+
proxy_config: dict | None,
|
|
208
|
+
defaults: dict,
|
|
209
|
+
max_retries: int,
|
|
210
|
+
accept_locs: list[str],
|
|
211
|
+
reject_locs: list[str],
|
|
212
|
+
glassdoor_map: dict,
|
|
213
|
+
title_excludes: list[str] | None = None,
|
|
214
|
+
) -> dict:
|
|
215
|
+
"""Run a single search query and store results in DB."""
|
|
216
|
+
s = search
|
|
217
|
+
label = f"\"{s['query']}\" in {s['location']} {'(remote)' if s.get('remote') else ''}"
|
|
218
|
+
if "tier" in s:
|
|
219
|
+
label += f" [tier {s['tier']}]"
|
|
220
|
+
|
|
221
|
+
# Split sites: Glassdoor needs simplified location, others use original
|
|
222
|
+
gd_location = glassdoor_map.get(s["location"], s["location"].split(",")[0])
|
|
223
|
+
has_glassdoor = "glassdoor" in sites
|
|
224
|
+
other_sites = [si for si in sites if si != "glassdoor"]
|
|
225
|
+
|
|
226
|
+
all_dfs = []
|
|
227
|
+
|
|
228
|
+
# Run non-Glassdoor sites with original location
|
|
229
|
+
if other_sites:
|
|
230
|
+
kwargs = {
|
|
231
|
+
"site_name": other_sites,
|
|
232
|
+
"search_term": s["query"],
|
|
233
|
+
"location": s["location"],
|
|
234
|
+
"results_wanted": results_per_site,
|
|
235
|
+
"hours_old": hours_old,
|
|
236
|
+
"description_format": "markdown",
|
|
237
|
+
"country_indeed": defaults.get("country_indeed", "usa"),
|
|
238
|
+
"verbose": 0,
|
|
239
|
+
}
|
|
240
|
+
if s.get("remote"):
|
|
241
|
+
kwargs["is_remote"] = True
|
|
242
|
+
if proxy_config:
|
|
243
|
+
kwargs["proxies"] = [proxy_config["jobspy"]]
|
|
244
|
+
if "linkedin" in other_sites:
|
|
245
|
+
kwargs["linkedin_fetch_description"] = True
|
|
246
|
+
try:
|
|
247
|
+
df = _scrape_with_retry(kwargs, max_retries=max_retries)
|
|
248
|
+
all_dfs.append(df)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
log.error("[%s] (non-gd): %s", label, e)
|
|
251
|
+
|
|
252
|
+
# Run Glassdoor separately with simplified location
|
|
253
|
+
if has_glassdoor:
|
|
254
|
+
gd_kwargs = {
|
|
255
|
+
"site_name": ["glassdoor"],
|
|
256
|
+
"search_term": s["query"],
|
|
257
|
+
"location": gd_location,
|
|
258
|
+
"results_wanted": results_per_site,
|
|
259
|
+
"hours_old": hours_old,
|
|
260
|
+
"description_format": "markdown",
|
|
261
|
+
"verbose": 0,
|
|
262
|
+
}
|
|
263
|
+
if s.get("remote"):
|
|
264
|
+
gd_kwargs["is_remote"] = True
|
|
265
|
+
if proxy_config:
|
|
266
|
+
gd_kwargs["proxies"] = [proxy_config["jobspy"]]
|
|
267
|
+
try:
|
|
268
|
+
gd_df = _scrape_with_retry(gd_kwargs, max_retries=max_retries)
|
|
269
|
+
all_dfs.append(gd_df)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
log.error("[%s] (glassdoor): %s", label, e)
|
|
272
|
+
|
|
273
|
+
if not all_dfs:
|
|
274
|
+
log.error("[%s]: all sites failed", label)
|
|
275
|
+
return {"new": 0, "existing": 0, "errors": 1, "filtered": 0, "total": 0, "label": label}
|
|
276
|
+
|
|
277
|
+
import pandas as pd
|
|
278
|
+
import warnings
|
|
279
|
+
with warnings.catch_warnings():
|
|
280
|
+
warnings.simplefilter("ignore", FutureWarning)
|
|
281
|
+
df = pd.concat(all_dfs, ignore_index=True) if len(all_dfs) > 1 else all_dfs[0]
|
|
282
|
+
|
|
283
|
+
if len(df) == 0:
|
|
284
|
+
log.info("[%s] 0 results", label)
|
|
285
|
+
return {"new": 0, "existing": 0, "errors": 0, "filtered": 0, "total": 0, "label": label}
|
|
286
|
+
|
|
287
|
+
# Filter by location before storing
|
|
288
|
+
before = len(df)
|
|
289
|
+
df = df[df.apply(lambda row: _location_ok(
|
|
290
|
+
str(row.get("location", "")) if str(row.get("location", "")) != "nan" else None,
|
|
291
|
+
accept_locs, reject_locs,
|
|
292
|
+
), axis=1)]
|
|
293
|
+
filtered_loc = before - len(df)
|
|
294
|
+
|
|
295
|
+
# Filter by title exclusion list
|
|
296
|
+
filtered_title = 0
|
|
297
|
+
if title_excludes:
|
|
298
|
+
before_title = len(df)
|
|
299
|
+
df = df[df.apply(lambda row: _title_ok(
|
|
300
|
+
str(row.get("title", "")) if str(row.get("title", "")) != "nan" else None,
|
|
301
|
+
title_excludes,
|
|
302
|
+
), axis=1)]
|
|
303
|
+
filtered_title = before_title - len(df)
|
|
304
|
+
|
|
305
|
+
conn = get_connection()
|
|
306
|
+
new, existing = store_jobspy_results(conn, df, s["query"])
|
|
307
|
+
|
|
308
|
+
msg = f"[{label}] {before} results -> {new} new, {existing} dupes"
|
|
309
|
+
if filtered_loc:
|
|
310
|
+
msg += f", {filtered_loc} filtered (location)"
|
|
311
|
+
if filtered_title:
|
|
312
|
+
msg += f", {filtered_title} filtered (title)"
|
|
313
|
+
log.info(msg)
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
"new": new,
|
|
317
|
+
"existing": existing,
|
|
318
|
+
"errors": 0,
|
|
319
|
+
"filtered": filtered_loc + filtered_title,
|
|
320
|
+
"total": before,
|
|
321
|
+
"label": label,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# -- Single query search -----------------------------------------------------
|
|
326
|
+
|
|
327
|
+
def search_jobs(
|
|
328
|
+
query: str,
|
|
329
|
+
location: str,
|
|
330
|
+
sites: list[str] | None = None,
|
|
331
|
+
remote_only: bool = False,
|
|
332
|
+
results_per_site: int = 50,
|
|
333
|
+
hours_old: int = 72,
|
|
334
|
+
proxy: str | None = None,
|
|
335
|
+
country_indeed: str = "usa",
|
|
336
|
+
) -> dict:
|
|
337
|
+
"""Run a single job search via JobSpy and store results in DB."""
|
|
338
|
+
if sites is None:
|
|
339
|
+
sites = ["indeed", "linkedin", "zip_recruiter"]
|
|
340
|
+
|
|
341
|
+
proxy_config = parse_proxy(proxy) if proxy else None
|
|
342
|
+
|
|
343
|
+
log.info("Search: \"%s\" in %s | sites=%s | remote=%s", query, location, sites, remote_only)
|
|
344
|
+
|
|
345
|
+
kwargs = {
|
|
346
|
+
"site_name": sites,
|
|
347
|
+
"search_term": query,
|
|
348
|
+
"location": location,
|
|
349
|
+
"results_wanted": results_per_site,
|
|
350
|
+
"hours_old": hours_old,
|
|
351
|
+
"description_format": "markdown",
|
|
352
|
+
"country_indeed": country_indeed,
|
|
353
|
+
"verbose": 2,
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if remote_only:
|
|
357
|
+
kwargs["is_remote"] = True
|
|
358
|
+
|
|
359
|
+
if proxy_config:
|
|
360
|
+
kwargs["proxies"] = [proxy_config["jobspy"]]
|
|
361
|
+
|
|
362
|
+
if "linkedin" in sites:
|
|
363
|
+
kwargs["linkedin_fetch_description"] = True
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
df = scrape_jobs(**kwargs)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
log.error("JobSpy search failed: %s", e)
|
|
369
|
+
return {"error": str(e), "total": 0, "new": 0, "existing": 0}
|
|
370
|
+
|
|
371
|
+
total = len(df)
|
|
372
|
+
log.info("JobSpy returned %d results", total)
|
|
373
|
+
|
|
374
|
+
if total == 0:
|
|
375
|
+
return {"total": 0, "new": 0, "existing": 0}
|
|
376
|
+
|
|
377
|
+
if "site" in df.columns:
|
|
378
|
+
site_counts = df["site"].value_counts()
|
|
379
|
+
for site, count in site_counts.items():
|
|
380
|
+
log.info(" %s: %d", site, count)
|
|
381
|
+
|
|
382
|
+
conn = init_db()
|
|
383
|
+
new, existing = store_jobspy_results(conn, df, query)
|
|
384
|
+
log.info("Stored: %d new, %d already in DB", new, existing)
|
|
385
|
+
|
|
386
|
+
db_total = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
|
|
387
|
+
pending = conn.execute("SELECT COUNT(*) FROM jobs WHERE detail_scraped_at IS NULL").fetchone()[0]
|
|
388
|
+
log.info("DB total: %d jobs, %d pending detail scrape", db_total, pending)
|
|
389
|
+
|
|
390
|
+
return {"total": total, "new": new, "existing": existing}
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# -- Full crawl (all queries x all locations) --------------------------------
|
|
394
|
+
|
|
395
|
+
def _full_crawl(
|
|
396
|
+
search_cfg: dict,
|
|
397
|
+
tiers: list[int] | None = None,
|
|
398
|
+
locations: list[str] | None = None,
|
|
399
|
+
sites: list[str] | None = None,
|
|
400
|
+
results_per_site: int = 100,
|
|
401
|
+
hours_old: int = 72,
|
|
402
|
+
proxy: str | None = None,
|
|
403
|
+
max_retries: int = 2,
|
|
404
|
+
workers: int = 4,
|
|
405
|
+
) -> dict:
|
|
406
|
+
"""Run all search queries from search config across all locations.
|
|
407
|
+
|
|
408
|
+
Workers > 1 runs multiple searches in parallel (network I/O bound).
|
|
409
|
+
Each worker uses a randomised delay so job boards see staggered requests.
|
|
410
|
+
"""
|
|
411
|
+
if sites is None:
|
|
412
|
+
sites = ["indeed", "linkedin", "zip_recruiter"]
|
|
413
|
+
|
|
414
|
+
# Build search combinations from config
|
|
415
|
+
queries = search_cfg.get("queries", [])
|
|
416
|
+
locs = search_cfg.get("locations", [])
|
|
417
|
+
defaults = search_cfg.get("defaults", {})
|
|
418
|
+
glassdoor_map = search_cfg.get("glassdoor_location_map", {})
|
|
419
|
+
accept_locs, reject_locs = _load_location_config(search_cfg)
|
|
420
|
+
title_excludes = _load_title_excludes(search_cfg)
|
|
421
|
+
|
|
422
|
+
if tiers:
|
|
423
|
+
queries = [q for q in queries if q.get("tier") in tiers]
|
|
424
|
+
if locations:
|
|
425
|
+
locs = [loc for loc in locs if loc.get("label") in locations]
|
|
426
|
+
|
|
427
|
+
searches = []
|
|
428
|
+
for q in queries:
|
|
429
|
+
for loc in locs:
|
|
430
|
+
searches.append({
|
|
431
|
+
"query": q["query"],
|
|
432
|
+
"location": loc["location"],
|
|
433
|
+
"remote": loc.get("remote", False),
|
|
434
|
+
"tier": q.get("tier", 0),
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
# Support per-worker proxy rotation: proxy can be a single string or
|
|
438
|
+
# a comma-separated list ("host:port:user:pass,host2:port2:user2:pass2").
|
|
439
|
+
proxy_list: list[dict | None] = [None]
|
|
440
|
+
if proxy:
|
|
441
|
+
raw_proxies = [p.strip() for p in proxy.split(",") if p.strip()]
|
|
442
|
+
proxy_list = [parse_proxy(p) for p in raw_proxies]
|
|
443
|
+
|
|
444
|
+
log.info("Full crawl: %d search combinations | workers=%d | proxies=%d",
|
|
445
|
+
len(searches), workers, len([p for p in proxy_list if p]))
|
|
446
|
+
log.info("Sites: %s | Results/site: %d | Hours old: %d",
|
|
447
|
+
", ".join(sites), results_per_site, hours_old)
|
|
448
|
+
|
|
449
|
+
# Ensure DB schema is ready
|
|
450
|
+
init_db()
|
|
451
|
+
|
|
452
|
+
_lock = threading.Lock()
|
|
453
|
+
total_new = 0
|
|
454
|
+
total_existing = 0
|
|
455
|
+
total_errors = 0
|
|
456
|
+
completed = 0
|
|
457
|
+
|
|
458
|
+
def _run_search(idx_search: tuple[int, dict]) -> dict:
|
|
459
|
+
import random
|
|
460
|
+
idx, s = idx_search
|
|
461
|
+
# Round-robin proxy assignment per search
|
|
462
|
+
proxy_cfg = proxy_list[idx % len(proxy_list)]
|
|
463
|
+
# Stagger start times so workers don't all hit the same site at once
|
|
464
|
+
time.sleep(random.uniform(0, 1.5) * (idx % workers))
|
|
465
|
+
return _run_one_search(
|
|
466
|
+
s, sites, results_per_site, hours_old,
|
|
467
|
+
proxy_cfg, defaults, max_retries,
|
|
468
|
+
accept_locs, reject_locs, glassdoor_map,
|
|
469
|
+
title_excludes,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if workers > 1:
|
|
473
|
+
with ThreadPoolExecutor(max_workers=workers,
|
|
474
|
+
thread_name_prefix="jobspy") as pool:
|
|
475
|
+
futures = {
|
|
476
|
+
pool.submit(_run_search, (i, s)): s
|
|
477
|
+
for i, s in enumerate(searches)
|
|
478
|
+
}
|
|
479
|
+
for future in as_completed(futures):
|
|
480
|
+
result = future.result()
|
|
481
|
+
with _lock:
|
|
482
|
+
total_new += result["new"]
|
|
483
|
+
total_existing += result["existing"]
|
|
484
|
+
total_errors += result["errors"]
|
|
485
|
+
completed += 1
|
|
486
|
+
if completed % 5 == 0 or completed == len(searches):
|
|
487
|
+
log.info("Progress: %d/%d queries done (%d new, %d dupes, %d errors)",
|
|
488
|
+
completed, len(searches), total_new, total_existing, total_errors)
|
|
489
|
+
else:
|
|
490
|
+
for i, s in enumerate(searches):
|
|
491
|
+
result = _run_search((i, s))
|
|
492
|
+
total_new += result["new"]
|
|
493
|
+
total_existing += result["existing"]
|
|
494
|
+
total_errors += result["errors"]
|
|
495
|
+
completed += 1
|
|
496
|
+
if completed % 5 == 0 or completed == len(searches):
|
|
497
|
+
log.info("Progress: %d/%d queries done (%d new, %d dupes, %d errors)",
|
|
498
|
+
completed, len(searches), total_new, total_existing, total_errors)
|
|
499
|
+
|
|
500
|
+
# Final stats
|
|
501
|
+
conn = get_connection()
|
|
502
|
+
db_total = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
|
|
503
|
+
|
|
504
|
+
log.info("Full crawl complete: %d new | %d dupes | %d errors | %d total in DB",
|
|
505
|
+
total_new, total_existing, total_errors, db_total)
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
"new": total_new,
|
|
509
|
+
"existing": total_existing,
|
|
510
|
+
"errors": total_errors,
|
|
511
|
+
"db_total": db_total,
|
|
512
|
+
"queries": len(searches),
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# -- Public entry point ------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
def run_discovery(cfg: dict | None = None, workers: int = 4) -> dict:
|
|
519
|
+
"""Main entry point for JobSpy-based job discovery.
|
|
520
|
+
|
|
521
|
+
Loads search queries and locations from the user's search config YAML,
|
|
522
|
+
then runs a full crawl across all configured job boards.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
cfg: Override the search configuration dict. If None, loads from
|
|
526
|
+
the user's searches.yaml file.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Dict with stats: new, existing, errors, db_total, queries.
|
|
530
|
+
"""
|
|
531
|
+
if cfg is None:
|
|
532
|
+
cfg = config.load_search_config()
|
|
533
|
+
|
|
534
|
+
if not cfg:
|
|
535
|
+
log.warning("No search configuration found. Run `divapply init` to create one.")
|
|
536
|
+
return {"new": 0, "existing": 0, "errors": 0, "db_total": 0, "queries": 0}
|
|
537
|
+
|
|
538
|
+
proxy = cfg.get("proxy")
|
|
539
|
+
sites = cfg.get("sites")
|
|
540
|
+
results_per_site = cfg.get("defaults", {}).get("results_per_site", 100)
|
|
541
|
+
hours_old = cfg.get("defaults", {}).get("hours_old", 72)
|
|
542
|
+
tiers = cfg.get("tiers")
|
|
543
|
+
locations = cfg.get("location_labels")
|
|
544
|
+
|
|
545
|
+
crawl_workers = cfg.get("defaults", {}).get("workers", workers)
|
|
546
|
+
|
|
547
|
+
return _full_crawl(
|
|
548
|
+
search_cfg=cfg,
|
|
549
|
+
tiers=tiers,
|
|
550
|
+
locations=locations,
|
|
551
|
+
sites=sites,
|
|
552
|
+
results_per_site=results_per_site,
|
|
553
|
+
hours_old=hours_old,
|
|
554
|
+
proxy=proxy,
|
|
555
|
+
workers=crawl_workers,
|
|
556
|
+
)
|
|
557
|
+
|