saitejatirunagari Cursor commited on
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 CHANGED
@@ -232,7 +232,10 @@ AUTOMATION = {
232
  from src.ever_jobs_bridge.platforms import INDIA_DEFAULT_PLATFORMS
233
 
234
  EVER_JOBS = {
235
- "api_url": "http://localhost:3001",
 
 
 
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 already running on port 3001")
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=168,
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": "r604800", # Last 7 days
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
- _q_log(f"⚠ ever-jobs sidecar DOWN β€” {len(_ej_platforms)} platforms "
1851
- f"(Indeed/Glassdoor/Greenhouse/Lever/etc.) SKIPPED this run. "
1852
- f"Direct scrapers (LinkedIn/Indeed/Glassdoor/Remotive/WWR/Naukri) still run.")
 
 
 
 
 
 
 
 
 
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.")