Commits

Ian Lewis committed 079cf49

Initial commit

  • Participants

Comments (0)

Files changed (10)

+syntax: glob
+*.pyc
+*.swp
+*.db
+*.pid
+*.log
+*.egg-info
+settings_local.py
+
+syntax: regexp
+^eggs/
+^build/
+^dist/
+django-skypehub-earthquake
+================================
+
+Overview
+-------------------
+
+This bot scrapes data from http://tenki.jp/ and notifies 
+registered skype rooms with information about earthquakes in
+Japan.
+
+**This bot does not provide advanced warning for earthquakes**
+
+It only notifies a skype room after http://tenki.jp/ has been updated
+and generally provides information on an earthquake within a few minutes.
+
+Setup
+---------------
+
+You need to install django-skypehub and set it up based on the instructions
+in that project.
+
+Add 'earthquake' to your installed apps::
+
+    INSTALLED_APPS = (
+        #...
+        'earthquake',
+        #...
+    )
+
+The earthquake bot supports a number of options::
+
+    SKYPE_EARTHQUAKE_POLL_INTERVAL=30
+    SKYPE_EARTHQUAKE_MIN_MAGNITUDE=3
+    SKYPE_EARTHQUAKE_PLACES=(
+        (u'東京', '*', u'渋谷区'),
+        (u'岩手', '*', '*'),
+    )
+
+SKYPE_EARTHQUAKE_POLL_INTERVAL is the interval that http://tenki.jp/
+is polled in seconds. Do not set this too low as it will increase
+your server resources and tenki.jp may block you or this bot altogether.
+The defalt value is 30 seconds.
+
+SKYPE_EARTHQUAKE_PLACES is a three tuple of places affected by the
+earthquake for which you wish to report. The format is 
+prefecture, area, district as given in the intensity table at
+http://tenki.jp/earthquake/. A special value of '*' is supported
+which matches all values. The default value of this settting is
+(('*','*','*'),) which reports on all places.
+
+SKYPE_EARTHQUAKE_MIN_MAGNITUDE is the minimum magnitude to report. If
+SKYPE_EARTHQUAKE_PLACES is specified this will check the magnitude
+of the specified places rather than the maximum magnitude of the
+earthquake.
+
+Usage
+---------------
+
+Register a room with the bot by typing::
+
+    #earthquake on
+
+The bot will register the room and begin notifying when earthquakes
+happen in Japan. You can also use '#earthquake off' to de-register
+a chat room.
+
+The #earthquake command will give info about the last earthquake event.

earthquake/__init__.py

Empty file added.

earthquake/models.py

+#:coding=utf-8:
+
+from django.db import models
+try:
+    import json
+except ImportError:
+    from django.utils import simplejson as json
+from datetime import datetime
+
+__all__ = (
+    'BroadcastRoom',
+    'Event',
+)
+
+class BroadcastRoom(models.Model):
+    sender = models.CharField(u'送信者', max_length=200)
+    chat_name = models.CharField(u'チャット名', max_length=100, db_index=True)
+    ctime = models.DateTimeField(u'作成日時', auto_now_add=True)
+
+class Event(models.Model):
+    event_id = models.IntegerField(db_index=True)
+    time = models.DateTimeField()
+    area = models.CharField(max_length=255)
+    latitude = models.FloatField(null=True)
+    longitude = models.FloatField(null=True)
+    map_image_url = models.URLField(max_length=255, null=True)
+    magnitude = models.CharField(max_length=255)
+    depth = models.CharField(max_length=255)
+    message = models.TextField()
+    intensity_table = models.TextField()
+    updated_at = models.DateTimeField()
+    timestamp = models.DateTimeField()
+
+    def todict(self):
+        return dict(
+            id=self.event_id,
+            updated_at=self.updated_at,
+            map_image_url=self.map_image_url,
+            message=self.message,
+            place=dict(
+                area=self.area,
+                latitude=self.latitude,
+                longitude=self.longitude),
+            time=self.time,
+            magnitude=self.magnitude,
+            depth=self.depth,
+            intensity_table=json.loads(self.intensity_table))
+
+    def populatewithdict(self, d):
+        self.event_id=d['id']
+        self.updated_at=d['updated_at']
+        self.map_image_url=d['map_image_url']
+        self.message=d['message']
+        self.area=d['place']['area']
+        self.latitude=d['place']['latitude']
+        self.longitude=d['place']['longitude']
+        self.time=d['time']
+        self.magnitude=d['magnitude']
+        self.depth=d['depth']
+        self.intensity_table=json.dumps(d['intensity_table'])
+
+    @classmethod
+    def fromdict(klass, d):
+        retval = klass()
+        retval.populatewithdict(d)
+        return retval
+
+    def save(self):
+        self.timestamp = datetime.now()
+        super(self.__class__, self).save()
+
+    @classmethod
+    def last(klass):
+        items = Event.objects.order_by('timestamp').reverse()
+        if len(items) == 0:
+            return None
+        else:
+            return items[0]

