From 339d0fdd69f652419a10f61c860c4ba7360eec08 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 1 Aug 2020 19:13:57 -0400 Subject: [PATCH 01/66] initial work --- jobfunnel/__init__.py | 15 + jobfunnel/indeed.py | 342 ---------------- jobfunnel/job.py | 64 +++ jobfunnel/jobfunnel.py | 375 ++---------------- jobfunnel/localization.py | 29 ++ jobfunnel/scrapers/__init__.py | 0 jobfunnel/scrapers/base.py | 76 ++++ jobfunnel/{ => scrapers}/glassdoor_base.py | 0 jobfunnel/{ => scrapers}/glassdoor_dynamic.py | 0 jobfunnel/{ => scrapers}/glassdoor_static.py | 0 jobfunnel/scrapers/indeed.py | 120 ++++++ jobfunnel/{ => scrapers}/monster.py | 0 jobfunnel/search_terms.py | 48 +++ jobfunnel/tools/tools.py | 13 +- 14 files changed, 391 insertions(+), 691 deletions(-) delete mode 100644 jobfunnel/indeed.py create mode 100644 jobfunnel/job.py create mode 100644 jobfunnel/localization.py create mode 100644 jobfunnel/scrapers/__init__.py create mode 100644 jobfunnel/scrapers/base.py rename jobfunnel/{ => scrapers}/glassdoor_base.py (100%) rename jobfunnel/{ => scrapers}/glassdoor_dynamic.py (100%) rename jobfunnel/{ => scrapers}/glassdoor_static.py (100%) create mode 100644 jobfunnel/scrapers/indeed.py rename jobfunnel/{ => scrapers}/monster.py (100%) create mode 100644 jobfunnel/search_terms.py diff --git a/jobfunnel/__init__.py b/jobfunnel/__init__.py index d3a156bb..4300391f 100644 --- a/jobfunnel/__init__.py +++ b/jobfunnel/__init__.py @@ -1 +1,16 @@ +import os +import random + + __version__ = '2.1.9' + + +# FIXME: gotta be a better way... +USER_AGENT_LIST_FILE = os.path.normpath( + os.path.join(os.path.dirname(__file__), 'text/user_agent_list.txt')) +USER_AGENT_LIST = [] +with open(USER_AGENT_LIST_FILE) as file: + for line in file: + li = line.strip() + if li and not li.startswith("#"): + USER_AGENT_LIST.append(line.rstrip('\n')) diff --git a/jobfunnel/indeed.py b/jobfunnel/indeed.py deleted file mode 100644 index 53fab9a1..00000000 --- a/jobfunnel/indeed.py +++ /dev/null @@ -1,342 +0,0 @@ -import re - -from bs4 import BeautifulSoup -from concurrent.futures import ThreadPoolExecutor, wait -from logging import info as log_info -from math import ceil -from time import sleep, time - -from .jobfunnel import JobFunnel, MASTERLIST_HEADER -from .tools.tools import filter_non_printables -from .tools.tools import post_date_from_relative_post_age - - -class Indeed(JobFunnel): - - def __init__(self, args): - super().__init__(args) - self.provider = 'indeed' - self.max_results_per_page = 50 - self.headers = { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.indeed.{0}/'.format( - self.search_terms['region']['domain']), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - } - # Sets headers as default on Session object - self.s.headers.update(self.headers) - # Concatenates keywords with '+' and encodes spaces as '+' - self.query = '+'.join(self.search_terms['keywords']).replace(' ', '+') - - def convert_radius(self, radius): - """function that quantizes the user input radius to a valid radius - value: 5, 10, 15, 25, 50, 100, and 200 kilometers or miles""" - if radius < 5: - radius = 0 - elif 5 <= radius < 10: - radius = 5 - elif 10 <= radius < 15: - radius = 10 - elif 15 <= radius < 25: - radius = 15 - elif 25 <= radius < 50: - radius = 25 - elif 50 <= radius < 100: - radius = 50 - elif radius >= 100: - radius = 100 - return radius - - def get_search_url(self, method='get'): - """gets the indeed search url""" - if method == 'get': - # form job search url - search = ('https://www.indeed.{0}/jobs?' - 'q={1}&l={2}%2C+{3}&radius={4}&limit={5}&filter={6}'.format( - self.search_terms['region']['domain'], - self.query, - self.search_terms['region']['city'].replace(' ', '+'), - self.search_terms['region']['province'], - self.convert_radius( - self.search_terms['region']['radius']), - self.max_results_per_page, - int(self.similar_results))) - - return search - elif method == 'post': - # @TODO implement post style for indeed - raise NotImplementedError() - else: - raise ValueError(f'No html method {method} exists') - - def search_page_for_job_soups(self, search, page, job_soup_list): - """function that scrapes the indeed page for a list of job soups""" - url = f'{search}&start={int(page * self.max_results_per_page)}' - log_info(f'getting indeed page {page} : {url}') - - jobs = BeautifulSoup( - self.s.get(url).text, self.bs4_parser). \ - find_all('div', attrs={'data-tn-component': 'organicJob'}) - - job_soup_list.extend(jobs) - - def search_joblink_for_blurb(self, job): - """function that scrapes the indeed job link for the blurb""" - search = job['link'] - log_info(f'getting indeed page: {search}') - - job_link_soup = BeautifulSoup( - self.s.get(search).text, self.bs4_parser) - - try: - job['blurb'] = job_link_soup.find( - id='jobDescriptionText').text.strip() - except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) - - def get_blurb_with_delay(self, job, delay): - """gets blurb from indeed job link and sets delays for requests""" - sleep(delay) - - search = job['link'] - log_info(f'delay of {delay:.2f}s, getting indeed search: {search}') - - res = self.s.get(search).text - return job, res - - def parse_blurb(self, job, html): - """parses and stores job description into dict entry""" - job_link_soup = BeautifulSoup(html, self.bs4_parser) - - try: - job['blurb'] = job_link_soup.find( - id='jobDescriptionText').text.strip() - except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) - - def get_num_pages_to_scrape(self, soup_base, max=0): - """ - Calculates the number of pages to be scraped. - Args: - soup_base: a BeautifulSoup object with the html data. - At the moment this method assumes that the soup_base was prepared statically. - max: the maximum number of pages to be scraped. - Returns: - The number of pages to be scraped. - If the number of pages that soup_base yields is higher than max, then max is returned. - """ - num_res = soup_base.find(id='searchCountPages').contents[0].strip() - num_res = int(re.findall(r'f (\d+) ', num_res.replace(',', ''))[0]) - number_of_pages = int(ceil(num_res / self.max_results_per_page)) - if max == 0: - return number_of_pages - elif number_of_pages < max: - return number_of_pages - else: - return max - - def get_title(self, soup): - """ - Fetches the title from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the title from. - Returns: - The job title scraped from soup. - Note that this function may throw an AttributeError if it cannot find the title. - The caller is expected to handle this exception. - """ - return soup.find('a', attrs={ - 'data-tn-element': 'jobTitle'}).text.strip() - - def get_company(self, soup): - """ - Fetches the company from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the company from. - Returns: - The company scraped from soup. - Note that this function may throw an AttributeError if it cannot find the company. - The caller is expected to handle this exception. - """ - return soup.find('span', attrs={ - 'class': 'company'}).text.strip() - - def get_location(self, soup): - """ - Fetches the job location from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the location from. - Returns: - The job location scraped from soup. - Note that this function may throw an AttributeError if it cannot find the location. - The caller is expected to handle this exception. - """ - return soup.find('span', attrs={ - 'class': 'location'}).text.strip() - - def get_tags(self, soup): - """ - Fetches the job location from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the location from. - Returns: - The job location scraped from soup. - Note that this function may throw an AttributeError if it cannot find the location. - The caller is expected to handle this exception. - """ - table = soup.find( - 'table', attrs={'class': 'jobCardShelfContainer'}). \ - find_all('td', attrs={'class': 'jobCardShelfItem'}) - return "\n".join([td.text.strip() for td in table]) - - def get_date(self, soup): - """ - Fetches the job date from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the date from. - Returns: - The job date scraped from soup. - Note that this function may throw an AttributeError if it cannot find the date. - The caller is expected to handle this exception. - """ - return soup.find('span', attrs={ - 'class': 'date'}).text.strip() - - def get_id(self, soup): - """ - Fetches the job id from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the id from. - Returns: - The job id scraped from soup. - Note that this function may throw an AttributeError if it cannot find the id. - The caller is expected to handle this exception. - """ - # id regex quantifiers - id_regex = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') - return id_regex.findall(str(soup.find('a', attrs={ - 'class': 'sl resultLink save-job-link'})))[0] - - def get_link(self, job_id): - """ - Constructs the link with the given job_id. - Args: - job_id: The id to be used to construct the link for this job. - Returns: - The constructed job link. - Note that this function does not check the correctness of this link. - The caller is responsible for checking correcteness. - """ - return (f"http://www.indeed." - f"{self.search_terms['region']['domain']}" - f"/viewjob?jk={job_id}") - - def scrape(self): - """function that scrapes job posting from indeed and pickles it""" - log_info(f'jobfunnel indeed to pickle running @ {self.date_string}') - - # get the search url - search = self.get_search_url() - - # get the html data, initialize bs4 with lxml - request_html = self.s.get(search) - - # create the soup base - soup_base = BeautifulSoup(request_html.text, self.bs4_parser) - - # parse total results, and calculate the # of pages needed - pages = self.get_num_pages_to_scrape(soup_base) - log_info(f'Found {pages} indeed results for query=' - f'{self.query}') - - # init list of job soups - job_soup_list = [] - # init threads - threads = ThreadPoolExecutor(max_workers=8) - # init futures list - fts = [] - - # scrape soups for all the pages containing jobs it found - for page in range(0, pages): - fts.append( # append thread job future to futures list - threads.submit(self.search_page_for_job_soups, - search, page, job_soup_list)) - wait(fts) # wait for all scrape jobs to finish - - # make a dict of job postings from the listing briefs - for s in job_soup_list: - # init dict to store scraped data - job = dict([(k, '') for k in MASTERLIST_HEADER]) - - # scrape the post data - job['status'] = 'new' - try: - # jobs should at minimum have a title, company and location - job['title'] = self.get_title(s) - job['company'] = self.get_company(s) - job['location'] = self.get_location(s) - except AttributeError: - continue - - job['blurb'] = '' - - try: - job['tags'] = self.get_tags(s) - except AttributeError: - job['tags'] = '' - - try: - job['date'] = self.get_date(s) - except AttributeError: - job['date'] = '' - - try: - job['id'] = self.get_id(s) - job['link'] = self.get_link(job['id']) - - except (AttributeError, IndexError): - job['id'] = '' - job['link'] = '' - - job['query'] = self.query - job['provider'] = self.provider - - # key by id - self.scrape_data[str(job['id'])] = job - - # stores references to jobs in list to be used in blurb retrieval - scrape_list = [i for i in self.scrape_data.values()] - - # converts job date formats into a standard date format - post_date_from_relative_post_age(scrape_list) - - # apply job pre-filter before scraping blurbs - super().pre_filter(self.scrape_data, self.provider) - - # checks if delay is set or not, then extracts blurbs from job links - if self.delay_config is not None: - # calls super class to run delay specific threading logic - super().delay_threader(scrape_list, self.get_blurb_with_delay, - self.parse_blurb, threads) - - else: - # start time recording - start = time() - - # maps jobs to threads and cleans them up when done - threads.map(self.search_joblink_for_blurb, scrape_list) - threads.shutdown() - - # end and print recorded time - end = time() - print(f'{self.provider} scrape job took {(end - start):.3f}s') diff --git a/jobfunnel/job.py b/jobfunnel/job.py new file mode 100644 index 00000000..91791bcf --- /dev/null +++ b/jobfunnel/job.py @@ -0,0 +1,64 @@ +"""Base Job class to be populated by Scrapers, manipulated by Filters and saved +to csv / etc by Exporter +""" +from datetime import date +from typing import Any, Optional, List +from jobfunnel.localization import Locale + + +class Job(): + """The base Job object which contains job information as attribs + """ + def __init__(self, + title: str, + company: str, + location: str, + scrape_date: date, + description: str, + key_id: str, + url: str, + locale: Locale, + post_date: Optional[date] = None, + raw: Optional[Any] = None, + tags: Optional[List[str]] = None) -> None: + """[summary] + + TODO: would be nice to use something standardized for location + TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? + + Args: + title (str): title of the job (should be somewhat short) + company (str): company the job was posted for (should also be short) + location (str): string that tells the user where the job is located + short_description (str): user-readable short description (one-liner) + long_description (str): complete description, may be many lines. + key_id (str): unique identifier for the job TODO: make more robust? + url (str): link to the page where the job exists + locale (Locale): identifier to help us with internationalization, + tells us what language and host-locale/domain a source is in. + raw (Optional[Any]): raw scrape data that we can use for + debugging/pickling, defualts to None. + post_date (Optional[date]): the date the job became available on the + job source. Defaults to None. + tags (Optional[List[str]], optional): additional key-words that are + in the job posting that identify the job. Defaults to []. + """ + # These must be populated by a Scraper + self.title = title + self.company = company + self.location = location + self.scrape_date = scrape_date + self.key_id = key_id + self.url = url + self.locale = locale + + # These may not always be populated in our job source + self.post_date = post_date + self.tags = tags if tags else [] + + # Semi-private attrib for debugging + self._raw_scrape_data = raw + + def is_valid(self) -> bool: + """TODO: implement this just to ensure that the metadata is good""" + pass diff --git a/jobfunnel/jobfunnel.py b/jobfunnel/jobfunnel.py index e6d90f9c..36ffd7bb 100755 --- a/jobfunnel/jobfunnel.py +++ b/jobfunnel/jobfunnel.py @@ -35,359 +35,42 @@ class JobFunnel(object): - """class that writes pickles to master list path and applies search - filters """ - - def __init__(self, args): - # The maximum number of days old a job can be - self.max_listing_days = args['max_listing_days'] - # paths - self.master_list_path = args['master_list_path'] - self.filterlist_path = args['filter_list_path'] - self.blacklist = args['black_list'] - self.logfile = args['log_path'] - self.loglevel = args['log_level'] - self.pickles_dir = args['data_path'] - self.duplicate_list_path = args['duplicate_list_path'] - - # other inits - self.filterlist = None - self.similar_results = args['similar'] - self.save_dup = args['save_duplicates'] - self.bs4_parser = 'lxml' - self.scrape_data = {} - - # user agent init - user_agent_list = [] - with open(USER_AGENT_LIST) as file: - for line in file: - li = line.strip() - if li and not li.startswith("#"): - user_agent_list.append(line.rstrip('\n')) - self.user_agent = random.choice(user_agent_list) - - # date string for pickle files + """Class that initializes a Scraper and scrapes a website to get jobs + """ + + def __init__(self, config: JobFunnelConfig): # FIXME: implement this + # Paths + self.master_file = config.master_file + self.user_deny_list_file = config.user_deny_list_file + self.global_deny_list_file = config.global_deny_list_file + self.cache_folder = config.cache_folder + self.log_file = config.log_file + + self.log_level = config.log_level self.date_string = date.today().strftime("%Y-%m-%d") - # search term configuration data - self.search_terms = args['search_terms'] - - # set delay settings if they exist + # Set delay settings if they exist self.delay_config = None - if args['delay_config'] is not None: - self.delay_config = args['delay_config'] + if config.delay_config is not None: + self.delay_config = config.delay_config - # set session with (potential proxy) - self.s = Session() + # Open a session with/out a proxy configured + self.session = Session() - # set proxy if given - if args['proxy'] is not None: - self.s.proxies = { - args['proxy']['protocol']: proxy_dict_to_url(args['proxy']) - } + # set proxy if given FIXME + # if config.proxy is not None: + # self.s.proxies = { + # config.proxy.protocol: proxy_dict_to_url(config.proxy) + # } - # create data dir - if not os.path.exists(args['data_path']): - os.makedirs(args['data_path']) + # # create data dir FIXME + # if not os.path.exists(args['data_path']): + # os.makedirs(args['data_path']) def init_logging(self): - # initialise logging to file - self.logger = logging.getLogger() - self.logger.setLevel(self.loglevel) - logging.basicConfig(filename=self.logfile, level=self.loglevel) - if self.loglevel == 20: - logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) - else: - logging.getLogger().addHandler(logging.StreamHandler()) - - self.logger.info(f'jobfunnel initialized at {self.date_string}') - - def get_search_url(self, method='get'): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_title(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_company(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_location(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_tags(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_date(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_id(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_link(): - """function to be implemented by child classes""" - raise NotImplementedError() - - def get_number_of_pages(): - """function to be implemented by child classes""" - raise NotImplementedError() + """Initialize a logger""" + pass def scrape(self): - """function to be implemented by child classes""" - raise NotImplementedError() - - def load_pickle(self, args): - """function to load today's daily scrape pickle""" - # only to be used in no_scrape mode - pickle_filepath = os.path.join(args['data_path'], - f'jobs_{self.date_string}.pkl') - try: - self.scrape_data = pickle.load(open(pickle_filepath, 'rb')) - except FileNotFoundError as e: - logging.error(f'{pickle_filepath} not found! Have you scraped ' - f'any jobs today?') - raise e - - def load_pickles(self, args): - """function to load all historic daily scrape pickles""" - # only to be used in recovery mode - pickle_found = False - pickle_path = os.path.join(args['data_path']) - for root, dirs, files in os.walk(pickle_path): - for file in files: - if re.findall(r'jobs_.*', file): - if not pickle_found: - pickle_found = True - pickle_file = file - pickle_filepath = os.path.join(pickle_path, pickle_file) - logging.info(f'loading pickle file: {pickle_filepath}') - self.scrape_data.update( - pickle.load(open(pickle_filepath, 'rb'))) - if not pickle_found: - logging.error(f'no pickles found in {pickle_path}!' - f' Have you scraped any jobs?') - raise Exception - - def dump_pickle(self): - """function to dump a pickle of the daily scrape dict""" - pickle_name = f'jobs_{self.date_string}.pkl' - pickle.dump(self.scrape_data, - open(os.path.join(self.pickles_dir, pickle_name), 'wb')) - - def read_csv(self, path, key_by_id=True): - # reads csv passed in as path - with open(path, 'r', encoding='utf8', errors='ignore') as csvfile: - reader = csv.DictReader(csvfile) - if key_by_id: - return dict([(j['id'], j) for j in reader]) - else: - return [row for row in reader] - - def write_csv(self, data, path, fieldnames=MASTERLIST_HEADER): - # writes data [dict(),..] to a csv at path - with open(path, 'w', encoding='utf8') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) - writer.writeheader() - for row in data: - writer.writerow(data[row]) - - def remove_jobs_in_filterlist(self, data: Dict[str, dict]): - # load the filter-list if it exists, apply it to remove scraped jobs - if data == {}: - raise ValueError('No scraped job data to filter') - - if os.path.isfile(self.filterlist_path): - self.filterlist = json.load(open(self.filterlist_path, 'r')) - n_filtered = 0 - for jobid in self.filterlist: - if jobid in data: - data.pop(jobid) - n_filtered += 1 - logging.info(f'removed {n_filtered} jobs present in filter-list') - else: - if hasattr(self, 'provider'): - pass - else: - self.logger.warning(f'no jobs filtered, ' - f'missing {self.filterlist_path}') - - def remove_blacklisted_companies(self, data: Dict[str, dict]): - # remove blacklisted companies from the scraped data - # @TODO allow people to add companies to this via 'blacklist' status - blacklist_ids = [] - for job_id, job_data in data.items(): - if job_data['company'] in self.blacklist: - blacklist_ids.append(job_id) - logging.info(f'removed {len(blacklist_ids)} jobs ' - f'in blacklist from master-list') - for job_id in blacklist_ids: - data.pop(job_id) - - def update_filterjson(self): - # parse master .csv file into an update for the filter-list json file - if os.path.isfile(self.master_list_path): - # load existing filtered jobs, if any - if os.path.isfile(self.filterlist_path): - filtered_jobs = json.load(open(self.filterlist_path, 'r')) - else: - filtered_jobs = {} - - # add jobs from csv that need to be filtered away, if any - for job in self.read_csv(self.master_list_path, key_by_id=False): - if job['status'] in REMOVE_STATUSES: - if job['id'] not in filtered_jobs: - logging.info('added {} to {}'.format( - job['id'], self.filterlist_path)) - filtered_jobs[job['id']] = job - - # write out complete list with any additions from the masterlist - with open(self.filterlist_path, 'w', encoding='utf8') as outfile: - outfile.write( - json.dumps( - filtered_jobs, - indent=4, - sort_keys=True, - separators=(',', ': '), - ensure_ascii=False)) - - # update class attribute - self.filterlist = filtered_jobs - else: - logging.warning("no master-list, filter-list was not updated") - - def pre_filter(self, data: Dict[str, dict], provider): - """function called by child classes that applies multiple filters - before getting job blurbs""" - # call date_filter if it is turned on - if self.max_listing_days is not None: - date_filter(data, self.max_listing_days) - # call id_filter for master and duplicate lists, if they exist - if os.path.isfile(self.master_list_path): - id_filter(data, self.read_csv(self.master_list_path), - provider) - if os.path.isfile(self.duplicate_list_path): - id_filter(data, self.read_csv( - self.duplicate_list_path), provider) - - # filter out scraped jobs we have rejected, archived or blacklisted - try: - self.remove_jobs_in_filterlist(data) - except ValueError: - pass - - self.remove_blacklisted_companies(data) - - def delay_threader(self, - scrape_list: List[Dict], scrape_fn, parse_fn, threads): - """function called by child classes to thread scrapes jobs - with delays""" - if not scrape_list: - raise ValueError('No jobs to scrape') - # calls delaying algorithm - print("Calculating delay...") - delays = delay_alg(len(scrape_list), self.delay_config) - print("Done! Starting scrape!") - # zips delays and scrape list as jobs for thread pool - scrape_jobs = zip(scrape_list, delays) - # start time recording - start = time() - # submits jobs and stores futures in dict - results = {threads.submit(scrape_fn, job, delays): job['id'] - for job, delays in scrape_jobs} - - # loops through futures and removes each if successfully parsed - while results: - # parses futures as they complete - for future in as_completed(results): - try: - job, html = future.result() - parse_fn(job, html) - del results[future] - del html - except Exception as e: - self.logger.error(f'Blurb Future Error: {e}') - pass - - - threads.shutdown() # clean up threads when done - # end and print recorded time - end = time() - print(f'{self.provider} scrape job took {(end - start):.3f}s') - - def update_masterlist(self): - """use the scraped job listings to update the master spreadsheet""" - if self.scrape_data == {}: - raise ValueError('No scraped jobs, cannot update masterlist') - - # converts scrape data to ordered dictionary to filter all duplicates - self.scrape_data = OrderedDict(sorted(self.scrape_data.items(), - key=lambda t: t[1]['tags'])) - # filter out scraped jobs we have rejected, archived or blacklisted - self.remove_jobs_in_filterlist(self.scrape_data) - self.remove_blacklisted_companies(self.scrape_data) - - # load and update existing masterlist - try: - # open masterlist if it exists & init updated masterlist - masterlist = self.read_csv(self.master_list_path) - - # update masterlist to remove filtered/blacklisted jobs - self.remove_jobs_in_filterlist(masterlist) - self.remove_blacklisted_companies(masterlist) - - # update masterlist to contain only new (unique) listings - if self.save_dup: # if true, saves duplicates to own file - # calls tfidf filter and returns popped duplicate list - duplicate_list = tfidf_filter(self.scrape_data, masterlist) - - logging.info(f'Saving {len(duplicate_list)} duplicates jobs to' - f' {self.duplicate_list_path}') - # checks if duplicate list has entries - if len(duplicate_list) > 0: - # checks if duplicate_list.csv exists - if os.path.isfile(self.duplicate_list_path): - # loads and adds current duplicates to list - master_dup = self.read_csv(self.duplicate_list_path) - master_dup.update(duplicate_list) - self.write_csv(data=master_dup, - path=self.duplicate_list_path) - else: - # saves duplicates to duplicates_list.csv - self.write_csv(data=duplicate_list, - path=self.duplicate_list_path) - else: - tfidf_filter(self.scrape_data, masterlist) - - masterlist.update(self.scrape_data) - - # save - self.write_csv(data=masterlist, path=self.master_list_path) - - except FileNotFoundError: - # run tfidf filter on initial scrape - if self.save_dup: # if true saves duplicates to own file - duplicate_list = tfidf_filter(self.scrape_data) - - logging.info( - f'Saving {len(duplicate_list)} duplicates jobs to ' - f'{self.duplicate_list_path}') - - if len(duplicate_list) > 0: - # saves duplicates to duplicates_list.csv - self.write_csv(data=duplicate_list, - path=self.duplicate_list_path) - - else: - tfidf_filter(self.scrape_data) - - # dump the results into the data folder as the masterlist - self.write_csv(data=self.scrape_data, path=self.master_list_path) - logging.info( - f'no masterlist detected, added {len(self.scrape_data.keys())}' - f' jobs to {self.master_list_path}') + """Scrape jobs""" + pass diff --git a/jobfunnel/localization.py b/jobfunnel/localization.py new file mode 100644 index 00000000..d242db49 --- /dev/null +++ b/jobfunnel/localization.py @@ -0,0 +1,29 @@ +"""Place to store Enums and such for localization / internationalization +""" +from enum import Enum +from typing import List, Optional + + +class Locale(Enum): + """This will allow Scrapers / Filters / Main to identify the support they + have for different domains of different websites + + TODO: better way using the locale module? + """ + CANADA_ENGLISH = 1 + CANADA_FRENCH = 2 + USA_ENGLISH = 3 + + +def get_domain_from_locale(locale: Locale) -> str: + """Get a domain string from the locale Enum + + TODO: we may want something more flexible in the future. + """ + if locale in [Locale.CANADA_ENGLISH, Locale.CANADA_FRENCH]: + return 'ca' + elif locale == Locale.USA_ENGLISH: + return 'com' + else: + raise ValueError(f"Unknown domain string for locale {locale}") + diff --git a/jobfunnel/scrapers/__init__.py b/jobfunnel/scrapers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jobfunnel/scrapers/base.py b/jobfunnel/scrapers/base.py new file mode 100644 index 00000000..d7de0864 --- /dev/null +++ b/jobfunnel/scrapers/base.py @@ -0,0 +1,76 @@ +"""The base scraper class to be used for all web-scraping emitting Job objects +""" +from abc import ABC, abstractmethod +import os +from typing import Dict, List +import random +from requests import Session + +from jobfunnel import USER_AGENT_LIST +from jobfunnel.job import Job +from jobfunnel.search_terms import SearchTerms +from jobfunnel.localization import Locale + + +class Scraper(ABC): + """Base scraper object, for generating List[Job] from a specific job source + + TODO: accept filters: List[Filter] here if we have Filter(ABC) + NOTE: we want to use filtering here because scraping blurbs can be slow. + """ + + @abstractmethod + def __init__(self, session: Session, search_terms: SearchTerms) -> None: + pass + + @property + def bs4_parser(self) -> str: + """Beautiful soup 4's parser setting + NOTE: it's the same for all scrapers rn so it's not abstract + """ + return 'lxml' + + @property + def user_agent(self) -> str: + """Get a user agent for this scraper + """ + return random.choice(USER_AGENT_LIST) + + @property + @abstractmethod + def locale(self) -> Locale: + """Get the localizations that this scraper was built for + We will use this to put the right filters & scrapers together + """ + pass + + @property + @abstractmethod + def headers(self) -> Dict[str, str]: + """Get the Session headers for this scraper to be used with + requests.Session.headers.update() + """ + pass + + @abstractmethod + def scrape(self) -> List[Job]: + """Scrapes raw data from a job source into a list of Job objects + + Returns: + List[Job]: list of jobs scraped from the job source + """ + pass + + @abstractmethod + def filter_jobs(self, jobs: List[Job]) -> List[Job]: + """Descriminate each Job in jobs using filters + + TODO: use self.filters: List[Filter] + + Args: + jobs (List[job]): input jobs + + Returns: + List[Job]: output jobs + """ + pass diff --git a/jobfunnel/glassdoor_base.py b/jobfunnel/scrapers/glassdoor_base.py similarity index 100% rename from jobfunnel/glassdoor_base.py rename to jobfunnel/scrapers/glassdoor_base.py diff --git a/jobfunnel/glassdoor_dynamic.py b/jobfunnel/scrapers/glassdoor_dynamic.py similarity index 100% rename from jobfunnel/glassdoor_dynamic.py rename to jobfunnel/scrapers/glassdoor_dynamic.py diff --git a/jobfunnel/glassdoor_static.py b/jobfunnel/scrapers/glassdoor_static.py similarity index 100% rename from jobfunnel/glassdoor_static.py rename to jobfunnel/scrapers/glassdoor_static.py diff --git a/jobfunnel/scrapers/indeed.py b/jobfunnel/scrapers/indeed.py new file mode 100644 index 00000000..f8627d8a --- /dev/null +++ b/jobfunnel/scrapers/indeed.py @@ -0,0 +1,120 @@ +"""Scraper designed to get jobs from www.indeed.com / www.indeed.ca +""" +from abc import ABC, abstractmethod +import datetime +from typing import Dict, List +from requests import Session + +from jobfunnel.job import Job +from jobfunnel.localization import Locale, get_domain_from_locale +from jobfunnel.search_terms import SearchTerms +from jobfunnel.scrapers.base import Scraper + + +class BaseIndeedScraper(Scraper): + """Scrapes jobs from www.indeed.X + """ + def __init__(self, session: Session, search_terms: SearchTerms) -> None: + """Init that contains indeed specific stuff + """ + self.max_results_per_page = 50 + self.search_terms = search_terms + self.query = '+'.join(self.search_terms.keywords) + + def scrape(self) -> List[Job]: + """Scrapes raw data from a job source into a list of Job objects + + Returns: + List[Job]: list of jobs scraped from the job source + """ + return [ # FIXME: testing... + Job( + title="Beef Collector", + company="Orwell Farms", + location="Middle Earth, Canada", + scrape_date=datetime.datetime.now(), + description="Collect beef, earn sand dollars", + key_id="d3adb33f", + url="www.indeed.ca/test-job1", + locale=Locale.CANADA_ENGLISH, + post_date=datetime.datetime.now(), + raw="HTML:someran/domdatahereHeept;atag:s2311", + tags=['beef', 'collector', 'apply', 'now'], + ), + Job( + title="Chickun Inhibitor", + company="Roswell Park", + location="Middle South, Canada", + scrape_date=datetime.datetime.now(), + description="Collect Chickun, earn sand dollars", + key_id="d4adb44f", + url="www.indeed.ca/test-job2", + locale=Locale.CANADA_ENGLISH, + post_date=datetime.datetime.now(), + raw="HTML:somera3n/domdatahereHeept;atag:s231131", + tags=['chickun', 'collector', 'apply', 'now'], + ) + ] + + def filter_jobs(self, jobs: List[Job]) -> List[Job]: + """Descriminate each Job in jobs using filters + + TODO: use self.filters: List[Filter] + + Args: + jobs (List[job]): input jobs + + Returns: + List[Job]: output jobs + """ + return jobs # FIXME: testing... + + +class IndeedScraperCA(BaseIndeedScraper): + """Scrapes jobs from www.indeed.ca + """ + @property + def locale(self) -> Locale: + return Locale.CANADA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + """Session header for Indeed + """ + return { + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', # FIXME + 'referer': 'https://www.indeed.{0}/'.format( + get_domain_from_locale(self.locale)), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + + +class IndeedScraperUSA(BaseIndeedScraper): + """Scrapes jobs from www.indeed.com + """ + @property + def locale(self) -> Locale: + return Locale.USA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + """Session header for Indeed + """ + return { + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', # FIXME + 'referer': 'https://www.indeed.{0}/'.format( + get_domain_from_locale(self.locale)), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } diff --git a/jobfunnel/monster.py b/jobfunnel/scrapers/monster.py similarity index 100% rename from jobfunnel/monster.py rename to jobfunnel/scrapers/monster.py diff --git a/jobfunnel/search_terms.py b/jobfunnel/search_terms.py new file mode 100644 index 00000000..9bc8722e --- /dev/null +++ b/jobfunnel/search_terms.py @@ -0,0 +1,48 @@ +"""Object to contain job query metadata +""" +from typing import List, Optional +from jobfunnel.localization import Locale + + +DEFAULT_SEARCH_RADIUS_KM = 25 +DEFAULT_MAX_LISTING_DAYS = 10 + + +class SearchTerms(object): + """object to contain region of interest for a Locale + + NOTE: ideally we'd have one of these per-locale, per-website, but then + the config would be a nightmare, so we'll just put everything in here + for now + FIXME: need a better soln since this is required to be too flexible... + perhaps something at the Scraper level? + TODO: move into serach terms... + """ + + def __init__(self, + keywords: List[str], + provience: Optional[str] = None, + state: Optional[str] = None, + city: Optional[str] = None, + distance_radius_km: Optional[int] = DEFAULT_SEARCH_RADIUS_KM, + return_similar_results: Optional[bool] = False, + max_listing_days: Optional[int] = DEFAULT_MAX_LISTING_DAYS): + """init TODO: document""" + self.provience = provience + self.state = state + self.city = city + self.radius = distance_radius_km + self.keywords = keywords + self.return_similar_results = return_similar_results # indeed thing + self.max_listing_days = max_listing_days + + def is_valid(self, locale: Locale) -> bool: + """we need to have the right information set, not mixing stuff + TODO: eval is_valid based on the scraper as well? + """ + if not self.keywords: + return False + if locale in [Locale.CANADA_ENGLISH, Locale.CANADA_FRENCH]: + return self.provience and not self.state + elif locale == Locale.USA_ENGLISH: + return not self.provience and self.state diff --git a/jobfunnel/tools/tools.py b/jobfunnel/tools/tools.py index 2412103d..153182ee 100644 --- a/jobfunnel/tools/tools.py +++ b/jobfunnel/tools/tools.py @@ -1,20 +1,27 @@ +"""Assorted tools for all aspects of funnelin'' +""" +# FIXME sort these import logging +import os import re +import random import string - from copy import deepcopy from dateutil.relativedelta import relativedelta from datetime import datetime, timedelta - from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.microsoft import IEDriverManager from webdriver_manager.microsoft import EdgeChromiumDriverManager from webdriver_manager.opera import OperaDriverManager from webdriver_manager.firefox import GeckoDriverManager - from selenium import webdriver +# def get_random_user_agent() -> str: +# """The user agent should be randomized per-Scraper to help with spam det. +# """ FIXME... should go here maybe? + + def filter_non_printables(job): """function that filters trailing characters in scraped strings""" # filter all of the weird characters some job postings have... From d47405ada51581304667a21462536633dfff8b50 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 2 Aug 2020 17:15:30 -0400 Subject: [PATCH 02/66] got scraping for indeed going again, with legacy CSV support --- jobfunnel/__init__.py | 3 +- jobfunnel/__main__.py | 81 +--- jobfunnel/backend/__init__.py | 2 + jobfunnel/backend/job.py | 190 ++++++++ jobfunnel/backend/jobfunnel.py | 273 +++++++++++ jobfunnel/{ => backend}/localization.py | 0 jobfunnel/backend/scrapers/__init__.py | 4 + jobfunnel/{ => backend}/scrapers/base.py | 17 +- .../{ => backend}/scrapers/glassdoor_base.py | 0 .../scrapers/glassdoor_dynamic.py | 0 .../scrapers/glassdoor_static.py | 0 jobfunnel/backend/scrapers/indeed.py | 454 ++++++++++++++++++ jobfunnel/{ => backend}/scrapers/monster.py | 0 .../{scrapers => backend/tools}/__init__.py | 0 jobfunnel/{ => backend}/tools/delay.py | 0 jobfunnel/{ => backend}/tools/filters.py | 0 jobfunnel/{ => backend}/tools/tools.py | 71 +-- jobfunnel/config/__init__.py | 5 + jobfunnel/config/base.py | 15 + jobfunnel/config/delay.py | 15 + jobfunnel/config/funnel.py | 85 ++++ jobfunnel/config/parser.py | 2 +- jobfunnel/config/proxy.py | 29 ++ jobfunnel/{ => config}/search_terms.py | 15 +- jobfunnel/job.py | 64 --- jobfunnel/jobfunnel.py | 76 --- jobfunnel/resources/resources.py | 6 + .../{text => resources}/user_agent_list.txt | 0 jobfunnel/scrapers/indeed.py | 120 ----- jobfunnel/tools/__init__.py | 0 30 files changed, 1120 insertions(+), 407 deletions(-) create mode 100644 jobfunnel/backend/__init__.py create mode 100644 jobfunnel/backend/job.py create mode 100755 jobfunnel/backend/jobfunnel.py rename jobfunnel/{ => backend}/localization.py (100%) create mode 100644 jobfunnel/backend/scrapers/__init__.py rename jobfunnel/{ => backend}/scrapers/base.py (83%) rename jobfunnel/{ => backend}/scrapers/glassdoor_base.py (100%) rename jobfunnel/{ => backend}/scrapers/glassdoor_dynamic.py (100%) rename jobfunnel/{ => backend}/scrapers/glassdoor_static.py (100%) create mode 100644 jobfunnel/backend/scrapers/indeed.py rename jobfunnel/{ => backend}/scrapers/monster.py (100%) rename jobfunnel/{scrapers => backend/tools}/__init__.py (100%) rename jobfunnel/{ => backend}/tools/delay.py (100%) rename jobfunnel/{ => backend}/tools/filters.py (100%) rename jobfunnel/{ => backend}/tools/tools.py (54%) create mode 100644 jobfunnel/config/base.py create mode 100644 jobfunnel/config/delay.py create mode 100644 jobfunnel/config/funnel.py create mode 100644 jobfunnel/config/proxy.py rename jobfunnel/{ => config}/search_terms.py (80%) delete mode 100644 jobfunnel/job.py delete mode 100755 jobfunnel/jobfunnel.py create mode 100644 jobfunnel/resources/resources.py rename jobfunnel/{text => resources}/user_agent_list.txt (100%) delete mode 100644 jobfunnel/scrapers/indeed.py delete mode 100644 jobfunnel/tools/__init__.py diff --git a/jobfunnel/__init__.py b/jobfunnel/__init__.py index 4300391f..912d78d3 100644 --- a/jobfunnel/__init__.py +++ b/jobfunnel/__init__.py @@ -7,7 +7,8 @@ # FIXME: gotta be a better way... USER_AGENT_LIST_FILE = os.path.normpath( - os.path.join(os.path.dirname(__file__), 'text/user_agent_list.txt')) + os.path.join(os.path.dirname(__file__), 'resources', 'user_agent_list.txt') +) USER_AGENT_LIST = [] with open(USER_AGENT_LIST_FILE) as file: for line in file: diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 220dfc86..7cc8d7e4 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -1,74 +1,27 @@ #!python -"""main script, scrapes data off several listings, pickles it, -and applies search filters""" -import sys +"""Builds a config from CLI, runs desired scrapers and updates JSON + CSV +NOTE: you can test this from cloned source by running python -m jobfunnel +""" +import sys from typing import Union -from .config.parser import parse_config, ConfigError -from .config.validate import validate_config - -from .jobfunnel import JobFunnel -from .indeed import Indeed -from .monster import Monster -from .glassdoor_base import GlassDoorBase -from .glassdoor_dynamic import GlassDoorDynamic -from .glassdoor_static import GlassDoorStatic - -PROVIDERS = { - 'indeed': Indeed, - 'monster': Monster, - 'glassdoorstatic': GlassDoorStatic, - 'glassdoordynamic': GlassDoorDynamic -} +from .backend.jobfunnel import JobFunnel +from .config import JobFunnelConfig, SearchTerms +from .backend.scrapers import IndeedScraperCAEng def main(): - """main function""" - try: - config = parse_config() - validate_config(config) - - except ConfigError as e: - print(e.strerror) - sys.exit() - - # init class + logging - jf = JobFunnel(config) - jf.init_logging() - - # parse the master list path to update filter list - jf.update_filterjson() - - # get jobs by either scraping jobs or loading dumped pickles - if config['recover']: - jf.load_pickles(config) - elif config['no_scrape']: - jf.load_pickle(config) - else: - for p in config['providers']: - # checks to see if provider is glassdoor - provider: Union[Monster, - Indeed, GlassDoorDynamic, GlassDoorStatic] = PROVIDERS[p](config) - - provider_id = provider.__class__.__name__ - - try: - provider.scrape() - jf.scrape_data.update(provider.scrape_data) - except Exception as e: - jf.logger.error( - f'failed to scrape {provider_id}: {str(e)}') - - # dump scraped data to pickle - jf.dump_pickle() - - # filter scraped data and dump to the masterlist file - jf.update_masterlist() - - # done! - jf.logger.info('done. see un-archived jobs in ' + - config['master_list_path']) + """Parse CLI and call jobfunnel() to manage scrapers and lists + """ + + # Init TODO: parse CLI to do this. + search_terms = SearchTerms(['Python', 'Scientist'], 'ON', None, 'waterloo', 25) + config = JobFunnelConfig( + 't_m.csv', 't_udnl.json', 't_gdnl.json', './t_cache', + search_terms, [IndeedScraperCAEng], 't_log.log' + ) + JobFunnel(config).run() if __name__ == '__main__': diff --git a/jobfunnel/backend/__init__.py b/jobfunnel/backend/__init__.py new file mode 100644 index 00000000..b427bfe5 --- /dev/null +++ b/jobfunnel/backend/__init__.py @@ -0,0 +1,2 @@ +# from jobfunnel.backend.jobfunnel import JobFunnel FIXME: causes circular imp. +from jobfunnel.backend.job import Job, JobStatus diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py new file mode 100644 index 00000000..2962b365 --- /dev/null +++ b/jobfunnel/backend/job.py @@ -0,0 +1,190 @@ +"""Base Job class to be populated by Scrapers, manipulated by Filters and saved +to csv / etc by Exporter +""" +from datetime import date, datetime, timedelta +from dateutil.relativedelta import relativedelta +from enum import Enum +import re +import string +from typing import Any, Dict, Optional, List + +from jobfunnel.backend.localization import Locale +from jobfunnel.resources.resources import CSV_HEADER + + +PRINTABLE_STRINGS = set(string.printable) + +# Initialize list and store regex objects of date quantifiers TODO: refactor +HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') +DAY_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)') +MONTH_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?month') +YEAR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?year') +RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') +RECENT_REGEX_B = re.compile(r'[yY]esterday') + + +class JobStatus(Enum): + """Job statuses that are built-into jobfunnel + """ + NEW = 1 + ARCHIVE = 2 + INTERVIEWING = 3 + INTERVIEWED = 4 + REJECTED = 5 + ACCEPTED = 6 + + +class Job(): + """The base Job object which contains job information as attribs + """ + def __init__(self, + title: str, + company: str, + location: str, + description: str, + key_id: str, + url: str, + locale: Locale, + query: str, + provider: str, + status: JobStatus, + scrape_date: Optional[date] = None, + short_description: Optional[str] = None, + post_date: Optional[date] = None, + raw: Optional[Any] = None, + tags: Optional[List[str]] = None) -> None: + """[summary] + + TODO: would be nice to use something standardized for location + TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? + + Args: + title (str): title of the job (should be somewhat short) + company (str): company the job was posted for (should also be short) + location (str): string that tells the user where the job is located + description (str): content of job description, ideally this is human + readable. + key_id (str): unique identifier for the job TODO: make more robust? + url (str): link to the page where the job exists + locale (Locale): identifier to help us with internationalization, + tells us what language and host-locale/domain a source is in. + query (str): the search string that this job was found with + provider (str): name of the job source + status (JobStatus): the status of the job (i.e. new) + scrape_date (Optional[date]): date the job was scraped, Defaults + to the time that the job object is created. + short_description (Optional[str]): user-readable short description + (one-liner) + post_date (Optional[date]): the date the job became available on the + job source. Defaults to None. + raw (Optional[Any]): raw scrape data that we can use for + debugging/pickling, defualts to None. + tags (Optional[List[str]], optional): additional key-words that are + in the job posting that identify the job. Defaults to []. + """ + # These must be populated by a Scraper + self.title = title + self.company = company + self.location = location + self.description = description + self.key_id = key_id + self.url = url + self.locale = locale + self.query = query + self.provider = provider + self.status = status + + # These may not always be populated in our job source + self.post_date = post_date + self.scrape_date = scrape_date if scrape_date else datetime.now() + self.tags = tags if tags else [] + if short_description: + self.short_description = short_description + else: + self.short_description = description # TODO: copy it? + + # Semi-private attrib for debugging + self._raw_scrape_data = raw + + def get_csv_row(self) -> Dict[str, str]: + """Builds a CSV row for this job entry + + TODO: this is legacy, no support for short_description/raw rn. + """ + return dict([ + (h, v) for h,v in zip( + CSV_HEADER, + [ + self.status.name, + self.title, + self.company, + self.location, + self.post_date, + self.description, + ', '.join(self.tags), + self.url, + self.key_id, + self.provider, + self.query, + self.locale.name, + ] + ) + ]) + + def clean_strings(self) -> None: + """Ensure that all string fields have only printable chars + TODO: do this automatically upon assignment (override assignment) + """ + for attr in vars(self): + if type(attr) == str: + self.attr = ''.join( + filter(lambda x: x in PRINTABLE_STRINGS, self.title) + ) + + def set_post_date_from_relative_date(self) -> None: + """Identifies a job's post date via post age, updates in-place + """ + post_date = None + # Supports almost all formats like 7 hours|days and 7 hr|d|+d + try: + # hours old + hours_ago = HOUR_REGEX.findall(self.post_date)[0] + post_date = datetime.now() - timedelta(hours=int(hours_ago)) + except IndexError: + # days old + try: + days_ago = DAY_REGEX.findall(self.post_date)[0] + post_date = datetime.now() - timedelta(days=int(days_ago)) + except IndexError: + # months old + try: + months_ago = MONTH_REGEX.findall(self.post_date)[0] + post_date = datetime.now() - relativedelta( + months=int(months_ago)) + except IndexError: + # years old + try: + years_ago = YEAR_REGEX.findall(self.post_date)[0] + post_date = datetime.now() - relativedelta( + years=int(years_ago)) + except IndexError: + # try phrases like today, just posted, or yesterday + if (RECENT_REGEX_A.findall(self.post_date) and + not post_date): + # today + post_date = datetime.now() + elif RECENT_REGEX_B.findall(self.post_date): + # yesterday + post_date = datetime.now() - timedelta(days=int(1)) + elif not post_date: + # we have failed. + raise ValueError( + f"Unable to calculate date for {self.title}" + ) + + # Format date in standard format e.g. 2020-01-01 + self.post_date = post_date.strftime('%Y-%m-%d') + + def validate(self) -> None: + """TODO: implement this just to ensure that the metadata is good""" + pass diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py new file mode 100755 index 00000000..cd210952 --- /dev/null +++ b/jobfunnel/backend/jobfunnel.py @@ -0,0 +1,273 @@ +"""Paul McInnis 2018 +Scrapes jobs, applies search filters and writes pickles to master list +""" + +import csv +from collections import OrderedDict +from datetime import date, datetime +import json +import logging +import os +import pickle +from requests import Session +import sys +from typing import Dict, List, Union +from time import time + +from jobfunnel.config import JobFunnelConfig +from jobfunnel.backend import Job +from jobfunnel.resources.resources import CSV_HEADER, REMOVE_STATUSES + + +class JobFunnel(object): + """Class that initializes a Scraper and scrapes a website to get jobs + """ + + def __init__(self, config: JobFunnelConfig): + """Initialize a JobFunnel object, with a JobFunnel Config + + Args: + config (JobFunnelConfig): config object containing paths etc. + """ + self.config = config + self.config.create_dirs() + self.config.validate() + self.date_string = date.today().strftime("%Y-%m-%d") + self.logger = None + self.init_logging() + + # Open a session with/out a proxy configured + self.session = Session() + if self.config.proxy_config: + self.session.proxies = { + self.config.proxy_config.protocol: self.config.proxy_config.url + } + + def run(self) -> None: + """Scrape, update lists and save to CSV. + """ + # Parse the master list path to update filter list + self.update_user_deny_list() + + # Get new jobs keyed by their unique ID + jobs_dict = self.scrape() # type: Dict[str, Job] + + # Filter out scraped jobs we have rejected, archived or blacklisted + # (before we add them to the CSV) + self.filter_excluded_jobs(jobs_dict) + + # Load and update existing masterlist + if os.path.exists(self.config.master_csv_file): + # open masterlist if it exists & init updated masterlist + masterlist = self.read_master_csv() # type: Dict[str, Job] + + # update masterlist to remove filtered/blacklisted jobs + self.filter_excluded_jobs(jobs_dict) + # n_filtered += tfidf_filter(jobs_dict, masterlist) # FIXME + masterlist.update(jobs_dict) + + # save + self.write_master_csv(jobs_dict) + + else: + # run tfidf filter on initial scrape + # n_filtered += tfidf_filter(jobs_dict, masterlist) # FIXME + + # dump the results into the data folder as the masterlist + self.write_master_csv(jobs_dict) + self.logger.info( + f'no masterlist detected, added {len(jobs_dict.keys())}' + f' jobs to {self.config.master_csv_file}' + ) + + self.logger.info( + f"Done. View your current jobs in {self.config.master_csv_file}" + ) + + def init_logging(self) -> None: + """Initialize a logger + TODO: we are mixing logging calls with self.logger here, is that OK? + """ + self.logger = logging.getLogger() + self.logger.setLevel(self.config.log_level) + logging.basicConfig( + filename=self.config.log_file, + level=self.config.log_level, + ) + if self.config.log_level == 20: + logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) + else: + logging.getLogger().addHandler(logging.StreamHandler()) + self.logger.info(f"jobfunnel initialized at {self.date_string}") + + def scrape(self) ->Dict[str, Job]: + """Run each of the desired Scraper.scrape() with threading and delaying + """ + if self.config.no_scrape: + self.logger.info("Bypassing scraping (--no-scrape).") + return + self.logger.info(f"Starting scraping for: {self.config.scraper_names}") + + # Iterate thru scrapers and run their scrape. + jobs = {} # type: Dict[str, Job] + for scraper_cls in self.config.scrapers: + # FIXME: need the threader and delay here + start = time() + scraper = scraper_cls( + self.session, self.config.search_terms, self.logger + ) + # TODO: warning for overwriting different jobs with same key + jobs.update(scraper.scrape()) + end = time() + self.logger.info( + f"Scraped {len(jobs.items())} jobs from {scraper_cls.__name__}," + f" took {(end - start):.3f}s'" + ) + + self.logger.info(f"Completed Scraping, got {len(jobs)} jobs.") + return jobs + + def recover(self): + """Build a new master CSV from all the available pickles in our cache + """ + # FIXME: impl. should read all the pickles and make a new masterlist + pass + + @property + def daily_pickle_file(self) -> str: + """The name for for pickle file containing the scraped data ran today + """ + return os.path.join( + self.config.data_path, f"jobs_{self.date_string}.pkl", + ) + + def load_pickle(self) -> Dict[str, Job]: + """Load today's scrape data from pickle via date string + """ + try: + jobs_dict = pickle.load(open(self.daily_pickle_file, 'rb')) + except FileNotFoundError as e: + self.logger.error( + f"{self.daily_pickle_file} not found! Have you scraped any jobs" + " today?" + ) + raise e + self.logger.info( + f"Loaded {len(jobs_dict.keys())} jobs from {self.daily_pickle_file}" + ) + return jobs_dict + + def dump_pickle(self, jobs_dict: Dict[str, Job]) -> None: + """Dump a pickle of the daily scrape dict + """ + pickle.dump(jobs_dict, open(self.daily_pickle_file, 'wb')) + n_jobs = 2 # FIXME + self.logger.info( + f"Dumped {n_jobs} jobs to {self.daily_pickle_file}" + ) + + def read_master_csv(self) -> Dict[str, Job]: + """Read in the master-list CSV to a dict of unique Jobs + + Args: + key_by_id (bool, optional): key jobs by ID, return as a List[Job] if + False. Defaults to True.1 + + TODO: update from legacy CSV header for short & long description + + Returns: + Dict[str, Job]: unique Job objects in the CSV + """ + with open(self.config.master_csv_file, 'r', encoding='utf8', + errors='ignore') as csvfile: + + jobs_dict = {} # type: Dict[str, Job] + for row in csv.DictReader(csvfile): + # NOTE: this is for legacy support: + locale = row['locale'] if 'locale' in row else '' + if 'description' in row: + short_description = row['description'] + else: + short_description = '' + if 'scrape_date' in row: + scrape_date = datetime.fromisoformat(row['scrape_date']) + else: + scrape_date = datetime(1970, 1, 1) + if 'raw' in row: + raw = row['raw'] + else: + raw = None + job = Job( + title=row['title'], + company=row['company'], + location=row['location'], + description=row['blurb'], + key_id=row['id'], + url=row['link'], + locale=locale, + query=row['query'], + status=row['status'], + provider=row['provider'], + short_description=short_description, + post_date=row['date'], + scrape_date=scrape_date, + raw=raw, + tags=row['tags'].split(','), + ) + job.validate() + jobs_dict[job.key_id] = job + + self.logger.info( + f"Read out {len(jobs_dict.keys())} jobs from " + f"{self.config.master_csv_file}" + ) + return jobs_dict + + def write_master_csv(self, jobs: Dict[str, Job]) -> None: + """Write out our dict of unique Jobs to a CSV + + Args: + jobs (Dict[str, Job]): Dict of unique Jobs, keyd by unique id's + """ + with open(self.config.master_csv_file, 'w', encoding='utf8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=CSV_HEADER) + writer.writeheader() + for job in jobs.values(): + job.validate() + writer.writerow(job.get_csv_row()) + n_jobs = len(jobs) + self.logger.info( + f"Wrote out {n_jobs} jobs to {self.config.master_csv_file}" + ) + + def update_user_deny_list(self): + """Read the master CSV file and pop jobs by status into our user deny + list (which is a JSON) + """ + # FIXME: impl. + self.logger.info(f"Updated {self.config.user_deny_list_file}") + + def filter_excluded_jobs(self, jobs_dict: Dict[str, Job]) -> int: + """Load the user's deny-list if it exists and pop any matching jobs by + key + Returns the number of filtered jobs + NOTE: modifies in-place + FIXME: load the company deny-list as well + """ + n_filtered = 0 + if os.path.isfile(self.config.user_deny_list_file): + deny_dict = json.load( + open(self.config.user_deny_list_file, 'r') + ) + for jobid in deny_dict: + if jobid in jobs_dict: + jobs_dict.pop(jobid) + n_filtered += 1 + self.logger.info( + f'removed {n_filtered} jobs present in filter-list' + ) + else: + self.logger.warning( + f'No jobs filtered, missing: {self.config.user_deny_list_file}' + ) + return n_filtered diff --git a/jobfunnel/localization.py b/jobfunnel/backend/localization.py similarity index 100% rename from jobfunnel/localization.py rename to jobfunnel/backend/localization.py diff --git a/jobfunnel/backend/scrapers/__init__.py b/jobfunnel/backend/scrapers/__init__.py new file mode 100644 index 00000000..a9bd4f6b --- /dev/null +++ b/jobfunnel/backend/scrapers/__init__.py @@ -0,0 +1,4 @@ +from jobfunnel.backend.scrapers.base import BaseScraper +from jobfunnel.backend.scrapers.indeed import ( + IndeedScraperCAEng, IndeedScraperUSAEng +) diff --git a/jobfunnel/scrapers/base.py b/jobfunnel/backend/scrapers/base.py similarity index 83% rename from jobfunnel/scrapers/base.py rename to jobfunnel/backend/scrapers/base.py index d7de0864..43aa2208 100644 --- a/jobfunnel/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -1,18 +1,19 @@ """The base scraper class to be used for all web-scraping emitting Job objects """ from abc import ABC, abstractmethod +import logging import os from typing import Dict, List import random from requests import Session from jobfunnel import USER_AGENT_LIST -from jobfunnel.job import Job -from jobfunnel.search_terms import SearchTerms -from jobfunnel.localization import Locale +from jobfunnel.backend import Job +from jobfunnel.backend.localization import Locale +from jobfunnel.config import SearchTerms -class Scraper(ABC): +class BaseScraper(ABC): """Base scraper object, for generating List[Job] from a specific job source TODO: accept filters: List[Filter] here if we have Filter(ABC) @@ -20,7 +21,9 @@ class Scraper(ABC): """ @abstractmethod - def __init__(self, session: Session, search_terms: SearchTerms) -> None: + def __init__(self, session: Session, search_terms: SearchTerms, + logger: logging.Logger) -> None: + # TODO: can we set self.session etc so inherited classes don't have to? pass @property @@ -53,7 +56,7 @@ def headers(self) -> Dict[str, str]: pass @abstractmethod - def scrape(self) -> List[Job]: + def scrape(self) -> Dict[str, Job]: """Scrapes raw data from a job source into a list of Job objects Returns: @@ -61,7 +64,7 @@ def scrape(self) -> List[Job]: """ pass - @abstractmethod + # TODO: we need to filter jobs here. def filter_jobs(self, jobs: List[Job]) -> List[Job]: """Descriminate each Job in jobs using filters diff --git a/jobfunnel/scrapers/glassdoor_base.py b/jobfunnel/backend/scrapers/glassdoor_base.py similarity index 100% rename from jobfunnel/scrapers/glassdoor_base.py rename to jobfunnel/backend/scrapers/glassdoor_base.py diff --git a/jobfunnel/scrapers/glassdoor_dynamic.py b/jobfunnel/backend/scrapers/glassdoor_dynamic.py similarity index 100% rename from jobfunnel/scrapers/glassdoor_dynamic.py rename to jobfunnel/backend/scrapers/glassdoor_dynamic.py diff --git a/jobfunnel/scrapers/glassdoor_static.py b/jobfunnel/backend/scrapers/glassdoor_static.py similarity index 100% rename from jobfunnel/scrapers/glassdoor_static.py rename to jobfunnel/backend/scrapers/glassdoor_static.py diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py new file mode 100644 index 00000000..c1e5116d --- /dev/null +++ b/jobfunnel/backend/scrapers/indeed.py @@ -0,0 +1,454 @@ +"""Scraper designed to get jobs from www.indeed.com / www.indeed.ca +""" +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor, wait +import datetime +import logging +from math import ceil +from time import sleep, time +from typing import Dict, List, Tuple, Optional +import re +from requests import Session + +from bs4 import BeautifulSoup + +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.localization import Locale, get_domain_from_locale +from jobfunnel.backend.scrapers import BaseScraper +from jobfunnel.config import SearchTerms + + +class BaseIndeedScraper(BaseScraper): + """Scrapes jobs from www.indeed.X + """ + def __init__(self, session: Session, search_terms: SearchTerms, + logger: logging.Logger) -> None: + """Init that contains indeed specific stuff + """ + self.session = session + self.search_terms = search_terms + self.logger = logger + self.max_results_per_page = 50 + self.query = '+'.join(self.search_terms.keywords) + + def scrape(self) -> Dict[str, Job]: + """Scrapes raw data from a job source into a list of Job objects + + Returns: + List[Job]: list of jobs scraped from the job source + """ + # Get the search url + search = self.get_search_url() + + # Get the html data, initialize bs4 with lxml + request_html = self.session.get(search) + + # Create the soup base + soup_base = BeautifulSoup(request_html.text, self.bs4_parser) + + # Parse total results, and calculate the # of pages needed + pages = self.get_num_pages_to_scrape(soup_base) + self.logger.info(f"Found {pages} indeed results for query={self.query}") + + # Init list of job soups + job_soup_list = [] # type: List[Any] + + # Init threads & futures list + threads = ThreadPoolExecutor(max_workers=8) + fts = [] + + # Scrape soups for all the pages containing jobs it found + for page in range(0, pages): + # Append thread job future to futures list + fts.append( + threads.submit( + self.search_page_for_job_soups, search, page, job_soup_list + ) + ) + + # Wait for all scrape jobs to finish + wait(fts) + + # make a dict of job postings from the listing briefs + jobs_dict = {} # type: Dict[str, Job] + for s in job_soup_list: + + # init + status = JobStatus.NEW + title, company, location, tags = None, None, None, [] + post_date, key_id, url, short_description = None, None, None, None + + # Scrape the data for the post, requiring a minimum of info... + try: + # Jobs should at minimum have a title, company and location + title = self.get_title(s) + company = self.get_company(s) + location = self.get_location(s) + key_id = self.get_id(s) + url = self.get_link(key_id) + except AttributeError: + self.logger.error("Unable to scrape minimum-required job info!") + continue + + try: + tags = self.get_tags(s) + except AttributeError: + self.logger.warning(f"Unable to scrape job tags for {key_id}") + + try: + post_date = self.get_date(s) + except AttributeError: + self.logger.warning( + f"Unable to scrape job post date for {key_id}" + ) + + # Init a new job + job = Job( + title=title, + company=company, + location=location, + description='', # We will populate this later + key_id=key_id, + url=url, + locale=self.locale, + query=self.query, + status=status, + provider='indeed', # FIXME: should inherit this? + short_description=short_description, + post_date=post_date, + raw=s, + tags=tags, + ) + + # FIXME: This doesn't work, and adding it would break existing csvs + # try: + # self.set_short_description(job, s) + # except AttributeError: + # self.logger.warning("Unable to scrape job short description.") + + # Fix the date to not be relative + try: + job.set_post_date_from_relative_date() + except ValueError: + self.logger.error( + f"Unknown date for job {key_id}, setting to epoch date." + ) + job.post_date = datetime.datetime(1970, 1, 1) + + # Key by id to prevent duplicate key_ids TODO: add a warning + jobs_dict[job.key_id] = job + + # FIXME: get the long descriptions + return jobs_dict + + def convert_radius(self, radius: int) -> int: + """function that quantizes the user input radius to a valid radius + value: 5, 10, 15, 25, 50, 100, and 200 kilometers or miles + """ + if radius < 5: + radius = 0 + elif 5 <= radius < 10: + radius = 5 + elif 10 <= radius < 15: + radius = 10 + elif 15 <= radius < 25: + radius = 15 + elif 25 <= radius < 50: + radius = 25 + elif 50 <= radius < 100: + radius = 50 + elif radius >= 100: + radius = 100 + return radius + + @abstractmethod + def get_search_url(self, method: Optional[str] = 'get') -> str: + """Get the indeed search url from SearchTerms + """ + pass + + @abstractmethod + def get_link(self, job_id) -> str: + """Constructs the link with the given job_id. + Args: + job_id: The id to be used to construct the link for this job. + Returns: + The constructed job link. + Note that this function does not check the correctness of this link. + The caller is responsible for checking correcteness. + """ + pass + + def search_page_for_job_soups(self, search, page, job_soup_list): + """Scrapes the indeed page for a list of job soups + FIXME: types + """ + url = f'{search}&start={int(page * self.max_results_per_page)}' + self.logger.info(f'getting indeed page {page} : {url}') + job_soup_list.extend( + BeautifulSoup( + self.session.get(url).text, self.bs4_parser + ).find_all('div', attrs={'data-tn-component': 'organicJob'}) + ) + + def get_full_description(self, job: Job) -> None: + """Scrapes the indeed job link for the blurb and sets Job.short_desc + """ + self.logger.info(f'getting indeed page: {job.url}') + + job_link_soup = BeautifulSoup( + self.session.get(job.url).text, self.bs4_parser + ) + try: + job.short_description = job_link_soup.find( + id='jobDescriptionText' + ).text.strip() + except AttributeError: + self.logger.warning(f"Unable to load description for: {job.url}") + job.short_description = '' + job.clean_strings() + + def get_job_page_with_delay(self, job: Job, + delay: float) -> Tuple[Job, str]: + """Gets data from the indeed job link and sets delays for requests + """ + sleep(delay) + self.logger.info( + f'delay of {delay:.2f}s, getting indeed search: {job.url}' + ) + return job, self.session.get(job.url).text + + + def set_short_description(self, job: Job, soup: str) -> None: + """Parses and stores job description from a job's page HTML + FIXME: doesn't work. seems soup isn't right + """ + job_link_soup = BeautifulSoup(soup, self.bs4_parser) + try: + job.description = job_link_soup.find( + id='jobDescriptionText' + ).text.strip() + except AttributeError: + job.description = '' + job.clean_strings() + + def get_num_pages_to_scrape(self, soup_base, max_pages=0) -> int: + """Calculates the number of pages to be scraped. + Args: + soup_base: a BeautifulSoup object with the html data. + At the moment this method assumes that the soup_base was + prepared statically. + max_pages: the maximum number of pages to be scraped. + Returns: + The number of pages to be scraped. + If the number of pages that soup_base yields is higher than max, + then max is returned. + """ + num_res = soup_base.find(id='searchCountPages').contents[0].strip() + num_res = int(re.findall(r'f (\d+) ', num_res.replace(',', ''))[0]) + number_of_pages = int(ceil(num_res / self.max_results_per_page)) + if max_pages == 0: + return number_of_pages + elif number_of_pages < max_pages: + return number_of_pages + else: + return max_pages + + def get_title(self, soup) -> str: + """Fetches the title from a BeautifulSoup base. + Args: + soup: BeautifulSoup base to scrape the title from. + Returns: + The job title scraped from soup. + NOTE: that this function may throw an AttributeError if it cannot + find the title. The caller is expected to handle this exception. + """ + return soup.find( + 'a', attrs={'data-tn-element': 'jobTitle'} + ).text.strip() + + def get_company(self, soup) -> str: + """Fetches the company from a BeautifulSoup base. + Args: + soup: BeautifulSoup base to scrape the company from. + Returns: + The company scraped from soup. + Note that this function may throw an AttributeError if it cannot + find the company. The caller is expected to handle this exception. + """ + return soup.find('span', attrs={'class': 'company'}).text.strip() + + def get_location(self, soup) -> str: + """Fetches the job location from a BeautifulSoup base. + Args: + soup: BeautifulSoup base to scrape the location from. + Returns: + The job location scraped from soup. + Note that this function may throw an AttributeError if it cannot + find the location. The caller is expected to handle this exception. + """ + return soup.find('span', attrs={'class': 'location'}).text.strip() + + def get_tags(self, soup) -> List[str]: + """Fetches the job tags / keywords from a BeautifulSoup base. + Args: + soup: BeautifulSoup base to scrape the location from. + Returns: + The job location scraped from soup. + Note that this function may throw an AttributeError if it cannot + find the location. The caller is expected to handle this exception. + """ + return [td.text.strip() for td in soup.find( + 'table', attrs={'class': 'jobCardShelfContainer'} + ).find_all('td', attrs={'class': 'jobCardShelfItem'})] + + def get_date(self, soup) -> str: + """Fetches the job date from a BeautifulSoup base. + Args: + soup: BeautifulSoup base to scrape the date from. + Returns: + The job date scraped from soup. + Note that this function may throw an AttributeError if it cannot + find the date. The caller is expected to handle this exception. + """ + return soup.find('span', attrs={'class': 'date'}).text.strip() + + def get_id(self, soup) -> str: + """Fetches the job id from a BeautifulSoup base. + NOTE: this should be unique, but we should probably use our own SHA + Args: + soup: BeautifulSoup base to scrape the id from. + Returns: + The job id scraped from soup. + Note that this function may throw an AttributeError if it cannot + find the id. The caller is expected to handle this exception. + """ + id_regex = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') + return id_regex.findall( + str(soup.find('a', attrs={'class': 'sl resultLink save-job-link'})) + )[0] + + +class IndeedScraperCAEng(BaseIndeedScraper): + """Scrapes jobs from www.indeed.ca + """ + @property + def locale(self) -> Locale: + return Locale.CANADA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + """Session header for Indeed + """ + return { + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', # FIXME correct? + 'referer': 'https://www.indeed.{0}/'.format( + get_domain_from_locale(self.locale)), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + + def get_search_url(self, method: Optional[str] = 'get') -> str: + """Get the indeed search url from SearchTerms + """ + if method == 'get': + # form job search url + search = ( + "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" + "limit={5}&filter={6}".format( + get_domain_from_locale(self.locale), + self.query, + self.search_terms.city.replace(' ', '+'), + self.search_terms.province, + self.convert_radius(self.search_terms.radius), + self.max_results_per_page, + int(self.search_terms.return_similar_results) + ) + ) + return search + elif method == 'post': + # TODO: implement post style for indeed.X + raise NotImplementedError() + else: + raise ValueError(f'No html method {method} exists') + + def get_link(self, job_id) -> str: + """Constructs the link with the given job_id. + Args: + job_id: The id to be used to construct the link for this job. + Returns: + The constructed job link. + Note that this function does not check the correctness of this link. + The caller is responsible for checking correcteness. + """ + return (f"http://www.indeed.{get_domain_from_locale(self.locale)}" + f"/viewjob?jk={job_id}" + ) + +# TODO: IndeedScraperCAFr + +class IndeedScraperUSAEng(BaseIndeedScraper): + """Scrapes jobs from www.indeed.com + """ + @property + def locale(self) -> Locale: + return Locale.USA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + """Session header for Indeed + """ + return { + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-US;q=0.8,en;q=0.6', + 'referer': 'https://www.indeed.{0}/'.format( + get_domain_from_locale(self.locale)), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + + def get_search_url(self, method: Optional[str] = 'get') -> str: + """Get the indeed search url from SearchTerms + """ + if method == 'get': + # form job search url + search = ( + "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" + "limit={5}&filter={6}".format( + get_domain_from_locale(self.locale), + self.query, + self.search_terms.city.replace(' ', '+'), + self.search_terms.state, + self.convert_radius(self.search_terms.region.radius), + self.max_results_per_page, + int(self.search_terms.return_similar_results) + ) + ) + return search + elif method == 'post': + # TODO: implement post style for indeed.X + raise NotImplementedError() + else: + raise ValueError(f'No html method {method} exists') + + def get_link(self, job_id) -> str: + """Constructs the link with the given job_id. + Args: + job_id: The id to be used to construct the link for this job. + Returns: + The constructed job link. + Note that this function does not check the correctness of this link. + The caller is responsible for checking correcteness. + """ + return (f"http://www.indeed.{get_domain_from_locale(self.locale)}" + f"/viewjob?jk={job_id}" + ) diff --git a/jobfunnel/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py similarity index 100% rename from jobfunnel/scrapers/monster.py rename to jobfunnel/backend/scrapers/monster.py diff --git a/jobfunnel/scrapers/__init__.py b/jobfunnel/backend/tools/__init__.py similarity index 100% rename from jobfunnel/scrapers/__init__.py rename to jobfunnel/backend/tools/__init__.py diff --git a/jobfunnel/tools/delay.py b/jobfunnel/backend/tools/delay.py similarity index 100% rename from jobfunnel/tools/delay.py rename to jobfunnel/backend/tools/delay.py diff --git a/jobfunnel/tools/filters.py b/jobfunnel/backend/tools/filters.py similarity index 100% rename from jobfunnel/tools/filters.py rename to jobfunnel/backend/tools/filters.py diff --git a/jobfunnel/tools/tools.py b/jobfunnel/backend/tools/tools.py similarity index 54% rename from jobfunnel/tools/tools.py rename to jobfunnel/backend/tools/tools.py index 153182ee..30c47a1a 100644 --- a/jobfunnel/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -1,4 +1,6 @@ """Assorted tools for all aspects of funnelin'' +FIXME: most of these are not using Job correctly!!! + """ # FIXME sort these import logging @@ -16,79 +18,14 @@ from webdriver_manager.firefox import GeckoDriverManager from selenium import webdriver +from jobfunnel.backend import Job + # def get_random_user_agent() -> str: # """The user agent should be randomized per-Scraper to help with spam det. # """ FIXME... should go here maybe? -def filter_non_printables(job): - """function that filters trailing characters in scraped strings""" - # filter all of the weird characters some job postings have... - printable = set(string.printable) - job['title'] = ''.join(filter(lambda x: x in printable, job['title'])) - job['blurb'] = ''.join(filter(lambda x: x in printable, job['blurb'])) - - -def post_date_from_relative_post_age(job_list): - """function that returns the post date from the relative post age""" - # initialize list and store regex objects of date quantifiers - date_regex = [re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)'), - re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)'), - re.compile(r'(\d+)(?:[ +]{1,3})?month'), - re.compile(r'(\d+)(?:[ +]{1,3})?year'), - re.compile(r'[tT]oday|[jJ]ust [pP]osted'), - re.compile(r'[yY]esterday')] - - for job in job_list: - if not job['date']: - return job['date'] - - post_date = None - - # supports almost all formats like 7 hours|days and 7 hr|d|+d - try: - # hours old - hours_ago = date_regex[0].findall(job['date'])[0] - post_date = datetime.now() - timedelta(hours=int(hours_ago)) - except IndexError: - # days old - try: - days_ago = \ - date_regex[1].findall(job['date'])[0] - post_date = datetime.now() - timedelta(days=int(days_ago)) - except IndexError: - # months old - try: - months_ago = \ - date_regex[2].findall(job['date'])[0] - post_date = datetime.now() - relativedelta( - months=int(months_ago)) - except IndexError: - # years old - try: - years_ago = \ - date_regex[3].findall(job['date'])[0] - post_date = datetime.now() - relativedelta( - years=int(years_ago)) - except IndexError: - # try phrases like today, just posted, or yesterday - if date_regex[4].findall( - job['date']) and not post_date: - # today - post_date = datetime.now() - elif date_regex[5].findall(job['date']): - # yesterday - post_date = datetime.now() - timedelta(days=int(1)) - elif not post_date: - # must be from the 1970's - post_date = datetime(1970, 1, 1) - logging.error(f"unknown date for job {job['id']}") - # format date in standard format e.g. 2020-01-01 - job['date'] = post_date.strftime('%Y-%m-%d') - # print('job['date']'') - - def split_url(url): # capture protocol, ip address and port from given url match = re.match(r'^(http[s]?):\/\/([A-Za-z0-9.]+):([0-9]+)?(.*)$', url) diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index e69de29b..4d8af292 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -0,0 +1,5 @@ +from jobfunnel.config.base import BaseConfig +from jobfunnel.config.delay import DelayConfig +from jobfunnel.config.proxy import ProxyConfig +from jobfunnel.config.search_terms import SearchTerms +from jobfunnel.config.funnel import JobFunnelConfig diff --git a/jobfunnel/config/base.py b/jobfunnel/config/base.py new file mode 100644 index 00000000..d08c1e1b --- /dev/null +++ b/jobfunnel/config/base.py @@ -0,0 +1,15 @@ +"""Base config object with a validator +""" +from abc import ABC, abstractmethod + + +class BaseConfig(ABC): + + @abstractmethod + def __init__(self) -> None: + pass + + def validate(self) -> None: + """This should raise Exceptions if self.attribs are bad + """ + pass diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py new file mode 100644 index 00000000..46ec10a0 --- /dev/null +++ b/jobfunnel/config/delay.py @@ -0,0 +1,15 @@ +"""Simple config object to contain the delay configuration +""" +from jobfunnel.config.base import BaseConfig + + +class DelayConfig(BaseConfig): + """Simple config object to contain the delay configuration + """ + pass # FIXME: impl + + def __init__(self): + pass + + def validate(self) -> None: + pass diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py new file mode 100644 index 00000000..ef458862 --- /dev/null +++ b/jobfunnel/config/funnel.py @@ -0,0 +1,85 @@ +"""Config object to run JobFunnel +""" +from typing import Optional, List +import os + +from jobfunnel.backend.scrapers import BaseScraper +from jobfunnel.config import BaseConfig, ProxyConfig, SearchTerms, DelayConfig + + +class JobFunnelConfig(BaseConfig): + """Simple config object to contain paths and sub-configs + """ + + def __init__(self, + master_csv_file: str, + user_deny_list_file: str, + global_dely_list_file: str, + cache_folder: str, + search_terms: SearchTerms, + scrapers: List[BaseScraper], + log_file: str, + log_level: Optional[int] = 0, + no_scrape: Optional[bool] = False, + delay_config: Optional[DelayConfig] = None, + proxy_config: Optional[ProxyConfig] = None) -> None: + """Init a config that determines how we will scrape jobs from Scrapers + and how we will update CSV and filtering lists + + Args: + master_csv_file (str): path to the .csv file that user interacts w/ + user_deny_list_file (str): path to a JSON that contains jobs user + has decided to omit from their .csv file (i.e. archive status) + global_dely_list_file (str): path to a JSON containing companies + that the user wants to never see in their .csv file + cache_folder (str): folder where all scrape data will be stored + search_terms (SearchTerms): SearchTerms config which contains the + desired job search information (i.e. keywords) + scrapers (List[BaseScraper]): List of scrapers we will scrape from + log_file (str): file to log all logger calls to + log_level (int): level to log at, use 20 for debugging + no_scrape (Optional[bool], optional): If True, will not scrape data + at all, instead will only update filters and CSV. Defaults to + False. + delay_config (Optional[DelayConfig], optional): delay config object. + Defaults to a default delay config object. + proxy_config (Optional[ProxyConfig], optional): proxy config object. + Defaults to None, which will result in no proxy being used + """ + self.master_csv_file = master_csv_file + self.user_deny_list_file = user_deny_list_file + self.global_dely_list_file = global_dely_list_file + self.cache_folder = cache_folder + self.search_terms = search_terms + self.scrapers = scrapers + self.log_file = log_file + self.log_level = log_level + self.no_scrape = no_scrape + if not delay_config: + self.delay_config = DelayConfig() + else: + self.delay_config = delay_config + self.proxy_config = proxy_config + + @property + def scraper_names(self) -> str: + """User-readable names of the scrapers we will be running + """ + return [s.__name__ for s in self.scrapers] + + def create_dirs(self) -> None: + """Create any missing dirs + """ + if not os.path.exists(self.cache_folder): # TODO: put this in tmpdir? + os.makedirs(self.cache_folder) + + def validate(self) -> None: + """Validate the config object i.e. paths exit + NOTE: will raise exceptions if issues are encountered. + FIXME: impl. more validation here + """ + assert os.path.exists(self.cache_folder) + self.search_terms.validate() + if self.proxy_config: + self.proxy_config.validate() + self.delay_config.validate() diff --git a/jobfunnel/config/parser.py b/jobfunnel/config/parser.py index 2acfa0d1..8d7e3008 100644 --- a/jobfunnel/config/parser.py +++ b/jobfunnel/config/parser.py @@ -22,7 +22,7 @@ def __init__(self, arg): def parse_cli(): """ Parse the command line arguments. - + FIXME: way too """ parser = argparse.ArgumentParser( 'CLI options take precedence over settings in the yaml file' diff --git a/jobfunnel/config/proxy.py b/jobfunnel/config/proxy.py new file mode 100644 index 00000000..800cde2d --- /dev/null +++ b/jobfunnel/config/proxy.py @@ -0,0 +1,29 @@ +"""Proxy configuration for Session() +""" +from jobfunnel.config import BaseConfig + + +class ProxyConfig(BaseConfig): + """Simple config object to contain proxy configuration + """ + def __init__(self, protocol: str, ip_address: str, port: int) -> None: + self.protocol = protocol + self.ip_address = ip_address + self.port = port + + @property + def url(self) -> str: + """Get the url string for use in a Session.proxies object + """ + url_str = '' + if self.protocol != '': + url_str += self.protocol + '://' + if self.ip_address != '': + url_str += self.ip_address + if self.port != '': + url_str += ':' + self.port + return url_str # FIXME: this could be done in one line + + def validate(self) -> None: + """TODO: validate ip addr is valid format etc""" + pass diff --git a/jobfunnel/search_terms.py b/jobfunnel/config/search_terms.py similarity index 80% rename from jobfunnel/search_terms.py rename to jobfunnel/config/search_terms.py index 9bc8722e..4ff4c85d 100644 --- a/jobfunnel/search_terms.py +++ b/jobfunnel/config/search_terms.py @@ -1,15 +1,16 @@ """Object to contain job query metadata """ from typing import List, Optional -from jobfunnel.localization import Locale +from jobfunnel.backend.localization import Locale +from jobfunnel.config import BaseConfig DEFAULT_SEARCH_RADIUS_KM = 25 DEFAULT_MAX_LISTING_DAYS = 10 -class SearchTerms(object): - """object to contain region of interest for a Locale +class SearchTerms(BaseConfig): + """Config object to contain region of interest for a Locale NOTE: ideally we'd have one of these per-locale, per-website, but then the config would be a nightmare, so we'll just put everything in here @@ -21,14 +22,14 @@ class SearchTerms(object): def __init__(self, keywords: List[str], - provience: Optional[str] = None, + province: Optional[str] = None, state: Optional[str] = None, city: Optional[str] = None, distance_radius_km: Optional[int] = DEFAULT_SEARCH_RADIUS_KM, return_similar_results: Optional[bool] = False, max_listing_days: Optional[int] = DEFAULT_MAX_LISTING_DAYS): """init TODO: document""" - self.provience = provience + self.province = province self.state = state self.city = city self.radius = distance_radius_km @@ -43,6 +44,6 @@ def is_valid(self, locale: Locale) -> bool: if not self.keywords: return False if locale in [Locale.CANADA_ENGLISH, Locale.CANADA_FRENCH]: - return self.provience and not self.state + return self.province and not self.state elif locale == Locale.USA_ENGLISH: - return not self.provience and self.state + return not self.province and self.state diff --git a/jobfunnel/job.py b/jobfunnel/job.py deleted file mode 100644 index 91791bcf..00000000 --- a/jobfunnel/job.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Base Job class to be populated by Scrapers, manipulated by Filters and saved -to csv / etc by Exporter -""" -from datetime import date -from typing import Any, Optional, List -from jobfunnel.localization import Locale - - -class Job(): - """The base Job object which contains job information as attribs - """ - def __init__(self, - title: str, - company: str, - location: str, - scrape_date: date, - description: str, - key_id: str, - url: str, - locale: Locale, - post_date: Optional[date] = None, - raw: Optional[Any] = None, - tags: Optional[List[str]] = None) -> None: - """[summary] - - TODO: would be nice to use something standardized for location - TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? - - Args: - title (str): title of the job (should be somewhat short) - company (str): company the job was posted for (should also be short) - location (str): string that tells the user where the job is located - short_description (str): user-readable short description (one-liner) - long_description (str): complete description, may be many lines. - key_id (str): unique identifier for the job TODO: make more robust? - url (str): link to the page where the job exists - locale (Locale): identifier to help us with internationalization, - tells us what language and host-locale/domain a source is in. - raw (Optional[Any]): raw scrape data that we can use for - debugging/pickling, defualts to None. - post_date (Optional[date]): the date the job became available on the - job source. Defaults to None. - tags (Optional[List[str]], optional): additional key-words that are - in the job posting that identify the job. Defaults to []. - """ - # These must be populated by a Scraper - self.title = title - self.company = company - self.location = location - self.scrape_date = scrape_date - self.key_id = key_id - self.url = url - self.locale = locale - - # These may not always be populated in our job source - self.post_date = post_date - self.tags = tags if tags else [] - - # Semi-private attrib for debugging - self._raw_scrape_data = raw - - def is_valid(self) -> bool: - """TODO: implement this just to ensure that the metadata is good""" - pass diff --git a/jobfunnel/jobfunnel.py b/jobfunnel/jobfunnel.py deleted file mode 100755 index 36ffd7bb..00000000 --- a/jobfunnel/jobfunnel.py +++ /dev/null @@ -1,76 +0,0 @@ -# Paul McInnis 2018 -# writes pickles to master list path and applies search filters - -import csv -import json -import logging -import os -import pickle -import random -import re -import sys - -from collections import OrderedDict -from concurrent.futures import as_completed -from datetime import date -from time import time -from typing import Dict, List -from requests import Session - -from .tools.delay import delay_alg -from .tools.filters import tfidf_filter, id_filter, date_filter -from .tools.tools import proxy_dict_to_url - -# setting job status to these words removes them from masterlist + adds to -# blacklist -REMOVE_STATUSES = ['archive', 'archived', 'remove', 'rejected'] - -# csv header -MASTERLIST_HEADER = ['status', 'title', 'company', 'location', 'date', - 'blurb', 'tags', 'link', 'id', 'provider', 'query'] - -# user agent list -USER_AGENT_LIST = os.path.normpath( - os.path.join(os.path.dirname(__file__), 'text/user_agent_list.txt')) - - -class JobFunnel(object): - """Class that initializes a Scraper and scrapes a website to get jobs - """ - - def __init__(self, config: JobFunnelConfig): # FIXME: implement this - # Paths - self.master_file = config.master_file - self.user_deny_list_file = config.user_deny_list_file - self.global_deny_list_file = config.global_deny_list_file - self.cache_folder = config.cache_folder - self.log_file = config.log_file - - self.log_level = config.log_level - self.date_string = date.today().strftime("%Y-%m-%d") - - # Set delay settings if they exist - self.delay_config = None - if config.delay_config is not None: - self.delay_config = config.delay_config - - # Open a session with/out a proxy configured - self.session = Session() - - # set proxy if given FIXME - # if config.proxy is not None: - # self.s.proxies = { - # config.proxy.protocol: proxy_dict_to_url(config.proxy) - # } - - # # create data dir FIXME - # if not os.path.exists(args['data_path']): - # os.makedirs(args['data_path']) - - def init_logging(self): - """Initialize a logger""" - pass - - def scrape(self): - """Scrape jobs""" - pass diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py new file mode 100644 index 00000000..884a54ec --- /dev/null +++ b/jobfunnel/resources/resources.py @@ -0,0 +1,6 @@ +# NOTE: Setting job's status to these moves the job from masterlist -> deny list +REMOVE_STATUSES = ['archive', 'archived', 'remove', 'rejected'] +CSV_HEADER = [ + 'status', 'title', 'company', 'location', 'date', 'blurb', 'tags', 'link', + 'id', 'provider', 'query', 'locale' +] # TODO: need to add short and long descriptions (breaking change) diff --git a/jobfunnel/text/user_agent_list.txt b/jobfunnel/resources/user_agent_list.txt similarity index 100% rename from jobfunnel/text/user_agent_list.txt rename to jobfunnel/resources/user_agent_list.txt diff --git a/jobfunnel/scrapers/indeed.py b/jobfunnel/scrapers/indeed.py deleted file mode 100644 index f8627d8a..00000000 --- a/jobfunnel/scrapers/indeed.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Scraper designed to get jobs from www.indeed.com / www.indeed.ca -""" -from abc import ABC, abstractmethod -import datetime -from typing import Dict, List -from requests import Session - -from jobfunnel.job import Job -from jobfunnel.localization import Locale, get_domain_from_locale -from jobfunnel.search_terms import SearchTerms -from jobfunnel.scrapers.base import Scraper - - -class BaseIndeedScraper(Scraper): - """Scrapes jobs from www.indeed.X - """ - def __init__(self, session: Session, search_terms: SearchTerms) -> None: - """Init that contains indeed specific stuff - """ - self.max_results_per_page = 50 - self.search_terms = search_terms - self.query = '+'.join(self.search_terms.keywords) - - def scrape(self) -> List[Job]: - """Scrapes raw data from a job source into a list of Job objects - - Returns: - List[Job]: list of jobs scraped from the job source - """ - return [ # FIXME: testing... - Job( - title="Beef Collector", - company="Orwell Farms", - location="Middle Earth, Canada", - scrape_date=datetime.datetime.now(), - description="Collect beef, earn sand dollars", - key_id="d3adb33f", - url="www.indeed.ca/test-job1", - locale=Locale.CANADA_ENGLISH, - post_date=datetime.datetime.now(), - raw="HTML:someran/domdatahereHeept;atag:s2311", - tags=['beef', 'collector', 'apply', 'now'], - ), - Job( - title="Chickun Inhibitor", - company="Roswell Park", - location="Middle South, Canada", - scrape_date=datetime.datetime.now(), - description="Collect Chickun, earn sand dollars", - key_id="d4adb44f", - url="www.indeed.ca/test-job2", - locale=Locale.CANADA_ENGLISH, - post_date=datetime.datetime.now(), - raw="HTML:somera3n/domdatahereHeept;atag:s231131", - tags=['chickun', 'collector', 'apply', 'now'], - ) - ] - - def filter_jobs(self, jobs: List[Job]) -> List[Job]: - """Descriminate each Job in jobs using filters - - TODO: use self.filters: List[Filter] - - Args: - jobs (List[job]): input jobs - - Returns: - List[Job]: output jobs - """ - return jobs # FIXME: testing... - - -class IndeedScraperCA(BaseIndeedScraper): - """Scrapes jobs from www.indeed.ca - """ - @property - def locale(self) -> Locale: - return Locale.CANADA_ENGLISH - - @property - def headers(self) -> Dict[str, str]: - """Session header for Indeed - """ - return { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', # FIXME - 'referer': 'https://www.indeed.{0}/'.format( - get_domain_from_locale(self.locale)), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - } - - -class IndeedScraperUSA(BaseIndeedScraper): - """Scrapes jobs from www.indeed.com - """ - @property - def locale(self) -> Locale: - return Locale.USA_ENGLISH - - @property - def headers(self) -> Dict[str, str]: - """Session header for Indeed - """ - return { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', # FIXME - 'referer': 'https://www.indeed.{0}/'.format( - get_domain_from_locale(self.locale)), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - } diff --git a/jobfunnel/tools/__init__.py b/jobfunnel/tools/__init__.py deleted file mode 100644 index e69de29b..00000000 From d60d780a44c3a2eed69de615cf834342b28e4714 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 3 Aug 2020 19:23:09 -0400 Subject: [PATCH 03/66] got delaying with DelayConfig and TFIDF + other filters working again --- jobfunnel/__main__.py | 19 +- jobfunnel/backend/__init__.py | 1 + jobfunnel/backend/job.py | 102 ++----- jobfunnel/backend/jobfunnel.py | 270 ++++++++++++------ jobfunnel/backend/localization.py | 7 +- jobfunnel/backend/scrapers/base.py | 18 +- jobfunnel/backend/scrapers/indeed.py | 160 ++++++++--- jobfunnel/backend/tools/delay.py | 135 +++++---- jobfunnel/backend/tools/filters.py | 80 ++---- jobfunnel/backend/tools/tools.py | 105 +------ jobfunnel/config/__init__.py | 2 +- jobfunnel/config/{parser.py => cli_parser.py} | 141 +-------- jobfunnel/config/delay.py | 23 +- jobfunnel/config/funnel.py | 28 +- .../config/{search_terms.py => search.py} | 4 +- jobfunnel/resources/resources.py | 1 - 16 files changed, 525 insertions(+), 571 deletions(-) rename jobfunnel/config/{parser.py => cli_parser.py} (56%) rename jobfunnel/config/{search_terms.py => search.py} (96%) diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 7cc8d7e4..4617f027 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -2,24 +2,37 @@ """Builds a config from CLI, runs desired scrapers and updates JSON + CSV NOTE: you can test this from cloned source by running python -m jobfunnel + +TODO/FIXME: + * make it easier to continue an existing search + * make it easier to run multiple searches at once w.r.t caching + * simplified CLI args with new --recover and --clean options + * impl Cereberus for YAML validation + * add warning around seperate cache folders blocklists per search + * document API usage in readme + ** add back the duplicates JSON """ import sys from typing import Union +import logging from .backend.jobfunnel import JobFunnel -from .config import JobFunnelConfig, SearchTerms +from .config import JobFunnelConfig, SearchConfig from .backend.scrapers import IndeedScraperCAEng def main(): """Parse CLI and call jobfunnel() to manage scrapers and lists """ + # TODO: need to warn user to use seperate cache folder and + # block list per search # Init TODO: parse CLI to do this. - search_terms = SearchTerms(['Python', 'Scientist'], 'ON', None, 'waterloo', 25) + search_terms = SearchConfig(['Python', 'Scientist'], 'ON', None, 'waterloo', 25) config = JobFunnelConfig( 't_m.csv', 't_udnl.json', 't_gdnl.json', './t_cache', - search_terms, [IndeedScraperCAEng], 't_log.log' + search_terms, [IndeedScraperCAEng], 't_log.log', + log_level=logging.INFO, ) JobFunnel(config).run() diff --git a/jobfunnel/backend/__init__.py b/jobfunnel/backend/__init__.py index b427bfe5..90a2ec1b 100644 --- a/jobfunnel/backend/__init__.py +++ b/jobfunnel/backend/__init__.py @@ -1,2 +1,3 @@ # from jobfunnel.backend.jobfunnel import JobFunnel FIXME: causes circular imp. from jobfunnel.backend.job import Job, JobStatus +from jobfunnel.backend.localization import Locale diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 2962b365..aabe368b 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -1,8 +1,7 @@ """Base Job class to be populated by Scrapers, manipulated by Filters and saved to csv / etc by Exporter """ -from datetime import date, datetime, timedelta -from dateutil.relativedelta import relativedelta +from datetime import date, datetime from enum import Enum import re import string @@ -14,24 +13,21 @@ PRINTABLE_STRINGS = set(string.printable) -# Initialize list and store regex objects of date quantifiers TODO: refactor -HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') -DAY_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)') -MONTH_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?month') -YEAR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?year') -RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') -RECENT_REGEX_B = re.compile(r'[yY]esterday') - - class JobStatus(Enum): """Job statuses that are built-into jobfunnel """ - NEW = 1 - ARCHIVE = 2 - INTERVIEWING = 3 - INTERVIEWED = 4 - REJECTED = 5 - ACCEPTED = 6 + UNKNOWN = 1 + NEW = 2 + ARCHIVE = 3 + INTERVIEWING = 4 + INTERVIEWED = 5 + REJECTED = 6 + ACCEPTED = 7 + DELETE = 8 + INTERESTED = 9 + + +REMOVE_STATUSES = [JobStatus.DELETE, JobStatus.ARCHIVE, JobStatus.REJECTED] class Job(): @@ -106,10 +102,17 @@ def __init__(self, # Semi-private attrib for debugging self._raw_scrape_data = raw - def get_csv_row(self) -> Dict[str, str]: - """Builds a CSV row for this job entry + @property + def is_remove_status(self) -> bool: + """Return True if the job's status is one of our removal statuses. + """ + return self.status in REMOVE_STATUSES + + @property + def as_row(self) -> Dict[str, str]: + """Builds a CSV row dict for this job entry - TODO: this is legacy, no support for short_description/raw rn. + TODO: this is legacy, no support for short_description/raw yet. """ return dict([ (h, v) for h,v in zip( @@ -119,7 +122,7 @@ def get_csv_row(self) -> Dict[str, str]: self.title, self.company, self.location, - self.post_date, + self.post_date.strftime('%Y-%m-%d'), self.description, ', '.join(self.tags), self.url, @@ -133,57 +136,14 @@ def get_csv_row(self) -> Dict[str, str]: def clean_strings(self) -> None: """Ensure that all string fields have only printable chars - TODO: do this automatically upon assignment (override assignment) + FIXME: do this automatically upon assignment (override assignment) + ...This way of doing it is janky and might not work right... """ - for attr in vars(self): - if type(attr) == str: - self.attr = ''.join( - filter(lambda x: x in PRINTABLE_STRINGS, self.title) - ) - - def set_post_date_from_relative_date(self) -> None: - """Identifies a job's post date via post age, updates in-place - """ - post_date = None - # Supports almost all formats like 7 hours|days and 7 hr|d|+d - try: - # hours old - hours_ago = HOUR_REGEX.findall(self.post_date)[0] - post_date = datetime.now() - timedelta(hours=int(hours_ago)) - except IndexError: - # days old - try: - days_ago = DAY_REGEX.findall(self.post_date)[0] - post_date = datetime.now() - timedelta(days=int(days_ago)) - except IndexError: - # months old - try: - months_ago = MONTH_REGEX.findall(self.post_date)[0] - post_date = datetime.now() - relativedelta( - months=int(months_ago)) - except IndexError: - # years old - try: - years_ago = YEAR_REGEX.findall(self.post_date)[0] - post_date = datetime.now() - relativedelta( - years=int(years_ago)) - except IndexError: - # try phrases like today, just posted, or yesterday - if (RECENT_REGEX_A.findall(self.post_date) and - not post_date): - # today - post_date = datetime.now() - elif RECENT_REGEX_B.findall(self.post_date): - # yesterday - post_date = datetime.now() - timedelta(days=int(1)) - elif not post_date: - # we have failed. - raise ValueError( - f"Unable to calculate date for {self.title}" - ) - - # Format date in standard format e.g. 2020-01-01 - self.post_date = post_date.strftime('%Y-%m-%d') + for attr in [self.title, self.company, self.description, self.tags, + self.url, self.key_id, self.provider, self.query]: + attr = ''.join( + filter(lambda x: x in PRINTABLE_STRINGS, self.title) + ) def validate(self) -> None: """TODO: implement this just to ensure that the metadata is good""" diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index cd210952..e66866a5 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -1,7 +1,6 @@ """Paul McInnis 2018 Scrapes jobs, applies search filters and writes pickles to master list """ - import csv from collections import OrderedDict from datetime import date, datetime @@ -15,8 +14,9 @@ from time import time from jobfunnel.config import JobFunnelConfig -from jobfunnel.backend import Job -from jobfunnel.resources.resources import CSV_HEADER, REMOVE_STATUSES +from jobfunnel.backend import Job, JobStatus, Locale +from jobfunnel.resources.resources import CSV_HEADER +from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter class JobFunnel(object): @@ -32,8 +32,8 @@ def __init__(self, config: JobFunnelConfig): self.config = config self.config.create_dirs() self.config.validate() - self.date_string = date.today().strftime("%Y-%m-%d") self.logger = None + self.__date_string = date.today().strftime("%Y-%m-%d") self.init_logging() # Open a session with/out a proxy configured @@ -43,40 +43,57 @@ def __init__(self, config: JobFunnelConfig): self.config.proxy_config.protocol: self.config.proxy_config.url } + @property + def daily_cache_file(self) -> str: + """The name for for pickle file containing the scraped data ran today + """ + return os.path.join( + self.config.cache_folder, f"jobs_{self.__date_string}.pkl", + ) + def run(self) -> None: """Scrape, update lists and save to CSV. + NOTE: we are assuming the user has distinct cache folder per-search, + otherwise we will load the cache for today, for a different search! """ - # Parse the master list path to update filter list - self.update_user_deny_list() + # Parse the master list path to update our block list + # NOTE: we want to do this first to ensure scraping is efficient when + # we are getting detailed job information (per-job) + self.update_block_list() - # Get new jobs keyed by their unique ID - jobs_dict = self.scrape() # type: Dict[str, Job] + # Get jobs keyed by their unique ID, use cache if we scraped today + if self.config.no_scrape: + jobs_dict = self.load_cache(self.daily_cache_file) + else: + if os.path.exists(self.daily_cache_file): + jobs_dict = self.load_cache(self.daily_cache_file) + else: + jobs_dict = self.scrape() # type: Dict[str, Job] + self.write_cache(jobs_dict) - # Filter out scraped jobs we have rejected, archived or blacklisted + # Filter out scraped jobs we have rejected, archived or block-listed # (before we add them to the CSV) - self.filter_excluded_jobs(jobs_dict) + self.filter(jobs_dict) # Load and update existing masterlist if os.path.exists(self.config.master_csv_file): - # open masterlist if it exists & init updated masterlist + + # Identify duplicate jobs using the existing masterlist masterlist = self.read_master_csv() # type: Dict[str, Job] + self.filter(masterlist) # NOTE: reduces size of masterlist + # FIXME: this doesn't handle empty descriptions or masterlist well + tfidf_filter(jobs_dict, masterlist) - # update masterlist to remove filtered/blacklisted jobs - self.filter_excluded_jobs(jobs_dict) - # n_filtered += tfidf_filter(jobs_dict, masterlist) # FIXME + # Expand the masterlist with filteres, non-duplicated jobs & save masterlist.update(jobs_dict) - - # save - self.write_master_csv(jobs_dict) + self.write_master_csv(masterlist) else: - # run tfidf filter on initial scrape - # n_filtered += tfidf_filter(jobs_dict, masterlist) # FIXME - - # dump the results into the data folder as the masterlist + # FIXME: we should still remove duplicates (TFIDF) within jobs_dict + # Dump the results into the data folder as the masterlist self.write_master_csv(jobs_dict) self.logger.info( - f'no masterlist detected, added {len(jobs_dict.keys())}' + f'No masterlist detected, added {len(jobs_dict.keys())}' f' jobs to {self.config.master_csv_file}' ) @@ -98,7 +115,7 @@ def init_logging(self) -> None: logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) else: logging.getLogger().addHandler(logging.StreamHandler()) - self.logger.info(f"jobfunnel initialized at {self.date_string}") + self.logger.info(f"JobFunnel initialized at {self.__date_string}") def scrape(self) ->Dict[str, Job]: """Run each of the desired Scraper.scrape() with threading and delaying @@ -111,12 +128,10 @@ def scrape(self) ->Dict[str, Job]: # Iterate thru scrapers and run their scrape. jobs = {} # type: Dict[str, Job] for scraper_cls in self.config.scrapers: - # FIXME: need the threader and delay here + # FIXME: need to add the threader and delaying here start = time() - scraper = scraper_cls( - self.session, self.config.search_terms, self.logger - ) - # TODO: warning for overwriting different jobs with same key + scraper = scraper_cls(self.session, self.config, self.logger) + # TODO: add a warning for overwriting different jobs with same key jobs.update(scraper.scrape()) end = time() self.logger.info( @@ -129,41 +144,45 @@ def scrape(self) ->Dict[str, Job]: def recover(self): """Build a new master CSV from all the available pickles in our cache + NOTE: maybe we can warn user that this will throw away their current + masterlist, since we are assuming it's corrupted somehow """ - # FIXME: impl. should read all the pickles and make a new masterlist - pass - - @property - def daily_pickle_file(self) -> str: - """The name for for pickle file containing the scraped data ran today - """ - return os.path.join( - self.config.data_path, f"jobs_{self.date_string}.pkl", - ) + self.logger.info("Recovering jobs from all cache files in cache folder") + if os.path.exists(self.config.user_block_list_file): + self.logger.warning( + "Running recovery mode, but with existing block-list, delete " + f"{self.config.user_block_list_file} if you want to start fresh" + " from the cached data and not filter any jobs away." + ) + all_jobs_dict = {} + for file in os.listdir(self.config.cache_folder): + if '.pkl' in file: + all_jobs_dict.update(self.load_cache(file)) + self.write_master_csv(all_jobs_dict) - def load_pickle(self) -> Dict[str, Job]: + def load_cache(self, cache_file: str) -> Dict[str, Job]: """Load today's scrape data from pickle via date string """ try: - jobs_dict = pickle.load(open(self.daily_pickle_file, 'rb')) + jobs_dict = pickle.load(open(cache_file, 'rb')) except FileNotFoundError as e: self.logger.error( - f"{self.daily_pickle_file} not found! Have you scraped any jobs" - " today?" + f"{cache_file} not found! Have you scraped any jobs today?" ) raise e self.logger.info( - f"Loaded {len(jobs_dict.keys())} jobs from {self.daily_pickle_file}" + f"Read {len(jobs_dict.keys())} jobs from {cache_file}" ) return jobs_dict - def dump_pickle(self, jobs_dict: Dict[str, Job]) -> None: - """Dump a pickle of the daily scrape dict + def write_cache(self, jobs_dict: Dict[str, Job], + cache_file: str = None) -> None: + """Dump a jobs_dict into a pickle """ - pickle.dump(jobs_dict, open(self.daily_pickle_file, 'wb')) - n_jobs = 2 # FIXME + cache_file = cache_file if cache_file else self.daily_cache_file + pickle.dump(jobs_dict, open(cache_file, 'wb')) self.logger.info( - f"Dumped {n_jobs} jobs to {self.daily_pickle_file}" + f"Dumped {len(jobs_dict.keys())} jobs to {cache_file}" ) def read_master_csv(self) -> Dict[str, Job]: @@ -178,25 +197,56 @@ def read_master_csv(self) -> Dict[str, Job]: Returns: Dict[str, Job]: unique Job objects in the CSV """ + jobs_dict = {} # type: Dict[str, Job] with open(self.config.master_csv_file, 'r', encoding='utf8', errors='ignore') as csvfile: - - jobs_dict = {} # type: Dict[str, Job] for row in csv.DictReader(csvfile): - # NOTE: this is for legacy support: - locale = row['locale'] if 'locale' in row else '' + # NOTE: we are doing legacy support here with 'blurb' etc. if 'description' in row: short_description = row['description'] else: short_description = '' + post_date = datetime.strptime(row['date'], '%Y-%m-%d') if 'scrape_date' in row: - scrape_date = datetime.fromisoformat(row['scrape_date']) + scrape_date = datetime.strptime( + row['scrape_date'], '%Y-%m-%d' + ) else: - scrape_date = datetime(1970, 1, 1) + scrape_date = post_date if 'raw' in row: raw = row['raw'] else: raw = None + + # We need to convert from user statuses + # TODO: put this in Job? + status = None + if 'status' in row: + status_str = row['status'].strip() + for p_status in JobStatus: + if status_str.lower() == p_status.name.lower(): + status = p_status + break + if not status: + self.logger.warning( + f"Unknown status {status_str}, setting to UNKNOWN" + ) + status = JobStatus.UNKNOWN + + # NOTE: this is for legacy support: + locale = None + if 'locale' in row: + locale_str = row['locale'].strip() + for p_locale in Locale: + if locale_str.lower() == p_locale.name.lower(): + locale = p_locale + break + if not locale: + self.logger.warning( + f"Unknown locale {locale_str}, setting to UNKNOWN" + ) + locale = locale.UNKNOWN + job = Job( title=row['title'], company=row['company'], @@ -206,10 +256,10 @@ def read_master_csv(self) -> Dict[str, Job]: url=row['link'], locale=locale, query=row['query'], - status=row['status'], + status=status, provider=row['provider'], short_description=short_description, - post_date=row['date'], + post_date=post_date, scrape_date=scrape_date, raw=raw, tags=row['tags'].split(','), @@ -218,7 +268,7 @@ def read_master_csv(self) -> Dict[str, Job]: jobs_dict[job.key_id] = job self.logger.info( - f"Read out {len(jobs_dict.keys())} jobs from " + f"Read {len(jobs_dict.keys())} jobs from master-CSV: " f"{self.config.master_csv_file}" ) return jobs_dict @@ -234,40 +284,98 @@ def write_master_csv(self, jobs: Dict[str, Job]) -> None: writer.writeheader() for job in jobs.values(): job.validate() - writer.writerow(job.get_csv_row()) + writer.writerow(job.as_row) n_jobs = len(jobs) self.logger.info( f"Wrote out {n_jobs} jobs to {self.config.master_csv_file}" ) - def update_user_deny_list(self): - """Read the master CSV file and pop jobs by status into our user deny - list (which is a JSON) + def update_block_list(self): + """Read the master CSV file and pop jobs by status into our user block + list (which is a JSON). + + NOTE: adding jobs to block list will result in filter() removing them + from all scraped & cached jobs in the future. """ - # FIXME: impl. - self.logger.info(f"Updated {self.config.user_deny_list_file}") + if os.path.isfile(self.config.master_csv_file): + + # Load existing filtered jobs, if any + if os.path.isfile(self.config.user_block_list_file): + blocked_jobs_dict = json.load( + open(self.config.user_block_list_file, 'r') + ) + else: + blocked_jobs_dict = {} + + # Add jobs from csv that need to be filtered away, if any + n_jobs_added = 0 + for job in self.read_master_csv().values(): + if job.is_remove_status and job.key_id not in blocked_jobs_dict: + n_jobs_added += 1 + logging.info( + f'Added {job.key_id} to ' + f'{self.config.user_block_list_file}' + ) + blocked_jobs_dict[job.key_id] = { + 'title': job.title, + 'post_date': job.post_date.strftime('%Y-%m-%d'), + 'description': job.description, + 'status': job.status, + } + + # Write out complete list with any additions from the masterlist + # NOTE: we use indent=4 so that it stays human-readable. + with open(self.config.user_block_list_file, 'w', + encoding='utf8') as outfile: + outfile.write( + json.dumps( + blocked_jobs_dict, + indent=4, + sort_keys=True, + separators=(',', ': '), + ensure_ascii=False, + ) + ) + self.logger.info( + f"Added {n_jobs_added} jobs to block-list: " + f"{self.config.user_block_list_file}" + ) + else: + logging.info( + "No master-CSV present, did not update block-list: " + f"{self.config.user_block_list_file}" + ) - def filter_excluded_jobs(self, jobs_dict: Dict[str, Job]) -> int: - """Load the user's deny-list if it exists and pop any matching jobs by - key + def filter(self, jobs_dict: Dict[str, Job]) -> int: + """Remove jobs from jobs_dict if they are: + 1. in our block-list + 2. status == DELETE, Returns the number of filtered jobs NOTE: modifies in-place - FIXME: load the company deny-list as well + TODO: would be cool if we could run TFIDF in here too + FIXME: load the global block-list as well """ - n_filtered = 0 - if os.path.isfile(self.config.user_deny_list_file): - deny_dict = json.load( - open(self.config.user_deny_list_file, 'r') - ) - for jobid in deny_dict: - if jobid in jobs_dict: - jobs_dict.pop(jobid) - n_filtered += 1 - self.logger.info( - f'removed {n_filtered} jobs present in filter-list' + if os.path.isfile(self.config.user_block_list_file): + block_dict = json.load( + open(self.config.user_block_list_file, 'r') ) else: - self.logger.warning( - f'No jobs filtered, missing: {self.config.user_deny_list_file}' - ) + block_dict = {} + + filter_jobs_ids = [] + for key_id, job in jobs_dict.items(): + if (key_id in block_dict + or job_is_old(job, self.config.search_terms.max_listing_days) + or job.is_remove_status): + filter_jobs_ids.append(key_id) + + for key_id in filter_jobs_ids: + jobs_dict.pop(key_id) + + n_filtered = len(filter_jobs_ids) + if n_filtered > 0: + self.logger.info(f'Filtered-out {n_filtered} jobs from results.') + else: + self.logger.info(f'No jobs filtered.') + return n_filtered diff --git a/jobfunnel/backend/localization.py b/jobfunnel/backend/localization.py index d242db49..f1af8d84 100644 --- a/jobfunnel/backend/localization.py +++ b/jobfunnel/backend/localization.py @@ -10,9 +10,10 @@ class Locale(Enum): TODO: better way using the locale module? """ - CANADA_ENGLISH = 1 - CANADA_FRENCH = 2 - USA_ENGLISH = 3 + UNKNOWN = 1 + CANADA_ENGLISH = 2 + CANADA_FRENCH = 3 + USA_ENGLISH = 4 def get_domain_from_locale(locale: Locale) -> str: diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 43aa2208..23a78e2b 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -10,7 +10,7 @@ from jobfunnel import USER_AGENT_LIST from jobfunnel.backend import Job from jobfunnel.backend.localization import Locale -from jobfunnel.config import SearchTerms +#from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue class BaseScraper(ABC): @@ -21,7 +21,7 @@ class BaseScraper(ABC): """ @abstractmethod - def __init__(self, session: Session, search_terms: SearchTerms, + def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: # TODO: can we set self.session etc so inherited classes don't have to? pass @@ -63,17 +63,3 @@ def scrape(self) -> Dict[str, Job]: List[Job]: list of jobs scraped from the job source """ pass - - # TODO: we need to filter jobs here. - def filter_jobs(self, jobs: List[Job]) -> List[Job]: - """Descriminate each Job in jobs using filters - - TODO: use self.filters: List[Filter] - - Args: - jobs (List[job]): input jobs - - Returns: - List[Job]: output jobs - """ - pass diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index c1e5116d..61c61a7e 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -2,7 +2,8 @@ """ from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, wait -import datetime +from datetime import date, datetime, timedelta +from dateutil.relativedelta import relativedelta import logging from math import ceil from time import sleep, time @@ -15,21 +16,31 @@ from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.localization import Locale, get_domain_from_locale from jobfunnel.backend.scrapers import BaseScraper -from jobfunnel.config import SearchTerms +from jobfunnel.backend.tools.delay import calculate_delays, delay_threader +#from jobfunnel.config import JobFunnelConfig + + +# Initialize list and store regex objects of date quantifiers TODO: refactor +HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') +DAY_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)') +MONTH_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?month') +YEAR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?year') +RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') +RECENT_REGEX_B = re.compile(r'[yY]esterday') class BaseIndeedScraper(BaseScraper): """Scrapes jobs from www.indeed.X """ - def __init__(self, session: Session, search_terms: SearchTerms, + def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: """Init that contains indeed specific stuff """ self.session = session - self.search_terms = search_terms + self.config = config self.logger = logger self.max_results_per_page = 50 - self.query = '+'.join(self.search_terms.keywords) + self.query = '+'.join(self.config.search_terms.keywords) def scrape(self) -> Dict[str, Job]: """Scrapes raw data from a job source into a list of Job objects @@ -96,51 +107,116 @@ def scrape(self) -> Dict[str, Job]: self.logger.warning(f"Unable to scrape job tags for {key_id}") try: - post_date = self.get_date(s) - except AttributeError: - self.logger.warning( - f"Unable to scrape job post date for {key_id}" + date_string = self.get_date_str(s) + post_date = self.calc_post_date_from_relative_str( + date_string ) + except (AttributeError, ValueError): + self.logger.error( + f"Unknown date for job {key_id}, setting to datetime.now()." + ) + post_date = datetime.now() + + # FIXME: impl. + # try: + # self.set_short_description(job, s) + # except AttributeError: + # self.logger.warning("Unable to scrape job short description.") - # Init a new job + # Init a new job from scraped data job = Job( title=title, company=company, location=location, - description='', # We will populate this later + description='', # We will populate this later per-job-page key_id=key_id, url=url, locale=self.locale, query=self.query, status=status, - provider='indeed', # FIXME: should inherit this? + provider='indeed', # FIXME: we should inherit this short_description=short_description, post_date=post_date, - raw=s, + raw='', # FIXME: we cannot pickle the soup object (s) tags=tags, ) - # FIXME: This doesn't work, and adding it would break existing csvs - # try: - # self.set_short_description(job, s) - # except AttributeError: - # self.logger.warning("Unable to scrape job short description.") - - # Fix the date to not be relative - try: - job.set_post_date_from_relative_date() - except ValueError: - self.logger.error( - f"Unknown date for job {key_id}, setting to epoch date." - ) - job.post_date = datetime.datetime(1970, 1, 1) - # Key by id to prevent duplicate key_ids TODO: add a warning jobs_dict[job.key_id] = job - # FIXME: get the long descriptions + # Get the detailed description with delayed scraping + if jobs_dict: + jobs_list = list(jobs_dict.values()) + delays = calculate_delays(len(jobs_list), self.config.delay_config) + delay_threader( + jobs_list, self.get_blurb_with_delay, self.parse_blurb, threads, + self.logger, delays, + ) + return jobs_dict + def get_blurb_with_delay(self, job: Job, delay: float) -> Tuple[Job, str]: + """Gets blurb from indeed job link and sets delays for requests + """ + sleep(delay) + self.logger.info( + f'Delay of {delay:.2f}s, getting indeed search: {job.url}' + ) + return job, self.session.get(job.url).text + + def parse_blurb(self, job: Job, html: str) -> None: + """Parses and stores job description html and sets Job.description + """ + job_link_soup = BeautifulSoup(html, self.bs4_parser) + + try: + job.description = job_link_soup.find( + id='jobDescriptionText' + ).text.strip() + except AttributeError: + job.description = '' + job.clean_strings() + + def calc_post_date_from_relative_str(self, date_str: str) -> date: + """Identifies a job's post date via post age, updates in-place + """ + post_date = datetime.now() # type: date + # Supports almost all formats like 7 hours|days and 7 hr|d|+d + try: + # hours old + hours_ago = HOUR_REGEX.findall(date_str)[0] + post_date -= timedelta(hours=int(hours_ago)) + except IndexError: + # days old + try: + days_ago = DAY_REGEX.findall(date_str)[0] + post_date -= timedelta(days=int(days_ago)) + except IndexError: + # months old + try: + months_ago = MONTH_REGEX.findall(date_str)[0] + post_date -= relativedelta( + months=int(months_ago)) + except IndexError: + # years old + try: + years_ago = YEAR_REGEX.findall(date_str)[0] + post_date -= relativedelta( + years=int(years_ago)) + except IndexError: + # try phrases like today, just posted, or yesterday + if (RECENT_REGEX_A.findall(date_str) and + not post_date): + # today + post_date = datetime.now() + elif RECENT_REGEX_B.findall(date_str): + # yesterday + post_date -= timedelta(days=int(1)) + elif not post_date: + # we have failed. + raise ValueError("Unable to calculate date") + return post_date + def convert_radius(self, radius: int) -> int: """function that quantizes the user input radius to a valid radius value: 5, 10, 15, 25, 50, 100, and 200 kilometers or miles @@ -164,18 +240,14 @@ def convert_radius(self, radius: int) -> int: @abstractmethod def get_search_url(self, method: Optional[str] = 'get') -> str: """Get the indeed search url from SearchTerms + NOTE: different indeed localizations implement this """ pass @abstractmethod def get_link(self, job_id) -> str: """Constructs the link with the given job_id. - Args: - job_id: The id to be used to construct the link for this job. - Returns: - The constructed job link. - Note that this function does not check the correctness of this link. - The caller is responsible for checking correcteness. + NOTE: different indeed localizations implement this """ pass @@ -302,7 +374,7 @@ def get_tags(self, soup) -> List[str]: 'table', attrs={'class': 'jobCardShelfContainer'} ).find_all('td', attrs={'class': 'jobCardShelfItem'})] - def get_date(self, soup) -> str: + def get_date_str(self, soup) -> str: """Fetches the job date from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the date from. @@ -363,11 +435,11 @@ def get_search_url(self, method: Optional[str] = 'get') -> str: "limit={5}&filter={6}".format( get_domain_from_locale(self.locale), self.query, - self.search_terms.city.replace(' ', '+'), - self.search_terms.province, - self.convert_radius(self.search_terms.radius), + self.config.search_terms.city.replace(' ', '+'), + self.config.search_terms.province, + self.convert_radius(self.config.search_terms.radius), self.max_results_per_page, - int(self.search_terms.return_similar_results) + int(self.config.search_terms.return_similar_results) ) ) return search @@ -426,11 +498,11 @@ def get_search_url(self, method: Optional[str] = 'get') -> str: "limit={5}&filter={6}".format( get_domain_from_locale(self.locale), self.query, - self.search_terms.city.replace(' ', '+'), - self.search_terms.state, - self.convert_radius(self.search_terms.region.radius), + self.config.search_terms.city.replace(' ', '+'), + self.config.search_terms.state, + self.convert_radius(self.config.search_terms.region.radius), self.max_results_per_page, - int(self.search_terms.return_similar_results) + int(self.config.search_terms.return_similar_results) ) ) return search diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 75f69efd..28b97623 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -1,14 +1,18 @@ +"""Module for calculating random or non-random delay """ -Module for calculating random or non-random delay -""" -import sys - +from concurrent.futures import ThreadPoolExecutor, as_completed from math import ceil, log, sqrt from numpy import arange from random import uniform +import sys +from typing import Dict, Union, List +from time import time +from logging import warning, Logger + from scipy.special import expit -from typing import Dict, Union -from logging import warning + +from jobfunnel.config import DelayConfig +from jobfunnel.backend import Job def _c_delay(list_len: int, delay: Union[int, float]): @@ -61,62 +65,83 @@ def _sig_delay(list_len: int, delay: Union[int, float]): return delays.tolist() # convert np array back to list -def delay_alg(list_len, delay_config: Dict): +def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: """ Checks delay config and returns calculated delay list. - Args: - list_len: length of scrape job list - delay_config: Delay configuration dictionary + NOTE: we do this to be respectful to online job sources + + Args: + list_len: length of scrape job list + delay_config: Delay configuration dictionary - Returns: - list of delay time matching length of scrape job list + Returns: + list of delay time matching length of scrape job list """ - if isinstance(list_len, list): # Prevents breaking if a list was passed - list_len = len(list_len) - - # init and check numerical arguments - delay = delay_config['delay'] - if delay <= 0: - raise ValueError("\nYour delay is set to 0 or less.\nCancelling " - "execution...") - - min_delay = delay_config['min_delay'] - if min_delay < 0 or min_delay >= delay: - warning( - "\nMinimum delay is below 0, or more than or equal to delay." - "\nSetting to 0 and continuing execution." - "\nIf this was a mistake, check your command line" - " arguments or settings file. \n") - min_delay = 0 - - # delay calculations using specified equations - if delay_config['function'] == 'constant': - delay_calcs = _c_delay(list_len, delay) - elif delay_config['function'] == 'linear': - delay_calcs = _lin_delay(list_len, delay) - elif delay_config['function'] == 'sigmoid': - delay_calcs = _sig_delay(list_len, delay) - - # check if minimum delay is above 0 and less than last element - if 0 < min_delay: - # sets min_delay to values greater than itself in delay_calcs - for i, n in enumerate(delay_calcs): - if n > min_delay: + delay_config.validate() + + # Delay calculations using specified equations + if delay_config.function_name == 'constant': + delay_vals = _c_delay(list_len, delay_config.duration) + elif delay_config.function_name == 'linear': + delay_vals = _lin_delay(list_len, delay_config.duration) + elif delay_config.function_name == 'sigmoid': + delay_vals = _sig_delay(list_len, delay_config.duration) + + # Check if minimum delay is above 0 and less than last element + if 0 < delay_config.min_delay: + # sets min_delay to values greater than itself in delay_vals + for i, n in enumerate(delay_vals): + if n > delay_config.min_delay: break - delay_calcs[i] = min_delay + delay_vals[i] = delay_config.min_delay - # outputs final list of delays rounded up to 3 decimal places - if delay_config['random']: # check if random delay was specified + # Outputs final list of delays rounded up to 3 decimal places + if delay_config.random: # check if random delay was specified # random.uniform(a, b) a = lower bound, b = upper bound - if delay_config['converge']: # checks if converging delay is True - # delay_calcs = lower bound, delay = upper bound - delays = [round(uniform(x, delay), 3) for x in delay_calcs] + if delay_config.converge: # checks if converging delay is True + # delay_vals = lower bound, delay = upper bound + durations = [ + round(uniform(x, delay_config.duration), 3) for x in delay_vals + ] else: - # lb = lower bounds, delay_calcs = upper bound - delays = [round(uniform(min_delay, x), 3) for x in delay_calcs] + # lb = lower bounds, delay_vals = upper bound + durations = [ + round(uniform(delay_config.min_delay, x), 3) for x in delay_vals + ] else: - delays = [round(i, 3) for i in delay_calcs] - # set first element to 0 so scrape starts right away - delays[0] = 0 - return delays + durations = [round(i, 3) for i in delay_vals] + + # Always set first element to 0 so scrape starts right away + durations[0] = 0 + + return durations + + +def delay_threader(jobs_list: List[Job], scrape_fn: object, + parse_fn: object, threads: ThreadPoolExecutor, + logger: Logger, delays: List[float]) -> None: + """Method to scrape descriptions from individual indeed postings. + with respectful-delaying + """ + scrape_jobs = zip(jobs_list, delays) + + # Submits jobs and stores futures in dict + start = time() + results = { + threads.submit(scrape_fn, job, delays): job.key_id + for job, delays in scrape_jobs + } + + # Loops through futures as completed and removes each if successfully parsed + while results: + for future in as_completed(results): + job, html = future.result() + parse_fn(job, html) + del results[future] + del html + + # Cleanup + log + threads.shutdown() + end = time() + logger.info(f'Scrape delay took {(end - start):.3f}s') diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index cd3fcfc1..b718adb7 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -1,3 +1,6 @@ +"""Filters that are used in jobfunnel's filter() method or as intermediate +filters to reduce un-necessesary scraping +""" import nltk import logging from datetime import datetime, date, timedelta @@ -6,51 +9,28 @@ from typing import Dict, Optional from numpy import delete as np_delete, max as np_max, fill_diagonal +from jobfunnel.backend import Job -def date_filter(cur_dict: Dict[str, dict], number_of_days: int): - """Filter out jobs that are older than number_of_days - The assumed date format is yyyy-mm-dd - Args: - cur_dict: today's job scrape dict - number_of_days: how many days old a job can be - """ - if number_of_days < 0 or cur_dict is None: - return - print("date_filter running") - cur_job_ids = [job['id'] for job in cur_dict.values()] - # calculate the oldest date a job can be - threshold_date = datetime.now() - timedelta(days=number_of_days) - for job_id in cur_job_ids: - # get the date from job with job_id - job_date = datetime.strptime(cur_dict[job_id]['date'], '%Y-%m-%d') - # if this job is older than threshold_date, delete it from current scrape - if job_date < threshold_date: - logging.info(f"{cur_dict[job_id]['link']} has been filtered out by date_filter because" - f" it is older than {number_of_days} days") - del cur_dict[job_id] - - -def id_filter(cur_dict: Dict[str, dict], prev_dict: Dict[str, dict], provider): - """ Filter duplicates on job id per provider. - Args: - cur_dict: today's job scrape dict - prev_dict: the existing master list job dict - provider: job board used +T_NOW = datetime.now() - """ - # get job ids from scrape and master list by provider as lists - cur_job_ids = [job['id'] for job in cur_dict.values()] - prev_job_ids = [job['id'] for job in prev_dict.values() - if job['provider'] == provider] - # pop duplicate job ids from current scrape - duplicate_ids = [cur_dict.pop(job_id)['id'] for job_id in cur_job_ids - if job_id in prev_job_ids] +def job_is_old(job: Job, number_of_days: int) -> bool: + """Identify if a job is older than number_of_days from today - # log duplicate ids - logging.info(f'found {len(cur_dict.keys())} unique job ids and ' - f'{len(duplicate_ids)} duplicates from {provider}') + NOTE: modifies job_dict in-place + + Args: + job_dict: today's job scrape dict + number_of_days: how many days old a job can be + + Returns: + True if it's older than number of days + False if it's fresh enough to keep + """ + assert number_of_days > 0 + # Calculate the oldest date a job can be + return job.post_date < (T_NOW - timedelta(days=number_of_days)) def tfidf_filter(cur_dict: Dict[str, dict], @@ -82,8 +62,8 @@ def tfidf_filter(cur_dict: Dict[str, dict], if prev_dict is None: # get query words and ids as lists - query_ids = [job['id'] for job in cur_dict.values()] - query_words = [job['blurb'] for job in cur_dict.values()] + query_ids = [job.key_id for job in cur_dict.values()] + query_words = [job.description for job in cur_dict.values()] # returns cosine similarity between jobs as square matrix (n,n) similarities = cosine_similarity(vectorizer.fit_transform(query_words)) @@ -117,11 +97,11 @@ def tfidf_filter(cur_dict: Dict[str, dict], duplicate_ids = tfidf_filter(cur_dict) # get query words and ids as lists - query_ids = [job['id'] for job in cur_dict.values()] - query_words = [job['blurb'] for job in cur_dict.values()] + query_ids = [job.key_id for job in cur_dict.values()] + query_words = [job.description for job in cur_dict.values()] # get reference words as list - reference_words = [job['blurb'] for job in prev_dict.values()] + reference_words = [job.description for job in prev_dict.values()] # fit vectorizer to entire corpus vectorizer.fit(query_words + reference_words) @@ -139,9 +119,11 @@ def tfidf_filter(cur_dict: Dict[str, dict], duplicate_ids.update({query_id: cur_dict.pop(query_id)}) # log something - logging.info(f'found {len(cur_dict.keys())} unique listings and ' - f'{len(duplicate_ids.keys())} duplicates ' - f'via TFIDF cosine similarity') + logging.info( + f'Found {len(cur_dict.keys())} unique listings and ' + f'{len(duplicate_ids.keys())} duplicates ' + f'via TFIDF cosine similarity' + ) - # returns a dictionary of duplicates + # returns a dictionary of duplicate key_ids return duplicate_ids diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 30c47a1a..82af37e7 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -1,16 +1,5 @@ -"""Assorted tools for all aspects of funnelin'' -FIXME: most of these are not using Job correctly!!! - +"""Assorted tools for all aspects of funnelin' that don't fit elsewhere """ -# FIXME sort these -import logging -import os -import re -import random -import string -from copy import deepcopy -from dateutil.relativedelta import relativedelta -from datetime import datetime, timedelta from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.microsoft import IEDriverManager from webdriver_manager.microsoft import EdgeChromiumDriverManager @@ -18,86 +7,19 @@ from webdriver_manager.firefox import GeckoDriverManager from selenium import webdriver -from jobfunnel.backend import Job - - -# def get_random_user_agent() -> str: -# """The user agent should be randomized per-Scraper to help with spam det. -# """ FIXME... should go here maybe? - - -def split_url(url): - # capture protocol, ip address and port from given url - match = re.match(r'^(http[s]?):\/\/([A-Za-z0-9.]+):([0-9]+)?(.*)$', url) - - # if not all groups have a match, match will be None - if match is not None: - return { - 'protocol': match.group(1), - 'ip_address': match.group(2), - 'port': match.group(3), - } - else: - return None - - -def proxy_dict_to_url(proxy_dict): - protocol = proxy_dict['protocol'] - ip = proxy_dict['ip_address'] - port = proxy_dict['port'] - - url_str = '' - if protocol != '': - url_str += protocol + '://' - if ip != '': - url_str += ip - if port != '': - url_str += ':' + port - - return url_str - - -def change_nested_dict(data, args, val): - """ Access nested dictionary using multiple arguments. - - https://stackoverflow.com/questions/10399614/accessing-value-inside-nested-dictionaries - """ - if args and data: - element = args[0] - if element: - if len(args) == 1: - data[element] = val - else: - change_nested_dict(data[element], args[1:], val) - - -def config_factory(base_config, attr_list): - """ Create new config files from attribute dictionary. - - """ - configs = [] - for attr in attr_list: - # create deep copy of nested dict - config_cp = deepcopy(base_config) - - # change value and append - change_nested_dict(config_cp, attr[0], attr[1]) - configs.append(config_cp) - - return configs - def get_webdriver(): """Get whatever webdriver is availiable in the system. webdriver_manager and selenium are currently being used for this. - Supported browsers:[Firefox, Chrome, Opera, Microsoft Edge, Internet Expolorer] + Supported: Firefox, Chrome, Opera, Microsoft Edge, Internet Explorer Returns: - a webdriver that can be used for scraping. Returns None if we don't find a supported webdriver. - + webdriver that can be used for scraping. + Returns None if we don't find a supported webdriver. """ try: driver = webdriver.Firefox( - executable_path=GeckoDriverManager().install()) + executable_path=GeckoDriverManager().install() + ) except Exception: try: driver = webdriver.Chrome(ChromeDriverManager().install()) @@ -107,14 +29,17 @@ def get_webdriver(): except Exception: try: driver = webdriver.Opera( - executable_path=OperaDriverManager().install()) + executable_path=OperaDriverManager().install() + ) except Exception: try: driver = webdriver.Edge( - EdgeChromiumDriverManager().install()) + EdgeChromiumDriverManager().install() + ) except Exception: - driver = None - logging.error( - "Your browser is not supported. Must have one of the following installed to scrape: [Firefox, Chrome, Opera, Microsoft Edge, Internet Expolorer]") - + raise RuntimeError( + "Your browser is not supported. Must have one of " + "the following installed to scrape: [Firefox, " + "Chrome, Opera, Microsoft Edge, Internet Explorer]" + ) return driver diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index 4d8af292..4f8d13c0 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -1,5 +1,5 @@ from jobfunnel.config.base import BaseConfig from jobfunnel.config.delay import DelayConfig from jobfunnel.config.proxy import ProxyConfig -from jobfunnel.config.search_terms import SearchTerms +from jobfunnel.config.search import SearchConfig from jobfunnel.config.funnel import JobFunnelConfig diff --git a/jobfunnel/config/parser.py b/jobfunnel/config/cli_parser.py similarity index 56% rename from jobfunnel/config/parser.py rename to jobfunnel/config/cli_parser.py index 8d7e3008..e8fd4220 100644 --- a/jobfunnel/config/parser.py +++ b/jobfunnel/config/cli_parser.py @@ -1,28 +1,15 @@ -"""Configuration parsing module. - +"""tools to parse CLI --> JobFunnelConfig """ import argparse -import logging -import os import yaml -from .valid_options import CONFIG_TYPES -from ..tools.tools import split_url - -log_levels = {'critical': logging.CRITICAL, 'error': logging.ERROR, - 'warning': logging.WARNING, 'info': logging.INFO, - 'debug': logging.DEBUG, 'notset': logging.NOTSET} - - -class ConfigError(ValueError): - def __init__(self, arg): - self.strerror = f"ConfigError: '{arg}' has an invalid value" - self.args = {arg} +from jobfunnel.config import ( + JobFunnelConfig, SearchConfig, ProxyConfig, DelayConfig) +# FIXME: implement cereberus to validate YAML with a schema def parse_cli(): """ Parse the command line arguments. - FIXME: way too """ parser = argparse.ArgumentParser( 'CLI options take precedence over settings in the yaml file' @@ -187,123 +174,3 @@ def cli_to_yaml(cli): if cli.proxy is not None: yaml['proxy'] = split_url(cli.proxy) return yaml - - -def update_yaml(config, new_yaml): - """ Update fields of current yaml with new yaml. - - """ - for k, v in new_yaml.items(): - # if v is a dict we need to dive deeper... - if type(v) is dict: - # There might be times where this dictionary is not in config, - # but it still is a valid option inside of CONFIG_TYPES - # such as it is in the case of proxy - if k not in config: - config[k] = v - - update_yaml(config[k], v) - else: - if v is not None: - config[k] = v - - -def recursive_check_config_types(config, types): - """ Recursively check type of setting vars. - - """ - for k, v in config.items(): - # if type is dict than we have to recursively handle this - if type(v) is dict: - yield from recursive_check_config_types(v, types[k]) - else: - yield (k, type(v) in types[k]) - - -def check_config_types(config): - """ Check if no settings have a wrong type and if we do not have unsupported - options. - - """ - # Get a dictionary of all types and boolean if it's the right type - types_check = recursive_check_config_types(config, CONFIG_TYPES) - - # Select all wrong types and throw error when there is such a value - - wrong_types = [k for k, v in types_check if v is False] - if len(wrong_types) > 0: - raise ConfigError(', '.join(wrong_types)) - - -def parse_config(): - """ Parse the JobFunnel configuration settings. - - """ - # find the jobfunnel root dir - jobfunnel_path = os.path.normpath( - os.path.join(os.path.dirname(__file__), '..')) - - # load the default settings - default_yaml_path = os.path.join(jobfunnel_path, 'config/settings.yaml') - default_yaml = yaml.safe_load(open(default_yaml_path, 'r')) - - # parse the command line arguments - cli = parse_cli() - cli_yaml = cli_to_yaml(cli) - - # parse the settings file for the line arguments - given_yaml = None - given_yaml_path = None - if cli.settings is not None: - given_yaml_path = os.path.dirname(cli.settings) - given_yaml = yaml.safe_load(open(cli.settings, 'r')) - - # combine default, given and argument yamls into one. Note that we update - # the values of the default_yaml, so we use this for the rest of the file. - # We could make a deep copy if necessary. - config = default_yaml - if given_yaml is not None: - update_yaml(config, given_yaml) - update_yaml(config, cli_yaml) - # check if the config has valid attribute types - check_config_types(config) - - # create output path and corresponding (children) data paths - # I feel like this is not in line with the rest of the file's philosophy - if cli.output_path is not None: - output_path = cli.output_path - elif given_yaml_path is not None: - output_path = os.path.join(given_yaml_path, given_yaml['output_path']) - else: - output_path = default_yaml['output_path'] - - # define paths and normalise - config['data_path'] = os.path.join(output_path, 'data') - config['master_list_path'] = os.path.join(output_path, 'master_list.csv') - config['duplicate_list_path'] = os.path.join( - output_path, 'duplicate_list.csv') - config['filter_list_path'] = os.path.join( - config['data_path'], 'filter_list.json') - config['log_path'] = os.path.join(config['data_path'], 'jobfunnel.log') - - # normalize paths - for p in ['data_path', 'master_list_path', 'duplicate_list_path', - 'log_path', 'filter_list_path']: - config[p] = os.path.normpath(config[p]) - - # lower provider and delay function - for i, p in enumerate(config['providers']): - config['providers'][i] = p.lower() - config['delay_config']['function'] = \ - config['delay_config']['function'].lower() - - # parse the log level - config['log_level'] = log_levels[config['log_level']] - - # check if proxy and max_listing_days have not been set yet (optional) - if 'proxy' not in config: - config['proxy'] = None - if 'max_listing_days' not in config: - config['max_listing_days'] = None - - return config diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py index 46ec10a0..e880d48a 100644 --- a/jobfunnel/config/delay.py +++ b/jobfunnel/config/delay.py @@ -6,10 +6,23 @@ class DelayConfig(BaseConfig): """Simple config object to contain the delay configuration """ - pass # FIXME: impl - - def __init__(self): - pass + def __init__(self, duration: float, min_delay: float, function_name: str, + random: bool = False, converge: bool = False): + # TODO: document + self.duration = duration + self.min_delay = min_delay + self.function_name = function_name + self.random = random + self.converge = converge def validate(self) -> None: - pass + assert self.function_name in ['constant', 'linear', 'sigmoid'] + + if self.duration <= 0: + raise ValueError("Your delay duration is set to 0 or less.") + + if self.min_delay < 0 or self.min_delay >= self.duration: + raise ValueError( + "Minimum delay is below 0, or more than or equal to delay." + ) + diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index ef458862..56681137 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -1,25 +1,26 @@ """Config object to run JobFunnel """ +import logging from typing import Optional, List import os from jobfunnel.backend.scrapers import BaseScraper -from jobfunnel.config import BaseConfig, ProxyConfig, SearchTerms, DelayConfig +from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig class JobFunnelConfig(BaseConfig): - """Simple config object to contain paths and sub-configs + """Master config containing all the information we need to run jobfunnel """ def __init__(self, master_csv_file: str, - user_deny_list_file: str, - global_dely_list_file: str, + user_block_list_file: str, + company_block_list_file: str, cache_folder: str, - search_terms: SearchTerms, + search_terms: SearchConfig, scrapers: List[BaseScraper], log_file: str, - log_level: Optional[int] = 0, + log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, proxy_config: Optional[ProxyConfig] = None) -> None: @@ -28,16 +29,17 @@ def __init__(self, Args: master_csv_file (str): path to the .csv file that user interacts w/ - user_deny_list_file (str): path to a JSON that contains jobs user + user_block_list_file (str): path to a JSON that contains jobs user has decided to omit from their .csv file (i.e. archive status) - global_dely_list_file (str): path to a JSON containing companies - that the user wants to never see in their .csv file + company_block_list_file (str): path to a JSON containing companies + that the user wants to never see in their .csv file for all + their searches cache_folder (str): folder where all scrape data will be stored search_terms (SearchTerms): SearchTerms config which contains the desired job search information (i.e. keywords) scrapers (List[BaseScraper]): List of scrapers we will scrape from log_file (str): file to log all logger calls to - log_level (int): level to log at, use 20 for debugging + log_level (int): level to log at, use 10 logging.DEBUG for more data no_scrape (Optional[bool], optional): If True, will not scrape data at all, instead will only update filters and CSV. Defaults to False. @@ -47,8 +49,8 @@ def __init__(self, Defaults to None, which will result in no proxy being used """ self.master_csv_file = master_csv_file - self.user_deny_list_file = user_deny_list_file - self.global_dely_list_file = global_dely_list_file + self.user_block_list_file = user_block_list_file + self.company_block_list_file = company_block_list_file self.cache_folder = cache_folder self.search_terms = search_terms self.scrapers = scrapers @@ -56,7 +58,7 @@ def __init__(self, self.log_level = log_level self.no_scrape = no_scrape if not delay_config: - self.delay_config = DelayConfig() + self.delay_config = DelayConfig(5.0, 1.0, 'linear') else: self.delay_config = delay_config self.proxy_config = proxy_config diff --git a/jobfunnel/config/search_terms.py b/jobfunnel/config/search.py similarity index 96% rename from jobfunnel/config/search_terms.py rename to jobfunnel/config/search.py index 4ff4c85d..ee582f73 100644 --- a/jobfunnel/config/search_terms.py +++ b/jobfunnel/config/search.py @@ -6,10 +6,10 @@ DEFAULT_SEARCH_RADIUS_KM = 25 -DEFAULT_MAX_LISTING_DAYS = 10 +DEFAULT_MAX_LISTING_DAYS = 45 -class SearchTerms(BaseConfig): +class SearchConfig(BaseConfig): """Config object to contain region of interest for a Locale NOTE: ideally we'd have one of these per-locale, per-website, but then diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 884a54ec..1451f01c 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -1,5 +1,4 @@ # NOTE: Setting job's status to these moves the job from masterlist -> deny list -REMOVE_STATUSES = ['archive', 'archived', 'remove', 'rejected'] CSV_HEADER = [ 'status', 'title', 'company', 'location', 'date', 'blurb', 'tags', 'link', 'id', 'provider', 'query', 'locale' From 607cd3992c58fced25bf93067f010856958022d5 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 3 Aug 2020 19:37:08 -0400 Subject: [PATCH 04/66] fix some status parsing issues --- jobfunnel/__init__.py | 14 +------------- jobfunnel/backend/job.py | 2 ++ jobfunnel/backend/jobfunnel.py | 4 ++-- jobfunnel/backend/scrapers/base.py | 2 +- jobfunnel/resources/__init__.py | 1 + jobfunnel/resources/resources.py | 19 ++++++++++++++++++- 6 files changed, 25 insertions(+), 17 deletions(-) create mode 100644 jobfunnel/resources/__init__.py diff --git a/jobfunnel/__init__.py b/jobfunnel/__init__.py index 912d78d3..dfef77fb 100644 --- a/jobfunnel/__init__.py +++ b/jobfunnel/__init__.py @@ -2,16 +2,4 @@ import random -__version__ = '2.1.9' - - -# FIXME: gotta be a better way... -USER_AGENT_LIST_FILE = os.path.normpath( - os.path.join(os.path.dirname(__file__), 'resources', 'user_agent_list.txt') -) -USER_AGENT_LIST = [] -with open(USER_AGENT_LIST_FILE) as file: - for line in file: - li = line.strip() - if li and not li.startswith("#"): - USER_AGENT_LIST.append(line.rstrip('\n')) +__version__ = '2.2.0' diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index aabe368b..72046a76 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -25,6 +25,8 @@ class JobStatus(Enum): ACCEPTED = 7 DELETE = 8 INTERESTED = 9 + APPLIED = 10 + APPLY = 11 REMOVE_STATUSES = [JobStatus.DELETE, JobStatus.ARCHIVE, JobStatus.REJECTED] diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index e66866a5..fa9fc0e5 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -15,7 +15,7 @@ from jobfunnel.config import JobFunnelConfig from jobfunnel.backend import Job, JobStatus, Locale -from jobfunnel.resources.resources import CSV_HEADER +from jobfunnel.resources import CSV_HEADER from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter @@ -320,7 +320,7 @@ def update_block_list(self): 'title': job.title, 'post_date': job.post_date.strftime('%Y-%m-%d'), 'description': job.description, - 'status': job.status, + 'status': job.status.name, } # Write out complete list with any additions from the masterlist diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 23a78e2b..a94bf49f 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -7,7 +7,7 @@ import random from requests import Session -from jobfunnel import USER_AGENT_LIST +from jobfunnel.resources import USER_AGENT_LIST from jobfunnel.backend import Job from jobfunnel.backend.localization import Locale #from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue diff --git a/jobfunnel/resources/__init__.py b/jobfunnel/resources/__init__.py new file mode 100644 index 00000000..77edaafe --- /dev/null +++ b/jobfunnel/resources/__init__.py @@ -0,0 +1 @@ +from jobfunnel.resources.resources import USER_AGENT_LIST, CSV_HEADER diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 1451f01c..4f9eacca 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -1,5 +1,22 @@ -# NOTE: Setting job's status to these moves the job from masterlist -> deny list +"""Constant definitions or files we need to load once can go here +""" +import os + + CSV_HEADER = [ 'status', 'title', 'company', 'location', 'date', 'blurb', 'tags', 'link', 'id', 'provider', 'query', 'locale' ] # TODO: need to add short and long descriptions (breaking change) + + +USER_AGENT_LIST_FILE = os.path.normpath( + os.path.join(os.path.dirname(__file__), 'user_agent_list.txt') +) + + +USER_AGENT_LIST = [] +with open(USER_AGENT_LIST_FILE) as file: + for line in file: + li = line.strip() + if li and not li.startswith("#"): + USER_AGENT_LIST.append(line.rstrip('\n')) From 6fdbb0668f12bbef760957d86186ab82da5cbc2b Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Tue, 4 Aug 2020 18:50:22 -0400 Subject: [PATCH 05/66] connected CLI parser, making no changes to args --- jobfunnel/__main__.py | 22 ++-- jobfunnel/backend/job.py | 6 +- jobfunnel/backend/jobfunnel.py | 18 ++- jobfunnel/backend/tools/filters.py | 1 + jobfunnel/backend/tools/tools.py | 17 +++ jobfunnel/config/__init__.py | 7 +- jobfunnel/config/base.py | 1 + jobfunnel/config/cli_parser.py | 188 ++++++++++++++++++++++++----- jobfunnel/config/funnel.py | 76 ++++++++++-- jobfunnel/config/search.py | 47 +++++--- jobfunnel/config/settings.yaml | 4 +- jobfunnel/config/valid_options.py | 2 +- jobfunnel/config/validate.py | 4 +- setup.py | 36 +++--- 14 files changed, 335 insertions(+), 94 deletions(-) diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 4617f027..77c7131e 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -7,34 +7,28 @@ * make it easier to continue an existing search * make it easier to run multiple searches at once w.r.t caching * simplified CLI args with new --recover and --clean options - * impl Cereberus for YAML validation * add warning around seperate cache folders blocklists per search * document API usage in readme ** add back the duplicates JSON """ +import argparse import sys from typing import Union import logging from .backend.jobfunnel import JobFunnel -from .config import JobFunnelConfig, SearchConfig -from .backend.scrapers import IndeedScraperCAEng +from .config import parse_config, validate_config, build_funnel_cfg_from_legacy def main(): """Parse CLI and call jobfunnel() to manage scrapers and lists """ - # TODO: need to warn user to use seperate cache folder and - # block list per search - - # Init TODO: parse CLI to do this. - search_terms = SearchConfig(['Python', 'Scientist'], 'ON', None, 'waterloo', 25) - config = JobFunnelConfig( - 't_m.csv', 't_udnl.json', 't_gdnl.json', './t_cache', - search_terms, [IndeedScraperCAEng], 't_log.log', - log_level=logging.INFO, - ) - JobFunnel(config).run() + # Parse CLI into a dict + config = parse_config() + validate_config(config) + funnel_cfg = build_funnel_cfg_from_legacy(config) + job_funnel = JobFunnel(funnel_cfg) + job_funnel.run() if __name__ == '__main__': diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 72046a76..1ed8373b 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -15,6 +15,7 @@ class JobStatus(Enum): """Job statuses that are built-into jobfunnel + NOTE: these are the only valid values for entries in 'status' in our CSV """ UNKNOWN = 1 NEW = 2 @@ -27,9 +28,12 @@ class JobStatus(Enum): INTERESTED = 9 APPLIED = 10 APPLY = 11 + OLD = 12 -REMOVE_STATUSES = [JobStatus.DELETE, JobStatus.ARCHIVE, JobStatus.REJECTED] +REMOVE_STATUSES = [ + JobStatus.DELETE, JobStatus.ARCHIVE, JobStatus.REJECTED, JobStatus.OLD +] class Job(): diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index fa9fc0e5..05bb0f1f 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -19,6 +19,9 @@ from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter +MAX_BLOCK_LIST_DESC_CHARS = 150 # maximum len of description in block_list JSON + + class JobFunnel(object): """Class that initializes a Scraper and scrapes a website to get jobs """ @@ -319,7 +322,8 @@ def update_block_list(self): blocked_jobs_dict[job.key_id] = { 'title': job.title, 'post_date': job.post_date.strftime('%Y-%m-%d'), - 'description': job.description, + 'description': '{0: int: """Remove jobs from jobs_dict if they are: 1. in our block-list - 2. status == DELETE, + 2. status == a removal status string (i.e. DELETE) + 3. job.company == one of our blocked company names + Returns the number of filtered jobs - NOTE: modifies in-place + TODO: would be cool if we could run TFIDF in here too FIXME: load the global block-list as well """ @@ -360,13 +366,15 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: open(self.config.user_block_list_file, 'r') ) else: - block_dict = {} + block_dict = {} # type: Dict[str, Job] filter_jobs_ids = [] for key_id, job in jobs_dict.items(): if (key_id in block_dict or job_is_old(job, self.config.search_terms.max_listing_days) - or job.is_remove_status): + or job.is_remove_status + or job.company in self.config.search_terms.blocked_company_names + ): filter_jobs_ids.append(key_id) for key_id in filter_jobs_ids: diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index b718adb7..12f3a616 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -30,6 +30,7 @@ def job_is_old(job: Job, number_of_days: int) -> bool: """ assert number_of_days > 0 # Calculate the oldest date a job can be + # NOTE: we may want to just set job.status = JobStatus.OLD return job.post_date < (T_NOW - timedelta(days=number_of_days)) diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 82af37e7..1d49c98d 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -1,5 +1,7 @@ """Assorted tools for all aspects of funnelin' that don't fit elsewhere """ +import re + from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.microsoft import IEDriverManager from webdriver_manager.microsoft import EdgeChromiumDriverManager @@ -43,3 +45,18 @@ def get_webdriver(): "Chrome, Opera, Microsoft Edge, Internet Explorer]" ) return driver + + +def split_url(url): + # capture protocol, ip address and port from given url + match = re.match(r'^(http[s]?):\/\/([A-Za-z0-9.]+):([0-9]+)?(.*)$', url) + + # if not all groups have a match, match will be None + if match is not None: + return { + 'protocol': match.group(1), + 'ip_address': match.group(2), + 'port': match.group(3), + } + else: + return None diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index 4f8d13c0..41142933 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -2,4 +2,9 @@ from jobfunnel.config.delay import DelayConfig from jobfunnel.config.proxy import ProxyConfig from jobfunnel.config.search import SearchConfig -from jobfunnel.config.funnel import JobFunnelConfig +from jobfunnel.config.funnel import ( + JobFunnelConfig, + build_funnel_cfg_from_legacy +) +from jobfunnel.config.cli_parser import parse_config, ConfigError +from jobfunnel.config.validate import validate_config diff --git a/jobfunnel/config/base.py b/jobfunnel/config/base.py index d08c1e1b..1eb87a2a 100644 --- a/jobfunnel/config/base.py +++ b/jobfunnel/config/base.py @@ -11,5 +11,6 @@ def __init__(self) -> None: def validate(self) -> None: """This should raise Exceptions if self.attribs are bad + FIXME: some way to run this on object creation would be nice """ pass diff --git a/jobfunnel/config/cli_parser.py b/jobfunnel/config/cli_parser.py index e8fd4220..f076a9bc 100644 --- a/jobfunnel/config/cli_parser.py +++ b/jobfunnel/config/cli_parser.py @@ -1,83 +1,97 @@ -"""tools to parse CLI --> JobFunnelConfig +"""Configuration parsing module. """ import argparse +import logging +import os import yaml -from jobfunnel.config import ( - JobFunnelConfig, SearchConfig, ProxyConfig, DelayConfig) +from jobfunnel.config.valid_options import CONFIG_TYPES +from jobfunnel.backend.tools.tools import split_url + + +log_levels = {'critical': logging.CRITICAL, 'error': logging.ERROR, + 'warning': logging.WARNING, 'info': logging.INFO, + 'debug': logging.DEBUG, 'notset': logging.NOTSET} + + +class ConfigError(ValueError): + def __init__(self, arg): + self.strerror = f"ConfigError: '{arg}' has an invalid value" + self.args = {arg} -# FIXME: implement cereberus to validate YAML with a schema def parse_cli(): - """ Parse the command line arguments. + """ Parse the command line arguments into an argv """ + parser = argparse.ArgumentParser( - 'CLI options take precedence over settings in the yaml file' - 'empty arguments are replaced by settings in the default yaml file') + 'JobFunnel CLI utility - https://github.com/PaulMcInnis/JobFunnel ' + '\nArguments passed here take precedence over YAML settings.') parser.add_argument('-s', dest='settings', type=str, required=False, - help='path to the yaml settings file') + help='Path to the yaml settings file') parser.add_argument('-o', dest='output_path', action='store', required=False, - help='directory where the search results will be ' - 'stored') + help='Directory where the search results will be ' + 'stored. NOTE: You should have seperate ' + 'directories per-search!') parser.add_argument('-kw', dest='keywords', nargs='*', required=False, - help='list of keywords to use in the job search. (' + help='List of keywords to use in the job search. (' 'i.e. Engineer, AI)') parser.add_argument('-p', dest='province', type=str, required=False, - help='province value for a region ') + help='Province/State value for search region') parser.add_argument('--city', dest='city', type=str, required=False, - help='city value for a region ') + help='City value for search region') parser.add_argument('--domain', dest='domain', type=str, required=False, - help='domain value for a region ') + help='Domain value for search region.') parser.add_argument('-r', dest='random', action='store_true', required=False, default=None, - help='turn on random delaying') + help='Turn on random delaying.') parser.add_argument('-c', dest='converge', action='store_true', required=False, default=None, - help='use converging random delay') + help='Use converging random delay.') parser.add_argument('-d', dest='delay', type=float, required=False, - help='set delay seconds for scrapes.') + help='Set delay seconds for scrapes.') parser.add_argument('-md', dest='min_delay', type=float, required=False, - help='set lower bound value for scraper') + help='Set lower bound value for scraper') parser.add_argument('--fun', dest='function', @@ -85,7 +99,7 @@ def parse_cli(): required=False, default=None, choices=['constant', 'linear', 'sigmoid'], - help='Select a function to calculate delay times with') + help='Select a function to calculate delay times with.') parser.add_argument('--log_level', dest='log_level', @@ -101,42 +115,43 @@ def parse_cli(): dest='similar', action='store_true', default=None, - help='pass to get \'similar\' job listings') + help='Pass to get \'similar\' job listings.') parser.add_argument('--no_scrape', dest='no_scrape', action='store_true', default=None, - help='skip web-scraping and load a previously saved ' - 'daily scrape pickle') + help='Skip web-scraping and load a previously saved ' + 'daily scrape pickle.') parser.add_argument('--proxy', dest='proxy', type=str, required=False, default=None, - help='proxy address') + help='Proxy address') parser.add_argument('--recover', dest='recover', action='store_true', default=None, - help='recover master-list by accessing all historic ' - 'scrapes pickles') + help='Recover master-list by accessing all historic ' + 'scrapes pickles.') parser.add_argument('--save_dup', dest='save_duplicates', action='store_true', required=False, default=None, - help='save duplicates popped by tf_idf filter to file') + help='Save duplicates popped by tf_idf filter to file.') + parser.add_argument('--max_listing_days', dest='max_listing_days', type=int, default=None, required=False, help='The maximum number of days old a job can be.' - '(i.e pass 30 to filter out jobs older than a month)') + '(i.e pass 30 to filter out jobs older than a month).') return parser.parse_args() @@ -151,6 +166,7 @@ def cli_to_yaml(cli): 'search_terms': { 'region': { 'province': cli.province, + #'state': cli.state, FIXME: we need to validate for localization 'city': cli.city, 'domain': cli.domain }, @@ -174,3 +190,121 @@ def cli_to_yaml(cli): if cli.proxy is not None: yaml['proxy'] = split_url(cli.proxy) return yaml + + +def update_yaml(config, new_yaml): + """ Update fields of current yaml with new yaml. + + """ + for k, v in new_yaml.items(): + # if v is a dict we need to dive deeper... + if type(v) is dict: + # There might be times where this dictionary is not in config, + # but it still is a valid option inside of CONFIG_TYPES + # such as it is in the case of proxy + if k not in config: + config[k] = v + + update_yaml(config[k], v) + else: + if v is not None: + config[k] = v + + +def recursive_check_config_types(config, types): + """ Recursively check type of setting vars. + + """ + for k, v in config.items(): + # if type is dict than we have to recursively handle this + if type(v) is dict: + yield from recursive_check_config_types(v, types[k]) + else: + yield (k, type(v) in types[k]) + + +def check_config_types(config): + """ Check if no settings have a wrong type and if we do not have unsupported + options. + + """ + # Get a dictionary of all types and boolean if it's the right type + types_check = recursive_check_config_types(config, CONFIG_TYPES) + + # Select all wrong types and throw error when there is such a value + + wrong_types = [k for k, v in types_check if v is False] + if len(wrong_types) > 0: + raise ConfigError(', '.join(wrong_types)) + + +def parse_config(): + """ Parse the JobFunnel configuration settings. + + """ + # load the default settings + default_yaml_path = os.path.join( + os.path.normpath(os.path.dirname(__file__)), 'settings.yaml' + ) + default_yaml = yaml.safe_load(open(default_yaml_path, 'r')) + + # parse the command line arguments + cli = parse_cli() + cli_yaml = cli_to_yaml(cli) + + # parse the settings file for the line arguments + given_yaml = None + given_yaml_path = None + if cli.settings is not None: + given_yaml_path = os.path.dirname(cli.settings) + given_yaml = yaml.safe_load(open(cli.settings, 'r')) + + # combine default, given and argument yamls into one. Note that we update + # the values of the default_yaml, so we use this for the rest of the file. + # We could make a deep copy if necessary. + config = default_yaml + if given_yaml is not None: + update_yaml(config, given_yaml) + update_yaml(config, cli_yaml) + # check if the config has valid attribute types + check_config_types(config) + + # create output path and corresponding (children) data paths + # I feel like this is not in line with the rest of the file's philosophy + if cli.output_path is not None: + output_path = cli.output_path + elif given_yaml_path is not None: + output_path = os.path.join(given_yaml_path, given_yaml['output_path']) + else: + output_path = default_yaml['output_path'] + + # define paths and normalise + config['data_path'] = os.path.join(output_path, 'data') + config['master_list_path'] = os.path.join(output_path, 'master_list.csv') + config['duplicate_list_path'] = os.path.join( + output_path, 'duplicate_list.csv') + config['filter_list_path'] = os.path.join( + config['data_path'], 'filter_list.json') + config['log_path'] = os.path.join(config['data_path'], 'jobfunnel.log') + + # normalize paths + for p in ['data_path', 'master_list_path', 'duplicate_list_path', + 'log_path', 'filter_list_path']: + config[p] = os.path.normpath(config[p]) + + # lower provider and delay function + for i, p in enumerate(config['providers']): + config['providers'][i] = p.lower() + config['delay_config']['function'] = \ + config['delay_config']['function'].lower() + + # parse the log level + config['log_level'] = log_levels[config['log_level']] + + # check if proxy and max_listing_days have not been set yet (optional) + if 'proxy' not in config: + config['proxy'] = None + if 'max_listing_days' not in config: + config['max_listing_days'] = None + + return config diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 56681137..172f508d 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -1,13 +1,23 @@ """Config object to run JobFunnel """ import logging -from typing import Optional, List +from typing import Optional, List, Dict, Any import os -from jobfunnel.backend.scrapers import BaseScraper +from jobfunnel.backend.scrapers import ( + BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng) from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig +SCRAPER_MAP = { + 'indeed': IndeedScraperCAEng, # TODO: deprecate and enforce below options + 'INDEED_CANADA_ENG': IndeedScraperCAEng, + 'INDEED_USA_ENG': IndeedScraperUSAEng, + #'monster': MonsterScraperCAEng, FIXME + #'MONSTER_CANADA_ENG': MonsterScraperCAEng, +} + + class JobFunnelConfig(BaseConfig): """Master config containing all the information we need to run jobfunnel """ @@ -15,7 +25,6 @@ class JobFunnelConfig(BaseConfig): def __init__(self, master_csv_file: str, user_block_list_file: str, - company_block_list_file: str, cache_folder: str, search_terms: SearchConfig, scrapers: List[BaseScraper], @@ -31,9 +40,6 @@ def __init__(self, master_csv_file (str): path to the .csv file that user interacts w/ user_block_list_file (str): path to a JSON that contains jobs user has decided to omit from their .csv file (i.e. archive status) - company_block_list_file (str): path to a JSON containing companies - that the user wants to never see in their .csv file for all - their searches cache_folder (str): folder where all scrape data will be stored search_terms (SearchTerms): SearchTerms config which contains the desired job search information (i.e. keywords) @@ -50,7 +56,6 @@ def __init__(self, """ self.master_csv_file = master_csv_file self.user_block_list_file = user_block_list_file - self.company_block_list_file = company_block_list_file self.cache_folder = cache_folder self.search_terms = search_terms self.scrapers = scrapers @@ -63,6 +68,16 @@ def __init__(self, self.delay_config = delay_config self.proxy_config = proxy_config + # Create folder that out output files are within, if it doesn't exist + for path_attr in [self.master_csv_file, self.user_block_list_file, + self.cache_folder]: + if path_attr: + output_dir = os.path.dirname(os.path.abspath(path_attr)) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + self.validate() + @property def scraper_names(self) -> str: """User-readable names of the scrapers we will be running @@ -85,3 +100,50 @@ def validate(self) -> None: if self.proxy_config: self.proxy_config.validate() self.delay_config.validate() + + +def build_funnel_cfg_from_legacy(config: Dict[str, Any]): + """Build config objects from legacy config dict + FIXME: when we implement a yaml parser with localization we can have it + """ + search_cfg = SearchConfig( + keywords=config['search_terms']['keywords'], + province=config['search_terms']['region']['province'], + state=None, + city=config['search_terms']['region']['city'], + distance_radius_km=config['search_terms']['region']['radius'], + return_similar_results=False, + max_listing_days=config['max_listing_days'], + blocked_company_names=config['black_list'], + ) + + delay_cfg = DelayConfig( + duration=config['delay_config']['delay'], + min_delay=config['delay_config']['min_delay'], + function_name=config['delay_config']['function'], + random=config['delay_config']['random'], + converge=config['delay_config']['converge'], + ) + + if config['proxy']: + proxy_cfg = ProxyConfig( + protocol=config['proxy']['protocol'], + ip_address=config['proxy']['ip_address'], + port=config['proxy']['port'], + ) + else: + proxy_cfg = None + + funnel_cfg = JobFunnelConfig( + master_csv_file=config['master_list_path'], + user_block_list_file=config['filter_list_path'], + cache_folder=config['data_path'], + search_terms=search_cfg, + scrapers=[SCRAPER_MAP[sc_name] for sc_name in config['providers']], + log_file=config['log_path'], + log_level=config['log_level'], + no_scrape=config['no_scrape'], + delay_config=delay_cfg, + proxy_config=proxy_cfg, + ) + return funnel_cfg diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index ee582f73..00480a96 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -6,18 +6,15 @@ DEFAULT_SEARCH_RADIUS_KM = 25 -DEFAULT_MAX_LISTING_DAYS = 45 +DEFAULT_MAX_LISTING_DAYS = 60 class SearchConfig(BaseConfig): """Config object to contain region of interest for a Locale - NOTE: ideally we'd have one of these per-locale, per-website, but then + FIXME: ideally we'd have one of these per-locale, per-website, but then the config would be a nightmare, so we'll just put everything in here for now - FIXME: need a better soln since this is required to be too flexible... - perhaps something at the Scraper level? - TODO: move into serach terms... """ def __init__(self, @@ -25,25 +22,37 @@ def __init__(self, province: Optional[str] = None, state: Optional[str] = None, city: Optional[str] = None, - distance_radius_km: Optional[int] = DEFAULT_SEARCH_RADIUS_KM, + distance_radius_km: Optional[int] = None, return_similar_results: Optional[bool] = False, - max_listing_days: Optional[int] = DEFAULT_MAX_LISTING_DAYS): - """init TODO: document""" + max_listing_days: Optional[int] = None, + blocked_company_names: Optional[List[str]] = None): + """Search config for all job sources + + Args: + keywords (List[str]): list of search keywords + province (Optional[str], optional): province. Defaults to None. + state (Optional[str], optional): state. Defaults to None. + city (Optional[str], optional): city. Defaults to None. + distance_radius_km (Optional[int], optional): km radius. Defaults to + DEFAULT_SEARCH_RADIUS_KM. + return_similar_results (Optional[bool], optional): return similar. + results (indeed), Defaults to False. + max_listing_days (Optional[int], optional): oldest listing to show. + Defaults to DEFAULT_MAX_LISTING_DAYS. + blocked_company_names (Optional[List[str]]): list of names of + companies that we never want to see in our results. + """ self.province = province self.state = state self.city = city - self.radius = distance_radius_km + self.radius = distance_radius_km or DEFAULT_SEARCH_RADIUS_KM self.keywords = keywords self.return_similar_results = return_similar_results # indeed thing - self.max_listing_days = max_listing_days + self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS + self.blocked_company_names = blocked_company_names - def is_valid(self, locale: Locale) -> bool: - """we need to have the right information set, not mixing stuff - TODO: eval is_valid based on the scraper as well? + def validate(self): + """We need to have the right information set, not mixing stuff + FIXME: impl. """ - if not self.keywords: - return False - if locale in [Locale.CANADA_ENGLISH, Locale.CANADA_FRENCH]: - return self.province and not self.state - elif locale == Locale.USA_ENGLISH: - return not self.province and self.state + pass diff --git a/jobfunnel/config/settings.yaml b/jobfunnel/config/settings.yaml index b58f5edc..224ae2c5 100644 --- a/jobfunnel/config/settings.yaml +++ b/jobfunnel/config/settings.yaml @@ -7,9 +7,9 @@ output_path: 'search' # providers from which to search (case insensitive) providers: - - 'GlassDoorStatic' - 'Indeed' - - 'Monster' +# - 'GlassDoorStatic' # FIXME + # - 'Monster' diff --git a/jobfunnel/config/valid_options.py b/jobfunnel/config/valid_options.py index b3b0c5f7..020ab47b 100644 --- a/jobfunnel/config/valid_options.py +++ b/jobfunnel/config/valid_options.py @@ -4,7 +4,7 @@ 'search_terms': { 'region': { 'province': [str], - 'state': [str], + #'state': [str], # FIXME: region needs to respect localization 'city': [str], 'domain': [str], 'radius': [int] diff --git a/jobfunnel/config/validate.py b/jobfunnel/config/validate.py index 1c40806b..ece2d7a4 100644 --- a/jobfunnel/config/validate.py +++ b/jobfunnel/config/validate.py @@ -1,7 +1,7 @@ import re -from .valid_options import DOMAINS, PROVIDERS, DELAY_FUN -from .parser import ConfigError +from jobfunnel.config.valid_options import DOMAINS, PROVIDERS, DELAY_FUN +from jobfunnel.config import ConfigError def validate_region(region): diff --git a/setup.py b/setup.py index 2c18edbe..b1e00604 100644 --- a/setup.py +++ b/setup.py @@ -1,22 +1,26 @@ +"""Install JobFunnel as a package +""" from setuptools import setup, find_packages from jobfunnel import __version__ as version + description = 'Automated tool for scraping job postings.' url = 'https://github.com/PaulMcInnis/JobFunnel' -requires = ['beautifulsoup4>=4.6.3', - 'lxml>=4.2.4', - 'requests>=2.19.1', - 'python-dateutil>=2.8.0', - 'PyYAML>=5.1', - 'scikit-learn>=0.21.2', - 'nltk>=3.4.1', - 'scipy>=1.4.1', - 'pytest>=5.3.1', - 'pytest-mock>=3.1.1', - 'selenium>=3.141.0', - 'webdriver-manager>=2.4.0' - ] +requires = [ + 'beautifulsoup4>=4.6.3', + 'lxml>=4.2.4', + 'requests>=2.19.1', + 'python-dateutil>=2.8.0', + 'PyYAML>=5.1', + 'scikit-learn>=0.21.2', + 'nltk>=3.4.1', + 'scipy>=1.4.1', + 'pytest>=5.3.1', + 'pytest-mock>=3.1.1', + 'selenium>=3.141.0', + 'webdriver-manager>=2.4.0', +] with open('readme.md', 'r') as f: readme = f.read() @@ -27,7 +31,8 @@ description=description, long_description=readme, long_description_content_type='text/markdown', - author='Paul McInnis, Bradley Kohler, Jose Alarcon, Erich Mengore, Mark van der Broek', + author='Paul McInnis, Bradley Kohler, Jose Alarcon, Erich Mengore, ' + 'Mark van der Broek', author_email='paulmcinnis99@gmail.com', url=url, license='MIT License', @@ -35,4 +40,5 @@ install_requires=requires, packages=find_packages(exclude=('demo', 'tests')), include_package_data=True, - entry_points={'console_scripts': ['funnel = jobfunnel.__main__:main']}) + entry_points={'console_scripts': ['funnel = jobfunnel.__main__:main']} +) From ec123a5a71ad1525860de9b0708d7fa1744ee3fd Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Tue, 4 Aug 2020 20:03:23 -0400 Subject: [PATCH 06/66] getting glassdoor going again --- jobfunnel/backend/scrapers/__init__.py | 4 + jobfunnel/backend/scrapers/base.py | 7 +- .../backend/scrapers/glassdoor/__init__.py | 0 jobfunnel/backend/scrapers/glassdoor/base.py | 126 ++++++++ .../backend/scrapers/glassdoor/dynamic.py | 54 ++++ .../static.py} | 216 +++++++------- jobfunnel/backend/scrapers/glassdoor_base.py | 92 ------ .../backend/scrapers/glassdoor_dynamic.py | 269 ------------------ jobfunnel/backend/scrapers/indeed.py | 8 +- jobfunnel/backend/tools/__init__.py | 1 + jobfunnel/config/cli_parser.py | 6 +- jobfunnel/config/funnel.py | 5 +- jobfunnel/resources/__init__.py | 4 +- .../default_settings.yaml} | 6 +- jobfunnel/resources/resources.py | 5 +- 15 files changed, 318 insertions(+), 485 deletions(-) create mode 100644 jobfunnel/backend/scrapers/glassdoor/__init__.py create mode 100644 jobfunnel/backend/scrapers/glassdoor/base.py create mode 100644 jobfunnel/backend/scrapers/glassdoor/dynamic.py rename jobfunnel/backend/scrapers/{glassdoor_static.py => glassdoor/static.py} (64%) delete mode 100644 jobfunnel/backend/scrapers/glassdoor_base.py delete mode 100644 jobfunnel/backend/scrapers/glassdoor_dynamic.py rename jobfunnel/{config/settings.yaml => resources/default_settings.yaml} (95%) diff --git a/jobfunnel/backend/scrapers/__init__.py b/jobfunnel/backend/scrapers/__init__.py index a9bd4f6b..e3bf79f4 100644 --- a/jobfunnel/backend/scrapers/__init__.py +++ b/jobfunnel/backend/scrapers/__init__.py @@ -2,3 +2,7 @@ from jobfunnel.backend.scrapers.indeed import ( IndeedScraperCAEng, IndeedScraperUSAEng ) + +# from jobfunnel.backend.scrapers.glassdoor.glassdoor_dynamic import ( +# GlassDoorDynamicScraperCAEng, + diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index a94bf49f..f94c201f 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -19,12 +19,11 @@ class BaseScraper(ABC): TODO: accept filters: List[Filter] here if we have Filter(ABC) NOTE: we want to use filtering here because scraping blurbs can be slow. """ - - @abstractmethod def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: - # TODO: can we set self.session etc so inherited classes don't have to? - pass + self.session = session + self.config = config + self.logger = logger @property def bs4_parser(self) -> str: diff --git a/jobfunnel/backend/scrapers/glassdoor/__init__.py b/jobfunnel/backend/scrapers/glassdoor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jobfunnel/backend/scrapers/glassdoor/base.py b/jobfunnel/backend/scrapers/glassdoor/base.py new file mode 100644 index 00000000..5ee9169a --- /dev/null +++ b/jobfunnel/backend/scrapers/glassdoor/base.py @@ -0,0 +1,126 @@ +"""Base Glassdoor Scraper used by both the selenium and statis scrapers +""" +import logging +from requests import Session +from typing import Dict, List, Tuple, Optional, Union + +from jobfunnel.backend.scrapers import BaseScraper +from jobfunnel.backend.localization import Locale, get_domain_from_locale + + +MAX_LOCATIONS_TO_RETURN = 10 +LOCATION_BASE_URL = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' +MAX_RESULTS_PER_GLASSDOOR_PAGE = 30 +GLASSDOOR_RADIUS_MAP = { + 0: 0, + 10: 6, + 20: 12, + 30: 19, + 50: 31, + 100: 62, + 200: 124, +} + +class GlassDoorBase(BaseScraper): + + def __init__(self, session: Session, config: 'JobFunnelConfig', + logger: logging.Logger): + """Init that contains glassdoor specific stuff + """ + super().__init__(session, config, logger) + self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE + self.query_string = '-'.join(self.config.search_terms.keywords) + + def get_search_url(self, + method='get') -> Union[str, Tuple[str, Dict[str,str]]]: + """Gets the glassdoor search url + NOTE: we this relies on your city, not the state / province! + """ + # Form the location lookup request data + data = { + 'term': self.config.search_terms.city, + 'maxLocationsToReturn': MAX_LOCATIONS_TO_RETURN + } + + # Get the location id for search location + location_response = self.session.post( + LOCATION_BASE_URL, headers=self.headers, data=data + ).json() + + if method == 'get': + + # Form job search url + search = ( + 'https://www.glassdoor.{}/Job/jobs.htm?clickSource=searchBtn' + '&sc.keyword={}&locT=C&locId={}&jobType=&radius={}'.format( + get_domain_from_locale(self.locale), + self.query_string, + location_response[0]['locationId'], + self.quantize_radius(self.config.search_terms.radius), + ) + ) + return search + + elif method == 'post': + + # Form the job search url + search = "https://www.glassdoor.{}/Job/jobs.htm".format( + get_domain_from_locale(self.locale) + ) + + # Form the job search data + data = { + 'clickSource': 'searchBtn', + 'sc.keyword': self.query_string, + 'locT': 'C', + 'locId': location_response[0]['locationId'], + 'jobType': '', + 'radius': self.quantize_radius( + self.config.search_terms.radius + ), + } + + return search, data + else: + + raise ValueError(f'No html method {method} exists') + + + def quantize_radius(self, radius): + """function that quantizes the user input radius to a valid radius + value: 10, 20, 30, 50, 100, and 200 kilometers + FIXME: use numpy.digitize instead + """ + if self.locale == Locale.USA_ENGLISH: + if radius < 5: + radius = 0 + elif 5 <= radius < 10: + radius = 5 + elif 10 <= radius < 15: + radius = 10 + elif 15 <= radius < 25: + radius = 15 + elif 25 <= radius < 50: + radius = 25 + elif 50 <= radius < 100: + radius = 50 + elif radius >= 100: + radius = 100 + return radius + else: + if radius < 10: + radius = 0 + elif 10 <= radius < 20: + radius = 10 + elif 20 <= radius < 30: + radius = 20 + elif 30 <= radius < 50: + radius = 30 + elif 50 <= radius < 100: + radius = 50 + elif 100 <= radius < 200: + radius = 100 + elif radius >= 200: + radius = 200 + + return GLASSDOOR_RADIUS_MAP[radius] diff --git a/jobfunnel/backend/scrapers/glassdoor/dynamic.py b/jobfunnel/backend/scrapers/glassdoor/dynamic.py new file mode 100644 index 00000000..4c3c2436 --- /dev/null +++ b/jobfunnel/backend/scrapers/glassdoor/dynamic.py @@ -0,0 +1,54 @@ +"""Base class for scraping jobs from GlassDoor +""" +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor, wait +import logging +from requests import post, Session +from typing import Dict, List, Tuple, Optional + +from jobfunnel.backend import Job +from jobfunnel.backend.tools import get_webdriver +from jobfunnel.backend.localization import Locale, get_domain_from_locale +from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase + + +class GlassDoorDynamic(GlassDoorBase): + """The Dynamic Version of the GlassDoor scraper, that uses selenium to scrape job postings. + """ + + def __init__(self, session: Session, config: 'JobFunnelConfig', + logger: logging.Logger): + """Init""" + super().__init__(session, config, logger) + self.driver = get_webdriver() + + def scrape(self): + # FIXME: impl! + pass + + +class GlassDoorDynamicCAEng(GlassDoorDynamic): + + @property + def locale(self) -> Locale: + """Get the localizations that this scraper was built for + We will use this to put the right filters & scrapers together + """ + return Locale.CANADA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + return{ + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + 'referer': 'https://www.glassdoor.{0}/'.format( + get_domain_from_locale(self.locale) + ), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } + diff --git a/jobfunnel/backend/scrapers/glassdoor_static.py b/jobfunnel/backend/scrapers/glassdoor/static.py similarity index 64% rename from jobfunnel/backend/scrapers/glassdoor_static.py rename to jobfunnel/backend/scrapers/glassdoor/static.py index 8c2a5d8f..ddb1c0f7 100644 --- a/jobfunnel/backend/scrapers/glassdoor_static.py +++ b/jobfunnel/backend/scrapers/glassdoor/static.py @@ -1,134 +1,85 @@ -import re - +""" +""" +from abc import ABC, abstractmethod from bs4 import BeautifulSoup from concurrent.futures import ThreadPoolExecutor, wait -from logging import info as log_info -from math import ceil -from time import sleep, time +import logging +from requests import post, Session +import re +from typing import Dict, List, Tuple, Optional +import time -from .jobfunnel import JobFunnel, MASTERLIST_HEADER -from .tools.tools import filter_non_printables -from .tools.tools import post_date_from_relative_post_age -from .glassdoor_base import GlassDoorBase +from jobfunnel.backend import Job +from jobfunnel.backend.localization import Locale, get_domain_from_locale +from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase class GlassDoorStatic(GlassDoorBase): - def __init__(self, args): - super().__init__(args) - self.provider = 'glassdoorstatic' - self.headers = { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.glassdoor.{0}/'.format( - self.search_terms['region']['domain'] - ), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } + def __init__(self, session: Session, config: 'JobFunnelConfig', + logger: logging.Logger): + """Init + """ + super().__init__(session, config, logger) # Sets headers as default on Session object - self.s.headers.update(self.headers) + self.session.headers.update(self.headers) # Concatenates keywords with '-' - self.query = ' '.join(self.search_terms['keywords']) - - def get_search_url(self, method='get'): - """gets the glassdoor search url""" - # form the location lookup request data - data = {'term': self.search_terms['region'] - ['city'], 'maxLocationsToReturn': 10} - - # form the location lookup url - location_url = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' - - # get location id for search location - location_response = self.s.post( - # set location headers to override default session headers - location_url, headers=self.location_headers, data=data - ).json() - - if method == 'get': - # @TODO implement get style for glassdoor - raise NotImplementedError() - elif method == 'post': - # form the job search url - search = ( - f'https://www.glassdoor.' - f"{self.search_terms['region']['domain']}/Job/jobs.htm" - ) - - # form the job search data - data = { - 'clickSource': 'searchBtn', - 'sc.keyword': self.query, - 'locT': 'C', - 'locId': location_response[0]['locationId'], - 'jobType': '', - 'radius': self.convert_radius(self.search_terms['region']['radius']), - } - - return search, data - else: - raise ValueError(f'No html method {method} exists') - - def search_page_for_job_soups(self, page, url, job_soup_list): - """function that scrapes the glassdoor page for a list of job soups""" - log_info(f'getting glassdoor page {page} : {url}') + self.query_string = ' '.join(self.search_terms['keywords']) + def search_page_for_job_soups(self, page, url, job_soup_list) -> None: + """Scrapes the glassdoor page for a list of job soups + TODO: document + """ + self.logger.info(f'Getting glassdoor page {page} : {url}') job = BeautifulSoup( - self.s.get(url).text, self.bs4_parser + self.session.get(url).text, self.bs4_parser ).find_all('li', attrs={'class', 'jl'}) job_soup_list.extend(job) - def search_joblink_for_blurb(self, job): - """function that scrapes the glassdoor job link for the blurb""" - search = job['link'] - log_info(f'getting glassdoor search: {search}') - + def set_description(self, job: Job) -> None: + """Scrapes the glassdoor job link for the description + TODO: document + """ + self.logger.info(f'Getting glassdoor search: {job.url}') job_link_soup = BeautifulSoup( - self.s.get(search).text, self.bs4_parser + self.session.get(job.url).text, self.bs4_parser ) - try: - job['blurb'] = job_link_soup.find( - id='JobDescriptionContainer').text.strip() + job.description = job_link_soup.find( + id='JobDescriptionContainer' + ).text.strip() + job.clean_strings() except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) - - # split apart above function into two so gotten blurbs can be parsed - # while others blurbs are being obtained - def get_blurb_with_delay(self, job, delay): - """gets blurb from glassdoor job link and sets delays for requests""" - sleep(delay) - - search = job['link'] - log_info(f'delay of {delay:.2f}s, getting glassdoor search: {search}') - - res = self.s.get(search).text - return job, res - - def scrape(self): - """function that scrapes job posting from glassdoor and pickles it""" - log_info(f'jobfunnel glassdoor to pickle running @ {self.date_string}') + self.logger.error(f"Unable to scrape description for: {job.url}") + job.description = '' + + def get_description_with_delay(self, job: Job, + delay: float) -> Tuple[Job, str]: + """Gets description from glassdoor job link with a request delay + NOTE: this is per-job + """ + time.sleep(delay) + self.logger.info( + f'Delay of {delay:.2f}s, getting glassdoor search: {job.url}' + ) + return job, self.session.get(job.url).text - # get the search url and data + def scrape(self) -> Dict[str, Job]: + """Scrapes job posting from glassdoor and pickles it + """ + # Get the search url and data search, data = self.get_search_url(method='post') - # get the html data, initialize bs4 with lxml - request_html = self.s.post(search, data=data) + # Get the html data + request_html = self.session.post(search, data=data) - # create the soup base + # Create the soup base soup_base = BeautifulSoup(request_html.text, self.bs4_parser) # scrape total number of results, and calculate the # pages needed num_res = soup_base.find( 'p', attrs={'class', 'jobsCount'}).text.strip() num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) - log_info( + self.logger.info( f'Found {num_res} glassdoor results for query=' f'{self.query}') pages = int(ceil(num_res / self.max_results_per_page)) @@ -256,7 +207,7 @@ def scrape(self): if self.delay_config is not None: # calls super class to run delay specific threading logic super().delay_threader( - scrape_list, self.get_blurb_with_delay, self.parse_blurb, threads + scrape_list, self.get_description_with_delay, self.parse_blurb, threads ) else: # maps jobs to threads and cleans them up when done @@ -264,9 +215,62 @@ def scrape(self): start = time() # maps jobs to threads and cleans them up when done - threads.map(self.search_joblink_for_blurb, scrape_list) + threads.map(self.set_description, scrape_list) threads.shutdown() # end and print recorded time end = time() print(f'{self.provider} scrape job took {(end - start):.3f}s') + + + +class GlassDoorStaticCAEng(GlassDoorStatic): + + @property + def locale(self) -> Locale: + """Get the localizations that this scraper was built for + We will use this to put the right filters & scrapers together + """ + return Locale.CANADA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + return{ + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + 'referer': 'https://www.glassdoor.{0}/'.format( + get_domain_from_locale(self.locale) + ), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } + + +class GlassDoorStaticUSAEng(GlassDoorStatic): + + @property + def locale(self) -> Locale: + """Get the localizations that this scraper was built for + We will use this to put the right filters & scrapers together + """ + return Locale.CANADA_ENGLISH + + @property + def headers(self) -> Dict[str, str]: + return{ + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-US;q=0.8,en;q=0.6', + 'referer': 'https://www.glassdoor.{0}/'.format( + get_domain_from_locale(self.locale) + ), + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } \ No newline at end of file diff --git a/jobfunnel/backend/scrapers/glassdoor_base.py b/jobfunnel/backend/scrapers/glassdoor_base.py deleted file mode 100644 index 6eb0bda4..00000000 --- a/jobfunnel/backend/scrapers/glassdoor_base.py +++ /dev/null @@ -1,92 +0,0 @@ -import re - -from bs4 import BeautifulSoup -from concurrent.futures import ThreadPoolExecutor, wait -from logging import info as log_info -from math import ceil -from requests import post -from time import sleep, time - -from .jobfunnel import JobFunnel, MASTERLIST_HEADER -from .tools.tools import filter_non_printables -from .tools.tools import post_date_from_relative_post_age - - -class GlassDoorBase(JobFunnel): - def __init__(self, args): - super().__init__(args) - self.provider = 'glassdoorbase' - self.max_results_per_page = 30 - self.delay = 0 - - self.location_headers = { - 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,' - 'image/webp,*/*;q=0.01', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.glassdoor.{0}/'.format( - self.search_terms['region']['domain'] - ), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } - - def convert_radius(self, radius): - """function that quantizes the user input radius to a valid radius - value: 10, 20, 30, 50, 100, and 200 kilometers""" - if self.search_terms['region']['domain'] == 'com': - if radius < 5: - radius = 0 - elif 5 <= radius < 10: - radius = 5 - elif 10 <= radius < 15: - radius = 10 - elif 15 <= radius < 25: - radius = 15 - elif 25 <= radius < 50: - radius = 25 - elif 50 <= radius < 100: - radius = 50 - elif radius >= 100: - radius = 100 - return radius - - else: - if radius < 10: - radius = 0 - elif 10 <= radius < 20: - radius = 10 - elif 20 <= radius < 30: - radius = 20 - elif 30 <= radius < 50: - radius = 30 - elif 50 <= radius < 100: - radius = 50 - elif 100 <= radius < 200: - radius = 100 - elif radius >= 200: - radius = 200 - - glassdoor_radius = {0: 0, - 10: 6, - 20: 12, - 30: 19, - 50: 31, - 100: 62, - 200: 124} - - return glassdoor_radius[radius] - - def parse_blurb(self, job, html): - """parses and stores job description into dict entry""" - job_link_soup = BeautifulSoup(html, self.bs4_parser) - - try: - job['blurb'] = job_link_soup.find( - id='JobDescriptionContainer').text.strip() - except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) diff --git a/jobfunnel/backend/scrapers/glassdoor_dynamic.py b/jobfunnel/backend/scrapers/glassdoor_dynamic.py deleted file mode 100644 index ffb753c1..00000000 --- a/jobfunnel/backend/scrapers/glassdoor_dynamic.py +++ /dev/null @@ -1,269 +0,0 @@ -import re - -from bs4 import BeautifulSoup -from selenium import webdriver -from concurrent.futures import ThreadPoolExecutor, wait -from logging import info as log_info -from math import ceil -from requests import post -from time import sleep, time - - -from .jobfunnel import JobFunnel, MASTERLIST_HEADER -from .tools.tools import filter_non_printables -from .tools.tools import post_date_from_relative_post_age, get_webdriver -from .glassdoor_base import GlassDoorBase - - -class GlassDoorDynamic(GlassDoorBase): - """The Dynamic Version of the GlassDoor scraper, that uses selenium to scrape job postings.""" - - def __init__(self, args): - super().__init__(args) - self.provider = 'glassdoordynamic' - - # Keeping old query function so this class does not break. - self.query = '-'.join(self.search_terms['keywords']) - # initialize the webdriver - self.driver = get_webdriver() - - def get_search_url(self, method='get'): - """gets the glassdoor search url""" - # form the location lookup request data - data = {'term': self.search_terms['region'] - ['city'], 'maxLocationsToReturn': 10} - - # form the location lookup url - location_url = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' - - # get the location id for search location - location_response = self.s.post( - location_url, headers=self.location_headers, data=data - ).json() - - if method == 'get': - # form job search url - search = ( - 'https://www.glassdoor.{0}/Job/jobs.htm?' - 'clickSource=searchBtn&sc.keyword={1}&locT=C&locId={2}&jobType=&radius={3}'.format( - self.search_terms['region']['domain'], - self.query, - location_response[0]['locationId'], - self.convert_radius(self.search_terms['region']['radius']), - ) - ) - - return search - elif method == 'post': - # form the job search url - search = ( - f'https://www.glassdoor.' - f"{self.search_terms['region']['domain']}/Job/jobs.htm" - ) - - # form the job search data - data = { - 'clickSource': 'searchBtn', - 'sc.keyword': self.query, - 'locT': 'C', - 'locId': location_response[0]['locationId'], - 'jobType': '', - 'radius': self.convert_radius(self.search_terms['region']['radius']), - } - - return search, data - else: - raise ValueError(f'No html method {method} exists') - - def search_page_for_job_soups(self, page, url, job_soup_list): - """function that scrapes the glassdoor page for a list of job soups""" - log_info(f'getting glassdoor page {page} : {url}') - - self.driver.get(url) - job = BeautifulSoup(self.driver.page_source, self.bs4_parser).find_all( - 'li', attrs={'class', 'jl'} - ) - job_soup_list.extend(job) - - def search_joblink_for_blurb(self, job): - """function that scrapes the glassdoor job link for the blurb""" - search = job['link'] - log_info(f'getting glassdoor search: {search}') - - self.driver.get(search) - job_link_soup = BeautifulSoup(self.driver.page_source, self.bs4_parser) - - try: - job['blurb'] = job_link_soup.find( - id='JobDescriptionContainer').text.strip() - except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) - - # split apart above function into two so gotten blurbs can be parsed - # while others blurbs are being obtained - def get_blurb_with_delay(self, job, delay): - """gets blurb from glassdoor job link and sets delays for requests""" - sleep(delay) - - search = job['link'] - log_info(f'delay of {delay:.2f}s, getting glassdoor search: {search}') - - self.driver.get(search) - res = self.driver.page_source - return job, res - - def scrape(self): - """function that scrapes job posting from glassdoor and pickles it""" - log_info(f'jobfunnel glassdoor to pickle running @ {self.date_string}') - - # get the se arch url - search = self.get_search_url() - - # get the html data, initialize bs4 with lxml - self.driver.get(search) - - # create the soup base - soup_base = BeautifulSoup(self.driver.page_source, self.bs4_parser) - num_res = soup_base.find('p', attrs={ - 'class', 'jobsCount'}) - while(num_res is None): - print("It looks like that Glassdoor might require you to fill out a CAPTCHA form. Follow these steps if it does ask you to complete a CAPTCHA:" - "\n 1.Refresh the glassdoor site in the new browser window that just popped up.\n" " 2.Then complete the CAPTCHA in the browser.\n 3.Press Enter to continue") - # wait for user to complete CAPTCHA - input() - soup_base = BeautifulSoup(self.driver.page_source, self.bs4_parser) - num_res = soup_base.find('p', attrs={'class', 'jobsCount'}) - # scrape total number of results, and calculate the # pages needed - - num_res = num_res.text.strip() - num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) - log_info( - f'Found {num_res} glassdoor results for query=' f'{self.query}') - - pages = int(ceil(num_res / self.max_results_per_page)) - - # init list of job soups - job_soup_list = [] - # init threads - threads = ThreadPoolExecutor(max_workers=1) - # init futures list - fts = [] - - # search the pages to extract the list of job soups - for page in range(1, pages + 1): - if page == 1: - fts.append( # append thread job future to futures list - threads.submit( - self.search_page_for_job_soups, - page, - self.driver.current_url, - job_soup_list, - ) - ) - else: - # gets partial url for next page - part_url = ( - soup_base.find('li', attrs={'class', 'next'}).find( - 'a').get('href') - ) - # uses partial url to construct next page url - page_url = re.sub( - r'.htm', - 'IP' + str(page) + '.htm', - f'https://www.glassdoor.' - f"{self.search_terms['region']['domain']}" - f'{part_url}', - ) - - fts.append( # append thread job future to futures list - threads.submit( - self.search_page_for_job_soups, page, page_url, job_soup_list - ) - ) - wait(fts) # wait for all scrape jobs to finish - # close and shutdown the web driver - self.driver.close() - # make a dict of job postings from the listing briefs - for s in job_soup_list: - # init dict to store scraped data - job = dict([(k, '') for k in MASTERLIST_HEADER]) - - # scrape the post data - job['status'] = 'new' - try: - # jobs should at minimum have a title, company and location - job['title'] = s.find_all('a', attrs={'class', 'jobTitle'})[ - 1 - ].text.strip() - job['company'] = s.find( - 'div', attrs={'class', 'jobEmpolyerName'} - ).text.strip() - job['location'] = s.find( - 'span', attrs={'class', 'loc'}).text.strip() - except AttributeError: - continue - - # set blurb to none for now - job['blurb'] = '' - - try: - labels = s.find_all('div', attrs={'class', 'jobLabel'}) - job['tags'] = '\n'.join( - [l.text.strip() for l in labels if l.text.strip() != 'New'] - ) - except AttributeError: - job['tags'] = '' - - try: - # dynamic way of fetching date - job['date'] = s.find('div', attrs={ - 'class', 'd-flex align-items-end pl-std minor css-65p68w'}).text.strip() - except AttributeError: - job['date'] = '' - - try: - job['id'] = s.get('data-id') - job['link'] = ( - s.find('div', attrs={'class', 'logoWrap'}).find( - 'a').get('href') - ) - - except (AttributeError, IndexError): - job['id'] = '' - job['link'] = '' - - job['query'] = self.query - job['provider'] = self.provider - - # key by id - self.scrape_data[str(job['id'])] = job - - # Do not change the order of the next three statements if you want date_filter to work - - # stores references to jobs in list to be used in blurb retrieval - scrape_list = [i for i in self.scrape_data.values()] - # converts job date formats into a standard date format - post_date_from_relative_post_age(scrape_list) - # apply job pre-filter before scraping blurbs - super().pre_filter(self.scrape_data, self.provider) - - # checks if delay is set or not, then extracts blurbs from job links - if self.delay_config is not None: - # calls super class to run delay specific threading logic - super().delay_threader( - scrape_list, self.get_blurb_with_delay, self.parse_blurb, threads - ) - - else: # maps jobs to threads and cleans them up when done - # start time recording - start = time() - - # maps jobs to threads and cleans them up when done - threads.map(self.search_joblink_for_blurb, scrape_list) - threads.shutdown() - - # end and print recorded time - end = time() - print(f'{self.provider} scrape job took {(end - start):.3f}s') diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 61c61a7e..53bba195 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -28,6 +28,8 @@ RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') RECENT_REGEX_B = re.compile(r'[yY]esterday') +MAX_RESULTS_PER_INDEED_PAGE = 50 + class BaseIndeedScraper(BaseScraper): """Scrapes jobs from www.indeed.X @@ -36,10 +38,8 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: """Init that contains indeed specific stuff """ - self.session = session - self.config = config - self.logger = logger - self.max_results_per_page = 50 + super().__init__(session, config, logger) + self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE self.query = '+'.join(self.config.search_terms.keywords) def scrape(self) -> Dict[str, Job]: diff --git a/jobfunnel/backend/tools/__init__.py b/jobfunnel/backend/tools/__init__.py index e69de29b..ac522fd2 100644 --- a/jobfunnel/backend/tools/__init__.py +++ b/jobfunnel/backend/tools/__init__.py @@ -0,0 +1 @@ +from jobfunnel.backend.tools.tools import get_webdriver diff --git a/jobfunnel/config/cli_parser.py b/jobfunnel/config/cli_parser.py index f076a9bc..d3ff49a4 100644 --- a/jobfunnel/config/cli_parser.py +++ b/jobfunnel/config/cli_parser.py @@ -7,6 +7,7 @@ from jobfunnel.config.valid_options import CONFIG_TYPES from jobfunnel.backend.tools.tools import split_url +from jobfunnel.resources import DEFAULT_YAML_PATH log_levels = {'critical': logging.CRITICAL, 'error': logging.ERROR, @@ -243,10 +244,7 @@ def parse_config(): """ # load the default settings - default_yaml_path = os.path.join( - os.path.normpath(os.path.dirname(__file__)), 'settings.yaml' - ) - default_yaml = yaml.safe_load(open(default_yaml_path, 'r')) + default_yaml = yaml.safe_load(open(DEFAULT_YAML_PATH, 'r')) # parse the command line arguments cli = parse_cli() diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 172f508d..9f15c3d7 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -10,10 +10,13 @@ SCRAPER_MAP = { + # FIXME: make user say 'indeed' and then have it figure out via their + # search terms which one to use 'indeed': IndeedScraperCAEng, # TODO: deprecate and enforce below options 'INDEED_CANADA_ENG': IndeedScraperCAEng, 'INDEED_USA_ENG': IndeedScraperUSAEng, - #'monster': MonsterScraperCAEng, FIXME + #'glassdoor': + # 'monster': MonsterScraperCAEng, FIXME #'MONSTER_CANADA_ENG': MonsterScraperCAEng, } diff --git a/jobfunnel/resources/__init__.py b/jobfunnel/resources/__init__.py index 77edaafe..dd6b8cd7 100644 --- a/jobfunnel/resources/__init__.py +++ b/jobfunnel/resources/__init__.py @@ -1 +1,3 @@ -from jobfunnel.resources.resources import USER_AGENT_LIST, CSV_HEADER +from jobfunnel.resources.resources import ( + USER_AGENT_LIST, CSV_HEADER, DEFAULT_YAML_PATH +) diff --git a/jobfunnel/config/settings.yaml b/jobfunnel/resources/default_settings.yaml similarity index 95% rename from jobfunnel/config/settings.yaml rename to jobfunnel/resources/default_settings.yaml index 224ae2c5..25870fa6 100644 --- a/jobfunnel/config/settings.yaml +++ b/jobfunnel/resources/default_settings.yaml @@ -7,9 +7,9 @@ output_path: 'search' # providers from which to search (case insensitive) providers: - - 'Indeed' -# - 'GlassDoorStatic' # FIXME - # - 'Monster' + # - 'glassdoor' + - 'indeed' + # - 'Monster_CANADA_ENG' diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 4f9eacca..1968229d 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -2,7 +2,6 @@ """ import os - CSV_HEADER = [ 'status', 'title', 'company', 'location', 'date', 'blurb', 'tags', 'link', 'id', 'provider', 'query', 'locale' @@ -20,3 +19,7 @@ li = line.strip() if li and not li.startswith("#"): USER_AGENT_LIST.append(line.rstrip('\n')) + +DEFAULT_YAML_PATH = os.path.join( + os.path.normpath(os.path.dirname(__file__)), 'default_settings.yaml' +) From 562f300695e3da896c3e0173626d06607fd2f739 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Wed, 5 Aug 2020 21:20:36 -0400 Subject: [PATCH 07/66] more progress, converging on having job setters within base scraper, and using a locale in YAML --- jobfunnel/backend/localization.py | 12 +- jobfunnel/backend/scrapers/__init__.py | 13 +- jobfunnel/backend/scrapers/base.py | 277 +++++++++- jobfunnel/backend/scrapers/glassdoor/base.py | 16 +- .../backend/scrapers/glassdoor/dynamic.py | 18 +- .../backend/scrapers/glassdoor/static.py | 512 +++++++++--------- jobfunnel/backend/scrapers/indeed.py | 411 +++++--------- jobfunnel/config/funnel.py | 47 +- 8 files changed, 716 insertions(+), 590 deletions(-) diff --git a/jobfunnel/backend/localization.py b/jobfunnel/backend/localization.py index f1af8d84..a56e2473 100644 --- a/jobfunnel/backend/localization.py +++ b/jobfunnel/backend/localization.py @@ -8,18 +8,25 @@ class Locale(Enum): """This will allow Scrapers / Filters / Main to identify the support they have for different domains of different websites - TODO: better way using the locale module? + NOTE: add locales here as you need them, we do them per-country per-language + becuase scrapers are written per-language-per-country as this matches + how the information is served by job websites. """ UNKNOWN = 1 CANADA_ENGLISH = 2 CANADA_FRENCH = 3 + CANADA_MANDARIN = 4 + CANADA_CANTONESE = 5 + CANADA_PUNJABI = 6 + CANADA_SPANISH = 7 USA_ENGLISH = 4 def get_domain_from_locale(locale: Locale) -> str: """Get a domain string from the locale Enum - TODO: we may want something more flexible in the future. + NOTE: if you want to override this you can always set domain in headers + directly without using this method """ if locale in [Locale.CANADA_ENGLISH, Locale.CANADA_FRENCH]: return 'ca' @@ -27,4 +34,3 @@ def get_domain_from_locale(locale: Locale) -> str: return 'com' else: raise ValueError(f"Unknown domain string for locale {locale}") - diff --git a/jobfunnel/backend/scrapers/__init__.py b/jobfunnel/backend/scrapers/__init__.py index e3bf79f4..cba03adf 100644 --- a/jobfunnel/backend/scrapers/__init__.py +++ b/jobfunnel/backend/scrapers/__init__.py @@ -1,8 +1,9 @@ -from jobfunnel.backend.scrapers.base import BaseScraper +from jobfunnel.backend.scrapers.base import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper, +) from jobfunnel.backend.scrapers.indeed import ( - IndeedScraperCAEng, IndeedScraperUSAEng + IndeedScraperCAEng, IndeedScraperUSAEng, +) +from jobfunnel.backend.scrapers.glassdoor.static import ( + GlassDoorStaticCAEng, GlassDoorStaticUSAEng, ) - -# from jobfunnel.backend.scrapers.glassdoor.glassdoor_dynamic import ( -# GlassDoorDynamicScraperCAEng, - diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index f94c201f..83dc05d7 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -1,18 +1,25 @@ """The base scraper class to be used for all web-scraping emitting Job objects """ from abc import ABC, abstractmethod +from bs4 import BeautifulSoup +from concurrent.futures import ThreadPoolExecutor, wait +import datetime import logging import os -from typing import Dict, List +from time import sleep, time +from typing import Dict, List, Tuple import random from requests import Session from jobfunnel.resources import USER_AGENT_LIST -from jobfunnel.backend import Job -from jobfunnel.backend.localization import Locale +from jobfunnel.backend.tools.delay import calculate_delays, delay_threader +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.localization import Locale, get_domain_from_locale #from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue +MAX_CPU_WORKERS = 8 + class BaseScraper(ABC): """Base scraper object, for generating List[Job] from a specific job source @@ -24,6 +31,15 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.session = session self.config = config self.logger = logger + self.session.headers.update(self.headers) + + @property + def domain(self) -> str: + """Get the domain string from the locale i.e. 'ca' + NOTE: if you have a special case for your locale (i.e. canadian .com) + inherit from BaseScraper and set this and locale in your Scraper class + """ + return get_domain_from_locale(self.locale) @property def bs4_parser(self) -> str: @@ -54,11 +70,260 @@ def headers(self) -> Dict[str, str]: """ pass - @abstractmethod def scrape(self) -> Dict[str, Job]: - """Scrapes raw data from a job source into a list of Job objects + """Scrape job source into a dict of unique jobs keyed by ID + """ + # Make a dict of job postings from the listing briefs + jobs_dict = {} # type: Dict[str, Job] + for job_soup in self.scrape_job_soups(): + + # Key by id to prevent duplicate key_ids FIXME: add a key-warning + job = self.scrape_job(job_soup) + jobs_dict[job.key_id] = job + + def _get_with_delay(self, job: Job, delay: float) -> Tuple[Job, str]: + """Get a job's page by the job url with a delay beforehand + """ + sleep(delay) + self.logger.info( + f'Delay of {delay:.2f}s, getting search results for: {job.url}' + ) + job_page_soup = BeautifulSoup( + self.session.get(job.url).text, self.bs4_parser + ) + return job, job_page_soup + + def _parse(self, job: Job, job_page_soup: BeautifulSoup) -> None: + """Set job.description + TODO: roll into our delay callback + """ + try: + self.get_short_job_description(job_page_soup) + except AttributeError: + self.logger.warning( + f"Unable to scrape short description for job {job.key_id}." + ) + job.clean_strings() + + # Scrape stuff that we are delaying for + # FIXME: this is hard-coded to delay scraping of descriptions only rn + # maybe we can just use a queue and calc delays on-the-fly in scrape_job + threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + jobs_list = list(jobs_dict.values()) + delays = calculate_delays(len(jobs_list), self.config.delay_config) + delay_threader( + jobs_list, _get_with_delay, _parse, threads, self.logger, delays + ) + + # FIXME: impl. once CSV supports it, indeed supports it and we make + # delaying more flexible (i.e. queue) + # try: + # self.get_short_job_description(job_soup) + # except AttributeError: + # self.logger.warning( + # f"Unable to scrape short description for job {key_id}." + # ) + + return jobs_dict + + def scrape_job(self, job_soup: BeautifulSoup) -> Job: + """Scrapes a search page and get a list of soups that will yield jobs + + NOTE: does not currently scrape anything that + + Returns: + Job: job object constructed from the soup and localization of class + """ + + # Init default values + status = JobStatus.NEW + post_date = datetime.datetime.now() + tags = [] # type: List[str] + title, company, location = None, None, None + key_id, url, short_description = None, None, None + + # Scrape the data for the post, requiring a minimum of info... + try: + # Jobs should at minimum have a title, company and location + title = self.get_job_title(job_soup) + company = self.get_job_company(job_soup) + location = self.get_job_location(job_soup) + key_id = self.get_job_key_id(job_soup) + url = self.get_job_url(key_id) + except Exception as err: + # TODO: decide how we should handle these, proceed or exit? + raise ValueError( + "Unable to scrape minimum-required job info!\nerror:" + str(err) + ) + + try: + tags = self.get_job_tags(job_soup) + except AttributeError: + self.logger.warning(f"Unable to scrape tags for job {key_id}") + + try: + post_date = self.get_job_date(job_soup) + except (AttributeError, ValueError): + self.logger.warning( + f"Unknown date for job {key_id}, setting to datetime.now()." + ) + + # Init a new job from scraped data + job = Job( + title=title, + company=company, + location=location, + description='', # We will populate this later per-job-page + key_id=key_id, + url=url, + locale=self.locale, + query='', #self.query_string, FIXME + status=status, + provider='', #self.__class___.__name__, FIXME + short_description='', # We will populate this later per-job-page + post_date=post_date, + raw='', # FIXME: we cannot pickle the soup object (job_soup) + tags=tags, + ) + + # TODO: make these calls work here, maybe use a queue with delaying? + # These calls require additional get using job.url + # try: + # self.get_job_description(job, job_soup) + # except AttributeError: + # self.logger.warning( + # f"Unable to scrape description for job {key_id}." + # ) + + # try: + # self.get_short_job_description(job_soup) + # except AttributeError: + # self.logger.warning( + # f"Unable to scrape short description for job {key_id}." + # ) + + return job + + # FIXME: review below types and complete docstrings + + @abstractmethod + def scrape_job_soups(self) -> List[BeautifulSoup]: + """Generate a list of soups for each job object. + i.e. the job listing on a search results page. + NOTE: you can use job soups to get more detailed listings later + i.e self.get('details_from_job_page') -> make get request to load desc. + """ + pass + + # TODO: this might be more elegant: + # @abstractmethod + # def get(self, parameter: str, + # soup: BeautifulSoup) -> Union[str, List[str], date]: + # """Get a single job attribute from a soup object + # i.e. get 'description' --> str + # """ + + @abstractmethod + def get_job_url(self, job_soup: BeautifulSoup) -> str: + """Get job url from a job soup + Args: + job_soup: BeautifulSoup base to scrape the title from. + Returns: + Title of the job (i.e. 'Secret Shopper') + """ + pass + + @abstractmethod + def get_job_title(self, job_soup: BeautifulSoup) -> str: + """Get job title from soup + Args: + job_soup: BeautifulSoup base to scrape the title from. + Returns: + Title of the job (i.e. 'Secret Shopper') + """ + pass + + @abstractmethod + def get_job_company(self, job_soup: BeautifulSoup) -> str: + """Get job company name from soup + Args: + job_soup: BeautifulSoup base to scrape the company from. + Returns: + Company name (i.e. 'Aperture Science') + """ + pass + + @abstractmethod + def get_job_location(self, job_soup: BeautifulSoup) -> str: + """Get job location string + TODO: we should have a better format than str for this. + """ + pass + @abstractmethod + def get_job_tags(self, job_soup: BeautifulSoup) -> List[str]: + """Fetches the job tags / keywords from a BeautifulSoup base. + """ + pass + + @abstractmethod + def get_job_post_date(self, job_soup: BeautifulSoup) -> datetime.date: + """Fetches the job date from a BeautifulSoup base. + Args: + soup: BeautifulSoup base to scrape the date from. + Returns: + date of the job's posting + """ + pass + + @abstractmethod + def get_job_key_id(self, job_soup: BeautifulSoup) -> str: + """Fetches the job id from a BeautifulSoup base. + NOTE: this should be unique, but we should probably use our own SHA + Args: + soup: BeautifulSoup base to scrape the id from. Returns: - List[Job]: list of jobs scraped from the job source + The job id scraped from soup. + Note that this function may throw an AttributeError if it cannot + find the id. The caller is expected to handle this exception. """ pass + + # FIXME: do we want all of these to take in a Job object? might be useful? + # ... the first in the chain would have job = None for its call though... + @abstractmethod + def get_job_description(self, job: Job, + job_soup: BeautifulSoup = None) -> None: + """Parses and stores job description html and sets Job.description + NOTE: this accepts Job because it allows using other job attributes + to make new session.get() for job-specific information. + """ + pass + + @abstractmethod + def get_short_job_description(self, job: Job, + job_soup: BeautifulSoup = None) -> None: + """Parses and stores job description from a job's page HTML + NOTE: this accepts Hob because it allows using other job attributes + to make new session.get() for job-specific information. + """ + pass + + +# Just some basic localized scrapers, can inherit these to set locale as well. + +class BaseUSAEngScraper(BaseScraper): + """Localized scraper for USA English + """ + @property + def locale(self) -> Locale: + return Locale.USA_ENGLISH + + +class BaseCANEngScraper(BaseScraper): + """Localized scraper for Canada English + """ + @property + def locale(self) -> Locale: + return Locale.USA_ENGLISH + diff --git a/jobfunnel/backend/scrapers/glassdoor/base.py b/jobfunnel/backend/scrapers/glassdoor/base.py index 5ee9169a..fc2adac1 100644 --- a/jobfunnel/backend/scrapers/glassdoor/base.py +++ b/jobfunnel/backend/scrapers/glassdoor/base.py @@ -31,6 +31,20 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE self.query_string = '-'.join(self.config.search_terms.keywords) + @property + def headers(self) -> Dict[str, str]: + return{ + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + 'referer': f'https://www.glassdoor.{self.domain}/', + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } + def get_search_url(self, method='get') -> Union[str, Tuple[str, Dict[str,str]]]: """Gets the glassdoor search url @@ -86,7 +100,7 @@ def get_search_url(self, raise ValueError(f'No html method {method} exists') - def quantize_radius(self, radius): + def quantize_radius(self, radius: int) -> int: """function that quantizes the user input radius to a valid radius value: 10, 20, 30, 50, 100, and 200 kilometers FIXME: use numpy.digitize instead diff --git a/jobfunnel/backend/scrapers/glassdoor/dynamic.py b/jobfunnel/backend/scrapers/glassdoor/dynamic.py index 4c3c2436..ba66410c 100644 --- a/jobfunnel/backend/scrapers/glassdoor/dynamic.py +++ b/jobfunnel/backend/scrapers/glassdoor/dynamic.py @@ -8,7 +8,7 @@ from jobfunnel.backend import Job from jobfunnel.backend.tools import get_webdriver -from jobfunnel.backend.localization import Locale, get_domain_from_locale +from jobfunnel.backend.localization import Locale from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase @@ -36,19 +36,3 @@ def locale(self) -> Locale: """ return Locale.CANADA_ENGLISH - @property - def headers(self) -> Dict[str, str]: - return{ - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.glassdoor.{0}/'.format( - get_domain_from_locale(self.locale) - ), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } - diff --git a/jobfunnel/backend/scrapers/glassdoor/static.py b/jobfunnel/backend/scrapers/glassdoor/static.py index ddb1c0f7..c7246eb4 100644 --- a/jobfunnel/backend/scrapers/glassdoor/static.py +++ b/jobfunnel/backend/scrapers/glassdoor/static.py @@ -4,273 +4,269 @@ from bs4 import BeautifulSoup from concurrent.futures import ThreadPoolExecutor, wait import logging +import math from requests import post, Session import re from typing import Dict, List, Tuple, Optional import time from jobfunnel.backend import Job -from jobfunnel.backend.localization import Locale, get_domain_from_locale +from jobfunnel.backend.localization import Locale +from jobfunnel.backend.scrapers import BaseCANEngScraper, BaseUSAEngScraper from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase class GlassDoorStatic(GlassDoorBase): def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger): - """Init - """ - super().__init__(session, config, logger) - # Sets headers as default on Session object - self.session.headers.update(self.headers) - # Concatenates keywords with '-' - self.query_string = ' '.join(self.search_terms['keywords']) - - def search_page_for_job_soups(self, page, url, job_soup_list) -> None: - """Scrapes the glassdoor page for a list of job soups - TODO: document - """ - self.logger.info(f'Getting glassdoor page {page} : {url}') - job = BeautifulSoup( - self.session.get(url).text, self.bs4_parser - ).find_all('li', attrs={'class', 'jl'}) - job_soup_list.extend(job) - - def set_description(self, job: Job) -> None: - """Scrapes the glassdoor job link for the description - TODO: document - """ - self.logger.info(f'Getting glassdoor search: {job.url}') - job_link_soup = BeautifulSoup( - self.session.get(job.url).text, self.bs4_parser - ) - try: - job.description = job_link_soup.find( - id='JobDescriptionContainer' - ).text.strip() - job.clean_strings() - except AttributeError: - self.logger.error(f"Unable to scrape description for: {job.url}") - job.description = '' - - def get_description_with_delay(self, job: Job, - delay: float) -> Tuple[Job, str]: - """Gets description from glassdoor job link with a request delay - NOTE: this is per-job - """ - time.sleep(delay) - self.logger.info( - f'Delay of {delay:.2f}s, getting glassdoor search: {job.url}' - ) - return job, self.session.get(job.url).text - - def scrape(self) -> Dict[str, Job]: - """Scrapes job posting from glassdoor and pickles it - """ - # Get the search url and data - search, data = self.get_search_url(method='post') - - # Get the html data - request_html = self.session.post(search, data=data) - - # Create the soup base - soup_base = BeautifulSoup(request_html.text, self.bs4_parser) - - # scrape total number of results, and calculate the # pages needed - num_res = soup_base.find( - 'p', attrs={'class', 'jobsCount'}).text.strip() - num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) - self.logger.info( - f'Found {num_res} glassdoor results for query=' f'{self.query}') - - pages = int(ceil(num_res / self.max_results_per_page)) - - # init list of job soups - job_soup_list = [] - # init threads - threads = ThreadPoolExecutor(max_workers=8) - # init futures list - fts = [] - - # search the pages to extract the list of job soups - for page in range(1, pages + 1): - if page == 1: - fts.append( # append thread job future to futures list - threads.submit( - self.search_page_for_job_soups, - page, - request_html.url, - job_soup_list, - ) - ) - else: - # gets partial url for next page - part_url = ( - soup_base.find('li', attrs={'class', 'next'}).find( - 'a').get('href') - ) - # uses partial url to construct next page url - page_url = re.sub( - r'_IP\d+\.', - '_IP' + str(page) + '.', - f'https://www.glassdoor.' - f"{self.search_terms['region']['domain']}" - f'{part_url}', - ) - - fts.append( # append thread job future to futures list - threads.submit( - self.search_page_for_job_soups, - page, - page_url, - job_soup_list, - ) - ) - wait(fts) # wait for all scrape jobs to finish - - # make a dict of job postings from the listing briefs - for s in job_soup_list: - # init dict to store scraped data - job = dict([(k, '') for k in MASTERLIST_HEADER]) - - # scrape the post data - job['status'] = 'new' - try: - # jobs should at minimum have a title, company and location - job['title'] = ( - s.find('div', attrs={'class', 'jobContainer'}) - .find( - 'a', - attrs={'class', 'jobLink jobInfoItem jobTitle'}, - recursive=False, - ) - .text.strip() - ) - job['company'] = s.find( - 'div', attrs={'class', 'jobInfoItem jobEmpolyerName'} - ).text.strip() - job['location'] = s.get('data-job-loc') - except AttributeError: - continue - - # set blurb to none for now - job['blurb'] = '' - - try: - labels = s.find_all('div', attrs={'class', 'jobLabel'}) - job['tags'] = '\n'.join( - [l.text.strip() for l in labels if l.text.strip() != 'New'] - ) - except AttributeError: - job['tags'] = '' - - try: - job['date'] = ( - s.find('div', attrs={'class', 'jobLabels'}) - .find('span', attrs={'class', 'jobLabel nowrap'}) - .text.strip() - ) - except AttributeError: - job['date'] = '' - - try: - part_url = ( - s.find('div', attrs={'class', 'logoWrap'}).find( - 'a').get('href') - ) - job['id'] = s.get('data-id') - job['link'] = ( - f'https://www.glassdoor.' - f"{self.search_terms['region']['domain']}" - f'{part_url}' - ) - - except (AttributeError, IndexError): - job['id'] = '' - job['link'] = '' - - job['query'] = self.query - job['provider'] = self.provider - - # key by id - self.scrape_data[str(job['id'])] = job - - # Do not change the order of the next three statements if you want date_filter to work - - # stores references to jobs in list to be used in blurb retrieval - scrape_list = [i for i in self.scrape_data.values()] - # converts job date formats into a standard date format - post_date_from_relative_post_age(scrape_list) - # apply job pre-filter before scraping blurbs - super().pre_filter(self.scrape_data, self.provider) - - # checks if delay is set or not, then extracts blurbs from job links - if self.delay_config is not None: - # calls super class to run delay specific threading logic - super().delay_threader( - scrape_list, self.get_description_with_delay, self.parse_blurb, threads - ) - - else: # maps jobs to threads and cleans them up when done - # start time recording - start = time() - - # maps jobs to threads and cleans them up when done - threads.map(self.set_description, scrape_list) - threads.shutdown() - - # end and print recorded time - end = time() - print(f'{self.provider} scrape job took {(end - start):.3f}s') - - - -class GlassDoorStaticCAEng(GlassDoorStatic): - - @property - def locale(self) -> Locale: - """Get the localizations that this scraper was built for - We will use this to put the right filters & scrapers together - """ - return Locale.CANADA_ENGLISH - - @property - def headers(self) -> Dict[str, str]: - return{ - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.glassdoor.{0}/'.format( - get_domain_from_locale(self.locale) - ), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } - - -class GlassDoorStaticUSAEng(GlassDoorStatic): - - @property - def locale(self) -> Locale: - """Get the localizations that this scraper was built for - We will use this to put the right filters & scrapers together - """ - return Locale.CANADA_ENGLISH - - @property - def headers(self) -> Dict[str, str]: - return{ - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.glassdoor.{0}/'.format( - get_domain_from_locale(self.locale) - ), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } \ No newline at end of file + pass +# """Init +# """ +# super().__init__(session, config, logger) +# # Concatenates keywords with '-' +# self.query_string = ' '.join(self.config.search_terms.keywords) + +# def search_page_for_job_soups(self, page: str, url: str, +# job_soup_list: List[BeautifulSoup]) -> None: +# """Scrapes the glassdoor page for a list of job soups +# TODO: document +# """ +# self.logger.info(f'Getting glassdoor page {page} : {url}') +# job = BeautifulSoup( +# self.session.get(url).text, self.bs4_parser +# ).find_all('li', attrs={'class', 'jl'}) +# job_soup_list.extend(job) + +# def set_description(self, job: Job) -> None: +# """Scrapes the glassdoor job link for the description +# TODO: document +# """ +# self.logger.info(f'Getting glassdoor search: {job.url}') +# job_link_soup = BeautifulSoup( +# self.session.get(job.url).text, self.bs4_parser +# ) +# try: +# job.description = job_link_soup.find( +# id='JobDescriptionContainer' +# ).text.strip() +# job.clean_strings() +# except AttributeError: +# self.logger.error(f"Unable to scrape description for: {job.url}") +# job.description = '' + +# def get_description_with_delay(self, job: Job, +# delay: float) -> Tuple[Job, str]: +# """Gets description from glassdoor job link with a request delay +# NOTE: this is per-job +# """ +# time.sleep(delay) +# self.logger.info( +# f'Delay of {delay:.2f}s, getting glassdoor search: {job.url}' +# ) +# return job, self.session.get(job.url).text + +# def get_num_pages(self, soup_base: BeautifulSoup) -> int: +# """Scrape total number of results, and calculate the # pages needed +# """ +# num_res = soup_base.find( +# 'p', attrs={'class', 'jobsCount'}).text.strip() +# num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) +# self.logger.info( +# f"Found {num_res} glassdoor results for query='{self.query_string}'" +# ) +# return int(math.ceil(num_res / self.max_results_per_page)) + +# def get_page_url(self, soup_base: BeautifulSoup) -> str: +# """Get the next page URL +# """ +# # Gets partial url for next page +# partial_url = soup_base.find( +# 'li', attrs={'class', 'next'} +# ).find('a').get('href') + +# # Uses partial url to construct next page url +# page_url = re.sub( +# r'_IP\d+\.', +# f'_IP{page}.', +# f"https://www.glassdoor.{self.domain}{partial_url}", +# ) + +# def get_job_title(self, soup: BeautifulSoup) -> str: +# """Get the title from page soup +# """ +# return soup.find( +# 'div', attrs={'class', 'jobContainer'} +# ).find( +# 'a', +# attrs={'class', 'jobLink jobInfoItem jobTitle'}, +# recursive=False, +# ).text.strip() + +# def get_job_company(self, soup: BeautifulSoup) -> str: +# """Get the company name from page soup +# """ +# return soup.find( +# 'div', attrs={'class', 'jobInfoItem jobEmpolyerName'} +# ).text.strip() + +# def get_job_location(self, soup: BeautifulSoup) -> str: +# """Get job location from page soup +# """ +# return soup.get('data-job-loc') + +# def get_job_tags(self, soup: BeautifulSoup) -> List[str]: +# """Get tags metadata from page soup +# """ +# labels = soup.find_all('div', attrs={'class', 'jobLabel'}) +# return [l.text.strip() for l in labels if l.text.strip() != 'New'] + +# def scrape(self) -> Dict[str, Job]: +# """Scrapes job posting from glassdoor and pickles it +# """ +# # Get the search url and data +# search, data = self.get_search_url(method='post') + +# # Get the html data +# request_html = self.session.post(search, data=data) + +# # Create the soup from our overall search request +# soup_base = BeautifulSoup(request_html.text, self.bs4_parser) +# num_pages = self.get_num_pages(soup_base) + +# # Init list of job soups, threads and a list to populate +# threads = ThreadPoolExecutor(max_workers=8) +# job_soup_list = [] # type: List[BeautifulSoup] +# fts = [] # FIXME: type? + +# # Search the pages to extract the list of job soups +# for page in range(1, num_pages + 1): +# if page == 1: +# fts.append( +# threads.submit( +# self.search_page_for_job_soups, +# page, +# request_html.url, +# job_soup_list, +# ) +# ) +# else: +# page_url = self.get_page_url(soup_base) +# fts.append( +# threads.submit( +# self.search_page_for_job_soups, +# page, +# page_url, +# job_soup_list, +# ) +# ) +# # Wait for all scrape jobs to finish +# wait(fts) + +# # Get the job data from brief listings +# jobs_dict = {} # type: Dict[str, Job] +# for soup in job_soup_list: + +# status = JobStatus.NEW +# title, company, location, tags = None, None, None, [] +# post_date, key_id, url, short_description = None, None, None, None + +# try: +# # Min. required scraping data +# title = self.get_job_title(soup) +# company = self.get_job_company(soup) +# location = self.get_job_location(soup) +# except AttributeError: +# self.logger.error("Unable to scrape minimum-required job info!") +# continue + +# try: +# tags = self.get_job_tags(soup) +# except AttributeError: +# self.logger.warning(f"Unable to scrape job tags for {key_id}") + +# try: +# job['date'] = ( +# soup.find('div', attrs={'class', 'jobLabels'}) +# .find('span', attrs={'class', 'jobLabel nowrap'}) +# .text.strip() +# ) +# except AttributeError: +# job['date'] = '' + +# try: +# part_url = ( +# soup.find('div', attrs={'class', 'logoWrap'}).find( +# 'a').get('href') +# ) +# job['id'] = soup.get('data-id') +# job['link'] = ( +# f'https://www.glassdoor.' +# f"{self.search_terms['region']['domain']}" +# f'{part_url}' +# ) + +# except (AttributeError, IndexError): +# job['id'] = '' +# job['link'] = '' + +# job['query'] = self.query +# job['provider'] = self.provider + +# # key by id +# self.scrape_data[str(job['id'])] = job + + +# job = Job( +# title=title, +# company=company,v +# location=location, +# description='', # We will populate this later per-job-page +# key_id=key_id, +# url=url, +# locale=self.locale, +# query=self.query, +# status=status, +# provider='indeed', # FIXME: we should inherit this +# short_description=short_description, +# post_date=post_date, +# raw='', # FIXME: we cannot pickle the soup object (s) +# tags=tags, +# ) + +# # Do not change the order of the next three statements if you want date_filter to work + +# # stores references to jobs in list to be used in blurb retrieval +# scrape_list = [i for i in self.scrape_data.values()] +# # converts job date formats into a standard date format +# post_date_from_relative_post_age(scrape_list) +# # apply job pre-filter before scraping blurbs +# super().pre_filter(self.scrape_data, self.provider) + +# # checks if delay is set or not, then extracts blurbs from job links +# if self.delay_config is not None: +# # calls super class to run delay specific threading logic +# super().delay_threader( +# scrape_list, self.get_description_with_delay, self.parse_blurb, threads +# ) + +# else: # maps jobs to threads and cleans them up when done +# # start time recording +# start = time() + +# # maps jobs to threads and cleans them up when done +# threads.map(self.set_description, scrape_list) +# threads.shutdown() + +# # end and print recorded time +# end = time() +# print(f'{self.provider} scrape job took {(end - start):.3f}s') + + + +# These are the same exact logic, same website beyond the domain. +class GlassDoorStaticCAEng(GlassDoorStatic, BaseCANEngScraper): + pass + + +class GlassDoorStaticUSAEng(GlassDoorStatic, BaseUSAEngScraper): + pass diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 53bba195..bf092504 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -14,10 +14,10 @@ from bs4 import BeautifulSoup from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.localization import Locale, get_domain_from_locale -from jobfunnel.backend.scrapers import BaseScraper +from jobfunnel.backend.localization import Locale +from jobfunnel.backend.scrapers import BaseScraper, BaseCANEngScraper, BaseUSAEngScraper from jobfunnel.backend.tools.delay import calculate_delays, delay_threader -#from jobfunnel.config import JobFunnelConfig +#from jobfunnel.config import JobFunnelConfig # causes a circular import # Initialize list and store regex objects of date quantifiers TODO: refactor @@ -27,6 +27,7 @@ YEAR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?year') RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') RECENT_REGEX_B = re.compile(r'[yY]esterday') +ID_REGEX = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') MAX_RESULTS_PER_INDEED_PAGE = 50 @@ -42,11 +43,40 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE self.query = '+'.join(self.config.search_terms.keywords) - def scrape(self) -> Dict[str, Job]: - """Scrapes raw data from a job source into a list of Job objects + @property + def headers(self) -> Dict[str, str]: + """Session header for indeed.X + """ + return { + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + 'referer': f'https://www.indeed.{self.domain}/', + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + + def search_page_for_job_soups(self, search: str, page: str, + job_soup_list: List[BeautifulSoup]) -> None: + """Scrapes the indeed page for a list of job soups + NOTE: modifies the job_soup_list in-place + """ + url = f'{search}&start={int(page * self.max_results_per_page)}' + self.logger.info(f'getting indeed page {page} : {url}') + job_soup_list.extend( + BeautifulSoup( + self.session.get(url).text, self.bs4_parser + ).find_all('div', attrs={'data-tn-component': 'organicJob'}) + ) + + def scrape_job_soups(self) -> List[BeautifulSoup]: + """Scrapes raw data from a job source into a list of job-soups Returns: - List[Job]: list of jobs scraped from the job source + List[BeautifulSoup]: list of jobs soups we can use to make Job """ # Get the search url search = self.get_search_url() @@ -66,7 +96,7 @@ def scrape(self) -> Dict[str, Job]: # Init threads & futures list threads = ThreadPoolExecutor(max_workers=8) - fts = [] + fts = [] # FIXME: type? # Scrape soups for all the pages containing jobs it found for page in range(0, pages): @@ -80,102 +110,7 @@ def scrape(self) -> Dict[str, Job]: # Wait for all scrape jobs to finish wait(fts) - # make a dict of job postings from the listing briefs - jobs_dict = {} # type: Dict[str, Job] - for s in job_soup_list: - - # init - status = JobStatus.NEW - title, company, location, tags = None, None, None, [] - post_date, key_id, url, short_description = None, None, None, None - - # Scrape the data for the post, requiring a minimum of info... - try: - # Jobs should at minimum have a title, company and location - title = self.get_title(s) - company = self.get_company(s) - location = self.get_location(s) - key_id = self.get_id(s) - url = self.get_link(key_id) - except AttributeError: - self.logger.error("Unable to scrape minimum-required job info!") - continue - - try: - tags = self.get_tags(s) - except AttributeError: - self.logger.warning(f"Unable to scrape job tags for {key_id}") - - try: - date_string = self.get_date_str(s) - post_date = self.calc_post_date_from_relative_str( - date_string - ) - except (AttributeError, ValueError): - self.logger.error( - f"Unknown date for job {key_id}, setting to datetime.now()." - ) - post_date = datetime.now() - - # FIXME: impl. - # try: - # self.set_short_description(job, s) - # except AttributeError: - # self.logger.warning("Unable to scrape job short description.") - - # Init a new job from scraped data - job = Job( - title=title, - company=company, - location=location, - description='', # We will populate this later per-job-page - key_id=key_id, - url=url, - locale=self.locale, - query=self.query, - status=status, - provider='indeed', # FIXME: we should inherit this - short_description=short_description, - post_date=post_date, - raw='', # FIXME: we cannot pickle the soup object (s) - tags=tags, - ) - - # Key by id to prevent duplicate key_ids TODO: add a warning - jobs_dict[job.key_id] = job - - # Get the detailed description with delayed scraping - if jobs_dict: - jobs_list = list(jobs_dict.values()) - delays = calculate_delays(len(jobs_list), self.config.delay_config) - delay_threader( - jobs_list, self.get_blurb_with_delay, self.parse_blurb, threads, - self.logger, delays, - ) - - return jobs_dict - - def get_blurb_with_delay(self, job: Job, delay: float) -> Tuple[Job, str]: - """Gets blurb from indeed job link and sets delays for requests - """ - sleep(delay) - self.logger.info( - f'Delay of {delay:.2f}s, getting indeed search: {job.url}' - ) - return job, self.session.get(job.url).text - - def parse_blurb(self, job: Job, html: str) -> None: - """Parses and stores job description html and sets Job.description - """ - job_link_soup = BeautifulSoup(html, self.bs4_parser) - - try: - job.description = job_link_soup.find( - id='jobDescriptionText' - ).text.strip() - except AttributeError: - job.description = '' - job.clean_strings() + return job_soup_list def calc_post_date_from_relative_str(self, date_str: str) -> date: """Identifies a job's post date via post age, updates in-place @@ -237,74 +172,41 @@ def convert_radius(self, radius: int) -> int: radius = 100 return radius - @abstractmethod def get_search_url(self, method: Optional[str] = 'get') -> str: """Get the indeed search url from SearchTerms - NOTE: different indeed localizations implement this """ - pass + if method == 'get': + # form job search url + search = ( + "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" + "limit={5}&filter={6}".format( + self.domain, + self.query, + self.config.search_terms.city.replace(' ', '+'), + self.config.search_terms.state, + self.convert_radius(self.config.search_terms.region.radius), + self.max_results_per_page, + int(self.config.search_terms.return_similar_results) + ) + ) + return search + elif method == 'post': + # TODO: implement post style for indeed.X + raise NotImplementedError() + else: + raise ValueError(f'No html method {method} exists') - @abstractmethod - def get_link(self, job_id) -> str: + def get_job_url(self, job_id: str) -> str: """Constructs the link with the given job_id. - NOTE: different indeed localizations implement this - """ - pass - - def search_page_for_job_soups(self, search, page, job_soup_list): - """Scrapes the indeed page for a list of job soups - FIXME: types - """ - url = f'{search}&start={int(page * self.max_results_per_page)}' - self.logger.info(f'getting indeed page {page} : {url}') - job_soup_list.extend( - BeautifulSoup( - self.session.get(url).text, self.bs4_parser - ).find_all('div', attrs={'data-tn-component': 'organicJob'}) - ) - - def get_full_description(self, job: Job) -> None: - """Scrapes the indeed job link for the blurb and sets Job.short_desc - """ - self.logger.info(f'getting indeed page: {job.url}') - - job_link_soup = BeautifulSoup( - self.session.get(job.url).text, self.bs4_parser - ) - try: - job.short_description = job_link_soup.find( - id='jobDescriptionText' - ).text.strip() - except AttributeError: - self.logger.warning(f"Unable to load description for: {job.url}") - job.short_description = '' - job.clean_strings() - - def get_job_page_with_delay(self, job: Job, - delay: float) -> Tuple[Job, str]: - """Gets data from the indeed job link and sets delays for requests + Args: + job_id: The id to be used to construct the link for this job. + Returns: + The constructed job link. """ - sleep(delay) - self.logger.info( - f'delay of {delay:.2f}s, getting indeed search: {job.url}' - ) - return job, self.session.get(job.url).text + return f"http://www.indeed.{self.domain}/viewjob?jk={job_id}" - - def set_short_description(self, job: Job, soup: str) -> None: - """Parses and stores job description from a job's page HTML - FIXME: doesn't work. seems soup isn't right - """ - job_link_soup = BeautifulSoup(soup, self.bs4_parser) - try: - job.description = job_link_soup.find( - id='jobDescriptionText' - ).text.strip() - except AttributeError: - job.description = '' - job.clean_strings() - - def get_num_pages_to_scrape(self, soup_base, max_pages=0) -> int: + def get_num_pages_to_scrape(self, soup_base: BeautifulSoup, + max_pages=0) -> int: """Calculates the number of pages to be scraped. Args: soup_base: a BeautifulSoup object with the html data. @@ -326,7 +228,17 @@ def get_num_pages_to_scrape(self, soup_base, max_pages=0) -> int: else: return max_pages - def get_title(self, soup) -> str: + def get_job_page_with_delay(self, job: Job, + delay: float) -> Tuple[Job, str]: + """Gets data from the indeed job link and sets delays for requests + """ + sleep(delay) + self.logger.info( + f'delay of {delay:.2f}s, getting indeed search: {job.url}' + ) + return job, self.session.get(job.url).text + + def get_job_title(self, soup: BeautifulSoup) -> str: """Fetches the title from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the title from. @@ -339,7 +251,7 @@ def get_title(self, soup) -> str: 'a', attrs={'data-tn-element': 'jobTitle'} ).text.strip() - def get_company(self, soup) -> str: + def get_job_company(self, soup: BeautifulSoup) -> str: """Fetches the company from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the company from. @@ -350,7 +262,7 @@ def get_company(self, soup) -> str: """ return soup.find('span', attrs={'class': 'company'}).text.strip() - def get_location(self, soup) -> str: + def get_job_location(self, soup: BeautifulSoup) -> str: """Fetches the job location from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the location from. @@ -361,7 +273,7 @@ def get_location(self, soup) -> str: """ return soup.find('span', attrs={'class': 'location'}).text.strip() - def get_tags(self, soup) -> List[str]: + def get_job_tags(self, soup: BeautifulSoup) -> List[str]: """Fetches the job tags / keywords from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the location from. @@ -374,7 +286,7 @@ def get_tags(self, soup) -> List[str]: 'table', attrs={'class': 'jobCardShelfContainer'} ).find_all('td', attrs={'class': 'jobCardShelfItem'})] - def get_date_str(self, soup) -> str: + def get_job_date(self, soup: BeautifulSoup) -> date: """Fetches the job date from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the date from. @@ -383,9 +295,10 @@ def get_date_str(self, soup) -> str: Note that this function may throw an AttributeError if it cannot find the date. The caller is expected to handle this exception. """ - return soup.find('span', attrs={'class': 'date'}).text.strip() + date_string = soup.find('span', attrs={'class': 'date'}).text.strip() + return self.calc_post_date_from_relative_str(date_string) - def get_id(self, soup) -> str: + def get_job_key_id(self, soup: BeautifulSoup) -> str: """Fetches the job id from a BeautifulSoup base. NOTE: this should be unique, but we should probably use our own SHA Args: @@ -395,132 +308,52 @@ def get_id(self, soup) -> str: Note that this function may throw an AttributeError if it cannot find the id. The caller is expected to handle this exception. """ - id_regex = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') - return id_regex.findall( + return ID_REGEX.findall( str(soup.find('a', attrs={'class': 'sl resultLink save-job-link'})) )[0] - -class IndeedScraperCAEng(BaseIndeedScraper): - """Scrapes jobs from www.indeed.ca - """ - @property - def locale(self) -> Locale: - return Locale.CANADA_ENGLISH - - @property - def headers(self) -> Dict[str, str]: - """Session header for Indeed + # def get_descriptions(self, soup: BeautifulSoup) + # # Get the detailed description with delayed scraping + # # FIXME: how to use delay threader? + # delay_threader( + # jobs_list, self.get_job_description_with_delay, + # self.get_job_description, threads, self.logger, delays, + # ) + + # def get_job_description_with_delay(self, job: Job, + # delay: float) -> Tuple[Job, str]: + # """Gets blurb from indeed job link and sets delays for requests + # """ + # sleep(delay) + # self.logger.info( + # f'Delay of {delay:.2f}s, getting indeed search: {job.url}' + # ) + # return job, self.session.get(job.url).text + + def get_job_description(self, job: Job, soup: BeautifulSoup) -> None: + """Parses and stores job description html and sets Job.description """ - return { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', # FIXME correct? - 'referer': 'https://www.indeed.{0}/'.format( - get_domain_from_locale(self.locale)), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - } + job_link_soup = BeautifulSoup( + self.session.get(job.url).text, self.bs4_parser + ) + return job_link_soup.find( + id='jobDescriptionText' + ).text.strip() - def get_search_url(self, method: Optional[str] = 'get') -> str: - """Get the indeed search url from SearchTerms - """ - if method == 'get': - # form job search url - search = ( - "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" - "limit={5}&filter={6}".format( - get_domain_from_locale(self.locale), - self.query, - self.config.search_terms.city.replace(' ', '+'), - self.config.search_terms.province, - self.convert_radius(self.config.search_terms.radius), - self.max_results_per_page, - int(self.config.search_terms.return_similar_results) - ) - ) - return search - elif method == 'post': - # TODO: implement post style for indeed.X - raise NotImplementedError() - else: - raise ValueError(f'No html method {method} exists') - def get_link(self, job_id) -> str: - """Constructs the link with the given job_id. - Args: - job_id: The id to be used to construct the link for this job. - Returns: - The constructed job link. - Note that this function does not check the correctness of this link. - The caller is responsible for checking correcteness. + def get_short_job_description(self, job: Job, soup: str) -> None: + """Parses and stores job description from a job's page HTML + # FIXME: impl. """ - return (f"http://www.indeed.{get_domain_from_locale(self.locale)}" - f"/viewjob?jk={job_id}" - ) + pass -# TODO: IndeedScraperCAFr -class IndeedScraperUSAEng(BaseIndeedScraper): - """Scrapes jobs from www.indeed.com +class IndeedScraperCAEng(BaseIndeedScraper, BaseCANEngScraper): + """Scrapes jobs from www.indeed.ca """ - @property - def locale(self) -> Locale: - return Locale.USA_ENGLISH - - @property - def headers(self) -> Dict[str, str]: - """Session header for Indeed - """ - return { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.indeed.{0}/'.format( - get_domain_from_locale(self.locale)), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - } - - def get_search_url(self, method: Optional[str] = 'get') -> str: - """Get the indeed search url from SearchTerms - """ - if method == 'get': - # form job search url - search = ( - "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" - "limit={5}&filter={6}".format( - get_domain_from_locale(self.locale), - self.query, - self.config.search_terms.city.replace(' ', '+'), - self.config.search_terms.state, - self.convert_radius(self.config.search_terms.region.radius), - self.max_results_per_page, - int(self.config.search_terms.return_similar_results) - ) - ) - return search - elif method == 'post': - # TODO: implement post style for indeed.X - raise NotImplementedError() - else: - raise ValueError(f'No html method {method} exists') + pass - def get_link(self, job_id) -> str: - """Constructs the link with the given job_id. - Args: - job_id: The id to be used to construct the link for this job. - Returns: - The constructed job link. - Note that this function does not check the correctness of this link. - The caller is responsible for checking correcteness. - """ - return (f"http://www.indeed.{get_domain_from_locale(self.locale)}" - f"/viewjob?jk={job_id}" - ) +class IndeedScraperUSAEng(BaseIndeedScraper, BaseUSAEngScraper): + """Scrapes jobs from www.indeed.com + """ + pass diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 9f15c3d7..34f651dd 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -4,21 +4,32 @@ from typing import Optional, List, Dict, Any import os +from jobfunnel.backend.localization import Locale from jobfunnel.backend.scrapers import ( - BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng) + BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng, GlassDoorStaticCAEng, + GlassDoorStaticUSAEng, +) from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig +# FIXME make enum +PROVIDERS_LIST = ['indeed', 'glassdoor', 'monster'] + +# NOTE: if you add a scraper you need to add it here SCRAPER_MAP = { # FIXME: make user say 'indeed' and then have it figure out via their # search terms which one to use - 'indeed': IndeedScraperCAEng, # TODO: deprecate and enforce below options - 'INDEED_CANADA_ENG': IndeedScraperCAEng, - 'INDEED_USA_ENG': IndeedScraperUSAEng, - #'glassdoor': + 'indeed': { + Locale.CANADA_ENGLISH: IndeedScraperCAEng, + Locale.USA_ENGLISH: IndeedScraperUSAEng, + }, + 'glassdoor': { + Locale.CANADA_ENGLISH: GlassDoorStaticCAEng, + Locale.CANADA_ENGLISH: GlassDoorStaticUSAEng, + }, # 'monster': MonsterScraperCAEng, FIXME #'MONSTER_CANADA_ENG': MonsterScraperCAEng, -} +} # type: class JobFunnelConfig(BaseConfig): @@ -30,7 +41,8 @@ def __init__(self, user_block_list_file: str, cache_folder: str, search_terms: SearchConfig, - scrapers: List[BaseScraper], + locale: Locale, + provider_names: List[str], log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, @@ -46,7 +58,9 @@ def __init__(self, cache_folder (str): folder where all scrape data will be stored search_terms (SearchTerms): SearchTerms config which contains the desired job search information (i.e. keywords) - scrapers (List[BaseScraper]): List of scrapers we will scrape from + provider_names (List[str]): names of job providers / websites that + we want to scrape. Must be defined in our PROVIDERS_LIST + locale (Locale): the locale we will use for the desired scrapers log_file (str): file to log all logger calls to log_level (int): level to log at, use 10 logging.DEBUG for more data no_scrape (Optional[bool], optional): If True, will not scrape data @@ -61,10 +75,11 @@ def __init__(self, self.user_block_list_file = user_block_list_file self.cache_folder = cache_folder self.search_terms = search_terms - self.scrapers = scrapers + self.provider_names = provider_names self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape + self.locale = locale if not delay_config: self.delay_config = DelayConfig(5.0, 1.0, 'linear') else: @@ -81,6 +96,15 @@ def __init__(self, self.validate() + @property + def scrapers(self) -> BaseScraper: + """All the compatible scrapers for the provider_name + """ + return [ + s for s in SCRAPER_MAP[pn[self.locale]] + for pn in self.provider_names + ] + @property def scraper_names(self) -> str: """User-readable names of the scrapers we will be running @@ -98,6 +122,8 @@ def validate(self) -> None: NOTE: will raise exceptions if issues are encountered. FIXME: impl. more validation here """ + for prov in self.provider_names: + assert prov in PROVIDERS_LIST assert os.path.exists(self.cache_folder) self.search_terms.validate() if self.proxy_config: @@ -142,7 +168,8 @@ def build_funnel_cfg_from_legacy(config: Dict[str, Any]): user_block_list_file=config['filter_list_path'], cache_folder=config['data_path'], search_terms=search_cfg, - scrapers=[SCRAPER_MAP[sc_name] for sc_name in config['providers']], + provider_names=config['providers'], + locale=config['locale'], #FIXME: impl. log_file=config['log_path'], log_level=config['log_level'], no_scrape=config['no_scrape'], From edb653de95566de86018cf03ca36edd78035fe2a Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 6 Aug 2020 21:47:18 -0400 Subject: [PATCH 08/66] more progress, working on the CLI side now. looking to streamline things here with some basic validation --- demo/settings.yaml | 93 ++-- jobfunnel/__init__.py | 2 +- jobfunnel/__main__.py | 7 +- jobfunnel/backend/__init__.py | 1 - jobfunnel/backend/job.py | 31 +- jobfunnel/backend/jobfunnel.py | 32 +- jobfunnel/backend/localization.py | 36 -- jobfunnel/backend/scrapers/__init__.py | 1 + jobfunnel/backend/scrapers/base.py | 28 +- jobfunnel/backend/scrapers/glassdoor/base.py | 5 +- .../backend/scrapers/glassdoor/dynamic.py | 3 +- .../backend/scrapers/glassdoor/static.py | 2 +- jobfunnel/backend/scrapers/indeed.py | 2 +- jobfunnel/backend/scrapers/monster.py | 468 +++++++++--------- jobfunnel/backend/scrapers/registry.py | 26 + jobfunnel/backend/tools/filters.py | 28 +- jobfunnel/config/__init__.py | 8 +- jobfunnel/config/cli.py | 283 +++++++++++ jobfunnel/config/cli_parser.py | 308 ------------ jobfunnel/config/delay.py | 11 +- jobfunnel/config/funnel.py | 92 +--- jobfunnel/config/proxy.py | 2 +- jobfunnel/config/search.py | 22 +- jobfunnel/config/valid_options.py | 38 -- jobfunnel/config/validate.py | 73 --- jobfunnel/resources/__init__.py | 5 +- jobfunnel/resources/default_settings.yaml | 66 --- jobfunnel/resources/enums.py | 51 ++ jobfunnel/resources/resources.py | 28 +- 29 files changed, 771 insertions(+), 981 deletions(-) delete mode 100644 jobfunnel/backend/localization.py create mode 100644 jobfunnel/backend/scrapers/registry.py create mode 100644 jobfunnel/config/cli.py delete mode 100644 jobfunnel/config/cli_parser.py delete mode 100644 jobfunnel/config/valid_options.py delete mode 100644 jobfunnel/config/validate.py delete mode 100644 jobfunnel/resources/default_settings.yaml create mode 100644 jobfunnel/resources/enums.py diff --git a/demo/settings.yaml b/demo/settings.yaml index 23b93e64..8978d2a8 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -1,46 +1,75 @@ -# all paths are relative to this file +# This is an example settings YAML -# paths -output_path: './' +# Path where your master CSV, block-lists, and cache data will be stored +output_path: search -# providers from which to search (case insensitive) -providers: +# Locale settings (i.e. USA_ENGLISH, CANADA_ENGLISH, CANADA_FRENCH) +# These are used to define the reference to what code implementation we should +# use for the scraper and the provider +locale: + CANADA_ENGLISH - - 'Indeed' - - 'Monster' - - 'GlassDoorStatic' - # - 'GlassDoorDynamic' +# Providers from which to search (i.e. glassdoor, monster) +# NOTE: we will choose domain via locale (i.e. CANADA_ENGLISH --> www.indeed.ca) +providers: + - indeed +# Also available: +# - glassdoor +# - monster -# filters +# Job search configuration search_terms: + + # This is the region you are searching for jobs in, and the distance in km + # within which to return jobs. region: - province: 'ON' - city: 'waterloo' - domain: 'ca' - radius: 10 + province_or_state: "ON" + city: "waterloo" + radius: 25 # This is in kilometers (km) + # These are the terms you would be typing into the website's search field + # NOTE: we will search with all the provided keywords and format according + # to the input format of job provider (i.e. the GET URLs). keywords: - - 'Python' + - Python + - Scientist + +# Blocked company names +# TODO: refactor --> block_list +company_block_list: + - "Infox Consulting" -black_list: - - 'Infox Consulting' - - 'Terminal' +# Logging level options are: critical, error, warning, info, debug, notset +log_level: info -# logging level options are: critical, error, warning, info, debug, notset -log_level: 'info' +# Keep similar job postings +similar: False -# saves duplicates removed by tfidf filter to duplicate_list.csv +# Skip web-scraping and load a previously saved daily scrape pickle +no_scrape: False + +# Recover master-list by accessing all historic scrapes pickles +recover: False + +# Saves duplicates removed by tfidf filter to duplicate_list.csv +# TODO: document why this should be done. save_duplicates: False -# delaying algorithm configuration +# Delaying algorithm configuration delay_config: - # functions used for delaying algorithm, options are: constant, linear, sigmoid - function: 'linear' - # maximum delay/upper bound for converging random delay - delay: 10 - # minimum delay/lower bound for random delay - min_delay: 1 - # random delay - random: True - # converging random delay, only used if 'random' is set to True - converge: True + # Functions used for delaying algorithm: 'constant', 'linear', 'sigmoid' + function: linear + # Maximum delay/upper bound for converging random delay + delay: 5.0 + # Minimum delay/lower bound for random delay + min_delay: 1.0 + # Random delay + random: False + # Converging random delay, only used if 'random' is set to True + converge: False + +# # Proxy settings +# proxy: +# protocol: https # NOTE: you can also set to 'http' +# ip_address: "1.1.1.1" +# port: '200' diff --git a/jobfunnel/__init__.py b/jobfunnel/__init__.py index dfef77fb..fab5ff01 100644 --- a/jobfunnel/__init__.py +++ b/jobfunnel/__init__.py @@ -2,4 +2,4 @@ import random -__version__ = '2.2.0' +__version__ = '3.0.0' diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 77c7131e..a3f68fbe 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -17,16 +17,15 @@ import logging from .backend.jobfunnel import JobFunnel -from .config import parse_config, validate_config, build_funnel_cfg_from_legacy +from .config import parse_cli, config_builder def main(): """Parse CLI and call jobfunnel() to manage scrapers and lists """ # Parse CLI into a dict - config = parse_config() - validate_config(config) - funnel_cfg = build_funnel_cfg_from_legacy(config) + args = parse_cli() + funnel_cfg = config_builder(args) job_funnel = JobFunnel(funnel_cfg) job_funnel.run() diff --git a/jobfunnel/backend/__init__.py b/jobfunnel/backend/__init__.py index 90a2ec1b..b427bfe5 100644 --- a/jobfunnel/backend/__init__.py +++ b/jobfunnel/backend/__init__.py @@ -1,3 +1,2 @@ # from jobfunnel.backend.jobfunnel import JobFunnel FIXME: causes circular imp. from jobfunnel.backend.job import Job, JobStatus -from jobfunnel.backend.localization import Locale diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 1ed8373b..c0854a8d 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -2,36 +2,17 @@ to csv / etc by Exporter """ from datetime import date, datetime -from enum import Enum import re import string from typing import Any, Dict, Optional, List -from jobfunnel.backend.localization import Locale -from jobfunnel.resources.resources import CSV_HEADER +from jobfunnel.resources import ( + Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS +) -PRINTABLE_STRINGS = set(string.printable) - -class JobStatus(Enum): - """Job statuses that are built-into jobfunnel - NOTE: these are the only valid values for entries in 'status' in our CSV - """ - UNKNOWN = 1 - NEW = 2 - ARCHIVE = 3 - INTERVIEWING = 4 - INTERVIEWED = 5 - REJECTED = 6 - ACCEPTED = 7 - DELETE = 8 - INTERESTED = 9 - APPLIED = 10 - APPLY = 11 - OLD = 12 - - -REMOVE_STATUSES = [ +# If job.status == one of these we filter it out of results +JOB_REMOVE_STATUSES = [ JobStatus.DELETE, JobStatus.ARCHIVE, JobStatus.REJECTED, JobStatus.OLD ] @@ -112,7 +93,7 @@ def __init__(self, def is_remove_status(self) -> bool: """Return True if the job's status is one of our removal statuses. """ - return self.status in REMOVE_STATUSES + return self.status in JOB_REMOVE_STATUSES @property def as_row(self) -> Dict[str, str]: diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 05bb0f1f..89ebe0ef 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -1,4 +1,4 @@ -"""Paul McInnis 2018 +"""Paul McInnis 2020 Scrapes jobs, applies search filters and writes pickles to master list """ import csv @@ -14,12 +14,12 @@ from time import time from jobfunnel.config import JobFunnelConfig -from jobfunnel.backend import Job, JobStatus, Locale -from jobfunnel.resources import CSV_HEADER +from jobfunnel.backend import Job +from jobfunnel.resources import CSV_HEADER, JobStatus, Locale from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter -MAX_BLOCK_LIST_DESC_CHARS = 150 # maximum len of description in block_list JSON +MAX_BLOCK_LIST_DESC_CHARS = 150 # Maximum len of description in block_list JSON class JobFunnel(object): @@ -83,8 +83,7 @@ def run(self) -> None: # Identify duplicate jobs using the existing masterlist masterlist = self.read_master_csv() # type: Dict[str, Job] - self.filter(masterlist) # NOTE: reduces size of masterlist - # FIXME: this doesn't handle empty descriptions or masterlist well + self.filter(masterlist) # NOTE: this reduces size of masterlist tfidf_filter(jobs_dict, masterlist) # Expand the masterlist with filteres, non-duplicated jobs & save @@ -361,20 +360,29 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: TODO: would be cool if we could run TFIDF in here too FIXME: load the global block-list as well """ + # Read the user's block list + block_dict = {} # type: Dict[str, Job] if os.path.isfile(self.config.user_block_list_file): block_dict = json.load( open(self.config.user_block_list_file, 'r') ) - else: - block_dict = {} # type: Dict[str, Job] + # Read the user's duplicate jobs list (from TFIDF) + duplicates_dict = {} # type: Dict[str, Job] + if os.path.isfile(self.config.duplicate_): + duplicates_dict = json.load( + open(self.config.user_block_list_file, 'r') + ) + + # Filter jobs out using all our available filters + # NOTE: checks are arranged in order of assumed calculation expense filter_jobs_ids = [] for key_id, job in jobs_dict.items(): - if (key_id in block_dict - or job_is_old(job, self.config.search_terms.max_listing_days) - or job.is_remove_status + if (job.is_remove_status or job.company in self.config.search_terms.blocked_company_names - ): + or key_id in block_dict + or key_id in duplicates_dict + or job_is_old(job, self.config.search_terms.max_listing_days)): filter_jobs_ids.append(key_id) for key_id in filter_jobs_ids: diff --git a/jobfunnel/backend/localization.py b/jobfunnel/backend/localization.py deleted file mode 100644 index a56e2473..00000000 --- a/jobfunnel/backend/localization.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Place to store Enums and such for localization / internationalization -""" -from enum import Enum -from typing import List, Optional - - -class Locale(Enum): - """This will allow Scrapers / Filters / Main to identify the support they - have for different domains of different websites - - NOTE: add locales here as you need them, we do them per-country per-language - becuase scrapers are written per-language-per-country as this matches - how the information is served by job websites. - """ - UNKNOWN = 1 - CANADA_ENGLISH = 2 - CANADA_FRENCH = 3 - CANADA_MANDARIN = 4 - CANADA_CANTONESE = 5 - CANADA_PUNJABI = 6 - CANADA_SPANISH = 7 - USA_ENGLISH = 4 - - -def get_domain_from_locale(locale: Locale) -> str: - """Get a domain string from the locale Enum - - NOTE: if you want to override this you can always set domain in headers - directly without using this method - """ - if locale in [Locale.CANADA_ENGLISH, Locale.CANADA_FRENCH]: - return 'ca' - elif locale == Locale.USA_ENGLISH: - return 'com' - else: - raise ValueError(f"Unknown domain string for locale {locale}") diff --git a/jobfunnel/backend/scrapers/__init__.py b/jobfunnel/backend/scrapers/__init__.py index cba03adf..9cb5127d 100644 --- a/jobfunnel/backend/scrapers/__init__.py +++ b/jobfunnel/backend/scrapers/__init__.py @@ -7,3 +7,4 @@ from jobfunnel.backend.scrapers.glassdoor.static import ( GlassDoorStaticCAEng, GlassDoorStaticUSAEng, ) +from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 83dc05d7..9ebc55cd 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -11,14 +11,19 @@ import random from requests import Session -from jobfunnel.resources import USER_AGENT_LIST +from jobfunnel.resources import USER_AGENT_LIST, Locale, MAX_CPU_WORKERS from jobfunnel.backend.tools.delay import calculate_delays, delay_threader from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.localization import Locale, get_domain_from_locale #from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue -MAX_CPU_WORKERS = 8 +# Defaults we use from localization, the scraper can always override it. +DOMAIN_FROM_LOCALE = { + Locale.CANADA_ENGLISH: 'ca', + Locale.CANADA_FRENCH: 'ca', + Locale.USA_ENGLISH: 'com', +} + class BaseScraper(ABC): """Base scraper object, for generating List[Job] from a specific job source @@ -39,7 +44,9 @@ def domain(self) -> str: NOTE: if you have a special case for your locale (i.e. canadian .com) inherit from BaseScraper and set this and locale in your Scraper class """ - return get_domain_from_locale(self.locale) + if not self.locale in DOMAIN_FROM_LOCALE: + raise ValueError(f"Unknown domain for locale: {self.locale}") + return DOMAIN_FROM_LOCALE[self.locale] @property def bs4_parser(self) -> str: @@ -134,14 +141,6 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: Returns: Job: job object constructed from the soup and localization of class """ - - # Init default values - status = JobStatus.NEW - post_date = datetime.datetime.now() - tags = [] # type: List[str] - title, company, location = None, None, None - key_id, url, short_description = None, None, None - # Scrape the data for the post, requiring a minimum of info... try: # Jobs should at minimum have a title, company and location @@ -156,14 +155,17 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: "Unable to scrape minimum-required job info!\nerror:" + str(err) ) + # Scrape the optional stuff try: tags = self.get_job_tags(job_soup) except AttributeError: + tags = [] # type: List[str] self.logger.warning(f"Unable to scrape tags for job {key_id}") try: post_date = self.get_job_date(job_soup) except (AttributeError, ValueError): + post_date = datetime.datetime.now() self.logger.warning( f"Unknown date for job {key_id}, setting to datetime.now()." ) @@ -178,7 +180,7 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: url=url, locale=self.locale, query='', #self.query_string, FIXME - status=status, + status=JobStatus.NEW, provider='', #self.__class___.__name__, FIXME short_description='', # We will populate this later per-job-page post_date=post_date, diff --git a/jobfunnel/backend/scrapers/glassdoor/base.py b/jobfunnel/backend/scrapers/glassdoor/base.py index fc2adac1..c76a97d1 100644 --- a/jobfunnel/backend/scrapers/glassdoor/base.py +++ b/jobfunnel/backend/scrapers/glassdoor/base.py @@ -5,7 +5,6 @@ from typing import Dict, List, Tuple, Optional, Union from jobfunnel.backend.scrapers import BaseScraper -from jobfunnel.backend.localization import Locale, get_domain_from_locale MAX_LOCATIONS_TO_RETURN = 10 @@ -67,7 +66,7 @@ def get_search_url(self, search = ( 'https://www.glassdoor.{}/Job/jobs.htm?clickSource=searchBtn' '&sc.keyword={}&locT=C&locId={}&jobType=&radius={}'.format( - get_domain_from_locale(self.locale), + self.domain, self.query_string, location_response[0]['locationId'], self.quantize_radius(self.config.search_terms.radius), @@ -79,7 +78,7 @@ def get_search_url(self, # Form the job search url search = "https://www.glassdoor.{}/Job/jobs.htm".format( - get_domain_from_locale(self.locale) + self.domain ) # Form the job search data diff --git a/jobfunnel/backend/scrapers/glassdoor/dynamic.py b/jobfunnel/backend/scrapers/glassdoor/dynamic.py index ba66410c..9f25c5db 100644 --- a/jobfunnel/backend/scrapers/glassdoor/dynamic.py +++ b/jobfunnel/backend/scrapers/glassdoor/dynamic.py @@ -8,10 +8,11 @@ from jobfunnel.backend import Job from jobfunnel.backend.tools import get_webdriver -from jobfunnel.backend.localization import Locale from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase +from jobfunnel.resources import Locale +# FIXME: maybe we can just move this to a dev branch? class GlassDoorDynamic(GlassDoorBase): """The Dynamic Version of the GlassDoor scraper, that uses selenium to scrape job postings. """ diff --git a/jobfunnel/backend/scrapers/glassdoor/static.py b/jobfunnel/backend/scrapers/glassdoor/static.py index c7246eb4..268541b4 100644 --- a/jobfunnel/backend/scrapers/glassdoor/static.py +++ b/jobfunnel/backend/scrapers/glassdoor/static.py @@ -11,9 +11,9 @@ import time from jobfunnel.backend import Job -from jobfunnel.backend.localization import Locale from jobfunnel.backend.scrapers import BaseCANEngScraper, BaseUSAEngScraper from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase +from jobfunnel.resources import Locale class GlassDoorStatic(GlassDoorBase): diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index bf092504..abb962b9 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -13,8 +13,8 @@ from bs4 import BeautifulSoup +from jobfunnel.resources import Locale from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.localization import Locale from jobfunnel.backend.scrapers import BaseScraper, BaseCANEngScraper, BaseUSAEngScraper from jobfunnel.backend.tools.delay import calculate_delays, delay_threader #from jobfunnel.config import JobFunnelConfig # causes a circular import diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 76dd984e..265c4d2c 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -1,242 +1,234 @@ -import re - -from bs4 import BeautifulSoup -from concurrent.futures import ThreadPoolExecutor -from logging import info as log_info -from math import ceil -from time import sleep, time - -from .jobfunnel import JobFunnel, MASTERLIST_HEADER -from .tools.tools import filter_non_printables -from .tools.tools import post_date_from_relative_post_age - +"""Scrapers for www.monster.X +""" class Monster(JobFunnel): def __init__(self, args): - super().__init__(args) - self.provider = 'monster' - self.max_results_per_page = 25 - self.headers = { - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': 'https://www.monster.{0}/'.format( - self.search_terms['region']['domain']), - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive' - } - # Sets headers as default on Session object - self.s.headers.update(self.headers) - # Concatenates keywords with '-' and encodes spaces as '-' - self.query = '-'.join(self.search_terms['keywords']).replace(' ', '-') - - def convert_radius(self, radius): - """function that quantizes the user input radius to a valid radius - in either kilometers or miles""" - if self.search_terms['region']['domain'] == 'com': - if radius < 5: - radius = 0 - elif 5 <= radius < 10: - radius = 5 - elif 10 <= radius < 20: - radius = 10 - elif 20 <= radius < 30: - radius = 20 - elif 30 <= radius < 40: - radius = 30 - elif 40 <= radius < 50: - radius = 40 - elif 50 <= radius < 60: - radius = 50 - elif 60 <= radius < 75: - radius = 60 - elif 75 <= radius < 100: - radius = 75 - elif 100 <= radius < 150: - radius = 100 - elif 150 <= radius < 200: - radius = 150 - elif radius >= 200: - radius = 200 - else: - if radius < 5: - radius = 0 - elif 5 <= radius < 10: - radius = 5 - elif 10 <= radius < 20: - radius = 10 - elif 20 <= radius < 50: - radius = 20 - elif 50 <= radius < 100: - radius = 50 - elif radius >= 100: - radius = 100 - - return radius - - def get_search_url(self, method='get'): - """gets the monster request html""" - # form job search url - if method == 'get': - search = ('https://www.monster.{0}/jobs/search/?' - 'q={1}&where={2}__2C-{3}&intcid={4}&rad={5}&where={2}__2c-{3}'.format( - self.search_terms['region']['domain'], - self.query, - self.search_terms['region']['city'].replace(' ', "-"), - self.search_terms['region']['province'], - 'skr_navigation_nhpso_searchMain', - self.convert_radius(self.search_terms['region']['radius']))) - - return search - elif method == 'post': - # @TODO implement post style for monster - raise NotImplementedError() - else: - raise ValueError(f'No html method {method} exists') - - def search_joblink_for_blurb(self, job): - """function that scrapes the monster job link for the blurb""" - search = job['link'] - log_info(f'getting monster search: {search}') - - job_link_soup = BeautifulSoup( - self.s.get(search).text, self.bs4_parser) - - try: - job['blurb'] = job_link_soup.find( - id='JobDescription').text.strip() - except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) - - # split apart above function into two so gotten blurbs can be parsed - # while others blurbs are being obtained - def get_blurb_with_delay(self, job, delay): - """gets blurb from monster job link and sets delays for requests""" - sleep(delay) - - search = job['link'] - log_info(f'delay of {delay:.2f}s, getting monster search: {search}') - - res = self.s.get(search).text - return job, res - - def parse_blurb(self, job, html): - """parses and stores job description into dict entry""" - job_link_soup = BeautifulSoup(html, self.bs4_parser) - - try: - job['blurb'] = job_link_soup.find( - id='JobDescription').text.strip() - except AttributeError: - job['blurb'] = '' - - filter_non_printables(job) - - def scrape(self): - """function that scrapes job posting from monster and pickles it""" - log_info(f'jobfunnel monster to pickle running @ {self.date_string}') - - # get the search url - search = self.get_search_url() - - # get the html data, initialize bs4 with lxml - request_html = self.s.get(search) - - # create the soup base - soup_base = BeautifulSoup(request_html.text, self.bs4_parser) - - # scrape total number of results, and calculate the # pages needed - num_res = soup_base.find('h2', 'figure').text.strip() - num_res = int(re.findall(r'(\d+)', num_res)[0]) - log_info(f'Found {num_res} monster results for query=' - f'{self.query}') - - pages = int(ceil(num_res / self.max_results_per_page)) - # scrape soups for all the pages containing jobs it found - page_url = f'{search}&start={pages}' - log_info(f'getting monster pages 1 to {pages} : {page_url}') - - jobs = BeautifulSoup( - self.s.get(page_url).text, self.bs4_parser). \ - find_all('div', attrs={'class': 'flex-row'}) - - job_soup_list = [] - job_soup_list.extend(jobs) - - # id regex quantifiers - id_regex = re.compile(r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f' - r']{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})|\d+)') - - # make a dict of job postings from the listing briefs - for s in job_soup_list: - # init dict to store scraped data - job = dict([(k, '') for k in MASTERLIST_HEADER]) - - # scrape the post data - job['status'] = 'new' - try: - # jobs should at minimum have a title, company and location - job['title'] = s.find('h2', attrs={ - 'class': 'title'}).text.strip() - job['company'] = s.find( - 'div', attrs={'class': 'company'}).text.strip() - job['location'] = s.find('div', attrs={ - 'class': 'location'}).text.strip() - except AttributeError: - continue - - # no blurb is available in monster job soups - job['blurb'] = '' - # tags are not supported in monster - job['tags'] = '' - try: - job['date'] = s.find('time').text.strip() - except AttributeError: - job['date'] = '' - # captures uuid or int ids, by extracting from url instead - try: - job['link'] = str(s.find('a', attrs={ - 'data-bypass': 'true'}).get('href')) - job['id'] = id_regex.findall(job['link'])[0] - except AttributeError: - job['id'] = '' - job['link'] = '' - - job['query'] = self.query - job['provider'] = self.provider - - # key by id - self.scrape_data[str(job['id'])] = job - - # Do not change the order of the next three statements if you want date_filter to work - - # stores references to jobs in list to be used in blurb retrieval - scrape_list = [i for i in self.scrape_data.values()] - # converts job date formats into a standard date format - post_date_from_relative_post_age(scrape_list) - # apply job pre-filter before scraping blurbs - super().pre_filter(self.scrape_data, self.provider) - - threads = ThreadPoolExecutor(max_workers=8) - # checks if delay is set or not, then extracts blurbs from job links - if self.delay_config is not None: - # calls super class to run delay specific threading logic - super().delay_threader(scrape_list, self.get_blurb_with_delay, - self.parse_blurb, threads) - else: - # start time recording - start = time() - - # maps jobs to threads and cleans them up when done - threads.map(self.search_joblink_for_blurb, scrape_list) - threads.shutdown() - - # end and print recorded time - end = time() - print(f'{self.provider} scrape job took {(end - start):.3f}s') + # FIXME: impl. + pass + # super().__init__(args) + # self.provider = 'monster' + # self.max_results_per_page = 25 + # self.headers = { + # 'accept': 'text/html,application/xhtml+xml,application/xml;' + # 'q=0.9,image/webp,*/*;q=0.8', + # 'accept-encoding': 'gzip, deflate, sdch, br', + # 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + # 'referer': 'https://www.monster.{0}/'.format( + # self.search_terms['region']['domain']), + # 'upgrade-insecure-requests': '1', + # 'user-agent': self.user_agent, + # 'Cache-Control': 'no-cache', + # 'Connection': 'keep-alive' + # } + # # Sets headers as default on Session object + # self.s.headers.update(self.headers) + # # Concatenates keywords with '-' and encodes spaces as '-' + # self.query = '-'.join(self.search_terms['keywords']).replace(' ', '-') + + # def convert_radius(self, radius): + # """function that quantizes the user input radius to a valid radius + # in either kilometers or miles""" + # if self.search_terms['region']['domain'] == 'com': + # if radius < 5: + # radius = 0 + # elif 5 <= radius < 10: + # radius = 5 + # elif 10 <= radius < 20: + # radius = 10 + # elif 20 <= radius < 30: + # radius = 20 + # elif 30 <= radius < 40: + # radius = 30 + # elif 40 <= radius < 50: + # radius = 40 + # elif 50 <= radius < 60: + # radius = 50 + # elif 60 <= radius < 75: + # radius = 60 + # elif 75 <= radius < 100: + # radius = 75 + # elif 100 <= radius < 150: + # radius = 100 + # elif 150 <= radius < 200: + # radius = 150 + # elif radius >= 200: + # radius = 200 + # else: + # if radius < 5: + # radius = 0 + # elif 5 <= radius < 10: + # radius = 5 + # elif 10 <= radius < 20: + # radius = 10 + # elif 20 <= radius < 50: + # radius = 20 + # elif 50 <= radius < 100: + # radius = 50 + # elif radius >= 100: + # radius = 100 + + # return radius + + # def get_search_url(self, method='get'): + # """gets the monster request html""" + # # form job search url + # if method == 'get': + # search = ('https://www.monster.{0}/jobs/search/?' + # 'q={1}&where={2}__2C-{3}&intcid={4}&rad={5}&where={2}__2c-{3}'.format( + # self.search_terms['region']['domain'], + # self.query, + # self.search_terms['region']['city'].replace(' ', "-"), + # self.search_terms['region']['province'], + # 'skr_navigation_nhpso_searchMain', + # self.convert_radius(self.search_terms['region']['radius']))) + + # return search + # elif method == 'post': + # # @TODO implement post style for monster + # raise NotImplementedError() + # else: + # raise ValueError(f'No html method {method} exists') + + # def search_joblink_for_blurb(self, job): + # """function that scrapes the monster job link for the blurb""" + # search = job['link'] + # log_info(f'getting monster search: {search}') + + # job_link_soup = BeautifulSoup( + # self.s.get(search).text, self.bs4_parser) + + # try: + # job['blurb'] = job_link_soup.find( + # id='JobDescription').text.strip() + # except AttributeError: + # job['blurb'] = '' + + # filter_non_printables(job) + + # # split apart above function into two so gotten blurbs can be parsed + # # while others blurbs are being obtained + # def get_blurb_with_delay(self, job, delay): + # """gets blurb from monster job link and sets delays for requests""" + # sleep(delay) + + # search = job['link'] + # log_info(f'delay of {delay:.2f}s, getting monster search: {search}') + + # res = self.s.get(search).text + # return job, res + + # def parse_blurb(self, job, html): + # """parses and stores job description into dict entry""" + # job_link_soup = BeautifulSoup(html, self.bs4_parser) + + # try: + # job['blurb'] = job_link_soup.find( + # id='JobDescription').text.strip() + # except AttributeError: + # job['blurb'] = '' + + # filter_non_printables(job) + + # def scrape(self): + # """function that scrapes job posting from monster and pickles it""" + # log_info(f'jobfunnel monster to pickle running @ {self.date_string}') + + # # get the search url + # search = self.get_search_url() + + # # get the html data, initialize bs4 with lxml + # request_html = self.s.get(search) + + # # create the soup base + # soup_base = BeautifulSoup(request_html.text, self.bs4_parser) + + # # scrape total number of results, and calculate the # pages needed + # num_res = soup_base.find('h2', 'figure').text.strip() + # num_res = int(re.findall(r'(\d+)', num_res)[0]) + # log_info(f'Found {num_res} monster results for query=' + # f'{self.query}') + + # pages = int(ceil(num_res / self.max_results_per_page)) + # # scrape soups for all the pages containing jobs it found + # page_url = f'{search}&start={pages}' + # log_info(f'getting monster pages 1 to {pages} : {page_url}') + + # jobs = BeautifulSoup( + # self.s.get(page_url).text, self.bs4_parser). \ + # find_all('div', attrs={'class': 'flex-row'}) + + # job_soup_list = [] + # job_soup_list.extend(jobs) + + # # id regex quantifiers + # id_regex = re.compile(r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f' + # r']{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})|\d+)') + + # # make a dict of job postings from the listing briefs + # for s in job_soup_list: + # # init dict to store scraped data + # job = dict([(k, '') for k in MASTERLIST_HEADER]) + + # # scrape the post data + # job['status'] = 'new' + # try: + # # jobs should at minimum have a title, company and location + # job['title'] = s.find('h2', attrs={ + # 'class': 'title'}).text.strip() + # job['company'] = s.find( + # 'div', attrs={'class': 'company'}).text.strip() + # job['location'] = s.find('div', attrs={ + # 'class': 'location'}).text.strip() + # except AttributeError: + # continue + + # # no blurb is available in monster job soups + # job['blurb'] = '' + # # tags are not supported in monster + # job['tags'] = '' + # try: + # job['date'] = s.find('time').text.strip() + # except AttributeError: + # job['date'] = '' + # # captures uuid or int ids, by extracting from url instead + # try: + # job['link'] = str(s.find('a', attrs={ + # 'data-bypass': 'true'}).get('href')) + # job['id'] = id_regex.findall(job['link'])[0] + # except AttributeError: + # job['id'] = '' + # job['link'] = '' + + # job['query'] = self.query + # job['provider'] = self.provider + + # # key by id + # self.scrape_data[str(job['id'])] = job + + # # Do not change the order of the next three statements if you want date_filter to work + + # # stores references to jobs in list to be used in blurb retrieval + # scrape_list = [i for i in self.scrape_data.values()] + # # converts job date formats into a standard date format + # post_date_from_relative_post_age(scrape_list) + # # apply job pre-filter before scraping blurbs + # super().pre_filter(self.scrape_data, self.provider) + + # threads = ThreadPoolExecutor(max_workers=8) + # # checks if delay is set or not, then extracts blurbs from job links + # if self.delay_config is not None: + # # calls super class to run delay specific threading logic + # super().delay_threader(scrape_list, self.get_blurb_with_delay, + # self.parse_blurb, threads) + # else: + # # start time recording + # start = time() + + # # maps jobs to threads and cleans them up when done + # threads.map(self.search_joblink_for_blurb, scrape_list) + # threads.shutdown() + + # # end and print recorded time + # end = time() + # print(f'{self.provider} scrape job took {(end - start):.3f}s') diff --git a/jobfunnel/backend/scrapers/registry.py b/jobfunnel/backend/scrapers/registry.py new file mode 100644 index 00000000..b53ee704 --- /dev/null +++ b/jobfunnel/backend/scrapers/registry.py @@ -0,0 +1,26 @@ +"""Lookup tables where we can map scrapers to locales, etc +""" +from jobfunnel.backend.scrapers import ( + BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng, GlassDoorStaticCAEng, + GlassDoorStaticUSAEng, +) +from jobfunnel.resources import Locale, Provider + + +# NOTE: if you add a scraper you need to add it here +# TODO: there must be a better way to do this by using class attrib of Provider +SCRAPER_FROM_LOCALE = { + # search terms which one to use + Provider.INDEED: { + Locale.CANADA_ENGLISH: IndeedScraperCAEng, + Locale.USA_ENGLISH: IndeedScraperUSAEng, + }, + Provider.GLASSDOOR: { + Locale.CANADA_ENGLISH: GlassDoorStaticCAEng, + Locale.CANADA_ENGLISH: GlassDoorStaticUSAEng, + }, + # 'monster': MonsterScraperCAEng, FIXME + #'MONSTER_CANADA_ENG': MonsterScraperCAEng, +} # type: + + diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 12f3a616..03f68284 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -39,6 +39,8 @@ def tfidf_filter(cur_dict: Dict[str, dict], max_similarity: float = 0.75): """ Fit a tfidf vectorizer to a corpus of all listing's text. + TODO: this should handle better empty inputs + Args: cur_dict: today's job scrape dict prev_dict: the existing master list job dict @@ -61,11 +63,19 @@ def tfidf_filter(cur_dict: Dict[str, dict], # init list to store duplicate ids duplicate_ids = {} - if prev_dict is None: - # get query words and ids as lists - query_ids = [job.key_id for job in cur_dict.values()] - query_words = [job.description for job in cur_dict.values()] + if prev_dict: + # checks current scrape for re-posts/duplicates + duplicate_ids = tfidf_filter(cur_dict) + + # get query words and ids as lists + query_ids, query_words = [], [] + for job in cur_dict.values(): + query_ids.append(job.key_id) + if len(job.description) > 0: + query_words.append(job.description) + assert query_words, "No query strings to fit, are your descriptions empty?" + if prev_dict is None: # returns cosine similarity between jobs as square matrix (n,n) similarities = cosine_similarity(vectorizer.fit_transform(query_words)) # fills diagonals with 0, so whole dict does not get popped @@ -92,15 +102,7 @@ def tfidf_filter(cur_dict: Dict[str, dict], # log something logging.info(f'Found and removed {len(duplicate_ids.keys())} ' f're-posts/duplicates via TFIDF cosine similarity!') - else: - # checks current scrape for re-posts/duplicates - duplicate_ids = tfidf_filter(cur_dict) - - # get query words and ids as lists - query_ids = [job.key_id for job in cur_dict.values()] - query_words = [job.description for job in cur_dict.values()] - # get reference words as list reference_words = [job.description for job in prev_dict.values()] @@ -123,7 +125,7 @@ def tfidf_filter(cur_dict: Dict[str, dict], logging.info( f'Found {len(cur_dict.keys())} unique listings and ' f'{len(duplicate_ids.keys())} duplicates ' - f'via TFIDF cosine similarity' + 'via TFIDF cosine similarity' ) # returns a dictionary of duplicate key_ids diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index 41142933..bc8486b0 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -2,9 +2,5 @@ from jobfunnel.config.delay import DelayConfig from jobfunnel.config.proxy import ProxyConfig from jobfunnel.config.search import SearchConfig -from jobfunnel.config.funnel import ( - JobFunnelConfig, - build_funnel_cfg_from_legacy -) -from jobfunnel.config.cli_parser import parse_config, ConfigError -from jobfunnel.config.validate import validate_config +from jobfunnel.config.funnel import JobFunnelConfig +from jobfunnel.config.cli import parse_cli, config_builder diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py new file mode 100644 index 00000000..55521a7d --- /dev/null +++ b/jobfunnel/config/cli.py @@ -0,0 +1,283 @@ +"""Configuration parsing module for CLI --> JobFunnelConfig +""" +import argparse +import logging +import os +from typing import Dict, Any, List +import yaml + +from jobfunnel.config import ( + JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig) +from jobfunnel.backend.tools.tools import split_url +from jobfunnel.resources import ( + Locale, DelayAlgorithm, DEFAULT_OUTPUT_DIRECTORY, DEFAULT_CACHE_DIRECTORY, +) + + +def parse_cli(): + """Parse the command line arguments into an argv with defaults + """ + parser = argparse.ArgumentParser('Job Search CLI') + + # path args + parser.add_argument( + '-s', + dest='settings_yaml_file', + type=str, + help='Path to a settings YAML file containing your job search info. ' + 'Pass an existing YAML file path to continue a search ' + 'by scraping new jobs and updating the CSV file. ' + ) + + # FIXME: make it mutually exclusive to pass -o or -mscv/-bl/-cache + parser.add_argument( + '-o', + dest='job_search_results_folder', + default=DEFAULT_OUTPUT_DIRECTORY, + help='Directory where the job search results will be stored. ' + 'Pass an existing search results folder to continue a search ' + 'by scraping new jobs and updating the CSV file. ' + 'Note that you should use seperate folders per-job-search! ' + 'Folder contents: /data/.cache/, /master_list.csv.' + ' These folders and associated files will be created if not found.' + f' Defaults to: {DEFAULT_OUTPUT_DIRECTORY}' + ) + + # parser.add_argument( + # '-cache', + # dest='cache_folder', + # default=DEFAULT_CACHE_DIRECTORY, + # help='Directory where cached scrape data will be stored. defaults to ' + # + DEFAULT_CACHE_DIRECTORY + # ) + + # parser.add_argument( + # '-bl', + # dest='block-list-file', + # nargs='*', + # help='JSON file of jobs you want to omit from your job search ' + # '(usually this is in the output of previous jobfunnel results).' + # ) + + # parser.add_argument( + # '-mcsv', + # dest='master_csv_file', + # nargs='*', + # help='Path to a master CSV file containing your search results' + # ) + + # Search terms + parser.add_argument( + '-k', + dest='search_keywords', + nargs='+', + default=['Python'], + help='List of job-search keywords. (i.e. Engineer, AI).' + ) + + parser.add_argument( + '-l', + dest='locale', + default=Locale.CANADA_ENGLISH, + choices=[l.name for l in Locale], + help='Global location and language to use to scrape the job provider' + ' website. (i.e. CANADA_ENGLISH --> indeed --> indeed.ca)' + ) + + parser.add_argument( + '-p', + dest='province_or_state', + default='ON', # TODO: we should use a Local object of some sort. + type=str, + help='Province/state value for your job-search region. NOTE: format ' + 'is job-provider-specific.' + ) + + parser.add_argument( + '-c', + dest='city', + default='Waterloo', + type=str, + help='City/town value for job-search region.' + ) + + parser.add_argument( + '-max-age', + type=int, + help='The maximum number of days-old a job can be. (i.e pass 30 to ' + 'filter out jobs older than a month).' + ) + + # Functionality + parser.add_argument( + '--log-level', + type=str, + choices=['critical', 'error', 'warning', 'info', 'debug', 'notset'], + help='Type of logging information shown on the terminal.' + ) + + parser.add_argument( + '--recover', + action='store_true', + help='Reconstruct a new master CSV file from all available cache files.' + ) + + parser.add_argument( + '--save-duplicates', + action='store_true', + help='Save duplicate job key_ids into file.' + ) + + # # Proxy stuff move to subparser. + # # FIXME missing stuff here + # parser.add_argument( + # '--proxy', + # type=str, + # help='Proxy address (URL).' + # ) + + # # Delay stuff + # # TODO: move delay args into a subparser for improved -h clarity + # parser.add_argument( + # '--random-delay', + # action='store_true', + # help='Turn on random delaying for certain get requests.' + # ) + + # parser.add_argument( + # '--converging-delay', + # action='store_true', + # help='Use converging random delay for certain get requests.' + # ) + + # parser.add_argument( + # '-delay-duration', + # type=float, + # help='Set delay seconds for certain get requests.' + # ) + + # parser.add_argument( + # '-delay-min', + # type=float, + # help='Set lower bound value for delay for certain get requests.' + # ) + + # parser.add_argument( + # '-delay-algorithm', + # choices=[a.name for a in DelayAlgorithm], + # help='Select a function to calculate delay times with.' + # ) + + + return parser.parse_args() + + +def config_builder(args: argparse.Namespace) -> JobFunnelConfig: + """Parse the JobFunnel configuration settings. + """ + #if args.yaml + import pdb; pdb.set_trace() + # # parse the settings file for the line arguments + # given_yaml = None + # given_yaml_path = None + # if cli.settings is not None: + # given_yaml_path = os.path.dirname(cli.settings) + # given_yaml = yaml.safe_load(open(cli.settings, 'r')) + + # # combine default, given and argument yamls into one. Note that we update + # # the values of the default_yaml, so we use this for the rest of the file. + # # We could make a deep copy if necessary. + # config = default_yaml + # if given_yaml is not None: + # update_yaml(config, given_yaml) + # update_yaml(config, cli_yaml) + # # check if the config has valid attribute types + # check_config_types(config) + + # # create output path and corresponding (children) data paths + # # I feel like this is not in line with the rest of the file's philosophy + # if cli.output_path is not None: + # output_path = cli.output_path + # elif given_yaml_path is not None: + # output_path = os.path.join(given_yaml_path, given_yaml['output_path']) + # else: + # output_path = default_yaml['output_path'] + + # # define paths and normalise + # config['data_path'] = os.path.join(output_path, 'data') + # config['master_list_path'] = os.path.join(output_path, 'master_list.csv') + # config['duplicate_list_path'] = os.path.join( + # output_path, 'duplicate_list.csv') + # config['filter_list_path'] = os.path.join( + # config['data_path'], 'filter_list.json') + # config['log_path'] = os.path.join(config['data_path'], 'jobfunnel.log') + + # # normalize paths + # for p in ['data_path', 'master_list_path', 'duplicate_list_path', + # 'log_path', 'filter_list_path']: + # config[p] = os.path.normpath(config[p]) + + # # lower provider and delay function + # for i, p in enumerate(config['providers']): + # config['providers'][i] = p.lower() + # config['delay_config']['function'] = \ + # config['delay_config']['function'].lower() + + # # parse the log level + # config['log_level'] = LOG_LEVELS_MAP[config['log_level']] + + # # parse the locale into Locale (must be upper case and match enum def name) + # for locale in Locale: + # if locale.name == config['locale']: + # config['locale'] = locale + + # # check if proxy and max_listing_days have not been set yet (optional) + # if 'proxy' not in config: + # config['proxy'] = None + # if 'max_listing_days' not in config: + # config['max_listing_days'] = None + + # return config + + search_cfg = SearchConfig( + keywords=config['search_terms']['keywords'], + province_or_state=config['search_terms']['region']['province_or_state'], + city=config['search_terms']['region']['city'], + distance_radius_km=config['search_terms']['region']['radius'], + return_similar_results=False, + max_listing_days=config['max_listing_days'], + blocked_company_names=config['company_block_list'], + ) + + delay_cfg = DelayConfig( + duration=config['delay_config']['delay'], + min_delay=config['delay_config']['min_delay'], + function_name=config['delay_config']['function'], + random=config['delay_config']['random'], + converge=config['delay_config']['converge'], + ) + + if config['proxy']: + proxy_cfg = ProxyConfig( + protocol=config['proxy']['protocol'], + ip_address=config['proxy']['ip_address'], + port=config['proxy']['port'], + ) + else: + proxy_cfg = None + + funnel_cfg = JobFunnelConfig( + master_csv_file=config['master_list_path'], + user_block_list_file=config['filter_list_path'], + duplicates_list_file=config['duplicate_list_path'], + cache_folder=config['data_path'], + search_terms=search_cfg, + provider_names=config['providers'], + locale=config['locale'], + log_file=config['log_path'], + log_level=config['log_level'], + no_scrape=config['no_scrape'], + delay_config=delay_cfg, + proxy_config=proxy_cfg, + ) + return funnel_cfg diff --git a/jobfunnel/config/cli_parser.py b/jobfunnel/config/cli_parser.py deleted file mode 100644 index d3ff49a4..00000000 --- a/jobfunnel/config/cli_parser.py +++ /dev/null @@ -1,308 +0,0 @@ -"""Configuration parsing module. -""" -import argparse -import logging -import os -import yaml - -from jobfunnel.config.valid_options import CONFIG_TYPES -from jobfunnel.backend.tools.tools import split_url -from jobfunnel.resources import DEFAULT_YAML_PATH - - -log_levels = {'critical': logging.CRITICAL, 'error': logging.ERROR, - 'warning': logging.WARNING, 'info': logging.INFO, - 'debug': logging.DEBUG, 'notset': logging.NOTSET} - - -class ConfigError(ValueError): - def __init__(self, arg): - self.strerror = f"ConfigError: '{arg}' has an invalid value" - self.args = {arg} - - -def parse_cli(): - """ Parse the command line arguments into an argv - """ - - parser = argparse.ArgumentParser( - 'JobFunnel CLI utility - https://github.com/PaulMcInnis/JobFunnel ' - '\nArguments passed here take precedence over YAML settings.') - - parser.add_argument('-s', - dest='settings', - type=str, - required=False, - help='Path to the yaml settings file') - - parser.add_argument('-o', - dest='output_path', - action='store', - required=False, - help='Directory where the search results will be ' - 'stored. NOTE: You should have seperate ' - 'directories per-search!') - - parser.add_argument('-kw', - dest='keywords', - nargs='*', - required=False, - help='List of keywords to use in the job search. (' - 'i.e. Engineer, AI)') - - parser.add_argument('-p', - dest='province', - type=str, - required=False, - help='Province/State value for search region') - - parser.add_argument('--city', - dest='city', - type=str, - required=False, - help='City value for search region') - - parser.add_argument('--domain', - dest='domain', - type=str, - required=False, - help='Domain value for search region.') - - parser.add_argument('-r', - dest='random', - action='store_true', - required=False, - default=None, - help='Turn on random delaying.') - - parser.add_argument('-c', - dest='converge', - action='store_true', - required=False, - default=None, - help='Use converging random delay.') - - parser.add_argument('-d', - dest='delay', - type=float, - required=False, - help='Set delay seconds for scrapes.') - - parser.add_argument('-md', - dest='min_delay', - type=float, - required=False, - help='Set lower bound value for scraper') - - parser.add_argument('--fun', - dest='function', - type=str, - required=False, - default=None, - choices=['constant', 'linear', 'sigmoid'], - help='Select a function to calculate delay times with.') - - parser.add_argument('--log_level', - dest='log_level', - type=str, - required=False, - default=None, - choices=['critical', 'error', 'warning', 'info', - 'debug', 'notset'], - help='Type of logging information shown on the ' - 'terminal.') - - parser.add_argument('--similar', - dest='similar', - action='store_true', - default=None, - help='Pass to get \'similar\' job listings.') - - parser.add_argument('--no_scrape', - dest='no_scrape', - action='store_true', - default=None, - help='Skip web-scraping and load a previously saved ' - 'daily scrape pickle.') - - parser.add_argument('--proxy', - dest='proxy', - type=str, - required=False, - default=None, - help='Proxy address') - - parser.add_argument('--recover', - dest='recover', - action='store_true', - default=None, - help='Recover master-list by accessing all historic ' - 'scrapes pickles.') - - parser.add_argument('--save_dup', - dest='save_duplicates', - action='store_true', - required=False, - default=None, - help='Save duplicates popped by tf_idf filter to file.') - - parser.add_argument('--max_listing_days', - dest='max_listing_days', - type=int, - default=None, - required=False, - help='The maximum number of days old a job can be.' - '(i.e pass 30 to filter out jobs older than a month).') - - return parser.parse_args() - - -def cli_to_yaml(cli): - """ Put program arguments into dictionary in same style as configuration - yaml. - - """ - yaml = { - 'output_path': cli.output_path, - 'search_terms': { - 'region': { - 'province': cli.province, - #'state': cli.state, FIXME: we need to validate for localization - 'city': cli.city, - 'domain': cli.domain - }, - 'keywords': cli.keywords - }, - 'log_level': cli.log_level, - 'similar': cli.similar, - 'no_scrape': cli.no_scrape, - 'recover': cli.recover, - 'save_duplicates': cli.save_duplicates, - 'delay_config': { - 'function': cli.function, - 'delay': cli.delay, - 'min_delay': cli.min_delay, - 'random': cli.random, - 'converge': cli.converge - }, - 'max_listing_days': cli.max_listing_days, - } - - if cli.proxy is not None: - yaml['proxy'] = split_url(cli.proxy) - return yaml - - -def update_yaml(config, new_yaml): - """ Update fields of current yaml with new yaml. - - """ - for k, v in new_yaml.items(): - # if v is a dict we need to dive deeper... - if type(v) is dict: - # There might be times where this dictionary is not in config, - # but it still is a valid option inside of CONFIG_TYPES - # such as it is in the case of proxy - if k not in config: - config[k] = v - - update_yaml(config[k], v) - else: - if v is not None: - config[k] = v - - -def recursive_check_config_types(config, types): - """ Recursively check type of setting vars. - - """ - for k, v in config.items(): - # if type is dict than we have to recursively handle this - if type(v) is dict: - yield from recursive_check_config_types(v, types[k]) - else: - yield (k, type(v) in types[k]) - - -def check_config_types(config): - """ Check if no settings have a wrong type and if we do not have unsupported - options. - - """ - # Get a dictionary of all types and boolean if it's the right type - types_check = recursive_check_config_types(config, CONFIG_TYPES) - - # Select all wrong types and throw error when there is such a value - - wrong_types = [k for k, v in types_check if v is False] - if len(wrong_types) > 0: - raise ConfigError(', '.join(wrong_types)) - - -def parse_config(): - """ Parse the JobFunnel configuration settings. - - """ - # load the default settings - default_yaml = yaml.safe_load(open(DEFAULT_YAML_PATH, 'r')) - - # parse the command line arguments - cli = parse_cli() - cli_yaml = cli_to_yaml(cli) - - # parse the settings file for the line arguments - given_yaml = None - given_yaml_path = None - if cli.settings is not None: - given_yaml_path = os.path.dirname(cli.settings) - given_yaml = yaml.safe_load(open(cli.settings, 'r')) - - # combine default, given and argument yamls into one. Note that we update - # the values of the default_yaml, so we use this for the rest of the file. - # We could make a deep copy if necessary. - config = default_yaml - if given_yaml is not None: - update_yaml(config, given_yaml) - update_yaml(config, cli_yaml) - # check if the config has valid attribute types - check_config_types(config) - - # create output path and corresponding (children) data paths - # I feel like this is not in line with the rest of the file's philosophy - if cli.output_path is not None: - output_path = cli.output_path - elif given_yaml_path is not None: - output_path = os.path.join(given_yaml_path, given_yaml['output_path']) - else: - output_path = default_yaml['output_path'] - - # define paths and normalise - config['data_path'] = os.path.join(output_path, 'data') - config['master_list_path'] = os.path.join(output_path, 'master_list.csv') - config['duplicate_list_path'] = os.path.join( - output_path, 'duplicate_list.csv') - config['filter_list_path'] = os.path.join( - config['data_path'], 'filter_list.json') - config['log_path'] = os.path.join(config['data_path'], 'jobfunnel.log') - - # normalize paths - for p in ['data_path', 'master_list_path', 'duplicate_list_path', - 'log_path', 'filter_list_path']: - config[p] = os.path.normpath(config[p]) - - # lower provider and delay function - for i, p in enumerate(config['providers']): - config['providers'][i] = p.lower() - config['delay_config']['function'] = \ - config['delay_config']['function'].lower() - - # parse the log level - config['log_level'] = log_levels[config['log_level']] - - # check if proxy and max_listing_days have not been set yet (optional) - if 'proxy' not in config: - config['proxy'] = None - if 'max_listing_days' not in config: - config['max_listing_days'] = None - - return config diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py index e880d48a..a57117d7 100644 --- a/jobfunnel/config/delay.py +++ b/jobfunnel/config/delay.py @@ -1,23 +1,23 @@ """Simple config object to contain the delay configuration """ from jobfunnel.config.base import BaseConfig +from jobfunnel.resources import DelayAlgorithm class DelayConfig(BaseConfig): """Simple config object to contain the delay configuration """ - def __init__(self, duration: float, min_delay: float, function_name: str, - random: bool = False, converge: bool = False): + def __init__(self, duration: float, min_delay: float, + algorithm: DelayAlgorithm, random: bool = False, + converge: bool = False): # TODO: document self.duration = duration self.min_delay = min_delay - self.function_name = function_name + self.algorithm = algorithm self.random = random self.converge = converge def validate(self) -> None: - assert self.function_name in ['constant', 'linear', 'sigmoid'] - if self.duration <= 0: raise ValueError("Your delay duration is set to 0 or less.") @@ -25,4 +25,3 @@ def validate(self) -> None: raise ValueError( "Minimum delay is below 0, or more than or equal to delay." ) - diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 34f651dd..5e93b264 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -4,32 +4,9 @@ from typing import Optional, List, Dict, Any import os -from jobfunnel.backend.localization import Locale -from jobfunnel.backend.scrapers import ( - BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng, GlassDoorStaticCAEng, - GlassDoorStaticUSAEng, -) +from jobfunnel.backend.scrapers import BaseScraper, SCRAPER_FROM_LOCALE from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig - - -# FIXME make enum -PROVIDERS_LIST = ['indeed', 'glassdoor', 'monster'] - -# NOTE: if you add a scraper you need to add it here -SCRAPER_MAP = { - # FIXME: make user say 'indeed' and then have it figure out via their - # search terms which one to use - 'indeed': { - Locale.CANADA_ENGLISH: IndeedScraperCAEng, - Locale.USA_ENGLISH: IndeedScraperUSAEng, - }, - 'glassdoor': { - Locale.CANADA_ENGLISH: GlassDoorStaticCAEng, - Locale.CANADA_ENGLISH: GlassDoorStaticUSAEng, - }, - # 'monster': MonsterScraperCAEng, FIXME - #'MONSTER_CANADA_ENG': MonsterScraperCAEng, -} # type: +from jobfunnel.resources import Locale, Provider class JobFunnelConfig(BaseConfig): @@ -39,10 +16,11 @@ class JobFunnelConfig(BaseConfig): def __init__(self, master_csv_file: str, user_block_list_file: str, + duplicates_list_file: str, cache_folder: str, search_terms: SearchConfig, locale: Locale, - provider_names: List[str], + providers: List[Provider], log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, @@ -55,11 +33,13 @@ def __init__(self, master_csv_file (str): path to the .csv file that user interacts w/ user_block_list_file (str): path to a JSON that contains jobs user has decided to omit from their .csv file (i.e. archive status) + duplicates_list_file (str): path to a JSON that contains jobs + which TFIDF has identified to be duplicates of an existing job cache_folder (str): folder where all scrape data will be stored search_terms (SearchTerms): SearchTerms config which contains the desired job search information (i.e. keywords) - provider_names (List[str]): names of job providers / websites that - we want to scrape. Must be defined in our PROVIDERS_LIST + providers (List[str]): names of job providers / websites that + we want to scrape from using the specified locale & searchterms. locale (Locale): the locale we will use for the desired scrapers log_file (str): file to log all logger calls to log_level (int): level to log at, use 10 logging.DEBUG for more data @@ -73,14 +53,16 @@ def __init__(self, """ self.master_csv_file = master_csv_file self.user_block_list_file = user_block_list_file + self.duplicates_list_file = duplicates_list_file self.cache_folder = cache_folder self.search_terms = search_terms - self.provider_names = provider_names + self.providers = providers self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape self.locale = locale if not delay_config: + # We will always use a delay config to be respectful self.delay_config = DelayConfig(5.0, 1.0, 'linear') else: self.delay_config = delay_config @@ -101,7 +83,7 @@ def scrapers(self) -> BaseScraper: """All the compatible scrapers for the provider_name """ return [ - s for s in SCRAPER_MAP[pn[self.locale]] + s for s in SCRAPER_FROM_LOCALE[pn[self.locale]] for pn in self.provider_names ] @@ -122,58 +104,8 @@ def validate(self) -> None: NOTE: will raise exceptions if issues are encountered. FIXME: impl. more validation here """ - for prov in self.provider_names: - assert prov in PROVIDERS_LIST assert os.path.exists(self.cache_folder) self.search_terms.validate() if self.proxy_config: self.proxy_config.validate() self.delay_config.validate() - - -def build_funnel_cfg_from_legacy(config: Dict[str, Any]): - """Build config objects from legacy config dict - FIXME: when we implement a yaml parser with localization we can have it - """ - search_cfg = SearchConfig( - keywords=config['search_terms']['keywords'], - province=config['search_terms']['region']['province'], - state=None, - city=config['search_terms']['region']['city'], - distance_radius_km=config['search_terms']['region']['radius'], - return_similar_results=False, - max_listing_days=config['max_listing_days'], - blocked_company_names=config['black_list'], - ) - - delay_cfg = DelayConfig( - duration=config['delay_config']['delay'], - min_delay=config['delay_config']['min_delay'], - function_name=config['delay_config']['function'], - random=config['delay_config']['random'], - converge=config['delay_config']['converge'], - ) - - if config['proxy']: - proxy_cfg = ProxyConfig( - protocol=config['proxy']['protocol'], - ip_address=config['proxy']['ip_address'], - port=config['proxy']['port'], - ) - else: - proxy_cfg = None - - funnel_cfg = JobFunnelConfig( - master_csv_file=config['master_list_path'], - user_block_list_file=config['filter_list_path'], - cache_folder=config['data_path'], - search_terms=search_cfg, - provider_names=config['providers'], - locale=config['locale'], #FIXME: impl. - log_file=config['log_path'], - log_level=config['log_level'], - no_scrape=config['no_scrape'], - delay_config=delay_cfg, - proxy_config=proxy_cfg, - ) - return funnel_cfg diff --git a/jobfunnel/config/proxy.py b/jobfunnel/config/proxy.py index 800cde2d..fad147f6 100644 --- a/jobfunnel/config/proxy.py +++ b/jobfunnel/config/proxy.py @@ -25,5 +25,5 @@ def url(self) -> str: return url_str # FIXME: this could be done in one line def validate(self) -> None: - """TODO: validate ip addr is valid format etc""" + """FIXME: impl. validate ip addr is valid format etc""" pass diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index 00480a96..89287e2f 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -1,12 +1,10 @@ """Object to contain job query metadata """ from typing import List, Optional -from jobfunnel.backend.localization import Locale from jobfunnel.config import BaseConfig - - -DEFAULT_SEARCH_RADIUS_KM = 25 -DEFAULT_MAX_LISTING_DAYS = 60 +from jobfunnel.resources import ( + Locale, DEFAULT_SEARCH_RADIUS_KM, DEFAULT_MAX_LISTING_DAYS +) class SearchConfig(BaseConfig): @@ -19,8 +17,8 @@ class SearchConfig(BaseConfig): def __init__(self, keywords: List[str], - province: Optional[str] = None, - state: Optional[str] = None, + province_or_state: Optional[str] = None, + # state: Optional[str] = None, TODO: impl. per-locale ? city: Optional[str] = None, distance_radius_km: Optional[int] = None, return_similar_results: Optional[bool] = False, @@ -30,8 +28,8 @@ def __init__(self, Args: keywords (List[str]): list of search keywords - province (Optional[str], optional): province. Defaults to None. - state (Optional[str], optional): state. Defaults to None. + province_or_state (Optional[str], optional): province or state. + Defaults to None. city (Optional[str], optional): city. Defaults to None. distance_radius_km (Optional[int], optional): km radius. Defaults to DEFAULT_SEARCH_RADIUS_KM. @@ -42,9 +40,9 @@ def __init__(self, blocked_company_names (Optional[List[str]]): list of names of companies that we never want to see in our results. """ - self.province = province - self.state = state - self.city = city + self.province = province_or_state + self.state = province_or_state + self.city = city.lower() self.radius = distance_radius_km or DEFAULT_SEARCH_RADIUS_KM self.keywords = keywords self.return_similar_results = return_similar_results # indeed thing diff --git a/jobfunnel/config/valid_options.py b/jobfunnel/config/valid_options.py deleted file mode 100644 index 020ab47b..00000000 --- a/jobfunnel/config/valid_options.py +++ /dev/null @@ -1,38 +0,0 @@ -CONFIG_TYPES = { - 'output_path': [str], - 'providers': [list], - 'search_terms': { - 'region': { - 'province': [str], - #'state': [str], # FIXME: region needs to respect localization - 'city': [str], - 'domain': [str], - 'radius': [int] - }, - 'keywords': [list] - }, - 'black_list': [list], - 'log_level': [str], - 'similar': [bool], - 'no_scrape': [bool], - 'recover': [bool], - 'save_duplicates': [bool], - 'delay_config': { - 'function': [str], - 'delay': [float, int], - 'min_delay': [float, int], - 'random': [bool], - 'converge': [bool] - }, - 'proxy': { - 'protocol': [str], - 'ip_address': [str], - 'port': [str] - }, - 'max_listing_days': [int], - -} - -PROVIDERS = ['glassdoordynamic', 'glassdoorstatic', 'indeed', 'monster'] -DOMAINS = ['com', 'ca'] -DELAY_FUN = ['constant', 'linear', 'sigmoid'] diff --git a/jobfunnel/config/validate.py b/jobfunnel/config/validate.py deleted file mode 100644 index ece2d7a4..00000000 --- a/jobfunnel/config/validate.py +++ /dev/null @@ -1,73 +0,0 @@ -import re - -from jobfunnel.config.valid_options import DOMAINS, PROVIDERS, DELAY_FUN -from jobfunnel.config import ConfigError - - -def validate_region(region): - """ Check if the region settings are valid. - - """ - # only allow supported domains - if region['domain'] not in DOMAINS: - raise ConfigError('domain') - - # search term state is inserted as province if province does not already - # exist - if 'state' in region: - if (region['state'] is not None) and (region['province'] is None): - region['province'] = region['state'] - - # north american jobs should have a province/state provided - if region['domain'] in ['com', 'ca'] and region['province'] is None: - raise ConfigError('province') - - -def validate_delay(delay): - """ Check if the delay has a valid configuration. - - """ - # delay function should be constant, linear or sigmoid - if delay['function'] not in DELAY_FUN: - raise ConfigError('delay_function') - - # maximum delay should be larger or equal to minimum delay - if delay['delay'] < delay['min_delay']: - raise ConfigError('(min)_delay') - - # minimum delay should be at least 1 and maximum delay at least 10 - if delay['delay'] < 10 or delay['min_delay'] < 1: - raise ConfigError('(min)_delay') - - -def validate_config(config): - """ Check whether the config is a valid configuration. - - Some options are already checked at the command-line tool, e.g., loggging. - Some checks are trivial while others have a separate function. - """ - # check if paths are valid - check_paths = { - 'data_path': r'data$', - 'master_list_path': r'master_list\.csv$', - 'duplicate_list_path': r'duplicate_list\.csv$', - 'log_path': r'data[\\\/]jobfunnel.log$', - 'filter_list_path': r'data[\\\/]filter_list\.json$', - } - - for path, pattern in check_paths.items(): - if not re.search(pattern, config[path]): - raise ConfigError(path) - # check if the provider list only consists of supported providers - if not set(config['providers']).issubset(PROVIDERS): - raise ConfigError('providers') - - # check validity of region settings - validate_region(config['search_terms']['region']) - - # check validity of delay settings - validate_delay(config['delay_config']) - - # check the validity of max_listing_days settings - if(config['max_listing_days'] is not None and config['max_listing_days'] < 0): - raise ConfigError('max_listing_days') diff --git a/jobfunnel/resources/__init__.py b/jobfunnel/resources/__init__.py index dd6b8cd7..5f0c1620 100644 --- a/jobfunnel/resources/__init__.py +++ b/jobfunnel/resources/__init__.py @@ -1,3 +1,2 @@ -from jobfunnel.resources.resources import ( - USER_AGENT_LIST, CSV_HEADER, DEFAULT_YAML_PATH -) +from jobfunnel.resources.resources import * +from jobfunnel.resources.enums import * diff --git a/jobfunnel/resources/default_settings.yaml b/jobfunnel/resources/default_settings.yaml deleted file mode 100644 index 25870fa6..00000000 --- a/jobfunnel/resources/default_settings.yaml +++ /dev/null @@ -1,66 +0,0 @@ -# This is the default settings file. Do not edit. - -# all paths are relative to this file - -# paths -output_path: 'search' - -# providers from which to search (case insensitive) -providers: - # - 'glassdoor' - - 'indeed' - # - 'Monster_CANADA_ENG' - - - -# filters -search_terms: - region: - province: 'ON' - city: 'waterloo' - domain: 'ca' - radius: 25 - - keywords: - - 'Python' - -# black-listed company names -black_list: - - 'Infox Consulting' - -# logging level options are: critical, error, warning, info, debug, notset -log_level: 'info' - -# keep similar job postings -similar: False - -# skip web-scraping and load a previously saved daily scrape pickle -no_scrape: False - -# recover master-list by accessing all historic scrapes pickles -recover: False - -# saves duplicates removed by tfidf filter to duplicate_list.csv -save_duplicates: False - -# delaying algorithm configuration -delay_config: - # functions used for delaying algorithm, options are: constant, linear, sigmoid - function: 'linear' - # maximum delay/upper bound for converging random delay - delay: 10.0 - # minimum delay/lower bound for random delay - min_delay: 1.0 - # random delay - random: False - # converging random delay, only used if 'random' is set to True - converge: False - -# proxy settings -# proxy: -# # protocol (http or https) -# protocol: 'https' -# # ip address -# ip_address: '1.1.1.1' -# # port -# port: '200' diff --git a/jobfunnel/resources/enums.py b/jobfunnel/resources/enums.py new file mode 100644 index 00000000..eeaa7f40 --- /dev/null +++ b/jobfunnel/resources/enums.py @@ -0,0 +1,51 @@ +from enum import Enum + +class Locale(Enum): + """This will allow Scrapers / Filters / Main to identify the support they + have for different domains of different websites + + Locale must be set as it defines the code implementation to use for forming + the correct GET requests, to allow us to interact with a job-source. + + NOTE: add locales here as you need them, we do them per-country per-language + becuase scrapers are written per-language-per-country as this matches how + the information is served by job websites. + """ + CANADA_ENGLISH = 1 + CANADA_FRENCH = 2 + USA_ENGLISH = 3 + + +# Some enums +class JobStatus(Enum): + """Job statuses that are built-into jobfunnel + NOTE: these are the only valid values for entries in 'status' in our CSV + """ + UNKNOWN = 1 + NEW = 2 + ARCHIVE = 3 + INTERVIEWING = 4 + INTERVIEWED = 5 + REJECTED = 6 + ACCEPTED = 7 + DELETE = 8 + INTERESTED = 9 + APPLIED = 10 + APPLY = 11 + OLD = 12 + + +class Provider(Enum): + """Job source providers + """ + INDEED = 1 + GLASSDOOR = 2 + MONSTER = 3 + + +class DelayAlgorithm(Enum): + """delaying algorithms + """ + CONSTANT = 1 + SIGMOID = 2 + LINEAR = 3 diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 1968229d..3767d567 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -1,25 +1,39 @@ """Constant definitions or files we need to load once can go here """ import os +import string +from pathlib import Path +# CSV header for output CSV. do not remove anything or you'll break usr's CSV's +# TODO: need to add short and long descriptions (breaking change) CSV_HEADER = [ 'status', 'title', 'company', 'location', 'date', 'blurb', 'tags', 'link', 'id', 'provider', 'query', 'locale' -] # TODO: need to add short and long descriptions (breaking change) +] +# Maximum num threads we use when scraping +MAX_CPU_WORKERS = 8 -USER_AGENT_LIST_FILE = os.path.normpath( - os.path.join(os.path.dirname(__file__), 'user_agent_list.txt') +# Default args +DEFAULT_SEARCH_RADIUS_KM = 25 +DEFAULT_MAX_LISTING_DAYS = 60 + +# Other definitions +USER_HOME_DIRECTORY = os.path.abspath(str(Path.home())) +DEFAULT_OUTPUT_DIRECTORY = os.path.join( + USER_HOME_DIRECTORY, 'job_search_results' ) +DEFAULT_CACHE_DIRECTORY = os.path.join(DEFAULT_OUTPUT_DIRECTORY, '.cache') +PRINTABLE_STRINGS = set(string.printable) +# Load the user agent list once only. +USER_AGENT_LIST_FILE = os.path.normpath( + os.path.join(os.path.dirname(__file__), 'user_agent_list.txt') +) USER_AGENT_LIST = [] with open(USER_AGENT_LIST_FILE) as file: for line in file: li = line.strip() if li and not li.startswith("#"): USER_AGENT_LIST.append(line.rstrip('\n')) - -DEFAULT_YAML_PATH = os.path.join( - os.path.normpath(os.path.dirname(__file__)), 'default_settings.yaml' -) From 9462b18c1bfcdd7daf159208f725a4de3cdfbfb2 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 7 Aug 2020 09:31:49 -0400 Subject: [PATCH 09/66] add cerberus schema validator --- demo/settings.yaml | 35 ++---- jobfunnel/config/__init__.py | 1 + jobfunnel/config/cli.py | 201 ++++++++++++++++--------------- jobfunnel/config/settings.py | 108 +++++++++++++++++ jobfunnel/resources/resources.py | 4 + setup.py | 1 + 6 files changed, 232 insertions(+), 118 deletions(-) create mode 100644 jobfunnel/config/settings.py diff --git a/demo/settings.yaml b/demo/settings.yaml index 8978d2a8..a449471a 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -12,13 +12,13 @@ locale: # Providers from which to search (i.e. glassdoor, monster) # NOTE: we will choose domain via locale (i.e. CANADA_ENGLISH --> www.indeed.ca) providers: - - indeed + - INDEED # Also available: -# - glassdoor -# - monster +# - GLASSDOOR +# - MONSTER # Job search configuration -search_terms: +search: # This is the region you are searching for jobs in, and the distance in km # within which to return jobs. @@ -40,36 +40,27 @@ company_block_list: - "Infox Consulting" # Logging level options are: critical, error, warning, info, debug, notset -log_level: info - -# Keep similar job postings -similar: False - -# Skip web-scraping and load a previously saved daily scrape pickle -no_scrape: False - -# Recover master-list by accessing all historic scrapes pickles -recover: False +log_level: INFO # Saves duplicates removed by tfidf filter to duplicate_list.csv # TODO: document why this should be done. save_duplicates: False # Delaying algorithm configuration -delay_config: - # Functions used for delaying algorithm: 'constant', 'linear', 'sigmoid' - function: linear +delay: + # Functions used for delaying algorithm: CONSTANT, LINEAR, SIGMOID + algorithm: LINEAR # Maximum delay/upper bound for converging random delay - delay: 5.0 + max_duration: 5.0 # Minimum delay/lower bound for random delay - min_delay: 1.0 + min_duration: 1.0 # Random delay - random: False + random_delay: False # Converging random delay, only used if 'random' is set to True - converge: False + converging_random_delay: False # # Proxy settings # proxy: # protocol: https # NOTE: you can also set to 'http' -# ip_address: "1.1.1.1" +# ip: "1.1.1.1" # port: '200' diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index bc8486b0..dc8c79f0 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -1,3 +1,4 @@ +from jobfunnel.config.settings import SettingsValidator from jobfunnel.config.base import BaseConfig from jobfunnel.config.delay import DelayConfig from jobfunnel.config.proxy import ProxyConfig diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 55521a7d..657c9c90 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -7,7 +7,7 @@ import yaml from jobfunnel.config import ( - JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig) + JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig, SettingsValidator) from jobfunnel.backend.tools.tools import split_url from jobfunnel.resources import ( Locale, DelayAlgorithm, DEFAULT_OUTPUT_DIRECTORY, DEFAULT_CACHE_DIRECTORY, @@ -66,67 +66,67 @@ def parse_cli(): # help='Path to a master CSV file containing your search results' # ) - # Search terms - parser.add_argument( - '-k', - dest='search_keywords', - nargs='+', - default=['Python'], - help='List of job-search keywords. (i.e. Engineer, AI).' - ) + # # Search terms + # parser.add_argument( + # '-k', + # dest='search_keywords', + # nargs='+', + # default=['Python'], + # help='List of job-search keywords. (i.e. Engineer, AI).' + # ) - parser.add_argument( - '-l', - dest='locale', - default=Locale.CANADA_ENGLISH, - choices=[l.name for l in Locale], - help='Global location and language to use to scrape the job provider' - ' website. (i.e. CANADA_ENGLISH --> indeed --> indeed.ca)' - ) + # parser.add_argument( + # '-l', + # dest='locale', + # default=Locale.CANADA_ENGLISH, + # choices=[l.name for l in Locale], + # help='Global location and language to use to scrape the job provider' + # ' website. (i.e. CANADA_ENGLISH --> indeed --> indeed.ca)' + # ) - parser.add_argument( - '-p', - dest='province_or_state', - default='ON', # TODO: we should use a Local object of some sort. - type=str, - help='Province/state value for your job-search region. NOTE: format ' - 'is job-provider-specific.' - ) + # parser.add_argument( + # '-p', + # dest='province_or_state', + # default='ON', # TODO: we should use a Local object of some sort. + # type=str, + # help='Province/state value for your job-search region. NOTE: format ' + # 'is job-provider-specific.' + # ) - parser.add_argument( - '-c', - dest='city', - default='Waterloo', - type=str, - help='City/town value for job-search region.' - ) + # parser.add_argument( + # '-c', + # dest='city', + # default='Waterloo', + # type=str, + # help='City/town value for job-search region.' + # ) - parser.add_argument( - '-max-age', - type=int, - help='The maximum number of days-old a job can be. (i.e pass 30 to ' - 'filter out jobs older than a month).' - ) + # parser.add_argument( + # '-max-age', + # type=int, + # help='The maximum number of days-old a job can be. (i.e pass 30 to ' + # 'filter out jobs older than a month).' + # ) - # Functionality - parser.add_argument( - '--log-level', - type=str, - choices=['critical', 'error', 'warning', 'info', 'debug', 'notset'], - help='Type of logging information shown on the terminal.' - ) + # # Functionality + # parser.add_argument( + # '--log-level', + # type=str, + # choices=['critical', 'error', 'warning', 'info', 'debug', 'notset'], + # help='Type of logging information shown on the terminal.' + # ) - parser.add_argument( - '--recover', - action='store_true', - help='Reconstruct a new master CSV file from all available cache files.' - ) + # parser.add_argument( + # '--recover', + # action='store_true', + # help='Reconstruct a new master CSV file from all available cache files.' + # ) - parser.add_argument( - '--save-duplicates', - action='store_true', - help='Save duplicate job key_ids into file.' - ) + # parser.add_argument( + # '--save-duplicates', + # action='store_true', + # help='Save duplicate job key_ids into file.' + # ) # # Proxy stuff move to subparser. # # FIXME missing stuff here @@ -175,7 +175,16 @@ def parse_cli(): def config_builder(args: argparse.Namespace) -> JobFunnelConfig: """Parse the JobFunnel configuration settings. """ - #if args.yaml + if args.settings_yaml_file: + config = yaml.load( + open(args.settings_yaml_file, 'r'), Loader=yaml.FullLoader + ) + if not SettingsValidator.validate(config): + # TODO: some way to print allowed values in error msg? + raise ValueError( + f"Invalid Config settings yaml:\n{SettingsValidator.errors}" + ) + import pdb; pdb.set_trace() # # parse the settings file for the line arguments # given_yaml = None @@ -239,45 +248,45 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: # return config - search_cfg = SearchConfig( - keywords=config['search_terms']['keywords'], - province_or_state=config['search_terms']['region']['province_or_state'], - city=config['search_terms']['region']['city'], - distance_radius_km=config['search_terms']['region']['radius'], - return_similar_results=False, - max_listing_days=config['max_listing_days'], - blocked_company_names=config['company_block_list'], - ) + # search_cfg = SearchConfig( + # keywords=config['search_terms']['keywords'], + # province_or_state=config['search_terms']['region']['province_or_state'], + # city=config['search_terms']['region']['city'], + # distance_radius_km=config['search_terms']['region']['radius'], + # return_similar_results=False, + # max_listing_days=config['max_listing_days'], + # blocked_company_names=config['company_block_list'], + # ) - delay_cfg = DelayConfig( - duration=config['delay_config']['delay'], - min_delay=config['delay_config']['min_delay'], - function_name=config['delay_config']['function'], - random=config['delay_config']['random'], - converge=config['delay_config']['converge'], - ) + # delay_cfg = DelayConfig( + # duration=config['delay_config']['delay'], + # min_delay=config['delay_config']['min_delay'], + # function_name=config['delay_config']['function'], + # random=config['delay_config']['random'], + # converge=config['delay_config']['converge'], + # ) - if config['proxy']: - proxy_cfg = ProxyConfig( - protocol=config['proxy']['protocol'], - ip_address=config['proxy']['ip_address'], - port=config['proxy']['port'], - ) - else: - proxy_cfg = None - - funnel_cfg = JobFunnelConfig( - master_csv_file=config['master_list_path'], - user_block_list_file=config['filter_list_path'], - duplicates_list_file=config['duplicate_list_path'], - cache_folder=config['data_path'], - search_terms=search_cfg, - provider_names=config['providers'], - locale=config['locale'], - log_file=config['log_path'], - log_level=config['log_level'], - no_scrape=config['no_scrape'], - delay_config=delay_cfg, - proxy_config=proxy_cfg, - ) - return funnel_cfg + # if config['proxy']: + # proxy_cfg = ProxyConfig( + # protocol=config['proxy']['protocol'], + # ip_address=config['proxy']['ip_address'], + # port=config['proxy']['port'], + # ) + # else: + # proxy_cfg = None + + # funnel_cfg = JobFunnelConfig( + # master_csv_file=config['master_list_path'], + # user_block_list_file=config['filter_list_path'], + # duplicates_list_file=config['duplicate_list_path'], + # cache_folder=config['data_path'], + # search_terms=search_cfg, + # provider_names=config['providers'], + # locale=config['locale'], + # log_file=config['log_path'], + # log_level=config['log_level'], + # no_scrape=config['no_scrape'], + # delay_config=delay_cfg, + # proxy_config=proxy_cfg, + # ) + # return funnel_cfg diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py new file mode 100644 index 00000000..23243fc4 --- /dev/null +++ b/jobfunnel/config/settings.py @@ -0,0 +1,108 @@ +"""Settings YAML Schema w/ validator +""" +from cerberus import Validator +import ipaddress +import logging + +from jobfunnel.resources import ( + Locale, Provider, DelayAlgorithm, LOG_LEVEL_NAMES +) + + +SETTINGS_YAML_SCHEMA = { + 'output_path': {'required': True, 'type': 'string'}, + 'locale' : {'required': True, 'allowed': [l.name for l in Locale]}, + 'providers': {'required': True, 'allowed': [p.name for p in Provider]}, + # 'no_scrape': {'required': False, 'type': 'boolean'}, # NOTE: CLI only. + # 'recover': {'required': False, 'type': 'boolean'}, # NOTE: CLI only. + 'search': { + 'type': 'dict', + 'required': True, + 'schema': { + 'region': { + 'type': 'dict', + 'required': True, + 'schema': { + 'province_or_state': {'required': True, 'type': 'string'}, + 'city': {'required': True, 'type': 'string'}, + 'radius': {'required': False, 'type': 'integer', 'min': 0}, + }, + }, + 'keywords': { + 'required': True, + 'type': 'list', 'schema': {'type': 'string'}, + }, + 'similar': {'required': False, 'type': 'boolean'}, + }, + }, + 'company_block_list': { + 'required': False, + 'type': 'list', 'schema': {'type': 'string'}, + }, + 'log_level': {'required': False, 'allowed': LOG_LEVEL_NAMES}, + 'save_duplicates': {'required': False, 'type': 'boolean'}, + 'delay': { + 'type': 'dict', + 'required': False, + 'schema' : { + 'algorithm': { + 'required': False, + 'allowed': [d.name for d in DelayAlgorithm], + }, + # TODO: implement custom rule max > min + 'max_duration': { + 'required': False, + 'type': 'float', + 'min': 0, + }, + 'min_duration': { + 'required': False, + 'type': 'float', + 'min': 0, + }, + 'random_delay': {'required': False, 'type': 'boolean'}, + 'converging_random_delay': {'required': False, 'type': 'boolean'}, + }, + }, + + 'proxy': { + 'type': 'dict', + 'required': False, + 'schema' : { + 'protocol': { + 'required': False, + 'allowed': ['http', 'https'], + }, + 'ip': { + 'required': False, + 'type': 'ipv4address', + }, + 'port': { + 'required': False, + 'type': 'integer', + 'min': 0, + }, + 'random_delay': {'required': False, 'type': 'boolean'}, + 'converging_random_delay': {'required': False, 'type': 'boolean'}, + }, + }, +} + + +class JobFunnelSettingsValidator(Validator): + """A simple JSON data validator with a custom data type for IPv4 addresses + https://codingnetworker.com/2016/03/validate-json-data-using-cerberus/ + """ + def _validate_type_ipv4address(self, field, value): + """ + checks that the given value is a valid IPv4 address + """ + try: + # try to create an IPv4 address object using the python3 ipaddress module + ipaddress.IPv4Address(value) + + except: + self._error(field, "Not a valid IPv4 address") + + +SettingsValidator = JobFunnelSettingsValidator(SETTINGS_YAML_SCHEMA) diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 3767d567..795e06e7 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -11,6 +11,10 @@ 'id', 'provider', 'query', 'locale' ] +LOG_LEVEL_NAMES = [ + 'CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET' +] + # Maximum num threads we use when scraping MAX_CPU_WORKERS = 8 diff --git a/setup.py b/setup.py index b1e00604..16869aa1 100644 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ 'pytest-mock>=3.1.1', 'selenium>=3.141.0', 'webdriver-manager>=2.4.0', + 'Cerberus>=1.3.2', ] with open('readme.md', 'r') as f: From 0626bc3ba00ad7a9c24ba01999b3025e9cef991b Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 7 Aug 2020 22:38:51 -0400 Subject: [PATCH 10/66] implemented config and validation with Cerberus --- jobfunnel/config/base.py | 3 +- jobfunnel/config/cli.py | 556 ++++++++++++++++++------------- jobfunnel/config/delay.py | 25 +- jobfunnel/config/funnel.py | 21 +- jobfunnel/config/search.py | 15 +- jobfunnel/config/settings.py | 55 ++- jobfunnel/resources/defaults.py | 102 ++++++ jobfunnel/resources/resources.py | 12 - 8 files changed, 490 insertions(+), 299 deletions(-) create mode 100644 jobfunnel/resources/defaults.py diff --git a/jobfunnel/config/base.py b/jobfunnel/config/base.py index 1eb87a2a..1e3f7021 100644 --- a/jobfunnel/config/base.py +++ b/jobfunnel/config/base.py @@ -11,6 +11,7 @@ def __init__(self) -> None: def validate(self) -> None: """This should raise Exceptions if self.attribs are bad - FIXME: some way to run this on object creation would be nice + FIXME: move this into cerberus schema validation, or, use the same + validators it does here. """ pass diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 657c9c90..0be6df61 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -10,8 +10,11 @@ JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig, SettingsValidator) from jobfunnel.backend.tools.tools import split_url from jobfunnel.resources import ( - Locale, DelayAlgorithm, DEFAULT_OUTPUT_DIRECTORY, DEFAULT_CACHE_DIRECTORY, -) + Locale, DelayAlgorithm, LOG_LEVEL_NAMES, Provider) +from jobfunnel.resources.defaults import * + + +PROVIDER_NAMES = [p.name for p in DEFAULT_PROVIDERS] def parse_cli(): @@ -26,267 +29,340 @@ def parse_cli(): type=str, help='Path to a settings YAML file containing your job search info. ' 'Pass an existing YAML file path to continue a search ' - 'by scraping new jobs and updating the CSV file. ' + 'by scraping new jobs and updating the CSV file. CLI args will ' + 'overwrite any settings in YAML.' ) - # FIXME: make it mutually exclusive to pass -o or -mscv/-bl/-cache + # This arg is problematic because you can't pass it and the + # paths to the files directly. parser.add_argument( '-o', - dest='job_search_results_folder', + dest='output_folder', default=DEFAULT_OUTPUT_DIRECTORY, help='Directory where the job search results will be stored. ' 'Pass an existing search results folder to continue a search ' 'by scraping new jobs and updating the CSV file. ' 'Note that you should use seperate folders per-job-search! ' 'Folder contents: /data/.cache/, /master_list.csv.' - ' These folders and associated files will be created if not found.' + ' These folders and associated files will be created if not found,' + ' or if -cache, -blf -dl, and -csv paths are not passed as args.' f' Defaults to: {DEFAULT_OUTPUT_DIRECTORY}' ) - # parser.add_argument( - # '-cache', - # dest='cache_folder', - # default=DEFAULT_CACHE_DIRECTORY, - # help='Directory where cached scrape data will be stored. defaults to ' - # + DEFAULT_CACHE_DIRECTORY - # ) - - # parser.add_argument( - # '-bl', - # dest='block-list-file', - # nargs='*', - # help='JSON file of jobs you want to omit from your job search ' - # '(usually this is in the output of previous jobfunnel results).' - # ) - - # parser.add_argument( - # '-mcsv', - # dest='master_csv_file', - # nargs='*', - # help='Path to a master CSV file containing your search results' - # ) - - # # Search terms - # parser.add_argument( - # '-k', - # dest='search_keywords', - # nargs='+', - # default=['Python'], - # help='List of job-search keywords. (i.e. Engineer, AI).' - # ) - - # parser.add_argument( - # '-l', - # dest='locale', - # default=Locale.CANADA_ENGLISH, - # choices=[l.name for l in Locale], - # help='Global location and language to use to scrape the job provider' - # ' website. (i.e. CANADA_ENGLISH --> indeed --> indeed.ca)' - # ) - - # parser.add_argument( - # '-p', - # dest='province_or_state', - # default='ON', # TODO: we should use a Local object of some sort. - # type=str, - # help='Province/state value for your job-search region. NOTE: format ' - # 'is job-provider-specific.' - # ) - - # parser.add_argument( - # '-c', - # dest='city', - # default='Waterloo', - # type=str, - # help='City/town value for job-search region.' - # ) - - # parser.add_argument( - # '-max-age', - # type=int, - # help='The maximum number of days-old a job can be. (i.e pass 30 to ' - # 'filter out jobs older than a month).' - # ) - - # # Functionality - # parser.add_argument( - # '--log-level', - # type=str, - # choices=['critical', 'error', 'warning', 'info', 'debug', 'notset'], - # help='Type of logging information shown on the terminal.' - # ) - - # parser.add_argument( - # '--recover', - # action='store_true', - # help='Reconstruct a new master CSV file from all available cache files.' - # ) - - # parser.add_argument( - # '--save-duplicates', - # action='store_true', - # help='Save duplicate job key_ids into file.' - # ) - - # # Proxy stuff move to subparser. - # # FIXME missing stuff here - # parser.add_argument( - # '--proxy', - # type=str, - # help='Proxy address (URL).' - # ) - - # # Delay stuff - # # TODO: move delay args into a subparser for improved -h clarity - # parser.add_argument( - # '--random-delay', - # action='store_true', - # help='Turn on random delaying for certain get requests.' - # ) - - # parser.add_argument( - # '--converging-delay', - # action='store_true', - # help='Use converging random delay for certain get requests.' - # ) - - # parser.add_argument( - # '-delay-duration', - # type=float, - # help='Set delay seconds for certain get requests.' - # ) - - # parser.add_argument( - # '-delay-min', - # type=float, - # help='Set lower bound value for delay for certain get requests.' - # ) - - # parser.add_argument( - # '-delay-algorithm', - # choices=[a.name for a in DelayAlgorithm], - # help='Select a function to calculate delay times with.' - # ) + parser.add_argument( + '-csv', + dest='master_csv_file', + default=DEFAULT_MASTER_CSV_FILE, + nargs='*', + help='Path to a master CSV file containing your search results. ' + f'Defaults to {DEFAULT_MASTER_CSV_FILE}' + ) + parser.add_argument( + '-cache', + dest='cache_folder', + default=DEFAULT_CACHE_DIRECTORY, + help='Directory where cached scrape data will be stored. ' + f'Defaults to {DEFAULT_CACHE_DIRECTORY}' + ) + + parser.add_argument( + '-blf', + dest='block_list_file', + nargs='*', + default=DEFAULT_BLOCK_LIST_FILE, + help='JSON file of jobs you want to omit from your job search ' + '(usually this is in the output of previous jobfunnel results). ' + f'Defaults to: {DEFAULT_BLOCK_LIST_FILE}' + ) + + parser.add_argument( + '-lf', + dest='log_file', + type=str, + default=DEFAULT_LOG_FILE, + help='path to logging file.' + ) + + parser.add_argument( + '-dl', + dest='duplicates_list_file', + nargs='*', + default=DEFAULT_DUPLICATES_FILE, + help='JSON file of jobs which have been detected to be duplicates of ' + 'existing jobs (usually this is in the output of previous ' + f'jobfunnel results). Defaults to: {DEFAULT_DUPLICATES_FILE}' + ) + + parser.add_argument( + '-cbl', + dest='search_company_block_list', + nargs='+', + default=DEFAULT_COMPANY_BLOCK_LIST, + help='List of company names to omit from all search results.' + ) + + # Search terms + parser.add_argument( + '-p', + dest='search_providers', + choices=PROVIDER_NAMES, + default=PROVIDER_NAMES, + help='List of job-search providers. (i.e. indeed, monster, glassdoor).' + ) + + parser.add_argument( + '-k', + dest='search_keywords', + nargs='+', + default=DEFAULT_SEARCH_KEYWORDS, + help='List of job-search keywords. (i.e. Engineer, AI).' + ) + + parser.add_argument( + '-l', + dest='search_locale', + default=DEFAULT_LOCALE.name, + choices=[l.name for l in Locale], + help='Global location and language to use to scrape the job provider' + ' website. (i.e. CANADA_ENGLISH --> indeed --> indeed.ca)' + ) + + parser.add_argument( + '-ps', + dest='search_region_province_or_state', + default=DEFAULT_PROVINCE, + type=str, + help='Province/state value for your job-search region. NOTE: format ' + 'is job-provider-specific.' + ) + + parser.add_argument( + '-c', + dest='search_region_city', + default=DEFAULT_CITY, + type=str, + help='City/town value for job-search region.' + ) + + parser.add_argument( + '-r', + dest='search_region_radius', + type=int, + default=DEFAULT_SEARCH_RADIUS_KM, + help='The maximum distance a job should be from the specified city.' + ) + + parser.add_argument( + '-max-listing-age', + dest='search_max_listing_days', + type=int, + default=DEFAULT_MAX_LISTING_DAYS, + help='The maximum number of days-old a job can be. (i.e pass 30 to ' + 'filter out jobs older than a month).' + ) + + parser.add_argument( + '--similar-results', + action='store_true', + help='Return \'similar\' results from search query (only for Indeed).' + ) + + # Functionality + parser.add_argument( + '--log-level', + type=str, + default=DEFAULT_LOG_LEVEL_NAME, + choices=LOG_LEVEL_NAMES, + help='Type of logging information shown on the terminal.' + ) + + parser.add_argument( + '--recover', + action='store_true', + help='Reconstruct a new master CSV file from all available cache files.' + ) + + parser.add_argument( + '--save-duplicates', + action='store_true', + help='Save duplicate job key_ids into file.' + ) + + parser.add_argument( + '--no-scrape', + action='store_true', + help='Do not make any get requests, and attempt to load from cache.' + ) + + # Proxy stuff + # TODO: subparser. + parser.add_argument( + '-protocol', + dest='proxy_protocol', + type=str, + help='Proxy protocol.' + ) + parser.add_argument( + '-ip', + dest='proxy_ip', + type=str, + help='Proxy IP (V4) address.' + ) + parser.add_argument( + '-port', + dest='proxy_port', + type=str, + help='Proxy port address.' + ) + + # Delay stuff + # TODO: move delay args into a subparser for improved -h clarity + parser.add_argument( + '--delay-random', + dest='delay_random', + action='store_true', + help='Turn on random delaying for certain get requests.' + ) + + parser.add_argument( + '--delay-converging', + dest='delay_converging', + action='store_true', + help='Use converging random delay for certain get requests.' + ) + + parser.add_argument( + '-delay-max', + dest='delay_max_duration', + default=DEFAULT_DELAY_MAX_DURATION, + type=float, + help='Set delay seconds for certain get requests.' + ) + + parser.add_argument( + '-delay-min', + dest='delay_min_duration', + default=DEFAULT_DELAY_MIN_DURATION, + type=float, + help='Set lower bound value for delay for certain get requests.' + ) + + parser.add_argument( + '-delay-algorithm', + default=DEFAULT_DELAY_ALGORITHM.name, + choices=[a.name for a in DelayAlgorithm], + help='Select a function to calculate delay times with.' + ) return parser.parse_args() def config_builder(args: argparse.Namespace) -> JobFunnelConfig: - """Parse the JobFunnel configuration settings. + """Parse the JobFunnel configuration settings into a JobFunnelConfig. + + args [argparse.Namespace]: cli arguments from argparser """ - if args.settings_yaml_file: + # Load config dict from the YAML (may be default) + args_dict = vars(args) + if args_dict.pop('settings_yaml_file'): config = yaml.load( open(args.settings_yaml_file, 'r'), Loader=yaml.FullLoader ) - if not SettingsValidator.validate(config): - # TODO: some way to print allowed values in error msg? + else: + config = DEFAULT_CONFIG + + # Ensure that if user provided output folder that the other paths aren't + if (args_dict['output_folder'] != DEFAULT_OUTPUT_DIRECTORY and ( + args_dict['master_csv_file'] != DEFAULT_MASTER_CSV_FILE + or args_dict['block_list_file'] != DEFAULT_BLOCK_LIST_FILE + or args_dict['duplicates_list_file'] != DEFAULT_DUPLICATES_FILE + or args_dict['cache_folder'] != DEFAULT_CACHE_DIRECTORY)): + raise ValueError( - f"Invalid Config settings yaml:\n{SettingsValidator.errors}" + "When providing output_folder, do not also provide -csv, -blf" + ", -dlf, or -cache, as these are defined by the output folder." + " If specifying file paths you must pass all the arguments and" + " not pass -o." ) - import pdb; pdb.set_trace() - # # parse the settings file for the line arguments - # given_yaml = None - # given_yaml_path = None - # if cli.settings is not None: - # given_yaml_path = os.path.dirname(cli.settings) - # given_yaml = yaml.safe_load(open(cli.settings, 'r')) - - # # combine default, given and argument yamls into one. Note that we update - # # the values of the default_yaml, so we use this for the rest of the file. - # # We could make a deep copy if necessary. - # config = default_yaml - # if given_yaml is not None: - # update_yaml(config, given_yaml) - # update_yaml(config, cli_yaml) - # # check if the config has valid attribute types - # check_config_types(config) - - # # create output path and corresponding (children) data paths - # # I feel like this is not in line with the rest of the file's philosophy - # if cli.output_path is not None: - # output_path = cli.output_path - # elif given_yaml_path is not None: - # output_path = os.path.join(given_yaml_path, given_yaml['output_path']) - # else: - # output_path = default_yaml['output_path'] - - # # define paths and normalise - # config['data_path'] = os.path.join(output_path, 'data') - # config['master_list_path'] = os.path.join(output_path, 'master_list.csv') - # config['duplicate_list_path'] = os.path.join( - # output_path, 'duplicate_list.csv') - # config['filter_list_path'] = os.path.join( - # config['data_path'], 'filter_list.json') - # config['log_path'] = os.path.join(config['data_path'], 'jobfunnel.log') - - # # normalize paths - # for p in ['data_path', 'master_list_path', 'duplicate_list_path', - # 'log_path', 'filter_list_path']: - # config[p] = os.path.normpath(config[p]) - - # # lower provider and delay function - # for i, p in enumerate(config['providers']): - # config['providers'][i] = p.lower() - # config['delay_config']['function'] = \ - # config['delay_config']['function'].lower() - - # # parse the log level - # config['log_level'] = LOG_LEVELS_MAP[config['log_level']] - - # # parse the locale into Locale (must be upper case and match enum def name) - # for locale in Locale: - # if locale.name == config['locale']: - # config['locale'] = locale - - # # check if proxy and max_listing_days have not been set yet (optional) - # if 'proxy' not in config: - # config['proxy'] = None - # if 'max_listing_days' not in config: - # config['max_listing_days'] = None - - # return config - - # search_cfg = SearchConfig( - # keywords=config['search_terms']['keywords'], - # province_or_state=config['search_terms']['region']['province_or_state'], - # city=config['search_terms']['region']['city'], - # distance_radius_km=config['search_terms']['region']['radius'], - # return_similar_results=False, - # max_listing_days=config['max_listing_days'], - # blocked_company_names=config['company_block_list'], - # ) - - # delay_cfg = DelayConfig( - # duration=config['delay_config']['delay'], - # min_delay=config['delay_config']['min_delay'], - # function_name=config['delay_config']['function'], - # random=config['delay_config']['random'], - # converge=config['delay_config']['converge'], - # ) - - # if config['proxy']: - # proxy_cfg = ProxyConfig( - # protocol=config['proxy']['protocol'], - # ip_address=config['proxy']['ip_address'], - # port=config['proxy']['port'], - # ) - # else: - # proxy_cfg = None - - # funnel_cfg = JobFunnelConfig( - # master_csv_file=config['master_list_path'], - # user_block_list_file=config['filter_list_path'], - # duplicates_list_file=config['duplicate_list_path'], - # cache_folder=config['data_path'], - # search_terms=search_cfg, - # provider_names=config['providers'], - # locale=config['locale'], - # log_file=config['log_path'], - # log_level=config['log_level'], - # no_scrape=config['no_scrape'], - # delay_config=delay_cfg, - # proxy_config=proxy_cfg, - # ) - # return funnel_cfg + # Inject any modified attributs only if they override our config/defaults + # TODO less messy way to do this? + output_folder = args_dict.pop('output_folder') + for key, value in args_dict.items(): + if key in config and config[key] != value: + config[key] = value + continue + if 'search_region' in key: + sub_sub_cfg_key = key.split('search_region_')[1] + if config['search']['region'][sub_sub_cfg_key] != value: + config['search']['region'][sub_sub_cfg_key] = value + continue + for sub_cfg_name in ['search', 'delay', 'proxy']: + if sub_cfg_name in key: + sub_cfg_key = key.split(f'{sub_cfg_name}_')[1] + if config[sub_cfg_name][sub_cfg_key] != value: + config[sub_cfg_name][sub_cfg_key] = value + continue + + # Create any folders that we need + if output_folder: + if not os.path.exists(output_folder): + os.makedirs(output_folder) + if not os.path.exists(args_dict['cache_folder']): + os.makedirs(args_dict['cache_folder']) + + # Validate the config we have built + if not SettingsValidator.validate(config): + # TODO: some way to print allowed values in error msg? + raise ValueError( + f"Invalid Config settings yaml:\n{SettingsValidator.errors}" + ) + + # Build JobFunnelConfig + search_cfg = SearchConfig( + keywords=config['search']['keywords'], + province_or_state=config['search']['region']['province_or_state'], + city=config['search']['region']['city'], + distance_radius_km=config['search']['region']['radius'], + return_similar_results=config['search']['similar_results'], + max_listing_days=config['search']['max_listing_days'], + blocked_company_names=config['search']['company_block_list'], + locale=Locale[config['search']['locale']], + providers=[Provider[p] for p in config['search']['providers']], + ) + + delay_cfg = DelayConfig( + max_duration=config['delay']['max_duration'], + min_duration=config['delay']['min_duration'], + algorithm=DelayAlgorithm[config['delay']['algorithm']], + random=config['delay']['random'], + converge=config['delay']['converging'], + ) + + if config['proxy']['ip']: + proxy_cfg = ProxyConfig( + protocol=config['proxy']['protocol'], + ip_address=config['proxy']['ip'], + port=config['proxy']['port'], + ) + else: + proxy_cfg = None + + funnel_cfg = JobFunnelConfig( + master_csv_file=config['master_csv_file'], + user_block_list_file=config['block_list_file'], + duplicates_list_file=config['duplicates_list_file'], + cache_folder=config['cache_folder'], + log_file=config['log_file'], + log_level=config['log_level'], + no_scrape=config['no_scrape'], + search_config=search_cfg, + delay_config=delay_cfg, + proxy_config=proxy_cfg, + ) + + # Validate funnel config as well (checks some stuff Cerberus doesn't rn) + funnel_cfg.validate() + + return funnel_cfg diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py index a57117d7..ddae1cf0 100644 --- a/jobfunnel/config/delay.py +++ b/jobfunnel/config/delay.py @@ -2,26 +2,33 @@ """ from jobfunnel.config.base import BaseConfig from jobfunnel.resources import DelayAlgorithm +from jobfunnel.resources.defaults import ( + DEFAULT_DELAY_ALGORITHM, DEFAULT_DELAY_MAX_DURATION, + DEFAULT_DELAY_MIN_DURATION, DEFAULT_DELAY_ALGORITHM, + DEFAULT_RANDOM_CONVERGING_DELAY, DEFAULT_RANDOM_DELAY, +) + class DelayConfig(BaseConfig): """Simple config object to contain the delay configuration """ - def __init__(self, duration: float, min_delay: float, - algorithm: DelayAlgorithm, random: bool = False, - converge: bool = False): + def __init__(self, max_duration: float = DEFAULT_DELAY_MAX_DURATION, + min_duration: float = DEFAULT_DELAY_MIN_DURATION, + algorithm: DelayAlgorithm = DEFAULT_DELAY_ALGORITHM, + random: bool = DEFAULT_RANDOM_DELAY, + converge: bool = DEFAULT_RANDOM_CONVERGING_DELAY): # TODO: document - self.duration = duration - self.min_delay = min_delay + self.max_duration = max_duration + self.min_duration = min_duration self.algorithm = algorithm self.random = random self.converge = converge def validate(self) -> None: - if self.duration <= 0: - raise ValueError("Your delay duration is set to 0 or less.") - - if self.min_delay < 0 or self.min_delay >= self.duration: + if self.max_duration <= 0: + raise ValueError("Your max delay is set to 0 or less.") + if self.min_duration < 0 or self.min_duration >= self.max_duration: raise ValueError( "Minimum delay is below 0, or more than or equal to delay." ) diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 5e93b264..7dc15d59 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -18,9 +18,7 @@ def __init__(self, user_block_list_file: str, duplicates_list_file: str, cache_folder: str, - search_terms: SearchConfig, - locale: Locale, - providers: List[Provider], + search_config: SearchConfig, log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, @@ -36,11 +34,8 @@ def __init__(self, duplicates_list_file (str): path to a JSON that contains jobs which TFIDF has identified to be duplicates of an existing job cache_folder (str): folder where all scrape data will be stored - search_terms (SearchTerms): SearchTerms config which contains the + search_config (SearchConfig): SearchTerms config which contains the desired job search information (i.e. keywords) - providers (List[str]): names of job providers / websites that - we want to scrape from using the specified locale & searchterms. - locale (Locale): the locale we will use for the desired scrapers log_file (str): file to log all logger calls to log_level (int): level to log at, use 10 logging.DEBUG for more data no_scrape (Optional[bool], optional): If True, will not scrape data @@ -55,15 +50,13 @@ def __init__(self, self.user_block_list_file = user_block_list_file self.duplicates_list_file = duplicates_list_file self.cache_folder = cache_folder - self.search_terms = search_terms - self.providers = providers + self.search_config = search_config self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape - self.locale = locale if not delay_config: # We will always use a delay config to be respectful - self.delay_config = DelayConfig(5.0, 1.0, 'linear') + self.delay_config = DelayConfig() else: self.delay_config = delay_config self.proxy_config = proxy_config @@ -83,8 +76,8 @@ def scrapers(self) -> BaseScraper: """All the compatible scrapers for the provider_name """ return [ - s for s in SCRAPER_FROM_LOCALE[pn[self.locale]] - for pn in self.provider_names + SCRAPER_FROM_LOCALE[p][self.search_config.locale] + for p in self.search_config.providers ] @property @@ -105,7 +98,7 @@ def validate(self) -> None: FIXME: impl. more validation here """ assert os.path.exists(self.cache_folder) - self.search_terms.validate() + self.search_config.validate() if self.proxy_config: self.proxy_config.validate() self.delay_config.validate() diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index 89287e2f..a20e2d04 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -2,8 +2,9 @@ """ from typing import List, Optional from jobfunnel.config import BaseConfig -from jobfunnel.resources import ( - Locale, DEFAULT_SEARCH_RADIUS_KM, DEFAULT_MAX_LISTING_DAYS +from jobfunnel.resources import Locale, Provider +from jobfunnel.resources.defaults import ( + DEFAULT_SEARCH_RADIUS_KM, DEFAULT_MAX_LISTING_DAYS ) @@ -17,8 +18,9 @@ class SearchConfig(BaseConfig): def __init__(self, keywords: List[str], - province_or_state: Optional[str] = None, - # state: Optional[str] = None, TODO: impl. per-locale ? + province_or_state: Optional[str], + locale: Locale, + providers: List[Provider], city: Optional[str] = None, distance_radius_km: Optional[int] = None, return_similar_results: Optional[bool] = False, @@ -28,8 +30,7 @@ def __init__(self, Args: keywords (List[str]): list of search keywords - province_or_state (Optional[str], optional): province or state. - Defaults to None. + province_or_state (str): province or state. city (Optional[str], optional): city. Defaults to None. distance_radius_km (Optional[int], optional): km radius. Defaults to DEFAULT_SEARCH_RADIUS_KM. @@ -44,6 +45,8 @@ def __init__(self, self.state = province_or_state self.city = city.lower() self.radius = distance_radius_km or DEFAULT_SEARCH_RADIUS_KM + self.locale = locale + self.providers = providers self.keywords = keywords self.return_similar_results = return_similar_results # indeed thing self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index 23243fc4..bc3fb6fc 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -10,15 +10,21 @@ SETTINGS_YAML_SCHEMA = { - 'output_path': {'required': True, 'type': 'string'}, - 'locale' : {'required': True, 'allowed': [l.name for l in Locale]}, - 'providers': {'required': True, 'allowed': [p.name for p in Provider]}, - # 'no_scrape': {'required': False, 'type': 'boolean'}, # NOTE: CLI only. - # 'recover': {'required': False, 'type': 'boolean'}, # NOTE: CLI only. + 'master_csv_file': {'required': True, 'type': 'string'}, + 'block_list_file': {'required': True, 'type': 'string'}, + 'cache_folder': {'required': True, 'type': 'string'}, + 'duplicates_list_file': {'required': False, 'type': 'string'}, + 'no_scrape': {'required': False, 'type': 'boolean'}, + 'recover': {'required': False, 'type': 'boolean'}, + 'log_level': {'required': False, 'allowed': LOG_LEVEL_NAMES}, + 'log_file': {'required': False, 'type': 'string'}, + 'save_duplicates': {'required': False, 'type': 'boolean'}, 'search': { 'type': 'dict', 'required': True, 'schema': { + 'providers': {'required': True, 'allowed': [p.name for p in Provider]}, + 'locale' : {'required': True, 'allowed': [l.name for l in Locale]}, 'region': { 'type': 'dict', 'required': True, @@ -28,62 +34,78 @@ 'radius': {'required': False, 'type': 'integer', 'min': 0}, }, }, + 'similar_results': {'required': False, 'type': 'boolean'}, 'keywords': { 'required': True, + 'type': 'list', + 'schema': {'type': 'string'}, + }, + 'max_listing_days': { + 'required': False, 'type': 'integer', 'min': 0 + }, + 'company_block_list': { + 'required': False, 'type': 'list', 'schema': {'type': 'string'}, }, - 'similar': {'required': False, 'type': 'boolean'}, }, }, - 'company_block_list': { - 'required': False, - 'type': 'list', 'schema': {'type': 'string'}, - }, - 'log_level': {'required': False, 'allowed': LOG_LEVEL_NAMES}, - 'save_duplicates': {'required': False, 'type': 'boolean'}, 'delay': { 'type': 'dict', 'required': False, + 'nullable': True, 'schema' : { 'algorithm': { 'required': False, 'allowed': [d.name for d in DelayAlgorithm], + 'nullable': True, }, # TODO: implement custom rule max > min 'max_duration': { 'required': False, 'type': 'float', 'min': 0, + 'nullable': True, }, 'min_duration': { 'required': False, 'type': 'float', 'min': 0, + 'nullable': True, }, - 'random_delay': {'required': False, 'type': 'boolean'}, - 'converging_random_delay': {'required': False, 'type': 'boolean'}, + 'random': { + 'required': False, + 'type': 'boolean', + 'nullable': True, + }, + 'converging': { + 'required': False, + 'type': 'boolean', + 'nullable': True, + }, }, }, 'proxy': { 'type': 'dict', 'required': False, + 'nullable': True, 'schema' : { 'protocol': { 'required': False, 'allowed': ['http', 'https'], + 'nullable': True, }, 'ip': { 'required': False, 'type': 'ipv4address', + 'nullable': True, }, 'port': { 'required': False, 'type': 'integer', 'min': 0, + 'nullable': True, }, - 'random_delay': {'required': False, 'type': 'boolean'}, - 'converging_random_delay': {'required': False, 'type': 'boolean'}, }, }, } @@ -100,7 +122,6 @@ def _validate_type_ipv4address(self, field, value): try: # try to create an IPv4 address object using the python3 ipaddress module ipaddress.IPv4Address(value) - except: self._error(field, "Not a valid IPv4 address") diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py new file mode 100644 index 00000000..0aae9b25 --- /dev/null +++ b/jobfunnel/resources/defaults.py @@ -0,0 +1,102 @@ +"""Default settings YAML used for every search without cli args +""" +import os +import logging +from pathlib import Path +from jobfunnel.resources.enums import Locale, DelayAlgorithm, Provider + +# Below defs constructs: +# output_path: search +# log_level: INFO + +# locale: +# CANADA_ENGLISH + +# providers: +# - INDEED +# # - GLASSDOOR +# # - MONSTER + +# search: +# region: +# province_or_state: "ON" +# city: "Waterloo" +# radius: 25 +# keywords: +# - Python + +# delay: +# algorithm: LINEAR +# max_duration: 5.0 +# min_duration: 1.0 +USER_HOME_DIRECTORY = os.path.abspath(str(Path.home())) + +DEFAULT_LOG_LEVEL_NAME = 'INFO' +DEFAULT_LOCALE = Locale.CANADA_ENGLISH +DEFAULT_CITY = 'Waterloo' +DEFAULT_PROVINCE = 'ON' +DEFAULT_SEARCH_KEYWORDS = ['Python'] +DEFAULT_COMPANY_BLOCK_LIST = [] +DEFAULT_OUTPUT_DIRECTORY = os.path.join( + USER_HOME_DIRECTORY, 'job_search_results' +) +DEFAULT_CACHE_DIRECTORY = os.path.join(DEFAULT_OUTPUT_DIRECTORY, '.jobfcache') +DEFAULT_BLOCK_LIST_FILE = os.path.join(DEFAULT_CACHE_DIRECTORY, 'block.json') +DEFAULT_DUPLICATES_FILE = os.path.join( + DEFAULT_CACHE_DIRECTORY, 'duplicates.json' +) +DEFAULT_LOG_FILE = os.path.join(DEFAULT_OUTPUT_DIRECTORY, 'log.log') +DEFAULT_MASTER_CSV_FILE = os.path.join(DEFAULT_OUTPUT_DIRECTORY, 'master.csv') +DEFAULT_SEARCH_RADIUS_KM = 25 +DEFAULT_MAX_LISTING_DAYS = 60 +DEFAULT_DELAY_MAX_DURATION = 5.0 +DEFAULT_DELAY_MIN_DURATION = 1.0 +DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR +DEFAULT_PROVIDERS = [Provider.INDEED] # Provider.MONSTER, Provider.GLASSDOOR] FIXME +DEFAULT_NO_SCRAPE = False +DEFAULT_RECOVER = False +DEFAULT_RETURN_SIMILAR_RESULTS = False +DEFAULT_SAVE_DUPLICATES = False +DEFAULT_RANDOM_DELAY= False +DEFAULT_RANDOM_CONVERGING_DELAY = False +DEFAULT_PROTOCOL = None +DEFAULT_IP = None +DEFAULT_PORT = None + +DEFAULT_CONFIG = { + 'master_csv_file': DEFAULT_MASTER_CSV_FILE, + 'block_list_file': DEFAULT_BLOCK_LIST_FILE, + 'duplicates_list_file': DEFAULT_DUPLICATES_FILE, + 'cache_folder': DEFAULT_CACHE_DIRECTORY, + 'no_scrape': DEFAULT_NO_SCRAPE, + 'recover': DEFAULT_RECOVER, + 'save_duplicates': DEFAULT_SAVE_DUPLICATES, + 'log_level': DEFAULT_LOG_LEVEL_NAME, + 'log_file': DEFAULT_LOG_FILE, + 'search': { + 'locale' : DEFAULT_LOCALE.name, + 'providers': [p.name for p in DEFAULT_PROVIDERS], + 'region': { + 'province_or_state': DEFAULT_PROVINCE, + 'city': DEFAULT_CITY, + 'radius': DEFAULT_SEARCH_RADIUS_KM, + }, + 'keywords': DEFAULT_SEARCH_KEYWORDS, + 'similar_results': DEFAULT_RETURN_SIMILAR_RESULTS, + 'max_listing_days': DEFAULT_MAX_LISTING_DAYS, + 'company_block_list': DEFAULT_COMPANY_BLOCK_LIST, + }, + 'delay': { + 'algorithm': DEFAULT_DELAY_ALGORITHM.name, + 'max_duration': DEFAULT_DELAY_MAX_DURATION, + 'min_duration': DEFAULT_DELAY_MIN_DURATION, + 'random': DEFAULT_RANDOM_DELAY, + 'converging': DEFAULT_RANDOM_CONVERGING_DELAY, + }, + + 'proxy': { + 'protocol': DEFAULT_PROTOCOL, + 'ip': DEFAULT_IP, + 'port': DEFAULT_PORT, + }, + } diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 795e06e7..cea66a31 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -2,7 +2,6 @@ """ import os import string -from pathlib import Path # CSV header for output CSV. do not remove anything or you'll break usr's CSV's # TODO: need to add short and long descriptions (breaking change) @@ -18,17 +17,6 @@ # Maximum num threads we use when scraping MAX_CPU_WORKERS = 8 -# Default args -DEFAULT_SEARCH_RADIUS_KM = 25 -DEFAULT_MAX_LISTING_DAYS = 60 - -# Other definitions -USER_HOME_DIRECTORY = os.path.abspath(str(Path.home())) -DEFAULT_OUTPUT_DIRECTORY = os.path.join( - USER_HOME_DIRECTORY, 'job_search_results' -) -DEFAULT_CACHE_DIRECTORY = os.path.join(DEFAULT_OUTPUT_DIRECTORY, '.cache') - PRINTABLE_STRINGS = set(string.printable) # Load the user agent list once only. From 33526a02dc922ae52bc83cc3bd453e0d1eb08279 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 9 Aug 2020 15:25:14 -0400 Subject: [PATCH 11/66] Got indeed going once more, now with the abstract job creation --- jobfunnel/backend/jobfunnel.py | 50 +++-- jobfunnel/backend/scrapers/base.py | 123 ++++++------- jobfunnel/backend/scrapers/indeed.py | 261 ++++++++++++--------------- jobfunnel/backend/tools/delay.py | 49 ++--- jobfunnel/backend/tools/filters.py | 20 +- jobfunnel/config/cli.py | 2 +- jobfunnel/config/funnel.py | 7 +- jobfunnel/config/search.py | 23 ++- jobfunnel/resources/defaults.py | 10 +- jobfunnel/resources/resources.py | 3 +- 10 files changed, 273 insertions(+), 275 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 89ebe0ef..05925fcc 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -15,13 +15,11 @@ from jobfunnel.config import JobFunnelConfig from jobfunnel.backend import Job -from jobfunnel.resources import CSV_HEADER, JobStatus, Locale +from jobfunnel.resources import ( + CSV_HEADER, JobStatus, Locale, MAX_BLOCK_LIST_DESC_CHARS) from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter -MAX_BLOCK_LIST_DESC_CHARS = 150 # Maximum len of description in block_list JSON - - class JobFunnel(object): """Class that initializes a Scraper and scrapes a website to get jobs """ @@ -48,7 +46,9 @@ def __init__(self, config: JobFunnelConfig): @property def daily_cache_file(self) -> str: - """The name for for pickle file containing the scraped data ran today + """The name for for pickle file containing the scraped data ran today' + TODO: instead of using a 'daily' cache file, we should be tying this + into the search that was made to prevent cross-caching results. """ return os.path.join( self.config.cache_folder, f"jobs_{self.__date_string}.pkl", @@ -65,14 +65,19 @@ def run(self) -> None: self.update_block_list() # Get jobs keyed by their unique ID, use cache if we scraped today - if self.config.no_scrape: + jobs_dict = {} # type: Dict[str, Job] + if os.path.exists(self.daily_cache_file): jobs_dict = self.load_cache(self.daily_cache_file) + elif self.config.no_scrape: + self.logger.warning( + f"No jobs cached, missing: {self.daily_cache_file}" + ) + + if self.config.no_scrape: + self.logger.info("Skipping scraping, running with --no-scrape.") else: - if os.path.exists(self.daily_cache_file): - jobs_dict = self.load_cache(self.daily_cache_file) - else: - jobs_dict = self.scrape() # type: Dict[str, Job] - self.write_cache(jobs_dict) + jobs_dict = self.scrape() # type: Dict[str, Job] + self.write_cache(jobs_dict) # Filter out scraped jobs we have rejected, archived or block-listed # (before we add them to the CSV) @@ -84,7 +89,12 @@ def run(self) -> None: # Identify duplicate jobs using the existing masterlist masterlist = self.read_master_csv() # type: Dict[str, Job] self.filter(masterlist) # NOTE: this reduces size of masterlist - tfidf_filter(jobs_dict, masterlist) + try: + tfidf_filter(jobs_dict, masterlist) + except ValueError as err: + self.logger.error( + f"Skipping similarity filter due to: {str(err)}" + ) # Expand the masterlist with filteres, non-duplicated jobs & save masterlist.update(jobs_dict) @@ -122,9 +132,6 @@ def init_logging(self) -> None: def scrape(self) ->Dict[str, Job]: """Run each of the desired Scraper.scrape() with threading and delaying """ - if self.config.no_scrape: - self.logger.info("Bypassing scraping (--no-scrape).") - return self.logger.info(f"Starting scraping for: {self.config.scraper_names}") # Iterate thru scrapers and run their scrape. @@ -164,6 +171,8 @@ def recover(self): def load_cache(self, cache_file: str) -> Dict[str, Job]: """Load today's scrape data from pickle via date string + TODO: search the cache for pickles that match search config. + (we may need a registry for the pickles and seach terms used) """ try: jobs_dict = pickle.load(open(cache_file, 'rb')) @@ -173,13 +182,14 @@ def load_cache(self, cache_file: str) -> Dict[str, Job]: ) raise e self.logger.info( - f"Read {len(jobs_dict.keys())} jobs from {cache_file}" + f"Read {len(jobs_dict.keys())} cached jobs from: {cache_file}" ) return jobs_dict def write_cache(self, jobs_dict: Dict[str, Job], cache_file: str = None) -> None: """Dump a jobs_dict into a pickle + TODO: write search_config into the cache file and jobfunnel version """ cache_file = cache_file if cache_file else self.daily_cache_file pickle.dump(jobs_dict, open(cache_file, 'wb')) @@ -369,7 +379,7 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: # Read the user's duplicate jobs list (from TFIDF) duplicates_dict = {} # type: Dict[str, Job] - if os.path.isfile(self.config.duplicate_): + if os.path.isfile(self.config.duplicates_list_file): duplicates_dict = json.load( open(self.config.user_block_list_file, 'r') ) @@ -379,10 +389,10 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: filter_jobs_ids = [] for key_id, job in jobs_dict.items(): if (job.is_remove_status - or job.company in self.config.search_terms.blocked_company_names + or job.company in self.config.search_config.blocked_company_names or key_id in block_dict or key_id in duplicates_dict - or job_is_old(job, self.config.search_terms.max_listing_days)): + or job_is_old(job, self.config.search_config.max_listing_days)): filter_jobs_ids.append(key_id) for key_id in filter_jobs_ids: @@ -392,6 +402,6 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: if n_filtered > 0: self.logger.info(f'Filtered-out {n_filtered} jobs from results.') else: - self.logger.info(f'No jobs filtered.') + self.logger.info(f'No jobs were filtered from results.') return n_filtered diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 9ebc55cd..eff14233 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -17,19 +17,15 @@ #from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue -# Defaults we use from localization, the scraper can always override it. -DOMAIN_FROM_LOCALE = { - Locale.CANADA_ENGLISH: 'ca', - Locale.CANADA_FRENCH: 'ca', - Locale.USA_ENGLISH: 'com', -} - - class BaseScraper(ABC): """Base scraper object, for generating List[Job] from a specific job source TODO: accept filters: List[Filter] here if we have Filter(ABC) NOTE: we want to use filtering here because scraping blurbs can be slow. + NOTE: we don't have domain as an attrib because multiple domains can belong + to multiple locales. The Locale is intended to define the format of the + website, the scraping logic needed and the language used - as such, + SearchConfig is what defines the domain (it is being requested). """ def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: @@ -38,16 +34,6 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.logger = logger self.session.headers.update(self.headers) - @property - def domain(self) -> str: - """Get the domain string from the locale i.e. 'ca' - NOTE: if you have a special case for your locale (i.e. canadian .com) - inherit from BaseScraper and set this and locale in your Scraper class - """ - if not self.locale in DOMAIN_FROM_LOCALE: - raise ValueError(f"Unknown domain for locale: {self.locale}") - return DOMAIN_FROM_LOCALE[self.locale] - @property def bs4_parser(self) -> str: """Beautiful soup 4's parser setting @@ -66,6 +52,7 @@ def user_agent(self) -> str: def locale(self) -> Locale: """Get the localizations that this scraper was built for We will use this to put the right filters & scrapers together + NOTE: it is best to inherit this from BaseClass """ pass @@ -79,33 +66,54 @@ def headers(self) -> Dict[str, str]: def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID + + FIXME: this is hard-coded to delay scraping of descriptions only rn + maybe we can just use a queue and calc delays on-the-fly in scrape_job + for all session.get requests? """ - # Make a dict of job postings from the listing briefs - jobs_dict = {} # type: Dict[str, Job] - for job_soup in self.scrape_job_soups(): + # Get a list of job soups from the initial search results page + try: + job_soups = self.get_job_listings_from_search_results() + except Exception as err: + raise ValueError( + "Unable to extract jobs from initial search result page:\n" + f"{str(err)}" + ) + self.logger.info( + f"Scraped {len(job_soups)} job listings from search results pages" + ) - # Key by id to prevent duplicate key_ids FIXME: add a key-warning + # For each job-soup object, scrape the soup into a Job (w/o desc.) + jobs_dict = {} # type: Dict[str, Job] + for job_soup in job_soups: job = self.scrape_job(job_soup) + if job.key_id in jobs_dict: + self.logger.error( + f"Job {job.title} and {jobs_dict[job.key_id].title} share " + f"duplicate key_id: {job.key_id}" + ) jobs_dict[job.key_id] = job - def _get_with_delay(self, job: Job, delay: float) -> Tuple[Job, str]: + # FIXME: get rid of these two _methods and replace with more flexible + # delaying implementation. + def _get_with_delay(job: Job, delay: float) -> Tuple[Job, str]: """Get a job's page by the job url with a delay beforehand """ sleep(delay) self.logger.info( - f'Delay of {delay:.2f}s, getting search results for: {job.url}' + f'Delay of {delay:.1f}s, getting search results for: {job.url}' ) job_page_soup = BeautifulSoup( self.session.get(job.url).text, self.bs4_parser ) return job, job_page_soup - def _parse(self, job: Job, job_page_soup: BeautifulSoup) -> None: - """Set job.description - TODO: roll into our delay callback + def _parse(job: Job, job_page_soup: BeautifulSoup) -> None: + """Set job.description with the job's own page. + TODO: move this into our delay callback """ try: - self.get_short_job_description(job_page_soup) + job.description = self.get_job_description(job, job_page_soup) except AttributeError: self.logger.warning( f"Unable to scrape short description for job {job.key_id}." @@ -113,24 +121,12 @@ def _parse(self, job: Job, job_page_soup: BeautifulSoup) -> None: job.clean_strings() # Scrape stuff that we are delaying for - # FIXME: this is hard-coded to delay scraping of descriptions only rn - # maybe we can just use a queue and calc delays on-the-fly in scrape_job threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) jobs_list = list(jobs_dict.values()) delays = calculate_delays(len(jobs_list), self.config.delay_config) delay_threader( jobs_list, _get_with_delay, _parse, threads, self.logger, delays ) - - # FIXME: impl. once CSV supports it, indeed supports it and we make - # delaying more flexible (i.e. queue) - # try: - # self.get_short_job_description(job_soup) - # except AttributeError: - # self.logger.warning( - # f"Unable to scrape short description for job {key_id}." - # ) - return jobs_dict def scrape_job(self, job_soup: BeautifulSoup) -> Job: @@ -158,12 +154,14 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: # Scrape the optional stuff try: tags = self.get_job_tags(job_soup) - except AttributeError: + except AttributeError as err: tags = [] # type: List[str] - self.logger.warning(f"Unable to scrape tags for job {key_id}") + self.logger.warning( + f"Unable to scrape tags for job {key_id}:\n{str(err)}" + ) try: - post_date = self.get_job_date(job_soup) + post_date = self.get_job_post_date(job_soup) except (AttributeError, ValueError): post_date = datetime.datetime.now() self.logger.warning( @@ -182,42 +180,31 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: query='', #self.query_string, FIXME status=JobStatus.NEW, provider='', #self.__class___.__name__, FIXME - short_description='', # We will populate this later per-job-page + short_description='', # TODO: impl. post_date=post_date, raw='', # FIXME: we cannot pickle the soup object (job_soup) tags=tags, ) - # TODO: make these calls work here, maybe use a queue with delaying? - # These calls require additional get using job.url - # try: - # self.get_job_description(job, job_soup) - # except AttributeError: - # self.logger.warning( - # f"Unable to scrape description for job {key_id}." - # ) - - # try: - # self.get_short_job_description(job_soup) - # except AttributeError: - # self.logger.warning( - # f"Unable to scrape short description for job {key_id}." - # ) - return job # FIXME: review below types and complete docstrings @abstractmethod - def scrape_job_soups(self) -> List[BeautifulSoup]: - """Generate a list of soups for each job object. - i.e. the job listing on a search results page. - NOTE: you can use job soups to get more detailed listings later - i.e self.get('details_from_job_page') -> make get request to load desc. + def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: + """Generate a list of soups for each job object from the response to our + job search query. + + NOTE: This should be in a format where there are many jobs shown to the + user to click-into in a single view. + + Returns a list of soup objects which correspond to each job shown on the + results page. """ pass - # TODO: this might be more elegant: + # TODO: implement getters like this so we can make the entire thing more + # flexible. # @abstractmethod # def get(self, parameter: str, # soup: BeautifulSoup) -> Union[str, List[str], date]: @@ -295,7 +282,7 @@ def get_job_key_id(self, job_soup: BeautifulSoup) -> str: # ... the first in the chain would have job = None for its call though... @abstractmethod def get_job_description(self, job: Job, - job_soup: BeautifulSoup = None) -> None: + job_soup: BeautifulSoup = None) -> str: """Parses and stores job description html and sets Job.description NOTE: this accepts Job because it allows using other job attributes to make new session.get() for job-specific information. @@ -304,7 +291,7 @@ def get_job_description(self, job: Job, @abstractmethod def get_short_job_description(self, job: Job, - job_soup: BeautifulSoup = None) -> None: + job_soup: BeautifulSoup = None) -> str: """Parses and stores job description from a job's page HTML NOTE: this accepts Hob because it allows using other job attributes to make new session.get() for job-specific information. diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index abb962b9..4a64afe4 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -13,14 +13,14 @@ from bs4 import BeautifulSoup -from jobfunnel.resources import Locale +from jobfunnel.resources import Locale, MAX_CPU_WORKERS from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.scrapers import BaseScraper, BaseCANEngScraper, BaseUSAEngScraper -from jobfunnel.backend.tools.delay import calculate_delays, delay_threader +from jobfunnel.backend.scrapers import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper) #from jobfunnel.config import JobFunnelConfig # causes a circular import -# Initialize list and store regex objects of date quantifiers TODO: refactor +# Initialize list and store regex objects of date quantifiers HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') DAY_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)') MONTH_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?month') @@ -41,7 +41,7 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', """ super().__init__(session, config, logger) self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE - self.query = '+'.join(self.config.search_terms.keywords) + self.query = '+'.join(self.config.search_config.keywords) @property def headers(self) -> Dict[str, str]: @@ -52,109 +52,76 @@ def headers(self) -> Dict[str, str]: 'q=0.9,image/webp,*/*;q=0.8', 'accept-encoding': 'gzip, deflate, sdch, br', 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': f'https://www.indeed.{self.domain}/', + 'referer': + f'https://www.indeed.{self.config.search_config.domain}/', 'upgrade-insecure-requests': '1', 'user-agent': self.user_agent, 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' } - def search_page_for_job_soups(self, search: str, page: str, - job_soup_list: List[BeautifulSoup]) -> None: - """Scrapes the indeed page for a list of job soups - NOTE: modifies the job_soup_list in-place - """ - url = f'{search}&start={int(page * self.max_results_per_page)}' - self.logger.info(f'getting indeed page {page} : {url}') - job_soup_list.extend( - BeautifulSoup( - self.session.get(url).text, self.bs4_parser - ).find_all('div', attrs={'data-tn-component': 'organicJob'}) - ) - - def scrape_job_soups(self) -> List[BeautifulSoup]: + def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: """Scrapes raw data from a job source into a list of job-soups Returns: List[BeautifulSoup]: list of jobs soups we can use to make Job """ # Get the search url - search = self.get_search_url() - - # Get the html data, initialize bs4 with lxml - request_html = self.session.get(search) - - # Create the soup base - soup_base = BeautifulSoup(request_html.text, self.bs4_parser) + search_url = self._get_search_url() # Parse total results, and calculate the # of pages needed - pages = self.get_num_pages_to_scrape(soup_base) + pages = self._get_num_search_result_pages(search_url) self.logger.info(f"Found {pages} indeed results for query={self.query}") # Init list of job soups job_soup_list = [] # type: List[Any] # Init threads & futures list - threads = ThreadPoolExecutor(max_workers=8) - fts = [] # FIXME: type? + threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + futures_list = [] # FIXME: type? - # Scrape soups for all the pages containing jobs it found + # Scrape soups for all the result pages containing lists of jobs found for page in range(0, pages): - # Append thread job future to futures list - fts.append( + futures_list.append( threads.submit( - self.search_page_for_job_soups, search, page, job_soup_list + self._get_job_soups_from_search_page, search_url, page, + job_soup_list ) ) # Wait for all scrape jobs to finish - wait(fts) + wait(futures_list) return job_soup_list - def calc_post_date_from_relative_str(self, date_str: str) -> date: - """Identifies a job's post date via post age, updates in-place + def _get_search_url(self, method: Optional[str] = 'get') -> str: + """Get the indeed search url from SearchTerms """ - post_date = datetime.now() # type: date - # Supports almost all formats like 7 hours|days and 7 hr|d|+d - try: - # hours old - hours_ago = HOUR_REGEX.findall(date_str)[0] - post_date -= timedelta(hours=int(hours_ago)) - except IndexError: - # days old - try: - days_ago = DAY_REGEX.findall(date_str)[0] - post_date -= timedelta(days=int(days_ago)) - except IndexError: - # months old - try: - months_ago = MONTH_REGEX.findall(date_str)[0] - post_date -= relativedelta( - months=int(months_ago)) - except IndexError: - # years old - try: - years_ago = YEAR_REGEX.findall(date_str)[0] - post_date -= relativedelta( - years=int(years_ago)) - except IndexError: - # try phrases like today, just posted, or yesterday - if (RECENT_REGEX_A.findall(date_str) and - not post_date): - # today - post_date = datetime.now() - elif RECENT_REGEX_B.findall(date_str): - # yesterday - post_date -= timedelta(days=int(1)) - elif not post_date: - # we have failed. - raise ValueError("Unable to calculate date") - return post_date + if method == 'get': + # form job search url + search = ( + "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" + "limit={5}&filter={6}".format( + self.config.search_config.domain, + self.query, + self.config.search_config.city.replace(' ', '+'), + self.config.search_config.state, + self._convert_radius(self.config.search_config.radius), + self.max_results_per_page, + int(self.config.search_config.return_similar_results) + ) + ) + return search + elif method == 'post': + # TODO: implement post style for indeed.X + raise NotImplementedError() + else: + raise ValueError(f'No html method {method} exists') - def convert_radius(self, radius: int) -> int: - """function that quantizes the user input radius to a valid radius - value: 5, 10, 15, 25, 50, 100, and 200 kilometers or miles + def _convert_radius(self, radius: int) -> int: + """Quantizes the user input radius to a valid radius value into: + 5, 10, 15, 25, 50, 100, and 200 kilometers or miles. + TODO: implement with numpy instead of if/else cases. """ if radius < 5: radius = 0 @@ -172,53 +139,34 @@ def convert_radius(self, radius: int) -> int: radius = 100 return radius - def get_search_url(self, method: Optional[str] = 'get') -> str: - """Get the indeed search url from SearchTerms - """ - if method == 'get': - # form job search url - search = ( - "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" - "limit={5}&filter={6}".format( - self.domain, - self.query, - self.config.search_terms.city.replace(' ', '+'), - self.config.search_terms.state, - self.convert_radius(self.config.search_terms.region.radius), - self.max_results_per_page, - int(self.config.search_terms.return_similar_results) - ) - ) - return search - elif method == 'post': - # TODO: implement post style for indeed.X - raise NotImplementedError() - else: - raise ValueError(f'No html method {method} exists') - - def get_job_url(self, job_id: str) -> str: - """Constructs the link with the given job_id. - Args: - job_id: The id to be used to construct the link for this job. - Returns: - The constructed job link. + def _get_job_soups_from_search_page(self, search: str, page: str, + job_soup_list: List[BeautifulSoup] + ) -> None: + """Scrapes the indeed page for a list of job soups + NOTE: modifies the job_soup_list in-place """ - return f"http://www.indeed.{self.domain}/viewjob?jk={job_id}" + url = f'{search}&start={int(page * self.max_results_per_page)}' + self.logger.info(f'Getting indeed page {page} : {url}') + job_soup_list.extend( + BeautifulSoup( + self.session.get(url).text, self.bs4_parser + ).find_all('div', attrs={'data-tn-component': 'organicJob'}) + ) - def get_num_pages_to_scrape(self, soup_base: BeautifulSoup, - max_pages=0) -> int: + def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: """Calculates the number of pages to be scraped. Args: - soup_base: a BeautifulSoup object with the html data. - At the moment this method assumes that the soup_base was - prepared statically. + soup_base: search URL for the job search we are making max_pages: the maximum number of pages to be scraped. Returns: The number of pages to be scraped. If the number of pages that soup_base yields is higher than max, then max is returned. """ - num_res = soup_base.find(id='searchCountPages').contents[0].strip() + # Get the html data, initialize bs4 with lxml + request_html = self.session.get(search_url) + query_resp = BeautifulSoup(request_html.text, self.bs4_parser) + num_res = query_resp.find(id='searchCountPages').contents[0].strip() num_res = int(re.findall(r'f (\d+) ', num_res.replace(',', ''))[0]) number_of_pages = int(ceil(num_res / self.max_results_per_page)) if max_pages == 0: @@ -228,15 +176,17 @@ def get_num_pages_to_scrape(self, soup_base: BeautifulSoup, else: return max_pages - def get_job_page_with_delay(self, job: Job, - delay: float) -> Tuple[Job, str]: - """Gets data from the indeed job link and sets delays for requests + def get_job_url(self, job_id: str) -> str: + """Constructs the link with the given job_id. + Args: + job_id: The id to be used to construct the link for this job. + Returns: + The constructed job link. """ - sleep(delay) - self.logger.info( - f'delay of {delay:.2f}s, getting indeed search: {job.url}' + return ( + f"http://www.indeed.{self.config.search_config.domain}/" + f"viewjob?jk={job_id}" ) - return job, self.session.get(job.url).text def get_job_title(self, soup: BeautifulSoup) -> str: """Fetches the title from a BeautifulSoup base. @@ -286,7 +236,7 @@ def get_job_tags(self, soup: BeautifulSoup) -> List[str]: 'table', attrs={'class': 'jobCardShelfContainer'} ).find_all('td', attrs={'class': 'jobCardShelfItem'})] - def get_job_date(self, soup: BeautifulSoup) -> date: + def get_job_post_date(self, soup: BeautifulSoup) -> date: """Fetches the job date from a BeautifulSoup base. Args: soup: BeautifulSoup base to scrape the date from. @@ -296,7 +246,49 @@ def get_job_date(self, soup: BeautifulSoup) -> date: find the date. The caller is expected to handle this exception. """ date_string = soup.find('span', attrs={'class': 'date'}).text.strip() - return self.calc_post_date_from_relative_str(date_string) + return self._calc_post_date_from_relative_str(date_string) + + def _calc_post_date_from_relative_str(self, date_str: str) -> date: + """Identifies a job's post date via post age, updates in-place + """ + post_date = datetime.now() # type: date + # Supports almost all formats like 7 hours|days and 7 hr|d|+d + try: + # Hours old + hours_ago = HOUR_REGEX.findall(date_str)[0] + post_date -= timedelta(hours=int(hours_ago)) + except IndexError: + # Days old + try: + days_ago = DAY_REGEX.findall(date_str)[0] + post_date -= timedelta(days=int(days_ago)) + except IndexError: + # Months old + try: + months_ago = MONTH_REGEX.findall(date_str)[0] + post_date -= relativedelta( + months=int(months_ago)) + except IndexError: + # Years old + try: + years_ago = YEAR_REGEX.findall(date_str)[0] + post_date -= relativedelta( + years=int(years_ago)) + except IndexError: + # Try phrases like 'today'/'just posted'/'yesterday' + if (RECENT_REGEX_A.findall(date_str) and + not post_date): + # Today + post_date = datetime.now() + elif RECENT_REGEX_B.findall(date_str): + # Yesterday + post_date -= timedelta(days=int(1)) + elif not post_date: + # We have failed to correctly evaluate date. + raise ValueError( + f"Unable to calculate date from:\n{date_str}" + ) + return post_date def get_job_key_id(self, soup: BeautifulSoup) -> str: """Fetches the job id from a BeautifulSoup base. @@ -312,34 +304,13 @@ def get_job_key_id(self, soup: BeautifulSoup) -> str: str(soup.find('a', attrs={'class': 'sl resultLink save-job-link'})) )[0] - # def get_descriptions(self, soup: BeautifulSoup) - # # Get the detailed description with delayed scraping - # # FIXME: how to use delay threader? - # delay_threader( - # jobs_list, self.get_job_description_with_delay, - # self.get_job_description, threads, self.logger, delays, - # ) - - # def get_job_description_with_delay(self, job: Job, - # delay: float) -> Tuple[Job, str]: - # """Gets blurb from indeed job link and sets delays for requests - # """ - # sleep(delay) - # self.logger.info( - # f'Delay of {delay:.2f}s, getting indeed search: {job.url}' - # ) - # return job, self.session.get(job.url).text - - def get_job_description(self, job: Job, soup: BeautifulSoup) -> None: + def get_job_description(self, job: Job, soup: BeautifulSoup) -> str: """Parses and stores job description html and sets Job.description """ job_link_soup = BeautifulSoup( self.session.get(job.url).text, self.bs4_parser ) - return job_link_soup.find( - id='jobDescriptionText' - ).text.strip() - + return job_link_soup.find(id='jobDescriptionText').text.strip() def get_short_job_description(self, job: Job, soup: str) -> None: """Parses and stores job description from a job's page HTML diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 28b97623..2556a4e2 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -11,13 +11,13 @@ from scipy.special import expit +from jobfunnel.resources import DelayAlgorithm from jobfunnel.config import DelayConfig from jobfunnel.backend import Job def _c_delay(list_len: int, delay: Union[int, float]): - """ Sets single delay value to whole list. - + """Sets single delay value to whole list. """ delays = [delay] * list_len # sets incrementing offsets to the first 8 elements @@ -34,8 +34,7 @@ def _c_delay(list_len: int, delay: Union[int, float]): def _lin_delay(list_len: int, delay: Union[int, float]): - """ Calculates y=.2*x and sets y=delay at intersection of x between lines. - + """Calculates y=.2*x and sets y=delay at intersection of x between lines. """ # calculates x value where lines intersect its = 5 * delay # its = intersection @@ -55,8 +54,7 @@ def _lin_delay(list_len: int, delay: Union[int, float]): # https://en.wikipedia.org/wiki/Generalised_logistic_function def _sig_delay(list_len: int, delay: Union[int, float]): - """ Calculates Richards/Sigmoid curve for delay. - + """Calculates Richards/Sigmoid curve for delay. """ gr = sqrt(delay) * 4 # growth rate y_0 = log(4 * delay) # Y(0) @@ -66,9 +64,11 @@ def _sig_delay(list_len: int, delay: Union[int, float]): def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: - """ Checks delay config and returns calculated delay list. + """Checks delay config and returns calculated delay list. NOTE: we do this to be respectful to online job sources + TODO: make this return a delay value based on list_len so we can use this + on-demand with the thread executor's GET queue. Args: list_len: length of scrape job list @@ -80,20 +80,22 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: delay_config.validate() # Delay calculations using specified equations - if delay_config.function_name == 'constant': - delay_vals = _c_delay(list_len, delay_config.duration) - elif delay_config.function_name == 'linear': - delay_vals = _lin_delay(list_len, delay_config.duration) - elif delay_config.function_name == 'sigmoid': - delay_vals = _sig_delay(list_len, delay_config.duration) + if delay_config.algorithm == DelayAlgorithm.CONSTANT: + delay_vals = _c_delay(list_len, delay_config.max_duration) + elif delay_config.algorithm == DelayAlgorithm.LINEAR: + delay_vals = _lin_delay(list_len, delay_config.max_duration) + elif delay_config.algorithm == DelayAlgorithm.SIGMOID: + delay_vals = _sig_delay(list_len, delay_config.max_duration) + else: + raise ValueError(f"Cannot calculate delay for {delay_config.algorithm}") # Check if minimum delay is above 0 and less than last element - if 0 < delay_config.min_delay: - # sets min_delay to values greater than itself in delay_vals + if 0 < delay_config.min_duration: + # sets min_duration to values greater than itself in delay_vals for i, n in enumerate(delay_vals): - if n > delay_config.min_delay: + if n > delay_config.min_duration: break - delay_vals[i] = delay_config.min_delay + delay_vals[i] = delay_config.min_duration # Outputs final list of delays rounded up to 3 decimal places if delay_config.random: # check if random delay was specified @@ -101,7 +103,8 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: if delay_config.converge: # checks if converging delay is True # delay_vals = lower bound, delay = upper bound durations = [ - round(uniform(x, delay_config.duration), 3) for x in delay_vals + round(uniform(x, delay_config.max_duration), 3) + for x in delay_vals ] else: # lb = lower bounds, delay_vals = upper bound @@ -120,18 +123,16 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: def delay_threader(jobs_list: List[Job], scrape_fn: object, parse_fn: object, threads: ThreadPoolExecutor, - logger: Logger, delays: List[float]) -> None: + logger: Logger, delays: List[float] = None) -> None: """Method to scrape descriptions from individual indeed postings. with respectful-delaying """ - scrape_jobs = zip(jobs_list, delays) # Submits jobs and stores futures in dict start = time() - results = { - threads.submit(scrape_fn, job, delays): job.key_id - for job, delays in scrape_jobs - } + results = {} + for job, delay in zip(jobs_list, delays): + results[threads.submit(scrape_fn, job=job, delay=delay)] = job.key_id # Loops through futures as completed and removes each if successfully parsed while results: diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 03f68284..0f9f7682 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -73,7 +73,10 @@ def tfidf_filter(cur_dict: Dict[str, dict], query_ids.append(job.key_id) if len(job.description) > 0: query_words.append(job.description) - assert query_words, "No query strings to fit, are your descriptions empty?" + if not query_words: + raise ValueError( + "No query strings to fit, are all of your job descriptions empty?" + ) if prev_dict is None: # returns cosine similarity between jobs as square matrix (n,n) @@ -101,7 +104,7 @@ def tfidf_filter(cur_dict: Dict[str, dict], index += 1 # log something logging.info(f'Found and removed {len(duplicate_ids.keys())} ' - f're-posts/duplicates via TFIDF cosine similarity!') + f're-posts/duplicates via TFIDF cosine similarity.') else: # get reference words as list reference_words = [job.description for job in prev_dict.values()] @@ -121,12 +124,13 @@ def tfidf_filter(cur_dict: Dict[str, dict], if np_max(sim) >= max_similarity: duplicate_ids.update({query_id: cur_dict.pop(query_id)}) - # log something - logging.info( - f'Found {len(cur_dict.keys())} unique listings and ' - f'{len(duplicate_ids.keys())} duplicates ' - 'via TFIDF cosine similarity' - ) + # FIXME: this message is wrong and we see it after above message. + # # log something + # logging.info( + # f'Found {len(cur_dict.keys())} unique listings and ' + # f'{len(duplicate_ids.keys())} duplicates ' + # 'via TFIDF cosine similarity' + # ) # returns a dictionary of duplicate key_ids return duplicate_ids diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 0be6df61..e371abef 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -324,7 +324,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: keywords=config['search']['keywords'], province_or_state=config['search']['region']['province_or_state'], city=config['search']['region']['city'], - distance_radius_km=config['search']['region']['radius'], + distance_radius=config['search']['region']['radius'], return_similar_results=config['search']['similar_results'], max_listing_days=config['search']['max_listing_days'], blocked_company_names=config['search']['company_block_list'], diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 7dc15d59..48a7c11c 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -22,6 +22,7 @@ def __init__(self, log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, + return_similar_results: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, proxy_config: Optional[ProxyConfig] = None) -> None: """Init a config that determines how we will scrape jobs from Scrapers @@ -41,6 +42,9 @@ def __init__(self, no_scrape (Optional[bool], optional): If True, will not scrape data at all, instead will only update filters and CSV. Defaults to False. + return_similar_resuts (Optional[bool], optional): If True, we will + ask the job provider to provide more loosely-similar results for + our search queries. NOTE: only a thing for indeed rn. delay_config (Optional[DelayConfig], optional): delay config object. Defaults to a default delay config object. proxy_config (Optional[ProxyConfig], optional): proxy config object. @@ -54,6 +58,7 @@ def __init__(self, self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape + self.return_similar_results = return_similar_results if not delay_config: # We will always use a delay config to be respectful self.delay_config = DelayConfig() @@ -89,7 +94,7 @@ def scraper_names(self) -> str: def create_dirs(self) -> None: """Create any missing dirs """ - if not os.path.exists(self.cache_folder): # TODO: put this in tmpdir? + if not os.path.exists(self.cache_folder): os.makedirs(self.cache_folder) def validate(self) -> None: diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index a20e2d04..a0012ce4 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -4,10 +4,10 @@ from jobfunnel.config import BaseConfig from jobfunnel.resources import Locale, Provider from jobfunnel.resources.defaults import ( - DEFAULT_SEARCH_RADIUS_KM, DEFAULT_MAX_LISTING_DAYS + DEFAULT_SEARCH_RADIUS_KM, DEFAULT_MAX_LISTING_DAYS, + DEFAULT_DOMAIN_FROM_LOCALE, ) - class SearchConfig(BaseConfig): """Config object to contain region of interest for a Locale @@ -22,17 +22,18 @@ def __init__(self, locale: Locale, providers: List[Provider], city: Optional[str] = None, - distance_radius_km: Optional[int] = None, + distance_radius: Optional[int] = None, return_similar_results: Optional[bool] = False, max_listing_days: Optional[int] = None, - blocked_company_names: Optional[List[str]] = None): + blocked_company_names: Optional[List[str]] = None, + domain: Optional[str] = None): """Search config for all job sources Args: keywords (List[str]): list of search keywords province_or_state (str): province or state. city (Optional[str], optional): city. Defaults to None. - distance_radius_km (Optional[int], optional): km radius. Defaults to + distance_radius (Optional[int], optional): km/m radius. Defaults to DEFAULT_SEARCH_RADIUS_KM. return_similar_results (Optional[bool], optional): return similar. results (indeed), Defaults to False. @@ -40,11 +41,13 @@ def __init__(self, Defaults to DEFAULT_MAX_LISTING_DAYS. blocked_company_names (Optional[List[str]]): list of names of companies that we never want to see in our results. + domain (Optional[str], optional): domain string to use for search + querying. If not passed, will set based on locale. (i.e. 'ca') """ self.province = province_or_state self.state = province_or_state self.city = city.lower() - self.radius = distance_radius_km or DEFAULT_SEARCH_RADIUS_KM + self.radius = distance_radius or DEFAULT_SEARCH_RADIUS_KM self.locale = locale self.providers = providers self.keywords = keywords @@ -52,6 +55,14 @@ def __init__(self, self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS self.blocked_company_names = blocked_company_names + # Try to infer the domain string based on the locale. + if not domain: + if not self.locale in DEFAULT_DOMAIN_FROM_LOCALE: + raise ValueError(f"Unknown domain for locale: {self.locale}") + self.domain = DEFAULT_DOMAIN_FROM_LOCALE[self.locale] + else: + self.domain = domain + def validate(self): """We need to have the right information set, not mixing stuff FIXME: impl. diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 0aae9b25..8fd19d2d 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -40,7 +40,8 @@ DEFAULT_OUTPUT_DIRECTORY = os.path.join( USER_HOME_DIRECTORY, 'job_search_results' ) -DEFAULT_CACHE_DIRECTORY = os.path.join(DEFAULT_OUTPUT_DIRECTORY, '.jobfcache') +# FIXME: move to home when we have per-search caching +DEFAULT_CACHE_DIRECTORY = os.path.join(DEFAULT_OUTPUT_DIRECTORY, '.cache') DEFAULT_BLOCK_LIST_FILE = os.path.join(DEFAULT_CACHE_DIRECTORY, 'block.json') DEFAULT_DUPLICATES_FILE = os.path.join( DEFAULT_CACHE_DIRECTORY, 'duplicates.json' @@ -63,6 +64,13 @@ DEFAULT_IP = None DEFAULT_PORT = None +# Defaults we use from localization, the scraper can always override it. +DEFAULT_DOMAIN_FROM_LOCALE = { + Locale.CANADA_ENGLISH: 'ca', + Locale.CANADA_FRENCH: 'ca', + Locale.USA_ENGLISH: 'com', +} + DEFAULT_CONFIG = { 'master_csv_file': DEFAULT_MASTER_CSV_FILE, 'block_list_file': DEFAULT_BLOCK_LIST_FILE, diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index cea66a31..f8248fd5 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -1,4 +1,4 @@ -"""Constant definitions or files we need to load once can go here +"""String-like resouces and other constants are initialized here. """ import os import string @@ -16,6 +16,7 @@ # Maximum num threads we use when scraping MAX_CPU_WORKERS = 8 +MAX_BLOCK_LIST_DESC_CHARS = 150 # Maximum len of description in block_list JSON PRINTABLE_STRINGS = set(string.printable) From 703524272c9aa50e1134a5fff5f08b2dc6b4da0a Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 9 Aug 2020 15:42:57 -0400 Subject: [PATCH 12/66] moved bs4 parser into JobFunnelConfig for future CLI/cfg impl --- jobfunnel/backend/scrapers/base.py | 33 ++++++++++++---------------- jobfunnel/backend/scrapers/indeed.py | 6 ++--- jobfunnel/config/cli.py | 1 + jobfunnel/config/funnel.py | 5 ++++- jobfunnel/resources/resources.py | 2 ++ 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index eff14233..7cad08ae 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -14,7 +14,7 @@ from jobfunnel.resources import USER_AGENT_LIST, Locale, MAX_CPU_WORKERS from jobfunnel.backend.tools.delay import calculate_delays, delay_threader from jobfunnel.backend import Job, JobStatus -#from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue +# from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue class BaseScraper(ABC): @@ -32,14 +32,8 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.session = session self.config = config self.logger = logger - self.session.headers.update(self.headers) - - @property - def bs4_parser(self) -> str: - """Beautiful soup 4's parser setting - NOTE: it's the same for all scrapers rn so it's not abstract - """ - return 'lxml' + if self.headers: + self.session.headers.update(self.headers) @property def user_agent(self) -> str: @@ -53,6 +47,7 @@ def locale(self) -> Locale: """Get the localizations that this scraper was built for We will use this to put the right filters & scrapers together NOTE: it is best to inherit this from BaseClass + NOTE: self.config.search.locale == self.locale should be true """ pass @@ -104,7 +99,7 @@ def _get_with_delay(job: Job, delay: float) -> Tuple[Job, str]: f'Delay of {delay:.1f}s, getting search results for: {job.url}' ) job_page_soup = BeautifulSoup( - self.session.get(job.url).text, self.bs4_parser + self.session.get(job.url).text, self.config.bs4_parser ) return job, job_page_soup @@ -190,6 +185,15 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: # FIXME: review below types and complete docstrings + # TODO: implement getters like this so we can make the entire thing more + # flexible. + # @abstractmethod + # def get(self, parameter: str, + # soup: BeautifulSoup) -> Union[str, List[str], date]: + # """Get a single job attribute from a soup object + # i.e. get 'description' --> str + # """ + @abstractmethod def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: """Generate a list of soups for each job object from the response to our @@ -203,15 +207,6 @@ def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: """ pass - # TODO: implement getters like this so we can make the entire thing more - # flexible. - # @abstractmethod - # def get(self, parameter: str, - # soup: BeautifulSoup) -> Union[str, List[str], date]: - # """Get a single job attribute from a soup object - # i.e. get 'description' --> str - # """ - @abstractmethod def get_job_url(self, job_soup: BeautifulSoup) -> str: """Get job url from a job soup diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 4a64afe4..60344282 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -149,7 +149,7 @@ def _get_job_soups_from_search_page(self, search: str, page: str, self.logger.info(f'Getting indeed page {page} : {url}') job_soup_list.extend( BeautifulSoup( - self.session.get(url).text, self.bs4_parser + self.session.get(url).text, self.config.bs4_parser ).find_all('div', attrs={'data-tn-component': 'organicJob'}) ) @@ -165,7 +165,7 @@ def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: """ # Get the html data, initialize bs4 with lxml request_html = self.session.get(search_url) - query_resp = BeautifulSoup(request_html.text, self.bs4_parser) + query_resp = BeautifulSoup(request_html.text, self.config.bs4_parser) num_res = query_resp.find(id='searchCountPages').contents[0].strip() num_res = int(re.findall(r'f (\d+) ', num_res.replace(',', ''))[0]) number_of_pages = int(ceil(num_res / self.max_results_per_page)) @@ -308,7 +308,7 @@ def get_job_description(self, job: Job, soup: BeautifulSoup) -> str: """Parses and stores job description html and sets Job.description """ job_link_soup = BeautifulSoup( - self.session.get(job.url).text, self.bs4_parser + self.session.get(job.url).text, self.config.bs4_parser ) return job_link_soup.find(id='jobDescriptionText').text.strip() diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index e371abef..0e3fac98 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -357,6 +357,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: log_file=config['log_file'], log_level=config['log_level'], no_scrape=config['no_scrape'], + # bs4_parser=config['bs4_parser'], # TODO: impl. cli/cfg when needed. search_config=search_cfg, delay_config=delay_cfg, proxy_config=proxy_cfg, diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 48a7c11c..0563f56d 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -6,7 +6,7 @@ from jobfunnel.backend.scrapers import BaseScraper, SCRAPER_FROM_LOCALE from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig -from jobfunnel.resources import Locale, Provider +from jobfunnel.resources import Locale, Provider, BS4_PARSER class JobFunnelConfig(BaseConfig): @@ -22,6 +22,7 @@ def __init__(self, log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, + bs4_parser: Optional[str] = BS4_PARSER, return_similar_results: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, proxy_config: Optional[ProxyConfig] = None) -> None: @@ -42,6 +43,7 @@ def __init__(self, no_scrape (Optional[bool], optional): If True, will not scrape data at all, instead will only update filters and CSV. Defaults to False. + bs4_parser (Optional[str], optional): the parser to use for BS4. return_similar_resuts (Optional[bool], optional): If True, we will ask the job provider to provide more loosely-similar results for our search queries. NOTE: only a thing for indeed rn. @@ -58,6 +60,7 @@ def __init__(self, self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape + self.bs4_parser = bs4_parser # TODO: add to config self.return_similar_results = return_similar_results if not delay_config: # We will always use a delay config to be respectful diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index f8248fd5..d0462d11 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -18,6 +18,8 @@ MAX_CPU_WORKERS = 8 MAX_BLOCK_LIST_DESC_CHARS = 150 # Maximum len of description in block_list JSON +BS4_PARSER = 'lxml' + PRINTABLE_STRINGS = set(string.printable) # Load the user agent list once only. From 04fec2d73cb2518bcea28011fc2db5ffb90f8f00 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 9 Aug 2020 18:17:06 -0400 Subject: [PATCH 13/66] Got set() and get() working with lots of assertions to keep it safe. Also added a progress bar and cleaned up the logs a bit --- jobfunnel/backend/job.py | 6 +- jobfunnel/backend/jobfunnel.py | 11 +- jobfunnel/backend/scrapers/base.py | 315 ++++++++++++--------------- jobfunnel/backend/scrapers/indeed.py | 216 ++++++------------ jobfunnel/backend/tools/tools.py | 50 +++++ jobfunnel/config/search.py | 10 + jobfunnel/resources/enums.py | 22 +- setup.py | 1 + 8 files changed, 290 insertions(+), 341 deletions(-) diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index c0854a8d..fcace833 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -36,8 +36,9 @@ def __init__(self, post_date: Optional[date] = None, raw: Optional[Any] = None, tags: Optional[List[str]] = None) -> None: - """[summary] + """Object to represent a single job that we have scraped + NOTE: self.attrs must be reflected in JobField so we can auto-get/set TODO: would be nice to use something standardized for location TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? @@ -50,7 +51,8 @@ def __init__(self, key_id (str): unique identifier for the job TODO: make more robust? url (str): link to the page where the job exists locale (Locale): identifier to help us with internationalization, - tells us what language and host-locale/domain a source is in. + tells us what the locale of the scraper was that scraped this + job. query (str): the search string that this job was found with provider (str): name of the job source status (JobStatus): the status of the job (i.e. new) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 05925fcc..7d18b411 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -104,10 +104,6 @@ def run(self) -> None: # FIXME: we should still remove duplicates (TFIDF) within jobs_dict # Dump the results into the data folder as the masterlist self.write_master_csv(jobs_dict) - self.logger.info( - f'No masterlist detected, added {len(jobs_dict.keys())}' - f' jobs to {self.config.master_csv_file}' - ) self.logger.info( f"Done. View your current jobs in {self.config.master_csv_file}" @@ -145,10 +141,10 @@ def scrape(self) ->Dict[str, Job]: end = time() self.logger.info( f"Scraped {len(jobs.items())} jobs from {scraper_cls.__name__}," - f" took {(end - start):.3f}s'" + f" took {(end - start):.3f}s" ) - self.logger.info(f"Completed Scraping, got {len(jobs)} jobs.") + self.logger.info(f"Completed all scraping, found {len(jobs)} new jobs.") return jobs def recover(self): @@ -299,7 +295,7 @@ def write_master_csv(self, jobs: Dict[str, Job]) -> None: writer.writerow(job.as_row) n_jobs = len(jobs) self.logger.info( - f"Wrote out {n_jobs} jobs to {self.config.master_csv_file}" + f"Wrote {n_jobs} jobs to {self.config.master_csv_file}" ) def update_block_list(self): @@ -402,6 +398,7 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: if n_filtered > 0: self.logger.info(f'Filtered-out {n_filtered} jobs from results.') else: + # TODO: print a % of jobs that are new /etc here. self.logger.info(f'No jobs were filtered from results.') return n_filtered diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 7cad08ae..1d898e08 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -7,11 +7,13 @@ import logging import os from time import sleep, time -from typing import Dict, List, Tuple +from tqdm import tqdm +from typing import Dict, List, Tuple, Union, Any import random from requests import Session -from jobfunnel.resources import USER_AGENT_LIST, Locale, MAX_CPU_WORKERS +from jobfunnel.resources import ( + Locale, JobField, USER_AGENT_LIST, MAX_CPU_WORKERS) from jobfunnel.backend.tools.delay import calculate_delays, delay_threader from jobfunnel.backend import Job, JobStatus # from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue @@ -35,19 +37,61 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', if self.headers: self.session.headers.update(self.headers) + # Ensure that the locale we want to use matches the locale that the + # scraper was written to scrape in: + if self.config.search_config.locale != self.locale: + raise ValueError( + f"Attempting to use scraper designed for {self.locale.name} " + "when config indicates user is searching with " + f"{self.config.search_config.locale.name}" + ) + + # Ensure our properties satisfy constraints + self._validate_get_set() + @property def user_agent(self) -> str: """Get a user agent for this scraper """ return random.choice(USER_AGENT_LIST) + @property + def min_required_job_fields(self) -> str: + """If we dont get() or set() any of these fields, we will raise an + exception instead of continuing without that information. + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.URL + ] + + @property + def job_get_fields(self) -> str: + """Call self.get(...) for the JobFields in this list when scraping a Job + + Override this as needed. + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.TAGS, JobField.POST_DATE, + ] + + @property + def job_set_fields(self) -> str: + """Call self.set(...) for the JobFields in this list when scraping a Job + NOTE: Since this passes the Job we are updating, the order of this list + matters if set fields rely on each-other. + + Override this as needed. + """ + return [JobField.URL, JobField.DESCRIPTION] + @property @abstractmethod def locale(self) -> Locale: """Get the localizations that this scraper was built for We will use this to put the right filters & scrapers together NOTE: it is best to inherit this from BaseClass - NOTE: self.config.search.locale == self.locale should be true """ pass @@ -66,12 +110,13 @@ def scrape(self) -> Dict[str, Job]: maybe we can just use a queue and calc delays on-the-fly in scrape_job for all session.get requests? """ + # Get a list of job soups from the initial search results page try: job_soups = self.get_job_listings_from_search_results() except Exception as err: raise ValueError( - "Unable to extract jobs from initial search result page:\n" + "Unable to extract jobs from initial search result page:\n\t" f"{str(err)}" ) self.logger.info( @@ -80,7 +125,7 @@ def scrape(self) -> Dict[str, Job]: # For each job-soup object, scrape the soup into a Job (w/o desc.) jobs_dict = {} # type: Dict[str, Job] - for job_soup in job_soups: + for job_soup in tqdm(job_soups): job = self.scrape_job(job_soup) if job.key_id in jobs_dict: self.logger.error( @@ -89,212 +134,133 @@ def scrape(self) -> Dict[str, Job]: ) jobs_dict[job.key_id] = job - # FIXME: get rid of these two _methods and replace with more flexible - # delaying implementation. - def _get_with_delay(job: Job, delay: float) -> Tuple[Job, str]: - """Get a job's page by the job url with a delay beforehand - """ - sleep(delay) - self.logger.info( - f'Delay of {delay:.1f}s, getting search results for: {job.url}' - ) - job_page_soup = BeautifulSoup( - self.session.get(job.url).text, self.config.bs4_parser - ) - return job, job_page_soup - - def _parse(job: Job, job_page_soup: BeautifulSoup) -> None: - """Set job.description with the job's own page. - TODO: move this into our delay callback - """ - try: - job.description = self.get_job_description(job, job_page_soup) - except AttributeError: - self.logger.warning( - f"Unable to scrape short description for job {job.key_id}." - ) - job.clean_strings() - - # Scrape stuff that we are delaying for - threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) - jobs_list = list(jobs_dict.values()) - delays = calculate_delays(len(jobs_list), self.config.delay_config) - delay_threader( - jobs_list, _get_with_delay, _parse, threads, self.logger, delays - ) return jobs_dict def scrape_job(self, job_soup: BeautifulSoup) -> Job: """Scrapes a search page and get a list of soups that will yield jobs + Arguments: + job_soup [BeautifulSoup]: This is a soup object that your get/set + will use to perform the get/set action. It should be specific + to this job and not contain other job information. - NOTE: does not currently scrape anything that + FIXME: need to have get and set trap to delay calc with a queue() Returns: Job: job object constructed from the soup and localization of class """ - # Scrape the data for the post, requiring a minimum of info... - try: - # Jobs should at minimum have a title, company and location - title = self.get_job_title(job_soup) - company = self.get_job_company(job_soup) - location = self.get_job_location(job_soup) - key_id = self.get_job_key_id(job_soup) - url = self.get_job_url(key_id) - except Exception as err: - # TODO: decide how we should handle these, proceed or exit? - raise ValueError( - "Unable to scrape minimum-required job info!\nerror:" + str(err) - ) + # Init kwargs + job_init_kwargs = { + JobField.STATUS: JobStatus.NEW, + JobField.LOCALE: self.locale, + JobField.QUERY: self.config.search_config.query_string, + JobField.DESCRIPTION: '', + JobField.URL: '', + JobField.SHORT_DESCRIPTION: '', # TODO: impl. + JobField.RAW: '', # TODO: impl. + JobField.PROVIDER: self.__class__.__name__, + } # type: Dict[JobField, Any] + + # Formulate the get/set actions + actions_list = [(True, f) for f in self.job_get_fields] + actions_list += [(False, f) for f in self.job_set_fields] - # Scrape the optional stuff - try: - tags = self.get_job_tags(job_soup) - except AttributeError as err: - tags = [] # type: List[str] - self.logger.warning( - f"Unable to scrape tags for job {key_id}:\n{str(err)}" - ) - - try: - post_date = self.get_job_post_date(job_soup) - except (AttributeError, ValueError): - post_date = datetime.datetime.now() - self.logger.warning( - f"Unknown date for job {key_id}, setting to datetime.now()." - ) - - # Init a new job from scraped data - job = Job( - title=title, - company=company, - location=location, - description='', # We will populate this later per-job-page - key_id=key_id, - url=url, - locale=self.locale, - query='', #self.query_string, FIXME - status=JobStatus.NEW, - provider='', #self.__class___.__name__, FIXME - short_description='', # TODO: impl. - post_date=post_date, - raw='', # FIXME: we cannot pickle the soup object (job_soup) - tags=tags, - ) + # Scrape the data for the post, requiring a minimum of info... + job = None # type: Union[None, Job] + for is_get, field in actions_list: + kwarg_name = field.name.lower() + try: + if is_get: + job_init_kwargs[field] = self.get(field, job_soup) + else: + if not job: + job = Job(**{ + k.name.lower(): v for k, v + in job_init_kwargs.items() + }) + self.set(field, job, job_soup) + except Exception as err: + if field in self.min_required_job_fields: + raise ValueError( + "Unable to scrape minimum-required job field: " + f"{field.name} Got error:{str(err)}" + ) + else: + self.logger.warning( + "Unable to scrape {} for job{}:\n\t{}".format( + kwarg_name, ' ' + job.url if job else '', str(err) + ) + ) + + assert job, "Failed to initialize job" # NOTE: should never see this + job.validate() return job - # FIXME: review below types and complete docstrings - - # TODO: implement getters like this so we can make the entire thing more - # flexible. - # @abstractmethod - # def get(self, parameter: str, - # soup: BeautifulSoup) -> Union[str, List[str], date]: - # """Get a single job attribute from a soup object - # i.e. get 'description' --> str - # """ - @abstractmethod def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: """Generate a list of soups for each job object from the response to our job search query. - NOTE: This should be in a format where there are many jobs shown to the user to click-into in a single view. - Returns a list of soup objects which correspond to each job shown on the results page. """ pass @abstractmethod - def get_job_url(self, job_soup: BeautifulSoup) -> str: - """Get job url from a job soup - Args: - job_soup: BeautifulSoup base to scrape the title from. - Returns: - Title of the job (i.e. 'Secret Shopper') - """ - pass - - @abstractmethod - def get_job_title(self, job_soup: BeautifulSoup) -> str: - """Get job title from soup - Args: - job_soup: BeautifulSoup base to scrape the title from. - Returns: - Title of the job (i.e. 'Secret Shopper') - """ - pass - - @abstractmethod - def get_job_company(self, job_soup: BeautifulSoup) -> str: - """Get job company name from soup - Args: - job_soup: BeautifulSoup base to scrape the company from. - Returns: - Company name (i.e. 'Aperture Science') - """ - pass - - @abstractmethod - def get_job_location(self, job_soup: BeautifulSoup) -> str: - """Get job location string - TODO: we should have a better format than str for this. - """ - pass + def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: + """Get a single job attribute from a soup object by JobField - @abstractmethod - def get_job_tags(self, job_soup: BeautifulSoup) -> List[str]: - """Fetches the job tags / keywords from a BeautifulSoup base. + i.e. if param is JobField.COMPANY --> scrape from soup --> return str + TODO: better way to handle ret type than a massive Union? """ pass @abstractmethod - def get_job_post_date(self, job_soup: BeautifulSoup) -> datetime.date: - """Fetches the job date from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the date from. - Returns: - date of the job's posting + def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: + """Set a single job attribute from a soup object by JobField + NOTE: use this to set Job attribs that rely on Job existing already + with the required minimum fields (i.e. you can set description by + getting the job's detail page with job.url) """ pass - @abstractmethod - def get_job_key_id(self, job_soup: BeautifulSoup) -> str: - """Fetches the job id from a BeautifulSoup base. - NOTE: this should be unique, but we should probably use our own SHA - Args: - soup: BeautifulSoup base to scrape the id from. - Returns: - The job id scraped from soup. - Note that this function may throw an AttributeError if it cannot - find the id. The caller is expected to handle this exception. - """ - pass - # FIXME: do we want all of these to take in a Job object? might be useful? - # ... the first in the chain would have job = None for its call though... - @abstractmethod - def get_job_description(self, job: Job, - job_soup: BeautifulSoup = None) -> str: - """Parses and stores job description html and sets Job.description - NOTE: this accepts Job because it allows using other job attributes - to make new session.get() for job-specific information. + def _validate_get_set(self) -> None: + """Ensure the get/set actions cover all need attribs and dont intersect """ - pass + set_job_get_fields = set(self.job_get_fields) + set_job_set_fields = set(self.job_set_fields) + all_set_get_fields = set(self.job_get_fields + self.job_set_fields) + set_min_fields = set(self.min_required_job_fields) - @abstractmethod - def get_short_job_description(self, job: Job, - job_soup: BeautifulSoup = None) -> str: - """Parses and stores job description from a job's page HTML - NOTE: this accepts Hob because it allows using other job attributes - to make new session.get() for job-specific information. - """ - pass + set_missing_req_fields = set_min_fields - all_set_get_fields + if set_missing_req_fields: + raise ValueError( + f"Job attributes: {set_missing_req_fields} are required and not" + f" implemented by {self.__class__.__name__}" + ) + field_intersection = set_job_get_fields.intersection(set_job_set_fields) + if field_intersection: + raise ValueError( + f"Job attributes: {field_intersection} are implemented by both" + f"get() and set() methods of {self.__class__.__name__}" + ) + for field in JobField: + # NOTE: we exclude status, locale, query, provider and scrape date + # because these are set without needing any scrape data. + # TODO: SHORT and RAW are not impl. rn. remove this check when impl. + if (field not in [JobField.STATUS, JobField.LOCALE, JobField.QUERY, + JobField.SCRAPE_DATE, JobField.PROVIDER, + JobField.SHORT_DESCRIPTION, JobField.RAW] + and field not in self.job_get_fields + and field not in self.job_set_fields): + self.logger.warning( + f"No get() or set() will be done for Job attr: {field.name}" + ) -# Just some basic localized scrapers, can inherit these to set locale as well. +# Just some basic localized scrapers, you can inherit these to set the locale. class BaseUSAEngScraper(BaseScraper): """Localized scraper for USA English @@ -309,5 +275,4 @@ class BaseCANEngScraper(BaseScraper): """ @property def locale(self) -> Locale: - return Locale.USA_ENGLISH - + return Locale.CANADA_ENGLISH diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 60344282..2caae22c 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -3,32 +3,24 @@ from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime, timedelta -from dateutil.relativedelta import relativedelta import logging from math import ceil from time import sleep, time -from typing import Dict, List, Tuple, Optional +from typing import Dict, List, Tuple, Optional, Any import re from requests import Session from bs4 import BeautifulSoup -from jobfunnel.resources import Locale, MAX_CPU_WORKERS +from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str from jobfunnel.backend.scrapers import ( BaseScraper, BaseCANEngScraper, BaseUSAEngScraper) #from jobfunnel.config import JobFunnelConfig # causes a circular import -# Initialize list and store regex objects of date quantifiers -HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') -DAY_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)') -MONTH_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?month') -YEAR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?year') -RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') -RECENT_REGEX_B = re.compile(r'[yY]esterday') ID_REGEX = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') - MAX_RESULTS_PER_INDEED_PAGE = 50 @@ -71,7 +63,9 @@ def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: # Parse total results, and calculate the # of pages needed pages = self._get_num_search_result_pages(search_url) - self.logger.info(f"Found {pages} indeed results for query={self.query}") + self.logger.info( + f"Found {pages} pages of search results for query={self.query}" + ) # Init list of job soups job_soup_list = [] # type: List[Any] @@ -94,6 +88,61 @@ def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: return job_soup_list + def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: + """Get a single job attribute from a soup object by JobField + """ + if parameter == JobField.TITLE: + return soup.find( + 'a', attrs={'data-tn-element': 'jobTitle'} + ).text.strip() + elif parameter == JobField.COMPANY: + return soup.find('span', attrs={'class': 'company'}).text.strip() + elif parameter == JobField.LOCATION: + return soup.find('span', attrs={'class': 'location'}).text.strip() + elif parameter == JobField.TAGS: + # tags may not be on page and that's ok. + table_soup = soup.find( + 'table', attrs={'class': 'jobCardShelfContainer'} + ) + if table_soup: + return [ + td.text.strip() for td in table_soup.find_all( + 'td', attrs={'class': 'jobCardShelfItem'} + ) + ] + elif parameter == JobField.POST_DATE: + return calc_post_date_from_relative_str( + soup.find('span', attrs={'class': 'date'}).text.strip() + ) + elif parameter == JobField.KEY_ID: + return ID_REGEX.findall( + str( + soup.find( + 'a', attrs={'class': 'sl resultLink save-job-link'} + ) + ) + )[0] + else: + raise NotImplementedError(f"Cannot get {parameter.name}") + + def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: + """Set a single job attribute from a soup object by JobField + """ + if parameter == JobField.DESCRIPTION: + job_link_soup = BeautifulSoup( + self.session.get(job.url).text, self.config.bs4_parser + ) + job.description = job_link_soup.find( + id='jobDescriptionText' + ).text.strip() + elif parameter == JobField.URL: + job.url = ( + f"http://www.indeed.{self.config.search_config.domain}/" + f"viewjob?jk={job.key_id}" + ) + else: + raise NotImplementedError(f"Cannot set {parameter.name}") + def _get_search_url(self, method: Optional[str] = 'get') -> str: """Get the indeed search url from SearchTerms """ @@ -146,7 +195,6 @@ def _get_job_soups_from_search_page(self, search: str, page: str, NOTE: modifies the job_soup_list in-place """ url = f'{search}&start={int(page * self.max_results_per_page)}' - self.logger.info(f'Getting indeed page {page} : {url}') job_soup_list.extend( BeautifulSoup( self.session.get(url).text, self.config.bs4_parser @@ -176,148 +224,6 @@ def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: else: return max_pages - def get_job_url(self, job_id: str) -> str: - """Constructs the link with the given job_id. - Args: - job_id: The id to be used to construct the link for this job. - Returns: - The constructed job link. - """ - return ( - f"http://www.indeed.{self.config.search_config.domain}/" - f"viewjob?jk={job_id}" - ) - - def get_job_title(self, soup: BeautifulSoup) -> str: - """Fetches the title from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the title from. - Returns: - The job title scraped from soup. - NOTE: that this function may throw an AttributeError if it cannot - find the title. The caller is expected to handle this exception. - """ - return soup.find( - 'a', attrs={'data-tn-element': 'jobTitle'} - ).text.strip() - - def get_job_company(self, soup: BeautifulSoup) -> str: - """Fetches the company from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the company from. - Returns: - The company scraped from soup. - Note that this function may throw an AttributeError if it cannot - find the company. The caller is expected to handle this exception. - """ - return soup.find('span', attrs={'class': 'company'}).text.strip() - - def get_job_location(self, soup: BeautifulSoup) -> str: - """Fetches the job location from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the location from. - Returns: - The job location scraped from soup. - Note that this function may throw an AttributeError if it cannot - find the location. The caller is expected to handle this exception. - """ - return soup.find('span', attrs={'class': 'location'}).text.strip() - - def get_job_tags(self, soup: BeautifulSoup) -> List[str]: - """Fetches the job tags / keywords from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the location from. - Returns: - The job location scraped from soup. - Note that this function may throw an AttributeError if it cannot - find the location. The caller is expected to handle this exception. - """ - return [td.text.strip() for td in soup.find( - 'table', attrs={'class': 'jobCardShelfContainer'} - ).find_all('td', attrs={'class': 'jobCardShelfItem'})] - - def get_job_post_date(self, soup: BeautifulSoup) -> date: - """Fetches the job date from a BeautifulSoup base. - Args: - soup: BeautifulSoup base to scrape the date from. - Returns: - The job date scraped from soup. - Note that this function may throw an AttributeError if it cannot - find the date. The caller is expected to handle this exception. - """ - date_string = soup.find('span', attrs={'class': 'date'}).text.strip() - return self._calc_post_date_from_relative_str(date_string) - - def _calc_post_date_from_relative_str(self, date_str: str) -> date: - """Identifies a job's post date via post age, updates in-place - """ - post_date = datetime.now() # type: date - # Supports almost all formats like 7 hours|days and 7 hr|d|+d - try: - # Hours old - hours_ago = HOUR_REGEX.findall(date_str)[0] - post_date -= timedelta(hours=int(hours_ago)) - except IndexError: - # Days old - try: - days_ago = DAY_REGEX.findall(date_str)[0] - post_date -= timedelta(days=int(days_ago)) - except IndexError: - # Months old - try: - months_ago = MONTH_REGEX.findall(date_str)[0] - post_date -= relativedelta( - months=int(months_ago)) - except IndexError: - # Years old - try: - years_ago = YEAR_REGEX.findall(date_str)[0] - post_date -= relativedelta( - years=int(years_ago)) - except IndexError: - # Try phrases like 'today'/'just posted'/'yesterday' - if (RECENT_REGEX_A.findall(date_str) and - not post_date): - # Today - post_date = datetime.now() - elif RECENT_REGEX_B.findall(date_str): - # Yesterday - post_date -= timedelta(days=int(1)) - elif not post_date: - # We have failed to correctly evaluate date. - raise ValueError( - f"Unable to calculate date from:\n{date_str}" - ) - return post_date - - def get_job_key_id(self, soup: BeautifulSoup) -> str: - """Fetches the job id from a BeautifulSoup base. - NOTE: this should be unique, but we should probably use our own SHA - Args: - soup: BeautifulSoup base to scrape the id from. - Returns: - The job id scraped from soup. - Note that this function may throw an AttributeError if it cannot - find the id. The caller is expected to handle this exception. - """ - return ID_REGEX.findall( - str(soup.find('a', attrs={'class': 'sl resultLink save-job-link'})) - )[0] - - def get_job_description(self, job: Job, soup: BeautifulSoup) -> str: - """Parses and stores job description html and sets Job.description - """ - job_link_soup = BeautifulSoup( - self.session.get(job.url).text, self.config.bs4_parser - ) - return job_link_soup.find(id='jobDescriptionText').text.strip() - - def get_short_job_description(self, job: Job, soup: str) -> None: - """Parses and stores job description from a job's page HTML - # FIXME: impl. - """ - pass - class IndeedScraperCAEng(BaseIndeedScraper, BaseCANEngScraper): """Scrapes jobs from www.indeed.ca diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 1d49c98d..87ec74ef 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -1,6 +1,8 @@ """Assorted tools for all aspects of funnelin' that don't fit elsewhere """ import re +from datetime import date, datetime, timedelta +from dateutil.relativedelta import relativedelta from webdriver_manager.chrome import ChromeDriverManager from webdriver_manager.microsoft import IEDriverManager @@ -10,6 +12,54 @@ from selenium import webdriver +# Initialize list and store regex objects of date quantifiers +HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') +DAY_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:day|d)') +MONTH_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?month') +YEAR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?year') +RECENT_REGEX_A = re.compile(r'[tT]oday|[jJ]ust [pP]osted') +RECENT_REGEX_B = re.compile(r'[yY]esterday') + + +def calc_post_date_from_relative_str(date_str: str) -> date: + """Identifies a job's post date via post age, updates in-place + """ + post_date = datetime.now() # type: date + # Supports almost all formats like 7 hours|days and 7 hr|d|+d + try: + # Hours old + hours_ago = HOUR_REGEX.findall(date_str)[0] + post_date -= timedelta(hours=int(hours_ago)) + except IndexError: + # Days old + try: + days_ago = DAY_REGEX.findall(date_str)[0] + post_date -= timedelta(days=int(days_ago)) + except IndexError: + # Months old + try: + months_ago = MONTH_REGEX.findall(date_str)[0] + post_date -= relativedelta(months=int(months_ago)) + except IndexError: + # Years old + try: + years_ago = YEAR_REGEX.findall(date_str)[0] + post_date -= relativedelta(years=int(years_ago)) + except IndexError: + # Try phrases like 'today'/'just posted'/'yesterday' + if RECENT_REGEX_A.findall(date_str) and not post_date: + # Today + post_date = datetime.now() + elif RECENT_REGEX_B.findall(date_str): + # Yesterday + post_date -= timedelta(days=int(1)) + elif not post_date: + # We have failed to correctly evaluate date. + raise ValueError( + f"Unable to calculate date from:\n{date_str}" + ) + return post_date + def get_webdriver(): """Get whatever webdriver is availiable in the system. webdriver_manager and selenium are currently being used for this. diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index a0012ce4..a2532f42 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -55,6 +55,8 @@ def __init__(self, self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS self.blocked_company_names = blocked_company_names + self.__query_string = '' # type: str + # Try to infer the domain string based on the locale. if not domain: if not self.locale in DEFAULT_DOMAIN_FROM_LOCALE: @@ -63,6 +65,14 @@ def __init__(self, else: self.domain = domain + @property + def query_string(self) -> str: + """User-readable version of the keywords we are searching with + """ + if not self.__query_string: + self.__query_string = ' '.join(self.keywords) + return self.__query_string + def validate(self): """We need to have the right information set, not mixing stuff FIXME: impl. diff --git a/jobfunnel/resources/enums.py b/jobfunnel/resources/enums.py index eeaa7f40..135bbafd 100644 --- a/jobfunnel/resources/enums.py +++ b/jobfunnel/resources/enums.py @@ -15,8 +15,6 @@ class Locale(Enum): CANADA_FRENCH = 2 USA_ENGLISH = 3 - -# Some enums class JobStatus(Enum): """Job statuses that are built-into jobfunnel NOTE: these are the only valid values for entries in 'status' in our CSV @@ -35,6 +33,26 @@ class JobStatus(Enum): OLD = 12 +class JobField(Enum): + """Fields of job that we need setters for, passed to Scraper.get(field=...) + """ + TITLE = 0 + COMPANY = 1 + LOCATION = 2 + DESCRIPTION = 3 + KEY_ID = 4 + URL = 5 + LOCALE = 6 + QUERY = 7 + PROVIDER = 8 + STATUS = 9 + SCRAPE_DATE = 10 + SHORT_DESCRIPTION = 11 + POST_DATE = 12 + RAW = 13 + TAGS = 14 + + class Provider(Enum): """Job source providers """ diff --git a/setup.py b/setup.py index 16869aa1..71e2d641 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,7 @@ 'selenium>=3.141.0', 'webdriver-manager>=2.4.0', 'Cerberus>=1.3.2', + 'tqdm>=4.47.0', ] with open('readme.md', 'r') as f: From ce37804b0130ab0630edf56081a0c4c628b7151e Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 9 Aug 2020 18:27:39 -0400 Subject: [PATCH 14/66] cleaning up some descriptions --- jobfunnel/backend/scrapers/base.py | 44 ++++++++++++++++------------ jobfunnel/backend/scrapers/indeed.py | 4 +-- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 1d898e08..ad42b689 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -20,14 +20,7 @@ class BaseScraper(ABC): - """Base scraper object, for generating List[Job] from a specific job source - - TODO: accept filters: List[Filter] here if we have Filter(ABC) - NOTE: we want to use filtering here because scraping blurbs can be slow. - NOTE: we don't have domain as an attrib because multiple domains can belong - to multiple locales. The Locale is intended to define the format of the - website, the scraping logic needed and the language used - as such, - SearchConfig is what defines the domain (it is being requested). + """Base scraper object, for scraping and filtering Jobs from a provider """ def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: @@ -59,6 +52,10 @@ def user_agent(self) -> str: def min_required_job_fields(self) -> str: """If we dont get() or set() any of these fields, we will raise an exception instead of continuing without that information. + + NOTE: pointless to check for locale / provider / other defaults + + Override this as needed. """ return [ JobField.TITLE, JobField.COMPANY, JobField.LOCATION, @@ -79,6 +76,7 @@ def job_get_fields(self) -> str: @property def job_set_fields(self) -> str: """Call self.set(...) for the JobFields in this list when scraping a Job + NOTE: Since this passes the Job we are updating, the order of this list matters if set fields rely on each-other. @@ -89,16 +87,18 @@ def job_set_fields(self) -> str: @property @abstractmethod def locale(self) -> Locale: - """Get the localizations that this scraper was built for + """The localization that this scraper was built for. + We will use this to put the right filters & scrapers together - NOTE: it is best to inherit this from BaseClass + + NOTE: it is best to inherit this from BaseClass (btm. of file) """ pass @property @abstractmethod def headers(self) -> Dict[str, str]: - """Get the Session headers for this scraper to be used with + """The Session headers for this scraper to be used with requests.Session.headers.update() """ pass @@ -113,7 +113,7 @@ def scrape(self) -> Dict[str, Job]: # Get a list of job soups from the initial search results page try: - job_soups = self.get_job_listings_from_search_results() + job_soups = self.get_job_soups_from_search_result_listings() except Exception as err: raise ValueError( "Unable to extract jobs from initial search result page:\n\t" @@ -197,13 +197,19 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: return job @abstractmethod - def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: - """Generate a list of soups for each job object from the response to our - job search query. - NOTE: This should be in a format where there are many jobs shown to the - user to click-into in a single view. - Returns a list of soup objects which correspond to each job shown on the - results page. + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: + """Scrapes a job provider's response to a search query where we are + shown many job listings at once. + + NOTE: the soups list returned by this method should contain enough + information to set your self.min_required_job_fields with get/set. + + NOTE: for situations where the data you want is in the job's own page + and we need to make another get request, handle those in set() + and make a request using job.url (it will be respectfully delayed) + + Returns: + List[BeautifulSoup]: list of jobs soups we can use to make a Job """ pass diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 2caae22c..76592239 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -52,11 +52,11 @@ def headers(self) -> Dict[str, str]: 'Connection': 'keep-alive' } - def get_job_listings_from_search_results(self) -> List[BeautifulSoup]: + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: """Scrapes raw data from a job source into a list of job-soups Returns: - List[BeautifulSoup]: list of jobs soups we can use to make Job + List[BeautifulSoup]: list of jobs soups we can use to make Job init """ # Get the search url search_url = self._get_search_url() From 81ec0c4f93ccd732136848074f509e82e760de07 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 10 Aug 2020 08:41:01 -0400 Subject: [PATCH 15/66] got delaying back, but only for fields that need it --- jobfunnel/backend/jobfunnel.py | 4 ++- jobfunnel/backend/scrapers/base.py | 48 +++++++++++++++++++++++++----- jobfunnel/backend/tools/delay.py | 29 ------------------ 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 7d18b411..0a847cdd 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -3,6 +3,7 @@ """ import csv from collections import OrderedDict +from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime import json import logging @@ -16,7 +17,7 @@ from jobfunnel.config import JobFunnelConfig from jobfunnel.backend import Job from jobfunnel.resources import ( - CSV_HEADER, JobStatus, Locale, MAX_BLOCK_LIST_DESC_CHARS) + JobStatus, Locale, CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, MAX_CPU_WORKERS) from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter @@ -35,6 +36,7 @@ def __init__(self, config: JobFunnelConfig): self.config.validate() self.logger = None self.__date_string = date.today().strftime("%Y-%m-%d") + self.__threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) self.init_logging() # Open a session with/out a proxy configured diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index ad42b689..d4cf8e63 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -2,7 +2,7 @@ """ from abc import ABC, abstractmethod from bs4 import BeautifulSoup -from concurrent.futures import ThreadPoolExecutor, wait +from concurrent.futures import ThreadPoolExecutor, as_completed import datetime import logging import os @@ -42,6 +42,9 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', # Ensure our properties satisfy constraints self._validate_get_set() + # Init a thread executor (multi-worker) TODO: can't reuse after shutdown + self.executor = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + @property def user_agent(self) -> str: """Get a user agent for this scraper @@ -84,6 +87,15 @@ def job_set_fields(self) -> str: """ return [JobField.URL, JobField.DESCRIPTION] + @property + def delayed_get_set_fields(self) -> str: + """Delay execution when getting /setting any of these attributes of a + job. + + Override this as needed. + """ + return [JobField.DESCRIPTION] + @property @abstractmethod def locale(self) -> Locale: @@ -119,14 +131,28 @@ def scrape(self) -> Dict[str, Job]: "Unable to extract jobs from initial search result page:\n\t" f"{str(err)}" ) + n_soups = len(job_soups) self.logger.info( - f"Scraped {len(job_soups)} job listings from search results pages" + f"Scraped {n_soups} job listings from search results pages" ) + # Calculate delays for all job get/set calls NOTE: only get/set + # calls which require delaying (in self.delayed_get_set_fields + # will be delayed. + delays = calculate_delays(n_soups, self.config.delay_config) + results = [] + for job_soup, delay in zip(job_soups, delays): + results.append( + self.executor.submit( + self.scrape_job, job_soup=job_soup, delay=delay + ) + ) + + # Loops through futures as completed and removes each if successfully parsed # For each job-soup object, scrape the soup into a Job (w/o desc.) jobs_dict = {} # type: Dict[str, Job] - for job_soup in tqdm(job_soups): - job = self.scrape_job(job_soup) + for future in tqdm(as_completed(results), total=n_soups): + job = future.result() if job.key_id in jobs_dict: self.logger.error( f"Job {job.title} and {jobs_dict[job.key_id].title} share " @@ -134,16 +160,19 @@ def scrape(self) -> Dict[str, Job]: ) jobs_dict[job.key_id] = job + # Cleanup + log + self.executor.shutdown() + return jobs_dict - def scrape_job(self, job_soup: BeautifulSoup) -> Job: + def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: """Scrapes a search page and get a list of soups that will yield jobs Arguments: job_soup [BeautifulSoup]: This is a soup object that your get/set will use to perform the get/set action. It should be specific to this job and not contain other job information. - - FIXME: need to have get and set trap to delay calc with a queue() + delay [float]: how long to delay getting/setting for certain + get/set calls. Returns: Job: job object constructed from the soup and localization of class @@ -167,6 +196,11 @@ def scrape_job(self, job_soup: BeautifulSoup) -> Job: # Scrape the data for the post, requiring a minimum of info... job = None # type: Union[None, Job] for is_get, field in actions_list: + + # Respectfully delay if it's configured to do so. + if field in self.delayed_get_set_fields: + sleep(delay) + kwarg_name = field.name.lower() try: if is_get: diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 2556a4e2..bb8a3ae9 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -67,8 +67,6 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: """Checks delay config and returns calculated delay list. NOTE: we do this to be respectful to online job sources - TODO: make this return a delay value based on list_len so we can use this - on-demand with the thread executor's GET queue. Args: list_len: length of scrape job list @@ -119,30 +117,3 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: durations[0] = 0 return durations - - -def delay_threader(jobs_list: List[Job], scrape_fn: object, - parse_fn: object, threads: ThreadPoolExecutor, - logger: Logger, delays: List[float] = None) -> None: - """Method to scrape descriptions from individual indeed postings. - with respectful-delaying - """ - - # Submits jobs and stores futures in dict - start = time() - results = {} - for job, delay in zip(jobs_list, delays): - results[threads.submit(scrape_fn, job=job, delay=delay)] = job.key_id - - # Loops through futures as completed and removes each if successfully parsed - while results: - for future in as_completed(results): - job, html = future.result() - parse_fn(job, html) - del results[future] - del html - - # Cleanup + log - threads.shutdown() - end = time() - logger.info(f'Scrape delay took {(end - start):.3f}s') From 6b3b2b0f20cd1dcf86a4c85b170e7ff40ea3c83c Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 10 Aug 2020 09:10:19 -0400 Subject: [PATCH 16/66] added --recover CLI and fixed stdout logging handler --- jobfunnel/backend/jobfunnel.py | 106 +++++++++++++++++------------ jobfunnel/backend/scrapers/base.py | 17 ++--- jobfunnel/backend/tools/delay.py | 7 +- jobfunnel/config/cli.py | 8 +++ jobfunnel/config/funnel.py | 7 ++ 5 files changed, 88 insertions(+), 57 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 0a847cdd..ece2de10 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -66,46 +66,53 @@ def run(self) -> None: # we are getting detailed job information (per-job) self.update_block_list() - # Get jobs keyed by their unique ID, use cache if we scraped today - jobs_dict = {} # type: Dict[str, Job] - if os.path.exists(self.daily_cache_file): - jobs_dict = self.load_cache(self.daily_cache_file) - elif self.config.no_scrape: - self.logger.warning( - f"No jobs cached, missing: {self.daily_cache_file}" - ) + if self.config.recover_from_cache: + + # Perform recovery from cache if --recover passed + self.recover() - if self.config.no_scrape: - self.logger.info("Skipping scraping, running with --no-scrape.") else: - jobs_dict = self.scrape() # type: Dict[str, Job] - self.write_cache(jobs_dict) - - # Filter out scraped jobs we have rejected, archived or block-listed - # (before we add them to the CSV) - self.filter(jobs_dict) - - # Load and update existing masterlist - if os.path.exists(self.config.master_csv_file): - - # Identify duplicate jobs using the existing masterlist - masterlist = self.read_master_csv() # type: Dict[str, Job] - self.filter(masterlist) # NOTE: this reduces size of masterlist - try: - tfidf_filter(jobs_dict, masterlist) - except ValueError as err: - self.logger.error( - f"Skipping similarity filter due to: {str(err)}" + + # Get jobs keyed by their unique ID, use cache if we scraped today + jobs_dict = {} # type: Dict[str, Job] + if os.path.exists(self.daily_cache_file): + jobs_dict = self.load_cache(self.daily_cache_file) + elif self.config.no_scrape: + self.logger.warning( + f"No jobs cached, missing: {self.daily_cache_file}" ) - # Expand the masterlist with filteres, non-duplicated jobs & save - masterlist.update(jobs_dict) - self.write_master_csv(masterlist) + if self.config.no_scrape: + self.logger.info("Skipping scraping, running with --no-scrape.") + else: + jobs_dict = self.scrape() # type: Dict[str, Job] + self.write_cache(jobs_dict) + + # Filter out scraped jobs we have rejected, archived or block-listed + # (before we add them to the CSV) + self.filter(jobs_dict) + + # Load and update existing masterlist + if os.path.exists(self.config.master_csv_file): + + # Identify duplicate jobs using the existing masterlist + masterlist = self.read_master_csv() # type: Dict[str, Job] + self.filter(masterlist) # NOTE: this reduces size of masterlist + try: + tfidf_filter(jobs_dict, masterlist) + except ValueError as err: + self.logger.error( + f"Skipping similarity filter due to: {str(err)}" + ) + + # Expand the masterlist with filters, non-duplicated jobs & save + masterlist.update(jobs_dict) + self.write_master_csv(masterlist) - else: - # FIXME: we should still remove duplicates (TFIDF) within jobs_dict - # Dump the results into the data folder as the masterlist - self.write_master_csv(jobs_dict) + else: + # FIXME: we should still remove duplicates within jobs_dict? + # Dump the results into the data folder as the masterlist + self.write_master_csv(jobs_dict) self.logger.info( f"Done. View your current jobs in {self.config.master_csv_file}" @@ -113,7 +120,7 @@ def run(self) -> None: def init_logging(self) -> None: """Initialize a logger - TODO: we are mixing logging calls with self.logger here, is that OK? + NOTE: make stdout format more configurable? """ self.logger = logging.getLogger() self.logger.setLevel(self.config.log_level) @@ -121,10 +128,10 @@ def init_logging(self) -> None: filename=self.config.log_file, level=self.config.log_level, ) - if self.config.log_level == 20: - logging.getLogger().addHandler(logging.StreamHandler(sys.stdout)) - else: - logging.getLogger().addHandler(logging.StreamHandler()) + formatter = logging.Formatter('[%(levelname)s] %(message)s') + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(formatter) + self.logger.addHandler(stdout_handler) self.logger.info(f"JobFunnel initialized at {self.__date_string}") def scrape(self) ->Dict[str, Job]: @@ -151,8 +158,6 @@ def scrape(self) ->Dict[str, Job]: def recover(self): """Build a new master CSV from all the available pickles in our cache - NOTE: maybe we can warn user that this will throw away their current - masterlist, since we are assuming it's corrupted somehow """ self.logger.info("Recovering jobs from all cache files in cache folder") if os.path.exists(self.config.user_block_list_file): @@ -164,7 +169,12 @@ def recover(self): all_jobs_dict = {} for file in os.listdir(self.config.cache_folder): if '.pkl' in file: - all_jobs_dict.update(self.load_cache(file)) + all_jobs_dict.update( + self.load_cache( + os.path.join(self.config.cache_folder, file) + ) + ) + self.filter(all_jobs_dict) self.write_master_csv(all_jobs_dict) def load_cache(self, cache_file: str) -> Dict[str, Job]: @@ -306,6 +316,8 @@ def update_block_list(self): NOTE: adding jobs to block list will result in filter() removing them from all scraped & cached jobs in the future. + + NOTE: we truncate descriptions in the block list """ if os.path.isfile(self.config.master_csv_file): @@ -329,8 +341,12 @@ def update_block_list(self): blocked_jobs_dict[job.key_id] = { 'title': job.title, 'post_date': job.post_date.strftime('%Y-%m-%d'), - 'description': '{0: MAX_BLOCK_LIST_DESC_CHARS + else job.description, 'status': job.status.name, } diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index d4cf8e63..718a1dc8 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -14,7 +14,7 @@ from jobfunnel.resources import ( Locale, JobField, USER_AGENT_LIST, MAX_CPU_WORKERS) -from jobfunnel.backend.tools.delay import calculate_delays, delay_threader +from jobfunnel.backend.tools.delay import calculate_delays from jobfunnel.backend import Job, JobStatus # from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue @@ -118,9 +118,11 @@ def headers(self) -> Dict[str, str]: def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID - FIXME: this is hard-coded to delay scraping of descriptions only rn - maybe we can just use a queue and calc delays on-the-fly in scrape_job - for all session.get requests? + NOTE: respectfully delays for scraping of configured job attributes in + self. + + Returns: + jobs (Dict[str, Job]): list of Jobs in a Dict keyed by job.key_id """ # Get a list of job soups from the initial search results page @@ -136,9 +138,8 @@ def scrape(self) -> Dict[str, Job]: f"Scraped {n_soups} job listings from search results pages" ) - # Calculate delays for all job get/set calls NOTE: only get/set - # calls which require delaying (in self.delayed_get_set_fields - # will be delayed. + # Calculate delays for get/set calls per-job NOTE: only get/set + # calls in self.delayed_get_set_fields will be delayed. delays = calculate_delays(n_soups, self.config.delay_config) results = [] for job_soup, delay in zip(job_soups, delays): @@ -172,7 +173,7 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: will use to perform the get/set action. It should be specific to this job and not contain other job information. delay [float]: how long to delay getting/setting for certain - get/set calls. + get/set calls while scraping data for this job. Returns: Job: job object constructed from the soup and localization of class diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index bb8a3ae9..48239538 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -1,13 +1,10 @@ """Module for calculating random or non-random delay """ -from concurrent.futures import ThreadPoolExecutor, as_completed from math import ceil, log, sqrt from numpy import arange from random import uniform -import sys from typing import Dict, Union, List from time import time -from logging import warning, Logger from scipy.special import expit @@ -68,6 +65,8 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: NOTE: we do this to be respectful to online job sources + TODO: we should calculate delays on-demand. + Args: list_len: length of scrape job list delay_config: Delay configuration dictionary @@ -114,6 +113,6 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: durations = [round(i, 3) for i in delay_vals] # Always set first element to 0 so scrape starts right away - durations[0] = 0 + durations[0] = 0.0 return durations diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 0e3fac98..85afb089 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -179,8 +179,12 @@ def parse_cli(): parser.add_argument( '--recover', + dest='recover_from_cache', action='store_true', help='Reconstruct a new master CSV file from all available cache files.' + 'WARNING: this will replace all the statuses/etc in your master ' + 'CSV, it is intended for starting fresh / recovering from a bad ' + 'state.' ) parser.add_argument( @@ -272,6 +276,9 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: else: config = DEFAULT_CONFIG + # Are we recovering? NOTE: this arg is not part of yaml like settings path + recover_from_cache = args_dict.pop('recover_from_cache') + # Ensure that if user provided output folder that the other paths aren't if (args_dict['output_folder'] != DEFAULT_OUTPUT_DIRECTORY and ( args_dict['master_csv_file'] != DEFAULT_MASTER_CSV_FILE @@ -358,6 +365,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: log_level=config['log_level'], no_scrape=config['no_scrape'], # bs4_parser=config['bs4_parser'], # TODO: impl. cli/cfg when needed. + recover_from_cache=recover_from_cache, # NOTE: this isn't in YAML search_config=search_cfg, delay_config=delay_cfg, proxy_config=proxy_cfg, diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 0563f56d..9b73c334 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -22,6 +22,7 @@ def __init__(self, log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, + recover_from_cache: Optional[bool] = False, bs4_parser: Optional[str] = BS4_PARSER, return_similar_results: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, @@ -29,6 +30,8 @@ def __init__(self, """Init a config that determines how we will scrape jobs from Scrapers and how we will update CSV and filtering lists + TODO: we might want to make a RunTimeConfig with the flags etc. + Args: master_csv_file (str): path to the .csv file that user interacts w/ user_block_list_file (str): path to a JSON that contains jobs user @@ -43,6 +46,9 @@ def __init__(self, no_scrape (Optional[bool], optional): If True, will not scrape data at all, instead will only update filters and CSV. Defaults to False. + recover_from_cache (Optional[bool], optional): if True, build the + master CSV file from the contents of all the cache files inside + self.cache_folder. NOTE: respects the block list. not in YAML. bs4_parser (Optional[str], optional): the parser to use for BS4. return_similar_resuts (Optional[bool], optional): If True, we will ask the job provider to provide more loosely-similar results for @@ -61,6 +67,7 @@ def __init__(self, self.log_level = log_level self.no_scrape = no_scrape self.bs4_parser = bs4_parser # TODO: add to config + self.recover_from_cache = recover_from_cache self.return_similar_results = return_similar_results if not delay_config: # We will always use a delay config to be respectful From 29d3573d04d59d5e261ec7903ff907aecf33c444 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 10 Aug 2020 20:49:41 -0400 Subject: [PATCH 17/66] Added back Monster scraper --- jobfunnel/backend/job.py | 10 +- jobfunnel/backend/jobfunnel.py | 16 +- jobfunnel/backend/scrapers/__init__.py | 3 + jobfunnel/backend/scrapers/base.py | 25 +- jobfunnel/backend/scrapers/indeed.py | 24 +- jobfunnel/backend/scrapers/monster.py | 474 +++++++++++++------------ jobfunnel/backend/scrapers/registry.py | 22 +- jobfunnel/config/search.py | 3 +- jobfunnel/resources/defaults.py | 3 +- 9 files changed, 303 insertions(+), 277 deletions(-) diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index fcace833..92597e97 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -25,12 +25,12 @@ def __init__(self, company: str, location: str, description: str, - key_id: str, url: str, locale: Locale, query: str, provider: str, status: JobStatus, + key_id: Optional[str] = '', scrape_date: Optional[date] = None, short_description: Optional[str] = None, post_date: Optional[date] = None, @@ -38,9 +38,11 @@ def __init__(self, tags: Optional[List[str]] = None) -> None: """Object to represent a single job that we have scraped - NOTE: self.attrs must be reflected in JobField so we can auto-get/set - TODO: would be nice to use something standardized for location + TODO integrate init with JobField somehow, ideally with validation. + TODO: would be nice to use something standardized for location str TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? + NOTE: ideally key_id is provided, but Monster sets() it, so it now + has a default = None and is checked for in validate() Args: title (str): title of the job (should be somewhat short) @@ -136,4 +138,4 @@ def clean_strings(self) -> None: def validate(self) -> None: """TODO: implement this just to ensure that the metadata is good""" - pass + assert self.key_id, "Key_ID is unset!" diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index ece2de10..8ba078fe 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -2,23 +2,23 @@ Scrapes jobs, applies search filters and writes pickles to master list """ import csv -from collections import OrderedDict -from concurrent.futures import ThreadPoolExecutor, wait -from datetime import date, datetime import json import logging import os import pickle -from requests import Session import sys -from typing import Dict, List, Union +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime from time import time +from typing import Dict, List + +from requests import Session -from jobfunnel.config import JobFunnelConfig from jobfunnel.backend import Job -from jobfunnel.resources import ( - JobStatus, Locale, CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, MAX_CPU_WORKERS) from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter +from jobfunnel.config import JobFunnelConfig +from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, + MAX_CPU_WORKERS, JobStatus, Locale) class JobFunnel(object): diff --git a/jobfunnel/backend/scrapers/__init__.py b/jobfunnel/backend/scrapers/__init__.py index 9cb5127d..3335c07a 100644 --- a/jobfunnel/backend/scrapers/__init__.py +++ b/jobfunnel/backend/scrapers/__init__.py @@ -7,4 +7,7 @@ from jobfunnel.backend.scrapers.glassdoor.static import ( GlassDoorStaticCAEng, GlassDoorStaticUSAEng, ) +from jobfunnel.backend.scrapers.monster import ( + MonsterScraperCAEng, MonsterScraperUSAEng, +) from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 718a1dc8..8c6da3b4 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -1,21 +1,22 @@ """The base scraper class to be used for all web-scraping emitting Job objects """ -from abc import ABC, abstractmethod -from bs4 import BeautifulSoup -from concurrent.futures import ThreadPoolExecutor, as_completed import datetime import logging import os -from time import sleep, time -from tqdm import tqdm -from typing import Dict, List, Tuple, Union, Any import random +from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor, as_completed +from time import sleep, time +from typing import Any, Dict, List, Tuple, Union + +from bs4 import BeautifulSoup from requests import Session +from tqdm import tqdm -from jobfunnel.resources import ( - Locale, JobField, USER_AGENT_LIST, MAX_CPU_WORKERS) -from jobfunnel.backend.tools.delay import calculate_delays from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools.delay import calculate_delays +from jobfunnel.resources import (MAX_CPU_WORKERS, USER_AGENT_LIST, JobField, + Locale) # from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue @@ -208,6 +209,7 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: job_init_kwargs[field] = self.get(field, job_soup) else: if not job: + # Build initial job object + populate all the job job = Job(**{ k.name.lower(): v for k, v in job_init_kwargs.items() @@ -260,7 +262,10 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: @abstractmethod def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField - NOTE: use this to set Job attribs that rely on Job existing already + + NOTE: (remember) do not return anything in here! it sets job attribs + + Use this to set Job attribs that rely on Job existing already with the required minimum fields (i.e. you can set description by getting the job's detail page with job.url) """ diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 76592239..dc4d3964 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -27,6 +27,7 @@ class BaseIndeedScraper(BaseScraper): """Scrapes jobs from www.indeed.X """ + def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger) -> None: """Init that contains indeed specific stuff @@ -70,7 +71,7 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: # Init list of job soups job_soup_list = [] # type: List[Any] - # Init threads & futures list + # Init threads & futures list FIXME: use existing ThreadPoolExecutor threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) futures_list = [] # FIXME: type? @@ -129,10 +130,10 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField """ if parameter == JobField.DESCRIPTION: - job_link_soup = BeautifulSoup( + detailed_job_soup = BeautifulSoup( self.session.get(job.url).text, self.config.bs4_parser ) - job.description = job_link_soup.find( + job.description = detailed_job_soup.find( id='jobDescriptionText' ).text.strip() elif parameter == JobField.URL: @@ -145,22 +146,21 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: def _get_search_url(self, method: Optional[str] = 'get') -> str: """Get the indeed search url from SearchTerms + TODO: use Enum for method instead of str. """ if method == 'get': - # form job search url - search = ( + return ( "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" "limit={5}&filter={6}".format( self.config.search_config.domain, self.query, - self.config.search_config.city.replace(' ', '+'), - self.config.search_config.state, + self.config.search_config.city.replace(' ', '+',), + self.config.search_config.province_or_state, self._convert_radius(self.config.search_config.radius), self.max_results_per_page, int(self.config.search_config.return_similar_results) ) ) - return search elif method == 'post': # TODO: implement post style for indeed.X raise NotImplementedError() @@ -202,14 +202,14 @@ def _get_job_soups_from_search_page(self, search: str, page: str, ) def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: - """Calculates the number of pages to be scraped. + """Calculates the number of pages of job listings to be scraped. + + i.e. your search yields 230 results at 50 res/page -> 5 pages of jobs + Args: - soup_base: search URL for the job search we are making max_pages: the maximum number of pages to be scraped. Returns: The number of pages to be scraped. - If the number of pages that soup_base yields is higher than max, - then max is returned. """ # Get the html data, initialize bs4 with lxml request_html = self.session.get(search_url) diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 265c4d2c..5e0bdcb1 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -1,234 +1,246 @@ """Scrapers for www.monster.X """ - -class Monster(JobFunnel): - - def __init__(self, args): - # FIXME: impl. +from abc import abstractmethod +from concurrent.futures import ThreadPoolExecutor, wait +from datetime import date, datetime, timedelta +import logging +from math import ceil +from time import sleep, time +from typing import Dict, List, Tuple, Optional, Any +import re +from requests import Session + +from bs4 import BeautifulSoup + +from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.scrapers import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper) + + +MAGIC_GLASSDOOR_SEARCH_STRING = 'skr_navigation_nhpso_searchMain' +MAX_RESULTS_PER_MONSTER_PAGE = 25 +ID_REGEX = re.compile( + r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]' + r'{12})|\d+)' +) + + +class BaseMonsterScraper(BaseScraper): + """Scraper for www.monster.X + """ + + def __init__(self, session: Session, config: 'JobFunnelConfig', + logger: logging.Logger) -> None: + """Init that contains monster specific stuff + """ + super().__init__(session, config, logger) + self.query = '-'.join( + self.config.search_config.keywords + ).replace(' ', '-') + + # TODO: implement TAGS + + @property + def min_required_job_fields(self) -> str: + """If we dont get() or set() any of these fields, we will raise an + exception instead of continuing without that information. + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.URL + ] + + @property + def job_get_fields(self) -> str: + """Call self.get(...) for the JobFields in this list when scraping a Job + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.POST_DATE, JobField.URL, + ] + + @property + def job_set_fields(self) -> str: + """Call self.set(...) for the JobFields in this list when scraping a Job + """ + return [JobField.KEY_ID, JobField.DESCRIPTION] + + @property + def headers(self) -> Dict[str, str]: + """Session header for monster.X + """ + return { + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + 'referer': + f'https://www.monster.{self.config.search_config.domain}/', + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } + + def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: + """Get a single job attribute from a soup object by JobField + """ + if parameter == JobField.TITLE: + return soup.find('h2', attrs={'class': 'title'}).text.strip() + elif parameter == JobField.COMPANY: + return soup.find('div', attrs={'class': 'company'}).text.strip() + elif parameter == JobField.LOCATION: + return soup.find('div', attrs={'class': 'location'}).text.strip() + elif parameter == JobField.POST_DATE: + return calc_post_date_from_relative_str( + soup.find('time').text.strip() + ) + elif parameter == JobField.URL: + return str( + soup.find('a', attrs={'data-bypass': 'true'}).get('href') + ) + else: + raise NotImplementedError(f"Cannot get {parameter.name}") + + def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: + """Set a single job attribute from a soup object by JobField + """ + if parameter == JobField.KEY_ID: + job.key_id = ID_REGEX.findall(job.url)[0] + elif parameter == JobField.DESCRIPTION: + detailed_job_soup = BeautifulSoup( + self.session.get(job.url).text, self.config.bs4_parser + ) + job.description = detailed_job_soup.find( + id='JobDescription' + ).text.strip() + else: + raise NotImplementedError(f"Cannot set {parameter.name}") + + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: + """Scrapes raw data from a job source into a list of job-soups + + Returns: + List[BeautifulSoup]: list of jobs soups we can use to make Job init + """ + # Get the search url + search_url = self._get_search_url() + + # Parse total results, and calculate the # of pages needed + pages = self._get_num_search_result_pages(search_url) + self.logger.info( + f"Found {pages} pages of search results for query={self.query}" + ) + + # Return list of soups from the listings (short) + return self._get_job_soups_from_search_page(search_url, pages) + + def _get_job_soups_from_search_page(self, search_url: str, + pages: int) -> List[BeautifulSoup]: + """Scrapes the monster page for a list of job soups + """ + page_url = f'{search_url}&start={pages}' + return BeautifulSoup( + self.session.get(page_url).text, self.config.bs4_parser + ). find_all('div', attrs={'class': 'flex-row'}) + + def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: + """Calculates the number of pages of job listings to be scraped. + + i.e. your search yields 230 results at 50 res/page -> 5 pages of jobs + + Args: + max_pages: the maximum number of pages to be scraped. + Returns: + The number of pages of job listings to be scraped. + """ + # scrape total number of results, and calculate the # pages needed + request_html = self.session.get(search_url) + soup_base = BeautifulSoup(request_html.text, self.config.bs4_parser) + num_res = soup_base.find('h2', 'figure').text.strip() + num_res = int(re.findall(r'(\d+)', num_res)[0]) + return int(ceil(num_res / MAX_RESULTS_PER_MONSTER_PAGE)) + + def _get_search_url(self, method: Optional[str] = 'get') -> str: + """Get the monster search url from SearchTerms + TODO: use Enum for method instead of str. + TODO: implement POST + """ + if method == 'get': + return ( + 'https://www.monster.{0}/jobs/search/?q={1}&where={2}__2C-{3}' + '&intcid={4}&rad={5}&where={2}__2c-{3}'.format( + self.config.search_config.domain, + self.query, + self.config.search_config.city.replace(' ', '-'), + self.config.search_config.province_or_state, + MAGIC_GLASSDOOR_SEARCH_STRING, + self._convert_radius(self.config.search_config.radius) + ) + ) + elif method == 'post': + raise NotImplementedError() + else: + raise ValueError(f'No html method {method} exists') + + @abstractmethod + def _convert_radius(self, radius: int) -> int: + """NOTE: radius conversion is units/locale specific + """ pass - # super().__init__(args) - # self.provider = 'monster' - # self.max_results_per_page = 25 - # self.headers = { - # 'accept': 'text/html,application/xhtml+xml,application/xml;' - # 'q=0.9,image/webp,*/*;q=0.8', - # 'accept-encoding': 'gzip, deflate, sdch, br', - # 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - # 'referer': 'https://www.monster.{0}/'.format( - # self.search_terms['region']['domain']), - # 'upgrade-insecure-requests': '1', - # 'user-agent': self.user_agent, - # 'Cache-Control': 'no-cache', - # 'Connection': 'keep-alive' - # } - # # Sets headers as default on Session object - # self.s.headers.update(self.headers) - # # Concatenates keywords with '-' and encodes spaces as '-' - # self.query = '-'.join(self.search_terms['keywords']).replace(' ', '-') - - # def convert_radius(self, radius): - # """function that quantizes the user input radius to a valid radius - # in either kilometers or miles""" - # if self.search_terms['region']['domain'] == 'com': - # if radius < 5: - # radius = 0 - # elif 5 <= radius < 10: - # radius = 5 - # elif 10 <= radius < 20: - # radius = 10 - # elif 20 <= radius < 30: - # radius = 20 - # elif 30 <= radius < 40: - # radius = 30 - # elif 40 <= radius < 50: - # radius = 40 - # elif 50 <= radius < 60: - # radius = 50 - # elif 60 <= radius < 75: - # radius = 60 - # elif 75 <= radius < 100: - # radius = 75 - # elif 100 <= radius < 150: - # radius = 100 - # elif 150 <= radius < 200: - # radius = 150 - # elif radius >= 200: - # radius = 200 - # else: - # if radius < 5: - # radius = 0 - # elif 5 <= radius < 10: - # radius = 5 - # elif 10 <= radius < 20: - # radius = 10 - # elif 20 <= radius < 50: - # radius = 20 - # elif 50 <= radius < 100: - # radius = 50 - # elif radius >= 100: - # radius = 100 - - # return radius - - # def get_search_url(self, method='get'): - # """gets the monster request html""" - # # form job search url - # if method == 'get': - # search = ('https://www.monster.{0}/jobs/search/?' - # 'q={1}&where={2}__2C-{3}&intcid={4}&rad={5}&where={2}__2c-{3}'.format( - # self.search_terms['region']['domain'], - # self.query, - # self.search_terms['region']['city'].replace(' ', "-"), - # self.search_terms['region']['province'], - # 'skr_navigation_nhpso_searchMain', - # self.convert_radius(self.search_terms['region']['radius']))) - - # return search - # elif method == 'post': - # # @TODO implement post style for monster - # raise NotImplementedError() - # else: - # raise ValueError(f'No html method {method} exists') - - # def search_joblink_for_blurb(self, job): - # """function that scrapes the monster job link for the blurb""" - # search = job['link'] - # log_info(f'getting monster search: {search}') - - # job_link_soup = BeautifulSoup( - # self.s.get(search).text, self.bs4_parser) - - # try: - # job['blurb'] = job_link_soup.find( - # id='JobDescription').text.strip() - # except AttributeError: - # job['blurb'] = '' - - # filter_non_printables(job) - - # # split apart above function into two so gotten blurbs can be parsed - # # while others blurbs are being obtained - # def get_blurb_with_delay(self, job, delay): - # """gets blurb from monster job link and sets delays for requests""" - # sleep(delay) - - # search = job['link'] - # log_info(f'delay of {delay:.2f}s, getting monster search: {search}') - - # res = self.s.get(search).text - # return job, res - - # def parse_blurb(self, job, html): - # """parses and stores job description into dict entry""" - # job_link_soup = BeautifulSoup(html, self.bs4_parser) - - # try: - # job['blurb'] = job_link_soup.find( - # id='JobDescription').text.strip() - # except AttributeError: - # job['blurb'] = '' - - # filter_non_printables(job) - - # def scrape(self): - # """function that scrapes job posting from monster and pickles it""" - # log_info(f'jobfunnel monster to pickle running @ {self.date_string}') - - # # get the search url - # search = self.get_search_url() - - # # get the html data, initialize bs4 with lxml - # request_html = self.s.get(search) - - # # create the soup base - # soup_base = BeautifulSoup(request_html.text, self.bs4_parser) - - # # scrape total number of results, and calculate the # pages needed - # num_res = soup_base.find('h2', 'figure').text.strip() - # num_res = int(re.findall(r'(\d+)', num_res)[0]) - # log_info(f'Found {num_res} monster results for query=' - # f'{self.query}') - - # pages = int(ceil(num_res / self.max_results_per_page)) - # # scrape soups for all the pages containing jobs it found - # page_url = f'{search}&start={pages}' - # log_info(f'getting monster pages 1 to {pages} : {page_url}') - - # jobs = BeautifulSoup( - # self.s.get(page_url).text, self.bs4_parser). \ - # find_all('div', attrs={'class': 'flex-row'}) - - # job_soup_list = [] - # job_soup_list.extend(jobs) - - # # id regex quantifiers - # id_regex = re.compile(r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f' - # r']{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})|\d+)') - - # # make a dict of job postings from the listing briefs - # for s in job_soup_list: - # # init dict to store scraped data - # job = dict([(k, '') for k in MASTERLIST_HEADER]) - - # # scrape the post data - # job['status'] = 'new' - # try: - # # jobs should at minimum have a title, company and location - # job['title'] = s.find('h2', attrs={ - # 'class': 'title'}).text.strip() - # job['company'] = s.find( - # 'div', attrs={'class': 'company'}).text.strip() - # job['location'] = s.find('div', attrs={ - # 'class': 'location'}).text.strip() - # except AttributeError: - # continue - - # # no blurb is available in monster job soups - # job['blurb'] = '' - # # tags are not supported in monster - # job['tags'] = '' - # try: - # job['date'] = s.find('time').text.strip() - # except AttributeError: - # job['date'] = '' - # # captures uuid or int ids, by extracting from url instead - # try: - # job['link'] = str(s.find('a', attrs={ - # 'data-bypass': 'true'}).get('href')) - # job['id'] = id_regex.findall(job['link'])[0] - # except AttributeError: - # job['id'] = '' - # job['link'] = '' - - # job['query'] = self.query - # job['provider'] = self.provider - - # # key by id - # self.scrape_data[str(job['id'])] = job - - # # Do not change the order of the next three statements if you want date_filter to work - - # # stores references to jobs in list to be used in blurb retrieval - # scrape_list = [i for i in self.scrape_data.values()] - # # converts job date formats into a standard date format - # post_date_from_relative_post_age(scrape_list) - # # apply job pre-filter before scraping blurbs - # super().pre_filter(self.scrape_data, self.provider) - - # threads = ThreadPoolExecutor(max_workers=8) - # # checks if delay is set or not, then extracts blurbs from job links - # if self.delay_config is not None: - # # calls super class to run delay specific threading logic - # super().delay_threader(scrape_list, self.get_blurb_with_delay, - # self.parse_blurb, threads) - # else: - # # start time recording - # start = time() - - # # maps jobs to threads and cleans them up when done - # threads.map(self.search_joblink_for_blurb, scrape_list) - # threads.shutdown() - - # # end and print recorded time - # end = time() - # print(f'{self.provider} scrape job took {(end - start):.3f}s') + +class MonsterScraperCAEng(BaseMonsterScraper, BaseCANEngScraper): + """Scrapes jobs from www.monster.ca + """ + def _convert_radius(self, radius: int) -> int: + """convert radius in miles TODO replace with numpy + """ + if radius < 5: + radius = 0 + elif 5 <= radius < 10: + radius = 5 + elif 10 <= radius < 20: + radius = 10 + elif 20 <= radius < 50: + radius = 20 + elif 50 <= radius < 100: + radius = 50 + elif radius >= 100: + radius = 100 + return radius + + +class MonsterScraperUSAEng(BaseMonsterScraper, BaseUSAEngScraper): + """Scrapes jobs from www.monster.com + """ + + def _convert_radius(self, radius: int) -> int: + """convert radius in miles TODO replace with numpy + """ + if radius < 5: + radius = 0 + elif 5 <= radius < 10: + radius = 5 + elif 10 <= radius < 20: + radius = 10 + elif 20 <= radius < 30: + radius = 20 + elif 30 <= radius < 40: + radius = 30 + elif 40 <= radius < 50: + radius = 40 + elif 50 <= radius < 60: + radius = 50 + elif 60 <= radius < 75: + radius = 60 + elif 75 <= radius < 100: + radius = 75 + elif 100 <= radius < 150: + radius = 100 + elif 150 <= radius < 200: + radius = 150 + elif radius >= 200: + radius = 200 + return radius diff --git a/jobfunnel/backend/scrapers/registry.py b/jobfunnel/backend/scrapers/registry.py index b53ee704..09d600ea 100644 --- a/jobfunnel/backend/scrapers/registry.py +++ b/jobfunnel/backend/scrapers/registry.py @@ -1,8 +1,12 @@ """Lookup tables where we can map scrapers to locales, etc + +NOTE: if you implement a scraper you must add it here or JobFunnel cannot +find it. +TODO: way to make this unnecessary? maybe import & map based on name? """ from jobfunnel.backend.scrapers import ( BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng, GlassDoorStaticCAEng, - GlassDoorStaticUSAEng, + GlassDoorStaticUSAEng, MonsterScraperCAEng, MonsterScraperUSAEng, ) from jobfunnel.resources import Locale, Provider @@ -15,12 +19,12 @@ Locale.CANADA_ENGLISH: IndeedScraperCAEng, Locale.USA_ENGLISH: IndeedScraperUSAEng, }, - Provider.GLASSDOOR: { - Locale.CANADA_ENGLISH: GlassDoorStaticCAEng, - Locale.CANADA_ENGLISH: GlassDoorStaticUSAEng, + # Provider.GLASSDOOR: { # FIXME + # Locale.CANADA_ENGLISH: GlassDoorStaticCAEng, + # Locale.CANADA_ENGLISH: GlassDoorStaticUSAEng, + # }, + Provider.MONSTER: { + Locale.CANADA_ENGLISH: MonsterScraperCAEng, + Locale.USA_ENGLISH: MonsterScraperUSAEng, }, - # 'monster': MonsterScraperCAEng, FIXME - #'MONSTER_CANADA_ENG': MonsterScraperCAEng, -} # type: - - +} diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index a2532f42..8aa65388 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -44,8 +44,7 @@ def __init__(self, domain (Optional[str], optional): domain string to use for search querying. If not passed, will set based on locale. (i.e. 'ca') """ - self.province = province_or_state - self.state = province_or_state + self.province_or_state = province_or_state self.city = city.lower() self.radius = distance_radius or DEFAULT_SEARCH_RADIUS_KM self.locale = locale diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 8fd19d2d..3609f6a6 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -53,7 +53,8 @@ DEFAULT_DELAY_MAX_DURATION = 5.0 DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR -DEFAULT_PROVIDERS = [Provider.INDEED] # Provider.MONSTER, Provider.GLASSDOOR] FIXME +# NOTE: we do indeed first b/c it has most information, monster is missing keys +DEFAULT_PROVIDERS = [Provider.INDEED, Provider.MONSTER, ] #, Provider.GLASSDOOR] FIXME DEFAULT_NO_SCRAPE = False DEFAULT_RECOVER = False DEFAULT_RETURN_SIMILAR_RESULTS = False From af4d55dfef8135c1d2633884323946c37038418a Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Wed, 12 Aug 2020 21:43:25 -0400 Subject: [PATCH 18/66] Got GlassDoorStatic going again. --- LICENSE | 2 +- MANIFEST.in | 4 +- jobfunnel/backend/jobfunnel.py | 2 +- jobfunnel/backend/scrapers/__init__.py | 13 - jobfunnel/backend/scrapers/base.py | 1 + jobfunnel/backend/scrapers/glassdoor/base.py | 141 +++--- .../backend/scrapers/glassdoor/driven.py | 46 ++ .../backend/scrapers/glassdoor/dynamic.py | 39 -- .../backend/scrapers/glassdoor/static.py | 437 +++++++----------- jobfunnel/backend/scrapers/indeed.py | 9 +- jobfunnel/backend/scrapers/monster.py | 12 +- jobfunnel/backend/scrapers/registry.py | 40 +- jobfunnel/config/cli.py | 11 + jobfunnel/config/funnel.py | 32 +- jobfunnel/config/settings.py | 5 +- jobfunnel/resources/defaults.py | 4 +- 16 files changed, 397 insertions(+), 401 deletions(-) create mode 100644 jobfunnel/backend/scrapers/glassdoor/driven.py delete mode 100644 jobfunnel/backend/scrapers/glassdoor/dynamic.py diff --git a/LICENSE b/LICENSE index d8d1a92c..f6ff2f97 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Paul McInnis +Copyright (c) 2020 Paul McInnis Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index 32ef57fe..e4bbfb26 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include jobfunnel/config/settings.yaml -include jobfunnel/text/user_agent_list.txt +include jobfunnel/demo/settings.yaml +include jobfunnel/resources/user_agent_list.txt diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 8ba078fe..cbd2d2b1 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -21,7 +21,7 @@ MAX_CPU_WORKERS, JobStatus, Locale) -class JobFunnel(object): +class JobFunnel: """Class that initializes a Scraper and scrapes a website to get jobs """ diff --git a/jobfunnel/backend/scrapers/__init__.py b/jobfunnel/backend/scrapers/__init__.py index 3335c07a..e69de29b 100644 --- a/jobfunnel/backend/scrapers/__init__.py +++ b/jobfunnel/backend/scrapers/__init__.py @@ -1,13 +0,0 @@ -from jobfunnel.backend.scrapers.base import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper, -) -from jobfunnel.backend.scrapers.indeed import ( - IndeedScraperCAEng, IndeedScraperUSAEng, -) -from jobfunnel.backend.scrapers.glassdoor.static import ( - GlassDoorStaticCAEng, GlassDoorStaticUSAEng, -) -from jobfunnel.backend.scrapers.monster import ( - MonsterScraperCAEng, MonsterScraperUSAEng, -) -from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 8c6da3b4..1e1e91e4 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -264,6 +264,7 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField NOTE: (remember) do not return anything in here! it sets job attribs + FIXME: have this automatically set the attribute by JobField. Use this to set Job attribs that rely on Job existing already with the required minimum fields (i.e. you can set description by diff --git a/jobfunnel/backend/scrapers/glassdoor/base.py b/jobfunnel/backend/scrapers/glassdoor/base.py index c76a97d1..05390afb 100644 --- a/jobfunnel/backend/scrapers/glassdoor/base.py +++ b/jobfunnel/backend/scrapers/glassdoor/base.py @@ -1,13 +1,17 @@ -"""Base Glassdoor Scraper used by both the selenium and statis scrapers +"""Base Glassdoor Scraper used by both the selenium (driven) and static scrapers """ +from abc import abstractmethod +from bs4 import BeautifulSoup import logging from requests import Session from typing import Dict, List, Tuple, Optional, Union -from jobfunnel.backend.scrapers import BaseScraper +from jobfunnel.backend.scrapers.base import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper +) -MAX_LOCATIONS_TO_RETURN = 10 +MAX_GLASSDOOR_LOCATIONS_TO_RETURN = 10 LOCATION_BASE_URL = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' MAX_RESULTS_PER_GLASSDOOR_PAGE = 30 GLASSDOOR_RADIUS_MAP = { @@ -20,7 +24,7 @@ 200: 124, } -class GlassDoorBase(BaseScraper): +class BaseGlassDoorScraper(BaseScraper): def __init__(self, session: Session, config: 'JobFunnelConfig', logger: logging.Logger): @@ -28,7 +32,7 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', """ super().__init__(session, config, logger) self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE - self.query_string = '-'.join(self.config.search_terms.keywords) + self.query = '-'.join(self.config.search_config.keywords) @property def headers(self) -> Dict[str, str]: @@ -37,13 +41,25 @@ def headers(self) -> Dict[str, str]: 'q=0.9,image/webp,*/*;q=0.8', 'accept-encoding': 'gzip, deflate, sdch, br', 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': f'https://www.glassdoor.{self.domain}/', + 'referer': + f'https://www.glassdoor.{self.config.search_config.domain}/', 'upgrade-insecure-requests': '1', 'user-agent': self.user_agent, 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', } + + @abstractmethod + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: + """Scrapes raw data from a job source into a list of job-soups + + Returns: + List[BeautifulSoup]: list of jobs soups we can use to make Job init + """ + pass + + def get_search_url(self, method='get') -> Union[str, Tuple[str, Dict[str,str]]]: """Gets the glassdoor search url @@ -51,14 +67,14 @@ def get_search_url(self, """ # Form the location lookup request data data = { - 'term': self.config.search_terms.city, - 'maxLocationsToReturn': MAX_LOCATIONS_TO_RETURN + 'term': self.config.search_config.city, + 'maxLocationsToReturn': MAX_GLASSDOOR_LOCATIONS_TO_RETURN, } # Get the location id for search location - location_response = self.session.post( + location_id = self.session.post( LOCATION_BASE_URL, headers=self.headers, data=data - ).json() + ).json()[0]['locationId'] if method == 'get': @@ -66,10 +82,10 @@ def get_search_url(self, search = ( 'https://www.glassdoor.{}/Job/jobs.htm?clickSource=searchBtn' '&sc.keyword={}&locT=C&locId={}&jobType=&radius={}'.format( - self.domain, - self.query_string, - location_response[0]['locationId'], - self.quantize_radius(self.config.search_terms.radius), + self.config.search_config.domain, + self.query, + location_id, + self.quantize_radius(self.config.search_config.radius), ) ) return search @@ -77,20 +93,20 @@ def get_search_url(self, elif method == 'post': # Form the job search url - search = "https://www.glassdoor.{}/Job/jobs.htm".format( - self.domain + search = ( + f"https://www.glassdoor.{self.config.search_config.domain}" + "/Job/jobs.htm" ) # Form the job search data data = { 'clickSource': 'searchBtn', - 'sc.keyword': self.query_string, + 'sc.keyword': self.query, 'locT': 'C', - 'locId': location_response[0]['locationId'], + 'locId': location_id, 'jobType': '', - 'radius': self.quantize_radius( - self.config.search_terms.radius - ), + 'radius': + self.quantize_radius(self.config.search_config.radius), } return search, data @@ -98,42 +114,53 @@ def get_search_url(self, raise ValueError(f'No html method {method} exists') - + @abstractmethod def quantize_radius(self, radius: int) -> int: - """function that quantizes the user input radius to a valid radius - value: 10, 20, 30, 50, 100, and 200 kilometers + """Get the glassdoor-quantized radius FIXME: use numpy.digitize instead """ - if self.locale == Locale.USA_ENGLISH: - if radius < 5: - radius = 0 - elif 5 <= radius < 10: - radius = 5 - elif 10 <= radius < 15: - radius = 10 - elif 15 <= radius < 25: - radius = 15 - elif 25 <= radius < 50: - radius = 25 - elif 50 <= radius < 100: - radius = 50 - elif radius >= 100: - radius = 100 - return radius - else: - if radius < 10: - radius = 0 - elif 10 <= radius < 20: - radius = 10 - elif 20 <= radius < 30: - radius = 20 - elif 30 <= radius < 50: - radius = 30 - elif 50 <= radius < 100: - radius = 50 - elif 100 <= radius < 200: - radius = 100 - elif radius >= 200: - radius = 200 - - return GLASSDOOR_RADIUS_MAP[radius] + pass + + +class BaseGlassDoorScraperCANEng(BaseGlassDoorScraper, BaseCANEngScraper): + + def quantize_radius(self, radius: int) -> int: + """Get a Canadian raduius (km) + """ + if radius < 10: + radius = 0 + elif 10 <= radius < 20: + radius = 10 + elif 20 <= radius < 30: + radius = 20 + elif 30 <= radius < 50: + radius = 30 + elif 50 <= radius < 100: + radius = 50 + elif 100 <= radius < 200: + radius = 100 + elif radius >= 200: + radius = 200 + return GLASSDOOR_RADIUS_MAP[radius] + + +class BaseGlassDoorScraperUSAEng(BaseGlassDoorScraper, BaseUSAEngScraper): + + def quantize_radius(self, radius: int) -> int: + """Get a USA raduius (miles) + """ + if radius < 5: + radius = 0 + elif 5 <= radius < 10: + radius = 5 + elif 10 <= radius < 15: + radius = 10 + elif 15 <= radius < 25: + radius = 15 + elif 25 <= radius < 50: + radius = 25 + elif 50 <= radius < 100: + radius = 50 + elif radius >= 100: + radius = 100 + return GLASSDOOR_RADIUS_MAP[radius] diff --git a/jobfunnel/backend/scrapers/glassdoor/driven.py b/jobfunnel/backend/scrapers/glassdoor/driven.py new file mode 100644 index 00000000..9217843e --- /dev/null +++ b/jobfunnel/backend/scrapers/glassdoor/driven.py @@ -0,0 +1,46 @@ +"""Base class for scraping jobs from GlassDoor +""" +from abc import abstractmethod +from concurrent.futures import ThreadPoolExecutor, wait +from datetime import date, datetime, timedelta +import logging +from math import ceil +from time import sleep, time +from typing import Dict, List, Tuple, Optional, Any +import re +from requests import Session + +from bs4 import BeautifulSoup + +from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.scrapers.base import ( + BaseCANEngScraper, BaseUSAEngScraper +) +from jobfunnel.backend.scrapers.glassdoor.base import BaseGlassDoorScraper + + +# FIXME: maybe we can just move this to a dev branch? +class DrivenGlassDoorScraper(BaseGlassDoorScraper): + """The Dynamic Version of the GlassDoor scraper, that uses selenium to scrape job postings. + """ + + def __init__(self, session: Session, config: 'JobFunnelConfig', + logger: logging.Logger): + """Init""" + super().__init__(session, config, logger) + #self.driver = get_webdriver() + + + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: + pass + # search_url, data = self.get_search_url() + # self.driver.get(search_url) + + +class DrivenGlassDoorScraperCANEng(DrivenGlassDoorScraper, BaseCANEngScraper): + pass + +class DrivenGlassDoorScraperUSAEng(DrivenGlassDoorScraper, BaseUSAEngScraper): + pass \ No newline at end of file diff --git a/jobfunnel/backend/scrapers/glassdoor/dynamic.py b/jobfunnel/backend/scrapers/glassdoor/dynamic.py deleted file mode 100644 index 9f25c5db..00000000 --- a/jobfunnel/backend/scrapers/glassdoor/dynamic.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Base class for scraping jobs from GlassDoor -""" -from abc import ABC, abstractmethod -from concurrent.futures import ThreadPoolExecutor, wait -import logging -from requests import post, Session -from typing import Dict, List, Tuple, Optional - -from jobfunnel.backend import Job -from jobfunnel.backend.tools import get_webdriver -from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase -from jobfunnel.resources import Locale - - -# FIXME: maybe we can just move this to a dev branch? -class GlassDoorDynamic(GlassDoorBase): - """The Dynamic Version of the GlassDoor scraper, that uses selenium to scrape job postings. - """ - - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger): - """Init""" - super().__init__(session, config, logger) - self.driver = get_webdriver() - - def scrape(self): - # FIXME: impl! - pass - - -class GlassDoorDynamicCAEng(GlassDoorDynamic): - - @property - def locale(self) -> Locale: - """Get the localizations that this scraper was built for - We will use this to put the right filters & scrapers together - """ - return Locale.CANADA_ENGLISH - diff --git a/jobfunnel/backend/scrapers/glassdoor/static.py b/jobfunnel/backend/scrapers/glassdoor/static.py index 268541b4..aac4af3e 100644 --- a/jobfunnel/backend/scrapers/glassdoor/static.py +++ b/jobfunnel/backend/scrapers/glassdoor/static.py @@ -1,272 +1,191 @@ +"""GlassDoor scraper that has no webdriver """ -""" -from abc import ABC, abstractmethod -from bs4 import BeautifulSoup +from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait +from datetime import date, datetime, timedelta import logging -import math -from requests import post, Session +from math import ceil +from time import sleep, time +from typing import Dict, List, Tuple, Optional, Any import re -from typing import Dict, List, Tuple, Optional -import time - -from jobfunnel.backend import Job -from jobfunnel.backend.scrapers import BaseCANEngScraper, BaseUSAEngScraper -from jobfunnel.backend.scrapers.glassdoor.base import GlassDoorBase -from jobfunnel.resources import Locale - - -class GlassDoorStatic(GlassDoorBase): - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger): - pass -# """Init -# """ -# super().__init__(session, config, logger) -# # Concatenates keywords with '-' -# self.query_string = ' '.join(self.config.search_terms.keywords) - -# def search_page_for_job_soups(self, page: str, url: str, -# job_soup_list: List[BeautifulSoup]) -> None: -# """Scrapes the glassdoor page for a list of job soups -# TODO: document -# """ -# self.logger.info(f'Getting glassdoor page {page} : {url}') -# job = BeautifulSoup( -# self.session.get(url).text, self.bs4_parser -# ).find_all('li', attrs={'class', 'jl'}) -# job_soup_list.extend(job) - -# def set_description(self, job: Job) -> None: -# """Scrapes the glassdoor job link for the description -# TODO: document -# """ -# self.logger.info(f'Getting glassdoor search: {job.url}') -# job_link_soup = BeautifulSoup( -# self.session.get(job.url).text, self.bs4_parser -# ) -# try: -# job.description = job_link_soup.find( -# id='JobDescriptionContainer' -# ).text.strip() -# job.clean_strings() -# except AttributeError: -# self.logger.error(f"Unable to scrape description for: {job.url}") -# job.description = '' - -# def get_description_with_delay(self, job: Job, -# delay: float) -> Tuple[Job, str]: -# """Gets description from glassdoor job link with a request delay -# NOTE: this is per-job -# """ -# time.sleep(delay) -# self.logger.info( -# f'Delay of {delay:.2f}s, getting glassdoor search: {job.url}' -# ) -# return job, self.session.get(job.url).text - -# def get_num_pages(self, soup_base: BeautifulSoup) -> int: -# """Scrape total number of results, and calculate the # pages needed -# """ -# num_res = soup_base.find( -# 'p', attrs={'class', 'jobsCount'}).text.strip() -# num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) -# self.logger.info( -# f"Found {num_res} glassdoor results for query='{self.query_string}'" -# ) -# return int(math.ceil(num_res / self.max_results_per_page)) - -# def get_page_url(self, soup_base: BeautifulSoup) -> str: -# """Get the next page URL -# """ -# # Gets partial url for next page -# partial_url = soup_base.find( -# 'li', attrs={'class', 'next'} -# ).find('a').get('href') - -# # Uses partial url to construct next page url -# page_url = re.sub( -# r'_IP\d+\.', -# f'_IP{page}.', -# f"https://www.glassdoor.{self.domain}{partial_url}", -# ) - -# def get_job_title(self, soup: BeautifulSoup) -> str: -# """Get the title from page soup -# """ -# return soup.find( -# 'div', attrs={'class', 'jobContainer'} -# ).find( -# 'a', -# attrs={'class', 'jobLink jobInfoItem jobTitle'}, -# recursive=False, -# ).text.strip() - -# def get_job_company(self, soup: BeautifulSoup) -> str: -# """Get the company name from page soup -# """ -# return soup.find( -# 'div', attrs={'class', 'jobInfoItem jobEmpolyerName'} -# ).text.strip() - -# def get_job_location(self, soup: BeautifulSoup) -> str: -# """Get job location from page soup -# """ -# return soup.get('data-job-loc') - -# def get_job_tags(self, soup: BeautifulSoup) -> List[str]: -# """Get tags metadata from page soup -# """ -# labels = soup.find_all('div', attrs={'class', 'jobLabel'}) -# return [l.text.strip() for l in labels if l.text.strip() != 'New'] - -# def scrape(self) -> Dict[str, Job]: -# """Scrapes job posting from glassdoor and pickles it -# """ -# # Get the search url and data -# search, data = self.get_search_url(method='post') - -# # Get the html data -# request_html = self.session.post(search, data=data) - -# # Create the soup from our overall search request -# soup_base = BeautifulSoup(request_html.text, self.bs4_parser) -# num_pages = self.get_num_pages(soup_base) - -# # Init list of job soups, threads and a list to populate -# threads = ThreadPoolExecutor(max_workers=8) -# job_soup_list = [] # type: List[BeautifulSoup] -# fts = [] # FIXME: type? +from requests import Session -# # Search the pages to extract the list of job soups -# for page in range(1, num_pages + 1): -# if page == 1: -# fts.append( -# threads.submit( -# self.search_page_for_job_soups, -# page, -# request_html.url, -# job_soup_list, -# ) -# ) -# else: -# page_url = self.get_page_url(soup_base) -# fts.append( -# threads.submit( -# self.search_page_for_job_soups, -# page, -# page_url, -# job_soup_list, -# ) -# ) -# # Wait for all scrape jobs to finish -# wait(fts) - -# # Get the job data from brief listings -# jobs_dict = {} # type: Dict[str, Job] -# for soup in job_soup_list: - -# status = JobStatus.NEW -# title, company, location, tags = None, None, None, [] -# post_date, key_id, url, short_description = None, None, None, None - -# try: -# # Min. required scraping data -# title = self.get_job_title(soup) -# company = self.get_job_company(soup) -# location = self.get_job_location(soup) -# except AttributeError: -# self.logger.error("Unable to scrape minimum-required job info!") -# continue - -# try: -# tags = self.get_job_tags(soup) -# except AttributeError: -# self.logger.warning(f"Unable to scrape job tags for {key_id}") - -# try: -# job['date'] = ( -# soup.find('div', attrs={'class', 'jobLabels'}) -# .find('span', attrs={'class', 'jobLabel nowrap'}) -# .text.strip() -# ) -# except AttributeError: -# job['date'] = '' - -# try: -# part_url = ( -# soup.find('div', attrs={'class', 'logoWrap'}).find( -# 'a').get('href') -# ) -# job['id'] = soup.get('data-id') -# job['link'] = ( -# f'https://www.glassdoor.' -# f"{self.search_terms['region']['domain']}" -# f'{part_url}' -# ) - -# except (AttributeError, IndexError): -# job['id'] = '' -# job['link'] = '' - -# job['query'] = self.query -# job['provider'] = self.provider - -# # key by id -# self.scrape_data[str(job['id'])] = job - - -# job = Job( -# title=title, -# company=company,v -# location=location, -# description='', # We will populate this later per-job-page -# key_id=key_id, -# url=url, -# locale=self.locale, -# query=self.query, -# status=status, -# provider='indeed', # FIXME: we should inherit this -# short_description=short_description, -# post_date=post_date, -# raw='', # FIXME: we cannot pickle the soup object (s) -# tags=tags, -# ) - -# # Do not change the order of the next three statements if you want date_filter to work - -# # stores references to jobs in list to be used in blurb retrieval -# scrape_list = [i for i in self.scrape_data.values()] -# # converts job date formats into a standard date format -# post_date_from_relative_post_age(scrape_list) -# # apply job pre-filter before scraping blurbs -# super().pre_filter(self.scrape_data, self.provider) - -# # checks if delay is set or not, then extracts blurbs from job links -# if self.delay_config is not None: -# # calls super class to run delay specific threading logic -# super().delay_threader( -# scrape_list, self.get_description_with_delay, self.parse_blurb, threads -# ) - -# else: # maps jobs to threads and cleans them up when done -# # start time recording -# start = time() - -# # maps jobs to threads and cleans them up when done -# threads.map(self.set_description, scrape_list) -# threads.shutdown() - -# # end and print recorded time -# end = time() -# print(f'{self.provider} scrape job took {(end - start):.3f}s') +from bs4 import BeautifulSoup +from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.scrapers.glassdoor.base import ( + BaseGlassDoorScraper, BaseGlassDoorScraperCANEng, + BaseGlassDoorScraperUSAEng, +) + + +class StaticGlassDoorScraper(BaseGlassDoorScraper): + + @property + def min_required_job_fields(self) -> str: + """If we dont get() or set() any of these fields, we will raise an + exception instead of continuing without that information. + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.URL + ] + + @property + def job_get_fields(self) -> str: + """Call self.get(...) for the JobFields in this list when scraping a Job + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.POST_DATE, JobField.URL, JobField.KEY_ID, + ] + + @property + def job_set_fields(self) -> str: + """Call self.set(...) for the JobFields in this list when scraping a Job + """ + return [JobField.DESCRIPTION] + + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: + """Scrapes raw data from a job source into a list of job-soups + + Returns: + List[BeautifulSoup]: list of jobs soups we can use to make Job init + """ + # Get the search url + search_url, data = self.get_search_url(method='post') + + # Get the search page result. + request_html = self.session.post(search_url, data=data) + soup_base = BeautifulSoup(request_html.text, self.config.bs4_parser) + + # Parse total results, and calculate the # of pages needed + n_pages = self._get_num_search_result_pages(soup_base) + self.logger.info( + f"Found {n_pages} pages of search results for query={self.query}" + ) + + # Init list of job soups + job_soup_list = [] # type: List[BeautifulSoup] + + # Init threads & futures list FIXME: use existing ThreadPoolExecutor? + threads = ThreadPoolExecutor(MAX_CPU_WORKERS) + futures_list = [] # FIXME: type? + + # search the pages to extract the list of job soups + # FIXME: something goes wrong here and we end up with a 4x duplicated job soups?? + for page in range(1, n_pages + 1): + if page == 1: + futures_list.append( + threads.submit( + self._search_page_for_job_soups, + request_html.url, + job_soup_list, + ) + ) + else: + # gets partial url for next page + part_url = soup_base.find( + 'li', attrs={'class', 'next'} + ).find('a').get('href') + + # uses partial url to construct next page url + page_url = re.sub( + r'_IP\d+\.', + f'_IP{page}.', + f'https://www.glassdoor.{self.config.search_config.domain}' + f'{part_url}', + ) + + futures_list.append( + threads.submit( + self._search_page_for_job_soups, + page_url, + job_soup_list, + ) + ) + wait(futures_list) # wait for all scrape jobs to finish + return job_soup_list + + def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: + """Get a single job attribute from a soup object by JobField + TODO: impl div class=compactStars value somewhere. + """ + if parameter == JobField.TITLE: + return soup.get('data-normalize-job-title') + elif parameter == JobField.COMPANY: + return soup.find( + 'div', attrs={'class', 'jobInfoItem jobEmpolyerName'} + ).text.strip() + elif parameter == JobField.LOCATION: + return soup.get('data-job-loc') + # FIXME: impl. + # elif parameter == JobField.TAGS: + # labels = soup.find_all('div', attrs={'class', 'jobLabel'}) + # if labels: + # return [ + # l.text.strip() for l in labels if l.text.strip() != 'New' + # ] + # else: + # return [] + elif parameter == JobField.POST_DATE: + return calc_post_date_from_relative_str( + soup.find( + 'div', attrs={ + 'class': 'd-flex align-items-end pl-std css-mi55ob' + } + ).text.strip() + ) + elif parameter == JobField.KEY_ID: + return soup.get('data-id') + elif parameter == JobField.URL: + part_url = soup.find( + 'div', attrs={'class', 'logoWrap'} + ).find('a').get('href') + return ( + f'https://www.glassdoor.{self.config.search_config.domain}' + f'{part_url}' + ) + else: + raise NotImplementedError(f"Cannot get {parameter.name}") + + def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: + """Set a single job attribute from a soup object by JobField + """ + if parameter == JobField.DESCRIPTION: + job_link_soup = BeautifulSoup( + self.session.get(job.url).text, self.config.bs4_parser + ) + job.description = job_link_soup.find( + id='JobDescriptionContainer' + ).text.strip() + else: + raise NotImplementedError(f"Cannot set {parameter.name}") + + def _search_page_for_job_soups(self, url: str, + job_soup_list: List[BeautifulSoup]): + """Get a list of job soups from a glassdoor page + """ + job = BeautifulSoup( + self.session.get(url).text, self.config.bs4_parser + ).find_all('li', attrs={'class', 'jl'}) + job_soup_list.extend(job) + + def _get_num_search_result_pages(self, soup_base: BeautifulSoup) -> int: + # scrape total number of results, and calculate the # pages needed + num_res = soup_base.find('p', attrs={'class', 'jobsCount'}).text.strip() + num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) + return int(ceil(num_res / self.max_results_per_page)) # These are the same exact logic, same website beyond the domain. -class GlassDoorStaticCAEng(GlassDoorStatic, BaseCANEngScraper): +class StaticGlassDoorScraperCANEng(StaticGlassDoorScraper, + BaseGlassDoorScraperCANEng): pass -class GlassDoorStaticUSAEng(GlassDoorStatic, BaseUSAEngScraper): +class StaticGlassDoorScraperUSAEng(StaticGlassDoorScraper, + BaseGlassDoorScraperUSAEng): pass diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index dc4d3964..f82f2c8f 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -1,6 +1,6 @@ """Scraper designed to get jobs from www.indeed.com / www.indeed.ca """ -from abc import ABC, abstractmethod +from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime, timedelta import logging @@ -15,8 +15,9 @@ from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.backend.scrapers import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper) +from jobfunnel.backend.scrapers.base import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper +) #from jobfunnel.config import JobFunnelConfig # causes a circular import @@ -225,7 +226,7 @@ def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: return max_pages -class IndeedScraperCAEng(BaseIndeedScraper, BaseCANEngScraper): +class IndeedScraperCANEng(BaseIndeedScraper, BaseCANEngScraper): """Scrapes jobs from www.indeed.ca """ pass diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 5e0bdcb1..1afb2cb0 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -15,11 +15,11 @@ from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.backend.scrapers import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper) - +from jobfunnel.backend.scrapers.base import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper +) -MAGIC_GLASSDOOR_SEARCH_STRING = 'skr_navigation_nhpso_searchMain' +MAGIC_MONSTER_SEARCH_STRING = 'skr_navigation_nhpso_searchMain' MAX_RESULTS_PER_MONSTER_PAGE = 25 ID_REGEX = re.compile( r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]' @@ -176,7 +176,7 @@ def _get_search_url(self, method: Optional[str] = 'get') -> str: self.query, self.config.search_config.city.replace(' ', '-'), self.config.search_config.province_or_state, - MAGIC_GLASSDOOR_SEARCH_STRING, + MAGIC_MONSTER_SEARCH_STRING, self._convert_radius(self.config.search_config.radius) ) ) @@ -191,7 +191,7 @@ def _convert_radius(self, radius: int) -> int: """ pass -class MonsterScraperCAEng(BaseMonsterScraper, BaseCANEngScraper): +class MonsterScraperCANEng(BaseMonsterScraper, BaseCANEngScraper): """Scrapes jobs from www.monster.ca """ def _convert_radius(self, radius: int) -> int: diff --git a/jobfunnel/backend/scrapers/registry.py b/jobfunnel/backend/scrapers/registry.py index 09d600ea..dbc7c29b 100644 --- a/jobfunnel/backend/scrapers/registry.py +++ b/jobfunnel/backend/scrapers/registry.py @@ -4,27 +4,47 @@ find it. TODO: way to make this unnecessary? maybe import & map based on name? """ -from jobfunnel.backend.scrapers import ( - BaseScraper, IndeedScraperCAEng, IndeedScraperUSAEng, GlassDoorStaticCAEng, - GlassDoorStaticUSAEng, MonsterScraperCAEng, MonsterScraperUSAEng, -) from jobfunnel.resources import Locale, Provider +from jobfunnel.backend.scrapers.indeed import ( + IndeedScraperCANEng, IndeedScraperUSAEng, +) +from jobfunnel.backend.scrapers.monster import ( + MonsterScraperCANEng, MonsterScraperUSAEng, +) +from jobfunnel.backend.scrapers.glassdoor.driven import ( + DrivenGlassDoorScraperUSAEng, DrivenGlassDoorScraperCANEng, +) +from jobfunnel.backend.scrapers.glassdoor.static import ( + StaticGlassDoorScraperCANEng, StaticGlassDoorScraperUSAEng, +) + + # NOTE: if you add a scraper you need to add it here # TODO: there must be a better way to do this by using class attrib of Provider SCRAPER_FROM_LOCALE = { # search terms which one to use Provider.INDEED: { - Locale.CANADA_ENGLISH: IndeedScraperCAEng, + Locale.CANADA_ENGLISH: IndeedScraperCANEng, Locale.USA_ENGLISH: IndeedScraperUSAEng, }, - # Provider.GLASSDOOR: { # FIXME - # Locale.CANADA_ENGLISH: GlassDoorStaticCAEng, - # Locale.CANADA_ENGLISH: GlassDoorStaticUSAEng, - # }, + Provider.GLASSDOOR: { # FIXME + Locale.CANADA_ENGLISH: StaticGlassDoorScraperCANEng, + Locale.USA_ENGLISH: StaticGlassDoorScraperUSAEng, + }, Provider.MONSTER: { - Locale.CANADA_ENGLISH: MonsterScraperCAEng, + Locale.CANADA_ENGLISH: MonsterScraperCANEng, Locale.USA_ENGLISH: MonsterScraperUSAEng, }, } + + +# Any of the web-driven scrapers will be chosen if we set --web-driven +# TODO: have defaults for these instead. +DRIVEN_SCRAPER_FROM_LOCALE = { + Provider.GLASSDOOR: { + Locale.CANADA_ENGLISH: DrivenGlassDoorScraperCANEng, + Locale.USA_ENGLISH: DrivenGlassDoorScraperUSAEng, + }, +} \ No newline at end of file diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 85afb089..2d070ffc 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -106,6 +106,7 @@ def parse_cli(): parser.add_argument( '-p', dest='search_providers', + nargs='+', choices=PROVIDER_NAMES, default=PROVIDER_NAMES, help='List of job-search providers. (i.e. indeed, monster, glassdoor).' @@ -164,6 +165,7 @@ def parse_cli(): parser.add_argument( '--similar-results', + dest='search_similar_results', action='store_true', help='Return \'similar\' results from search query (only for Indeed).' ) @@ -199,6 +201,14 @@ def parse_cli(): help='Do not make any get requests, and attempt to load from cache.' ) + parser.add_argument( + '--web-driver', + dest='use_web_driver', + action='store_true', + help='Use web-driven scraping if available. Currently only available ' + 'for GlassDoor scrapers. WARNING: this is in beta.' + ) + # Proxy stuff # TODO: subparser. parser.add_argument( @@ -369,6 +379,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: search_config=search_cfg, delay_config=delay_cfg, proxy_config=proxy_cfg, + web_driven_scraping=config['use_web_driver'], ) # Validate funnel config as well (checks some stuff Cerberus doesn't rn) diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 9b73c334..9a1e45d5 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -4,10 +4,13 @@ from typing import Optional, List, Dict, Any import os -from jobfunnel.backend.scrapers import BaseScraper, SCRAPER_FROM_LOCALE +# from jobfunnel.backend.scrapers.base import BaseScraper CYCLICAL! from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig from jobfunnel.resources import Locale, Provider, BS4_PARSER +from jobfunnel.backend.scrapers.registry import ( + SCRAPER_FROM_LOCALE, DRIVEN_SCRAPER_FROM_LOCALE +) class JobFunnelConfig(BaseConfig): """Master config containing all the information we need to run jobfunnel @@ -26,7 +29,8 @@ def __init__(self, bs4_parser: Optional[str] = BS4_PARSER, return_similar_results: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, - proxy_config: Optional[ProxyConfig] = None) -> None: + proxy_config: Optional[ProxyConfig] = None, + web_driven_scraping: Optional[bool] = False) -> None: """Init a config that determines how we will scrape jobs from Scrapers and how we will update CSV and filtering lists @@ -57,6 +61,8 @@ def __init__(self, Defaults to a default delay config object. proxy_config (Optional[ProxyConfig], optional): proxy config object. Defaults to None, which will result in no proxy being used + web_driven_scraping (Optional[bool], optional): use web-driven + scraper implementation if available. NOTE: beta feature! """ self.master_csv_file = master_csv_file self.user_block_list_file = user_block_list_file @@ -69,6 +75,7 @@ def __init__(self, self.bs4_parser = bs4_parser # TODO: add to config self.recover_from_cache = recover_from_cache self.return_similar_results = return_similar_results + self.web_driven_scraping = web_driven_scraping if not delay_config: # We will always use a delay config to be respectful self.delay_config = DelayConfig() @@ -87,13 +94,24 @@ def __init__(self, self.validate() @property - def scrapers(self) -> BaseScraper: + def scrapers(self) -> List['BaseScraper']: """All the compatible scrapers for the provider_name """ - return [ - SCRAPER_FROM_LOCALE[p][self.search_config.locale] - for p in self.search_config.providers - ] + scrapers = [] # type: List[BaseScraper] + for pr in self.search_config.providers: + if self.web_driven_scraping and pr in DRIVEN_SCRAPER_FROM_LOCALE: + scrapers.append( + DRIVEN_SCRAPER_FROM_LOCALE[pr][self.search_config.locale] + ) + elif pr in SCRAPER_FROM_LOCALE: + scrapers.append( + SCRAPER_FROM_LOCALE[pr][self.search_config.locale] + ) + else: + raise ValueError( + f"No scraper available for unknown provider {pr}" + ) + return scrapers @property def scraper_names(self) -> str: diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index bc3fb6fc..57c0d500 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -19,11 +19,14 @@ 'log_level': {'required': False, 'allowed': LOG_LEVEL_NAMES}, 'log_file': {'required': False, 'type': 'string'}, 'save_duplicates': {'required': False, 'type': 'boolean'}, + 'use_web_driver': {'required': False, 'type': 'boolean'}, 'search': { 'type': 'dict', 'required': True, 'schema': { - 'providers': {'required': True, 'allowed': [p.name for p in Provider]}, + 'providers': { + 'required': True, 'allowed': [p.name for p in Provider] + }, 'locale' : {'required': True, 'allowed': [l.name for l in Locale]}, 'region': { 'type': 'dict', diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 3609f6a6..86a461d6 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -54,8 +54,9 @@ DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR # NOTE: we do indeed first b/c it has most information, monster is missing keys -DEFAULT_PROVIDERS = [Provider.INDEED, Provider.MONSTER, ] #, Provider.GLASSDOOR] FIXME +DEFAULT_PROVIDERS = [Provider.GLASSDOOR, Provider.INDEED, Provider.MONSTER] #, ] FIXME DEFAULT_NO_SCRAPE = False +DEFAULT_USE_WEB_DRIVER = False DEFAULT_RECOVER = False DEFAULT_RETURN_SIMILAR_RESULTS = False DEFAULT_SAVE_DUPLICATES = False @@ -82,6 +83,7 @@ 'save_duplicates': DEFAULT_SAVE_DUPLICATES, 'log_level': DEFAULT_LOG_LEVEL_NAME, 'log_file': DEFAULT_LOG_FILE, + 'use_web_driver': DEFAULT_USE_WEB_DRIVER, 'search': { 'locale' : DEFAULT_LOCALE.name, 'providers': [p.name for p in DEFAULT_PROVIDERS], From 67f7b2b0fc0e483eef060e6e4f8f0e880c7f546b Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Wed, 19 Aug 2020 21:43:17 -0400 Subject: [PATCH 19/66] added Remote and wage jobfields and added wage scraping to GlassDoor static scraper --- jobfunnel/backend/job.py | 20 ++++- jobfunnel/backend/jobfunnel.py | 2 +- jobfunnel/backend/scrapers/base.py | 10 ++- .../backend/scrapers/glassdoor/static.py | 90 +++++++++++-------- jobfunnel/resources/defaults.py | 2 +- jobfunnel/resources/enums.py | 2 + jobfunnel/resources/resources.py | 2 +- 7 files changed, 83 insertions(+), 45 deletions(-) diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 92597e97..d2d90049 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -1,6 +1,7 @@ """Base Job class to be populated by Scrapers, manipulated by Filters and saved to csv / etc by Exporter """ +from bs4 import BeautifulSoup from datetime import date, datetime import re import string @@ -34,13 +35,16 @@ def __init__(self, scrape_date: Optional[date] = None, short_description: Optional[str] = None, post_date: Optional[date] = None, - raw: Optional[Any] = None, - tags: Optional[List[str]] = None) -> None: + raw: Optional[BeautifulSoup] = None, + wage: Optional[str] = None, + tags: Optional[List[str]] = None, + remote: Optional[str] = None) -> None: """Object to represent a single job that we have scraped TODO integrate init with JobField somehow, ideally with validation. TODO: would be nice to use something standardized for location str TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? + TODO: wage ought to be a number or an object, but is str for flexibility NOTE: ideally key_id is provided, but Monster sets() it, so it now has a default = None and is checked for in validate() @@ -64,10 +68,13 @@ def __init__(self, (one-liner) post_date (Optional[date]): the date the job became available on the job source. Defaults to None. - raw (Optional[Any]): raw scrape data that we can use for + raw (Optional[BeautifulSoup]): raw scrape data that we can use for debugging/pickling, defualts to None. + wage (Optional[str], optional): string describing wage (may be est) tags (Optional[List[str]], optional): additional key-words that are in the job posting that identify the job. Defaults to []. + remote (Optional[str], optional): string describing remote work + allowance/status i.e. ('temporarily remote', 'fully remote' etc) """ # These must be populated by a Scraper self.title = title @@ -80,6 +87,8 @@ def __init__(self, self.query = query self.provider = provider self.status = status + self.wage = wage + self.remote = remote # These may not always be populated in our job source self.post_date = post_date @@ -121,6 +130,8 @@ def as_row(self) -> Dict[str, str]: self.provider, self.query, self.locale.name, + self.wage, + self.remote, ] ) ]) @@ -131,7 +142,8 @@ def clean_strings(self) -> None: ...This way of doing it is janky and might not work right... """ for attr in [self.title, self.company, self.description, self.tags, - self.url, self.key_id, self.provider, self.query]: + self.url, self.key_id, self.provider, self.query, + self.wage]: attr = ''.join( filter(lambda x: x in PRINTABLE_STRINGS, self.title) ) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index cbd2d2b1..4d60c304 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -36,7 +36,6 @@ def __init__(self, config: JobFunnelConfig): self.config.validate() self.logger = None self.__date_string = date.today().strftime("%Y-%m-%d") - self.__threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) self.init_logging() # Open a session with/out a proxy configured @@ -198,6 +197,7 @@ def write_cache(self, jobs_dict: Dict[str, Job], cache_file: str = None) -> None: """Dump a jobs_dict into a pickle TODO: write search_config into the cache file and jobfunnel version + FIXME: some way to cache raw data without recur-limit """ cache_file = cache_file if cache_file else self.daily_cache_file pickle.dump(jobs_dict, open(cache_file, 'wb')) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 1e1e91e4..12f54a77 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -179,7 +179,7 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: Returns: Job: job object constructed from the soup and localization of class """ - # Init kwargs + # Init kwargs which allow for defaults + known information job_init_kwargs = { JobField.STATUS: JobStatus.NEW, JobField.LOCALE: self.locale, @@ -187,8 +187,10 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: JobField.DESCRIPTION: '', JobField.URL: '', JobField.SHORT_DESCRIPTION: '', # TODO: impl. - JobField.RAW: '', # TODO: impl. + JobField.RAW: None, JobField.PROVIDER: self.__class__.__name__, + JobField.REMOTE: '', + JobField.WAGE: '', } # type: Dict[JobField, Any] # Formulate the get/set actions @@ -231,6 +233,10 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: assert job, "Failed to initialize job" # NOTE: should never see this job.validate() + # FIXME: this is to prevent issues with JSON and raw data recur limit + # We could handle this when scraping but this will also save memory. + job._raw_scrape_data = None + return job @abstractmethod diff --git a/jobfunnel/backend/scrapers/glassdoor/static.py b/jobfunnel/backend/scrapers/glassdoor/static.py index aac4af3e..79a5a8ad 100644 --- a/jobfunnel/backend/scrapers/glassdoor/static.py +++ b/jobfunnel/backend/scrapers/glassdoor/static.py @@ -39,7 +39,7 @@ def job_get_fields(self) -> str: """ return [ JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.POST_DATE, JobField.URL, JobField.KEY_ID, + JobField.POST_DATE, JobField.URL, JobField.KEY_ID, JobField.WAGE, ] @property @@ -67,53 +67,33 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: f"Found {n_pages} pages of search results for query={self.query}" ) - # Init list of job soups - job_soup_list = [] # type: List[BeautifulSoup] + # Get the first page of job soups from the search results listings + job_soup_list = self._parse_job_listings_to_bs4(soup_base) # Init threads & futures list FIXME: use existing ThreadPoolExecutor? threads = ThreadPoolExecutor(MAX_CPU_WORKERS) futures_list = [] # FIXME: type? - # search the pages to extract the list of job soups - # FIXME: something goes wrong here and we end up with a 4x duplicated job soups?? - for page in range(1, n_pages + 1): - if page == 1: + #Search the remaining pages to extract the list of job soups + if n_pages > 1: + for page in range(2, n_pages + 1): futures_list.append( threads.submit( self._search_page_for_job_soups, - request_html.url, + self._get_next_page_url(soup_base, page), job_soup_list, ) ) - else: - # gets partial url for next page - part_url = soup_base.find( - 'li', attrs={'class', 'next'} - ).find('a').get('href') - - # uses partial url to construct next page url - page_url = re.sub( - r'_IP\d+\.', - f'_IP{page}.', - f'https://www.glassdoor.{self.config.search_config.domain}' - f'{part_url}', - ) - futures_list.append( - threads.submit( - self._search_page_for_job_soups, - page_url, - job_soup_list, - ) - ) wait(futures_list) # wait for all scrape jobs to finish - return job_soup_list + return job_soup_list[25:30] def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: """Get a single job attribute from a soup object by JobField TODO: impl div class=compactStars value somewhere. """ if parameter == JobField.TITLE: + # TODO: we should instead get what user sees in the return soup.get('data-normalize-job-title') elif parameter == JobField.COMPANY: return soup.find( @@ -138,6 +118,12 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: } ).text.strip() ) + elif parameter == JobField.WAGE: + # NOTE: most jobs don't have this so we wont raise a warning here + # and will fail silently instead + wage = soup.find('span', attrs={'class': 'gray salary'}) + if wage is not None: + return wage.text.strip() elif parameter == JobField.KEY_ID: return soup.get('data-id') elif parameter == JobField.URL: @@ -153,6 +139,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField + NOTE: Description has to get and should be respectfully delayed """ if parameter == JobField.DESCRIPTION: job_link_soup = BeautifulSoup( @@ -161,17 +148,29 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: job.description = job_link_soup.find( id='JobDescriptionContainer' ).text.strip() + job._raw_scrape_data = job_link_soup # This is so we can set wage else: raise NotImplementedError(f"Cannot set {parameter.name}") - def _search_page_for_job_soups(self, url: str, - job_soup_list: List[BeautifulSoup]): - """Get a list of job soups from a glassdoor page + def _search_page_for_job_soups(self, listings_page_url: str, + job_soup_list: List[BeautifulSoup]) -> None: + """Get a list of job soups from a glassdoor page, by loading the page. + NOTE: this makes GET requests and should be respectfully delayed. """ - job = BeautifulSoup( - self.session.get(url).text, self.config.bs4_parser - ).find_all('li', attrs={'class', 'jl'}) - job_soup_list.extend(job) + job_soup_list.extend( + self._parse_job_listings_to_bs4( + BeautifulSoup( + self.session.get(listings_page_url).text, + self.config.bs4_parser, + ) + ) + ) + + def _parse_job_listings_to_bs4(self, page_soup: BeautifulSoup + ) -> List[BeautifulSoup]: + """Parse a page of job listings HTML text into job soups + """ + return page_soup.find_all('li', attrs={'class', 'jl'}) def _get_num_search_result_pages(self, soup_base: BeautifulSoup) -> int: # scrape total number of results, and calculate the # pages needed @@ -179,6 +178,25 @@ def _get_num_search_result_pages(self, soup_base: BeautifulSoup) -> int: num_res = int(re.findall(r'(\d+)', num_res.replace(',', ''))[0]) return int(ceil(num_res / self.max_results_per_page)) + def _get_next_page_url(self, soup_base: BeautifulSoup, + results_page_number: int) -> str: + """Construct the next page of search results from the initial search + results page BeautifulSoup. + """ + part_url = soup_base.find( + 'li', attrs={'class', 'next'} + ).find('a').get('href') + + assert part_url is not None, "Unable to find next page in listing soup!" + + # Uses partial url to construct next page url + return re.sub( + r'_IP\d+\.', + f'_IP{results_page_number}.', + f'https://www.glassdoor.{self.config.search_config.domain}' + f'{part_url}', + ) + # These are the same exact logic, same website beyond the domain. class StaticGlassDoorScraperCANEng(StaticGlassDoorScraper, diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 86a461d6..8c178a05 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -54,7 +54,7 @@ DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR # NOTE: we do indeed first b/c it has most information, monster is missing keys -DEFAULT_PROVIDERS = [Provider.GLASSDOOR, Provider.INDEED, Provider.MONSTER] #, ] FIXME +DEFAULT_PROVIDERS = [Provider.GLASSDOOR] #, Provider.INDEED, Provider.MONSTER] #, ] FIXME DEFAULT_NO_SCRAPE = False DEFAULT_USE_WEB_DRIVER = False DEFAULT_RECOVER = False diff --git a/jobfunnel/resources/enums.py b/jobfunnel/resources/enums.py index 135bbafd..f047fe07 100644 --- a/jobfunnel/resources/enums.py +++ b/jobfunnel/resources/enums.py @@ -51,6 +51,8 @@ class JobField(Enum): POST_DATE = 12 RAW = 13 TAGS = 14 + WAGE = 15 + REMOTE = 16 class Provider(Enum): diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index d0462d11..b0c6c835 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -7,7 +7,7 @@ # TODO: need to add short and long descriptions (breaking change) CSV_HEADER = [ 'status', 'title', 'company', 'location', 'date', 'blurb', 'tags', 'link', - 'id', 'provider', 'query', 'locale' + 'id', 'provider', 'query', 'locale', 'wage', 'remote', ] LOG_LEVEL_NAMES = [ From 6c11b01ad72a353117fbf65ed7955606ec16140e Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 20 Aug 2020 21:12:20 -0400 Subject: [PATCH 20/66] further improve defaults, schema and CLI interaction --- demo/settings.yaml | 51 +++-- jobfunnel/__main__.py | 6 +- jobfunnel/backend/jobfunnel.py | 80 ++++---- jobfunnel/backend/scrapers/base.py | 43 ++-- .../{glassdoor/static.py => glassdoor.py} | 188 ++++++++++++++++-- .../backend/scrapers/glassdoor/__init__.py | 0 jobfunnel/backend/scrapers/glassdoor/base.py | 166 ---------------- .../backend/scrapers/glassdoor/driven.py | 46 ----- jobfunnel/backend/scrapers/indeed.py | 49 +++++ jobfunnel/backend/scrapers/monster.py | 67 +++++-- jobfunnel/backend/scrapers/registry.py | 26 +-- jobfunnel/config/__init__.py | 2 +- jobfunnel/config/cli.py | 178 ++++++++--------- jobfunnel/config/funnel.py | 15 +- jobfunnel/config/search.py | 8 +- jobfunnel/config/settings.py | 103 ++++++---- jobfunnel/resources/defaults.py | 45 +---- 17 files changed, 526 insertions(+), 547 deletions(-) rename jobfunnel/backend/scrapers/{glassdoor/static.py => glassdoor.py} (58%) delete mode 100644 jobfunnel/backend/scrapers/glassdoor/__init__.py delete mode 100644 jobfunnel/backend/scrapers/glassdoor/base.py delete mode 100644 jobfunnel/backend/scrapers/glassdoor/driven.py diff --git a/demo/settings.yaml b/demo/settings.yaml index a449471a..a33227b0 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -1,43 +1,40 @@ -# This is an example settings YAML +# This is an example settings YAML, it closely mirrors our default search # Path where your master CSV, block-lists, and cache data will be stored -output_path: search - -# Locale settings (i.e. USA_ENGLISH, CANADA_ENGLISH, CANADA_FRENCH) -# These are used to define the reference to what code implementation we should -# use for the scraper and the provider -locale: - CANADA_ENGLISH - -# Providers from which to search (i.e. glassdoor, monster) -# NOTE: we will choose domain via locale (i.e. CANADA_ENGLISH --> www.indeed.ca) -providers: - - INDEED -# Also available: -# - GLASSDOOR -# - MONSTER +output_path: demo_search # Job search configuration search: - # This is the region you are searching for jobs in, and the distance in km - # within which to return jobs. - region: - province_or_state: "ON" - city: "waterloo" - radius: 25 # This is in kilometers (km) + # Providers from which to search + # NOTE: we choose domain via locale (i.e. CANADA_ENGLISH -> www.indeed.ca) + providers: + - INDEED + - MONSTER + + # Locale settings (i.e. USA_ENGLISH, CANADA_ENGLISH, CANADA_FRENCH) + # These are used to define the reference to what code implementation we should + # use for the scraper and the provider + locale: CANADA_ENGLISH + + # Region that we are searching for jobs within + province_or_state: "ON" + city: "waterloo" + radius: 25 # km (NOTE: if we were in locale: USA_ENGLISH it's in miles) # These are the terms you would be typing into the website's search field # NOTE: we will search with all the provided keywords and format according # to the input format of job provider (i.e. the GET URLs). keywords: - Python - - Scientist -# Blocked company names -# TODO: refactor --> block_list -company_block_list: - - "Infox Consulting" + # Don't return any listings older than this + max_listing_days: 35 + + # Blocked company names that will never appear in any results + # TODO: refactor --> block_list + company_block_list: + - "Infox Consulting" # Logging level options are: critical, error, warning, info, debug, notset log_level: INFO diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index a3f68fbe..16f50596 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -25,9 +25,13 @@ def main(): """ # Parse CLI into a dict args = parse_cli() + do_recovery_mode = args.do_recovery_mode # NOTE: we modify args for config funnel_cfg = config_builder(args) job_funnel = JobFunnel(funnel_cfg) - job_funnel.run() + if do_recovery_mode: + job_funnel.recover() + else: + job_funnel.run() if __name__ == '__main__': diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 4d60c304..fe76181b 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -65,53 +65,46 @@ def run(self) -> None: # we are getting detailed job information (per-job) self.update_block_list() - if self.config.recover_from_cache: - - # Perform recovery from cache if --recover passed - self.recover() + # Get jobs keyed by their unique ID, use cache if we scraped today + jobs_dict = {} # type: Dict[str, Job] + if os.path.exists(self.daily_cache_file): + jobs_dict = self.load_cache(self.daily_cache_file) + elif self.config.no_scrape: + self.logger.warning( + f"No jobs cached, missing: {self.daily_cache_file}" + ) + if self.config.no_scrape: + self.logger.info("Skipping scraping, running with --no-scrape.") else: - - # Get jobs keyed by their unique ID, use cache if we scraped today - jobs_dict = {} # type: Dict[str, Job] - if os.path.exists(self.daily_cache_file): - jobs_dict = self.load_cache(self.daily_cache_file) - elif self.config.no_scrape: - self.logger.warning( - f"No jobs cached, missing: {self.daily_cache_file}" + jobs_dict = self.scrape() # type: Dict[str, Job] + self.write_cache(jobs_dict) + + # Filter out scraped jobs we have rejected, archived or block-listed + # (before we add them to the CSV) + self.filter(jobs_dict) + + # Load and update existing masterlist + if os.path.exists(self.config.master_csv_file): + + # Identify duplicate jobs using the existing masterlist + masterlist = self.read_master_csv() # type: Dict[str, Job] + self.filter(masterlist) # NOTE: this reduces size of masterlist + try: + tfidf_filter(jobs_dict, masterlist) + except ValueError as err: + self.logger.error( + f"Skipping similarity filter due to: {str(err)}" ) - if self.config.no_scrape: - self.logger.info("Skipping scraping, running with --no-scrape.") - else: - jobs_dict = self.scrape() # type: Dict[str, Job] - self.write_cache(jobs_dict) - - # Filter out scraped jobs we have rejected, archived or block-listed - # (before we add them to the CSV) - self.filter(jobs_dict) - - # Load and update existing masterlist - if os.path.exists(self.config.master_csv_file): - - # Identify duplicate jobs using the existing masterlist - masterlist = self.read_master_csv() # type: Dict[str, Job] - self.filter(masterlist) # NOTE: this reduces size of masterlist - try: - tfidf_filter(jobs_dict, masterlist) - except ValueError as err: - self.logger.error( - f"Skipping similarity filter due to: {str(err)}" - ) - - # Expand the masterlist with filters, non-duplicated jobs & save - masterlist.update(jobs_dict) - self.write_master_csv(masterlist) + # Expand the masterlist with filters, non-duplicated jobs & save + masterlist.update(jobs_dict) + self.write_master_csv(masterlist) - else: - # FIXME: we should still remove duplicates within jobs_dict? - # Dump the results into the data folder as the masterlist - self.write_master_csv(jobs_dict) + else: + # FIXME: we should still remove duplicates within jobs_dict? + # Dump the results into the data folder as the masterlist + self.write_master_csv(jobs_dict) self.logger.info( f"Done. View your current jobs in {self.config.master_csv_file}" @@ -305,9 +298,8 @@ def write_master_csv(self, jobs: Dict[str, Job]) -> None: for job in jobs.values(): job.validate() writer.writerow(job.as_row) - n_jobs = len(jobs) self.logger.info( - f"Wrote {n_jobs} jobs to {self.config.master_csv_file}" + f"Wrote {len(jobs)} jobs to {self.config.master_csv_file}" ) def update_block_list(self): diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 12f54a77..23d2466d 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -44,7 +44,7 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self._validate_get_set() # Init a thread executor (multi-worker) TODO: can't reuse after shutdown - self.executor = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + self.executor = ThreadPoolExecutor(max_workers=1) #MAX_CPU_WORKERS) @property def user_agent(self) -> str: @@ -53,49 +53,41 @@ def user_agent(self) -> str: return random.choice(USER_AGENT_LIST) @property - def min_required_job_fields(self) -> str: + @abstractmethod + def min_required_job_fields(self) -> List[JobField]: """If we dont get() or set() any of these fields, we will raise an exception instead of continuing without that information. NOTE: pointless to check for locale / provider / other defaults - - Override this as needed. """ - return [ - JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.KEY_ID, JobField.URL - ] + pass @property - def job_get_fields(self) -> str: - """Call self.get(...) for the JobFields in this list when scraping a Job - - Override this as needed. + @abstractmethod + def job_get_fields(self) -> List[JobField]: + """Call self.get(...) for the JobFields in this list when scraping a Job. """ - return [ - JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.KEY_ID, JobField.TAGS, JobField.POST_DATE, - ] + pass @property - def job_set_fields(self) -> str: + @abstractmethod + def job_set_fields(self) -> List[JobField]: """Call self.set(...) for the JobFields in this list when scraping a Job NOTE: Since this passes the Job we are updating, the order of this list - matters if set fields rely on each-other. - - Override this as needed. + matters if set fields rely on each-other.ed. """ - return [JobField.URL, JobField.DESCRIPTION] + pass @property - def delayed_get_set_fields(self) -> str: + @abstractmethod + def delayed_get_set_fields(self) -> List[JobField]: """Delay execution when getting /setting any of these attributes of a job. Override this as needed. """ - return [JobField.DESCRIPTION] + pass @property @abstractmethod @@ -217,6 +209,11 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: in job_init_kwargs.items() }) self.set(field, job, job_soup) + + # FIXME: Abort scraping immediately if we have a duplicate ? + # This will prevent getting un-needed descriptions (delayed) + # if field == JobField.KEY_ID and job.key_id in .... + except Exception as err: if field in self.min_required_job_fields: raise ValueError( diff --git a/jobfunnel/backend/scrapers/glassdoor/static.py b/jobfunnel/backend/scrapers/glassdoor.py similarity index 58% rename from jobfunnel/backend/scrapers/glassdoor/static.py rename to jobfunnel/backend/scrapers/glassdoor.py index 79a5a8ad..37c2b6e8 100644 --- a/jobfunnel/backend/scrapers/glassdoor/static.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -1,5 +1,19 @@ -"""GlassDoor scraper that has no webdriver +"""Scraper for www.glassdoor.X """ +from abc import abstractmethod +from bs4 import BeautifulSoup +import logging +from requests import Session +from typing import Dict, List, Tuple, Optional, Union + +from jobfunnel.backend.scrapers.base import ( + BaseScraper, BaseCANEngScraper, BaseUSAEngScraper +) +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools import get_webdriver +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField + from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime, timedelta @@ -10,18 +24,36 @@ import re from requests import Session -from bs4 import BeautifulSoup -from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField -from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.backend.scrapers.glassdoor.base import ( - BaseGlassDoorScraper, BaseGlassDoorScraperCANEng, - BaseGlassDoorScraperUSAEng, -) +MAX_GLASSDOOR_LOCATIONS_TO_RETURN = 10 +LOCATION_BASE_URL = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' +MAX_RESULTS_PER_GLASSDOOR_PAGE = 30 +GLASSDOOR_RADIUS_MAP = { + 0: 0, + 10: 6, + 20: 12, + 30: 19, + 50: 31, + 100: 62, + 200: 124, +} +class BaseGlassDoorScraper(BaseScraper): -class StaticGlassDoorScraper(BaseGlassDoorScraper): + def __init__(self, session: Session, config: 'JobFunnelConfig', + logger: logging.Logger): + """Init that contains glassdoor specific stuff + """ + super().__init__(session, config, logger) + self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE + self.query = '-'.join(self.config.search_config.keywords) + # self.driver = get_webdriver() TODO: we can use this if-needed + + @abstractmethod + def quantize_radius(self, radius: int) -> int: + """Get the glassdoor-quantized radius + """ + pass @property def min_required_job_fields(self) -> str: @@ -48,6 +80,84 @@ def job_set_fields(self) -> str: """ return [JobField.DESCRIPTION] + @property + def delayed_get_set_fields(self) -> str: + """Delay execution when getting /setting any of these attributes of a + job. + + Override this as needed. + """ + return [JobField.DESCRIPTION] + + @property + def headers(self) -> Dict[str, str]: + return{ + 'accept': 'text/html,application/xhtml+xml,application/xml;' + 'q=0.9,image/webp,*/*;q=0.8', + 'accept-encoding': 'gzip, deflate, sdch, br', + 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', + 'referer': + f'https://www.glassdoor.{self.config.search_config.domain}/', + 'upgrade-insecure-requests': '1', + 'user-agent': self.user_agent, + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + } + + def get_search_url(self, + method='get') -> Union[str, Tuple[str, Dict[str,str]]]: + """Gets the glassdoor search url + NOTE: we this relies on your city, not the state / province! + """ + # Form the location lookup request data + data = { + 'term': self.config.search_config.city, + 'maxLocationsToReturn': MAX_GLASSDOOR_LOCATIONS_TO_RETURN, + } + + # Get the location id for search location + location_id = self.session.post( + LOCATION_BASE_URL, headers=self.headers, data=data + ).json()[0]['locationId'] + + if method == 'get': + + # Form job search url + search = ( + 'https://www.glassdoor.{}/Job/jobs.htm?clickSource=searchBtn' + '&sc.keyword={}&locT=C&locId={}&jobType=&radius={}'.format( + self.config.search_config.domain, + self.query, + location_id, + self.quantize_radius(self.config.search_config.radius), + ) + ) + return search + + elif method == 'post': + + # Form the job search url + search = ( + f"https://www.glassdoor.{self.config.search_config.domain}" + "/Job/jobs.htm" + ) + + # Form the job search data + data = { + 'clickSource': 'searchBtn', + 'sc.keyword': self.query, + 'locT': 'C', + 'locId': location_id, + 'jobType': '', + 'radius': + self.quantize_radius(self.config.search_config.radius), + } + + return search, data + else: + + raise ValueError(f'No html method {method} exists') + def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: """Scrapes raw data from a job source into a list of job-soups @@ -74,7 +184,10 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: threads = ThreadPoolExecutor(MAX_CPU_WORKERS) futures_list = [] # FIXME: type? - #Search the remaining pages to extract the list of job soups + # Search the remaining pages to extract the list of job soups + # FIXME: we can't load page 2, it redirects to page 1. + # There is toast that shows to get email notifs that shows up if + # I click it myself, must be an event listener? if n_pages > 1: for page in range(2, n_pages + 1): futures_list.append( @@ -86,7 +199,7 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: ) wait(futures_list) # wait for all scrape jobs to finish - return job_soup_list[25:30] + return job_soup_list def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: """Get a single job attribute from a soup object by JobField @@ -110,6 +223,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: # ] # else: # return [] + # FIXME: impl JobField.REMOTE elif parameter == JobField.POST_DATE: return calc_post_date_from_relative_str( soup.find( @@ -157,6 +271,7 @@ def _search_page_for_job_soups(self, listings_page_url: str, """Get a list of job soups from a glassdoor page, by loading the page. NOTE: this makes GET requests and should be respectfully delayed. """ + self.logger.debug(f"Scraping listings page {listings_page_url}") job_soup_list.extend( self._parse_job_listings_to_bs4( BeautifulSoup( @@ -198,12 +313,47 @@ def _get_next_page_url(self, soup_base: BeautifulSoup, ) -# These are the same exact logic, same website beyond the domain. -class StaticGlassDoorScraperCANEng(StaticGlassDoorScraper, - BaseGlassDoorScraperCANEng): - pass +class GlassDoorScraperCANEng(BaseGlassDoorScraper, BaseCANEngScraper): + + def quantize_radius(self, radius: int) -> int: + """Get a Canadian raduius (km) + FIXME: use numpy.digitize instead + """ + if radius < 10: + radius = 0 + elif 10 <= radius < 20: + radius = 10 + elif 20 <= radius < 30: + radius = 20 + elif 30 <= radius < 50: + radius = 30 + elif 50 <= radius < 100: + radius = 50 + elif 100 <= radius < 200: + radius = 100 + elif radius >= 200: + radius = 200 + return GLASSDOOR_RADIUS_MAP[radius] -class StaticGlassDoorScraperUSAEng(StaticGlassDoorScraper, - BaseGlassDoorScraperUSAEng): - pass +class GlassDoorScraperUSAEng(BaseGlassDoorScraper, BaseUSAEngScraper): + + def quantize_radius(self, radius: int) -> int: + """Get a USA raduius (miles) + FIXME: use numpy.digitize instead + """ + if radius < 5: + radius = 0 + elif 5 <= radius < 10: + radius = 5 + elif 10 <= radius < 15: + radius = 10 + elif 15 <= radius < 25: + radius = 15 + elif 25 <= radius < 50: + radius = 25 + elif 50 <= radius < 100: + radius = 50 + elif radius >= 100: + radius = 100 + return GLASSDOOR_RADIUS_MAP[radius] diff --git a/jobfunnel/backend/scrapers/glassdoor/__init__.py b/jobfunnel/backend/scrapers/glassdoor/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/jobfunnel/backend/scrapers/glassdoor/base.py b/jobfunnel/backend/scrapers/glassdoor/base.py deleted file mode 100644 index 05390afb..00000000 --- a/jobfunnel/backend/scrapers/glassdoor/base.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Base Glassdoor Scraper used by both the selenium (driven) and static scrapers -""" -from abc import abstractmethod -from bs4 import BeautifulSoup -import logging -from requests import Session -from typing import Dict, List, Tuple, Optional, Union - -from jobfunnel.backend.scrapers.base import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper -) - - -MAX_GLASSDOOR_LOCATIONS_TO_RETURN = 10 -LOCATION_BASE_URL = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' -MAX_RESULTS_PER_GLASSDOOR_PAGE = 30 -GLASSDOOR_RADIUS_MAP = { - 0: 0, - 10: 6, - 20: 12, - 30: 19, - 50: 31, - 100: 62, - 200: 124, -} - -class BaseGlassDoorScraper(BaseScraper): - - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger): - """Init that contains glassdoor specific stuff - """ - super().__init__(session, config, logger) - self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE - self.query = '-'.join(self.config.search_config.keywords) - - @property - def headers(self) -> Dict[str, str]: - return{ - 'accept': 'text/html,application/xhtml+xml,application/xml;' - 'q=0.9,image/webp,*/*;q=0.8', - 'accept-encoding': 'gzip, deflate, sdch, br', - 'accept-language': 'en-GB,en-US;q=0.8,en;q=0.6', - 'referer': - f'https://www.glassdoor.{self.config.search_config.domain}/', - 'upgrade-insecure-requests': '1', - 'user-agent': self.user_agent, - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - } - - - @abstractmethod - def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: - """Scrapes raw data from a job source into a list of job-soups - - Returns: - List[BeautifulSoup]: list of jobs soups we can use to make Job init - """ - pass - - - def get_search_url(self, - method='get') -> Union[str, Tuple[str, Dict[str,str]]]: - """Gets the glassdoor search url - NOTE: we this relies on your city, not the state / province! - """ - # Form the location lookup request data - data = { - 'term': self.config.search_config.city, - 'maxLocationsToReturn': MAX_GLASSDOOR_LOCATIONS_TO_RETURN, - } - - # Get the location id for search location - location_id = self.session.post( - LOCATION_BASE_URL, headers=self.headers, data=data - ).json()[0]['locationId'] - - if method == 'get': - - # Form job search url - search = ( - 'https://www.glassdoor.{}/Job/jobs.htm?clickSource=searchBtn' - '&sc.keyword={}&locT=C&locId={}&jobType=&radius={}'.format( - self.config.search_config.domain, - self.query, - location_id, - self.quantize_radius(self.config.search_config.radius), - ) - ) - return search - - elif method == 'post': - - # Form the job search url - search = ( - f"https://www.glassdoor.{self.config.search_config.domain}" - "/Job/jobs.htm" - ) - - # Form the job search data - data = { - 'clickSource': 'searchBtn', - 'sc.keyword': self.query, - 'locT': 'C', - 'locId': location_id, - 'jobType': '', - 'radius': - self.quantize_radius(self.config.search_config.radius), - } - - return search, data - else: - - raise ValueError(f'No html method {method} exists') - - @abstractmethod - def quantize_radius(self, radius: int) -> int: - """Get the glassdoor-quantized radius - FIXME: use numpy.digitize instead - """ - pass - - -class BaseGlassDoorScraperCANEng(BaseGlassDoorScraper, BaseCANEngScraper): - - def quantize_radius(self, radius: int) -> int: - """Get a Canadian raduius (km) - """ - if radius < 10: - radius = 0 - elif 10 <= radius < 20: - radius = 10 - elif 20 <= radius < 30: - radius = 20 - elif 30 <= radius < 50: - radius = 30 - elif 50 <= radius < 100: - radius = 50 - elif 100 <= radius < 200: - radius = 100 - elif radius >= 200: - radius = 200 - return GLASSDOOR_RADIUS_MAP[radius] - - -class BaseGlassDoorScraperUSAEng(BaseGlassDoorScraper, BaseUSAEngScraper): - - def quantize_radius(self, radius: int) -> int: - """Get a USA raduius (miles) - """ - if radius < 5: - radius = 0 - elif 5 <= radius < 10: - radius = 5 - elif 10 <= radius < 15: - radius = 10 - elif 15 <= radius < 25: - radius = 15 - elif 25 <= radius < 50: - radius = 25 - elif 50 <= radius < 100: - radius = 50 - elif radius >= 100: - radius = 100 - return GLASSDOOR_RADIUS_MAP[radius] diff --git a/jobfunnel/backend/scrapers/glassdoor/driven.py b/jobfunnel/backend/scrapers/glassdoor/driven.py deleted file mode 100644 index 9217843e..00000000 --- a/jobfunnel/backend/scrapers/glassdoor/driven.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Base class for scraping jobs from GlassDoor -""" -from abc import abstractmethod -from concurrent.futures import ThreadPoolExecutor, wait -from datetime import date, datetime, timedelta -import logging -from math import ceil -from time import sleep, time -from typing import Dict, List, Tuple, Optional, Any -import re -from requests import Session - -from bs4 import BeautifulSoup - -from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField -from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.backend.scrapers.base import ( - BaseCANEngScraper, BaseUSAEngScraper -) -from jobfunnel.backend.scrapers.glassdoor.base import BaseGlassDoorScraper - - -# FIXME: maybe we can just move this to a dev branch? -class DrivenGlassDoorScraper(BaseGlassDoorScraper): - """The Dynamic Version of the GlassDoor scraper, that uses selenium to scrape job postings. - """ - - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger): - """Init""" - super().__init__(session, config, logger) - #self.driver = get_webdriver() - - - def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: - pass - # search_url, data = self.get_search_url() - # self.driver.get(search_url) - - -class DrivenGlassDoorScraperCANEng(DrivenGlassDoorScraper, BaseCANEngScraper): - pass - -class DrivenGlassDoorScraperUSAEng(DrivenGlassDoorScraper, BaseUSAEngScraper): - pass \ No newline at end of file diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index f82f2c8f..41fe2d4a 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -37,6 +37,49 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE self.query = '+'.join(self.config.search_config.keywords) + @property + def min_required_job_fields(self) -> str: + """If we dont get() or set() any of these fields, we will raise an + exception instead of continuing without that information. + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.URL + ] + + @property + def job_get_fields(self) -> str: + """Call self.get(...) for the JobFields in this list when scraping a Job + + Override this as needed. + """ + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.TAGS, JobField.POST_DATE, + # JobField.WAGE, JobField.REMOTE + # FIXME: wage and remote are available in listings + ] + + @property + def job_set_fields(self) -> str: + """Call self.set(...) for the JobFields in this list when scraping a Job + + NOTE: Since this passes the Job we are updating, the order of this list + matters if set fields rely on each-other. + + Override this as needed. + """ + return [JobField.URL, JobField.DESCRIPTION] + + @property + def delayed_get_set_fields(self) -> str: + """Delay execution when getting /setting any of these attributes of a + job. + + Override this as needed. + """ + return [JobField.DESCRIPTION] + @property def headers(self) -> Dict[str, str]: """Session header for indeed.X @@ -112,6 +155,10 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: 'td', attrs={'class': 'jobCardShelfItem'} ) ] + # elif parameter == JobField.REMOTE: + # TODO: Impl, this is available in listings as: ... + # elif parameter == JobField.WAGE: + # TODO: Impl, this is available as: ... elif parameter == JobField.POST_DATE: return calc_post_date_from_relative_str( soup.find('span', attrs={'class': 'date'}).text.strip() @@ -150,6 +197,8 @@ def _get_search_url(self, method: Optional[str] = 'get') -> str: TODO: use Enum for method instead of str. """ if method == 'get': + # TODO: impl. &remotejob=.... string which allows for remote search + # i.e &remotejob=032b3046-06a3-4876-8dfd-474eb5e7ed11 return ( "https://www.indeed.{0}/jobs?q={1}&l={2}%2C+{3}&radius={4}&" "limit={5}&filter={6}".format( diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 1afb2cb0..a909232a 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -67,6 +67,15 @@ def job_set_fields(self) -> str: """ return [JobField.KEY_ID, JobField.DESCRIPTION] + @property + def delayed_get_set_fields(self) -> str: + """Delay execution when getting /setting any of these attributes of a + job. + + Override this as needed. + """ + return [JobField.DESCRIPTION] + @property def headers(self) -> Dict[str, str]: """Session header for monster.X @@ -122,45 +131,71 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: """Scrapes raw data from a job source into a list of job-soups + TODO: use threading here too + Returns: List[BeautifulSoup]: list of jobs soups we can use to make Job init """ # Get the search url search_url = self._get_search_url() + # Load our initial search results listings page + initial_seach_results_html = self.session.get(search_url) + initial_search_results_soup = BeautifulSoup( + initial_seach_results_html.text, self.config.bs4_parser + ) + # Parse total results, and calculate the # of pages needed - pages = self._get_num_search_result_pages(search_url) + n_pages = self._get_num_search_result_pages(initial_search_results_soup) self.logger.info( - f"Found {pages} pages of search results for query={self.query}" + f"Found {n_pages} pages of search results for query={self.query}" ) - # Return list of soups from the listings (short) - return self._get_job_soups_from_search_page(search_url, pages) + # Get first page of listing soups from our search results listings page + job_soups_list = self._get_job_soups_from_search_page( + initial_search_results_soup + ) + + # Get all the other pages + for page in range(1, n_pages): + next_listings_page_soup = BeautifulSoup( + self.session.get(self._get_results_page_url(page, search_url)), + self.config.bs4_parser, + ) + job_soups_list.extend( + self._get_job_soups_from_search_page(next_listings_page_soup) + ) + + return job_soups_list + + def _get_results_page_url(self, cur_page: int, search_url: str) -> str: + """Get the next page of search listings + """ + return f'{search_url}&start={cur_page}' - def _get_job_soups_from_search_page(self, search_url: str, - pages: int) -> List[BeautifulSoup]: - """Scrapes the monster page for a list of job soups + def _get_job_soups_from_search_page(self, + initial_results_soup: BeautifulSoup, + ) -> List[BeautifulSoup]: + """Get individual job listing soups from a results page of many jobs """ - page_url = f'{search_url}&start={pages}' - return BeautifulSoup( - self.session.get(page_url).text, self.config.bs4_parser - ). find_all('div', attrs={'class': 'flex-row'}) + return initial_results_soup.find_all('div', attrs={'class': 'flex-row'}) - def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: + def _get_num_search_result_pages(self, initial_results_soup: BeautifulSoup, + max_pages=0) -> int: """Calculates the number of pages of job listings to be scraped. i.e. your search yields 230 results at 50 res/page -> 5 pages of jobs Args: + initial_results_soup: the soup for the first search results page max_pages: the maximum number of pages to be scraped. Returns: The number of pages of job listings to be scraped. """ # scrape total number of results, and calculate the # pages needed - request_html = self.session.get(search_url) - soup_base = BeautifulSoup(request_html.text, self.config.bs4_parser) - num_res = soup_base.find('h2', 'figure').text.strip() - num_res = int(re.findall(r'(\d+)', num_res)[0]) + partial = initial_results_soup.find('h2', 'figure').text.strip() + assert partial, "Unable to identify number of search results" + num_res = int(re.findall(r'(\d+)', partial)[0]) return int(ceil(num_res / MAX_RESULTS_PER_MONSTER_PAGE)) def _get_search_url(self, method: Optional[str] = 'get') -> str: diff --git a/jobfunnel/backend/scrapers/registry.py b/jobfunnel/backend/scrapers/registry.py index dbc7c29b..0907eba2 100644 --- a/jobfunnel/backend/scrapers/registry.py +++ b/jobfunnel/backend/scrapers/registry.py @@ -2,7 +2,7 @@ NOTE: if you implement a scraper you must add it here or JobFunnel cannot find it. -TODO: way to make this unnecessary? maybe import & map based on name? +TODO: must be a way to make this unnecessary? maybe import & map based on name? """ from jobfunnel.resources import Locale, Provider @@ -12,13 +12,9 @@ from jobfunnel.backend.scrapers.monster import ( MonsterScraperCANEng, MonsterScraperUSAEng, ) -from jobfunnel.backend.scrapers.glassdoor.driven import ( - DrivenGlassDoorScraperUSAEng, DrivenGlassDoorScraperCANEng, +from jobfunnel.backend.scrapers.glassdoor import ( + GlassDoorScraperCANEng, GlassDoorScraperUSAEng, ) -from jobfunnel.backend.scrapers.glassdoor.static import ( - StaticGlassDoorScraperCANEng, StaticGlassDoorScraperUSAEng, -) - # NOTE: if you add a scraper you need to add it here @@ -29,22 +25,12 @@ Locale.CANADA_ENGLISH: IndeedScraperCANEng, Locale.USA_ENGLISH: IndeedScraperUSAEng, }, - Provider.GLASSDOOR: { # FIXME - Locale.CANADA_ENGLISH: StaticGlassDoorScraperCANEng, - Locale.USA_ENGLISH: StaticGlassDoorScraperUSAEng, + Provider.GLASSDOOR: { + Locale.CANADA_ENGLISH: GlassDoorScraperCANEng, + Locale.USA_ENGLISH: GlassDoorScraperUSAEng, }, Provider.MONSTER: { Locale.CANADA_ENGLISH: MonsterScraperCANEng, Locale.USA_ENGLISH: MonsterScraperUSAEng, }, } - - -# Any of the web-driven scrapers will be chosen if we set --web-driven -# TODO: have defaults for these instead. -DRIVEN_SCRAPER_FROM_LOCALE = { - Provider.GLASSDOOR: { - Locale.CANADA_ENGLISH: DrivenGlassDoorScraperCANEng, - Locale.USA_ENGLISH: DrivenGlassDoorScraperUSAEng, - }, -} \ No newline at end of file diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index dc8c79f0..487bff99 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -1,4 +1,4 @@ -from jobfunnel.config.settings import SettingsValidator +from jobfunnel.config.settings import SettingsValidator, SETTINGS_YAML_SCHEMA from jobfunnel.config.base import BaseConfig from jobfunnel.config.delay import DelayConfig from jobfunnel.config.proxy import ProxyConfig diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 2d070ffc..731eb89e 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -7,18 +7,21 @@ import yaml from jobfunnel.config import ( - JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig, SettingsValidator) + JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig, + SettingsValidator, SETTINGS_YAML_SCHEMA +) from jobfunnel.backend.tools.tools import split_url from jobfunnel.resources import ( Locale, DelayAlgorithm, LOG_LEVEL_NAMES, Provider) from jobfunnel.resources.defaults import * -PROVIDER_NAMES = [p.name for p in DEFAULT_PROVIDERS] - - def parse_cli(): """Parse the command line arguments into an argv with defaults + + NOTE: we only provide defaults for entries that are required and have no + default in Cerberus schema (SettingsValidator), this lets users try it out + without having to configure anything at all. """ parser = argparse.ArgumentParser('Job Search CLI') @@ -52,7 +55,6 @@ def parse_cli(): parser.add_argument( '-csv', dest='master_csv_file', - default=DEFAULT_MASTER_CSV_FILE, nargs='*', help='Path to a master CSV file containing your search results. ' f'Defaults to {DEFAULT_MASTER_CSV_FILE}' @@ -61,7 +63,6 @@ def parse_cli(): parser.add_argument( '-cache', dest='cache_folder', - default=DEFAULT_CACHE_DIRECTORY, help='Directory where cached scrape data will be stored. ' f'Defaults to {DEFAULT_CACHE_DIRECTORY}' ) @@ -70,7 +71,6 @@ def parse_cli(): '-blf', dest='block_list_file', nargs='*', - default=DEFAULT_BLOCK_LIST_FILE, help='JSON file of jobs you want to omit from your job search ' '(usually this is in the output of previous jobfunnel results). ' f'Defaults to: {DEFAULT_BLOCK_LIST_FILE}' @@ -84,11 +84,18 @@ def parse_cli(): help='path to logging file.' ) + parser.add_argument( + '--log-level', + type=str, + default=DEFAULT_LOG_LEVEL_NAME, + choices=LOG_LEVEL_NAMES, + help='Type of logging information shown on the terminal.' + ) + parser.add_argument( '-dl', dest='duplicates_list_file', nargs='*', - default=DEFAULT_DUPLICATES_FILE, help='JSON file of jobs which have been detected to be duplicates of ' 'existing jobs (usually this is in the output of previous ' f'jobfunnel results). Defaults to: {DEFAULT_DUPLICATES_FILE}' @@ -98,7 +105,6 @@ def parse_cli(): '-cbl', dest='search_company_block_list', nargs='+', - default=DEFAULT_COMPANY_BLOCK_LIST, help='List of company names to omit from all search results.' ) @@ -107,8 +113,8 @@ def parse_cli(): '-p', dest='search_providers', nargs='+', - choices=PROVIDER_NAMES, - default=PROVIDER_NAMES, + choices=[p.name for p in Provider], + default=[p.name for p in DEFAULT_PROVIDERS], help='List of job-search providers. (i.e. indeed, monster, glassdoor).' ) @@ -131,7 +137,7 @@ def parse_cli(): parser.add_argument( '-ps', - dest='search_region_province_or_state', + dest='search_province_or_state', default=DEFAULT_PROVINCE, type=str, help='Province/state value for your job-search region. NOTE: format ' @@ -140,7 +146,7 @@ def parse_cli(): parser.add_argument( '-c', - dest='search_region_city', + dest='search_city', default=DEFAULT_CITY, type=str, help='City/town value for job-search region.' @@ -148,9 +154,8 @@ def parse_cli(): parser.add_argument( '-r', - dest='search_region_radius', + dest='search_radius', type=int, - default=DEFAULT_SEARCH_RADIUS_KM, help='The maximum distance a job should be from the specified city.' ) @@ -158,7 +163,6 @@ def parse_cli(): '-max-listing-age', dest='search_max_listing_days', type=int, - default=DEFAULT_MAX_LISTING_DAYS, help='The maximum number of days-old a job can be. (i.e pass 30 to ' 'filter out jobs older than a month).' ) @@ -170,18 +174,10 @@ def parse_cli(): help='Return \'similar\' results from search query (only for Indeed).' ) - # Functionality - parser.add_argument( - '--log-level', - type=str, - default=DEFAULT_LOG_LEVEL_NAME, - choices=LOG_LEVEL_NAMES, - help='Type of logging information shown on the terminal.' - ) - + # Flags: NOTE: all the defaults for these should be False. parser.add_argument( '--recover', - dest='recover_from_cache', + dest='do_recovery_mode', action='store_true', help='Reconstruct a new master CSV file from all available cache files.' 'WARNING: this will replace all the statuses/etc in your master ' @@ -201,14 +197,6 @@ def parse_cli(): help='Do not make any get requests, and attempt to load from cache.' ) - parser.add_argument( - '--web-driver', - dest='use_web_driver', - action='store_true', - help='Use web-driven scraping if available. Currently only available ' - 'for GlassDoor scrapers. WARNING: this is in beta.' - ) - # Proxy stuff # TODO: subparser. parser.add_argument( @@ -249,7 +237,6 @@ def parse_cli(): parser.add_argument( '-delay-max', dest='delay_max_duration', - default=DEFAULT_DELAY_MAX_DURATION, type=float, help='Set delay seconds for certain get requests.' ) @@ -257,14 +244,12 @@ def parse_cli(): parser.add_argument( '-delay-min', dest='delay_min_duration', - default=DEFAULT_DELAY_MIN_DURATION, type=float, help='Set lower bound value for delay for certain get requests.' ) parser.add_argument( '-delay-algorithm', - default=DEFAULT_DELAY_ALGORITHM.name, choices=[a.name for a in DelayAlgorithm], help='Select a function to calculate delay times with.' ) @@ -277,57 +262,64 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: args [argparse.Namespace]: cli arguments from argparser """ - # Load config dict from the YAML (may be default) + # Init and pop args that are cli-only and not in our schema args_dict = vars(args) - if args_dict.pop('settings_yaml_file'): - config = yaml.load( - open(args.settings_yaml_file, 'r'), Loader=yaml.FullLoader + settings_yaml_file = args_dict.pop('settings_yaml_file') + output_folder = args_dict.pop('output_folder') + args_dict.pop('do_recovery_mode') # NOTE: this is handled in __main__ + + # Load config dict from the YAML if passed + config = {'search': {}, 'delay': {}, 'proxy': {}} + if settings_yaml_file: + config.update( + yaml.load(open(settings_yaml_file, 'r'), Loader=yaml.FullLoader) ) + + # Handle output_folder argument which is a shortcut to specifying all paths + if (output_folder == DEFAULT_OUTPUT_DIRECTORY and not( + args_dict['master_csv_file'] and args_dict['block_list_file'] and + args_dict['duplicates_list_file'] and args_dict['cache_folder'])): + + # We have been given an output folder, so we will use default paths + config['master_csv_file'] = DEFAULT_MASTER_CSV_FILE + config['block_list_file'] = DEFAULT_BLOCK_LIST_FILE + config['duplicates_list_file'] = DEFAULT_DUPLICATES_FILE + config['cache_folder'] = DEFAULT_CACHE_DIRECTORY + + elif not output_folder: + + # We have a combination + if not config['master_csv_file']: + config['master_csv_file'] = DEFAULT_MASTER_CSV_FILE + if not config['block_list_file']: + config['block_list_file'] = DEFAULT_BLOCK_LIST_FILE + if not config['duplicates_list_file']: + config['duplicates_list_file'] = DEFAULT_DUPLICATES_FILE + if not config['cache_folder']: + config['cache_folder'] = DEFAULT_CACHE_DIRECTORY + else: - config = DEFAULT_CONFIG - - # Are we recovering? NOTE: this arg is not part of yaml like settings path - recover_from_cache = args_dict.pop('recover_from_cache') - - # Ensure that if user provided output folder that the other paths aren't - if (args_dict['output_folder'] != DEFAULT_OUTPUT_DIRECTORY and ( - args_dict['master_csv_file'] != DEFAULT_MASTER_CSV_FILE - or args_dict['block_list_file'] != DEFAULT_BLOCK_LIST_FILE - or args_dict['duplicates_list_file'] != DEFAULT_DUPLICATES_FILE - or args_dict['cache_folder'] != DEFAULT_CACHE_DIRECTORY)): - - raise ValueError( - "When providing output_folder, do not also provide -csv, -blf" - ", -dlf, or -cache, as these are defined by the output folder." - " If specifying file paths you must pass all the arguments and" - " not pass -o." - ) - - # Inject any modified attributs only if they override our config/defaults - # TODO less messy way to do this? - output_folder = args_dict.pop('output_folder') - for key, value in args_dict.items(): - if key in config and config[key] != value: - config[key] = value - continue - if 'search_region' in key: - sub_sub_cfg_key = key.split('search_region_')[1] - if config['search']['region'][sub_sub_cfg_key] != value: - config['search']['region'][sub_sub_cfg_key] = value - continue - for sub_cfg_name in ['search', 'delay', 'proxy']: - if sub_cfg_name in key: - sub_cfg_key = key.split(f'{sub_cfg_name}_')[1] - if config[sub_cfg_name][sub_cfg_key] != value: - config[sub_cfg_name][sub_cfg_key] = value - continue + # User cannot specify both output folder and other paths + raise ValueError( + "When providing output_folder, do not also provide -csv, -blf" + ", -dlf, or -cache, as these are defined by the output folder." + " If specifying file paths you must pass all the arguments and" + " not pass -o." + ) - # Create any folders that we need - if output_folder: - if not os.path.exists(output_folder): - os.makedirs(output_folder) - if not os.path.exists(args_dict['cache_folder']): - os.makedirs(args_dict['cache_folder']) + # Inject any cli, non-default attributes + for key, value in args_dict.items(): + if value is not None: + if key in SETTINGS_YAML_SCHEMA: + config[key] = value + else: + for sub_key in ['search', 'delay', 'proxy']: + if sub_key in key: + config[sub_key][key.split(sub_key + '_')[1]] = value + break + + # Set any defaults in our schema + config = SettingsValidator.normalized(config) # Validate the config we have built if not SettingsValidator.validate(config): @@ -336,12 +328,19 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: f"Invalid Config settings yaml:\n{SettingsValidator.errors}" ) + # Create any folders that we need + if output_folder: + if not os.path.exists(output_folder): + os.makedirs(output_folder) + if not os.path.exists(config['cache_folder']): + os.makedirs(config['cache_folder']) + # Build JobFunnelConfig search_cfg = SearchConfig( keywords=config['search']['keywords'], - province_or_state=config['search']['region']['province_or_state'], - city=config['search']['region']['city'], - distance_radius=config['search']['region']['radius'], + province_or_state=config['search']['province_or_state'], + city=config['search']['city'], + distance_radius=config['search']['radius'], return_similar_results=config['search']['similar_results'], max_listing_days=config['search']['max_listing_days'], blocked_company_names=config['search']['company_block_list'], @@ -357,7 +356,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: converge=config['delay']['converging'], ) - if config['proxy']['ip']: + if config['proxy']: proxy_cfg = ProxyConfig( protocol=config['proxy']['protocol'], ip_address=config['proxy']['ip'], @@ -374,12 +373,9 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: log_file=config['log_file'], log_level=config['log_level'], no_scrape=config['no_scrape'], - # bs4_parser=config['bs4_parser'], # TODO: impl. cli/cfg when needed. - recover_from_cache=recover_from_cache, # NOTE: this isn't in YAML search_config=search_cfg, delay_config=delay_cfg, proxy_config=proxy_cfg, - web_driven_scraping=config['use_web_driver'], ) # Validate funnel config as well (checks some stuff Cerberus doesn't rn) diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 9a1e45d5..871b801d 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -7,10 +7,8 @@ # from jobfunnel.backend.scrapers.base import BaseScraper CYCLICAL! from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig from jobfunnel.resources import Locale, Provider, BS4_PARSER +from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE -from jobfunnel.backend.scrapers.registry import ( - SCRAPER_FROM_LOCALE, DRIVEN_SCRAPER_FROM_LOCALE -) class JobFunnelConfig(BaseConfig): """Master config containing all the information we need to run jobfunnel @@ -25,7 +23,6 @@ def __init__(self, log_file: str, log_level: Optional[int] = logging.INFO, no_scrape: Optional[bool] = False, - recover_from_cache: Optional[bool] = False, bs4_parser: Optional[str] = BS4_PARSER, return_similar_results: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, @@ -50,9 +47,6 @@ def __init__(self, no_scrape (Optional[bool], optional): If True, will not scrape data at all, instead will only update filters and CSV. Defaults to False. - recover_from_cache (Optional[bool], optional): if True, build the - master CSV file from the contents of all the cache files inside - self.cache_folder. NOTE: respects the block list. not in YAML. bs4_parser (Optional[str], optional): the parser to use for BS4. return_similar_resuts (Optional[bool], optional): If True, we will ask the job provider to provide more loosely-similar results for @@ -73,7 +67,6 @@ def __init__(self, self.log_level = log_level self.no_scrape = no_scrape self.bs4_parser = bs4_parser # TODO: add to config - self.recover_from_cache = recover_from_cache self.return_similar_results = return_similar_results self.web_driven_scraping = web_driven_scraping if not delay_config: @@ -99,11 +92,7 @@ def scrapers(self) -> List['BaseScraper']: """ scrapers = [] # type: List[BaseScraper] for pr in self.search_config.providers: - if self.web_driven_scraping and pr in DRIVEN_SCRAPER_FROM_LOCALE: - scrapers.append( - DRIVEN_SCRAPER_FROM_LOCALE[pr][self.search_config.locale] - ) - elif pr in SCRAPER_FROM_LOCALE: + if pr in SCRAPER_FROM_LOCALE: scrapers.append( SCRAPER_FROM_LOCALE[pr][self.search_config.locale] ) diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index 8aa65388..d4d2db84 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -23,10 +23,11 @@ def __init__(self, providers: List[Provider], city: Optional[str] = None, distance_radius: Optional[int] = None, - return_similar_results: Optional[bool] = False, + return_similar_results: bool = False, max_listing_days: Optional[int] = None, blocked_company_names: Optional[List[str]] = None, - domain: Optional[str] = None): + domain: Optional[str] = None, + remote: bool = False,): """Search config for all job sources Args: @@ -43,6 +44,7 @@ def __init__(self, companies that we never want to see in our results. domain (Optional[str], optional): domain string to use for search querying. If not passed, will set based on locale. (i.e. 'ca') + remote: True if searching for remote jobs only TODO: impl. for scr. """ self.province_or_state = province_or_state self.city = city.lower() @@ -53,7 +55,7 @@ def __init__(self, self.return_similar_results = return_similar_results # indeed thing self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS self.blocked_company_names = blocked_company_names - + self.remote = remote self.__query_string = '' # type: str # Try to infer the domain string based on the locale. diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index 57c0d500..e8fc441e 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -7,107 +7,142 @@ from jobfunnel.resources import ( Locale, Provider, DelayAlgorithm, LOG_LEVEL_NAMES ) +from jobfunnel.resources.defaults import * SETTINGS_YAML_SCHEMA = { - 'master_csv_file': {'required': True, 'type': 'string'}, - 'block_list_file': {'required': True, 'type': 'string'}, - 'cache_folder': {'required': True, 'type': 'string'}, - 'duplicates_list_file': {'required': False, 'type': 'string'}, - 'no_scrape': {'required': False, 'type': 'boolean'}, - 'recover': {'required': False, 'type': 'boolean'}, - 'log_level': {'required': False, 'allowed': LOG_LEVEL_NAMES}, - 'log_file': {'required': False, 'type': 'string'}, - 'save_duplicates': {'required': False, 'type': 'boolean'}, - 'use_web_driver': {'required': False, 'type': 'boolean'}, + 'master_csv_file': { + 'required': True, + 'type': 'string', + }, + 'block_list_file': { + 'required': True, + 'type': 'string', + }, + 'cache_folder': { + 'required': True, + 'type': 'string', + }, + 'duplicates_list_file': { + 'required': False, + 'type': 'string', + 'default': DEFAULT_DUPLICATES_FILE, + }, + 'no_scrape': { + 'required': False, + 'type': 'boolean', + 'default': DEFAULT_NO_SCRAPE, + }, + 'log_level': { + 'required': False, + 'allowed': LOG_LEVEL_NAMES, + 'default': DEFAULT_LOG_LEVEL_NAME, + }, + 'log_file': { + 'required': False, + 'type': 'string', + 'default': DEFAULT_LOG_FILE, + }, + 'save_duplicates': { + 'required': False, + 'type': 'boolean', + 'default': DEFAULT_SAVE_DUPLICATES, + }, 'search': { 'type': 'dict', 'required': True, 'schema': { 'providers': { - 'required': True, 'allowed': [p.name for p in Provider] + 'required': False, + 'allowed': [p.name for p in Provider], + 'default': DEFAULT_PROVIDERS, }, - 'locale' : {'required': True, 'allowed': [l.name for l in Locale]}, - 'region': { - 'type': 'dict', + 'locale' : { 'required': True, - 'schema': { - 'province_or_state': {'required': True, 'type': 'string'}, - 'city': {'required': True, 'type': 'string'}, - 'radius': {'required': False, 'type': 'integer', 'min': 0}, - }, + 'allowed': [l.name for l in Locale], + }, + 'province_or_state': {'required': True, 'type': 'string'}, + 'city': {'required': True, 'type': 'string'}, + 'radius': { + 'required': False, + 'type': 'integer', + 'min': 0, + 'default': DEFAULT_SEARCH_RADIUS_KM, + }, + 'similar_results': { + 'required': False, + 'type': 'boolean', + 'default': DEFAULT_RETURN_SIMILAR_RESULTS, }, - 'similar_results': {'required': False, 'type': 'boolean'}, 'keywords': { 'required': True, 'type': 'list', 'schema': {'type': 'string'}, }, 'max_listing_days': { - 'required': False, 'type': 'integer', 'min': 0 + 'required': False, + 'type': 'integer', + 'min': 0, + 'default': DEFAULT_MAX_LISTING_DAYS, }, 'company_block_list': { 'required': False, - 'type': 'list', 'schema': {'type': 'string'}, + 'type': 'list', + 'schema': {'type': 'string'}, + 'default': DEFAULT_COMPANY_BLOCK_LIST, }, }, }, 'delay': { 'type': 'dict', 'required': False, - 'nullable': True, 'schema' : { 'algorithm': { 'required': False, 'allowed': [d.name for d in DelayAlgorithm], - 'nullable': True, + 'default': DEFAULT_DELAY_ALGORITHM.name, }, # TODO: implement custom rule max > min 'max_duration': { 'required': False, 'type': 'float', 'min': 0, - 'nullable': True, + 'default': DEFAULT_DELAY_MAX_DURATION, }, 'min_duration': { 'required': False, 'type': 'float', 'min': 0, - 'nullable': True, + 'default': DEFAULT_DELAY_MIN_DURATION, }, 'random': { 'required': False, 'type': 'boolean', - 'nullable': True, - }, + 'default': DEFAULT_RANDOM_DELAY, + }, 'converging': { 'required': False, 'type': 'boolean', - 'nullable': True, + 'default': DEFAULT_RANDOM_CONVERGING_DELAY, }, }, }, - 'proxy': { 'type': 'dict', 'required': False, - 'nullable': True, 'schema' : { 'protocol': { 'required': False, 'allowed': ['http', 'https'], - 'nullable': True, }, 'ip': { 'required': False, 'type': 'ipv4address', - 'nullable': True, }, 'port': { 'required': False, 'type': 'integer', 'min': 0, - 'nullable': True, }, }, }, diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 8c178a05..5e3df51e 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -54,7 +54,8 @@ DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR # NOTE: we do indeed first b/c it has most information, monster is missing keys -DEFAULT_PROVIDERS = [Provider.GLASSDOOR] #, Provider.INDEED, Provider.MONSTER] #, ] FIXME +# FIXME: re-enable glassdoor once we fix issue with it. +DEFAULT_PROVIDERS = [Provider.MONSTER, Provider.INDEED] #, Provider.GLASSDOOR] DEFAULT_NO_SCRAPE = False DEFAULT_USE_WEB_DRIVER = False DEFAULT_RECOVER = False @@ -62,9 +63,6 @@ DEFAULT_SAVE_DUPLICATES = False DEFAULT_RANDOM_DELAY= False DEFAULT_RANDOM_CONVERGING_DELAY = False -DEFAULT_PROTOCOL = None -DEFAULT_IP = None -DEFAULT_PORT = None # Defaults we use from localization, the scraper can always override it. DEFAULT_DOMAIN_FROM_LOCALE = { @@ -72,42 +70,3 @@ Locale.CANADA_FRENCH: 'ca', Locale.USA_ENGLISH: 'com', } - -DEFAULT_CONFIG = { - 'master_csv_file': DEFAULT_MASTER_CSV_FILE, - 'block_list_file': DEFAULT_BLOCK_LIST_FILE, - 'duplicates_list_file': DEFAULT_DUPLICATES_FILE, - 'cache_folder': DEFAULT_CACHE_DIRECTORY, - 'no_scrape': DEFAULT_NO_SCRAPE, - 'recover': DEFAULT_RECOVER, - 'save_duplicates': DEFAULT_SAVE_DUPLICATES, - 'log_level': DEFAULT_LOG_LEVEL_NAME, - 'log_file': DEFAULT_LOG_FILE, - 'use_web_driver': DEFAULT_USE_WEB_DRIVER, - 'search': { - 'locale' : DEFAULT_LOCALE.name, - 'providers': [p.name for p in DEFAULT_PROVIDERS], - 'region': { - 'province_or_state': DEFAULT_PROVINCE, - 'city': DEFAULT_CITY, - 'radius': DEFAULT_SEARCH_RADIUS_KM, - }, - 'keywords': DEFAULT_SEARCH_KEYWORDS, - 'similar_results': DEFAULT_RETURN_SIMILAR_RESULTS, - 'max_listing_days': DEFAULT_MAX_LISTING_DAYS, - 'company_block_list': DEFAULT_COMPANY_BLOCK_LIST, - }, - 'delay': { - 'algorithm': DEFAULT_DELAY_ALGORITHM.name, - 'max_duration': DEFAULT_DELAY_MAX_DURATION, - 'min_duration': DEFAULT_DELAY_MIN_DURATION, - 'random': DEFAULT_RANDOM_DELAY, - 'converging': DEFAULT_RANDOM_CONVERGING_DELAY, - }, - - 'proxy': { - 'protocol': DEFAULT_PROTOCOL, - 'ip': DEFAULT_IP, - 'port': DEFAULT_PORT, - }, - } From ab7d9a3a38e0a4c49198fb10f11f96e1d6e01867 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 21 Aug 2020 08:17:23 -0400 Subject: [PATCH 21/66] fix demo settings YAML and make loggers display scraper name / jobfunnel --- demo/settings.yaml | 11 ++++++-- jobfunnel/backend/jobfunnel.py | 6 ++-- jobfunnel/backend/scrapers/base.py | 37 +++++++++++++++++++------ jobfunnel/backend/scrapers/glassdoor.py | 5 ++-- jobfunnel/backend/scrapers/indeed.py | 5 ++-- jobfunnel/backend/scrapers/monster.py | 5 ++-- jobfunnel/config/settings.py | 3 +- jobfunnel/resources/defaults.py | 27 ++---------------- 8 files changed, 49 insertions(+), 50 deletions(-) diff --git a/demo/settings.yaml b/demo/settings.yaml index a33227b0..9e1059f6 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -1,7 +1,12 @@ # This is an example settings YAML, it closely mirrors our default search # Path where your master CSV, block-lists, and cache data will be stored -output_path: demo_search +# NOTE: when you are using CLI, you can just specify output_folder and we +# will calculate these paths for you. +master_csv_file: demo_search.csv +cache_folder: .demo_cache # NOTE: this will be created if it doesn't exist +block_list_file: .demo_cache/demo_block_list.json +duplicates_list_file: .demo_cache/demo_bduplicates_list.json # Job search configuration search: @@ -52,9 +57,9 @@ delay: # Minimum delay/lower bound for random delay min_duration: 1.0 # Random delay - random_delay: False + random: False # Converging random delay, only used if 'random' is set to True - converging_random_delay: False + converging: False # # Proxy settings # proxy: diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index fe76181b..3bfc82e8 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -114,13 +114,13 @@ def init_logging(self) -> None: """Initialize a logger NOTE: make stdout format more configurable? """ - self.logger = logging.getLogger() + self.logger = logging.getLogger('jobfunnel') self.logger.setLevel(self.config.log_level) logging.basicConfig( filename=self.config.log_file, level=self.config.log_level, ) - formatter = logging.Formatter('[%(levelname)s] %(message)s') + formatter = logging.Formatter('[%(levelname)s] jobfunnel: %(message)s') stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) self.logger.addHandler(stdout_handler) @@ -136,7 +136,7 @@ def scrape(self) ->Dict[str, Job]: for scraper_cls in self.config.scrapers: # FIXME: need to add the threader and delaying here start = time() - scraper = scraper_cls(self.session, self.config, self.logger) + scraper = scraper_cls(self.session, self.config) # TODO: add a warning for overwriting different jobs with same key jobs.update(scraper.scrape()) end = time() diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 23d2466d..b3b91138 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -4,6 +4,7 @@ import logging import os import random +import sys from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed from time import sleep, time @@ -23,14 +24,16 @@ class BaseScraper(ABC): """Base scraper object, for scraping and filtering Jobs from a provider """ - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger) -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: self.session = session self.config = config - self.logger = logger + self.logger = None if self.headers: self.session.headers.update(self.headers) + # Init logging + self.init_logging() + # Ensure that the locale we want to use matches the locale that the # scraper was written to scrape in: if self.config.search_config.locale != self.locale: @@ -108,6 +111,23 @@ def headers(self) -> Dict[str, str]: """ pass + def init_logging(self) -> None: + """Initialize a logger which displays clearly the name of the scraper + TODO: make this less of a duplication of JobFunnel.init_logging() + """ + self.logger = logging.getLogger(self.__class__.__name__) + self.logger.setLevel(self.config.log_level) + logging.basicConfig( + filename=self.config.log_file, + level=self.config.log_level, + ) + formatter = logging.Formatter( + f'[%(levelname)s] {self.__class__.__name__}: %(message)s' + ) + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(formatter) + self.logger.addHandler(stdout_handler) + def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID @@ -278,6 +298,7 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: def _validate_get_set(self) -> None: """Ensure the get/set actions cover all need attribs and dont intersect + TODO: we should link a helpful article on how to implement get/set mthds """ set_job_get_fields = set(self.job_get_fields) set_job_set_fields = set(self.job_set_fields) @@ -287,15 +308,15 @@ def _validate_get_set(self) -> None: set_missing_req_fields = set_min_fields - all_set_get_fields if set_missing_req_fields: raise ValueError( - f"Job attributes: {set_missing_req_fields} are required and not" - f" implemented by {self.__class__.__name__}" + f"Scraper: {self.__class__.__name__} Job attributes: " + f"{set_missing_req_fields} are required and not implemented." ) field_intersection = set_job_get_fields.intersection(set_job_set_fields) if field_intersection: raise ValueError( - f"Job attributes: {field_intersection} are implemented by both" - f"get() and set() methods of {self.__class__.__name__}" + f"Scraper: {self.__class__.__name__} Job attributes: " + f"{field_intersection} are implemented by both get() and set()!" ) for field in JobField: # NOTE: we exclude status, locale, query, provider and scrape date @@ -308,7 +329,7 @@ def _validate_get_set(self) -> None: and field not in self.job_set_fields): self.logger.warning( f"No get() or set() will be done for Job attr: {field.name}" - ) + ) # NOTE: we have the class name in the logger format # Just some basic localized scrapers, you can inherit these to set the locale. diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index 37c2b6e8..206a8f48 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -40,11 +40,10 @@ class BaseGlassDoorScraper(BaseScraper): - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger): + def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: """Init that contains glassdoor specific stuff """ - super().__init__(session, config, logger) + super().__init__(session, config) self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE self.query = '-'.join(self.config.search_config.keywords) # self.driver = get_webdriver() TODO: we can use this if-needed diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 41fe2d4a..08f51843 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -29,11 +29,10 @@ class BaseIndeedScraper(BaseScraper): """Scrapes jobs from www.indeed.X """ - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger) -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: """Init that contains indeed specific stuff """ - super().__init__(session, config, logger) + super().__init__(session, config) self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE self.query = '+'.join(self.config.search_config.keywords) diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index a909232a..503e1d7e 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -31,11 +31,10 @@ class BaseMonsterScraper(BaseScraper): """Scraper for www.monster.X """ - def __init__(self, session: Session, config: 'JobFunnelConfig', - logger: logging.Logger) -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: """Init that contains monster specific stuff """ - super().__init__(session, config, logger) + super().__init__(session, config) self.query = '-'.join( self.config.search_config.keywords ).replace(' ', '-') diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index e8fc441e..3a65a724 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -24,9 +24,8 @@ 'type': 'string', }, 'duplicates_list_file': { - 'required': False, + 'required': True, 'type': 'string', - 'default': DEFAULT_DUPLICATES_FILE, }, 'no_scrape': { 'required': False, diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 5e3df51e..23d02623 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -1,34 +1,11 @@ -"""Default settings YAML used for every search without cli args +"""Default arguments for both JobFunnelConfig and CLI arguments. +NOTE: we include defaults for all arguments so that JobFunnel is plug-n-play """ import os import logging from pathlib import Path from jobfunnel.resources.enums import Locale, DelayAlgorithm, Provider -# Below defs constructs: -# output_path: search -# log_level: INFO - -# locale: -# CANADA_ENGLISH - -# providers: -# - INDEED -# # - GLASSDOOR -# # - MONSTER - -# search: -# region: -# province_or_state: "ON" -# city: "Waterloo" -# radius: 25 -# keywords: -# - Python - -# delay: -# algorithm: LINEAR -# max_duration: 5.0 -# min_duration: 1.0 USER_HOME_DIRECTORY = os.path.abspath(str(Path.home())) DEFAULT_LOG_LEVEL_NAME = 'INFO' From a12423627e2d19b84b395b5ca8141b6e85bd65b4 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 21 Aug 2020 22:59:26 -0400 Subject: [PATCH 22/66] Added json entry property to Job, improved logging format, improved cache handling, moved job default init kwargs into BaseScraper property, made a re-usable get_logging method, reworking vectorizer filter. --- jobfunnel/backend/job.py | 21 +- jobfunnel/backend/jobfunnel.py | 320 ++++++++++++++++++---------- jobfunnel/backend/scrapers/base.py | 76 ++++--- jobfunnel/backend/tools/__init__.py | 4 +- jobfunnel/backend/tools/filters.py | 224 +++++++++++-------- jobfunnel/backend/tools/tools.py | 40 +++- jobfunnel/resources/resources.py | 5 +- 7 files changed, 440 insertions(+), 250 deletions(-) diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index d2d90049..bc2a3855 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Optional, List from jobfunnel.resources import ( - Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS + Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS, MAX_BLOCK_LIST_DESC_CHARS ) @@ -136,6 +136,25 @@ def as_row(self) -> Dict[str, str]: ) ]) + @property + def as_json_entry(self) -> Dict[str, str]: + """This formats a job for the purpose of saving it to a block JSON + i.e. duplicates list file or user's block list file + NOTE: we truncate descriptions in block lists, TODO: use 'short' desc + """ + return { + 'title': self.title, + 'company': self.company, + 'post_date': self.post_date.strftime('%Y-%m-%d'), + 'description': ( + self.description[:MAX_BLOCK_LIST_DESC_CHARS] + + '..' + ) + if len(self.description) > MAX_BLOCK_LIST_DESC_CHARS + else self.description, + 'status': self.status.name, + } + def clean_strings(self) -> None: """Ensure that all string fields have only printable chars FIXME: do this automatically upon assignment (override assignment) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 3bfc82e8..25cf3d01 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -10,22 +10,24 @@ from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime from time import time -from typing import Dict, List +from typing import Dict, List, Optional from requests import Session from jobfunnel.backend import Job from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter +from jobfunnel.backend.tools import update_job_if_newer, get_logger from jobfunnel.config import JobFunnelConfig from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, - MAX_CPU_WORKERS, JobStatus, Locale) + MAX_CPU_WORKERS, JobStatus, Locale, + MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH) class JobFunnel: """Class that initializes a Scraper and scrapes a website to get jobs """ - def __init__(self, config: JobFunnelConfig): + def __init__(self, config: JobFunnelConfig) -> None: """Initialize a JobFunnel object, with a JobFunnel Config Args: @@ -34,9 +36,14 @@ def __init__(self, config: JobFunnelConfig): self.config = config self.config.create_dirs() self.config.validate() - self.logger = None + self.logger = get_logger( + self.__class__.__name__, + self.config.log_level, + self.config.log_file, + f"[%(asctime)s] [%(levelname)s] {self.__class__.__name__}: " + "%(message)s" + ) self.__date_string = date.today().strftime("%Y-%m-%d") - self.init_logging() # Open a session with/out a proxy configured self.session = Session() @@ -60,76 +67,76 @@ def run(self) -> None: NOTE: we are assuming the user has distinct cache folder per-search, otherwise we will load the cache for today, for a different search! """ - # Parse the master list path to update our block list + # Load master csv jobs if they exist and update our block list with + # any jobs the user has set the status to == a remove status # NOTE: we want to do this first to ensure scraping is efficient when # we are getting detailed job information (per-job) - self.update_block_list() + master_jobs_dict = {} # type: Dict[str, Job[ + if os.path.isfile(self.config.master_csv_file): + master_jobs_dict = self.read_master_csv() + self.update_user_block_list(master_jobs_dict) + else: + logging.debug( + "No master-CSV present, did not update block-list: " + f"{self.config.user_block_list_file}" + ) - # Get jobs keyed by their unique ID, use cache if we scraped today - jobs_dict = {} # type: Dict[str, Job] + # Get jobs keyed by their unique ID, use cache if --no-scrape is set + scraped_jobs_dict = {} # type: Dict[str, Job] if os.path.exists(self.daily_cache_file): - jobs_dict = self.load_cache(self.daily_cache_file) + scraped_jobs_dict = self.load_cache(self.daily_cache_file) elif self.config.no_scrape: self.logger.warning( f"No jobs cached, missing: {self.daily_cache_file}" ) + # Scrape and writeout the cache if self.config.no_scrape: self.logger.info("Skipping scraping, running with --no-scrape.") else: - jobs_dict = self.scrape() # type: Dict[str, Job] - self.write_cache(jobs_dict) + scraped_jobs_dict = self.scrape() # type: Dict[str, Job] + self.write_cache(scraped_jobs_dict) + + # Pre-filter by removing jobs with duplicate IDs from scraped_jobs_dict + if master_jobs_dict: + self.filter_duplicates( + scraped_jobs_dict, master_jobs_dict, by_key_id_only=True, + ) # Filter out scraped jobs we have rejected, archived or block-listed - # (before we add them to the CSV) - self.filter(jobs_dict) - - # Load and update existing masterlist - if os.path.exists(self.config.master_csv_file): - - # Identify duplicate jobs using the existing masterlist - masterlist = self.read_master_csv() # type: Dict[str, Job] - self.filter(masterlist) # NOTE: this reduces size of masterlist - try: - tfidf_filter(jobs_dict, masterlist) - except ValueError as err: - self.logger.error( - f"Skipping similarity filter due to: {str(err)}" - ) + # or which we previously detected to be duplicates before updating CSV. + self.filter(scraped_jobs_dict) - # Expand the masterlist with filters, non-duplicated jobs & save - masterlist.update(jobs_dict) - self.write_master_csv(masterlist) + # Update master CSV iif we have one + if master_jobs_dict: + + # Mabye reduce the size of master_jobs (may have blocked new jobs) + self.filter(master_jobs_dict) + + # Filter out duplicates and update duplicates list file + # NOTE: this will match duplicates by job description contents + self.filter_duplicates(scraped_jobs_dict, master_jobs_dict) + + # Expand master_jobs_dict with filtered, non-duplicated jobs & save + # NOTE: this may be an empty update.. TODO: save the write call? + master_jobs_dict.update(scraped_jobs_dict) + self.write_master_csv(master_jobs_dict) else: - # FIXME: we should still remove duplicates within jobs_dict? # Dump the results into the data folder as the masterlist - self.write_master_csv(jobs_dict) + # FIXME: we could still detect duplicates within the CSV itself? + self.write_master_csv(scraped_jobs_dict) self.logger.info( f"Done. View your current jobs in {self.config.master_csv_file}" ) - def init_logging(self) -> None: - """Initialize a logger - NOTE: make stdout format more configurable? - """ - self.logger = logging.getLogger('jobfunnel') - self.logger.setLevel(self.config.log_level) - logging.basicConfig( - filename=self.config.log_file, - level=self.config.log_level, - ) - formatter = logging.Formatter('[%(levelname)s] jobfunnel: %(message)s') - stdout_handler = logging.StreamHandler(sys.stdout) - stdout_handler.setFormatter(formatter) - self.logger.addHandler(stdout_handler) - self.logger.info(f"JobFunnel initialized at {self.__date_string}") - def scrape(self) ->Dict[str, Job]: """Run each of the desired Scraper.scrape() with threading and delaying """ - self.logger.info(f"Starting scraping for: {self.config.scraper_names}") + self.logger.info( + f"Scraping local providers with: {self.config.scraper_names}" + ) # Iterate thru scrapers and run their scrape. jobs = {} # type: Dict[str, Job] @@ -140,7 +147,7 @@ def scrape(self) ->Dict[str, Job]: # TODO: add a warning for overwriting different jobs with same key jobs.update(scraper.scrape()) end = time() - self.logger.info( + self.logger.debug( f"Scraped {len(jobs.items())} jobs from {scraper_cls.__name__}," f" took {(end - start):.3f}s" ) @@ -148,7 +155,7 @@ def scrape(self) ->Dict[str, Job]: self.logger.info(f"Completed all scraping, found {len(jobs)} new jobs.") return jobs - def recover(self): + def recover(self) -> None: """Build a new master CSV from all the available pickles in our cache """ self.logger.info("Recovering jobs from all cache files in cache folder") @@ -170,42 +177,61 @@ def recover(self): self.write_master_csv(all_jobs_dict) def load_cache(self, cache_file: str) -> Dict[str, Job]: + """Load today's scrape data from pickle via date string + TODO: search the cache for pickles that match search config. (we may need a registry for the pickles and seach terms used) + + Args: + cache_file (str): path to cache pickle file containing jobs dict + keyed by Job.KEY_ID. + + Raises: + FileNotFoundError: if cache file is missing + + Returns: + Dict[str, Job]: [description] """ - try: - jobs_dict = pickle.load(open(cache_file, 'rb')) - except FileNotFoundError as e: - self.logger.error( + if not os.path.exists(cache_file): + raise FileNotFoundError( f"{cache_file} not found! Have you scraped any jobs today?" ) - raise e - self.logger.info( - f"Read {len(jobs_dict.keys())} cached jobs from: {cache_file}" - ) - return jobs_dict + else: + jobs_dict = pickle.load(open(cache_file, 'rb')) + self.logger.info( + f"Read {len(jobs_dict.keys())} jobs from previously-scraped " + f"jobs cache: {cache_file}." + ) + self.logger.debug( + "NOTE: you may see many duplicate IDs detected if these jobs " + "exist in your master CSV already." + ) + return jobs_dict def write_cache(self, jobs_dict: Dict[str, Job], cache_file: str = None) -> None: """Dump a jobs_dict into a pickle + TODO: write search_config into the cache file and jobfunnel version FIXME: some way to cache raw data without recur-limit + + Args: + jobs_dict (Dict[str, Job]): jobs dict to dump into cache. + cache_file (str, optional): file path to write to. Defaults to None. """ + cache_file = cache_file if cache_file else self.daily_cache_file pickle.dump(jobs_dict, open(cache_file, 'wb')) - self.logger.info( + self.logger.debug( f"Dumped {len(jobs_dict.keys())} jobs to {cache_file}" ) def read_master_csv(self) -> Dict[str, Job]: """Read in the master-list CSV to a dict of unique Jobs - Args: - key_by_id (bool, optional): key jobs by ID, return as a List[Job] if - False. Defaults to True.1 - TODO: update from legacy CSV header for short & long description + TODO: the header contents should match JobField names Returns: Dict[str, Job]: unique Job objects in the CSV @@ -280,7 +306,7 @@ def read_master_csv(self) -> Dict[str, Job]: job.validate() jobs_dict[job.key_id] = job - self.logger.info( + self.logger.debug( f"Read {len(jobs_dict.keys())} jobs from master-CSV: " f"{self.config.master_csv_file}" ) @@ -298,50 +324,62 @@ def write_master_csv(self, jobs: Dict[str, Job]) -> None: for job in jobs.values(): job.validate() writer.writerow(job.as_row) - self.logger.info( + self.logger.debug( f"Wrote {len(jobs)} jobs to {self.config.master_csv_file}" ) - def update_block_list(self): - """Read the master CSV file and pop jobs by status into our user block - list (which is a JSON). + def update_user_block_list(self, + master_jobs_dict: Optional[Dict[str, Job]] = None + ) -> None: + """From data in master CSV file, add jobs with removeable statuses to + our configured user block list file and save (if any) + + NOTE: we assume that the contents of master_jobs_dict match the contents + returned by self.read_master_csv, passing this argument just saves us + loading twice in jobfunnel.run() NOTE: adding jobs to block list will result in filter() removing them from all scraped & cached jobs in the future. - NOTE: we truncate descriptions in the block list + Args: + master_jobs_dict (Optional[Dict[str, Job]], optional): the existing + jobs in the user's master CSV file. If None we will load from + CSV or raise an error if CSV does not exist. + Raises: + FileNotFoundError: if no master_jobs_dict is provided and master csv + file does not exist. """ - if os.path.isfile(self.config.master_csv_file): - # Load existing filtered jobs, if any - if os.path.isfile(self.config.user_block_list_file): - blocked_jobs_dict = json.load( - open(self.config.user_block_list_file, 'r') - ) + # Load from CSV if not passed by argument + if not master_jobs_dict: + if os.path.isfile(self.config.master_csv_file): + master_jobs_dict or self.read_master_csv() else: - blocked_jobs_dict = {} - - # Add jobs from csv that need to be filtered away, if any - n_jobs_added = 0 - for job in self.read_master_csv().values(): - if job.is_remove_status and job.key_id not in blocked_jobs_dict: - n_jobs_added += 1 - logging.info( - f'Added {job.key_id} to ' - f'{self.config.user_block_list_file}' - ) - blocked_jobs_dict[job.key_id] = { - 'title': job.title, - 'post_date': job.post_date.strftime('%Y-%m-%d'), - 'description': ( - job.description[:MAX_BLOCK_LIST_DESC_CHARS] - + '..' - ) - if len(job.description) > MAX_BLOCK_LIST_DESC_CHARS - else job.description, - 'status': job.status.name, - } + raise FileNotFoundError( + f"Cannot update {self.config.user_block_list_file} without " + f"{self.config.master_csv_file}" + ) + # Load existing filtered jobs, if any + if os.path.isfile(self.config.user_block_list_file): + blocked_jobs_dict = json.load( + open(self.config.user_block_list_file, 'r') + ) + else: + blocked_jobs_dict = {} + + # Add jobs from csv that need to be filtered away, if any + n_jobs_added = 0 + for job in master_jobs_dict.values(): + if job.is_remove_status and job.key_id not in blocked_jobs_dict: + n_jobs_added += 1 + blocked_jobs_dict[job.key_id] = job.as_json_entry + logging.info( + f'Added {job.key_id} to ' + f'{self.config.user_block_list_file}' + ) + + if n_jobs_added: # Write out complete list with any additions from the masterlist # NOTE: we use indent=4 so that it stays human-readable. with open(self.config.user_block_list_file, 'w', @@ -356,13 +394,8 @@ def update_block_list(self): ) ) self.logger.info( - f"Added {n_jobs_added} jobs to block-list: " - f"{self.config.user_block_list_file}" - ) - else: - logging.info( - "No master-CSV present, did not update block-list: " - f"{self.config.user_block_list_file}" + f"Moved {n_jobs_added} jobs into block-list due to removable " + f"statuses: {self.config.user_block_list_file}" ) def filter(self, jobs_dict: Dict[str, Job]) -> int: @@ -373,8 +406,10 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: Returns the number of filtered jobs - TODO: would be cool if we could run TFIDF in here too - FIXME: load the global block-list as well + NOTE: this also removes any duplicates from jobs_dict if a duplicates + list file is configured. + + TODO: make the filters used configurable, i.e. list of FilterType """ # Read the user's block list block_dict = {} # type: Dict[str, Job] @@ -395,7 +430,8 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: filter_jobs_ids = [] for key_id, job in jobs_dict.items(): if (job.is_remove_status - or job.company in self.config.search_config.blocked_company_names + or job.company in + self.config.search_config.blocked_company_names or key_id in block_dict or key_id in duplicates_dict or job_is_old(job, self.config.search_config.max_listing_days)): @@ -406,9 +442,67 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: n_filtered = len(filter_jobs_ids) if n_filtered > 0: - self.logger.info(f'Filtered-out {n_filtered} jobs from results.') - else: - # TODO: print a % of jobs that are new /etc here. - self.logger.info(f'No jobs were filtered from results.') + self.logger.info( + f"Removed {n_filtered} job(s) from scraped data, jobs are " + "blocked/removed, old, or content-duplicates of jobs in " + "master CSV." + ) return n_filtered + + + def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], + existing_jobs_dict: Dict[str, Job], + by_key_id_only: bool = False) -> None: + """Identify duplicate jobs between scrape data and existing_jobs_dict + and update the duplicates block list if any are found by contents. + + TODO: move this into self.filter() which should be more configurable + TODO: make max_similarity configurable i.e. self.config.filter... + TODO: we are wrapping in a try/catch because TFIDF filter is missing + some error handling. Remove once it is safer to use w.out crashing + NOTE: only duplicates detected by job contents will be written to + the duplicates_list_file JSON, as checking by key_id is not + an expensive comparison vs full TFIDF vectorization. + NOTE: when we detect that an existing job is a duplicate of a new job + we update the existing job with the new job's post date and other + information. (only if post date is newer!) + + Args: + scraped_jobs_dict (Dict[str, Job]): currently scraped jobs dict + existing_jobs_dict (Dict[str, Job]): existing jobs dict i.e. master + by_key_id_only (bool, optional): if True, only remove duplicates + via key_id. If false, use the contents of the jobs to identify + duplicates as well (NOTE: currently only TFIDF filter for desc). + """ + # First we need to remove any duplicates by id directly + for key_id in existing_jobs_dict: + if key_id in scraped_jobs_dict: + duplicate_job = scraped_jobs_dict.pop(key_id) + if update_job_if_newer(existing_jobs_dict[key_id], + duplicate_job): + self.logger.debug( + f"Updated job {key_id} with duplicate's contents." + ) + + # If we have any jobs left, filter these using their contents. + if scraped_jobs_dict: + if (len(scraped_jobs_dict.keys()) + len(existing_jobs_dict.keys()) + >= MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH): + try: + tfidf_filter( + cur_dict=scraped_jobs_dict, + prev_dict=existing_jobs_dict, + log_level=self.config.log_level, + log_file=self.config.log_file, + duplicate_jobs_file=self.config.duplicates_list_file, + ) + except ValueError as err: + self.logger.error( + f"Skipping similarity filter due to error: {str(err)}" + ) + else: + self.logger.warning( + "Skipping similarity filter because there are fewer than " + f"{MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH} jobs." + ) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index b3b91138..b2874afc 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -16,6 +16,7 @@ from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools.delay import calculate_delays +from jobfunnel.backend.tools import get_logger from jobfunnel.resources import (MAX_CPU_WORKERS, USER_AGENT_LIST, JobField, Locale) # from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue @@ -27,13 +28,16 @@ class BaseScraper(ABC): def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: self.session = session self.config = config - self.logger = None + self.logger = get_logger( + self.__class__.__name__, + self.config.log_level, + self.config.log_file, + f"[%(asctime)s] [%(levelname)s] {self.__class__.__name__}: " + "%(message)s" + ) if self.headers: self.session.headers.update(self.headers) - # Init logging - self.init_logging() - # Ensure that the locale we want to use matches the locale that the # scraper was written to scrape in: if self.config.search_config.locale != self.locale: @@ -55,6 +59,28 @@ def user_agent(self) -> str: """ return random.choice(USER_AGENT_LIST) + @property + def job_init_kwargs(self) -> Dict[JobField, Any]: + """This is a helper property that stores a Dict of JobField : value that + we set defaults for when scraping. If the scraper fails to get/set these + we can fail back to the empty value from here. + + i.e. JobField.POST_DATE defaults to today. + TODO: formalize the defaults for JobFields via Job.__init__(Jobfields... + """ + return { + JobField.STATUS: JobStatus.NEW, + JobField.LOCALE: self.locale, + JobField.QUERY: self.config.search_config.query_string, + JobField.DESCRIPTION: '', + JobField.URL: '', + JobField.SHORT_DESCRIPTION: '', + JobField.RAW: None, + JobField.PROVIDER: self.__class__.__name__, + JobField.REMOTE: '', + JobField.WAGE: '', + } + @property @abstractmethod def min_required_job_fields(self) -> List[JobField]: @@ -111,23 +137,6 @@ def headers(self) -> Dict[str, str]: """ pass - def init_logging(self) -> None: - """Initialize a logger which displays clearly the name of the scraper - TODO: make this less of a duplication of JobFunnel.init_logging() - """ - self.logger = logging.getLogger(self.__class__.__name__) - self.logger.setLevel(self.config.log_level) - logging.basicConfig( - filename=self.config.log_file, - level=self.config.log_level, - ) - formatter = logging.Formatter( - f'[%(levelname)s] {self.__class__.__name__}: %(message)s' - ) - stdout_handler = logging.StreamHandler(sys.stdout) - stdout_handler.setFormatter(formatter) - self.logger.addHandler(stdout_handler) - def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID @@ -152,8 +161,9 @@ def scrape(self) -> Dict[str, Job]: ) # Calculate delays for get/set calls per-job NOTE: only get/set - # calls in self.delayed_get_set_fields will be delayed. - delays = calculate_delays(n_soups, self.config.delay_config) + # calls in self.delayed_get_set_fields will be delayed. FIXME: remove bypass! + import numpy as np + delays = np.ones(n_soups) * 0.1 #calculate_delays(n_soups, self.config.delay_config) results = [] for job_soup, delay in zip(job_soups, delays): results.append( @@ -191,26 +201,13 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: Returns: Job: job object constructed from the soup and localization of class """ - # Init kwargs which allow for defaults + known information - job_init_kwargs = { - JobField.STATUS: JobStatus.NEW, - JobField.LOCALE: self.locale, - JobField.QUERY: self.config.search_config.query_string, - JobField.DESCRIPTION: '', - JobField.URL: '', - JobField.SHORT_DESCRIPTION: '', # TODO: impl. - JobField.RAW: None, - JobField.PROVIDER: self.__class__.__name__, - JobField.REMOTE: '', - JobField.WAGE: '', - } # type: Dict[JobField, Any] - # Formulate the get/set actions actions_list = [(True, f) for f in self.job_get_fields] actions_list += [(False, f) for f in self.job_set_fields] # Scrape the data for the post, requiring a minimum of info... job = None # type: Union[None, Job] + job_init_kwargs = self.job_init_kwargs # NOTE: best to construct once for is_get, field in actions_list: # Respectfully delay if it's configured to do so. @@ -329,10 +326,11 @@ def _validate_get_set(self) -> None: and field not in self.job_set_fields): self.logger.warning( f"No get() or set() will be done for Job attr: {field.name}" - ) # NOTE: we have the class name in the logger format + ) -# Just some basic localized scrapers, you can inherit these to set the locale. +# Just some basic localized scrapers, you can inherit these to set the locale. +# TODO: move into own file once we get enough of em... class BaseUSAEngScraper(BaseScraper): """Localized scraper for USA English """ diff --git a/jobfunnel/backend/tools/__init__.py b/jobfunnel/backend/tools/__init__.py index ac522fd2..d49cac65 100644 --- a/jobfunnel/backend/tools/__init__.py +++ b/jobfunnel/backend/tools/__init__.py @@ -1 +1,3 @@ -from jobfunnel.backend.tools.tools import get_webdriver +from jobfunnel.backend.tools.tools import ( + get_webdriver, update_job_if_newer, get_logger +) diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 0f9f7682..e80ca96e 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -1,15 +1,21 @@ """Filters that are used in jobfunnel's filter() method or as intermediate -filters to reduce un-necessesary scraping +filters to reduce un-necessesary scraping. +FIXME: we should have a Enum(Filter) for all job filters to allow configuration +and generic log messages. """ -import nltk import logging -from datetime import datetime, date, timedelta +from datetime import date, datetime, timedelta +from typing import Dict, List, Optional +import json + +import nltk +import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity -from typing import Dict, Optional -from numpy import delete as np_delete, max as np_max, fill_diagonal from jobfunnel.backend import Job +from jobfunnel.backend.tools import update_job_if_newer, get_logger +from jobfunnel.resources import DEFAULT_MAX_TFIDF_SIMILARITY T_NOW = datetime.now() @@ -36,20 +42,51 @@ def job_is_old(job: Job, number_of_days: int) -> bool: def tfidf_filter(cur_dict: Dict[str, dict], prev_dict: Optional[Dict[str, dict]] = None, - max_similarity: float = 0.75): - """ Fit a tfidf vectorizer to a corpus of all listing's text. - - TODO: this should handle better empty inputs - - Args: - cur_dict: today's job scrape dict - prev_dict: the existing master list job dict - max_similarity: threshold above which blurb similarity = duplicate - - Returns: - list of duplicate job ids which were removed from cur_dict + max_similarity: float = DEFAULT_MAX_TFIDF_SIMILARITY, + duplicate_jobs_file: Optional[str] = None, + log_level: int = logging.INFO, + log_file: str = None, + ) -> List[Job]: + """Fit a tfidf vectorizer to a corpus of Job.DESCRIPTIONs and identify + duplicate jobs by cosine-similarity. + + NOTE: This will update jobs in cur_dict if the content match has a newer + post_date. + FIXME: we should make max_similarity configurable in SearchConfig + FIXME: this should be integrated into jobfunnel.filter with other filters + FIXME: fix logger arg-passing once we get this in some kind of class + NOTE: this only uses job descriptions to do the content matching. + NOTE: it is recommended that you have at least around 25 Jobs. + TODO: have this raise an exception if there are too few words? + FIXME: make this a class so we can call it many times on single queries. + + Args: + cur_dict (Dict[str, dict]): dict of jobs containing potential duplicates + (i.e jobs we just scraped) + prev_dict (Optional[Dict[str, dict]], optional): the existing jobs dict + (i.e. master CSV contents). If None, we will remove duplicates + from within the cur_dict only. Defaults to None. + max_similarity (float, optional): threshold above which blurb similarity + is considered a duplicate. Defaults to DEFAULT_MAX_TFIDF_SIMILARITY. + duplicate_jobs_file (str, optional): location to save duplicates that + we identify via content matching. Defaults to None. + ... + + Raises: + ValueError: cur_dict contains no job descriptions + + Returns: + List[Job]: list of duplicate Jobs which were removed from cur_dict """ - # retrieve stopwords if not already downloaded + logger = get_logger( + tfidf_filter.__name__, + log_level, + log_file, + f"[%(asctime)s] [%(levelname)s] {tfidf_filter.__name__}: %(message)s" + ) + + # Retrieve stopwords if not already downloaded + # TODO: we should use this to make jobs attrs tokenizable as a property. try: stopwords = nltk.corpus.stopwords.words('english') except LookupError: @@ -57,80 +94,89 @@ def tfidf_filter(cur_dict: Dict[str, dict], stopwords = nltk.corpus.stopwords.words('english') # init vectorizer - vectorizer = TfidfVectorizer(strip_accents='unicode', lowercase=True, - analyzer='word', stop_words=stopwords) - - # init list to store duplicate ids - duplicate_ids = {} - - if prev_dict: - # checks current scrape for re-posts/duplicates - duplicate_ids = tfidf_filter(cur_dict) - - # get query words and ids as lists - query_ids, query_words = [], [] + vectorizer = TfidfVectorizer( + strip_accents='unicode', + lowercase=True, + analyzer='word', + stop_words=stopwords, + ) + + # TODO: assert on length of contents of the lists + combine into one method + # Get query words and ids as lists for convenience + query_ids = [] # type: List[str] + query_words = [] # type: List[str] for job in cur_dict.values(): - query_ids.append(job.key_id) if len(job.description) > 0: + query_ids.append(job.key_id) query_words.append(job.description) + if not query_words: - raise ValueError( - "No query strings to fit, are all of your job descriptions empty?" + raise ValueError("No data to fit, are your job descriptions all empty?") + + # Get reference words as list + reference_ids = [] # type: List[str] + reference_words = [] # type: List[str] + for job in prev_dict.values(): + if len(job.description) > 0: + reference_ids.append(job.key_id) + reference_words.append(job.description) + + if not reference_words: + raise ValueError("No data to fit, are your job descriptions all empty?") + + # Fit vectorizer to entire corpus + vectorizer.fit(query_words + reference_words) + + # Calculate cosine similarity between reference and current blurbs + # This is a list of the similarity between that query job and all the + # TODO: impl. in a more efficient way since fit() does the transform already + similarities_per_query = cosine_similarity( + vectorizer.transform(query_words), + vectorizer.transform(reference_words), + ) + + # Get duplicate job ids and pop them, updating cur_dict if they are newer + duplicate_jobs_list = [] # type: List[Job] + for query_similarities, query_id in zip(similarities_per_query, query_ids): + + # Identify the jobs in prev_dict that our query is a duplicate of + # FIXME: handle if everything is highly similar! + for similar_index in np.where(query_similarities >= max_similarity)[0]: + update_job_if_newer( + prev_dict[reference_ids[similar_index]], + cur_dict[query_id], + ) + duplicate_jobs_list.append(cur_dict.pop(query_id)) + logger.debug( + f"Removed {query_id} from scraped data, TFIDF content match." + ) + + # Save to our duplicates file if any are detected exist + if duplicate_jobs_list: + + logger.info( + f'Found and removed {len(duplicate_jobs_list)} ' + f're-posts/duplicate postings via TFIDF cosine similarity.' ) - if prev_dict is None: - # returns cosine similarity between jobs as square matrix (n,n) - similarities = cosine_similarity(vectorizer.fit_transform(query_words)) - # fills diagonals with 0, so whole dict does not get popped - fill_diagonal(similarities, 0) - # init index - index = 0 - # identifies duplicates and stores them in duplicate ids dictionary - while True: - # loop breaks when index is equal to matrix height - if index == len(similarities): - break - - # deletes row and column, every time a max is found for a job id - if np_max(similarities[index]) >= max_similarity: - # query ids are popped so index always matches correct element - duplicate_ids.update( - {query_ids[index]: cur_dict.pop(query_ids.pop(index))}) - # reduce matrix dimensions, (n-1, n-1) - similarities = np_delete(similarities, index, axis=0) - similarities = np_delete(similarities, index, axis=1) - - else: # increment index by one - index += 1 - # log something - logging.info(f'Found and removed {len(duplicate_ids.keys())} ' - f're-posts/duplicates via TFIDF cosine similarity.') - else: - # get reference words as list - reference_words = [job.description for job in prev_dict.values()] - - # fit vectorizer to entire corpus - vectorizer.fit(query_words + reference_words) - - # set reference tfidf for cosine similarity later - references = vectorizer.transform(reference_words) - - # calculate cosine similarity between reference and current blurbs - similarities = cosine_similarity( - vectorizer.transform(query_words), references) - - # get duplicate job ids and pop them - for sim, query_id in zip(similarities, query_ids): - if np_max(sim) >= max_similarity: - duplicate_ids.update({query_id: cur_dict.pop(query_id)}) - - # FIXME: this message is wrong and we see it after above message. - # # log something - # logging.info( - # f'Found {len(cur_dict.keys())} unique listings and ' - # f'{len(duplicate_ids.keys())} duplicates ' - # 'via TFIDF cosine similarity' - # ) - - # returns a dictionary of duplicate key_ids - return duplicate_ids + # NOTE: we use indent=4 so that it stays human-readable. + if duplicate_jobs_file: + with open(duplicate_jobs_file, 'w', encoding='utf8') as outfile: + outfile.write( + json.dumps( + {dj.key_id: dj.as_json_entry + for dj in duplicate_jobs_list}, + indent=4, + sort_keys=True, + separators=(',', ': '), + ensure_ascii=False, + ) + ) + else: + logger.warning( + "Duplicates will not be saved, no duplicates list file set. " + "Saving to a duplicates file will ensure that these persist." + ) + + # returns a list of duplicate Jobs + return duplicate_jobs_list diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 87ec74ef..059a7f63 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -1,16 +1,19 @@ """Assorted tools for all aspects of funnelin' that don't fit elsewhere """ +import logging import re +import sys from datetime import date, datetime, timedelta -from dateutil.relativedelta import relativedelta +from dateutil.relativedelta import relativedelta +from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager -from webdriver_manager.microsoft import IEDriverManager -from webdriver_manager.microsoft import EdgeChromiumDriverManager -from webdriver_manager.opera import OperaDriverManager from webdriver_manager.firefox import GeckoDriverManager -from selenium import webdriver +from webdriver_manager.microsoft import (EdgeChromiumDriverManager, + IEDriverManager) +from webdriver_manager.opera import OperaDriverManager +from jobfunnel.backend import Job # Initialize list and store regex objects of date quantifiers HOUR_REGEX = re.compile(r'(\d+)(?:[ +]{1,3})?(?:hour|hr)') @@ -21,6 +24,33 @@ RECENT_REGEX_B = re.compile(r'[yY]esterday') +def get_logger(logger_name: str, log_level: int, filename: str, + message_format: str) -> logging.Logger: + """Initialize and return a logger + TODO: make this a class so we can inherit it into any class + TODO: make more easily configurable w/ defaults + TODO: streamline + """ + logger = logging.getLogger(logger_name) + logger.setLevel(log_level) + logging.basicConfig(filename=filename, level=log_level) + formatter = logging.Formatter(message_format) + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(formatter) + logger.addHandler(stdout_handler) + return logger + + +def update_job_if_newer(existing_job: Job, new_job: Job) -> None: + """Update an existing job with new metadata but keep user's status, + but only if the new_job.post_date > existing_job.post_date! + + Returns: True if existing job was updated + """ + if (new_job.post_date > existing_job.post_date): + new_job.status = existing_job.status + existing_job = new_job + def calc_post_date_from_relative_str(date_str: str) -> date: """Identifies a job's post date via post age, updates in-place """ diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index b0c6c835..625e2c5a 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -14,9 +14,10 @@ 'CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET' ] -# Maximum num threads we use when scraping -MAX_CPU_WORKERS = 8 +MAX_CPU_WORKERS = 8 # Maximum num threads we use when scraping +MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH = 25 # Minimum # of jobs we need to TFIDF MAX_BLOCK_LIST_DESC_CHARS = 150 # Maximum len of description in block_list JSON +DEFAULT_MAX_TFIDF_SIMILARITY = 0.75 # Maximum similarity between job text TFIDF BS4_PARSER = 'lxml' From f745f2bec8188b423b5d629df73334817a775471 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 22 Aug 2020 11:51:17 -0400 Subject: [PATCH 23/66] Improve jobfunnel behaviour in --no-scrape mode to prevent writing an empty CSV --- jobfunnel/backend/jobfunnel.py | 63 +++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 24 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 25cf3d01..544a85dd 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -98,38 +98,53 @@ def run(self) -> None: self.write_cache(scraped_jobs_dict) # Pre-filter by removing jobs with duplicate IDs from scraped_jobs_dict - if master_jobs_dict: - self.filter_duplicates( - scraped_jobs_dict, master_jobs_dict, by_key_id_only=True, - ) + if scraped_jobs_dict: + if master_jobs_dict: + self.filter_duplicates( + scraped_jobs_dict, master_jobs_dict, by_key_id_only=True, + ) - # Filter out scraped jobs we have rejected, archived or block-listed - # or which we previously detected to be duplicates before updating CSV. - self.filter(scraped_jobs_dict) + # Filter out scraped jobs we have rejected, archived or block-listed + # or which we previously detected as duplicates before updating CSV. + self.filter(scraped_jobs_dict) - # Update master CSV iif we have one - if master_jobs_dict: + # Update master CSV if we have one with scrape data (if we have it) + if scraped_jobs_dict: - # Mabye reduce the size of master_jobs (may have blocked new jobs) - self.filter(master_jobs_dict) + if master_jobs_dict: - # Filter out duplicates and update duplicates list file - # NOTE: this will match duplicates by job description contents - self.filter_duplicates(scraped_jobs_dict, master_jobs_dict) + # Mabye reduce the size of master_jobs (user-blocked new jobs) + self.filter(master_jobs_dict) - # Expand master_jobs_dict with filtered, non-duplicated jobs & save - # NOTE: this may be an empty update.. TODO: save the write call? - master_jobs_dict.update(scraped_jobs_dict) - self.write_master_csv(master_jobs_dict) + # Filter out duplicates and update duplicates list file + # NOTE: this will match duplicates by job description contents + if scraped_jobs_dict: + self.filter_duplicates(scraped_jobs_dict, master_jobs_dict) + + # Expand master_jobs_dict with scraped & filtered jobs + master_jobs_dict.update(scraped_jobs_dict) + + # Update the existing master jobs dict (i.e. remove status-jobs) + self.write_master_csv(master_jobs_dict) + + else: + # Dump the results into the data folder as the masterlist + # FIXME: we could still detect duplicates within the CSV itself? + self.write_master_csv(scraped_jobs_dict) else: - # Dump the results into the data folder as the masterlist - # FIXME: we could still detect duplicates within the CSV itself? - self.write_master_csv(scraped_jobs_dict) + # User is running --no-scrape and hasn't got a master CSV + self.logger.error( + "Running --no-scrape without any cached file or CSV, nothing " + "was done!" + ) - self.logger.info( - f"Done. View your current jobs in {self.config.master_csv_file}" - ) + # Tell user we updated CSV and/or scraped successfully. + # TODO: chuck a stat or two in here? + if scraped_jobs_dict or master_jobs_dict: + self.logger.info( + f"Done. View your current jobs in {self.config.master_csv_file}" + ) def scrape(self) ->Dict[str, Job]: """Run each of the desired Scraper.scrape() with threading and delaying From 7854850c0b73ac1e613b764bd709344cf92ce5a5 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 22 Aug 2020 13:50:52 -0400 Subject: [PATCH 24/66] Fixed monster scraping (multi-page), Fixed TFIDF filter and duplicates handling --- jobfunnel/backend/jobfunnel.py | 2 +- jobfunnel/backend/scrapers/base.py | 8 +- jobfunnel/backend/scrapers/monster.py | 95 +++++++++++--------- jobfunnel/backend/tools/filters.py | 124 ++++++++++++++++++-------- 4 files changed, 150 insertions(+), 79 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 544a85dd..9063cf70 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -437,7 +437,7 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: duplicates_dict = {} # type: Dict[str, Job] if os.path.isfile(self.config.duplicates_list_file): duplicates_dict = json.load( - open(self.config.user_block_list_file, 'r') + open(self.config.duplicates_list_file, 'r') ) # Filter jobs out using all our available filters diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index b2874afc..65791c14 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -140,6 +140,10 @@ def headers(self) -> Dict[str, str]: def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID + FIXME: we need to accept some kind of filter bank argument + here so we can abort scraping that isn't promising with a minimal + number of delayed get/sets + NOTE: respectfully delays for scraping of configured job attributes in self. @@ -172,7 +176,7 @@ def scrape(self) -> Dict[str, Job]: ) ) - # Loops through futures as completed and removes each if successfully parsed + # Loops through futures as completed and removes if successfully parsed # For each job-soup object, scrape the soup into a Job (w/o desc.) jobs_dict = {} # type: Dict[str, Job] for future in tqdm(as_completed(results), total=n_soups): @@ -198,6 +202,8 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: delay [float]: how long to delay getting/setting for certain get/set calls while scraping data for this job. + FIXME: abort on get(key_id) when that key_id matches an existing job + Returns: Job: job object constructed from the soup and localization of class """ diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 503e1d7e..a6068d58 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -19,7 +19,6 @@ BaseScraper, BaseCANEngScraper, BaseUSAEngScraper ) -MAGIC_MONSTER_SEARCH_STRING = 'skr_navigation_nhpso_searchMain' MAX_RESULTS_PER_MONSTER_PAGE = 25 ID_REGEX = re.compile( r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]' @@ -56,15 +55,15 @@ def job_get_fields(self) -> str: """Call self.get(...) for the JobFields in this list when scraping a Job """ return [ - JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.POST_DATE, JobField.URL, + JobField.KEY_ID, JobField.TITLE, JobField.COMPANY, + JobField.LOCATION, JobField.POST_DATE, JobField.URL, ] @property def job_set_fields(self) -> str: """Call self.set(...) for the JobFields in this list when scraping a Job """ - return [JobField.KEY_ID, JobField.DESCRIPTION] + return [JobField.DESCRIPTION] @property def delayed_get_set_fields(self) -> str: @@ -95,7 +94,13 @@ def headers(self) -> Dict[str, str]: def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: """Get a single job attribute from a soup object by JobField """ - if parameter == JobField.TITLE: + if parameter == JobField.KEY_ID: + # TODO: is there a way to combine these calls? + # NOTE: do not use 'data-m_impr_j_jobid' as this is duplicated + return soup.find('h2', attrs={'class': 'title'}).find('a').get( + 'data-m_impr_j_postingid' + ) + elif parameter == JobField.TITLE: return soup.find('h2', attrs={'class': 'title'}).text.strip() elif parameter == JobField.COMPANY: return soup.find('div', attrs={'class': 'company'}).text.strip() @@ -115,9 +120,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField """ - if parameter == JobField.KEY_ID: - job.key_id = ID_REGEX.findall(job.url)[0] - elif parameter == JobField.DESCRIPTION: + if parameter == JobField.DESCRIPTION: detailed_job_soup = BeautifulSoup( self.session.get(job.url).text, self.config.bs4_parser ) @@ -130,7 +133,7 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: """Scrapes raw data from a job source into a list of job-soups - TODO: use threading here too + TODO: use threading here too? Returns: List[BeautifulSoup]: list of jobs soups we can use to make Job init @@ -139,9 +142,9 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: search_url = self._get_search_url() # Load our initial search results listings page - initial_seach_results_html = self.session.get(search_url) + initial_search_results_html = self.session.get(search_url) initial_search_results_soup = BeautifulSoup( - initial_seach_results_html.text, self.config.bs4_parser + initial_search_results_html.text, self.config.bs4_parser ) # Parse total results, and calculate the # of pages needed @@ -151,26 +154,35 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: ) # Get first page of listing soups from our search results listings page - job_soups_list = self._get_job_soups_from_search_page( - initial_search_results_soup - ) + # NOTE: Monster is an endless-scroll style of job site so we have to + # Remove previous pages as we go. + # TODO: better error handling here? + # TODO: maybe we can move this into get set / BaseScraper somehow? + def __get_job_soups_by_key_id(result_listings: BeautifulSoup + ) -> Dict[str, BeautifulSoup]: + return { + self.get(JobField.KEY_ID, job_soup): job_soup + for job_soup in self._get_job_soups_from_search_page( + result_listings + ) + } - # Get all the other pages - for page in range(1, n_pages): - next_listings_page_soup = BeautifulSoup( - self.session.get(self._get_results_page_url(page, search_url)), - self.config.bs4_parser, - ) - job_soups_list.extend( - self._get_job_soups_from_search_page(next_listings_page_soup) - ) + job_soups_dict = __get_job_soups_by_key_id(initial_search_results_soup) - return job_soups_list + # Get all the other pages + if n_pages > 1: + for page in range(2, n_pages): + next_listings_page_soup = BeautifulSoup( + self.session.get(self._get_search_url(page=page)).text, + self.config.bs4_parser, + ) + # Add only the jobs that we didn't 'scroll' past already + job_soups_dict.update( + __get_job_soups_by_key_id(next_listings_page_soup) + ) - def _get_results_page_url(self, cur_page: int, search_url: str) -> str: - """Get the next page of search listings - """ - return f'{search_url}&start={cur_page}' + # TODO: would be cool if we could avoid key_id scrape duplication in get + return list(job_soups_dict.values()) def _get_job_soups_from_search_page(self, initial_results_soup: BeautifulSoup, @@ -180,14 +192,13 @@ def _get_job_soups_from_search_page(self, return initial_results_soup.find_all('div', attrs={'class': 'flex-row'}) def _get_num_search_result_pages(self, initial_results_soup: BeautifulSoup, - max_pages=0) -> int: + ) -> int: """Calculates the number of pages of job listings to be scraped. i.e. your search yields 230 results at 50 res/page -> 5 pages of jobs Args: initial_results_soup: the soup for the first search results page - max_pages: the maximum number of pages to be scraped. Returns: The number of pages of job listings to be scraped. """ @@ -197,21 +208,25 @@ def _get_num_search_result_pages(self, initial_results_soup: BeautifulSoup, num_res = int(re.findall(r'(\d+)', partial)[0]) return int(ceil(num_res / MAX_RESULTS_PER_MONSTER_PAGE)) - def _get_search_url(self, method: Optional[str] = 'get') -> str: + def _get_search_url(self, method: Optional[str] = 'get', + page: int = 1) -> str: """Get the monster search url from SearchTerms - TODO: use Enum for method instead of str. + TODO: implement fulltime/parttime portion + company search? TODO: implement POST + NOTE: unfortunately we cannot start on any page other than 1, + so the jobs displayed just scrolls forever and we will see + all previous jobs as we go. """ if method == 'get': return ( - 'https://www.monster.{0}/jobs/search/?q={1}&where={2}__2C-{3}' - '&intcid={4}&rad={5}&where={2}__2c-{3}'.format( - self.config.search_config.domain, - self.query, - self.config.search_config.city.replace(' ', '-'), - self.config.search_config.province_or_state, - MAGIC_MONSTER_SEARCH_STRING, - self._convert_radius(self.config.search_config.radius) + 'https://www.monster.{}/jobs/search/?{}q={}&where={}__2C-{}' + '&rad={}'.format( + self.config.search_config.domain, + f'page={page}&' if page > 1 else '', + self.query, + self.config.search_config.city.replace(' ', '-'), + self.config.search_config.province_or_state, + self._convert_radius(self.config.search_config.radius) ) ) elif method == 'post': diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index e80ca96e..70f342ab 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -5,8 +5,9 @@ """ import logging from datetime import date, datetime, timedelta -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple import json +import os import nltk import numpy as np @@ -15,7 +16,9 @@ from jobfunnel.backend import Job from jobfunnel.backend.tools import update_job_if_newer, get_logger -from jobfunnel.resources import DEFAULT_MAX_TFIDF_SIMILARITY +from jobfunnel.resources import ( + DEFAULT_MAX_TFIDF_SIMILARITY, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH +) T_NOW = datetime.now() @@ -23,7 +26,7 @@ def job_is_old(job: Job, number_of_days: int) -> bool: """Identify if a job is older than number_of_days from today - + TODO: move this into Job.job_is_old() NOTE: modifies job_dict in-place Args: @@ -52,6 +55,8 @@ def tfidf_filter(cur_dict: Dict[str, dict], NOTE: This will update jobs in cur_dict if the content match has a newer post_date. + NOTE/WARNING: if you are running this method, you should have already + removed any duplicates by key_id FIXME: we should make max_similarity configurable in SearchConfig FIXME: this should be integrated into jobfunnel.filter with other filters FIXME: fix logger arg-passing once we get this in some kind of class @@ -87,13 +92,14 @@ def tfidf_filter(cur_dict: Dict[str, dict], # Retrieve stopwords if not already downloaded # TODO: we should use this to make jobs attrs tokenizable as a property. + # TODO: make the vectorizer persistant. try: stopwords = nltk.corpus.stopwords.words('english') except LookupError: nltk.download('stopwords', quiet=True) stopwords = nltk.corpus.stopwords.words('english') - # init vectorizer + # init vectorizer NOTE: pretty fast call but we should do this once! vectorizer = TfidfVectorizer( strip_accents='unicode', lowercase=True, @@ -101,31 +107,65 @@ def tfidf_filter(cur_dict: Dict[str, dict], stop_words=stopwords, ) - # TODO: assert on length of contents of the lists + combine into one method - # Get query words and ids as lists for convenience - query_ids = [] # type: List[str] - query_words = [] # type: List[str] - for job in cur_dict.values(): - if len(job.description) > 0: - query_ids.append(job.key_id) - query_words.append(job.description) - - if not query_words: - raise ValueError("No data to fit, are your job descriptions all empty?") - - # Get reference words as list - reference_ids = [] # type: List[str] - reference_words = [] # type: List[str] - for job in prev_dict.values(): - if len(job.description) > 0: - reference_ids.append(job.key_id) - reference_words.append(job.description) - - if not reference_words: - raise ValueError("No data to fit, are your job descriptions all empty?") + # Load known duplicate keys from JSON if we have it + # NOTE: this allows us to do smaller TFIDF comparisons because we ensure + # that we are skipping previously-detected job duplicates (by id) + existing_duplicate_keys = {} # type: Set[str] + existing_duplicate_jobs_dict = {} # type: Dict[str, str] + if duplicate_jobs_file and os.path.isfile(duplicate_jobs_file): + existing_duplicate_jobs_dict = json.load( + open(duplicate_jobs_file, 'r') + ) + existing_duplicate_keys = existing_duplicate_jobs_dict.keys() + + def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] + ) -> Tuple[List[str], List[str]]: + """Get query words and ids as lists + prefilter + NOTE: this is just a convenience method since we do this 2x + """ + ids = [] # type: List[str] + words = [] # type: List[str] + filt_job_dict = {} # type: Dict[str, Job] + for job in cur_dict.values(): + if job.key_id in existing_duplicate_keys: + logger.debug( + f"Removing {job.key_id} from scrape result, existing " + "duplicate." + ) + elif not len(job.description): + logger.debug( + f"Removing {job.key_id} from scrape result, empty " + "description." + ) + else: + ids.append(job.key_id) + words.append(job.description) + # NOTE: We want to leave changing cur_dict in place till the end + # or we will break usage of update_job_if_newer() + filt_job_dict[job.key_id] = job + + # TODO: assert on length of contents of the lists as well + if not words: + raise ValueError( + "No data to fit, are your job descriptions all empty?" + ) + return ids, words, filt_job_dict + + query_ids, query_words, filt_cur_dict = __dict_to_ids_and_words(cur_dict) + reference_ids, reference_words, filt_prev_dict = __dict_to_ids_and_words( + prev_dict + ) + + # Provide a warning if we have few words. + corpus = query_words + reference_words + if len(corpus) < MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH: + logger.warning( + "It is not recommended to use this filter with less than " + f"{MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH} words" + ) # Fit vectorizer to entire corpus - vectorizer.fit(query_words + reference_words) + vectorizer.fit(corpus) # Calculate cosine similarity between reference and current blurbs # This is a list of the similarity between that query job and all the @@ -143,29 +183,39 @@ def tfidf_filter(cur_dict: Dict[str, dict], # FIXME: handle if everything is highly similar! for similar_index in np.where(query_similarities >= max_similarity)[0]: update_job_if_newer( - prev_dict[reference_ids[similar_index]], - cur_dict[query_id], - ) - duplicate_jobs_list.append(cur_dict.pop(query_id)) - logger.debug( - f"Removed {query_id} from scraped data, TFIDF content match." + filt_prev_dict[reference_ids[similar_index]], + filt_cur_dict[query_id], ) + duplicate_jobs_list.append(filt_cur_dict[query_id]) - # Save to our duplicates file if any are detected exist if duplicate_jobs_list: + # NOTE: multiple jobs can be a duplicate of the same job. + duplicate_ids = {job.key_id for job in duplicate_jobs_list} + + # Remove duplicates from cur_dict + save to our duplicates file + for key_id in duplicate_ids: + cur_dict.pop(key_id) + logger.debug( + f"Removed {key_id} from scraped data, TFIDF content match." + ) + logger.info( f'Found and removed {len(duplicate_jobs_list)} ' f're-posts/duplicate postings via TFIDF cosine similarity.' ) - # NOTE: we use indent=4 so that it stays human-readable. if duplicate_jobs_file: + # Write out a list of duplicates so that detections persist under + # changing input data. + existing_duplicate_jobs_dict.update( + {dj.key_id: dj.as_json_entry for dj in duplicate_jobs_list} + ) with open(duplicate_jobs_file, 'w', encoding='utf8') as outfile: + # NOTE: we use indent=4 so that it stays human-readable. outfile.write( json.dumps( - {dj.key_id: dj.as_json_entry - for dj in duplicate_jobs_list}, + existing_duplicate_jobs_dict, indent=4, sort_keys=True, separators=(',', ': '), From 2058067e1c6f21d20ab27abbfa0add1695d0d787 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 22 Aug 2020 16:03:53 -0400 Subject: [PATCH 25/66] Move is_old into Job, minimize JSON and CSV loading by setting as self and reorganizing the open()s --- jobfunnel/backend/job.py | 16 ++- jobfunnel/backend/jobfunnel.py | 192 ++++++++++++++++++----------- jobfunnel/backend/tools/filters.py | 91 +++----------- jobfunnel/resources/resources.py | 2 + 4 files changed, 159 insertions(+), 142 deletions(-) diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index bc2a3855..17e48ebd 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -7,6 +7,8 @@ import string from typing import Any, Dict, Optional, List +from datetime import date, datetime, timedelta + from jobfunnel.resources import ( Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS, MAX_BLOCK_LIST_DESC_CHARS ) @@ -108,6 +110,18 @@ def is_remove_status(self) -> bool: """ return self.status in JOB_REMOVE_STATUSES + def is_old(self, max_age: datetime) -> bool: + """Identify if a job is older than a certain max_age + + Args: + max_age_days: maximum allowable age for a job + + Returns: + True if it's older than number of days + False if it's fresh enough to keep + """ + return self.post_date < max_age + @property def as_row(self) -> Dict[str, str]: """Builds a CSV row dict for this job entry @@ -158,7 +172,7 @@ def as_json_entry(self) -> Dict[str, str]: def clean_strings(self) -> None: """Ensure that all string fields have only printable chars FIXME: do this automatically upon assignment (override assignment) - ...This way of doing it is janky and might not work right... + FIXME: maybe we can use stopwords? """ for attr in [self.title, self.company, self.description, self.tags, self.url, self.key_id, self.provider, self.query, diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 9063cf70..fe89ff41 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -8,18 +8,18 @@ import pickle import sys from concurrent.futures import ThreadPoolExecutor -from datetime import date, datetime +from datetime import date, datetime, timedelta from time import time from typing import Dict, List, Optional from requests import Session from jobfunnel.backend import Job -from jobfunnel.backend.tools.filters import job_is_old, tfidf_filter +from jobfunnel.backend.tools.filters import tfidf_filter from jobfunnel.backend.tools import update_job_if_newer, get_logger from jobfunnel.config import JobFunnelConfig from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, - MAX_CPU_WORKERS, JobStatus, Locale, + MAX_CPU_WORKERS, JobStatus, Locale, T_NOW, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH) @@ -44,6 +44,14 @@ def __init__(self, config: JobFunnelConfig) -> None: "%(message)s" ) self.__date_string = date.today().strftime("%Y-%m-%d") + self.max_job_date = T_NOW - timedelta( + days=self.config.search_config.max_listing_days + ) + + # NOTE: we have these as attrs so they are read minimally. + self.user_block_jobs_dict = {} # type: Dict[str, str] + self.duplicate_jobs_dict = {} # type: Dict[str, str] + self.master_jobs_dict = {} # type: Dict[str, Job] # Open a session with/out a proxy configured self.session = Session() @@ -52,6 +60,22 @@ def __init__(self, config: JobFunnelConfig) -> None: self.config.proxy_config.protocol: self.config.proxy_config.url } + # Read the user's block list + if os.path.isfile(self.config.user_block_list_file): + self.user_block_jobs_dict = json.load( + open(self.config.user_block_list_file, 'r') + ) + + # Read the user's duplicate jobs list (from TFIDF) + if os.path.isfile(self.config.duplicates_list_file): + self.duplicate_jobs_dict = json.load( + open(self.config.duplicates_list_file, 'r') + ) + + # Read the master CSV file + if os.path.isfile(self.config.master_csv_file): + self.master_jobs_dict = self.read_master_csv() + @property def daily_cache_file(self) -> str: """The name for for pickle file containing the scraped data ran today' @@ -67,65 +91,69 @@ def run(self) -> None: NOTE: we are assuming the user has distinct cache folder per-search, otherwise we will load the cache for today, for a different search! """ + # Load master csv jobs if they exist and update our block list with # any jobs the user has set the status to == a remove status # NOTE: we want to do this first to ensure scraping is efficient when # we are getting detailed job information (per-job) - master_jobs_dict = {} # type: Dict[str, Job[ - if os.path.isfile(self.config.master_csv_file): - master_jobs_dict = self.read_master_csv() - self.update_user_block_list(master_jobs_dict) + if self.master_jobs_dict: + self.update_user_block_list(self.master_jobs_dict) else: logging.debug( "No master-CSV present, did not update block-list: " f"{self.config.user_block_list_file}" ) - # Get jobs keyed by their unique ID, use cache if --no-scrape is set + # Get jobs keyed by their unique ID scraped_jobs_dict = {} # type: Dict[str, Job] - if os.path.exists(self.daily_cache_file): - scraped_jobs_dict = self.load_cache(self.daily_cache_file) - elif self.config.no_scrape: - self.logger.warning( - f"No jobs cached, missing: {self.daily_cache_file}" - ) - - # Scrape and writeout the cache if self.config.no_scrape: + + # Load cache since --no-scrape is set self.logger.info("Skipping scraping, running with --no-scrape.") + if os.path.exists(self.daily_cache_file): + scraped_jobs_dict = self.load_cache(self.daily_cache_file) + else: + self.logger.warning( + f"No jobs cached, missing: {self.daily_cache_file}" + ) else: - scraped_jobs_dict = self.scrape() # type: Dict[str, Job] + + # Scrape new jobs and cache them + scraped_jobs_dict = self.scrape() self.write_cache(scraped_jobs_dict) # Pre-filter by removing jobs with duplicate IDs from scraped_jobs_dict if scraped_jobs_dict: - if master_jobs_dict: + if self.master_jobs_dict: self.filter_duplicates( - scraped_jobs_dict, master_jobs_dict, by_key_id_only=True, + scraped_jobs_dict, self.master_jobs_dict, + by_key_id_only=True, ) # Filter out scraped jobs we have rejected, archived or block-listed # or which we previously detected as duplicates before updating CSV. - self.filter(scraped_jobs_dict) + self.filter_jobs(scraped_jobs_dict) # Update master CSV if we have one with scrape data (if we have it) if scraped_jobs_dict: - if master_jobs_dict: + if self.master_jobs_dict: # Mabye reduce the size of master_jobs (user-blocked new jobs) - self.filter(master_jobs_dict) + self.filter_jobs(self.master_jobs_dict) # Filter out duplicates and update duplicates list file # NOTE: this will match duplicates by job description contents if scraped_jobs_dict: - self.filter_duplicates(scraped_jobs_dict, master_jobs_dict) + self.filter_duplicates( + scraped_jobs_dict, self.master_jobs_dict + ) - # Expand master_jobs_dict with scraped & filtered jobs - master_jobs_dict.update(scraped_jobs_dict) + # Expand self.master_jobs_dict with scraped & filtered jobs + self.master_jobs_dict.update(scraped_jobs_dict) # Update the existing master jobs dict (i.e. remove status-jobs) - self.write_master_csv(master_jobs_dict) + self.write_master_csv(self.master_jobs_dict) else: # Dump the results into the data folder as the masterlist @@ -141,7 +169,7 @@ def run(self) -> None: # Tell user we updated CSV and/or scraped successfully. # TODO: chuck a stat or two in here? - if scraped_jobs_dict or master_jobs_dict: + if scraped_jobs_dict or self.master_jobs_dict: self.logger.info( f"Done. View your current jobs in {self.config.master_csv_file}" ) @@ -188,7 +216,7 @@ def recover(self) -> None: os.path.join(self.config.cache_folder, file) ) ) - self.filter(all_jobs_dict) + self.filter_jobs(all_jobs_dict) # NOTE: we warn user about this above self.write_master_csv(all_jobs_dict) def load_cache(self, cache_file: str) -> Dict[str, Job]: @@ -365,30 +393,23 @@ def update_user_block_list(self, file does not exist. """ - # Load from CSV if not passed by argument - if not master_jobs_dict: + # Try to load from CSV if master_jobs_dict is un-set + if not self.master_jobs_dict: if os.path.isfile(self.config.master_csv_file): - master_jobs_dict or self.read_master_csv() + self.master_jobs_dict or self.read_master_csv() else: raise FileNotFoundError( f"Cannot update {self.config.user_block_list_file} without " f"{self.config.master_csv_file}" ) - # Load existing filtered jobs, if any - if os.path.isfile(self.config.user_block_list_file): - blocked_jobs_dict = json.load( - open(self.config.user_block_list_file, 'r') - ) - else: - blocked_jobs_dict = {} - - # Add jobs from csv that need to be filtered away, if any + # Add jobs from csv that need to be filtered away, if any + update self n_jobs_added = 0 - for job in master_jobs_dict.values(): - if job.is_remove_status and job.key_id not in blocked_jobs_dict: + for job in self.master_jobs_dict.values(): + if (job.is_remove_status and job.key_id + not in self.user_block_jobs_dict): n_jobs_added += 1 - blocked_jobs_dict[job.key_id] = job.as_json_entry + self.user_block_jobs_dict[job.key_id] = job.as_json_entry logging.info( f'Added {job.key_id} to ' f'{self.config.user_block_list_file}' @@ -401,7 +422,7 @@ def update_user_block_list(self, encoding='utf8') as outfile: outfile.write( json.dumps( - blocked_jobs_dict, + self.user_block_jobs_dict, indent=4, sort_keys=True, separators=(',', ': '), @@ -413,7 +434,25 @@ def update_user_block_list(self, f"statuses: {self.config.user_block_list_file}" ) - def filter(self, jobs_dict: Dict[str, Job]) -> int: + def filter_job(self, job: Job, + user_block_jobs_dict: Optional[Dict[str, str]] = None, + duplicate_jobs_dict: Optional[Dict[str, str]] = None + ) -> bool: + """Filter jobs out using all our available filters except TFIDF + FIXME: how can we update_if_newer if we are prempting the scrape? + TODO: arrange checks by how long they take to run + """ + return ( + job.is_remove_status + or (job.company in self.config.search_config.blocked_company_names) + or job.is_old(self.max_job_date) + or (self.user_block_jobs_dict + and job.key_id in self.user_block_jobs_dict) + or (self.duplicate_jobs_dict + and job.key_id in self.duplicate_jobs_dict) + ) + + def filter_jobs(self, jobs_dict: Dict[str, Job]) -> int: """Remove jobs from jobs_dict if they are: 1. in our block-list 2. status == a removal status string (i.e. DELETE) @@ -426,30 +465,12 @@ def filter(self, jobs_dict: Dict[str, Job]) -> int: TODO: make the filters used configurable, i.e. list of FilterType """ - # Read the user's block list - block_dict = {} # type: Dict[str, Job] - if os.path.isfile(self.config.user_block_list_file): - block_dict = json.load( - open(self.config.user_block_list_file, 'r') - ) - # Read the user's duplicate jobs list (from TFIDF) - duplicates_dict = {} # type: Dict[str, Job] - if os.path.isfile(self.config.duplicates_list_file): - duplicates_dict = json.load( - open(self.config.duplicates_list_file, 'r') - ) - - # Filter jobs out using all our available filters - # NOTE: checks are arranged in order of assumed calculation expense + # Filter jobs with all filters except TFIDF, then remove filtered jobs filter_jobs_ids = [] for key_id, job in jobs_dict.items(): - if (job.is_remove_status - or job.company in - self.config.search_config.blocked_company_names - or key_id in block_dict - or key_id in duplicates_dict - or job_is_old(job, self.config.search_config.max_listing_days)): + if self.filter_job(job, self.user_block_jobs_dict, + self.duplicate_jobs_dict): filter_jobs_ids.append(key_id) for key_id in filter_jobs_ids: @@ -472,7 +493,7 @@ def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], """Identify duplicate jobs between scrape data and existing_jobs_dict and update the duplicates block list if any are found by contents. - TODO: move this into self.filter() which should be more configurable + TODO: move this into self.filter_jobs() - it should be more configurable TODO: make max_similarity configurable i.e. self.config.filter... TODO: we are wrapping in a try/catch because TFIDF filter is missing some error handling. Remove once it is safer to use w.out crashing @@ -501,21 +522,54 @@ def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], ) # If we have any jobs left, filter these using their contents. + new_duplicate_jobs_list = [] # type: List[Job] if scraped_jobs_dict: if (len(scraped_jobs_dict.keys()) + len(existing_jobs_dict.keys()) >= MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH): try: - tfidf_filter( + new_duplicate_jobs_list = tfidf_filter( cur_dict=scraped_jobs_dict, prev_dict=existing_jobs_dict, log_level=self.config.log_level, log_file=self.config.log_file, - duplicate_jobs_file=self.config.duplicates_list_file, + duplicate_jobs_dict=self.duplicate_jobs_dict, ) + except ValueError as err: self.logger.error( f"Skipping similarity filter due to error: {str(err)}" ) + + if new_duplicate_jobs_list: + + if self.config.duplicates_list_file: + + # Write out a list of duplicates so that detections + # persist under changing input data / scrape data. + self.duplicate_jobs_dict = { + dj.key_id: dj.as_json_entry + for dj in new_duplicate_jobs_list + } # type: Dict[str, str] + + with open(self.config.duplicates_list_file, 'w', + encoding='utf8') as outfile: + # NOTE: we use indent=4 for human-readability + outfile.write( + json.dumps( + self.config.duplicates_list_file, + indent=4, + sort_keys=True, + separators=(',', ': '), + ensure_ascii=False, + ) + ) + else: + self.logger.warning( + "Duplicates will not be saved, no duplicates list " + "file set. Saving to a duplicates file will ensure " + "that these persist." + ) + else: self.logger.warning( "Skipping similarity filter because there are fewer than " diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 70f342ab..18a01381 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -4,7 +4,6 @@ and generic log messages. """ import logging -from datetime import date, datetime, timedelta from typing import Dict, List, Optional, Tuple import json import os @@ -21,32 +20,10 @@ ) -T_NOW = datetime.now() - - -def job_is_old(job: Job, number_of_days: int) -> bool: - """Identify if a job is older than number_of_days from today - TODO: move this into Job.job_is_old() - NOTE: modifies job_dict in-place - - Args: - job_dict: today's job scrape dict - number_of_days: how many days old a job can be - - Returns: - True if it's older than number of days - False if it's fresh enough to keep - """ - assert number_of_days > 0 - # Calculate the oldest date a job can be - # NOTE: we may want to just set job.status = JobStatus.OLD - return job.post_date < (T_NOW - timedelta(days=number_of_days)) - - def tfidf_filter(cur_dict: Dict[str, dict], prev_dict: Optional[Dict[str, dict]] = None, max_similarity: float = DEFAULT_MAX_TFIDF_SIMILARITY, - duplicate_jobs_file: Optional[str] = None, + duplicate_jobs_dict: Optional[Dict[str, str]] = None, log_level: int = logging.INFO, log_file: str = None, ) -> List[Job]: @@ -73,15 +50,15 @@ def tfidf_filter(cur_dict: Dict[str, dict], from within the cur_dict only. Defaults to None. max_similarity (float, optional): threshold above which blurb similarity is considered a duplicate. Defaults to DEFAULT_MAX_TFIDF_SIMILARITY. - duplicate_jobs_file (str, optional): location to save duplicates that - we identify via content matching. Defaults to None. + duplicate_jobs_dict (str, optional): cntents of user's duplicate job + detection JSON so we can make content matching persist better. ... Raises: ValueError: cur_dict contains no job descriptions Returns: - List[Job]: list of duplicate Jobs which were removed from cur_dict + List[Job]: list of new duplicate Jobs which were removed from cur_dict """ logger = get_logger( tfidf_filter.__name__, @@ -110,13 +87,8 @@ def tfidf_filter(cur_dict: Dict[str, dict], # Load known duplicate keys from JSON if we have it # NOTE: this allows us to do smaller TFIDF comparisons because we ensure # that we are skipping previously-detected job duplicates (by id) - existing_duplicate_keys = {} # type: Set[str] - existing_duplicate_jobs_dict = {} # type: Dict[str, str] - if duplicate_jobs_file and os.path.isfile(duplicate_jobs_file): - existing_duplicate_jobs_dict = json.load( - open(duplicate_jobs_file, 'r') - ) - existing_duplicate_keys = existing_duplicate_jobs_dict.keys() + duplicate_jobs_dict = duplicate_jobs_dict or {} # type: Dict[str, str] + existing_duplicate_keys = duplicate_jobs_dict.keys() def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] ) -> Tuple[List[str], List[str]]: @@ -176,7 +148,8 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] ) # Get duplicate job ids and pop them, updating cur_dict if they are newer - duplicate_jobs_list = [] # type: List[Job] + # NOTE: multiple jobs can be determined to be a duplicate of the same job! + new_duplicate_jobs_list = [] # type: List[Job] for query_similarities, query_id in zip(similarities_per_query, query_ids): # Identify the jobs in prev_dict that our query is a duplicate of @@ -186,47 +159,21 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] filt_prev_dict[reference_ids[similar_index]], filt_cur_dict[query_id], ) - duplicate_jobs_list.append(filt_cur_dict[query_id]) - - if duplicate_jobs_list: - - # NOTE: multiple jobs can be a duplicate of the same job. - duplicate_ids = {job.key_id for job in duplicate_jobs_list} - - # Remove duplicates from cur_dict + save to our duplicates file - for key_id in duplicate_ids: - cur_dict.pop(key_id) + new_duplicate_jobs_list.append(filt_cur_dict[query_id]) logger.debug( - f"Removed {key_id} from scraped data, TFIDF content match." + f"Removed {job.key_id} from scraped data, TFIDF content match." ) + # Pop duplicates from cur_dict and return them + # NOTE: we cannot change cur_dict in above loop, or no updates possible. + if new_duplicate_jobs_list: + for job in new_duplicate_jobs_list: + cur_dict.pop(job.key_id) + logger.info( - f'Found and removed {len(duplicate_jobs_list)} ' + f'Found and removed {len(new_duplicate_jobs_list)} ' f're-posts/duplicate postings via TFIDF cosine similarity.' ) - if duplicate_jobs_file: - # Write out a list of duplicates so that detections persist under - # changing input data. - existing_duplicate_jobs_dict.update( - {dj.key_id: dj.as_json_entry for dj in duplicate_jobs_list} - ) - with open(duplicate_jobs_file, 'w', encoding='utf8') as outfile: - # NOTE: we use indent=4 so that it stays human-readable. - outfile.write( - json.dumps( - existing_duplicate_jobs_dict, - indent=4, - sort_keys=True, - separators=(',', ': '), - ensure_ascii=False, - ) - ) - else: - logger.warning( - "Duplicates will not be saved, no duplicates list file set. " - "Saving to a duplicates file will ensure that these persist." - ) - - # returns a list of duplicate Jobs - return duplicate_jobs_list + # returns a list of newly-detected duplicate Jobs + return new_duplicate_jobs_list diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index 625e2c5a..f97f5c1f 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -1,5 +1,6 @@ """String-like resouces and other constants are initialized here. """ +import datetime import os import string @@ -20,6 +21,7 @@ DEFAULT_MAX_TFIDF_SIMILARITY = 0.75 # Maximum similarity between job text TFIDF BS4_PARSER = 'lxml' +T_NOW = datetime.datetime.now() PRINTABLE_STRINGS = set(string.printable) From ccea515dab8370e9b528ae353b5ae441725200b7 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 22 Aug 2020 17:06:39 -0400 Subject: [PATCH 26/66] Make filtering dynamic so we can prevent scraping un-promising jobs as quickly as possible --- jobfunnel/backend/jobfunnel.py | 65 +++++++++++++------------ jobfunnel/backend/scrapers/base.py | 62 +++++++++++++++++------ jobfunnel/backend/scrapers/glassdoor.py | 6 ++- jobfunnel/backend/scrapers/indeed.py | 6 ++- jobfunnel/backend/scrapers/monster.py | 6 ++- jobfunnel/backend/tools/filters.py | 64 +++++++++++++++++++++--- 6 files changed, 149 insertions(+), 60 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index fe89ff41..a68414ab 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -15,7 +15,7 @@ from requests import Session from jobfunnel.backend import Job -from jobfunnel.backend.tools.filters import tfidf_filter +from jobfunnel.backend.tools.filters import tfidf_filter, JobFilter from jobfunnel.backend.tools import update_job_if_newer, get_logger from jobfunnel.config import JobFunnelConfig from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, @@ -44,9 +44,6 @@ def __init__(self, config: JobFunnelConfig) -> None: "%(message)s" ) self.__date_string = date.today().strftime("%Y-%m-%d") - self.max_job_date = T_NOW - timedelta( - days=self.config.search_config.max_listing_days - ) # NOTE: we have these as attrs so they are read minimally. self.user_block_jobs_dict = {} # type: Dict[str, str] @@ -76,6 +73,14 @@ def __init__(self, config: JobFunnelConfig) -> None: if os.path.isfile(self.config.master_csv_file): self.master_jobs_dict = self.read_master_csv() + # NOTE: we will set this after updating everything + self.job_filter = JobFilter( + self.user_block_jobs_dict, + self.duplicate_jobs_dict, + self.config.search_config.blocked_company_names, + T_NOW - timedelta(days=self.config.search_config.max_listing_days) + ) + @property def daily_cache_file(self) -> str: """The name for for pickle file containing the scraped data ran today' @@ -125,7 +130,7 @@ def run(self) -> None: # Pre-filter by removing jobs with duplicate IDs from scraped_jobs_dict if scraped_jobs_dict: if self.master_jobs_dict: - self.filter_duplicates( + self.filter_new_duplicates( scraped_jobs_dict, self.master_jobs_dict, by_key_id_only=True, ) @@ -145,7 +150,7 @@ def run(self) -> None: # Filter out duplicates and update duplicates list file # NOTE: this will match duplicates by job description contents if scraped_jobs_dict: - self.filter_duplicates( + self.filter_new_duplicates( scraped_jobs_dict, self.master_jobs_dict ) @@ -181,12 +186,15 @@ def scrape(self) ->Dict[str, Job]: f"Scraping local providers with: {self.config.scraper_names}" ) + # Ensure filters are up-to-date for per-job filtering as we scrape + self._update_job_filter() + # Iterate thru scrapers and run their scrape. jobs = {} # type: Dict[str, Job] for scraper_cls in self.config.scrapers: # FIXME: need to add the threader and delaying here start = time() - scraper = scraper_cls(self.session, self.config) + scraper = scraper_cls(self.session, self.config, self.job_filter) # TODO: add a warning for overwriting different jobs with same key jobs.update(scraper.scrape()) end = time() @@ -434,23 +442,12 @@ def update_user_block_list(self, f"statuses: {self.config.user_block_list_file}" ) - def filter_job(self, job: Job, - user_block_jobs_dict: Optional[Dict[str, str]] = None, - duplicate_jobs_dict: Optional[Dict[str, str]] = None - ) -> bool: - """Filter jobs out using all our available filters except TFIDF - FIXME: how can we update_if_newer if we are prempting the scrape? - TODO: arrange checks by how long they take to run + def _update_job_filter(self) -> None: + """Ensure that the filtering attribs are up-to-date TODO: better way? """ - return ( - job.is_remove_status - or (job.company in self.config.search_config.blocked_company_names) - or job.is_old(self.max_job_date) - or (self.user_block_jobs_dict - and job.key_id in self.user_block_jobs_dict) - or (self.duplicate_jobs_dict - and job.key_id in self.duplicate_jobs_dict) - ) + self.job_filter.user_block_jobs_dict = self.user_block_jobs_dict + self.job_filter.master_jobs_dict = self.master_jobs_dict + self.job_filter.duplicate_jobs_dict = self.duplicate_jobs_dict def filter_jobs(self, jobs_dict: Dict[str, Job]) -> int: """Remove jobs from jobs_dict if they are: @@ -465,12 +462,12 @@ def filter_jobs(self, jobs_dict: Dict[str, Job]) -> int: TODO: make the filters used configurable, i.e. list of FilterType """ + self._update_job_filter() # Filter jobs with all filters except TFIDF, then remove filtered jobs filter_jobs_ids = [] for key_id, job in jobs_dict.items(): - if self.filter_job(job, self.user_block_jobs_dict, - self.duplicate_jobs_dict): + if self.job_filter.filter(job): filter_jobs_ids.append(key_id) for key_id in filter_jobs_ids: @@ -487,12 +484,14 @@ def filter_jobs(self, jobs_dict: Dict[str, Job]) -> int: return n_filtered - def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], - existing_jobs_dict: Dict[str, Job], - by_key_id_only: bool = False) -> None: + def filter_new_duplicates(self, scraped_jobs_dict: Dict[str, Job], + existing_jobs_dict: Dict[str, Job], + by_key_id_only: bool = False) -> None: """Identify duplicate jobs between scrape data and existing_jobs_dict and update the duplicates block list if any are found by contents. + NOTE: this is intended for identifying new duplicates + TODO: move this into self.filter_jobs() - it should be more configurable TODO: make max_similarity configurable i.e. self.config.filter... TODO: we are wrapping in a try/catch because TFIDF filter is missing @@ -512,6 +511,7 @@ def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], duplicates as well (NOTE: currently only TFIDF filter for desc). """ # First we need to remove any duplicates by id directly + # FIXME: this should go into BaseScraper get set for key_id in existing_jobs_dict: if key_id in scraped_jobs_dict: duplicate_job = scraped_jobs_dict.pop(key_id) @@ -522,10 +522,12 @@ def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], ) # If we have any jobs left, filter these using their contents. - new_duplicate_jobs_list = [] # type: List[Job] - if scraped_jobs_dict: + if by_key_id_only and scraped_jobs_dict: if (len(scraped_jobs_dict.keys()) + len(existing_jobs_dict.keys()) >= MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH): + + # Run the TFIDF content matching filter and get new duplicates + new_duplicate_jobs_list = [] # type: List[Job] try: new_duplicate_jobs_list = tfidf_filter( cur_dict=scraped_jobs_dict, @@ -540,6 +542,7 @@ def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], f"Skipping similarity filter due to error: {str(err)}" ) + # Save any new duplicates if new_duplicate_jobs_list: if self.config.duplicates_list_file: @@ -556,7 +559,7 @@ def filter_duplicates(self, scraped_jobs_dict: Dict[str, Job], # NOTE: we use indent=4 for human-readability outfile.write( json.dumps( - self.config.duplicates_list_file, + self.duplicate_jobs_dict, indent=4, sort_keys=True, separators=(',', ': '), diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 65791c14..848dfea9 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed from time import sleep, time -from typing import Any, Dict, List, Tuple, Union +from typing import Any, Dict, List, Tuple, Union, Optional from bs4 import BeautifulSoup from requests import Session @@ -17,6 +17,7 @@ from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools.delay import calculate_delays from jobfunnel.backend.tools import get_logger +from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.resources import (MAX_CPU_WORKERS, USER_AGENT_LIST, JobField, Locale) # from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue @@ -25,7 +26,22 @@ class BaseScraper(ABC): """Base scraper object, for scraping and filtering Jobs from a provider """ - def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig', + job_filter: JobFilter) -> None: + """Init + + Args: + session (Session): session object used to make post and get requests + config (JobFunnelConfig): config containing all needed paths, search + proxy, delaying and other metadata. + job_filter (JobFilter): filtering class used to perform on-the-fly + filtering of jobs to reduce the number of delayed get or set + (i.e. operations that make requests). + + Raises: + ValueError: if no Locale is configured in the JobFunnelConfig + """ + self.job_filter = job_filter # We will use this for live-filtering self.session = session self.config = config self.logger = get_logger( @@ -181,19 +197,23 @@ def scrape(self) -> Dict[str, Job]: jobs_dict = {} # type: Dict[str, Job] for future in tqdm(as_completed(results), total=n_soups): job = future.result() - if job.key_id in jobs_dict: - self.logger.error( - f"Job {job.title} and {jobs_dict[job.key_id].title} share " - f"duplicate key_id: {job.key_id}" - ) - jobs_dict[job.key_id] = job + if job: + # Handle duplicates that exist within the scraped set. + # NOTE: if you see alot of these our scrape for key_id is bad + if job.key_id in jobs_dict: + self.logger.error( + f"Job {job.title} and {jobs_dict[job.key_id].title} " + f"share duplicate key_id: {job.key_id}" + ) + jobs_dict[job.key_id] = job # Cleanup + log self.executor.shutdown() return jobs_dict - def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: + def scrape_job(self, job_soup: BeautifulSoup, delay: float + ) -> Optional[Job]: """Scrapes a search page and get a list of soups that will yield jobs Arguments: job_soup [BeautifulSoup]: This is a soup object that your get/set @@ -202,10 +222,11 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: delay [float]: how long to delay getting/setting for certain get/set calls while scraping data for this job. - FIXME: abort on get(key_id) when that key_id matches an existing job + NOTE: this will never raise an exception to prevent killing workers. Returns: - Job: job object constructed from the soup and localization of class + Optional[Job]: job object constructed from the soup and localization + of class, returns None if scrape failed. """ # Formulate the get/set actions actions_list = [(True, f) for f in self.job_get_fields] @@ -216,6 +237,14 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: job_init_kwargs = self.job_init_kwargs # NOTE: best to construct once for is_get, field in actions_list: + # Break out immediately because we have failed a filterable + # condition with something we initialized while scraping. + if job and self.job_filter(job): + self.logger.debug( + f"Skipping scraping job {job.key_id}, failed JobFilter" + ) + break + # Respectfully delay if it's configured to do so. if field in self.delayed_get_set_fields: sleep(delay) @@ -250,12 +279,13 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float) -> Job: ) ) - assert job, "Failed to initialize job" # NOTE: should never see this - job.validate() + # Validate job fields if we got something + if job: + job.validate() - # FIXME: this is to prevent issues with JSON and raw data recur limit - # We could handle this when scraping but this will also save memory. - job._raw_scrape_data = None + # FIXME: this is to prevent issues with JSON and raw data recur lim. + # We could handle this when scraping but this will also save memory. + job._raw_scrape_data = None return job diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index 206a8f48..c7c3181f 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -12,6 +12,7 @@ from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools import get_webdriver from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from abc import abstractmethod @@ -40,10 +41,11 @@ class BaseGlassDoorScraper(BaseScraper): - def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig', + job_filter: JobFilter) -> None: """Init that contains glassdoor specific stuff """ - super().__init__(session, config) + super().__init__(session, config, job_filter) self.max_results_per_page = MAX_RESULTS_PER_GLASSDOOR_PAGE self.query = '-'.join(self.config.search_config.keywords) # self.driver = get_webdriver() TODO: we can use this if-needed diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 08f51843..634a307a 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -15,6 +15,7 @@ from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.backend.scrapers.base import ( BaseScraper, BaseCANEngScraper, BaseUSAEngScraper ) @@ -29,10 +30,11 @@ class BaseIndeedScraper(BaseScraper): """Scrapes jobs from www.indeed.X """ - def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig', + job_filter: JobFilter) -> None: """Init that contains indeed specific stuff """ - super().__init__(session, config) + super().__init__(session, config, job_filter) self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE self.query = '+'.join(self.config.search_config.keywords) diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index a6068d58..5bde6495 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -15,6 +15,7 @@ from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.backend.scrapers.base import ( BaseScraper, BaseCANEngScraper, BaseUSAEngScraper ) @@ -30,10 +31,11 @@ class BaseMonsterScraper(BaseScraper): """Scraper for www.monster.X """ - def __init__(self, session: Session, config: 'JobFunnelConfig') -> None: + def __init__(self, session: Session, config: 'JobFunnelConfig', + job_filter: JobFilter) -> None: """Init that contains monster specific stuff """ - super().__init__(session, config) + super().__init__(session, config, job_filter) self.query = '-'.join( self.config.search_config.keywords ).replace(' ', '-') diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 18a01381..fa9146d9 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -5,6 +5,7 @@ """ import logging from typing import Dict, List, Optional, Tuple +from datetime import datetime import json import os @@ -20,6 +21,50 @@ ) +class JobFilter: + """Class Used by JobFunnel and BaseScraper to filter collections of jobs + """ + def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, + duplicate_jobs_dict: Optional[Dict[str, str]] = None, + blocked_company_names_list: Optional[List[str]] = None, + max_job_date: Optional[datetime] = None) -> None: + """Init + + NOTE: remember to update attributes as needed. + + Args: + user_block_jobs_dict (Optional[Dict[str, str]], optional): dict + containing user's blocked jobs. Defaults to None. + duplicate_jobs_dict (Optional[Dict[str, str]], optional): dict + containing duplicate jobs, detected by content. Defaults to None + blocked_company_names_list (Optional[List[str]], optional): list of + company names disallowed from results. Defaults to None. + max_job_date (Optional[datetime], optional): maximium date that a + job can be scraped. Defaults to None. + """ + self.user_block_jobs_dict = user_block_jobs_dict or {} + self.duplicate_jobs_dict = duplicate_jobs_dict or {} + self.blocked_company_names_list = blocked_company_names_list or [] + self.max_job_date = max_job_date + # TODO: add tfidf to this class for per-job scraping + + def filter(self, job: Job) -> bool: + """Filter jobs out using all our available filters + TODO: arrange checks by how long they take to run + NOTE: this does a lot of checks because job may be partially initialized + """ + return ( + job.status and job.is_remove_status + or (job.company in self.blocked_company_names_list) + or (job.post_date and self.max_job_date + and job.is_old(self.max_job_date)) + or (job.key_id and self.user_block_jobs_dict + and job.key_id in self.user_block_jobs_dict) + or (job.key_id and self.duplicate_jobs_dict + and job.key_id in self.duplicate_jobs_dict) + ) + + def tfidf_filter(cur_dict: Dict[str, dict], prev_dict: Optional[Dict[str, dict]] = None, max_similarity: float = DEFAULT_MAX_TFIDF_SIMILARITY, @@ -35,10 +80,9 @@ def tfidf_filter(cur_dict: Dict[str, dict], NOTE/WARNING: if you are running this method, you should have already removed any duplicates by key_id FIXME: we should make max_similarity configurable in SearchConfig - FIXME: this should be integrated into jobfunnel.filter with other filters - FIXME: fix logger arg-passing once we get this in some kind of class + FIXME: this should be integrated into JobFilter (on the fly content match) NOTE: this only uses job descriptions to do the content matching. - NOTE: it is recommended that you have at least around 25 Jobs. + NOTE: it is recommended that you have at least around 25 ish Jobs. TODO: have this raise an exception if there are too few words? FIXME: make this a class so we can call it many times on single queries. @@ -88,7 +132,10 @@ def tfidf_filter(cur_dict: Dict[str, dict], # NOTE: this allows us to do smaller TFIDF comparisons because we ensure # that we are skipping previously-detected job duplicates (by id) duplicate_jobs_dict = duplicate_jobs_dict or {} # type: Dict[str, str] - existing_duplicate_keys = duplicate_jobs_dict.keys() + if duplicate_jobs_dict: + existing_duplicate_keys = duplicate_jobs_dict.keys() + else: + existing_duplicate_keys = {} # type: Dict[str, str] def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] ) -> Tuple[List[str], List[str]]: @@ -160,15 +207,18 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] filt_cur_dict[query_id], ) new_duplicate_jobs_list.append(filt_cur_dict[query_id]) - logger.debug( - f"Removed {job.key_id} from scraped data, TFIDF content match." - ) + + # Make sure the duplicate jobs list contains only unique entries + new_duplicate_jobs_list = list(set(new_duplicate_jobs_list)) # Pop duplicates from cur_dict and return them # NOTE: we cannot change cur_dict in above loop, or no updates possible. if new_duplicate_jobs_list: for job in new_duplicate_jobs_list: cur_dict.pop(job.key_id) + logger.debug( + f"Removed {job.key_id} from scraped data, TFIDF content match." + ) logger.info( f'Found and removed {len(new_duplicate_jobs_list)} ' From ee63de75cdf6859c90b14c9b01cad41dad90d896 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 24 Aug 2020 08:43:50 -0400 Subject: [PATCH 27/66] moved update if newer into Job, Vastly improved duplicates detection workflow with DuplicateJob, Made raw scrape first set, added tag scraping to monster, Made JobFilter class --- jobfunnel/backend/job.py | 54 ++- jobfunnel/backend/jobfunnel.py | 380 ++++++++----------- jobfunnel/backend/scrapers/base.py | 45 ++- jobfunnel/backend/scrapers/glassdoor.py | 14 +- jobfunnel/backend/scrapers/indeed.py | 13 +- jobfunnel/backend/scrapers/monster.py | 29 +- jobfunnel/backend/tools/__init__.py | 2 +- jobfunnel/backend/tools/filters.py | 462 +++++++++++++++--------- jobfunnel/backend/tools/tools.py | 15 +- jobfunnel/resources/enums.py | 11 + jobfunnel/resources/resources.py | 3 +- 11 files changed, 593 insertions(+), 435 deletions(-) diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 17e48ebd..951d7864 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -1,6 +1,7 @@ """Base Job class to be populated by Scrapers, manipulated by Filters and saved to csv / etc by Exporter """ +from copy import deepcopy from bs4 import BeautifulSoup from datetime import date, datetime import re @@ -10,7 +11,8 @@ from datetime import date, datetime, timedelta from jobfunnel.resources import ( - Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS, MAX_BLOCK_LIST_DESC_CHARS + Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS, MAX_BLOCK_LIST_DESC_CHARS, + MIN_DESCRIPTION_CHARS, ) @@ -94,12 +96,12 @@ def __init__(self, # These may not always be populated in our job source self.post_date = post_date - self.scrape_date = scrape_date if scrape_date else datetime.now() + self.scrape_date = scrape_date if scrape_date else datetime.today() self.tags = tags if tags else [] if short_description: self.short_description = short_description else: - self.short_description = description # TODO: copy it? + self.short_description = '' # Semi-private attrib for debugging self._raw_scrape_data = raw @@ -110,6 +112,45 @@ def is_remove_status(self) -> bool: """ return self.status in JOB_REMOVE_STATUSES + def update_if_newer(self, job: 'Job') -> bool: + """Update an existing job with new metadata but keep user's status, + but only if the job.post_date > existing_job.post_date! + + TODO: we should do more checks to ensure we are not seeing a totally + different job by accident (since this check is usually done by key_id) + TODO: more elegant way? maybe we can deepcopy self? + Returns: True if we updated + NOTE: if you have hours or minutes or seconds set, the comparison will + favour the extra information as being newer! + TODO: Currently we do day precision but if we wanted to update because + something is newer by hours we will need to revisit this limitation and + store scrape hour in the CSV as well. + + Returns: + True if we updated self with job, False if we didn't + """ + if (job.post_date > self.post_date): + # Update all attrs other than status (which user can set). + self.company = deepcopy(job.company) + self.location = deepcopy(job.location) + self.description = deepcopy(job.description) + self.key_id = deepcopy(job.key_id) # NOTE: be careful doing this! + self.url = deepcopy(job.url) + self.locale = deepcopy(job.locale) + self.query = deepcopy(job.query) + self.provider = deepcopy(job.provider) + self.status = deepcopy(job.status) + self.wage = deepcopy(job.wage) + self.remote = deepcopy(job.remote) + self.post_date = deepcopy(job.post_date) + self.scrape_date = deepcopy(job.scrape_date) + self.tags = deepcopy(job.tags) + self.short_description = deepcopy(job.short_description) + self._raw_scrape_data = deepcopy(job._raw_scrape_data) + return True + else: + return False + def is_old(self, max_age: datetime) -> bool: """Identify if a job is older than a certain max_age @@ -182,5 +223,10 @@ def clean_strings(self) -> None: ) def validate(self) -> None: - """TODO: implement this just to ensure that the metadata is good""" + """FIXME: implement this just to ensure that the metadata is good + """ assert self.key_id, "Key_ID is unset!" + assert self.title, "Title is unset!" + assert self.company, "Company is unset!" + assert self.url, "URL is unset!" + assert len(self.description) > MIN_DESCRIPTION_CHARS, "Description too short!" diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index a68414ab..5065c0ce 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -10,21 +10,26 @@ from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime, timedelta from time import time -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from requests import Session from jobfunnel.backend import Job -from jobfunnel.backend.tools.filters import tfidf_filter, JobFilter -from jobfunnel.backend.tools import update_job_if_newer, get_logger +from jobfunnel.backend.tools.filters import JobFilter, DuplicatedJob +from jobfunnel.backend.tools import get_logger from jobfunnel.config import JobFunnelConfig from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, MAX_CPU_WORKERS, JobStatus, Locale, T_NOW, - MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH) + MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, + DuplicateType) class JobFunnel: """Class that initializes a Scraper and scrapes a website to get jobs + + NOTE: This is intended to be used with persistant cache and CSV files + dedicated to a single, consistant job search. + FIXME: instead of Dic[str, Job] we should be using JobsDict """ def __init__(self, config: JobFunnelConfig) -> None: @@ -44,10 +49,6 @@ def __init__(self, config: JobFunnelConfig) -> None: "%(message)s" ) self.__date_string = date.today().strftime("%Y-%m-%d") - - # NOTE: we have these as attrs so they are read minimally. - self.user_block_jobs_dict = {} # type: Dict[str, str] - self.duplicate_jobs_dict = {} # type: Dict[str, str] self.master_jobs_dict = {} # type: Dict[str, Job] # Open a session with/out a proxy configured @@ -58,33 +59,33 @@ def __init__(self, config: JobFunnelConfig) -> None: } # Read the user's block list + user_block_jobs_dict = {} # type: Dict[str, str] if os.path.isfile(self.config.user_block_list_file): - self.user_block_jobs_dict = json.load( + user_block_jobs_dict = json.load( open(self.config.user_block_list_file, 'r') ) # Read the user's duplicate jobs list (from TFIDF) + duplicate_jobs_dict = {} # type: Dict[str, str] if os.path.isfile(self.config.duplicates_list_file): - self.duplicate_jobs_dict = json.load( + duplicate_jobs_dict = json.load( open(self.config.duplicates_list_file, 'r') ) - # Read the master CSV file - if os.path.isfile(self.config.master_csv_file): - self.master_jobs_dict = self.read_master_csv() - - # NOTE: we will set this after updating everything + # Initialize our job filter self.job_filter = JobFilter( - self.user_block_jobs_dict, - self.duplicate_jobs_dict, + user_block_jobs_dict, + duplicate_jobs_dict, self.config.search_config.blocked_company_names, - T_NOW - timedelta(days=self.config.search_config.max_listing_days) + T_NOW - timedelta(days=self.config.search_config.max_listing_days), + log_level=self.config.log_level, + log_file=self.config.log_file, ) @property def daily_cache_file(self) -> str: """The name for for pickle file containing the scraped data ran today' - TODO: instead of using a 'daily' cache file, we should be tying this + FIXME: instead of using a 'daily' cache file, we should be tying this into the search that was made to prevent cross-caching results. """ return os.path.join( @@ -93,23 +94,23 @@ def daily_cache_file(self) -> str: def run(self) -> None: """Scrape, update lists and save to CSV. - NOTE: we are assuming the user has distinct cache folder per-search, - otherwise we will load the cache for today, for a different search! """ + # Read the master CSV file + if os.path.isfile(self.config.master_csv_file): + self.master_jobs_dict = self.read_master_csv() # Load master csv jobs if they exist and update our block list with # any jobs the user has set the status to == a remove status - # NOTE: we want to do this first to ensure scraping is efficient when - # we are getting detailed job information (per-job) + # NOTE: we want to do this first to make our filters use current info. if self.master_jobs_dict: - self.update_user_block_list(self.master_jobs_dict) + self.update_user_block_list() else: logging.debug( "No master-CSV present, did not update block-list: " f"{self.config.user_block_list_file}" ) - # Get jobs keyed by their unique ID + # Scrape jobs or load them from a cache if one exists (--no-scrape) scraped_jobs_dict = {} # type: Dict[str, Job] if self.config.no_scrape: @@ -119,66 +120,104 @@ def run(self) -> None: scraped_jobs_dict = self.load_cache(self.daily_cache_file) else: self.logger.warning( - f"No jobs cached, missing: {self.daily_cache_file}" + f"No incoming jobs, missing cache: {self.daily_cache_file}" ) else: - # Scrape new jobs and cache them + # Scrape new jobs from all our configured providers and cache them scraped_jobs_dict = self.scrape() self.write_cache(scraped_jobs_dict) - # Pre-filter by removing jobs with duplicate IDs from scraped_jobs_dict + # Filter out any jobs we have rejected, archived or block-listed + # NOTE: we do not remove duplicates here as these may trigger updates if scraped_jobs_dict: - if self.master_jobs_dict: - self.filter_new_duplicates( - scraped_jobs_dict, self.master_jobs_dict, - by_key_id_only=True, - ) + scraped_jobs_dict = self.job_filter.filter( + scraped_jobs_dict, remove_existing_duplicate_keys=False + ) + if self.master_jobs_dict: + self.master_jobs_dict = self.job_filter.filter( + self.master_jobs_dict, remove_existing_duplicate_keys=False, + ) - # Filter out scraped jobs we have rejected, archived or block-listed - # or which we previously detected as duplicates before updating CSV. - self.filter_jobs(scraped_jobs_dict) + # Parse duplicate jobs into updates for master jobs dict + # FIXME: we need to search for duplicates without master jobs too! + duplicate_jobs = [] # type: List[DuplicateJob] + if self.master_jobs_dict and scraped_jobs_dict: - # Update master CSV if we have one with scrape data (if we have it) - if scraped_jobs_dict: + # Remove jobs with duplicated key_ids from scrape + update master + duplicate_jobs = self.job_filter.find_duplicates( + self.master_jobs_dict, scraped_jobs_dict, + ) - if self.master_jobs_dict: + for match in duplicate_jobs: - # Mabye reduce the size of master_jobs (user-blocked new jobs) - self.filter_jobs(self.master_jobs_dict) + # Was it a key-id match? + if match.type in [DuplicateType.KEY_ID or + DuplicateType.EXISTING_TFIDF]: - # Filter out duplicates and update duplicates list file - # NOTE: this will match duplicates by job description contents - if scraped_jobs_dict: - self.filter_new_duplicates( - scraped_jobs_dict, self.master_jobs_dict + # NOTE: original and duplicate have same key id for these. + # When it's EXISTING_TFIDF, we can't set match.duplicate + # because it is only partially stored in the block list JSON + if match.original.key_id and (match.original.key_id + != match.duplicate.key_id): + raise ValueError( + "Found duplicate by key-id, but keys dont match! " + f"{match.original.key_id}, {match.duplicate.key_id}" + ) + + # Got a key-id match, pop from scrape dict and maybe update + upd = self.master_jobs_dict[ + match.duplicate.key_id].update_if_newer( + scraped_jobs_dict.pop(match.duplicate.key_id) + ) + self.logger.debug( + f"Identified duplicate {match.duplicate.key_id} and " + f"{'updated older' if upd else 'did not update'} " + f"original job of same key-id with its data." ) - # Expand self.master_jobs_dict with scraped & filtered jobs - self.master_jobs_dict.update(scraped_jobs_dict) + # Was it a content-match? + elif match.type == DuplicateType.NEW_TFIDF: - # Update the existing master jobs dict (i.e. remove status-jobs) - self.write_master_csv(self.master_jobs_dict) + # Got a content match, pop from scrape dict and maybe update + upd = self.master_jobs_dict[ + match.original.key_id].update_if_newer( + scraped_jobs_dict.pop(match.duplicate.key_id) + ) + self.logger.debug( + f"Identified {match.duplicate.key_id} as a " + "duplicate by contents and " + f"{'updated older' if upd else 'did not update'} " + f"original job {match.original.key_id} with its data." + ) - else: - # Dump the results into the data folder as the masterlist - # FIXME: we could still detect duplicates within the CSV itself? - self.write_master_csv(scraped_jobs_dict) + # Update duplicates file (if any updates are incoming) + if duplicate_jobs: + self.update_duplicates_file() - else: - # User is running --no-scrape and hasn't got a master CSV - self.logger.error( - "Running --no-scrape without any cached file or CSV, nothing " - "was done!" - ) + # Update master jobs dict with the incoming jobs that passed filters + if scraped_jobs_dict: + self.master_jobs_dict.update(scraped_jobs_dict) + + # Write-out to CSV or log messages + if self.master_jobs_dict: - # Tell user we updated CSV and/or scraped successfully. - # TODO: chuck a stat or two in here? - if scraped_jobs_dict or self.master_jobs_dict: + # Write our updated jobs out (if none, dont make the file at all) + self.write_master_csv(self.master_jobs_dict) self.logger.info( f"Done. View your current jobs in {self.config.master_csv_file}" ) + else: + # We got no new, unique jobs. This is normal if loading scrape + # with --no-scrape as all jobs are removed by duplicate filter + if self.config.no_scrape: + # User is running --no-scrape probably just to update lists + self.logger.debug("No new jobs were added.") + else: + self.logger.warning("No new jobs were added to CSV.") + + def scrape(self) ->Dict[str, Job]: """Run each of the desired Scraper.scrape() with threading and delaying """ @@ -186,9 +225,6 @@ def scrape(self) ->Dict[str, Job]: f"Scraping local providers with: {self.config.scraper_names}" ) - # Ensure filters are up-to-date for per-job filtering as we scrape - self._update_job_filter() - # Iterate thru scrapers and run their scrape. jobs = {} # type: Dict[str, Job] for scraper_cls in self.config.scrapers: @@ -216,7 +252,7 @@ def recover(self) -> None: f"{self.config.user_block_list_file} if you want to start fresh" " from the cached data and not filter any jobs away." ) - all_jobs_dict = {} + all_jobs_dict = {} # type: Dict[str, Job] for file in os.listdir(self.config.cache_folder): if '.pkl' in file: all_jobs_dict.update( @@ -224,8 +260,7 @@ def recover(self) -> None: os.path.join(self.config.cache_folder, file) ) ) - self.filter_jobs(all_jobs_dict) # NOTE: we warn user about this above - self.write_master_csv(all_jobs_dict) + self.write_master_csv(self.job_filter.filter(all_jobs_dict)) def load_cache(self, cache_file: str) -> Dict[str, Job]: @@ -265,14 +300,16 @@ def write_cache(self, jobs_dict: Dict[str, Job], """Dump a jobs_dict into a pickle TODO: write search_config into the cache file and jobfunnel version - FIXME: some way to cache raw data without recur-limit + FIXME: add versioning to this Args: jobs_dict (Dict[str, Job]): jobs dict to dump into cache. cache_file (str, optional): file path to write to. Defaults to None. """ - cache_file = cache_file if cache_file else self.daily_cache_file + # FIXME: some way to cache raw data without recur-limit + for job in jobs_dict.values(): + job._raw_scrape_data = None pickle.dump(jobs_dict, open(cache_file, 'wb')) self.logger.debug( f"Dumped {len(jobs_dict.keys())} jobs to {cache_file}" @@ -292,8 +329,9 @@ def read_master_csv(self) -> Dict[str, Job]: errors='ignore') as csvfile: for row in csv.DictReader(csvfile): # NOTE: we are doing legacy support here with 'blurb' etc. - if 'description' in row: - short_description = row['description'] + # In the future we should have an actual condensed descript. + if 'short_description' in row: + short_description = row['short_description'] else: short_description = '' post_date = datetime.strptime(row['date'], '%Y-%m-%d') @@ -304,6 +342,7 @@ def read_master_csv(self) -> Dict[str, Job]: else: scrape_date = post_date if 'raw' in row: + # NOTE: we should never see this because raw cant be in CSV raw = row['raw'] else: raw = None @@ -379,23 +418,13 @@ def write_master_csv(self, jobs: Dict[str, Job]) -> None: f"Wrote {len(jobs)} jobs to {self.config.master_csv_file}" ) - def update_user_block_list(self, - master_jobs_dict: Optional[Dict[str, Job]] = None - ) -> None: + def update_user_block_list(self) -> None: """From data in master CSV file, add jobs with removeable statuses to our configured user block list file and save (if any) - NOTE: we assume that the contents of master_jobs_dict match the contents - returned by self.read_master_csv, passing this argument just saves us - loading twice in jobfunnel.run() - NOTE: adding jobs to block list will result in filter() removing them from all scraped & cached jobs in the future. - Args: - master_jobs_dict (Optional[Dict[str, Job]], optional): the existing - jobs in the user's master CSV file. If None we will load from - CSV or raise an error if CSV does not exist. Raises: FileNotFoundError: if no master_jobs_dict is provided and master csv file does not exist. @@ -414,14 +443,20 @@ def update_user_block_list(self, # Add jobs from csv that need to be filtered away, if any + update self n_jobs_added = 0 for job in self.master_jobs_dict.values(): - if (job.is_remove_status and job.key_id - not in self.user_block_jobs_dict): - n_jobs_added += 1 - self.user_block_jobs_dict[job.key_id] = job.as_json_entry - logging.info( - f'Added {job.key_id} to ' - f'{self.config.user_block_list_file}' - ) + if job.is_remove_status: + if job.key_id not in self.job_filter.user_block_jobs_dict: + n_jobs_added += 1 + self.job_filter.user_block_jobs_dict[ + job.key_id] = job.as_json_entry + logging.info( + f'Added {job.key_id} to ' + f'{self.config.user_block_list_file}' + ) + else: + self.logger.warning( + f"Job {job.key_id} has been set to a removable status " + "and removed from master CSV multiple times." + ) if n_jobs_added: # Write out complete list with any additions from the masterlist @@ -430,151 +465,46 @@ def update_user_block_list(self, encoding='utf8') as outfile: outfile.write( json.dumps( - self.user_block_jobs_dict, + self.job_filter.user_block_jobs_dict, indent=4, sort_keys=True, separators=(',', ': '), ensure_ascii=False, ) ) + self.logger.info( f"Moved {n_jobs_added} jobs into block-list due to removable " f"statuses: {self.config.user_block_list_file}" ) - def _update_job_filter(self) -> None: - """Ensure that the filtering attribs are up-to-date TODO: better way? - """ - self.job_filter.user_block_jobs_dict = self.user_block_jobs_dict - self.job_filter.master_jobs_dict = self.master_jobs_dict - self.job_filter.duplicate_jobs_dict = self.duplicate_jobs_dict - - def filter_jobs(self, jobs_dict: Dict[str, Job]) -> int: - """Remove jobs from jobs_dict if they are: - 1. in our block-list - 2. status == a removal status string (i.e. DELETE) - 3. job.company == one of our blocked company names - - Returns the number of filtered jobs - - NOTE: this also removes any duplicates from jobs_dict if a duplicates - list file is configured. - - TODO: make the filters used configurable, i.e. list of FilterType - """ - self._update_job_filter() - - # Filter jobs with all filters except TFIDF, then remove filtered jobs - filter_jobs_ids = [] - for key_id, job in jobs_dict.items(): - if self.job_filter.filter(job): - filter_jobs_ids.append(key_id) - - for key_id in filter_jobs_ids: - jobs_dict.pop(key_id) - - n_filtered = len(filter_jobs_ids) - if n_filtered > 0: - self.logger.info( - f"Removed {n_filtered} job(s) from scraped data, jobs are " - "blocked/removed, old, or content-duplicates of jobs in " - "master CSV." - ) - - return n_filtered - - - def filter_new_duplicates(self, scraped_jobs_dict: Dict[str, Job], - existing_jobs_dict: Dict[str, Job], - by_key_id_only: bool = False) -> None: - """Identify duplicate jobs between scrape data and existing_jobs_dict - and update the duplicates block list if any are found by contents. - - NOTE: this is intended for identifying new duplicates - - TODO: move this into self.filter_jobs() - it should be more configurable - TODO: make max_similarity configurable i.e. self.config.filter... - TODO: we are wrapping in a try/catch because TFIDF filter is missing - some error handling. Remove once it is safer to use w.out crashing - NOTE: only duplicates detected by job contents will be written to - the duplicates_list_file JSON, as checking by key_id is not - an expensive comparison vs full TFIDF vectorization. - NOTE: when we detect that an existing job is a duplicate of a new job - we update the existing job with the new job's post date and other - information. (only if post date is newer!) - - Args: - scraped_jobs_dict (Dict[str, Job]): currently scraped jobs dict - existing_jobs_dict (Dict[str, Job]): existing jobs dict i.e. master - by_key_id_only (bool, optional): if True, only remove duplicates - via key_id. If false, use the contents of the jobs to identify - duplicates as well (NOTE: currently only TFIDF filter for desc). + def update_duplicates_file(self) -> None: + """Update duplicates filter file if we have a path and contents + FIXME: this should be writing out DuplicateJob objects and a version """ - # First we need to remove any duplicates by id directly - # FIXME: this should go into BaseScraper get set - for key_id in existing_jobs_dict: - if key_id in scraped_jobs_dict: - duplicate_job = scraped_jobs_dict.pop(key_id) - if update_job_if_newer(existing_jobs_dict[key_id], - duplicate_job): - self.logger.debug( - f"Updated job {key_id} with duplicate's contents." - ) - - # If we have any jobs left, filter these using their contents. - if by_key_id_only and scraped_jobs_dict: - if (len(scraped_jobs_dict.keys()) + len(existing_jobs_dict.keys()) - >= MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH): - - # Run the TFIDF content matching filter and get new duplicates - new_duplicate_jobs_list = [] # type: List[Job] - try: - new_duplicate_jobs_list = tfidf_filter( - cur_dict=scraped_jobs_dict, - prev_dict=existing_jobs_dict, - log_level=self.config.log_level, - log_file=self.config.log_file, - duplicate_jobs_dict=self.duplicate_jobs_dict, - ) - - except ValueError as err: - self.logger.error( - f"Skipping similarity filter due to error: {str(err)}" - ) - - # Save any new duplicates - if new_duplicate_jobs_list: - - if self.config.duplicates_list_file: - - # Write out a list of duplicates so that detections - # persist under changing input data / scrape data. - self.duplicate_jobs_dict = { - dj.key_id: dj.as_json_entry - for dj in new_duplicate_jobs_list - } # type: Dict[str, str] - - with open(self.config.duplicates_list_file, 'w', - encoding='utf8') as outfile: - # NOTE: we use indent=4 for human-readability - outfile.write( - json.dumps( - self.duplicate_jobs_dict, - indent=4, - sort_keys=True, - separators=(',', ': '), - ensure_ascii=False, - ) - ) - else: - self.logger.warning( - "Duplicates will not be saved, no duplicates list " - "file set. Saving to a duplicates file will ensure " - "that these persist." + if self.config.duplicates_list_file: + if self.job_filter.duplicate_jobs_dict: + + # Write out the changes NOTE: indent=4 is for human-readability + self.logger.debug("Extending existing duplicate jobs dict.") + with open(self.config.duplicates_list_file, 'w', + encoding='utf8') as outfile: + outfile.write( + json.dumps( + self.job_filter.duplicate_jobs_dict, + indent=4, + sort_keys=True, + separators=(',', ': '), + ensure_ascii=False, ) - + ) else: - self.logger.warning( - "Skipping similarity filter because there are fewer than " - f"{MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH} jobs." + self.logger.debug( + "Current duplicate jobs dict is empty, no updates written." ) + else: + self.logger.warning( + "Duplicates will not be saved, no duplicates list " + "file set. Saving to a duplicates file will ensure " + "that jobs detected to be duplicates by contents persist." + ) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 848dfea9..1dd661ab 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -1,6 +1,5 @@ """The base scraper class to be used for all web-scraping emitting Job objects """ -import datetime import logging import os import random @@ -30,6 +29,8 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', job_filter: JobFilter) -> None: """Init + TODO: we should have a way of establishing pre-requsites for set() + Args: session (Session): session object used to make post and get requests config (JobFunnelConfig): config containing all needed paths, search @@ -67,7 +68,7 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self._validate_get_set() # Init a thread executor (multi-worker) TODO: can't reuse after shutdown - self.executor = ThreadPoolExecutor(max_workers=1) #MAX_CPU_WORKERS) + self.executor = ThreadPoolExecutor(max_workers=1) #MAX_CPU_WORKERS) FIXME @property def user_agent(self) -> str: @@ -198,7 +199,7 @@ def scrape(self) -> Dict[str, Job]: for future in tqdm(as_completed(results), total=n_soups): job = future.result() if job: - # Handle duplicates that exist within the scraped set. + # Handle duplicates that exist within the scraped data itself. # NOTE: if you see alot of these our scrape for key_id is bad if job.key_id in jobs_dict: self.logger.error( @@ -239,9 +240,19 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float # Break out immediately because we have failed a filterable # condition with something we initialized while scraping. - if job and self.job_filter(job): + # NOTE: if we pre-empt scraping duplicates we cannot update + # the existing job listing with the new information! + # TODO: make this configurable? + if job and self.job_filter.filterable(job): + # if self.job_filter.is_duplicate(job): + # self.logger.debug( # FIXME: do we want this message? + # f"Scraped job {job.key_id} has key_id " + # "in known duplicates list. Continuing scrape of job " + # "to update existing job attributes." + # ) + # else: self.logger.debug( - f"Skipping scraping job {job.key_id}, failed JobFilter" + f"Cancelled scraping of {job.key_id}, failed JobFilter" ) break @@ -262,11 +273,10 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float }) self.set(field, job, job_soup) - # FIXME: Abort scraping immediately if we have a duplicate ? - # This will prevent getting un-needed descriptions (delayed) - # if field == JobField.KEY_ID and job.key_id in .... - except Exception as err: + + # Crash out gracefully so we can continue scraping. + # (hopefully it's a one-off) if field in self.min_required_job_fields: raise ValueError( "Unable to scrape minimum-required job field: " @@ -283,10 +293,6 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float if job: job.validate() - # FIXME: this is to prevent issues with JSON and raw data recur lim. - # We could handle this when scraping but this will also save memory. - job._raw_scrape_data = None - return job @abstractmethod @@ -351,6 +357,7 @@ def _validate_get_set(self) -> None: f"Scraper: {self.__class__.__name__} Job attributes: " f"{field_intersection} are implemented by both get() and set()!" ) + excluded_fields = [] # type: List[JobField] for field in JobField: # NOTE: we exclude status, locale, query, provider and scrape date # because these are set without needing any scrape data. @@ -360,9 +367,15 @@ def _validate_get_set(self) -> None: JobField.SHORT_DESCRIPTION, JobField.RAW] and field not in self.job_get_fields and field not in self.job_set_fields): - self.logger.warning( - f"No get() or set() will be done for Job attr: {field.name}" - ) + excluded_fields.append(field) + if excluded_fields: + # NOTE: INFO level because this is OK, but ideally ppl see this + # so they are motivated to help and understand why stuff might + # be missing in the CSV + self.logger.info( + "No get() or set() will be done for Job attrs: " + f"{[field.name for field in excluded_fields]}" + ) # Just some basic localized scrapers, you can inherit these to set the locale. diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index c7c3181f..fca08010 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -1,4 +1,5 @@ """Scraper for www.glassdoor.X +FIXME: this is currently unable to get past page 1 of job results. """ from abc import abstractmethod from bs4 import BeautifulSoup @@ -79,7 +80,7 @@ def job_get_fields(self) -> str: def job_set_fields(self) -> str: """Call self.set(...) for the JobFields in this list when scraping a Job """ - return [JobField.DESCRIPTION] + return [JobField.RAW, JobField.DESCRIPTION] @property def delayed_get_set_fields(self) -> str: @@ -88,7 +89,7 @@ def delayed_get_set_fields(self) -> str: Override this as needed. """ - return [JobField.DESCRIPTION] + return [JobField.RAW] @property def headers(self) -> Dict[str, str]: @@ -256,14 +257,15 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField NOTE: Description has to get and should be respectfully delayed """ - if parameter == JobField.DESCRIPTION: - job_link_soup = BeautifulSoup( + if parameter == JobField.RAW: + job._raw_scrape_data = BeautifulSoup( self.session.get(job.url).text, self.config.bs4_parser ) - job.description = job_link_soup.find( + elif parameter == JobField.DESCRIPTION: + assert job._raw_scrape_data + job.description = job._raw_scrape_data.find( id='JobDescriptionContainer' ).text.strip() - job._raw_scrape_data = job_link_soup # This is so we can set wage else: raise NotImplementedError(f"Cannot set {parameter.name}") diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 634a307a..0809becd 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -70,7 +70,7 @@ def job_set_fields(self) -> str: Override this as needed. """ - return [JobField.URL, JobField.DESCRIPTION] + return [JobField.RAW, JobField.URL, JobField.DESCRIPTION] @property def delayed_get_set_fields(self) -> str: @@ -79,7 +79,7 @@ def delayed_get_set_fields(self) -> str: Override this as needed. """ - return [JobField.DESCRIPTION] + return [JobField.RAW] @property def headers(self) -> Dict[str, str]: @@ -178,14 +178,17 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField """ - if parameter == JobField.DESCRIPTION: - detailed_job_soup = BeautifulSoup( + if parameter == JobField.RAW: + job._raw_scrape_data = BeautifulSoup( self.session.get(job.url).text, self.config.bs4_parser ) - job.description = detailed_job_soup.find( + elif parameter == JobField.DESCRIPTION: + assert job._raw_scrape_data + job.description = job._raw_scrape_data.find( id='jobDescriptionText' ).text.strip() elif parameter == JobField.URL: + assert job.key_id job.url = ( f"http://www.indeed.{self.config.search_config.domain}/" f"viewjob?jk={job.key_id}" diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 5bde6495..7ace04c2 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -21,6 +21,7 @@ ) MAX_RESULTS_PER_MONSTER_PAGE = 25 +MONSTER_SIDEPANEL_TAG_ENTRIES = ['industries', 'job type'] # these --> Job.tags ID_REGEX = re.compile( r'/((?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]' r'{12})|\d+)' @@ -29,6 +30,9 @@ class BaseMonsterScraper(BaseScraper): """Scraper for www.monster.X + + NOTE: I dont think it's possible to scrape REMOTE other than from desc. + as of aug 2020. -pm """ def __init__(self, session: Session, config: 'JobFunnelConfig', @@ -65,7 +69,7 @@ def job_get_fields(self) -> str: def job_set_fields(self) -> str: """Call self.set(...) for the JobFields in this list when scraping a Job """ - return [JobField.DESCRIPTION] + return [JobField.RAW, JobField.DESCRIPTION, JobField.TAGS] @property def delayed_get_set_fields(self) -> str: @@ -74,7 +78,7 @@ def delayed_get_set_fields(self) -> str: Override this as needed. """ - return [JobField.DESCRIPTION] + return [JobField.RAW] @property def headers(self) -> Dict[str, str]: @@ -113,6 +117,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: soup.find('time').text.strip() ) elif parameter == JobField.URL: + # NOTE: seems that it is a bit hard to view these links? getting 503 return str( soup.find('a', attrs={'data-bypass': 'true'}).get('href') ) @@ -122,13 +127,27 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField """ - if parameter == JobField.DESCRIPTION: - detailed_job_soup = BeautifulSoup( + if parameter == JobField.RAW: + job._raw_scrape_data = BeautifulSoup( self.session.get(job.url).text, self.config.bs4_parser ) - job.description = detailed_job_soup.find( + elif parameter == JobField.DESCRIPTION: + assert job._raw_scrape_data + job.description = job._raw_scrape_data.find( id='JobDescription' ).text.strip() + elif parameter == JobField.TAGS: + # NOTE: this seems a bit flimsy, monster allows a lot of flex. here + assert job._raw_scrape_data + tags = [] # type: List[str] + for li in job._raw_scrape_data.find_all( + 'section', attrs={'class': 'summary-section'}): + table_key = li.find('dt') + if (table_key and table_key.text.strip().lower() + in MONSTER_SIDEPANEL_TAG_ENTRIES): + table_value = li.find('dd') + if table_value: + tags.append(table_value.text.strip()) else: raise NotImplementedError(f"Cannot set {parameter.name}") diff --git a/jobfunnel/backend/tools/__init__.py b/jobfunnel/backend/tools/__init__.py index d49cac65..83baaef6 100644 --- a/jobfunnel/backend/tools/__init__.py +++ b/jobfunnel/backend/tools/__init__.py @@ -1,3 +1,3 @@ from jobfunnel.backend.tools.tools import ( - get_webdriver, update_job_if_newer, get_logger + get_webdriver, get_logger ) diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index fa9146d9..9d6dd19e 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -3,6 +3,8 @@ FIXME: we should have a Enum(Filter) for all job filters to allow configuration and generic log messages. """ +from collections import namedtuple +from copy import deepcopy import logging from typing import Dict, List, Optional, Tuple from datetime import datetime @@ -15,22 +17,35 @@ from sklearn.metrics.pairwise import cosine_similarity from jobfunnel.backend import Job -from jobfunnel.backend.tools import update_job_if_newer, get_logger +from jobfunnel.backend.tools import get_logger from jobfunnel.resources import ( - DEFAULT_MAX_TFIDF_SIMILARITY, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH + DEFAULT_MAX_TFIDF_SIMILARITY, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, + DuplicateType +) + +DuplicatedJob = namedtuple( + 'DuplicatedJob', ['original', 'duplicate', 'type'], ) class JobFilter: """Class Used by JobFunnel and BaseScraper to filter collections of jobs + + TODO: make more configurable, maybe with a Filter class and a FilterBank. """ + def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, duplicate_jobs_dict: Optional[Dict[str, str]] = None, blocked_company_names_list: Optional[List[str]] = None, - max_job_date: Optional[datetime] = None) -> None: + max_job_date: Optional[datetime] = None, + max_similarity: float = DEFAULT_MAX_TFIDF_SIMILARITY, + min_tfidf_corpus_size: + int = MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, + log_level: int = logging.INFO, + log_file: str = None) -> None: """Init - NOTE: remember to update attributes as needed. + TODO: need a config for this Args: user_block_jobs_dict (Optional[Dict[str, str]], optional): dict @@ -46,184 +61,309 @@ def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, self.duplicate_jobs_dict = duplicate_jobs_dict or {} self.blocked_company_names_list = blocked_company_names_list or [] self.max_job_date = max_job_date - # TODO: add tfidf to this class for per-job scraping + self.max_similarity = max_similarity + self.min_tfidf_corpus_size = min_tfidf_corpus_size + self.logger = get_logger( + self.__class__.__name__, + log_level, + log_file, + f"[%(asctime)s] [%(levelname)s] {self.__class__.__name__}: " + "%(message)s" + ) + # Retrieve stopwords if not already downloaded + try: + stopwords = nltk.corpus.stopwords.words('english') + except LookupError: + nltk.download('stopwords', quiet=True) + stopwords = nltk.corpus.stopwords.words('english') - def filter(self, job: Job) -> bool: + # Init vectorizer e! + self.vectorizer = TfidfVectorizer( + strip_accents='unicode', + lowercase=True, + analyzer='word', + stop_words=stopwords, + ) + + def filter(self, jobs_dict: Dict[str, Job], + remove_existing_duplicate_keys: bool = True) -> Dict[str, Job]: + """Filter jobs that fail numerous tests, possibly including duplication + + Arguments: + remove_existing_duplicate_keys: pass True to remove jobs if their + ID was previously detected to be a duplicate via TFIDF cosine + similarity + + NOTE: if you remove duplicates before processesing them into updates + you will retain potentially stale job information. + + FIXME: it would make sense if we could integrate filter_duplicates + into this as well. + + Returns: + jobs_dict with all filtered items removed. + """ + return { + key_id: job for key_id, job in jobs_dict.items() + if not self.filterable( + job, check_existing_duplicates=remove_existing_duplicate_keys + ) + } + + def filterable(self, job: Job, + check_existing_duplicates: bool = True) -> bool: """Filter jobs out using all our available filters + + Arguments: + check_existing_duplicates: pass True to check if ID was previously + detected to be a duplicate via TFIDF cosine similarity + + Returns: + True if the job should be removed from incoming data, else False + TODO: arrange checks by how long they take to run NOTE: this does a lot of checks because job may be partially initialized """ - return ( + return bool( job.status and job.is_remove_status or (job.company in self.blocked_company_names_list) or (job.post_date and self.max_job_date and job.is_old(self.max_job_date)) or (job.key_id and self.user_block_jobs_dict and job.key_id in self.user_block_jobs_dict) - or (job.key_id and self.duplicate_jobs_dict - and job.key_id in self.duplicate_jobs_dict) + or (check_existing_duplicates and self.is_duplicate(job)) ) + def is_duplicate(self, job: Job) -> bool: + """Return true if passed Job has key_id and it is in our duplicates list + """ + return bool(job.key_id and self.duplicate_jobs_dict + and job.key_id in self.duplicate_jobs_dict) -def tfidf_filter(cur_dict: Dict[str, dict], - prev_dict: Optional[Dict[str, dict]] = None, - max_similarity: float = DEFAULT_MAX_TFIDF_SIMILARITY, - duplicate_jobs_dict: Optional[Dict[str, str]] = None, - log_level: int = logging.INFO, - log_file: str = None, - ) -> List[Job]: - """Fit a tfidf vectorizer to a corpus of Job.DESCRIPTIONs and identify - duplicate jobs by cosine-similarity. - - NOTE: This will update jobs in cur_dict if the content match has a newer - post_date. - NOTE/WARNING: if you are running this method, you should have already - removed any duplicates by key_id - FIXME: we should make max_similarity configurable in SearchConfig - FIXME: this should be integrated into JobFilter (on the fly content match) - NOTE: this only uses job descriptions to do the content matching. - NOTE: it is recommended that you have at least around 25 ish Jobs. - TODO: have this raise an exception if there are too few words? - FIXME: make this a class so we can call it many times on single queries. - - Args: - cur_dict (Dict[str, dict]): dict of jobs containing potential duplicates - (i.e jobs we just scraped) - prev_dict (Optional[Dict[str, dict]], optional): the existing jobs dict - (i.e. master CSV contents). If None, we will remove duplicates - from within the cur_dict only. Defaults to None. - max_similarity (float, optional): threshold above which blurb similarity - is considered a duplicate. Defaults to DEFAULT_MAX_TFIDF_SIMILARITY. - duplicate_jobs_dict (str, optional): cntents of user's duplicate job - detection JSON so we can make content matching persist better. - ... - - Raises: - ValueError: cur_dict contains no job descriptions - - Returns: - List[Job]: list of new duplicate Jobs which were removed from cur_dict - """ - logger = get_logger( - tfidf_filter.__name__, - log_level, - log_file, - f"[%(asctime)s] [%(levelname)s] {tfidf_filter.__name__}: %(message)s" - ) - - # Retrieve stopwords if not already downloaded - # TODO: we should use this to make jobs attrs tokenizable as a property. - # TODO: make the vectorizer persistant. - try: - stopwords = nltk.corpus.stopwords.words('english') - except LookupError: - nltk.download('stopwords', quiet=True) - stopwords = nltk.corpus.stopwords.words('english') - - # init vectorizer NOTE: pretty fast call but we should do this once! - vectorizer = TfidfVectorizer( - strip_accents='unicode', - lowercase=True, - analyzer='word', - stop_words=stopwords, - ) - - # Load known duplicate keys from JSON if we have it - # NOTE: this allows us to do smaller TFIDF comparisons because we ensure - # that we are skipping previously-detected job duplicates (by id) - duplicate_jobs_dict = duplicate_jobs_dict or {} # type: Dict[str, str] - if duplicate_jobs_dict: - existing_duplicate_keys = duplicate_jobs_dict.keys() - else: - existing_duplicate_keys = {} # type: Dict[str, str] - - def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] - ) -> Tuple[List[str], List[str]]: - """Get query words and ids as lists + prefilter - NOTE: this is just a convenience method since we do this 2x + def find_duplicates(self, existing_jobs_dict: Dict[str, Job], + incoming_jobs_dict: Dict[str, Job], + ) -> List[DuplicatedJob]: + """Remove all known duplicates from jobs_dict and update original data + + Args: + existing_jobs_dict (Dict[str, Job]): dict of jobs keyed by key_id. + incoming_jobs_dict (Dict[str, Job]): dict of new jobs by key_id. + + Returns: + Dict[str, Job]: jobs dict with all jobs keyed by known-duplicate + key_ids removed, and their originals updated. """ - ids = [] # type: List[str] - words = [] # type: List[str] - filt_job_dict = {} # type: Dict[str, Job] - for job in cur_dict.values(): - if job.key_id in existing_duplicate_keys: - logger.debug( - f"Removing {job.key_id} from scrape result, existing " - "duplicate." + duplicate_jobs_list = [] # type: List[DuplicateJob] + filt_existing_jobs_dict = deepcopy(existing_jobs_dict) + # FIXME: we assume there are no duplicates by content in existing jobs + # And this is a bad assumption... need to fix this. + filt_incoming_jobs_dict = {} # type: Dict[str, Job] + + # Look for matches by key id only + for key_id, incoming_job in incoming_jobs_dict.items(): + + # The key-ids are a direct match between existing and new + if key_id in existing_jobs_dict: + self.logger.debug( + f"Identified duplicate {key_id} between incoming data " + "and existing data." ) - elif not len(job.description): - logger.debug( - f"Removing {job.key_id} from scrape result, empty " - "description." + duplicate_jobs_list.append( + DuplicatedJob( + original=existing_jobs_dict[key_id], + duplicate=incoming_job, + type=DuplicateType.KEY_ID, + ) + ) + + # The key id is a known-duplicate we detected via content match + # NOTE: original and duplicate have the same key id. + elif key_id in self.duplicate_jobs_dict: + self.logger.debug( + f"Identified existing content-matched duplicate {key_id} " + "in incoming data." + ) + duplicate_jobs_list.append( + DuplicatedJob( + original=None, # FIXME: we should keep original ref. + duplicate=incoming_job, + type=DuplicateType.EXISTING_TFIDF, + ) ) else: - ids.append(job.key_id) - words.append(job.description) - # NOTE: We want to leave changing cur_dict in place till the end - # or we will break usage of update_job_if_newer() - filt_job_dict[job.key_id] = job - - # TODO: assert on length of contents of the lists as well - if not words: - raise ValueError( - "No data to fit, are your job descriptions all empty?" - ) - return ids, words, filt_job_dict - - query_ids, query_words, filt_cur_dict = __dict_to_ids_and_words(cur_dict) - reference_ids, reference_words, filt_prev_dict = __dict_to_ids_and_words( - prev_dict - ) - - # Provide a warning if we have few words. - corpus = query_words + reference_words - if len(corpus) < MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH: - logger.warning( - "It is not recommended to use this filter with less than " - f"{MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH} words" - ) + # This key_id is not duplicate, we can use it for TFIDF + filt_incoming_jobs_dict[key_id] = deepcopy(incoming_job) - # Fit vectorizer to entire corpus - vectorizer.fit(corpus) - - # Calculate cosine similarity between reference and current blurbs - # This is a list of the similarity between that query job and all the - # TODO: impl. in a more efficient way since fit() does the transform already - similarities_per_query = cosine_similarity( - vectorizer.transform(query_words), - vectorizer.transform(reference_words), - ) - - # Get duplicate job ids and pop them, updating cur_dict if they are newer - # NOTE: multiple jobs can be determined to be a duplicate of the same job! - new_duplicate_jobs_list = [] # type: List[Job] - for query_similarities, query_id in zip(similarities_per_query, query_ids): - - # Identify the jobs in prev_dict that our query is a duplicate of - # FIXME: handle if everything is highly similar! - for similar_index in np.where(query_similarities >= max_similarity)[0]: - update_job_if_newer( - filt_prev_dict[reference_ids[similar_index]], - filt_cur_dict[query_id], + # Run the tfidf vectorizer if we have enough jobs left after removing + # key duplicates + if (len(filt_incoming_jobs_dict.keys()) + len(filt_existing_jobs_dict.keys()) + < self.min_tfidf_corpus_size): + self.logger.warning( + "Skipping similarity filter because there are fewer than " + f"{self.min_tfidf_corpus_size} jobs." + ) + elif filt_incoming_jobs_dict: + duplicate_jobs_list.extend( + self.tfidf_filter( + incoming_jobs_dict=filt_incoming_jobs_dict, + existing_jobs_dict=filt_existing_jobs_dict, + ) ) - new_duplicate_jobs_list.append(filt_cur_dict[query_id]) - - # Make sure the duplicate jobs list contains only unique entries - new_duplicate_jobs_list = list(set(new_duplicate_jobs_list)) - - # Pop duplicates from cur_dict and return them - # NOTE: we cannot change cur_dict in above loop, or no updates possible. - if new_duplicate_jobs_list: - for job in new_duplicate_jobs_list: - cur_dict.pop(job.key_id) - logger.debug( - f"Removed {job.key_id} from scraped data, TFIDF content match." + else: + self.logger.warning( + "Skipping similarity filter because there are no incoming jobs" ) - logger.info( - f'Found and removed {len(new_duplicate_jobs_list)} ' - f're-posts/duplicate postings via TFIDF cosine similarity.' + # Update duplicates list with more JSON-friendly entries + # FIXME: we should retain a reference to the original job + self.duplicate_jobs_dict.update({ + j.duplicate.key_id: j.duplicate.as_json_entry + for j in duplicate_jobs_list + }) + + return duplicate_jobs_list + + def tfidf_filter(self, incoming_jobs_dict: Dict[str, dict], + existing_jobs_dict: Dict[str, dict], + ) -> List[DuplicatedJob]: + """Fit a tfidf vectorizer to a corpus of Job.DESCRIPTIONs and identify + duplicate jobs by cosine-similarity. + + FIXME: need to handle existing_jobs_dict = None! + + NOTE/WARNING: if you are running this method, you should have already + removed any duplicates by key_id + NOTE: this only uses job descriptions to do the content matching. + NOTE: it is recommended that you have at least around 25 ish Jobs. + TODO: have this raise an exception if there are too few words. + TODO: we should consider caching the transformed corups. + + Args: + incoming_jobs_dict (Dict[str, dict]): dict of jobs containing + potential duplicates (i.e jobs we just scraped) + existing_jobs_dict (Dict[str, dict]): the existing jobs dict + (i.e. Master CSV) + max_similarity (float, optional): threshold above which desc + similarity is considered a duplicate. Defaults to + DEFAULT_MAX_TFIDF_SIMILARITY. + duplicate_jobs_dict (str, optional): contents of user's duplicate + job detection JSON so we can persist previous detections during + this run + + Raises: + ValueError: incoming_jobs_dict contains no job descriptions + + Returns: + List[DuplicatedJob]: list of new duplicate Jobs and their existing Jobs + found via content matching (for use in JobFunnel). + """ + def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] + ) -> Tuple[List[str], List[str]]: + """Get query words and ids as lists + prefilter + NOTE: this is just a convenience method since we do this 2x + TODO: consider moving this once/if we change iteration + """ + ids = [] # type: List[str] + words = [] # type: List[str] + filt_job_dict = {} # type: Dict[str, Job] + for job in jobs_dict.values(): + if job.key_id in self.duplicate_jobs_dict: + # NOTE: we should never see this. might want to raise Error + raise ValueError( + "Attempting to run TFIDF with existing duplicate " + f"{job.key_id}" + ) + elif not len(job.description): + self.logger.debug( + f"Removing {job.key_id} from scrape result, empty " + "description." + ) + else: + ids.append(job.key_id) + words.append(job.description) + # NOTE: We want to leave changing incoming_jobs_dict in + # place till the end or we will break usage of + # Job.update_if_newer() + filt_job_dict[job.key_id] = job + + # TODO: assert on length of contents of the lists as well + if not words: + raise ValueError( + "No data to fit, are your job descriptions all empty?" + ) + return ids, words, filt_job_dict + + query_ids, query_words, filt_incoming_jobs_dict = \ + __dict_to_ids_and_words(incoming_jobs_dict) + + # Calculate corpus and format query data for TFIDF calculation + corpus = [] # type: List[str] + if existing_jobs_dict: + self.logger.debug("Running TFIDF on incoming vs existing data.") + reference_ids, reference_words, filt_existing_jobs_dict = \ + __dict_to_ids_and_words(existing_jobs_dict) + corpus = query_words + reference_words + else: + self.logger.debug("Running TFIDF on incoming data only.") + reference_ids = query_ids, + reference_words = query_words + filt_existing_jobs_dict = filt_incoming_jobs_dict + corpus = query_words + + # Provide a warning if we have few words. + # FIXME: warning should reflect actual corpus size + if len(corpus) < self.min_tfidf_corpus_size: + self.logger.warning( + "It is not recommended to use this filter with less than " + f"{self.min_tfidf_corpus_size} jobs" + ) + + # Fit vectorizer to entire corpus + self.vectorizer.fit(corpus) + + # Calculate cosine similarity between reference and current blurbs + # This is a list of the similarity between that query job and all the + # TODO: impl. in a more efficient way since fit() does the transform too + similarities_per_query = cosine_similarity( + self.vectorizer.transform(query_words), + self.vectorizer.transform(reference_words) + if existing_jobs_dict else None, ) - # returns a list of newly-detected duplicate Jobs - return new_duplicate_jobs_list + # Find Duplicate jobs by similarity score + # NOTE: multiple jobs can be determined to be a duplicate of same job! + # TODO: traverse this so we look at max similarity for original vs query + # currently it's the other way around so we can look at multi-matching + # original jobs but not multiple matching queries for our original job. + new_duplicate_jobs_list = [] # type: List[DuplicatedJob] + for query_similarities, query_id in zip(similarities_per_query, + query_ids): + + # Identify the jobs in existing_jobs_dict that our query is a + # duplicate of + # FIXME: handle if everything is highly similar! + similar_indeces = np.where( + query_similarities >= self.max_similarity + )[0] + if similar_indeces.size > 0: + # TODO: capture if more jobs are similar by content match + top_similar_job = np.argmax(query_similarities[similar_indeces]) + self.logger.debug( + f"Identified incoming job {query_id} as new duplicate by " + "contents of existing job " + f"{reference_ids[top_similar_job]}" + ) + new_duplicate_jobs_list.append( + DuplicatedJob( + original=filt_existing_jobs_dict[ + reference_ids[top_similar_job]], + duplicate=filt_incoming_jobs_dict[query_id], + type=DuplicateType.NEW_TFIDF, + ) + ) + + if not new_duplicate_jobs_list: + self.logger.debug("Found no duplicates by content-matching.") + + # returns a list of newly-detected duplicate Jobs + return new_duplicate_jobs_list diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 059a7f63..ebb1be83 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -41,18 +41,9 @@ def get_logger(logger_name: str, log_level: int, filename: str, return logger -def update_job_if_newer(existing_job: Job, new_job: Job) -> None: - """Update an existing job with new metadata but keep user's status, - but only if the new_job.post_date > existing_job.post_date! - - Returns: True if existing job was updated - """ - if (new_job.post_date > existing_job.post_date): - new_job.status = existing_job.status - existing_job = new_job - def calc_post_date_from_relative_str(date_str: str) -> date: """Identifies a job's post date via post age, updates in-place + NOTE: we round to nearest day only. """ post_date = datetime.now() # type: date # Supports almost all formats like 7 hours|days and 7 hr|d|+d @@ -88,7 +79,9 @@ def calc_post_date_from_relative_str(date_str: str) -> date: raise ValueError( f"Unable to calculate date from:\n{date_str}" ) - return post_date + + return post_date.replace(hour=0, minute=0, second=0, microsecond=0) + def get_webdriver(): """Get whatever webdriver is availiable in the system. diff --git a/jobfunnel/resources/enums.py b/jobfunnel/resources/enums.py index f047fe07..a0e2f9e5 100644 --- a/jobfunnel/resources/enums.py +++ b/jobfunnel/resources/enums.py @@ -1,5 +1,6 @@ from enum import Enum + class Locale(Enum): """This will allow Scrapers / Filters / Main to identify the support they have for different domains of different websites @@ -15,6 +16,7 @@ class Locale(Enum): CANADA_FRENCH = 2 USA_ENGLISH = 3 + class JobStatus(Enum): """Job statuses that are built-into jobfunnel NOTE: these are the only valid values for entries in 'status' in our CSV @@ -55,6 +57,15 @@ class JobField(Enum): REMOTE = 16 +class DuplicateType(Enum): + """Ways in which a job can be a duplicate + NOTE: we use these to determine what action(s) to take + """ + KEY_ID = 0 + EXISTING_TFIDF = 1 + NEW_TFIDF = 2 + + class Provider(Enum): """Job source providers """ diff --git a/jobfunnel/resources/resources.py b/jobfunnel/resources/resources.py index f97f5c1f..ffddb64a 100644 --- a/jobfunnel/resources/resources.py +++ b/jobfunnel/resources/resources.py @@ -15,13 +15,14 @@ 'CRITICAL', 'FATAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET' ] +MIN_DESCRIPTION_CHARS = 5 # If Job.description is less than this we fail valid. MAX_CPU_WORKERS = 8 # Maximum num threads we use when scraping MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH = 25 # Minimum # of jobs we need to TFIDF MAX_BLOCK_LIST_DESC_CHARS = 150 # Maximum len of description in block_list JSON DEFAULT_MAX_TFIDF_SIMILARITY = 0.75 # Maximum similarity between job text TFIDF BS4_PARSER = 'lxml' -T_NOW = datetime.datetime.now() +T_NOW = datetime.datetime.today() # NOTE: use today so we only compare days PRINTABLE_STRINGS = set(string.printable) From 8030c9e3c7062f92d7c609f95cfbb9295e424ce1 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 24 Aug 2020 09:24:00 -0400 Subject: [PATCH 28/66] Make it so that we always scrape duplicates to ensure that we can update the existing jobs --- jobfunnel/backend/scrapers/base.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 1dd661ab..7e1ba2d6 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -244,17 +244,18 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float # the existing job listing with the new information! # TODO: make this configurable? if job and self.job_filter.filterable(job): - # if self.job_filter.is_duplicate(job): - # self.logger.debug( # FIXME: do we want this message? - # f"Scraped job {job.key_id} has key_id " - # "in known duplicates list. Continuing scrape of job " - # "to update existing job attributes." - # ) - # else: - self.logger.debug( - f"Cancelled scraping of {job.key_id}, failed JobFilter" - ) - break + if self.job_filter.is_duplicate(job): + # FIXME: make this configurable + self.logger.debug( + f"Scraped job {job.key_id} has key_id " + "in known duplicates list. Continuing scrape of job " + "to update existing job attributes." + ) + else: + self.logger.debug( + f"Cancelled scraping of {job.key_id}, failed JobFilter" + ) + break # Respectfully delay if it's configured to do so. if field in self.delayed_get_set_fields: From 5830edbbf31b2a82e66edead1e428ae68a688179 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Tue, 25 Aug 2020 09:49:22 -0400 Subject: [PATCH 29/66] Update readme.md fix installer instruct package name --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 63c7f22f..6ab47867 100644 --- a/readme.md +++ b/readme.md @@ -44,7 +44,7 @@ If you want to develop JobFunnel, you may want to install it in-place: ``` git clone git@github.com:PaulMcInnis/JobFunnel.git jobfunnel -pip install -e ./jobfunnel +pip install -e ./JobFunnel funnel --help ``` From 4734523ad7e5dcafbb2c5144590f0cf6cdbc43a5 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 21:31:35 -0400 Subject: [PATCH 30/66] Formalize minimum required job fields a bit more, improve crash-out behavior for get/set, stop checking against duplicates dict when inspecting existing CSV. --- jobfunnel/backend/jobfunnel.py | 6 +++-- jobfunnel/backend/scrapers/base.py | 34 +++++++++++++++---------- jobfunnel/backend/scrapers/glassdoor.py | 10 -------- jobfunnel/backend/scrapers/indeed.py | 10 -------- jobfunnel/backend/scrapers/monster.py | 12 --------- jobfunnel/backend/tools/filters.py | 17 ++++++++----- 6 files changed, 35 insertions(+), 54 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 5065c0ce..0c53e314 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -328,19 +328,22 @@ def read_master_csv(self) -> Dict[str, Job]: with open(self.config.master_csv_file, 'r', encoding='utf8', errors='ignore') as csvfile: for row in csv.DictReader(csvfile): + # NOTE: we are doing legacy support here with 'blurb' etc. - # In the future we should have an actual condensed descript. + # In the future we should have an actual short description if 'short_description' in row: short_description = row['short_description'] else: short_description = '' post_date = datetime.strptime(row['date'], '%Y-%m-%d') + if 'scrape_date' in row: scrape_date = datetime.strptime( row['scrape_date'], '%Y-%m-%d' ) else: scrape_date = post_date + if 'raw' in row: # NOTE: we should never see this because raw cant be in CSV raw = row['raw'] @@ -348,7 +351,6 @@ def read_master_csv(self) -> Dict[str, Job]: raw = None # We need to convert from user statuses - # TODO: put this in Job? status = None if 'status' in row: status_str = row['status'].strip() diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 7e1ba2d6..90210b16 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -99,14 +99,19 @@ def job_init_kwargs(self) -> Dict[JobField, Any]: } @property - @abstractmethod def min_required_job_fields(self) -> List[JobField]: """If we dont get() or set() any of these fields, we will raise an exception instead of continuing without that information. NOTE: pointless to check for locale / provider / other defaults + + Override if needed, but be aware that key_id should always be populated + along with URL or the user can do nothing with the result. """ - pass + return [ + JobField.TITLE, JobField.COMPANY, JobField.LOCATION, + JobField.KEY_ID, JobField.URL + ] @property @abstractmethod @@ -121,7 +126,8 @@ def job_set_fields(self) -> List[JobField]: """Call self.set(...) for the JobFields in this list when scraping a Job NOTE: Since this passes the Job we are updating, the order of this list - matters if set fields rely on each-other.ed. + matters if set fields rely on each-other. (i.e if desc relies on raw: + then you would want to do [raw, description]) """ pass @@ -130,8 +136,6 @@ def job_set_fields(self) -> List[JobField]: def delayed_get_set_fields(self) -> List[JobField]: """Delay execution when getting /setting any of these attributes of a job. - - Override this as needed. """ pass @@ -254,14 +258,13 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float else: self.logger.debug( f"Cancelled scraping of {job.key_id}, failed JobFilter" - ) + ) # TODO a reason would be nice maybe JobFilterFailure ? break # Respectfully delay if it's configured to do so. if field in self.delayed_get_set_fields: sleep(delay) - kwarg_name = field.name.lower() try: if is_get: job_init_kwargs[field] = self.get(field, job_soup) @@ -276,19 +279,23 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float except Exception as err: - # Crash out gracefully so we can continue scraping. - # (hopefully it's a one-off) if field in self.min_required_job_fields: raise ValueError( "Unable to scrape minimum-required job field: " f"{field.name} Got error:{str(err)}" ) else: + # Crash out gracefully so we can continue scraping. self.logger.warning( - "Unable to scrape {} for job{}:\n\t{}".format( - kwarg_name, ' ' + job.url if job else '', str(err) - ) + f"Unable to scrape {field.name.lower()} for job:" + f"\n\t{str(err)}" ) + # Log the job url if we have it. + # TODO: we should really dump the soup object to an XML file + # so that users encountering bugs can submit it and we can + # quickly fix any failing scraping. + if job.url: + self.logger.debug(f"Job URL was {job.url}") # Validate job fields if we got something if job: @@ -318,7 +325,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: """Get a single job attribute from a soup object by JobField i.e. if param is JobField.COMPANY --> scrape from soup --> return str - TODO: better way to handle ret type than a massive Union? + TODO: better way to handle ret type? """ pass @@ -339,6 +346,7 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: def _validate_get_set(self) -> None: """Ensure the get/set actions cover all need attribs and dont intersect TODO: we should link a helpful article on how to implement get/set mthds + TODO: we should try to identify if any get/set fields have circ. dep. """ set_job_get_fields = set(self.job_get_fields) set_job_set_fields = set(self.job_set_fields) diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index fca08010..0b86ad62 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -57,16 +57,6 @@ def quantize_radius(self, radius: int) -> int: """ pass - @property - def min_required_job_fields(self) -> str: - """If we dont get() or set() any of these fields, we will raise an - exception instead of continuing without that information. - """ - return [ - JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.KEY_ID, JobField.URL - ] - @property def job_get_fields(self) -> str: """Call self.get(...) for the JobFields in this list when scraping a Job diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 0809becd..7028162d 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -38,16 +38,6 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.max_results_per_page = MAX_RESULTS_PER_INDEED_PAGE self.query = '+'.join(self.config.search_config.keywords) - @property - def min_required_job_fields(self) -> str: - """If we dont get() or set() any of these fields, we will raise an - exception instead of continuing without that information. - """ - return [ - JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.KEY_ID, JobField.URL - ] - @property def job_get_fields(self) -> str: """Call self.get(...) for the JobFields in this list when scraping a Job diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 7ace04c2..e850a68a 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -44,18 +44,6 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self.config.search_config.keywords ).replace(' ', '-') - # TODO: implement TAGS - - @property - def min_required_job_fields(self) -> str: - """If we dont get() or set() any of these fields, we will raise an - exception instead of continuing without that information. - """ - return [ - JobField.TITLE, JobField.COMPANY, JobField.LOCATION, - JobField.KEY_ID, JobField.URL - ] - @property def job_get_fields(self) -> str: """Call self.get(...) for the JobFields in this list when scraping a Job diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 9d6dd19e..2c0d9471 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -196,8 +196,8 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], # Run the tfidf vectorizer if we have enough jobs left after removing # key duplicates - if (len(filt_incoming_jobs_dict.keys()) + len(filt_existing_jobs_dict.keys()) - < self.min_tfidf_corpus_size): + if (len(filt_incoming_jobs_dict.keys()) + + len(filt_existing_jobs_dict.keys()) < self.min_tfidf_corpus_size): self.logger.warning( "Skipping similarity filter because there are fewer than " f"{self.min_tfidf_corpus_size} jobs." @@ -257,7 +257,8 @@ def tfidf_filter(self, incoming_jobs_dict: Dict[str, dict], List[DuplicatedJob]: list of new duplicate Jobs and their existing Jobs found via content matching (for use in JobFunnel). """ - def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] + def __dict_to_ids_and_words(jobs_dict: Dict[str, Job], + is_incoming: bool = False, ) -> Tuple[List[str], List[str]]: """Get query words and ids as lists + prefilter NOTE: this is just a convenience method since we do this 2x @@ -267,8 +268,10 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] words = [] # type: List[str] filt_job_dict = {} # type: Dict[str, Job] for job in jobs_dict.values(): - if job.key_id in self.duplicate_jobs_dict: - # NOTE: we should never see this. might want to raise Error + if is_incoming and job.key_id in self.duplicate_jobs_dict: + # NOTE: we should never see this for incoming jobs. + # we will see it for existing jobs since duplicates can + # share a key_id. FIXME: need to look closer into this. raise ValueError( "Attempting to run TFIDF with existing duplicate " f"{job.key_id}" @@ -294,14 +297,14 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job] return ids, words, filt_job_dict query_ids, query_words, filt_incoming_jobs_dict = \ - __dict_to_ids_and_words(incoming_jobs_dict) + __dict_to_ids_and_words(incoming_jobs_dict, is_incoming=True) # Calculate corpus and format query data for TFIDF calculation corpus = [] # type: List[str] if existing_jobs_dict: self.logger.debug("Running TFIDF on incoming vs existing data.") reference_ids, reference_words, filt_existing_jobs_dict = \ - __dict_to_ids_and_words(existing_jobs_dict) + __dict_to_ids_and_words(existing_jobs_dict, is_incoming=False) corpus = query_words + reference_words else: self.logger.debug("Running TFIDF on incoming data only.") From 9dc4c09ad69fa5c37cc08caf50b2f42495b2882f Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 21:33:42 -0400 Subject: [PATCH 31/66] Implement high-priority JobFields so we can stage set() operations + improve default retry behaviour --- jobfunnel/backend/scrapers/base.py | 40 +++++++++++++++++++++++---- jobfunnel/backend/scrapers/indeed.py | 7 +++++ jobfunnel/backend/scrapers/monster.py | 8 ++++++ 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 90210b16..aa96ab92 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -11,6 +11,8 @@ from bs4 import BeautifulSoup from requests import Session +from requests.adapters import HTTPAdapter +from requests.packages.urllib3.util.retry import Retry from tqdm import tqdm from jobfunnel.backend import Job, JobStatus @@ -55,6 +57,12 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', if self.headers: self.session.headers.update(self.headers) + # Elongate the retries TODO: make configurable + retry = Retry(connect=3, backoff_factor=0.5) + adapter = HTTPAdapter(max_retries=retry) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + # Ensure that the locale we want to use matches the locale that the # scraper was written to scrape in: if self.config.search_config.locale != self.locale: @@ -117,6 +125,10 @@ def min_required_job_fields(self) -> List[JobField]: @abstractmethod def job_get_fields(self) -> List[JobField]: """Call self.get(...) for the JobFields in this list when scraping a Job. + + NOTE: these will be passed job listing soups, if you have data you need + to populate that exists in the Job.RAW (the soup from the listing's own + page), you should use job_set_fields. """ pass @@ -125,9 +137,8 @@ def job_get_fields(self) -> List[JobField]: def job_set_fields(self) -> List[JobField]: """Call self.set(...) for the JobFields in this list when scraping a Job - NOTE: Since this passes the Job we are updating, the order of this list - matters if set fields rely on each-other. (i.e if desc relies on raw: - then you would want to do [raw, description]) + NOTE: You should generally set the job's own page as soup to RAW first + and then populate other fields from this soup, or from each-other here. """ pass @@ -139,6 +150,17 @@ def delayed_get_set_fields(self) -> List[JobField]: """ pass + @property + def high_priority_get_set_fields(self) -> List[JobField]: + """These get() and/or set() fields will be populated first. + + i.e we need the RAW populated before DESCRIPTION, so RAW should be high. + i.e. we need to get key_id before we set job.url, so key_id is high. + + NOTE: override as needed. + """ + return [] + @property @abstractmethod def locale(self) -> Locale: @@ -227,15 +249,21 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float delay [float]: how long to delay getting/setting for certain get/set calls while scraping data for this job. - NOTE: this will never raise an exception to prevent killing workers. + NOTE: we should scrape all-priority get fields first, then do high + set priorities, and finally low priority set fields. + NOTE: this will never raise an exception to prevent killing workers, + who are building jobs sequentially. Returns: Optional[Job]: job object constructed from the soup and localization of class, returns None if scrape failed. """ - # Formulate the get/set actions + # Formulate the get/set actions, we will do these in-sequence actions_list = [(True, f) for f in self.job_get_fields] - actions_list += [(False, f) for f in self.job_set_fields] + actions_list += [(False, f) for f in self.job_set_fields if f in + self.high_priority_get_set_fields] + actions_list += [(False, f) for f in self.job_set_fields if f not in + self.high_priority_get_set_fields] # Scrape the data for the post, requiring a minimum of info... job = None # type: Union[None, Job] diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 7028162d..d38bac5a 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -71,6 +71,12 @@ def delayed_get_set_fields(self) -> str: """ return [JobField.RAW] + @property + def high_priority_get_set_fields(self) -> List[JobField]: + """These get() and/or set() fields will be populated first. + """ + return [JobField.URL] + @property def headers(self) -> Dict[str, str]: """Session header for indeed.X @@ -167,6 +173,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField + NOTE: URL is high-priority, since we need it to get RAW. """ if parameter == JobField.RAW: job._raw_scrape_data = BeautifulSoup( diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index e850a68a..3ac193b3 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -59,6 +59,12 @@ def job_set_fields(self) -> str: """ return [JobField.RAW, JobField.DESCRIPTION, JobField.TAGS] + @property + def high_priority_get_set_fields(self) -> List[JobField]: + """We need to populate these fields first + """ + return [JobField.RAW, JobField.KEY_ID] + @property def delayed_get_set_fields(self) -> str: """Delay execution when getting /setting any of these attributes of a @@ -87,6 +93,7 @@ def headers(self) -> Dict[str, str]: def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: """Get a single job attribute from a soup object by JobField + NOTE: priority is all the same. """ if parameter == JobField.KEY_ID: # TODO: is there a way to combine these calls? @@ -114,6 +121,7 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField + NOTE: priority is: HIGH: RAW, LOW: DESCRIPTION / TAGS """ if parameter == JobField.RAW: job._raw_scrape_data = BeautifulSoup( From caab319ab783f4b7326212c9bd60bbaaabeda106 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 21:33:50 -0400 Subject: [PATCH 32/66] undo delay bypass --- jobfunnel/backend/scrapers/base.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index aa96ab92..2d8134f5 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -76,7 +76,7 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', self._validate_get_set() # Init a thread executor (multi-worker) TODO: can't reuse after shutdown - self.executor = ThreadPoolExecutor(max_workers=1) #MAX_CPU_WORKERS) FIXME + self.executor = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) @property def user_agent(self) -> str: @@ -208,9 +208,8 @@ def scrape(self) -> Dict[str, Job]: ) # Calculate delays for get/set calls per-job NOTE: only get/set - # calls in self.delayed_get_set_fields will be delayed. FIXME: remove bypass! - import numpy as np - delays = np.ones(n_soups) * 0.1 #calculate_delays(n_soups, self.config.delay_config) + # calls in self.delayed_get_set_fields will be delayed. + delays = calculate_delays(n_soups, self.config.delay_config) results = [] for job_soup, delay in zip(job_soups, delays): results.append( From f74d5005ce9533d418099c561dbca2c2e822eb17 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 21:34:25 -0400 Subject: [PATCH 33/66] fix CLI defaults when mixing YAML and arguments and possibly defaults. --- jobfunnel/config/cli.py | 50 +++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 731eb89e..940ae511 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -274,40 +274,52 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: config.update( yaml.load(open(settings_yaml_file, 'r'), Loader=yaml.FullLoader) ) + # TODO: need a way to handle injecting defaults with YAML but also + # without an incomplete set of arguments here. Cerberus should do this + # if we further break down config into sub-configs. + missing_or_invalid_attrs = [] # type: List[str] + for attr in ['master_csv_file', 'block_list_file', 'cache_folder', + 'duplicates_list_file']: + if attr in config: + if os.path.exists(config[attr]): + missing_or_invalid_attrs.append(attr) + else: + missing_or_invalid_attrs.append(attr) + if missing_or_invalid_attrs: + raise ValueError( + f"Passed YAML {settings_yaml_file} fields are missing or " + f"invalid: {missing_or_invalid_attrs}" + ) # Handle output_folder argument which is a shortcut to specifying all paths - if (output_folder == DEFAULT_OUTPUT_DIRECTORY and not( + user_passed_paths = bool( + ( args_dict['master_csv_file'] and args_dict['block_list_file'] and - args_dict['duplicates_list_file'] and args_dict['cache_folder'])): + args_dict['duplicates_list_file'] and args_dict['cache_folder'] + ) or ( + config['master_csv_file'] and config['block_list_file'] and + config['duplicates_list_file'] and config['cache_folder'] + ) + ) + if output_folder == DEFAULT_OUTPUT_DIRECTORY and not user_passed_paths: - # We have been given an output folder, so we will use default paths + # We are using all defaults only (no -s or paths passed) config['master_csv_file'] = DEFAULT_MASTER_CSV_FILE config['block_list_file'] = DEFAULT_BLOCK_LIST_FILE config['duplicates_list_file'] = DEFAULT_DUPLICATES_FILE config['cache_folder'] = DEFAULT_CACHE_DIRECTORY - elif not output_folder: + elif output_folder != DEFAULT_OUTPUT_DIRECTORY and user_passed_paths: - # We have a combination - if not config['master_csv_file']: - config['master_csv_file'] = DEFAULT_MASTER_CSV_FILE - if not config['block_list_file']: - config['block_list_file'] = DEFAULT_BLOCK_LIST_FILE - if not config['duplicates_list_file']: - config['duplicates_list_file'] = DEFAULT_DUPLICATES_FILE - if not config['cache_folder']: - config['cache_folder'] = DEFAULT_CACHE_DIRECTORY - - else: # User cannot specify both output folder and other paths raise ValueError( "When providing output_folder, do not also provide -csv, -blf" - ", -dlf, or -cache, as these are defined by the output folder." - " If specifying file paths you must pass all the arguments and" - " not pass -o." + ", -dlf, -cache or -s, as paths are defined by the output folder." + " If specifying file paths you must pass all the arguments and " + "not pass -o." ) - # Inject any cli, non-default attributes + # Inject any cli, non-default attributes (excluding paths) for key, value in args_dict.items(): if value is not None: if key in SETTINGS_YAML_SCHEMA: From 8dccf910bf8cdf7620546d843df33c9d0e92bd51 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 21:34:52 -0400 Subject: [PATCH 34/66] make demo not create hidden cache folder --- .gitignore | 3 ++- demo/settings.yaml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 4fc43cd6..da2544fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ data/ *.csv demo/data/ +demo_cache # Byte-compiled / optimized / DLL files __pycache__/ @@ -177,4 +178,4 @@ $RECYCLE.BIN/ .com.apple.timemachine.donotpresent # VScode trash -.vscode/ \ No newline at end of file +.vscode/ diff --git a/demo/settings.yaml b/demo/settings.yaml index 9e1059f6..7598fb7a 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -4,9 +4,9 @@ # NOTE: when you are using CLI, you can just specify output_folder and we # will calculate these paths for you. master_csv_file: demo_search.csv -cache_folder: .demo_cache # NOTE: this will be created if it doesn't exist -block_list_file: .demo_cache/demo_block_list.json -duplicates_list_file: .demo_cache/demo_bduplicates_list.json +cache_folder: demo_cache # NOTE: this will be created if it doesn't exist +block_list_file: demo_cache/demo_block_list.json +duplicates_list_file: demo_cache/demo_duplicates_list.json # Job search configuration search: From 224547ea2bba3db71fab287e2b0333dff203dd85 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 22:41:59 -0400 Subject: [PATCH 35/66] Update readme.md --- jobfunnel/config/cli.py | 4 +- readme.md | 142 ++++++++++++++++++++++++---------------- 2 files changed, 88 insertions(+), 58 deletions(-) diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 940ae511..e085ccab 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -119,7 +119,7 @@ def parse_cli(): ) parser.add_argument( - '-k', + '-kw', dest='search_keywords', nargs='+', default=DEFAULT_SEARCH_KEYWORDS, @@ -160,7 +160,7 @@ def parse_cli(): ) parser.add_argument( - '-max-listing-age', + '-max-listing-days', dest='search_max_listing_days', type=int, help='The maximum number of days-old a job can be. (i.e pass 30 to ' diff --git a/readme.md b/readme.md index 6ab47867..ccc5054d 100644 --- a/readme.md +++ b/readme.md @@ -22,111 +22,141 @@ pip install -e jobfunnelabc * Never see the same job twice! * Browse all search results at once, in an easy to read/sort spreadsheet. * Keep track of all explicitly new job postings in your area. -* See jobs from multiple job search sites all in one place. +* See jobs from multiple job search websites all in one place. +* Compare job search results across locations The spreadsheet for managing your job search: ![masterlist.csv][masterlist] -### Dependencies +---- + +### Installation -JobFunnel requires [Python][python] 3.6 or later.
-All dependencies are listed in `setup.py`, and can be installed automatically with `pip` when installing JobFunnel. +_JobFunnel requires [Python][python] 3.6 or later._ -### Installing JobFunnel +All dependencies are listed in `setup.py`, and can be installed automatically with `pip`. ``` pip install git+https://github.com/PaulMcInnis/JobFunnel.git -funnel --help ``` -If you want to develop JobFunnel, you may want to install it in-place: +If you want to develop JobFunnel, you can install it in-place: ``` -git clone git@github.com:PaulMcInnis/JobFunnel.git jobfunnel +git clone git@github.com:PaulMcInnis/JobFunnel.git pip install -e ./JobFunnel -funnel --help ``` +---- + ### Using JobFunnel +After installation you can run with default settings and locale just by +running: + +``` +funnel +``` + +or you can review the extensive options available via CLI by running: + +``` +funnel -h +``` + +or you can build your own `settings.yaml` file from the example provided in [demo/readme.md][demo] and run: + +``` +funnel -s my_own_job_search_settings.yaml +``` + +---- + +### Reviewing Results + +Follow these steps to continuously-improve your job search results CSV: + 1. Set your job search preferences in the `yaml` configuration file (or use `-kw`). -1. Run `funnel` to scrape all-available job listings. -1. Review jobs in the master-list, update the job `status` to other values such as `interview` or `offer`. -1. Set any undesired job `status` to `archive`, these jobs will be removed from the `.csv` next time you run `funnel`. -1. Check out [demo/readme.md][demo] if you want to try the demo. +2. Run `funnel` to scrape all-available job listings. +3. Review jobs in the master-list, update the job `status` to reflect your interest or progression: `interested`, `applied`, `interview` or `offer`. +4. Set any a job `status` to `archive`, `rejected` or `delete` to remove them from the `.csv`. ___Note: listings you filter away by `status` are persistant___ -__*Note*__: `rejected` jobs will be filtered out and will disappear from the output `.csv`. +---- -### Usage Notes +### Job Statuses -* **Custom Status**
- Note that any custom states (i.e `applied`) are preserved in the spreadsheet. +_NOTE: `status` values are not case-sensitive_ -* **Running Filters**
- To update active filters and to see any `new` jobs going forwards, just run `funnel` again, and review the `.csv` file. +| Status | Purpose | +| ------ | ------------- | +| `NEW` | The job has been freshly scraped, likely un-reviewed. | +| `ARCHIVE`, `REJECTED`, `DELETE`, `OLD` | The job will be added to filter lists and will not appear in CSV again. You can see any jobs which have been added to your filter lists by reviewing your `block_list_file` JSON. | +| `INTERESTED`, `APPLY`, `APPLIED`, `ACCEPTED`, `INTERVIEWED`, `INTERVIEWING` | Use these to boost visibility of desirable jobs or to track progress. | -* **Recovering Lost Master-list**
- If ever your master-list gets deleted you still have the historic pickle files.
- Simply run `funnel --recover` to generate a new master-list. +---- + +### Advanced Usage * **Managing Multiple Searches**
- You can keep multiple search results across multiple `.csv` files: + JobFunnel works best if you keep distinct searches in their own `.csv` files: ``` - funnel -kw Python -o python_search - funnel -kw AI Machine Learning -o ML_search + funnel -kw Python -c Waterloo -ps ON -l CANADA_ENGLISH -o canada_python + funnel -kw AI Machine Learning -c Seattle -ps WA -l USA_ENGLISH -o USA_ML ``` -* **Filtering Undesired Companies**
-Filter undesired companies by providing your own `yaml` configuration and adding them to the black list(see `JobFunnel/jobfunnel/config/settings.yaml`). - -* **Filtering Old Jobs**
- Filter jobs that you think are too old: - `funnel -s JobFunnel/demo/settings.yaml --max_listing_days 30` will filter out job listings that are older than 30 days. - - * **Automating Searches**
JobFunnel can be easily automated to run nightly with [crontab][cron]
For more information see the [crontab document][cron_doc]. - - * **Glassdoor Notes**
- The `GlassDoor` scraper has two versions: `GlassDoorStatic` and `GlassDoorDynamic`. Both of these give you the same end result: they scrape GlassDoor and dump your job listings onto your `master_list.csv`. We recommend to *always* run `GlassDoorStatic` (this is the default preset we have on our demo `settings.yaml` file) because it is *a lot* faster than `GlassDoorDynamic`. However, given the event that `GlassDoorStatic` fails, you may use `GlassDoorDynamic`. It is very slow, but you'll still be able to scrape GlassDoor. - - When using `GlassDoorDynamic` Glassdoor **might** require a human to complete a CAPTCHA. Therefore, in the case of automating with something like cron, you **might** need to be **physically present** to complete the Glassdoor CAPTCHA. - - You may also of course disable the Glassdoor scraper when using `GlassDoorDynamic` in your `settings.yaml` to not have to complete any CAPTCHA at all: -``` - - 'Indeed' - - 'Monster' - # - 'GlassDoorStatic' - # - 'GlassDoorDynamic' -``` + +* **Writing your own Scrapers**
+ If you have a job website you'd like to write a scraper for, you are welcome to implement it, Review the [BaseScraper][BaseScraper] for implementation details. + +* **Adding Support for X Language Job Website**
+ JobFunnel supports scraping jobs from the same job website across differnt locales. If you are interested in adding support, you may only need to define session headers and domain strings, Review the [BaseScraper][BaseScraper] for further implementation details. + +* **Recovering Lost Master-list**
+ JobFunnel can re-build your master CSV from the scrape cache, where all the historic scrape data is located: + ``` + funnel --recover + ``` + +* **Filtering Undesired Companies**
+ Filter undesired companies by adding them to your `company_block_list` in your YAML or pass them by command line as `-cbl`. + +* **Filtering Old Jobs**
+ You can configure the maximum age of scraped listings (in days) by setting `max_listing_days` in your YAML, or by passing: + ``` + funnel -max-listing-days 30 + ``` * **Reviewing Jobs in Terminal**
You can review the job list in the command line: ``` column -s, -t < master_list.csv | less -#2 -N -S ``` -* **Saving Duplicates**
- You can save removed duplicates in a separate file, which is stored in the same place as your master list:
+ +* **Saving Duplicates**
+ It is recommended that you save duplicate jobs detected via content match to ensure detections persist. You can configure this path via `duplicates_list_file` in YAML or by passing command line: ``` - funnel --save_dup + funnel -dl my_duplicates_list.json ``` + * **Respectful Delaying**
Respectfully scrape your job posts with our built-in delaying algorithm, which can be configured using a config file (see `JobFunnel/jobfunnel/config/settings.yaml`) or with command line arguments: - - `-d` lets you set your max delay value: `funnel -s demo/settings.yaml -kw AI -d 15` - - `-r` lets you specify if you want to use random delaying, and uses `-d` to control the range of randoms we pull from:
`funnel -s demo/settings.yaml -kw AI -r` - - `-c` specifies converging random delay, which is an alternative mode of random delay. Random delay needed to be turned on as well for it to work. Proper usage would look something like this:
`funnel -s demo/settings.yaml -kw AI -r -c` - - `-md` lets you set a minimum delay value:
`funnel -s demo/settings.yaml -d 15 -md 5` - - `--fun` can be used to set which mathematical function (`constant`, `linear`, or `sigmoid`) is used to calculate delay:
`funnel -s demo/settings.yaml --fun sigmoid` - - `--no_delay` Turns off delaying, but it's usage is not recommended. + - `-delay-max` lets you set your max delay value in seconds. + - `-delay-min` lets you set a minimum delay value in seconds.
_NOTE: must be smaller than maximum_ + - `--delay-random` lets you specify if you want to use random delaying, and uses `-delay-max` to control the range of randoms we pull from. + - `--delay-converging` specifies converging random delay, which is an alternative mode of random delay.
_NOTE: this is intended to be used in combination with `--delay-random`_ + - `-delay-algorithm` can be used to set which mathematical function (`constant`, `linear`, or `sigmoid`) is used to calculate delay. - To better understand how to configure delaying, check out [this Jupyter Notebook][delay_jp] breaking down the algorithm step by step with code and visualizations. + To better understand how to configure delaying, check out [this Jupyter Notebook][delay_jp] which breaks down the algorithm step by step with code and visualizations. [masterlist]:demo/assests/demo.png "masterlist.csv" [python]:https://www.python.org/ [demo]:demo/readme.md +[basescraper]:jobfunnel/backend/scraper/base.py [cron]:https://en.wikipedia.org/wiki/Cron [cron_doc]:docs/crontab/readme.md [conc_fut]:https://docs.python.org/dev/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor From 61fed010e71b0b747b998f045154d1e6b0eabe3d Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 28 Aug 2020 23:10:36 -0400 Subject: [PATCH 36/66] Fix flake8 issues without introducing circular imports + fix remaining issue with CLI + fix build test cli. --- .travis.yml | 2 +- jobfunnel/backend/jobfunnel.py | 4 ++-- jobfunnel/backend/scrapers/base.py | 6 +++++- jobfunnel/backend/scrapers/glassdoor.py | 5 +++++ jobfunnel/backend/scrapers/indeed.py | 5 ++++- jobfunnel/backend/scrapers/monster.py | 5 +++++ jobfunnel/backend/tools/filters.py | 2 +- jobfunnel/config/cli.py | 14 ++++++-------- jobfunnel/config/funnel.py | 5 ++++- 9 files changed, 33 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8af05521..fa2d67cd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ install: - 'python -m nltk.downloader stopwords' before_script: 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' script: - - 'python -m jobfunnel -s demo/settings.yaml -o demo/' + - 'python -m jobfunnel -s demo/settings.yaml' - 'pytest --cov=jobfunnel --cov-report=xml' after_success: - 'bash <(curl -s https://codecov.io/bash)' diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 0c53e314..99c6d9d1 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -141,7 +141,7 @@ def run(self) -> None: # Parse duplicate jobs into updates for master jobs dict # FIXME: we need to search for duplicates without master jobs too! - duplicate_jobs = [] # type: List[DuplicateJob] + duplicate_jobs = [] # type: List[DuplicatedJob] if self.master_jobs_dict and scraped_jobs_dict: # Remove jobs with duplicated key_ids from scrape + update master @@ -482,7 +482,7 @@ def update_user_block_list(self) -> None: def update_duplicates_file(self) -> None: """Update duplicates filter file if we have a path and contents - FIXME: this should be writing out DuplicateJob objects and a version + FIXME: this should be writing out DuplicatedJob objects and a version """ if self.config.duplicates_list_file: if self.job_filter.duplicate_jobs_dict: diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 2d8134f5..4cbe80e8 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -21,7 +21,11 @@ from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.resources import (MAX_CPU_WORKERS, USER_AGENT_LIST, JobField, Locale) -# from jobfunnel.config import JobFunnelConfig FIXME: circular imports issue + + +if False: # or typing.TYPE_CHECKING if python3.5.3+ + from jobfunnel.config import JobFunnelConfig + class BaseScraper(ABC): diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index 0b86ad62..56269041 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -27,6 +27,10 @@ from requests import Session +if False: # or typing.TYPE_CHECKING if python3.5.3+ + from jobfunnel.config import JobFunnelConfig + + MAX_GLASSDOOR_LOCATIONS_TO_RETURN = 10 LOCATION_BASE_URL = 'https://www.glassdoor.co.in/findPopularLocationAjax.htm?' MAX_RESULTS_PER_GLASSDOOR_PAGE = 30 @@ -40,6 +44,7 @@ 200: 124, } + class BaseGlassDoorScraper(BaseScraper): def __init__(self, session: Session, config: 'JobFunnelConfig', diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index d38bac5a..179af426 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -19,7 +19,10 @@ from jobfunnel.backend.scrapers.base import ( BaseScraper, BaseCANEngScraper, BaseUSAEngScraper ) -#from jobfunnel.config import JobFunnelConfig # causes a circular import + + +if False: # or typing.TYPE_CHECKING if python3.5.3+ + from jobfunnel.config import JobFunnelConfig ID_REGEX = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 3ac193b3..f8d18786 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -20,6 +20,11 @@ BaseScraper, BaseCANEngScraper, BaseUSAEngScraper ) + +if False: # or typing.TYPE_CHECKING if python3.5.3+ + from jobfunnel.config import JobFunnelConfig + + MAX_RESULTS_PER_MONSTER_PAGE = 25 MONSTER_SIDEPANEL_TAG_ENTRIES = ['industries', 'job type'] # these --> Job.tags ID_REGEX = re.compile( diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 2c0d9471..e0d91bb7 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -153,7 +153,7 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], Dict[str, Job]: jobs dict with all jobs keyed by known-duplicate key_ids removed, and their originals updated. """ - duplicate_jobs_list = [] # type: List[DuplicateJob] + duplicate_jobs_list = [] # type: List[DuplicatedJob] filt_existing_jobs_dict = deepcopy(existing_jobs_dict) # FIXME: we assume there are no duplicates by content in existing jobs # And this is a bad assumption... need to fix this. diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index e085ccab..91d444db 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -274,21 +274,19 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: config.update( yaml.load(open(settings_yaml_file, 'r'), Loader=yaml.FullLoader) ) + # TODO: need a way to handle injecting defaults with YAML but also # without an incomplete set of arguments here. Cerberus should do this # if we further break down config into sub-configs. - missing_or_invalid_attrs = [] # type: List[str] + missing_attrs = [] # type: List[str] for attr in ['master_csv_file', 'block_list_file', 'cache_folder', 'duplicates_list_file']: - if attr in config: - if os.path.exists(config[attr]): - missing_or_invalid_attrs.append(attr) - else: - missing_or_invalid_attrs.append(attr) - if missing_or_invalid_attrs: + if attr not in config: + missing_attrs.append(attr) + if missing_attrs: raise ValueError( f"Passed YAML {settings_yaml_file} fields are missing or " - f"invalid: {missing_or_invalid_attrs}" + f"invalid: {missing_attrs}" ) # Handle output_folder argument which is a shortcut to specifying all paths diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/funnel.py index 871b801d..1c427d2c 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/funnel.py @@ -4,12 +4,15 @@ from typing import Optional, List, Dict, Any import os -# from jobfunnel.backend.scrapers.base import BaseScraper CYCLICAL! from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig from jobfunnel.resources import Locale, Provider, BS4_PARSER from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE +if False: # or typing.TYPE_CHECKING if python3.5.3+ + from jobfunnel.backend.scrapers.base import BaseScraper + + class JobFunnelConfig(BaseConfig): """Master config containing all the information we need to run jobfunnel """ From af93ccdf718fdc060189c8765f998229a65a2f39 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 11:23:55 -0400 Subject: [PATCH 37/66] JobFunnelConfig -> JobFunnelConfigManager --- jobfunnel/backend/jobfunnel.py | 6 +++--- jobfunnel/backend/scrapers/base.py | 10 +++++----- jobfunnel/backend/scrapers/glassdoor.py | 4 ++-- jobfunnel/backend/scrapers/indeed.py | 4 ++-- jobfunnel/backend/scrapers/monster.py | 4 ++-- jobfunnel/config/__init__.py | 2 +- jobfunnel/config/cli.py | 16 ++++++++-------- jobfunnel/config/{funnel.py => manager.py} | 2 +- jobfunnel/resources/defaults.py | 4 +--- 9 files changed, 25 insertions(+), 27 deletions(-) rename jobfunnel/config/{funnel.py => manager.py} (99%) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 99c6d9d1..002c88f4 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -17,7 +17,7 @@ from jobfunnel.backend import Job from jobfunnel.backend.tools.filters import JobFilter, DuplicatedJob from jobfunnel.backend.tools import get_logger -from jobfunnel.config import JobFunnelConfig +from jobfunnel.config import JobFunnelConfigManager from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, MAX_CPU_WORKERS, JobStatus, Locale, T_NOW, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, @@ -32,11 +32,11 @@ class JobFunnel: FIXME: instead of Dic[str, Job] we should be using JobsDict """ - def __init__(self, config: JobFunnelConfig) -> None: + def __init__(self, config: JobFunnelConfigManager) -> None: """Initialize a JobFunnel object, with a JobFunnel Config Args: - config (JobFunnelConfig): config object containing paths etc. + config (JobFunnelConfigManager): config object containing paths etc. """ self.config = config self.config.create_dirs() diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 4cbe80e8..4a9010f8 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -24,14 +24,14 @@ if False: # or typing.TYPE_CHECKING if python3.5.3+ - from jobfunnel.config import JobFunnelConfig + from jobfunnel.config import JobFunnelConfigManager class BaseScraper(ABC): """Base scraper object, for scraping and filtering Jobs from a provider """ - def __init__(self, session: Session, config: 'JobFunnelConfig', + def __init__(self, session: Session, config: 'JobFunnelConfigManager', job_filter: JobFilter) -> None: """Init @@ -39,14 +39,14 @@ def __init__(self, session: Session, config: 'JobFunnelConfig', Args: session (Session): session object used to make post and get requests - config (JobFunnelConfig): config containing all needed paths, search - proxy, delaying and other metadata. + config (JobFunnelConfigManager): config containing all needed paths, + search proxy, delaying and other metadata. job_filter (JobFilter): filtering class used to perform on-the-fly filtering of jobs to reduce the number of delayed get or set (i.e. operations that make requests). Raises: - ValueError: if no Locale is configured in the JobFunnelConfig + ValueError: if no Locale is configured in the JobFunnelConfigManager """ self.job_filter = job_filter # We will use this for live-filtering self.session = session diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index 56269041..79ecbf7a 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -28,7 +28,7 @@ if False: # or typing.TYPE_CHECKING if python3.5.3+ - from jobfunnel.config import JobFunnelConfig + from jobfunnel.config import JobFunnelConfigManager MAX_GLASSDOOR_LOCATIONS_TO_RETURN = 10 @@ -47,7 +47,7 @@ class BaseGlassDoorScraper(BaseScraper): - def __init__(self, session: Session, config: 'JobFunnelConfig', + def __init__(self, session: Session, config: 'JobFunnelConfigManager', job_filter: JobFilter) -> None: """Init that contains glassdoor specific stuff """ diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 179af426..7365b475 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -22,7 +22,7 @@ if False: # or typing.TYPE_CHECKING if python3.5.3+ - from jobfunnel.config import JobFunnelConfig + from jobfunnel.config import JobFunnelConfigManager ID_REGEX = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') @@ -33,7 +33,7 @@ class BaseIndeedScraper(BaseScraper): """Scrapes jobs from www.indeed.X """ - def __init__(self, session: Session, config: 'JobFunnelConfig', + def __init__(self, session: Session, config: 'JobFunnelConfigManager', job_filter: JobFilter) -> None: """Init that contains indeed specific stuff """ diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index f8d18786..847440be 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -22,7 +22,7 @@ if False: # or typing.TYPE_CHECKING if python3.5.3+ - from jobfunnel.config import JobFunnelConfig + from jobfunnel.config import JobFunnelConfigManager MAX_RESULTS_PER_MONSTER_PAGE = 25 @@ -40,7 +40,7 @@ class BaseMonsterScraper(BaseScraper): as of aug 2020. -pm """ - def __init__(self, session: Session, config: 'JobFunnelConfig', + def __init__(self, session: Session, config: 'JobFunnelConfigManager', job_filter: JobFilter) -> None: """Init that contains monster specific stuff """ diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index 487bff99..271651db 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -3,5 +3,5 @@ from jobfunnel.config.delay import DelayConfig from jobfunnel.config.proxy import ProxyConfig from jobfunnel.config.search import SearchConfig -from jobfunnel.config.funnel import JobFunnelConfig +from jobfunnel.config.manager import JobFunnelConfigManager from jobfunnel.config.cli import parse_cli, config_builder diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 91d444db..f4149be8 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -1,4 +1,4 @@ -"""Configuration parsing module for CLI --> JobFunnelConfig +"""Configuration parsing module for CLI --> JobFunnelConfigManager """ import argparse import logging @@ -7,7 +7,7 @@ import yaml from jobfunnel.config import ( - JobFunnelConfig, DelayConfig, SearchConfig, ProxyConfig, + JobFunnelConfigManager, DelayConfig, SearchConfig, ProxyConfig, SettingsValidator, SETTINGS_YAML_SCHEMA ) from jobfunnel.backend.tools.tools import split_url @@ -257,8 +257,8 @@ def parse_cli(): return parser.parse_args() -def config_builder(args: argparse.Namespace) -> JobFunnelConfig: - """Parse the JobFunnel configuration settings into a JobFunnelConfig. +def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: + """Parse the JobFunnel configuration settings into a JobFunnelConfigManager. args [argparse.Namespace]: cli arguments from argparser """ @@ -345,7 +345,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: if not os.path.exists(config['cache_folder']): os.makedirs(config['cache_folder']) - # Build JobFunnelConfig + # Build JobFunnelConfigManager search_cfg = SearchConfig( keywords=config['search']['keywords'], province_or_state=config['search']['province_or_state'], @@ -375,7 +375,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: else: proxy_cfg = None - funnel_cfg = JobFunnelConfig( + funnel_cfg_mgr = JobFunnelConfigManager( master_csv_file=config['master_csv_file'], user_block_list_file=config['block_list_file'], duplicates_list_file=config['duplicates_list_file'], @@ -389,6 +389,6 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfig: ) # Validate funnel config as well (checks some stuff Cerberus doesn't rn) - funnel_cfg.validate() + funnel_cfg_mgr.validate() - return funnel_cfg + return funnel_cfg_mgr diff --git a/jobfunnel/config/funnel.py b/jobfunnel/config/manager.py similarity index 99% rename from jobfunnel/config/funnel.py rename to jobfunnel/config/manager.py index 1c427d2c..c584ace1 100644 --- a/jobfunnel/config/funnel.py +++ b/jobfunnel/config/manager.py @@ -13,7 +13,7 @@ from jobfunnel.backend.scrapers.base import BaseScraper -class JobFunnelConfig(BaseConfig): +class JobFunnelConfigManager(BaseConfig): """Master config containing all the information we need to run jobfunnel """ diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 23d02623..d99daaf4 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -1,4 +1,4 @@ -"""Default arguments for both JobFunnelConfig and CLI arguments. +"""Default arguments for both JobFunnelConfigManager and CLI arguments. NOTE: we include defaults for all arguments so that JobFunnel is plug-n-play """ import os @@ -17,7 +17,6 @@ DEFAULT_OUTPUT_DIRECTORY = os.path.join( USER_HOME_DIRECTORY, 'job_search_results' ) -# FIXME: move to home when we have per-search caching DEFAULT_CACHE_DIRECTORY = os.path.join(DEFAULT_OUTPUT_DIRECTORY, '.cache') DEFAULT_BLOCK_LIST_FILE = os.path.join(DEFAULT_CACHE_DIRECTORY, 'block.json') DEFAULT_DUPLICATES_FILE = os.path.join( @@ -30,7 +29,6 @@ DEFAULT_DELAY_MAX_DURATION = 5.0 DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR -# NOTE: we do indeed first b/c it has most information, monster is missing keys # FIXME: re-enable glassdoor once we fix issue with it. DEFAULT_PROVIDERS = [Provider.MONSTER, Provider.INDEED] #, Provider.GLASSDOOR] DEFAULT_NO_SCRAPE = False From a3b17a63d12527ad0af0f5e76fe62b665c7d3a44 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 11:40:30 -0400 Subject: [PATCH 38/66] Use get since config sub-keys can be missing. --- jobfunnel/config/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index f4149be8..268a8fd3 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -295,8 +295,8 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: args_dict['master_csv_file'] and args_dict['block_list_file'] and args_dict['duplicates_list_file'] and args_dict['cache_folder'] ) or ( - config['master_csv_file'] and config['block_list_file'] and - config['duplicates_list_file'] and config['cache_folder'] + config.get('master_csv_file') and config.get('block_list_file') and + config.get('duplicates_list_file') and config.get('cache_folder') ) ) if output_folder == DEFAULT_OUTPUT_DIRECTORY and not user_passed_paths: From fc65c188e8232a20a94a069c59817447a8bed44d Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 11:53:43 -0400 Subject: [PATCH 39/66] Make logging handled through a Logger class which is inheritable --- jobfunnel/backend/jobfunnel.py | 17 ++++++------- jobfunnel/backend/scrapers/base.py | 34 +++++++++++-------------- jobfunnel/backend/tools/__init__.py | 2 +- jobfunnel/backend/tools/filters.py | 17 ++++++------- jobfunnel/backend/tools/tools.py | 39 ++++++++++++++++++++++++++--- 5 files changed, 66 insertions(+), 43 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 002c88f4..3d359707 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -16,7 +16,7 @@ from jobfunnel.backend import Job from jobfunnel.backend.tools.filters import JobFilter, DuplicatedJob -from jobfunnel.backend.tools import get_logger +from jobfunnel.backend.tools import Logger from jobfunnel.config import JobFunnelConfigManager from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, MAX_CPU_WORKERS, JobStatus, Locale, T_NOW, @@ -24,12 +24,12 @@ DuplicateType) -class JobFunnel: +class JobFunnel(Logger): """Class that initializes a Scraper and scrapes a website to get jobs NOTE: This is intended to be used with persistant cache and CSV files dedicated to a single, consistant job search. - FIXME: instead of Dic[str, Job] we should be using JobsDict + TODO: instead of Dic[str, Job] we should be using JobsDict """ def __init__(self, config: JobFunnelConfigManager) -> None: @@ -38,16 +38,13 @@ def __init__(self, config: JobFunnelConfigManager) -> None: Args: config (JobFunnelConfigManager): config object containing paths etc. """ + super().__init__( + level=config.log_level, + file_path=config.log_file, + ) self.config = config self.config.create_dirs() self.config.validate() - self.logger = get_logger( - self.__class__.__name__, - self.config.log_level, - self.config.log_file, - f"[%(asctime)s] [%(levelname)s] {self.__class__.__name__}: " - "%(message)s" - ) self.__date_string = date.today().strftime("%Y-%m-%d") self.master_jobs_dict = {} # type: Dict[str, Job] diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 4a9010f8..f292d84b 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed from time import sleep, time -from typing import Any, Dict, List, Tuple, Union, Optional +from typing import Any, Dict, List, Optional, Tuple, Union from bs4 import BeautifulSoup from requests import Session @@ -16,48 +16,44 @@ from tqdm import tqdm from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.tools import Logger from jobfunnel.backend.tools.delay import calculate_delays -from jobfunnel.backend.tools import get_logger from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.resources import (MAX_CPU_WORKERS, USER_AGENT_LIST, JobField, Locale) - if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager - -class BaseScraper(ABC): +class BaseScraper(ABC, Logger): """Base scraper object, for scraping and filtering Jobs from a provider """ + def __init__(self, session: Session, config: 'JobFunnelConfigManager', job_filter: JobFilter) -> None: """Init - TODO: we should have a way of establishing pre-requsites for set() - Args: session (Session): session object used to make post and get requests config (JobFunnelConfigManager): config containing all needed paths, search proxy, delaying and other metadata. - job_filter (JobFilter): filtering class used to perform on-the-fly - filtering of jobs to reduce the number of delayed get or set - (i.e. operations that make requests). + job_filter (JobFilter): object for filtering incoming jobs using + various internal filters, including a content-matching tool. + NOTE: this runs-on-the-fly as well, and preempts un-promising + job scrapes to minimize session() usage. Raises: ValueError: if no Locale is configured in the JobFunnelConfigManager """ - self.job_filter = job_filter # We will use this for live-filtering - self.session = session - self.config = config - self.logger = get_logger( - self.__class__.__name__, - self.config.log_level, - self.config.log_file, - f"[%(asctime)s] [%(levelname)s] {self.__class__.__name__}: " - "%(message)s" + # Inits + super().__init__( + level=config.log_level, + file_path=config.log_file, ) + self.job_filter=job_filter + self.session=session + self.config=config if self.headers: self.session.headers.update(self.headers) diff --git a/jobfunnel/backend/tools/__init__.py b/jobfunnel/backend/tools/__init__.py index 83baaef6..0b367055 100644 --- a/jobfunnel/backend/tools/__init__.py +++ b/jobfunnel/backend/tools/__init__.py @@ -1,3 +1,3 @@ from jobfunnel.backend.tools.tools import ( - get_webdriver, get_logger + get_webdriver, get_logger, Logger ) diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index e0d91bb7..810adf85 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -17,7 +17,7 @@ from sklearn.metrics.pairwise import cosine_similarity from jobfunnel.backend import Job -from jobfunnel.backend.tools import get_logger +from jobfunnel.backend.tools import Logger from jobfunnel.resources import ( DEFAULT_MAX_TFIDF_SIMILARITY, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, DuplicateType @@ -28,7 +28,7 @@ ) -class JobFilter: +class JobFilter(Logger): """Class Used by JobFunnel and BaseScraper to filter collections of jobs TODO: make more configurable, maybe with a Filter class and a FilterBank. @@ -56,20 +56,19 @@ def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, company names disallowed from results. Defaults to None. max_job_date (Optional[datetime], optional): maximium date that a job can be scraped. Defaults to None. + log_level (Optional[int], optional): log level. Defaults to INFO. + log_file (Optional[str], optional): log file, Defaults to None. """ + super().__init__( + level=config.log_level, + file_path=config.log_file, + ) self.user_block_jobs_dict = user_block_jobs_dict or {} self.duplicate_jobs_dict = duplicate_jobs_dict or {} self.blocked_company_names_list = blocked_company_names_list or [] self.max_job_date = max_job_date self.max_similarity = max_similarity self.min_tfidf_corpus_size = min_tfidf_corpus_size - self.logger = get_logger( - self.__class__.__name__, - log_level, - log_file, - f"[%(asctime)s] [%(levelname)s] {self.__class__.__name__}: " - "%(message)s" - ) # Retrieve stopwords if not already downloaded try: stopwords = nltk.corpus.stopwords.words('english') diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index ebb1be83..7ba400fa 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -3,6 +3,7 @@ import logging import re import sys +from typing import Optional from datetime import date, datetime, timedelta from dateutil.relativedelta import relativedelta @@ -24,16 +25,17 @@ RECENT_REGEX_B = re.compile(r'[yY]esterday') -def get_logger(logger_name: str, log_level: int, filename: str, +def get_logger(logger_name: str, level: int, file_path: str, message_format: str) -> logging.Logger: """Initialize and return a logger - TODO: make this a class so we can inherit it into any class + NOTE: you can use this as a method to add logging to any function, but if + you want to use this within a class, just inherit Logger class. TODO: make more easily configurable w/ defaults TODO: streamline """ logger = logging.getLogger(logger_name) - logger.setLevel(log_level) - logging.basicConfig(filename=filename, level=log_level) + logger.setLevel(level) + logging.basicConfig(filename=file_path, level=level) formatter = logging.Formatter(message_format) stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) @@ -41,6 +43,35 @@ def get_logger(logger_name: str, log_level: int, filename: str, return logger +class Logger: + """Class that adds a self.logger attribute for stdio and fileio""" + + def __init__(self, level: int, file_path: Optional[str] = None, + logger_name: Optional[str] = None, + message_format: Optional[str] = None) -> None: + """Add a logger to any class + + Args: + level (int): logging level, which ought to be an Enum but isn't + file_path (Optional[str], optional): file path to log messages to. + NOTE: this logs at the specified log level. + logger_name (Optional[str], optional): base name for the logger, + should be unique. Defaults to inherited class name. + message_format (Optional[str], optional): the formatting of the + message to log. Defaults to a complete message with all info. + """ + logger_name = logger_name or self.__class__.__name__ + message_format = message_format or ( + f"[%(asctime)s] [%(levelname)s] {logger_name}: %(message)s" + ) + self.logger = get_logger( + logger_name=logger_name, + level=level, + file_path=file_path, + message_format=message_format, + ) + + def calc_post_date_from_relative_str(date_str: str) -> date: """Identifies a job's post date via post age, updates in-place NOTE: we round to nearest day only. From f0f8b130c787a171075045c0af74ed6d8d5c84fd Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 12:05:24 -0400 Subject: [PATCH 40/66] Add graphviz generator for call graphs + fix some minor leftovers --- .gitignore | 3 +++ docs/gen_call_graphs.sh | 11 +++++++++++ jobfunnel/backend/scrapers/base.py | 2 +- jobfunnel/backend/tools/filters.py | 4 ++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100755 docs/gen_call_graphs.sh diff --git a/.gitignore b/.gitignore index da2544fe..1f5140ca 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ data/ demo/data/ demo_cache +# GraphViz +*.dot + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/docs/gen_call_graphs.sh b/docs/gen_call_graphs.sh new file mode 100755 index 00000000..f8d0f565 --- /dev/null +++ b/docs/gen_call_graphs.sh @@ -0,0 +1,11 @@ +# Install pyan3 via pip3 install pyan3 and then you can do below: +echo "building call graph .dot files in ./call_graphs" + +mkdir ./call_graphs +pyan3 jobfunnel/backend/tools/filters.py -c --dot > ./call_graphs/filters.dot +pyan3 jobfunnel/backend/scrapers/indeed.py -c --dot > ./call_graphs/indeed.dot +pyan3 jobfunnel/backend/jobfunnel.py -c --dot > ./call_graphs/jobfunnel.dot + +echo "Done." +# Then you can visualize the created files with graphviz by making svg, or you +# can copypaste their contents here and look online: http://www.webgraphviz.com/ diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index f292d84b..85696fb9 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -12,7 +12,7 @@ from bs4 import BeautifulSoup from requests import Session from requests.adapters import HTTPAdapter -from requests.packages.urllib3.util.retry import Retry +from urllib3.util import Retry from tqdm import tqdm from jobfunnel.backend import Job, JobStatus diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 810adf85..bc1039f6 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -60,8 +60,8 @@ def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, log_file (Optional[str], optional): log file, Defaults to None. """ super().__init__( - level=config.log_level, - file_path=config.log_file, + level=log_level, + file_path=log_file, ) self.user_block_jobs_dict = user_block_jobs_dict or {} self.duplicate_jobs_dict = duplicate_jobs_dict or {} From 18c5224a012446989fe1c572fe3f1439ad2f5a78 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 13:21:37 -0400 Subject: [PATCH 41/66] Fix delay synchronization between worker threads by controlling access with multiprocessing.Manager.Lock --- jobfunnel/backend/scrapers/base.py | 95 +++++++++++++++++++----------- jobfunnel/backend/tools/filters.py | 5 +- 2 files changed, 64 insertions(+), 36 deletions(-) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 85696fb9..09333ccc 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -6,7 +6,8 @@ import sys from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed -from time import sleep, time +from multiprocessing import Manager, Lock +from time import time, sleep from typing import Any, Dict, List, Optional, Tuple, Union from bs4 import BeautifulSoup @@ -74,9 +75,7 @@ def __init__(self, session: Session, config: 'JobFunnelConfigManager', # Ensure our properties satisfy constraints self._validate_get_set() - - # Init a thread executor (multi-worker) TODO: can't reuse after shutdown - self.executor = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + self.thread_manager = Manager() @property def user_agent(self) -> str: @@ -207,51 +206,71 @@ def scrape(self) -> Dict[str, Job]: f"Scraped {n_soups} job listings from search results pages" ) - # Calculate delays for get/set calls per-job NOTE: only get/set - # calls in self.delayed_get_set_fields will be delayed. - delays = calculate_delays(n_soups, self.config.delay_config) - results = [] - for job_soup, delay in zip(job_soups, delays): - results.append( - self.executor.submit( - self.scrape_job, job_soup=job_soup, delay=delay - ) - ) - - # Loops through futures as completed and removes if successfully parsed - # For each job-soup object, scrape the soup into a Job (w/o desc.) jobs_dict = {} # type: Dict[str, Job] - for future in tqdm(as_completed(results), total=n_soups): - job = future.result() - if job: - # Handle duplicates that exist within the scraped data itself. - # NOTE: if you see alot of these our scrape for key_id is bad - if job.key_id in jobs_dict: - self.logger.error( - f"Job {job.title} and {jobs_dict[job.key_id].title} " - f"share duplicate key_id: {job.key_id}" + + try: + # Init a Manager so we can control delaying + # TODO: make session use async io to coordinate on-the-fly delaying. + # this is assuming every job will incur one delayed session.get() + # NOTE pylint issue: https://github.com/PyCQA/pylint/issues/3313 + delay_lock = self.thread_manager.Lock() # pylint: disable=no-member + # Init a process executor (multi-worker) + executor = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + + # Calculate delays for get/set calls per-job NOTE: only get/set + # calls in self.delayed_get_set_fields will be delayed. + # and it busy-waits. + delays = calculate_delays(n_soups, self.config.delay_config) + futures = [] + for job_soup, delay in zip(job_soups, delays): + futures.append( + executor.submit( + self.scrape_job, + job_soup=job_soup, + delay=delay, + delay_lock=delay_lock, ) - jobs_dict[job.key_id] = job + ) - # Cleanup + log - self.executor.shutdown() + # Loops through futures as completed and removes if successfully parsed + # For each job-soup object, scrape the soup into a Job (w/o desc.) + for future in tqdm(as_completed(futures), total=n_soups): + job = future.result() + if job: + # Handle duplicates that exist within the scraped data itself. + # NOTE: if you see alot of these our scrape for key_id is bad + if job.key_id in jobs_dict: + self.logger.error( + f"Job {job.title} and {jobs_dict[job.key_id].title} " + f"share duplicate key_id: {job.key_id}" + ) + jobs_dict[job.key_id] = job + + finally: + # Cleanup + executor.shutdown() return jobs_dict - def scrape_job(self, job_soup: BeautifulSoup, delay: float - ) -> Optional[Job]: + # pylint: disable=no-member + def scrape_job(self, job_soup: BeautifulSoup, delay: float, + delay_lock: Optional[Lock] = None) -> Optional[Job]: """Scrapes a search page and get a list of soups that will yield jobs Arguments: - job_soup [BeautifulSoup]: This is a soup object that your get/set + job_soup (BeautifulSoup): This is a soup object that your get/set will use to perform the get/set action. It should be specific to this job and not contain other job information. - delay [float]: how long to delay getting/setting for certain + delay (float): how long to delay getting/setting for certain get/set calls while scraping data for this job. + delay_lock (Optional[Manager.Lock], optional): semaphore for + synchronizing respectful delaying across workers NOTE: we should scrape all-priority get fields first, then do high set priorities, and finally low priority set fields. NOTE: this will never raise an exception to prevent killing workers, who are building jobs sequentially. + FIXME: we need to make this use an event loop so that delays are correct + and we can perform a synchronized wait. Returns: Optional[Job]: job object constructed from the soup and localization @@ -265,6 +284,7 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float self.high_priority_get_set_fields] # Scrape the data for the post, requiring a minimum of info... + # NOTE: if we perform a self.session.get we may get respectfully delayed job = None # type: Union[None, Job] job_init_kwargs = self.job_init_kwargs # NOTE: best to construct once for is_get, field in actions_list: @@ -289,8 +309,14 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float break # Respectfully delay if it's configured to do so. + # TODO: move into overriden session and manage this access there. if field in self.delayed_get_set_fields: - sleep(delay) + if delay_lock: + self.logger.debug(f"Delaying for {delay}") + with delay_lock: + sleep(delay) + else: + sleep(delay) try: if is_get: @@ -329,6 +355,7 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float job.validate() return job + # pylint: enable=no-member @abstractmethod def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index bc1039f6..8fc02a54 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -198,7 +198,7 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], if (len(filt_incoming_jobs_dict.keys()) + len(filt_existing_jobs_dict.keys()) < self.min_tfidf_corpus_size): self.logger.warning( - "Skipping similarity filter because there are fewer than " + "Skipping content-similarity filter because there are fewer than " f"{self.min_tfidf_corpus_size} jobs." ) elif filt_incoming_jobs_dict: @@ -210,7 +210,8 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], ) else: self.logger.warning( - "Skipping similarity filter because there are no incoming jobs" + "Skipping content-similarity filter because there are no " + "incoming jobs" ) # Update duplicates list with more JSON-friendly entries From dcf7f87cb710a680f8b00a3094d65e31af271635 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 14:44:57 -0400 Subject: [PATCH 42/66] Cleanup + calculate job get/set actions once in BaseScraper --- jobfunnel/__init__.py | 4 -- jobfunnel/__main__.py | 8 --- jobfunnel/backend/job.py | 36 +++++------ jobfunnel/backend/jobfunnel.py | 27 ++++---- jobfunnel/backend/scrapers/base.py | 86 +++++++++++-------------- jobfunnel/backend/scrapers/glassdoor.py | 63 +++++++++--------- jobfunnel/backend/scrapers/indeed.py | 49 +++++++------- jobfunnel/backend/scrapers/monster.py | 22 +++---- jobfunnel/backend/tools/__init__.py | 5 +- jobfunnel/backend/tools/delay.py | 13 ++-- jobfunnel/backend/tools/filters.py | 53 ++++++--------- jobfunnel/backend/tools/tools.py | 20 +----- jobfunnel/config/base.py | 4 +- jobfunnel/config/cli.py | 15 ++--- jobfunnel/config/delay.py | 11 ++-- jobfunnel/config/manager.py | 19 ++---- jobfunnel/config/proxy.py | 2 +- jobfunnel/config/search.py | 12 ++-- jobfunnel/config/settings.py | 12 ++-- jobfunnel/resources/defaults.py | 2 +- 20 files changed, 199 insertions(+), 264 deletions(-) diff --git a/jobfunnel/__init__.py b/jobfunnel/__init__.py index fab5ff01..4eb28e38 100644 --- a/jobfunnel/__init__.py +++ b/jobfunnel/__init__.py @@ -1,5 +1 @@ -import os -import random - - __version__ = '3.0.0' diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 16f50596..b45d2bff 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -2,14 +2,6 @@ """Builds a config from CLI, runs desired scrapers and updates JSON + CSV NOTE: you can test this from cloned source by running python -m jobfunnel - -TODO/FIXME: - * make it easier to continue an existing search - * make it easier to run multiple searches at once w.r.t caching - * simplified CLI args with new --recover and --clean options - * add warning around seperate cache folders blocklists per search - * document API usage in readme - ** add back the duplicates JSON """ import argparse import sys diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 951d7864..06599128 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -1,20 +1,17 @@ """Base Job class to be populated by Scrapers, manipulated by Filters and saved to csv / etc by Exporter """ -from copy import deepcopy -from bs4 import BeautifulSoup -from datetime import date, datetime import re import string -from typing import Any, Dict, Optional, List - +from copy import deepcopy from datetime import date, datetime, timedelta +from typing import Any, Dict, List, Optional -from jobfunnel.resources import ( - Locale, CSV_HEADER, JobStatus, PRINTABLE_STRINGS, MAX_BLOCK_LIST_DESC_CHARS, - MIN_DESCRIPTION_CHARS, -) +from bs4 import BeautifulSoup +from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, + MIN_DESCRIPTION_CHARS, PRINTABLE_STRINGS, + JobStatus, Locale) # If job.status == one of these we filter it out of results JOB_REMOVE_STATUSES = [ @@ -47,7 +44,6 @@ def __init__(self, TODO integrate init with JobField somehow, ideally with validation. TODO: would be nice to use something standardized for location str - TODO: perhaps we can do 'remote' for location w/ Enum for those jobs? TODO: wage ought to be a number or an object, but is str for flexibility NOTE: ideally key_id is provided, but Monster sets() it, so it now has a default = None and is checked for in validate() @@ -116,15 +112,14 @@ def update_if_newer(self, job: 'Job') -> bool: """Update an existing job with new metadata but keep user's status, but only if the job.post_date > existing_job.post_date! + NOTE: if you have hours or minutes or seconds set, and jobs were scraped + on the same day, the comparison will favour the extra info as newer! TODO: we should do more checks to ensure we are not seeing a totally different job by accident (since this check is usually done by key_id) TODO: more elegant way? maybe we can deepcopy self? - Returns: True if we updated - NOTE: if you have hours or minutes or seconds set, the comparison will - favour the extra information as being newer! TODO: Currently we do day precision but if we wanted to update because something is newer by hours we will need to revisit this limitation and - store scrape hour in the CSV as well. + store scrape hour/etc in the CSV as well. Returns: True if we updated self with job, False if we didn't @@ -167,7 +162,8 @@ def is_old(self, max_age: datetime) -> bool: def as_row(self) -> Dict[str, str]: """Builds a CSV row dict for this job entry - TODO: this is legacy, no support for short_description/raw yet. + TODO: this is legacy, no support for short_description yet. + NOTE: RAW cannot be put into CSV. """ return dict([ (h, v) for h,v in zip( @@ -212,8 +208,8 @@ def as_json_entry(self) -> Dict[str, str]: def clean_strings(self) -> None: """Ensure that all string fields have only printable chars - FIXME: do this automatically upon assignment (override assignment) - FIXME: maybe we can use stopwords? + TODO: do this automatically upon assignment (override assignment) + TODO: maybe we can use stopwords? """ for attr in [self.title, self.company, self.description, self.tags, self.url, self.key_id, self.provider, self.query, @@ -223,10 +219,12 @@ def clean_strings(self) -> None: ) def validate(self) -> None: - """FIXME: implement this just to ensure that the metadata is good + """Simple checks just to ensure that the metadata is good + TODO: consider expanding to cover all attribs. """ assert self.key_id, "Key_ID is unset!" assert self.title, "Title is unset!" assert self.company, "Company is unset!" assert self.url, "URL is unset!" - assert len(self.description) > MIN_DESCRIPTION_CHARS, "Description too short!" + if len(self.description) < MIN_DESCRIPTION_CHARS: + raise ValueError("Description too short!") diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 3d359707..e4465ff7 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -1,5 +1,5 @@ -"""Paul McInnis 2020 -Scrapes jobs, applies search filters and writes pickles to master list +"""Scrapes jobs, applies search filters and writes pickles to master list +Paul McInnis 2020 """ import csv import json @@ -15,13 +15,13 @@ from requests import Session from jobfunnel.backend import Job -from jobfunnel.backend.tools.filters import JobFilter, DuplicatedJob from jobfunnel.backend.tools import Logger +from jobfunnel.backend.tools.filters import DuplicatedJob, JobFilter from jobfunnel.config import JobFunnelConfigManager from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, - MAX_CPU_WORKERS, JobStatus, Locale, T_NOW, - MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, - DuplicateType) + MAX_CPU_WORKERS, + MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, T_NOW, + DuplicateType, JobStatus, Locale) class JobFunnel(Logger): @@ -82,7 +82,7 @@ def __init__(self, config: JobFunnelConfigManager) -> None: @property def daily_cache_file(self) -> str: """The name for for pickle file containing the scraped data ran today' - FIXME: instead of using a 'daily' cache file, we should be tying this + TODO: instead of using a 'daily' cache file, we should be tying this into the search that was made to prevent cross-caching results. """ return os.path.join( @@ -225,10 +225,9 @@ def scrape(self) ->Dict[str, Job]: # Iterate thru scrapers and run their scrape. jobs = {} # type: Dict[str, Job] for scraper_cls in self.config.scrapers: - # FIXME: need to add the threader and delaying here start = time() scraper = scraper_cls(self.session, self.config, self.job_filter) - # TODO: add a warning for overwriting different jobs with same key + # TODO: add a warning for overwriting different jobs with same key! jobs.update(scraper.scrape()) end = time() self.logger.debug( @@ -297,6 +296,7 @@ def write_cache(self, jobs_dict: Dict[str, Job], """Dump a jobs_dict into a pickle TODO: write search_config into the cache file and jobfunnel version + TODO: some way to cache Job.RAW without hitting recursion limit FIXME: add versioning to this Args: @@ -304,7 +304,6 @@ def write_cache(self, jobs_dict: Dict[str, Job], cache_file (str, optional): file path to write to. Defaults to None. """ cache_file = cache_file if cache_file else self.daily_cache_file - # FIXME: some way to cache raw data without recur-limit for job in jobs_dict.values(): job._raw_scrape_data = None pickle.dump(jobs_dict, open(cache_file, 'wb')) @@ -315,8 +314,7 @@ def write_cache(self, jobs_dict: Dict[str, Job], def read_master_csv(self) -> Dict[str, Job]: """Read in the master-list CSV to a dict of unique Jobs - TODO: update from legacy CSV header for short & long description - TODO: the header contents should match JobField names + TODO: make blurb --> description and add short_description Returns: Dict[str, Job]: unique Job objects in the CSV @@ -422,7 +420,7 @@ def update_user_block_list(self) -> None: our configured user block list file and save (if any) NOTE: adding jobs to block list will result in filter() removing them - from all scraped & cached jobs in the future. + from all scraped & cached jobs in the future (persistant). Raises: FileNotFoundError: if no master_jobs_dict is provided and master csv @@ -479,7 +477,8 @@ def update_user_block_list(self) -> None: def update_duplicates_file(self) -> None: """Update duplicates filter file if we have a path and contents - FIXME: this should be writing out DuplicatedJob objects and a version + TODO: this should be writing out DuplicatedJob objects and a version + so that we retain links to original jobs. """ if self.config.duplicates_list_file: if self.job_filter.duplicate_jobs_dict: diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 09333ccc..e229333b 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -6,15 +6,15 @@ import sys from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed -from multiprocessing import Manager, Lock -from time import time, sleep +from multiprocessing import Lock, Manager +from time import sleep, time from typing import Any, Dict, List, Optional, Tuple, Union from bs4 import BeautifulSoup from requests import Session from requests.adapters import HTTPAdapter -from urllib3.util import Retry from tqdm import tqdm +from urllib3.util import Retry from jobfunnel.backend import Job, JobStatus from jobfunnel.backend.tools import Logger @@ -77,9 +77,16 @@ def __init__(self, session: Session, config: 'JobFunnelConfigManager', self._validate_get_set() self.thread_manager = Manager() + # Construct actions list which respects priority for scraping Jobs + self._actions_list = [(True, f) for f in self.job_get_fields] + self._actions_list += [(False, f) for f in self.job_set_fields if f + in self.high_priority_get_set_fields] + self._actions_list += [(False, f) for f in self.job_set_fields if f not + in self.high_priority_get_set_fields] + @property def user_agent(self) -> str: - """Get a user agent for this scraper + """Get a randomized user agent for this scraper """ return random.choice(USER_AGENT_LIST) @@ -146,6 +153,8 @@ def job_set_fields(self) -> List[JobField]: def delayed_get_set_fields(self) -> List[JobField]: """Delay execution when getting /setting any of these attributes of a job. + + TODO: handle this within an overridden self.session.get() """ pass @@ -165,6 +174,9 @@ def high_priority_get_set_fields(self) -> List[JobField]: def locale(self) -> Locale: """The localization that this scraper was built for. + i.e. I am looking for jobs on the Canadian version of Indeed, and I + speak english, so I will have this return Locale.CANADA_ENGLISH + We will use this to put the right filters & scrapers together NOTE: it is best to inherit this from BaseClass (btm. of file) @@ -182,18 +194,12 @@ def headers(self) -> Dict[str, str]: def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID - FIXME: we need to accept some kind of filter bank argument - here so we can abort scraping that isn't promising with a minimal - number of delayed get/sets - - NOTE: respectfully delays for scraping of configured job attributes in - self. - Returns: jobs (Dict[str, Job]): list of Jobs in a Dict keyed by job.key_id """ # Get a list of job soups from the initial search results page + # These wont contain enough information to do more than initialize Job try: job_soups = self.get_job_soups_from_search_result_listings() except Exception as err: @@ -206,17 +212,17 @@ def scrape(self) -> Dict[str, Job]: f"Scraped {n_soups} job listings from search results pages" ) - jobs_dict = {} # type: Dict[str, Job] + # Init a Manager so we can control delaying + # TODO: make session use async io to coordinate on-the-fly delaying. + # this is assuming every job will incur one delayed session.get() + # NOTE pylint issue: https://github.com/PyCQA/pylint/issues/3313 + delay_lock = self.thread_manager.Lock() # pylint: disable=no-member + threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) + # Distribute work to N workers such that each worker is building one + # Job at a time, getting and setting all required attributes + jobs_dict = {} # type: Dict[str, Job] try: - # Init a Manager so we can control delaying - # TODO: make session use async io to coordinate on-the-fly delaying. - # this is assuming every job will incur one delayed session.get() - # NOTE pylint issue: https://github.com/PyCQA/pylint/issues/3313 - delay_lock = self.thread_manager.Lock() # pylint: disable=no-member - # Init a process executor (multi-worker) - executor = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) - # Calculate delays for get/set calls per-job NOTE: only get/set # calls in self.delayed_get_set_fields will be delayed. # and it busy-waits. @@ -224,7 +230,7 @@ def scrape(self) -> Dict[str, Job]: futures = [] for job_soup, delay in zip(job_soups, delays): futures.append( - executor.submit( + threads.submit( self.scrape_job, job_soup=job_soup, delay=delay, @@ -248,7 +254,7 @@ def scrape(self) -> Dict[str, Job]: finally: # Cleanup - executor.shutdown() + threads.shutdown() return jobs_dict @@ -265,29 +271,18 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float, delay_lock (Optional[Manager.Lock], optional): semaphore for synchronizing respectful delaying across workers - NOTE: we should scrape all-priority get fields first, then do high - set priorities, and finally low priority set fields. NOTE: this will never raise an exception to prevent killing workers, who are building jobs sequentially. - FIXME: we need to make this use an event loop so that delays are correct - and we can perform a synchronized wait. Returns: Optional[Job]: job object constructed from the soup and localization of class, returns None if scrape failed. """ - # Formulate the get/set actions, we will do these in-sequence - actions_list = [(True, f) for f in self.job_get_fields] - actions_list += [(False, f) for f in self.job_set_fields if f in - self.high_priority_get_set_fields] - actions_list += [(False, f) for f in self.job_set_fields if f not in - self.high_priority_get_set_fields] - # Scrape the data for the post, requiring a minimum of info... # NOTE: if we perform a self.session.get we may get respectfully delayed - job = None # type: Union[None, Job] - job_init_kwargs = self.job_init_kwargs # NOTE: best to construct once - for is_get, field in actions_list: + job = None # type: Optional[Job] + job_init_kwargs = self.job_init_kwargs # NOTE: faster? + for is_get, field in self._actions_list: # Break out immediately because we have failed a filterable # condition with something we initialized while scraping. @@ -363,11 +358,7 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: shown many job listings at once. NOTE: the soups list returned by this method should contain enough - information to set your self.min_required_job_fields with get/set. - - NOTE: for situations where the data you want is in the job's own page - and we need to make another get request, handle those in set() - and make a request using job.url (it will be respectfully delayed) + information to set your self.min_required_job_fields with get() Returns: List[BeautifulSoup]: list of jobs soups we can use to make a Job @@ -387,20 +378,19 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: """Set a single job attribute from a soup object by JobField + Use this to set Job attribs that rely on Job existing already + with the required minimum fields. + + i.e. I can set() the Job.RAW to be the soup of it's own dedicated web + page (Job.URL), then I can set() my Job.DESCRIPTION from the Job.RAW + NOTE: (remember) do not return anything in here! it sets job attribs FIXME: have this automatically set the attribute by JobField. - - Use this to set Job attribs that rely on Job existing already - with the required minimum fields (i.e. you can set description by - getting the job's detail page with job.url) """ pass - def _validate_get_set(self) -> None: """Ensure the get/set actions cover all need attribs and dont intersect - TODO: we should link a helpful article on how to implement get/set mthds - TODO: we should try to identify if any get/set fields have circ. dep. """ set_job_get_fields = set(self.job_get_fields) set_job_set_fields = set(self.job_set_fields) diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index 79ecbf7a..a9b09e91 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -1,31 +1,25 @@ """Scraper for www.glassdoor.X FIXME: this is currently unable to get past page 1 of job results. """ -from abc import abstractmethod -from bs4 import BeautifulSoup import logging -from requests import Session -from typing import Dict, List, Tuple, Optional, Union - -from jobfunnel.backend.scrapers.base import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper -) -from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.tools import get_webdriver -from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.backend.tools.filters import JobFilter -from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField - +import re from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime, timedelta -import logging from math import ceil from time import sleep, time -from typing import Dict, List, Tuple, Optional, Any -import re +from typing import Any, Dict, List, Optional, Tuple, Union + +from bs4 import BeautifulSoup from requests import Session +from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend.scrapers.base import (BaseCANEngScraper, BaseScraper, + BaseUSAEngScraper) +from jobfunnel.backend.tools import get_webdriver +from jobfunnel.backend.tools.filters import JobFilter +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.resources import MAX_CPU_WORKERS, JobField, Locale if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager @@ -177,25 +171,28 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: # Get the first page of job soups from the search results listings job_soup_list = self._parse_job_listings_to_bs4(soup_base) - # Init threads & futures list FIXME: use existing ThreadPoolExecutor? + # Init threads & futures list FIXME: we should probably delay here too threads = ThreadPoolExecutor(MAX_CPU_WORKERS) - futures_list = [] # FIXME: type? - - # Search the remaining pages to extract the list of job soups - # FIXME: we can't load page 2, it redirects to page 1. - # There is toast that shows to get email notifs that shows up if - # I click it myself, must be an event listener? - if n_pages > 1: - for page in range(2, n_pages + 1): - futures_list.append( - threads.submit( - self._search_page_for_job_soups, - self._get_next_page_url(soup_base, page), - job_soup_list, + try: + # Search the remaining pages to extract the list of job soups + # FIXME: we can't load page 2, it redirects to page 1. + # There is toast that shows to get email notifs that shows up if + # I click it myself, must be an event listener? + futures = [] + if n_pages > 1: + for page in range(2, n_pages + 1): + futures.append( + threads.submit( + self._search_page_for_job_soups, + self._get_next_page_url(soup_base, page), + job_soup_list, + ) ) - ) - wait(futures_list) # wait for all scrape jobs to finish + wait(futures) # wait for all scrape jobs to finish + finally: + threads.shutdown() + return job_soup_list def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 7365b475..58dd4708 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -1,25 +1,23 @@ -"""Scraper designed to get jobs from www.indeed.com / www.indeed.ca +"""Scraper designed to get jobs from www.indeed.X """ +import logging +import re from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime, timedelta -import logging from math import ceil from time import sleep, time -from typing import Dict, List, Tuple, Optional, Any -import re -from requests import Session +from typing import Any, Dict, List, Optional, Tuple from bs4 import BeautifulSoup +from requests import Session -from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.scrapers.base import (BaseCANEngScraper, BaseScraper, + BaseUSAEngScraper) from jobfunnel.backend.tools.filters import JobFilter -from jobfunnel.backend.scrapers.base import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper -) - +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.resources import MAX_CPU_WORKERS, JobField, Locale if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager @@ -51,7 +49,7 @@ def job_get_fields(self) -> str: JobField.TITLE, JobField.COMPANY, JobField.LOCATION, JobField.KEY_ID, JobField.TAGS, JobField.POST_DATE, # JobField.WAGE, JobField.REMOTE - # FIXME: wage and remote are available in listings + # TODO: wage and remote are available in listings sometimes ] @property @@ -115,21 +113,24 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: # Init list of job soups job_soup_list = [] # type: List[Any] - # Init threads & futures list FIXME: use existing ThreadPoolExecutor + # Init threads & futures list FIXME: we should probably delay here too threads = ThreadPoolExecutor(max_workers=MAX_CPU_WORKERS) - futures_list = [] # FIXME: type? - - # Scrape soups for all the result pages containing lists of jobs found - for page in range(0, pages): - futures_list.append( - threads.submit( - self._get_job_soups_from_search_page, search_url, page, - job_soup_list + try: + # Scrape soups for all the result pages containing many job listings + futures = [] + for page in range(0, pages): + futures.append( + threads.submit( + self._get_job_soups_from_search_page, search_url, page, + job_soup_list + ) ) - ) - # Wait for all scrape jobs to finish - wait(futures_list) + # Wait for all scrape jobs to finish + wait(futures) + + finally: + threads.shutdown() return job_soup_list diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 847440be..90ab35b2 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -1,25 +1,23 @@ """Scrapers for www.monster.X """ +import logging +import re from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait from datetime import date, datetime, timedelta -import logging from math import ceil from time import sleep, time -from typing import Dict, List, Tuple, Optional, Any -import re -from requests import Session +from typing import Any, Dict, List, Optional, Tuple from bs4 import BeautifulSoup +from requests import Session -from jobfunnel.resources import Locale, MAX_CPU_WORKERS, JobField from jobfunnel.backend import Job, JobStatus -from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.backend.scrapers.base import (BaseCANEngScraper, BaseScraper, + BaseUSAEngScraper) from jobfunnel.backend.tools.filters import JobFilter -from jobfunnel.backend.scrapers.base import ( - BaseScraper, BaseCANEngScraper, BaseUSAEngScraper -) - +from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str +from jobfunnel.resources import MAX_CPU_WORKERS, JobField, Locale if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager @@ -155,7 +153,7 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: """Scrapes raw data from a job source into a list of job-soups - TODO: use threading here too? + TODO: use threading here too Returns: List[BeautifulSoup]: list of jobs soups we can use to make Job init @@ -233,7 +231,7 @@ def _get_num_search_result_pages(self, initial_results_soup: BeautifulSoup, def _get_search_url(self, method: Optional[str] = 'get', page: int = 1) -> str: """Get the monster search url from SearchTerms - TODO: implement fulltime/parttime portion + company search? + TODO: implement fulltime/part-time portion + company search? TODO: implement POST NOTE: unfortunately we cannot start on any page other than 1, so the jobs displayed just scrolls forever and we will see diff --git a/jobfunnel/backend/tools/__init__.py b/jobfunnel/backend/tools/__init__.py index 0b367055..5ffd68b8 100644 --- a/jobfunnel/backend/tools/__init__.py +++ b/jobfunnel/backend/tools/__init__.py @@ -1,3 +1,2 @@ -from jobfunnel.backend.tools.tools import ( - get_webdriver, get_logger, Logger -) +from jobfunnel.backend.tools.tools import get_webdriver, get_logger, Logger +# FIXME: we can't import delays here or we cause circular import. diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 48239538..11998913 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -1,16 +1,16 @@ """Module for calculating random or non-random delay """ from math import ceil, log, sqrt -from numpy import arange from random import uniform -from typing import Dict, Union, List from time import time +from typing import Dict, List, Union +from numpy import arange from scipy.special import expit -from jobfunnel.resources import DelayAlgorithm -from jobfunnel.config import DelayConfig from jobfunnel.backend import Job +from jobfunnel.config import DelayConfig +from jobfunnel.resources import DelayAlgorithm def _c_delay(list_len: int, delay: Union[int, float]): @@ -49,9 +49,9 @@ def _lin_delay(list_len: int, delay: Union[int, float]): return delays -# https://en.wikipedia.org/wiki/Generalised_logistic_function def _sig_delay(list_len: int, delay: Union[int, float]): """Calculates Richards/Sigmoid curve for delay. + NOTE: https://en.wikipedia.org/wiki/Generalised_logistic_function """ gr = sqrt(delay) * 4 # growth rate y_0 = log(4 * delay) # Y(0) @@ -64,8 +64,7 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: """Checks delay config and returns calculated delay list. NOTE: we do this to be respectful to online job sources - - TODO: we should calculate delays on-demand. + TODO: we should be able to calculate delays on-demand. Args: list_len: length of scrape job list diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index 8fc02a54..b50bac9e 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -1,15 +1,13 @@ """Filters that are used in jobfunnel's filter() method or as intermediate -filters to reduce un-necessesary scraping. -FIXME: we should have a Enum(Filter) for all job filters to allow configuration -and generic log messages. +filters to reduce un-necessesary scraping """ +import json +import logging +import os from collections import namedtuple from copy import deepcopy -import logging -from typing import Dict, List, Optional, Tuple from datetime import datetime -import json -import os +from typing import Dict, List, Optional, Tuple import nltk import numpy as np @@ -18,10 +16,9 @@ from jobfunnel.backend import Job from jobfunnel.backend.tools import Logger -from jobfunnel.resources import ( - DEFAULT_MAX_TFIDF_SIMILARITY, MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, - DuplicateType -) +from jobfunnel.resources import (DEFAULT_MAX_TFIDF_SIMILARITY, + MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, + DuplicateType) DuplicatedJob = namedtuple( 'DuplicatedJob', ['original', 'duplicate', 'type'], @@ -31,7 +28,7 @@ class JobFilter(Logger): """Class Used by JobFunnel and BaseScraper to filter collections of jobs - TODO: make more configurable, maybe with a Filter class and a FilterBank. + TODO: make more configurable, maybe with a FilterBank class. """ def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, @@ -69,6 +66,7 @@ def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, self.max_job_date = max_job_date self.max_similarity = max_similarity self.min_tfidf_corpus_size = min_tfidf_corpus_size + # Retrieve stopwords if not already downloaded try: stopwords = nltk.corpus.stopwords.words('english') @@ -76,7 +74,7 @@ def __init__(self, user_block_jobs_dict: Optional[Dict[str, str]] = None, nltk.download('stopwords', quiet=True) stopwords = nltk.corpus.stopwords.words('english') - # Init vectorizer e! + # Init vectorizer self.vectorizer = TfidfVectorizer( strip_accents='unicode', lowercase=True, @@ -96,9 +94,6 @@ def filter(self, jobs_dict: Dict[str, Job], NOTE: if you remove duplicates before processesing them into updates you will retain potentially stale job information. - FIXME: it would make sense if we could integrate filter_duplicates - into this as well. - Returns: jobs_dict with all filtered items removed. """ @@ -113,15 +108,14 @@ def filterable(self, job: Job, check_existing_duplicates: bool = True) -> bool: """Filter jobs out using all our available filters + NOTE: this allows job to be partially initialized + Arguments: check_existing_duplicates: pass True to check if ID was previously detected to be a duplicate via TFIDF cosine similarity Returns: True if the job should be removed from incoming data, else False - - TODO: arrange checks by how long they take to run - NOTE: this does a lot of checks because job may be partially initialized """ return bool( job.status and job.is_remove_status @@ -144,6 +138,8 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], ) -> List[DuplicatedJob]: """Remove all known duplicates from jobs_dict and update original data + FIXME: we assume there are no duplicates by content in existing jobs + Args: existing_jobs_dict (Dict[str, Job]): dict of jobs keyed by key_id. incoming_jobs_dict (Dict[str, Job]): dict of new jobs by key_id. @@ -154,8 +150,6 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], """ duplicate_jobs_list = [] # type: List[DuplicatedJob] filt_existing_jobs_dict = deepcopy(existing_jobs_dict) - # FIXME: we assume there are no duplicates by content in existing jobs - # And this is a bad assumption... need to fix this. filt_incoming_jobs_dict = {} # type: Dict[str, Job] # Look for matches by key id only @@ -215,7 +209,7 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], ) # Update duplicates list with more JSON-friendly entries - # FIXME: we should retain a reference to the original job + # FIXME: we should retain a reference to the original job contents self.duplicate_jobs_dict.update({ j.duplicate.key_id: j.duplicate.as_json_entry for j in duplicate_jobs_list @@ -229,33 +223,26 @@ def tfidf_filter(self, incoming_jobs_dict: Dict[str, dict], """Fit a tfidf vectorizer to a corpus of Job.DESCRIPTIONs and identify duplicate jobs by cosine-similarity. - FIXME: need to handle existing_jobs_dict = None! - NOTE/WARNING: if you are running this method, you should have already removed any duplicates by key_id NOTE: this only uses job descriptions to do the content matching. NOTE: it is recommended that you have at least around 25 ish Jobs. + FIXME: need to handle existing_jobs_dict = None TODO: have this raise an exception if there are too few words. - TODO: we should consider caching the transformed corups. + TODO: we should consider caching the transformed corpus. Args: incoming_jobs_dict (Dict[str, dict]): dict of jobs containing potential duplicates (i.e jobs we just scraped) existing_jobs_dict (Dict[str, dict]): the existing jobs dict (i.e. Master CSV) - max_similarity (float, optional): threshold above which desc - similarity is considered a duplicate. Defaults to - DEFAULT_MAX_TFIDF_SIMILARITY. - duplicate_jobs_dict (str, optional): contents of user's duplicate - job detection JSON so we can persist previous detections during - this run Raises: ValueError: incoming_jobs_dict contains no job descriptions Returns: - List[DuplicatedJob]: list of new duplicate Jobs and their existing Jobs - found via content matching (for use in JobFunnel). + List[DuplicatedJob]: list of new duplicate Jobs and their existing + Jobs found via content matching (for use in JobFunnel). """ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job], is_incoming: bool = False, diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 7ba400fa..97992cca 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -3,8 +3,8 @@ import logging import re import sys -from typing import Optional from datetime import date, datetime, timedelta +from typing import Optional from dateutil.relativedelta import relativedelta from selenium import webdriver @@ -74,7 +74,8 @@ def __init__(self, level: int, file_path: Optional[str] = None, def calc_post_date_from_relative_str(date_str: str) -> date: """Identifies a job's post date via post age, updates in-place - NOTE: we round to nearest day only. + NOTE: we round to nearest day only so that comparisons dont capture + portions of days. """ post_date = datetime.now() # type: date # Supports almost all formats like 7 hours|days and 7 hr|d|+d @@ -149,18 +150,3 @@ def get_webdriver(): "Chrome, Opera, Microsoft Edge, Internet Explorer]" ) return driver - - -def split_url(url): - # capture protocol, ip address and port from given url - match = re.match(r'^(http[s]?):\/\/([A-Za-z0-9.]+):([0-9]+)?(.*)$', url) - - # if not all groups have a match, match will be None - if match is not None: - return { - 'protocol': match.group(1), - 'ip_address': match.group(2), - 'port': match.group(3), - } - else: - return None diff --git a/jobfunnel/config/base.py b/jobfunnel/config/base.py index 1e3f7021..8f613298 100644 --- a/jobfunnel/config/base.py +++ b/jobfunnel/config/base.py @@ -11,7 +11,7 @@ def __init__(self) -> None: def validate(self) -> None: """This should raise Exceptions if self.attribs are bad - FIXME: move this into cerberus schema validation, or, use the same - validators it does here. + NOTE: if we use sub-configs we could potentiall use Cerberus for this + against any vars(Config) """ pass diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 268a8fd3..36c0a6be 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -3,16 +3,15 @@ import argparse import logging import os -from typing import Dict, Any, List +from typing import Any, Dict, List + import yaml -from jobfunnel.config import ( - JobFunnelConfigManager, DelayConfig, SearchConfig, ProxyConfig, - SettingsValidator, SETTINGS_YAML_SCHEMA -) -from jobfunnel.backend.tools.tools import split_url -from jobfunnel.resources import ( - Locale, DelayAlgorithm, LOG_LEVEL_NAMES, Provider) +from jobfunnel.config import (SETTINGS_YAML_SCHEMA, DelayConfig, + JobFunnelConfigManager, ProxyConfig, + SearchConfig, SettingsValidator) +from jobfunnel.resources import (LOG_LEVEL_NAMES, DelayAlgorithm, Locale, + Provider) from jobfunnel.resources.defaults import * diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py index ddae1cf0..7e70cae2 100644 --- a/jobfunnel/config/delay.py +++ b/jobfunnel/config/delay.py @@ -2,12 +2,11 @@ """ from jobfunnel.config.base import BaseConfig from jobfunnel.resources import DelayAlgorithm -from jobfunnel.resources.defaults import ( - DEFAULT_DELAY_ALGORITHM, DEFAULT_DELAY_MAX_DURATION, - DEFAULT_DELAY_MIN_DURATION, DEFAULT_DELAY_ALGORITHM, - DEFAULT_RANDOM_CONVERGING_DELAY, DEFAULT_RANDOM_DELAY, -) - +from jobfunnel.resources.defaults import (DEFAULT_DELAY_ALGORITHM, + DEFAULT_DELAY_MAX_DURATION, + DEFAULT_DELAY_MIN_DURATION, + DEFAULT_RANDOM_CONVERGING_DELAY, + DEFAULT_RANDOM_DELAY) class DelayConfig(BaseConfig): diff --git a/jobfunnel/config/manager.py b/jobfunnel/config/manager.py index c584ace1..b4f98b00 100644 --- a/jobfunnel/config/manager.py +++ b/jobfunnel/config/manager.py @@ -1,13 +1,12 @@ """Config object to run JobFunnel """ import logging -from typing import Optional, List, Dict, Any import os +from typing import Any, Dict, List, Optional -from jobfunnel.config import BaseConfig, ProxyConfig, SearchConfig, DelayConfig -from jobfunnel.resources import Locale, Provider, BS4_PARSER from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE - +from jobfunnel.config import BaseConfig, DelayConfig, ProxyConfig, SearchConfig +from jobfunnel.resources import BS4_PARSER, Locale, Provider if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.backend.scrapers.base import BaseScraper @@ -29,8 +28,7 @@ def __init__(self, bs4_parser: Optional[str] = BS4_PARSER, return_similar_results: Optional[bool] = False, delay_config: Optional[DelayConfig] = None, - proxy_config: Optional[ProxyConfig] = None, - web_driven_scraping: Optional[bool] = False) -> None: + proxy_config: Optional[ProxyConfig] = None) -> None: """Init a config that determines how we will scrape jobs from Scrapers and how we will update CSV and filtering lists @@ -58,8 +56,6 @@ def __init__(self, Defaults to a default delay config object. proxy_config (Optional[ProxyConfig], optional): proxy config object. Defaults to None, which will result in no proxy being used - web_driven_scraping (Optional[bool], optional): use web-driven - scraper implementation if available. NOTE: beta feature! """ self.master_csv_file = master_csv_file self.user_block_list_file = user_block_list_file @@ -69,9 +65,8 @@ def __init__(self, self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape - self.bs4_parser = bs4_parser # TODO: add to config + self.bs4_parser = bs4_parser # TODO: add to config YAML? self.return_similar_results = return_similar_results - self.web_driven_scraping = web_driven_scraping if not delay_config: # We will always use a delay config to be respectful self.delay_config = DelayConfig() @@ -106,7 +101,7 @@ def scrapers(self) -> List['BaseScraper']: return scrapers @property - def scraper_names(self) -> str: + def scraper_names(self) -> List[str]: """User-readable names of the scrapers we will be running """ return [s.__name__ for s in self.scrapers] @@ -120,7 +115,7 @@ def create_dirs(self) -> None: def validate(self) -> None: """Validate the config object i.e. paths exit NOTE: will raise exceptions if issues are encountered. - FIXME: impl. more validation here + TODO: impl. more validation here """ assert os.path.exists(self.cache_folder) self.search_config.validate() diff --git a/jobfunnel/config/proxy.py b/jobfunnel/config/proxy.py index fad147f6..519f4910 100644 --- a/jobfunnel/config/proxy.py +++ b/jobfunnel/config/proxy.py @@ -25,5 +25,5 @@ def url(self) -> str: return url_str # FIXME: this could be done in one line def validate(self) -> None: - """FIXME: impl. validate ip addr is valid format etc""" + """TODO: impl. validate ip addr is valid format etc""" pass diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index d4d2db84..10b72965 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -9,11 +9,9 @@ ) class SearchConfig(BaseConfig): - """Config object to contain region of interest for a Locale - - FIXME: ideally we'd have one of these per-locale, per-website, but then - the config would be a nightmare, so we'll just put everything in here - for now + """Config object containing our desired job search information including + the Locale of the searcher, the region to search and what job providers to + search with. """ def __init__(self, @@ -33,6 +31,8 @@ def __init__(self, Args: keywords (List[str]): list of search keywords province_or_state (str): province or state. + locale(Locale): the searcher's Locale, defines the job website + domain and the scrapers we will use to scrape it. city (Optional[str], optional): city. Defaults to None. distance_radius (Optional[int], optional): km/m radius. Defaults to DEFAULT_SEARCH_RADIUS_KM. @@ -76,6 +76,6 @@ def query_string(self) -> str: def validate(self): """We need to have the right information set, not mixing stuff - FIXME: impl. + TODO: impl. with _validate_type_ipv4address """ pass diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index 3a65a724..ada29a7c 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -1,14 +1,13 @@ """Settings YAML Schema w/ validator """ -from cerberus import Validator import ipaddress import logging -from jobfunnel.resources import ( - Locale, Provider, DelayAlgorithm, LOG_LEVEL_NAMES -) -from jobfunnel.resources.defaults import * +from cerberus import Validator +from jobfunnel.resources import (LOG_LEVEL_NAMES, DelayAlgorithm, Locale, + Provider) +from jobfunnel.resources.defaults import * SETTINGS_YAML_SCHEMA = { 'master_csv_file': { @@ -157,7 +156,8 @@ def _validate_type_ipv4address(self, field, value): checks that the given value is a valid IPv4 address """ try: - # try to create an IPv4 address object using the python3 ipaddress module + # try to create an IPv4 address object using the python3 ipaddress + # module ipaddress.IPv4Address(value) except: self._error(field, "Not a valid IPv4 address") diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index d99daaf4..f69dc7ac 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -29,7 +29,7 @@ DEFAULT_DELAY_MAX_DURATION = 5.0 DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR -# FIXME: re-enable glassdoor once we fix issue with it. +# FIXME: re-enable glassdoor once we fix issue with it. (#87) DEFAULT_PROVIDERS = [Provider.MONSTER, Provider.INDEED] #, Provider.GLASSDOOR] DEFAULT_NO_SCRAPE = False DEFAULT_USE_WEB_DRIVER = False From 954a0799f41f8d7ad9a969e7a3d69b4ae0465c14 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 29 Aug 2020 16:08:11 -0400 Subject: [PATCH 43/66] Fixed some minor validation issues, moved path creation logic out of config manager --- jobfunnel/config/cli.py | 12 +- jobfunnel/config/delay.py | 12 +- jobfunnel/config/manager.py | 21 +-- jobfunnel/config/proxy.py | 1 + jobfunnel/config/search.py | 10 +- tests/conftest.py | 116 +--------------- tests/test_config.py | 132 ++++++++++++++++++ tests/test_countries.py | 105 +-------------- tests/test_delay.py | 87 +----------- tests/test_filters.py | 60 +-------- tests/test_glassdoor.py | 1 + tests/test_indeed.py | 245 +-------------------------------- tests/test_parse.py | 261 +++++------------------------------- tests/test_tools.py | 211 +---------------------------- tests/test_validate.py | 168 ----------------------- 15 files changed, 197 insertions(+), 1245 deletions(-) create mode 100644 tests/test_config.py delete mode 100644 tests/test_validate.py diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 36c0a6be..41c64025 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -337,12 +337,12 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: f"Invalid Config settings yaml:\n{SettingsValidator.errors}" ) - # Create any folders that we need - if output_folder: - if not os.path.exists(output_folder): - os.makedirs(output_folder) - if not os.path.exists(config['cache_folder']): - os.makedirs(config['cache_folder']) + # Create folders that out output files are within if they don't exist + for path_attr in ['master_csv_file', 'user_block_list_file', + 'cache_folder', 'duplicates_list_file']: + output_dir = os.path.dirname(os.path.abspath(config[path_attr])) + if not os.path.exists(output_dir): + os.makedirs(output_dir) # Build JobFunnelConfigManager search_cfg = SearchConfig( diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py index 7e70cae2..0c6bcd1a 100644 --- a/jobfunnel/config/delay.py +++ b/jobfunnel/config/delay.py @@ -18,6 +18,7 @@ def __init__(self, max_duration: float = DEFAULT_DELAY_MAX_DURATION, random: bool = DEFAULT_RANDOM_DELAY, converge: bool = DEFAULT_RANDOM_CONVERGING_DELAY): # TODO: document + super().__init__() self.max_duration = max_duration self.min_duration = min_duration self.algorithm = algorithm @@ -27,7 +28,16 @@ def __init__(self, max_duration: float = DEFAULT_DELAY_MAX_DURATION, def validate(self) -> None: if self.max_duration <= 0: raise ValueError("Your max delay is set to 0 or less.") - if self.min_duration < 0 or self.min_duration >= self.max_duration: + if self.min_duration <= 0 or self.min_duration >= self.max_duration: raise ValueError( "Minimum delay is below 0, or more than or equal to delay." ) + if type(self.algorithm) != DelayAlgorithm: + raise ValueError( + f"Invalid Value for delaying algorithm: {self.algorithm}" + ) + if self.converge and not self.random: + raise ValueError( + "You cannot configure convering random delay without also " + "enabling random delaying" + ) diff --git a/jobfunnel/config/manager.py b/jobfunnel/config/manager.py index b4f98b00..ba9ae255 100644 --- a/jobfunnel/config/manager.py +++ b/jobfunnel/config/manager.py @@ -2,11 +2,11 @@ """ import logging import os -from typing import Any, Dict, List, Optional +from typing import List, Optional from jobfunnel.backend.scrapers.registry import SCRAPER_FROM_LOCALE from jobfunnel.config import BaseConfig, DelayConfig, ProxyConfig, SearchConfig -from jobfunnel.resources import BS4_PARSER, Locale, Provider +from jobfunnel.resources import BS4_PARSER if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.backend.scrapers.base import BaseScraper @@ -57,6 +57,7 @@ def __init__(self, proxy_config (Optional[ProxyConfig], optional): proxy config object. Defaults to None, which will result in no proxy being used """ + super().__init__() self.master_csv_file = master_csv_file self.user_block_list_file = user_block_list_file self.duplicates_list_file = duplicates_list_file @@ -74,16 +75,6 @@ def __init__(self, self.delay_config = delay_config self.proxy_config = proxy_config - # Create folder that out output files are within, if it doesn't exist - for path_attr in [self.master_csv_file, self.user_block_list_file, - self.cache_folder]: - if path_attr: - output_dir = os.path.dirname(os.path.abspath(path_attr)) - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - self.validate() - @property def scrapers(self) -> List['BaseScraper']: """All the compatible scrapers for the provider_name @@ -106,12 +97,6 @@ def scraper_names(self) -> List[str]: """ return [s.__name__ for s in self.scrapers] - def create_dirs(self) -> None: - """Create any missing dirs - """ - if not os.path.exists(self.cache_folder): - os.makedirs(self.cache_folder) - def validate(self) -> None: """Validate the config object i.e. paths exit NOTE: will raise exceptions if issues are encountered. diff --git a/jobfunnel/config/proxy.py b/jobfunnel/config/proxy.py index 519f4910..65c8456d 100644 --- a/jobfunnel/config/proxy.py +++ b/jobfunnel/config/proxy.py @@ -7,6 +7,7 @@ class ProxyConfig(BaseConfig): """Simple config object to contain proxy configuration """ def __init__(self, protocol: str, ip_address: str, port: int) -> None: + super().__init__() self.protocol = protocol self.ip_address = ip_address self.port = port diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index 10b72965..de0eb6d8 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -46,8 +46,9 @@ def __init__(self, querying. If not passed, will set based on locale. (i.e. 'ca') remote: True if searching for remote jobs only TODO: impl. for scr. """ + super().__init__() self.province_or_state = province_or_state - self.city = city.lower() + self.city = city.lower() if city else None self.radius = distance_radius or DEFAULT_SEARCH_RADIUS_KM self.locale = locale self.providers = providers @@ -56,7 +57,6 @@ def __init__(self, self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS self.blocked_company_names = blocked_company_names self.remote = remote - self.__query_string = '' # type: str # Try to infer the domain string based on the locale. if not domain: @@ -68,11 +68,9 @@ def __init__(self, @property def query_string(self) -> str: - """User-readable version of the keywords we are searching with + """User-readable version of the keywords we are searching with for CSV """ - if not self.__query_string: - self.__query_string = ' '.join(self.keywords) - return self.__query_string + return ' '.join(self.keywords) def validate(self): """We need to have the right information set, not mixing stuff diff --git a/tests/conftest.py b/tests/conftest.py index f35b71b2..d3b7fbfb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,115 +1 @@ -import pytest -import sys - -from unittest.mock import patch - -from jobfunnel.config.parser import parse_config -from jobfunnel.tools.tools import config_factory -from jobfunnel.__main__ import PROVIDERS -from jobfunnel.jobfunnel import MASTERLIST_HEADER - -""" search_term_configs is a collection of search_terms configurations for all supported countries. If more countries are added to JobFunnel, one may add those new configurations to this variable and those new countries/domains will be tested without having to write new tests for them, assuming of course that one uses @pytest.mark.parametrize to feed search_term_configs to those new tests.""" -search_term_configs = [{'region': {'province': 'ON', 'city': 'waterloo', 'domain': 'ca', 'radius': 25}}, { - 'region': {'province': '', 'city': 'new york', 'domain': 'com', 'radius': 25}}] - - -@pytest.fixture() -def configure_options(): - def setup(options: list): - """Assigns the options to argv(as if JobFunnel were called from the command line with those options) - and calls parse_config(). This fixture assumes that the test_parse module has been tested and passes. - """ - with patch.object(sys, 'argv', options): - config = parse_config() - return config - - return setup - - -@pytest.fixture() -def job_listings(): - def setup(attr_list: list): - """ - This function generates job listings. - If attr_list is empty, then it returns a single job with - the contents of job_format, which is a default job listing defined on this fixture. - If attr_list is not empty, it returns a job listing for each attribute pair on attr_list. - The expected format for each item on attr_list is - [['key1', 'key2', 'keyN'], 'value'] - """ - job_format = {'status': 'new', 'title': 'Python Engineer', 'company': 'Python Corp', 'location': 'Waterloo, ON', 'date': '10 days ago', 'blurb': '', 'tags': '', - 'link': - 'https://job-openings.best-job-board.domain/python-engineer-waterloo-on-ca-pro' - 'com/216808420', 'id': '216808420', 'provider': 'monster', 'query': 'Python'} - if len(attr_list) > 0: - return config_factory(job_format, attr_list) - else: - return job_format - return setup - - -@pytest.fixture() -def per_id_job_listings(job_listings): - def setup(attr_list: list, first_job_id: int = 0): - """ - This function generates job_listings in the {'job_id':{job_listing}} - fashion. This is particularly useful for functions like tfidf_filter that expect job listings in this format. - Args: - attr_list: an attribute list in the [['key1', 'key2', 'keyN'], 'value'] format. - first_job_id: At what number to start generating job ids. This is particular useful when you want different job ids but the len of attr_list is the same across multiple calls to this function. - Returns: - A dictionary of the format {'job_id#1':{job_listing},'job_id#2':{job_listing}, - 'job_id#3':{job_listing}}. Please note that every job_id is unique. - """ - job_list = job_listings(attr_list) - new_job_id = first_job_id - per_id_job_list = {} - for job in job_list: - job['id'] = str(new_job_id) - per_id_job_list.update({job['id']: job}) - new_job_id += 1 - return per_id_job_list - return setup - - -@pytest.fixture() -def init_scraper(configure_options): - def setup(provider: str, options: list = ['']): - """ - This function initializes a scraper(such as Indeed, Monster, etc) specified by provider. - Hopefully it'll reduce some code duplication in tests. - Args: - provider: the provider to be inialized. - Note that provider must match one of the keys defined for each scraper on the PROVIDERS dict on __main__. - options: the options to be passed to the scraper, such as keywords, domain, etc. - Note that only command-line options are accepted. Anything that needs to be tweaked that is not a command line option needs to be configured by the caller manually. - Returns: - An instance of the specified provider. - """ - return PROVIDERS[provider](configure_options(options)) - return setup - - -@pytest.fixture() -def setup_scraper(init_scraper): - def setup(scraper: str): - """ - This fixture initializes the scraper state up until the point of - having a BeautifulSoup list that can be used for scraping. - This will help us avoid code duplication for tests. - Args: - scraper: The name of the scraper. Note that this name is used as a key for the PROVIDERS dict defined on __main__.py - Returns: - A dict of the form {'job_provider':provider,'job_list':job_soup_list, 'job_keys':job}. - job_provider is the Indeed scraper object. - job_soup_list is the list of BeautifulSoup objects that is ready to be scraped. - job is a dict with all the keys from MASTERLIST_HEADER and empty values. - """ - provider = init_scraper(scraper) - # get the search url - search = provider.get_search_url() - job_soup_list = [] - provider.search_page_for_job_soups(search, 0, job_soup_list) - job = dict([(k, '') for k in MASTERLIST_HEADER]) - return {'job_provider': provider, 'job_list': job_soup_list, 'job_keys': job} - return setup +# FIXME \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..2cf0e48b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,132 @@ +"""Test the config library +""" +import pytest + +from jobfunnel.config import (DelayConfig, JobFunnelConfigManager, ProxyConfig, + SearchConfig) +from jobfunnel.resources import DelayAlgorithm +from jobfunnel.resources import Locale + + +@pytest.mark.parametrize("max_duration, min_duration, invalid_dur", [ + (1.0, 1.0, True), + (-1.0, 1.0, True), + (5.0, 0.0, True), + (5.0, 1.0, False), +]) +@pytest.mark.parametrize("random, converge, invalid_rand", [ + (True, True, False), + (True, False, False), + (False, True, True), +]) +@pytest.mark.parametrize("delay_algorithm", (DelayAlgorithm.LINEAR, None)) +def test_delay_config_validate(max_duration, min_duration, invalid_dur, + delay_algorithm, random, converge, invalid_rand): + """Test DelayConfig + TODO: test messages too + """ + cfg = DelayConfig( + max_duration=max_duration, + min_duration=min_duration, + algorithm=delay_algorithm, + random=random, + converge=converge, + ) + + # FUT + if invalid_dur or not delay_algorithm or invalid_rand: + with pytest.raises(ValueError): + cfg.validate() + else: + cfg.validate() + + +@pytest.mark.parametrize("keywords, exp_query_str", [ + (['b33f', 'd3ad'], 'b33f d3ad'), + (['trumpet'], 'trumpet'), +]) +def test_search_config_query_string(mocker, keywords, exp_query_str): + """Test that search config can build keyword query string correctly. + """ + cfg = SearchConfig( + keywords=keywords, + province_or_state=mocker.Mock(), + locale=Locale.CANADA_FRENCH, + providers=mocker.Mock(), + ) + + # FUT + query_str = cfg.query_string + + # Assertions + assert query_str == exp_query_str + + +@pytest.mark.parametrize("locale, domain, exp_domain", [ + (Locale.CANADA_ENGLISH, None, 'ca'), + (Locale.CANADA_FRENCH, None, 'ca'), + (Locale.USA_ENGLISH, None, 'com'), + (Locale.USA_ENGLISH, 'xyz', 'xyz'), + (None, None, None), +]) +def test_search_config_init(mocker, locale, domain, exp_domain): + """Make sure the init functions as we expect wrt to domain selection + """ + # FUT + if not locale: + # test our error + with pytest.raises(ValueError, match=r"Unknown domain for locale.*"): + cfg = SearchConfig( + keywords=mocker.Mock(), + province_or_state=mocker.Mock(), + locale=-1, # AKA an unknown Enum entry to Locale + providers=mocker.Mock(), + ) + else: + cfg = SearchConfig( + keywords=mocker.Mock(), + province_or_state=mocker.Mock(), + locale=locale, + domain=domain, + providers=mocker.Mock(), + ) + + # Assertions + assert cfg.domain == exp_domain + + +# TODO: implement once we add validation to ProxyConfig +# def test_proxy_config(protocol, ip_address, port): +# pass + +# FIXME: need to break down config manager stuff, perhaps it shouldn't be +# creating the paths in it's init. Makes this test complicated. +# @pytest.mark.parametrize('pass_del_cfg', (True, False)) +# def test_config_manager_init(mocker, pass_del_cfg): +# """NOTE: unlike other configs this one validates itself on creation +# """ +# # Mocks +# patch_del_cfg = mocker.patch('jobfunnel.config.manager.DelayConfig') +# patch_os = mocker.patch('jobfunnel.config.manager.os') +# patch_os.path.exists.return_value = False # check it makes all paths +# mock_master_csv = mocker.Mock() +# mock_block_list = mocker.Mock() +# mock_dupe_list = mocker.Mock() +# mock_cache_folder = mocker.Mock() +# mock_search_cfg = mocker.Mock() +# mock_proxy_cfg = mocker.Mock() +# mock_del_cfg = mocker.Mock() + +# # FUT +# cfg = JobFunnelConfigManager( +# master_csv_file=mock_master_csv, +# user_block_list_file=mock_block_list, +# duplicates_list_file=mock_dupe_list, +# cache_folder=mock_cache_folder, +# search_config=mock_search_cfg, +# delay_config=mock_del_cfg if pass_del_cfg else None, +# proxy_config=mock_proxy_cfg, +# log_file='', # TODO optional? +# ) + +# # Assertions diff --git a/tests/test_countries.py b/tests/test_countries.py index ab40e2ee..d3b7fbfb 100644 --- a/tests/test_countries.py +++ b/tests/test_countries.py @@ -1,104 +1 @@ -import pytest -import os -import re -import sys -import json -import random - -from bs4 import BeautifulSoup -from requests import get, post -from typing import Union -from unittest.mock import patch - -from jobfunnel.config.parser import parse_config -from jobfunnel.indeed import Indeed -from jobfunnel.monster import Monster -from jobfunnel.glassdoor_static import GlassDoorStatic - - -PROVIDERS = {'indeed': Indeed, 'monster': Monster, - 'glassdoorstatic': GlassDoorStatic} - -# TODO: Test GlassdoorDynamic Provider - -DOMAINS = {'America': 'com', 'Canada': 'ca'} - -cities_america = os.path.normpath( - os.path.join(os.path.dirname(__file__), 'json/cities_america.json')) -cities_canada = os.path.normpath( - os.path.join(os.path.dirname(__file__), 'json/cities_canada.json')) - -with open(cities_america, 'r') as file: - cities_america = json.load(file) - -with open(cities_canada, 'r') as file: - cities_canada = json.load(file) - -cities = cities_america + cities_canada -test_size = 100 -if len(cities) < test_size: - test_size = len(cities) - -# take a random sample of cities of size test_size -cities = random.sample(cities, test_size) - -with patch.object(sys, 'argv', ['']): - config = parse_config() - - -@pytest.mark.xfail(strict=False) -@pytest.mark.parametrize('city', cities) -def test_cities(city, delay=1): - """tests american city""" - count = 0 # a count of providers with successful test cases - for p in config['providers']: - provider: Union[GlassDoorStatic, Monster, - Indeed] = PROVIDERS[p](config) - provider.search_terms['region']['domain'] = DOMAINS[city['country']] - provider.search_terms['region']['province'] = city['abbreviation'] - provider.search_terms['region']['city'] = city['city'] - if isinstance(provider, Indeed): - # get search url - search = provider.get_search_url() - - # get the html data, initialize bs4 with lxml - request_html = get(search, headers=provider.headers) - elif isinstance(provider, Monster): - # get search url - search = provider.get_search_url() - - # get the html data, initialize bs4 with lxml - request_html = get(search, headers=provider.headers) - elif isinstance(provider, GlassDoorStatic): - try: - # get search url - search, data = provider.get_search_url(method='post') - except IndexError: - # sometimes glassdoor does not find the location id - continue - - # get the html data, initialize bs4 with lxml - request_html = post(search, headers=provider.headers, data=data) - else: - raise TypeError( - f'Type {type(provider)} does not match any of the providers.') - - # create the soup base - soup_base = BeautifulSoup(request_html.text, provider.bs4_parser) - - # parse the location text field - where = None # initialize location variable - location = ', '.join([city['city'], city['abbreviation']]) - location = re.sub("['-]", '', location) - if isinstance(provider, Indeed): - where = soup_base.find(id='where')['value'].strip() - elif isinstance(provider, Monster): - where = soup_base.find(id='location')['value'].strip() - elif isinstance(provider, GlassDoorStatic): - where = soup_base.find(id='sc.location')['value'] - - if where.lower() == location.lower(): - count += 1 - - # assert that at least one provider found the correct location - assert count > 0 +# FIXME \ No newline at end of file diff --git a/tests/test_delay.py b/tests/test_delay.py index 8b6bd53e..d3b7fbfb 100644 --- a/tests/test_delay.py +++ b/tests/test_delay.py @@ -1,86 +1 @@ -import pytest -from jobfunnel.tools.tools import config_factory -from jobfunnel.tools.delay import delay_alg - -# Define mock data for this test module - -linear_delay = [0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8] -sigmoid_delay = [0, 0.263, 0.284, 0.307, - 0.332, 0.358, 0.386, 0.417, 0.449, 0.485] -constant_delay = [0, 8.6, 8.8, 9.0, 9.2, 9.4, 9.6, 9.8, 10.0, 10.0] -random_delay = [0, 5, 5, 5, 5, 5, 5, 5, 5, 5] -job_list = ['job1', 'job2', 'job3', 'job4', 'job5', - 'job6', 'job7', 'job8', 'job9', 'job10'] - - -# mock random.uniform to get constant values - - -def mock_rand_uniform(a, b): - return 5 - - -@pytest.mark.parametrize('function, expected_result', [('linear', linear_delay), ('sigmoid', sigmoid_delay), ('constant', constant_delay)]) -class TestClass: - - # test linear, constant and sigmoid delay - # This test considers configurations with random and converge fields - @pytest.mark.parametrize('random,converge', [(True, True), (True, False), (False, False)]) - def test_delay_alg(self, configure_options, function, expected_result, random, converge, monkeypatch): - config = configure_options(['']) - config['delay_config']['random'] = random - config['delay_config']['function'] = function - config['delay_config']['converge'] = converge - if random: - monkeypatch.setattr( - 'jobfunnel.tools.delay.uniform', mock_rand_uniform) - expected_result = random_delay - else: - config['delay_config']['min_delay'] = 0 - delay_result = delay_alg(10, config['delay_config']) - assert delay_result == expected_result - - # test linear, constant and sigmoid delay with a negative min_delay - - def test_delay_alg_negative_min_delay(self, configure_options, function, expected_result): - config = configure_options(['']) - config['delay_config']['random'] = False - config['delay_config']['function'] = function - config['delay_config']['min_delay'] = -2 - delay_result = delay_alg(10, config['delay_config']) - assert delay_result == expected_result - - # test linear, constant and sigmoid delay when min_delay is greater than the delay - - def test_delay_alg_min_delay_greater_than_delay(self, configure_options, function, expected_result): - config = configure_options(['']) - config['delay_config']['random'] = False - config['delay_config']['function'] = function - # Set the delay value to its default - config['delay_config']['delay'] = 10 - config['delay_config']['min_delay'] = 15 - delay_result = delay_alg(10, config['delay_config']) - assert delay_result == expected_result - - # test linear, constant and sigmoid delay with negative delay - - def test_delay_alg_negative_delay(self, configure_options, function, expected_result): - config = configure_options(['']) - config['delay_config']['random'] = False - config['delay_config']['function'] = function - config['delay_config']['min_delay'] = 0 - config['delay_config']['delay'] = -2 - with pytest.raises(ValueError) as e: - delay_result = delay_alg(10, config['delay_config']) - assert str( - e.value) == "\nYour delay is set to 0 or less.\nCancelling execution..." - - # test linear, constant and sigmoid delay with random and a list as input - - def test_delay_alg_list_linear(self, configure_options, function, expected_result): - config = configure_options(['']) - config['delay_config']['random'] = False - config['delay_config']['function'] = function - config['delay_config']['min_delay'] = 0 - delay_result = delay_alg(job_list, config['delay_config']) - assert delay_result == expected_result +# FIXME \ No newline at end of file diff --git a/tests/test_filters.py b/tests/test_filters.py index cb64d7cd..d3b7fbfb 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1,59 +1 @@ -import pytest - -from collections import OrderedDict -from datetime import datetime, timedelta -from unittest.mock import patch - -from jobfunnel.tools.filters import tfidf_filter, id_filter, date_filter - - -attr_list = [[['blurb'], 'Looking for a passionate team player that is willing to learn new technologies. Our company X is still growing at an exponential rate. In order to be a perfect fit' - ' you must tell us your favorite movie at the interview; favorite food; and a fun fact about yourself. The ideal fit will also know Python and SDLC.'], - [['blurb'], 'Looking for a passionate developer that is willing to learn new technologies. Our company X is still growing at an exponential rate. In order to be a perfect fit' - ' you must tell us your favorite movie at the interview; favorite food; and your favorite programming langauge. The ideal candiadate will also know Python and SDLC.'], - [['blurb'], 'We make the best ice cream in the world. Our company still young and growing. We have stable funding and a lot of crazy ideas to make our company grow. The ideal candidate should like ice cream.'], - [['blurb'], 'We make the best ice cream in the world. Our company still young and growing. We have stable funding and a lot of crazy ideas to make our company grow. The ideal candidate should love ice cream and all things ice cream.'], - ] - - -def test_date_filter(per_id_job_listings): - new_job_listings = per_id_job_listings([attr_list[0], attr_list[1]]) - # assign two different dates to the job_postings - job_date = datetime.now() - timedelta(days=10) - new_job_listings['0']['date'] = job_date.strftime('%Y-%m-%d') - job_date = datetime.now() - timedelta(days=3) - new_job_listings['1']['date'] = job_date.strftime('%Y-%m-%d') - date_filter(new_job_listings, 5) - # assert that that jobs older than 5 days have been removed - assert list(new_job_listings) == ['1'] - - -def test_id_filter(per_id_job_listings): - new_job_listings = per_id_job_listings([attr_list[0], attr_list[2]]) - # generate job listings with the same ids as new_job_listings - previous_job_listings = per_id_job_listings([attr_list[1], attr_list[3]]) - id_filter(new_job_listings, previous_job_listings, - new_job_listings['0']['provider']) - # assert that the new job listings have been removed since they already exist - assert len(new_job_listings) == 0 - # assert that the correct job ids are in the new filtered new_job_listings - assert list(previous_job_listings) == ['0', '1'] - - -def test_tfidf_filter_no_previous_scrape(per_id_job_listings): - new_job_listings = per_id_job_listings(attr_list[0:4]) - tfidf_filter(new_job_listings) - # assert that the correct job ids are in the new filtered new_job_listings - assert list(new_job_listings) == ['1', '3'] - - -def test_tfidf_filter_with_previous_scrape(per_id_job_listings): - new_job_listings = per_id_job_listings([attr_list[0], attr_list[2]]) - # generate job listings with different job ids than new_job_listings - previous_job_listings = per_id_job_listings( - [attr_list[1], attr_list[3]], first_job_id=2) - tfidf_filter(new_job_listings, previous_job_listings) - # assert that the new job listings have been removed since they already exist - assert len(new_job_listings) == 0 - # assert that the correct job ids are in the new filtered new_job_listings - assert list(previous_job_listings) == ['2', '3'] +# FIXME \ No newline at end of file diff --git a/tests/test_glassdoor.py b/tests/test_glassdoor.py index e69de29b..d3b7fbfb 100644 --- a/tests/test_glassdoor.py +++ b/tests/test_glassdoor.py @@ -0,0 +1 @@ +# FIXME \ No newline at end of file diff --git a/tests/test_indeed.py b/tests/test_indeed.py index b42dac8b..d3b7fbfb 100644 --- a/tests/test_indeed.py +++ b/tests/test_indeed.py @@ -1,244 +1 @@ -from jobfunnel.indeed import Indeed -from jobfunnel.tools.delay import delay_alg -import pytest -from bs4 import BeautifulSoup -import re -from .conftest import search_term_configs - - -#test the correctness of search_tems since our tests depend on it - -def test_search_terms(init_scraper): - indeed = init_scraper('indeed') - assert indeed.search_terms == { 'region': {'province':'ON', - 'city':'waterloo', 'domain':'ca', 'radius':25}, 'keywords':['Python']} - -@pytest.mark.parametrize('search_terms_config', search_term_configs) -class TestClass(): - - def test_convert_radius(self, init_scraper, search_terms_config): - provider = init_scraper('indeed') - provider.search_terms = search_terms_config - assert 0 == provider.convert_radius(-1) - assert 0 == provider.convert_radius(3) - assert 5 == provider.convert_radius(7) - assert 10 == provider.convert_radius(12) - assert 15 == provider.convert_radius(20) - assert 25 == provider.convert_radius(37) - assert 50 == provider.convert_radius(75) - assert 100 == provider.convert_radius(300) - - - def test_get_search_url(self, init_scraper, search_terms_config): - provider = init_scraper('indeed') - provider.search_terms = search_terms_config - if(provider.search_terms['region']['domain'] == 'ca'): - assert'https://www.indeed.ca/jobs?q=Python&l=waterloo%2C+ON&radius=25&limit=50&filter=0' == provider.get_search_url() - with pytest.raises(ValueError) as e: - provider.get_search_url('panda') - assert str(e.value) == 'No html method panda exists' - with pytest.raises(NotImplementedError) as e: - provider.get_search_url('post') - - - def test_get_num_pages_to_scrape(self, init_scraper, search_terms_config): - provider = init_scraper('indeed') - provider.search_terms = search_terms_config - # get the search url - search = provider.get_search_url() - - # get the html data, initialize bs4 with lxml - request_html = provider.s.get(search, headers=provider.headers) - - # create the soup base - soup_base = BeautifulSoup(request_html.text, provider.bs4_parser) - assert provider.get_num_pages_to_scrape(soup_base, max=3) <= 3 - - - def test_search_page_for_job_soups(self, init_scraper, search_terms_config): - provider = init_scraper('indeed') - provider.search_terms = search_terms_config - # get the search url - search = provider.get_search_url() - - # get the html data, initialize bs4 with lxml - request_html = provider.s.get(search, headers=provider.headers) - job_soup_list = [] - provider.search_page_for_job_soups(search, 0, job_soup_list) - assert 0 < len(job_soup_list) - - -# test the process of fetching title data from a job - - def test_get_title(self, setup_scraper, search_terms_config): - scraper = setup_scraper('indeed') - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider = scraper['job_provider'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['title'] = provider.get_title(soup) - except AttributeError: - continue - if(0 < len(job['title'])): - assert True - return - assert False - - -# test the process of fetching company data from a job - - def test_get_company(self, setup_scraper, search_terms_config): - scraper = setup_scraper('indeed') - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider = scraper['job_provider'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['company'] = provider.get_company(soup) - except AttributeError: - continue - if(0 < len(job['company'])): - assert True - return - assert False - - -# test the process of fetching location data from a job - - def test_get_location(self, setup_scraper, search_terms_config): - scraper = setup_scraper('indeed') - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider = scraper['job_provider'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['location'] = provider.get_location(soup) - except AttributeError: - continue - if(0 < len(job['location'])): - assert True - return - assert False - - -# test the process of fetching date data from a job - - def test_get_date(self, setup_scraper, search_terms_config): - scraper = setup_scraper('indeed') - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider = scraper['job_provider'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['date'] = provider.get_date(soup) - except AttributeError: - continue - if(0 < len(job['date'])): - assert True - return - assert False - -# Test the id with a strict assertion because without a job id we have -# no job link, and without job link, we have no job to apply to - def test_get_id(self, setup_scraper, search_terms_config): - scraper = setup_scraper('indeed') - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider = scraper['job_provider'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['id'] = provider.get_id(soup) - except: - assert False - assert True - - -# test the process of fetching the link to a job - - def test_get_link(self, setup_scraper, search_terms_config): - scraper = setup_scraper('indeed') - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider = scraper['job_provider'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['id'] = provider.get_id(soup) - job['link'] = provider.get_link(job['id']) - except AttributeError: - continue - if(0 < len(job['link'])): - assert True - return - - assert False - - -# test the process of fetching the blurb from a job - - def test_get_blurb_with_delay(self, setup_scraper, search_terms_config): - """ - Tests whether the process of fetching blurb data is working. - """ - scraper = setup_scraper('indeed') - provider = scraper['job_provider'] - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['id'] = provider.get_id(soup) - job['link'] = provider.get_link(job['id']) - res_job, html = provider.get_blurb_with_delay(job, delay_alg( - len(job_soup_list), provider.delay_config)[0]) - provider.parse_blurb(job, html) - except AttributeError: - continue - if(0 < len(job['blurb'])): - assert True - return - - assert False - - - - def test_search_joblink_for_blurb(self, setup_scraper, search_terms_config): - """ - Tests whether the process of fetching blurb data is working. - This test assumes that no delay configuration has been set. - """ - scraper = setup_scraper('indeed') - provider = scraper['job_provider'] - job_soup_list = scraper['job_list'] - job = scraper['job_keys'] - provider.delay_config = None - provider.search_terms = search_terms_config - for soup in job_soup_list: - try: - job['id'] = provider.get_id(soup) - job['link'] = provider.get_link(job['id']) - provider.search_joblink_for_blurb(job) - except AttributeError: - continue - if(0 < len(job['blurb'])): - assert True - return - - assert False - - - # Test the entire integration - - def test_scrape(self, init_scraper, mocker, - search_terms_config): - # ensure that we don't scrape more than one page - mocker.patch('jobfunnel.indeed.Indeed.get_num_pages_to_scrape', return_value=1) - provider = init_scraper('indeed') - provider.search_terms = search_terms_config - provider.scrape() +# FIXME \ No newline at end of file diff --git a/tests/test_parse.py b/tests/test_parse.py index 86dd276e..3df6e617 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -1,229 +1,34 @@ +"""Test CLI parsing +""" import pytest -import sys -import os -import yaml - -from pathlib import Path -from unittest.mock import patch - -from jobfunnel.config.parser import parse_config, parse_cli, cli_to_yaml, update_yaml, check_config_types, log_levels - - -config_dict = { - 'output_path': 'fish', - 'providers': ['Indeed', 'Monster'], - 'search_terms': { - 'region': { - 'state': 'NY', - 'city': 'New York', - 'domain': 'com', - } - } -} - -config_dict_fail = { - 'this_should_fail': False -} - -cli_options = [ - ['', '-s', 'settings.yaml'], - ['', '-o', '.'], - ['', '-kw', 'java', 'python'], - ['', '-p', 'ON'], - ['', '--city', 'New York'], - ['', '--domain', 'com'], - ['', '-r'], - ['', '-c'], - ['', '-d', '20'], - ['', '-md', '10'], - ['', '--fun', 'linear'], - ['', '--log_level', 'info'], - ['', '--similar'], - ['', '--no_scrape'], - # US proxy grabbed from https://www.free-proxy-list.net/ - ['', '--proxy', 'http://50.193.9.202:53888'], - ['', '--recover'], - ['', '--save_dup'], - ['', '--max_listing_days', '30'], -] - - -# test parse_cli with all command line options - -@pytest.mark.parametrize('option', cli_options) -def test_parse_cli_pass(option): - with patch.object(sys, 'argv', option): - config = parse_cli() - - -# test Parse_cli with an invalid argument - -def test_parse_cli_fail(): - with patch.object(sys, 'argv', ['', 'invalid_arg']): - with pytest.raises(SystemExit): - config = parse_cli() - - -@pytest.mark.parametrize('option', cli_options) -def test_parse_cli_to_yaml_pass(option): - with patch.object(sys, 'argv', option): - cli = parse_cli() - cli_to_yaml(cli) - - -# create config fixture to avoid code duplication - -@pytest.fixture() -def config_dependency(): - def setup(default_path='config/settings.yaml', patch_path=None): - """Does everything parse_config does up until loading the settings file passed in - by the user, if they choose to pass one, to prepare the config dictionary for - other tests to use. This fixture assumes that the tests - test_parse_cli_* and test_parse_cli_to_yaml_* have passed. - - Returns the dictionary with keys 'config', 'given_yaml' and 'cli_yaml' - - It is ensured that config and given_yaml are valid, otherwise an exception is thrown. - """ - # find the jobfunnel root dir - jobfunnel_path = os.path.normpath( - os.path.join(os.path.dirname(__file__), '../jobfunnel')) - - # load the default settings - default_yaml_path = os.path.join(jobfunnel_path, default_path) - default_yaml = yaml.safe_load(open(default_yaml_path, 'r')) - - # parse the command line arguments - if patch_path == None: - with patch.object(sys, 'argv', ['', '-s', default_yaml_path]): - cli = parse_cli() - else: - with patch.object(sys, 'argv', ['', '-s', patch_path]): - cli = parse_cli() - cli_yaml = cli_to_yaml(cli) - - # parse the settings file for the line arguments - given_yaml = None - given_yaml_path = None - if cli.settings is not None: - given_yaml_path = os.path.dirname(cli.settings) - given_yaml = yaml.safe_load(open(cli.settings, 'r')) - - config = default_yaml - return {'config': config, 'given_yaml': given_yaml, - 'cli_yaml': cli_yaml} - return setup - - -# test update_update_yaml with every command line option - -@pytest.mark.parametrize('option', cli_options) -def test_update_yaml_pass(option, config_dependency): - config_setup = config_dependency() - with patch.object(sys, 'argv', option): - # parse the command line arguments - cli = parse_cli() - cli_yaml = cli_to_yaml(cli) - - # parse the settings file for the line arguments - given_yaml = None - if cli.settings is not None: - # take this opportunity to ensure that the demo settings file exists - given_yaml = config_setup['given_yaml'] - - # combine default, given and argument yamls into one. Note that we update - # the values of the default_yaml, so we use this for the rest of the file. - # We could make a deep copy if necessary. - config = config_setup['config'] - - if given_yaml is not None: - update_yaml(config, given_yaml) - update_yaml(config, cli_yaml) - - -def test_check_config_types_fail(tmpdir, config_dependency): - # create temporary settings file and write yaml file - yaml_file = Path(tmpdir) / 'settings.yaml' - with open(yaml_file, mode='w') as f: - yaml.dump(config_dict_fail, f) - - # create an invalid config_dependency with data from config_dict_fail - config_setup = config_dependency(patch_path=str(yaml_file)) - config = config_setup['config'] - given_yaml = config_setup['given_yaml'] - cli_yaml = config_setup['cli_yaml'] - if given_yaml is not None: - update_yaml(config, given_yaml) - update_yaml(config, cli_yaml) - with pytest.raises(KeyError): - check_config_types(config) - - -def test_user_yaml(tmpdir): - # create temporary settings file and write yaml file - yaml_file = Path(tmpdir) / 'settings.yaml' - with open(yaml_file, mode='w') as f: - yaml.dump(config_dict, f) - - # call funnel with user-defined settings - with patch.object(sys, 'argv', ['', '-s', str(yaml_file)]): - config = parse_config() - - assert config['output_path'] == "fish" - assert set(config['providers']) == set(['indeed', 'monster']) - assert config['search_terms']['region']['state'] == 'NY' - # assert config['search_terms']['region']['province'] == 'NY' # I believe this should pass - assert config['search_terms']['region']['city'] == 'New York' - assert config['search_terms']['region']['domain'] == 'com' - assert config['search_terms']['region']['radius'] == 25 - - -# test the final config from parse_config with each command line option - -def test_cli_yaml(): - with patch.object(sys, 'argv', cli_options[1]): - config = parse_config() - assert config['output_path'] == '.' - with patch.object(sys, 'argv', cli_options[2]): - config = parse_config() - assert config['search_terms']['keywords'] == ['java', 'python'] - with patch.object(sys, 'argv', cli_options[3]): - config = parse_config() - assert config['search_terms']['region']['province'] == 'ON' - with patch.object(sys, 'argv', cli_options[4]): - config = parse_config() - assert config['search_terms']['region']['city'] == 'New York' - with patch.object(sys, 'argv', cli_options[5]): - config = parse_config() - assert config['search_terms']['region']['domain'] == 'com' - with patch.object(sys, 'argv', cli_options[6]): - config = parse_config() - assert config['delay_config']['random'] is True - with patch.object(sys, 'argv', cli_options[7]): - config = parse_config() - assert config['delay_config']['converge'] is True - with patch.object(sys, 'argv', cli_options[8]): - config = parse_config() - assert config['delay_config']['delay'] == 20 - with patch.object(sys, 'argv', cli_options[9]): - config = parse_config() - assert config['delay_config']['min_delay'] == 10 - with patch.object(sys, 'argv', cli_options[10]): - config = parse_config() - assert config['delay_config']['function'] == 'linear' - with patch.object(sys, 'argv', cli_options[11]): - config = parse_config() - assert config['log_level'] == log_levels['info'] - with patch.object(sys, 'argv', cli_options[12]): - config = parse_config() - assert config['similar'] is True - with patch.object(sys, 'argv', cli_options[13]): - config = parse_config() - assert config['no_scrape'] is True - with patch.object(sys, 'argv', cli_options[14]): - config = parse_config() - assert config['proxy'] == { - 'protocol': 'http', - 'ip_address': '50.193.9.202', - 'port': '53888' - } +from jobfunnel.config import parse_cli, config_builder +from jobfunnel.resources.defaults import * + +# FIXME +# @pytest.mark.parametrize("kwargs, exception, match", [ +# ( +# { +# 'settings_yaml_file': 'demo/settings.yaml', +# }, +# ValueError, +# r".*If specifying paths you must pass all arguments.*", +# ), + +# ]) +# def test_config_builder(mocker, kwargs, exception, match): + +# # Inject our settings as augmentations of CLI +# # TODO: we should break parse_cli into own test +# args = vars(parse_cli()) +# for kwarg, value in kwargs.items(): +# args[kwarg] = value + +# patch_os = mocker.patch('jobfunnel.config.cli.os') +# mocker.patch('jobfunnel.config.cli.vars', return_value=args) + +# # FUT +# if exception: +# with pytest.raises(exception, match=match): +# config_builder(None) +# else: +# cfg = config_builder(None) diff --git a/tests/test_tools.py b/tests/test_tools.py index 30337220..623b65e9 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,211 +1,2 @@ -import pytest +# FIXME -from dateutil.relativedelta import relativedelta -from datetime import datetime, timedelta - -from jobfunnel.tools.tools import split_url, proxy_dict_to_url, config_factory, post_date_from_relative_post_age, filter_non_printables - - -URLS = [ - { - 'url': 'https://192.168.178.20:812', - 'splits': { - 'protocol': 'https', - 'ip_address': '192.168.178.20', - 'port': '812' - }, - 'complete': True - }, - { - 'url': '1.168.178.20:812', - 'splits': { - 'protocol': '', - 'ip_address': '1.168.178.20', - 'port': '812' - }, - 'complete': False - }, - { - 'url': 'https://192.168.178.20', - 'splits': { - 'protocol': 'https', - 'ip_address': '192.168.178.20', - 'port': '' - }, - 'complete': False - }, - { - 'url': '192.168.178.20', - 'splits': { - 'protocol': '', - 'ip_address': '192.168.178.20', - 'port': '' - }, - 'complete': False - } -] - -# Define an attribute list for all tests to use in this module - -attr_list = [ - [['title'], 'Test Engineer'], - [['title'], 'Software Engineer–'], - [['blurb'], 'Test and develop'], - [['blurb'], 'Develop and design software–'], - [['date'], 'Just posted'], - [['date'], 'today'], - [['date'], '1 hour ago'], - [['date'], '2 hours ago'], - [['date'], 'yesterday'], - [['date'], '1 day ago'], - [['date'], '2 days ago'], - [['date'], '1 month'], - [['date'], '2 months'], - [['date'], '1 year ago'], - [['date'], '2 years ago'], - [['date'], '1 epoch ago'], - [['date'], 'junk'], - [['some_option'], 'option_value'] -] - -# test clean/dirty characters that may be on title and blurb fields - -def test_filter_non_printables_clean_title(job_listings): - job_list = job_listings(attr_list[0:1]) - filter_non_printables(job_list[0]) - assert job_list[0]['title'] == 'Test Engineer' - - -def test_filter_non_printables_dirty_title(job_listings): - job_list = job_listings(attr_list[1:2]) - filter_non_printables(job_list[0]) - assert job_list[0]['title'] == 'Software Engineer' - - -def test_filter_non_printables_clean_blurb(job_listings): - job_list = job_listings(attr_list[2:3]) - filter_non_printables(job_list[0]) - assert job_list[0]['blurb'] == 'Test and develop' - - -def test_filter_non_printables_diryt_blurb(job_listings): - job_list = job_listings(attr_list[3:4]) - filter_non_printables(job_list[0]) - assert job_list[0]['blurb'] == 'Develop and design software' - -# test job_listing dates with all possible formats - -def test_post_date_from_relative_post_age_just_posted_pass(job_listings): - job_list = job_listings(attr_list[4:5]) - post_date_from_relative_post_age(job_list) - assert datetime.now().strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_post_age_today_pass(job_listings): - job_list = job_listings(attr_list[5:6]) - post_date_from_relative_post_age(job_list) - assert datetime.now().strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_post_age_1_hour_ago_pass(job_listings): - job_list = job_listings(attr_list[6:7]) - post_date_from_relative_post_age(job_list) - now = datetime.now() - assert now.strftime('%Y-%m-%d') == job_list[0]['date'] or \ - (now - timedelta(days=int(1))).strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_post_age_2_hours_ago_pass(job_listings): - job_list = job_listings(attr_list[7:8]) - post_date_from_relative_post_age(job_list) - now = datetime.now() - assert now.strftime('%Y-%m-%d') == job_list[0]['date'] or \ - (now - timedelta(days=int(1))).strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_yesterday_ago_pass(job_listings): - job_list = job_listings(attr_list[8:9]) - post_date_from_relative_post_age(job_list) - yesterday = datetime.now() - timedelta(days=int(1)) - assert yesterday.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_1_day_ago_pass(job_listings): - job_list = job_listings(attr_list[9:10]) - post_date_from_relative_post_age(job_list) - one_day_ago = datetime.now() - timedelta(days=int(1)) - assert one_day_ago.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_2_days_ago_pass(job_listings): - job_list = job_listings(attr_list[10:11]) - post_date_from_relative_post_age(job_list) - two_days_ago = datetime.now() - timedelta(days=int(2)) - assert two_days_ago.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_1_month_ago_pass(job_listings): - job_list = job_listings(attr_list[11:12]) - post_date_from_relative_post_age(job_list) - one_month_ago = datetime.now() - relativedelta(months=int(1)) - assert one_month_ago.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_2_months_ago_pass(job_listings): - job_list = job_listings(attr_list[12:13]) - post_date_from_relative_post_age(job_list) - two_months_ago = datetime.now() - relativedelta(months=int(2)) - assert two_months_ago.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_1_year_ago_pass(job_listings): - job_list = job_listings(attr_list[13:14]) - post_date_from_relative_post_age(job_list) - one_year_ago = datetime.now() - relativedelta(years=int(1)) - assert one_year_ago.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_2_years_ago_pass(job_listings): - job_list = job_listings(attr_list[14:15]) - post_date_from_relative_post_age(job_list) - two_years_ago = datetime.now() - relativedelta(years=int(2)) - assert two_years_ago.strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_1_epoch_ago_pass(job_listings): - job_list = job_listings(attr_list[15:16]) - post_date_from_relative_post_age(job_list) - assert datetime(1970, 1, 1).strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_post_date_from_relative_ago_post_age_junk(job_listings): - job_list = job_listings(attr_list[16:17]) - post_date_from_relative_post_age(job_list) - assert datetime(1970, 1, 1).strftime('%Y-%m-%d') == job_list[0]['date'] - - -def test_config_factory(configure_options): - config = config_factory(configure_options( - ['']), attr_list[17:18])[0] - assert config['some_option'] == 'option_value' - - -@pytest.mark.parametrize('url', URLS) -def test_split_url(url): - # gives dictionary with protocol, ip and port - url_dic = split_url(url['url']) - - # check if all elements match with provided output - if url['complete']: - assert url_dic == url['splits'] - else: - assert url_dic is None - - -@pytest.mark.parametrize('url', URLS) -def test_proxy_dict_to_url(url): - # gives dictionary with protocol, ip and port - url_str = proxy_dict_to_url(url['splits']) - - # check if all elements match with provided output - assert url_str == url['url'] diff --git a/tests/test_validate.py b/tests/test_validate.py deleted file mode 100644 index 644c0f5e..00000000 --- a/tests/test_validate.py +++ /dev/null @@ -1,168 +0,0 @@ -import pytest -import sys - -from unittest.mock import patch - -from jobfunnel.config.parser import parse_config -from jobfunnel.config.validate import validate_config, validate_delay, validate_region -from jobfunnel.tools.tools import config_factory - - -# define config dictionaries that are not valid -# invalid path -attr_list = [ - [['master_list_path'], 'masterzz_list.csv'], - [['providers'], ['indeed', 'twitter']], - [['search_terms', 'region', 'domain'], 'cjas'], - [['search_terms', 'region', 'province'], None], - [['delay_config', 'function'], 'weird'], - [['delay_config', 'min_delay'], 50.0], - [['delay_config', 'min_delay'], -1], - [['delay_config', 'delay'], 2], - [['max_listing_days'], -1], - [['data_path'], 'data_dump'], - [['duplicate_list_path'], 'duplicate_list_.csv'], - [['log_path'], 'data/jobfunnel_.log'], - [['filter_list_path'], 'data/filter_list_.json'] -] - -# test all paths with invalid values - -def test_filter_list_path_fail(configure_options): - path_configs = config_factory( - configure_options(['']), attr_list[12: 13])[0] - with pytest.raises(Exception) as e: - validate_config(path_configs) - assert str(e.value) == 'filter_list_path' - - -def test_log_path_fail(configure_options): - path_configs = config_factory(configure_options(['']), attr_list[11:12])[0] - with pytest.raises(Exception) as e: - validate_config(path_configs) - assert str(e.value) == 'log_path' - - -def test_duplicate_list_path_fail(configure_options): - path_configs = config_factory( - configure_options(['']), attr_list[10: 11])[0] - with pytest.raises(Exception) as e: - validate_config(path_configs) - assert str(e.value) == 'duplicate_list_path' - - -def test_data_path_fail(configure_options): - path_configs = config_factory(configure_options(['']), attr_list[9: 10])[0] - with pytest.raises(Exception) as e: - validate_config(path_configs) - assert str(e.value) == 'data_path' - - -def test_master_list_path_fail(configure_options): - path_configs = config_factory(configure_options(['']), attr_list[0: 1])[0] - with pytest.raises(Exception) as e: - validate_config(path_configs) - assert str(e.value) == 'master_list_path' - - -# test with invalid providers - -def test_providers_fail(configure_options): - providers_config = config_factory( - configure_options(['']), attr_list[1: 2])[0] - with pytest.raises(Exception) as e: - validate_config(providers_config) - assert str(e.value) == 'providers' - - -# test with invalid regions and domains - -def test_domain_fail(configure_options): - region_config = config_factory(configure_options(['']), attr_list[2:3])[0] - with pytest.raises(Exception) as e: - validate_region(region_config['search_terms']['region']) - assert str(e.value) == 'domain' - - -def test_province_fail(configure_options): - region_config = config_factory(configure_options(['']), attr_list[3:4])[0] - with pytest.raises(Exception) as e: - validate_region(region_config['search_terms']['region']) - assert str(e.value) == 'province' - - -# test validate_region with the default valid Configuration - -def test_region_pass(configure_options): - validate_region(configure_options([''])['search_terms']['region']) - - -# generate config with invalid delay function name - -def test_delay_function_fail(configure_options): - delay_configs = config_factory(configure_options(['']), attr_list[4: 5])[0] - with pytest.raises(Exception) as e: - validate_delay(delay_configs['delay_config']) - assert str(e.value) == 'delay_function' - - -# test delay_function with original configuration - -def test_delay_function_pass(configure_options): - validate_delay(configure_options([''])['delay_config']) - - -# generate config with invalid min delay value of -1 - -def test_delay_min_delay_fail(configure_options): - delay_configs = config_factory(configure_options(['']), attr_list[6: 7])[0] - with pytest.raises(Exception) as e: - validate_delay(delay_configs['delay_config']) - assert str(e.value) == '(min)_delay' - - -# test validate_delay with a min_delay greater than delay - -def test_delay_min_delay_greater_than_delay_fail(configure_options): - delay_configs = config_factory(configure_options(['']), attr_list[5: 6])[0] - with pytest.raises(Exception) as e: - validate_delay(delay_configs['delay_config']) - assert str(e.value) == '(min)_delay' - - -# test validate_delay with a delay less than 10(the minimum) - -def test_delay_less_than_10_fail(configure_options): - delay_configs = config_factory(configure_options(['']), attr_list[7: 8])[0] - with pytest.raises(Exception) as e: - validate_delay(delay_configs['delay_config']) - assert str(e.value) == '(min)_delay' - - -# test validate_delay with the original configuration - -def test_delay_pass(configure_options): - validate_delay(configure_options([''])['delay_config']) - - -# test validate_delay with a max_listing_days value of -1 - -def test_delay_max_listing_days_fail(configure_options): - max_listing_days_config = config_factory( - configure_options(['']), attr_list[8: 9])[0] - with pytest.raises(Exception) as e: - validate_config(max_listing_days_config) - assert str(e.value) == 'max_listing_days' - - -# test the integration of all parts with the config as a whole - -@pytest.mark.parametrize('attribute', attr_list) -def test_config_fail(configure_options, attribute): - config = config_factory(configure_options(['']), [attribute])[0] - with pytest.raises(Exception): - validate_config(config) - - -def test_config_pass(configure_options): - validate_config(configure_options([''])) From d7f470317916905d8bbf02f9e9b4d6a4f37a756e Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 30 Aug 2020 12:38:52 -0400 Subject: [PATCH 44/66] Fix block list file naming + remove create_dirs call --- jobfunnel/backend/jobfunnel.py | 3 +-- jobfunnel/config/cli.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index e4465ff7..07ce1f46 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -43,8 +43,7 @@ def __init__(self, config: JobFunnelConfigManager) -> None: file_path=config.log_file, ) self.config = config - self.config.create_dirs() - self.config.validate() + self.config.validate() # NOTE: this ensures directories exist self.__date_string = date.today().strftime("%Y-%m-%d") self.master_jobs_dict = {} # type: Dict[str, Job] diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 41c64025..8a4bdd5c 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -338,7 +338,7 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: ) # Create folders that out output files are within if they don't exist - for path_attr in ['master_csv_file', 'user_block_list_file', + for path_attr in ['master_csv_file', 'block_list_file', 'cache_folder', 'duplicates_list_file']: output_dir = os.path.dirname(os.path.abspath(config[path_attr])) if not os.path.exists(output_dir): From f50f781f0214670d027db187ed834b3faeab07aa Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sun, 30 Aug 2020 12:55:52 -0400 Subject: [PATCH 45/66] Fix PyEnv --- Pipfile | 8 +- Pipfile.lock | 392 ++++++++++++++++++++++++++------------------------- 2 files changed, 201 insertions(+), 199 deletions(-) diff --git a/Pipfile b/Pipfile index 538eb902..d10db224 100644 --- a/Pipfile +++ b/Pipfile @@ -1,9 +1,7 @@ [[source]] -name = "pypi" -url = "https://pypi.org/simple" +url = "https://pypi.python.org/simple" verify_ssl = true - -[dev-packages] +name = "pypi" [packages] jobfunnel = {path = ".",editable = true} @@ -11,4 +9,4 @@ selenium = "*" webdriver_manager = "*" [requires] -python_version = "3.6" +python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index a17e661e..1c621aa8 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,16 +1,16 @@ { "_meta": { "hash": { - "sha256": "0840194ad12b002f72da2e91c7102bbd184cbf167cd2bda40f6de3db105d9928" + "sha256": "2d9f46efd3d0a0c36a84818426686d6ec5c83064736ecc25ca787d780eeb6317" }, "pipfile-spec": 6, "requires": { - "python_version": "3.6" + "python_version": "3.8" }, "sources": [ { "name": "pypi", - "url": "https://pypi.org/simple", + "url": "https://pypi.python.org/simple", "verify_ssl": true } ] @@ -18,25 +18,29 @@ "default": { "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a", + "sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff" ], - "version": "==19.3.0" + "version": "==20.1.0" }, "beautifulsoup4": { "hashes": [ - "sha256:594ca51a10d2b3443cbac41214e12dbb2a1cd57e1a7344659849e2e20ba6a8d8", - "sha256:a4bbe77fd30670455c5296242967a123ec28c37e9702a8a81bd2f20a4baf0368", - "sha256:d4e96ac9b0c3a6d3f0caae2e4124e6055c5dcafde8e2f831ff194c104f0775a0" + "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", + "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", + "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" ], - "version": "==4.9.0" + "version": "==4.9.1" + }, + "cdee1ca": { + "editable": true, + "path": "./JobFunnel" }, "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" ], - "version": "==2020.4.5.1" + "version": "==2020.6.20" }, "chardet": { "hashes": [ @@ -68,75 +72,74 @@ }, "crayons": { "hashes": [ - "sha256:50e5fa729d313e2c607ae8bf7b53bb487652e10bd8e7a1e08c4bc8bf62755ffc", - "sha256:8c9e4a3a607bc10e9a9140d496ecd16c6805088dd16c852c378f1f1d5db7aeb6" + "sha256:bd33b7547800f2cfbd26b38431f9e64b487a7de74a947b0fafc89b45a601813f", + "sha256:e73ad105c78935d71fe454dd4b85c5c437ba199294e7ffd3341842bc683654b1" ], - "version": "==0.3.0" + "version": "==0.4.0" }, "idna": { "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==2.9" + "version": "==2.10" }, - "importlib-metadata": { + "iniconfig": { "hashes": [ - "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", - "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" + "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", + "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" ], - "markers": "python_version < '3.8'", - "version": "==1.6.0" - }, - "jobfunnel": { - "editable": true, - "path": "." + "version": "==1.0.1" }, "joblib": { "hashes": [ - "sha256:0630eea4f5664c463f23fbf5dcfc54a2bc6168902719fa8e19daf033022786c8", - "sha256:bdb4fd9b72915ffb49fde2229ce482dd7ae79d842ed8c2b4c932441495af1403" + "sha256:8f52bf24c64b608bf0b2563e0e47d6fcf516abc8cfafe10cfd98ad66d94f92d6", + "sha256:d348c5d4ae31496b2aa060d6d9b787864dd204f9480baaa52d18850cb43e9f49" ], - "version": "==0.14.1" + "version": "==0.16.0" }, "lxml": { "hashes": [ - "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd", - "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c", - "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081", - "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f", - "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261", - "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a", - "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9", - "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a", - "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb", - "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60", - "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128", - "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a", - "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717", - "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89", - "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72", - "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8", - "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3", - "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7", - "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8", - "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77", - "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1", - "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15", - "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679", - "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012", - "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6", - "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc", - "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca" - ], - "version": "==4.5.0" + "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", + "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", + "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", + "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3", + "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", + "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", + "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", + "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", + "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", + "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", + "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", + "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", + "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", + "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293", + "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", + "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", + "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", + "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", + "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", + "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", + "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304", + "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", + "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1", + "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", + "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", + "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", + "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" + ], + "version": "==4.5.2" }, "more-itertools": { "hashes": [ - "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", - "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" + "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", + "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" ], - "version": "==8.2.0" + "version": "==8.5.0" }, "nltk": { "hashes": [ @@ -146,36 +149,41 @@ }, "numpy": { "hashes": [ - "sha256:00d7b54c025601e28f468953d065b9b121ddca7fff30bed7be082d3656dd798d", - "sha256:02ec9582808c4e48be4e93cd629c855e644882faf704bc2bd6bbf58c08a2a897", - "sha256:0e6f72f7bb08f2f350ed4408bb7acdc0daba637e73bce9f5ea2b207039f3af88", - "sha256:1be2e96314a66f5f1ce7764274327fd4fb9da58584eaff00b5a5221edefee7d6", - "sha256:2466fbcf23711ebc5daa61d28ced319a6159b260a18839993d871096d66b93f7", - "sha256:2b573fcf6f9863ce746e4ad00ac18a948978bb3781cffa4305134d31801f3e26", - "sha256:3f0dae97e1126f529ebb66f3c63514a0f72a177b90d56e4bce8a0b5def34627a", - "sha256:50fb72bcbc2cf11e066579cb53c4ca8ac0227abb512b6cbc1faa02d1595a2a5d", - "sha256:57aea170fb23b1fd54fa537359d90d383d9bf5937ee54ae8045a723caa5e0961", - "sha256:709c2999b6bd36cdaf85cf888d8512da7433529f14a3689d6e37ab5242e7add5", - "sha256:7d59f21e43bbfd9a10953a7e26b35b6849d888fc5a331fa84a2d9c37bd9fe2a2", - "sha256:904b513ab8fbcbdb062bed1ce2f794ab20208a1b01ce9bd90776c6c7e7257032", - "sha256:96dd36f5cdde152fd6977d1bbc0f0561bccffecfde63cd397c8e6033eb66baba", - "sha256:9933b81fecbe935e6a7dc89cbd2b99fea1bf362f2790daf9422a7bb1dc3c3085", - "sha256:bbcc85aaf4cd84ba057decaead058f43191cc0e30d6bc5d44fe336dc3d3f4509", - "sha256:dccd380d8e025c867ddcb2f84b439722cf1f23f3a319381eac45fd077dee7170", - "sha256:e22cd0f72fc931d6abc69dc7764484ee20c6a60b0d0fee9ce0426029b1c1bdae", - "sha256:ed722aefb0ebffd10b32e67f48e8ac4c5c4cf5d3a785024fdf0e9eb17529cd9d", - "sha256:efb7ac5572c9a57159cf92c508aad9f856f1cb8e8302d7fdb99061dbe52d712c", - "sha256:efdba339fffb0e80fcc19524e4fdbda2e2b5772ea46720c44eaac28096d60720", - "sha256:f22273dd6a403ed870207b853a856ff6327d5cbce7a835dfa0645b3fc00273ec" - ], - "version": "==1.18.4" + "sha256:082f8d4dd69b6b688f64f509b91d482362124986d98dc7dc5f5e9f9b9c3bb983", + "sha256:1bc0145999e8cb8aed9d4e65dd8b139adf1919e521177f198529687dbf613065", + "sha256:309cbcfaa103fc9a33ec16d2d62569d541b79f828c382556ff072442226d1968", + "sha256:3673c8b2b29077f1b7b3a848794f8e11f401ba0b71c49fbd26fb40b71788b132", + "sha256:480fdd4dbda4dd6b638d3863da3be82873bba6d32d1fc12ea1b8486ac7b8d129", + "sha256:56ef7f56470c24bb67fb43dae442e946a6ce172f97c69f8d067ff8550cf782ff", + "sha256:5a936fd51049541d86ccdeef2833cc89a18e4d3808fe58a8abeb802665c5af93", + "sha256:5b6885c12784a27e957294b60f97e8b5b4174c7504665333c5e94fbf41ae5d6a", + "sha256:667c07063940e934287993366ad5f56766bc009017b4a0fe91dbd07960d0aba7", + "sha256:7ed448ff4eaffeb01094959b19cbaf998ecdee9ef9932381420d514e446601cd", + "sha256:8343bf67c72e09cfabfab55ad4a43ce3f6bf6e6ced7acf70f45ded9ebb425055", + "sha256:92feb989b47f83ebef246adabc7ff3b9a59ac30601c3f6819f8913458610bdcc", + "sha256:935c27ae2760c21cd7354402546f6be21d3d0c806fffe967f745d5f2de5005a7", + "sha256:aaf42a04b472d12515debc621c31cf16c215e332242e7a9f56403d814c744624", + "sha256:b12e639378c741add21fbffd16ba5ad25c0a1a17cf2b6fe4288feeb65144f35b", + "sha256:b1cca51512299841bf69add3b75361779962f9cee7d9ee3bb446d5982e925b69", + "sha256:b8456987b637232602ceb4d663cb34106f7eb780e247d51a260b84760fd8f491", + "sha256:b9792b0ac0130b277536ab8944e7b754c69560dac0415dd4b2dbd16b902c8954", + "sha256:c9591886fc9cbe5532d5df85cb8e0cc3b44ba8ce4367bd4cf1b93dc19713da72", + "sha256:cf1347450c0b7644ea142712619533553f02ef23f92f781312f6a3553d031fc7", + "sha256:de8b4a9b56255797cbddb93281ed92acbc510fb7b15df3f01bd28f46ebc4edae", + "sha256:e1b1dc0372f530f26a03578ac75d5e51b3868b9b76cd2facba4c9ee0eb252ab1", + "sha256:e45f8e981a0ab47103181773cc0a54e650b2aef8c7b6cd07405d0fa8d869444a", + "sha256:e4f6d3c53911a9d103d8ec9518190e52a8b945bab021745af4939cfc7c0d4a9e", + "sha256:ed8a311493cf5480a2ebc597d1e177231984c818a86875126cfd004241a73c3e", + "sha256:ef71a1d4fd4858596ae80ad1ec76404ad29701f8ca7cdcebc50300178db14dfc" + ], + "version": "==1.19.1" }, "packaging": { "hashes": [ - "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", - "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], - "version": "==20.3" + "version": "==20.4" }, "pluggy": { "hashes": [ @@ -186,10 +194,10 @@ }, "py": { "hashes": [ - "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", - "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" ], - "version": "==1.8.1" + "version": "==1.9.0" }, "pyparsing": { "hashes": [ @@ -200,10 +208,17 @@ }, "pytest": { "hashes": [ - "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", - "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" + "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4", + "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad" + ], + "version": "==6.0.1" + }, + "pytest-mock": { + "hashes": [ + "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2", + "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82" ], - "version": "==5.4.1" + "version": "==3.3.1" }, "python-dateutil": { "hashes": [ @@ -230,145 +245,134 @@ }, "regex": { "hashes": [ - "sha256:021a0ae4d2baeeb60a3014805a2096cb329bd6d9f30669b7ad0da51a9cb73349", - "sha256:04d6e948ef34d3eac133bedc0098364a9e635a7914f050edb61272d2ddae3608", - "sha256:099568b372bda492be09c4f291b398475587d49937c659824f891182df728cdf", - "sha256:0ff50843535593ee93acab662663cb2f52af8e31c3f525f630f1dc6156247938", - "sha256:1b17bf37c2aefc4cac8436971fe6ee52542ae4225cfc7762017f7e97a63ca998", - "sha256:1e2255ae938a36e9bd7db3b93618796d90c07e5f64dd6a6750c55f51f8b76918", - "sha256:2bc6a17a7fa8afd33c02d51b6f417fc271538990297167f68a98cae1c9e5c945", - "sha256:3ab5e41c4ed7cd4fa426c50add2892eb0f04ae4e73162155cd668257d02259dd", - "sha256:3b059e2476b327b9794c792c855aa05531a3f3044737e455d283c7539bd7534d", - "sha256:4df91094ced6f53e71f695c909d9bad1cca8761d96fd9f23db12245b5521136e", - "sha256:5493a02c1882d2acaaf17be81a3b65408ff541c922bfd002535c5f148aa29f74", - "sha256:5b741ecc3ad3e463d2ba32dce512b412c319993c1bb3d999be49e6092a769fb2", - "sha256:652ab4836cd5531d64a34403c00ada4077bb91112e8bcdae933e2eae232cf4a8", - "sha256:669a8d46764a09f198f2e91fc0d5acdac8e6b620376757a04682846ae28879c4", - "sha256:73a10404867b835f1b8a64253e4621908f0d71150eb4e97ab2e7e441b53e9451", - "sha256:7ce4a213a96d6c25eeae2f7d60d4dad89ac2b8134ec3e69db9bc522e2c0f9388", - "sha256:8127ca2bf9539d6a64d03686fd9e789e8c194fc19af49b69b081f8c7e6ecb1bc", - "sha256:b5b5b2e95f761a88d4c93691716ce01dc55f288a153face1654f868a8034f494", - "sha256:b7c9f65524ff06bf70c945cd8d8d1fd90853e27ccf86026af2afb4d9a63d06b1", - "sha256:f7f2f4226db6acd1da228adf433c5c3792858474e49d80668ea82ac87cf74a03", - "sha256:fa09da4af4e5b15c0e8b4986a083f3fd159302ea115a6cc0649cd163435538b8" - ], - "version": "==2020.5.7" + "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204", + "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162", + "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f", + "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb", + "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6", + "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7", + "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88", + "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99", + "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644", + "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a", + "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840", + "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067", + "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd", + "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4", + "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e", + "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89", + "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e", + "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc", + "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf", + "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341", + "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7" + ], + "version": "==2020.7.14" }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], - "version": "==2.23.0" + "version": "==2.24.0" }, "scikit-learn": { "hashes": [ - "sha256:1bf45e62799b6938357cfce19f72e3751448c4b27010e4f98553da669b5bbd86", - "sha256:267ad874b54c67b479c3b45eb132ef4a56ab2b27963410624a413a4e2a3fc388", - "sha256:2d1bb83d6c51a81193d8a6b5f31930e2959c0e1019d49bdd03f54163735dae4b", - "sha256:349ba3d837fb3f7cb2b91486c43713e4b7de17f9e852f165049b1b7ac2f81478", - "sha256:3f4d8eea3531d3eaf613fa33f711113dfff6021d57a49c9d319af4afb46f72f0", - "sha256:4990f0e166292d2a0f0ee528233723bcfd238bfdb3ec2512a9e27f5695362f35", - "sha256:57538d138ba54407d21e27c306735cbd42a6aae0df6a5a30c7a6edde46b0017d", - "sha256:5b722e8bb708f254af028dc2da86d23df5371cba57e24f889b672e7b15423caa", - "sha256:6043e2c4ccfc68328c331b0fc19691be8fb02bd76d694704843a23ad651de902", - "sha256:672ea38eb59b739a8907ec063642b486bcb5a2073dda5b72b7983eeaf1fd67c1", - "sha256:73207dca6e70f8f611f28add185cf3a793c8232a1722f21d82259560dc35cd50", - "sha256:83fc104a799cb340054e485c25dfeee712b36f5638fb374eba45a9db490f16ff", - "sha256:8416150ab505f1813da02cdbdd9f367b05bfc75cf251235015bb09f8674358a0", - "sha256:84e759a766c315deb5c85139ff879edbb0aabcddb9358acf499564ed1c21e337", - "sha256:8ed66ab27b3d68e57bb1f315fc35e595a5c4a1f108c3420943de4d18fc40e615", - "sha256:a7f8aa93f61aaad080b29a9018db93ded0586692c03ddf2122e47dd1d3a14e1b", - "sha256:ddd3bf82977908ff69303115dd5697606e669d8a7eafd7d83bb153ef9e11bd5e", - "sha256:de9933297f8659ee3bb330eafdd80d74cd73d5dab39a9026b65a4156bc479063", - "sha256:ea91a70a992ada395efc3d510cf011dc2d99dc9037bb38cd1cb00e14745005f5", - "sha256:eb4c9f0019abb374a2e55150f070a333c8f990b850d1eb4dfc2765fc317ffc7c", - "sha256:ffce8abfdcd459e72e5b91727b247b401b22253cbd18d251f842a60e26262d6f" - ], - "version": "==0.22.2.post1" + "sha256:0a127cc70990d4c15b1019680bfedc7fec6c23d14d3719fdf9b64b22d37cdeca", + "sha256:0d39748e7c9669ba648acf40fb3ce96b8a07b240db6888563a7cb76e05e0d9cc", + "sha256:1b8a391de95f6285a2f9adffb7db0892718950954b7149a70c783dc848f104ea", + "sha256:20766f515e6cd6f954554387dfae705d93c7b544ec0e6c6a5d8e006f6f7ef480", + "sha256:2aa95c2f17d2f80534156215c87bee72b6aa314a7f8b8fe92a2d71f47280570d", + "sha256:5ce7a8021c9defc2b75620571b350acc4a7d9763c25b7593621ef50f3bd019a2", + "sha256:6c28a1d00aae7c3c9568f61aafeaad813f0f01c729bee4fd9479e2132b215c1d", + "sha256:7671bbeddd7f4f9a6968f3b5442dac5f22bf1ba06709ef888cc9132ad354a9ab", + "sha256:914ac2b45a058d3f1338d7736200f7f3b094857758895f8667be8a81ff443b5b", + "sha256:98508723f44c61896a4e15894b2016762a55555fbf09365a0bb1870ecbd442de", + "sha256:a64817b050efd50f9abcfd311870073e500ae11b299683a519fbb52d85e08d25", + "sha256:cb3e76380312e1f86abd20340ab1d5b3cc46a26f6593d3c33c9ea3e4c7134028", + "sha256:d0dcaa54263307075cb93d0bee3ceb02821093b1b3d25f66021987d305d01dce", + "sha256:d9a1ce5f099f29c7c33181cc4386660e0ba891b21a60dc036bf369e3a3ee3aec", + "sha256:da8e7c302003dd765d92a5616678e591f347460ac7b53e53d667be7dfe6d1b10", + "sha256:daf276c465c38ef736a79bd79fc80a249f746bcbcae50c40945428f7ece074f8" + ], + "version": "==0.23.2" }, "scipy": { "hashes": [ - "sha256:00af72998a46c25bdb5824d2b729e7dabec0c765f9deb0b504f928591f5ff9d4", - "sha256:0902a620a381f101e184a958459b36d3ee50f5effd186db76e131cbefcbb96f7", - "sha256:1e3190466d669d658233e8a583b854f6386dd62d655539b77b3fa25bfb2abb70", - "sha256:2cce3f9847a1a51019e8c5b47620da93950e58ebc611f13e0d11f4980ca5fecb", - "sha256:3092857f36b690a321a662fe5496cb816a7f4eecd875e1d36793d92d3f884073", - "sha256:386086e2972ed2db17cebf88610aab7d7f6e2c0ca30042dc9a89cf18dcc363fa", - "sha256:71eb180f22c49066f25d6df16f8709f215723317cc951d99e54dc88020ea57be", - "sha256:770254a280d741dd3436919d47e35712fb081a6ff8bafc0f319382b954b77802", - "sha256:787cc50cab3020a865640aba3485e9fbd161d4d3b0d03a967df1a2881320512d", - "sha256:8a07760d5c7f3a92e440ad3aedcc98891e915ce857664282ae3c0220f3301eb6", - "sha256:8d3bc3993b8e4be7eade6dcc6fd59a412d96d3a33fa42b0fa45dc9e24495ede9", - "sha256:9508a7c628a165c2c835f2497837bf6ac80eb25291055f56c129df3c943cbaf8", - "sha256:a144811318853a23d32a07bc7fd5561ff0cac5da643d96ed94a4ffe967d89672", - "sha256:a1aae70d52d0b074d8121333bc807a485f9f1e6a69742010b33780df2e60cfe0", - "sha256:a2d6df9eb074af7f08866598e4ef068a2b310d98f87dc23bd1b90ec7bdcec802", - "sha256:bb517872058a1f087c4528e7429b4a44533a902644987e7b2fe35ecc223bc408", - "sha256:c5cac0c0387272ee0e789e94a570ac51deb01c796b37fb2aad1fb13f85e2f97d", - "sha256:cc971a82ea1170e677443108703a2ec9ff0f70752258d0e9f5433d00dda01f59", - "sha256:dba8306f6da99e37ea08c08fef6e274b5bf8567bb094d1dbe86a20e532aca088", - "sha256:dc60bb302f48acf6da8ca4444cfa17d52c63c5415302a9ee77b3b21618090521", - "sha256:dee1bbf3a6c8f73b6b218cb28eed8dd13347ea2f87d572ce19b289d6fd3fbc59" - ], - "version": "==1.4.1" + "sha256:066c513d90eb3fd7567a9e150828d39111ebd88d3e924cdfc9f8ce19ab6f90c9", + "sha256:07e52b316b40a4f001667d1ad4eb5f2318738de34597bd91537851365b6c61f1", + "sha256:0a0e9a4e58a4734c2eba917f834b25b7e3b6dc333901ce7784fd31aefbd37b2f", + "sha256:1c7564a4810c1cd77fcdee7fa726d7d39d4e2695ad252d7c86c3ea9d85b7fb8f", + "sha256:315aa2165aca31375f4e26c230188db192ed901761390be908c9b21d8b07df62", + "sha256:6e86c873fe1335d88b7a4bfa09d021f27a9e753758fd75f3f92d714aa4093768", + "sha256:8e28e74b97fc8d6aa0454989db3b5d36fc27e69cef39a7ee5eaf8174ca1123cb", + "sha256:92eb04041d371fea828858e4fff182453c25ae3eaa8782d9b6c32b25857d23bc", + "sha256:a0afbb967fd2c98efad5f4c24439a640d39463282040a88e8e928db647d8ac3d", + "sha256:a785409c0fa51764766840185a34f96a0a93527a0ff0230484d33a8ed085c8f8", + "sha256:cca9fce15109a36a0a9f9cfc64f870f1c140cb235ddf27fe0328e6afb44dfed0", + "sha256:d56b10d8ed72ec1be76bf10508446df60954f08a41c2d40778bc29a3a9ad9bce", + "sha256:dac09281a0eacd59974e24525a3bc90fa39b4e95177e638a31b14db60d3fa806", + "sha256:ec5fe57e46828d034775b00cd625c4a7b5c7d2e354c3b258d820c6c72212a6ec", + "sha256:eecf40fa87eeda53e8e11d265ff2254729d04000cd40bae648e76ff268885d66", + "sha256:fc98f3eac993b9bfdd392e675dfe19850cc8c7246a8fd2b42443e506344be7d9" + ], + "version": "==1.5.2" }, "selenium": { "hashes": [ "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d" ], - "index": "pypi", "version": "==3.141.0" }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.14.0" + "version": "==1.15.0" }, "soupsieve": { "hashes": [ - "sha256:e914534802d7ffd233242b785229d5ba0766a7f487385e3f714446a07bf540ae", - "sha256:fcd71e08c0aee99aca1b73f45478549ee7e7fc006d51b37bec9e9def7dc22b69" + "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", + "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], - "version": "==2.0" + "version": "==2.0.1" }, - "tqdm": { + "threadpoolctl": { "hashes": [ - "sha256:4733c4a10d0f2a4d098d801464bdaf5240c7dadd2a7fde4ee93b0a0efd9fb25e", - "sha256:acdafb20f51637ca3954150d0405ff1a7edde0ff19e38fb99a80a66210d2a28f" + "sha256:38b74ca20ff3bb42caca8b00055111d74159ee95c4370882bbff2b93d24da725", + "sha256:ddc57c96a38beb63db45d6c159b5ab07b6bced12c45a1f07b2b92f272aebfa6b" ], - "version": "==4.46.0" + "version": "==2.1.0" }, - "urllib3": { + "toml": { "hashes": [ - "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", - "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", + "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" ], - "version": "==1.25.9" + "version": "==0.10.1" }, - "wcwidth": { + "tqdm": { "hashes": [ - "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", - "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" + "sha256:1a336d2b829be50e46b84668691e0a2719f26c97c62846298dd5ae2937e4d5cf", + "sha256:564d632ea2b9cb52979f7956e093e831c28d441c11751682f84c86fc46e4fd21" ], - "version": "==0.1.9" + "version": "==4.48.2" }, - "webdriver-manager": { + "urllib3": { "hashes": [ - "sha256:87f3f4bfda4917fa0ef1387fd1ddbbb5738e6961eb846434895801167771f652" + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" ], - "index": "pypi", - "version": "==2.4.0" + "version": "==1.25.10" }, - "zipp": { + "webdriver-manager": { "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" + "sha256:18a665c6400bb7cf1a9ec9e1030ac5539cd5c892c97075f58940c62971470ce3", + "sha256:c2d4ee0a78226c355f3657dd0337e515187585a1497229af2ce5f4705234da9c" ], - "version": "==3.1.0" + "version": "==3.2.2" } }, "develop": {} From 0d6a31cfecb5ed9448d31625d1869df61afd5249 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 31 Aug 2020 09:48:39 -0400 Subject: [PATCH 46/66] Sig. improved CLI vs YAML vs defaults handling, upped python requires to 3.8, made duplicates list file mandatory. --- MANIFEST.in | 2 + demo/settings.yaml | 4 - jobfunnel/config/cli.py | 161 +++++++++++++++++--------------- jobfunnel/config/settings.py | 23 ++--- jobfunnel/resources/defaults.py | 1 - setup.py | 4 +- 6 files changed, 99 insertions(+), 96 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index e4bbfb26..4a09a320 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ include jobfunnel/demo/settings.yaml include jobfunnel/resources/user_agent_list.txt +include readme.md +include LICENSE diff --git a/demo/settings.yaml b/demo/settings.yaml index 7598fb7a..993f9712 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -44,10 +44,6 @@ search: # Logging level options are: critical, error, warning, info, debug, notset log_level: INFO -# Saves duplicates removed by tfidf filter to duplicate_list.csv -# TODO: document why this should be done. -save_duplicates: False - # Delaying algorithm configuration delay: # Functions used for delaying algorithm: CONSTANT, LINEAR, SIGMOID diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 8a4bdd5c..1bf24b8f 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -75,12 +75,20 @@ def parse_cli(): f'Defaults to: {DEFAULT_BLOCK_LIST_FILE}' ) + parser.add_argument( + '-dl', + dest='duplicates_list_file', + nargs='*', + help='JSON file of jobs which have been detected to be duplicates of ' + 'existing jobs (usually this is in the output of previous ' + f'jobfunnel results). Defaults to: {DEFAULT_DUPLICATES_FILE}' + ) + parser.add_argument( '-lf', dest='log_file', type=str, - default=DEFAULT_LOG_FILE, - help='path to logging file.' + help=f'path to logging file. defaults to {DEFAULT_LOG_FILE}' ) parser.add_argument( @@ -91,15 +99,6 @@ def parse_cli(): help='Type of logging information shown on the terminal.' ) - parser.add_argument( - '-dl', - dest='duplicates_list_file', - nargs='*', - help='JSON file of jobs which have been detected to be duplicates of ' - 'existing jobs (usually this is in the output of previous ' - f'jobfunnel results). Defaults to: {DEFAULT_DUPLICATES_FILE}' - ) - parser.add_argument( '-cbl', dest='search_company_block_list', @@ -184,12 +183,6 @@ def parse_cli(): 'state.' ) - parser.add_argument( - '--save-duplicates', - action='store_true', - help='Save duplicate job key_ids into file.' - ) - parser.add_argument( '--no-scrape', action='store_true', @@ -261,85 +254,103 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: args [argparse.Namespace]: cli arguments from argparser """ + # NOTE: log_file and output_folder are specially handled + path_attrs = [ + 'master_csv_file', 'cache_folder', + 'block_list_file', 'duplicates_list_file', + ] # Init and pop args that are cli-only and not in our schema args_dict = vars(args) settings_yaml_file = args_dict.pop('settings_yaml_file') output_folder = args_dict.pop('output_folder') args_dict.pop('do_recovery_mode') # NOTE: this is handled in __main__ - - # Load config dict from the YAML if passed config = {'search': {}, 'delay': {}, 'proxy': {}} + + # Build a config that respects CLI, defaults and YAML if settings_yaml_file: + + # Ensure user isn't pasing output_folder as this cannot be used here + if output_folder != DEFAULT_OUTPUT_DIRECTORY: + raise ValueError( + "Cannot combine -s YAML and -o argument, all file paths must " + "be specified individually." + ) + + # Load YAML config.update( yaml.load(open(settings_yaml_file, 'r'), Loader=yaml.FullLoader) ) + # Set defaults for our YAML + config = SettingsValidator.normalized(config) - # TODO: need a way to handle injecting defaults with YAML but also - # without an incomplete set of arguments here. Cerberus should do this - # if we further break down config into sub-configs. - missing_attrs = [] # type: List[str] - for attr in ['master_csv_file', 'block_list_file', 'cache_folder', - 'duplicates_list_file']: - if attr not in config: - missing_attrs.append(attr) - if missing_attrs: + # Validate the config passed via YAML + if not SettingsValidator.validate(config): raise ValueError( - f"Passed YAML {settings_yaml_file} fields are missing or " - f"invalid: {missing_attrs}" + f"Invalid Config settings yaml:\n{SettingsValidator.errors}" ) - # Handle output_folder argument which is a shortcut to specifying all paths - user_passed_paths = bool( - ( - args_dict['master_csv_file'] and args_dict['block_list_file'] and - args_dict['duplicates_list_file'] and args_dict['cache_folder'] - ) or ( - config.get('master_csv_file') and config.get('block_list_file') and - config.get('duplicates_list_file') and config.get('cache_folder') - ) - ) - if output_folder == DEFAULT_OUTPUT_DIRECTORY and not user_passed_paths: - - # We are using all defaults only (no -s or paths passed) - config['master_csv_file'] = DEFAULT_MASTER_CSV_FILE - config['block_list_file'] = DEFAULT_BLOCK_LIST_FILE - config['duplicates_list_file'] = DEFAULT_DUPLICATES_FILE - config['cache_folder'] = DEFAULT_CACHE_DIRECTORY - - elif output_folder != DEFAULT_OUTPUT_DIRECTORY and user_passed_paths: - - # User cannot specify both output folder and other paths - raise ValueError( - "When providing output_folder, do not also provide -csv, -blf" - ", -dlf, -cache or -s, as paths are defined by the output folder." - " If specifying file paths you must pass all the arguments and " - "not pass -o." - ) + # Handle CLI arguments, overwriting YAML if needed + if output_folder: + if (output_folder != DEFAULT_OUTPUT_DIRECTORY + and (args_dict['master_csv_file'] or args_dict['cache_folder'] + or args_dict['block_list_file'] + or args_dict['duplicates_list_file'])): + # NOTE: we handle the -s with -o case before we get here + raise ValueError( + "Cannot combine -o with -blf, -cache, -dlf arguments, as -o" + " defines these paths." + ) + else: + # Set paths based on passed output_folder: + # NOTE: these will match defaults if using DEFAULT_OUTPUT_PATH + config['master_csv_file'] = os.path.join( + output_folder, 'master.csv' + ) + config['cache_folder'] = os.path.join( + output_folder, '.cache' + ) + config['block_list_file'] = os.path.join( + config['cache_folder'], 'block.json' + ) + config['duplicates_list_file'] = os.path.join( + config['cache_folder'], 'duplicates.json' + ) + if not args_dict['log_file']: + # User can specify a different log location if they want. + config['log_file'] = os.path.join( + output_folder, 'log.log' + ) + else: + # We should have all the paths we need + for path_arg in path_attrs: + config[path_arg] = args_dict[path_arg] + + if not args_dict['log_file']: + # We will define log to be where the csv is. + config['log_file'] = os.path.join( + os.path.dirname(os.path.abspath(config['cache_folder'])), + 'log.log', + ) - # Inject any cli, non-default attributes (excluding paths) - for key, value in args_dict.items(): - if value is not None: - if key in SETTINGS_YAML_SCHEMA: - config[key] = value + # Turn args_dict into config dict by nesting as-needed + for key, arg_value in args_dict.items(): + if arg_value is not None: + if key == 'log_level' and arg_value != DEFAULT_LOG_LEVEL_NAME: + # We got a non-default log level, overwrite any YAML setting + config[key] = arg_value + elif key == 'no_scrape': + # Default is False. + config[key] = arg_value else: + # Set sub-config value for sub_key in ['search', 'delay', 'proxy']: if sub_key in key: - config[sub_key][key.split(sub_key + '_')[1]] = value + config[sub_key][key.split(sub_key + '_')[1]] = arg_value break - # Set any defaults in our schema - config = SettingsValidator.normalized(config) - - # Validate the config we have built - if not SettingsValidator.validate(config): - # TODO: some way to print allowed values in error msg? - raise ValueError( - f"Invalid Config settings yaml:\n{SettingsValidator.errors}" - ) - # Create folders that out output files are within if they don't exist - for path_attr in ['master_csv_file', 'block_list_file', - 'cache_folder', 'duplicates_list_file']: + path_attrs.append('log_file') + for path_attr in path_attrs: output_dir = os.path.dirname(os.path.abspath(config[path_attr])) if not os.path.exists(output_dir): os.makedirs(output_dir) diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index ada29a7c..21930736 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -41,11 +41,6 @@ 'type': 'string', 'default': DEFAULT_LOG_FILE, }, - 'save_duplicates': { - 'required': False, - 'type': 'boolean', - 'default': DEFAULT_SAVE_DUPLICATES, - }, 'search': { 'type': 'dict', 'required': True, @@ -99,26 +94,26 @@ 'required': False, 'allowed': [d.name for d in DelayAlgorithm], 'default': DEFAULT_DELAY_ALGORITHM.name, - }, + }, # TODO: implement custom rule max > min 'max_duration': { 'required': False, 'type': 'float', 'min': 0, 'default': DEFAULT_DELAY_MAX_DURATION, - }, + }, 'min_duration': { 'required': False, 'type': 'float', 'min': 0, 'default': DEFAULT_DELAY_MIN_DURATION, - }, - 'random': { + }, + 'random': { 'required': False, 'type': 'boolean', 'default': DEFAULT_RANDOM_DELAY, - }, - 'converging': { + }, + 'converging': { 'required': False, 'type': 'boolean', 'default': DEFAULT_RANDOM_CONVERGING_DELAY, @@ -132,16 +127,16 @@ 'protocol': { 'required': False, 'allowed': ['http', 'https'], - }, + }, 'ip': { 'required': False, 'type': 'ipv4address', - }, + }, 'port': { 'required': False, 'type': 'integer', 'min': 0, - }, + }, }, }, } diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index f69dc7ac..768febcf 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -2,7 +2,6 @@ NOTE: we include defaults for all arguments so that JobFunnel is plug-n-play """ import os -import logging from pathlib import Path from jobfunnel.resources.enums import Locale, DelayAlgorithm, Provider diff --git a/setup.py b/setup.py index 71e2d641..91f0fbbe 100644 --- a/setup.py +++ b/setup.py @@ -38,9 +38,9 @@ author_email='paulmcinnis99@gmail.com', url=url, license='MIT License', - python_requires='>=3.6.0', + python_requires='>=3.8.0', install_requires=requires, - packages=find_packages(exclude=('demo', 'tests')), + packages=find_packages(exclude=('demo', 'tests', 'docs', 'images')), include_package_data=True, entry_points={'console_scripts': ['funnel = jobfunnel.__main__:main']} ) From 408491d443b2fdc9c4d8060375e1a0b789a13f2e Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 31 Aug 2020 09:50:57 -0400 Subject: [PATCH 47/66] min_delay -> min_duration --- jobfunnel/backend/tools/delay.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 11998913..911cd543 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -105,7 +105,8 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: else: # lb = lower bounds, delay_vals = upper bound durations = [ - round(uniform(delay_config.min_delay, x), 3) for x in delay_vals + round(uniform(delay_config.min_duration, x), 3) + for x in delay_vals ] else: From f03a6e93ebfe83e3914ee54b8423de933f6c8abb Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 31 Aug 2020 18:07:56 -0400 Subject: [PATCH 48/66] Swap over to pip from pipenv to fix build --- .travis.yml | 9 +- Pipfile | 4 +- Pipfile.lock | 379 --------------------------------------------------- 3 files changed, 8 insertions(+), 384 deletions(-) delete mode 100644 Pipfile.lock diff --git a/.travis.yml b/.travis.yml index fa2d67cd..ce6b8ee6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,14 @@ language: python python: - - '3.6.9' + - '3.8.0' install: + - 'pip install -e .' - 'pip install flake8 pipenv pytest-cov pytest-mock' - - 'pipenv sync' - 'python -m nltk.downloader stopwords' -before_script: 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' +before_script: + - 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' script: - - 'python -m jobfunnel -s demo/settings.yaml' + - 'funnel -s demo/settings.yaml' - 'pytest --cov=jobfunnel --cov-report=xml' after_success: - 'bash <(curl -s https://codecov.io/bash)' diff --git a/Pipfile b/Pipfile index d10db224..df669fd6 100644 --- a/Pipfile +++ b/Pipfile @@ -3,8 +3,10 @@ url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" +[dev-packages] + [packages] -jobfunnel = {path = ".",editable = true} +jobfunnel = {path = ".", editable = true} selenium = "*" webdriver_manager = "*" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 1c621aa8..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,379 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "2d9f46efd3d0a0c36a84818426686d6ec5c83064736ecc25ca787d780eeb6317" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.python.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "attrs": { - "hashes": [ - "sha256:0ef97238856430dcf9228e07f316aefc17e8939fc8507e18c6501b761ef1a42a", - "sha256:2867b7b9f8326499ab5b0e2d12801fa5c98842d2cbd22b35112ae04bf85b4dff" - ], - "version": "==20.1.0" - }, - "beautifulsoup4": { - "hashes": [ - "sha256:73cc4d115b96f79c7d77c1c7f7a0a8d4c57860d1041df407dd1aae7f07a77fd7", - "sha256:a6237df3c32ccfaee4fd201c8f5f9d9df619b93121d01353a64a73ce8c6ef9a8", - "sha256:e718f2342e2e099b640a34ab782407b7b676f47ee272d6739e60b8ea23829f2c" - ], - "version": "==4.9.1" - }, - "cdee1ca": { - "editable": true, - "path": "./JobFunnel" - }, - "certifi": { - "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" - ], - "version": "==2020.6.20" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "version": "==7.1.2" - }, - "colorama": { - "hashes": [ - "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", - "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" - ], - "version": "==0.4.3" - }, - "configparser": { - "hashes": [ - "sha256:2ca44140ee259b5e3d8aaf47c79c36a7ab0d5e94d70bd4105c03ede7a20ea5a1", - "sha256:cffc044844040c7ce04e9acd1838b5f2e5fa3170182f6fda4d2ea8b0099dbadd" - ], - "version": "==5.0.0" - }, - "crayons": { - "hashes": [ - "sha256:bd33b7547800f2cfbd26b38431f9e64b487a7de74a947b0fafc89b45a601813f", - "sha256:e73ad105c78935d71fe454dd4b85c5c437ba199294e7ffd3341842bc683654b1" - ], - "version": "==0.4.0" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "version": "==2.10" - }, - "iniconfig": { - "hashes": [ - "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437", - "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69" - ], - "version": "==1.0.1" - }, - "joblib": { - "hashes": [ - "sha256:8f52bf24c64b608bf0b2563e0e47d6fcf516abc8cfafe10cfd98ad66d94f92d6", - "sha256:d348c5d4ae31496b2aa060d6d9b787864dd204f9480baaa52d18850cb43e9f49" - ], - "version": "==0.16.0" - }, - "lxml": { - "hashes": [ - "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", - "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", - "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", - "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", - "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3", - "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", - "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", - "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", - "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", - "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", - "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", - "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", - "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", - "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", - "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", - "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293", - "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", - "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", - "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", - "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", - "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", - "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", - "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", - "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304", - "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", - "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", - "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1", - "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", - "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", - "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", - "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" - ], - "version": "==4.5.2" - }, - "more-itertools": { - "hashes": [ - "sha256:6f83822ae94818eae2612063a5101a7311e68ae8002005b5e05f03fd74a86a20", - "sha256:9b30f12df9393f0d28af9210ff8efe48d10c94f73e5daf886f10c4b0b0b4f03c" - ], - "version": "==8.5.0" - }, - "nltk": { - "hashes": [ - "sha256:845365449cd8c5f9731f7cb9f8bd6fd0767553b9d53af9eb1b3abf7700936b35" - ], - "version": "==3.5" - }, - "numpy": { - "hashes": [ - "sha256:082f8d4dd69b6b688f64f509b91d482362124986d98dc7dc5f5e9f9b9c3bb983", - "sha256:1bc0145999e8cb8aed9d4e65dd8b139adf1919e521177f198529687dbf613065", - "sha256:309cbcfaa103fc9a33ec16d2d62569d541b79f828c382556ff072442226d1968", - "sha256:3673c8b2b29077f1b7b3a848794f8e11f401ba0b71c49fbd26fb40b71788b132", - "sha256:480fdd4dbda4dd6b638d3863da3be82873bba6d32d1fc12ea1b8486ac7b8d129", - "sha256:56ef7f56470c24bb67fb43dae442e946a6ce172f97c69f8d067ff8550cf782ff", - "sha256:5a936fd51049541d86ccdeef2833cc89a18e4d3808fe58a8abeb802665c5af93", - "sha256:5b6885c12784a27e957294b60f97e8b5b4174c7504665333c5e94fbf41ae5d6a", - "sha256:667c07063940e934287993366ad5f56766bc009017b4a0fe91dbd07960d0aba7", - "sha256:7ed448ff4eaffeb01094959b19cbaf998ecdee9ef9932381420d514e446601cd", - "sha256:8343bf67c72e09cfabfab55ad4a43ce3f6bf6e6ced7acf70f45ded9ebb425055", - "sha256:92feb989b47f83ebef246adabc7ff3b9a59ac30601c3f6819f8913458610bdcc", - "sha256:935c27ae2760c21cd7354402546f6be21d3d0c806fffe967f745d5f2de5005a7", - "sha256:aaf42a04b472d12515debc621c31cf16c215e332242e7a9f56403d814c744624", - "sha256:b12e639378c741add21fbffd16ba5ad25c0a1a17cf2b6fe4288feeb65144f35b", - "sha256:b1cca51512299841bf69add3b75361779962f9cee7d9ee3bb446d5982e925b69", - "sha256:b8456987b637232602ceb4d663cb34106f7eb780e247d51a260b84760fd8f491", - "sha256:b9792b0ac0130b277536ab8944e7b754c69560dac0415dd4b2dbd16b902c8954", - "sha256:c9591886fc9cbe5532d5df85cb8e0cc3b44ba8ce4367bd4cf1b93dc19713da72", - "sha256:cf1347450c0b7644ea142712619533553f02ef23f92f781312f6a3553d031fc7", - "sha256:de8b4a9b56255797cbddb93281ed92acbc510fb7b15df3f01bd28f46ebc4edae", - "sha256:e1b1dc0372f530f26a03578ac75d5e51b3868b9b76cd2facba4c9ee0eb252ab1", - "sha256:e45f8e981a0ab47103181773cc0a54e650b2aef8c7b6cd07405d0fa8d869444a", - "sha256:e4f6d3c53911a9d103d8ec9518190e52a8b945bab021745af4939cfc7c0d4a9e", - "sha256:ed8a311493cf5480a2ebc597d1e177231984c818a86875126cfd004241a73c3e", - "sha256:ef71a1d4fd4858596ae80ad1ec76404ad29701f8ca7cdcebc50300178db14dfc" - ], - "version": "==1.19.1" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "version": "==20.4" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" - ], - "version": "==1.9.0" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "version": "==2.4.7" - }, - "pytest": { - "hashes": [ - "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4", - "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad" - ], - "version": "==6.0.1" - }, - "pytest-mock": { - "hashes": [ - "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2", - "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82" - ], - "version": "==3.3.1" - }, - "python-dateutil": { - "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" - ], - "version": "==2.8.1" - }, - "pyyaml": { - "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" - ], - "version": "==5.3.1" - }, - "regex": { - "hashes": [ - "sha256:0dc64ee3f33cd7899f79a8d788abfbec168410be356ed9bd30bbd3f0a23a7204", - "sha256:1269fef3167bb52631ad4fa7dd27bf635d5a0790b8e6222065d42e91bede4162", - "sha256:14a53646369157baa0499513f96091eb70382eb50b2c82393d17d7ec81b7b85f", - "sha256:3a3af27a8d23143c49a3420efe5b3f8cf1a48c6fc8bc6856b03f638abc1833bb", - "sha256:46bac5ca10fb748d6c55843a931855e2727a7a22584f302dd9bb1506e69f83f6", - "sha256:4c037fd14c5f4e308b8370b447b469ca10e69427966527edcab07f52d88388f7", - "sha256:51178c738d559a2d1071ce0b0f56e57eb315bcf8f7d4cf127674b533e3101f88", - "sha256:5ea81ea3dbd6767873c611687141ec7b06ed8bab43f68fad5b7be184a920dc99", - "sha256:6961548bba529cac7c07af2fd4d527c5b91bb8fe18995fed6044ac22b3d14644", - "sha256:75aaa27aa521a182824d89e5ab0a1d16ca207318a6b65042b046053cfc8ed07a", - "sha256:7a2dd66d2d4df34fa82c9dc85657c5e019b87932019947faece7983f2089a840", - "sha256:8a51f2c6d1f884e98846a0a9021ff6861bdb98457879f412fdc2b42d14494067", - "sha256:9c568495e35599625f7b999774e29e8d6b01a6fb684d77dee1f56d41b11b40cd", - "sha256:9eddaafb3c48e0900690c1727fba226c4804b8e6127ea409689c3bb492d06de4", - "sha256:bbb332d45b32df41200380fff14712cb6093b61bd142272a10b16778c418e98e", - "sha256:bc3d98f621898b4a9bc7fecc00513eec8f40b5b83913d74ccb445f037d58cd89", - "sha256:c11d6033115dc4887c456565303f540c44197f4fc1a2bfb192224a301534888e", - "sha256:c50a724d136ec10d920661f1442e4a8b010a4fe5aebd65e0c2241ea41dbe93dc", - "sha256:d0a5095d52b90ff38592bbdc2644f17c6d495762edf47d876049cfd2968fbccf", - "sha256:d6cff2276e502b86a25fd10c2a96973fdb45c7a977dca2138d661417f3728341", - "sha256:e46d13f38cfcbb79bfdb2964b0fe12561fe633caf964a77a5f8d4e45fe5d2ef7" - ], - "version": "==2020.7.14" - }, - "requests": { - "hashes": [ - "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", - "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" - ], - "version": "==2.24.0" - }, - "scikit-learn": { - "hashes": [ - "sha256:0a127cc70990d4c15b1019680bfedc7fec6c23d14d3719fdf9b64b22d37cdeca", - "sha256:0d39748e7c9669ba648acf40fb3ce96b8a07b240db6888563a7cb76e05e0d9cc", - "sha256:1b8a391de95f6285a2f9adffb7db0892718950954b7149a70c783dc848f104ea", - "sha256:20766f515e6cd6f954554387dfae705d93c7b544ec0e6c6a5d8e006f6f7ef480", - "sha256:2aa95c2f17d2f80534156215c87bee72b6aa314a7f8b8fe92a2d71f47280570d", - "sha256:5ce7a8021c9defc2b75620571b350acc4a7d9763c25b7593621ef50f3bd019a2", - "sha256:6c28a1d00aae7c3c9568f61aafeaad813f0f01c729bee4fd9479e2132b215c1d", - "sha256:7671bbeddd7f4f9a6968f3b5442dac5f22bf1ba06709ef888cc9132ad354a9ab", - "sha256:914ac2b45a058d3f1338d7736200f7f3b094857758895f8667be8a81ff443b5b", - "sha256:98508723f44c61896a4e15894b2016762a55555fbf09365a0bb1870ecbd442de", - "sha256:a64817b050efd50f9abcfd311870073e500ae11b299683a519fbb52d85e08d25", - "sha256:cb3e76380312e1f86abd20340ab1d5b3cc46a26f6593d3c33c9ea3e4c7134028", - "sha256:d0dcaa54263307075cb93d0bee3ceb02821093b1b3d25f66021987d305d01dce", - "sha256:d9a1ce5f099f29c7c33181cc4386660e0ba891b21a60dc036bf369e3a3ee3aec", - "sha256:da8e7c302003dd765d92a5616678e591f347460ac7b53e53d667be7dfe6d1b10", - "sha256:daf276c465c38ef736a79bd79fc80a249f746bcbcae50c40945428f7ece074f8" - ], - "version": "==0.23.2" - }, - "scipy": { - "hashes": [ - "sha256:066c513d90eb3fd7567a9e150828d39111ebd88d3e924cdfc9f8ce19ab6f90c9", - "sha256:07e52b316b40a4f001667d1ad4eb5f2318738de34597bd91537851365b6c61f1", - "sha256:0a0e9a4e58a4734c2eba917f834b25b7e3b6dc333901ce7784fd31aefbd37b2f", - "sha256:1c7564a4810c1cd77fcdee7fa726d7d39d4e2695ad252d7c86c3ea9d85b7fb8f", - "sha256:315aa2165aca31375f4e26c230188db192ed901761390be908c9b21d8b07df62", - "sha256:6e86c873fe1335d88b7a4bfa09d021f27a9e753758fd75f3f92d714aa4093768", - "sha256:8e28e74b97fc8d6aa0454989db3b5d36fc27e69cef39a7ee5eaf8174ca1123cb", - "sha256:92eb04041d371fea828858e4fff182453c25ae3eaa8782d9b6c32b25857d23bc", - "sha256:a0afbb967fd2c98efad5f4c24439a640d39463282040a88e8e928db647d8ac3d", - "sha256:a785409c0fa51764766840185a34f96a0a93527a0ff0230484d33a8ed085c8f8", - "sha256:cca9fce15109a36a0a9f9cfc64f870f1c140cb235ddf27fe0328e6afb44dfed0", - "sha256:d56b10d8ed72ec1be76bf10508446df60954f08a41c2d40778bc29a3a9ad9bce", - "sha256:dac09281a0eacd59974e24525a3bc90fa39b4e95177e638a31b14db60d3fa806", - "sha256:ec5fe57e46828d034775b00cd625c4a7b5c7d2e354c3b258d820c6c72212a6ec", - "sha256:eecf40fa87eeda53e8e11d265ff2254729d04000cd40bae648e76ff268885d66", - "sha256:fc98f3eac993b9bfdd392e675dfe19850cc8c7246a8fd2b42443e506344be7d9" - ], - "version": "==1.5.2" - }, - "selenium": { - "hashes": [ - "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c", - "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d" - ], - "version": "==3.141.0" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "version": "==1.15.0" - }, - "soupsieve": { - "hashes": [ - "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", - "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" - ], - "version": "==2.0.1" - }, - "threadpoolctl": { - "hashes": [ - "sha256:38b74ca20ff3bb42caca8b00055111d74159ee95c4370882bbff2b93d24da725", - "sha256:ddc57c96a38beb63db45d6c159b5ab07b6bced12c45a1f07b2b92f272aebfa6b" - ], - "version": "==2.1.0" - }, - "toml": { - "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" - ], - "version": "==0.10.1" - }, - "tqdm": { - "hashes": [ - "sha256:1a336d2b829be50e46b84668691e0a2719f26c97c62846298dd5ae2937e4d5cf", - "sha256:564d632ea2b9cb52979f7956e093e831c28d441c11751682f84c86fc46e4fd21" - ], - "version": "==4.48.2" - }, - "urllib3": { - "hashes": [ - "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", - "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" - ], - "version": "==1.25.10" - }, - "webdriver-manager": { - "hashes": [ - "sha256:18a665c6400bb7cf1a9ec9e1030ac5539cd5c892c97075f58940c62971470ce3", - "sha256:c2d4ee0a78226c355f3657dd0337e515187585a1497229af2ce5f4705234da9c" - ], - "version": "==3.2.2" - } - }, - "develop": {} -} From 082d525b9b2604e829c1d124ea1981692a01d9d9 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 31 Aug 2020 18:10:04 -0400 Subject: [PATCH 49/66] fix comparison --- jobfunnel/backend/tools/delay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 911cd543..8fbfc07e 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -86,7 +86,7 @@ def calculate_delays(list_len: int, delay_config: DelayConfig) -> List[float]: raise ValueError(f"Cannot calculate delay for {delay_config.algorithm}") # Check if minimum delay is above 0 and less than last element - if 0 < delay_config.min_duration: + if delay_config.min_duration > 0: # sets min_duration to values greater than itself in delay_vals for i, n in enumerate(delay_vals): if n > delay_config.min_duration: From 04635ab0c389c3f3b27a0fd95eda7bd44003dbe0 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Mon, 31 Aug 2020 18:34:37 -0400 Subject: [PATCH 50/66] Clean up imports + expand travis smoke testing to include USA --- .travis.yml | 8 +++++++- jobfunnel/backend/scrapers/base.py | 9 ++++----- jobfunnel/backend/scrapers/glassdoor.py | 11 +++++------ jobfunnel/backend/scrapers/indeed.py | 15 ++++++--------- jobfunnel/backend/scrapers/monster.py | 12 +++++------- jobfunnel/backend/tools/delay.py | 6 ++---- jobfunnel/backend/tools/tools.py | 4 +++- jobfunnel/config/cli.py | 12 ++++-------- jobfunnel/config/manager.py | 2 ++ jobfunnel/config/settings.py | 1 - jobfunnel/resources/defaults.py | 2 +- 11 files changed, 39 insertions(+), 43 deletions(-) diff --git a/.travis.yml b/.travis.yml index ce6b8ee6..92a7ff86 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,13 @@ install: before_script: - 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' script: - - 'funnel -s demo/settings.yaml' + - 'funnel -s demo/settings.yaml -log-level DEBUG' + # NOTE: we might want to make below search somewhere else so it isn't + # so very specific. + - 'funnel -s demo/settings.yaml -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG' - 'pytest --cov=jobfunnel --cov-report=xml' + # - './tests/verify-artifacts.sh' TODO: verify that JSON exist and are good + # - './tests/verify_time.sh' TODO: some way of verifying execution time after_success: - 'bash <(curl -s https://codecov.io/bash)' + # - './demo/gen_call_graphs.sh' TODO: some way of showing .dot on GitHub? diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index e229333b..75df55f9 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -1,14 +1,11 @@ """The base scraper class to be used for all web-scraping emitting Job objects """ -import logging -import os import random -import sys from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor, as_completed from multiprocessing import Lock, Manager -from time import sleep, time -from typing import Any, Dict, List, Optional, Tuple, Union +from time import sleep +from typing import Any, Dict, List, Optional from bs4 import BeautifulSoup from requests import Session @@ -23,8 +20,10 @@ from jobfunnel.resources import (MAX_CPU_WORKERS, USER_AGENT_LIST, JobField, Locale) +# pylint: disable=using-constant-test,unused-import if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager +# pylint: enable=using-constant-test,unused-import class BaseScraper(ABC, Logger): diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index a9b09e91..b726b570 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -1,28 +1,27 @@ """Scraper for www.glassdoor.X FIXME: this is currently unable to get past page 1 of job results. """ -import logging import re from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait -from datetime import date, datetime, timedelta from math import ceil -from time import sleep, time -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Tuple, Union from bs4 import BeautifulSoup from requests import Session -from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend import Job from jobfunnel.backend.scrapers.base import (BaseCANEngScraper, BaseScraper, BaseUSAEngScraper) from jobfunnel.backend.tools import get_webdriver from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.resources import MAX_CPU_WORKERS, JobField, Locale +from jobfunnel.resources import MAX_CPU_WORKERS, JobField +# pylint: disable=using-constant-test,unused-import if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager +# pylint: enable=using-constant-test,unused-import MAX_GLASSDOOR_LOCATIONS_TO_RETURN = 10 diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 58dd4708..1506e26a 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -1,27 +1,24 @@ """Scraper designed to get jobs from www.indeed.X """ -import logging import re -from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, wait -from datetime import date, datetime, timedelta from math import ceil -from time import sleep, time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from bs4 import BeautifulSoup from requests import Session -from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend import Job from jobfunnel.backend.scrapers.base import (BaseCANEngScraper, BaseScraper, BaseUSAEngScraper) from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.resources import MAX_CPU_WORKERS, JobField, Locale +from jobfunnel.resources import MAX_CPU_WORKERS, JobField +# pylint: disable=using-constant-test,unused-import if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager - +# pylint: enable=using-constant-test,unused-import ID_REGEX = re.compile(r'id=\"sj_([a-zA-Z0-9]*)\"') MAX_RESULTS_PER_INDEED_PAGE = 50 @@ -210,7 +207,7 @@ def _get_search_url(self, method: Optional[str] = 'get') -> str: self.config.search_config.domain, self.query, self.config.search_config.city.replace(' ', '+',), - self.config.search_config.province_or_state, + self.config.search_config.province_or_state.upper(), self._convert_radius(self.config.search_config.radius), self.max_results_per_page, int(self.config.search_config.return_similar_results) diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 90ab35b2..585ba9b5 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -1,26 +1,24 @@ """Scrapers for www.monster.X """ -import logging import re from abc import abstractmethod -from concurrent.futures import ThreadPoolExecutor, wait -from datetime import date, datetime, timedelta from math import ceil -from time import sleep, time -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from bs4 import BeautifulSoup from requests import Session -from jobfunnel.backend import Job, JobStatus +from jobfunnel.backend import Job from jobfunnel.backend.scrapers.base import (BaseCANEngScraper, BaseScraper, BaseUSAEngScraper) from jobfunnel.backend.tools.filters import JobFilter from jobfunnel.backend.tools.tools import calc_post_date_from_relative_str -from jobfunnel.resources import MAX_CPU_WORKERS, JobField, Locale +from jobfunnel.resources import JobField +# pylint: disable=using-constant-test,unused-import if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.config import JobFunnelConfigManager +# pylint: enable=using-constant-test,unused-import MAX_RESULTS_PER_MONSTER_PAGE = 25 diff --git a/jobfunnel/backend/tools/delay.py b/jobfunnel/backend/tools/delay.py index 8fbfc07e..2ee45cbb 100644 --- a/jobfunnel/backend/tools/delay.py +++ b/jobfunnel/backend/tools/delay.py @@ -2,13 +2,11 @@ """ from math import ceil, log, sqrt from random import uniform -from time import time -from typing import Dict, List, Union +from typing import List, Union from numpy import arange -from scipy.special import expit +from scipy.special import expit # pylint: disable=no-name-in-module -from jobfunnel.backend import Job from jobfunnel.config import DelayConfig from jobfunnel.resources import DelayAlgorithm diff --git a/jobfunnel/backend/tools/tools.py b/jobfunnel/backend/tools/tools.py index 97992cca..dd850ac6 100644 --- a/jobfunnel/backend/tools/tools.py +++ b/jobfunnel/backend/tools/tools.py @@ -35,11 +35,13 @@ def get_logger(logger_name: str, level: int, file_path: str, """ logger = logging.getLogger(logger_name) logger.setLevel(level) - logging.basicConfig(filename=file_path, level=level) formatter = logging.Formatter(message_format) stdout_handler = logging.StreamHandler(sys.stdout) stdout_handler.setFormatter(formatter) logger.addHandler(stdout_handler) + file_handler = logging.FileHandler(file_path) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) return logger diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 1bf24b8f..eca98a46 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -1,15 +1,12 @@ """Configuration parsing module for CLI --> JobFunnelConfigManager """ import argparse -import logging import os -from typing import Any, Dict, List import yaml -from jobfunnel.config import (SETTINGS_YAML_SCHEMA, DelayConfig, - JobFunnelConfigManager, ProxyConfig, - SearchConfig, SettingsValidator) +from jobfunnel.config import (DelayConfig, JobFunnelConfigManager, + ProxyConfig, SearchConfig, SettingsValidator) from jobfunnel.resources import (LOG_LEVEL_NAMES, DelayAlgorithm, Locale, Provider) from jobfunnel.resources.defaults import * @@ -85,14 +82,13 @@ def parse_cli(): ) parser.add_argument( - '-lf', - dest='log_file', + '-log-file', type=str, help=f'path to logging file. defaults to {DEFAULT_LOG_FILE}' ) parser.add_argument( - '--log-level', + '-log-level', type=str, default=DEFAULT_LOG_LEVEL_NAME, choices=LOG_LEVEL_NAMES, diff --git a/jobfunnel/config/manager.py b/jobfunnel/config/manager.py index ba9ae255..ee0597bb 100644 --- a/jobfunnel/config/manager.py +++ b/jobfunnel/config/manager.py @@ -8,8 +8,10 @@ from jobfunnel.config import BaseConfig, DelayConfig, ProxyConfig, SearchConfig from jobfunnel.resources import BS4_PARSER +# pylint: disable=using-constant-test,unused-import if False: # or typing.TYPE_CHECKING if python3.5.3+ from jobfunnel.backend.scrapers.base import BaseScraper +# pylint: enable=using-constant-test,unused-import class JobFunnelConfigManager(BaseConfig): diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index 21930736..ee0670bb 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -1,7 +1,6 @@ """Settings YAML Schema w/ validator """ import ipaddress -import logging from cerberus import Validator diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 768febcf..8ac22f9f 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -35,7 +35,7 @@ DEFAULT_RECOVER = False DEFAULT_RETURN_SIMILAR_RESULTS = False DEFAULT_SAVE_DUPLICATES = False -DEFAULT_RANDOM_DELAY= False +DEFAULT_RANDOM_DELAY = False DEFAULT_RANDOM_CONVERGING_DELAY = False # Defaults we use from localization, the scraper can always override it. From 90a0398531208926662fd886e5c60f4c57ae1a4b Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 5 Sep 2020 17:01:14 -0400 Subject: [PATCH 51/66] Fix numerous issues still existing in CLI. --- jobfunnel/__main__.py | 12 ++-- jobfunnel/config/__init__.py | 2 +- jobfunnel/config/cli.py | 111 +++++++++++++++++++---------------- 3 files changed, 64 insertions(+), 61 deletions(-) diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index b45d2bff..cd32dde6 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -3,13 +3,8 @@ NOTE: you can test this from cloned source by running python -m jobfunnel """ -import argparse -import sys -from typing import Union -import logging - from .backend.jobfunnel import JobFunnel -from .config import parse_cli, config_builder +from .config import parse_cli, config_parser, config_builder def main(): @@ -17,8 +12,9 @@ def main(): """ # Parse CLI into a dict args = parse_cli() - do_recovery_mode = args.do_recovery_mode # NOTE: we modify args for config - funnel_cfg = config_builder(args) + do_recovery_mode = args['do_recovery_mode'] # NOTE: we modify args below + cfg_dict = config_parser(args) + funnel_cfg = config_builder(cfg_dict) job_funnel = JobFunnel(funnel_cfg) if do_recovery_mode: job_funnel.recover() diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index 271651db..b48f4738 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -4,4 +4,4 @@ from jobfunnel.config.proxy import ProxyConfig from jobfunnel.config.search import SearchConfig from jobfunnel.config.manager import JobFunnelConfigManager -from jobfunnel.config.cli import parse_cli, config_builder +from jobfunnel.config.cli import parse_cli, config_builder, config_parser diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index eca98a46..1a1e489f 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -2,7 +2,7 @@ """ import argparse import os - +from typing import Dict, Any import yaml from jobfunnel.config import (DelayConfig, JobFunnelConfigManager, @@ -12,6 +12,12 @@ from jobfunnel.resources.defaults import * +PATH_ATTRS = [ + 'master_csv_file', 'cache_folder', 'log_file', 'block_list_file', + 'duplicates_list_file', +] + + def parse_cli(): """Parse the command line arguments into an argv with defaults @@ -37,7 +43,6 @@ def parse_cli(): parser.add_argument( '-o', dest='output_folder', - default=DEFAULT_OUTPUT_DIRECTORY, help='Directory where the job search results will be stored. ' 'Pass an existing search results folder to continue a search ' 'by scraping new jobs and updating the CSV file. ' @@ -51,7 +56,6 @@ def parse_cli(): parser.add_argument( '-csv', dest='master_csv_file', - nargs='*', help='Path to a master CSV file containing your search results. ' f'Defaults to {DEFAULT_MASTER_CSV_FILE}' ) @@ -66,7 +70,6 @@ def parse_cli(): parser.add_argument( '-blf', dest='block_list_file', - nargs='*', help='JSON file of jobs you want to omit from your job search ' '(usually this is in the output of previous jobfunnel results). ' f'Defaults to: {DEFAULT_BLOCK_LIST_FILE}' @@ -75,7 +78,6 @@ def parse_cli(): parser.add_argument( '-dl', dest='duplicates_list_file', - nargs='*', help='JSON file of jobs which have been detected to be duplicates of ' 'existing jobs (usually this is in the output of previous ' f'jobfunnel results). Defaults to: {DEFAULT_DUPLICATES_FILE}' @@ -242,36 +244,29 @@ def parse_cli(): help='Select a function to calculate delay times with.' ) - return parser.parse_args() - + return vars(parser.parse_args()) -def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: - """Parse the JobFunnel configuration settings into a JobFunnelConfigManager. - args [argparse.Namespace]: cli arguments from argparser +def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: + """Parse the JobFunnel configuration settings and combine CLI, YAML and + defaults to build a valid config dictionary for initializing config objects. """ - # NOTE: log_file and output_folder are specially handled - path_attrs = [ - 'master_csv_file', 'cache_folder', - 'block_list_file', 'duplicates_list_file', - ] # Init and pop args that are cli-only and not in our schema - args_dict = vars(args) settings_yaml_file = args_dict.pop('settings_yaml_file') output_folder = args_dict.pop('output_folder') args_dict.pop('do_recovery_mode') # NOTE: this is handled in __main__ config = {'search': {}, 'delay': {}, 'proxy': {}} + # NOTE: these are mutually exclusive from output_folder + user_passed_paths = bool( + args_dict['master_csv_file'] or args_dict['cache_folder'] + or args_dict['block_list_file'] or args_dict['duplicates_list_file'] + ) + # Build a config that respects CLI, defaults and YAML + # NOTE: we a passed settings YAML first so we can inject CLI after if needed if settings_yaml_file: - # Ensure user isn't pasing output_folder as this cannot be used here - if output_folder != DEFAULT_OUTPUT_DIRECTORY: - raise ValueError( - "Cannot combine -s YAML and -o argument, all file paths must " - "be specified individually." - ) - # Load YAML config.update( yaml.load(open(settings_yaml_file, 'r'), Loader=yaml.FullLoader) @@ -285,20 +280,27 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: f"Invalid Config settings yaml:\n{SettingsValidator.errors}" ) - # Handle CLI arguments, overwriting YAML if needed if output_folder: - if (output_folder != DEFAULT_OUTPUT_DIRECTORY - and (args_dict['master_csv_file'] or args_dict['cache_folder'] - or args_dict['block_list_file'] - or args_dict['duplicates_list_file'])): - # NOTE: we handle the -s with -o case before we get here + + # We must build paths based on -o path exclusively + if user_passed_paths: + + # This is an invalid case + raise ValueError( + "Cannot combine -o with -csv, -blf, -cache, -dlf arguments, " + "as -o defines these paths." + ) + + elif settings_yaml_file: + + # This is another invalid case raise ValueError( - "Cannot combine -o with -blf, -cache, -dlf arguments, as -o" - " defines these paths." + "Cannot combine -s YAML and -o argument, all file paths must " + "be specified individually." ) + else: - # Set paths based on passed output_folder: - # NOTE: these will match defaults if using DEFAULT_OUTPUT_PATH + # Set paths based on passed output_folder directly: config['master_csv_file'] = os.path.join( output_folder, 'master.csv' ) @@ -311,32 +313,30 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: config['duplicates_list_file'] = os.path.join( config['cache_folder'], 'duplicates.json' ) - if not args_dict['log_file']: - # User can specify a different log location if they want. + if args_dict['log_file']: + config['log_file'] = args_dict['log_file'] + else: config['log_file'] = os.path.join( output_folder, 'log.log' ) + + elif user_passed_paths: + + # Handle CLI arguments for paths, possibly overwriting YAML + for path_arg in PATH_ATTRS: + if args_dict.get(path_arg): + config[path_arg] = args_dict[path_arg] else: - # We should have all the paths we need - for path_arg in path_attrs: - config[path_arg] = args_dict[path_arg] - - if not args_dict['log_file']: - # We will define log to be where the csv is. - config['log_file'] = os.path.join( - os.path.dirname(os.path.abspath(config['cache_folder'])), - 'log.log', - ) - # Turn args_dict into config dict by nesting as-needed + # NOTE: we will do this so that we can run without any arguments passed + output_folder = DEFAULT_OUTPUT_DIRECTORY + + # Handle all the sub-configs, and non-path, non-default CLI args for key, arg_value in args_dict.items(): - if arg_value is not None: + if arg_value: if key == 'log_level' and arg_value != DEFAULT_LOG_LEVEL_NAME: # We got a non-default log level, overwrite any YAML setting config[key] = arg_value - elif key == 'no_scrape': - # Default is False. - config[key] = arg_value else: # Set sub-config value for sub_key in ['search', 'delay', 'proxy']: @@ -344,9 +344,16 @@ def config_builder(args: argparse.Namespace) -> JobFunnelConfigManager: config[sub_key][key.split(sub_key + '_')[1]] = arg_value break + import pdb; pdb.set_trace() + return config + + +def config_builder(config: Dict[str, Any]) -> JobFunnelConfigManager: + """Method to build Config* objects from a valid config dictionary + """ + # Create folders that out output files are within if they don't exist - path_attrs.append('log_file') - for path_attr in path_attrs: + for path_attr in PATH_ATTRS: output_dir = os.path.dirname(os.path.abspath(config[path_attr])) if not os.path.exists(output_dir): os.makedirs(output_dir) From 1d64943678a54832eeeec924f9449e29828369d1 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Sat, 5 Sep 2020 17:19:28 -0400 Subject: [PATCH 52/66] remove import pdb! --- jobfunnel/config/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 1a1e489f..bf6d27a2 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -250,6 +250,8 @@ def parse_cli(): def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: """Parse the JobFunnel configuration settings and combine CLI, YAML and defaults to build a valid config dictionary for initializing config objects. + + FIXME: still doesn't handle CLI args only properly... """ # Init and pop args that are cli-only and not in our schema settings_yaml_file = args_dict.pop('settings_yaml_file') @@ -344,7 +346,6 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: config[sub_key][key.split(sub_key + '_')[1]] = arg_value break - import pdb; pdb.set_trace() return config From fe45a4de7516633291f5d504f8a8273d7707a38c Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Tue, 8 Sep 2020 17:43:48 -0400 Subject: [PATCH 53/66] Fix test module structure --- .gitignore | 3 --- {images => logo}/jobfunnel.png | Bin {images => logo}/jobfunnel_banner.png | Bin {images => logo}/svg/jobfunnel.svg | 0 {images => logo}/svg/jobfunnel_banner.svg | 0 tests/backend/__init__.py | 0 tests/backend/scrapers/__init__.py | 0 tests/{ => backend/scrapers}/test_glassdoor.py | 0 tests/{ => backend/scrapers}/test_indeed.py | 0 tests/backend/scrapers/test_monster.py | 0 tests/backend/tools/__init__.py | 0 tests/backend/tools/test_delay.py | 0 tests/{ => backend/tools}/test_filters.py | 0 tests/{ => backend/tools}/test_tools.py | 0 tests/config/__init__.py | 0 tests/{test_parse.py => config/test_cli.py} | 0 tests/{ => config}/test_delay.py | 0 tests/config/test_proxy.py | 0 tests/{test_config.py => config/test_search.py} | 0 tests/{json => data}/cities_america.json | 0 tests/{json => data}/cities_canada.json | 0 tests/test_countries.py | 1 - 22 files changed, 4 deletions(-) rename {images => logo}/jobfunnel.png (100%) rename {images => logo}/jobfunnel_banner.png (100%) rename {images => logo}/svg/jobfunnel.svg (100%) rename {images => logo}/svg/jobfunnel_banner.svg (100%) create mode 100644 tests/backend/__init__.py create mode 100644 tests/backend/scrapers/__init__.py rename tests/{ => backend/scrapers}/test_glassdoor.py (100%) rename tests/{ => backend/scrapers}/test_indeed.py (100%) create mode 100644 tests/backend/scrapers/test_monster.py create mode 100644 tests/backend/tools/__init__.py create mode 100644 tests/backend/tools/test_delay.py rename tests/{ => backend/tools}/test_filters.py (100%) rename tests/{ => backend/tools}/test_tools.py (100%) create mode 100644 tests/config/__init__.py rename tests/{test_parse.py => config/test_cli.py} (100%) rename tests/{ => config}/test_delay.py (100%) create mode 100644 tests/config/test_proxy.py rename tests/{test_config.py => config/test_search.py} (100%) rename tests/{json => data}/cities_america.json (100%) rename tests/{json => data}/cities_canada.json (100%) delete mode 100644 tests/test_countries.py diff --git a/.gitignore b/.gitignore index 1f5140ca..1046ea13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ -data/ *.csv -demo/data/ -demo_cache # GraphViz *.dot diff --git a/images/jobfunnel.png b/logo/jobfunnel.png similarity index 100% rename from images/jobfunnel.png rename to logo/jobfunnel.png diff --git a/images/jobfunnel_banner.png b/logo/jobfunnel_banner.png similarity index 100% rename from images/jobfunnel_banner.png rename to logo/jobfunnel_banner.png diff --git a/images/svg/jobfunnel.svg b/logo/svg/jobfunnel.svg similarity index 100% rename from images/svg/jobfunnel.svg rename to logo/svg/jobfunnel.svg diff --git a/images/svg/jobfunnel_banner.svg b/logo/svg/jobfunnel_banner.svg similarity index 100% rename from images/svg/jobfunnel_banner.svg rename to logo/svg/jobfunnel_banner.svg diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backend/scrapers/__init__.py b/tests/backend/scrapers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_glassdoor.py b/tests/backend/scrapers/test_glassdoor.py similarity index 100% rename from tests/test_glassdoor.py rename to tests/backend/scrapers/test_glassdoor.py diff --git a/tests/test_indeed.py b/tests/backend/scrapers/test_indeed.py similarity index 100% rename from tests/test_indeed.py rename to tests/backend/scrapers/test_indeed.py diff --git a/tests/backend/scrapers/test_monster.py b/tests/backend/scrapers/test_monster.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backend/tools/__init__.py b/tests/backend/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/backend/tools/test_delay.py b/tests/backend/tools/test_delay.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_filters.py b/tests/backend/tools/test_filters.py similarity index 100% rename from tests/test_filters.py rename to tests/backend/tools/test_filters.py diff --git a/tests/test_tools.py b/tests/backend/tools/test_tools.py similarity index 100% rename from tests/test_tools.py rename to tests/backend/tools/test_tools.py diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_parse.py b/tests/config/test_cli.py similarity index 100% rename from tests/test_parse.py rename to tests/config/test_cli.py diff --git a/tests/test_delay.py b/tests/config/test_delay.py similarity index 100% rename from tests/test_delay.py rename to tests/config/test_delay.py diff --git a/tests/config/test_proxy.py b/tests/config/test_proxy.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_config.py b/tests/config/test_search.py similarity index 100% rename from tests/test_config.py rename to tests/config/test_search.py diff --git a/tests/json/cities_america.json b/tests/data/cities_america.json similarity index 100% rename from tests/json/cities_america.json rename to tests/data/cities_america.json diff --git a/tests/json/cities_canada.json b/tests/data/cities_canada.json similarity index 100% rename from tests/json/cities_canada.json rename to tests/data/cities_canada.json diff --git a/tests/test_countries.py b/tests/test_countries.py deleted file mode 100644 index d3b7fbfb..00000000 --- a/tests/test_countries.py +++ /dev/null @@ -1 +0,0 @@ -# FIXME \ No newline at end of file From 9733ef44f9d756253ace3e4f4e5ab89fad168c19 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Tue, 8 Sep 2020 17:50:43 -0400 Subject: [PATCH 54/66] fix CLI by restricting sub-commands to either load (YAML) or custom (CLI), updated readme to reflect this and also updated demo settings desc. --- demo/settings.yaml | 42 ++- jobfunnel/config/cli.py | 441 +++++++++++++++----------------- jobfunnel/config/search.py | 6 +- jobfunnel/config/settings.py | 2 +- jobfunnel/resources/defaults.py | 3 +- readme.md | 35 ++- 6 files changed, 256 insertions(+), 273 deletions(-) diff --git a/demo/settings.yaml b/demo/settings.yaml index 993f9712..f4cbb610 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -1,43 +1,41 @@ -# This is an example settings YAML, it closely mirrors our default search +# This is an example of a feature-complete JobFunnel configuration YAML. +# Try this out by simply running: "funnel load -s demo/settings.yaml" # Path where your master CSV, block-lists, and cache data will be stored -# NOTE: when you are using CLI, you can just specify output_folder and we -# will calculate these paths for you. -master_csv_file: demo_search.csv -cache_folder: demo_cache # NOTE: this will be created if it doesn't exist -block_list_file: demo_cache/demo_block_list.json -duplicates_list_file: demo_cache/demo_duplicates_list.json +# NOTE: we create any missing directories in these filepaths +master_csv_file: demo_job_search_results/demo_search.csv +cache_folder: demo_job_search_results/cache +block_list_file: demo_job_search_results/demo_block_list.json +duplicates_list_file: demo_job_search_results/demo_duplicates_list.json # Job search configuration search: - # Providers from which to search + # Locale settings, one of USA_ENGLISH, CANADA_ENGLISH, CANADA_FRENCH: + # This tells JobFunnel where the website we are scraping is located, and + # what language the contents are in. + locale: CANADA_ENGLISH + + # Job providers which we will search, one of INDEED, MONSTER, GLASSDOOR: # NOTE: we choose domain via locale (i.e. CANADA_ENGLISH -> www.indeed.ca) + # FIXME: we need to add back GLASSDOOR when that's working again. providers: - INDEED - MONSTER - # Locale settings (i.e. USA_ENGLISH, CANADA_ENGLISH, CANADA_FRENCH) - # These are used to define the reference to what code implementation we should - # use for the scraper and the provider - locale: CANADA_ENGLISH - - # Region that we are searching for jobs within - province_or_state: "ON" - city: "waterloo" + # Region that we are searching for jobs within: + province_or_state: "ON" # NOTE: this is generally 2 characters long. + city: "Waterloo" # NOTE: this is the full city / town name. radius: 25 # km (NOTE: if we were in locale: USA_ENGLISH it's in miles) - # These are the terms you would be typing into the website's search field - # NOTE: we will search with all the provided keywords and format according - # to the input format of job provider (i.e. the GET URLs). + # These are the terms you would be typing into the website's search field: keywords: - Python - # Don't return any listings older than this + # Don't return any listings older than this: max_listing_days: 35 - # Blocked company names that will never appear in any results - # TODO: refactor --> block_list + # Blocked company names that will never appear in any results: company_block_list: - "Infox Consulting" diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index bf6d27a2..a2aea1d1 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -18,261 +18,289 @@ ] -def parse_cli(): - """Parse the command line arguments into an argv with defaults +def parse_cli() -> Dict[str, Any]: + """Parse the command line arguments into an Dict[arg_name, arg_value] - NOTE: we only provide defaults for entries that are required and have no - default in Cerberus schema (SettingsValidator), this lets users try it out - without having to configure anything at all. + TODO: need to ensure users can try out JobFunnel as easily as possible. """ - parser = argparse.ArgumentParser('Job Search CLI') + base_parser = argparse.ArgumentParser('Job Search CLI.') - # path args - parser.add_argument( + # Independant arguments + base_parser.add_argument( + '--recover', + dest='do_recovery_mode', + action='store_true', + help='Reconstruct a new master CSV file from all available cache files.' + 'WARNING: this will replace all the statuses/etc in your master ' + 'CSV, it is intended for starting fresh / recovering from a bad ' + 'state.', + ) + + base_subparsers = base_parser.add_subparsers(required=False) + + # Configure everything via a YAML (NOTE: no other parsers may be passed) + yaml_parser = base_subparsers.add_parser( + 'load', + help='Run using an existing configuration YAML.', + ) + + yaml_parser.add_argument( '-s', dest='settings_yaml_file', type=str, - help='Path to a settings YAML file containing your job search info. ' - 'Pass an existing YAML file path to continue a search ' - 'by scraping new jobs and updating the CSV file. CLI args will ' - 'overwrite any settings in YAML.' - ) - - # This arg is problematic because you can't pass it and the - # paths to the files directly. - parser.add_argument( - '-o', - dest='output_folder', - help='Directory where the job search results will be stored. ' - 'Pass an existing search results folder to continue a search ' - 'by scraping new jobs and updating the CSV file. ' - 'Note that you should use seperate folders per-job-search! ' - 'Folder contents: /data/.cache/, /master_list.csv.' - ' These folders and associated files will be created if not found,' - ' or if -cache, -blf -dl, and -csv paths are not passed as args.' - f' Defaults to: {DEFAULT_OUTPUT_DIRECTORY}' - ) - - parser.add_argument( + help='Path to a settings YAML file containing your job search config.' + ) + + yaml_parser.add_argument( + '--no-scrape', + action='store_true', + help='Do not make any get requests, instead, load jobs from cache ' + 'and update filters + CSV file. NOTE: overrides setting in YAML.', + ) + + yaml_parser.add_argument( + '-log-level', + type=str, + choices=LOG_LEVEL_NAMES, + default=DEFAULT_LOG_LEVEL_NAME, + help='Type of logging information shown on the terminal. NOTE: ' + 'overrides setting in YAML.', + ) + + # We are using CLI for all arguments. + cli_parser = base_subparsers.add_parser( + 'custom', + help='Configure search query and data providers via CLI.', + ) + + cli_parser.add_argument( + '-log-level', + type=str, + choices=LOG_LEVEL_NAMES, + default=DEFAULT_LOG_LEVEL_NAME, + help='Type of logging information shown on the terminal.', + ) + cli_parser.add_argument( + '--no-scrape', + action='store_true', + help='Do not make any get requests, instead, load jobs from cache ' + 'and update filters + CSV file.', + ) + + # Paths + search_group = cli_parser.add_argument_group('paths') + search_group.add_argument( '-csv', dest='master_csv_file', - help='Path to a master CSV file containing your search results. ' - f'Defaults to {DEFAULT_MASTER_CSV_FILE}' + type=str, + help='Path to a master CSV file containing your search results.', + required=True, ) - parser.add_argument( + search_group.add_argument( '-cache', dest='cache_folder', - help='Directory where cached scrape data will be stored. ' - f'Defaults to {DEFAULT_CACHE_DIRECTORY}' + type=str, + help='Directory where cached scrape data will be stored.', + required=True, ) - parser.add_argument( + search_group.add_argument( '-blf', dest='block_list_file', - help='JSON file of jobs you want to omit from your job search ' - '(usually this is in the output of previous jobfunnel results). ' - f'Defaults to: {DEFAULT_BLOCK_LIST_FILE}' + type=str, + help='JSON file of jobs you want to omit from your job search.', + required=True, ) - parser.add_argument( + search_group.add_argument( '-dl', dest='duplicates_list_file', + type=str, help='JSON file of jobs which have been detected to be duplicates of ' - 'existing jobs (usually this is in the output of previous ' - f'jobfunnel results). Defaults to: {DEFAULT_DUPLICATES_FILE}' + 'existing jobs.', + required=True, ) - parser.add_argument( + search_group.add_argument( '-log-file', type=str, - help=f'path to logging file. defaults to {DEFAULT_LOG_FILE}' - ) - - parser.add_argument( - '-log-level', - type=str, - default=DEFAULT_LOG_LEVEL_NAME, - choices=LOG_LEVEL_NAMES, - help='Type of logging information shown on the terminal.' - ) - - parser.add_argument( - '-cbl', - dest='search_company_block_list', - nargs='+', - help='List of company names to omit from all search results.' + help='Path to log file.', + required=True, # FIXME: This should be optional (no writing to it all). ) - # Search terms - parser.add_argument( - '-p', - dest='search_providers', - nargs='+', - choices=[p.name for p in Provider], - default=[p.name for p in DEFAULT_PROVIDERS], - help='List of job-search providers. (i.e. indeed, monster, glassdoor).' - ) - - parser.add_argument( + # SearchConfig via CLI args subparser + search_group = cli_parser.add_argument_group('search') + search_group.add_argument( '-kw', - dest='search_keywords', + dest='search.keywords', + type=str, nargs='+', - default=DEFAULT_SEARCH_KEYWORDS, - help='List of job-search keywords. (i.e. Engineer, AI).' + help='List of job-search keywords (i.e. Engineer, AI).', + required=True, ) - parser.add_argument( + search_group.add_argument( '-l', - dest='search_locale', - default=DEFAULT_LOCALE.name, + dest='search.locale', + type=str, choices=[l.name for l in Locale], help='Global location and language to use to scrape the job provider' - ' website. (i.e. CANADA_ENGLISH --> indeed --> indeed.ca)' + ' website (i.e. -l CANADA_ENGLISH -p indeed --> indeed.ca).', + required=True, ) - parser.add_argument( + search_group.add_argument( '-ps', - dest='search_province_or_state', - default=DEFAULT_PROVINCE, + dest='search.province_or_state', type=str, - help='Province/state value for your job-search region. NOTE: format ' - 'is job-provider-specific.' + help='Province/state value for your job-search area of interest. ' + '(i.e. Ontario).', + required=True, ) - parser.add_argument( + search_group.add_argument( '-c', - dest='search_city', - default=DEFAULT_CITY, + dest='search.city', + type=str, + help='City/town value for job-search region (i.e. Waterloo).', + required=True, + ) + + search_group.add_argument( + '-cbl', type=str, - help='City/town value for job-search region.' + dest='search.company_block_list', + nargs='+', + help='List of company names to omit from all search results ' + '(i.e. SpamCompany, Cash5Gold).', + required=False, ) - parser.add_argument( + search_group.add_argument( + '-p', + dest='search.providers', + type=str, + nargs='+', + choices=[p.name for p in Provider], + default=DEFAULT_PROVIDER_NAMES, + help='List of job-search providers (i.e. Indeed, Monster, GlassDoor).', + required=False, + ) + + search_group.add_argument( '-r', - dest='search_radius', + dest='search.radius', type=int, - help='The maximum distance a job should be from the specified city.' + default=DEFAULT_SEARCH_RADIUS, + help='The maximum distance a job should be from the specified city. ' + 'NOTE: units are [km] CANADA locales and [mi] for US locales.', + required=False, ) - parser.add_argument( + search_group.add_argument( '-max-listing-days', - dest='search_max_listing_days', + dest='search.max_listing_days', type=int, + default=DEFAULT_MAX_LISTING_DAYS, help='The maximum number of days-old a job can be. (i.e pass 30 to ' - 'filter out jobs older than a month).' + 'filter out jobs older than a month).', + required=False, ) - parser.add_argument( + search_group.add_argument( '--similar-results', - dest='search_similar_results', - action='store_true', - help='Return \'similar\' results from search query (only for Indeed).' - ) - - # Flags: NOTE: all the defaults for these should be False. - parser.add_argument( - '--recover', - dest='do_recovery_mode', + dest='search.similar_results', action='store_true', - help='Reconstruct a new master CSV file from all available cache files.' - 'WARNING: this will replace all the statuses/etc in your master ' - 'CSV, it is intended for starting fresh / recovering from a bad ' - 'state.' - ) - - parser.add_argument( - '--no-scrape', - action='store_true', - help='Do not make any get requests, and attempt to load from cache.' + help='Return more general results from search query ' + '(NOTE: this is only available for Indeed provider).', ) - # Proxy stuff - # TODO: subparser. - parser.add_argument( + # Proxy stuff. TODO: way to tell argparse if proxy is seen all are req'd? + proxy_group = cli_parser.add_argument_group('proxy') + proxy_group.add_argument( '-protocol', - dest='proxy_protocol', + dest='proxy.protocol', type=str, - help='Proxy protocol.' + help='Proxy protocol.', ) - parser.add_argument( + proxy_group.add_argument( '-ip', - dest='proxy_ip', + dest='proxy.ip', type=str, - help='Proxy IP (V4) address.' + help='Proxy IP (V4) address.', ) - parser.add_argument( + proxy_group.add_argument( '-port', - dest='proxy_port', + dest='proxy.port', type=str, - help='Proxy port address.' + help='Proxy port address.', ) # Delay stuff - # TODO: move delay args into a subparser for improved -h clarity - parser.add_argument( - '--delay-random', - dest='delay_random', + delay_group = cli_parser.add_argument_group('delay') + delay_group.add_argument( + '--random', + dest='delay.random', action='store_true', - help='Turn on random delaying for certain get requests.' + help='Turn on random delaying.', ) - parser.add_argument( - '--delay-converging', - dest='delay_converging', + delay_group.add_argument( + '--converging', + dest='delay.converging', action='store_true', - help='Use converging random delay for certain get requests.' + help='Use converging random delay. NOTE: this is intended to be used ' + 'with --random', ) - parser.add_argument( - '-delay-max', - dest='delay_max_duration', + delay_group.add_argument( + '-max', + dest='delay.max', type=float, - help='Set delay seconds for certain get requests.' + default=DEFAULT_DELAY_MAX_DURATION, + help='Set the maximum delay duration in seconds.', ) - parser.add_argument( - '-delay-min', - dest='delay_min_duration', + delay_group.add_argument( + '-min', + dest='delay.min', type=float, - help='Set lower bound value for delay for certain get requests.' + default=DEFAULT_DELAY_MIN_DURATION, + help='Set the minimum delay duration in seconds', ) - parser.add_argument( - '-delay-algorithm', + delay_group.add_argument( + '-algorithm', + dest='delay.algorithm', choices=[a.name for a in DelayAlgorithm], - help='Select a function to calculate delay times with.' + default=DEFAULT_DELAY_ALGORITHM.name, + help='Select a function to calculate delay times with.', ) - return vars(parser.parse_args()) + return vars(base_parser.parse_args()) def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: """Parse the JobFunnel configuration settings and combine CLI, YAML and defaults to build a valid config dictionary for initializing config objects. - - FIXME: still doesn't handle CLI args only properly... """ - # Init and pop args that are cli-only and not in our schema - settings_yaml_file = args_dict.pop('settings_yaml_file') - output_folder = args_dict.pop('output_folder') - args_dict.pop('do_recovery_mode') # NOTE: this is handled in __main__ - config = {'search': {}, 'delay': {}, 'proxy': {}} - - # NOTE: these are mutually exclusive from output_folder - user_passed_paths = bool( - args_dict['master_csv_file'] or args_dict['cache_folder'] - or args_dict['block_list_file'] or args_dict['duplicates_list_file'] - ) + # NOTE: this is handled in __main__ + args_dict.pop('do_recovery_mode') # Build a config that respects CLI, defaults and YAML # NOTE: we a passed settings YAML first so we can inject CLI after if needed - if settings_yaml_file: + if 'settings_yaml_file' in args_dict: # Load YAML - config.update( - yaml.load(open(settings_yaml_file, 'r'), Loader=yaml.FullLoader) + config = yaml.load( + open(args_dict['settings_yaml_file'], 'r'), + Loader=yaml.FullLoader, ) + + # Inject any --no-scrape + config['no_scrape'] = args_dict['no_scrape'] + # Set defaults for our YAML config = SettingsValidator.normalized(config) @@ -282,69 +310,24 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: f"Invalid Config settings yaml:\n{SettingsValidator.errors}" ) - if output_folder: - - # We must build paths based on -o path exclusively - if user_passed_paths: - - # This is an invalid case - raise ValueError( - "Cannot combine -o with -csv, -blf, -cache, -dlf arguments, " - "as -o defines these paths." - ) - - elif settings_yaml_file: - - # This is another invalid case - raise ValueError( - "Cannot combine -s YAML and -o argument, all file paths must " - "be specified individually." - ) - - else: - # Set paths based on passed output_folder directly: - config['master_csv_file'] = os.path.join( - output_folder, 'master.csv' - ) - config['cache_folder'] = os.path.join( - output_folder, '.cache' - ) - config['block_list_file'] = os.path.join( - config['cache_folder'], 'block.json' - ) - config['duplicates_list_file'] = os.path.join( - config['cache_folder'], 'duplicates.json' - ) - if args_dict['log_file']: - config['log_file'] = args_dict['log_file'] - else: - config['log_file'] = os.path.join( - output_folder, 'log.log' - ) - - elif user_passed_paths: - - # Handle CLI arguments for paths, possibly overwriting YAML - for path_arg in PATH_ATTRS: - if args_dict.get(path_arg): - config[path_arg] = args_dict[path_arg] else: - # NOTE: we will do this so that we can run without any arguments passed - output_folder = DEFAULT_OUTPUT_DIRECTORY - - # Handle all the sub-configs, and non-path, non-default CLI args - for key, arg_value in args_dict.items(): - if arg_value: - if key == 'log_level' and arg_value != DEFAULT_LOG_LEVEL_NAME: - # We got a non-default log level, overwrite any YAML setting - config[key] = arg_value - else: - # Set sub-config value - for sub_key in ['search', 'delay', 'proxy']: - if sub_key in key: - config[sub_key][key.split(sub_key + '_')[1]] = arg_value - break + # Handle CLI arguments for paths, possibly overwriting YAML + sub_keys = ['search', 'delay', 'proxy'] + config = {k: {} for k in sub_keys} # type: Dict[str, Dict[str, Any]] + + # Handle all the sub-configs, and non-path, non-default CLI args + for key, value in args_dict.items(): + if value: + if any([sub_key in key for sub_key in sub_keys]): + # Set sub-config value + key_sub_strings = key.split('.') + assert len(key_sub_strings) == 2, "Bad dest name: " + key + config[key_sub_strings[0]][key_sub_strings[1]] = value + else: + # Set base-config value + assert '.' not in key, "Bad base-key: " + key + config[key] = value return config @@ -353,12 +336,6 @@ def config_builder(config: Dict[str, Any]) -> JobFunnelConfigManager: """Method to build Config* objects from a valid config dictionary """ - # Create folders that out output files are within if they don't exist - for path_attr in PATH_ATTRS: - output_dir = os.path.dirname(os.path.abspath(config[path_attr])) - if not os.path.exists(output_dir): - os.makedirs(output_dir) - # Build JobFunnelConfigManager search_cfg = SearchConfig( keywords=config['search']['keywords'], @@ -380,7 +357,7 @@ def config_builder(config: Dict[str, Any]) -> JobFunnelConfigManager: converge=config['delay']['converging'], ) - if config['proxy']: + if config.get('proxy'): proxy_cfg = ProxyConfig( protocol=config['proxy']['protocol'], ip_address=config['proxy']['ip'], @@ -402,7 +379,15 @@ def config_builder(config: Dict[str, Any]) -> JobFunnelConfigManager: proxy_config=proxy_cfg, ) + # Create folders that out output files are within if they don't exist + # TODO: perhaps we should move this elsewhere? + for path_attr in PATH_ATTRS: + output_dir = os.path.dirname(os.path.abspath(config[path_attr])) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + # Validate funnel config as well (checks some stuff Cerberus doesn't rn) funnel_cfg_mgr.validate() return funnel_cfg_mgr + diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index de0eb6d8..e7b98396 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -4,7 +4,7 @@ from jobfunnel.config import BaseConfig from jobfunnel.resources import Locale, Provider from jobfunnel.resources.defaults import ( - DEFAULT_SEARCH_RADIUS_KM, DEFAULT_MAX_LISTING_DAYS, + DEFAULT_SEARCH_RADIUS, DEFAULT_MAX_LISTING_DAYS, DEFAULT_DOMAIN_FROM_LOCALE, ) @@ -35,7 +35,7 @@ def __init__(self, domain and the scrapers we will use to scrape it. city (Optional[str], optional): city. Defaults to None. distance_radius (Optional[int], optional): km/m radius. Defaults to - DEFAULT_SEARCH_RADIUS_KM. + DEFAULT_SEARCH_RADIUS. return_similar_results (Optional[bool], optional): return similar. results (indeed), Defaults to False. max_listing_days (Optional[int], optional): oldest listing to show. @@ -49,7 +49,7 @@ def __init__(self, super().__init__() self.province_or_state = province_or_state self.city = city.lower() if city else None - self.radius = distance_radius or DEFAULT_SEARCH_RADIUS_KM + self.radius = distance_radius or DEFAULT_SEARCH_RADIUS self.locale = locale self.providers = providers self.keywords = keywords diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index ee0670bb..47d53a80 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -59,7 +59,7 @@ 'required': False, 'type': 'integer', 'min': 0, - 'default': DEFAULT_SEARCH_RADIUS_KM, + 'default': DEFAULT_SEARCH_RADIUS, }, 'similar_results': { 'required': False, diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 8ac22f9f..8fb58fa6 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -23,13 +23,14 @@ ) DEFAULT_LOG_FILE = os.path.join(DEFAULT_OUTPUT_DIRECTORY, 'log.log') DEFAULT_MASTER_CSV_FILE = os.path.join(DEFAULT_OUTPUT_DIRECTORY, 'master.csv') -DEFAULT_SEARCH_RADIUS_KM = 25 +DEFAULT_SEARCH_RADIUS = 25 DEFAULT_MAX_LISTING_DAYS = 60 DEFAULT_DELAY_MAX_DURATION = 5.0 DEFAULT_DELAY_MIN_DURATION = 1.0 DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR # FIXME: re-enable glassdoor once we fix issue with it. (#87) DEFAULT_PROVIDERS = [Provider.MONSTER, Provider.INDEED] #, Provider.GLASSDOOR] +DEFAULT_PROVIDER_NAMES = [p.name for p in DEFAULT_PROVIDERS] DEFAULT_NO_SCRAPE = False DEFAULT_USE_WEB_DRIVER = False DEFAULT_RECOVER = False diff --git a/readme.md b/readme.md index ccc5054d..de7ec49c 100644 --- a/readme.md +++ b/readme.md @@ -52,34 +52,33 @@ pip install -e ./JobFunnel ### Using JobFunnel -After installation you can run with default settings and locale just by -running: +After installation you can search for jobs with YAML configuration files or by passing command arguments. -``` -funnel -``` - -or you can review the extensive options available via CLI by running: +Run the below commands to perform a demonstration job search that saves results in your local directory within a folder called `demo_job_search_results`. ``` -funnel -h +wget https://www.github.com/PaulMcInnis/JobFunnel/demo/settings.yaml +funnel load -s settings.yaml ``` -or you can build your own `settings.yaml` file from the example provided in [demo/readme.md][demo] and run: +If you would prefer to use the extensive CLI arguments in-place of a configuration +YAML file, review the command structure by running the below command: ``` -funnel -s my_own_job_search_settings.yaml +funnel custom -h ``` +The recommended approach is to build your own `settings.yaml` file from the example provided in [demo/readme.md][demo] and run `funnel load -s ` + ---- ### Reviewing Results Follow these steps to continuously-improve your job search results CSV: -1. Set your job search preferences in the `yaml` configuration file (or use `-kw`). -2. Run `funnel` to scrape all-available job listings. -3. Review jobs in the master-list, update the job `status` to reflect your interest or progression: `interested`, `applied`, `interview` or `offer`. +1. Set your job search preferences in a `yaml` configuration file. +2. Run `funnel load -s ...` to scrape all-available job listings. +3. Review jobs in the master-list CSV, and update the job `status` to reflect your interest or progression: `interested`, `applied`, `interview` or `offer`. 4. Set any a job `status` to `archive`, `rejected` or `delete` to remove them from the `.csv`. ___Note: listings you filter away by `status` are persistant___ ---- @@ -99,10 +98,10 @@ _NOTE: `status` values are not case-sensitive_ ### Advanced Usage * **Managing Multiple Searches**
- JobFunnel works best if you keep distinct searches in their own `.csv` files: + JobFunnel works best if you keep distinct searches in their own `.csv` files, i.e.: ``` - funnel -kw Python -c Waterloo -ps ON -l CANADA_ENGLISH -o canada_python - funnel -kw AI Machine Learning -c Seattle -ps WA -l USA_ENGLISH -o USA_ML + funnel custom -kw Python -c Waterloo -ps ON -l CANADA_ENGLISH -o canada_python + funnel custom -kw AI Machine Learning -c Seattle -ps WA -l USA_ENGLISH -o USA_ML ``` * **Automating Searches**
@@ -116,9 +115,9 @@ _NOTE: `status` values are not case-sensitive_ JobFunnel supports scraping jobs from the same job website across differnt locales. If you are interested in adding support, you may only need to define session headers and domain strings, Review the [BaseScraper][BaseScraper] for further implementation details. * **Recovering Lost Master-list**
- JobFunnel can re-build your master CSV from the scrape cache, where all the historic scrape data is located: + JobFunnel can re-build your master CSV from your search's scrape cache, where all the historic scrape data is located: ``` - funnel --recover + funnel --recover load -s my_search_settings.yaml ``` * **Filtering Undesired Companies**
From d31cbd81d9edad23b5484953fc600a03ee4f3f34 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Tue, 8 Sep 2020 17:56:20 -0400 Subject: [PATCH 55/66] Remove .idea (too-user-specific), and Pipfile (PipEnv is no longer needed) --- .gitignore | 4 ++++ .idea/JobFunnel.iml | 15 --------------- .idea/misc.xml | 4 ---- .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ Pipfile | 14 -------------- 6 files changed, 4 insertions(+), 47 deletions(-) delete mode 100644 .idea/JobFunnel.iml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 Pipfile diff --git a/.gitignore b/.gitignore index 1046ea13..4fcbfeb3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ +# Outputs *.csv +# IntelliJ/Pycharm configs +.idea/ + # GraphViz *.dot diff --git a/.idea/JobFunnel.iml b/.idea/JobFunnel.iml deleted file mode 100644 index 29089b45..00000000 --- a/.idea/JobFunnel.iml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index bd43d393..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 1c2b45cf..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Pipfile b/Pipfile deleted file mode 100644 index df669fd6..00000000 --- a/Pipfile +++ /dev/null @@ -1,14 +0,0 @@ -[[source]] -url = "https://pypi.python.org/simple" -verify_ssl = true -name = "pypi" - -[dev-packages] - -[packages] -jobfunnel = {path = ".", editable = true} -selenium = "*" -webdriver_manager = "*" - -[requires] -python_version = "3.8" From 10a3cbe86e23040cfdbc30c6b0d0b3d4b0b4d453 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 15:40:37 -0400 Subject: [PATCH 56/66] make log-level a base argument --- jobfunnel/config/cli.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index a2aea1d1..48bb6af7 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -25,7 +25,7 @@ def parse_cli() -> Dict[str, Any]: """ base_parser = argparse.ArgumentParser('Job Search CLI.') - # Independant arguments + # Independant arguments base_parser.add_argument( '--recover', dest='do_recovery_mode', @@ -62,9 +62,8 @@ def parse_cli() -> Dict[str, Any]: '-log-level', type=str, choices=LOG_LEVEL_NAMES, - default=DEFAULT_LOG_LEVEL_NAME, help='Type of logging information shown on the terminal. NOTE: ' - 'overrides setting in YAML.', + 'is passed, overrides the setting in YAML.', ) # We are using CLI for all arguments. @@ -285,9 +284,6 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: """Parse the JobFunnel configuration settings and combine CLI, YAML and defaults to build a valid config dictionary for initializing config objects. """ - # NOTE: this is handled in __main__ - args_dict.pop('do_recovery_mode') - # Build a config that respects CLI, defaults and YAML # NOTE: we a passed settings YAML first so we can inject CLI after if needed if 'settings_yaml_file' in args_dict: @@ -298,8 +294,10 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: Loader=yaml.FullLoader, ) - # Inject any --no-scrape + # Inject any base level args (--no-scrape, -log-level) config['no_scrape'] = args_dict['no_scrape'] + if args_dict['log-level']: + config['log-level'] = args_dict['log_level'] # Set defaults for our YAML config = SettingsValidator.normalized(config) @@ -318,7 +316,10 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: # Handle all the sub-configs, and non-path, non-default CLI args for key, value in args_dict.items(): - if value: + if key == 'do_recovery_mode': + # This is not present in the schema, it is CLI only. + continue + elif value: if any([sub_key in key for sub_key in sub_keys]): # Set sub-config value key_sub_strings = key.split('.') From 82119fa0f734c97e9cd7ddc80cccf4fafd7ed819 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 17:06:57 -0400 Subject: [PATCH 57/66] Add some cli parser testing + fix more minor bugs in CLI parser --- demo/settings.yaml | 1 + jobfunnel/__main__.py | 20 +++--- jobfunnel/backend/jobfunnel.py | 8 +-- jobfunnel/config/__init__.py | 4 +- jobfunnel/config/cli.py | 66 ++++++++------------ jobfunnel/config/manager.py | 11 ++++ jobfunnel/resources/defaults.py | 4 +- tests/config/test_cli.py | 106 ++++++++++++++++++++++++-------- tests/config/test_delay.py | 40 +++++++++++- tests/config/test_proxy.py | 3 + tests/config/test_search.py | 76 +---------------------- 11 files changed, 181 insertions(+), 158 deletions(-) diff --git a/demo/settings.yaml b/demo/settings.yaml index f4cbb610..bd6cc0c5 100644 --- a/demo/settings.yaml +++ b/demo/settings.yaml @@ -7,6 +7,7 @@ master_csv_file: demo_job_search_results/demo_search.csv cache_folder: demo_job_search_results/cache block_list_file: demo_job_search_results/demo_block_list.json duplicates_list_file: demo_job_search_results/demo_duplicates_list.json +log_file: demo_job_search_results/log.log # Job search configuration search: diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index cd32dde6..4760c59e 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -3,20 +3,26 @@ NOTE: you can test this from cloned source by running python -m jobfunnel """ +import sys from .backend.jobfunnel import JobFunnel -from .config import parse_cli, config_parser, config_builder +from .config import parse_cli, build_config_dict, get_config_manager def main(): """Parse CLI and call jobfunnel() to manage scrapers and lists """ - # Parse CLI into a dict - args = parse_cli() - do_recovery_mode = args['do_recovery_mode'] # NOTE: we modify args below - cfg_dict = config_parser(args) - funnel_cfg = config_builder(cfg_dict) + # Parse CLI into validated schema + args = parse_cli(sys.argv[1:]) + cfg_dict = build_config_dict(args) + + # Build config manager + funnel_cfg = get_config_manager(cfg_dict) + + # Init job_funnel = JobFunnel(funnel_cfg) - if do_recovery_mode: + + # Run or recover + if args['do_recovery_mode']: job_funnel.recover() else: job_funnel.run() diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index 07ce1f46..c8138d90 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -38,12 +38,10 @@ def __init__(self, config: JobFunnelConfigManager) -> None: Args: config (JobFunnelConfigManager): config object containing paths etc. """ - super().__init__( - level=config.log_level, - file_path=config.log_file, - ) + super().__init__(level=config.log_level, file_path=config.log_file) self.config = config - self.config.validate() # NOTE: this ensures directories exist + self.config.create_dirs() + self.config.validate() self.__date_string = date.today().strftime("%Y-%m-%d") self.master_jobs_dict = {} # type: Dict[str, Job] diff --git a/jobfunnel/config/__init__.py b/jobfunnel/config/__init__.py index b48f4738..e33f17b6 100644 --- a/jobfunnel/config/__init__.py +++ b/jobfunnel/config/__init__.py @@ -4,4 +4,6 @@ from jobfunnel.config.proxy import ProxyConfig from jobfunnel.config.search import SearchConfig from jobfunnel.config.manager import JobFunnelConfigManager -from jobfunnel.config.cli import parse_cli, config_builder, config_parser +from jobfunnel.config.cli import ( + parse_cli, get_config_manager, build_config_dict +) diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 48bb6af7..371439ef 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -2,7 +2,7 @@ """ import argparse import os -from typing import Dict, Any +from typing import Dict, Any, List import yaml from jobfunnel.config import (DelayConfig, JobFunnelConfigManager, @@ -12,13 +12,7 @@ from jobfunnel.resources.defaults import * -PATH_ATTRS = [ - 'master_csv_file', 'cache_folder', 'log_file', 'block_list_file', - 'duplicates_list_file', -] - - -def parse_cli() -> Dict[str, Any]: +def parse_cli(args: List[str]) -> Dict[str, Any]: """Parse the command line arguments into an Dict[arg_name, arg_value] TODO: need to ensure users can try out JobFunnel as easily as possible. @@ -35,37 +29,39 @@ def parse_cli() -> Dict[str, Any]: 'CSV, it is intended for starting fresh / recovering from a bad ' 'state.', ) - + base_subparsers = base_parser.add_subparsers(required=False) - + # Configure everything via a YAML (NOTE: no other parsers may be passed) yaml_parser = base_subparsers.add_parser( 'load', help='Run using an existing configuration YAML.', ) - + yaml_parser.add_argument( '-s', dest='settings_yaml_file', type=str, - help='Path to a settings YAML file containing your job search config.' + help='Path to a settings YAML file containing your job search config.', + required=True, ) - + yaml_parser.add_argument( '--no-scrape', action='store_true', help='Do not make any get requests, instead, load jobs from cache ' 'and update filters + CSV file. NOTE: overrides setting in YAML.', ) - + yaml_parser.add_argument( '-log-level', type=str, choices=LOG_LEVEL_NAMES, help='Type of logging information shown on the terminal. NOTE: ' - 'is passed, overrides the setting in YAML.', + 'if passed, overrides the setting in YAML.', + required=False, ) - + # We are using CLI for all arguments. cli_parser = base_subparsers.add_parser( 'custom', @@ -255,7 +251,7 @@ def parse_cli() -> Dict[str, Any]: delay_group.add_argument( '-max', - dest='delay.max', + dest='delay.max_duration', type=float, default=DEFAULT_DELAY_MAX_DURATION, help='Set the maximum delay duration in seconds.', @@ -263,7 +259,7 @@ def parse_cli() -> Dict[str, Any]: delay_group.add_argument( '-min', - dest='delay.min', + dest='delay.min_duration', type=float, default=DEFAULT_DELAY_MIN_DURATION, help='Set the minimum delay duration in seconds', @@ -276,11 +272,10 @@ def parse_cli() -> Dict[str, Any]: default=DEFAULT_DELAY_ALGORITHM.name, help='Select a function to calculate delay times with.', ) + return vars(base_parser.parse_args(args)) - return vars(base_parser.parse_args()) - -def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: +def build_config_dict(args_dict: Dict[str, Any]) -> Dict[str, Any]: """Parse the JobFunnel configuration settings and combine CLI, YAML and defaults to build a valid config dictionary for initializing config objects. """ @@ -290,15 +285,15 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: # Load YAML config = yaml.load( - open(args_dict['settings_yaml_file'], 'r'), + open(args_dict['settings_yaml_file'], 'r'), Loader=yaml.FullLoader, ) - + # Inject any base level args (--no-scrape, -log-level) config['no_scrape'] = args_dict['no_scrape'] - if args_dict['log-level']: - config['log-level'] = args_dict['log_level'] - + if args_dict.get('log_level'): + config['log_level'] = args_dict['log_level'] + # Set defaults for our YAML config = SettingsValidator.normalized(config) @@ -318,8 +313,8 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: for key, value in args_dict.items(): if key == 'do_recovery_mode': # This is not present in the schema, it is CLI only. - continue - elif value: + continue + elif value is not None: if any([sub_key in key for sub_key in sub_keys]): # Set sub-config value key_sub_strings = key.split('.') @@ -333,8 +328,8 @@ def config_parser(args_dict: Dict[str, Any]) -> Dict[str, Any]: return config -def config_builder(config: Dict[str, Any]) -> JobFunnelConfigManager: - """Method to build Config* objects from a valid config dictionary +def get_config_manager(config: Dict[str, Any]) -> JobFunnelConfigManager: + """Method to build JobFunnelConfigManager from a config dictionary """ # Build JobFunnelConfigManager @@ -380,15 +375,4 @@ def config_builder(config: Dict[str, Any]) -> JobFunnelConfigManager: proxy_config=proxy_cfg, ) - # Create folders that out output files are within if they don't exist - # TODO: perhaps we should move this elsewhere? - for path_attr in PATH_ATTRS: - output_dir = os.path.dirname(os.path.abspath(config[path_attr])) - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - # Validate funnel config as well (checks some stuff Cerberus doesn't rn) - funnel_cfg_mgr.validate() - return funnel_cfg_mgr - diff --git a/jobfunnel/config/manager.py b/jobfunnel/config/manager.py index ee0597bb..7ed10c0e 100644 --- a/jobfunnel/config/manager.py +++ b/jobfunnel/config/manager.py @@ -99,6 +99,17 @@ def scraper_names(self) -> List[str]: """ return [s.__name__ for s in self.scrapers] + def create_dirs(self) -> None: + """Create the directories for attributes which refer to files / folders + NOTE: should be called before we validate() + """ + for path_attr in [self.master_csv_file, self.user_block_list_file, + self.duplicates_list_file, self.cache_folder, + self.log_file]: + output_dir = os.path.dirname(os.path.abspath(path_attr)) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + def validate(self) -> None: """Validate the config object i.e. paths exit NOTE: will raise exceptions if issues are encountered. diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index 8fb58fa6..e03c380b 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -1,5 +1,5 @@ """Default arguments for both JobFunnelConfigManager and CLI arguments. -NOTE: we include defaults for all arguments so that JobFunnel is plug-n-play +FIXME: we need to remove un-used defaults from here """ import os from pathlib import Path @@ -30,7 +30,7 @@ DEFAULT_DELAY_ALGORITHM = DelayAlgorithm.LINEAR # FIXME: re-enable glassdoor once we fix issue with it. (#87) DEFAULT_PROVIDERS = [Provider.MONSTER, Provider.INDEED] #, Provider.GLASSDOOR] -DEFAULT_PROVIDER_NAMES = [p.name for p in DEFAULT_PROVIDERS] +DEFAULT_PROVIDER_NAMES = [p.name for p in DEFAULT_PROVIDERS] DEFAULT_NO_SCRAPE = False DEFAULT_USE_WEB_DRIVER = False DEFAULT_RECOVER = False diff --git a/tests/config/test_cli.py b/tests/config/test_cli.py index 3df6e617..83eaa46c 100644 --- a/tests/config/test_cli.py +++ b/tests/config/test_cli.py @@ -1,34 +1,86 @@ -"""Test CLI parsing +"""Test CLI parsing --> config dict """ +import os import pytest -from jobfunnel.config import parse_cli, config_builder -from jobfunnel.resources.defaults import * +from jobfunnel.config import parse_cli, build_config_dict -# FIXME -# @pytest.mark.parametrize("kwargs, exception, match", [ -# ( -# { -# 'settings_yaml_file': 'demo/settings.yaml', -# }, -# ValueError, -# r".*If specifying paths you must pass all arguments.*", -# ), -# ]) -# def test_config_builder(mocker, kwargs, exception, match): +TEST_YAML = os.path.join('tests', 'data', 'test_config.yml') -# # Inject our settings as augmentations of CLI -# # TODO: we should break parse_cli into own test -# args = vars(parse_cli()) -# for kwarg, value in kwargs.items(): -# args[kwarg] = value -# patch_os = mocker.patch('jobfunnel.config.cli.os') -# mocker.patch('jobfunnel.config.cli.vars', return_value=args) +@pytest.mark.parametrize('argv, exp_exception', [ + # Test schema from YAML + (['load', '-s', TEST_YAML], None), + # Test overrideable args + (['load', '-s', TEST_YAML, '-log-level', 'DEBUG'], None), + (['load', '-s', TEST_YAML, '-log-level', 'WARNING'], None), + (['load', '-s', TEST_YAML, '--no-scrape'], None), + # Test schema from CLI + (['custom', '-csv', 'TEST_search', '-log-level', 'DEBUG', '-cache', + 'TEST_cache', '-blf', 'TEST_block_list', '-dl', 'TEST_duplicates_list', + '-log-file', 'TEST_log_file', '-kw', 'I', 'Am', 'Testing', '-l', + 'CANADA_ENGLISH', '-ps', 'TESTPS', '-c', 'TestCity', '-cbl', + 'Blocked Company', 'Blocked Company 2', '-p', 'INDEED', 'MONSTER', + '-r', '42', '-max-listing-days', '44', '--similar-results', '--random', + '--converging', '-max', '8', '-min', '2', '-algorithm', 'LINEAR'], None), + # Invalid cases + (['load'], SystemExit), + (['load', '-csv', 'boo'], SystemExit), + (['custom', '-csv', 'TEST_search', '-log-level', 'DEBUG', '-cache', + 'TEST_cache', '-blf', 'TEST_block_list', '-dl', + 'TEST_duplicates_list'], SystemExit), + (['-csv', 'test.csv'], SystemExit), + (['-l', + 'CANADA_ENGLISH', '-ps', 'TESTPS', '-c', 'TestCity', '-cbl', + 'Blocked Company', 'Blocked Company 2', '-p', 'INDEED', 'MONSTER', + '-r', '42', '-max-listing-days', '44', '--similar-results', '--random', + '--converging', '-max', '8', '-min', '2', '-algorithm', + 'LINEAR'], SystemExit), +]) +def test_parse_cli_build_config_dict(argv, exp_exception): + """Functional test to ensure that the CLI functions as we expect + TODO: break down into test_parse_cli and test_config_parser + FIXME: add exception message assertions + """ + # FUT + if exp_exception: + with pytest.raises(exp_exception): + args = parse_cli(argv) + cfg = build_config_dict(args) + else: + args = parse_cli(argv) + cfg = build_config_dict(args) -# # FUT -# if exception: -# with pytest.raises(exception, match=match): -# config_builder(None) -# else: -# cfg = config_builder(None) + # Assertions + assert cfg['master_csv_file'] == 'TEST_search' + assert cfg['cache_folder'] == 'TEST_cache' + assert cfg['block_list_file'] == 'TEST_block_list' + assert cfg['duplicates_list_file'] == 'TEST_duplicates_list' + assert cfg['search']['locale'] == 'CANADA_ENGLISH' + assert cfg['search']['providers'] == ['INDEED', 'MONSTER'] + assert cfg['search']['province_or_state'] == 'TESTPS' + assert cfg['search']['city'] == 'TestCity' + assert cfg['search']['radius'] == 42 + assert cfg['search']['keywords'] == ['I', 'Am', 'Testing'] + assert cfg['search']['max_listing_days'] == 44 + assert cfg['search']['company_block_list'] == ['Blocked Company', + 'Blocked Company 2'] + if '-log-level' in argv: + # NOTE: need to always pass log level in same place for this cdtn + assert cfg['log_level'] == argv[4] + else: + assert cfg['log_level'] == 'INFO' + if '--no-scrape' in argv: + assert cfg['no_scrape'] + else: + assert not cfg['no_scrape'] + if '--similar-results' in argv: + assert cfg['search']['similar_results'] + else: + assert not cfg['search']['similar_results'] + + assert cfg['delay']['algorithm'] == 'LINEAR' + assert cfg['delay']['max_duration'] == 8 + assert cfg['delay']['min_duration'] == 2 + assert cfg['delay']['random'] + assert cfg['delay']['converging'] diff --git a/tests/config/test_delay.py b/tests/config/test_delay.py index d3b7fbfb..011d26a9 100644 --- a/tests/config/test_delay.py +++ b/tests/config/test_delay.py @@ -1 +1,39 @@ -# FIXME \ No newline at end of file +"""Test the DelayConfig +""" +import pytest + +from jobfunnel.config import DelayConfig +from jobfunnel.resources import DelayAlgorithm + + +@pytest.mark.parametrize("max_duration, min_duration, invalid_dur", [ + (1.0, 1.0, True), + (-1.0, 1.0, True), + (5.0, 0.0, True), + (5.0, 1.0, False), +]) +@pytest.mark.parametrize("random, converge, invalid_rand", [ + (True, True, False), + (True, False, False), + (False, True, True), +]) +@pytest.mark.parametrize("delay_algorithm", (DelayAlgorithm.LINEAR, None)) +def test_delay_config_validate(max_duration, min_duration, invalid_dur, + delay_algorithm, random, converge, invalid_rand): + """Test DelayConfig + TODO: test messages too + """ + cfg = DelayConfig( + max_duration=max_duration, + min_duration=min_duration, + algorithm=delay_algorithm, + random=random, + converge=converge, + ) + + # FUT + if invalid_dur or not delay_algorithm or invalid_rand: + with pytest.raises(ValueError): + cfg.validate() + else: + cfg.validate() diff --git a/tests/config/test_proxy.py b/tests/config/test_proxy.py index e69de29b..20261d62 100644 --- a/tests/config/test_proxy.py +++ b/tests/config/test_proxy.py @@ -0,0 +1,3 @@ +# FIXME +# def test_proxy_config(protocol, ip_address, port): +# pass diff --git a/tests/config/test_search.py b/tests/config/test_search.py index 2cf0e48b..c0787e3a 100644 --- a/tests/config/test_search.py +++ b/tests/config/test_search.py @@ -1,46 +1,11 @@ -"""Test the config library +"""Test the search config """ import pytest -from jobfunnel.config import (DelayConfig, JobFunnelConfigManager, ProxyConfig, - SearchConfig) -from jobfunnel.resources import DelayAlgorithm +from jobfunnel.config import SearchConfig from jobfunnel.resources import Locale -@pytest.mark.parametrize("max_duration, min_duration, invalid_dur", [ - (1.0, 1.0, True), - (-1.0, 1.0, True), - (5.0, 0.0, True), - (5.0, 1.0, False), -]) -@pytest.mark.parametrize("random, converge, invalid_rand", [ - (True, True, False), - (True, False, False), - (False, True, True), -]) -@pytest.mark.parametrize("delay_algorithm", (DelayAlgorithm.LINEAR, None)) -def test_delay_config_validate(max_duration, min_duration, invalid_dur, - delay_algorithm, random, converge, invalid_rand): - """Test DelayConfig - TODO: test messages too - """ - cfg = DelayConfig( - max_duration=max_duration, - min_duration=min_duration, - algorithm=delay_algorithm, - random=random, - converge=converge, - ) - - # FUT - if invalid_dur or not delay_algorithm or invalid_rand: - with pytest.raises(ValueError): - cfg.validate() - else: - cfg.validate() - - @pytest.mark.parametrize("keywords, exp_query_str", [ (['b33f', 'd3ad'], 'b33f d3ad'), (['trumpet'], 'trumpet'), @@ -93,40 +58,3 @@ def test_search_config_init(mocker, locale, domain, exp_domain): # Assertions assert cfg.domain == exp_domain - - -# TODO: implement once we add validation to ProxyConfig -# def test_proxy_config(protocol, ip_address, port): -# pass - -# FIXME: need to break down config manager stuff, perhaps it shouldn't be -# creating the paths in it's init. Makes this test complicated. -# @pytest.mark.parametrize('pass_del_cfg', (True, False)) -# def test_config_manager_init(mocker, pass_del_cfg): -# """NOTE: unlike other configs this one validates itself on creation -# """ -# # Mocks -# patch_del_cfg = mocker.patch('jobfunnel.config.manager.DelayConfig') -# patch_os = mocker.patch('jobfunnel.config.manager.os') -# patch_os.path.exists.return_value = False # check it makes all paths -# mock_master_csv = mocker.Mock() -# mock_block_list = mocker.Mock() -# mock_dupe_list = mocker.Mock() -# mock_cache_folder = mocker.Mock() -# mock_search_cfg = mocker.Mock() -# mock_proxy_cfg = mocker.Mock() -# mock_del_cfg = mocker.Mock() - -# # FUT -# cfg = JobFunnelConfigManager( -# master_csv_file=mock_master_csv, -# user_block_list_file=mock_block_list, -# duplicates_list_file=mock_dupe_list, -# cache_folder=mock_cache_folder, -# search_config=mock_search_cfg, -# delay_config=mock_del_cfg if pass_del_cfg else None, -# proxy_config=mock_proxy_cfg, -# log_file='', # TODO optional? -# ) - -# # Assertions From 28e061f3f60fc4e37bb922f8b1d5aa0011323249 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 17:07:35 -0400 Subject: [PATCH 58/66] missed files --- tests/config/test_manager.py | 30 ++++++++++++++++++++++++++++++ tests/data/test_config.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/config/test_manager.py create mode 100644 tests/data/test_config.yml diff --git a/tests/config/test_manager.py b/tests/config/test_manager.py new file mode 100644 index 00000000..a1ac20e3 --- /dev/null +++ b/tests/config/test_manager.py @@ -0,0 +1,30 @@ +# FIXME: need to break down config manager testing a bit more +# @pytest.mark.parametrize('pass_del_cfg', (True, False)) +# def test_config_manager_init(mocker, pass_del_cfg): +# """NOTE: unlike other configs this one validates itself on creation +# """ +# # Mocks +# patch_del_cfg = mocker.patch('jobfunnel.config.manager.DelayConfig') +# patch_os = mocker.patch('jobfunnel.config.manager.os') +# patch_os.path.exists.return_value = False # check it makes all paths +# mock_master_csv = mocker.Mock() +# mock_block_list = mocker.Mock() +# mock_dupe_list = mocker.Mock() +# mock_cache_folder = mocker.Mock() +# mock_search_cfg = mocker.Mock() +# mock_proxy_cfg = mocker.Mock() +# mock_del_cfg = mocker.Mock() + +# # FUT +# cfg = JobFunnelConfigManager( +# master_csv_file=mock_master_csv, +# user_block_list_file=mock_block_list, +# duplicates_list_file=mock_dupe_list, +# cache_folder=mock_cache_folder, +# search_config=mock_search_cfg, +# delay_config=mock_del_cfg if pass_del_cfg else None, +# proxy_config=mock_proxy_cfg, +# log_file='', # TODO optional? +# ) + +# # Assertions diff --git a/tests/data/test_config.yml b/tests/data/test_config.yml new file mode 100644 index 00000000..b47804a5 --- /dev/null +++ b/tests/data/test_config.yml @@ -0,0 +1,28 @@ +master_csv_file: TEST_search +cache_folder: TEST_cache +block_list_file: TEST_block_list +duplicates_list_file: TEST_duplicates_list +log_file: TEST_log_file +search: + locale: CANADA_ENGLISH + providers: + - INDEED + - MONSTER + province_or_state: TESTPS + city: TestCity + radius: 42 + keywords: + - I + - Am + - Testing + max_listing_days: 44 + company_block_list: + - "Blocked Company" + - "Blocked Company 2" +log_level: INFO +delay: + algorithm: LINEAR + max_duration: 8 + min_duration: 2 + random: True + converging: True From 39d926f640c7e87435f9717f67f0c290c364e983 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 17:17:45 -0400 Subject: [PATCH 59/66] Fix directory creation for cache folder --- jobfunnel/__main__.py | 1 + jobfunnel/backend/jobfunnel.py | 3 +-- jobfunnel/config/manager.py | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 4760c59e..116ee37e 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -17,6 +17,7 @@ def main(): # Build config manager funnel_cfg = get_config_manager(cfg_dict) + funnel_cfg.create_dirs() # Init job_funnel = JobFunnel(funnel_cfg) diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index c8138d90..edbeed55 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -38,10 +38,9 @@ def __init__(self, config: JobFunnelConfigManager) -> None: Args: config (JobFunnelConfigManager): config object containing paths etc. """ + config.validate() # NOTE: this ensures logger gets a good path super().__init__(level=config.log_level, file_path=config.log_file) self.config = config - self.config.create_dirs() - self.config.validate() self.__date_string = date.today().strftime("%Y-%m-%d") self.master_jobs_dict = {} # type: Dict[str, Job] diff --git a/jobfunnel/config/manager.py b/jobfunnel/config/manager.py index 7ed10c0e..975e2c38 100644 --- a/jobfunnel/config/manager.py +++ b/jobfunnel/config/manager.py @@ -103,12 +103,13 @@ def create_dirs(self) -> None: """Create the directories for attributes which refer to files / folders NOTE: should be called before we validate() """ - for path_attr in [self.master_csv_file, self.user_block_list_file, - self.duplicates_list_file, self.cache_folder, - self.log_file]: - output_dir = os.path.dirname(os.path.abspath(path_attr)) + for file_path in [self.master_csv_file, self.user_block_list_file, + self.duplicates_list_file, self.log_file]: + output_dir = os.path.dirname(os.path.abspath(file_path)) if not os.path.exists(output_dir): os.makedirs(output_dir) + if not os.path.exists(self.cache_folder): + os.makedirs(self.cache_folder) def validate(self) -> None: """Validate the config object i.e. paths exit From 0ebaf86bb43359272e88ea9bf522965209052951 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 17:21:12 -0400 Subject: [PATCH 60/66] Update travis to use new command format --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 92a7ff86..f366c2cb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,10 @@ install: before_script: - 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' script: - - 'funnel -s demo/settings.yaml -log-level DEBUG' + - 'funnel load -s demo/settings.yaml -log-level DEBUG' # NOTE: we might want to make below search somewhere else so it isn't # so very specific. - - 'funnel -s demo/settings.yaml -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG' + - 'funnel load -s demo/settings.yaml -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG' - 'pytest --cov=jobfunnel --cov-report=xml' # - './tests/verify-artifacts.sh' TODO: verify that JSON exist and are good # - './tests/verify_time.sh' TODO: some way of verifying execution time From 8fcbe5d3846082b14d8fb8f3c1cdf8a8f3340325 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 18:39:31 -0400 Subject: [PATCH 61/66] simplify readme.md and remove some old demos I dont want to maintain --- demo/assests/demo.gif | Bin 344848 -> 0 bytes demo/{assests => }/demo.png | Bin demo/readme.md | 15 -- docs/crontab/cronjob.sh | 2 +- docs/pycharm/images/debug_configurations.png | Bin 46889 -> 0 bytes docs/pycharm/images/pycharm.png | Bin 160721 -> 0 bytes docs/pycharm/images/pycharm_banner.png | Bin 45831 -> 0 bytes docs/pycharm/images/svg/pycharm.svg | 71 ---------- docs/pycharm/images/svg/pycharm_banner.svg | 37 ----- docs/pycharm/readme.md | 20 --- readme.md | 142 ++++++------------- requirements.txt | 4 +- 12 files changed, 51 insertions(+), 240 deletions(-) delete mode 100644 demo/assests/demo.gif rename demo/{assests => }/demo.png (100%) delete mode 100644 demo/readme.md delete mode 100644 docs/pycharm/images/debug_configurations.png delete mode 100644 docs/pycharm/images/pycharm.png delete mode 100644 docs/pycharm/images/pycharm_banner.png delete mode 100644 docs/pycharm/images/svg/pycharm.svg delete mode 100644 docs/pycharm/images/svg/pycharm_banner.svg delete mode 100644 docs/pycharm/readme.md diff --git a/demo/assests/demo.gif b/demo/assests/demo.gif deleted file mode 100644 index 275a17480eadc9d634b6d351a6ad0af08576d736..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 344848 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 D1H@FI diff --git a/demo/assests/demo.png b/demo/demo.png similarity index 100% rename from demo/assests/demo.png rename to demo/demo.png diff --git a/demo/readme.md b/demo/readme.md deleted file mode 100644 index f1369f9a..00000000 --- a/demo/readme.md +++ /dev/null @@ -1,15 +0,0 @@ -To try this demo simply open a terminal on this folder and type: - -```bash -funnel -s settings.yaml -``` - -Feel free to copy the settings file to configure your own job searches.
-If installed via `pip`, the `funnel` command can be called from any folder in your system. - -__*Note*__: JobFunnel prioritizes Linux development.
-Therefore, naturally Jobfunnel works best in Linux.
-It has been tested on other operating systems such as Windows.
-Get in touch with the developers if you would like to maintain macOS or Windows support. - -![Demo GIF](assests/demo.gif) diff --git a/docs/crontab/cronjob.sh b/docs/crontab/cronjob.sh index d1bc0d07..36b0bb04 100755 --- a/docs/crontab/cronjob.sh +++ b/docs/crontab/cronjob.sh @@ -7,6 +7,6 @@ do if [ -d "$DUMP/$location" ] && echo "funnel scaping job for $location @ $(date +"%T")" then cd $DUMP/$location && -~/.local/bin/funnel -s settings.yaml > cronjob.log 2>&1 +~/.local/bin/funnel load -s settings.yaml > cronjob.log 2>&1 fi done diff --git a/docs/pycharm/images/debug_configurations.png b/docs/pycharm/images/debug_configurations.png deleted file mode 100644 index 2bdb8c3a4f177cef59665112788ea9e54a82946d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46889 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⋘rqWW1tSZ|GH^M4eiRPsnz3(@ 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 D+^_@^ diff --git a/docs/pycharm/images/pycharm_banner.png b/docs/pycharm/images/pycharm_banner.png deleted file mode 100644 index b28385a8e3deb8e8a9c2aef59388755a17f8c3d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45831 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/pycharm/images/svg/pycharm_banner.svg b/docs/pycharm/images/svg/pycharm_banner.svg deleted file mode 100644 index fffd8817..00000000 --- a/docs/pycharm/images/svg/pycharm_banner.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/pycharm/readme.md b/docs/pycharm/readme.md deleted file mode 100644 index 89752f83..00000000 --- a/docs/pycharm/readme.md +++ /dev/null @@ -1,20 +0,0 @@ -PyCharm - -This document is a guide to developing this project with PyCharm. - -### Installing PyCharm - -Go to the JetBrains website for instructions on how to install.
-https://www.jetbrains.com/help/pycharm/installation-guide.html - -### Installing Pipenv - -Go to the Jetbrains website for instructions on how to install and set up pipenv.
-https://www.jetbrains.com/help/pycharm/pipenv.html - -### Setting Debug Configurations - -Do not follow these fields exactly. However, if you go to _Run->Edit Configurations..._ -make a new run configuration by clicking _+_. Click _Python_ and fill in the fields -as shown below.
-Debug Configurations diff --git a/readme.md b/readme.md index de7ec49c..d83fcbe0 100644 --- a/readme.md +++ b/readme.md @@ -1,107 +1,66 @@ -JobFunnel Banner

+JobFunnel Banner
[![Build Status](https://travis-ci.com/PaulMcInnis/JobFunnel.svg?branch=master)](https://travis-ci.com/PaulMcInnis/JobFunnel) [![Code Coverage](https://codecov.io/gh/PaulMcInnis/JobFunnel/branch/master/graph/badge.svg)](https://codecov.io/gh/PaulMcInnis/JobFunnel) Automated tool for scraping job postings into a `.csv` file. ----- -__*Note (Sept 5 2020)*__: If you are having trouble scraping jobs on current release, please try `ABCJobFunnel` branch and report any bugs you encounter! Current known issues discussion in thread here: [#90](https://github.com/PaulMcInnis/JobFunnel/pull/90) - -Install this branch via: -``` -git clone git@github.com:PaulMcInnis/JobFunnel.git jobfunnelabc -cd jobfunnelabc -git checkout ABCJobFunnel -cd ../ -pip install -e jobfunnelabc -``` ----- - ### Benefits over job search sites: * Never see the same job twice! -* Browse all search results at once, in an easy to read/sort spreadsheet. -* Keep track of all explicitly new job postings in your area. +* No advertising. * See jobs from multiple job search websites all in one place. -* Compare job search results across locations - -The spreadsheet for managing your job search: +* Compare job search results between locations, and queries. ![masterlist.csv][masterlist] ----- - -### Installation -_JobFunnel requires [Python][python] 3.6 or later._ +# Installation -All dependencies are listed in `setup.py`, and can be installed automatically with `pip`. +_JobFunnel requires [Python][python] 3.8 or later._ ``` pip install git+https://github.com/PaulMcInnis/JobFunnel.git ``` -If you want to develop JobFunnel, you can install it in-place: - -``` -git clone git@github.com:PaulMcInnis/JobFunnel.git -pip install -e ./JobFunnel -``` - ----- - -### Using JobFunnel +# Usage After installation you can search for jobs with YAML configuration files or by passing command arguments. -Run the below commands to perform a demonstration job search that saves results in your local directory within a folder called `demo_job_search_results`. +## Configuring +Begin by customizing our [demo settings][demo_yaml] to suit your needs: ``` -wget https://www.github.com/PaulMcInnis/JobFunnel/demo/settings.yaml -funnel load -s settings.yaml +wget https://raw.githubusercontent.com/PaulMcInnis/JobFunnel/master/demo/settings.yaml -O my_settings.yaml +nano my_settings.yaml ``` -If you would prefer to use the extensive CLI arguments in-place of a configuration -YAML file, review the command structure by running the below command: +_NOTE: It is recommended to provide as few search keywords as possible (i.e. try using `AI`, `Python` instead of `Software`, `Developer`, `Python`, `AI`)._ + +## Scraping +Run `funnel` to populate your master CSV file with jobs: ``` -funnel custom -h +funnel load -s my_settings.yaml ``` -The recommended approach is to build your own `settings.yaml` file from the example provided in [demo/readme.md][demo] and run `funnel load -s ` - ----- - -### Reviewing Results - -Follow these steps to continuously-improve your job search results CSV: - -1. Set your job search preferences in a `yaml` configuration file. -2. Run `funnel load -s ...` to scrape all-available job listings. -3. Review jobs in the master-list CSV, and update the job `status` to reflect your interest or progression: `interested`, `applied`, `interview` or `offer`. -4. Set any a job `status` to `archive`, `rejected` or `delete` to remove them from the `.csv`. ___Note: listings you filter away by `status` are persistant___ +## Reviewing ----- +Open the master CSV file and update the jobs' `status`: -### Job Statuses +* Set to `interested`, `applied`, `interview` or `offer` to reflect interest or progression on the job. -_NOTE: `status` values are not case-sensitive_ +* Set to `archive`, `rejected` or `delete` to remove a job from the `.csv` permanently (for this search). You can review 'blocked' jobs within your `block_list_file`. -| Status | Purpose | -| ------ | ------------- | -| `NEW` | The job has been freshly scraped, likely un-reviewed. | -| `ARCHIVE`, `REJECTED`, `DELETE`, `OLD` | The job will be added to filter lists and will not appear in CSV again. You can see any jobs which have been added to your filter lists by reviewing your `block_list_file` JSON. | -| `INTERESTED`, `APPLY`, `APPLIED`, `ACCEPTED`, `INTERVIEWED`, `INTERVIEWING` | Use these to boost visibility of desirable jobs or to track progress. | +By combining regular scraping with regular reviewing, you can cut through the noise of even the busiest job markets. ----- - -### Advanced Usage +# Advanced Usage * **Managing Multiple Searches**
- JobFunnel works best if you keep distinct searches in their own `.csv` files, i.e.: + JobFunnel works best if you keep distinct searches in their own distinct `.csv` files, i.e.: ``` - funnel custom -kw Python -c Waterloo -ps ON -l CANADA_ENGLISH -o canada_python - funnel custom -kw AI Machine Learning -c Seattle -ps WA -l USA_ENGLISH -o USA_ML + funnel custom -kw Python ... -csv python_jobs.csv + + funnel custom -kw AI Machine Learning ... -csv ml_jobs.cs ``` * **Automating Searches**
@@ -109,25 +68,16 @@ _NOTE: `status` values are not case-sensitive_ For more information see the [crontab document][cron_doc]. * **Writing your own Scrapers**
- If you have a job website you'd like to write a scraper for, you are welcome to implement it, Review the [BaseScraper][BaseScraper] for implementation details. + If you have a job website you'd like to write a scraper for, you are welcome to implement it, Review the [Base Scraper][basescraper] for implementation details. -* **Adding Support for X Language Job Website**
- JobFunnel supports scraping jobs from the same job website across differnt locales. If you are interested in adding support, you may only need to define session headers and domain strings, Review the [BaseScraper][BaseScraper] for further implementation details. - -* **Recovering Lost Master-list**
- JobFunnel can re-build your master CSV from your search's scrape cache, where all the historic scrape data is located: - ``` - funnel --recover load -s my_search_settings.yaml - ``` +* **Adding Support for X Language / Job Website**
+ JobFunnel supports scraping jobs from the same job website across locales & domains. If you are interested in adding support, you may only need to define session headers and domain strings, Review the [Base Scraper][basescraper] for further implementation details. -* **Filtering Undesired Companies**
+* **Blocking Companies**
Filter undesired companies by adding them to your `company_block_list` in your YAML or pass them by command line as `-cbl`. * **Filtering Old Jobs**
- You can configure the maximum age of scraped listings (in days) by setting `max_listing_days` in your YAML, or by passing: - ``` - funnel -max-listing-days 30 - ``` + You can configure the maximum age of scraped listings (in days) by configuring `max_listing_days`. * **Reviewing Jobs in Terminal**
You can review the job list in the command line: @@ -135,27 +85,29 @@ _NOTE: `status` values are not case-sensitive_ column -s, -t < master_list.csv | less -#2 -N -S ``` -* **Saving Duplicates**
- It is recommended that you save duplicate jobs detected via content match to ensure detections persist. You can configure this path via `duplicates_list_file` in YAML or by passing command line: - ``` - funnel -dl my_duplicates_list.json - ``` - * **Respectful Delaying**
- Respectfully scrape your job posts with our built-in delaying algorithm, which can be configured using a config file (see `JobFunnel/jobfunnel/config/settings.yaml`) or with command line arguments: - - `-delay-max` lets you set your max delay value in seconds. - - `-delay-min` lets you set a minimum delay value in seconds.
_NOTE: must be smaller than maximum_ - - `--delay-random` lets you specify if you want to use random delaying, and uses `-delay-max` to control the range of randoms we pull from. - - `--delay-converging` specifies converging random delay, which is an alternative mode of random delay.
_NOTE: this is intended to be used in combination with `--delay-random`_ - - `-delay-algorithm` can be used to set which mathematical function (`constant`, `linear`, or `sigmoid`) is used to calculate delay. + Respectfully scrape your job posts with our built-in delaying algorithms. To better understand how to configure delaying, check out [this Jupyter Notebook][delay_jp] which breaks down the algorithm step by step with code and visualizations. +* **Recovering Lost Data**
+ JobFunnel can re-build your master CSV from your `cache_folder` where all the historic scrape data is located: + ``` + funnel --recover ... + ``` + +* **Running by CLI**
+ You can run JobFunnel using CLI only, review the command structure via: + ``` + funnel custom -h + ``` + -[masterlist]:demo/assests/demo.png "masterlist.csv" +[requirements]:requirements.txt +[masterlist]:demo/demo.png "masterlist.csv" +[demo_yaml]:demo/settings.yaml [python]:https://www.python.org/ -[demo]:demo/readme.md -[basescraper]:jobfunnel/backend/scraper/base.py +[basescraper]:jobfunnel/backend/scrapers/base.py [cron]:https://en.wikipedia.org/wiki/Cron [cron_doc]:docs/crontab/readme.md [conc_fut]:https://docs.python.org/dev/library/concurrent.futures.html#concurrent.futures.ThreadPoolExecutor diff --git a/requirements.txt b/requirements.txt index 94a51871..f93c4ee9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,6 @@ scipy>=1.4.1 pytest>=5.3.1 pytest-mock>=3.1.1 selenium>=3.141.0 -webdriver-manager>=2.4. \ No newline at end of file +webdriver-manager>=2.4.0 +Cerberus>=1.3.2 +tqdm>=4.47.0 From d89991eaea163fd038342354ad3075673cb5ae3f Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 19:54:08 -0400 Subject: [PATCH 62/66] Add versioning to cache files, cleanup logo files, fix block list default and travis build --- .travis.yml | 6 +++--- jobfunnel/backend/job.py | 6 ++---- jobfunnel/backend/jobfunnel.py | 29 +++++++++++++++++-------- jobfunnel/backend/scrapers/base.py | 32 +++++++++++++++------------- jobfunnel/config/cli.py | 1 + logo/jobfunnel.png | Bin 86535 -> 0 bytes logo/{svg => }/jobfunnel.svg | 0 logo/jobfunnel_banner.png | Bin 24350 -> 0 bytes logo/{svg => }/jobfunnel_banner.svg | 0 readme.md | 12 ++--------- 10 files changed, 45 insertions(+), 41 deletions(-) delete mode 100644 logo/jobfunnel.png rename logo/{svg => }/jobfunnel.svg (100%) delete mode 100644 logo/jobfunnel_banner.png rename logo/{svg => }/jobfunnel_banner.svg (100%) diff --git a/.travis.yml b/.travis.yml index f366c2cb..16d9dcc9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,10 @@ install: before_script: - 'flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics' script: + # Run CANADA_ENGLISH demo by settings YAML - 'funnel load -s demo/settings.yaml -log-level DEBUG' - # NOTE: we might want to make below search somewhere else so it isn't - # so very specific. - - 'funnel load -s demo/settings.yaml -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG' + # Run an american search by CLI + - 'funnel custom -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG -csv demo_job_search_results/demo_search.csv -cache demo_job_search_results/cache2 -blf demo_job_search_results/demo_block_list.json -dl demo_job_search_results/demo_duplicates_list.json -log-file demo_job_search_results/log.log' - 'pytest --cov=jobfunnel --cov-report=xml' # - './tests/verify-artifacts.sh' TODO: verify that JSON exist and are good # - './tests/verify_time.sh' TODO: some way of verifying execution time diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index 06599128..dec8f70b 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -1,11 +1,9 @@ """Base Job class to be populated by Scrapers, manipulated by Filters and saved to csv / etc by Exporter """ -import re -import string from copy import deepcopy -from datetime import date, datetime, timedelta -from typing import Any, Dict, List, Optional +from datetime import date, datetime +from typing import Dict, List, Optional from bs4 import BeautifulSoup diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index edbeed55..d9ab1f37 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -6,21 +6,18 @@ import logging import os import pickle -import sys -from concurrent.futures import ThreadPoolExecutor from datetime import date, datetime, timedelta from time import time -from typing import Dict, List, Optional, Tuple +from typing import Dict, List from requests import Session +from jobfunnel import __version__ from jobfunnel.backend import Job from jobfunnel.backend.tools import Logger from jobfunnel.backend.tools.filters import DuplicatedJob, JobFilter from jobfunnel.config import JobFunnelConfigManager -from jobfunnel.resources import (CSV_HEADER, MAX_BLOCK_LIST_DESC_CHARS, - MAX_CPU_WORKERS, - MIN_JOBS_TO_PERFORM_SIMILARITY_SEARCH, T_NOW, +from jobfunnel.resources import (CSV_HEADER, T_NOW, DuplicateType, JobStatus, Locale) @@ -98,7 +95,7 @@ def run(self) -> None: if self.master_jobs_dict: self.update_user_block_list() else: - logging.debug( + self.logger.debug( "No master-CSV present, did not update block-list: " f"{self.config.user_block_list_file}" ) @@ -276,7 +273,15 @@ def load_cache(self, cache_file: str) -> Dict[str, Job]: f"{cache_file} not found! Have you scraped any jobs today?" ) else: - jobs_dict = pickle.load(open(cache_file, 'rb')) + cache_dict = pickle.load(open(cache_file, 'rb')) + jobs_dict = cache_dict['jobs_dict'] + version = cache_dict['version'] + if version != __version__: + # NOTE: this may be an error in the future + self.logger.warning( + "Loaded jobs cache has version mismatch! " + f"cache version: {version}, current version: {__version__}" + ) self.logger.info( f"Read {len(jobs_dict.keys())} jobs from previously-scraped " f"jobs cache: {cache_file}." @@ -302,7 +307,13 @@ def write_cache(self, jobs_dict: Dict[str, Job], cache_file = cache_file if cache_file else self.daily_cache_file for job in jobs_dict.values(): job._raw_scrape_data = None - pickle.dump(jobs_dict, open(cache_file, 'wb')) + pickle.dump( + { + 'version': __version__, + 'jobs_dict': jobs_dict, + }, + open(cache_file, 'wb'), + ) self.logger.debug( f"Dumped {len(jobs_dict.keys())} jobs to {cache_file}" ) diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index 75df55f9..abc2916a 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -237,17 +237,16 @@ def scrape(self) -> Dict[str, Job]: ) ) - # Loops through futures as completed and removes if successfully parsed - # For each job-soup object, scrape the soup into a Job (w/o desc.) + # For each job-soup object, scrape the soup into a Job (w/o desc.) for future in tqdm(as_completed(futures), total=n_soups): job = future.result() if job: - # Handle duplicates that exist within the scraped data itself. - # NOTE: if you see alot of these our scrape for key_id is bad + # Handle inter-scraped data duplicates by key. + # TODO: move this functionality into duplicates filter if job.key_id in jobs_dict: self.logger.error( - f"Job {job.title} and {jobs_dict[job.key_id].title} " - f"share duplicate key_id: {job.key_id}" + f"Job {job.title} and {jobs_dict[job.key_id].title}" + f" share duplicate key_id: {job.key_id}" ) jobs_dict[job.key_id] = job @@ -326,27 +325,30 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float, except Exception as err: + # TODO: we should really dump the soup object to an XML file + # so that users encountering bugs can submit it and we can + # quickly fix any failing scraping. + if field in self.min_required_job_fields: raise ValueError( "Unable to scrape minimum-required job field: " - f"{field.name} Got error:{str(err)}" + f"{field.name} Got error:{str(err)}. {job.url}" ) else: # Crash out gracefully so we can continue scraping. self.logger.warning( f"Unable to scrape {field.name.lower()} for job:" - f"\n\t{str(err)}" + f"\n\t{str(err)}. {job.url}" ) - # Log the job url if we have it. - # TODO: we should really dump the soup object to an XML file - # so that users encountering bugs can submit it and we can - # quickly fix any failing scraping. - if job.url: - self.logger.debug(f"Job URL was {job.url}") # Validate job fields if we got something if job: - job.validate() + try: + job.validate() + except Exception as err: + # Bad job scrapes can't take down execution! + self.logger.error(f"Job failed validation: {err}") + return None return job # pylint: enable=no-member diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index 371439ef..d207def0 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -167,6 +167,7 @@ def parse_cli(args: List[str]) -> Dict[str, Any]: type=str, dest='search.company_block_list', nargs='+', + default=[], help='List of company names to omit from all search results ' '(i.e. SpamCompany, Cash5Gold).', required=False, diff --git a/logo/jobfunnel.png b/logo/jobfunnel.png deleted file mode 100644 index 02b7dd020607d5ae40ae213f046af925022b3f43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86535 zcmYg%18}54*KV+}oouwR?M$$-ZQHiB$;P(1v7Jn8+qP{dH{bWyfA5{Du6d`c>%6Cr z`t)-wJo3Yf{S{GP;d7EyOrvNLsdGjKEkadUH{x3INxGBU6?p|^81%edtE z2?Fv9L_%0V**)`o!_6CgVYT~aE3NY6?~nLtFQwnV|M-K2LkQDChc9h*=*}#kvAt4b zDxZgUXjj8(E69iE<`U!A8Wc2>Cf;XnWF38wC`+u6)~9VS>hZsryP8e%gk^Y`PG;WW z{T(obk+2)~u<2M9rJUD~CHU?23l$pt)?a&@VO#94=J~Jp44Qd>33;$}tvZ-^8ovL4 z;mwwE>mt#h~^9%D)&d+N|*mlQDkApUul3X2H{#kPnFYE~CaFm(mNj5x4o82MZ2azAC! zZ;behaa`yoBFaNLV!#lOCQpl2fK+Fe4XP6ki4U_JWJIsFgWd7e`7g9Z#sasNxRH(EPH zLgzxIWd{KV%gz-UGh)C`Rj^t8PcU%s92ybjL`cW_|C{YTbL{z@TfX9eC;OlM1|k2K z$ZxSpMnEw2eGWAm>NsMrVeRwKe_2A_7p^xd`J?C_$V4A8pa}6liv{GqC&Pau)f?5Q z(|*=|GiX@%v>y)**77XbR}yV1reCZ6ZbarBFg7n2Impf{vbsANXwzG2hz8Ou#T21OMJ}Sw2at>Cpcp*<5x~ z*ezSXR}wB*$pL_j7}zEJAGUg?Oo!w#{=Qqf!hZ{a|Gz@;|FxBhFND!<($dKRfc}4L ziS$zO`?g@b0SZ3y`mT8YcYSx`so$AD;du0({Lgl~{}TEiX$JjbNXPFZa)mRQ`yT;K z{YOBt^8H^>%hs-VN&6>f^XIu)gm$&+(Z=6fCll8$*1huwE_|yDL6$h@zi(IM_%Bn# zE|#msxkVY+f8@lfS_M0lObx#|E+qJ`0phnpE}IX=Veohdrq?b<8fnoc2P>FD>BH#! zg-h@ZEpkE4xx71FotluHTDEnSmKjiP%_TYBnhPB(gFOvAOLYxX?9m8bG_0K;mpo49 z9@9x>T@Owto_>o>fiMV|U6`0DHhVYFw3it^V{S@`W=9f7ys!Q75J3DGK6>>0?FZ-@ zFdK@6nGg<4?CW0`BLAj}={NnJvbq>w2)hCP9qBr7)Aw_RvYUGFe%(M8yVW2I_UMa695Sxcqs_L7=pjQMnmmqpUavzkFScD2V&_m*an=&( z#7or@o<#Jpc*FK4;)y&*I3sVItc4QC`rU~y>KD|YClFx=zjLazm_xsnGM4l!tZtRL zPFPp;q_Qe1GZXc>Z20TQNVo`piU2>u^OlqguqEBA_pUq$T6!McEv2ZyXOROH>QD)0B_JwVYftRzQiH;X3e z=Or88ZQp@!(z+_l77a93p=f`0{Ebjg?|v!R((BTnb!-;mvx9C?4`&aR!AGbI7zhfE zno_}7toD*2TjjaRhshYQ*}Wx;?v_416^ha=PhT_1-^uDST9&%2-5iyF? ziM{OV4BV{~(i4m;Tf|43b_y6aTwmgTZDM~PaVu_N%Oor{FlJ9);S zi-l>eHg>dP;jgDsmmz7GeI~l7Z`W>Ar0(8Q>Hv%aM?2k5us6z&652*b*O^zSv>zKU6cQ^&n8}cW&7DzR6P$vo@ z8?J$_8bI#3pRA^(N!HkIkl``@soa;RBJ(z7a}E9}Ye}|U93=TIzbj$JjkqIPzx5GS zQSpKT6sp>E<5-$KcR!WDisO>p&r9rQQR8T+&HP*GxpMshvTV8HTrD@sJ&=>0u{CY< zbBE~VTv;GPU?ZF3>PV@i0cznGD>8a))_M5#Zc0??tKhLK>XfQg$0hZq!9hVInq5`! z3pdW0j)mjlXOP(UIW%O13~G8jT#0ZIKBX7yh$fj`@H!>inmZYeA6J`8G{hu%kkx8~ z^B{Hj22&C-yKo#68U7#g!En(6Cu1deUDL>XK{I6uY*EvS-w~I;KYE?vb=mpCWDW`l zAsg1c-~QYy!MpGr{>mQT{L7<4QF>?i=)`K~9`{dS$v6u$U4?&*%QHW77daWbbq9fb zfplrDEsSoR!}?OUePH@pxbD1)OSY9J0<6?=9i|y5`_W|NMjFeLh|H2I$Br=~a@BTE zOXe^e4%eM*g&j$4vFRtnYVwM}oJnf;gJdaXe$O@nGHtp7?nKgL5 zCn$B%V2Rn<8hY>SxNWaCv8ss42u_V>u_$Bt zz~pI>7S&3Rk7qqq4WD{$=veK(Au$?nHPgqo<-FfF&_uya*$seNHZ%Ud2xbj9gYaE> za_LyPVTH{Yg;!sb9D`P0OC}$)bp?qsILdA{>K|$dp4Vd-r6K2UMvfU;2i>8vNw5ck zttZfF$H#R5Y_p_swNhSAV|;u~GnJL$`SQ7TXt!mAj4F~wNg1tl)hSlHP&lF74`Nev zQHB+-OEs9-U?*xZHVV)}aY+<15bWk=_t{>8veN)&CCA?IE^>!!9L6>JgU#>l=We%7 zfqR1$ceNL-2P5S$G1qo8^{~vxQt_9vB@y1xwLshAk~ZIcv}S^3Z#Yaa7@#9-R($2!dkj5+0`a;)p;u%jBzSu>0KTj-{emRoIS}E~(9ks&w!rKR_ zNT#gZfszzg+k_0$pIEx51=s}h1RQoVBQ82E|A1Gn8EP;28DdR{B?{pOS_yAi2Y+x@ z#O+C9L?L%h*)ah z0VSul-Y~6F-X*OC!J1{WrHhSX5QIZrn)vgOe2(av+8@}px;~;YE74k=MiKfsVi4|k zM8dSQGZ{}ieiIIhBaNMJa=-fvOmSkI-hPK+w<4h)VwaJbvq)C)De2 z(bs0~Gm34A!1N573g4~VrgS_A5!fQC=Vx=tLy)q$$R|#3d3_%`{jNqEsy)SU#$=Z6{O5n*-q-izZ zJ|@Uv-xT24ug4NaX4=mL&_5B`b^Sdo$!sbDZ7Y&FdeW#54UgJ?EUqe$N%_7~V7V8Y z)W@m)xc>zyl_KIw>G?BN{i)+Xi{kO_N~IwE{Z3!1Z)EV!4)g(f`zllgiFm5k=ZaT` zEKP2e^~C#cdN9)qnbOj|?^9#j6(x@G3Zo2po|)NH7`@nsIB&|>`umY9L8mD!oo18a zs9DHV(~T$3x1nb_pUj0QVCe*H%sgYrluI6ir2jcj%efhg#0e4$b~xL&I$o~V(GNs% zv5@S=AzJ##q_3ZXA^`6q7&2DNP9@QmR_n{?4_ln&G#>L9d2V=9&*xF3Hs>mB7i5Jg1&;l7uR_@r&5?E%zha9 z#OHU#8!aPaGH#NR7C&y457(dLg*YVB5*wedm<_a{_It%A)}a(8j_uKH&6>4jzX{ydc?q3 z+e-2+(NnG1{#4Vm5x)Z^BNmP!kg*gdrP*{3EVlED!X*So@&$T9p2prN=8=OExh;6> z9IRduUcK#lr0r4X=C@*LxdOS%``3?YWx^Eae7c+8I-?PLO+d{TiiCB3yaPaNIH-tn zrbUFm&s#4H=;6xxP<^LmnXJG|B$OUrZ|p$UsKV-w!H6&4kh3ozrjHOvYyHjrT~#mw6@BJ1p2>Pl9aWB7l(Q z2A0UMmuWm{rV~+cd(kv8-tZ>%D!Q5BQ&5fY<7nM;ZCJ${K3IBupFaqBo~U$VV&)jI zRjb|CC;x$u;Qi{|u=NuZ3j?Yd&v=dgZ?QZW~b{ttQY$u95LG`0Jr39 z7{K^TdWlpP#vot0g&Y<9SCSGNQU-OQ@IWZ)#rD%KYGLn_j~3*6M+Dc?w4Ja*8BCIi z?O(85ohR zF>WOJ&MPZThZJygriPHDlCOtWlM<5?8PCsaoliWZv)007N-{qb<8tIu+a4QDe^3Gr zu?Kl>8S5{VcJ*{+jr0Rdg~djSiD9KJT0!majqef}%fk2wqZQpyV+`g6Fwc@QAdXNFtZx zYCRp8|=eE_%tllRrgbu|S?m^kK+Hf_KH0Un`Qv)m5d|)Ab^yI@`mV zZ2FEW5$*Gm!uZ9A9^U3k_u!bI=!x!EAPZge7gU@aU*(B6u{RDryIh)K@f!i{KW6*x zJ$?A1$%!(iS)3kqAsg5L(dpg~C(?XAUesA=_ze+_Xn(I4yHXrS2bSzlTaT7XE1TY^ zmkz#epbY5VuMmc@=2uvP*o>-0ot3gjQ=f|jBl@vRp7|m$BJ*i&E&JexyV4foW z`JG=em&^3TUR_4taq{3N9#iFutL7^TOE4$FCH@1cq|w(?;mr!vi3_1HCzH?7m29h< zn~ZC}q6uS0im0hvfY;D?*{g@N2FqWjUKGQ=j$>4TT*{U;r!vrjKhhA!dCxT8CwF4pRJ9P+pM*GFF)oDHg@(6zjFjf!4cAIAbEFQXd4b=qNYl<#>6p`MYt8G{{&G zE6sFlG^vd=6wt5x(&eXAEDcABowwJJ3 zn=LaEbEVT1j!2L#bM(}4xm|78Ss#&OzME`Iz3W*@#7J?tp)*oQ|FYS~`V#fMv0Lca zAYlPKSnC&UAwagyA8?4!)Vj>FRTiM0P)fQe6KDBH|CT~J_=zVym=E@5;A}-7?ckCd zQqQ}QX>)RH9i{{EIHjiaGptD+)u#5|k+5Pwi5|ncV!7w)KOS&w_Sq zB17m?&kB3vZJ1ag{SOT!MMBaOT=hCjTzz%ca4gH@effq{b!Rx%WowGgmbcJsje$$J zg`FbmUbav2S7G7JL$MSU9o12p7rA~t4(p7-2tts$AisfyW@?oM?{6SYUP9GODYM5M z3X#Hmp3Z2LAH9V^jG6v2)4hXRVMsat<1ZZl3q@Ja7Vhc3g@9W4Vr}Z@N^cha#E+iv z*{$Kn1E||;SY_Cxj$ag)U9p;#WKI_QX&_wSDWy^Q%hw_=WzRF&uUHzh-etHog*!xq z_?M@yYCHdA5-@4zPehyPLL2{;5ehQrMU+U5N9U2VYpHE>uJC@zb_Z{>IU1U`EFPW(4DFZ< zlDh$WPk7j0YW&282)$(_G8Rh0{l`I_YpMos!`ti`|6-+%li1d{*1n-=C2i$U8HVC< zv_UI4cE)x$8c5ejqFJB)>Q&o0bCd)9wZW){3uUj6K*ey}ud6Tj+vE(5r}O#-JO|jy zDLyE($giWU$W&~4_ZyKSnattQ>P1w1+!21;s-<0Lzv4V^EYQI!StYTd>UI)|uxfsB z2C`W=YxIC97hpC?jorPB!ACfXi&QMP6uESP>320I+q{L1f(Amu{>7 z&n`v@uHga~W&F?i5CA%S&6or}rUt-X>&h1NDM%sH<;1<{4bD84#sR zkIC<$v!2fa{KIeI>{o&+)=>v#TKpfgfY&GYF_cv6-wsZJQZ<_3B3m2dTd6}D43h?E zdP|xHUEkSRf14eX_B+5h^t~s-f$JhKvNBtzi?aHN*{lyZebWbdB#9=5X!$4dU@fYDj5A$WcWTNibW9hjrV`;HXv&yEh44Q9v@r^ zGq_Q>dO5x1o5ScORTL>RRQs5LzuT7E>LjkJvjny=Nxp;5*yGFx)Z!^hWf-$Ff4>esuTUpXHAgZ&k&B^=pHzfSe$e}$V z8j`^GcOF&~5*n63y{>l#rXBXtvC&g!__n^DpfEHpmvzqM$&8_xvEpAlh}XT!k|#qRQ^|%QN7;S(V6pqY z!blo%^zrnyy%yp}TKZ6~q!$sIRGoXem(K1*+m-x*8i|O)K;5T^MU2N}^d0PypnxRs z+xCC-a%-TLji{<;>2{6v#MTG#XoORh9gPKJ8~9h7kI#ccL*elr&s~w$D!$ZDNqUUB zQPp}U;|22KWcL%TEJiaphOT&Q4#4$&47HkOb@QdpzJMisM5xNOroxBLG*|pKXVc4u zr|Z*R7?;#w*OH)p<3Vi)2&1Z!x7+;)vx(it5W=b@2P@)qU(D(4plsm#A*N&lC~#1p z3?3Acvb17}&^zn!GptsGk^Gi)G7>M6q?=?uP^HDu$#z~|R5ZJLX`uW?Z)a)$1?(yF zLq^SxfbOG6_RT$Uux-+)+_M}*oJJwDK)7#@lETGfm3-SvKa6tiPg1Fz4g0V2SU0z! zwiF)V$&A3yRoYXQ4)xS>d9+zTF%~wV&K!oK8Ai$!FCGS|kdM{2eMl`8OG;4xsT#IR ztG4hUS)J_}=B(!rYn2AkV)?#+d*CI9`$!fQL5N!oDh?<-C@>;lLRuMSPCGK{=j8D) zk(dqf7ogdZnc5M#ulMa%V#m-Fi>}9Ne7?D_qK@pYwjLKt`F-b!kAeHGUgpkATQbB) zFNN|Z4#-2N)PovuC7@MD%w|a^rr;ASw6V0)nUnK+SNcg1awM?L{jT+!MkyAGhFiHk z%{TFL^C$7YNqR{mcg88+t!kO2Y-8(9`Lp2%-@uUFMy_vAi^*n=mYI>p%2!x%_Jr-R zIJ<_mGJ(>ijFJlndv_pV9zFgk4?R3@XUIX~oYIi(Ymfuark9NaYbdh^CywIU;L{s3 z1-m|VI&ahQF%J)b<+Yf%U=LQ>J$P63>3w28osel9;Ly!EEuj+pJV9FcjTHpUpw$MJ_Mo%Nze7H-XzbZVQ4o1HJN>`ir}%YRrq7uI<{UwTqnCps;3zY zjxCEJ86mxH?id`hz-x809>yKrU0Ha3pDsZw7fmHU<-88K5H*(@M1S(p8#lZ1i|S36 z8!f~eBy;|H8Zjk_t*bS9v*3|#!tGsxqV{@D&T?JUIg9`6Ik5bywK^`}l1K8SZ%79B z>7j|;=9MxICEr+>3ZCq@)glxcfPGLD>8Sm9M?eXhJQ45ca zQbv>Z>xdu{jc~*hC2lwGckA#p@ip6~T}=km4nqAZzGV zwHrFCt(1Y^2<<`)fZ)+^8K@KZxclrlYM-n;5*=@LpIq2S5Aio)VbK}dT8NBAyIi58 zvf_eD^`^+#TIi|&?FHxzge+*7&T`VunmG8oWNkHfs+TJUVaqnKJvSRSOy__VEG>|# zf3T>njR6QYh#R`|cxHE0!2!5_N!#WzanxfhFP9Bq&nAO%gP@5p)w0rd8Fz;_*d;OI zw%30H5C$PDJsN^i;Pqi{P?Gq3g}-YbpG*A#ZtHXmtZKvHsi*EA+k5W+CgImpGldrbQQhbxm zqtk{l17Qg8@jAV2Z~<5S$NL?anux^B*T>bz7jlEDX1~UNFA?ri0dp4+c`55p!beipvamJcNd={TG$^m z3ruA_j2usYQXC0882X}LpuX$OwvQa=1-1SDqIVHlJ)A$VP=cbD0+b&CozDVIOBDm( zycYVp_`{2$1?FEU7gTuP*PKbf-T(rE)Oq0W{7$SHOJ9>{Cr|aB2a?GQFWDiNt$~>x zZP<*?sVh4W%He(2Jt6`WBb$*!sCN4H?hN%p7Pml*w$|~r2u`iJcIZbTt!+((@o?Jl zDQTVTN8fJ&)Qe5FmQu;Y)w0GCH7ij2Y}ozIEj9|$7KAdND}ykS<;_mAN-=1*l5TfI zSJJgw{~HKS^weCMES{Z!~GiKg0##OU!RcxyyOh7k}15(;SHllJpBpA)J>M3YVss% zTu3kHWw%||fFkZGp1DRh2pa~KWkAg%OTiir>A_2ID3>$&=ZW7mm()q;`4?taL*kda z<4w41I7#$bL2!-DVa)nx*A%wL%RnakrMGoc5!(P&Ss0%XA)ycKUV&I{1(Rfv0}X}S zfL`yJN6wAO4Xn`GWos*(P7{6wKC&LN*x3f1hUdBEWB(zxX9Y-TF>|Y4JdoHAYHW@V z)aEsOzPL+_Z6_Zo8C-C;;%w%jL|@L0xVKl%l=&^H8K>eKCViI2D`P&D(xJes^`SuJ zQdnjA>JV9f+ziR|=~IE{KdTr-1n95JFy_gH@dgH1v^oV>gdOUP+gi?athTiNs@=lV6h&3yZ$flRSLVRS$Jrja3w8h_$q1{p z^R~k_b$8${)k4gO>5xfsEa#bJz6ff++cEHRulDRqq_;`&5&<_{>#o{2TxbOce={rL zA=>+O7CzaBDW*-=bMyK+z1dPm*}7uqVv$rD?oo>Iiwa$BD0(Myn1o@R_E3S6J__fr z7S~IQzCqxc)TY)4cEYtI#|=d7Il^b#eVH>FSipT>VJWGbjICg>j&i(&kC)PGt2 z!g6-~_g$n+3B4d`xM8-Yz4AYigzxwbz@3h-97ri!y4#3CE{ux)t-JErlCo23VITk* zXA`FJ3fR9#_pADuq8(J%>_vV;tI2rgNN<~l*1&hO8o_po-3WX`7F++fG1Nz$Ap}Hg zD?HyGywF;ugi@2*6L5l0!-#Hu`% zWXqV@-DVn)$0m+)(>nR$V{2W3fpt<`XV1E9w=pV;KqMAAET}5*;_8HFJWX2JJ*7|A zX}KUQf+xD;MJi78G@t)!)r#wT;2x+ysAy^nOBfgw5Ga+K6t>DUvRiTWuVTHMQXpcB31GSNnCp}HhpK2H0iW+ zl>^o=u+NK$=#aI(uVy|#(7|WD_4~eBwe$&`)tL3d(294j?udR?W zR6p?aWm>`U;|noOJddNBPj)P)`de-m=NpiX`vxy=u$;EC)mBbND(*gYGg2CLd=HLf zyY|ZL6;G*!<`p>h#>m5Gx{tp@J3ojLTh>zwLON<X3|7l8trHAYPSEduJ$8oC_W%yqf>_n^FO_yX zNWL@hq5OFg8l@aA>TIE)_*n5{*%?@GKdEB^Jy0$PnlQwxfIfn#(3JSw3-n}L^ZDgu zzZWvAPPU>N5g|?;`YJg&F^CcU21S?-VHchAR%ho1<^~xQgNJ6gRE`h)rV6BcN2olb zk7}zI+s|Uk!sGdRBr{!iwjl!kEy${EqduZ>uR0&Hf>M5>|3l%kdC zpB_I~Y~?fx2&(gBVfmwXPzEO#zRptEq`iD#GrlT3xeMkFNSicxn#h?4s@Zj`L)tTW zcrc*EA|jyA{B*|ZpSHxCH-}h0)3q$tR$90(jzp36mTnQ=wSDI&jbYI%9c2LpN-4#3 z^Ue?3bplhgY=Dvi9dT0U!o=?ax(?EP)u~T?yhq6~i#uaJ$K{o|naJJ(n0d($E%@&^ z$yC9MVsP2e{IZ{gh4j|pb~=-U7M#=Xj|LStPyKiJxWI{O`p-HKbJLaa0f&@2@K~$K zb{W2#kiT+wMvMMn*KcD&f@J8YBQ3&!%ng?PS=ABcywDK~j-lvrdgONk{Mdn-t;BBsLFG77u?$~&1)g^1w&yQ zPcMPXS?bf0;~&1QKEiXaJgenxxlQ()J$vp9#v_>*85%&v$C7H+Ns;76~3?5rEOTPO*mo)14!rZGn4 zF5!pHj_3qWW>D`2s+7NP^05;I)V{&ac)u=c8Jn5lpoVOmY|pOQB88hB7Xo8=?99J! z(>PqAmsPI8jZdF&35cXlGW8A%m0$boQuGzKUsATNh+Uu8Xfw4l_dHb`u5WRE(ST-a zqYpyAxLF{Xj;HDd#>WvuS6ot@z|rM~xP{RRtb|vE@ za}>?v^F`v$OVIgzETg#UO6u|~F7%DpuCKgncLo@4ylYU2h4lajcfSKHT+*=4n4J3~ z6bY{0bxVI@(oGFgRk34@u1`NGhq?X5xwFRm zpxz?$cCxVc4Et?U;$4o~=KeEL;K_2O@51ajyY+tQcfscRT}bX1aXZTa5x4V|$?$xW zI$ivjq3EsSB?Y9gWoaNv{`_gcccSfX^9o)b<99g~%>KM0F(-zj^OVQ2+kTxR^-&I-7v2bX{fWF7_>G<-SitEIW=!Ef z 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
^KB~Lo#IgW>4?~cf?ZXp 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§)2MbSTGPok`BA=mbmQ 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%0D7SPx&5;C{odig_v{n(wL_*XKn<~{1y>ZH4v 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 diff --git a/logo/svg/jobfunnel.svg b/logo/jobfunnel.svg similarity index 100% rename from logo/svg/jobfunnel.svg rename to logo/jobfunnel.svg diff --git a/logo/jobfunnel_banner.png b/logo/jobfunnel_banner.png deleted file mode 100644 index 11b8ee127192ed1718f421e84e61245d161f96ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24350 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ŽWFc_ga9#KBhHYh%aog?lU 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 wH~`=sfVV-Q4u`kAj&jJ9$*xc+6eF1b57U>|gm
+JobFunnel Banner
[![Build Status](https://travis-ci.com/PaulMcInnis/JobFunnel.svg?branch=master)](https://travis-ci.com/PaulMcInnis/JobFunnel) [![Code Coverage](https://codecov.io/gh/PaulMcInnis/JobFunnel/branch/master/graph/badge.svg)](https://codecov.io/gh/PaulMcInnis/JobFunnel) @@ -55,14 +55,6 @@ By combining regular scraping with regular reviewing, you can cut through the no # Advanced Usage -* **Managing Multiple Searches**
- JobFunnel works best if you keep distinct searches in their own distinct `.csv` files, i.e.: - ``` - funnel custom -kw Python ... -csv python_jobs.csv - - funnel custom -kw AI Machine Learning ... -csv ml_jobs.cs - ``` - * **Automating Searches**
JobFunnel can be easily automated to run nightly with [crontab][cron]
For more information see the [crontab document][cron_doc]. @@ -76,7 +68,7 @@ By combining regular scraping with regular reviewing, you can cut through the no * **Blocking Companies**
Filter undesired companies by adding them to your `company_block_list` in your YAML or pass them by command line as `-cbl`. -* **Filtering Old Jobs**
+* **Job Age Filter**
You can configure the maximum age of scraped listings (in days) by configuring `max_listing_days`. * **Reviewing Jobs in Terminal**
From b9771d253f583c7029278c2f194b6e412df654c4 Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 20:15:06 -0400 Subject: [PATCH 63/66] Update demo CSV image --- .gitignore | 1 + demo/demo.png | Bin 162537 -> 177813 bytes 2 files changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4fcbfeb3..773667c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Outputs *.csv +demo_job_search_results # IntelliJ/Pycharm configs .idea/ diff --git a/demo/demo.png b/demo/demo.png index 13cf1a756a8122d54352151a66e50c70d257f78f..60e915044a958d3e5b90b93c8a5f2c01ca65117c 100644 GIT binary patch literal 177813 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 literal 162537 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%0D-O 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�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%0D5ag z$<$R3BAMBGY={Vi>R}&So%CKtbMb6q5bif;+uIuEdUKjMQSxi`qSP~HcdAYYN5Ogf z7Y?Jw5E*oVNm`x!`m`M3w3ji%Fx1-0V_6EN{A!y#g8xk4rxp#j@2dcT?$Jaq8BupX z_tzPvo+n8HP@eBSp+Y`aZizsQRdM%$(y}goctic0UpbdotI2x2 z2=o4Ai;u*M)~CanHQ^tj1A+%n9+|csUcb9ig5r`h>HVaVjgn{HspDY$2)LzFx5L*`V__{9WyY?mZy zKuwLGe4_+Jy&o8E5WBO!rk>VZ8Q6}6(*qF=aq3XgvlpRiR&)4J2N2@>eIGm6Gsy1As&I3_H2oOd@4bBscM=b z=VlA7TC_9KMU$?Bm=5yZ+%wp@cmMO5F3?Lt9HA|Dkqn-GdXf9_3b4RTOkIE%T@El9 z*Ox-+VbEe8N$0G#f~9J7w14`wDewLc>HjVA)18bi7-M1jBrv3xr~YXKC&cs9mSEH8|+&5C<__unT9^ZKGFtT3|)3*>q2k)8w*Vu zx%;lK8Q}1J*Qs3YI_-I5EbT=nc5`;S^xGL*>(pn!$@clz?5qIzQt1MJZ@zC$$e2-w zyyIQUDFGvKxk`#`&$V05CECN+t4Ik4JK}OqqxO;NRo?^f*`YTg=baC-&Ju|&2<4mK zp78^QrxBECNh4R+xF1`FDtFtlI~R`>)*U z7$6?!h)Xg?+-Te8yT2s3#JTwaiqNc2(TVU|``nx)0?%aK#EW4x>c+)Izhd zAJ33>j$#hy^V^^Rk>`tsIiPgO7zG*1D;Hr#SrJGnCrSWmlJKW3D{F{3o> z2KFt)Y^y4Mc<}^0{OQ;&+$RjbBmh&*o}_-$DbnX~35nNoEp~O}g4QKXwhOh(}6> zwgug0n=QAq;>}=F`Ip1C>&_|d<@Fh^qe1C=J@*<8SJ`YJ6UqP{y%P$+$;fkifPdpQ z$hULPDr;b$UOJn45WGu$0JZH^{5k(M)DiVF;pr&#dHql;%XPskC}!Q%QR9 zWu#iOzOrfmDzc(ikGY0;E$ZNAp2Hh<3@L}@VDJ8J^_FhcNKpg|Ml|2RFKey5&mN2;p zjw^0{;zAOHK@$K%ciX>Z>r7YfmQ3^fmfM5%y7JLhuKaWk<`O~oAf}Pv?Y4qerZW;f z`3jKHkPIJf$&zz77ll%#j_VX zA~fou^9Js_Y%8IRrk*-nw12pdjr5Nj^NMN=gQLuOM>2#Hexp=qK%%ufx4ilg_=OcM zz-2>+`I-5wpqNPfWT&C_7_T(bs`ojm^ zek3OB)cyt9Wlo5D3n!N1*p5bOSXxCGeDKNRayGnJI`^wC$Hu{AF&PWiYEx#rFx>0e zr7+RgPaUG%RiivP0e=FEb1r#Kx_%m!`yBLlpQU8;SVu54ClTVb*fcT@W&Mu&gYxmO zk@$>J_YeJCa#6{xgwxP4zvcIglOA>~_<+k)Z2!3I_SAIQVZ)07seuAjh4#FF77#4U z9&vcF>j-^+zn;3{M2vze#jT-6j7=Pge0Zdg0XC22-PRgDlApyMv*aDugyV^w&6B^a zN&L%ci&SfRuElSy*=I6y1u~C;?qp~qnv-W5 zme3I->q@e3|4MJ9AR*!pmC-xLswf_xk3%a zH&KUDr@6#=WVq_oD()m|>TNGMmYTt75ce@Ao}(i-=jYN@k$X7NDBxt@#R^Jd5Ci4! zS%O~x(3nNFaZy2VbLrqZH{Lm&u#>tO4WHt((1QN-2xF)gUMWAizLJb`z^Pbc;3s)V zwP~)rPZ1{mypk((9hKL10iBCv$2XQgVj_!o=U=k;kiWeRUD!n^cPnL z#>>DgLATSm!V!=ocE!?-$mxR#EKUUmO z=AT&C!QQo}MqA)R)cerLDmQD`!sdC3>uk~D!@=y+&ioU3h+8=Sy~KgwtWEIgQHw2p z1th_(?diMZo#4`hNtqK=*l6k&pX$wg8kaTI3)^}bY8nUX%2hTMVi+>sRNh`XL|kMr z>V(U@#M(4E=a$u?dbxHkNpkPFzV24PK@@5S7lg9g4WAHBwa;g}UFhiTUs-eL%8n*D z&!FBAqFkr(F1Z-DY6t3fDaQ$uCGhfS_tYlWjj*WZB?dw>UrCcC-y^V+TZ0i%#}Ae) zsv{0WYvB7dUxXWkZ^~hqq0)@GU7%k7gE*MA^&v|;QF|msVI3+=7RDPzfS&u~etBm6 zq|^vUAq1t_bN%zrSo=2-H~1T_)a^&M<1z5!z-xu2%oemJ>%<)ZJPn{SEJVMQ9k|YY zCj3+veYmI8Hga*(Bz*&xuTzGRzIM5pPW4e#Paev0D=?2~S16=i|4*uQgu35>v)J!z|XXq?fYEJpaaTJNFQHCFDdG^O{?u zx|2%+x*S~U7iF+waLpy$&%P=5shaQHppWm@~}U zV%<8%awRnGq5_<)*;gn^TS=5QDNW3@4;}iR-<0Yn^&cqoXAKlJ!RvcVt!9)2n{3*m(dLxML*KG0Y| z&QN?oxi$~^t9V7c0(e}hrSZgN*SBgWgVR07u^b296P682eGqt_Ug2tnI}MR$RS~RO ztGqgyLl(L-8lpAt5I2Lfqn+&)v;TT!UcrM7h8!=qYSg1YzgcI-6;wzrpCT9Moyt0JQXHtNTje&Px2+;8DrV%Ku*ufjkY7(!p2G)_q`+@a5Hiv}gQJ-O; zKD)1aq@}RAML6~Af`U^yUohsR1LM2?`(DA|jyT_67+CjeH;1Dzi!FtP%juV$7rW80 z`fn@%O5@#eGgfQG41x3|3qH`~Go3t*_nPIZrS6wi9Q6WVx^f`k4;+8y`X)YB7}^*jeG8!?ZdV-mq5wABR8*ifTI*RLOj-p@Q8ud)BoWGTYpLN81; z=>Kv#{Ly*#cLd9;En|Q6tQo@Kd^1zBD}7O(;5FUaHe6b-t}kU!a=6jye-AwAFN47|KxczShd2r|3z*WWCSUu;23ZQP-S_&JWN0;N z3BuY~EQ;6zY!}}@J^Uy>E>-V)9_u~4Y%5%-%7cqM{r-aaTaFVavgca)%QH=&I?q34=dji zwO7kYqCQmz3Zu(|gu}(4l;{c!5S|sn#ph~rlJU{W{5&u!GYe8PSjSOyQD=fbrR+DLI|q8XM6M1Lzmps$~z-%jU)n zYfqUcmf1mNvE*nUnUlUs7ZPkFGFW5p#F2^o1#bzNq~r(fvV9YCmSsgu_5cBWtgN&E zgV7$+`H7FRKF5m{Ioq&QS(mqoDrt+j_L{?j-=;@AeVXLk&MQkBG+y(UsM7m1m(6dg z3#~ip>++Q=vE@3Xr{Y#>4jUHh&-Ze*w{(c~`|yl|oPiuUO4CT^Y98C-mP(IWD}fvB zw}~dNf$N;>&das)$bHLPtSusR!{j!rMnghw-`U&9{KjU7>w;%*G~E;vZnJ(eMeSGO z8-+O#KFG94p1^tRKhJu#=CxV5<}CJ;71G8DYO-G!t`1BhX1@153Fd4%o(N&ay-!s( zdjc#r?j`n|_1I5f6?425a@MyN+tDjgVH#)O)dNd7BZqLMtY?(_E&%-$R=XUCNSi*s zrRr&G@G8z;s4eb}ecR-*i1=~LCdHRNFHuwCfXyGKDc1l?KELMVNb{Iv1 ziGgKfHb3y|sp0Ni%jN3m4pEFK1_ww@(B_$-1LBKeP#+3Pnf|Y$CUeVBso^V*`Z1FC zpTfnM{YBX{X~6BDPp>qQ;1FI?j*xpg@o>fjXv|@_X2k4}X0hTWj>TyaobGl(>D(1T zJfic7E)};b(AJuSro$%RjIY=#Tx+StKDP8)vH0Ff8_n)_cR*Z0j(BP;THVz${GbPl zR5DxJ;|7UiIAGk@bOp_y82@ZM_?_Ow-`1^PcVFyg&+zLkuqK5n&N?qhlV)+$4VR=l znwI3<+xBwc(5~fthPj|Rg$8jqs@+{4w30lX?@uYVy(Kh1dd(s>=|n{4P}9Ro9cnsV zUOpBC6u~qOyw%el*rdx>**k^Ub#E~SPy%u!>OR>H{ z62~dz(29>DEoY+55A==dYhpgJlf9|~v!@gv$V_A@)c)q%z&%}v=DJ-5&@1OpB(|&{ zWQrA>mdTbe@E`YbvEbgIJGN#Xv!GSa%d|<`YmmI{E^ZRz|2&29kY>~xlkamu)7fOd zpHXO6aukjdnBUBGknIn~oP)rKp65i-pOGwH*Lwp4Wt2`2;K;oMyvapp$Ln3VyD zIMeKHWs-|`x>a@ZVT0y->`!Ay6PWf3*lQkA^koh%!v#ZqTwiGI5_Sx^1m-}vc6m0o zB>j24|2j%Jk{uS|@m_LFFO#zx@uoqJ3_ltLADEwovlJ@>@Ag^zlmMsaZ2{Y^g6}P@ zSiQrArCZBrgqrZocgF0C!Xno;DXd*rZ{r+3*~GlsfDCi<$5JU!heQTkOP3w5fOVgG zFA+G&v(n=?wKCi0%j1IgH6I@RjSpH)GXawR9ITLl3HVv+hS_Y@N}+$W4|>9<9Auz5 zm`a;+SsxJe?c+sORUi3_@>SZO{NVF+_0+-?sO6Dd(8EqX{=2&o!$6;$Q3#S49G(3( zeNf$|wBd0+62(vWVg=w3tCN26ngu|#7%}uNtdqHB>DB&vUXN;Cg{C%egZ!s?stU2Q zRje-5QtUQ@ns1S6ZT9$YQ`4_dHGG4PS=N~_funawa1oET&YXCiR{7l-57F*}Q{1bA zUr;4~>#~+)eoF_VIQe|ZV=Ee;<>V1E)YxIxeNE;Rv9g{;6-cwKJQMdkC#O%dbPXjL z24ATH8&h4ERM!j}lRaLzZ8`=ge{0fo%CE^Y=?ySnTGB5|#&*J7y(W-rEw$UjLmXu# z;ad&AX#2Q%2A9T&(?YRNh5hVu6&!ciC@6zpAQdt(#?^VFa&eRa*l>~<_ zYyI=hy<*ww6DUK3PFmQ@u$RH)<`=#2yzr@}RYt@)Q9@Pe65nUU7bhCyX43G_8lQIp zREb)3b->1$XRMhTgweQGwPHe?v5{g}Bz_lzKZBq9o#PUzrSYY)w<2xYe+gLj-o6)* zP>(Exwd?WuE!NEklppCer3%>dk^IIi|3Y9R(n_}RMym|7%(O<{35 zqZ}D7X27G{5A_SO5}uToh0izy&eJr;5Z?$c&S_9IXhDwzigUHL`IGjmpvlGR5GmU* zfo4nCnQZ8!LI;DQUuTo`j2ort5Z%4z8@0l`L}b-Bhdj*JWmkm- z7?UjW1p1kk$h<{2v1k?3|I_$WK_I?^u>V**MnBp5pI=5Uos~rh;oiH{h*wq+CLuEh zj?9?0piC{QBH7%9m~EzOsr9_oRJhO8AD?OCVaL=fL+{mUPo^pKLYfdJQ#H78n`giJ z5RE~@cTunHKZJtP{lc#U+oe-DP1>aJK3wLWs9n+m*P6zqvv7IA64ieh-vDj{PbN1U zeps#fzdv$cHKTg$d58}Vw|!}WgR-VA3b@$yxenRLcpN&Ek>6J?zW;=Y$AH{yK6ah+ zZk7tEh4K|eH(7N`jY_E&;`2y|U5>fKQdKl0Va z_PyJ8xct+`dr~y;de^DL@vz34TKUV*+Gj&0NRXD8cA`O}G`VzHiAK+k-rU zIebl8-$X$a=yX;}#lW}R?#$@pz89Yf4~G-Xe$Q-l!!LIjxFS~?W|P+PiQ=RPyh&s> zj^8i^-JNVNn<;(mZCIUvi?89a(01t1OE_H;GQ#r&5T|TsQSGy@Xtdpx%4+X)#LGip zHMfKMUN^7WPe`s-8!b^apg}|(Lwr-m6E*rnVk10PpB{AwlN)yMcU_Y2IJRScol%ib zk;zEJg)6Na^F(NK1g8BZW!|PFf5DBY>-`xI!vflNX^$`k9Bd!pwK?~3JFQ*po% znTU+8TdwP=6RyF&;N8E$YRx?`Y5$p_CVt>+cpOCZ_Lg;4E< z2>)(J+gAO4z*WU}<)we9^1C7&lHvMygsOu9m$P*5n(a`1oG_`&RgW4sa*M1Pa0%v= zoKG9_e@l0~hel5A0DrYjMLgW%r@pZH)wIKdkCZN0jMt8rmDF&`#l(Knz68m|G3aot z7vAym5*L6Q3nZyF&nAo2DlLYtdf<3GYhxnD(5&zMnv+y*_Q82XDE(2r_ce3WvxiGZ z=S18@81KyqQC|R_f0UYdakqT-s&Myx-Z-dh?-4^s-NE){ed#IcfIdpK0N3IV zhQ@WBx9oT$EL22N=D|-qmurFEU&4vvsUAuL`!Q$&x{5_wDucO!djX^T9c@3vSniWf zKag#u$ToyMp{@@3A+Gszf4;{Jmtb@Y9XP3=z3Efyp%51%{`PxEi^?u6;>;p>@P)(! zl!H6`rL_fMe~X%CuF7Yxsg?ud)5f<@j3VXUOE=QE4k?X>h$%c?*jpnZI#HJ6JO8Fy zv=`3-NgpLn>f)fEf8^P|bbISFpz_|EN2oy@s+6wgTJJtoK5n`cIWYeYGocG9)+P%3 zfK5rq@+VG~g$#ipR@l?^FUFYxImxyJi08SH5zj3&G-C;7Spb?iF?~5};Xm8E7ig*N zXF6cbo9D`fVg%%Q8}K-2M^Bs>SQP&|dj@PaQG&G@lWI8>!kMDh+jV!mShnix+Zolc zNlI9;h3@VvcIQ>hKAldId$dFSajg}KjFC7WFGcgc=j$mIpr3LG=HgDTAMgQ5-3N16 zObH`d(WiSYQ5^`avW|$P5v4%)$i0&qI)qV7|MKy?nWTS=+x^tQ-MejeW046LT%Fta zjnN#i(Zx?uDZi*F4~yC;EWqxJ{cWfqkpTgDliPTg=2}hjy&}5b*CdqV4%xrvN<>>I z_+scR+-N(+7gsflx^tVPG~naAA7m@`9FJN3mA5dAeiG%yV$J^yKqu z_GiuvN+&$TRk8^FVC@?Gw3QtV2_Zemv+S>CsejbLhfvrNuf)$PWop%?44b-RpMh0^ zg6F#oN}XYnI4$Z@`oI@BPzqZfu5E8@1xb7yPOPS*eZM8J0-_LX!9mlRW* z%Q6L1p#JO_Rb&c@M3vYl2f28vxf9FOrE5CilUv-0@L1@#S5+_?YA}_vs2Dwexqq#$ zA58i?U1&rUiS(t1bS5~%ZlH0C`JC^lM>y_^0{Egoy1iy$S27SBBF9&0Pyr6d1{!S4 zuQl28n}arp<7wC4D}5rE_FD3`K#;G#!<`r~8alD{$M;pu#wtB!?*-}ntuavB&CbWL zobd4Q;0hoy$>a5*5qPvfxJbzpElXU^EyjqOW`DPV||A8y#k^-uve}X=KKjvp3)1)vZ?+Jur0N-Ui z;_Q{S)8{HRiu-G|AahmcfOyv3f1kowaatAn_;#GBP#1g!f}&r%`E&P<2AI1xRgp6}MNgIs#IQ2dT6 zl|6?lVS!4Gw^|f8V@gM}DnM`p>&fukT>_P`pUalqdt_vz|M-#4mhMbgP5x@_+LXr7~LnYfWiBKT~199%3=yRiY7R!+p zi0=rp*|UXo8DHSj(qYZF<_ftAtEkQ3JtDPgy^2e%mjk0OHPP)qPBx#2UH>h=9}>bx zY~(J4#qojscWbBq?#Zso#1=4-j2`Uz^-g~^ z)`fhf)veojQun$L){6CzDZjlCVU_Cu@P}2T8TPHu=#k&)_>fc2-raOeEhi4Q_?i&_ zdsF0;S7NLB>HZ*~@o(4}l~KaNCP~UUC|ct+UGI70pm4?CHoP^pUe_PU5$uoiOVqIe zw91B`2TU55;z@3aws}T5yu8`*inNnMB!u^d@JiE6eyTDuEDIBiNW#%;Uz*-rEBC?-NJCe93Igcad;R)s^T-*xQ+_ z&c=IYklM#&a$witCN(%ed$@uG`c6AHcGncYV}7zeH>4o?tYb3EMev+el()6ctbo1K z;y)zccM*C5qWV=4>m$?maLkU@Kj4r4lGySod?1`Pw8}3W+h96^0I?rFY;)EJ|0N#( z3kCicfQ(5%l6TyJv>g=x#z!InNEst~}hrnT~n6*RgSQ5SKbEQcN*br1|D7}j9lh>UBC9<9ws#CavC%U*w0>A5u4{FO3jD#A`OT;A->c* zX`S_)T-;s(A74J6B}mxdW_0g+EjD>(tJGcC%~j=6iTet8!2Qh%cpykRWhZ5fz9Hli z4xj^G9x%32tT590pDVS9xc_-|^-%(he0froc#?m819mwwEkz02PGzb=l;)qiJxqu!&qFn!%>BzdqClk8XF$B3QnsL_dF`R=~N9utU1(k1uvb+YN9$u&q&E)mzLUX7U2 z$w@pD?ABu8ljQG6MV?6aJ=0zCZ6rM)NJTA1R`{XLs8<8e;!xO0+v@V+p&ffz#LenT z=HLqOlp4^U&%n%Q_jUKU6NCNL*0Riufw4 z$7)=IO}D85Z~?`su$X>H!nKQ0Zp6dA0TN#{wKS$Ea4+z@liPuDxR1!ed#m-4g7s&0 zt&dlFwFZob!Y;}982C{$W$MBDrgX6aPKYl8HD-DeNT>$SjCJL=?%rQ0V(N@~=OV)_ z@%Bm}$?WM0!0CQlvl!jzU0cA7^tRZaQt0yV)&1L3-+2#jU$-!zeA+(AeW@nK*jyf1 z?YP+l-=(lV*D{ARhQ50I{%|$&_yS|6&7pPY#x`$;x6%G00V?k{%v!-Mt66mH=X!HA zdqw??FQ5z|KX=e*hMRJi)vg)xoo(x@RFq2Axs&VPjkel1O6g;bT)u0;VTLr^zVpZW zwjR4^xPmF=6Nzc-+}g%~JF5Z;$(l`Slb)e*swGS4Sy<6@xoXtqoqUx@hoAgs8QI%1i_3;LmZgFhjrgRpAa%P-z&-`erJS_4)~ z`oiWdcS4iCX(T=%qwxV)%o9?FXtJ1f_?mAEOZ~-N8HE&|l3|MZfj(p7J9DI;1mIR%u9f1;a5p@NwdAXAohjphZ0ywG=>EjIEZKM_vWC<* zj`BCcFRpK!=0{HYLSps)CiWA=(+k%QQgATp2B1?c@Ue1UHt+q+a3$*bb*Jx2z)E28 zX&CbZrpx&VM8ya*Z%%LR4~;J=5El*-_kW^LB6`jrB4RXGlgtgI@!2jEYI5CTu4Tz% zskxZhFYfy&$PLEPkE#(&6-YEQgi)9?quSpjSm*e)yx?Y-o3WPd%W?>PmB(Ku|AI*^ z&v=(&2mI>+KNL!FvxIe*hXV#VxA;SQRfhD5{8(6dMJxF{_9vbw?~!(hWUX8zwbft(MNJ`xH^VI3r&Tc1It(PW+h9h5Io=Ap2}8ZiIS#A=dr|Z6W2k17_YG| zs6~8hfF+}w@SsRygAaxPi}5#n2Pycs90^vnWyc>SZaA4W~|WDgT?NsSn?;yRU$G*C~U(`#MNj&nOJ!<2lXleupcURhe* z>ST#c8jdpZZCoJ}9|SjIc($fjfN>duf!=tXI~bNFp7ZQth^rjMN0*)C66vE)S=A zOQrkwp1&2TIiwV4I5oZXZ;pxu?a#VIhv}^$PiF)8Jg()b6EsHlBrh!A7Uq0Pz5UUW z0w>raCyx=*>VeRvsyV^S6_b?L?!D5if-FL}{oP9L8v@#K4LW3Ql}#8egI4G#VsA~W zDB)bD^XTw#VleayKa0aTf-7|_A%N>fG%krmlrdH)WY;A%FNM*H=z#b0PjsI`ReObW zfv6*K5AF2F7P%&CWlq8MyfV|77jmk@DnQj#m0tL~L+|x0UjR!LGT4D98g;3q0`RGL z#S}_%D~C=72?^<^=N{Qjpbuohze{+teuZnv#s?n6Fd=S*Ia+_V<-WPMUZ<}-feQQf z4*tGDbdLyElHYAVtYnrJ3I*e|T;*G8UevPr(gIKZh7jJnT!yc2!^(RTYZ4L?NuiId zY{|LK71OwzClRigYE?+hbBD##L6@KJ`Z=}xa4-jQSoVb1zogBMO;pud5V%j*gr%eikATdj;SKL{eD z|6S7P57e$EBXWV(bS??%SJ(F(z}|UiZThE$0UYPvCfD292lr+k0WDzVX0xW|P%8J%;)|3BTG>Ole&+KUydKM9hphYP#j&x)eIM?hY2VA{v%e>!cH}wwhqSDDZP#nM@HC+MUsCqqZ6ouGuPWU73sRC?dDqf&#DlM(1vK$ zJlAU(Zbjs6e54lbEwO}Q&n205Ju~}9e=D`(gGM6tWhEFfn*g@;RUT&ESR2U>5K2iT z%_<4_CeN@dQ;m@|Qo)b?%v1qjn(%3Tf3U9U(=PE%!;>gq(1thq-tb1kKNPtZbF9us z5_B$Nh$fSg9m!O=8flecQvW=R7TOSo@K_he+xd%rvAtdSBxZ1PwnIcbX~Vg!a2P?W z6V{;}wOM#Iaq-+57479#Lq>GgZHsTiw>kyP+tj!wu>ILFVT0lGbyMND0o*vxFh@7S7w%+x6KC$h%cn9x@Dq4RWJJD?Zzz3=c3Bg78E-*yf> z6bTR4Gdrqr({bhpULCDf9}2;GRv$wPt^0}wlbPIYsNurty^?`T4T_3cfqz!7On`#3 z9SV7!v3sAs1i>{v@6>Cv?9(Tw?by6EgrQZK2kir69}PnBXc8qS*4MlOWckyT;sipgvy z(sklQ$e@(Mj%?hN#VpSnz6p~7GA#jIS;gVr z#G3WWIvwitYB&sL7H=MBV@W_L?*0pr+^KS@TqX$Q_FThj4DtcG4ao7J_Kik0H6euD zR7HvC&jxMt?Q(hOHkC1xCm82h5yBvxa5*cjSlhYmN7&(NX=l_=I>qNbqMI?iNoKKI z?f(Fb>_~udH!d7;V6wB_r?#sU(&V$iZ=!r|d za@lv41Ihg`c;1dr1`LSsmnP-N?uWgk1+bY>gtbU6f^P%1laHC5&Dk==L*6bo%B3wi zcjFI3*B{e6DTA~IJr}%ZIKrc?B2Q=G zNTS)M_9>rA0Cu%xb~&;=+if+F9Vc}xhD5}5CYAPn7CEyebEJePXYVsY%E;hVm3wn} zSBh}IQKgVa{+;1dt+LwbQn$wH?Tj9kZC|bVxqPlk@4uAqjC|L$OohCFZw^(mPlQ4= z{9m1QnH;`$cq55lp?l@B|9Hy*%jborvm%*!I=+D=PfBgq%lNGw*+)tI_7`2L)v6_DKNM-ZW>SuTBKwoR;R_)5Na#~Ke~~QWHH4UR%6-!{1h5$ zCHv)+vEO3-;4z%bP%NTwLt`XKyqoKsJkDaFOUAwReA0VeHLEJRQ@EZuoLntOOhw}1 z$i1}-pASX|yVrpZ#GOc2yus$z)xPcWUJvYgW(i?pKt_)oR{cmyR^v*^XhpfmJm0^9 zzUA$$Zl#F%+X|CzvHF;2GO5ZhJIk2ox7TBD7`iG>wo{0HQ|v{dJ);i=*Ir`tM@YccUV(s zJFXhV8ha>T>YJMTK!3~KoX$;Zj95I;GM}fYm-sm8uK(b?h!4|uPP~7oL%zqMKfz-o zkOj;0x7&F!mek zfjwGbbhJ=6G2c)gIXQmYiRm|?>XzHtC&wk~Rxz~oW)PcNv4l_3+)B?MxH z0TzR3`gtLCrau+qTJiB2cTnk;BAk;z^=a+E&V8snLTD?ef*6-!W`9*eJnolk zDme~||IS%K*M{~DXxNrrY4gICE1v42)c09b>>sNfEHc+b6duhVEDx4q-yxJ48OE}O zwfa2WRPO^d`N|a7I-5&UntYlS|3M>%)4sj=ctD)Y?HJ1c!kP$;N2yEu0PCk+UL)|D ztN2-aS8IbV@F2@ z#Y5ItUyG+kr_-It$GU9NTkG|X1T=UQcD8P^tzt%=DtKv>jqYOZ!B@0f)I#W4-wwA! z4czU9U?zS{&3&6+vvCx?&K?_t+U@Zr-HdUu9eAG4jeyrc4<+m99Im0?XhikLJea$r}~2 zL6Ibc8hw=_BDQro>m~-6M~0%34(F>K%vUm9%^Al z@jGeM513_du|sdnO|0q{o!hfc47aHJ55Hg(+XVE7#^05hw3~-W3|U5&ue|EtxnKs* zX`&aO7&34Z^G$s!4c8ZVOMg#)-W*9(^}|#G7iEPz6Tkpb@#_oKeX=K%3C2bd%JjI4MpuBviM)Dy=7D!3%9Nt2=4AK z0fM``OK^90*T&u59fA|w32s4yyEg9b4!74Hd!KX9x$FCP`PWp9>XOmjRdYV`c`Y7C zS<}7)HTD1Yk!_rHd#hQ!IITGQ^81VraCYvJ_{gPm0P#Y|AifSML>dp;vagpx2X=-b z*ZtTBl^xW+?4d%%Y#YQZcK0a;s-=742_Pe8rw2>S)M4lmuekc4CFPPm`+NUV#8X@h zy71n-*Jt%e$F0pySM>RYE8c6PX!fJMQlIb#$3NP|C%`6m{!+{j5z|NrA z=~G$o9yhwc4}mM?&e}B4FdvnSCQ5H7@8-jS)Tj!m%A;CLFKUQ8?Rrn z=6D}M$wL<2B$RcssEM6VeME<6fW{Eso1{PEac=5SV8Nb8jr4)|shvL{(kS2RVfOHAlEcX);1z!&M{3jv z_NGH}WTm@Iytlog!}S(qc5$;(xk^T7p;z8IhW=mb?#*n+gMx449V*qcCo>r74Rc#z|8bsX|PZejz-agFzAHTyhVjjoLx@W1aXV^Os)QJR=*5d z)h!bb-~Wc<(CLl;%3G9)mE7Vbe=?mgr!xV=*)hU5sbXU?MB?23voIi`x%Ed8$=!&Q z$ePbwe~2)otht*U#e&tW!J@dW{^J#OEQS&*LC2w68WOT|Uh&%4oKY>-+I;J?=n;iQ z{C`{ki)2U-hRB#fb5Aq*mEu6{p#C|uZxP~Hf0HK|*VjZSz)7*^aY^H*`8xvg!d${o zu~Z_&6C&59=7(+MU}XKY{3*v%e0w!taDol_?1t!?yLAcz>c@!Ymkd0Wn49&fs}7lj zJ7ZD6ZB5Y0Y%uL!h()Jd7&{4+>rj~I^d1iEx$Ua0-@DLX1dC7$_kMdOa*EquNTNJSC@?*vqx)$FMrI-j*KJA$L znhh^0(Ac4}N#>^SgC?0H>u}fn7><&`DNvRi?ze*9)z_0y$qCu3GCzm=d3qXZR_v&& zDhwK3Da70q>6EW<1STmctS2%UPJN`pe;enH>+RAP*>S{p(g_ zwF=(+6A8DUO4y0mTXe1%KDkpfp7AwA5`FHr-M?YnjZSAtMbH>kiT$E8XqLgl35FIC zoG*NS=lRx)-hUbih7?Q)N;SI z#P2Uh7ODXWqa48AUhm zBv1=XdZcgWxTly6IHK(@=%WM8lUm}iLs=DiT&?~r)@cXn7gUFC7V}{Q5KPrBe(QCO zamX!p4;;zl?-1WFP9YRO6d9{uaiW#-QZ>L*@kgj;iqu?u;_H@K=~W$ zQ;!O~?qjr!e6=pem+$`JF_1XZulK~%wx>p&ELiWeJ0;}Cv6d7OHyYLYm8bYJ^gR)V zQv}nB#v(*rBDiG(xF3&yKE?)GWsUg* zor3G3cHtq2Gg}=KZ>+jdg9ZJdWeGWPcG(YbyViiKiv-tZP zIC-5u2`trp(&(9j!p>0M3EAswOxLu7Kqwus_9Uf@@7V(`%e3BVfrS#d(&UuU*cy%~ zNwBT*`2t-=UP~3>Gd9Pqy?RLbA!og=-&NaHI7_h^+`5z)qGgi3@uX40;+y?P5H2qF zPl5*72;#YXGRR)-YdfyhX%ydhT%M$7P?CqdY)7@dpY^*=a=pC1ea#XOJL)z00b|Ei z!j4Xo14G>3A9sJB3*1oSws7Hxzu7=Mv4)w2CPrLzPo>sS0$wcWiN3zglBM*GoFDk~ zX9{q7qmt5Dq8RzzpU$U|l{if2|M}A4Z1(qvHCv)5=XoG6oY+sdbQf6{mOc7A2-iL_ zoQmMwl)cYl5^>xS#3@?eajLt`o+=%x{5#roH>bS2B0i$o<=%zBv|WG3y8lt6;GUuY z^}q?NH??jX2Fw*|p_@>wRqIe61?!tn;dd4&MmBhH!AL}hA)Y|Tc``jd)O*O?#?*h= z&_7mxclDo?2>7T}>#F#kK#hPRfLR>FIQr)BgeQA zLBnxz{~AuDEQv9_q<>N99kn9TJ4Nf1UpGfZY*VY|?XX3Hxz}}1A(kduSJg1cnqr0F z)2ZwSdSaPljL&BO324J#5ZarV(jPcQ&0#LSnG>nP1l2v#?5O|86!|=*-Akm(=y|AH zeX>w26zDS+{_I&eODTKuc3&#EbQXq%0Dpp2 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

  • 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 z Date: Thu, 10 Sep 2020 20:40:38 -0400 Subject: [PATCH 64/66] Use tad preview b/c it's prettier + lower codecov standards for now --- .codecov.yml | 6 ++++++ demo/demo.png | Bin 177813 -> 156003 bytes 2 files changed, 6 insertions(+) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 00000000..a9961276 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,6 @@ +coverage: + status: + patch: + default: + target: 30% + diff --git a/demo/demo.png b/demo/demo.png index 60e915044a958d3e5b90b93c8a5f2c01ca65117c..a39ddb4ca905f37ee597a42852baf39c47ed6805 100644 GIT binary patch literal 156003 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 literal 177813 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 From bce106bc0260892f434eb3ea1549af881dc02e1a Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Thu, 10 Sep 2020 21:03:34 -0400 Subject: [PATCH 65/66] Refactor custom --> inline, resolve more pylint warnings, lower project codecov, clean up more imports, add inter-scraper-validity check, expand config validators --- .codecov.yml | 5 +- .travis.yml | 2 +- jobfunnel/__init__.py | 2 + jobfunnel/__main__.py | 2 - jobfunnel/backend/__init__.py | 1 - jobfunnel/backend/job.py | 17 ++-- jobfunnel/backend/jobfunnel.py | 118 ++++++++++++++---------- jobfunnel/backend/scrapers/base.py | 92 ++++++++---------- jobfunnel/backend/scrapers/glassdoor.py | 1 - jobfunnel/backend/scrapers/indeed.py | 9 +- jobfunnel/backend/scrapers/monster.py | 3 +- jobfunnel/backend/scrapers/registry.py | 8 +- jobfunnel/backend/tools/__init__.py | 2 +- jobfunnel/backend/tools/filters.py | 17 ++-- jobfunnel/config/base.py | 5 +- jobfunnel/config/cli.py | 7 +- jobfunnel/config/delay.py | 15 ++- jobfunnel/config/manager.py | 2 +- jobfunnel/config/proxy.py | 23 +++-- jobfunnel/config/search.py | 11 ++- jobfunnel/config/settings.py | 3 +- jobfunnel/resources/defaults.py | 2 +- jobfunnel/resources/enums.py | 6 +- jobfunnel/resources/user_agent_list.txt | 5 +- readme.md | 29 +++--- tests/config/test_cli.py | 4 +- 26 files changed, 206 insertions(+), 185 deletions(-) diff --git a/.codecov.yml b/.codecov.yml index a9961276..27596b1a 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,6 +1,9 @@ coverage: + # FIXME: Set these back to automatic once we up coverage more status: patch: default: target: 30% - + project: + default: + target: 30% diff --git a/.travis.yml b/.travis.yml index 16d9dcc9..252befdc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,7 +11,7 @@ script: # Run CANADA_ENGLISH demo by settings YAML - 'funnel load -s demo/settings.yaml -log-level DEBUG' # Run an american search by CLI - - 'funnel custom -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG -csv demo_job_search_results/demo_search.csv -cache demo_job_search_results/cache2 -blf demo_job_search_results/demo_block_list.json -dl demo_job_search_results/demo_duplicates_list.json -log-file demo_job_search_results/log.log' + - 'funnel inline -kw Python Data Scientist PHD AI -ps WA -c Seattle -l USA_ENGLISH -log-level DEBUG -csv demo_job_search_results/demo_search.csv -cache demo_job_search_results/cache2 -blf demo_job_search_results/demo_block_list.json -dl demo_job_search_results/demo_duplicates_list.json -log-file demo_job_search_results/log.log' - 'pytest --cov=jobfunnel --cov-report=xml' # - './tests/verify-artifacts.sh' TODO: verify that JSON exist and are good # - './tests/verify_time.sh' TODO: some way of verifying execution time diff --git a/jobfunnel/__init__.py b/jobfunnel/__init__.py index 4eb28e38..da1b425d 100644 --- a/jobfunnel/__init__.py +++ b/jobfunnel/__init__.py @@ -1 +1,3 @@ +"""JobFunnel base package init, we keep module version here. +""" __version__ = '3.0.0' diff --git a/jobfunnel/__main__.py b/jobfunnel/__main__.py index 116ee37e..e816a939 100755 --- a/jobfunnel/__main__.py +++ b/jobfunnel/__main__.py @@ -1,7 +1,5 @@ #!python """Builds a config from CLI, runs desired scrapers and updates JSON + CSV - -NOTE: you can test this from cloned source by running python -m jobfunnel """ import sys from .backend.jobfunnel import JobFunnel diff --git a/jobfunnel/backend/__init__.py b/jobfunnel/backend/__init__.py index b427bfe5..0188e570 100644 --- a/jobfunnel/backend/__init__.py +++ b/jobfunnel/backend/__init__.py @@ -1,2 +1 @@ -# from jobfunnel.backend.jobfunnel import JobFunnel FIXME: causes circular imp. from jobfunnel.backend.job import Job, JobStatus diff --git a/jobfunnel/backend/job.py b/jobfunnel/backend/job.py index dec8f70b..655f1a31 100644 --- a/jobfunnel/backend/job.py +++ b/jobfunnel/backend/job.py @@ -114,7 +114,6 @@ def update_if_newer(self, job: 'Job') -> bool: on the same day, the comparison will favour the extra info as newer! TODO: we should do more checks to ensure we are not seeing a totally different job by accident (since this check is usually done by key_id) - TODO: more elegant way? maybe we can deepcopy self? TODO: Currently we do day precision but if we wanted to update because something is newer by hours we will need to revisit this limitation and store scrape hour/etc in the CSV as well. @@ -139,7 +138,9 @@ def update_if_newer(self, job: 'Job') -> bool: self.scrape_date = deepcopy(job.scrape_date) self.tags = deepcopy(job.tags) self.short_description = deepcopy(job.short_description) + # pylint: disable=protected-access self._raw_scrape_data = deepcopy(job._raw_scrape_data) + # pylint: enable=protected-access return True else: return False @@ -189,31 +190,29 @@ def as_row(self) -> Dict[str, str]: def as_json_entry(self) -> Dict[str, str]: """This formats a job for the purpose of saving it to a block JSON i.e. duplicates list file or user's block list file - NOTE: we truncate descriptions in block lists, TODO: use 'short' desc + NOTE: we truncate descriptions in block lists """ return { 'title': self.title, 'company': self.company, 'post_date': self.post_date.strftime('%Y-%m-%d'), 'description': ( - self.description[:MAX_BLOCK_LIST_DESC_CHARS] - + '..' - ) - if len(self.description) > MAX_BLOCK_LIST_DESC_CHARS - else self.description, + self.description[:MAX_BLOCK_LIST_DESC_CHARS] + '..' + ) if len(self.description) > MAX_BLOCK_LIST_DESC_CHARS else ( + self.description + ), 'status': self.status.name, } def clean_strings(self) -> None: """Ensure that all string fields have only printable chars - TODO: do this automatically upon assignment (override assignment) TODO: maybe we can use stopwords? """ for attr in [self.title, self.company, self.description, self.tags, self.url, self.key_id, self.provider, self.query, self.wage]: attr = ''.join( - filter(lambda x: x in PRINTABLE_STRINGS, self.title) + filter(lambda x: x in PRINTABLE_STRINGS, attr) ) def validate(self) -> None: diff --git a/jobfunnel/backend/jobfunnel.py b/jobfunnel/backend/jobfunnel.py index d9ab1f37..3c8c5b0a 100755 --- a/jobfunnel/backend/jobfunnel.py +++ b/jobfunnel/backend/jobfunnel.py @@ -3,7 +3,6 @@ """ import csv import json -import logging import os import pickle from datetime import date, datetime, timedelta @@ -23,10 +22,6 @@ class JobFunnel(Logger): """Class that initializes a Scraper and scrapes a website to get jobs - - NOTE: This is intended to be used with persistant cache and CSV files - dedicated to a single, consistant job search. - TODO: instead of Dic[str, Job] we should be using JobsDict """ def __init__(self, config: JobFunnelConfigManager) -> None: @@ -35,7 +30,7 @@ def __init__(self, config: JobFunnelConfigManager) -> None: Args: config (JobFunnelConfigManager): config object containing paths etc. """ - config.validate() # NOTE: this ensures logger gets a good path + config.validate() # NOTE: this ensures log file path exists super().__init__(level=config.log_level, file_path=config.log_file) self.config = config self.__date_string = date.today().strftime("%Y-%m-%d") @@ -96,8 +91,8 @@ def run(self) -> None: self.update_user_block_list() else: self.logger.debug( - "No master-CSV present, did not update block-list: " - f"{self.config.user_block_list_file}" + "No master-CSV present, did not update block-list: %s", + self.config.user_block_list_file ) # Scrape jobs or load them from a cache if one exists (--no-scrape) @@ -110,7 +105,7 @@ def run(self) -> None: scraped_jobs_dict = self.load_cache(self.daily_cache_file) else: self.logger.warning( - f"No incoming jobs, missing cache: {self.daily_cache_file}" + "No incoming jobs, missing cache: %s", self.daily_cache_file ) else: @@ -130,7 +125,8 @@ def run(self) -> None: ) # Parse duplicate jobs into updates for master jobs dict - # FIXME: we need to search for duplicates without master jobs too! + # NOTE: we prevent inter-scrape duplicates by key-id within BaseScraper + # FIXME: impl. TFIDF on inter-scrape duplicates duplicate_jobs = [] # type: List[DuplicatedJob] if self.master_jobs_dict and scraped_jobs_dict: @@ -158,12 +154,13 @@ def run(self) -> None: # Got a key-id match, pop from scrape dict and maybe update upd = self.master_jobs_dict[ match.duplicate.key_id].update_if_newer( - scraped_jobs_dict.pop(match.duplicate.key_id) - ) + scraped_jobs_dict.pop(match.duplicate.key_id)) + self.logger.debug( - f"Identified duplicate {match.duplicate.key_id} and " - f"{'updated older' if upd else 'did not update'} " - f"original job of same key-id with its data." + "Identified duplicate %s by key-id and %s original job " + "with its data.", + match.duplicate.key_id, + 'updated older' if upd else 'did not update', ) # Was it a content-match? @@ -175,10 +172,11 @@ def run(self) -> None: scraped_jobs_dict.pop(match.duplicate.key_id) ) self.logger.debug( - f"Identified {match.duplicate.key_id} as a " - "duplicate by contents and " - f"{'updated older' if upd else 'did not update'} " - f"original job {match.original.key_id} with its data." + "Identified %s as a duplicate by description and %s " + "original job %s with its data.", + match.duplicate.key_id, + 'updated older' if upd else 'did not update', + match.original.key_id, ) # Update duplicates file (if any updates are incoming) @@ -195,7 +193,8 @@ def run(self) -> None: # Write our updated jobs out (if none, dont make the file at all) self.write_master_csv(self.master_jobs_dict) self.logger.info( - f"Done. View your current jobs in {self.config.master_csv_file}" + "Done. View your current jobs in %s", + self.config.master_csv_file ) else: @@ -207,12 +206,25 @@ def run(self) -> None: else: self.logger.warning("No new jobs were added to CSV.") + def _check_for_inter_scraper_validity(self, existing_jobs: Dict[str, Job], + incoming_jobs: Dict[str, Job], + ) -> None: + """Verify that we aren't overwriting jobs by key-id between scrapers + NOTE: this is a slow check, would be cool to improve the O(n) on this + """ + existing_job_keys = existing_jobs.keys() + for inc_key_id in incoming_jobs.keys(): + for exist_key_id in existing_job_keys: + if inc_key_id == exist_key_id: + raise ValueError( + f"Inter-scraper key-id duplicate! {exist_key_id}" + ) def scrape(self) ->Dict[str, Job]: """Run each of the desired Scraper.scrape() with threading and delaying """ self.logger.info( - f"Scraping local providers with: {self.config.scraper_names}" + "Scraping local providers with: %s", self.config.scraper_names ) # Iterate thru scrapers and run their scrape. @@ -220,15 +232,25 @@ def scrape(self) ->Dict[str, Job]: for scraper_cls in self.config.scrapers: start = time() scraper = scraper_cls(self.session, self.config, self.job_filter) - # TODO: add a warning for overwriting different jobs with same key! - jobs.update(scraper.scrape()) + incoming_jobs_dict = scraper.scrape() + + # Ensure we have no duplicates between our scrapers by key-id + # (since we are updating the jobs dict with results) + self._check_for_inter_scraper_validity( + incoming_jobs_dict, + jobs, + ) + + jobs.update() end = time() self.logger.debug( - f"Scraped {len(jobs.items())} jobs from {scraper_cls.__name__}," - f" took {(end - start):.3f}s" + "Scraped %d jobs from %s, took %.3fs", + len(jobs.items()), scraper_cls.__name__, (end - start), ) - self.logger.info(f"Completed all scraping, found {len(jobs)} new jobs.") + self.logger.info( + "Completed all scraping, found %d new jobs.", len(jobs) + ) return jobs def recover(self) -> None: @@ -238,8 +260,8 @@ def recover(self) -> None: if os.path.exists(self.config.user_block_list_file): self.logger.warning( "Running recovery mode, but with existing block-list, delete " - f"{self.config.user_block_list_file} if you want to start fresh" - " from the cached data and not filter any jobs away." + "%s if you want to start fresh from the cached data and not " + "filter any jobs away.", self.config.user_block_list_file ) all_jobs_dict = {} # type: Dict[str, Job] for file in os.listdir(self.config.cache_folder): @@ -280,11 +302,12 @@ def load_cache(self, cache_file: str) -> Dict[str, Job]: # NOTE: this may be an error in the future self.logger.warning( "Loaded jobs cache has version mismatch! " - f"cache version: {version}, current version: {__version__}" + "cache version: %s, current version: %s", + version, __version__ ) self.logger.info( - f"Read {len(jobs_dict.keys())} jobs from previously-scraped " - f"jobs cache: {cache_file}." + "Read %d jobs from previously-scraped jobs cache: %s.", + len(jobs_dict.keys()), cache_file, ) self.logger.debug( "NOTE: you may see many duplicate IDs detected if these jobs " @@ -298,7 +321,6 @@ def write_cache(self, jobs_dict: Dict[str, Job], TODO: write search_config into the cache file and jobfunnel version TODO: some way to cache Job.RAW without hitting recursion limit - FIXME: add versioning to this Args: jobs_dict (Dict[str, Job]): jobs dict to dump into cache. @@ -306,7 +328,7 @@ def write_cache(self, jobs_dict: Dict[str, Job], """ cache_file = cache_file if cache_file else self.daily_cache_file for job in jobs_dict.values(): - job._raw_scrape_data = None + job._raw_scrape_data = None # pylint: disable=protected-access pickle.dump( { 'version': __version__, @@ -315,7 +337,7 @@ def write_cache(self, jobs_dict: Dict[str, Job], open(cache_file, 'wb'), ) self.logger.debug( - f"Dumped {len(jobs_dict.keys())} jobs to {cache_file}" + "Dumped %d jobs to %s", len(jobs_dict.keys()), cache_file ) def read_master_csv(self) -> Dict[str, Job]: @@ -362,7 +384,7 @@ def read_master_csv(self) -> Dict[str, Job]: break if not status: self.logger.warning( - f"Unknown status {status_str}, setting to UNKNOWN" + "Unknown status %s, setting to UNKNOWN", status_str ) status = JobStatus.UNKNOWN @@ -376,7 +398,7 @@ def read_master_csv(self) -> Dict[str, Job]: break if not locale: self.logger.warning( - f"Unknown locale {locale_str}, setting to UNKNOWN" + "Unknown locale %s, setting to UNKNOWN", locale_str ) locale = locale.UNKNOWN @@ -401,8 +423,8 @@ def read_master_csv(self) -> Dict[str, Job]: jobs_dict[job.key_id] = job self.logger.debug( - f"Read {len(jobs_dict.keys())} jobs from master-CSV: " - f"{self.config.master_csv_file}" + "Read %d jobs from master-CSV: %s", + len(jobs_dict.keys()), self.config.master_csv_file ) return jobs_dict @@ -419,7 +441,7 @@ def write_master_csv(self, jobs: Dict[str, Job]) -> None: job.validate() writer.writerow(job.as_row) self.logger.debug( - f"Wrote {len(jobs)} jobs to {self.config.master_csv_file}" + "Wrote %d jobs to %s", len(jobs), self.config.master_csv_file, ) def update_user_block_list(self) -> None: @@ -437,7 +459,7 @@ def update_user_block_list(self) -> None: # Try to load from CSV if master_jobs_dict is un-set if not self.master_jobs_dict: if os.path.isfile(self.config.master_csv_file): - self.master_jobs_dict or self.read_master_csv() + self.master_jobs_dict = self.read_master_csv() else: raise FileNotFoundError( f"Cannot update {self.config.user_block_list_file} without " @@ -452,14 +474,16 @@ def update_user_block_list(self) -> None: n_jobs_added += 1 self.job_filter.user_block_jobs_dict[ job.key_id] = job.as_json_entry - logging.info( - f'Added {job.key_id} to ' - f'{self.config.user_block_list_file}' + self.logger.info( + "Added %s to %s", + job.key_id, + self.config.user_block_list_file ) else: + # This could happen if we are somehow mishandling block list self.logger.warning( - f"Job {job.key_id} has been set to a removable status " - "and removed from master CSV multiple times." + "Job %s has been set to a removable status and removed " + "from master CSV multiple times.", job.key_id ) if n_jobs_added: @@ -478,8 +502,8 @@ def update_user_block_list(self) -> None: ) self.logger.info( - f"Moved {n_jobs_added} jobs into block-list due to removable " - f"statuses: {self.config.user_block_list_file}" + "Moved %d jobs into block-list due to removable statuses: %s", + n_jobs_added, self.config.user_block_list_file ) def update_duplicates_file(self) -> None: diff --git a/jobfunnel/backend/scrapers/base.py b/jobfunnel/backend/scrapers/base.py index abc2916a..44f72f08 100644 --- a/jobfunnel/backend/scrapers/base.py +++ b/jobfunnel/backend/scrapers/base.py @@ -1,4 +1,5 @@ """The base scraper class to be used for all web-scraping emitting Job objects +Paul McInnis 2020 """ import random from abc import ABC, abstractmethod @@ -47,13 +48,10 @@ def __init__(self, session: Session, config: 'JobFunnelConfigManager', ValueError: if no Locale is configured in the JobFunnelConfigManager """ # Inits - super().__init__( - level=config.log_level, - file_path=config.log_file, - ) - self.job_filter=job_filter - self.session=session - self.config=config + super().__init__(level=config.log_level, file_path=config.log_file) + self.job_filter = job_filter + self.session = session + self.config = config if self.headers: self.session.headers.update(self.headers) @@ -126,6 +124,17 @@ def min_required_job_fields(self) -> List[JobField]: JobField.KEY_ID, JobField.URL ] + @property + def high_priority_get_set_fields(self) -> List[JobField]: + """These get() and/or set() fields will be populated first. + + i.e we need the RAW populated before DESCRIPTION, so RAW should be high. + i.e. we need to get key_id before we set job.url, so key_id is high. + + NOTE: override as needed. + """ + return [] + @property @abstractmethod def job_get_fields(self) -> List[JobField]: @@ -135,7 +144,6 @@ def job_get_fields(self) -> List[JobField]: to populate that exists in the Job.RAW (the soup from the listing's own page), you should use job_set_fields. """ - pass @property @abstractmethod @@ -145,7 +153,6 @@ def job_set_fields(self) -> List[JobField]: NOTE: You should generally set the job's own page as soup to RAW first and then populate other fields from this soup, or from each-other here. """ - pass @property @abstractmethod @@ -155,18 +162,6 @@ def delayed_get_set_fields(self) -> List[JobField]: TODO: handle this within an overridden self.session.get() """ - pass - - @property - def high_priority_get_set_fields(self) -> List[JobField]: - """These get() and/or set() fields will be populated first. - - i.e we need the RAW populated before DESCRIPTION, so RAW should be high. - i.e. we need to get key_id before we set job.url, so key_id is high. - - NOTE: override as needed. - """ - return [] @property @abstractmethod @@ -180,7 +175,6 @@ def locale(self) -> Locale: NOTE: it is best to inherit this from BaseClass (btm. of file) """ - pass @property @abstractmethod @@ -188,7 +182,6 @@ def headers(self) -> Dict[str, str]: """The Session headers for this scraper to be used with requests.Session.headers.update() """ - pass def scrape(self) -> Dict[str, Job]: """Scrape job source into a dict of unique jobs keyed by ID @@ -208,11 +201,10 @@ def scrape(self) -> Dict[str, Job]: ) n_soups = len(job_soups) self.logger.info( - f"Scraped {n_soups} job listings from search results pages" + "Scraped %s job listings from search results pages", n_soups ) # Init a Manager so we can control delaying - # TODO: make session use async io to coordinate on-the-fly delaying. # this is assuming every job will incur one delayed session.get() # NOTE pylint issue: https://github.com/PyCQA/pylint/issues/3313 delay_lock = self.thread_manager.Lock() # pylint: disable=no-member @@ -245,10 +237,11 @@ def scrape(self) -> Dict[str, Job]: # TODO: move this functionality into duplicates filter if job.key_id in jobs_dict: self.logger.error( - f"Job {job.title} and {jobs_dict[job.key_id].title}" - f" share duplicate key_id: {job.key_id}" + "Job %s and %s share duplicate key_id: %s", + job.title, jobs_dict[job.key_id].title, job.key_id ) - jobs_dict[job.key_id] = job + else: + jobs_dict[job.key_id] = job finally: # Cleanup @@ -284,28 +277,28 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float, # Break out immediately because we have failed a filterable # condition with something we initialized while scraping. - # NOTE: if we pre-empt scraping duplicates we cannot update - # the existing job listing with the new information! - # TODO: make this configurable? if job and self.job_filter.filterable(job): if self.job_filter.is_duplicate(job): - # FIXME: make this configurable + # NOTE: if we pre-empt scraping duplicates we cannot update + # the existing job listing with the new information! + # TODO: make this behaviour configurable? ('minimal-get' ?) self.logger.debug( - f"Scraped job {job.key_id} has key_id " - "in known duplicates list. Continuing scrape of job " - "to update existing job attributes." + "Scraped job %s has key_id in known duplicates list. " + "Continuing scrape of job to update existing job " + "attributes.", + job.key_id ) else: self.logger.debug( - f"Cancelled scraping of {job.key_id}, failed JobFilter" - ) # TODO a reason would be nice maybe JobFilterFailure ? + "Cancelled scraping of %s, failed JobFilter", + job.key_id + ) break # Respectfully delay if it's configured to do so. - # TODO: move into overriden session and manage this access there. if field in self.delayed_get_set_fields: if delay_lock: - self.logger.debug(f"Delaying for {delay}") + self.logger.debug("Delaying for %.4f", delay) with delay_lock: sleep(delay) else: @@ -337,8 +330,10 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float, else: # Crash out gracefully so we can continue scraping. self.logger.warning( - f"Unable to scrape {field.name.lower()} for job:" - f"\n\t{str(err)}. {job.url}" + "Unable to scrape %s for job: %s. %s", + field.name.lower(), + err, + job.url, ) # Validate job fields if we got something @@ -347,7 +342,7 @@ def scrape_job(self, job_soup: BeautifulSoup, delay: float, job.validate() except Exception as err: # Bad job scrapes can't take down execution! - self.logger.error(f"Job failed validation: {err}") + self.logger.error("Job failed validation: %s", err) return None return job @@ -364,7 +359,6 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: Returns: List[BeautifulSoup]: list of jobs soups we can use to make a Job """ - pass @abstractmethod def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: @@ -373,7 +367,6 @@ def get(self, parameter: JobField, soup: BeautifulSoup) -> Any: i.e. if param is JobField.COMPANY --> scrape from soup --> return str TODO: better way to handle ret type? """ - pass @abstractmethod def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: @@ -384,11 +377,7 @@ def set(self, parameter: JobField, job: Job, soup: BeautifulSoup) -> None: i.e. I can set() the Job.RAW to be the soup of it's own dedicated web page (Job.URL), then I can set() my Job.DESCRIPTION from the Job.RAW - - NOTE: (remember) do not return anything in here! it sets job attribs - FIXME: have this automatically set the attribute by JobField. """ - pass def _validate_get_set(self) -> None: """Ensure the get/set actions cover all need attribs and dont intersect @@ -421,19 +410,18 @@ def _validate_get_set(self) -> None: JobField.SHORT_DESCRIPTION, JobField.RAW] and field not in self.job_get_fields and field not in self.job_set_fields): - excluded_fields.append(field) + excluded_fields.append(field) if excluded_fields: # NOTE: INFO level because this is OK, but ideally ppl see this # so they are motivated to help and understand why stuff might # be missing in the CSV self.logger.info( - "No get() or set() will be done for Job attrs: " - f"{[field.name for field in excluded_fields]}" + "No get() or set() will be done for Job attrs: %s", + [field.name for field in excluded_fields] ) # Just some basic localized scrapers, you can inherit these to set the locale. -# TODO: move into own file once we get enough of em... class BaseUSAEngScraper(BaseScraper): """Localized scraper for USA English """ diff --git a/jobfunnel/backend/scrapers/glassdoor.py b/jobfunnel/backend/scrapers/glassdoor.py index b726b570..29c03d88 100644 --- a/jobfunnel/backend/scrapers/glassdoor.py +++ b/jobfunnel/backend/scrapers/glassdoor.py @@ -53,7 +53,6 @@ def __init__(self, session: Session, config: 'JobFunnelConfigManager', def quantize_radius(self, radius: int) -> int: """Get the glassdoor-quantized radius """ - pass @property def job_get_fields(self) -> str: diff --git a/jobfunnel/backend/scrapers/indeed.py b/jobfunnel/backend/scrapers/indeed.py index 1506e26a..5f11be34 100644 --- a/jobfunnel/backend/scrapers/indeed.py +++ b/jobfunnel/backend/scrapers/indeed.py @@ -104,7 +104,7 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: # Parse total results, and calculate the # of pages needed pages = self._get_num_search_result_pages(search_url) self.logger.info( - f"Found {pages} pages of search results for query={self.query}" + "Found %d pages of search results for query=%s", pages, self.query ) # Init list of job soups @@ -208,7 +208,7 @@ def _get_search_url(self, method: Optional[str] = 'get') -> str: self.query, self.config.search_config.city.replace(' ', '+',), self.config.search_config.province_or_state.upper(), - self._convert_radius(self.config.search_config.radius), + self._quantize_radius(self.config.search_config.radius), self.max_results_per_page, int(self.config.search_config.return_similar_results) ) @@ -219,7 +219,7 @@ def _get_search_url(self, method: Optional[str] = 'get') -> str: else: raise ValueError(f'No html method {method} exists') - def _convert_radius(self, radius: int) -> int: + def _quantize_radius(self, radius: int) -> int: """Quantizes the user input radius to a valid radius value into: 5, 10, 15, 25, 50, 100, and 200 kilometers or miles. TODO: implement with numpy instead of if/else cases. @@ -280,9 +280,8 @@ def _get_num_search_result_pages(self, search_url: str, max_pages=0) -> int: class IndeedScraperCANEng(BaseIndeedScraper, BaseCANEngScraper): """Scrapes jobs from www.indeed.ca """ - pass + class IndeedScraperUSAEng(BaseIndeedScraper, BaseUSAEngScraper): """Scrapes jobs from www.indeed.com """ - pass diff --git a/jobfunnel/backend/scrapers/monster.py b/jobfunnel/backend/scrapers/monster.py index 585ba9b5..f3680687 100644 --- a/jobfunnel/backend/scrapers/monster.py +++ b/jobfunnel/backend/scrapers/monster.py @@ -168,7 +168,7 @@ def get_job_soups_from_search_result_listings(self) -> List[BeautifulSoup]: # Parse total results, and calculate the # of pages needed n_pages = self._get_num_search_result_pages(initial_search_results_soup) self.logger.info( - f"Found {n_pages} pages of search results for query={self.query}" + "Found %d pages of search results for query=%s", n_pages, self.query ) # Get first page of listing soups from our search results listings page @@ -256,7 +256,6 @@ def _get_search_url(self, method: Optional[str] = 'get', def _convert_radius(self, radius: int) -> int: """NOTE: radius conversion is units/locale specific """ - pass class MonsterScraperCANEng(BaseMonsterScraper, BaseCANEngScraper): """Scrapes jobs from www.monster.ca diff --git a/jobfunnel/backend/scrapers/registry.py b/jobfunnel/backend/scrapers/registry.py index 0907eba2..12ef742d 100644 --- a/jobfunnel/backend/scrapers/registry.py +++ b/jobfunnel/backend/scrapers/registry.py @@ -1,8 +1,7 @@ """Lookup tables where we can map scrapers to locales, etc -NOTE: if you implement a scraper you must add it here or JobFunnel cannot -find it. -TODO: must be a way to make this unnecessary? maybe import & map based on name? +NOTE: if you implement a scraper you must add it here +TODO: there must be a better way to do this by using class attrib of Provider """ from jobfunnel.resources import Locale, Provider @@ -16,9 +15,6 @@ GlassDoorScraperCANEng, GlassDoorScraperUSAEng, ) - -# NOTE: if you add a scraper you need to add it here -# TODO: there must be a better way to do this by using class attrib of Provider SCRAPER_FROM_LOCALE = { # search terms which one to use Provider.INDEED: { diff --git a/jobfunnel/backend/tools/__init__.py b/jobfunnel/backend/tools/__init__.py index 5ffd68b8..13c46753 100644 --- a/jobfunnel/backend/tools/__init__.py +++ b/jobfunnel/backend/tools/__init__.py @@ -1,2 +1,2 @@ from jobfunnel.backend.tools.tools import get_webdriver, get_logger, Logger -# FIXME: we can't import delays here or we cause circular import. +# NOTE: we can't import delays here or we cause circular import. diff --git a/jobfunnel/backend/tools/filters.py b/jobfunnel/backend/tools/filters.py index b50bac9e..b6d25437 100644 --- a/jobfunnel/backend/tools/filters.py +++ b/jobfunnel/backend/tools/filters.py @@ -1,9 +1,8 @@ """Filters that are used in jobfunnel's filter() method or as intermediate filters to reduce un-necessesary scraping +Paul McInnis 2020 """ -import json import logging -import os from collections import namedtuple from copy import deepcopy from datetime import datetime @@ -138,7 +137,7 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], ) -> List[DuplicatedJob]: """Remove all known duplicates from jobs_dict and update original data - FIXME: we assume there are no duplicates by content in existing jobs + TODO: find duplicates by content within existing jobs Args: existing_jobs_dict (Dict[str, Job]): dict of jobs keyed by key_id. @@ -178,7 +177,7 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], ) duplicate_jobs_list.append( DuplicatedJob( - original=None, # FIXME: we should keep original ref. + original=None, # TODO: load ref from duplicates dict duplicate=incoming_job, type=DuplicateType.EXISTING_TFIDF, ) @@ -209,7 +208,7 @@ def find_duplicates(self, existing_jobs_dict: Dict[str, Job], ) # Update duplicates list with more JSON-friendly entries - # FIXME: we should retain a reference to the original job contents + # TODO: we should retain a reference to the original job's contents self.duplicate_jobs_dict.update({ j.duplicate.key_id: j.duplicate.as_json_entry for j in duplicate_jobs_list @@ -227,7 +226,7 @@ def tfidf_filter(self, incoming_jobs_dict: Dict[str, dict], removed any duplicates by key_id NOTE: this only uses job descriptions to do the content matching. NOTE: it is recommended that you have at least around 25 ish Jobs. - FIXME: need to handle existing_jobs_dict = None + TODO: need to handle existing_jobs_dict = None TODO: have this raise an exception if there are too few words. TODO: we should consider caching the transformed corpus. @@ -258,7 +257,7 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job], if is_incoming and job.key_id in self.duplicate_jobs_dict: # NOTE: we should never see this for incoming jobs. # we will see it for existing jobs since duplicates can - # share a key_id. FIXME: need to look closer into this. + # share a key_id. raise ValueError( "Attempting to run TFIDF with existing duplicate " f"{job.key_id}" @@ -301,7 +300,7 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job], corpus = query_words # Provide a warning if we have few words. - # FIXME: warning should reflect actual corpus size + # TODO: warning should reflect actual corpus size if len(corpus) < self.min_tfidf_corpus_size: self.logger.warning( "It is not recommended to use this filter with less than " @@ -331,7 +330,7 @@ def __dict_to_ids_and_words(jobs_dict: Dict[str, Job], # Identify the jobs in existing_jobs_dict that our query is a # duplicate of - # FIXME: handle if everything is highly similar! + # TODO: handle if everything is highly similar! similar_indeces = np.where( query_similarities >= self.max_similarity )[0] diff --git a/jobfunnel/config/base.py b/jobfunnel/config/base.py index 8f613298..19561bf2 100644 --- a/jobfunnel/config/base.py +++ b/jobfunnel/config/base.py @@ -4,6 +4,8 @@ class BaseConfig(ABC): + """Base config object + """ @abstractmethod def __init__(self) -> None: @@ -11,7 +13,6 @@ def __init__(self) -> None: def validate(self) -> None: """This should raise Exceptions if self.attribs are bad - NOTE: if we use sub-configs we could potentiall use Cerberus for this + NOTE: if we use sub-configs we could potentially use Cerberus for this against any vars(Config) """ - pass diff --git a/jobfunnel/config/cli.py b/jobfunnel/config/cli.py index d207def0..268b1303 100644 --- a/jobfunnel/config/cli.py +++ b/jobfunnel/config/cli.py @@ -1,7 +1,6 @@ """Configuration parsing module for CLI --> JobFunnelConfigManager """ import argparse -import os from typing import Dict, Any, List import yaml @@ -64,7 +63,7 @@ def parse_cli(args: List[str]) -> Dict[str, Any]: # We are using CLI for all arguments. cli_parser = base_subparsers.add_parser( - 'custom', + 'inline', help='Configure search query and data providers via CLI.', ) @@ -121,7 +120,7 @@ def parse_cli(args: List[str]) -> Dict[str, Any]: '-log-file', type=str, help='Path to log file.', - required=True, # FIXME: This should be optional (no writing to it all). + required=True, # TODO: This should be optional (no writing to it all). ) # SearchConfig via CLI args subparser @@ -167,7 +166,7 @@ def parse_cli(args: List[str]) -> Dict[str, Any]: type=str, dest='search.company_block_list', nargs='+', - default=[], + default=DEFAULT_COMPANY_BLOCK_LIST, help='List of company names to omit from all search results ' '(i.e. SpamCompany, Cash5Gold).', required=False, diff --git a/jobfunnel/config/delay.py b/jobfunnel/config/delay.py index 0c6bcd1a..ec631098 100644 --- a/jobfunnel/config/delay.py +++ b/jobfunnel/config/delay.py @@ -17,7 +17,20 @@ def __init__(self, max_duration: float = DEFAULT_DELAY_MAX_DURATION, algorithm: DelayAlgorithm = DEFAULT_DELAY_ALGORITHM, random: bool = DEFAULT_RANDOM_DELAY, converge: bool = DEFAULT_RANDOM_CONVERGING_DELAY): - # TODO: document + """Delaying Configuration for GET requests + + Args: + max_duration (float, optional): max duration. + Defaults to DEFAULT_DELAY_MAX_DURATION. + min_duration (float, optional): min duration. + Defaults to DEFAULT_DELAY_MIN_DURATION. + algorithm (DelayAlgorithm, optional): algorithm. + Defaults to DEFAULT_DELAY_ALGORITHM. + random (bool, optional): [enable random delaying. + Defaults to DEFAULT_RANDOM_DELAY. + converge (bool, optional): enable random converging delaying. + Defaults to DEFAULT_RANDOM_CONVERGING_DELAY. + """ super().__init__() self.max_duration = max_duration self.min_duration = min_duration diff --git a/jobfunnel/config/manager.py b/jobfunnel/config/manager.py index 975e2c38..13bcfa24 100644 --- a/jobfunnel/config/manager.py +++ b/jobfunnel/config/manager.py @@ -68,7 +68,7 @@ def __init__(self, self.log_file = log_file self.log_level = log_level self.no_scrape = no_scrape - self.bs4_parser = bs4_parser # TODO: add to config YAML? + self.bs4_parser = bs4_parser # NOTE: this is not currently configurable self.return_similar_results = return_similar_results if not delay_config: # We will always use a delay config to be respectful diff --git a/jobfunnel/config/proxy.py b/jobfunnel/config/proxy.py index 65c8456d..c3e23ac7 100644 --- a/jobfunnel/config/proxy.py +++ b/jobfunnel/config/proxy.py @@ -1,11 +1,14 @@ """Proxy configuration for Session() """ +import ipaddress + from jobfunnel.config import BaseConfig class ProxyConfig(BaseConfig): """Simple config object to contain proxy configuration """ + def __init__(self, protocol: str, ip_address: str, port: int) -> None: super().__init__() self.protocol = protocol @@ -16,15 +19,15 @@ def __init__(self, protocol: str, ip_address: str, port: int) -> None: def url(self) -> str: """Get the url string for use in a Session.proxies object """ - url_str = '' - if self.protocol != '': - url_str += self.protocol + '://' - if self.ip_address != '': - url_str += self.ip_address - if self.port != '': - url_str += ':' + self.port - return url_str # FIXME: this could be done in one line + return f"{self.protocol}://{self.ip_address}:{self.port}" def validate(self) -> None: - """TODO: impl. validate ip addr is valid format etc""" - pass + """Validate the format of ip addr and port + """ + try: + # try to create an IPv4 address + ipaddress.IPv4Address(self.ip_address) + except: + raise ValueError(f"{self.ip_address} is not a valid IPv4 address") + assert isinstance(self.port, int), "Port must be an integer" + assert self.protocol, "Protocol is not set" diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index e7b98396..44080042 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -53,7 +53,7 @@ def __init__(self, self.locale = locale self.providers = providers self.keywords = keywords - self.return_similar_results = return_similar_results # indeed thing + self.return_similar_results = return_similar_results # Indeed.X thing self.max_listing_days = max_listing_days or DEFAULT_MAX_LISTING_DAYS self.blocked_company_names = blocked_company_names self.remote = remote @@ -74,6 +74,11 @@ def query_string(self) -> str: def validate(self): """We need to have the right information set, not mixing stuff - TODO: impl. with _validate_type_ipv4address """ - pass + assert self.province_or_state, "Province/State not set" + assert self.city, "City not set" + assert self.locale, "Locale not set" + assert self.providers and len(self.providers) >= 1, "Providers not set" + assert self.keywords and len(self.keywords) >= 1, "Keywords not set" + assert self.max_listing_days >= 1, "Cannot set max posting days < 1" + assert self.domain and '.' in self.domain, "Invalid domain" diff --git a/jobfunnel/config/settings.py b/jobfunnel/config/settings.py index 47d53a80..02e8dbe0 100644 --- a/jobfunnel/config/settings.py +++ b/jobfunnel/config/settings.py @@ -36,7 +36,7 @@ 'default': DEFAULT_LOG_LEVEL_NAME, }, 'log_file': { - 'required': False, + 'required': True, # TODO: allow this to be optional 'type': 'string', 'default': DEFAULT_LOG_FILE, }, @@ -141,6 +141,7 @@ } + class JobFunnelSettingsValidator(Validator): """A simple JSON data validator with a custom data type for IPv4 addresses https://codingnetworker.com/2016/03/validate-json-data-using-cerberus/ diff --git a/jobfunnel/resources/defaults.py b/jobfunnel/resources/defaults.py index e03c380b..47b58efa 100644 --- a/jobfunnel/resources/defaults.py +++ b/jobfunnel/resources/defaults.py @@ -1,5 +1,5 @@ """Default arguments for both JobFunnelConfigManager and CLI arguments. -FIXME: we need to remove un-used defaults from here +NOTE: Not all defaults here are used, as we rely on YAML for demo and not kwargs """ import os from pathlib import Path diff --git a/jobfunnel/resources/enums.py b/jobfunnel/resources/enums.py index a0e2f9e5..ac8cc0d3 100644 --- a/jobfunnel/resources/enums.py +++ b/jobfunnel/resources/enums.py @@ -7,10 +7,6 @@ class Locale(Enum): Locale must be set as it defines the code implementation to use for forming the correct GET requests, to allow us to interact with a job-source. - - NOTE: add locales here as you need them, we do them per-country per-language - becuase scrapers are written per-language-per-country as this matches how - the information is served by job websites. """ CANADA_ENGLISH = 1 CANADA_FRENCH = 2 @@ -59,7 +55,7 @@ class JobField(Enum): class DuplicateType(Enum): """Ways in which a job can be a duplicate - NOTE: we use these to determine what action(s) to take + NOTE: we use these to determine what action(s) to take for a duplicate """ KEY_ID = 0 EXISTING_TFIDF = 1 diff --git a/jobfunnel/resources/user_agent_list.txt b/jobfunnel/resources/user_agent_list.txt index 6de5a410..97711f52 100644 --- a/jobfunnel/resources/user_agent_list.txt +++ b/jobfunnel/resources/user_agent_list.txt @@ -1,5 +1,4 @@ -# user agent list -# https://developers.whatismybrowser.com/useragents/explore/ +# User agent list: https://developers.whatismybrowser.com/useragents/explore/ # chrome Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36 Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36 @@ -52,4 +51,4 @@ Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0) Mozilla/5.0 (Windows NT 6.1; Win64; x64; Trident/7.0; rv:11.0) like Gecko Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0) Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0) -Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET +Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET diff --git a/readme.md b/readme.md index 44862f82..3a86c4a4 100644 --- a/readme.md +++ b/readme.md @@ -23,35 +23,34 @@ pip install git+https://github.com/PaulMcInnis/JobFunnel.git ``` # Usage +By performing regular scraping and reviewing, you can cut through the noise of even the busiest job markets. -After installation you can search for jobs with YAML configuration files or by passing command arguments. +## Configure +You can search for jobs with YAML configuration files or by passing command arguments. -## Configuring - -Begin by customizing our [demo settings][demo_yaml] to suit your needs: +Get started by customizing our demo [settings.yaml][demo_yaml] to suit your needs: ``` wget https://raw.githubusercontent.com/PaulMcInnis/JobFunnel/master/demo/settings.yaml -O my_settings.yaml nano my_settings.yaml ``` -_NOTE: It is recommended to provide as few search keywords as possible (i.e. try using `AI`, `Python` instead of `Software`, `Developer`, `Python`, `AI`)._ +_NOTE: It is recommended to provide as few search keywords as possible (i.e. try using `Python`, `AI` instead of `Software`, `Developer`, `Python`, `AI`)._ + +## Scrape -## Scraping -Run `funnel` to populate your master CSV file with jobs: +Run `funnel` to populate your master CSV file with jobs from available providers: ``` funnel load -s my_settings.yaml ``` -## Reviewing - -Open the master CSV file and update the jobs' `status`: +## Review -* Set to `interested`, `applied`, `interview` or `offer` to reflect interest or progression on the job. +Open the master CSV file and update the per-job `status`: -* Set to `archive`, `rejected` or `delete` to remove a job from the `.csv` permanently (for this search). You can review 'blocked' jobs within your `block_list_file`. +* Set to `interested`, `applied`, `interview` or `offer` to reflect your progression on the job. -By combining regular scraping with regular reviewing, you can cut through the noise of even the busiest job markets. +* Set to `archive`, `rejected` or `delete` to remove a job from this search. You can review 'blocked' jobs within your `block_list_file`. # Advanced Usage @@ -68,7 +67,7 @@ By combining regular scraping with regular reviewing, you can cut through the no * **Blocking Companies**
    Filter undesired companies by adding them to your `company_block_list` in your YAML or pass them by command line as `-cbl`. -* **Job Age Filter**
    +* **Job Age Filter**
    You can configure the maximum age of scraped listings (in days) by configuring `max_listing_days`. * **Reviewing Jobs in Terminal**
    @@ -91,7 +90,7 @@ By combining regular scraping with regular reviewing, you can cut through the no * **Running by CLI**
    You can run JobFunnel using CLI only, review the command structure via: ``` - funnel custom -h + funnel inline -h ``` diff --git a/tests/config/test_cli.py b/tests/config/test_cli.py index 83eaa46c..c0cb9e4d 100644 --- a/tests/config/test_cli.py +++ b/tests/config/test_cli.py @@ -16,7 +16,7 @@ (['load', '-s', TEST_YAML, '-log-level', 'WARNING'], None), (['load', '-s', TEST_YAML, '--no-scrape'], None), # Test schema from CLI - (['custom', '-csv', 'TEST_search', '-log-level', 'DEBUG', '-cache', + (['inline', '-csv', 'TEST_search', '-log-level', 'DEBUG', '-cache', 'TEST_cache', '-blf', 'TEST_block_list', '-dl', 'TEST_duplicates_list', '-log-file', 'TEST_log_file', '-kw', 'I', 'Am', 'Testing', '-l', 'CANADA_ENGLISH', '-ps', 'TESTPS', '-c', 'TestCity', '-cbl', @@ -26,7 +26,7 @@ # Invalid cases (['load'], SystemExit), (['load', '-csv', 'boo'], SystemExit), - (['custom', '-csv', 'TEST_search', '-log-level', 'DEBUG', '-cache', + (['inline', '-csv', 'TEST_search', '-log-level', 'DEBUG', '-cache', 'TEST_cache', '-blf', 'TEST_block_list', '-dl', 'TEST_duplicates_list'], SystemExit), (['-csv', 'test.csv'], SystemExit), From cbbd91730413d409a4f98ca94c1fc770f67d9efa Mon Sep 17 00:00:00 2001 From: Paul McInnis Date: Fri, 11 Sep 2020 19:35:24 -0400 Subject: [PATCH 66/66] Fix domain validation + minor readme changes --- jobfunnel/config/search.py | 2 +- readme.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/jobfunnel/config/search.py b/jobfunnel/config/search.py index 44080042..c9a60bc0 100644 --- a/jobfunnel/config/search.py +++ b/jobfunnel/config/search.py @@ -81,4 +81,4 @@ def validate(self): assert self.providers and len(self.providers) >= 1, "Providers not set" assert self.keywords and len(self.keywords) >= 1, "Keywords not set" assert self.max_listing_days >= 1, "Cannot set max posting days < 1" - assert self.domain and '.' in self.domain, "Invalid domain" + assert self.domain, "Domain not set" diff --git a/readme.md b/readme.md index 3a86c4a4..baae3434 100644 --- a/readme.md +++ b/readme.md @@ -28,13 +28,14 @@ By performing regular scraping and reviewing, you can cut through the noise of e ## Configure You can search for jobs with YAML configuration files or by passing command arguments. -Get started by customizing our demo [settings.yaml][demo_yaml] to suit your needs: +Get started by customizing our demo [settings.yaml][demo_yaml] to suit your needs (or just run it as-is): ``` wget https://raw.githubusercontent.com/PaulMcInnis/JobFunnel/master/demo/settings.yaml -O my_settings.yaml -nano my_settings.yaml ``` +_NOTE:_ +* _It is recommended to provide as few search keywords as possible (i.e. `Python`, `AI`)._ -_NOTE: It is recommended to provide as few search keywords as possible (i.e. try using `Python`, `AI` instead of `Software`, `Developer`, `Python`, `AI`)._ +* _JobFunnel currently only supports `CANADA_ENGLISH` and `USA_ENGLISH` locales._ ## Scrape @@ -84,7 +85,7 @@ Open the master CSV file and update the per-job `status`: * **Recovering Lost Data**
    JobFunnel can re-build your master CSV from your `cache_folder` where all the historic scrape data is located: ``` - funnel --recover ... + funnel --recover ``` * **Running by CLI**