Coverage for integrations / social / feed_export.py: 0.0%
224 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2feed_export.py - RSS and Atom feed generation for HevolveSocial
4Generates standardized feed formats (RSS 2.0, Atom 1.0, JSON Feed 1.1)
5from social content including posts, comments, and user activity.
7Usage:
8 from integrations.social.feed_export import FeedGenerator
10 generator = FeedGenerator(db)
11 rss_xml = generator.generate_rss(feed_type='global', limit=50)
12 atom_xml = generator.generate_atom(feed_type='trending')
13"""
15import logging
16from datetime import datetime, timezone
17from typing import List, Dict, Any, Optional
18from xml.etree import ElementTree as ET
19from html import escape
20import json
22logger = logging.getLogger('hevolve_social')
24# Feed configuration
25FEED_CONFIG = {
26 'title': 'HART Social',
27 'description': 'Crowdsourced agentic intelligence platform for humans and agents',
28 'link': 'https://hevolve.ai',
29 'language': 'en-us',
30 'generator': 'HARTSocial Feed Generator v1.0',
31 'ttl': 60, # minutes
32}
35class FeedGenerator:
36 """Generates RSS, Atom, and JSON feeds from social content."""
38 def __init__(self, db_session, base_url: str = None):
39 """
40 Initialize feed generator.
42 Args:
43 db_session: SQLAlchemy database session
44 base_url: Base URL for feed links (default: from config)
45 """
46 self.db = db_session
47 self.base_url = base_url or FEED_CONFIG['link']
49 def _get_posts(self, feed_type: str = 'global', limit: int = 50,
50 user_id: int = None, community_id: int = None) -> List[Dict]:
51 """
52 Fetch posts for feed generation.
54 Args:
55 feed_type: 'global', 'trending', 'personalized', 'agents'
56 limit: Maximum number of posts
57 user_id: User ID for personalized/user feeds
58 community_id: Community ID for community feeds
59 """
60 try:
61 from .feed_engine import FeedEngine
62 from .models import Post, User, Community
64 engine = FeedEngine(self.db)
66 if community_id:
67 # Community-specific feed
68 posts = self.db.query(Post).filter(
69 Post.community_id == community_id,
70 Post.deleted_at.is_(None)
71 ).order_by(Post.created_at.desc()).limit(limit).all()
72 elif user_id and feed_type == 'personalized':
73 # Personalized feed for user
74 posts = engine.get_personalized_feed(user_id, limit=limit)
75 elif feed_type == 'trending':
76 posts = engine.get_trending_feed(limit=limit)
77 elif feed_type == 'agents':
78 posts = engine.get_agent_feed(limit=limit)
79 else:
80 # Global feed
81 posts = engine.get_global_feed(limit=limit)
83 # Convert to dict format
84 result = []
85 for post in posts:
86 post_dict = post.to_dict() if hasattr(post, 'to_dict') else post
87 result.append(post_dict)
89 return result
91 except Exception as e:
92 logger.error(f"Error fetching posts for feed: {e}")
93 return []
95 def _format_post_content(self, post: Dict) -> str:
96 """Format post content for feed inclusion."""
97 content = post.get('content', '')
99 # Add media if present
100 media = post.get('media_urls') or []
101 if media:
102 content += '\n\n'
103 for url in media:
104 if any(ext in url.lower() for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
105 content += f'<img src="{escape(url)}" />\n'
106 else:
107 content += f'<a href="{escape(url)}">{escape(url)}</a>\n'
109 return content
111 def _get_post_url(self, post: Dict) -> str:
112 """Generate URL for a post."""
113 post_id = post.get('id', '')
114 return f"{self.base_url}/social/post/{post_id}"
116 def _get_author_url(self, author: Dict) -> str:
117 """Generate URL for an author."""
118 author_id = author.get('id', '')
119 return f"{self.base_url}/social/u/{author_id}"
121 def generate_rss(self, feed_type: str = 'global', limit: int = 50,
122 user_id: int = None, community_id: int = None,
123 title: str = None) -> str:
124 """
125 Generate RSS 2.0 feed.
127 Args:
128 feed_type: Type of feed ('global', 'trending', 'personalized', 'agents')
129 limit: Maximum number of items
130 user_id: User ID for personalized feeds
131 community_id: Community ID for community feeds
132 title: Custom feed title
134 Returns:
135 RSS 2.0 XML string
136 """
137 posts = self._get_posts(feed_type, limit, user_id, community_id)
139 # Build RSS structure
140 rss = ET.Element('rss', version='2.0')
141 rss.set('xmlns:atom', 'http://www.w3.org/2005/Atom')
142 rss.set('xmlns:dc', 'http://purl.org/dc/elements/1.1/')
144 channel = ET.SubElement(rss, 'channel')
146 # Channel metadata
147 feed_title = title or f"{FEED_CONFIG['title']} - {feed_type.title()} Feed"
148 ET.SubElement(channel, 'title').text = feed_title
149 ET.SubElement(channel, 'link').text = self.base_url
150 ET.SubElement(channel, 'description').text = FEED_CONFIG['description']
151 ET.SubElement(channel, 'language').text = FEED_CONFIG['language']
152 ET.SubElement(channel, 'generator').text = FEED_CONFIG['generator']
153 ET.SubElement(channel, 'ttl').text = str(FEED_CONFIG['ttl'])
154 ET.SubElement(channel, 'lastBuildDate').text = datetime.now(timezone.utc).strftime(
155 '%a, %d %b %Y %H:%M:%S +0000')
157 # Self-referential atom:link
158 atom_link = ET.SubElement(channel, '{http://www.w3.org/2005/Atom}link')
159 atom_link.set('href', f"{self.base_url}/api/social/feeds/rss?type={feed_type}")
160 atom_link.set('rel', 'self')
161 atom_link.set('type', 'application/rss+xml')
163 # Add items
164 for post in posts:
165 item = ET.SubElement(channel, 'item')
167 # Title - use first line of content or truncate
168 content = post.get('content', 'Untitled')
169 title_text = content.split('\n')[0][:100]
170 if len(content) > 100:
171 title_text += '...'
172 ET.SubElement(item, 'title').text = title_text
174 # Link
175 ET.SubElement(item, 'link').text = self._get_post_url(post)
177 # GUID
178 guid = ET.SubElement(item, 'guid')
179 guid.text = self._get_post_url(post)
180 guid.set('isPermaLink', 'true')
182 # Description (full content)
183 ET.SubElement(item, 'description').text = self._format_post_content(post)
185 # Author
186 author = post.get('author', {})
187 author_name = author.get('display_name') or author.get('username', 'Anonymous')
188 ET.SubElement(item, '{http://purl.org/dc/elements/1.1/}creator').text = author_name
190 # Publication date
191 created_at = post.get('created_at')
192 if created_at:
193 if isinstance(created_at, str):
194 try:
195 created_at = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
196 except Exception:
197 created_at = datetime.now(timezone.utc)
198 ET.SubElement(item, 'pubDate').text = created_at.strftime(
199 '%a, %d %b %Y %H:%M:%S +0000')
201 # Categories (tags)
202 tags = post.get('tags', [])
203 for tag in tags:
204 ET.SubElement(item, 'category').text = tag
206 # Community as category
207 community = post.get('community')
208 if community:
209 community_name = community.get('name') if isinstance(community, dict) else str(community)
210 ET.SubElement(item, 'category').text = f"s/{community_name}"
212 # Generate XML string
213 return ET.tostring(rss, encoding='unicode', xml_declaration=True)
215 def generate_atom(self, feed_type: str = 'global', limit: int = 50,
216 user_id: int = None, community_id: int = None,
217 title: str = None) -> str:
218 """
219 Generate Atom 1.0 feed.
221 Args:
222 feed_type: Type of feed
223 limit: Maximum number of entries
224 user_id: User ID for personalized feeds
225 community_id: Community ID for community feeds
226 title: Custom feed title
228 Returns:
229 Atom 1.0 XML string
230 """
231 posts = self._get_posts(feed_type, limit, user_id, community_id)
233 # Atom namespace
234 ATOM_NS = 'http://www.w3.org/2005/Atom'
236 feed = ET.Element('{%s}feed' % ATOM_NS)
237 feed.set('xmlns', ATOM_NS)
239 # Feed metadata
240 feed_title = title or f"{FEED_CONFIG['title']} - {feed_type.title()} Feed"
241 ET.SubElement(feed, 'title').text = feed_title
242 ET.SubElement(feed, 'subtitle').text = FEED_CONFIG['description']
244 # Links
245 link_self = ET.SubElement(feed, 'link')
246 link_self.set('href', f"{self.base_url}/api/social/feeds/atom?type={feed_type}")
247 link_self.set('rel', 'self')
248 link_self.set('type', 'application/atom+xml')
250 link_alt = ET.SubElement(feed, 'link')
251 link_alt.set('href', self.base_url)
252 link_alt.set('rel', 'alternate')
253 link_alt.set('type', 'text/html')
255 # Feed ID
256 ET.SubElement(feed, 'id').text = f"{self.base_url}/feeds/{feed_type}"
258 # Updated
259 ET.SubElement(feed, 'updated').text = datetime.now(timezone.utc).isoformat()
261 # Generator
262 generator = ET.SubElement(feed, 'generator')
263 generator.text = 'HevolveSocial'
264 generator.set('version', '1.0')
265 generator.set('uri', self.base_url)
267 # Add entries
268 for post in posts:
269 entry = ET.SubElement(feed, 'entry')
271 # Title
272 content = post.get('content', 'Untitled')
273 title_text = content.split('\n')[0][:100]
274 if len(content) > 100:
275 title_text += '...'
276 ET.SubElement(entry, 'title').text = title_text
278 # ID
279 ET.SubElement(entry, 'id').text = self._get_post_url(post)
281 # Link
282 link = ET.SubElement(entry, 'link')
283 link.set('href', self._get_post_url(post))
284 link.set('rel', 'alternate')
285 link.set('type', 'text/html')
287 # Content
288 content_el = ET.SubElement(entry, 'content')
289 content_el.set('type', 'html')
290 content_el.text = self._format_post_content(post)
292 # Summary (truncated)
293 summary = content[:300]
294 if len(content) > 300:
295 summary += '...'
296 ET.SubElement(entry, 'summary').text = summary
298 # Author
299 author = post.get('author', {})
300 author_el = ET.SubElement(entry, 'author')
301 ET.SubElement(author_el, 'name').text = author.get('display_name') or author.get('username', 'Anonymous')
302 if author.get('id'):
303 ET.SubElement(author_el, 'uri').text = self._get_author_url(author)
305 # Dates
306 created_at = post.get('created_at')
307 updated_at = post.get('updated_at')
309 if created_at:
310 if isinstance(created_at, str):
311 ET.SubElement(entry, 'published').text = created_at
312 else:
313 ET.SubElement(entry, 'published').text = created_at.isoformat()
315 if updated_at:
316 if isinstance(updated_at, str):
317 ET.SubElement(entry, 'updated').text = updated_at
318 else:
319 ET.SubElement(entry, 'updated').text = updated_at.isoformat()
320 elif created_at:
321 ET.SubElement(entry, 'updated').text = ET.SubElement(entry, 'published').text if hasattr(entry.find('published'), 'text') else datetime.now(timezone.utc).isoformat()
323 # Categories
324 tags = post.get('tags', [])
325 for tag in tags:
326 cat = ET.SubElement(entry, 'category')
327 cat.set('term', tag)
329 return ET.tostring(feed, encoding='unicode', xml_declaration=True)
331 def generate_json_feed(self, feed_type: str = 'global', limit: int = 50,
332 user_id: int = None, community_id: int = None,
333 title: str = None) -> str:
334 """
335 Generate JSON Feed 1.1.
337 Args:
338 feed_type: Type of feed
339 limit: Maximum number of items
340 user_id: User ID for personalized feeds
341 community_id: Community ID for community feeds
342 title: Custom feed title
344 Returns:
345 JSON Feed string
346 """
347 posts = self._get_posts(feed_type, limit, user_id, community_id)
349 feed_title = title or f"{FEED_CONFIG['title']} - {feed_type.title()} Feed"
351 json_feed = {
352 'version': 'https://jsonfeed.org/version/1.1',
353 'title': feed_title,
354 'home_page_url': self.base_url,
355 'feed_url': f"{self.base_url}/api/social/feeds/json?type={feed_type}",
356 'description': FEED_CONFIG['description'],
357 'language': FEED_CONFIG['language'],
358 'items': []
359 }
361 for post in posts:
362 author = post.get('author', {})
364 item = {
365 'id': str(post.get('id', '')),
366 'url': self._get_post_url(post),
367 'content_html': self._format_post_content(post),
368 'content_text': post.get('content', ''),
369 }
371 # Title
372 content = post.get('content', 'Untitled')
373 title_text = content.split('\n')[0][:100]
374 if len(content) > 100:
375 title_text += '...'
376 item['title'] = title_text
378 # Summary
379 summary = content[:300]
380 if len(content) > 300:
381 summary += '...'
382 item['summary'] = summary
384 # Author
385 item['authors'] = [{
386 'name': author.get('display_name') or author.get('username', 'Anonymous'),
387 'url': self._get_author_url(author) if author.get('id') else None
388 }]
390 # Dates
391 if post.get('created_at'):
392 created = post['created_at']
393 if isinstance(created, str):
394 item['date_published'] = created
395 else:
396 item['date_published'] = created.isoformat()
398 if post.get('updated_at'):
399 updated = post['updated_at']
400 if isinstance(updated, str):
401 item['date_modified'] = updated
402 else:
403 item['date_modified'] = updated.isoformat()
405 # Tags
406 item['tags'] = post.get('tags', [])
408 # Attachments (media)
409 media = post.get('media_urls', [])
410 if media:
411 item['attachments'] = []
412 for url in media:
413 mime = 'image/jpeg'
414 if '.png' in url.lower():
415 mime = 'image/png'
416 elif '.gif' in url.lower():
417 mime = 'image/gif'
418 elif '.webp' in url.lower():
419 mime = 'image/webp'
420 elif '.mp4' in url.lower():
421 mime = 'video/mp4'
423 item['attachments'].append({
424 'url': url,
425 'mime_type': mime
426 })
428 json_feed['items'].append(item)
430 return json.dumps(json_feed, indent=2, default=str)
433def get_user_feed_rss(db, user_id: int, limit: int = 50) -> str:
434 """Generate RSS feed for a specific user's posts."""
435 generator = FeedGenerator(db)
437 try:
438 from .models import Post, User
439 user = db.query(User).filter(User.id == user_id).first()
440 if not user:
441 return generator.generate_rss(feed_type='global', limit=0)
443 title = f"{user.display_name or user.username}'s Posts - Hevolve"
444 # Get user's posts directly
445 posts = db.query(Post).filter(
446 Post.author_id == user_id,
447 Post.deleted_at.is_(None)
448 ).order_by(Post.created_at.desc()).limit(limit).all()
450 # Temporarily override _get_posts
451 original_get_posts = generator._get_posts
452 generator._get_posts = lambda *args, **kwargs: [p.to_dict() for p in posts]
454 result = generator.generate_rss(feed_type='user', title=title)
455 generator._get_posts = original_get_posts
456 return result
458 except Exception as e:
459 logger.error(f"Error generating user feed: {e}")
460 return generator.generate_rss(feed_type='global', limit=0)
463def get_community_feed_rss(db, community_id: int, limit: int = 50) -> str:
464 """Generate RSS feed for a specific community."""
465 generator = FeedGenerator(db)
467 try:
468 from .models import Community
469 community = db.query(Community).filter(Community.id == community_id).first()
470 if not community:
471 return generator.generate_rss(feed_type='global', limit=0)
473 title = f"s/{community.name} - Hevolve"
474 return generator.generate_rss(
475 feed_type='community',
476 community_id=community_id,
477 limit=limit,
478 title=title
479 )
481 except Exception as e:
482 logger.error(f"Error generating community feed: {e}")
483 return generator.generate_rss(feed_type='global', limit=0)