earthquake/skypebot.py

+# encoding: utf-8
+"""
+#earthquake
+"""
+
+import logging
+from skypehub.handlers import on_message, on_time
+from time import time
+from lxml import html
+import re
+from datetime import datetime
+from models import *
+from utils import scrape
+from django.utils import simplejson as json
+from django.conf import settings
+
+from django.utils.encoding import force_unicode
+
+TENKI_JP_URL = "http://tenki.jp/earthquake/"
+
+logger = logging.getLogger("django.skypehub.earthquake")
+
+MIN_MAGNITUDE=getattr(settings, 'SKYPE_EARTHQUAKE_MIN_MAGNITUDE', 3)
+POLL_INTERVAL=getattr(settings, 'SKYPE_EARTHQUAKE_POLL_INTERVAL', 15)
+PLACES=getattr(settings, 'SKYPE_EARTHQUAKE_PLACES', None)
+
+def format_intensity(d):
+    retval = []
+    intensity, places = d
+    retval.append(u"""%s:""" % intensity)
+    for place in places:
+        retval.append(u"""%(prefecture)s %(area)s %(district)s""" % place)
+    retval.append("")
+    return "\n".join(retval)
+
+def format_latitude(value):
+    if value is not None:
+        return u'%s%.2f度' % ((u'北緯', u'南緯')[value < 0], value)
+    else:
+        return u'不明'
+
+def format_longitude(value):
+    if value is not None:
+        return u'%s%.2f度' % ((u'東経', u'西経')[value < 0], value)
+    else:
+        return u'不明'
+
+
+def format_event(d):
+    return u"""%(year)d年%(month)d月%(day)d日%(hour)d時%(minute)d分ごろ地震がありました。%(message)s
+震源: %(place)s %(magnitude)s %(depth)s [%(latitude)s %(longitude)s]
+%(map_image_url)s
+%(intensity_table)s
+各地の震度は http://tenki.jp/earthquake/detail-%(event_id)s.html を参照してください。
+(%(updated_at_year)d年%(updated_at_month)d月%(updated_at_day)d日%(updated_at_hour)d時%(updated_at_minute)d分 更新)
+""" % dict(
+        event_id=d['id'],
+        year=d['time'].year,
+        month=d['time'].month,
+        day=d['time'].day,
+        hour=d['time'].hour,
+        minute=d['time'].minute,
+        message=d['message'],
+        place=d['place']['area'],
+        magnitude=d['magnitude'],
+        depth=d['depth'],
+        latitude=format_latitude(d['place']['latitude']),
+        longitude=format_longitude(d['place']['longitude']),
+        map_image_url=d['map_image_url'] or u"",
+        intensity_table=len(d['intensity_table']) > 0 and format_intensity(d['intensity_table'][0]) or "",
+        updated_at_year=d['updated_at'].year,
+        updated_at_month=d['updated_at'].month,
+        updated_at_day=d['updated_at'].day,
+        updated_at_hour=d['updated_at'].hour,
+        updated_at_minute=d['updated_at'].minute)
+
+def receiver(handler, message, status):
+    g = re.match(ur"#earthquake(?:\s+(\S+))?", message.Body)
+    if g:
+        command = g.group(1)
+        if not command:
+            last_event = Event.last()
+            if last_event is None:
+                message.Chat.SendMessage(u"データがありません")
+            else:
+                message.Chat.SendMessage(format_event(last_event.todict()))
+        elif command == u"on":
+            # Register
+            room, created = BroadcastRoom.objects.get_or_create(
+                chat_name = message.Chat.Name,
+                defaults={'sender': message.Sender.Handle},
+            )
+            if created:
+                message.Chat.SendMessage(u"この部屋を登録しました。")
+            else:
+                message.Chat.SendMessage(u"この部屋はすでに登録しています。")
+        elif command == u"off":
+            # Unregister
+            BroadcastRoom.objects.filter(chat_name = message.Chat.Name).delete()
+            message.Chat.SendMessage(u"この部屋の登録を外しました。")
+
+def poller(handler, time):
+    try:
+        data = scrape(html.parse(TENKI_JP_URL))
+        try:
+            last_event = Event.objects.get(event_id=data['id'])
+        except Event.DoesNotExist:
+            last_event = None
+
+        updated=False
+        if last_event is None:
+            logger.info("New event with id %s" % data['id'])
+            last_event = Event.fromdict(data)
+            last_event.save()
+            updated=True
+        elif last_event.updated_at < data['updated_at']:
+            event_dict = last_event.todict()
+            if (event_dict['time'] != data['time'] or
+                    event_dict['magnitude'] != data['magnitude'] or
+                    event_dict['place']['area'] != data['place']['area'] or
+                    event_dict['place']['latitude'] != data['place']['latitude'] or
+                    event_dict['place']['longitude'] != data['place']['longitude'] or
+                    len(event_dict['intensity_table']) != len(data['intensity_table'])):
+                logger.info("Event %s updated" % data['id'])
+                last_event.populatewithdict(data)
+                last_event.save()
+                updated=True
+
+        place_intensity_hit = False
+        if data['place']['area'] != u'不明' and data['intensity_table']:
+            if PLACES is None:
+                place_intensity_hit = data['intensity_table'][0][0] >= (u"震度%s" % MIN_MAGNITUDE)
+                if places_intensity_hit:
+                    logging.info(u"Intensity hit: %s >= %s" % (intensity, (u"震度%s" % MIN_MAGNITUDE)))
+            else:
+                logging.debug("Checking PLACES...")
+                for intensity, intensity_table in data['intensity_table']:
+                    if intensity >= (u"震度%s" % MIN_MAGNITUDE):
+                        for intensity_data in intensity_table:
+                            logging.debug(u"Checking intensity: %s %s %s" % (
+                                intensity_data['prefecture'],
+                                intensity_data['area'],
+                                intensity_data['district'],
+                            ))
+                            for prefecture, area, district in PLACES:
+                                logging.debug(u"Checking area: %s %s %s" % (
+                                    force_unicode(prefecture),
+                                    force_unicode(area),
+                                    force_unicode(district)
+                                ))
+                                if ((prefecture == '*' or prefecture == intensity_data['prefecture']) and
+                                   (area == '*' or area == intensity_data['area']) and
+                                   (district == '*' or district == intensity_data['district'])):
+                                    logging.info(u"Intensity hit: %s==%s %s==%s %s==%s (%s >= %s)" % (
+                                        intensity_data['prefecture'], prefecture,
+                                        intensity_data['area'], area,
+                                        intensity_data['district'], district,
+                                        intensity, (u"震度%s" % MIN_MAGNITUDE)))
+                                    place_intensity_hit = True
+
+
+        if (updated and place_intensity_hit):
+            for room in BroadcastRoom.objects.all():
+                handler.skype.Chat(room.chat_name).SendMessage(format_event(data))
+    except Exception, e:
+        logger.exception(str(e))
+    handler.connect(poller, time + POLL_INTERVAL)
+
+on_message.connect(receiver)
+on_time.connect(poller, time() + 5)

earthquake/tests.py

+"""
+This file demonstrates two different styles of tests (one doctest and one
+unittest). These will both pass when you run "manage.py test".
+
+Replace these with more appropriate tests for your application.
+"""
+
+from django.test import TestCase
+
+class SimpleTest(TestCase):
+    def test_basic_addition(self):
+        """
+        Tests that 1 + 1 always equals 2.
+        """
+        self.failUnlessEqual(1 + 1, 2)
+
+__test__ = {"doctest": """
+Another way to test that 1 + 1 is equal to 2.
+
+>>> 1 + 1 == 2
+True
+"""}
+

earthquake/utils.py

+# encoding: utf-8
+import re
+from lxml import html
+import datetime
+
+__all__ = (
+    'scrape',
+    )
+
+class UnexpectedStructureError(Exception):
+    pass
+
+def find(n, path):
+    retval = n.xpath(path)
+    if len(retval) == 0:
+        raise UnexpectedStructureError(path, n)
+    return retval[0]
+
+def match(regex, text):
+    retval = re.match(regex, text)
+    if retval is None:
+        raise UnexpectedStructureError(regex, text)
+    return retval 
+
+def scrape(t):
+    first_contents_box_node = find(t, u"//div[@class='contentsBox'][1]")
+    g = match(ur"(\d+)年(\d+)月(\d+)日 (\d+)時(\d+)分",
+        find(first_contents_box_node, u".//div[@class='dateRight']").text_content().strip())
+    try:
+        updated_at = datetime.datetime(year=int(g.group(1)),
+                                       month=int(g.group(2)),
+                                       day=int(g.group(3)),
+                                       hour=int(g.group(4)),
+                                       minute=int(g.group(5)))
+    except ValueError, e:
+        raise UnexpectedStructureError(e)
+
+    intro_input_node = find(t, u".//div[@class='introductionTagBox']//input[@name='tag']")
+    event_id = int(match(".*\?id=([0-9]+)", intro_input_node.value.strip()).group(1))
+
+    map_image_url_node = first_contents_box_node.find(u".//p[@class='mainImage']/img")
+    if map_image_url_node is not None:
+        map_image_url = map_image_url_node.get("src")
+    else:
+        map_image_url = None
+
+    message = find(t, u".//div[@class='contentsBox'][2]//div[contains(string(@class), 'relationMessege')]").text_content().strip()
+
+    table_node = find(t, u".//div[@class='contentsBox'][3]//table[@class='earthquakeDetailTable']")
+
+    g = match(ur"(\d+)月(\d+)日 (\d+)時(\d+)分",
+        find(table_node,
+            u".//th[@abbr='発生時刻']/following-sibling::td").text_content().strip())
+    try:
+        event_month = int(g.group(1))
+        if event_month > updated_at.month:
+            event_year = updated_at.year - 1
+        else:
+            event_year = updated_at.year
+        event_time = datetime.datetime(year=event_year,
+                                       month=event_month,
+                                       day=int(g.group(2)),
+                                       hour=int(g.group(3)),
+                                       minute=int(g.group(4)))
+    except ValueError, e:
+        raise UnexpectedStructureError(e)
+
+    event_place = find(table_node, u".//th[@abbr='震源地']/following-sibling::td").text_content().strip()
+
+    try:
+        g = match(ur"(北緯|南緯)(\d+(?:\.\d+)?)度",
+            find(table_node, u".//th[@abbr='位置']/following-sibling::th[@abbr='緯度']/following-sibling::td").text_content().strip())
+        event_place_latitude = float(g.group(2))
+        if g.group(1) == u"南緯":
+            event_place_latitude = -event_place_latitude
+    except:
+        event_place_latitude = None
+
+    try:
+        g = match(ur"(東経|西経)(\d+(?:\.\d+)?)度",
+            find(table_node, u".//tr[child::th[@abbr='位置']]/following-sibling::tr/th[@abbr='経度']/following::td").text_content().strip())
+        event_place_longitude = float(g.group(2))
+        if g.group(1) == u"西経":
+            event_place_longitude = -event_place_longitude
+    except:
+        event_place_longitude = None
+
+    magnitude = find(table_node, u".//th[@abbr='震源']/following-sibling::th[@abbr='マグニチュード']/following-sibling::td").text_content().strip()
+    depth = find(table_node, u".//tr[child::th[@abbr='震源']]/following-sibling::tr/th[@abbr='深さ']/following::td").text_content().strip()
+
+    intensity_table_node = find(t, u".//div[@class='contentsBox'][4]//table[@id='seismicIntensity']")
+    intensity_table = []
+    rowspan = 0
+    intensity_table_inner = None
+
+    try:
+        for n in intensity_table_node.xpath(u".//tr[position() > 1]"):
+            row = n.xpath(".//td")
+            if rowspan == 0:
+                intensity_node=row[0]
+                rowspan = int(intensity_node.get("rowspan", 1))
+                intensity = intensity_node.text_content().strip()
+                intensity_table_inner = []
+                intensity_table.append((intensity, intensity_table_inner))
+                row.pop(0)
+            intensity_table_inner.append(
+                dict(
+                    prefecture=row[0].text_content().strip(),
+                    area=row[1].text_content().strip(),
+                    district=row[2].text_content().strip()))
+            rowspan -= 1
+    except ValueError, e:
+        raise UnexpectedStructureError(e)
+
+    return dict(
+        id=event_id,
+        updated_at=updated_at,
+        map_image_url=map_image_url,
+        message=message,
+        place=dict(
+            area=event_place,
+            latitude=event_place_latitude,
+            longitude=event_place_longitude),
+        time=event_time,
+        magnitude=magnitude,
+        depth=depth,
+        intensity_table=intensity_table)

earthquake/views.py

+# Create your views here.
+[egg_info]
+tag_build = dev
+tag_date = true
+
+[aliases]
+release = egg_info -RDb ''
+# -*- coding:utf8 -*-
+
+from setuptools import setup, find_packages
+
+setup(
+    name='django-skypehub-earthquake',
+    version='1.0.0',
+    description='An earthquake notifier built on django-skypehub',
+    author='Moriyoshi Koizumi',
+    url='http://bitbucket.org/IanLewis/django-skypehub-earthquake/',
+    packages = find_packages(),
+    license='BSD',
+    keywords='django skype webapp',
+    install_requires = [
+        'Django>=1.2',
+        'django-skypehub>=0.2.0',
+    ],
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Framework :: Django',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: BSD License',
+        'Natural Language :: English',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+    ]
+)