main_app.py
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
from SQL import builder
import numpy as np
import sqlite3
import concurrent.futures
import threading
from sqlite_worker import SqliteWorker

MAX_THREADS = 30

async def async_request(root, url, session):
    for i in range(1, 51):
        page_url = urljoin(url, f'?page={str(i)}&s=-total')
        print(page_url)
        response = await session.get(url)
        page = response.text
        page_soup = BeautifulSoup(page, 'html.parser')
        # Our data is in the tr tags so we find all
        trs = page_soup.find_all('tr')
        count = 0
        for tr in trs[1:]:
            root.parse_tr(tr)
            root.write_dict['url'] = url
            # # Make sure this entry doesn't already exist so we can stop and start:
            # # If it exists, we'll just skip it
            # if self.sql.select('main',
            #                    table='salaries',
            #                    columns=self.columns,
            #                    where=self.write_dict,
            #                    where_and=True):
            #     print('skipping', self.write_dict['employee_name'], self.write_dict['year'])
            # otherwise, we will insert the data
            # else:
            # root.sql.insert('main', table='salaries', data=root.write_dict)
            print('inserted', 'name:', root.write_dict['employee_name'],
                  'year:', root.write_dict['year'],
                  'county:', root.write_dict['county'],
                  'entity:', root.write_dict['entity'])
            # increment the count
            count += 1
        # if count is zero, we stop increasing
        if count == 0:
            break

    root.completed.append(url + '\n')
    return response

async def make_async_requests(root, urls):
    async with AsyncClient() as session:
        tasks = [asyncio.create_task(async_request(root, url, session)) for url in urls]
        responses = await asyncio.gather(*tasks)
    return responses

