Merge pull request #3 from AdrianoKF/feature/vaccination-data
Crawling and parsing of vaccination data, see #2
This commit is contained in:
10
.github/workflows/python-app.yml
vendored
10
.github/workflows/python-app.yml
vendored
@@ -50,3 +50,13 @@ jobs:
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
poetry run pytest
|
||||
|
||||
validate:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v2"
|
||||
- name: HACS validation
|
||||
uses: "hacs/action@main"
|
||||
with:
|
||||
category: "integration"
|
||||
ignore: brands wheels
|
||||
|
||||
24
README.md
24
README.md
@@ -2,7 +2,8 @@
|
||||
|
||||
## Adding to your dashboard
|
||||
|
||||
You can add an overview of the current infection numbers to your dashboard using the [multiple-entity-row](https://github.com/benct/lovelace-multiple-entity-row) card:
|
||||
You can add an overview of the current infection and vaccination numbers to your dashboard
|
||||
using the [multiple-entity-row](https://github.com/benct/lovelace-multiple-entity-row) card:
|
||||
|
||||
```yaml
|
||||
type: entities
|
||||
@@ -24,4 +25,25 @@ entities:
|
||||
secondary_info:
|
||||
attribute: incidence
|
||||
unit: cases/100k
|
||||
- type: custom:multiple-entity-row
|
||||
entity: sensor.covid_19_vaccinations_augsburg
|
||||
entities:
|
||||
- attribute: ratio_vaccinated_once
|
||||
name: Once
|
||||
format: precision1
|
||||
unit: '%'
|
||||
- attribute: ratio_vaccinated_full
|
||||
name: Fully
|
||||
format: precision1
|
||||
unit: '%'
|
||||
- attribute: ratio_vaccinated_total
|
||||
name: Total
|
||||
format: precision1
|
||||
unit: '%'
|
||||
show_state: false
|
||||
icon: mdi:biohazard
|
||||
name: COVID-19 Vaccinations
|
||||
secondary_info:
|
||||
attribute: date
|
||||
format: date
|
||||
```
|
||||
|
||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .crawler import CovidCrawler, IncidenceData
|
||||
from .crawler import CovidCrawler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,9 +67,12 @@ async def get_coordinator(hass: HomeAssistant):
|
||||
if DOMAIN in hass.data:
|
||||
return hass.data[DOMAIN]
|
||||
|
||||
async def async_get_data() -> IncidenceData:
|
||||
async def async_get_data() -> dict:
|
||||
crawler = CovidCrawler(hass)
|
||||
return await crawler.crawl()
|
||||
return {
|
||||
"incidence": await crawler.crawl_incidence(),
|
||||
"vaccination": await crawler.crawl_vaccination(),
|
||||
}
|
||||
|
||||
hass.data[DOMAIN] = DataUpdateCoordinator(
|
||||
hass,
|
||||
|
||||
@@ -15,6 +15,32 @@ def parse_num(s, t=int):
|
||||
return 0
|
||||
|
||||
|
||||
def parse_date(
|
||||
day: int, month: str, year=datetime.datetime.now().year
|
||||
) -> datetime.date:
|
||||
"""Parse a German medium-form date, e.g. 17. August into a datetime.date"""
|
||||
months = [
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember",
|
||||
]
|
||||
date = datetime.date(
|
||||
year=int(year),
|
||||
month=1 + months.index(month),
|
||||
day=parse_num(day),
|
||||
)
|
||||
return date
|
||||
|
||||
|
||||
@dataclass
|
||||
class IncidenceData:
|
||||
location: str
|
||||
@@ -26,40 +52,60 @@ class IncidenceData:
|
||||
num_dead: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VaccinationData:
|
||||
date: str
|
||||
|
||||
total_vaccinations: int = 0
|
||||
num_vaccinated_once: int = 0
|
||||
num_vaccinated_full: int = 0
|
||||
|
||||
ratio_vaccinated_once: float = 0.0
|
||||
ratio_vaccinated_full: float = 0.0
|
||||
ratio_vaccinated_total: float = 0.0
|
||||
|
||||
|
||||
class CovidCrawlerBase(ABC):
|
||||
@abstractmethod
|
||||
def crawl(self) -> IncidenceData:
|
||||
def crawl_incidence(self) -> IncidenceData:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def crawl_vaccination(self) -> VaccinationData:
|
||||
pass
|
||||
|
||||
|
||||
class CovidCrawler(CovidCrawlerBase):
|
||||
def __init__(self, hass=None) -> None:
|
||||
self.url = (
|
||||
"https://www.augsburg.de/umwelt-soziales/gesundheit/coronavirus/fallzahlen"
|
||||
)
|
||||
self.hass = hass
|
||||
|
||||
async def crawl(self) -> IncidenceData:
|
||||
async def _fetch(self, url: str) -> str:
|
||||
"""Fetch a URL, using either the current Home Assistant instance or requests"""
|
||||
|
||||
if self.hass:
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
result = await aiohttp_client.async_get_clientsession(self.hass).get(url)
|
||||
soup = BeautifulSoup(await result.text(), "html.parser")
|
||||
else:
|
||||
import requests
|
||||
|
||||
result = requests.get(url)
|
||||
result.raise_for_status()
|
||||
soup = BeautifulSoup(result.text, "html.parser")
|
||||
return soup
|
||||
|
||||
async def crawl_incidence(self) -> IncidenceData:
|
||||
"""
|
||||
Fetch COVID-19 infection data from the target website.
|
||||
"""
|
||||
|
||||
_log.info("Fetching COVID-19 data update")
|
||||
|
||||
if self.hass:
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
result = await aiohttp_client.async_get_clientsession(self.hass).get(
|
||||
self.url
|
||||
url = (
|
||||
"https://www.augsburg.de/umwelt-soziales/gesundheit/coronavirus/fallzahlen"
|
||||
)
|
||||
soup = BeautifulSoup(await result.text(), "html.parser")
|
||||
else:
|
||||
import requests
|
||||
|
||||
result = requests.get(self.url)
|
||||
if not result.ok:
|
||||
result.raise_for_status()
|
||||
soup = BeautifulSoup(result.text, "html.parser")
|
||||
soup = await self._fetch(url)
|
||||
|
||||
match = soup.find(class_="frame--type-textpic")
|
||||
text = match.p.text
|
||||
@@ -79,27 +125,7 @@ class CovidCrawler(CovidCrawlerBase):
|
||||
if not matches:
|
||||
raise ValueError(f"Could not extract date from scraped web page, {text=}")
|
||||
|
||||
months = [
|
||||
"Januar",
|
||||
"Februar",
|
||||
"März",
|
||||
"April",
|
||||
"Mai",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"August",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Dezember",
|
||||
]
|
||||
day = parse_num(matches.group(1))
|
||||
month_name = matches.group(2)
|
||||
date = datetime.date(
|
||||
year=datetime.datetime.now().year,
|
||||
month=1 + months.index(month_name),
|
||||
day=day,
|
||||
)
|
||||
date = parse_date(matches.group(1), matches.group(2))
|
||||
_log.debug(f"Parsed date: {date}")
|
||||
|
||||
match = match.find_next_sibling(class_="frame--type-textpic")
|
||||
@@ -130,3 +156,50 @@ class CovidCrawler(CovidCrawlerBase):
|
||||
_log.debug(f"Result data: {result}")
|
||||
|
||||
return result
|
||||
|
||||
async def crawl_vaccination(self) -> VaccinationData:
|
||||
_log.info("Fetching COVID-19 vaccination data update")
|
||||
url = (
|
||||
"https://www.augsburg.de/umwelt-soziales/gesundheit/coronavirus/impfzentrum"
|
||||
)
|
||||
soup = await self._fetch(url)
|
||||
|
||||
container_id = "c1088140"
|
||||
result = soup.find(id=container_id)
|
||||
text = re.sub(r"\s+", " ", result.text)
|
||||
regexes = [
|
||||
r"(?P<total_vaccinations>\d+[.]\d+) Impfdosen",
|
||||
r"Weitere (?P<num_vaccinated_once>\d+[.]\d+) Personen haben die Erstimpfung erhalten",
|
||||
r"(?P<num_vaccinated_full>\d+[.]\d+) Personen sind bereits vollständig geimpft",
|
||||
]
|
||||
values = {}
|
||||
for r in regexes:
|
||||
matches = re.search(r, text)
|
||||
if not matches:
|
||||
continue
|
||||
values.update(
|
||||
{
|
||||
k: parse_num(v.replace(".", ""))
|
||||
for k, v in matches.groupdict().items()
|
||||
}
|
||||
)
|
||||
|
||||
matches = re.search(r"Stand (?P<day>\d+)\. (?P<month>\w+) (?P<year>\d+)", text)
|
||||
if not matches:
|
||||
raise ValueError(f"Could not extract date from scraped web page, {text=}")
|
||||
|
||||
values["date"] = parse_date(**matches.groupdict()).strftime("%Y-%m-%d")
|
||||
result = VaccinationData(**values)
|
||||
|
||||
# Total population in Augsburg as of 2020
|
||||
# https://www.augsburg.de/fileadmin/user_upload/buergerservice_rathaus/rathaus/statisiken_und_geodaten/statistiken/Monitoring/Demografiemonitoring_der_Stadt_Augsburg_2021.pdf
|
||||
population = 299021
|
||||
|
||||
result.ratio_vaccinated_full = result.num_vaccinated_full / population * 100
|
||||
result.ratio_vaccinated_once = result.num_vaccinated_once / population * 100
|
||||
result.ratio_vaccinated_total = (
|
||||
result.ratio_vaccinated_once + result.ratio_vaccinated_full
|
||||
)
|
||||
_log.debug(f"Result data: {result}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,7 +3,10 @@ from .crawler import CovidCrawler
|
||||
|
||||
async def main():
|
||||
crawler = CovidCrawler()
|
||||
result = await crawler.crawl()
|
||||
result = await crawler.crawl_incidence()
|
||||
print(result)
|
||||
|
||||
result = await crawler.crawl_vaccination()
|
||||
print(result)
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"domain": "covid19_augsburg",
|
||||
"name": "COVID-19 Augsburg",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.0",
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/AdrianoKF/home-assistant-covid19-augsburg",
|
||||
"issue_tracker": "https://github.com/AdrianoKF/home-assistant-covid19-augsburg/issues",
|
||||
"requirements": ["beautifulsoup4==4.8.2"],
|
||||
"dependencies": [],
|
||||
"codeowners": ["@AdrianoKF"]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from dataclasses import asdict
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import get_coordinator
|
||||
@@ -7,7 +9,12 @@ async def async_setup_entry(hass, _, async_add_entities):
|
||||
"""Defer sensor setup to the shared sensor module."""
|
||||
coordinator = await get_coordinator(hass)
|
||||
|
||||
async_add_entities([CoronaAugsburgSensor(coordinator)])
|
||||
async_add_entities(
|
||||
[
|
||||
CoronaAugsburgSensor(coordinator),
|
||||
CoronaAugsburgVaccinationSensor(coordinator),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CoronaAugsburgSensor(Entity):
|
||||
@@ -41,18 +48,59 @@ class CoronaAugsburgSensor(Entity):
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.coordinator.data.incidence
|
||||
return self.coordinator.data["incidence"].incidence
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
return {
|
||||
"date": self.coordinator.data.date,
|
||||
"incidence": self.coordinator.data.incidence,
|
||||
"total_cases": self.coordinator.data.total_cases,
|
||||
"num_dead": self.coordinator.data.num_dead,
|
||||
"num_recovered": self.coordinator.data.num_recovered,
|
||||
"num_infected": self.coordinator.data.num_infected,
|
||||
}
|
||||
data = self.coordinator.data["incidence"]
|
||||
return asdict(data)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""When entity is added to hass."""
|
||||
self.coordinator.async_add_listener(self.async_write_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""When entity will be removed from hass."""
|
||||
self.coordinator.async_remove_listener(self.async_write_ha_state)
|
||||
|
||||
|
||||
class CoronaAugsburgVaccinationSensor(Entity):
|
||||
"""Representation of vaccination data for the city of Augsburg"""
|
||||
|
||||
def __init__(self, coordinator):
|
||||
"""Initialize sensor."""
|
||||
self.coordinator = coordinator
|
||||
self._name = "COVID-19 Vaccinations Augsburg"
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return self.coordinator.last_update_success and self.coordinator.data
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
return "mdi:biohazard"
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
return ""
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
return self.coordinator.data["vaccination"].total_vaccinations
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
data = self.coordinator.data["vaccination"]
|
||||
return asdict(data)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""When entity is added to hass."""
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
TODO: Remove once other tests have been added.
|
||||
"""
|
||||
|
||||
|
||||
def test_example():
|
||||
assert True
|
||||
|
||||
Reference in New Issue
Block a user