Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Marin County Scraper #80

Merged
merged 50 commits into from
Sep 3, 2020
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
95f93c2
i think I got series data for the cases
kwonangela7 May 19, 2020
46d23cf
tried a variety of things to download csvs, eventually selected the r…
kwonangela7 May 26, 2020
e3e2357
added in Rob's suggested code
kwonangela7 May 26, 2020
9893f63
revised csv parsing logic now that I'm working with a csv_string
kwonangela7 May 27, 2020
a5a66d8
finished breakdown parsings
kwonangela7 Jun 2, 2020
a27073f
finalized series and test scraping methods with function annotations …
kwonangela7 Jun 10, 2020
30ccf08
fixed the bug so that only chart notes from the charts I'm looking at…
kwonangela7 Jun 10, 2020
cffeaea
Merge branch 'master' of https://github.com/sfbrigade/data-covid19-sf…
kwonangela7 Jun 10, 2020
cb9947c
moved marin scraper to folder
kwonangela7 Jun 10, 2020
b53c84e
deleted extra copy of marin_scraper.py
kwonangela7 Jun 11, 2020
ac14dfb
pulled new files
kwonangela7 Jun 20, 2020
c108ac1
converted tab to 4 spaces
kwonangela7 Jun 20, 2020
c6a969f
raised error for wrong kind of href
kwonangela7 Jun 20, 2020
c3b6f1a
Merge branch 'master' into marin-county
kwonangela7 Jun 24, 2020
0156f85
Update covid19_sfbayarea/data/marin_scraper.py
kwonangela7 Jun 28, 2020
029f367
changed module name
kwonangela7 Jun 28, 2020
cf1ddb3
deleted marin_scraper.py
kwonangela7 Jun 28, 2020
07760b2
Merge branch 'master' into marin-county
kwonangela7 Jun 28, 2020
a36b2c0
renamed file, will rename at the end lol
kwonangela7 Jun 28, 2020
0aadb08
renamed county function, added scraper to init file
kwonangela7 Jun 28, 2020
37880a6
pls ignore previous renaming commits, this is the actual commit to pr…
kwonangela7 Jun 28, 2020
9b90510
removing file with the wrong name
kwonangela7 Jun 28, 2020
8b2f8b9
added import to init statement
kwonangela7 Jun 29, 2020
39ce2bb
used soup.select('h4+p') instead of find_next_sibling + threw error
kwonangela7 Jul 1, 2020
7521dfb
fixed get_case_series to use csv modeul, not use numpy, and use the p…
kwonangela7 Jul 7, 2020
1e3fcbc
fixed case and deaths series data + breakdown functions to use csv mo…
kwonangela7 Jul 8, 2020
5b04be9
testing to get the most recent commits on this branch
Jul 11, 2020
850650e
Merge branch 'marin-county' of https://github.com/sfbrigade/data-covi…
Jul 11, 2020
2ba5273
simplified test logic
kwonangela7 Jul 15, 2020
d574680
fixed testing data logic, fixed age mappings. The raw counts for age …
kwonangela7 Jul 16, 2020
0b94cc4
fixed linter errors
kwonangela7 Jul 17, 2020
f7f532b
Merge branch 'master' into marin-county
kwonangela7 Jul 17, 2020
eb079be
ready to write up code in context managers tomorrow
kwonangela7 Jul 17, 2020
0fc2903
Merge branch 'marin-county' of https://github.com/sfbrigade/data-covi…
kwonangela7 Jul 17, 2020
862a240
rewrote metadata and extract csv functions using context managers
kwonangela7 Jul 18, 2020
153d379
fixed half of metadata function, not sure what's wrong with the other…
kwonangela7 Jul 23, 2020
90e75ee
fixed metadata function - finallygit add covid19_sfbayarea/data/marin…
kwonangela7 Aug 18, 2020
176cbd7
Merge remote-tracking branch 'origin/master' into marin-county
kwonangela7 Aug 18, 2020
6d12de7
added data points to data model needed for marin, updated README, and…
kwonangela7 Aug 22, 2020
04e62e1
fixed linter issue
kwonangela7 Aug 22, 2020
ef46d85
Update covid19_sfbayarea/data/marin.py
kwonangela7 Aug 29, 2020
b4826cc
removed instances of inmate as that data is not collected by marin co…
kwonangela7 Aug 29, 2020
e4f586f
updated README - inmate section
kwonangela7 Aug 29, 2020
c1e3be8
Merge branch 'marin-county' of https://github.com/sfbrigade/data-covi…
kwonangela7 Aug 29, 2020
e4b185b
updated Race and Ethnicity README
kwonangela7 Aug 29, 2020
d35f453
made sure to use 4-space indentation, fixed test series function to o…
kwonangela7 Aug 29, 2020
eb7c0c1
updated meta_from_baypd
kwonangela7 Aug 29, 2020
f5d8ae1
updated meta_from_source
kwonangela7 Aug 29, 2020
3a03180
updated meta_from_source about testing data nuances
kwonangela7 Aug 29, 2020
6d55284
Delete inmates from population_totals
elaguerta Sep 3, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion covid19_sfbayarea/data/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from typing import Dict, Any
from . import alameda
from . import san_francisco
from . import marin
from . import sonoma
from . import solano

scrapers: Dict[str, Any] = {
'alameda': alameda,
# 'contra_costa': None,
# 'marin': None,
'marin': marin,
# 'napa': None,
'san_francisco': san_francisco,
# 'san_mateo': None,
Expand Down
277 changes: 277 additions & 0 deletions covid19_sfbayarea/data/marin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
import csv
from typing import List, Dict, Tuple
from bs4 import BeautifulSoup # type: ignore
from urllib.parse import unquote_plus
from datetime import datetime
from contextlib import contextmanager
import time


from ..webdriver import get_firefox
from .utils import get_data_model

def get_county() -> Dict:
"""Main method for populating county data"""

url = 'https://coronavirus.marinhhs.org/surveillance'
model = get_data_model()

chart_ids = {"cases": "Eq6Es", "deaths": "Eq6Es", "inmates": "KCNZn", "age": "zSHDs", "gender": "FEciW", "race_eth": "aBeEd"}
# I removed "tests": '2Hgir' from chart_ids b/c it seems to have disappeared from the website?
elaguerta marked this conversation as resolved.
Show resolved Hide resolved

model['name'] = "Marin County"
model['update_time'] = datetime.today().isoformat()
model["meta_from_baypd"] = ["There's no actual update time on their website. Not all charts are updated daily.", "The cases and deaths total include inmate numbers, but the cases and deaths series, the testing data and data broken down by race/ethnicity, gender and age do not."]
model['source_url'] = url
model['meta_from_source'] = get_chart_meta(url, chart_ids)

model["series"]["cases"] = get_series_data(chart_ids["cases"], url, ['Date', 'Total Cases', 'Total Recovered*', 'Total Hospitalized', 'Total Deaths'], "cumul_cases", 'Total Cases', 'cases')
elaguerta marked this conversation as resolved.
Show resolved Hide resolved
model["series"]["deaths"] = get_series_data(chart_ids["deaths"], url, ['Date', 'Total Cases', 'Total Recovered*', 'Total Hospitalized', 'Total Deaths'], "cumul_deaths", 'Total Deaths', 'deaths')
model["inmates"]["cases"] = get_inmate_totals(chart_ids["inmates"], url)[0]
model["inmates"]["deaths"] = get_inmate_totals(chart_ids["inmates"], url)[1]

#model["series"]["tests"] = get_test_series(chart_ids["tests"], url)
model["case_totals"]["age_group"], model["death_totals"]["age_group"] = get_breakdown_age(chart_ids["age"], url)
model["case_totals"]["gender"], model["death_totals"]["gender"] = get_breakdown_gender(chart_ids["gender"], url)
model["case_totals"]["race_eth"], model["death_totals"]["race_eth"] = get_breakdown_race_eth(chart_ids["race_eth"], url)
return model

@contextmanager
def chart_frame(driver, chart_id: str): # type: ignore
# is this bad practice? I didn't know what type to specify here for the frame.
elaguerta marked this conversation as resolved.
Show resolved Hide resolved
frame = driver.find_element_by_css_selector(f'iframe[src*="//datawrapper.dwcdn.net/{chart_id}/"]')
driver.switch_to.frame(frame)
try:
yield frame
finally:
driver.switch_to.default_content()
driver.quit()

def get_chart_data(url: str, chart_id: str) -> List[str]:
"""This method extracts parsed csv data from the csv linked in the data wrapper charts."""
with get_firefox() as driver:
driver.implicitly_wait(30)
driver.get(url)

with chart_frame(driver, chart_id):
csv_data = driver.find_element_by_class_name('dw-data-link').get_attribute('href')
# Deal with the data
if csv_data.startswith('data:'):
media, data = csv_data[5:].split(',', 1)
# Will likely always have this kind of data type
if media != 'application/octet-stream;charset=utf-8':
raise ValueError(f'Cannot handle media type "{media}"')
csv_string = unquote_plus(data)
csv_data = csv_string.splitlines()
else:
raise ValueError('Cannot handle this csv_data href')

return csv_data

def get_chart_meta(url: str, chart_ids: Dict[str, str]) -> Tuple[List, List]:
"""This method gets all the metadata underneath the data wrapper charts and the metadata at the top of the county dashboard."""
metadata: set = set()
chart_metadata: set = set()

with get_firefox() as driver:
driver.implicitly_wait(30)
driver.get(url)
soup = BeautifulSoup(driver.page_source, 'html5lib')

for soup_obj in soup.findAll('div', attrs={"class":"surveillance-data-text"}):
if soup_obj.findAll('p'):
metadata = set({paragraph.text.replace("\u2014","").replace("\u00a0", "").replace("\u2019","") for paragraph in soup_obj.findAll('p')})
else:
raise ValueError('Metadata location has changed.')

with get_firefox() as driver: # I keep getting a connection error so maybe I need to do this again? seems weird.
driver.implicitly_wait(30)
driver.get(url)
# Metadata for each chart visualizing the data of the csv file I'll pull.
# I had to change my metadata function b/c for whatever reason, my usual code didn't pick up on the class notes block.
# There's something weird with the website that Ricardo and I couldn't quite pinpoint.
source_list: set = set()
for chart_id in chart_ids.values():
driver.implicitly_wait(30)
source = driver.find_element_by_css_selector(f'iframe[src*="//datawrapper.dwcdn.net/{chart_id}/"]').get_attribute('src')
source_list.add(source)

with get_firefox() as driver:
for source in source_list:
driver.get(source)
#breakpoint()
time.sleep(5) # this ensures there's enough time for the soup to find the elements and for the chart_metadata to populate.
# From the source code it seems that .get() should be synchronous but it's not working like that :(
soup = BeautifulSoup(driver.page_source, 'html5lib')
for data in soup.findAll('div', attrs = {'class': 'notes-block'}):
#breakpoint()
chart_metadata.add(data.text.strip())

# Return the metadata. I take the set of the chart_metadata since there are repeating metadata strings.
return list(metadata), list(chart_metadata)

def get_inmate_totals(chart_id: str, url: str) -> Tuple:
"""This method extracts the number of cases and deaths for San Quentin inmates."""
csv_data = get_chart_data(url, chart_id)
csv_reader = csv.DictReader(csv_data)

keys = csv_reader.fieldnames

if keys != ['Updated', 'Total Confirmed Cases', 'Total Resolved Cases', 'COVID-19 Deaths']:
raise ValueError('The headers have changed')

for row in csv_reader:
cases = row['Total Confirmed Cases']
deaths = row['COVID-19 Deaths']

return (cases, deaths)

def get_series_data(chart_id: str, url: str, headers: list, model_typ: str, typ: str, new_count: str) -> List:
"""This method extracts the date, number of cases/deaths, and new cases/deaths."""

csv_data = get_chart_data(url, chart_id)
csv_reader = csv.DictReader(csv_data)

keys = csv_reader.fieldnames

series: list = list()

if keys != headers:
raise ValueError('The headers have changed')

history: list = list()

for row in csv_reader:
daily: dict = dict()
date_time_obj = datetime.strptime(row['Date'], '%m/%d/%Y')
daily["date"] = date_time_obj.strftime('%Y-%m-%d')
# Collect the case totals in order to compute the change in cases per day
history.append(int(row[typ]))
daily[model_typ] = int(row[typ])
series.append(daily)

history_diff: list = list()
# Since i'm substracting pairwise elements, I need to adjust the range so I don't get an off by one error.
for i in range(0, len(history)-1):
history_diff.append((int(history[i+1]) - int(history[i])) + int(series[0][model_typ]))
# from what I've seen, series[0]["cumul_cases"] will be 0, but I shouldn't assume that.
history_diff.insert(0, int(series[0][model_typ]))

for val, num in enumerate(history_diff):
series[val][new_count] = num
return series

def get_breakdown_age(chart_id: str, url: str) -> Tuple[List, List]:
"""This method gets the breakdown of cases and deaths by age."""
csv_data = get_chart_data(url, chart_id)
csv_reader = csv.DictReader(csv_data)

keys = csv_reader.fieldnames

c_brkdown: list = list()
d_brkdown: list = list()

if keys != ['Age Category', 'POPULATION', 'Cases', 'Hospitalizations', 'Deaths']:
raise ValueError('The headers have changed')

key_mapping = {"0-9": "0_to_9", "10-18": "10_to_18", "19-34": "19_to_34", "35-49": "35_to_49", "50-64": "50_to_64", "65-79": "65_to_79", "80-94": "80_to_94", "95+": "95_and_older"}

for row in csv_reader:
c_age: dict = dict()
d_age: dict = dict()
# Extracting the age group and the raw count for both cases and deaths.
c_age["group"], d_age["group"] = row['Age Category'], row['Age Category']
if c_age["group"] not in key_mapping:
raise ValueError(str(c_age["group"]) + ' is not in the list of age groups. The age groups have changed.')
else:
c_age["group"] = key_mapping[c_age["group"]]
c_age["raw_count"] = int(row["Cases"])
d_age["group"] = key_mapping[d_age["group"]]
d_age["raw_count"] = int(row["Deaths"])
c_brkdown.append(c_age)
d_brkdown.append(d_age)

return c_brkdown, d_brkdown

def get_breakdown_gender(chart_id: str, url: str) -> Tuple[Dict, Dict]:
"""This method gets the breakdown of cases and deaths by gender."""
csv_data = get_chart_data(url, chart_id)
csv_reader = csv.DictReader(csv_data)

keys = csv_reader.fieldnames

if keys != ['Gender', 'POPULATION', 'Cases', 'Hospitalizations', 'Deaths']:
raise ValueError('The headers have changed.')

genders = ['male', 'female']
c_gender: dict = dict()
d_gender: dict = dict()

for row in csv_reader:
# Extracting the gender and the raw count (the 3rd and 5th columns, respectively) for both cases and deaths.
# Each new row has data for a different gender.
gender = row["Gender"].lower()
if gender not in genders:
return ValueError("The genders have changed.") # type: ignore
kwonangela7 marked this conversation as resolved.
Show resolved Hide resolved
# is doing this bad practice? mypy doesn't have an issue with the error on line 244 so not sure why this one causes an error
elaguerta marked this conversation as resolved.
Show resolved Hide resolved
c_gender[gender] = int(row["Cases"])
d_gender[gender] = int(row["Deaths"])

return c_gender, d_gender

def get_breakdown_race_eth(chart_id: str, url: str) -> Tuple[Dict, Dict]:
"""This method gets the breakdown of cases and deaths by race/ethnicity."""

csv_data = get_chart_data(url, chart_id)
csv_reader = csv.DictReader(csv_data)

keys = csv_reader.fieldnames

if keys != ['Race/Ethnicity', 'COUNTY POPULATION', 'Cases', 'Case Percent', 'Hospitalizations', 'Hospitalizations Percent', 'Deaths', 'Deaths Percent']:
raise ValueError("The headers have changed.")

key_mapping = {"Black/African American":"African_Amer", "Hispanic/Latino": "Latinx_or_Hispanic", "White": "White", "Asian": "Asian", "Native Hawaiian/Pacific Islander": "Pacific_Islander", "American Indian/Alaska Native": "Native_Amer", "Multi or Other Race": "Multi_or_Other"}

c_race_eth: dict = dict()
d_race_eth: dict = dict()

for row in csv_reader:
race_eth = row["Race/Ethnicity"]
if race_eth not in key_mapping:
raise ValueError("The race_eth groups have changed.")
else:
c_race_eth[key_mapping[race_eth]] = int(row["Cases"])
d_race_eth[key_mapping[race_eth]] = int(row["Deaths"])

return c_race_eth, d_race_eth

def get_test_series(chart_id: str, url: str) -> List:
"""This method gets the date, the number of positive and negative tests on that date, and the number of cumulative positive and negative tests."""
csv_data = get_chart_data(url, chart_id)

dates, positives, negatives = [row.split(',')[1:] for row in csv_data]
series = zip(dates, positives, negatives)

test_series: list = list()

cumul_pos = 0
cumul_neg = 0
for entry in series:
daily: dict = dict()
# I'm not sure why, but I just found out that some of the test series have a 'null' value (in the spot where the number of positive tests is), so I needed to account for that here.
# At least for now, it's only present at the end, so I just break out of the loop and return the test series.
if entry[1] != 'null':
date_time_obj = datetime.strptime(entry[0], '%m/%d/%Y')
daily["date"] = date_time_obj.strftime('%Y-%m-%d')
daily["positive"] = int(entry[1])
cumul_pos += daily["positive"]
daily["negative"] = int(entry[2])
cumul_neg += daily["negative"]
daily["cumul_pos"] = cumul_pos
daily["cumul_neg"] = cumul_neg
test_series.append(daily)
else:
break

return test_series
16 changes: 15 additions & 1 deletion data_models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Below are the tabulations we are making by gender, age group, race/ethnicity, an
"Pacific_Islander":-1,
"White":-1,
"Unknown":-1
"Multi_or_Other": -1
},
"underlying_cond": {
"none":-1,
Expand Down Expand Up @@ -137,7 +138,18 @@ The fields will be used for normalizing the county case and death tabulations, a
}
```

5. __Hospitalization Data__
6. __Inmate Data__

kwonangela7 marked this conversation as resolved.
Show resolved Hide resolved
This part of the data model currently only applies to Marin County, which reports the case and death count separately from the case and death count in the Marin County community. Note that the case and death data available for inmates is not in series form; there are only aggregated totals.

```
"inmates": {
"cases": -1,
"deaths": -1
}
```

6. __Hospitalization Data__

California COVID-19 hospitalization data is retrieved separately from the the
[California Health and Human Services Open Data Portal
Expand Down Expand Up @@ -205,6 +217,8 @@ Scraper authors, please keep an eye out for amendments to the data model.
# Race and Ethnicity
We need to collapse counties that report race and ethnicity into one race/ethnicity dimension. This section will be updated pending information about San Francisco County's methods for reporting race and ethnicity.

The category "Multi_or_Other" was included because Marin rolls up the numbers from "Multi" and "Other" into one.

kwonangela7 marked this conversation as resolved.
Show resolved Hide resolved
# Gender
One future potential issue is that some counties still lump non-binary and cis-gender people under "Other", and other counties have started to differentiate. Our data model would ideally match the most detailed county's gender categories. A county with only the "Other" county would have the value of -1 for the non male/female categories, indicating that they are not collecting that information. However, this means that our `"Other"` category would not be internally comparable or consistent. The `"Other"` category for a county that has "Male, Female, Other, MTF, FTM" as separate datapoints should really be called `"Other - not MTF, not FTM"` and is not comparable to the `"Other"` category for a county that only has "Male, Female, Other".

Expand Down
14 changes: 10 additions & 4 deletions data_models/data_model.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"meta_from_baypd": "STORE IMPORTANT NOTES ABOUT OUR METHODS HERE",
"series": {
"cases": [
{ "date": "yyyy-mm-dd", "cases": -1, "cumul_cases": -1},
{ "date": "yyyy-mm-dd", "cases": -1, "cumul_cases": -1 },
{ "date": "yyyy-mm-dd", "cases": -1, "cumul_cases": -1 }
],
"deaths": [
{ "date": "yyyy-mm-dd", "deaths": -1, "cumul_deaths": -1 },
{ "date": "yyyy-mm-dd", "deaths": -1, "cumul_deaths": -1}
{ "date": "yyyy-mm-dd", "deaths": -1, "cumul_deaths": -1 }
],
"tests": [
{
Expand Down Expand Up @@ -57,7 +57,8 @@
"Other": -1,
"Pacific_Islander":-1,
"White":-1,
"Unknown":-1
"Unknown":-1,
"Multi_or_Other": -1
},
"transmission_cat": {
"community": -1,
Expand All @@ -84,7 +85,8 @@
"Other": -1,
"Pacific_Islander":-1,
"White":-1,
"Unknown":-1
"Unknown":-1,
"Multi_or_Other": -1
},
"underlying_cond": {
"none":-1,
Expand Down Expand Up @@ -128,5 +130,9 @@
"White":-1,
"Unknown":-1
}
},
"inmates": {
"cases": -1,
"deaths": -1
}
}