python-job-scraper 0.3.0__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.
- jobscraper/__init__.py +302 -0
- jobscraper/exception.py +32 -0
- jobscraper/glassdoor/__init__.py +309 -0
- jobscraper/glassdoor/constant.py +33 -0
- jobscraper/glassdoor/util.py +215 -0
- jobscraper/indeed/__init__.py +331 -0
- jobscraper/indeed/constant.py +38 -0
- jobscraper/indeed/util.py +157 -0
- jobscraper/linkedin/__init__.py +5 -0
- jobscraper/linkedin/_scraper.py +283 -0
- jobscraper/linkedin/constant.py +60 -0
- jobscraper/linkedin/util.py +331 -0
- jobscraper/model.py +144 -0
- jobscraper/util.py +500 -0
- python_job_scraper-0.3.0.dist-info/METADATA +221 -0
- python_job_scraper-0.3.0.dist-info/RECORD +19 -0
- python_job_scraper-0.3.0.dist-info/WHEEL +5 -0
- python_job_scraper-0.3.0.dist-info/licenses/LICENSE +21 -0
- python_job_scraper-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Utility functions for parsing Indeed job data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from bs4 import BeautifulSoup
|
|
10
|
+
|
|
11
|
+
from jobscraper.model import Compensation, CompensationInterval, Country, Location
|
|
12
|
+
from jobscraper.util import currency_parser, extract_emails_from_text, extract_salary
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_mosaic_json(html: str) -> list[dict[str, Any]]:
|
|
16
|
+
"""Extract and parse the <script id="mosaic-data"> JSON blob from Indeed HTML.
|
|
17
|
+
|
|
18
|
+
Returns a list of job dicts from the jobKeysWithInfo section,
|
|
19
|
+
or an empty list on failure.
|
|
20
|
+
"""
|
|
21
|
+
soup = BeautifulSoup(html, "lxml")
|
|
22
|
+
script_tag = soup.find("script", {"id": "mosaic-data"})
|
|
23
|
+
if not script_tag:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
raw_js = script_tag.string or ""
|
|
28
|
+
# The script sets window.mosaic.providerData["mosaic-provider-jobcards"]
|
|
29
|
+
match = re.search(
|
|
30
|
+
r'window\.mosaic\.providerData\["mosaic-provider-jobcards"\]\s*=\s*(\{.*?\});',
|
|
31
|
+
raw_js,
|
|
32
|
+
re.DOTALL,
|
|
33
|
+
)
|
|
34
|
+
if match:
|
|
35
|
+
data = json.loads(match.group(1))
|
|
36
|
+
else:
|
|
37
|
+
data = json.loads(raw_js)
|
|
38
|
+
|
|
39
|
+
# Navigate to the job list
|
|
40
|
+
# Structure: metaData -> mosaicProviderJobCardsModel -> results
|
|
41
|
+
meta = data.get("metaData", {})
|
|
42
|
+
model = meta.get("mosaicProviderJobCardsModel", {})
|
|
43
|
+
results = model.get("results", [])
|
|
44
|
+
return results if isinstance(results, list) else []
|
|
45
|
+
except (json.JSONDecodeError, AttributeError, KeyError):
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_compensation(job_data: dict[str, Any]) -> Compensation | None:
|
|
50
|
+
"""Extract compensation info from an Indeed job dict.
|
|
51
|
+
|
|
52
|
+
Indeed may provide salary under 'extractedSalary' (structured) or
|
|
53
|
+
'salarySnippet' (text). Returns a Compensation model or None if no
|
|
54
|
+
salary data is found.
|
|
55
|
+
"""
|
|
56
|
+
# Try extractedSalary first (structured data)
|
|
57
|
+
extracted = job_data.get("extractedSalary")
|
|
58
|
+
if extracted:
|
|
59
|
+
try:
|
|
60
|
+
min_val = extracted.get("min")
|
|
61
|
+
max_val = extracted.get("max")
|
|
62
|
+
interval_str = extracted.get("type", "").lower()
|
|
63
|
+
interval_map: dict[str, CompensationInterval] = {
|
|
64
|
+
"yearly": CompensationInterval.YEARLY,
|
|
65
|
+
"monthly": CompensationInterval.MONTHLY,
|
|
66
|
+
"weekly": CompensationInterval.WEEKLY,
|
|
67
|
+
"daily": CompensationInterval.DAILY,
|
|
68
|
+
"hourly": CompensationInterval.HOURLY,
|
|
69
|
+
}
|
|
70
|
+
interval = interval_map.get(interval_str)
|
|
71
|
+
if min_val is not None or max_val is not None:
|
|
72
|
+
return Compensation(
|
|
73
|
+
interval=interval,
|
|
74
|
+
min_amount=float(min_val) if min_val is not None else None,
|
|
75
|
+
max_amount=float(max_val) if max_val is not None else None,
|
|
76
|
+
)
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
# Try salarySnippet (text snippet)
|
|
81
|
+
snippet = job_data.get("salarySnippet", {})
|
|
82
|
+
if isinstance(snippet, dict):
|
|
83
|
+
text = snippet.get("text", "")
|
|
84
|
+
else:
|
|
85
|
+
text = str(snippet) if snippet else ""
|
|
86
|
+
|
|
87
|
+
if text:
|
|
88
|
+
try:
|
|
89
|
+
interval_str, min_val, max_val, currency = extract_salary(text)
|
|
90
|
+
if min_val is not None:
|
|
91
|
+
interval = CompensationInterval(interval_str) if interval_str else None
|
|
92
|
+
return Compensation(
|
|
93
|
+
interval=interval,
|
|
94
|
+
min_amount=min_val,
|
|
95
|
+
max_amount=max_val,
|
|
96
|
+
currency=currency,
|
|
97
|
+
)
|
|
98
|
+
except (ValueError, TypeError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_location(raw: str) -> Location:
|
|
105
|
+
"""Parse an Indeed location string into a Location model.
|
|
106
|
+
|
|
107
|
+
Handles formats like:
|
|
108
|
+
- "Bangalore, Karnataka"
|
|
109
|
+
- "Remote in Mumbai, Maharashtra"
|
|
110
|
+
- "Hyderabad, Telangana 500001"
|
|
111
|
+
"""
|
|
112
|
+
if not raw:
|
|
113
|
+
return Location()
|
|
114
|
+
|
|
115
|
+
# Strip "Remote in " prefix
|
|
116
|
+
clean = re.sub(r"^(?:remote\s+in\s+)", "", raw.strip(), flags=re.IGNORECASE)
|
|
117
|
+
|
|
118
|
+
# Remove postal code (5–6 digits at end)
|
|
119
|
+
clean = re.sub(r"\s*\d{5,6}\s*$", "", clean).strip()
|
|
120
|
+
|
|
121
|
+
parts = [p.strip() for p in clean.split(",") if p.strip()]
|
|
122
|
+
|
|
123
|
+
if len(parts) >= 2:
|
|
124
|
+
return Location(city=parts[0], state=parts[1])
|
|
125
|
+
elif len(parts) == 1:
|
|
126
|
+
return Location(city=parts[0])
|
|
127
|
+
return Location()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_job_detail_url(job_key: str, country: Country) -> str:
|
|
131
|
+
"""Build the full URL for an Indeed job detail page.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
job_key: The Indeed job key (e.g. 'abc123def456').
|
|
135
|
+
country: Country enum value (e.g. Country.INDIA).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Full URL string for the job detail page.
|
|
139
|
+
"""
|
|
140
|
+
from jobscraper.indeed.constant import BASE_URL
|
|
141
|
+
|
|
142
|
+
base = BASE_URL.format(country=country.value)
|
|
143
|
+
return f"{base}/viewjob?jk={job_key}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def extract_emails(html: str) -> list[str]:
|
|
147
|
+
"""Extract email addresses from Indeed job detail HTML.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
html: Raw HTML string from a job detail page.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of unique email addresses found.
|
|
154
|
+
"""
|
|
155
|
+
soup = BeautifulSoup(html, "lxml")
|
|
156
|
+
text = soup.get_text()
|
|
157
|
+
return list(set(extract_emails_from_text(text)))
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""LinkedIn scraper implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jobscraper.exception import LinkedInException
|
|
11
|
+
from jobscraper.linkedin.constant import (
|
|
12
|
+
BASE_URL,
|
|
13
|
+
JOB_DETAIL_URL,
|
|
14
|
+
JOB_TYPE_MAP,
|
|
15
|
+
LINKEDIN_HEADERS,
|
|
16
|
+
PAGE_SIZE,
|
|
17
|
+
VOYAGER_DECORATION,
|
|
18
|
+
VOYAGER_HEADERS,
|
|
19
|
+
VOYAGER_JOB_URL,
|
|
20
|
+
)
|
|
21
|
+
from jobscraper.linkedin.util import (
|
|
22
|
+
build_search_params,
|
|
23
|
+
parse_compensation,
|
|
24
|
+
parse_date,
|
|
25
|
+
parse_html_detail,
|
|
26
|
+
parse_location,
|
|
27
|
+
parse_search_html,
|
|
28
|
+
parse_voyager_job,
|
|
29
|
+
)
|
|
30
|
+
from jobscraper.model import JobPost, JobResponse, JobType, Scraper, ScraperInput, Site
|
|
31
|
+
from jobscraper.util import create_logger, create_session, markdown_converter
|
|
32
|
+
|
|
33
|
+
logger = create_logger("linkedin")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LinkedInScraper(Scraper):
|
|
37
|
+
"""Scraper for linkedin.com job listings.
|
|
38
|
+
|
|
39
|
+
Uses public HTML search (no auth required) to discover jobs. When
|
|
40
|
+
``cookies["li_at"]`` is supplied, fetches rich detail data from
|
|
41
|
+
LinkedIn's internal Voyager API. Falls back to HTML detail page otherwise.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def scrape(self, scraper_input: ScraperInput) -> JobResponse:
|
|
45
|
+
"""Fetch job listings from LinkedIn and return as a JobResponse.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
scraper_input: Validated scraper configuration.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
JobResponse containing all collected JobPost objects.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
LinkedInException: On unrecoverable HTTP errors.
|
|
55
|
+
"""
|
|
56
|
+
session = create_session(
|
|
57
|
+
proxies=scraper_input.proxies,
|
|
58
|
+
ca_cert=scraper_input.ca_cert,
|
|
59
|
+
is_tls=True,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
headers = dict(LINKEDIN_HEADERS)
|
|
63
|
+
if scraper_input.user_agent:
|
|
64
|
+
headers["User-Agent"] = scraper_input.user_agent
|
|
65
|
+
|
|
66
|
+
li_at = (scraper_input.cookies or {}).get("li_at")
|
|
67
|
+
|
|
68
|
+
# Warmup — acquire JSESSIONID from homepage cookies
|
|
69
|
+
jsessionid: str | None = None
|
|
70
|
+
try:
|
|
71
|
+
warmup_resp = session.get(BASE_URL, headers=headers)
|
|
72
|
+
cookies = getattr(warmup_resp, "cookies", {})
|
|
73
|
+
raw_jsid = cookies.get("JSESSIONID") or ""
|
|
74
|
+
jsessionid = raw_jsid.strip('"') if raw_jsid else None
|
|
75
|
+
time.sleep(random.uniform(1.0, 2.0))
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
# Build authenticated headers when li_at is present
|
|
80
|
+
voyager_headers: dict[str, str] | None = None
|
|
81
|
+
if li_at:
|
|
82
|
+
cookie_str = f"li_at={li_at}"
|
|
83
|
+
if jsessionid:
|
|
84
|
+
cookie_str += f'; JSESSIONID="{jsessionid}"'
|
|
85
|
+
headers["Cookie"] = cookie_str
|
|
86
|
+
if jsessionid:
|
|
87
|
+
headers["Csrf-Token"] = jsessionid
|
|
88
|
+
|
|
89
|
+
voyager_headers = dict(VOYAGER_HEADERS)
|
|
90
|
+
voyager_headers["Cookie"] = cookie_str
|
|
91
|
+
if jsessionid:
|
|
92
|
+
voyager_headers["Csrf-Token"] = jsessionid
|
|
93
|
+
if scraper_input.user_agent:
|
|
94
|
+
voyager_headers["User-Agent"] = scraper_input.user_agent
|
|
95
|
+
|
|
96
|
+
jobs: list[JobPost] = []
|
|
97
|
+
start = scraper_input.offset
|
|
98
|
+
|
|
99
|
+
while len(jobs) < scraper_input.results_wanted:
|
|
100
|
+
params = build_search_params(scraper_input, start)
|
|
101
|
+
logger.info("Fetching LinkedIn jobs start=%d", start)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
response = session.get(
|
|
105
|
+
BASE_URL + "/jobs/search/", headers=headers, params=params
|
|
106
|
+
)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
raise LinkedInException(
|
|
109
|
+
f"Failed to fetch LinkedIn search page: {exc}"
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
status = getattr(response, "status_code", None)
|
|
113
|
+
if isinstance(status, int) and status >= 400:
|
|
114
|
+
raise LinkedInException(
|
|
115
|
+
f"LinkedIn returned HTTP {status}. Bot detection may be active."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
html = (
|
|
119
|
+
response.text
|
|
120
|
+
if hasattr(response, "text")
|
|
121
|
+
else response.content.decode()
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
raw_jobs = parse_search_html(html)
|
|
125
|
+
if not raw_jobs:
|
|
126
|
+
logger.info("No jobs parsed at start=%d; stopping.", start)
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
for raw in raw_jobs:
|
|
130
|
+
if len(jobs) >= scraper_input.results_wanted:
|
|
131
|
+
break
|
|
132
|
+
job = self._build_job_post(
|
|
133
|
+
raw, scraper_input, session, headers, voyager_headers
|
|
134
|
+
)
|
|
135
|
+
if job:
|
|
136
|
+
jobs.append(job)
|
|
137
|
+
|
|
138
|
+
if len(raw_jobs) < PAGE_SIZE:
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
start += PAGE_SIZE
|
|
142
|
+
time.sleep(random.uniform(0.5, 2.5))
|
|
143
|
+
|
|
144
|
+
return JobResponse(jobs=jobs)
|
|
145
|
+
|
|
146
|
+
def _build_job_post(
|
|
147
|
+
self,
|
|
148
|
+
raw: dict[str, Any],
|
|
149
|
+
scraper_input: ScraperInput,
|
|
150
|
+
session: Any,
|
|
151
|
+
headers: dict[str, str],
|
|
152
|
+
voyager_headers: dict[str, str] | None,
|
|
153
|
+
) -> JobPost | None:
|
|
154
|
+
"""Convert a raw LinkedIn search card dict to a JobPost.
|
|
155
|
+
|
|
156
|
+
Uses Voyager API when voyager_headers are set; falls back to HTML
|
|
157
|
+
detail page. Field-level try/except prevents partial data from
|
|
158
|
+
crashing the scraper.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
job_id = raw.get("id")
|
|
162
|
+
if not job_id:
|
|
163
|
+
logger.warning("LinkedIn job missing id, skipping")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
job_url = JOB_DETAIL_URL.format(job_id=job_id)
|
|
167
|
+
|
|
168
|
+
title = raw.get("title")
|
|
169
|
+
if not title:
|
|
170
|
+
logger.warning("LinkedIn job %s missing title", job_id)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
company: str | None = raw.get("company")
|
|
174
|
+
location = parse_location(raw.get("location") or "")
|
|
175
|
+
date_posted = parse_date(raw.get("date"))
|
|
176
|
+
|
|
177
|
+
description: str | None = None
|
|
178
|
+
job_type: list[JobType] | None = None
|
|
179
|
+
is_remote: bool | None = None
|
|
180
|
+
compensation = None
|
|
181
|
+
company_url: str | None = None
|
|
182
|
+
company_logo: str | None = None
|
|
183
|
+
job_url_direct: str | None = None
|
|
184
|
+
is_indeed_apply: bool | None = None
|
|
185
|
+
emails: list[str] | None = None
|
|
186
|
+
|
|
187
|
+
# ---- Voyager path (authenticated) --------------------------------
|
|
188
|
+
if voyager_headers and scraper_input.fetch_full_description:
|
|
189
|
+
try:
|
|
190
|
+
vurl = VOYAGER_JOB_URL.format(job_id=job_id)
|
|
191
|
+
vresp = session.get(
|
|
192
|
+
vurl,
|
|
193
|
+
headers=voyager_headers,
|
|
194
|
+
params={"decorationId": VOYAGER_DECORATION},
|
|
195
|
+
)
|
|
196
|
+
vstatus = getattr(vresp, "status_code", None)
|
|
197
|
+
if isinstance(vstatus, int) and vstatus < 400:
|
|
198
|
+
vdata = vresp.json()
|
|
199
|
+
parsed = parse_voyager_job(vdata.get("data") or vdata)
|
|
200
|
+
|
|
201
|
+
title = parsed.get("title") or title
|
|
202
|
+
company = parsed.get("company") or company
|
|
203
|
+
company_url = parsed.get("company_url")
|
|
204
|
+
company_logo = parsed.get("company_logo")
|
|
205
|
+
job_url_direct = parsed.get("job_url_direct")
|
|
206
|
+
is_indeed_apply = parsed.get("is_easy_apply")
|
|
207
|
+
is_remote = parsed.get("is_remote")
|
|
208
|
+
|
|
209
|
+
emp = parsed.get("employment_status") or ""
|
|
210
|
+
jt = JOB_TYPE_MAP.get(emp)
|
|
211
|
+
job_type = [jt] if jt else None
|
|
212
|
+
|
|
213
|
+
loc_str = parsed.get("formatted_location")
|
|
214
|
+
if loc_str:
|
|
215
|
+
location = parse_location(loc_str)
|
|
216
|
+
|
|
217
|
+
listed_at = parsed.get("listed_at")
|
|
218
|
+
if listed_at:
|
|
219
|
+
try:
|
|
220
|
+
date_posted = datetime.fromtimestamp(
|
|
221
|
+
int(listed_at) / 1000
|
|
222
|
+
).date()
|
|
223
|
+
except (ValueError, OSError):
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
compensation = parse_compensation(parsed.get("salary"))
|
|
227
|
+
|
|
228
|
+
raw_desc = parsed.get("description_html")
|
|
229
|
+
if raw_desc:
|
|
230
|
+
description = (
|
|
231
|
+
markdown_converter(raw_desc)
|
|
232
|
+
if scraper_input.description_format == "markdown"
|
|
233
|
+
else raw_desc
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
time.sleep(random.uniform(0.5, 2.5))
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
logger.warning(
|
|
239
|
+
"Job %s: Voyager fetch failed (%s); falling back to HTML",
|
|
240
|
+
job_id,
|
|
241
|
+
exc,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# ---- HTML fallback (no cookie or Voyager failed) -----------------
|
|
245
|
+
if description is None and scraper_input.fetch_full_description:
|
|
246
|
+
try:
|
|
247
|
+
detail_resp = session.get(job_url, headers=headers)
|
|
248
|
+
detail_html = (
|
|
249
|
+
detail_resp.text
|
|
250
|
+
if hasattr(detail_resp, "text")
|
|
251
|
+
else detail_resp.content.decode()
|
|
252
|
+
)
|
|
253
|
+
description, job_url_direct, emails = parse_html_detail(
|
|
254
|
+
detail_html, scraper_input.description_format
|
|
255
|
+
)
|
|
256
|
+
time.sleep(random.uniform(0.5, 2.5))
|
|
257
|
+
except Exception as exc:
|
|
258
|
+
logger.warning(
|
|
259
|
+
"Job %s: HTML detail fetch failed: %s", job_id, exc
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return JobPost(
|
|
263
|
+
id=str(job_id),
|
|
264
|
+
site=Site.LINKEDIN,
|
|
265
|
+
job_url=job_url,
|
|
266
|
+
job_url_direct=job_url_direct,
|
|
267
|
+
title=title,
|
|
268
|
+
company=company,
|
|
269
|
+
location=location,
|
|
270
|
+
date_posted=date_posted,
|
|
271
|
+
job_type=job_type,
|
|
272
|
+
compensation=compensation,
|
|
273
|
+
is_remote=is_remote,
|
|
274
|
+
is_indeed_apply=is_indeed_apply,
|
|
275
|
+
description=description,
|
|
276
|
+
emails=emails,
|
|
277
|
+
company_url=company_url,
|
|
278
|
+
company_logo=company_logo,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
except Exception as exc:
|
|
282
|
+
logger.warning("Unexpected error building LinkedIn JobPost: %s", exc)
|
|
283
|
+
return None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Constants for the LinkedIn scraper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from jobscraper.model import JobType
|
|
6
|
+
|
|
7
|
+
BASE_URL = "https://www.linkedin.com"
|
|
8
|
+
JOBS_SEARCH_URL = BASE_URL + "/jobs/search/"
|
|
9
|
+
JOB_DETAIL_URL = BASE_URL + "/jobs/view/{job_id}/"
|
|
10
|
+
VOYAGER_JOB_URL = BASE_URL + "/voyager/api/jobs/jobPostings/{job_id}"
|
|
11
|
+
VOYAGER_DECORATION = "com.linkedin.voyager.deco.jobs.web.shared.WebFullJobPosting-65"
|
|
12
|
+
|
|
13
|
+
LINKEDIN_HEADERS: dict[str, str] = {
|
|
14
|
+
"User-Agent": (
|
|
15
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
16
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
17
|
+
"Chrome/120.0.0.0 Safari/537.36"
|
|
18
|
+
),
|
|
19
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
20
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
21
|
+
"Referer": BASE_URL + "/",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
VOYAGER_HEADERS: dict[str, str] = {
|
|
25
|
+
"Accept": "application/vnd.linkedin.normalized+json+2.1",
|
|
26
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
27
|
+
"X-RestLi-Protocol-Version": "2.0.0",
|
|
28
|
+
"X-Li-Track": (
|
|
29
|
+
'{"clientVersion":"1.13.1665","mpVersion":"1.13.1665","osName":"web",'
|
|
30
|
+
'"timezoneOffset":5.5,"timezone":"Asia/Calcutta","deviceFormFactor":"DESKTOP",'
|
|
31
|
+
'"mpName":"voyager-web","displayDensity":2,"displayWidth":1920,"displayHeight":1080}'
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# LinkedIn URL filter code → JobType enum
|
|
36
|
+
JOB_TYPE_MAP: dict[str, JobType] = {
|
|
37
|
+
"full-time": JobType.FULL_TIME,
|
|
38
|
+
"full_time": JobType.FULL_TIME,
|
|
39
|
+
"f": JobType.FULL_TIME,
|
|
40
|
+
"part-time": JobType.PART_TIME,
|
|
41
|
+
"part_time": JobType.PART_TIME,
|
|
42
|
+
"p": JobType.PART_TIME,
|
|
43
|
+
"contract": JobType.CONTRACT,
|
|
44
|
+
"c": JobType.CONTRACT,
|
|
45
|
+
"temporary": JobType.TEMPORARY,
|
|
46
|
+
"t": JobType.TEMPORARY,
|
|
47
|
+
"internship": JobType.INTERNSHIP,
|
|
48
|
+
"i": JobType.INTERNSHIP,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# JobType enum value → LinkedIn URL filter code (for search params)
|
|
52
|
+
JOB_TYPE_FILTER: dict[str, str] = {
|
|
53
|
+
"fulltime": "F",
|
|
54
|
+
"parttime": "P",
|
|
55
|
+
"contract": "C",
|
|
56
|
+
"temporary": "T",
|
|
57
|
+
"internship": "I",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
PAGE_SIZE = 25 # LinkedIn returns up to 25 results per search page
|