mirror of
https://github.com/Bunsly/JobSpy.git
synced 2026-03-05 12:04:33 -08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb7ffe5fd | ||
|
|
cd29f79796 | ||
|
|
65d2e5e707 | ||
|
|
08d63a87a2 | ||
|
|
1ffdb1756f | ||
|
|
1185693422 | ||
|
|
dcd7144318 | ||
|
|
bf73c061bd | ||
|
|
8dd08ed9fd | ||
|
|
5d3df732e6 | ||
|
|
86f858e06d | ||
|
|
1089d1f0a5 | ||
|
|
3e93454738 | ||
|
|
0d150d519f | ||
|
|
cc3497f929 | ||
|
|
5986f75346 | ||
|
|
4b7bdb9313 | ||
|
|
80213f28d2 | ||
|
|
ada38532c3 |
94
README.md
94
README.md
@@ -11,7 +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) -
|
[Video Guide for JobSpy](https://www.youtube.com/watch?v=RuP1HrAZnxs&pp=ygUgam9icyBzY3JhcGVyIGJvdCBsaW5rZWRpbiBpbmRlZWQ%3D) -
|
||||||
Updated for release v1.1.3
|
Updated for release v1.1.3
|
||||||
@@ -38,7 +38,11 @@ jobs = scrape_jobs(
|
|||||||
location="Dallas, TX",
|
location="Dallas, TX",
|
||||||
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)
|
||||||
|
# proxies=["Efb5EA8OIk0BQb:wifi;us;@proxy.soax.com:9000", "localhost"],
|
||||||
|
|
||||||
)
|
)
|
||||||
print(f"Found {len(jobs)} jobs")
|
print(f"Found {len(jobs)} jobs")
|
||||||
print(jobs.head())
|
print(jobs.head())
|
||||||
@@ -48,7 +52,7 @@ jobs.to_csv("jobs.csv", quoting=csv.QUOTE_NONNUMERIC, escapechar="\\", index=Fal
|
|||||||
### Output
|
### Output
|
||||||
|
|
||||||
```
|
```
|
||||||
SITE TITLE COMPANY_NAME CITY STATE JOB_TYPE INTERVAL MIN_AMOUNT MAX_AMOUNT JOB_URL DESCRIPTION
|
SITE TITLE COMPANY CITY STATE JOB_TYPE INTERVAL MIN_AMOUNT MAX_AMOUNT JOB_URL DESCRIPTION
|
||||||
indeed Software Engineer AMERICAN SYSTEMS Arlington VA None yearly 200000 150000 https://www.indeed.com/viewjob?jk=5e409e577046... THIS POSITION COMES WITH A 10K SIGNING BONUS!...
|
indeed Software Engineer AMERICAN SYSTEMS Arlington VA None yearly 200000 150000 https://www.indeed.com/viewjob?jk=5e409e577046... THIS POSITION COMES WITH A 10K SIGNING BONUS!...
|
||||||
indeed Senior Software Engineer TherapyNotes.com Philadelphia PA fulltime yearly 135000 110000 https://www.indeed.com/viewjob?jk=da39574a40cb... About Us TherapyNotes is the national leader i...
|
indeed Senior Software Engineer TherapyNotes.com Philadelphia PA fulltime yearly 135000 110000 https://www.indeed.com/viewjob?jk=da39574a40cb... About Us TherapyNotes is the national leader i...
|
||||||
linkedin Software Engineer - Early Career Lockheed Martin Sunnyvale CA fulltime yearly None None https://www.linkedin.com/jobs/view/3693012711 Description:By bringing together people that u...
|
linkedin Software Engineer - Early Career Lockheed Martin Sunnyvale CA fulltime yearly None None https://www.linkedin.com/jobs/view/3693012711 Description:By bringing together people that u...
|
||||||
@@ -60,25 +64,71 @@ zip_recruiter Software Developer TEKsystems Phoenix
|
|||||||
### Parameters for `scrape_jobs()`
|
### Parameters for `scrape_jobs()`
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
Required
|
|
||||||
├── site_type (List[enum]): linkedin, zip_recruiter, indeed, glassdoor
|
|
||||||
└── search_term (str)
|
|
||||||
Optional
|
Optional
|
||||||
|
├── site_name (list|str):
|
||||||
|
| linkedin, zip_recruiter, indeed, glassdoor
|
||||||
|
| (default is all four)
|
||||||
|
│
|
||||||
|
├── search_term (str)
|
||||||
|
│
|
||||||
├── location (str)
|
├── location (str)
|
||||||
├── distance (int): in miles, default 50
|
│
|
||||||
├── job_type (enum): 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 ():
|
||||||
|
| in format ['user:pass@host:port', 'localhost']
|
||||||
|
| each job board will round robin through the proxies
|
||||||
|
│
|
||||||
├── is_remote (bool)
|
├── is_remote (bool)
|
||||||
├── linkedin_fetch_description (bool): fetches full description for LinkedIn (slower)
|
│
|
||||||
├── results_wanted (int): number of job results to retrieve for each site specified in 'site_type'
|
├── results_wanted (int):
|
||||||
├── easy_apply (bool): filters for jobs that are hosted on the job board site (not supported on Indeed)
|
| 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 (enum): markdown, html (format type of the job descriptions)
|
├── easy_apply (bool):
|
||||||
├── country_indeed (enum): filters the country on Indeed (see below for correct spelling)
|
| filters for jobs that are hosted on the job board site
|
||||||
├── offset (num): 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 or is_remote)
|
├── description_format (str):
|
||||||
|
| markdown, html (Format type of the job descriptions. Default is markdown.)
|
||||||
|
│
|
||||||
|
├── 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
├── 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
|
||||||
@@ -119,7 +169,7 @@ Indeed specific
|
|||||||
|
|
||||||
### **LinkedIn**
|
### **LinkedIn**
|
||||||
|
|
||||||
LinkedIn searches globally & uses only the `location` parameter. You can only fetch 1000 jobs max from the LinkedIn endpoint we are using
|
LinkedIn searches globally & uses only the `location` parameter.
|
||||||
|
|
||||||
### **ZipRecruiter**
|
### **ZipRecruiter**
|
||||||
|
|
||||||
@@ -154,8 +204,8 @@ 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.
|
||||||
* Glassdoor/Ziprecruiter can only fetch 900/1000 jobs from the endpoints we are using 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
|
||||||
|
|
||||||
@@ -170,7 +220,7 @@ persist, [submit an issue](https://github.com/Bunsly/JobSpy/issues).
|
|||||||
**Q: Received a response code 429?**
|
**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:
|
**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).
|
- Wait some time between scrapes (site-dependent).
|
||||||
- Trying a VPN or proxy to change your IP address.
|
- Try using the proxies param 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,77 +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}")
|
|
||||||
2068
poetry.lock
generated
2068
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "python-jobspy"
|
name = "python-jobspy"
|
||||||
version = "1.1.49"
|
version = "1.1.54"
|
||||||
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"
|
||||||
@@ -19,13 +19,14 @@ NUMPY = "1.24.2"
|
|||||||
pydantic = "^2.3.0"
|
pydantic = "^2.3.0"
|
||||||
tls-client = "^1.0.1"
|
tls-client = "^1.0.1"
|
||||||
markdownify = "^0.11.6"
|
markdownify = "^0.11.6"
|
||||||
|
regex = "^2024.4.28"
|
||||||
|
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
pytest = "^7.4.1"
|
pytest = "^7.4.1"
|
||||||
jupyter = "^1.0.0"
|
jupyter = "^1.0.0"
|
||||||
black = "^24.2.0"
|
black = "*"
|
||||||
pre-commit = "^3.6.2"
|
pre-commit = "*"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ 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
|
from .scrapers.utils import logger, set_logger_level
|
||||||
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
|
||||||
@@ -30,17 +30,18 @@ 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,
|
||||||
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,
|
||||||
|
verbose: int = 2,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> pd.DataFrame:
|
) -> pd.DataFrame:
|
||||||
"""
|
"""
|
||||||
Simultaneously scrapes job data from multiple job sites.
|
Simultaneously scrapes job data from multiple job sites.
|
||||||
:return: results_wanted: pandas dataframe containing job data
|
:return: pandas dataframe containing job data
|
||||||
"""
|
"""
|
||||||
SCRAPER_MAPPING = {
|
SCRAPER_MAPPING = {
|
||||||
Site.LINKEDIN: LinkedInScraper,
|
Site.LINKEDIN: LinkedInScraper,
|
||||||
@@ -48,6 +49,7 @@ def scrape_jobs(
|
|||||||
Site.ZIP_RECRUITER: ZipRecruiterScraper,
|
Site.ZIP_RECRUITER: ZipRecruiterScraper,
|
||||||
Site.GLASSDOOR: GlassdoorScraper,
|
Site.GLASSDOOR: GlassdoorScraper,
|
||||||
}
|
}
|
||||||
|
set_logger_level(verbose)
|
||||||
|
|
||||||
def map_str_to_site(site_name: str) -> Site:
|
def map_str_to_site(site_name: str) -> Site:
|
||||||
return Site[site_name.upper()]
|
return Site[site_name.upper()]
|
||||||
@@ -94,7 +96,7 @@ 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)
|
||||||
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
|
||||||
@@ -166,6 +168,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",
|
||||||
|
|||||||
@@ -226,6 +226,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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from ..jobs import (
|
from ..jobs import (
|
||||||
Enum,
|
Enum,
|
||||||
BaseModel,
|
BaseModel,
|
||||||
@@ -36,9 +38,10 @@ class ScraperInput(BaseModel):
|
|||||||
hours_old: int | None = None
|
hours_old: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class Scraper:
|
class Scraper(ABC):
|
||||||
def __init__(self, site: Site, proxy: list[str] | None = None):
|
def __init__(self, site: Site, proxies: list[str] | None = None):
|
||||||
|
self.proxies = proxies
|
||||||
self.site = site
|
self.site = site
|
||||||
self.proxy = (lambda p: {"http": p, "https": p} if p else None)(proxy)
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def scrape(self, scraper_input: ScraperInput) -> JobResponse: ...
|
def scrape(self, scraper_input: ScraperInput) -> JobResponse: ...
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ from ...jobs import (
|
|||||||
|
|
||||||
|
|
||||||
class GlassdoorScraper(Scraper):
|
class GlassdoorScraper(Scraper):
|
||||||
def __init__(self, proxy: Optional[str] = None):
|
def __init__(self, proxies: list[str] | 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)
|
||||||
|
|
||||||
self.base_url = None
|
self.base_url = None
|
||||||
self.country = None
|
self.country = None
|
||||||
@@ -59,7 +59,7 @@ 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, 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
|
self.headers["gd-csrf-token"] = token if token else self.fallback_token
|
||||||
|
|
||||||
@@ -190,6 +190,7 @@ class GlassdoorScraper(Scraper):
|
|||||||
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"
|
||||||
return JobPost(
|
return JobPost(
|
||||||
|
id=str(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,
|
||||||
@@ -244,7 +245,6 @@ 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, headers=self.headers)
|
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:
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ from typing import Tuple
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from concurrent.futures import ThreadPoolExecutor, Future
|
from concurrent.futures import ThreadPoolExecutor, Future
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
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,
|
logger,
|
||||||
|
create_session,
|
||||||
)
|
)
|
||||||
from ...jobs import (
|
from ...jobs import (
|
||||||
JobPost,
|
JobPost,
|
||||||
@@ -33,10 +32,13 @@ from ...jobs import (
|
|||||||
|
|
||||||
|
|
||||||
class IndeedScraper(Scraper):
|
class IndeedScraper(Scraper):
|
||||||
def __init__(self, proxy: str | None = None):
|
def __init__(self, proxies: list[str] | 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, 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 +47,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:
|
||||||
"""
|
"""
|
||||||
@@ -90,18 +90,18 @@ class IndeedScraper(Scraper):
|
|||||||
jobs = []
|
jobs = []
|
||||||
new_cursor = None
|
new_cursor = None
|
||||||
filters = self._build_filters()
|
filters = self._build_filters()
|
||||||
location = (
|
search_term = (
|
||||||
self.scraper_input.location
|
self.scraper_input.search_term.replace('"', '\\"')
|
||||||
or self.scraper_input.country.value[0].split(",")[-1]
|
if self.scraper_input.search_term
|
||||||
|
else ""
|
||||||
)
|
)
|
||||||
query = self.job_search_query.format(
|
query = self.job_search_query.format(
|
||||||
what=(
|
what=(f'what: "{search_term}"' if search_term else ""),
|
||||||
f'what: "{self.scraper_input.search_term}"'
|
location=(
|
||||||
if self.scraper_input.search_term
|
f'location: {{where: "{self.scraper_input.location}", radius: {self.scraper_input.distance}, radiusUnit: MILES}}'
|
||||||
|
if self.scraper_input.location
|
||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
location=location,
|
|
||||||
radius=self.scraper_input.distance,
|
|
||||||
dateOnIndeed=self.scraper_input.hours_old,
|
dateOnIndeed=self.scraper_input.hours_old,
|
||||||
cursor=f'cursor: "{cursor}"' if cursor else "",
|
cursor=f'cursor: "{cursor}"' if cursor else "",
|
||||||
filters=filters,
|
filters=filters,
|
||||||
@@ -111,16 +111,15 @@ class IndeedScraper(Scraper):
|
|||||||
}
|
}
|
||||||
api_headers = self.api_headers.copy()
|
api_headers = self.api_headers.copy()
|
||||||
api_headers["indeed-co"] = self.api_country_code
|
api_headers["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,
|
||||||
json=payload,
|
json=payload,
|
||||||
proxies=self.proxy,
|
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Indeed responded with status code: {response.status_code} (submit GitHub issue if this appears to be a beg)"
|
f"Indeed 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()
|
||||||
@@ -151,6 +150,15 @@ class IndeedScraper(Scraper):
|
|||||||
""".format(
|
""".format(
|
||||||
start=self.scraper_input.hours_old
|
start=self.scraper_input.hours_old
|
||||||
)
|
)
|
||||||
|
elif self.scraper_input.easy_apply:
|
||||||
|
filters_str = """
|
||||||
|
filters: {
|
||||||
|
keyword: {
|
||||||
|
field: "indeedApplyScope",
|
||||||
|
keys: ["DESKTOP"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
elif self.scraper_input.job_type or self.scraper_input.is_remote:
|
elif self.scraper_input.job_type or self.scraper_input.is_remote:
|
||||||
job_type_key_mapping = {
|
job_type_key_mapping = {
|
||||||
JobType.FULL_TIME: "CF3CP",
|
JobType.FULL_TIME: "CF3CP",
|
||||||
@@ -204,6 +212,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=str(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,
|
||||||
@@ -343,7 +352,7 @@ class IndeedScraper(Scraper):
|
|||||||
query GetJobData {{
|
query GetJobData {{
|
||||||
jobSearch(
|
jobSearch(
|
||||||
{what}
|
{what}
|
||||||
location: {{ where: "{location}", radius: {radius}, radiusUnit: MILES }}
|
{location}
|
||||||
includeSponsoredResults: NONE
|
includeSponsoredResults: NONE
|
||||||
limit: 100
|
limit: 100
|
||||||
sort: DATE
|
sort: DATE
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
|
import regex as re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from threading import Lock
|
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 .. import Scraper, ScraperInput, Site
|
from .. import Scraper, ScraperInput, Site
|
||||||
from ..exceptions import LinkedInException
|
from ..exceptions import LinkedInException
|
||||||
@@ -44,13 +45,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):
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
|
self.session = create_session(
|
||||||
|
proxies=self.proxies,
|
||||||
|
is_tls=False,
|
||||||
|
has_retry=True,
|
||||||
|
delay=5,
|
||||||
|
clear_cookies=True,
|
||||||
|
)
|
||||||
|
self.session.headers.update(self.headers)
|
||||||
self.scraper_input = None
|
self.scraper_input = None
|
||||||
self.country = "worldwide"
|
self.country = "worldwide"
|
||||||
|
self.job_url_direct_regex = re.compile(r'(?<=\?url=)[^"]+')
|
||||||
|
|
||||||
def scrape(self, scraper_input: ScraperInput) -> JobResponse:
|
def scrape(self, scraper_input: ScraperInput) -> JobResponse:
|
||||||
"""
|
"""
|
||||||
@@ -71,7 +81,6 @@ class LinkedInScraper(Scraper):
|
|||||||
)
|
)
|
||||||
while continue_search():
|
while continue_search():
|
||||||
logger.info(f"LinkedIn search page: {page // 25 + 1}")
|
logger.info(f"LinkedIn search page: {page // 25 + 1}")
|
||||||
session = create_session(is_tls=False, has_retry=True, delay=5)
|
|
||||||
params = {
|
params = {
|
||||||
"keywords": scraper_input.search_term,
|
"keywords": scraper_input.search_term,
|
||||||
"location": scraper_input.location,
|
"location": scraper_input.location,
|
||||||
@@ -96,12 +105,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):
|
||||||
@@ -194,18 +200,19 @@ class LinkedInScraper(Scraper):
|
|||||||
if metadata_card
|
if metadata_card
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
date_posted = description = job_type = None
|
date_posted = None
|
||||||
if datetime_tag and "datetime" in datetime_tag.attrs:
|
if datetime_tag and "datetime" in datetime_tag.attrs:
|
||||||
datetime_str = datetime_tag["datetime"]
|
datetime_str = datetime_tag["datetime"]
|
||||||
try:
|
try:
|
||||||
date_posted = datetime.strptime(datetime_str, "%Y-%m-%d")
|
date_posted = datetime.strptime(datetime_str, "%Y-%m-%d")
|
||||||
except:
|
except:
|
||||||
date_posted = None
|
date_posted = None
|
||||||
benefits_tag = job_card.find("span", class_="result-benefits__text")
|
job_details = {}
|
||||||
if full_descr:
|
if full_descr:
|
||||||
description, job_type = self._get_job_description(job_url)
|
job_details = self._get_job_details(job_url)
|
||||||
|
|
||||||
return JobPost(
|
return JobPost(
|
||||||
|
id=self._get_id(job_url),
|
||||||
title=title,
|
title=title,
|
||||||
company_name=company,
|
company_name=company,
|
||||||
company_url=company_url,
|
company_url=company_url,
|
||||||
@@ -213,29 +220,36 @@ class LinkedInScraper(Scraper):
|
|||||||
date_posted=date_posted,
|
date_posted=date_posted,
|
||||||
job_url=job_url,
|
job_url=job_url,
|
||||||
compensation=compensation,
|
compensation=compensation,
|
||||||
job_type=job_type,
|
job_type=job_details.get("job_type"),
|
||||||
description=description,
|
description=job_details.get("description"),
|
||||||
emails=extract_emails_from_text(description) if description else None,
|
job_url_direct=job_details.get("job_url_direct"),
|
||||||
|
emails=extract_emails_from_text(job_details.get("description")),
|
||||||
|
logo_photo_url=job_details.get("logo_photo_url"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_job_description(
|
def _get_id(self, url: str):
|
||||||
self, job_page_url: str
|
|
||||||
) -> tuple[None, None] | tuple[str | None, tuple[str | None, JobType | None]]:
|
|
||||||
"""
|
"""
|
||||||
Retrieves job description by going to the job page url
|
Extracts the job id from the job url
|
||||||
|
:param url:
|
||||||
|
:return: str
|
||||||
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
return url.split("/")[-1]
|
||||||
|
|
||||||
|
def _get_job_details(self, job_page_url: str) -> dict:
|
||||||
|
"""
|
||||||
|
Retrieves job description and other job details by going to the job page url
|
||||||
:param job_page_url:
|
:param job_page_url:
|
||||||
:return: description or None
|
:return: dict
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
session = create_session(is_tls=False, has_retry=True)
|
response = self.session.get(job_page_url, timeout=5)
|
||||||
response = session.get(
|
|
||||||
job_page_url, headers=self.headers, timeout=5, proxies=self.proxy
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except:
|
except:
|
||||||
return None, None
|
return {}
|
||||||
if response.url == "https://www.linkedin.com/signup":
|
if response.url == "https://www.linkedin.com/signup":
|
||||||
return None, None
|
return {}
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
div_content = soup.find(
|
div_content = soup.find(
|
||||||
@@ -253,7 +267,14 @@ class LinkedInScraper(Scraper):
|
|||||||
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)
|
||||||
return description, self._parse_job_type(soup)
|
return {
|
||||||
|
"description": description,
|
||||||
|
"job_type": self._parse_job_type(soup),
|
||||||
|
"job_url_direct": self._parse_job_url_direct(soup),
|
||||||
|
"logo_photo_url": soup.find("img", {"class": "artdeco-entity-image"}).get(
|
||||||
|
"data-delayed-url"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
def _get_location(self, metadata_card: Optional[Tag]) -> Location:
|
def _get_location(self, metadata_card: Optional[Tag]) -> Location:
|
||||||
"""
|
"""
|
||||||
@@ -306,6 +327,23 @@ 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 []
|
||||||
|
|
||||||
|
def _parse_job_url_direct(self, soup: BeautifulSoup) -> str | None:
|
||||||
|
"""
|
||||||
|
Gets the job url direct from job page
|
||||||
|
:param soup:
|
||||||
|
:return: str
|
||||||
|
"""
|
||||||
|
job_url_direct = None
|
||||||
|
job_url_direct_content = soup.find("code", id="applyUrl")
|
||||||
|
if job_url_direct_content:
|
||||||
|
job_url_direct_match = self.job_url_direct_regex.search(
|
||||||
|
job_url_direct_content.decode_contents().strip()
|
||||||
|
)
|
||||||
|
if job_url_direct_match:
|
||||||
|
job_url_direct = unquote(job_url_direct_match.group())
|
||||||
|
|
||||||
|
return job_url_direct
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def job_type_code(job_type_enum: JobType) -> str:
|
def job_type_code(job_type_enum: JobType) -> str:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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
|
||||||
@@ -21,6 +23,121 @@ if not logger.handlers:
|
|||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(
|
||||||
|
*,
|
||||||
|
proxies: dict | 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def set_logger_level(verbose: int = 2):
|
||||||
|
"""
|
||||||
|
Adjusts the logger's level. This function allows the logging level to be changed at runtime.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- verbose: int {0, 1, 2} (default=2, all logs)
|
||||||
|
"""
|
||||||
|
if verbose is None:
|
||||||
|
return
|
||||||
|
level_name = {2: "INFO", 1: "WARNING", 0: "ERROR"}.get(verbose, "INFO")
|
||||||
|
level = getattr(logging, level_name.upper(), None)
|
||||||
|
if level is not None:
|
||||||
|
logger.setLevel(level)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid log level: {level_name}")
|
||||||
|
|
||||||
|
|
||||||
def markdown_converter(description_html: str):
|
def markdown_converter(description_html: str):
|
||||||
if description_html is None:
|
if description_html is None:
|
||||||
return None
|
return None
|
||||||
@@ -35,39 +152,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.
|
||||||
|
|||||||
@@ -36,14 +36,15 @@ 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):
|
||||||
"""
|
"""
|
||||||
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)
|
||||||
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
|
||||||
@@ -151,6 +152,7 @@ class ZipRecruiterScraper(Scraper):
|
|||||||
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")
|
||||||
return JobPost(
|
return JobPost(
|
||||||
|
id=str(job["listing_key"]),
|
||||||
title=title,
|
title=title,
|
||||||
company_name=company,
|
company_name=company,
|
||||||
location=location,
|
location=location,
|
||||||
|
|||||||
Reference in New Issue
Block a user