Compare commits

..

21 Commits

Author SHA1 Message Date
Cullen Watson
0cc34287f7 fix:turkey 2024-10-02 01:31:00 -05:00
Anton Pikhteryev
923979093b Add Malta for linkedin country support (#198) 2024-09-19 20:41:22 -05:00
Cullen Watson
286f0e4487 docs:readme 2024-09-18 18:49:41 -05:00
Cullen Watson
f7b29d43a2 fix(indeed):sort relevance not date (#197) 2024-09-18 18:42:25 -05:00
Cullen Watson
6f1490458c fix key error (#186) 2024-08-14 02:54:40 -05:00
Cullen Watson
6bb7d81ba8 change linkedin ep (#185) 2024-08-14 02:39:43 -05:00
Cullen Watson
0e046432d1 fix:variable bug (#181) 2024-08-05 12:47:55 -05:00
Cullen Watson
209e0e65b6 fix:malaysia indeed (#180) 2024-08-03 22:48:53 -05:00
Cullen Watson
8570c0651e fix:key error (#176) 2024-07-21 13:05:18 -05:00
Cullen Watson
8678b0bbe4 enh: test on pr (#174) 2024-07-19 14:25:25 -05:00
Cullen Watson
60d4d911c9 lock file (#173) 2024-07-17 21:21:22 -05:00
Lluís Salord Quetglas
2a0cba8c7e FEAT: Optional convertion to annual and know salary source (#170) 2024-07-17 21:05:33 -05:00
Mason DePalma
de70189fa2 Update pyproject.toml (#172)
Changed Numpy to the most recent version so the package can properly install
2024-07-17 20:54:08 -05:00
Cullen Watson
b55c0eb86d docs:readme 2024-07-16 19:24:38 -05:00
Cullen Watson
88c95c4ad5 enh: estimated salary (#169) 2024-07-16 19:20:34 -05:00
Cullen Watson
d8d33d602f docs: readme 2024-07-15 21:30:11 -05:00
Cullen Watson
6330c14879 minor fix 2024-07-15 21:19:01 -05:00
Ali Bakhshi Ilani
48631ea271 Add company industry and job level to linkedin scraper (#166) 2024-07-15 21:07:39 -05:00
Cullen Watson
edffe18e65 enh: listing source (#168) 2024-07-15 20:30:04 -05:00
Lluís Salord Quetglas
0988230a24 FEAT: Add Glassdoor logo data if available (#167) 2024-07-15 20:25:18 -05:00
Cullen Watson
d000a81eb3 Salary parse (#163) 2024-06-09 17:45:38 -05:00
18 changed files with 942 additions and 745 deletions

22
.github/workflows/python-test.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Python Tests
on:
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.8'
- name: Install dependencies
run: |
pip install poetry
poetry install
- name: Run tests
run: poetry run pytest src/tests/test_all.py

View File

@@ -37,7 +37,7 @@ jobs = scrape_jobs(
hours_old=72, # (only Linkedin/Indeed is hour specific, others round up to days old) hours_old=72, # (only Linkedin/Indeed is hour specific, others round up to days old)
country_indeed='USA', # only needed for indeed / glassdoor country_indeed='USA', # only needed for indeed / glassdoor
# linkedin_fetch_description=True # get full description and direct job url for linkedin (slower) # linkedin_fetch_description=True # get more info such as full description, direct job url for linkedin (slower)
# proxies=["208.195.175.46:65095", "208.195.175.45:65095", "localhost"], # proxies=["208.195.175.46:65095", "208.195.175.45:65095", "localhost"],
) )
@@ -78,7 +78,7 @@ Optional
├── proxies (list): ├── proxies (list):
| in format ['user:pass@host:port', 'localhost'] | in format ['user:pass@host:port', 'localhost']
| each job board will round robin through the proxies | each job board scraper will round robin through the proxies
├── is_remote (bool) ├── is_remote (bool)
@@ -110,6 +110,9 @@ Optional
| |
├── country_indeed (str): ├── country_indeed (str):
| filters the country on Indeed & Glassdoor (see below for correct spelling) | filters the country on Indeed & Glassdoor (see below for correct spelling)
|
├── enforce_annual_salary (bool):
| converts wages to annual salary
``` ```
``` ```
@@ -130,37 +133,42 @@ Optional
```plaintext ```plaintext
JobPost JobPost
├── title (str) ├── title
├── company (str) ├── company
├── company_url (str) ├── company_url
├── job_url (str) ├── job_url
├── location (object) ├── location
│ ├── country (str) │ ├── country
│ ├── city (str) │ ├── city
│ ├── state (str) │ ├── state
├── description (str) ├── description
├── job_type (str): fulltime, parttime, internship, contract ├── job_type: fulltime, parttime, internship, contract
├── job_function (str) ├── job_function
├── compensation (object) │ ├── interval: yearly, monthly, weekly, daily, hourly
│ ├── interval (str): yearly, monthly, weekly, daily, hourly │ ├── min_amount
│ ├── min_amount (int) │ ├── max_amount
│ ├── max_amount (int) │ ├── currency
│ └── currency (enum) │ └── salary_source: direct_data, description (parsed from posting)
├── date_posted (date) ├── date_posted
├── emails (str) ├── emails
└── is_remote (bool) └── is_remote
Linkedin specific
└── job_level
Linkedin & Indeed specific
└── company_industry
Indeed specific Indeed specific
├── company_country (str) ├── company_country
── company_addresses (str) ── company_addresses
── company_industry (str) ── company_employees_label
── company_employees_label (str) ── company_revenue_label
── company_revenue_label (str) ── company_description
── company_description (str) ── ceo_name
── ceo_name (str) ── ceo_photo_url
── ceo_photo_url (str) ── logo_photo_url
└── logo_photo_url (str) └── banner_photo_url
└── banner_photo_url (str)
``` ```
## Supported Countries for Job Searching ## Supported Countries for Job Searching
@@ -208,10 +216,8 @@ You can specify the following countries when searching on Indeed (use the exact
## Frequently Asked Questions ## Frequently Asked Questions
--- ---
**Q: Why is Indeed giving unrelated roles?**
**Q: Encountering issues with your queries?** **A:** Indeed is searching each one of your terms e.g. software intern, it searches software OR intern. Try search_term='"software intern"' in quotes for stricter searching
**A:** Try reducing the number of `results_wanted` and/or broadening the filters. If problems
persist, [submit an issue](https://github.com/Bunsly/JobSpy/issues).
--- ---
@@ -222,3 +228,9 @@ persist, [submit an issue](https://github.com/Bunsly/JobSpy/issues).
- Try using the proxies param to change your IP address. - Try using the proxies param to change your IP address.
--- ---
**Q: Encountering issues with your queries?**
**A:** Try reducing the number of `results_wanted` and/or broadening the filters. If problems
persist, [submit an issue](https://github.com/Bunsly/JobSpy/issues).
---

1228
poetry.lock generated

File diff suppressed because it is too large Load Diff

2
poetry.toml Normal file
View File

@@ -0,0 +1,2 @@
[virtualenvs]
in-project = true

View File

@@ -1,10 +1,11 @@
[tool.poetry] [tool.poetry]
name = "python-jobspy" name = "python-jobspy"
version = "1.1.56" version = "1.1.68"
description = "Job scraper for LinkedIn, Indeed, Glassdoor & ZipRecruiter" description = "Job scraper for LinkedIn, Indeed, Glassdoor & ZipRecruiter"
authors = ["Zachary Hampton <zachary@bunsly.com>", "Cullen Watson <cullen@bunsly.com>"] authors = ["Zachary Hampton <zachary@bunsly.com>", "Cullen Watson <cullen@bunsly.com>"]
homepage = "https://github.com/Bunsly/JobSpy" homepage = "https://github.com/Bunsly/JobSpy"
readme = "README.md" readme = "README.md"
keywords = ['jobs-scraper', 'linkedin', 'indeed', 'glassdoor', 'ziprecruiter']
packages = [ packages = [
{ include = "jobspy", from = "src" } { include = "jobspy", from = "src" }
@@ -15,7 +16,7 @@ python = "^3.10"
requests = "^2.31.0" requests = "^2.31.0"
beautifulsoup4 = "^4.12.2" beautifulsoup4 = "^4.12.2"
pandas = "^2.1.0" pandas = "^2.1.0"
NUMPY = "1.24.2" NUMPY = "1.26.3"
pydantic = "^2.3.0" pydantic = "^2.3.0"
tls-client = "^1.0.1" tls-client = "^1.0.1"
markdownify = "^0.11.6" markdownify = "^0.11.6"

View File

@@ -5,12 +5,12 @@ from typing import Tuple
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from .jobs import JobType, Location from .jobs import JobType, Location
from .scrapers.utils import logger, set_logger_level from .scrapers.utils import logger, set_logger_level, extract_salary
from .scrapers.indeed import IndeedScraper from .scrapers.indeed import IndeedScraper
from .scrapers.ziprecruiter import ZipRecruiterScraper from .scrapers.ziprecruiter import ZipRecruiterScraper
from .scrapers.glassdoor import GlassdoorScraper from .scrapers.glassdoor import GlassdoorScraper
from .scrapers.linkedin import LinkedInScraper from .scrapers.linkedin import LinkedInScraper
from .scrapers import ScraperInput, Site, JobResponse, Country from .scrapers import SalarySource, ScraperInput, Site, JobResponse, Country
from .scrapers.exceptions import ( from .scrapers.exceptions import (
LinkedInException, LinkedInException,
IndeedException, IndeedException,
@@ -36,6 +36,7 @@ def scrape_jobs(
linkedin_company_ids: list[int] | None = None, linkedin_company_ids: list[int] | None = None,
offset: int | None = 0, offset: int | None = 0,
hours_old: int = None, hours_old: int = None,
enforce_annual_salary: bool = False,
verbose: int = 2, verbose: int = 2,
**kwargs, **kwargs,
) -> pd.DataFrame: ) -> pd.DataFrame:
@@ -118,6 +119,21 @@ def scrape_jobs(
site_value, scraped_data = future.result() site_value, scraped_data = future.result()
site_to_jobs_dict[site_value] = scraped_data site_to_jobs_dict[site_value] = scraped_data
def convert_to_annual(job_data: dict):
if job_data["interval"] == "hourly":
job_data["min_amount"] *= 2080
job_data["max_amount"] *= 2080
if job_data["interval"] == "monthly":
job_data["min_amount"] *= 12
job_data["max_amount"] *= 12
if job_data["interval"] == "weekly":
job_data["min_amount"] *= 52
job_data["max_amount"] *= 52
if job_data["interval"] == "daily":
job_data["min_amount"] *= 260
job_data["max_amount"] *= 260
job_data["interval"] = "yearly"
jobs_dfs: list[pd.DataFrame] = [] jobs_dfs: list[pd.DataFrame] = []
for site, job_response in site_to_jobs_dict.items(): for site, job_response in site_to_jobs_dict.items():
@@ -150,12 +166,33 @@ def scrape_jobs(
job_data["min_amount"] = compensation_obj.get("min_amount") job_data["min_amount"] = compensation_obj.get("min_amount")
job_data["max_amount"] = compensation_obj.get("max_amount") job_data["max_amount"] = compensation_obj.get("max_amount")
job_data["currency"] = compensation_obj.get("currency", "USD") job_data["currency"] = compensation_obj.get("currency", "USD")
else: job_data["salary_source"] = SalarySource.DIRECT_DATA.value
job_data["interval"] = None if enforce_annual_salary and (
job_data["min_amount"] = None job_data["interval"]
job_data["max_amount"] = None and job_data["interval"] != "yearly"
job_data["currency"] = None and job_data["min_amount"]
and job_data["max_amount"]
):
convert_to_annual(job_data)
else:
if country_enum == Country.USA:
(
job_data["interval"],
job_data["min_amount"],
job_data["max_amount"],
job_data["currency"],
) = extract_salary(
job_data["description"],
enforce_annual_salary=enforce_annual_salary,
)
job_data["salary_source"] = SalarySource.DESCRIPTION.value
job_data["salary_source"] = (
job_data["salary_source"]
if "min_amount" in job_data and job_data["min_amount"]
else None
)
job_df = pd.DataFrame([job_data]) job_df = pd.DataFrame([job_data])
jobs_dfs.append(job_df) jobs_dfs.append(job_df)
@@ -177,18 +214,21 @@ def scrape_jobs(
"location", "location",
"job_type", "job_type",
"date_posted", "date_posted",
"salary_source",
"interval", "interval",
"min_amount", "min_amount",
"max_amount", "max_amount",
"currency", "currency",
"is_remote", "is_remote",
"job_level",
"job_function", "job_function",
"company_industry",
"listing_type",
"emails", "emails",
"description", "description",
"company_url", "company_url",
"company_url_direct", "company_url_direct",
"company_addresses", "company_addresses",
"company_industry",
"company_num_employees", "company_num_employees",
"company_revenue", "company_revenue",
"company_description", "company_description",

View File

@@ -92,7 +92,8 @@ class Country(Enum):
JAPAN = ("japan", "jp") JAPAN = ("japan", "jp")
KUWAIT = ("kuwait", "kw") KUWAIT = ("kuwait", "kw")
LUXEMBOURG = ("luxembourg", "lu") LUXEMBOURG = ("luxembourg", "lu")
MALAYSIA = ("malaysia", "malaysia") MALAYSIA = ("malaysia", "malaysia:my", "com")
MALTA = ("malta", "malta:mt", "mt")
MEXICO = ("mexico", "mx", "com.mx") MEXICO = ("mexico", "mx", "com.mx")
MOROCCO = ("morocco", "ma") MOROCCO = ("morocco", "ma")
NETHERLANDS = ("netherlands", "nl", "nl") NETHERLANDS = ("netherlands", "nl", "nl")
@@ -117,7 +118,7 @@ class Country(Enum):
SWITZERLAND = ("switzerland", "ch", "de:ch") SWITZERLAND = ("switzerland", "ch", "de:ch")
TAIWAN = ("taiwan", "tw") TAIWAN = ("taiwan", "tw")
THAILAND = ("thailand", "th") THAILAND = ("thailand", "th")
TURKEY = ("turkey", "tr") TURKEY = ("türkiye,turkey", "tr")
UKRAINE = ("ukraine", "ua") UKRAINE = ("ukraine", "ua")
UNITEDARABEMIRATES = ("united arab emirates", "ae") UNITEDARABEMIRATES = ("united arab emirates", "ae")
UK = ("uk,united kingdom", "uk:gb", "co.uk") UK = ("uk,united kingdom", "uk:gb", "co.uk")
@@ -242,10 +243,16 @@ class JobPost(BaseModel):
date_posted: date | None = None date_posted: date | None = None
emails: list[str] | None = None emails: list[str] | None = None
is_remote: bool | None = None is_remote: bool | None = None
listing_type: str | None = None
# linkedin specific
job_level: str | None = None
# linkedin and indeed specific
company_industry: str | None = None
# indeed specific # indeed specific
company_addresses: str | None = None company_addresses: str | None = None
company_industry: str | None = None
company_num_employees: str | None = None company_num_employees: str | None = None
company_revenue: str | None = None company_revenue: str | None = None
company_description: str | None = None company_description: str | None = None

View File

@@ -18,6 +18,9 @@ class Site(Enum):
ZIP_RECRUITER = "zip_recruiter" ZIP_RECRUITER = "zip_recruiter"
GLASSDOOR = "glassdoor" GLASSDOOR = "glassdoor"
class SalarySource(Enum):
DIRECT_DATA = "direct_data"
DESCRIPTION = "description"
class ScraperInput(BaseModel): class ScraperInput(BaseModel):
site_type: list[Site] site_type: list[Site]

View File

@@ -69,7 +69,7 @@ class GlassdoorScraper(Scraper):
if location_type is None: if location_type is None:
logger.error("Glassdoor: location not parsed") logger.error("Glassdoor: location not parsed")
return JobResponse(jobs=[]) return JobResponse(jobs=[])
all_jobs: list[JobPost] = [] job_list: list[JobPost] = []
cursor = None cursor = None
range_start = 1 + (scraper_input.offset // self.jobs_per_page) range_start = 1 + (scraper_input.offset // self.jobs_per_page)
@@ -81,14 +81,14 @@ class GlassdoorScraper(Scraper):
jobs, cursor = self._fetch_jobs_page( jobs, cursor = self._fetch_jobs_page(
scraper_input, location_id, location_type, page, cursor scraper_input, location_id, location_type, page, cursor
) )
all_jobs.extend(jobs) job_list.extend(jobs)
if not jobs or len(all_jobs) >= scraper_input.results_wanted: if not jobs or len(job_list) >= scraper_input.results_wanted:
all_jobs = all_jobs[: scraper_input.results_wanted] job_list = job_list[: scraper_input.results_wanted]
break break
except Exception as e: except Exception as e:
logger.error(f"Glassdoor: {str(e)}") logger.error(f"Glassdoor: {str(e)}")
break break
return JobResponse(jobs=all_jobs) return JobResponse(jobs=job_list)
def _fetch_jobs_page( def _fetch_jobs_page(
self, self,
@@ -189,6 +189,15 @@ class GlassdoorScraper(Scraper):
except: except:
description = None description = None
company_url = f"{self.base_url}Overview/W-EI_IE{company_id}.htm" company_url = f"{self.base_url}Overview/W-EI_IE{company_id}.htm"
company_logo = (
job_data["jobview"].get("overview", {}).get("squareLogoUrl", None)
)
listing_type = (
job_data["jobview"]
.get("header", {})
.get("adOrderSponsorshipLevel", "")
.lower()
)
return JobPost( return JobPost(
id=str(job_id), id=str(job_id),
title=title, title=title,
@@ -201,6 +210,8 @@ class GlassdoorScraper(Scraper):
is_remote=is_remote, is_remote=is_remote,
description=description, description=description,
emails=extract_emails_from_text(description) if description else None, emails=extract_emails_from_text(description) if description else None,
logo_photo_url=company_logo,
listing_type=listing_type,
) )
def _fetch_job_description(self, job_id): def _fetch_job_description(self, job_id):

View File

@@ -176,7 +176,7 @@ class IndeedScraper(Scraper):
keys.append("DSQF7") keys.append("DSQF7")
if keys: if keys:
keys_str = '", "'.join(keys) # Prepare your keys string keys_str = '", "'.join(keys)
filters_str = f""" filters_str = f"""
filters: {{ filters: {{
composite: {{ composite: {{
@@ -226,7 +226,7 @@ class IndeedScraper(Scraper):
country=job.get("location", {}).get("countryCode"), country=job.get("location", {}).get("countryCode"),
), ),
job_type=job_type, job_type=job_type,
compensation=self._get_compensation(job), compensation=self._get_compensation(job["compensation"]),
date_posted=date_posted, date_posted=date_posted,
job_url=job_url, job_url=job_url,
job_url_direct=( job_url_direct=(
@@ -244,6 +244,7 @@ class IndeedScraper(Scraper):
.replace("Iv1", "") .replace("Iv1", "")
.replace("_", " ") .replace("_", " ")
.title() .title()
.strip()
if employer_details.get("industry") if employer_details.get("industry")
else None else None
), ),
@@ -280,14 +281,19 @@ class IndeedScraper(Scraper):
return job_types return job_types
@staticmethod @staticmethod
def _get_compensation(job: dict) -> Compensation | None: def _get_compensation(compensation: dict) -> Compensation | None:
""" """
Parses the job to get compensation Parses the job to get compensation
:param job: :param job:
:param job:
:return: compensation object :return: compensation object
""" """
comp = job["compensation"]["baseSalary"] if not compensation["baseSalary"] and not compensation["estimated"]:
return None
comp = (
compensation["baseSalary"]
if compensation["baseSalary"]
else compensation["estimated"]["baseSalary"]
)
if not comp: if not comp:
return None return None
interval = IndeedScraper._get_compensation_interval(comp["unitOfWork"]) interval = IndeedScraper._get_compensation_interval(comp["unitOfWork"])
@@ -297,9 +303,13 @@ class IndeedScraper(Scraper):
max_range = comp["range"].get("max") max_range = comp["range"].get("max")
return Compensation( return Compensation(
interval=interval, interval=interval,
min_amount=round(min_range, 2) if min_range is not None else None, min_amount=int(min_range) if min_range is not None else None,
max_amount=round(max_range, 2) if max_range is not None else None, max_amount=int(max_range) if max_range is not None else None,
currency=job["compensation"]["currencyCode"], currency=(
compensation["estimated"]["currencyCode"]
if compensation["estimated"]
else compensation["currencyCode"]
),
) )
@staticmethod @staticmethod
@@ -353,10 +363,9 @@ class IndeedScraper(Scraper):
jobSearch( jobSearch(
{what} {what}
{location} {location}
includeSponsoredResults: NONE
limit: 100 limit: 100
sort: DATE
{cursor} {cursor}
sort: RELEVANCE
{filters} {filters}
) {{ ) {{
pageInfo {{ pageInfo {{
@@ -365,6 +374,9 @@ class IndeedScraper(Scraper):
results {{ results {{
trackingKey trackingKey
job {{ job {{
source {{
name
}}
key key
title title
datePublished datePublished
@@ -385,6 +397,18 @@ class IndeedScraper(Scraper):
}} }}
}} }}
compensation {{ compensation {{
estimated {{
currencyCode
baseSalary {{
unitOfWork
range {{
... on Range {{
min
max
}}
}}
}}
}}
baseSalary {{ baseSalary {{
unitOfWork unitOfWork
range {{ range {{

View File

@@ -69,7 +69,7 @@ class LinkedInScraper(Scraper):
""" """
self.scraper_input = scraper_input self.scraper_input = scraper_input
job_list: list[JobPost] = [] job_list: list[JobPost] = []
seen_urls = set() seen_ids = set()
page = scraper_input.offset // 10 * 10 if scraper_input.offset else 0 page = scraper_input.offset // 10 * 10 if scraper_input.offset else 0
request_count = 0 request_count = 0
seconds_old = ( seconds_old = (
@@ -133,25 +133,24 @@ class LinkedInScraper(Scraper):
return JobResponse(jobs=job_list) return JobResponse(jobs=job_list)
for job_card in job_cards: for job_card in job_cards:
job_url = None
href_tag = job_card.find("a", class_="base-card__full-link") href_tag = job_card.find("a", class_="base-card__full-link")
if href_tag and "href" in href_tag.attrs: if href_tag and "href" in href_tag.attrs:
href = href_tag.attrs["href"].split("?")[0] href = href_tag.attrs["href"].split("?")[0]
job_id = href.split("-")[-1] job_id = href.split("-")[-1]
job_url = f"{self.base_url}/jobs/view/{job_id}"
if job_url in seen_urls: if job_id in seen_ids:
continue continue
seen_urls.add(job_url) seen_ids.add(job_id)
try:
fetch_desc = scraper_input.linkedin_fetch_description try:
job_post = self._process_job(job_card, job_url, fetch_desc) fetch_desc = scraper_input.linkedin_fetch_description
if job_post: job_post = self._process_job(job_card, job_id, fetch_desc)
job_list.append(job_post) if job_post:
if not continue_search(): job_list.append(job_post)
break if not continue_search():
except Exception as e: break
raise LinkedInException(str(e)) except Exception as e:
raise LinkedInException(str(e))
if continue_search(): if continue_search():
time.sleep(random.uniform(self.delay, self.delay + self.band_delay)) time.sleep(random.uniform(self.delay, self.delay + self.band_delay))
@@ -161,7 +160,7 @@ class LinkedInScraper(Scraper):
return JobResponse(jobs=job_list) return JobResponse(jobs=job_list)
def _process_job( def _process_job(
self, job_card: Tag, job_url: str, full_descr: bool self, job_card: Tag, job_id: str, full_descr: bool
) -> Optional[JobPost]: ) -> Optional[JobPost]:
salary_tag = job_card.find("span", class_="job-search-card__salary-info") salary_tag = job_card.find("span", class_="job-search-card__salary-info")
@@ -208,18 +207,20 @@ class LinkedInScraper(Scraper):
date_posted = None date_posted = None
job_details = {} job_details = {}
if full_descr: if full_descr:
job_details = self._get_job_details(job_url) job_details = self._get_job_details(job_id)
return JobPost( return JobPost(
id=self._get_id(job_url), id=job_id,
title=title, title=title,
company_name=company, company_name=company,
company_url=company_url, company_url=company_url,
location=location, location=location,
date_posted=date_posted, date_posted=date_posted,
job_url=job_url, job_url=f"{self.base_url}/jobs/view/{job_id}",
compensation=compensation, compensation=compensation,
job_type=job_details.get("job_type"), job_type=job_details.get("job_type"),
job_level=job_details.get("job_level", "").lower(),
company_industry=job_details.get("company_industry"),
description=job_details.get("description"), description=job_details.get("description"),
job_url_direct=job_details.get("job_url_direct"), job_url_direct=job_details.get("job_url_direct"),
emails=extract_emails_from_text(job_details.get("description")), emails=extract_emails_from_text(job_details.get("description")),
@@ -227,24 +228,16 @@ class LinkedInScraper(Scraper):
job_function=job_details.get("job_function"), job_function=job_details.get("job_function"),
) )
def _get_id(self, url: str): def _get_job_details(self, job_id: str) -> dict:
"""
Extracts the job id from the job url
:param url:
:return: str
"""
if not url:
return None
return url.split("/")[-1]
def _get_job_details(self, job_page_url: str) -> dict:
""" """
Retrieves job description and other job details by going to the job page url Retrieves job description and other job details by going to the job page url
:param job_page_url: :param job_page_url:
:return: dict :return: dict
""" """
try: try:
response = self.session.get(job_page_url, timeout=5) response = self.session.get(
f"{self.base_url}/jobs/view/{job_id}", timeout=5
)
response.raise_for_status() response.raise_for_status()
except: except:
return {} return {}
@@ -275,6 +268,8 @@ class LinkedInScraper(Scraper):
job_function = job_function_span.text.strip() job_function = job_function_span.text.strip()
return { return {
"description": description, "description": description,
"job_level": self._parse_job_level(soup),
"company_industry": self._parse_company_industry(soup),
"job_type": self._parse_job_type(soup), "job_type": self._parse_job_type(soup),
"job_url_direct": self._parse_job_url_direct(soup), "job_url_direct": self._parse_job_url_direct(soup),
"logo_photo_url": soup.find("img", {"class": "artdeco-entity-image"}).get( "logo_photo_url": soup.find("img", {"class": "artdeco-entity-image"}).get(
@@ -334,6 +329,52 @@ class LinkedInScraper(Scraper):
return [get_enum_from_job_type(employment_type)] if employment_type else [] return [get_enum_from_job_type(employment_type)] if employment_type else []
@staticmethod
def _parse_job_level(soup_job_level: BeautifulSoup) -> str | None:
"""
Gets the job level from job page
:param soup_job_level:
:return: str
"""
h3_tag = soup_job_level.find(
"h3",
class_="description__job-criteria-subheader",
string=lambda text: "Seniority level" in text,
)
job_level = None
if h3_tag:
job_level_span = h3_tag.find_next_sibling(
"span",
class_="description__job-criteria-text description__job-criteria-text--criteria",
)
if job_level_span:
job_level = job_level_span.get_text(strip=True)
return job_level
@staticmethod
def _parse_company_industry(soup_industry: BeautifulSoup) -> str | None:
"""
Gets the company industry from job page
:param soup_industry:
:return: str
"""
h3_tag = soup_industry.find(
"h3",
class_="description__job-criteria-subheader",
string=lambda text: "Industries" in text,
)
industry = None
if h3_tag:
industry_span = h3_tag.find_next_sibling(
"span",
class_="description__job-criteria-text description__job-criteria-text--criteria",
)
if industry_span:
industry = industry_span.get_text(strip=True)
return industry
def _parse_job_url_direct(self, soup: BeautifulSoup) -> str | None: def _parse_job_url_direct(self, soup: BeautifulSoup) -> str | None:
""" """
Gets the job url direct from job page Gets the job url direct from job page

View File

@@ -10,7 +10,7 @@ import numpy as np
from markdownify import markdownify as md from markdownify import markdownify as md
from requests.adapters import HTTPAdapter, Retry from requests.adapters import HTTPAdapter, Retry
from ..jobs import JobType from ..jobs import CompensationInterval, JobType
logger = logging.getLogger("JobSpy") logger = logging.getLogger("JobSpy")
logger.propagate = False logger.propagate = False
@@ -185,3 +185,69 @@ def remove_attributes(tag):
for attr in list(tag.attrs): for attr in list(tag.attrs):
del tag[attr] del tag[attr]
return tag return tag
def extract_salary(
salary_str,
lower_limit=1000,
upper_limit=700000,
hourly_threshold=350,
monthly_threshold=30000,
enforce_annual_salary=False,
):
if not salary_str:
return None, None, None, None
annual_max_salary = None
min_max_pattern = r"\$(\d+(?:,\d+)?(?:\.\d+)?)([kK]?)\s*[-—–]\s*(?:\$)?(\d+(?:,\d+)?(?:\.\d+)?)([kK]?)"
def to_int(s):
return int(float(s.replace(",", "")))
def convert_hourly_to_annual(hourly_wage):
return hourly_wage * 2080
def convert_monthly_to_annual(monthly_wage):
return monthly_wage * 12
match = re.search(min_max_pattern, salary_str)
if match:
min_salary = to_int(match.group(1))
max_salary = to_int(match.group(3))
# Handle 'k' suffix for min and max salaries independently
if "k" in match.group(2).lower() or "k" in match.group(4).lower():
min_salary *= 1000
max_salary *= 1000
# Convert to annual if less than the hourly threshold
if min_salary < hourly_threshold:
interval = CompensationInterval.HOURLY.value
annual_min_salary = convert_hourly_to_annual(min_salary)
if max_salary < hourly_threshold:
annual_max_salary = convert_hourly_to_annual(max_salary)
elif min_salary < monthly_threshold:
interval = CompensationInterval.MONTHLY.value
annual_min_salary = convert_monthly_to_annual(min_salary)
if max_salary < monthly_threshold:
annual_max_salary = convert_monthly_to_annual(max_salary)
else:
interval = CompensationInterval.YEARLY.value
annual_min_salary = min_salary
annual_max_salary = max_salary
# Ensure salary range is within specified limits
if not annual_max_salary:
return None, None, None, None
if (
lower_limit <= annual_min_salary <= upper_limit
and lower_limit <= annual_max_salary <= upper_limit
and annual_min_salary < annual_max_salary
):
if enforce_annual_salary:
return interval, annual_min_salary, annual_max_salary, "USD"
else:
return interval, min_salary, max_salary, "USD"
return None, None, None, None

View File

@@ -135,6 +135,7 @@ class ZipRecruiterScraper(Scraper):
self.seen_urls.add(job_url) self.seen_urls.add(job_url)
description = job.get("job_description", "").strip() description = job.get("job_description", "").strip()
listing_type = job.get("buyer_type", "")
description = ( description = (
markdown_converter(description) markdown_converter(description)
if self.scraper_input.description_format == DescriptionFormat.MARKDOWN if self.scraper_input.description_format == DescriptionFormat.MARKDOWN
@@ -175,6 +176,7 @@ class ZipRecruiterScraper(Scraper):
description=description_full if description_full else description, description=description_full if description_full else description,
emails=extract_emails_from_text(description) if description else None, emails=extract_emails_from_text(description) if description else None,
job_url_direct=job_url_direct, job_url_direct=job_url_direct,
listing_type=listing_type,
) )
def _get_descr(self, job_url): def _get_descr(self, job_url):
@@ -198,7 +200,7 @@ class ZipRecruiterScraper(Scraper):
script_tag = soup.find("script", type="application/json") script_tag = soup.find("script", type="application/json")
if script_tag: if script_tag:
job_json = json.loads(script_tag.string) job_json = json.loads(script_tag.string)
job_url_val = job_json["model"]["saveJobURL"] job_url_val = job_json["model"].get("saveJobURL", "")
m = re.search(r"job_url=(.+)", job_url_val) m = re.search(r"job_url=(.+)", job_url_val)
if m: if m:
job_url_direct = m.group(1) job_url_direct = m.group(1)

View File

@@ -4,11 +4,15 @@ import pandas as pd
def test_all(): def test_all():
result = scrape_jobs( result = scrape_jobs(
site_name=["linkedin", "indeed", "zip_recruiter", "glassdoor"], site_name=[
search_term="software engineer", "linkedin",
"indeed",
"glassdoor",
], # ziprecruiter needs good ip, and temp fix to pass test on ci
search_term="engineer",
results_wanted=5, results_wanted=5,
) )
assert ( assert (
isinstance(result, pd.DataFrame) and not result.empty isinstance(result, pd.DataFrame) and len(result) == 15
), "Result should be a non-empty DataFrame" ), "Result should be a non-empty DataFrame"

View File

@@ -2,10 +2,12 @@ from ..jobspy import scrape_jobs
import pandas as pd import pandas as pd
def test_indeed(): def test_glassdoor():
result = scrape_jobs( result = scrape_jobs(
site_name="glassdoor", search_term="software engineer", country_indeed="USA" site_name="glassdoor",
search_term="engineer",
results_wanted=5,
) )
assert ( assert (
isinstance(result, pd.DataFrame) and not result.empty isinstance(result, pd.DataFrame) and len(result) == 5
), "Result should be a non-empty DataFrame" ), "Result should be a non-empty DataFrame"

View File

@@ -4,8 +4,10 @@ import pandas as pd
def test_indeed(): def test_indeed():
result = scrape_jobs( result = scrape_jobs(
site_name="indeed", search_term="software engineer", country_indeed="usa" site_name="indeed",
search_term="engineer",
results_wanted=5,
) )
assert ( assert (
isinstance(result, pd.DataFrame) and not result.empty isinstance(result, pd.DataFrame) and len(result) == 5
), "Result should be a non-empty DataFrame" ), "Result should be a non-empty DataFrame"

View File

@@ -3,10 +3,7 @@ import pandas as pd
def test_linkedin(): def test_linkedin():
result = scrape_jobs( result = scrape_jobs(site_name="linkedin", search_term="engineer", results_wanted=5)
site_name="linkedin",
search_term="software engineer",
)
assert ( assert (
isinstance(result, pd.DataFrame) and not result.empty isinstance(result, pd.DataFrame) and len(result) == 5
), "Result should be a non-empty DataFrame" ), "Result should be a non-empty DataFrame"

View File

@@ -4,10 +4,9 @@ import pandas as pd
def test_ziprecruiter(): def test_ziprecruiter():
result = scrape_jobs( result = scrape_jobs(
site_name="zip_recruiter", site_name="zip_recruiter", search_term="software engineer", results_wanted=5
search_term="software engineer",
) )
assert ( assert (
isinstance(result, pd.DataFrame) and not result.empty isinstance(result, pd.DataFrame) and len(result) == 5
), "Result should be a non-empty DataFrame" ), "Result should be a non-empty DataFrame"