### # Copyright (c) 2020, Hoaas # Copyright (c) 2020, oddluck # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # * Redistributions of source code must retain the above copyright notice, # this list of conditions, and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright notice, # this list of conditions, and the following disclaimer in the # documentation and/or other materials provided with the distribution. # * Neither the name of the author of this software nor the name of # contributors to this software may be used to endorse or promote products # derived from this software without specific prior written consent. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. ### import requests import datetime import re from bs4 import BeautifulSoup from .codes import states, countries from supybot import utils, plugins, ircutils, callbacks, log from supybot.commands import * try: from supybot.i18n import PluginInternationalization _ = PluginInternationalization("Corona") except ImportError: # Placeholder that allows to run the plugin on a bot # without the i18n module _ = lambda x: x class Corona(callbacks.Plugin): """Displays current stats of the Coronavirus outbreak""" threaded = True def __init__(self, irc): self.__parent = super(Corona, self) self.__parent.__init__(irc) self.countries = requests.structures.CaseInsensitiveDict() self.states = requests.structures.CaseInsensitiveDict() self.updated = None self.top = {} self.top["countries"] = [] self.top["states"] = [] def time_created(self, time): """ Return relative time delta between now and s (dt string). """ d = datetime.datetime.utcnow() - time if d.days: rel_time = "{:1d}d ago".format(abs(d.days)) elif d.seconds > 3600: rel_time = "{:.1f}h ago".format(round((abs(d.seconds) / 3600), 1)) elif 60 <= d.seconds < 3600: rel_time = "{:.1f}m ago".format(round((abs(d.seconds) / 60), 1)) else: rel_time = "%ss ago" % (abs(d.seconds)) return rel_time def get_data(self): headers = {} headers["countries"] = [] headers["states"] = [] OK = False try: r = requests.get("https://www.worldometers.info/coronavirus/", timeout=10) r.raise_for_status() OK = True except ( requests.exceptions.RequestException, requests.exceptions.HTTPError, ) as e: log.error("Corona: error retrieving World data from API: {0}".format(e)) OK = False return soup = BeautifulSoup(r.content) update = soup.find("div", text=re.compile("Last updated:")) update = update.text.split(":", 1)[1].replace("GMT", "UTC").strip() updated = datetime.datetime.strptime(update, "%B %d, %Y, %H:%M %Z") if OK and not self.updated or OK and updated > self.updated: self.updated = updated table = soup.find("table", {"id": "main_table_countries_today"}) headers["countries"] = [header.text for header in table.find_all("th")] results = [ { headers["countries"][i]: cell.text.strip() for i, cell in enumerate(row.find_all("td")) } for row in table.find_all("tr", {"style": ""}) ] results = sorted( results, key=lambda k_v: int(re.sub(r"[^\d]", "", k_v["TotalCases"])) if len(k_v) == len(headers["countries"]) else 0, reverse=True, ) top = [] for item in results: if len(item) == len(headers["countries"]): i = 0 while i < len(headers["countries"]): if i < 8 and not item[headers["countries"][i]]: item[headers["countries"][i]] = "0" elif not item[headers["countries"][i]]: item[headers["countries"][i]] = "N/A" if re.sub( r"[^\w. ]", "", item[headers["countries"][i]] ).isdigit(): item[headers["countries"][i]] = int( re.sub(r"[^\d]", "", item[headers["countries"][i]]) ) i += 1 self.countries[item["Country,Other"]] = item rank = results.index(item) - 1 self.countries[item["Country,Other"]]["rank"] = rank if rank > 0 and rank <= 10: top.append( "#{0}: \x1F{1}\x1F (\x0307{2}\x03/\x0304{3}\x03)".format( rank, item["Country,Other"], "{:,}".format(item["TotalCases"]), "{:,}".format(item["TotalDeaths"]), ) ) self.top["countries"] = top for item in self.countries: try: self.countries[item]["ratio_new_cases"] = "{0:.1%}".format( self.countries[item]["NewCases"] / ( self.countries[item]["TotalCases"] - self.countries[item]["NewCases"] ) ) except: self.countries[item]["ratio_new_cases"] = "0%" try: self.countries[item]["ratio_new_dead"] = "{0:.1%}".format( self.countries[item]["NewDeaths"] / ( self.countries[item]["TotalDeaths"] - self.countries[item]["NewDeaths"] ) ) except: self.countries[item]["ratio_new_dead"] = "0%" try: self.countries[item]["ratio_dead"] = "{0:.1%}".format( self.countries[item]["TotalDeaths"] / self.countries[item]["TotalCases"] ) except: self.countries[item]["ratio_dead"] = "N/A" try: self.countries[item]["ratio_recovered"] = "{0:.1%}".format( self.countries[item]["TotalRecovered"] / self.countries[item]["TotalCases"] ) except: self.countries[item]["ratio_recovered"] = "N/A" try: self.countries[item]["mild"] = ( self.countries[item]["ActiveCases"] - self.countries[item]["Serious,Critical"] ) except: self.countries[item]["mild"] = "N/A" try: self.countries[item]["ratio_mild"] = "{0:.1%}".format( self.countries[item]["mild"] / self.countries[item]["ActiveCases"] ) except: self.countries[item]["ratio_mild"] = "N/A" try: self.countries[item]["ratio_serious"] = "{0:.1%}".format( self.countries[item]["Serious,Critical"] / self.countries[item]["ActiveCases"] ) except: self.countries[item]["ratio_serious"] = "N/A" try: self.countries[item]["CFR"] = "{0:.1%}".format( self.countries[item]["TotalDeaths"] / ( self.countries[item]["TotalDeaths"] + self.countries[item]["TotalRecovered"] ) ) except: self.countries[item]["CFR"] = "N/A" for value in self.countries[item]: if isinstance(self.countries[item][value], int): self.countries[item][value] = "{:,}".format( self.countries[item][value] ) try: r = requests.get( "https://www.worldometers.info/coronavirus/country/us/", timeout=10 ) r.raise_for_status() OK = True except ( requests.exceptions.RequestException, requests.exceptions.HTTPError, ) as e: log.error("Corona: error retrieving USA data from API: {0}".format(e)) OK = False return if OK: soup = BeautifulSoup(r.content) table = soup.find("table", {"id": "usa_table_countries_today"}) headers["states"] = [header.text for header in table.find_all("th")] results = [ { headers["states"][i]: cell.text.strip() for i, cell in enumerate(row.find_all("td")) } for row in table.find_all("tr") ] results = sorted( results, key=lambda k_v: int(re.sub(r"[^\d]", "", k_v["TotalCases"])) if len(k_v) == len(headers["states"]) else 0, reverse=True, ) top = [] for item in results: if len(item) == len(headers["states"]): i = 0 while i < len(headers["states"]): if not item[headers["states"][i]]: item[headers["states"][i]] = "0" if re.sub( r"[^\w. ]", "", item[headers["states"][i]] ).isdigit(): item[headers["states"][i]] = int( re.sub(r"[^\d]", "", item[headers["states"][i]]) ) i += 1 self.states[item["USAState"]] = item rank = results.index(item) self.states[item["USAState"]]["rank"] = rank if rank > 0 and rank <= 10: top.append( "#{0}: \x1F{1}\x1F (\x0307{2}\x03/\x0304{3}\x03)".format( rank, item["USAState"], "{:,}".format(item["TotalCases"]), "{:,}".format(item["TotalDeaths"]), ) ) self.top["states"] = top for item in self.states: try: self.states[item]["ratio_new_cases"] = "{0:.1%}".format( self.states[item]["NewCases"] / ( self.states[item]["TotalCases"] - self.states[item]["NewCases"] ) ) except: self.states[item]["ratio_new_cases"] = "0%" try: self.states[item]["ratio_new_dead"] = "{0:.1%}".format( self.states[item]["NewDeaths"] / ( self.states[item]["TotalDeaths"] - self.states[item]["NewDeaths"] ) ) except: self.states[item]["ratio_new_dead"] = "0%" try: self.states[item]["ratio_dead"] = "{0:.1%}".format( self.states[item]["TotalDeaths"] / self.states[item]["TotalCases"] ) except: self.states[item]["ratio_dead"] = "N/A" for value in self.states[item]: if isinstance(self.states[item][value], int): self.states[item][value] = "{:,}".format( self.states[item][value] ) return True else: log.error("Corona: unable to retrieve latest USA data") return elif len(self.countries) > 0 and len(self.states) > 0: log.info("Corona: data not yet updated, using cache") return True else: log.error("Corona: Error. Unable to retrieve data.") return @wrap([getopts({"top10": ""}), optional("text")]) def corona(self, irc, msg, args, optlist, search): """[region] Return Coronavirus statistics from https://www.worldometers.info/coronavirus/. Search accepts full country/state names or ISO 3166-1 alpha-2 (two character) country abbreviations and US Postal (two character) state abbreviations. Invalid region names or search terms without data return global results. """ optlist = dict(optlist) if "top10" in optlist: self.top10(irc, msg, args, None) return if search: search = search.strip() if not self.get_data(): irc.reply( "Error retrieving data from https://www.worldometers.info/coronavirus/" ) return if search and len(search) == 2: if self.registryValue("countryFirst", msg.channel): try: search = countries[search.upper()] except KeyError: try: search = states[search.upper()] except KeyError: pass else: try: search = states[search.upper()] except KeyError: try: search = countries[search.upper()] except KeyError: pass def reply_country(): irc.reply( "\x02\x1F{0}\x1F: World Rank: #{1} | Cases: \x0307{2}\x03 " "(\x0307+{3}\x03) (\x0307+{4}\x03) | Deaths: \x0304{5}\x03 " "(\x0304{6}\x03) (\x0304+{7}\x03) (\x0304+{8}\x03) | Recovered: " "\x0309{9}\x03 (\x0309{10}\x03) | Active: \x0307{11}\x03 " "(\x0310{12}\x03 Mild) (\x0313{13}\x03 Serious) (\x0310{14}\x03/" "\x0313{15}\x03) | Cases/1M: \x0307{16}\x03 | Deaths/1M: \x0304{17}" "\x03 | CFR: {18} | Updated: {19}".format( self.countries[search]["Country,Other"], self.countries[search]["rank"], self.countries[search]["TotalCases"], self.countries[search]["NewCases"], self.countries[search]["ratio_new_cases"], self.countries[search]["TotalDeaths"], self.countries[search]["ratio_dead"], self.countries[search]["NewDeaths"], self.countries[search]["ratio_new_dead"], self.countries[search]["TotalRecovered"], self.countries[search]["ratio_recovered"], self.countries[search]["ActiveCases"], self.countries[search]["mild"], self.countries[search]["Serious,Critical"], self.countries[search]["ratio_mild"], self.countries[search]["ratio_serious"], self.countries[search]["Tot\xa0Cases/1M pop"], self.countries[search]["Deaths/1M pop"], self.countries[search]["CFR"], self.time_created(self.updated), ) ) def reply_state(): irc.reply( "\x02\x1F{0}\x1F: USA Rank: #{1} | Cases: \x0307{2}\x03 " "(\x0307+{3}\x03) (\x0307+{4}\x03) | Deaths: \x0304{5}\x03 " "(\x0304{6}\x03) (\x0304+{7}\x03) (\x0304+{8}\x03) | Active: " "\x0307{9}\x03 | Cases/1M: \x0307{10}\x03 | Deaths/1M: " "\x0304{11}\x03 | Updated: {12}".format( self.states[search]["USAState"], self.states[search]["rank"], self.states[search]["TotalCases"], self.states[search]["NewCases"], self.states[search]["ratio_new_cases"], self.states[search]["TotalDeaths"], self.states[search]["ratio_dead"], self.states[search]["NewDeaths"], self.states[search]["ratio_new_dead"], self.states[search]["ActiveCases"], self.states[search]["Tot\xa0Cases/1M pop"], self.states[search]["Deaths/1M pop"], self.time_created(self.updated), ) ) def reply_global(): irc.reply( "\x02\x1F{0}\x1F: Cases: \x0307{1}\x03 (\x0307+{2}\x03) " "(\x0307+{3}\x03) | Deaths: \x0304{4}\x03 (\x0304{5}\x03) " "(\x0304+{6}\x03) (\x0304+{7}\x03) | Recovered: \x0309{8}\x03 " "(\x0309{9}\x03) | Active: \x0307{10}\x03 (\x0310{11}\x03 Mild) " "(\x0313{12}\x03 Serious) (\x0310{13}\x03/\x0313{14}\x03) | " "Cases/1M: \x0307{15}\x03 | Deaths/1M: \x0304{16}\x03 | " "CFR: {17} | Updated: {18}".format( "Global", self.countries[list(self.countries)[0]]["TotalCases"], self.countries[list(self.countries)[0]]["NewCases"], self.countries[list(self.countries)[0]]["ratio_new_cases"], self.countries[list(self.countries)[0]]["TotalDeaths"], self.countries[list(self.countries)[0]]["ratio_dead"], self.countries[list(self.countries)[0]]["NewDeaths"], self.countries[list(self.countries)[0]]["ratio_new_dead"], self.countries[list(self.countries)[0]]["TotalRecovered"], self.countries[list(self.countries)[0]]["ratio_recovered"], self.countries[list(self.countries)[0]]["ActiveCases"], self.countries[list(self.countries)[0]]["mild"], self.countries[list(self.countries)[0]]["Serious,Critical"], self.countries[list(self.countries)[0]]["ratio_mild"], self.countries[list(self.countries)[0]]["ratio_serious"], self.countries[list(self.countries)[0]]["Tot\xa0Cases/1M pop"], self.countries[list(self.countries)[0]]["Deaths/1M pop"], self.countries[list(self.countries)[0]]["CFR"], self.time_created(self.updated), ) ) if self.registryValue("countryFirst", msg.channel): if search and self.countries.get(search): reply_country() elif search and self.states.get(search): reply_state() else: reply_global() else: if search and self.states.get(search): reply_state() elif search and self.countries.get(search): reply_country() else: reply_global() @wrap([optional("text")]) def top10(self, irc, msg, args, search): """[usa|global] Return the countries with the most confirmed cases. Valid options are USA or global. Returns global list if no option given. """ if not self.get_data(): irc.reply( "Error retrieving data from https://www.worldometers.info/coronavirus/" ) return if search: search = search.strip().lower() else: search = "global" if not search.startswith("us"): irc.reply( "{0} | Updated: {1}".format( ", ".join(self.top["countries"]), self.time_created(self.updated) ) ) else: irc.reply( "{0} | Updated: {1}".format( ", ".join(self.top["states"]), self.time_created(self.updated) ) ) Class = Corona