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