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 = '%s>' % 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 "%s> is not real!" % name
+ self.handle_data('%s>' % 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: