diff --git a/plugins/Weather/BeautifulSoup.py b/plugins/Weather/BeautifulSoup.py new file mode 100644 index 000000000..199f7d39f --- /dev/null +++ b/plugins/Weather/BeautifulSoup.py @@ -0,0 +1,450 @@ +"""Beautiful Soup +Elixir and Tonic +"The Screen-Scraper's Friend" + +The BeautifulSoup class turns arbitrarily bad HTML into a tree-like +nested tag-soup list of Tag objects and text snippets. A Tag object +corresponds to an HTML tag. It knows about the HTML tag's attributes, +and contains a representation of everything contained between the +original tag and its closing tag (if any). It's easy to extract Tags +that meet certain criteria. + +A well-formed HTML document will yield a well-formed data +structure. An ill-formed HTML document will yield a correspondingly +ill-formed data structure. If your document is only locally +well-formed, you can use this to process the well-formed part of it. + +#Example: +#-------- +from BeautifulSoup import BeautifulSoup +text = ''' +The Title + +Link text (italicized) +Link text 2 + +''' +soup = BeautifulSoup() +soup.feed(text) +print soup("a") #Returns a list of 2 Tag objects, one for each link in + #the source +print soup.first("a", {'class':'foo'})['href'] #Returns http://www.crummy.com/ +print soup.first("title").contents[0] #Returns "The title" +print soup.first("a", {'href':'http://www.crummy.com/'}).first("i").contents[0] +#Returns "text (italicized)" + +#Example of SQL-style attribute wildcards -- all four 'find' calls will +#find the link. +#---------------------------------------------------------------------- +soup = BeautifulSoup() +soup.feed('''bla''') +print soup.fetch('a', {'href': 'http://foo.com/'}) +print soup.fetch('a', {'href': 'http://%'}) +print soup.fetch('a', {'href': '%.com/'}) +print soup.fetch('a', {'href': '%o.c%'}) + +#Example with horrible HTML: +#--------------------------- +soup = BeautifulSoup() +soup.feed(''' +Go here +or go Home +''') +print soup.fetch('a') #Returns a list of 2 Tag objects. +print soup.first(attrs={'href': 'here.html'})['class'] #Returns "that" +print soup.first(attrs={'class': 'that'}).first('i').contents[0] #returns "here" + +This library has no external dependencies. It works with Python 1.5.2 +and up. If you can install a Python extension, you might want to use +the ElementTree Tidy HTML Tree Builder instead: + http://www.effbot.org/zone/element-tidylib.htm + +You can use BeautifulSoup on any SGML-like substance, such as XML or a +domain-specific language that looks like HTML but has different tag +names. For such purposes you may want to use the BeautifulStoneSoup +class, which knows nothing at all about HTML per se. I also reserve +the right to make the BeautifulSoup parser smarter between releases, +so if you want forwards-compatibility without having to think about +it, you might want to go with BeautifulStoneSoup. + +Release status: + +(I do a new release whenever I make a change that breaks backwards +compatibility.) + +Current release: + + Applied patch from Richie Hindle (richie at entrian dot com) that + makes tag.string a shorthand for tag.contents[0].string when the tag + has only one string-owning child. + +1.2 "Who for such dainties would not stoop?" (2004/07/08): Applied + patch from Ben Last (ben at benlast dot com) that made + Tag.renderContents() correctly handle Unicode. + + Made BeautifulStoneSoup even dumber by making it not implicitly + close a tag when another tag of the same type is encountered; only + when an actual closing tag is encountered. This change courtesy of + Fuzzy (mike at pcblokes dot com). BeautifulSoup still works as + before. + +1.1 "Swimming in a hot tureen": Added more 'nestable' tags. Changed + popping semantics so that when a nestable tag is encountered, tags are + popped up to the previously encountered nestable tag (of whatever kind). + I will revert this if enough people complain, but it should make + more people's lives easier than harder. + + This enhancement was suggested by Anthony Baxter (anthony at + interlink dot com dot au). + +1.0 "So rich and green": Initial release. + +Retreived from: http://www.crummy.com/software/BeautifulSoup/ +""" + +__author__ = "Leonard Richardson (leonardr@segfault.org)" +__version__ = "1.1 $Revision: 1.2 $" +__date__ = "$Date: 2004/08/27 20:06:12 $" +__copyright__ = "Copyright (c) 2004 Leonard Richardson" +__license__ = "Python" + +from sgmllib import SGMLParser +import string +import types + +class PageElement: + """Contains the navigational information for some part of the page + (either a tag or a piece of text)""" + + def __init__(self, parent=None, previous=None): + self.parent = parent + self.previous = previous + self.next = None + +class NavigableText(PageElement): + + """A simple wrapper around a string that keeps track of where in + the document the string was found. Doesn't implement all the + string methods because I'm lazy. You could have this extend + UserString if you were using 2.2.""" + + def __init__(self, string, parent=None, previous=None): + PageElement.__init__(self, parent, previous) + self.string = string + + def __eq__(self, other): + return self.string == str(other) + + def __str__(self): + return self.string + + def strip(self): + return self.string.strip() + +class Tag(PageElement): + + """Represents a found HTML tag with its attributes and contents.""" + + def __init__(self, name, attrs={}, parent=None, previous=None): + PageElement.__init__(self, parent, previous) + self.name = name + self.attrs = attrs + self.contents = [] + self.foundClose = 0 + + def get(self, key, default=None): + return self._getAttrMap().get(key, default) + + def __call__(self, *args): + return apply(self.fetch, args) + + def __getitem__(self, key): + return self._getAttrMap()[key] + + def __setitem__(self, key, value): + self._getAttrMap() + self.attrMap[key] = value + for i in range(0, len(self.attrs)): + if self.attrs[i][0] == key: + self.attrs[i] = (key, value) + + def _getAttrMap(self): + if not hasattr(self, 'attrMap'): + self.attrMap = {} + for (key, value) in self.attrs: + self.attrMap[key] = value + return self.attrMap + + def __repr__(self): + return str(self) + + def __ne__(self, other): + return not self == other + + def __eq__(self, other): + if not isinstance(other, Tag) or self.name != other.name or self.attrs != other.attrs or len(self.contents) != len(other.contents): + return 0 + for i in range(0, len(self.contents)): + if self.contents[i] != other.contents[i]: + return 0 + return 1 + + def __str__(self): + attrs = '' + if self.attrs: + for key, val in self.attrs: + attrs = attrs + ' %s="%s"' % (key, val) + close = '' + closeTag = '' + if self.isSelfClosing(): + close = ' /' + elif self.foundClose: + closeTag = '' % self.name + s = self.renderContents() + if not hasattr(self, 'hideTag'): + s = '<%s%s%s>' % (self.name, attrs, close) + s + closeTag + return s + + def renderContents(self): + s='' #non-Unicode + for c in self.contents: + try: + s = s + str(c) + except UnicodeEncodeError: + if type(s) <> types.UnicodeType: + s = s.decode('utf8') #convert ascii to Unicode + #str() should, strictly speaking, not return a Unicode + #string, but NavigableText never checks and will return + #Unicode data if it was initialised with it. + s = s + str(c) + return s + + def isSelfClosing(self): + return self.name in BeautifulSoup.SELF_CLOSING_TAGS + + def append(self, tag): + self.contents.append(tag) + + def first(self, name=None, attrs={}, contents=None, recursive=1): + r = None + l = self.fetch(name, attrs, contents, recursive) + if l: + r = l[0] + return r + + def fetch(self, name=None, attrs={}, contents=None, recursive=1): + """Extracts Tag objects that match the given criteria. You + can specify the name of the Tag, any attributes you want the + Tag to have, and what text and Tags you want to see inside the + Tag.""" + if contents and type(contents) != type([]): + contents = [contents] + results = [] + for i in self.contents: + if isinstance(i, Tag): + if not name or i.name == name: + match = 1 + for attr, value in attrs.items(): + check = i.get(attr) + #By default, find the specific value called for. + #Use SQL-style wildcards to find substrings, prefix, + #suffix, etc. + result = (check == value) + if check and value: + if len(value) > 1 and value[0] == '%' and value[-1] == '%' and value[-2] != '\\': + result = (check.find(value[1:-1]) != -1) + elif value[0] == '%': + print "blah" + result = check.rfind(value[1:]) == len(check)-len(value)+1 + elif value[-1] == '%': + result = check.find(value[:-1]) == 0 + if not result: + match = 0 + break + match = match and (not contents or i.contents == contents) + if match: + results.append(i) + if recursive: + results.extend(i.fetch(name, attrs, contents, recursive)) + return results + +class BeautifulSoup(SGMLParser, Tag): + + """The actual parser. It knows the following facts about HTML, and + not much else: + + * Some tags have no closing tag and should be interpreted as being + closed as soon as they are encountered. + + * Most tags can't be nested; encountering an open tag when there's + already an open tag of that type in the stack means that the + previous tag of that type should be implicitly closed. However, + some tags can be nested. When a nestable tag is encountered, + it's okay to close all unclosed tags up to the last nestable + tag. It might not be safe to close any more, so that's all it + closes. + + * The text inside some tags (ie. 'script') may contain tags which + are not really part of the document and which should be parsed + as text, not tags. If you want to parse the text as tags, you can + always get it and parse it explicitly.""" + + SELF_CLOSING_TAGS = ['br', 'hr', 'input', 'img', 'meta', 'spacer', + 'link', 'frame'] + NESTABLE_TAGS = ['font', 'table', 'tr', 'td', 'th', 'tbody', 'p', + 'div'] + QUOTE_TAGS = ['script'] + + IMPLICITLY_CLOSE_TAGS = 1 + + def __init__(self, text=None): + Tag.__init__(self, '[document]') + SGMLParser.__init__(self) + self.quoteStack = [] + self.hideTag = 1 + self.reset() + if text: + self.feed(text) + + def feed(self, text): + SGMLParser.feed(self, text) + self.endData() + + def reset(self): + SGMLParser.reset(self) + self.currentData = '' + self.currentTag = None + self.tagStack = [] + self.pushTag(self) + + def popTag(self, closedTagName=None): + tag = self.tagStack.pop() + if closedTagName == tag.name: + tag.foundClose = 1 + + # Tags with just one string-owning child get the same string + # property as the child, so that soup.tag.string is shorthand + # for soup.tag.contents[0].string + if len(self.currentTag.contents) == 1 and \ + hasattr(self.currentTag.contents[0], 'string'): + self.currentTag.string = self.currentTag.contents[0].string + + #print "Pop", tag.name + self.currentTag = self.tagStack[-1] + return self.currentTag + + def pushTag(self, tag): + #print "Push", tag.name + if self.currentTag: + self.currentTag.append(tag) + self.tagStack.append(tag) + self.currentTag = self.tagStack[-1] + + def endData(self): + if self.currentData: + if not string.strip(self.currentData): + if '\n' in self.currentData: + self.currentData = '\n' + else: + self.currentData = ' ' + o = NavigableText(self.currentData, self.currentTag, self.previous) + if self.previous: + self.previous.next = o + self.previous = o + self.currentTag.contents.append(o) + self.currentData = '' + + def _popToTag(self, name, closedTag=0): + """Pops the tag stack up to and including the most recent + instance of the given tag. If a list of tags is given, will + accept any of those tags as an excuse to stop popping, and will + *not* pop the tag that caused it to stop popping.""" + if self.IMPLICITLY_CLOSE_TAGS: + closedTag = 1 + numPops = 0 + mostRecentTag = None + oneTag = (type(name) == types.StringType) + for i in range(len(self.tagStack)-1, 0, -1): + thisTag = self.tagStack[i].name + if (oneTag and thisTag == name) \ + or (not oneTag and thisTag in name): + numPops = len(self.tagStack)-i + break + if not oneTag: + numPops = numPops - 1 + + closedTagName = None + if closedTag: + closedTagName = name + + for i in range(0, numPops): + mostRecentTag = self.popTag(closedTagName) + return mostRecentTag + + def unknown_starttag(self, name, attrs): + if self.quoteStack: + #This is not a real tag. + #print "<%s> is not real!" % name + attrs = map(lambda(x, y): '%s="%s"' % (x, y), attrs) + self.handle_data('<%s %s>' % (name, attrs)) + return + self.endData() + tag = Tag(name, attrs, self.currentTag, self.previous) + if self.previous: + self.previous.next = tag + self.previous = tag + if not name in self.SELF_CLOSING_TAGS: + if name in self.NESTABLE_TAGS: + self._popToTag(self.NESTABLE_TAGS) + else: + self._popToTag(name) + self.pushTag(tag) + if name in self.SELF_CLOSING_TAGS: + self.popTag() + if name in self.QUOTE_TAGS: + #print "Beginning quote (%s)" % name + self.quoteStack.append(name) + + def unknown_endtag(self, name): + if self.quoteStack and self.quoteStack[-1] != name: + #This is not a real end tag. + #print " is not real!" % name + self.handle_data('' % name) + return + self.endData() + self._popToTag(name, 1) + if self.quoteStack and self.quoteStack[-1] == name: + #print "That's the end of %s!" % self.quoteStack[-1] + self.quoteStack.pop() + + def handle_data(self, data): + self.currentData = self.currentData + data + + def handle_comment(self, text): + "Propagate comments right through." + self.handle_data("" % text) + + def handle_charref(self, ref): + "Propagate char refs right through." + self.handle_data('&#%s;' % ref) + + def handle_entityref(self, ref): + "Propagate entity refs right through." + self.handle_data('&%s;' % ref) + + def handle_decl(self, data): + "Propagate DOCTYPEs right through." + self.handle_data('' % data) + +class BeautifulStoneSoup(BeautifulSoup): + + """A version of BeautifulSoup that doesn't know anything at all + about what HTML tags have special behavior. Useful for parsing + things that aren't HTML, or when BeautifulSoup makes an assumption + counter to what you were expecting.""" + + IMPLICITLY_CLOSE_TAGS = 0 + + SELF_CLOSING_TAGS = [] + NESTABLE_TAGS = [] + QUOTE_TAGS = [] diff --git a/plugins/Weather/README.txt b/plugins/Weather/README.txt new file mode 100644 index 000000000..d60b47a97 --- /dev/null +++ b/plugins/Weather/README.txt @@ -0,0 +1 @@ +Insert a description of your plugin here, with any notes, etc. about using it. diff --git a/plugins/Weather/__init__.py b/plugins/Weather/__init__.py new file mode 100644 index 000000000..d63113073 --- /dev/null +++ b/plugins/Weather/__init__.py @@ -0,0 +1,69 @@ +### +# Copyright (c) 2005, James Vega +# 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. +### + +""" +This plugin does weather-related stuff. It can't change the weather, though, +so don't get your hopes up. We just report it. +""" + +import supybot +import supybot.world as world + +# Use this for the version of this plugin. You may wish to put a CVS keyword +# in here if you're keeping the plugin in CVS or some similar system. +__version__ = "%%VERSION%%" + +__author__ = supybot.authors.unknown + +# This is a dictionary mapping supybot.Author instances to lists of +# contributions. +__contributors__ = { + supybot.authors.jamessan: ['cnn', 'wunder', + 'temperatureUnit configuration variable', + 'convert configuration variable'], + supybot.authors.jemfinch: ['weather'], + supybot.authors.bwp: ['ham'], + } + +import config +import plugin +reload(plugin) # In case we're being reloaded. +# Add more reloads here if you add third-party modules and want them to be +# reloaded when this plugin is reloaded. Don't forget to import them as well! +import BeautifulSoup +reload(BeautifulSoup) + +if world.testing: + import test + +Class = plugin.Class +configure = config.configure + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Weather/config.py b/plugins/Weather/config.py new file mode 100644 index 000000000..fcee2c9bb --- /dev/null +++ b/plugins/Weather/config.py @@ -0,0 +1,82 @@ +### +# Copyright (c) 2005, James Vega +# 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 plugin + +import supybot.conf as conf +import supybot.utils as utils +import supybot.registry as registry + +def configure(advanced): + # This will be called by supybot to configure this module. advanced is + # a bool that specifies whether the user identified himself as an advanced + # user or not. You should effect your configuration by manipulating the + # registry as appropriate. + from supybot.questions import expect, anything, something, yn + conf.registerPlugin('Weather', True) + +class WeatherUnit(registry.String): + def setValue(self, s): + #print '***', repr(s) + s = s.capitalize() + if s not in plugin.unitAbbrevs: + raise registry.InvalidRegistryValue,\ + 'Unit must be one of Fahrenheit, Celsius, or Kelvin.' + s = plugin.unitAbbrevs[s] + registry.String.setValue(self, s) + +class WeatherCommand(registry.String): + def setValue(self, s): + m = plugin.Weather.weatherCommands + if s not in m: + raise registry.InvalidRegistryValue,\ + format('Command must be one of %L', m) + else: + method = getattr(plugin.Weather, s) + plugin.Weather.weather.im_func.__doc__ = method.__doc__ + registry.String.setValue(self, s) + +Weather = conf.registerPlugin('Weather') +conf.registerChannelValue(Weather, 'temperatureUnit', + WeatherUnit('Fahrenheit', """Sets the default temperature unit to use when + reporting the weather.""")) +conf.registerChannelValue(Weather, 'command', + WeatherCommand('wunder', """Sets the default command to use when retrieving + the weather. Command must be one of %s.""" % + utils.str.commaAndify(plugin.Weather.weatherCommands, And='or'))) +conf.registerChannelValue(Weather, 'convert', + registry.Boolean(True, """Determines whether the weather commands will + automatically convert weather units to the unit specified in + supybot.plugins.Weather.temperatureUnit.""")) + +conf.registerUserValue(conf.users.plugins.Weather, 'lastLocation', + registry.String('', '')) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78 diff --git a/plugins/Weather/plugin.py b/plugins/Weather/plugin.py new file mode 100644 index 000000000..50769aef4 --- /dev/null +++ b/plugins/Weather/plugin.py @@ -0,0 +1,443 @@ +### +# Copyright (c) 2005, James Vega +# 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 re + +import BeautifulSoup + +import supybot.utils as utils +from supybot.commands import * +import supybot.ircutils as ircutils +import supybot.callbacks as callbacks + + +unitAbbrevs = utils.abbrev(['Fahrenheit', 'Celsius', 'Centigrade', 'Kelvin']) +unitAbbrevs['C'] = 'Celsius' +unitAbbrevs['Ce'] = 'Celsius' + +noLocationError = 'No such location could be found.' +class NoLocation(callbacks.Error): + pass + +class Weather(callbacks.Privmsg): + weatherCommands = ['wunder', 'cnn', 'ham'] + threaded = True + def __init__(self, irc): + self.__parent = super(Weather, self) + self.__parent.__init__(irc) + + def callCommand(self, name, irc, msg, *L, **kwargs): + try: + self.__parent.callCommand(name, irc, msg, *L, **kwargs) + except utils.web.Error, e: + irc.error(str(e)) + + def _noLocation(self): + raise NoLocation, noLocationError + + def weather(self, irc, msg, args, location): + # This specifically does not have a docstring. + channel = None + if irc.isChannel(msg.args[0]): + channel = msg.args[0] + if not location: + location = self.userValue('lastLocation', msg.prefix) + if not location: + raise callbacks.ArgumentError + self.setUserValue('lastLocation', msg.prefix, + location, ignoreNoUser=True) + args = [location] + realCommandName = self.registryValue('command', channel) + realCommand = getattr(self, realCommandName) + try: + realCommand(irc, msg, args[:]) + except NoLocation: + self.log.info('%s lookup failed, Trying others.', realCommandName) + for command in self.weatherCommands: + if command != realCommandName: + self.log.info('Trying %s.', command) + try: + getattr(self, command)(irc, msg, args[:]) + self.log.info('%s lookup succeeded.', command) + return + except NoLocation: + self.log.info('%s lookup failed as backup.', command) + irc.error(format('Could not retrieve weather for %q.', location)) + weather = wrap(weather, [additional('text')]) + + def _toCelsius(self, temp, unit): + if unit == 'K': + return temp - 273.15 + elif unit == 'F': + return (temp - 32) * 5 /9 + else: + return temp + + _temp = re.compile(r'(-?\d+)(.*?)(F|C)') + def _getTemp(self, temp, deg, unit, chan): + assert unit == unit.upper() + assert temp == int(temp) + default = self.registryValue('temperatureUnit', chan) + if unitAbbrevs[unit] == default: + # Short circuit if we're the same unit as the default. + return format('%i%s%s', temp, deg, unit) + temp = self._toCelsius(temp, unit) + unit = 'C' + if default == 'Kelvin': + temp = temp + 273.15 + unit = 'K' + deg = ' ' + elif default == 'Fahrenheit': + temp = temp * 9 / 5 + 32 + unit = 'F' + return '%i%s%s' % (temp, deg, unit) + + _hamLoc = re.compile( + r'' + r'(.*?), (.*?),(.*?)', re.I) + _interregex = re.compile( + r'' + r'([^,]+), ([^<]+)', re.I) + _hamCond = re.compile( + r'' + r'([^<]+)', re.I) + _hamTemp = re.compile( + r'' + r'(-?\d+)(.*?)(F|C)', re.I) + _hamChill = re.compile( + r'Wind Chill:\s+' + r'([^N][^<]+)', + re.I | re.S) + _hamHeat = re.compile( + r'Heat Index:\s+' + r'([^N][^<]+)', + re.I | re.S) + # States + _realStates = set(['ak', 'al', 'ar', 'az', 'ca', 'co', 'ct', + 'dc', 'de', 'fl', 'ga', 'hi', 'ia', 'id', + 'il', 'in', 'ks', 'ky', 'la', 'ma', 'md', + 'me', 'mi', 'mn', 'mo', 'ms', 'mt', 'nc', + 'nd', 'ne', 'nh', 'nj', 'nm', 'nv', 'ny', + 'oh', 'ok', 'or', 'pa', 'ri', 'sc', 'sd', + 'tn', 'tx', 'ut', 'va', 'vt', 'wa', 'wi', + 'wv', 'wy']) + # Provinces. (Province being a metric state measurement mind you. :D) + _fakeStates = set(['ab', 'bc', 'mb', 'nb', 'nf', 'ns', 'nt', + 'nu', 'on', 'pe', 'qc', 'sk', 'yk']) + # Certain countries are expected to use a standard abbreviation + # The weather we pull uses weird codes. Map obvious ones here. + _hamCountryMap = {'uk': 'gb'} + def ham(self, irc, msg, args, loc): + """ + + Returns the approximate weather conditions for a given city. + """ + + #If we received more than one argument, then we have received + #a city and state argument that we need to process. + if ' ' in loc: + #If we received more than 1 argument, then we got a city with a + #multi-word name. ie ['Garden', 'City', 'KS'] instead of + #['Liberal', 'KS']. We join it together with a + to pass + #to our query + loc = utils.str.rsplit(loc, None, 1) + state = loc.pop().lower() + city = '+'.join(loc) + city = city.rstrip(',').lower() + #We must break the States up into two sections. The US and + #Canada are the only countries that require a State argument. + + if state in self._realStates: + country = 'us' + elif state in self._fakeStates: + country = 'ca' + else: + country = state + state = '' + if country in self._hamCountryMap.keys(): + country = self._hamCountryMap[country] + url = 'http://www.hamweather.net/cgi-bin/hw3/hw3.cgi?' \ + 'pass=&dpp=&forecast=zandh&config=&' \ + 'place=%s&state=%s&country=%s' % (city, state, country) + html = utils.web.getUrl(url) + if 'was not found' in html: + url = 'http://www.hamweather.net/cgi-bin/hw3/hw3.cgi?' \ + 'pass=&dpp=&forecast=zandh&config=&' \ + 'place=%s&state=&country=%s' % (city, state) + html = utils.web.getUrl(url) + if 'was not found' in html: # Still. + self._noLocation() + + #We received a single argument. Zipcode or station id. + else: + zip = loc.replace(',', '') + zip = zip.lower() + url = 'http://www.hamweather.net/cgi-bin/hw3/hw3.cgi?' \ + 'config=&forecast=zandh&pands=%s&Submit=GO' % zip + html = utils.web.getUrl(url) + if 'was not found' in html: + self._noLocation() + + headData = self._hamLoc.search(html) + if headData is not None: + (city, state, country) = headData.groups() + else: + headData = self._interregex.search(html) + if headData: + (city, state) = headData.groups() + else: + self._noLocation() + + city = city.strip() + state = state.strip() + temp = self._hamTemp.search(html) + convert = self.registryValue('convert', msg.args[0]) + if temp is not None: + (temp, deg, unit) = temp.groups() + if convert: + temp = self._getTemp(int(temp), deg, unit, msg.args[0]) + else: + temp = deg.join((temp, unit)) + conds = self._hamCond.search(html) + if conds is not None: + conds = conds.group(1) + index = '' + chill = self._hamChill.search(html) + if chill is not None: + chill = chill.group(1) + chill = utils.web.htmlToText(chill) + if convert: + tempsplit = self._temp.search(chill) + if tempsplit: + (chill, deg, unit) = tempsplit.groups() + chill = self._getTemp(int(chill), deg, unit,msg.args[0]) + if float(chill[:-2]) < float(temp[:-2]): + index = format(' (Wind Chill: %s)', chill) + heat = self._hamHeat.search(html) + if heat is not None: + heat = heat.group(1) + heat = utils.web.htmlToText(heat) + if convert: + tempsplit = self._temp.search(heat) + if tempsplit: + (heat, deg, unit) = tempsplit.groups() + if convert: + heat = self._getTemp(int(heat), deg, unit,msg.args[0]) + if float(heat[:-2]) > float(temp[:-2]): + index = format(' (Heat Index: %s)', heat) + if temp and conds and city and state: + conds = conds.replace('Tsra', 'Thunderstorms') + conds = conds.replace('Ts', 'Thunderstorms') + s = format('The current temperature in %s, %s is %s%s. ' + 'Conditions: %s.', + city, state, temp, index, conds) + irc.reply(s) + else: + irc.errorPossibleBug('The format of the page was odd.') + ham = wrap(ham, ['text']) + + _cnnUrl = 'http://weather.cnn.com/weather/search?wsearch=' + _cnnFTemp = re.compile(r'(-?\d+)(°)(F)', re.I | re.S) + _cnnCond = re.compile(r'align="center">([^<]+)', + re.I | re.S) + _cnnHumid = re.compile(r'Rel. Humidity: (\d+%)', re.I | re.S) + _cnnWind = re.compile(r'Wind: ([^<]+)', re.I | re.S) + _cnnLoc = re.compile(r'([^<]+)', re.I | re.S) + _cnnMultiLoc = re.compile(r'href="([^f]+forecast.jsp[^"]+)', re.I) + # Certain countries are expected to use a standard abbreviation + # The weather we pull uses weird codes. Map obvious ones here. + _cnnCountryMap = {'uk': 'en', 'de': 'ge'} + def cnn(self, irc, msg, args, loc): + """ + + Returns the approximate weather conditions for a given city. + """ + if ' ' in loc: + #If we received more than 1 argument, then we got a city with a + #multi-word name. ie ['Garden', 'City', 'KS'] instead of + #['Liberal', 'KS']. + loc = utils.str.rsplit(loc, None, 1) + state = loc.pop().lower() + city = ' '.join(loc) + city = city.rstrip(',').lower() + if state in self._cnnCountryMap: + state = self._cnnCountryMap[state] + loc = ' '.join([city, state]) + else: + #We received a single argument. Zipcode or station id. + loc = loc.replace(',', '') + url = '%s%s' % (self._cnnUrl, utils.web.urlquote(loc)) + text = utils.web.getUrl(url) # Errors caught in callCommand. + if 'No search results' in text or \ + 'does not match a zip code' in text: + self._noLocation() + elif 'several matching locations for' in text: + m = self._cnnMultiLoc.search(text) + if m: + text = utils.web.getUrl(m.group(1)) + else: + self._noLocation() + location = self._cnnLoc.search(text) + temp = self._cnnFTemp.search(text) + conds = self._cnnCond.search(text) + humidity = self._cnnHumid.search(text) + wind = self._cnnWind.search(text) + convert = self.registryValue('convert', msg.args[0]) + if location and temp: + location = location.group(1) + location = location.split('-')[-1].strip() + (temp, deg, unit) = temp.groups() + if convert: + temp = self._getTemp(int(temp), deg, unit, msg.args[0]) + else: + temp = deg.join((temp, unit)) + resp = [format('The current temperature in %s is %s.', + location, temp)] + if conds is not None: + resp.append(format('Conditions: %s.', conds.group(1))) + if humidity is not None: + resp.append(format('Humidity: %s.', humidity.group(1))) + if wind is not None: + resp.append(format('Wind: %s.', wind.group(1))) + resp = map(utils.web.htmlToText, resp) + irc.reply(' '.join(resp)) + else: + irc.errorPossibleBug('Could not find weather information.') + cnn = wrap(cnn, ['text']) + + _wunderUrl = 'http://mobile.wunderground.com/cgi-bin/' \ + 'findweather/getForecast?query=' + _wunderSevere = re.compile(r'font color="?#ff0000"?>([^<]+)<', re.I) + _wunderLoc = re.compile(r' (.+?) Forecast', re.I | re.S) + _wunderMultiLoc = re.compile(r' + + Returns the approximate weather conditions for a given city. + """ + url = '%s%s' % (self._wunderUrl, utils.web.urlquote(loc)) + text = utils.web.getUrl(url) + severe = '' + m = self._wunderSevere.search(text) + self.log.critical('%s', m) + if m: + severe = ircutils.bold(format(' %s', m.group(1))) + if 'Search not found' in text or \ + re.search(text, r'size="2"> Place ', re.I): + self._noLocation() + soup = BeautifulSoup.BeautifulSoup() + soup.feed(text) + # Get the table with all the weather info + table = soup.first('table', {'border':'1'}) + if table is None: + self._noLocation() + trs = table.fetch('tr') + try: + time = trs.pop(0).first('b').string + except AttributeError: + time = '' + info = {} + def isText(t): + return not isinstance(t,BeautifulSoup.NavigableText) and t.contents + def getText(t): + s = getattr(t, 'string', None) + if s is None: + t = t.contents + num = t[0].string + units = t[1].string + # htmlToText strips leading whitespace, so we have to handle + # strings with   differently. + if units.startswith(' '): + units = utils.web.htmlToText(units) + s = ' '.join((num, units)) + else: + units = utils.web.htmlToText(units) + s = ' '.join((num, units[0], units[1:])) + return s + for tr in trs: + k = tr.first('td').string + v = filter(isText, tr.fetch('td')[1].contents) + value = map(getText, v) + info[k] = ' '.join(value) + location = self._wunderLoc.search(text) + temp = info['Temperature'] + convert = self.registryValue('convert', msg.args[0]) + if location and temp: + (temp, deg, unit) = temp.split() + if convert: + temp = self._getTemp(int(temp), deg, unit, msg.args[0]) + else: + temp = deg.join((temp, unit)) + resp = ['The current temperature in %s is %s (%s).' %\ + (location.group(1), temp, time)] + conds = info['Conditions'] + resp.append('Conditions: %s.' % info['Conditions']) + humidity = info['Humidity'] + resp.append('Humidity: %s.' % info['Humidity']) + # Apparently, the "Dew Point" and "Wind" categories are occasionally + # set to "-" instead of an actual reading. So, we'll just catch + # the ValueError from trying to unpack a tuple of the wrong size. + try: + (dew, deg, unit) = info['Dew Point'].split() + if convert: + dew = self._getTemp(int(dew), deg, unit, msg.args[0]) + else: + dew = deg.join((dew, unit)) + resp.append('Dew Point: %s.' % dew) + except ValueError: + pass + try: + resp.append('Wind: %s at %s %s.' % tuple(info['Wind'].split())) + except (ValueError, TypeError): + pass + try: + (chill, deg, unit) = info['Dew Point'].split() + if convert: + chill = self._getTemp(int(chill), deg, unit, msg.args[0]) + else: + dew = deg.join((chill, unit)) + resp.append('Windchill: %s.' % chill) + except (ValueError, TypeError): + pass + if info['Pressure']: + resp.append('Pressure: %s.' % info['Pressure']) + if info['Visibility']: + resp.append('Visibility: %s.' % info['Visibility']) + resp.append(severe) + resp = map(utils.web.htmlToText, resp) + irc.reply(' '.join(resp)) + else: + irc.error('Could not find weather information.') + wunder = wrap(wunder, ['text']) + + +Class = Weather + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: diff --git a/plugins/Weather/test.py b/plugins/Weather/test.py new file mode 100644 index 000000000..33dbd8f85 --- /dev/null +++ b/plugins/Weather/test.py @@ -0,0 +1,110 @@ +### +# Copyright (c) 2005, James Vega +# 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. +### + +from supybot.test import * + +class WeatherTestCase(PluginTestCase): + plugins = ('Weather',) + if network: + def testHam(self): + self.assertNotError('ham Columbus, OH') + self.assertNotError('ham 43221') + self.assertNotRegexp('ham Paris, FR', 'Virginia') + self.assertError('ham alsdkfjasdl, asdlfkjsadlfkj') + self.assertNotError('ham London, uk') + self.assertNotError('ham London, UK') + self.assertNotError('ham Munich, de') + self.assertNotError('ham Tucson, AZ') + self.assertError('ham hell') + + def testCnn(self): + self.assertNotError('cnn Columbus, OH') + self.assertNotError('cnn 43221') + self.assertNotRegexp('cnn Paris, FR', 'Virginia') + self.assertError('cnn alsdkfjasdl, asdlfkjsadlfkj') + self.assertNotError('cnn London, uk') + self.assertNotError('cnn London, UK') + self.assertNotError('cnn Munich, de') + self.assertNotError('cnn Tucson, AZ') + + def testWunder(self): + self.assertNotError('wunder Columbus, OH') + self.assertNotError('wunder 43221') + self.assertNotRegexp('wunder Paris, FR', 'Virginia') + self.assertError('wunder alsdkfjasdl, asdlfkjsadlfkj') + self.assertNotError('wunder London, uk') + self.assertNotError('wunder London, UK') + self.assertNotError('wunder Munich, de') + self.assertNotError('wunder Tucson, AZ') + + def testTemperatureUnit(self): + try: + orig = conf.supybot.plugins.Weather.temperatureUnit() + conf.supybot.plugins.Weather.temperatureUnit.setValue('F') + self.assertRegexp('cnn Columbus, OH', r'is -?\d+.F') + self.assertRegexp('ham Columbus, OH', r'is -?\d+.F') + conf.supybot.plugins.Weather.temperatureUnit.setValue('C') + self.assertRegexp('cnn Columbus, OH', r'is -?\d+.C') + self.assertRegexp('ham Columbus, OH', r'is -?\d+.C') + conf.supybot.plugins.Weather.temperatureUnit.setValue('K') + self.assertRegexp('cnn Columbus, OH', r'is -?\d+\.15\sK') + self.assertRegexp('ham Columbus, OH', r'is -?\d+\.15\sK') + finally: + conf.supybot.plugins.Weather.temperatureUnit.setValue(orig) + + def testNoEscapingWebError(self): + self.assertNotRegexp('ham "buenos aires"', 'WebError') + + def testWeatherRepliesWithBogusLocation(self): + self.assertRegexp('weather some place that doesn\'t exist', r'.') + + def testConvertConfig(self): + try: + convert = conf.supybot.plugins.Weather.convert() + unit = conf.supybot.plugins.Weather.temperatureUnit() + conf.supybot.plugins.Weather.convert.setValue(False) + conf.supybot.plugins.Weather.temperatureUnit.setValue('C') + self.assertRegexp('ham london, gb', r'-?\d+.C') + self.assertRegexp('ham 02115', r'-?\d+.F') + conf.supybot.plugins.Weather.temperatureUnit.setValue('F') + self.assertRegexp('ham london, gb', r'-?\d+.C') + self.assertRegexp('ham 02115', r'-?\d+.F') + conf.supybot.plugins.Weather.convert.setValue(True) + conf.supybot.plugins.Weather.temperatureUnit.setValue('C') + self.assertRegexp('ham london, gb', r'-?\d+.C') + self.assertRegexp('ham 02115', r'-?\d+.C') + conf.supybot.plugins.Weather.temperatureUnit.setValue('F') + self.assertRegexp('ham london, gb', r'-?\d+.F') + self.assertRegexp('ham 02115', r'-?\d+.F') + finally: + conf.supybot.plugins.Weather.convert.setValue(convert) + conf.supybot.plugins.Weather.temperatureUnit.setValue(unit) + + +# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: