mirror of
https://github.com/Bunsly/JobSpy.git
synced 2026-03-05 03:54:31 -08:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f395597fdd | ||
|
|
6372e41bd9 | ||
|
|
6c869decb8 | ||
|
|
9f4083380d | ||
|
|
9207ab56f6 | ||
|
|
757a94853e | ||
|
|
6bc191d5c7 | ||
|
|
0cc34287f7 | ||
|
|
923979093b | ||
|
|
286f0e4487 | ||
|
|
f7b29d43a2 | ||
|
|
6f1490458c | ||
|
|
6bb7d81ba8 | ||
|
|
0e046432d1 | ||
|
|
209e0e65b6 | ||
|
|
8570c0651e | ||
|
|
8678b0bbe4 | ||
|
|
60d4d911c9 | ||
|
|
2a0cba8c7e | ||
|
|
de70189fa2 | ||
|
|
b55c0eb86d | ||
|
|
88c95c4ad5 | ||
|
|
d8d33d602f | ||
|
|
6330c14879 | ||
|
|
48631ea271 | ||
|
|
edffe18e65 | ||
|
|
0988230a24 | ||
|
|
d000a81eb3 | ||
|
|
ccb0c17660 | ||
|
|
df339610fa | ||
|
|
c501006bd8 | ||
|
|
89a3ee231c | ||
|
|
6439f71433 | ||
|
|
7f6271b2e0 | ||
|
|
5cb7ffe5fd | ||
|
|
cd29f79796 | ||
|
|
65d2e5e707 | ||
|
|
08d63a87a2 |
22
.github/workflows/python-test.yml
vendored
Normal file
22
.github/workflows/python-test.yml
vendored
Normal 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 tests/test_all.py
|
||||||
172
README.md
172
README.md
@@ -11,10 +11,7 @@ work with us.*
|
|||||||
|
|
||||||
- Scrapes job postings from **LinkedIn**, **Indeed**, **Glassdoor**, & **ZipRecruiter** simultaneously
|
- Scrapes job postings from **LinkedIn**, **Indeed**, **Glassdoor**, & **ZipRecruiter** simultaneously
|
||||||
- Aggregates the job postings in a Pandas DataFrame
|
- Aggregates the job postings in a Pandas DataFrame
|
||||||
- Proxy support
|
- Proxies support
|
||||||
|
|
||||||
[Video Guide for JobSpy](https://www.youtube.com/watch?v=RuP1HrAZnxs&pp=ygUgam9icyBzY3JhcGVyIGJvdCBsaW5rZWRpbiBpbmRlZWQ%3D) -
|
|
||||||
Updated for release v1.1.3
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -39,11 +36,14 @@ jobs = scrape_jobs(
|
|||||||
results_wanted=20,
|
results_wanted=20,
|
||||||
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"],
|
||||||
|
|
||||||
)
|
)
|
||||||
print(f"Found {len(jobs)} jobs")
|
print(f"Found {len(jobs)} jobs")
|
||||||
print(jobs.head())
|
print(jobs.head())
|
||||||
jobs.to_csv("jobs.csv", quoting=csv.QUOTE_NONNUMERIC, escapechar="\\", index=False) # to_xlsx
|
jobs.to_csv("jobs.csv", quoting=csv.QUOTE_NONNUMERIC, escapechar="\\", index=False) # to_excel
|
||||||
```
|
```
|
||||||
|
|
||||||
### Output
|
### Output
|
||||||
@@ -62,59 +62,113 @@ zip_recruiter Software Developer TEKsystems Phoenix
|
|||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
Optional
|
Optional
|
||||||
├── site_name (list|str): linkedin, zip_recruiter, indeed, glassdoor (default is all four)
|
├── site_name (list|str):
|
||||||
|
| linkedin, zip_recruiter, indeed, glassdoor
|
||||||
|
| (default is all four)
|
||||||
|
│
|
||||||
├── search_term (str)
|
├── search_term (str)
|
||||||
|
│
|
||||||
├── location (str)
|
├── location (str)
|
||||||
├── distance (int): in miles, default 50
|
│
|
||||||
├── job_type (str): fulltime, parttime, internship, contract
|
├── distance (int):
|
||||||
├── proxy (str): in format 'http://user:pass@host:port'
|
| in miles, default 50
|
||||||
|
│
|
||||||
|
├── job_type (str):
|
||||||
|
| fulltime, parttime, internship, contract
|
||||||
|
│
|
||||||
|
├── proxies (list):
|
||||||
|
| in format ['user:pass@host:port', 'localhost']
|
||||||
|
| each job board scraper will round robin through the proxies
|
||||||
|
|
|
||||||
|
├── ca_cert (str)
|
||||||
|
| path to CA Certificate file for proxies
|
||||||
|
│
|
||||||
├── is_remote (bool)
|
├── is_remote (bool)
|
||||||
├── results_wanted (int): number of job results to retrieve for each site specified in 'site_name'
|
│
|
||||||
├── easy_apply (bool): filters for jobs that are hosted on the job board site (LinkedIn & Indeed do not allow pairing this with hours_old)
|
├── results_wanted (int):
|
||||||
├── linkedin_fetch_description (bool): fetches full description and direct job url for LinkedIn (slower)
|
| number of job results to retrieve for each site specified in 'site_name'
|
||||||
├── linkedin_company_ids (list[int]): searches for linkedin jobs with specific company ids
|
│
|
||||||
├── description_format (str): markdown, html (Format type of the job descriptions. Default is markdown.)
|
├── easy_apply (bool):
|
||||||
├── country_indeed (str): filters the country on Indeed (see below for correct spelling)
|
| filters for jobs that are hosted on the job board site
|
||||||
├── offset (int): starts the search from an offset (e.g. 25 will start the search from the 25th result)
|
│
|
||||||
├── hours_old (int): filters jobs by the number of hours since the job was posted (ZipRecruiter and Glassdoor round up to next day. If you use this on Indeed, it will not filter by job_type/is_remote/easy_apply)
|
├── description_format (str):
|
||||||
├── verbose (int) {0, 1, 2}: Controls the verbosity of the runtime printouts (0 prints only errors, 1 is errors+warnings, 2 is all logs. Default is 2.)
|
| markdown, html (Format type of the job descriptions. Default is markdown.)
|
||||||
├── hyperlinks (bool): Whether to turn `job_url`s into hyperlinks. Default is false.
|
│
|
||||||
|
├── offset (int):
|
||||||
|
| starts the search from an offset (e.g. 25 will start the search from the 25th result)
|
||||||
|
│
|
||||||
|
├── hours_old (int):
|
||||||
|
| filters jobs by the number of hours since the job was posted
|
||||||
|
| (ZipRecruiter and Glassdoor round up to next day.)
|
||||||
|
│
|
||||||
|
├── verbose (int) {0, 1, 2}:
|
||||||
|
| Controls the verbosity of the runtime printouts
|
||||||
|
| (0 prints only errors, 1 is errors+warnings, 2 is all logs. Default is 2.)
|
||||||
|
|
||||||
|
├── linkedin_fetch_description (bool):
|
||||||
|
| fetches full description and direct job url for LinkedIn (Increases requests by O(n))
|
||||||
|
│
|
||||||
|
├── linkedin_company_ids (list[int]):
|
||||||
|
| searches for linkedin jobs with specific company ids
|
||||||
|
|
|
||||||
|
├── country_indeed (str):
|
||||||
|
| filters the country on Indeed & Glassdoor (see below for correct spelling)
|
||||||
|
|
|
||||||
|
├── enforce_annual_salary (bool):
|
||||||
|
| converts wages to annual salary
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
├── Indeed limitations:
|
||||||
|
| Only one from this list can be used in a search:
|
||||||
|
| - hours_old
|
||||||
|
| - job_type & is_remote
|
||||||
|
| - easy_apply
|
||||||
|
│
|
||||||
|
└── LinkedIn limitations:
|
||||||
|
| Only one from this list can be used in a search:
|
||||||
|
| - hours_old
|
||||||
|
| - easy_apply
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### JobPost Schema
|
### JobPost Schema
|
||||||
|
|
||||||
```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
|
||||||
├── compensation (object)
|
├── job_function
|
||||||
│ ├── interval (str): yearly, monthly, weekly, daily, hourly
|
│ ├── interval: yearly, monthly, weekly, daily, hourly
|
||||||
│ ├── min_amount (int)
|
│ ├── min_amount
|
||||||
│ ├── max_amount (int)
|
│ ├── max_amount
|
||||||
│ └── currency (enum)
|
│ ├── currency
|
||||||
└── date_posted (date)
|
│ └── salary_source: direct_data, description (parsed from posting)
|
||||||
└── emails (str)
|
├── date_posted
|
||||||
└── is_remote (bool)
|
├── emails
|
||||||
|
└── 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)
|
└── logo_photo_url
|
||||||
└── ceo_name (str)
|
|
||||||
└── ceo_photo_url (str)
|
|
||||||
└── logo_photo_url (str)
|
|
||||||
└── banner_photo_url (str)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Countries for Job Searching
|
## Supported Countries for Job Searching
|
||||||
@@ -157,10 +211,22 @@ You can specify the following countries when searching on Indeed (use the exact
|
|||||||
## Notes
|
## Notes
|
||||||
* Indeed is the best scraper currently with no rate limiting.
|
* Indeed is the best scraper currently with no rate limiting.
|
||||||
* All the job board endpoints are capped at around 1000 jobs on a given search.
|
* All the job board endpoints are capped at around 1000 jobs on a given search.
|
||||||
* LinkedIn is the most restrictive and usually rate limits around the 10th page.
|
* LinkedIn is the most restrictive and usually rate limits around the 10th page with one ip. Proxies are a must basically.
|
||||||
|
|
||||||
## Frequently Asked Questions
|
## Frequently Asked Questions
|
||||||
|
|
||||||
|
---
|
||||||
|
**Q: Why is Indeed giving unrelated roles?**
|
||||||
|
**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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Q: Received a response code 429?**
|
||||||
|
**A:** This indicates that you have been blocked by the job board site for sending too many requests. All of the job board sites are aggressive with blocking. We recommend:
|
||||||
|
|
||||||
|
- Wait some time between scrapes (site-dependent).
|
||||||
|
- Try using the proxies param to change your IP address.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Q: Encountering issues with your queries?**
|
**Q: Encountering issues with your queries?**
|
||||||
@@ -168,11 +234,3 @@ You can specify the following countries when searching on Indeed (use the exact
|
|||||||
persist, [submit an issue](https://github.com/Bunsly/JobSpy/issues).
|
persist, [submit an issue](https://github.com/Bunsly/JobSpy/issues).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Q: Received a response code 429?**
|
|
||||||
**A:** This indicates that you have been blocked by the job board site for sending too many requests. All of the job board sites are aggressive with blocking. We recommend:
|
|
||||||
|
|
||||||
- Waiting some time between scrapes (site-dependent).
|
|
||||||
- Trying a VPN or proxy to change your IP address.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
from jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
jobs: pd.DataFrame = scrape_jobs(
|
|
||||||
site_name=["indeed", "linkedin", "zip_recruiter", "glassdoor"],
|
|
||||||
search_term="software engineer",
|
|
||||||
location="Dallas, TX",
|
|
||||||
results_wanted=25, # be wary the higher it is, the more likey you'll get blocked (rotating proxy can help tho)
|
|
||||||
country_indeed="USA",
|
|
||||||
# proxy="http://jobspy:5a4vpWtj8EeJ2hoYzk@ca.smartproxy.com:20001",
|
|
||||||
)
|
|
||||||
|
|
||||||
# formatting for pandas
|
|
||||||
pd.set_option("display.max_columns", None)
|
|
||||||
pd.set_option("display.max_rows", None)
|
|
||||||
pd.set_option("display.width", None)
|
|
||||||
pd.set_option("display.max_colwidth", 50) # set to 0 to see full job url / desc
|
|
||||||
|
|
||||||
# 1: output to console
|
|
||||||
print(jobs)
|
|
||||||
|
|
||||||
# 2: output to .csv
|
|
||||||
jobs.to_csv("./jobs.csv", index=False)
|
|
||||||
print("outputted to jobs.csv")
|
|
||||||
|
|
||||||
# 3: output to .xlsx
|
|
||||||
# jobs.to_xlsx('jobs.xlsx', index=False)
|
|
||||||
|
|
||||||
# 4: display in Jupyter Notebook (1. pip install jupyter 2. jupyter notebook)
|
|
||||||
# display(jobs)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "00a94b47-f47b-420f-ba7e-714ef219c006",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"from jobspy import scrape_jobs\n",
|
|
||||||
"import pandas as pd\n",
|
|
||||||
"from IPython.display import display, HTML"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "9f773e6c-d9fc-42cc-b0ef-63b739e78435",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"pd.set_option('display.max_columns', None)\n",
|
|
||||||
"pd.set_option('display.max_rows', None)\n",
|
|
||||||
"pd.set_option('display.width', None)\n",
|
|
||||||
"pd.set_option('display.max_colwidth', 50)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "1253c1f8-9437-492e-9dd3-e7fe51099420",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# example 1 (no hyperlinks, USA)\n",
|
|
||||||
"jobs = scrape_jobs(\n",
|
|
||||||
" site_name=[\"linkedin\"],\n",
|
|
||||||
" location='san francisco',\n",
|
|
||||||
" search_term=\"engineer\",\n",
|
|
||||||
" results_wanted=5,\n",
|
|
||||||
"\n",
|
|
||||||
" # use if you want to use a proxy\n",
|
|
||||||
" # proxy=\"socks5://jobspy:5a4vpWtj4EeJ2hoYzk@us.smartproxy.com:10001\",\n",
|
|
||||||
" proxy=\"http://jobspy:5a4vpWtj4EeJ2hoYzk@us.smartproxy.com:10001\",\n",
|
|
||||||
" #proxy=\"https://jobspy:5a4vpWtj4EeJ2hoYzk@us.smartproxy.com:10001\",\n",
|
|
||||||
")\n",
|
|
||||||
"display(jobs)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "6a581b2d-f7da-4fac-868d-9efe143ee20a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# example 2 - remote USA & hyperlinks\n",
|
|
||||||
"jobs = scrape_jobs(\n",
|
|
||||||
" site_name=[\"linkedin\", \"zip_recruiter\", \"indeed\"],\n",
|
|
||||||
" # location='san francisco',\n",
|
|
||||||
" search_term=\"software engineer\",\n",
|
|
||||||
" country_indeed=\"USA\",\n",
|
|
||||||
" hyperlinks=True,\n",
|
|
||||||
" is_remote=True,\n",
|
|
||||||
" results_wanted=5, \n",
|
|
||||||
")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "fe8289bc-5b64-4202-9a64-7c117c83fd9a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# use if hyperlinks=True\n",
|
|
||||||
"html = jobs.to_html(escape=False)\n",
|
|
||||||
"# change max-width: 200px to show more or less of the content\n",
|
|
||||||
"truncate_width = f'<style>.dataframe td {{ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}</style>{html}'\n",
|
|
||||||
"display(HTML(truncate_width))"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "951c2fe1-52ff-407d-8bb1-068049b36777",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# example 3 - with hyperlinks, international - linkedin (no zip_recruiter)\n",
|
|
||||||
"jobs = scrape_jobs(\n",
|
|
||||||
" site_name=[\"linkedin\"],\n",
|
|
||||||
" location='berlin',\n",
|
|
||||||
" search_term=\"engineer\",\n",
|
|
||||||
" hyperlinks=True,\n",
|
|
||||||
" results_wanted=5,\n",
|
|
||||||
" easy_apply=True\n",
|
|
||||||
")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "1e37a521-caef-441c-8fc2-2eb5b2e7da62",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# use if hyperlinks=True\n",
|
|
||||||
"html = jobs.to_html(escape=False)\n",
|
|
||||||
"# change max-width: 200px to show more or less of the content\n",
|
|
||||||
"truncate_width = f'<style>.dataframe td {{ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}</style>{html}'\n",
|
|
||||||
"display(HTML(truncate_width))"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "0650e608-0b58-4bf5-ae86-68348035b16a",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# example 4 - international indeed (no zip_recruiter)\n",
|
|
||||||
"jobs = scrape_jobs(\n",
|
|
||||||
" site_name=[\"indeed\"],\n",
|
|
||||||
" search_term=\"engineer\",\n",
|
|
||||||
" country_indeed = \"China\",\n",
|
|
||||||
" hyperlinks=True\n",
|
|
||||||
")"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "40913ac8-3f8a-4d7e-ac47-afb88316432b",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# use if hyperlinks=True\n",
|
|
||||||
"html = jobs.to_html(escape=False)\n",
|
|
||||||
"# change max-width: 200px to show more or less of the content\n",
|
|
||||||
"truncate_width = f'<style>.dataframe td {{ max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}</style>{html}'\n",
|
|
||||||
"display(HTML(truncate_width))"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Python 3 (ipykernel)",
|
|
||||||
"language": "python",
|
|
||||||
"name": "python3"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": {
|
|
||||||
"name": "ipython",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"file_extension": ".py",
|
|
||||||
"mimetype": "text/x-python",
|
|
||||||
"name": "python",
|
|
||||||
"nbconvert_exporter": "python",
|
|
||||||
"pygments_lexer": "ipython3",
|
|
||||||
"version": "3.11.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
# creates csv a new filename if the jobs.csv already exists.
|
|
||||||
csv_filename = "jobs.csv"
|
|
||||||
counter = 1
|
|
||||||
while os.path.exists(csv_filename):
|
|
||||||
csv_filename = f"jobs_{counter}.csv"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
# results wanted and offset
|
|
||||||
results_wanted = 1000
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
all_jobs = []
|
|
||||||
|
|
||||||
# max retries
|
|
||||||
max_retries = 3
|
|
||||||
|
|
||||||
# nuumber of results at each iteration
|
|
||||||
results_in_each_iteration = 30
|
|
||||||
|
|
||||||
while len(all_jobs) < results_wanted:
|
|
||||||
retry_count = 0
|
|
||||||
while retry_count < max_retries:
|
|
||||||
print("Doing from", offset, "to", offset + results_in_each_iteration, "jobs")
|
|
||||||
try:
|
|
||||||
jobs = scrape_jobs(
|
|
||||||
site_name=["indeed"],
|
|
||||||
search_term="software engineer",
|
|
||||||
# New York, NY
|
|
||||||
# Dallas, TX
|
|
||||||
# Los Angeles, CA
|
|
||||||
location="Los Angeles, CA",
|
|
||||||
results_wanted=min(
|
|
||||||
results_in_each_iteration, results_wanted - len(all_jobs)
|
|
||||||
),
|
|
||||||
country_indeed="USA",
|
|
||||||
offset=offset,
|
|
||||||
# proxy="http://jobspy:5a4vpWtj8EeJ2hoYzk@ca.smartproxy.com:20001",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add the scraped jobs to the list
|
|
||||||
all_jobs.extend(jobs.to_dict("records"))
|
|
||||||
|
|
||||||
# Increment the offset for the next page of results
|
|
||||||
offset += results_in_each_iteration
|
|
||||||
|
|
||||||
# Add a delay to avoid rate limiting (you can adjust the delay time as needed)
|
|
||||||
print(f"Scraped {len(all_jobs)} jobs")
|
|
||||||
print("Sleeping secs", 100 * (retry_count + 1))
|
|
||||||
time.sleep(100 * (retry_count + 1)) # Sleep for 2 seconds between requests
|
|
||||||
|
|
||||||
break # Break out of the retry loop if successful
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
retry_count += 1
|
|
||||||
print("Sleeping secs before retry", 100 * (retry_count + 1))
|
|
||||||
time.sleep(100 * (retry_count + 1))
|
|
||||||
if retry_count >= max_retries:
|
|
||||||
print("Max retries reached. Exiting.")
|
|
||||||
break
|
|
||||||
|
|
||||||
# DataFrame from the collected job data
|
|
||||||
jobs_df = pd.DataFrame(all_jobs)
|
|
||||||
|
|
||||||
# Formatting
|
|
||||||
pd.set_option("display.max_columns", None)
|
|
||||||
pd.set_option("display.max_rows", None)
|
|
||||||
pd.set_option("display.width", None)
|
|
||||||
pd.set_option("display.max_colwidth", 50)
|
|
||||||
|
|
||||||
print(jobs_df)
|
|
||||||
|
|
||||||
jobs_df.to_csv(csv_filename, index=False)
|
|
||||||
print(f"Outputted to {csv_filename}")
|
|
||||||
2181
poetry.lock
generated
2181
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
2
poetry.toml
Normal file
2
poetry.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[virtualenvs]
|
||||||
|
in-project = true
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "python-jobspy"
|
name = "python-jobspy"
|
||||||
version = "1.1.52"
|
version = "1.1.72"
|
||||||
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,10 +16,10 @@ 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.13.1"
|
||||||
regex = "^2024.4.28"
|
regex = "^2024.4.28"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 set_logger_level, extract_salary, create_logger
|
||||||
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,
|
||||||
@@ -30,12 +30,14 @@ def scrape_jobs(
|
|||||||
results_wanted: int = 15,
|
results_wanted: int = 15,
|
||||||
country_indeed: str = "usa",
|
country_indeed: str = "usa",
|
||||||
hyperlinks: bool = False,
|
hyperlinks: bool = False,
|
||||||
proxy: str | None = None,
|
proxies: list[str] | str | None = None,
|
||||||
|
ca_cert: str | None = None,
|
||||||
description_format: str = "markdown",
|
description_format: str = "markdown",
|
||||||
linkedin_fetch_description: bool | None = False,
|
linkedin_fetch_description: bool | None = False,
|
||||||
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:
|
||||||
@@ -96,11 +98,11 @@ def scrape_jobs(
|
|||||||
|
|
||||||
def scrape_site(site: Site) -> Tuple[str, JobResponse]:
|
def scrape_site(site: Site) -> Tuple[str, JobResponse]:
|
||||||
scraper_class = SCRAPER_MAPPING[site]
|
scraper_class = SCRAPER_MAPPING[site]
|
||||||
scraper = scraper_class(proxy=proxy)
|
scraper = scraper_class(proxies=proxies, ca_cert=ca_cert)
|
||||||
scraped_data: JobResponse = scraper.scrape(scraper_input)
|
scraped_data: JobResponse = scraper.scrape(scraper_input)
|
||||||
cap_name = site.value.capitalize()
|
cap_name = site.value.capitalize()
|
||||||
site_name = "ZipRecruiter" if cap_name == "Zip_recruiter" else cap_name
|
site_name = "ZipRecruiter" if cap_name == "Zip_recruiter" else cap_name
|
||||||
logger.info(f"{site_name} finished scraping")
|
create_logger(site_name).info(f"finished scraping")
|
||||||
return site.value, scraped_data
|
return site.value, scraped_data
|
||||||
|
|
||||||
site_to_jobs_dict = {}
|
site_to_jobs_dict = {}
|
||||||
@@ -118,6 +120,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 +167,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)
|
||||||
|
|
||||||
@@ -168,6 +206,7 @@ def scrape_jobs(
|
|||||||
|
|
||||||
# Desired column order
|
# Desired column order
|
||||||
desired_order = [
|
desired_order = [
|
||||||
|
"id",
|
||||||
"site",
|
"site",
|
||||||
"job_url_hyper" if hyperlinks else "job_url",
|
"job_url_hyper" if hyperlinks else "job_url",
|
||||||
"job_url_direct",
|
"job_url_direct",
|
||||||
@@ -176,24 +215,25 @@ 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",
|
||||||
|
"company_industry",
|
||||||
|
"listing_type",
|
||||||
"emails",
|
"emails",
|
||||||
"description",
|
"description",
|
||||||
"company_url",
|
"company_url",
|
||||||
|
"logo_photo_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",
|
||||||
"logo_photo_url",
|
|
||||||
"banner_photo_url",
|
|
||||||
"ceo_name",
|
|
||||||
"ceo_photo_url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Step 3: Ensure all desired columns are present, adding missing ones as empty
|
# Step 3: Ensure all desired columns are present, adding missing ones as empty
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -226,6 +227,7 @@ class DescriptionFormat(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class JobPost(BaseModel):
|
class JobPost(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
title: str
|
title: str
|
||||||
company_name: str | None
|
company_name: str | None
|
||||||
job_url: str
|
job_url: str
|
||||||
@@ -241,18 +243,25 @@ 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
|
||||||
ceo_name: str | None = None
|
|
||||||
ceo_photo_url: str | None = None
|
|
||||||
logo_photo_url: str | None = None
|
logo_photo_url: str | None = None
|
||||||
banner_photo_url: str | None = None
|
banner_photo_url: str | None = None
|
||||||
|
|
||||||
|
# linkedin only atm
|
||||||
|
job_function: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class JobResponse(BaseModel):
|
class JobResponse(BaseModel):
|
||||||
jobs: list[JobPost] = []
|
jobs: list[JobPost] = []
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -39,9 +42,10 @@ class ScraperInput(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Scraper(ABC):
|
class Scraper(ABC):
|
||||||
def __init__(self, site: Site, proxy: list[str] | None = None):
|
def __init__(self, site: Site, proxies: list[str] | None = None, ca_cert: str | None = None):
|
||||||
self.site = site
|
self.site = site
|
||||||
self.proxy = (lambda p: {"http": p, "https": p} if p else None)(proxy)
|
self.proxies = proxies
|
||||||
|
self.ca_cert = ca_cert
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def scrape(self, scraper_input: ScraperInput) -> JobResponse: ...
|
def scrape(self, scraper_input: ScraperInput) -> JobResponse: ...
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ from typing import Optional, Tuple
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from .constants import fallback_token, query_template, headers
|
||||||
from .. import Scraper, ScraperInput, Site
|
from .. import Scraper, ScraperInput, Site
|
||||||
from ..utils import extract_emails_from_text
|
from ..utils import extract_emails_from_text, create_logger
|
||||||
from ..exceptions import GlassdoorException
|
from ..exceptions import GlassdoorException
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
create_session,
|
create_session,
|
||||||
markdown_converter,
|
markdown_converter,
|
||||||
logger,
|
|
||||||
)
|
)
|
||||||
from ...jobs import (
|
from ...jobs import (
|
||||||
JobPost,
|
JobPost,
|
||||||
@@ -32,14 +32,18 @@ from ...jobs import (
|
|||||||
DescriptionFormat,
|
DescriptionFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = create_logger("Glassdoor")
|
||||||
|
|
||||||
|
|
||||||
class GlassdoorScraper(Scraper):
|
class GlassdoorScraper(Scraper):
|
||||||
def __init__(self, proxy: Optional[str] = None):
|
def __init__(
|
||||||
|
self, proxies: list[str] | str | None = None, ca_cert: str | None = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initializes GlassdoorScraper with the Glassdoor job search url
|
Initializes GlassdoorScraper with the Glassdoor job search url
|
||||||
"""
|
"""
|
||||||
site = Site(Site.GLASSDOOR)
|
site = Site(Site.GLASSDOOR)
|
||||||
super().__init__(site, proxy=proxy)
|
super().__init__(site, proxies=proxies, ca_cert=ca_cert)
|
||||||
|
|
||||||
self.base_url = None
|
self.base_url = None
|
||||||
self.country = None
|
self.country = None
|
||||||
@@ -59,9 +63,12 @@ class GlassdoorScraper(Scraper):
|
|||||||
self.scraper_input.results_wanted = min(900, scraper_input.results_wanted)
|
self.scraper_input.results_wanted = min(900, scraper_input.results_wanted)
|
||||||
self.base_url = self.scraper_input.country.get_glassdoor_url()
|
self.base_url = self.scraper_input.country.get_glassdoor_url()
|
||||||
|
|
||||||
self.session = create_session(self.proxy, is_tls=True, has_retry=True)
|
self.session = create_session(
|
||||||
|
proxies=self.proxies, ca_cert=self.ca_cert, is_tls=True, has_retry=True
|
||||||
|
)
|
||||||
token = self._get_csrf_token()
|
token = self._get_csrf_token()
|
||||||
self.headers["gd-csrf-token"] = token if token else self.fallback_token
|
headers["gd-csrf-token"] = token if token else fallback_token
|
||||||
|
self.session.headers.update(headers)
|
||||||
|
|
||||||
location_id, location_type = self._get_location(
|
location_id, location_type = self._get_location(
|
||||||
scraper_input.location, scraper_input.is_remote
|
scraper_input.location, scraper_input.is_remote
|
||||||
@@ -69,26 +76,26 @@ 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)
|
||||||
tot_pages = (scraper_input.results_wanted // self.jobs_per_page) + 2
|
tot_pages = (scraper_input.results_wanted // self.jobs_per_page) + 2
|
||||||
range_end = min(tot_pages, self.max_pages + 1)
|
range_end = min(tot_pages, self.max_pages + 1)
|
||||||
for page in range(range_start, range_end):
|
for page in range(range_start, range_end):
|
||||||
logger.info(f"Glassdoor search page: {page}")
|
logger.info(f"search page: {page} / {range_end-1}")
|
||||||
try:
|
try:
|
||||||
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,
|
||||||
@@ -107,7 +114,6 @@ class GlassdoorScraper(Scraper):
|
|||||||
payload = self._add_payload(location_id, location_type, page_num, cursor)
|
payload = self._add_payload(location_id, location_type, page_num, cursor)
|
||||||
response = self.session.post(
|
response = self.session.post(
|
||||||
f"{self.base_url}/graph",
|
f"{self.base_url}/graph",
|
||||||
headers=self.headers,
|
|
||||||
timeout_seconds=15,
|
timeout_seconds=15,
|
||||||
data=payload,
|
data=payload,
|
||||||
)
|
)
|
||||||
@@ -148,9 +154,7 @@ class GlassdoorScraper(Scraper):
|
|||||||
"""
|
"""
|
||||||
Fetches csrf token needed for API by visiting a generic page
|
Fetches csrf token needed for API by visiting a generic page
|
||||||
"""
|
"""
|
||||||
res = self.session.get(
|
res = self.session.get(f"{self.base_url}/Job/computer-science-jobs.htm")
|
||||||
f"{self.base_url}/Job/computer-science-jobs.htm", headers=self.headers
|
|
||||||
)
|
|
||||||
pattern = r'"token":\s*"([^"]+)"'
|
pattern = r'"token":\s*"([^"]+)"'
|
||||||
matches = re.findall(pattern, res.text)
|
matches = re.findall(pattern, res.text)
|
||||||
token = None
|
token = None
|
||||||
@@ -189,7 +193,17 @@ 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=f"gd-{job_id}",
|
||||||
title=title,
|
title=title,
|
||||||
company_url=company_url if company_id else None,
|
company_url=company_url if company_id else None,
|
||||||
company_name=company_name,
|
company_name=company_name,
|
||||||
@@ -200,6 +214,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):
|
||||||
@@ -231,7 +247,7 @@ class GlassdoorScraper(Scraper):
|
|||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
res = requests.post(url, json=body, headers=self.headers)
|
res = requests.post(url, json=body, headers=headers)
|
||||||
if res.status_code != 200:
|
if res.status_code != 200:
|
||||||
return None
|
return None
|
||||||
data = res.json()[0]
|
data = res.json()[0]
|
||||||
@@ -244,8 +260,7 @@ class GlassdoorScraper(Scraper):
|
|||||||
if not location or is_remote:
|
if not location or is_remote:
|
||||||
return "11047", "STATE" # remote options
|
return "11047", "STATE" # remote options
|
||||||
url = f"{self.base_url}/findPopularLocationAjax.htm?maxLocationsToReturn=10&term={location}"
|
url = f"{self.base_url}/findPopularLocationAjax.htm?maxLocationsToReturn=10&term={location}"
|
||||||
session = create_session(self.proxy, has_retry=True)
|
res = self.session.get(url)
|
||||||
res = self.session.get(url, headers=self.headers)
|
|
||||||
if res.status_code != 200:
|
if res.status_code != 200:
|
||||||
if res.status_code == 429:
|
if res.status_code == 429:
|
||||||
err = f"429 Response - Blocked by Glassdoor for too many requests"
|
err = f"429 Response - Blocked by Glassdoor for too many requests"
|
||||||
@@ -299,7 +314,7 @@ class GlassdoorScraper(Scraper):
|
|||||||
"fromage": fromage,
|
"fromage": fromage,
|
||||||
"sort": "date",
|
"sort": "date",
|
||||||
},
|
},
|
||||||
"query": self.query_template,
|
"query": query_template,
|
||||||
}
|
}
|
||||||
if self.scraper_input.job_type:
|
if self.scraper_input.job_type:
|
||||||
payload["variables"]["filterParams"].append(
|
payload["variables"]["filterParams"].append(
|
||||||
@@ -347,188 +362,3 @@ class GlassdoorScraper(Scraper):
|
|||||||
for cursor_data in pagination_cursors:
|
for cursor_data in pagination_cursors:
|
||||||
if cursor_data["pageNumber"] == page_num:
|
if cursor_data["pageNumber"] == page_num:
|
||||||
return cursor_data["cursor"]
|
return cursor_data["cursor"]
|
||||||
|
|
||||||
fallback_token = "Ft6oHEWlRZrxDww95Cpazw:0pGUrkb2y3TyOpAIqF2vbPmUXoXVkD3oEGDVkvfeCerceQ5-n8mBg3BovySUIjmCPHCaW0H2nQVdqzbtsYqf4Q:wcqRqeegRUa9MVLJGyujVXB7vWFPjdaS1CtrrzJq-ok"
|
|
||||||
headers = {
|
|
||||||
"authority": "www.glassdoor.com",
|
|
||||||
"accept": "*/*",
|
|
||||||
"accept-language": "en-US,en;q=0.9",
|
|
||||||
"apollographql-client-name": "job-search-next",
|
|
||||||
"apollographql-client-version": "4.65.5",
|
|
||||||
"content-type": "application/json",
|
|
||||||
"origin": "https://www.glassdoor.com",
|
|
||||||
"referer": "https://www.glassdoor.com/",
|
|
||||||
"sec-ch-ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"',
|
|
||||||
"sec-ch-ua-mobile": "?0",
|
|
||||||
"sec-ch-ua-platform": '"macOS"',
|
|
||||||
"sec-fetch-dest": "empty",
|
|
||||||
"sec-fetch-mode": "cors",
|
|
||||||
"sec-fetch-site": "same-origin",
|
|
||||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
|
|
||||||
}
|
|
||||||
query_template = """
|
|
||||||
query JobSearchResultsQuery(
|
|
||||||
$excludeJobListingIds: [Long!],
|
|
||||||
$keyword: String,
|
|
||||||
$locationId: Int,
|
|
||||||
$locationType: LocationTypeEnum,
|
|
||||||
$numJobsToShow: Int!,
|
|
||||||
$pageCursor: String,
|
|
||||||
$pageNumber: Int,
|
|
||||||
$filterParams: [FilterParams],
|
|
||||||
$originalPageUrl: String,
|
|
||||||
$seoFriendlyUrlInput: String,
|
|
||||||
$parameterUrlInput: String,
|
|
||||||
$seoUrl: Boolean
|
|
||||||
) {
|
|
||||||
jobListings(
|
|
||||||
contextHolder: {
|
|
||||||
searchParams: {
|
|
||||||
excludeJobListingIds: $excludeJobListingIds,
|
|
||||||
keyword: $keyword,
|
|
||||||
locationId: $locationId,
|
|
||||||
locationType: $locationType,
|
|
||||||
numPerPage: $numJobsToShow,
|
|
||||||
pageCursor: $pageCursor,
|
|
||||||
pageNumber: $pageNumber,
|
|
||||||
filterParams: $filterParams,
|
|
||||||
originalPageUrl: $originalPageUrl,
|
|
||||||
seoFriendlyUrlInput: $seoFriendlyUrlInput,
|
|
||||||
parameterUrlInput: $parameterUrlInput,
|
|
||||||
seoUrl: $seoUrl,
|
|
||||||
searchType: SR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
companyFilterOptions {
|
|
||||||
id
|
|
||||||
shortName
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
filterOptions
|
|
||||||
indeedCtk
|
|
||||||
jobListings {
|
|
||||||
...JobView
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
jobListingSeoLinks {
|
|
||||||
linkItems {
|
|
||||||
position
|
|
||||||
url
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
jobSearchTrackingKey
|
|
||||||
jobsPageSeoData {
|
|
||||||
pageMetaDescription
|
|
||||||
pageTitle
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
paginationCursors {
|
|
||||||
cursor
|
|
||||||
pageNumber
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
indexablePageForSeo
|
|
||||||
searchResultsMetadata {
|
|
||||||
searchCriteria {
|
|
||||||
implicitLocation {
|
|
||||||
id
|
|
||||||
localizedDisplayName
|
|
||||||
type
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
keyword
|
|
||||||
location {
|
|
||||||
id
|
|
||||||
shortName
|
|
||||||
localizedShortName
|
|
||||||
localizedDisplayName
|
|
||||||
type
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
helpCenterDomain
|
|
||||||
helpCenterLocale
|
|
||||||
jobSerpJobOutlook {
|
|
||||||
occupation
|
|
||||||
paragraph
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
showMachineReadableJobs
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
totalJobsCount
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fragment JobView on JobListingSearchResult {
|
|
||||||
jobview {
|
|
||||||
header {
|
|
||||||
adOrderId
|
|
||||||
advertiserType
|
|
||||||
adOrderSponsorshipLevel
|
|
||||||
ageInDays
|
|
||||||
divisionEmployerName
|
|
||||||
easyApply
|
|
||||||
employer {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
shortName
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
employerNameFromSearch
|
|
||||||
goc
|
|
||||||
gocConfidence
|
|
||||||
gocId
|
|
||||||
jobCountryId
|
|
||||||
jobLink
|
|
||||||
jobResultTrackingKey
|
|
||||||
jobTitleText
|
|
||||||
locationName
|
|
||||||
locationType
|
|
||||||
locId
|
|
||||||
needsCommission
|
|
||||||
payCurrency
|
|
||||||
payPeriod
|
|
||||||
payPeriodAdjustedPay {
|
|
||||||
p10
|
|
||||||
p50
|
|
||||||
p90
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
rating
|
|
||||||
salarySource
|
|
||||||
savedJobId
|
|
||||||
sponsored
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
job {
|
|
||||||
description
|
|
||||||
importConfigId
|
|
||||||
jobTitleId
|
|
||||||
jobTitleText
|
|
||||||
listingId
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
jobListingAdminDetails {
|
|
||||||
cpcVal
|
|
||||||
importConfigId
|
|
||||||
jobListingId
|
|
||||||
jobSourceId
|
|
||||||
userEligibleForAdminJobDetails
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
overview {
|
|
||||||
shortName
|
|
||||||
squareLogoUrl
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
__typename
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|||||||
184
src/jobspy/scrapers/glassdoor/constants.py
Normal file
184
src/jobspy/scrapers/glassdoor/constants.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
headers = {
|
||||||
|
"authority": "www.glassdoor.com",
|
||||||
|
"accept": "*/*",
|
||||||
|
"accept-language": "en-US,en;q=0.9",
|
||||||
|
"apollographql-client-name": "job-search-next",
|
||||||
|
"apollographql-client-version": "4.65.5",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"origin": "https://www.glassdoor.com",
|
||||||
|
"referer": "https://www.glassdoor.com/",
|
||||||
|
"sec-ch-ua": '"Chromium";v="118", "Google Chrome";v="118", "Not=A?Brand";v="99"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
|
"sec-fetch-dest": "empty",
|
||||||
|
"sec-fetch-mode": "cors",
|
||||||
|
"sec-fetch-site": "same-origin",
|
||||||
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
|
||||||
|
}
|
||||||
|
query_template = """
|
||||||
|
query JobSearchResultsQuery(
|
||||||
|
$excludeJobListingIds: [Long!],
|
||||||
|
$keyword: String,
|
||||||
|
$locationId: Int,
|
||||||
|
$locationType: LocationTypeEnum,
|
||||||
|
$numJobsToShow: Int!,
|
||||||
|
$pageCursor: String,
|
||||||
|
$pageNumber: Int,
|
||||||
|
$filterParams: [FilterParams],
|
||||||
|
$originalPageUrl: String,
|
||||||
|
$seoFriendlyUrlInput: String,
|
||||||
|
$parameterUrlInput: String,
|
||||||
|
$seoUrl: Boolean
|
||||||
|
) {
|
||||||
|
jobListings(
|
||||||
|
contextHolder: {
|
||||||
|
searchParams: {
|
||||||
|
excludeJobListingIds: $excludeJobListingIds,
|
||||||
|
keyword: $keyword,
|
||||||
|
locationId: $locationId,
|
||||||
|
locationType: $locationType,
|
||||||
|
numPerPage: $numJobsToShow,
|
||||||
|
pageCursor: $pageCursor,
|
||||||
|
pageNumber: $pageNumber,
|
||||||
|
filterParams: $filterParams,
|
||||||
|
originalPageUrl: $originalPageUrl,
|
||||||
|
seoFriendlyUrlInput: $seoFriendlyUrlInput,
|
||||||
|
parameterUrlInput: $parameterUrlInput,
|
||||||
|
seoUrl: $seoUrl,
|
||||||
|
searchType: SR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
companyFilterOptions {
|
||||||
|
id
|
||||||
|
shortName
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
filterOptions
|
||||||
|
indeedCtk
|
||||||
|
jobListings {
|
||||||
|
...JobView
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
jobListingSeoLinks {
|
||||||
|
linkItems {
|
||||||
|
position
|
||||||
|
url
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
jobSearchTrackingKey
|
||||||
|
jobsPageSeoData {
|
||||||
|
pageMetaDescription
|
||||||
|
pageTitle
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
paginationCursors {
|
||||||
|
cursor
|
||||||
|
pageNumber
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
indexablePageForSeo
|
||||||
|
searchResultsMetadata {
|
||||||
|
searchCriteria {
|
||||||
|
implicitLocation {
|
||||||
|
id
|
||||||
|
localizedDisplayName
|
||||||
|
type
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
keyword
|
||||||
|
location {
|
||||||
|
id
|
||||||
|
shortName
|
||||||
|
localizedShortName
|
||||||
|
localizedDisplayName
|
||||||
|
type
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
helpCenterDomain
|
||||||
|
helpCenterLocale
|
||||||
|
jobSerpJobOutlook {
|
||||||
|
occupation
|
||||||
|
paragraph
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
showMachineReadableJobs
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
totalJobsCount
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment JobView on JobListingSearchResult {
|
||||||
|
jobview {
|
||||||
|
header {
|
||||||
|
adOrderId
|
||||||
|
advertiserType
|
||||||
|
adOrderSponsorshipLevel
|
||||||
|
ageInDays
|
||||||
|
divisionEmployerName
|
||||||
|
easyApply
|
||||||
|
employer {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
shortName
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
employerNameFromSearch
|
||||||
|
goc
|
||||||
|
gocConfidence
|
||||||
|
gocId
|
||||||
|
jobCountryId
|
||||||
|
jobLink
|
||||||
|
jobResultTrackingKey
|
||||||
|
jobTitleText
|
||||||
|
locationName
|
||||||
|
locationType
|
||||||
|
locId
|
||||||
|
needsCommission
|
||||||
|
payCurrency
|
||||||
|
payPeriod
|
||||||
|
payPeriodAdjustedPay {
|
||||||
|
p10
|
||||||
|
p50
|
||||||
|
p90
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
rating
|
||||||
|
salarySource
|
||||||
|
savedJobId
|
||||||
|
sponsored
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
job {
|
||||||
|
description
|
||||||
|
importConfigId
|
||||||
|
jobTitleId
|
||||||
|
jobTitleText
|
||||||
|
listingId
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
jobListingAdminDetails {
|
||||||
|
cpcVal
|
||||||
|
importConfigId
|
||||||
|
jobListingId
|
||||||
|
jobSourceId
|
||||||
|
userEligibleForAdminJobDetails
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
overview {
|
||||||
|
shortName
|
||||||
|
squareLogoUrl
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
fallback_token = "Ft6oHEWlRZrxDww95Cpazw:0pGUrkb2y3TyOpAIqF2vbPmUXoXVkD3oEGDVkvfeCerceQ5-n8mBg3BovySUIjmCPHCaW0H2nQVdqzbtsYqf4Q:wcqRqeegRUa9MVLJGyujVXB7vWFPjdaS1CtrrzJq-ok"
|
||||||
@@ -10,16 +10,15 @@ from __future__ import annotations
|
|||||||
import math
|
import math
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, Future
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
from .constants import job_search_query, api_headers
|
||||||
from .. import Scraper, ScraperInput, Site
|
from .. import Scraper, ScraperInput, Site
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
extract_emails_from_text,
|
extract_emails_from_text,
|
||||||
get_enum_from_job_type,
|
get_enum_from_job_type,
|
||||||
markdown_converter,
|
markdown_converter,
|
||||||
logger,
|
create_session,
|
||||||
|
create_logger,
|
||||||
)
|
)
|
||||||
from ...jobs import (
|
from ...jobs import (
|
||||||
JobPost,
|
JobPost,
|
||||||
@@ -31,12 +30,21 @@ from ...jobs import (
|
|||||||
DescriptionFormat,
|
DescriptionFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = create_logger("Indeed")
|
||||||
|
|
||||||
|
|
||||||
class IndeedScraper(Scraper):
|
class IndeedScraper(Scraper):
|
||||||
def __init__(self, proxy: str | None = None):
|
def __init__(
|
||||||
|
self, proxies: list[str] | str | None = None, ca_cert: str | None = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initializes IndeedScraper with the Indeed API url
|
Initializes IndeedScraper with the Indeed API url
|
||||||
"""
|
"""
|
||||||
|
super().__init__(Site.INDEED, proxies=proxies)
|
||||||
|
|
||||||
|
self.session = create_session(
|
||||||
|
proxies=self.proxies, ca_cert=ca_cert, is_tls=False
|
||||||
|
)
|
||||||
self.scraper_input = None
|
self.scraper_input = None
|
||||||
self.jobs_per_page = 100
|
self.jobs_per_page = 100
|
||||||
self.num_workers = 10
|
self.num_workers = 10
|
||||||
@@ -45,8 +53,6 @@ class IndeedScraper(Scraper):
|
|||||||
self.api_country_code = None
|
self.api_country_code = None
|
||||||
self.base_url = None
|
self.base_url = None
|
||||||
self.api_url = "https://apis.indeed.com/graphql"
|
self.api_url = "https://apis.indeed.com/graphql"
|
||||||
site = Site(Site.INDEED)
|
|
||||||
super().__init__(site, proxy=proxy)
|
|
||||||
|
|
||||||
def scrape(self, scraper_input: ScraperInput) -> JobResponse:
|
def scrape(self, scraper_input: ScraperInput) -> JobResponse:
|
||||||
"""
|
"""
|
||||||
@@ -57,29 +63,29 @@ class IndeedScraper(Scraper):
|
|||||||
self.scraper_input = scraper_input
|
self.scraper_input = scraper_input
|
||||||
domain, self.api_country_code = self.scraper_input.country.indeed_domain_value
|
domain, self.api_country_code = self.scraper_input.country.indeed_domain_value
|
||||||
self.base_url = f"https://{domain}.indeed.com"
|
self.base_url = f"https://{domain}.indeed.com"
|
||||||
self.headers = self.api_headers.copy()
|
self.headers = api_headers.copy()
|
||||||
self.headers["indeed-co"] = self.scraper_input.country.indeed_domain_value
|
self.headers["indeed-co"] = self.scraper_input.country.indeed_domain_value
|
||||||
job_list = []
|
job_list = []
|
||||||
page = 1
|
page = 1
|
||||||
|
|
||||||
cursor = None
|
cursor = None
|
||||||
offset_pages = math.ceil(self.scraper_input.offset / 100)
|
|
||||||
for _ in range(offset_pages):
|
|
||||||
logger.info(f"Indeed skipping search page: {page}")
|
|
||||||
__, cursor = self._scrape_page(cursor)
|
|
||||||
if not __:
|
|
||||||
logger.info(f"Indeed found no jobs on page: {page}")
|
|
||||||
break
|
|
||||||
|
|
||||||
while len(self.seen_urls) < scraper_input.results_wanted:
|
while len(self.seen_urls) < scraper_input.results_wanted + scraper_input.offset:
|
||||||
logger.info(f"Indeed search page: {page}")
|
logger.info(
|
||||||
|
f"search page: {page} / {math.ceil(scraper_input.results_wanted / 100)}"
|
||||||
|
)
|
||||||
jobs, cursor = self._scrape_page(cursor)
|
jobs, cursor = self._scrape_page(cursor)
|
||||||
if not jobs:
|
if not jobs:
|
||||||
logger.info(f"Indeed found no jobs on page: {page}")
|
logger.info(f"found no jobs on page: {page}")
|
||||||
break
|
break
|
||||||
job_list += jobs
|
job_list += jobs
|
||||||
page += 1
|
page += 1
|
||||||
return JobResponse(jobs=job_list[: scraper_input.results_wanted])
|
return JobResponse(
|
||||||
|
jobs=job_list[
|
||||||
|
scraper_input.offset : scraper_input.offset
|
||||||
|
+ scraper_input.results_wanted
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
def _scrape_page(self, cursor: str | None) -> Tuple[list[JobPost], str | None]:
|
def _scrape_page(self, cursor: str | None) -> Tuple[list[JobPost], str | None]:
|
||||||
"""
|
"""
|
||||||
@@ -90,13 +96,13 @@ class IndeedScraper(Scraper):
|
|||||||
jobs = []
|
jobs = []
|
||||||
new_cursor = None
|
new_cursor = None
|
||||||
filters = self._build_filters()
|
filters = self._build_filters()
|
||||||
search_term = self.scraper_input.search_term.replace('"', '\\"') if self.scraper_input.search_term else ""
|
search_term = (
|
||||||
query = self.job_search_query.format(
|
self.scraper_input.search_term.replace('"', '\\"')
|
||||||
what=(
|
if self.scraper_input.search_term
|
||||||
f'what: "{search_term}"'
|
|
||||||
if search_term
|
|
||||||
else ""
|
else ""
|
||||||
),
|
)
|
||||||
|
query = job_search_query.format(
|
||||||
|
what=(f'what: "{search_term}"' if search_term else ""),
|
||||||
location=(
|
location=(
|
||||||
f'location: {{where: "{self.scraper_input.location}", radius: {self.scraper_input.distance}, radiusUnit: MILES}}'
|
f'location: {{where: "{self.scraper_input.location}", radius: {self.scraper_input.distance}, radiusUnit: MILES}}'
|
||||||
if self.scraper_input.location
|
if self.scraper_input.location
|
||||||
@@ -109,29 +115,29 @@ class IndeedScraper(Scraper):
|
|||||||
payload = {
|
payload = {
|
||||||
"query": query,
|
"query": query,
|
||||||
}
|
}
|
||||||
api_headers = self.api_headers.copy()
|
api_headers_temp = api_headers.copy()
|
||||||
api_headers["indeed-co"] = self.api_country_code
|
api_headers_temp["indeed-co"] = self.api_country_code
|
||||||
response = requests.post(
|
response = self.session.post(
|
||||||
self.api_url,
|
self.api_url,
|
||||||
headers=api_headers,
|
headers=api_headers_temp,
|
||||||
json=payload,
|
json=payload,
|
||||||
proxies=self.proxy,
|
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if not response.ok:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Indeed responded with status code: {response.status_code} (submit GitHub issue if this appears to be a bug)"
|
f"responded with status code: {response.status_code} (submit GitHub issue if this appears to be a bug)"
|
||||||
)
|
)
|
||||||
return jobs, new_cursor
|
return jobs, new_cursor
|
||||||
data = response.json()
|
data = response.json()
|
||||||
jobs = data["data"]["jobSearch"]["results"]
|
jobs = data["data"]["jobSearch"]["results"]
|
||||||
new_cursor = data["data"]["jobSearch"]["pageInfo"]["nextCursor"]
|
new_cursor = data["data"]["jobSearch"]["pageInfo"]["nextCursor"]
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=self.num_workers) as executor:
|
job_list = []
|
||||||
job_results: list[Future] = [
|
for job in jobs:
|
||||||
executor.submit(self._process_job, job["job"]) for job in jobs
|
processed_job = self._process_job(job["job"])
|
||||||
]
|
if processed_job:
|
||||||
job_list = [result.result() for result in job_results if result.result()]
|
job_list.append(processed_job)
|
||||||
|
|
||||||
return job_list, new_cursor
|
return job_list, new_cursor
|
||||||
|
|
||||||
def _build_filters(self):
|
def _build_filters(self):
|
||||||
@@ -177,7 +183,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: {{
|
||||||
@@ -213,6 +219,7 @@ class IndeedScraper(Scraper):
|
|||||||
employer_details = employer.get("employerDetails", {}) if employer else {}
|
employer_details = employer.get("employerDetails", {}) if employer else {}
|
||||||
rel_url = job["employer"]["relativeCompanyPageUrl"] if job["employer"] else None
|
rel_url = job["employer"]["relativeCompanyPageUrl"] if job["employer"] else None
|
||||||
return JobPost(
|
return JobPost(
|
||||||
|
id=f'in-{job["key"]}',
|
||||||
title=job["title"],
|
title=job["title"],
|
||||||
description=description,
|
description=description,
|
||||||
company_name=job["employer"].get("name") if job.get("employer") else None,
|
company_name=job["employer"].get("name") if job.get("employer") else None,
|
||||||
@@ -226,7 +233,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,24 +251,18 @@ 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
|
||||||
),
|
),
|
||||||
company_num_employees=employer_details.get("employeesLocalizedLabel"),
|
company_num_employees=employer_details.get("employeesLocalizedLabel"),
|
||||||
company_revenue=employer_details.get("revenueLocalizedLabel"),
|
company_revenue=employer_details.get("revenueLocalizedLabel"),
|
||||||
company_description=employer_details.get("briefDescription"),
|
company_description=employer_details.get("briefDescription"),
|
||||||
ceo_name=employer_details.get("ceoName"),
|
|
||||||
ceo_photo_url=employer_details.get("ceoPhotoUrl"),
|
|
||||||
logo_photo_url=(
|
logo_photo_url=(
|
||||||
employer["images"].get("squareLogoUrl")
|
employer["images"].get("squareLogoUrl")
|
||||||
if employer and employer.get("images")
|
if employer and employer.get("images")
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
banner_photo_url=(
|
|
||||||
employer["images"].get("headerImageUrl")
|
|
||||||
if employer and employer.get("images")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -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
|
||||||
@@ -337,98 +347,3 @@ class IndeedScraper(Scraper):
|
|||||||
return CompensationInterval[mapped_interval]
|
return CompensationInterval[mapped_interval]
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported interval: {interval}")
|
raise ValueError(f"Unsupported interval: {interval}")
|
||||||
|
|
||||||
api_headers = {
|
|
||||||
"Host": "apis.indeed.com",
|
|
||||||
"content-type": "application/json",
|
|
||||||
"indeed-api-key": "161092c2017b5bbab13edb12461a62d5a833871e7cad6d9d475304573de67ac8",
|
|
||||||
"accept": "application/json",
|
|
||||||
"indeed-locale": "en-US",
|
|
||||||
"accept-language": "en-US,en;q=0.9",
|
|
||||||
"user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Indeed App 193.1",
|
|
||||||
"indeed-app-info": "appv=193.1; appid=com.indeed.jobsearch; osv=16.6.1; os=ios; dtype=phone",
|
|
||||||
}
|
|
||||||
job_search_query = """
|
|
||||||
query GetJobData {{
|
|
||||||
jobSearch(
|
|
||||||
{what}
|
|
||||||
{location}
|
|
||||||
includeSponsoredResults: NONE
|
|
||||||
limit: 100
|
|
||||||
sort: DATE
|
|
||||||
{cursor}
|
|
||||||
{filters}
|
|
||||||
) {{
|
|
||||||
pageInfo {{
|
|
||||||
nextCursor
|
|
||||||
}}
|
|
||||||
results {{
|
|
||||||
trackingKey
|
|
||||||
job {{
|
|
||||||
key
|
|
||||||
title
|
|
||||||
datePublished
|
|
||||||
dateOnIndeed
|
|
||||||
description {{
|
|
||||||
html
|
|
||||||
}}
|
|
||||||
location {{
|
|
||||||
countryName
|
|
||||||
countryCode
|
|
||||||
admin1Code
|
|
||||||
city
|
|
||||||
postalCode
|
|
||||||
streetAddress
|
|
||||||
formatted {{
|
|
||||||
short
|
|
||||||
long
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
compensation {{
|
|
||||||
baseSalary {{
|
|
||||||
unitOfWork
|
|
||||||
range {{
|
|
||||||
... on Range {{
|
|
||||||
min
|
|
||||||
max
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
currencyCode
|
|
||||||
}}
|
|
||||||
attributes {{
|
|
||||||
key
|
|
||||||
label
|
|
||||||
}}
|
|
||||||
employer {{
|
|
||||||
relativeCompanyPageUrl
|
|
||||||
name
|
|
||||||
dossier {{
|
|
||||||
employerDetails {{
|
|
||||||
addresses
|
|
||||||
industry
|
|
||||||
employeesLocalizedLabel
|
|
||||||
revenueLocalizedLabel
|
|
||||||
briefDescription
|
|
||||||
ceoName
|
|
||||||
ceoPhotoUrl
|
|
||||||
}}
|
|
||||||
images {{
|
|
||||||
headerImageUrl
|
|
||||||
squareLogoUrl
|
|
||||||
}}
|
|
||||||
links {{
|
|
||||||
corporateWebsite
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
recruit {{
|
|
||||||
viewJobUrl
|
|
||||||
detailedSalary
|
|
||||||
workSchedule
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|||||||
109
src/jobspy/scrapers/indeed/constants.py
Normal file
109
src/jobspy/scrapers/indeed/constants.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
job_search_query = """
|
||||||
|
query GetJobData {{
|
||||||
|
jobSearch(
|
||||||
|
{what}
|
||||||
|
{location}
|
||||||
|
limit: 100
|
||||||
|
{cursor}
|
||||||
|
sort: RELEVANCE
|
||||||
|
{filters}
|
||||||
|
) {{
|
||||||
|
pageInfo {{
|
||||||
|
nextCursor
|
||||||
|
}}
|
||||||
|
results {{
|
||||||
|
trackingKey
|
||||||
|
job {{
|
||||||
|
source {{
|
||||||
|
name
|
||||||
|
}}
|
||||||
|
key
|
||||||
|
title
|
||||||
|
datePublished
|
||||||
|
dateOnIndeed
|
||||||
|
description {{
|
||||||
|
html
|
||||||
|
}}
|
||||||
|
location {{
|
||||||
|
countryName
|
||||||
|
countryCode
|
||||||
|
admin1Code
|
||||||
|
city
|
||||||
|
postalCode
|
||||||
|
streetAddress
|
||||||
|
formatted {{
|
||||||
|
short
|
||||||
|
long
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
compensation {{
|
||||||
|
estimated {{
|
||||||
|
currencyCode
|
||||||
|
baseSalary {{
|
||||||
|
unitOfWork
|
||||||
|
range {{
|
||||||
|
... on Range {{
|
||||||
|
min
|
||||||
|
max
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
baseSalary {{
|
||||||
|
unitOfWork
|
||||||
|
range {{
|
||||||
|
... on Range {{
|
||||||
|
min
|
||||||
|
max
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
currencyCode
|
||||||
|
}}
|
||||||
|
attributes {{
|
||||||
|
key
|
||||||
|
label
|
||||||
|
}}
|
||||||
|
employer {{
|
||||||
|
relativeCompanyPageUrl
|
||||||
|
name
|
||||||
|
dossier {{
|
||||||
|
employerDetails {{
|
||||||
|
addresses
|
||||||
|
industry
|
||||||
|
employeesLocalizedLabel
|
||||||
|
revenueLocalizedLabel
|
||||||
|
briefDescription
|
||||||
|
ceoName
|
||||||
|
ceoPhotoUrl
|
||||||
|
}}
|
||||||
|
images {{
|
||||||
|
headerImageUrl
|
||||||
|
squareLogoUrl
|
||||||
|
}}
|
||||||
|
links {{
|
||||||
|
corporateWebsite
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
recruit {{
|
||||||
|
viewJobUrl
|
||||||
|
detailedSalary
|
||||||
|
workSchedule
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
api_headers = {
|
||||||
|
"Host": "apis.indeed.com",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"indeed-api-key": "161092c2017b5bbab13edb12461a62d5a833871e7cad6d9d475304573de67ac8",
|
||||||
|
"accept": "application/json",
|
||||||
|
"indeed-locale": "en-US",
|
||||||
|
"accept-language": "en-US,en;q=0.9",
|
||||||
|
"user-agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Indeed App 193.1",
|
||||||
|
"indeed-app-info": "appv=193.1; appid=com.indeed.jobsearch; osv=16.6.1; os=ios; dtype=phone",
|
||||||
|
}
|
||||||
@@ -7,21 +7,21 @@ This module contains routines to scrape LinkedIn.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
import regex as re
|
import regex as re
|
||||||
import urllib.parse
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from threading import Lock
|
|
||||||
from bs4.element import Tag
|
from bs4.element import Tag
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse, unquote
|
||||||
|
|
||||||
|
from .constants import headers
|
||||||
from .. import Scraper, ScraperInput, Site
|
from .. import Scraper, ScraperInput, Site
|
||||||
from ..exceptions import LinkedInException
|
from ..exceptions import LinkedInException
|
||||||
from ..utils import create_session
|
from ..utils import create_session, remove_attributes, create_logger
|
||||||
from ...jobs import (
|
from ...jobs import (
|
||||||
JobPost,
|
JobPost,
|
||||||
Location,
|
Location,
|
||||||
@@ -32,13 +32,14 @@ from ...jobs import (
|
|||||||
DescriptionFormat,
|
DescriptionFormat,
|
||||||
)
|
)
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
logger,
|
|
||||||
extract_emails_from_text,
|
extract_emails_from_text,
|
||||||
get_enum_from_job_type,
|
get_enum_from_job_type,
|
||||||
currency_parser,
|
currency_parser,
|
||||||
markdown_converter,
|
markdown_converter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = create_logger("LinkedIn")
|
||||||
|
|
||||||
|
|
||||||
class LinkedInScraper(Scraper):
|
class LinkedInScraper(Scraper):
|
||||||
base_url = "https://www.linkedin.com"
|
base_url = "https://www.linkedin.com"
|
||||||
@@ -46,11 +47,22 @@ class LinkedInScraper(Scraper):
|
|||||||
band_delay = 4
|
band_delay = 4
|
||||||
jobs_per_page = 25
|
jobs_per_page = 25
|
||||||
|
|
||||||
def __init__(self, proxy: Optional[str] = None):
|
def __init__(
|
||||||
|
self, proxies: list[str] | str | None = None, ca_cert: str | None = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initializes LinkedInScraper with the LinkedIn job search url
|
Initializes LinkedInScraper with the LinkedIn job search url
|
||||||
"""
|
"""
|
||||||
super().__init__(Site(Site.LINKEDIN), proxy=proxy)
|
super().__init__(Site.LINKEDIN, proxies=proxies, ca_cert=ca_cert)
|
||||||
|
self.session = create_session(
|
||||||
|
proxies=self.proxies,
|
||||||
|
ca_cert=ca_cert,
|
||||||
|
is_tls=False,
|
||||||
|
has_retry=True,
|
||||||
|
delay=5,
|
||||||
|
clear_cookies=True,
|
||||||
|
)
|
||||||
|
self.session.headers.update(headers)
|
||||||
self.scraper_input = None
|
self.scraper_input = None
|
||||||
self.country = "worldwide"
|
self.country = "worldwide"
|
||||||
self.job_url_direct_regex = re.compile(r'(?<=\?url=)[^"]+')
|
self.job_url_direct_regex = re.compile(r'(?<=\?url=)[^"]+')
|
||||||
@@ -63,18 +75,20 @@ 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()
|
||||||
url_lock = Lock()
|
start = scraper_input.offset // 10 * 10 if scraper_input.offset else 0
|
||||||
page = scraper_input.offset // 25 + 25 if scraper_input.offset else 0
|
request_count = 0
|
||||||
seconds_old = (
|
seconds_old = (
|
||||||
scraper_input.hours_old * 3600 if scraper_input.hours_old else None
|
scraper_input.hours_old * 3600 if scraper_input.hours_old else None
|
||||||
)
|
)
|
||||||
continue_search = (
|
continue_search = (
|
||||||
lambda: len(job_list) < scraper_input.results_wanted and page < 1000
|
lambda: len(job_list) < scraper_input.results_wanted and start < 1000
|
||||||
)
|
)
|
||||||
while continue_search():
|
while continue_search():
|
||||||
logger.info(f"LinkedIn search page: {page // 25 + 1}")
|
request_count += 1
|
||||||
session = create_session(is_tls=False, has_retry=True, delay=5)
|
logger.info(
|
||||||
|
f"search page: {request_count} / {math.ceil(scraper_input.results_wanted / 10)}"
|
||||||
|
)
|
||||||
params = {
|
params = {
|
||||||
"keywords": scraper_input.search_term,
|
"keywords": scraper_input.search_term,
|
||||||
"location": scraper_input.location,
|
"location": scraper_input.location,
|
||||||
@@ -86,7 +100,7 @@ class LinkedInScraper(Scraper):
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"pageNum": 0,
|
"pageNum": 0,
|
||||||
"start": page + scraper_input.offset,
|
"start": start,
|
||||||
"f_AL": "true" if scraper_input.easy_apply else None,
|
"f_AL": "true" if scraper_input.easy_apply else None,
|
||||||
"f_C": (
|
"f_C": (
|
||||||
",".join(map(str, scraper_input.linkedin_company_ids))
|
",".join(map(str, scraper_input.linkedin_company_ids))
|
||||||
@@ -99,12 +113,9 @@ class LinkedInScraper(Scraper):
|
|||||||
|
|
||||||
params = {k: v for k, v in params.items() if v is not None}
|
params = {k: v for k, v in params.items() if v is not None}
|
||||||
try:
|
try:
|
||||||
response = session.get(
|
response = self.session.get(
|
||||||
f"{self.base_url}/jobs-guest/jobs/api/seeMoreJobPostings/search?",
|
f"{self.base_url}/jobs-guest/jobs/api/seeMoreJobPostings/search?",
|
||||||
params=params,
|
params=params,
|
||||||
allow_redirects=True,
|
|
||||||
proxies=self.proxy,
|
|
||||||
headers=self.headers,
|
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if response.status_code not in range(200, 400):
|
if response.status_code not in range(200, 400):
|
||||||
@@ -130,20 +141,18 @@ 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}"
|
|
||||||
|
|
||||||
with url_lock:
|
if job_id in seen_ids:
|
||||||
if job_url in seen_urls:
|
|
||||||
continue
|
continue
|
||||||
seen_urls.add(job_url)
|
seen_ids.add(job_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fetch_desc = scraper_input.linkedin_fetch_description
|
fetch_desc = scraper_input.linkedin_fetch_description
|
||||||
job_post = self._process_job(job_card, job_url, fetch_desc)
|
job_post = self._process_job(job_card, job_id, fetch_desc)
|
||||||
if job_post:
|
if job_post:
|
||||||
job_list.append(job_post)
|
job_list.append(job_post)
|
||||||
if not continue_search():
|
if not continue_search():
|
||||||
@@ -153,13 +162,13 @@ class LinkedInScraper(Scraper):
|
|||||||
|
|
||||||
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))
|
||||||
page += self.jobs_per_page
|
start += len(job_list)
|
||||||
|
|
||||||
job_list = job_list[: scraper_input.results_wanted]
|
job_list = job_list[: scraper_input.results_wanted]
|
||||||
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")
|
||||||
|
|
||||||
@@ -206,38 +215,41 @@ 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=f"li-{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")),
|
||||||
logo_photo_url=job_details.get("logo_photo_url"),
|
logo_photo_url=job_details.get("logo_photo_url"),
|
||||||
|
job_function=job_details.get("job_function"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_job_details(self, job_page_url: str) -> dict:
|
def _get_job_details(self, job_id: 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:
|
||||||
session = create_session(is_tls=False, has_retry=True)
|
response = self.session.get(
|
||||||
response = session.get(
|
f"{self.base_url}/jobs/view/{job_id}", timeout=5
|
||||||
job_page_url, headers=self.headers, timeout=5, proxies=self.proxy
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except:
|
except:
|
||||||
return {}
|
return {}
|
||||||
if response.url == "https://www.linkedin.com/signup":
|
if "linkedin.com/signup" in response.url:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
@@ -246,23 +258,36 @@ class LinkedInScraper(Scraper):
|
|||||||
)
|
)
|
||||||
description = None
|
description = None
|
||||||
if div_content is not None:
|
if div_content is not None:
|
||||||
|
|
||||||
def remove_attributes(tag):
|
|
||||||
for attr in list(tag.attrs):
|
|
||||||
del tag[attr]
|
|
||||||
return tag
|
|
||||||
|
|
||||||
div_content = remove_attributes(div_content)
|
div_content = remove_attributes(div_content)
|
||||||
description = div_content.prettify(formatter="html")
|
description = div_content.prettify(formatter="html")
|
||||||
if self.scraper_input.description_format == DescriptionFormat.MARKDOWN:
|
if self.scraper_input.description_format == DescriptionFormat.MARKDOWN:
|
||||||
description = markdown_converter(description)
|
description = markdown_converter(description)
|
||||||
|
|
||||||
|
h3_tag = soup.find(
|
||||||
|
"h3", text=lambda text: text and "Job function" in text.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
job_function = None
|
||||||
|
if h3_tag:
|
||||||
|
job_function_span = h3_tag.find_next(
|
||||||
|
"span", class_="description__job-criteria-text"
|
||||||
|
)
|
||||||
|
if job_function_span:
|
||||||
|
job_function = job_function_span.text.strip()
|
||||||
|
|
||||||
|
logo_photo_url = (
|
||||||
|
logo_image.get("data-delayed-url")
|
||||||
|
if (logo_image := soup.find("img", {"class": "artdeco-entity-image"}))
|
||||||
|
else None
|
||||||
|
)
|
||||||
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": logo_photo_url,
|
||||||
"data-delayed-url"
|
"job_function": job_function,
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_location(self, metadata_card: Optional[Tag]) -> Location:
|
def _get_location(self, metadata_card: Optional[Tag]) -> Location:
|
||||||
@@ -316,6 +341,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
|
||||||
@@ -329,7 +400,7 @@ class LinkedInScraper(Scraper):
|
|||||||
job_url_direct_content.decode_contents().strip()
|
job_url_direct_content.decode_contents().strip()
|
||||||
)
|
)
|
||||||
if job_url_direct_match:
|
if job_url_direct_match:
|
||||||
job_url_direct = urllib.parse.unquote(job_url_direct_match.group())
|
job_url_direct = unquote(job_url_direct_match.group())
|
||||||
|
|
||||||
return job_url_direct
|
return job_url_direct
|
||||||
|
|
||||||
@@ -342,12 +413,3 @@ class LinkedInScraper(Scraper):
|
|||||||
JobType.CONTRACT: "C",
|
JobType.CONTRACT: "C",
|
||||||
JobType.TEMPORARY: "T",
|
JobType.TEMPORARY: "T",
|
||||||
}.get(job_type_enum, "")
|
}.get(job_type_enum, "")
|
||||||
|
|
||||||
headers = {
|
|
||||||
"authority": "www.linkedin.com",
|
|
||||||
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
||||||
"accept-language": "en-US,en;q=0.9",
|
|
||||||
"cache-control": "max-age=0",
|
|
||||||
"upgrade-insecure-requests": "1",
|
|
||||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
||||||
}
|
|
||||||
|
|||||||
8
src/jobspy/scrapers/linkedin/constants.py
Normal file
8
src/jobspy/scrapers/linkedin/constants.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
headers = {
|
||||||
|
"authority": "www.linkedin.com",
|
||||||
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||||
|
"accept-language": "en-US,en;q=0.9",
|
||||||
|
"cache-control": "max-age=0",
|
||||||
|
"upgrade-insecure-requests": "1",
|
||||||
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
}
|
||||||
@@ -2,23 +2,131 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
|
from itertools import cycle
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import tls_client
|
import tls_client
|
||||||
import numpy as np
|
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")
|
|
||||||
|
def create_logger(name: str):
|
||||||
|
logger = logging.getLogger(f"JobSpy:{name}")
|
||||||
logger.propagate = False
|
logger.propagate = False
|
||||||
if not logger.handlers:
|
if not logger.handlers:
|
||||||
logger.setLevel(logging.INFO)
|
logger.setLevel(logging.INFO)
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
|
||||||
formatter = logging.Formatter(format)
|
formatter = logging.Formatter(format)
|
||||||
console_handler.setFormatter(formatter)
|
console_handler.setFormatter(formatter)
|
||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
class RotatingProxySession:
|
||||||
|
def __init__(self, proxies=None):
|
||||||
|
if isinstance(proxies, str):
|
||||||
|
self.proxy_cycle = cycle([self.format_proxy(proxies)])
|
||||||
|
elif isinstance(proxies, list):
|
||||||
|
self.proxy_cycle = (
|
||||||
|
cycle([self.format_proxy(proxy) for proxy in proxies])
|
||||||
|
if proxies
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.proxy_cycle = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_proxy(proxy):
|
||||||
|
"""Utility method to format a proxy string into a dictionary."""
|
||||||
|
if proxy.startswith("http://") or proxy.startswith("https://"):
|
||||||
|
return {"http": proxy, "https": proxy}
|
||||||
|
return {"http": f"http://{proxy}", "https": f"http://{proxy}"}
|
||||||
|
|
||||||
|
|
||||||
|
class RequestsRotating(RotatingProxySession, requests.Session):
|
||||||
|
|
||||||
|
def __init__(self, proxies=None, has_retry=False, delay=1, clear_cookies=False):
|
||||||
|
RotatingProxySession.__init__(self, proxies=proxies)
|
||||||
|
requests.Session.__init__(self)
|
||||||
|
self.clear_cookies = clear_cookies
|
||||||
|
self.allow_redirects = True
|
||||||
|
self.setup_session(has_retry, delay)
|
||||||
|
|
||||||
|
def setup_session(self, has_retry, delay):
|
||||||
|
if has_retry:
|
||||||
|
retries = Retry(
|
||||||
|
total=3,
|
||||||
|
connect=3,
|
||||||
|
status=3,
|
||||||
|
status_forcelist=[500, 502, 503, 504, 429],
|
||||||
|
backoff_factor=delay,
|
||||||
|
)
|
||||||
|
adapter = HTTPAdapter(max_retries=retries)
|
||||||
|
self.mount("http://", adapter)
|
||||||
|
self.mount("https://", adapter)
|
||||||
|
|
||||||
|
def request(self, method, url, **kwargs):
|
||||||
|
if self.clear_cookies:
|
||||||
|
self.cookies.clear()
|
||||||
|
|
||||||
|
if self.proxy_cycle:
|
||||||
|
next_proxy = next(self.proxy_cycle)
|
||||||
|
if next_proxy["http"] != "http://localhost":
|
||||||
|
self.proxies = next_proxy
|
||||||
|
else:
|
||||||
|
self.proxies = {}
|
||||||
|
return requests.Session.request(self, method, url, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TLSRotating(RotatingProxySession, tls_client.Session):
|
||||||
|
|
||||||
|
def __init__(self, proxies=None):
|
||||||
|
RotatingProxySession.__init__(self, proxies=proxies)
|
||||||
|
tls_client.Session.__init__(self, random_tls_extension_order=True)
|
||||||
|
|
||||||
|
def execute_request(self, *args, **kwargs):
|
||||||
|
if self.proxy_cycle:
|
||||||
|
next_proxy = next(self.proxy_cycle)
|
||||||
|
if next_proxy["http"] != "http://localhost":
|
||||||
|
self.proxies = next_proxy
|
||||||
|
else:
|
||||||
|
self.proxies = {}
|
||||||
|
response = tls_client.Session.execute_request(self, *args, **kwargs)
|
||||||
|
response.ok = response.status_code in range(200, 400)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
*,
|
||||||
|
proxies: dict | str | None = None,
|
||||||
|
ca_cert: str | None = None,
|
||||||
|
is_tls: bool = True,
|
||||||
|
has_retry: bool = False,
|
||||||
|
delay: int = 1,
|
||||||
|
clear_cookies: bool = False,
|
||||||
|
) -> requests.Session:
|
||||||
|
"""
|
||||||
|
Creates a requests session with optional tls, proxy, and retry settings.
|
||||||
|
:return: A session object
|
||||||
|
"""
|
||||||
|
if is_tls:
|
||||||
|
session = TLSRotating(proxies=proxies)
|
||||||
|
else:
|
||||||
|
session = RequestsRotating(
|
||||||
|
proxies=proxies,
|
||||||
|
has_retry=has_retry,
|
||||||
|
delay=delay,
|
||||||
|
clear_cookies=clear_cookies,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ca_cert:
|
||||||
|
session.verify = ca_cert
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
def set_logger_level(verbose: int = 2):
|
def set_logger_level(verbose: int = 2):
|
||||||
@@ -33,7 +141,9 @@ def set_logger_level(verbose: int = 2):
|
|||||||
level_name = {2: "INFO", 1: "WARNING", 0: "ERROR"}.get(verbose, "INFO")
|
level_name = {2: "INFO", 1: "WARNING", 0: "ERROR"}.get(verbose, "INFO")
|
||||||
level = getattr(logging, level_name.upper(), None)
|
level = getattr(logging, level_name.upper(), None)
|
||||||
if level is not None:
|
if level is not None:
|
||||||
logger.setLevel(level)
|
for logger_name in logging.root.manager.loggerDict:
|
||||||
|
if logger_name.startswith("JobSpy:"):
|
||||||
|
logging.getLogger(logger_name).setLevel(level)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid log level: {level_name}")
|
raise ValueError(f"Invalid log level: {level_name}")
|
||||||
|
|
||||||
@@ -52,39 +162,6 @@ def extract_emails_from_text(text: str) -> list[str] | None:
|
|||||||
return email_regex.findall(text)
|
return email_regex.findall(text)
|
||||||
|
|
||||||
|
|
||||||
def create_session(
|
|
||||||
proxy: dict | None = None,
|
|
||||||
is_tls: bool = True,
|
|
||||||
has_retry: bool = False,
|
|
||||||
delay: int = 1,
|
|
||||||
) -> requests.Session:
|
|
||||||
"""
|
|
||||||
Creates a requests session with optional tls, proxy, and retry settings.
|
|
||||||
:return: A session object
|
|
||||||
"""
|
|
||||||
if is_tls:
|
|
||||||
session = tls_client.Session(random_tls_extension_order=True)
|
|
||||||
session.proxies = proxy
|
|
||||||
else:
|
|
||||||
session = requests.Session()
|
|
||||||
session.allow_redirects = True
|
|
||||||
if proxy:
|
|
||||||
session.proxies.update(proxy)
|
|
||||||
if has_retry:
|
|
||||||
retries = Retry(
|
|
||||||
total=3,
|
|
||||||
connect=3,
|
|
||||||
status=3,
|
|
||||||
status_forcelist=[500, 502, 503, 504, 429],
|
|
||||||
backoff_factor=delay,
|
|
||||||
)
|
|
||||||
adapter = HTTPAdapter(max_retries=retries)
|
|
||||||
|
|
||||||
session.mount("http://", adapter)
|
|
||||||
session.mount("https://", adapter)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def get_enum_from_job_type(job_type_str: str) -> JobType | None:
|
def get_enum_from_job_type(job_type_str: str) -> JobType | None:
|
||||||
"""
|
"""
|
||||||
Given a string, returns the corresponding JobType enum member if a match is found.
|
Given a string, returns the corresponding JobType enum member if a match is found.
|
||||||
@@ -111,3 +188,79 @@ def currency_parser(cur_str):
|
|||||||
num = float(cur_str)
|
num = float(cur_str)
|
||||||
|
|
||||||
return np.round(num, 2)
|
return np.round(num, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_attributes(tag):
|
||||||
|
for attr in list(tag.attrs):
|
||||||
|
del tag[attr]
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def extract_salary(
|
||||||
|
salary_str,
|
||||||
|
lower_limit=1000,
|
||||||
|
upper_limit=700000,
|
||||||
|
hourly_threshold=350,
|
||||||
|
monthly_threshold=30000,
|
||||||
|
enforce_annual_salary=False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Extracts salary information from a string and returns the salary interval, min and max salary values, and currency.
|
||||||
|
(TODO: Needs test cases as the regex is complicated and may not cover all edge cases)
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|||||||
@@ -7,19 +7,25 @@ This module contains routines to scrape ZipRecruiter.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Tuple, Any
|
from typing import Optional, Tuple, Any
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from .constants import headers
|
||||||
from .. import Scraper, ScraperInput, Site
|
from .. import Scraper, ScraperInput, Site
|
||||||
from ..utils import (
|
from ..utils import (
|
||||||
logger,
|
|
||||||
extract_emails_from_text,
|
extract_emails_from_text,
|
||||||
create_session,
|
create_session,
|
||||||
markdown_converter,
|
markdown_converter,
|
||||||
|
remove_attributes,
|
||||||
|
create_logger,
|
||||||
)
|
)
|
||||||
from ...jobs import (
|
from ...jobs import (
|
||||||
JobPost,
|
JobPost,
|
||||||
@@ -31,19 +37,25 @@ from ...jobs import (
|
|||||||
DescriptionFormat,
|
DescriptionFormat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = create_logger("ZipRecruiter")
|
||||||
|
|
||||||
|
|
||||||
class ZipRecruiterScraper(Scraper):
|
class ZipRecruiterScraper(Scraper):
|
||||||
base_url = "https://www.ziprecruiter.com"
|
base_url = "https://www.ziprecruiter.com"
|
||||||
api_url = "https://api.ziprecruiter.com"
|
api_url = "https://api.ziprecruiter.com"
|
||||||
|
|
||||||
def __init__(self, proxy: Optional[str] = None):
|
def __init__(
|
||||||
|
self, proxies: list[str] | str | None = None, ca_cert: str | None = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initializes ZipRecruiterScraper with the ZipRecruiter job search url
|
Initializes ZipRecruiterScraper with the ZipRecruiter job search url
|
||||||
"""
|
"""
|
||||||
|
super().__init__(Site.ZIP_RECRUITER, proxies=proxies)
|
||||||
|
|
||||||
self.scraper_input = None
|
self.scraper_input = None
|
||||||
self.session = create_session(proxy)
|
self.session = create_session(proxies=proxies, ca_cert=ca_cert)
|
||||||
|
self.session.headers.update(headers)
|
||||||
self._get_cookies()
|
self._get_cookies()
|
||||||
super().__init__(Site.ZIP_RECRUITER, proxy=proxy)
|
|
||||||
|
|
||||||
self.delay = 5
|
self.delay = 5
|
||||||
self.jobs_per_page = 20
|
self.jobs_per_page = 20
|
||||||
@@ -65,7 +77,7 @@ class ZipRecruiterScraper(Scraper):
|
|||||||
break
|
break
|
||||||
if page > 1:
|
if page > 1:
|
||||||
time.sleep(self.delay)
|
time.sleep(self.delay)
|
||||||
logger.info(f"ZipRecruiter search page: {page}")
|
logger.info(f"search page: {page} / {max_pages}")
|
||||||
jobs_on_page, continue_token = self._find_jobs_in_page(
|
jobs_on_page, continue_token = self._find_jobs_in_page(
|
||||||
scraper_input, continue_token
|
scraper_input, continue_token
|
||||||
)
|
)
|
||||||
@@ -91,9 +103,7 @@ class ZipRecruiterScraper(Scraper):
|
|||||||
if continue_token:
|
if continue_token:
|
||||||
params["continue_from"] = continue_token
|
params["continue_from"] = continue_token
|
||||||
try:
|
try:
|
||||||
res = self.session.get(
|
res = self.session.get(f"{self.api_url}/jobs-app/jobs", params=params)
|
||||||
f"{self.api_url}/jobs-app/jobs", headers=self.headers, params=params
|
|
||||||
)
|
|
||||||
if res.status_code not in range(200, 400):
|
if res.status_code not in range(200, 400):
|
||||||
if res.status_code == 429:
|
if res.status_code == 429:
|
||||||
err = "429 Response - Blocked by ZipRecruiter for too many requests"
|
err = "429 Response - Blocked by ZipRecruiter for too many requests"
|
||||||
@@ -129,6 +139,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
|
||||||
@@ -150,7 +161,10 @@ class ZipRecruiterScraper(Scraper):
|
|||||||
comp_min = int(job["compensation_min"]) if "compensation_min" in job else None
|
comp_min = int(job["compensation_min"]) if "compensation_min" in job else None
|
||||||
comp_max = int(job["compensation_max"]) if "compensation_max" in job else None
|
comp_max = int(job["compensation_max"]) if "compensation_max" in job else None
|
||||||
comp_currency = job.get("compensation_currency")
|
comp_currency = job.get("compensation_currency")
|
||||||
|
description_full, job_url_direct = self._get_descr(job_url)
|
||||||
|
|
||||||
return JobPost(
|
return JobPost(
|
||||||
|
id=f'zr-{job["listing_key"]}',
|
||||||
title=title,
|
title=title,
|
||||||
company_name=company,
|
company_name=company,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -163,14 +177,47 @@ class ZipRecruiterScraper(Scraper):
|
|||||||
),
|
),
|
||||||
date_posted=date_posted,
|
date_posted=date_posted,
|
||||||
job_url=job_url,
|
job_url=job_url,
|
||||||
description=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,
|
||||||
|
listing_type=listing_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_descr(self, job_url):
|
||||||
|
res = self.session.get(job_url, allow_redirects=True)
|
||||||
|
description_full = job_url_direct = None
|
||||||
|
if res.ok:
|
||||||
|
soup = BeautifulSoup(res.text, "html.parser")
|
||||||
|
job_descr_div = soup.find("div", class_="job_description")
|
||||||
|
company_descr_section = soup.find("section", class_="company_description")
|
||||||
|
job_description_clean = (
|
||||||
|
remove_attributes(job_descr_div).prettify(formatter="html")
|
||||||
|
if job_descr_div
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
company_description_clean = (
|
||||||
|
remove_attributes(company_descr_section).prettify(formatter="html")
|
||||||
|
if company_descr_section
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
description_full = job_description_clean + company_description_clean
|
||||||
|
script_tag = soup.find("script", type="application/json")
|
||||||
|
if script_tag:
|
||||||
|
job_json = json.loads(script_tag.string)
|
||||||
|
job_url_val = job_json["model"].get("saveJobURL", "")
|
||||||
|
m = re.search(r"job_url=(.+)", job_url_val)
|
||||||
|
if m:
|
||||||
|
job_url_direct = m.group(1)
|
||||||
|
|
||||||
|
if self.scraper_input.description_format == DescriptionFormat.MARKDOWN:
|
||||||
|
description_full = markdown_converter(description_full)
|
||||||
|
|
||||||
|
return description_full, job_url_direct
|
||||||
|
|
||||||
def _get_cookies(self):
|
def _get_cookies(self):
|
||||||
data = "event_type=session&logged_in=false&number_of_retry=1&property=model%3AiPhone&property=os%3AiOS&property=locale%3Aen_us&property=app_build_number%3A4734&property=app_version%3A91.0&property=manufacturer%3AApple&property=timestamp%3A2024-01-12T12%3A04%3A42-06%3A00&property=screen_height%3A852&property=os_version%3A16.6.1&property=source%3Ainstall&property=screen_width%3A393&property=device_model%3AiPhone%2014%20Pro&property=brand%3AApple"
|
data = "event_type=session&logged_in=false&number_of_retry=1&property=model%3AiPhone&property=os%3AiOS&property=locale%3Aen_us&property=app_build_number%3A4734&property=app_version%3A91.0&property=manufacturer%3AApple&property=timestamp%3A2024-01-12T12%3A04%3A42-06%3A00&property=screen_height%3A852&property=os_version%3A16.6.1&property=source%3Ainstall&property=screen_width%3A393&property=device_model%3AiPhone%2014%20Pro&property=brand%3AApple"
|
||||||
url = f"{self.api_url}/jobs-app/event"
|
url = f"{self.api_url}/jobs-app/event"
|
||||||
self.session.post(url, data=data, headers=self.headers)
|
self.session.post(url, data=data)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_job_type_enum(job_type_str: str) -> list[JobType] | None:
|
def _get_job_type_enum(job_type_str: str) -> list[JobType] | None:
|
||||||
@@ -198,14 +245,3 @@ class ZipRecruiterScraper(Scraper):
|
|||||||
if scraper_input.distance:
|
if scraper_input.distance:
|
||||||
params["radius"] = scraper_input.distance
|
params["radius"] = scraper_input.distance
|
||||||
return {k: v for k, v in params.items() if v is not None}
|
return {k: v for k, v in params.items() if v is not None}
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Host": "api.ziprecruiter.com",
|
|
||||||
"accept": "*/*",
|
|
||||||
"x-zr-zva-override": "100000000;vid:ZT1huzm_EQlDTVEc",
|
|
||||||
"x-pushnotificationid": "0ff4983d38d7fc5b3370297f2bcffcf4b3321c418f5c22dd152a0264707602a0",
|
|
||||||
"x-deviceid": "D77B3A92-E589-46A4-8A39-6EF6F1D86006",
|
|
||||||
"user-agent": "Job Search/87.0 (iPhone; CPU iOS 16_6_1 like Mac OS X)",
|
|
||||||
"authorization": "Basic YTBlZjMyZDYtN2I0Yy00MWVkLWEyODMtYTI1NDAzMzI0YTcyOg==",
|
|
||||||
"accept-language": "en-US,en;q=0.9",
|
|
||||||
}
|
|
||||||
|
|||||||
10
src/jobspy/scrapers/ziprecruiter/constants.py
Normal file
10
src/jobspy/scrapers/ziprecruiter/constants.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
headers = {
|
||||||
|
"Host": "api.ziprecruiter.com",
|
||||||
|
"accept": "*/*",
|
||||||
|
"x-zr-zva-override": "100000000;vid:ZT1huzm_EQlDTVEc",
|
||||||
|
"x-pushnotificationid": "0ff4983d38d7fc5b3370297f2bcffcf4b3321c418f5c22dd152a0264707602a0",
|
||||||
|
"x-deviceid": "D77B3A92-E589-46A4-8A39-6EF6F1D86006",
|
||||||
|
"user-agent": "Job Search/87.0 (iPhone; CPU iOS 16_6_1 like Mac OS X)",
|
||||||
|
"authorization": "Basic YTBlZjMyZDYtN2I0Yy00MWVkLWEyODMtYTI1NDAzMzI0YTcyOg==",
|
||||||
|
"accept-language": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from ..jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def test_all():
|
|
||||||
result = scrape_jobs(
|
|
||||||
site_name=["linkedin", "indeed", "zip_recruiter", "glassdoor"],
|
|
||||||
search_term="software engineer",
|
|
||||||
results_wanted=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
isinstance(result, pd.DataFrame) and not result.empty
|
|
||||||
), "Result should be a non-empty DataFrame"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from ..jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def test_indeed():
|
|
||||||
result = scrape_jobs(
|
|
||||||
site_name="glassdoor", search_term="software engineer", country_indeed="USA"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
isinstance(result, pd.DataFrame) and not result.empty
|
|
||||||
), "Result should be a non-empty DataFrame"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from ..jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def test_indeed():
|
|
||||||
result = scrape_jobs(
|
|
||||||
site_name="indeed", search_term="software engineer", country_indeed="usa"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
isinstance(result, pd.DataFrame) and not result.empty
|
|
||||||
), "Result should be a non-empty DataFrame"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from ..jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def test_linkedin():
|
|
||||||
result = scrape_jobs(
|
|
||||||
site_name="linkedin",
|
|
||||||
search_term="software engineer",
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
isinstance(result, pd.DataFrame) and not result.empty
|
|
||||||
), "Result should be a non-empty DataFrame"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from ..jobspy import scrape_jobs
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
|
|
||||||
def test_ziprecruiter():
|
|
||||||
result = scrape_jobs(
|
|
||||||
site_name="zip_recruiter",
|
|
||||||
search_term="software engineer",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert (
|
|
||||||
isinstance(result, pd.DataFrame) and not result.empty
|
|
||||||
), "Result should be a non-empty DataFrame"
|
|
||||||
18
tests/test_all.py
Normal file
18
tests/test_all.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from jobspy import scrape_jobs
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def test_all():
|
||||||
|
sites = [
|
||||||
|
"indeed",
|
||||||
|
"glassdoor",
|
||||||
|
] # ziprecruiter/linkedin needs good ip, and temp fix to pass test on ci
|
||||||
|
result = scrape_jobs(
|
||||||
|
site_name=sites,
|
||||||
|
search_term="engineer",
|
||||||
|
results_wanted=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
isinstance(result, pd.DataFrame) and len(result) == len(sites) * 5
|
||||||
|
), "Result should be a non-empty DataFrame"
|
||||||
13
tests/test_glassdoor.py
Normal file
13
tests/test_glassdoor.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from jobspy import scrape_jobs
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def test_glassdoor():
|
||||||
|
result = scrape_jobs(
|
||||||
|
site_name="glassdoor",
|
||||||
|
search_term="engineer",
|
||||||
|
results_wanted=5,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
isinstance(result, pd.DataFrame) and len(result) == 5
|
||||||
|
), "Result should be a non-empty DataFrame"
|
||||||
13
tests/test_indeed.py
Normal file
13
tests/test_indeed.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from jobspy import scrape_jobs
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def test_indeed():
|
||||||
|
result = scrape_jobs(
|
||||||
|
site_name="indeed",
|
||||||
|
search_term="engineer",
|
||||||
|
results_wanted=5,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
isinstance(result, pd.DataFrame) and len(result) == 5
|
||||||
|
), "Result should be a non-empty DataFrame"
|
||||||
9
tests/test_linkedin.py
Normal file
9
tests/test_linkedin.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from jobspy import scrape_jobs
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def test_linkedin():
|
||||||
|
result = scrape_jobs(site_name="linkedin", search_term="engineer", results_wanted=5)
|
||||||
|
assert (
|
||||||
|
isinstance(result, pd.DataFrame) and len(result) == 5
|
||||||
|
), "Result should be a non-empty DataFrame"
|
||||||
12
tests/test_ziprecruiter.py
Normal file
12
tests/test_ziprecruiter.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from jobspy import scrape_jobs
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
def test_ziprecruiter():
|
||||||
|
result = scrape_jobs(
|
||||||
|
site_name="zip_recruiter", search_term="software engineer", results_wanted=5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
isinstance(result, pd.DataFrame) and len(result) == 5
|
||||||
|
), "Result should be a non-empty DataFrame"
|
||||||
Reference in New Issue
Block a user