Initial commit
This commit is contained in:
parent
3f88b5dd52
commit
66c83f82d8
|
@ -13,7 +13,7 @@ develop-eggs/
|
|||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
lib/
|
||||
# lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
Copyright 2015 Laszlo Zeke
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -0,0 +1,17 @@
|
|||
## Twitch RSS Webapp for Google App Engine
|
||||
This project is a very small web application for serving RSS feed for broadcasts
|
||||
in Twitch. It fetches data from [Twitch API](https://github.com/justintv/twitch-api) and caches in Memcache.
|
||||
The engine is webapp2.
|
||||
|
||||
A running version can be tried out at:
|
||||
https://twitchrss.appspot.com/vod/twitch
|
||||
|
||||
### Deployment
|
||||
See how to deploy on [Google App Engine](https://cloud.google.com/appengine/docs/python/gettingstartedpython27/introduction).
|
||||
|
||||
### Other things
|
||||
The project uses a slightly modified [Feedformatter](https://code.google.com/p/feedformatter/) to support
|
||||
more tags and time zone in pubDate tag.
|
||||
|
||||
### About
|
||||
The project has been developed by László Zeke.
|
|
@ -0,0 +1,9 @@
|
|||
application: twitchrss-app-engine
|
||||
version: 1
|
||||
runtime: python27
|
||||
api_version: 1
|
||||
threadsafe: true
|
||||
|
||||
handlers:
|
||||
- url: /.*
|
||||
script: twitchrss.app
|
|
@ -0,0 +1,6 @@
|
|||
"""`appengine_config` gets loaded when starting a new application instance."""
|
||||
import vendor
|
||||
# insert `lib` as a site directory so our `main` module can load
|
||||
# third-party libraries, and override built-ins with newer
|
||||
# versions.
|
||||
vendor.add('lib')
|
|
@ -0,0 +1,476 @@
|
|||
# Feedformatter
|
||||
# Copyright (c) 2008, Luke Maurits <luke@maurits.id.au>
|
||||
# Copyright (c) 2015, Laszlo Zeke
|
||||
# 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.
|
||||
# * The name of the author may not be used to endorse or promote products
|
||||
# derived from this software without specific prior written permission.
|
||||
#
|
||||
# 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.
|
||||
|
||||
__version__ = "0.4"
|
||||
|
||||
from cStringIO import StringIO
|
||||
|
||||
# This "staircase" of import attempts is ugly. If there's a nicer way to do
|
||||
# this, please let me know!
|
||||
try:
|
||||
import xml.etree.cElementTree as ET
|
||||
except ImportError:
|
||||
try:
|
||||
import xml.etree.ElementTree as ET
|
||||
except ImportError:
|
||||
try:
|
||||
import cElementTree as ET
|
||||
except ImportError:
|
||||
try:
|
||||
from elementtree import ElementTree as ET
|
||||
except ImportError:
|
||||
raise ImportError("Could not import any form of element tree!")
|
||||
|
||||
try:
|
||||
from xml.dom.ext import PrettyPrint
|
||||
from xml.dom.ext.reader.Sax import FromXml
|
||||
feedformatterCanPrettyPrint = True
|
||||
except ImportError:
|
||||
feedformatterCanPrettyPrint = False
|
||||
|
||||
from time import time, strftime, localtime, mktime, struct_time, timezone
|
||||
|
||||
# RSS 1.0 Functions ----------
|
||||
|
||||
_rss1_channel_mappings = (
|
||||
(("title",), "title"),
|
||||
(("link", "url"), "link"),
|
||||
(("description", "desc", "summary"), "description")
|
||||
)
|
||||
|
||||
_rss1_item_mappings = (
|
||||
(("title",), "title"),
|
||||
(("link", "url"), "link"),
|
||||
(("description", "desc", "summary"), "description")
|
||||
)
|
||||
|
||||
# RSS 2.0 Functions ----------
|
||||
|
||||
_rss2_channel_mappings = (
|
||||
(("title",), "title"),
|
||||
(("link", "url"), "link"),
|
||||
(("description", "desc", "summary"), "description"),
|
||||
(("pubDate", "pubdate", "date", "published", "updated"), "pubDate", lambda(x): _format_datetime("rss2",x)),
|
||||
(("category",), "category"),
|
||||
(("language",), "language"),
|
||||
(("copyright",), "copyright"),
|
||||
(("webMaster",), "webmaster"),
|
||||
(("image",), "image"),
|
||||
(("skipHours",), "skipHours"),
|
||||
(("skipDays",), "skipDays")
|
||||
)
|
||||
|
||||
_rss2_item_mappings = (
|
||||
(("title",), "title"),
|
||||
(("link", "url"), "link"),
|
||||
(("description", "desc", "summary"), "description"),
|
||||
(("guid", "id"), "guid"),
|
||||
(("pubDate", "pubdate", "date", "published", "updated"), "pubDate", lambda(x): _format_datetime("rss2",x)),
|
||||
(("category",), "category"),
|
||||
(("author",), "author", lambda(x): _rssify_author(x)),
|
||||
(("ttl",), "ttl")
|
||||
)
|
||||
|
||||
# Atom 1.0 ----------
|
||||
|
||||
_atom_feed_mappings = (
|
||||
(("title",), "title"),
|
||||
(("link", "url"), "id"),
|
||||
(("description", "desc", "summary"), "subtitle"),
|
||||
(("pubDate", "pubdate", "date", "published", "updated"), "pubDate", lambda(x): _format_datetime("atom",x)),
|
||||
(("category",), "category"),
|
||||
(("author",), "author", lambda(x): _atomise_author(x))
|
||||
)
|
||||
|
||||
_atom_item_mappings = (
|
||||
(("title",), "title"),
|
||||
(("link", "url"), "id"),
|
||||
(("link", "url"), "link", lambda(x): _atomise_link(x)),
|
||||
(("description", "desc", "summary"), "summary"),
|
||||
(("pubDate", "pubdate", "date", "published", "updated"), "pubDate", lambda(x): _format_datetime("atom",x)),
|
||||
(("category",), "category"),
|
||||
(("author",), "author", lambda(x): _atomise_author(x))
|
||||
)
|
||||
|
||||
def _get_tz_offset():
|
||||
|
||||
"""
|
||||
Return the current timezone's offset from GMT as a string
|
||||
in the format +/-HH:MM, as required by RFC3339.
|
||||
"""
|
||||
|
||||
seconds = -1*timezone # Python gets the offset backward! >:(
|
||||
minutes = seconds/60
|
||||
hours = minutes/60
|
||||
minutes = minutes - hours*60
|
||||
if seconds < 0:
|
||||
return "-%02d:%d" % (hours, minutes)
|
||||
else:
|
||||
return "+%02d:%d" % (hours, minutes)
|
||||
|
||||
def _convert_datetime(time):
|
||||
|
||||
"""
|
||||
Convert time, which may be one of a whole lot of things, into a
|
||||
standard 9 part time tuple.
|
||||
"""
|
||||
|
||||
if (type(time) is tuple and len(time) ==9) or type(time) is struct_time:
|
||||
# Already done!
|
||||
return time
|
||||
elif type(time) is int or type(time) is float:
|
||||
# Assume this is a seconds-since-epoch time
|
||||
return localtime(time)
|
||||
elif type(time) is str:
|
||||
if time.isalnum():
|
||||
# String is alphanumeric - a time stamp?
|
||||
try:
|
||||
return strptime(time, "%a, %d %b %Y %H:%M:%S %Z")
|
||||
except ValueError:
|
||||
raise Exception("Unrecongised time format!")
|
||||
else:
|
||||
# Maybe this is a string of an epoch time?
|
||||
try:
|
||||
return localtime(float(time))
|
||||
except ValueError:
|
||||
# Guess not.
|
||||
raise Exception("Unrecongised time format!")
|
||||
else:
|
||||
# No idea what this is. Give up!
|
||||
raise Exception("Unrecongised time format!")
|
||||
|
||||
def _format_datetime(feed_type, time):
|
||||
|
||||
"""
|
||||
Convert some representation of a date and time into a string which can be
|
||||
used in a validly formatted feed of type feed_type. Raise an
|
||||
Exception if this cannot be done.
|
||||
"""
|
||||
|
||||
# First, convert time into a time structure
|
||||
time = _convert_datetime(time)
|
||||
|
||||
# Then, convert that to the appropriate string
|
||||
if feed_type is "rss2":
|
||||
return strftime("%a, %d %b %Y %H:%M:%S UTC", time)
|
||||
elif feed_type is "atom":
|
||||
return strftime("%Y-%m-%dT%H:%M:%S", time) + _get_tz_offset();
|
||||
|
||||
def _atomise_link(link):
|
||||
|
||||
if type(link) is dict:
|
||||
return dict
|
||||
else:
|
||||
return {"href" : link}
|
||||
|
||||
def _atomise_author(author):
|
||||
|
||||
"""
|
||||
Convert author from whatever it is to a dictionary representing an
|
||||
atom:Person construct.
|
||||
"""
|
||||
|
||||
if type(author) is dict:
|
||||
return author
|
||||
else:
|
||||
if author.startswith("http://") or author.startswith("www"):
|
||||
# This is clearly a URI
|
||||
return {"uri" : author}
|
||||
elif "@" in author and "." in author:
|
||||
# This is most probably an email address
|
||||
return {"email" : author}
|
||||
else:
|
||||
# Must be a name
|
||||
return {"name" : author}
|
||||
|
||||
def _rssify_author(author):
|
||||
|
||||
"""
|
||||
Convert author from whatever it is to a plain old email string for
|
||||
use in an RSS 2.0 feed.
|
||||
"""
|
||||
|
||||
if type(author) is dict:
|
||||
try:
|
||||
return author["email"]
|
||||
except KeyError:
|
||||
return None
|
||||
else:
|
||||
if "@" in author and "." in author:
|
||||
# Probably an email address
|
||||
return author
|
||||
else:
|
||||
return None
|
||||
|
||||
def _add_subelems(root_element, mappings, dictionary):
|
||||
|
||||
"""
|
||||
Add one subelement to root_element for each key in dictionary
|
||||
which is supported by a mapping in mappings
|
||||
"""
|
||||
for mapping in mappings:
|
||||
for key in mapping[0]:
|
||||
if key in dictionary:
|
||||
if len(mapping) == 2:
|
||||
value = dictionary[key]
|
||||
elif len(mapping) == 3:
|
||||
value = mapping[2](dictionary[key])
|
||||
_add_subelem(root_element, mapping[1], value)
|
||||
break
|
||||
|
||||
def _add_subelem(root_element, name, value):
|
||||
|
||||
if value is None:
|
||||
return
|
||||
|
||||
if type(value) is dict:
|
||||
### HORRIBLE HACK!
|
||||
if name=="link":
|
||||
ET.SubElement(root_element, name, href=value["href"])
|
||||
else:
|
||||
subElem = ET.SubElement(root_element, name)
|
||||
for key in value:
|
||||
_add_subelem(subElem, key, value[key])
|
||||
else:
|
||||
ET.SubElement(root_element, name).text = value
|
||||
|
||||
def _stringify(tree, pretty):
|
||||
|
||||
"""
|
||||
Turn an ElementTree into a string, optionally with line breaks and indentation.
|
||||
"""
|
||||
|
||||
if pretty and feedformatterCanPrettyPrint:
|
||||
string = StringIO()
|
||||
doc = FromXml(ET.tostring(tree))
|
||||
PrettyPrint(doc,string,indent=" ")
|
||||
return string.getvalue()
|
||||
else:
|
||||
return ET.tostring(tree)
|
||||
|
||||
class Feed:
|
||||
|
||||
### INTERNAL METHODS ------------------------------
|
||||
|
||||
def __init__(self, feed=None, items=None):
|
||||
|
||||
if feed:
|
||||
self.feed = feed
|
||||
else:
|
||||
self.feed = {}
|
||||
if items:
|
||||
self.items = items
|
||||
else:
|
||||
self.items = []
|
||||
self.entries = self.items
|
||||
|
||||
### RSS 1.0 STUFF ------------------------------
|
||||
|
||||
def validate_rss1(self):
|
||||
|
||||
"""Raise an InvalidFeedException if the feed cannot be validly
|
||||
formatted as RSS 1.0."""
|
||||
|
||||
# <channel> must contain "title"
|
||||
if "title" not in self.feed:
|
||||
raise InvalidFeedException("The channel element of an "
|
||||
"RSS 1.0 feed must contain a title subelement")
|
||||
|
||||
# <channel> must contain "link"
|
||||
if "link" not in self.feed:
|
||||
raise InvalidFeedException("The channel element of an "
|
||||
" RSS 1.0 feeds must contain a link subelement")
|
||||
|
||||
# <channel> must contain "description"
|
||||
if "description" not in self.feed:
|
||||
raise InvalidFeedException("The channel element of an "
|
||||
"RSS 1.0 feeds must contain a description subelement")
|
||||
|
||||
# Each <item> must contain "title" and "link"
|
||||
for item in self.items:
|
||||
if "title" not in item:
|
||||
raise InvalidFeedException("Each item element in an RSS 1.0 "
|
||||
"feed must contain a title subelement")
|
||||
if "link" not in item:
|
||||
raise InvalidFeedException("Each item element in an RSS 1.0 "
|
||||
"feed must contain a link subelement")
|
||||
|
||||
def format_rss1_string(self, validate=True, pretty=False):
|
||||
|
||||
"""Format the feed as RSS 1.0 and return the result as a string."""
|
||||
|
||||
if validate:
|
||||
self.validate_rss1()
|
||||
RSS1root = ET.Element( 'rdf:RDF',
|
||||
{"xmlns:rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
||||
"xmlns" : "http://purl.org/rss/1.0/"} )
|
||||
RSS1channel = ET.SubElement(RSS1root, 'channel',
|
||||
{"rdf:about" : self.feed["link"]})
|
||||
_add_subelems(RSS1channel, _rss1_channel_mappings, self.feed)
|
||||
RSS1contents = ET.SubElement(RSS1channel, 'items')
|
||||
RSS1contents_seq = ET.SubElement (RSS1contents, 'rdf:Seq')
|
||||
for item in self.items:
|
||||
ET.SubElement(RSS1contents_seq, 'rdf:li', resource=item["link"])
|
||||
for item in self.items:
|
||||
RSS1item = ET.SubElement (RSS1root, 'item',
|
||||
{"rdf:about" : item["link"]})
|
||||
_add_subelems(RSS1item, _rss1_item_mappings, item)
|
||||
return _stringify(RSS1root, pretty=pretty)
|
||||
|
||||
def format_rss1_file(self, filename, validate=True, pretty=False):
|
||||
|
||||
"""Format the feed as RSS 1.0 and save the result to a file."""
|
||||
|
||||
string = self.format_rss1_string(validate, pretty)
|
||||
fp = open(filename, "w")
|
||||
fp.write(string)
|
||||
fp.close()
|
||||
|
||||
### RSS 2.0 STUFF ------------------------------
|
||||
|
||||
def validate_rss2(self):
|
||||
|
||||
"""Raise an InvalidFeedException if the feed cannot be validly
|
||||
formatted as RSS 2.0."""
|
||||
|
||||
# <channel> must contain "title"
|
||||
if "title" not in self.feed:
|
||||
raise InvalidFeedException("The channel element of an "
|
||||
"RSS 2.0 feed must contain a title subelement")
|
||||
|
||||
# <channel> must contain "link"
|
||||
if "link" not in self.feed:
|
||||
raise InvalidFeedException("The channel element of an "
|
||||
" RSS 2.0 feeds must contain a link subelement")
|
||||
|
||||
# <channel> must contain "description"
|
||||
if "description" not in self.feed:
|
||||
raise InvalidFeedException("The channel element of an "
|
||||
"RSS 2.0 feeds must contain a description subelement")
|
||||
|
||||
# Each <item> must contain at least "title" OR "description"
|
||||
for item in self.items:
|
||||
if not ("title" in item or "description" in item):
|
||||
raise InvalidFeedException("Each item element in an RSS 2.0 "
|
||||
"feed must contain at least a title or description subelement")
|
||||
|
||||
def format_rss2_string(self, validate=True, pretty=False):
|
||||
|
||||
"""Format the feed as RSS 2.0 and return the result as a string."""
|
||||
|
||||
if validate:
|
||||
self.validate_rss2()
|
||||
RSS2root = ET.Element( 'rss', {'version':'2.0'} )
|
||||
RSS2channel = ET.SubElement( RSS2root, 'channel' )
|
||||
_add_subelems(RSS2channel, _rss2_channel_mappings, self.feed)
|
||||
for item in self.items:
|
||||
RSS2item = ET.SubElement ( RSS2channel, 'item' )
|
||||
_add_subelems(RSS2item, _rss2_item_mappings, item)
|
||||
return _stringify(RSS2root, pretty=pretty)
|
||||
|
||||
def format_rss2_file(self, filename, validate=True, pretty=False):
|
||||
|
||||
"""Format the feed as RSS 2.0 and save the result to a file."""
|
||||
|
||||
string = self.format_rss2_string(validate, pretty)
|
||||
fp = open(filename, "w")
|
||||
fp.write(string)
|
||||
fp.close()
|
||||
|
||||
### ATOM STUFF ------------------------------
|
||||
|
||||
def validate_atom(self):
|
||||
|
||||
"""Raise an InvalidFeedException if the feed cannot be validly
|
||||
formatted as Atom 1.0."""
|
||||
|
||||
# Must have at least one "author" element in "feed" OR at least
|
||||
# "author" element in each "entry".
|
||||
if "author" not in self.feed:
|
||||
for entry in self.entries:
|
||||
if "author" not in entry:
|
||||
raise InvalidFeedException("Atom feeds must have either at "
|
||||
"least one author element in the feed element or at least "
|
||||
" one author element in each entry element")
|
||||
|
||||
def format_atom_string(self, validate=True, pretty=False):
|
||||
|
||||
"""Format the feed as Atom 1.0 and return the result as a string."""
|
||||
|
||||
if validate:
|
||||
self.validate_atom()
|
||||
AtomRoot = ET.Element( 'feed', {"xmlns":"http://www.w3.org/2005/Atom"} )
|
||||
_add_subelems(AtomRoot, _atom_feed_mappings, self.feed)
|
||||
for entry in self.entries:
|
||||
AtomItem = ET.SubElement ( AtomRoot, 'entry' )
|
||||
_add_subelems(AtomItem, _atom_item_mappings, entry)
|
||||
return _stringify(AtomRoot, pretty=pretty)
|
||||
|
||||
def format_atom_file(self, filename, validate=True, pretty=False):
|
||||
|
||||
"""Format the feed as Atom 1.0 and save the result to a file."""
|
||||
|
||||
string = self.format_atom_string(validate, pretty)
|
||||
fp = open(filename, "w")
|
||||
fp.write(string)
|
||||
fp.close()
|
||||
|
||||
class InvalidFeedException(Exception):
|
||||
|
||||
pass
|
||||
|
||||
### FACTORY FUNCTIONS ------------------------------
|
||||
|
||||
def fromUFP(ufp):
|
||||
|
||||
return Feed(ufp["feed"], ufp["items"])
|
||||
|
||||
### MAIN ------------------------------
|
||||
|
||||
def main():
|
||||
|
||||
feed = Feed()
|
||||
feed.feed["title"] = "Test Feed"
|
||||
feed.feed["link"] = "http://code.google.com/p/feedformatter/"
|
||||
feed.feed["author"] = "Luke Maurits"
|
||||
feed.feed["description"] = "A simple test feed for the feedformatter project"
|
||||
item = {}
|
||||
item["title"] = "Test item"
|
||||
item["link"] = "http://www.python.org"
|
||||
item["description"] = "Python programming language"
|
||||
item["guid"] = "1234567890"
|
||||
feed.items.append(item)
|
||||
print("---- RSS 1.0 ----")
|
||||
print feed.format_rss1_string(pretty=True)
|
||||
print("---- RSS 2.0 ----")
|
||||
print feed.format_rss2_string(pretty=True)
|
||||
print("---- Atom 1.0 ----")
|
||||
print feed.format_atom_string(pretty=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -0,0 +1,132 @@
|
|||
#
|
||||
# Copyright 2015 Laszlo Zeke
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
import webapp2
|
||||
from webapp2 import Route
|
||||
import urllib2
|
||||
import json
|
||||
import datetime
|
||||
import logging
|
||||
from feedformatter import Feed
|
||||
from google.appengine.api import memcache
|
||||
|
||||
|
||||
class MainPage(webapp2.RequestHandler):
|
||||
def get(self):
|
||||
self.response.headers['Content-Type'] = 'text/html'
|
||||
html_resp = """
|
||||
<html>
|
||||
<head>
|
||||
<title>Twitch stream RSS generator</title>
|
||||
</head>
|
||||
<body>
|
||||
<p style="font-family: helvetica; font-size:20pt; padding: 20px;">
|
||||
Twitch stream RSS generator
|
||||
</p>
|
||||
<p style="font-family: helvetica; font-size:12pt; padding: 20px;">
|
||||
You can get RSS of broadcasts by subscribing to https://twitchrss.appspot.com/vod/<channel name><br/>
|
||||
For example: <a href="https://twitchrss.appspot.com/vod/riotgames">https://twitchrss.appspot.com/vod/riotgames</a><br/><br/>
|
||||
Not endorsed by Twitch.tv, just a fun project.<br/>
|
||||
<a href="https://github.com/lzeke0/TwitchRSS">Project home</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.response.write(html_resp)
|
||||
|
||||
|
||||
class RSSVoDServer(webapp2.RequestHandler):
|
||||
def get(self, channel):
|
||||
channel_json = self.lookup_cache(channel)
|
||||
if channel_json == '':
|
||||
channel_json = self.fetch_json(channel)
|
||||
if channel_json == '':
|
||||
self.abort(404)
|
||||
else:
|
||||
self.store_cache(channel, channel_json)
|
||||
|
||||
decoded_json = json.loads(channel_json)
|
||||
rss_data = self.construct_rss(channel, decoded_json)
|
||||
self.response.headers['Content-Type'] = 'application/xhtml+xml'
|
||||
self.response.write(rss_data)
|
||||
|
||||
@staticmethod
|
||||
def lookup_cache(channel_name):
|
||||
cached_data = memcache.get('vodcache:%s' % channel_name)
|
||||
if cached_data is not None:
|
||||
logging.debug('Cache hit for %s' % channel_name)
|
||||
return cached_data
|
||||
else:
|
||||
logging.debug('Cache miss for %s' % channel_name)
|
||||
return ''
|
||||
|
||||
@staticmethod
|
||||
def store_cache(channel_name, data):
|
||||
try:
|
||||
logging.debug('Cached data for %s' % channel_name)
|
||||
memcache.set('vodcache:%s' % channel_name, data, 120)
|
||||
except:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def fetch_json(channel):
|
||||
url = 'https://api.twitch.tv/kraken/channels/%s/videos?broadcasts=true' % channel
|
||||
request = urllib2.Request(url,headers={'Accept':'application/vnd.twitchtv.v3+json'})
|
||||
try:
|
||||
result = urllib2.urlopen(request)
|
||||
logging.debug('Fetch from twitch for %s with code %s' % (channel, result.getcode()))
|
||||
return result.read()
|
||||
except urllib2.URLError, e:
|
||||
return ''
|
||||
|
||||
def construct_rss(self, channel_name, vods_info):
|
||||
feed = Feed()
|
||||
|
||||
# Set the feed/channel level properties
|
||||
feed.feed["title"] = "%s's Twitch video RSS" % channel_name
|
||||
feed.feed["link"] = "https://twitchrss.appspot.com/"
|
||||
feed.feed["author"] = "Twitch RSS Gen"
|
||||
feed.feed["description"] = "The RSS Feed of %s's videos on Twitch" % channel_name
|
||||
|
||||
# Create an item
|
||||
try:
|
||||
if vods_info['videos'] is not None:
|
||||
for vod in vods_info['videos']:
|
||||
item = {}
|
||||
item["title"] = vod['title']
|
||||
link = ""
|
||||
if vod["status"] == "recording":
|
||||
link = "http://www.twitch.tv/%s" % channel_name
|
||||
else:
|
||||
link = vod['url']
|
||||
item["link"] = link
|
||||
item["description"] = "<a href=\"%s\"><img src=\"%s\" /></a>" % (link, vod['preview'])
|
||||
d = datetime.datetime.strptime(vod['recorded_at'], '%Y-%m-%dT%H:%M:%SZ')
|
||||
item["pubDate"] = d.timetuple()
|
||||
item["guid"] = vod['_id']
|
||||
if vod["status"] == "recording": # To show a different news item when live is over
|
||||
item["guid"] += "_live"
|
||||
item["ttl"] = '10'
|
||||
feed.items.append(item)
|
||||
except KeyError:
|
||||
self.abort(404)
|
||||
|
||||
return feed.format_rss2_string()
|
||||
|
||||
app = webapp2.WSGIApplication([
|
||||
Route('/', MainPage),
|
||||
Route('/vod/<channel:[a-zA-Z0-9_]{4,25}>', RSSVoDServer)
|
||||
], debug=False)
|
|
@ -0,0 +1,71 @@
|
|||
#
|
||||
# Copyright 2014 Jon Wayne Parrott, [proppy], Michael R. Bernstein
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
# Notes:
|
||||
# - Imported from https://github.com/jonparrott/Darth-Vendor/.
|
||||
# - Added license header.
|
||||
# - Renamed `darth.vendor` to `vendor.add` to match upcoming SDK interface.
|
||||
# - Renamed `position` param to `index` to match upcoming SDK interface.
|
||||
# - Removed funny arworks docstring.
|
||||
|
||||
import site
|
||||
import os.path
|
||||
import sys
|
||||
|
||||
|
||||
def add(folder, index=1):
|
||||
"""
|
||||
Adds the given folder to the python path. Supports namespaced packages.
|
||||
By default, packages in the given folder take precedence over site-packages
|
||||
and any previous path manipulations.
|
||||
|
||||
Args:
|
||||
folder: Path to the folder containing packages, relative to ``os.getcwd()``
|
||||
position: Where in ``sys.path`` to insert the vendor packages. By default
|
||||
this is set to 1. It is inadvisable to set it to 0 as it will override
|
||||
any modules in the current working directory.
|
||||
"""
|
||||
|
||||
# Check if the path contains a virtualenv.
|
||||
site_dir = os.path.join(folder, 'lib', 'python' + sys.version[:3], 'site-packages')
|
||||
if os.path.exists(site_dir):
|
||||
folder = site_dir
|
||||
# Otherwise it's just a normal path, make it absolute.
|
||||
else:
|
||||
folder = os.path.join(os.path.dirname(__file__), folder)
|
||||
|
||||
# Use site.addsitedir() because it appropriately reads .pth
|
||||
# files for namespaced packages. Unfortunately, there's not an
|
||||
# option to choose where addsitedir() puts its paths in sys.path
|
||||
# so we have to do a little bit of magic to make it play along.
|
||||
|
||||
# We're going to grab the current sys.path and split it up into
|
||||
# the first entry and then the rest. Essentially turning
|
||||
# ['.', '/site-packages/x', 'site-packages/y']
|
||||
# into
|
||||
# ['.'] and ['/site-packages/x', 'site-packages/y']
|
||||
# The reason for this is we want '.' to remain at the top of the
|
||||
# list but we want our vendor files to override everything else.
|
||||
sys.path, remainder = sys.path[:1], sys.path[1:]
|
||||
|
||||
# Now we call addsitedir which will append our vendor directories
|
||||
# to sys.path (which was truncated by the last step.)
|
||||
site.addsitedir(folder)
|
||||
|
||||
# Finally, we'll add the paths we removed back.
|
||||
# The final product is something like this:
|
||||
# ['.', '/vendor-folder', /site-packages/x', 'site-packages/y']
|
||||
sys.path.extend(remainder)
|
Loading…
Reference in New Issue