class MainApp:
    most_recent = None
    write_dict = {}

    def __init__(self, start_url, base_url):
        # Key should be the location of our db, value should be the alias, in this case main
        db_info = {'SQL/salary_db.db': 'main'}
        # instantiate our builder, pass in db_info. Create=False because we want to create the table ourselves this time
        # If the table isn't there, we want it to fail so we can make it correctly
        self.sql = builder.Build(db_info=db_info, create=False)
        # We want to set up a dictionary where all columns but ID are set up as the keys that we can put values into later
        # We start by making a list of the columns in the db and dropping the first element (ID).
        self.columns = list(self.sql.column_names('main', table='salaries'))[1:]

        self.worker = SqliteWorker('SQL/salary_db.db')
        self.completed = []

        # Our URL to start with
        self.start_url = start_url
        self.base_url = base_url

    def go(self):
        # Get a beautiful soup of the base url
        base_soup = self.get_soup(self.start_url)
        # Find all td tags in the page
        tds = base_soup.find_all('td')
        most_recent = self.get_most_recent_entry()
        start = False
        # Loop through our td tags
        for i, td in enumerate(tds):
            # We've enumerated so we can skip the evens (the districts are doubled up and we only need one)
            if i % 2 == 0:
                continue
            # Write_dict is the columns in our database (ID has already been removed because we don't need it for this)
            self.write_dict = {x: None for x in self.columns}
            # The links are just the endings (where it delineates, at the date portion of the url), so we'll get them
            # by searching the a tags
            ending_list = td.find_all('a')
            for ending in ending_list:
                this_ending = ending['href']
                if not start:
                    start = self.check_start(this_ending, most_recent)
                if start:
                    self.parse_url_info(this_ending)
                    this_url = urljoin(self.base_url, this_ending)
                    print(this_url)
                    # The site only actually shows entries for the first 50 pages, then asks you to download files
                    # We'll check if it's more so we can skip ones with more and download them
                    # If the function returns true, the page is full so we should skip it
                    # The function logs it so we can download it later
                    if self.check_page50_full(this_url):
                        continue
                    # If the page50 check didn't jump out of this, we'll land here
                    # We want to loop through up to page 50
                    for j in range(1, 51):
                        # Add page number to the URL
                        page_url = urljoin(this_url, f'?page={str(j)}')
                        print(page_url)
                        # request the html from the site
                        page_soup = self.get_soup(page_url)
                        # Our data is in the tr tags so we find all
                        trs = page_soup.find_all('tr')
                        # Now loop through them (the first one is the header, so we skip it)
                        # Count is so we can jump out if count is zero
                        count = 0
                        for tr in trs[1:]:
                            self.parse_tr(tr)
                            self.write_dict['url'] = this_url
                            # Make sure this entry doesn't already exist so we can stop and start:
                            # If it exists, we'll just skip it
                            if self.sql.select('main',
                                               table='salaries',
                                               columns=self.columns,
                                               where=self.write_dict,
                                               where_and=True):
                                print('skipping', self.write_dict['employee_name'], self.write_dict['year'])
                            # otherwise, we will insert the data
                            else:
                                self.sql.insert('main', table='salaries', data=self.write_dict)
                                # print('inserted', 'name:', self.write_dict['employee_name'],
                                #       'year:', self.write_dict['year'],
                                #       'county:', self.write_dict['county'],
                                #       'entity:', self.write_dict['entity'])
                            # increment the count
                            count += 1
                        # If there is only one page, there will not be page numbers, which means until 51, every page
                        # number you supply, it will just repeat the one page of names.
                        # So we check for pagination, and if it's not there, we stop here after the first page
                        find = page_soup.find('div', class_='pagination pagination-centered')
                        if not find:
                            break
                        # if count is zero, we stop increasing
                        if count == 0:
                            break

    def go_small(self):
        try:
            # Open and read the file
            with open('more_than_50.txt', 'r') as f:
                urls = f.readlines()
            f.close()
            # Replace the line breaks; also, I'm using the more_than_50.txt to keep track of what I've downloaded by putting an
            # X before. So replacing that so they match the url  in the db and get removed as well.
            url_list = [f"{x.replace('\n', '')}" for x in urls if 'X' not in x]
            url_list = [x for x in url_list if x != '']
            self.write_dict = {x: None for x in self.columns}
            completed = []
            for i, url in enumerate(url_list):
                print(url)
                this_ending = url[32:]
                self.parse_url_info(this_ending)
                for i in range(1, 51):
                    page_url = urljoin(url, f'?page={str(i)}&s=total')
                    print(page_url)
                    # request the html from the site
                    page_soup = self.get_soup(page_url)
                    # Our data is in the tr tags so we find all
                    trs = page_soup.find_all('tr')
                    count = 0
                    for tr in trs[1:]:
                        self.parse_tr(tr)
                        self.write_dict['url'] = url
                        self.sql.insert('main', table='salaries', data=self.write_dict)
                        # print('inserted', 'name:', self.write_dict['employee_name'],
                        #       'year:', self.write_dict['year'],
                        #       'county:', self.write_dict['county'],
                        #       'entity:', self.write_dict['entity'])
                        # increment the count
                        count += 1
                    # if count is zero, we stop increasing
                    if count == 0:
                        break

                completed.append(url + '\n')
        except KeyboardInterrupt:
            print("Interrupt detected, wrapping up")
            for item in completed:
                urls[urls.index(item)] = 'X' + item

            with open('more_than_50.txt', 'w+') as f:
                f.writelines(urls)
            f.close()

        for item in completed:
            urls[urls.index(item)] = 'X' + item

        with open('more_than_50.txt', 'w+') as f:
            f.writelines(urls)
        f.close()

    def go_big_concurrent(self):
        # Open and read the file
        with open('more_than_50.txt', 'r') as f:
            urls = f.readlines()
        f.close()
        # Replace the line breaks; also, I'm using the more_than_50.txt to keep track of what I've downloaded by putting an
        # X before. So replacing that so they match the url  in the db and get removed as well.
        url_list = [f"{x.replace('\n', '')}" for x in urls if 'X' not in x]
        url_list = [x for x in url_list if x != '']
        url_batch_list = [[f'{x}?page={str(i)}&s=total' for i in range(1, 51)] for x in url_list]
        threads = []
        for url_batch in url_batch_list:
            t = threading.Thread(target=self.do_thing, args=(url_batch,))
            t.start()
            threads.append(t)

        for t in threads:
            t.join()

        token = self.worker.execute('SELECT COUNT(*) FROM salaries')
        count = self.worker.fetch_results(token)[0][0]
        print(f'Scraped {count} records')

        self.worker.close()

    def do_thing(self, url_batch):
        for page_url in url_batch:
            write_dict = {}
            page_soup = self.get_soup(page_url)
            url = page_url[:-16]
            print(url)
            this_ending = url[32:]
            # When we split the endings, some are entity and not county
            this_split = this_ending.split('/')
            if len(this_split) == 7:
                write_dict['county'] = this_split[4].strip()
                write_dict['entity'] = this_split[5].strip()
            elif len(this_split) == 4:
                write_dict['county'] = ''
                write_dict['entity'] = this_split[3].strip()
            elif len(this_split) == 6:
                write_dict['county'] = this_split[4].strip()
                write_dict['entity'] = this_split[5].strip()
            write_dict['year'] = this_split[2]
            # Our data is in the tr tags so we find all
            trs = page_soup.find_all('tr')
            for tr in trs[1:]:
                tds = tr.find_all('td')
                all_texts = [x.text.strip() for x in tds]

                all_texts[1] = all_texts[1].split('\n')[0]

                write_dict['employee_name'] = all_texts[0].replace("'", '')
                write_dict['job_title'] = all_texts[1].replace("'", '')
                # All except blocks below are because if there is text in one of these fields, we don't care about it, they're
                # numeric. So whatever that is, we turn to NaN
                try:
                    write_dict['regular_pay'] = float(all_texts[2].replace('$', '').replace(',', ''))
                except ValueError:
                    write_dict['regular_pay'] = np.nan
                try:
                    write_dict['overtime_pay'] = float(all_texts[3].replace('$', '').replace(',', ''))
                except ValueError:
                    write_dict['overtime_pay'] = np.nan
                try:
                    write_dict['other_pay'] = float(all_texts[4].replace('$', '').replace(',', ''))
                except ValueError:
                    write_dict['other_pay'] = np.nan
                try:
                    write_dict['total_pay'] = float(all_texts[5].replace('$', '').replace(',', ''))
                except ValueError:
                    write_dict['total_pay'] = np.nan
                try:
                    write_dict['benefits'] = float(all_texts[6].replace('$', '').replace(',', ''))
                except ValueError:
                    write_dict['benefits'] = np.nan
                try:
                    write_dict['total_pay_and_benefits'] = float(all_texts[7].replace('$', '').replace(',', ''))
                except ValueError:
                    write_dict['total_pay_and_benefits'] = np.nan
                write_dict['url'] = url
                try:
                    self.worker.execute('INSERT INTO salaries (employee_name, job_title, county, entity, year, regular_pay,'
                                        ' overtime_pay, other_pay, total_pay, benefits, total_pay_and_benefits, url) VALUES '
                                        '(:employee_name, :job_title, :county, :entity, :year, :regular_pay, :overtime_pay,'
                                        ' :other_pay, :total_pay, :benefits, '
                                        ':total_pay_and_benefits, :url)', write_dict)
                except Exception as e:
                    print(f'Error writing {url}: {e}')
                self.completed.append(url + '\n')
                # print('inserted', 'name:', write_dict['employee_name'],
                #   'year:', self.write_dict['year'],
                #   'county:', self.write_dict['county'],
                #   'entity:', self.write_dict['entity'])

    def check_pages(self):
        more_than_100 = []
        try:
            # Open and read the file
            with open('more_than_50.txt', 'r') as f:
                urls = f.readlines()
            f.close()
            with open('more_than_100.txt', 'r') as f:
                lines = f.readlines()
            f.close()
            last_entry = lines[-1].split(': ')[0]

            # Replace the line breaks; also, I'm using the more_than_50.txt to keep track of what I've downloaded by putting an
            # X before. So replacing that so they match the url  in the db and get removed as well.
            url_list = [f"{x.replace('\n', '').replace('X', '')}" for x in urls]
            url_list = [x for x in url_list if x != '']
            try:
                start_idx = url_list.index(last_entry)
            except ValueError:
                start_idx = 0

            for i, url in enumerate(url_list[start_idx + 1:]):
                print(url)
                page_soup = self.get_soup(url)
                # Our data is in the tr tags so we find all
                txt = page_soup.find('span', class_='page_subtitle').text.strip()
                find_page_nums = page_soup.find('div', class_='pagination pagination-centered')
                if not find_page_nums:
                    continue
                pages = int(txt.split('Page')[1].split('\xa0')[-1].replace(',', ''))

                if pages > 100:
                    more_than_100.append(f'{url}: {pages} pages\n')
        except:
            print(txt)
            with open('more_than_100.txt', 'w+') as f:
                f.writelines(more_than_100)
            f.close()
            exit()
        with open('more_than_100.txt', 'w+') as f:
            f.writelines(more_than_100)
        f.close()

    def check_page50_full(self, this_url):
        # The site only actually shows entries for the first 50 pages
        # Adding this code to check if it's more so we can skip ones with more
        page50_url = urljoin(this_url, f'?page=50')
        page50 = requests.get(page50_url)
        this_soup = BeautifulSoup(page50.content, 'html.parser')
        trs50 = this_soup.find_all('tr')
        # if page 50 is full, we assume it has more than 50
        if len(trs50) == 51:
            with open('more_than_50.txt', 'a+') as f:
                f.write(this_url + '\n')
            f.close()
            return True
        return False

    def check_start(self, this_ending, most_recent):
        # If the most recent url endswith this ending, we'll go ahead and start parsing again
        # But first we check that it's not None. If it's None, there were no entries so no most recent exists
        if most_recent and most_recent.endswith(this_ending):
            return True
        elif not most_recent:
            return True
        else:
            return False

    def get_most_recent_entry(self):
        most_recent = self.sql.select('main',
                                      table='salaries',
                                      columns=['url'],
                                      where='ID=(SELECT MAX(ID) FROM salaries)')
        if most_recent:
            return most_recent[0][0]
        else:
            return None

    def get_soup(self, url):
        # Request the HTML from the page
        page = requests.get(url)
        # Turn it into soup and return the soup
        return BeautifulSoup(page.content, 'html.parser')

    def parse_tr(self, tr):
        tds = tr.find_all('td')
        all_texts = [x.text.strip() for x in tds]

        all_texts[1] = all_texts[1].split('\n')[0]

        self.write_dict['employee_name'] = all_texts[0].replace("'", '')
        self.write_dict['job_title'] = all_texts[1].replace("'", '')
        # All except blocks below are because if there is text in one of these fields, we don't care about it, they're
        # numeric. So whatever that is, we turn to NaN
        try:
            self.write_dict['regular_pay'] = float(all_texts[2].replace('$', '').replace(',', ''))
        except ValueError:
            self.write_dict['regular_pay'] = np.nan
        try:
            self.write_dict['overtime_pay'] = float(all_texts[3].replace('$', '').replace(',', ''))
        except ValueError:
            self.write_dict['overtime_pay'] = np.nan
        try:
            self.write_dict['other_pay'] = float(all_texts[4].replace('$', '').replace(',', ''))
        except ValueError:
            self.write_dict['other_pay'] = np.nan
        try:
            self.write_dict['total_pay'] = float(all_texts[5].replace('$', '').replace(',', ''))
        except ValueError:
            self.write_dict['total_pay'] = np.nan
        try:
            self.write_dict['benefits'] = float(all_texts[6].replace('$', '').replace(',', ''))
        except ValueError:
            self.write_dict['benefits'] = np.nan
        try:
            self.write_dict['total_pay_and_benefits'] = float(all_texts[7].replace('$', '').replace(',', ''))
        except ValueError:
            self.write_dict['total_pay_and_benefits'] = np.nan

    def parse_url_info(self, this_ending):
        # When we split the endings, some are entity and not county
        this_split = this_ending.split('/')

        if len(this_split) == 7:
            self.write_dict['county'] = this_split[4].strip()
            self.write_dict['entity'] = this_split[5].strip()
        elif len(this_split) == 5:
            self.write_dict['county'] = None
            self.write_dict['entity'] = this_split[3].strip()
        self.write_dict['year'] = this_split[2]