Skip to content

Commit

Permalink
start on RSS output
Browse files Browse the repository at this point in the history
for #124
  • Loading branch information
snarfed committed Feb 26, 2019
1 parent 427d743 commit debfbfc
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -303,6 +303,7 @@ On the open source side, there are many related projects. [php-mf2-shim](https:/
Changelog
---
### 1.15 - unreleased
* Add RSS 2.0 output! ([#124](https://github.com/snarfed/granary/issues/124))
* All silos:
* Switch users' primary URLs from web site to silo profile ([#158](https://github.com/snarfed/granary/issues/158)).
* GitHub:
Expand Down
121 changes: 121 additions & 0 deletions granary/rss.py
@@ -0,0 +1,121 @@
"""Convert between ActivityStreams and RSS 2.0.
RSS 2.0 spec: http://www.rssboard.org/rss-specification
"""
from __future__ import absolute_import, unicode_literals
from builtins import str
from past.builtins import basestring

import mimetypes

from feedgen.feed import FeedGenerator
import mf2util
from oauth_dropins.webutil import util

from . import microformats2

# allowed ActivityStreams objectTypes for media enclosures
ENCLOSURE_TYPES = {'audio', 'video'}


def from_activities(activities, actor=None, title=None, description=None,
feed_url=None, home_page_url=None, image_url=None):
"""Converts ActivityStreams activities to an RSS 2.0 feed.
Args:
activities: sequence of ActivityStreams activity dicts
actor: ActivityStreams actor dict, the author of the feed
title: string, the feed title
description, the feed description
home_page_url: string, the home page URL
# feed_url: the URL of this RSS feed, if any
image_url: the URL of an image representing this feed
Returns:
unicode string with RSS 2.0 XML
"""
try:
iter(activities)
except TypeError:
raise TypeError('activities must be iterable')

if isinstance(activities, (dict, basestring)):
raise TypeError('activities may not be a dict or string')

fg = FeedGenerator()
fg.id(feed_url)
fg.link(href=feed_url, rel='self')
fg.link(href=home_page_url, rel='alternate')
fg.title(title)
fg.description(description)
fg.generator('granary', uri='https://granary.io/')
if image_url:
fg.image(image_url)

latest = None
for activity in activities:
obj = activity.get('object') or activity
if obj.get('objectType') == 'person':
continue

item = fg.add_entry()
url = obj.get('url')
item.id(obj.get('id') or url)
item.link(href=url)
item.guid(url, permalink=True)

item.title(obj.get('title') or obj.get('displayName'))
content = microformats2.render_content(
obj, include_location=True, render_attachments=False) or obj.get('summary')
if content:
item.content(content, type='CDATA')

item.category([{'term': t.displayName} for t in obj.get('tags', [])
if t.displayName and t.verb not in ('like', 'react', 'share')])

author = obj.get('author', {})
item.author({
'name': author.get('displayName') or author.get('username'),
'uri': author.get('url'),
})

for prop in 'published', 'updated':
val = obj.get(prop)
if val:
dt = util.parse_iso8601(val)
getattr(item, prop)(dt)
if not latest or dt > latest:
latest = dt

enclosures = False
for att in obj.get('attachments', []):
stream = util.get_first(att, 'stream') or att
if not stream:
continue

url = stream.get('url')
mime = mimetypes.guess_type(url)[0] if url else None
if (att.get('objectType') in ENCLOSURE_TYPES or
mime and mime.split('/')[0] in ENCLOSURE_TYPES):
enclosures = True
item.enclosure(url=url, type=mime) # TODO: length (bytes)

item.load_extension('podcast')
duration = stream.get('duration')
if duration:
item.podcast.itunes_duration(duration)

if enclosures:
fg.load_extension('podcast')
if actor:
fg.podcast.itunes_author(actor.get('displayName') or actor.get('username'))
fg.podcast.itunes_image(image_url)
if description:
fg.podcast.itunes_subtitle(description)
fg.podcast.itunes_explicit('no')
fg.podcast.itunes_block(False)

if latest:
fg.lastBuildDate(dt)

return fg.rss_str(pretty=True)
9 changes: 8 additions & 1 deletion granary/tests/test_testdata.py
Expand Up @@ -13,7 +13,7 @@
from oauth_dropins.webutil import testutil
from oauth_dropins.webutil import util

from granary import as2, jsonfeed, microformats2
from granary import as2, jsonfeed, microformats2, rss


def filepairs(ext1, ext2s):
Expand Down Expand Up @@ -80,6 +80,12 @@ def jsonfeed_to_activity(jf):
def html_to_activity(html):
return microformats2.html_to_activities(html)[0]['object']

def rss_from_activities(activities):
return rss.from_activities(
activities, actor=ACTOR, title='Stuff', description='some stuff by meee',
feed_url='http://site/feed', home_page_url='http://site/',
image_url='http://site/logo.png').decode('utf-8')

# source extension, destination extension, conversion function, exclude prefix
mappings = (
('as.json', ['mf2-from-as.json', 'mf2.json'], microformats2.object_to_json, ()),
Expand All @@ -94,6 +100,7 @@ def html_to_activity(html):
('feed.json', ['as-from-feed.json', 'as.json'], jsonfeed_to_activity, ()),
('as.json', ['as2-from-as.json', 'as2.json'], as2.from_as1, ()),
('as2.json', ['as-from-as2.json', 'as.json'], as2.to_as1, ()),
('as.json', ['rss.xml'], rss_from_activities, ()),
)

test_funcs = {}
Expand Down
32 changes: 32 additions & 0 deletions granary/tests/testdata/feed_with_audio_video.as.json
@@ -0,0 +1,32 @@
[{
"url": "http://podcast/post",
"objectType": "article",
"displayName": "i'm ready to speak",
"content": "<p>some HTML</p>",
"published": "2012-12-05T00:58:26+00:00",
"attachments": [{
"stream": {
"url": "http://a/podcast.mp3",
"duration": 328
},
"objectType": "audio"
}]
},
{
"url": "http://vidjo/post",
"objectType": "article",
"displayName": "i'm ready to perform",
"summary": "other thing",
"updated": "2012-12-06T00:58:26+00:00",
"attachments": [{
"stream": {
"url": "http://a/vidjo.mov",
"duration": 428
},
"objectType": "video"
}],
"stream": [{
"url": "http://a/vidjo.mov",
"duration": 428
}]
}]
52 changes: 52 additions & 0 deletions granary/tests/testdata/feed_with_audio_video.mf2.json
@@ -0,0 +1,52 @@
{
"items": [{
"type": ["h-feed"],
"lang": "en",
"properties": {
"name": ["Pawd Kaast"],
"summary": ["a pawd kaast by meee"],
"photo": ["https://cover/"],
"author": [{
"type": ["h-card"],
"properties": {
"name": ["Meee"],
"photo": ["https://photo/of/meee"],
"url": ["https://meee.com"],
"bio": [{
"html": "my <a>bio</a>",
"value": "my bio"
}]
},
"value": "Meeeeee"
}]
},
"children": [{
"type": ["h-entry"],
"properties": {
"url": ["http://a/podcast"],
"name": ["i'm ready to speak"],
"audio": ["http://a/podcast.mp3"],
"duration": ["328"],
"size": ["7.77mb"],
"summary": [{
"html": "something",
"value": "something"
}]
}
},
{
"type": ["h-entry"],
"properties": {
"name": ["i'm ready to perform"],
"url": ["http://a/vidjo"],
"video": ["http://a/vidjo.mov"],
"duration": ["428"],
"size": ["8.88mb"],
"summary": [{
"html": "other thing",
"value": "other thing"
}]
}
}]
}]
}
42 changes: 42 additions & 0 deletions granary/tests/testdata/feed_with_audio_video.rss.xml
@@ -0,0 +1,42 @@
<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
<channel>
<title>Stuff</title>
<link>http://site/</link>
<description>some stuff by meee</description>
<atom:link href="http://site/feed" rel="self"/>
<docs>http://www.rssboard.org/rss-specification</docs>
<generator>granary</generator>
<image>
<url>http://site/logo.png</url>
<title>Stuff</title>
<link>http://site/</link>
</image>
<lastBuildDate>Thu, 06 Dec 2012 00:58:26 +0000</lastBuildDate>
<itunes:author>Martin Smith</itunes:author>
<itunes:block>no</itunes:block>
<itunes:image href="http://site/logo.png"/>
<itunes:explicit>no</itunes:explicit>
<itunes:subtitle>some stuff by meee</itunes:subtitle>

<item>
<title>i'm ready to perform</title>
<link>http://vidjo/post</link>
<description>other thing</description>
<guid isPermaLink="true">http://vidjo/post</guid>
<enclosure url="http://a/vidjo.mov" length="0" type="video/quicktime"/>
<itunes:duration>428</itunes:duration>
</item>

<item>
<title>i'm ready to speak</title>
<link>http://podcast/post</link>
<description>&lt;p&gt;some HTML&lt;/p&gt;</description>
<guid isPermaLink="true">http://podcast/post</guid>
<enclosure url="http://a/podcast.mp3" length="0" type="audio/mpeg"/>
<pubDate>Wed, 05 Dec 2012 00:58:26 +0000</pubDate>
<itunes:duration>328</itunes:duration>
</item>

</channel>
</rss>
1 change: 1 addition & 0 deletions requirements.freeze.txt
Expand Up @@ -4,6 +4,7 @@ certifi==2018.4.16
chardet==3.0.4
coverage==4.0.3
-e git+https://github.com/snarfed/gdata-python-client-1.git@1df4e1efea7e5cf2754bc7eec6c1ab48ab09e3b1#egg=gdata
feedgen==0.7.0
future==0.16.0
google-api-python-client==1.7.4
html2text==2018.1.9
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
@@ -1,6 +1,7 @@
# Keep in sync with setup.py's install_requires!
beautifulsoup4
brevity>=0.2.17
feedgen>=0.7.0
future
html2text
jinja2
Expand Down
1 change: 1 addition & 0 deletions setup.py
Expand Up @@ -46,6 +46,7 @@ def __init__(self, *args, **kwargs):
# Keep in sync with requirements.txt!
'beautifulsoup4',
'brevity>=0.2.17',
'feedgen>=0.7.0',
'future',
'html2text',
'jinja2',
Expand Down

0 comments on commit debfbfc

Please sign in to comment.