Created
October 8, 2020 16:37
-
-
Save max-degterev/f6affc8a9d1ff325dedd9ee3a631fb1a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const got = require('got'); | |
const sendMail = require('../mailer'); | |
const render = require('./template'); | |
const recipients = [ | |
'[email protected]', | |
]; | |
// berlin.de is new to the internet and wants every user of the API identify himself with a correct | |
// user agent 🙈 Let's see how they block these. | |
const userAgents = [ | |
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML like Gecko) Chrome/51.0.2704.79 Safari/537.36 Edge/14.14931', | |
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36', | |
'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:64.0) Gecko/20100101 Firefox/64.0', | |
'Opera/9.80 (Macintosh; Intel Mac OS X 10.14.1) Presto/2.12.388 Version/12.16', | |
]; | |
const getOptions = () => ({ | |
responseType: 'json', | |
headers: { 'user-agent': userAgents[Math.floor(Math.random() * userAgents.length)] }, | |
}); | |
const COUNTRY_URL = 'https://rki-covid-api.now.sh/api/states'; | |
const CITY_URL = 'https://www.berlin.de/lageso/gesundheit/infektionsepidemiologie-infektionsschutz/corona/tabelle-bezirke/index.php/index/all.json'; | |
const CITY_EXTRAS_URL = 'https://knudmoeller.github.io/berlin_corona_cases/data/target/berlin_corona_traffic_light.json'; | |
const OFFICIAL_STAT_ERROR_RATE_FACTOR = 8; // Latest number from the US | |
const NON_REPORTED_CASES_COMP_FACTOR = 10; | |
const COUNTRY_MORTALITY_RATE = .05; | |
const CITY_POPULATION_COUNT = 3562038; | |
const SYMPTOMS_ONSET_MIN_DIFF = 5; | |
const SYMPTOMS_ONSET_MAX_DIFF = 14; | |
const MULTIPLIER = OFFICIAL_STAT_ERROR_RATE_FACTOR * NON_REPORTED_CASES_COMP_FACTOR; | |
const padLeft = (number) => number.toString().padStart(2, '0'); | |
const getDate = (diff) => { | |
const date = new Date(); | |
date.setDate(date.getDate() + diff); | |
return `${date.getFullYear()}-${padLeft(date.getMonth() + 1)}-${padLeft(date.getDate())}`; | |
}; | |
const sendEmail = ({ country, city, extras }) => { | |
const date = getDate(-1); | |
const infectionWindow = [ | |
getDate(-SYMPTOMS_ONSET_MAX_DIFF), | |
getDate(-SYMPTOMS_ONSET_MIN_DIFF), | |
]; | |
const props = { date, infectionWindow, country, city, extras }; | |
const text = render(props); | |
if (!process.env.DEBUG) { | |
recipients.forEach((address) => ( | |
sendMail(address, `Corona statistics for ${date}`, text) | |
)); | |
} else { | |
console.log(text); | |
} | |
}; | |
const addCountryFields = (target, item) => ['count', 'difference', 'deaths'].reduce((acc, key) => ({ | |
...acc, | |
[key]: (target[key] || 0) + item[key], | |
}), {}); | |
const formatCityFigures = ({ fallzahl, genesen }) => ({ | |
count: parseInt(fallzahl, 10), | |
recovered: parseInt(genesen, 10), | |
// This figure is missing deaths count for a correct calculation | |
// Replacing with calculation based on Germany's death rate of 5% | |
approxActive: Math.max( | |
0, | |
parseInt(fallzahl, 10) | |
- parseInt(genesen, 10) | |
- Math.floor(parseInt(fallzahl, 10) * COUNTRY_MORTALITY_RATE), | |
), | |
}); | |
const formatCityExtrasFigures = ({ indicators }) => ({ | |
reproduction: indicators.basic_reproduction_number.value, | |
incidence: indicators.incidence_new_infections.value, | |
icu_occupancy: indicators.icu_occupancy_rate.value, | |
}); | |
const mergeCityNumbers = (rki, berlin) => { | |
const { deaths, difference } = rki; | |
const { recovered } = berlin; | |
const count = Math.max(rki.count, berlin.count); | |
const active = count - deaths - recovered; | |
const realActive = active * MULTIPLIER; | |
const infectionRisk = (active / CITY_POPULATION_COUNT) * 100; | |
const realInfectionRisk = (realActive / CITY_POPULATION_COUNT) * 100; | |
const mortality = (deaths / count) * 100; | |
const recoveryRate = (recovered / count) * 100; | |
const headCount = Math.ceil(100 / infectionRisk); | |
const realHeadCount = Math.ceil(100 / realInfectionRisk); | |
return { | |
count, | |
difference, | |
deaths, | |
recovered, | |
active, | |
realActive, | |
infectionRisk, | |
realInfectionRisk, | |
mortality, | |
recoveryRate, | |
headCount, | |
realHeadCount, | |
mult: String(MULTIPLIER), | |
}; | |
}; | |
const getCountryNumbers = async() => { | |
const { body } = await got(COUNTRY_URL, getOptions()); | |
const country = body.states.reduce((acc, item) => addCountryFields(acc, item), {}); | |
const city = body.states.find(({ name }) => name === 'Berlin'); | |
return { country, city }; | |
}; | |
const getCityNumbers = async() => { | |
const { body } = await got(CITY_URL, getOptions()); | |
const city = formatCityFigures(body.index.find(({ bezirk }) => bezirk === 'Summe' || bezirk === 'Berlin')); | |
return city; | |
}; | |
const getCityExtrasNumbers = async() => { | |
const { body } = await got(CITY_EXTRAS_URL, getOptions()); | |
const extras = formatCityExtrasFigures(body[0]); | |
return extras; | |
}; | |
const runJob = async() => { | |
const { country, city: rkiCity } = await getCountryNumbers(); | |
const berlinCity = await getCityNumbers(); | |
const extras = await getCityExtrasNumbers(); | |
const city = mergeCityNumbers(rkiCity, berlinCity); | |
sendEmail({ country, city, extras }); | |
}; | |
module.exports = runJob; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const nodemailer = require('nodemailer'); | |
const config = require('../config'); | |
const credentials = [config.smtp.email, config.smtp.password].map(encodeURIComponent).join(':'); | |
const transporter = nodemailer.createTransport(`smtps://${credentials}@smtp.gmail.com`); | |
const sendMail = (to, subject, text, done) => { | |
const options = { | |
to, | |
subject, | |
text, | |
from: config.contacts.from, | |
}; | |
const complete = (error, result) => { | |
if (error) console.error(`Sending email failed: ${error}`); | |
else console.log(`Email sent: ${result.response}`); | |
if (done) done(error, result); | |
}; | |
transporter.sendMail(options, complete); | |
}; | |
module.exports = sendMail; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const LEVELS = ['critical', 'dangerous', 'elevated', 'acceptable', 'low']; | |
const getStatusLabel = ({ realInfectionRisk }) => { | |
if (realInfectionRisk > 10) return LEVELS[0]; | |
if (realInfectionRisk > 6) return LEVELS[1]; | |
if (realInfectionRisk > 4) return LEVELS[2]; | |
if (realInfectionRisk > 2) return LEVELS[3]; | |
return LEVELS[4]; | |
}; | |
// R-Wert < 1,1 = Grün | |
// R-Wert mindestens 3 Mal in Folge ≥ 1,1 = Gelb | |
// R-Wert mindestens 3 Mal in Folge ≥ 1,2 = Rot | |
const getReproductionStatusLabel = (value) => { | |
if (value > 1.2) return LEVELS[0]; | |
if (value > 1.1) return LEVELS[1]; | |
if (value > 1.0) return LEVELS[2]; | |
return LEVELS[3]; | |
}; | |
// Zahl < 20 je 100.000 Einwohner*innen = Grün | |
// Zahl ≥ 20 je 100.000 Einwohner*innen = Gelb | |
// Zahl ≥ 30 je 100.000 Einwohner*innen = Rot | |
const getIncidenceStatusLabel = (value) => { | |
if (value > 30) return LEVELS[0]; | |
if (value > 20) return LEVELS[1]; | |
if (value > 10) return LEVELS[2]; | |
return LEVELS[3]; | |
}; | |
// Anteil < 15 % = Grün | |
// Anteil ≥ 15 % = Gelb | |
// Anteil ≥ 25 % = Rot | |
const getICUStatusLabel = (value) => { | |
if (value > 25) return LEVELS[0]; | |
if (value > 15) return LEVELS[1]; | |
if (value > 10) return LEVELS[2]; | |
return LEVELS[3]; | |
}; | |
const renderDetailedStats = ({ | |
active, realActive, | |
infectionRisk, realInfectionRisk, | |
mortality, recoveryRate, | |
headCount, realHeadCount, | |
mult, | |
}) => ` | |
Mortality rate: ${mortality.toFixed(2)}% | |
Recovery rate: ${recoveryRate.toFixed(2)}% | |
Total active cases: ${active} | |
Estimated real active cases (x${mult}): ${realActive} | |
Infection risk: ${infectionRisk.toFixed(2)}% | |
Estimated real infection risk (x${mult}): ${realInfectionRisk.toFixed(2)}% | |
Crowd factor: ${headCount} | |
Estimated real crowd factor (÷${mult}): ${realHeadCount}`; | |
const renderExtras = ({ recovered, deaths, difference, approxActive, ...detailed }) => ( | |
[ | |
[recovered, () => ` Recovered: ${recovered}`], | |
[difference, () => ` New active cases: +${difference}`], | |
[deaths, () => ` New deaths: +${deaths}`], | |
[approxActive, () => ` Total active cases (approx.): ${approxActive}`], | |
[typeof detailed.active === 'number', () => renderDetailedStats(detailed)], | |
] | |
.filter((item) => item[0]) | |
.map((item) => item[1]()) | |
.join('\n') | |
); | |
const renderNode = (label, { count, ...extras }) => ` | |
Breakdown for ${label}: | |
Total reported cases: ${count} | |
${renderExtras(extras)}`; | |
module.exports = ({ | |
date, country, city, infectionWindow, extras: { reproduction, incidence, icu_occupancy }, | |
}) => ` | |
Corona statistics for ${date}. | |
Current infection risk is ${city.realInfectionRisk.toFixed(2)}% (${getStatusLabel(city)}). Avoid groups of more than ${city.realHeadCount} people. | |
${renderNode('Germany', country)} | |
${renderNode('Berlin', city)} | |
Statistics over time (Berlin): | |
R: ${reproduction} (${getReproductionStatusLabel(reproduction)}) | |
Incidence per 100k: ${incidence} (${getIncidenceStatusLabel(incidence)}) | |
ICU Occupancy: ${icu_occupancy}% (${getICUStatusLabel(icu_occupancy)}) | |
Infection window for these figures: ${infectionWindow[0]} -> ${infectionWindow[1]}. | |
`.trim(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment