Spaces:
Sleeping
Sleeping
Commit Β·
39623f2
1
Parent(s): 5f5a649
Fix location/freshness filters and make ever-jobs URL configurable
Browse files- Add src/geo_filter.py and apply it during scraping to drop jobs outside the user's selected locations (stops international jobs leaking in for India searches; respects Remote/Worldwide)
- Wire the user's days_posted into scrapers: LinkedIn f_TPR and ever-jobs hours_old were hardcoded to 7 days and ignored the freshness selection
- Make the ever-jobs sidecar URL env-configurable (EVER_JOBS_API_URL) across config/client/server so the 150+ extra platforms can use a hosted sidecar on HF Spaces; skip local Docker/npm startup when pointed at a remote URL, with clearer DOWN messaging
Co-authored-by: Cursor <cursoragent@cursor.com>
- config.py +4 -1
- src/ever_jobs_bridge/client.py +2 -1
- src/ever_jobs_bridge/server.py +18 -2
- src/geo_filter.py +107 -0
- src/scrapers/ever_jobs.py +7 -1
- src/scrapers/linkedin.py +10 -1
- ui.py +28 -3
config.py
CHANGED
|
@@ -232,7 +232,10 @@ AUTOMATION = {
|
|
| 232 |
from src.ever_jobs_bridge.platforms import INDIA_DEFAULT_PLATFORMS
|
| 233 |
|
| 234 |
EVER_JOBS = {
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
"default_platforms": INDIA_DEFAULT_PLATFORMS,
|
| 237 |
"max_results": 25,
|
| 238 |
"request_timeout": 30,
|
|
|
|
| 232 |
from src.ever_jobs_bridge.platforms import INDIA_DEFAULT_PLATFORMS
|
| 233 |
|
| 234 |
EVER_JOBS = {
|
| 235 |
+
# Point this at a HOSTED ever-jobs sidecar to enable the 150+ extra
|
| 236 |
+
# platforms on HF Spaces (where a local Node process can't run). Defaults
|
| 237 |
+
# to the local dev sidecar.
|
| 238 |
+
"api_url": os.getenv("EVER_JOBS_API_URL", "http://localhost:3001"),
|
| 239 |
"default_platforms": INDIA_DEFAULT_PLATFORMS,
|
| 240 |
"max_results": 25,
|
| 241 |
"request_timeout": 30,
|
src/ever_jobs_bridge/client.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
|
|
| 1 |
import logging
|
| 2 |
import requests
|
| 3 |
from typing import Optional
|
| 4 |
|
| 5 |
log = logging.getLogger(__name__)
|
| 6 |
|
| 7 |
-
API_BASE = "http://localhost:3001"
|
| 8 |
SEARCH_ENDPOINT = f"{API_BASE}/api/jobs/search"
|
| 9 |
DEFAULT_TIMEOUT = 120
|
| 10 |
DEFAULT_REQUEST_TIMEOUT = 30
|
|
|
|
| 1 |
+
import os
|
| 2 |
import logging
|
| 3 |
import requests
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
log = logging.getLogger(__name__)
|
| 7 |
|
| 8 |
+
API_BASE = os.getenv("EVER_JOBS_API_URL", "http://localhost:3001")
|
| 9 |
SEARCH_ENDPOINT = f"{API_BASE}/api/jobs/search"
|
| 10 |
DEFAULT_TIMEOUT = 120
|
| 11 |
DEFAULT_REQUEST_TIMEOUT = 30
|
src/ever_jobs_bridge/server.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import subprocess
|
| 2 |
import requests
|
| 3 |
import time
|
|
@@ -8,11 +9,21 @@ from pathlib import Path
|
|
| 8 |
log = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
EVER_JOBS_DIR = str(Path(__file__).parent.parent.parent / "vendor" / "ever-jobs")
|
| 11 |
-
API_BASE = "http://localhost:3001"
|
| 12 |
HEALTH_ENDPOINT = f"{API_BASE}/health"
|
| 13 |
_npm_process = None
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
def is_running() -> bool:
|
| 17 |
try:
|
| 18 |
r = requests.get(HEALTH_ENDPOINT, timeout=3)
|
|
@@ -92,9 +103,14 @@ def stop_npm():
|
|
| 92 |
|
| 93 |
def ensure_running() -> tuple[bool, str]:
|
| 94 |
if is_running():
|
| 95 |
-
log.info("ever-jobs API
|
| 96 |
return True, "already_running"
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
log.info("ever-jobs not running β attempting to start...")
|
| 99 |
|
| 100 |
if shutil.which("docker"):
|
|
|
|
| 1 |
+
import os
|
| 2 |
import subprocess
|
| 3 |
import requests
|
| 4 |
import time
|
|
|
|
| 9 |
log = logging.getLogger(__name__)
|
| 10 |
|
| 11 |
EVER_JOBS_DIR = str(Path(__file__).parent.parent.parent / "vendor" / "ever-jobs")
|
| 12 |
+
API_BASE = os.getenv("EVER_JOBS_API_URL", "http://localhost:3001")
|
| 13 |
HEALTH_ENDPOINT = f"{API_BASE}/health"
|
| 14 |
_npm_process = None
|
| 15 |
|
| 16 |
|
| 17 |
+
def _is_remote() -> bool:
|
| 18 |
+
"""True when pointed at a hosted sidecar (not localhost) β e.g. on HF Spaces.
|
| 19 |
+
|
| 20 |
+
In that case we must NOT try to spawn Docker/npm locally; we just health-
|
| 21 |
+
check the remote URL.
|
| 22 |
+
"""
|
| 23 |
+
return bool(os.getenv("EVER_JOBS_API_URL")) and "localhost" not in API_BASE \
|
| 24 |
+
and "127.0.0.1" not in API_BASE
|
| 25 |
+
|
| 26 |
+
|
| 27 |
def is_running() -> bool:
|
| 28 |
try:
|
| 29 |
r = requests.get(HEALTH_ENDPOINT, timeout=3)
|
|
|
|
| 103 |
|
| 104 |
def ensure_running() -> tuple[bool, str]:
|
| 105 |
if is_running():
|
| 106 |
+
log.info(f"ever-jobs API reachable at {API_BASE}")
|
| 107 |
return True, "already_running"
|
| 108 |
|
| 109 |
+
# Hosted sidecar (HF Spaces): we can't start Node locally β just report it.
|
| 110 |
+
if _is_remote():
|
| 111 |
+
log.error(f"ever-jobs remote sidecar unreachable at {API_BASE}")
|
| 112 |
+
return False, "remote_unreachable"
|
| 113 |
+
|
| 114 |
log.info("ever-jobs not running β attempting to start...")
|
| 115 |
|
| 116 |
if shutil.which("docker"):
|
src/geo_filter.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Location filtering β drop jobs that are clearly outside the user's selected
|
| 3 |
+
geography (fixes "international jobs leaking in").
|
| 4 |
+
|
| 5 |
+
The pipeline searches role x location, but several boards (LinkedIn "Remote",
|
| 6 |
+
Remotive, WeWorkRemotely, ever-jobs aggregators) return jobs from anywhere
|
| 7 |
+
regardless of the requested location. This module decides, per job, whether its
|
| 8 |
+
location is acceptable given what the user selected.
|
| 9 |
+
|
| 10 |
+
Design goals:
|
| 11 |
+
- Be permissive about INDIA jobs (LinkedIn usually appends ", India" or the
|
| 12 |
+
state, but some only show the city), so keep a broad Indian-city list.
|
| 13 |
+
- Only drop a job when it is clearly foreign (names a non-India country/city)
|
| 14 |
+
or, in India-only mode, when it is neither India nor an allowed remote role.
|
| 15 |
+
- If the user selected "Worldwide"/"Anywhere", do not filter at all.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
INDIA_TOKENS = {
|
| 21 |
+
"india", "bharat", "bengaluru", "bangalore", "mumbai", "delhi", "new delhi",
|
| 22 |
+
"delhi ncr", "ncr", "gurugram", "gurgaon", "noida", "hyderabad", "pune",
|
| 23 |
+
"chennai", "kolkata", "ahmedabad", "jaipur", "surat", "lucknow", "kochi",
|
| 24 |
+
"cochin", "coimbatore", "indore", "nagpur", "chandigarh", "bhubaneswar",
|
| 25 |
+
"visakhapatnam", "vizag", "thiruvananthapuram", "trivandrum", "mysuru",
|
| 26 |
+
"mysore", "mohali", "vadodara", "nashik", "kanpur", "patna", "guwahati",
|
| 27 |
+
"raipur", "bhopal", "goa", "mangaluru", "mangalore",
|
| 28 |
+
# states / UTs commonly shown in LinkedIn location strings
|
| 29 |
+
"karnataka", "maharashtra", "telangana", "tamil nadu", "kerala",
|
| 30 |
+
"uttar pradesh", "west bengal", "gujarat", "haryana", "rajasthan",
|
| 31 |
+
"andhra pradesh", "madhya pradesh", "punjab", "odisha", "bihar",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
# Clear non-India signals β if present (and India is not), the job is foreign.
|
| 35 |
+
FOREIGN_TOKENS = {
|
| 36 |
+
"united states", "usa", "u.s.", " us)", "u.s", "america",
|
| 37 |
+
"united kingdom", " uk", "u.k", "england", "london", "scotland",
|
| 38 |
+
"canada", "toronto", "vancouver",
|
| 39 |
+
"germany", "berlin", "munich", "deutschland",
|
| 40 |
+
"france", "paris", "spain", "madrid", "italy", "netherlands", "amsterdam",
|
| 41 |
+
"ireland", "dublin", "poland", "portugal", "sweden", "switzerland",
|
| 42 |
+
"singapore", "malaysia", "kuala lumpur", "indonesia", "jakarta",
|
| 43 |
+
"philippines", "manila", "thailand", "bangkok", "vietnam", "hong kong",
|
| 44 |
+
"china", "japan", "tokyo", "korea", "seoul",
|
| 45 |
+
"australia", "sydney", "melbourne", "new zealand",
|
| 46 |
+
"dubai", "abu dhabi", "uae", "saudi", "qatar", "doha", "bahrain", "kuwait",
|
| 47 |
+
"egypt", "nigeria", "kenya", "south africa", "brazil", "mexico", "argentina",
|
| 48 |
+
"bangladesh", "dhaka", "pakistan", "sri lanka", "nepal",
|
| 49 |
+
# US state abbreviations frequently seen as "City, CA" / "City, NY"
|
| 50 |
+
" ca", " ny", " tx", " wa", " ma", " il", " ga", " fl", " co", " va",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
REMOTE_TOKENS = {"remote", "anywhere", "work from home", "wfh"}
|
| 54 |
+
GLOBAL_TOKENS = {"worldwide", "anywhere", "global"}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _norm(s: str) -> str:
|
| 58 |
+
return (s or "").strip().lower()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def wants_india(selected_locations) -> bool:
|
| 62 |
+
sl = " ".join(_norm(x) for x in (selected_locations or []))
|
| 63 |
+
return any(tok in sl for tok in ("india", "bangalore", "bengaluru", "mumbai",
|
| 64 |
+
"delhi", "hyderabad", "pune", "chennai",
|
| 65 |
+
"noida", "gurgaon", "kolkata"))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def allows_remote(selected_locations) -> bool:
|
| 69 |
+
sl = [_norm(x) for x in (selected_locations or [])]
|
| 70 |
+
return any(any(t in s for t in REMOTE_TOKENS | GLOBAL_TOKENS) for s in sl)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def no_geo_restriction(selected_locations) -> bool:
|
| 74 |
+
sl = [_norm(x) for x in (selected_locations or [])]
|
| 75 |
+
return any(any(t in s for t in GLOBAL_TOKENS) for s in sl)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _has(tokens, text) -> bool:
|
| 79 |
+
return any(t in text for t in tokens)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def location_allowed(job_location: str, selected_locations) -> bool:
|
| 83 |
+
"""True if `job_location` is acceptable for the user's selected locations."""
|
| 84 |
+
if no_geo_restriction(selected_locations):
|
| 85 |
+
return True
|
| 86 |
+
|
| 87 |
+
jl = _norm(job_location)
|
| 88 |
+
if not jl:
|
| 89 |
+
return True # unknown location β don't drop (LinkedIn often omits it)
|
| 90 |
+
|
| 91 |
+
india_mode = wants_india(selected_locations)
|
| 92 |
+
is_india = _has(INDIA_TOKENS, jl)
|
| 93 |
+
is_remote = _has(REMOTE_TOKENS, jl)
|
| 94 |
+
is_foreign = _has(FOREIGN_TOKENS, jl) and not is_india
|
| 95 |
+
|
| 96 |
+
if india_mode:
|
| 97 |
+
if is_india:
|
| 98 |
+
return True
|
| 99 |
+
# Allow a remote role only if the user opted into remote AND it isn't
|
| 100 |
+
# explicitly a foreign-remote posting (e.g. "Remote, US").
|
| 101 |
+
if is_remote and allows_remote(selected_locations) and not is_foreign:
|
| 102 |
+
return True
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
# Non-India searches: only drop the obviously-foreign-to-selection case is
|
| 106 |
+
# hard to generalize, so keep everything (user didn't pick India).
|
| 107 |
+
return True
|
src/scrapers/ever_jobs.py
CHANGED
|
@@ -39,12 +39,18 @@ class EverJobsScraper(BaseScraper):
|
|
| 39 |
log.warning("EverJobsScraper: no platforms specified β returning empty")
|
| 40 |
return []
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
raw_jobs = self._client.search(
|
| 43 |
site_types=self.platforms,
|
| 44 |
search_term=role,
|
| 45 |
location=location,
|
| 46 |
results_wanted=max_results,
|
| 47 |
-
hours_old=
|
| 48 |
description_format="plain",
|
| 49 |
request_timeout=30,
|
| 50 |
)
|
|
|
|
| 39 |
log.warning("EverJobsScraper: no platforms specified β returning empty")
|
| 40 |
return []
|
| 41 |
|
| 42 |
+
try:
|
| 43 |
+
_days = float(getattr(self, "_days_posted", 7) or 7)
|
| 44 |
+
except (TypeError, ValueError):
|
| 45 |
+
_days = 7
|
| 46 |
+
hours_old = max(1, int(_days * 24))
|
| 47 |
+
|
| 48 |
raw_jobs = self._client.search(
|
| 49 |
site_types=self.platforms,
|
| 50 |
search_term=role,
|
| 51 |
location=location,
|
| 52 |
results_wanted=max_results,
|
| 53 |
+
hours_old=hours_old,
|
| 54 |
description_format="plain",
|
| 55 |
request_timeout=30,
|
| 56 |
)
|
src/scrapers/linkedin.py
CHANGED
|
@@ -20,11 +20,20 @@ class LinkedInScraper(BaseScraper):
|
|
| 20 |
start = 0
|
| 21 |
page_size = 25
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
while len(jobs) < max_results:
|
| 24 |
params = {
|
| 25 |
"keywords": role,
|
| 26 |
"location": location,
|
| 27 |
-
"f_TPR":
|
| 28 |
"start": start,
|
| 29 |
"count": page_size,
|
| 30 |
}
|
|
|
|
| 20 |
start = 0
|
| 21 |
page_size = 25
|
| 22 |
|
| 23 |
+
# Freshness window β honour the user's selected days_posted (set by the
|
| 24 |
+
# pipeline via `_days_posted`); default to 7 days. f_TPR is in seconds.
|
| 25 |
+
try:
|
| 26 |
+
_days = float(getattr(self, "_days_posted", 7) or 7)
|
| 27 |
+
except (TypeError, ValueError):
|
| 28 |
+
_days = 7
|
| 29 |
+
_seconds = max(3600, int(_days * 86400))
|
| 30 |
+
f_tpr = f"r{_seconds}"
|
| 31 |
+
|
| 32 |
while len(jobs) < max_results:
|
| 33 |
params = {
|
| 34 |
"keywords": role,
|
| 35 |
"location": location,
|
| 36 |
+
"f_TPR": f_tpr,
|
| 37 |
"start": start,
|
| 38 |
"count": page_size,
|
| 39 |
}
|
ui.py
CHANGED
|
@@ -1847,9 +1847,18 @@ if show_config and start and not st.session_state.running:
|
|
| 1847 |
scraper_map["ever_jobs"] = ("EverJobs", EverJobsScraper(_ej_platforms))
|
| 1848 |
_q_log(f"π ever-jobs sidecar UP β {len(_ej_platforms)} extra platforms enabled")
|
| 1849 |
else:
|
| 1850 |
-
|
| 1851 |
-
|
| 1852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1853 |
_step_skip("ever_jobs")
|
| 1854 |
else:
|
| 1855 |
_step_skip("ever_jobs")
|
|
@@ -1857,6 +1866,16 @@ if show_config and start and not st.session_state.running:
|
|
| 1857 |
scrape_pct = 12
|
| 1858 |
pct_per_plat = 35 / max(1, len(scraper_map))
|
| 1859 |
MAX_PER_PLAT = _jscfg["max_jobs_per_platform"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1860 |
|
| 1861 |
for plat_id, (pname, scraper) in scraper_map.items():
|
| 1862 |
t0 = _t.time()
|
|
@@ -1889,6 +1908,9 @@ if show_config and start and not st.session_state.running:
|
|
| 1889 |
continue
|
| 1890 |
if not scraper.is_pm_role(j.title):
|
| 1891 |
continue
|
|
|
|
|
|
|
|
|
|
| 1892 |
if is_duplicate(j.url, days=30):
|
| 1893 |
skipped_dup += 1
|
| 1894 |
continue
|
|
@@ -1923,6 +1945,9 @@ if show_config and start and not st.session_state.running:
|
|
| 1923 |
_q_log(f"β
{pname}: {len(platform_jobs)} jobs ({n_desc} with JD) "
|
| 1924 |
f"| {skipped_dup} dupes skipped")
|
| 1925 |
|
|
|
|
|
|
|
|
|
|
| 1926 |
_q_log(f"β
Total unique jobs: {len(all_jobs)}")
|
| 1927 |
if not all_jobs:
|
| 1928 |
_q_log("β No jobs found. Check internet or platform settings.")
|
|
|
|
| 1847 |
scraper_map["ever_jobs"] = ("EverJobs", EverJobsScraper(_ej_platforms))
|
| 1848 |
_q_log(f"π ever-jobs sidecar UP β {len(_ej_platforms)} extra platforms enabled")
|
| 1849 |
else:
|
| 1850 |
+
import os as _os
|
| 1851 |
+
_hosted = bool(_os.getenv("EVER_JOBS_API_URL"))
|
| 1852 |
+
if _hosted:
|
| 1853 |
+
_q_log(f"β ever-jobs sidecar unreachable at {_os.getenv('EVER_JOBS_API_URL')} "
|
| 1854 |
+
f"β {len(_ej_platforms)} extra platforms SKIPPED. Check the hosted "
|
| 1855 |
+
f"sidecar is running. Direct scrapers (LinkedIn etc.) still run.")
|
| 1856 |
+
else:
|
| 1857 |
+
_q_log(f"β ever-jobs sidecar DOWN β {len(_ej_platforms)} extra platforms "
|
| 1858 |
+
f"(Greenhouse/Lever/Google/Foundit/etc.) SKIPPED. These need the "
|
| 1859 |
+
f"Node sidecar, which can't run inside HF Spaces. To enable them, host "
|
| 1860 |
+
f"ever-jobs elsewhere and set the EVER_JOBS_API_URL secret. "
|
| 1861 |
+
f"Direct scrapers (LinkedIn/Indeed/Glassdoor/Remotive/WWR) still run.")
|
| 1862 |
_step_skip("ever_jobs")
|
| 1863 |
else:
|
| 1864 |
_step_skip("ever_jobs")
|
|
|
|
| 1866 |
scrape_pct = 12
|
| 1867 |
pct_per_plat = 35 / max(1, len(scraper_map))
|
| 1868 |
MAX_PER_PLAT = _jscfg["max_jobs_per_platform"]
|
| 1869 |
+
_days_posted = _jscfg.get("days_posted", 7)
|
| 1870 |
+
_sel_locs = _jscfg.get("locations", [])
|
| 1871 |
+
from src.geo_filter import location_allowed
|
| 1872 |
+
# Thread the freshness window into every scraper that supports it.
|
| 1873 |
+
for _pid, (_pn, _sc) in scraper_map.items():
|
| 1874 |
+
try:
|
| 1875 |
+
_sc._days_posted = _days_posted
|
| 1876 |
+
except Exception:
|
| 1877 |
+
pass
|
| 1878 |
+
_geo_dropped = 0
|
| 1879 |
|
| 1880 |
for plat_id, (pname, scraper) in scraper_map.items():
|
| 1881 |
t0 = _t.time()
|
|
|
|
| 1908 |
continue
|
| 1909 |
if not scraper.is_pm_role(j.title):
|
| 1910 |
continue
|
| 1911 |
+
if not location_allowed(j.location, _sel_locs):
|
| 1912 |
+
_geo_dropped += 1
|
| 1913 |
+
continue
|
| 1914 |
if is_duplicate(j.url, days=30):
|
| 1915 |
skipped_dup += 1
|
| 1916 |
continue
|
|
|
|
| 1945 |
_q_log(f"β
{pname}: {len(platform_jobs)} jobs ({n_desc} with JD) "
|
| 1946 |
f"| {skipped_dup} dupes skipped")
|
| 1947 |
|
| 1948 |
+
if _geo_dropped:
|
| 1949 |
+
_q_log(f"π Filtered out {_geo_dropped} job(s) outside your selected "
|
| 1950 |
+
f"locations ({', '.join(_sel_locs[:4])}{'β¦' if len(_sel_locs) > 4 else ''}).")
|
| 1951 |
_q_log(f"β
Total unique jobs: {len(all_jobs)}")
|
| 1952 |
if not all_jobs:
|
| 1953 |
_q_log("β No jobs found. Check internet or platform settings.")
|