redturtle.rssservice 2.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ # -*- coding: utf-8 -*-
2
+ from DateTime import DateTime
3
+ from DateTime.interfaces import SyntaxError
4
+ from os import environ
5
+ from plone.dexterity.utils import iterSchemata
6
+ from plone.restapi.serializer.converters import json_compatible
7
+ from plone.restapi.serializer.utils import uid_to_url
8
+ from plone.restapi.services import Service
9
+ from redturtle.rssservice import _
10
+ from redturtle.rssservice.interfaces import IRSSMixerFeed
11
+ from requests.exceptions import RequestException
12
+ from requests.exceptions import Timeout
13
+ from time import time
14
+ from zExceptions import BadRequest
15
+ from zExceptions import NotFound
16
+ from zope.i18n import translate
17
+ from zope.interface import implementer
18
+ from zope.schema import getFields
19
+ from App.config import getConfiguration
20
+ import feedparser
21
+ import json
22
+ import logging
23
+ import requests
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # Accept these bozo_exceptions encountered by feedparser when parsing
29
+ # the feed:
30
+ ACCEPTED_FEEDPARSER_EXCEPTIONS = (feedparser.CharacterEncodingOverride,)
31
+
32
+ # store the feeds here (which means in RAM)
33
+ FEED_DATA = {} # url: ({date, title, url, itemlist})
34
+
35
+ REQUESTS_TIMEOUT = int(environ.get("RSS_SERVICE_TIMEOUT", "5")) or 5
36
+ REQUESTS_USER_AGENT = environ.get("RSS_USER_AGENT")
37
+ RSSMIXER_HTTP_PROXY = environ.get("RSSMIXER_PROXY", "")
38
+
39
+ DEBUGMODE = getConfiguration().debug_mode
40
+
41
+
42
+ class RSSMixerService(Service):
43
+ """ """
44
+
45
+ def reply(self):
46
+ feed_config = self.get_feed_config()
47
+ limit = feed_config.get("limit", 20)
48
+ feeds = feed_config.get("feeds", [])
49
+ if not feeds:
50
+ raise BadRequest(
51
+ translate(
52
+ _(
53
+ "missing_feeds_parameter",
54
+ default="Missing required parameter: feeds",
55
+ ),
56
+ context=self.request,
57
+ )
58
+ )
59
+ return self._getFeeds(feeds=feeds, limit=limit)
60
+
61
+ def get_feed_config(self):
62
+ """ """
63
+ query = self.request.form
64
+ if DEBUGMODE and query.get("rss_debug_uri"):
65
+ return {"feeds": [{"url": query.get("rss_debug_uri")}]}
66
+
67
+ block_id = query.get("block", "")
68
+ if not block_id:
69
+ raise BadRequest(
70
+ translate(
71
+ _(
72
+ "missing_block_id",
73
+ default="Missing required parameter: block",
74
+ ),
75
+ context=self.request,
76
+ )
77
+ )
78
+
79
+ block_data = self.get_block_data(block_id=block_id)
80
+ if not block_data:
81
+ raise NotFound(
82
+ translate(
83
+ _(
84
+ "block_not_found",
85
+ default='Block with id "{}" not found in this context.'.format(
86
+ block_id
87
+ ),
88
+ ),
89
+ context=self.request,
90
+ )
91
+ )
92
+ if block_data.get("@type", "") != "rssBlock":
93
+ raise BadRequest(
94
+ translate(
95
+ _(
96
+ "wrong_block_type",
97
+ default='Block with id "{}" is not an RSS block.'.format(
98
+ block_id
99
+ ),
100
+ ),
101
+ context=self.request,
102
+ )
103
+ )
104
+ return block_data
105
+
106
+ def get_block_data(self, block_id):
107
+ blocks = getattr(self.context, "blocks", {})
108
+ if not blocks:
109
+ return {}
110
+ if not isinstance(blocks, dict):
111
+ # plone < 6 support
112
+ blocks = json.loads(blocks)
113
+ rss_block = blocks.get(block_id, {})
114
+ if not rss_block:
115
+ # maybe is in some Block field
116
+ for schema in iterSchemata(self.context):
117
+ for name, field in getFields(schema).items():
118
+ value = field.get(self.context)
119
+ if not value:
120
+ continue
121
+ if not isinstance(value, dict):
122
+ continue
123
+ rss_block = value.get("blocks", {}).get(block_id, {})
124
+ return rss_block
125
+
126
+ def _getFeeds(self, feeds, limit=20):
127
+ """Return all feeds"""
128
+ data = []
129
+ for feed_data in feeds:
130
+ url = feed_data.get("url", "")
131
+ source = feed_data.get("source", "")
132
+ feed = FEED_DATA.get(url, None)
133
+ if feed is None:
134
+ # create it
135
+ feed = FEED_DATA[url] = RSSMixerFeed(
136
+ url=url,
137
+ source=source,
138
+ timeout=100,
139
+ )
140
+ # if it's new, populate it, else try to see if it need to be updated
141
+ else:
142
+ # check if we need to update the source
143
+ if feed.source != source:
144
+ feed.source = source
145
+ feed.update()
146
+ data.append(feed)
147
+ return self._sortedFeeds(feeds=data, limit=limit)
148
+
149
+ def _sortedFeeds(self, feeds, limit):
150
+ """Sort feed items by date"""
151
+
152
+ itemsWithDate = []
153
+ itemsWithoutDate = []
154
+ for feed in feeds:
155
+ for item in feed.items:
156
+ if "date" in item:
157
+ itemsWithDate.append(item)
158
+ else:
159
+ itemsWithoutDate.append(item)
160
+ sortedItems = sorted(itemsWithDate, key=lambda d: d["date"], reverse=True)
161
+ total = sortedItems + itemsWithoutDate
162
+
163
+ # fix date format
164
+ return total[:limit]
165
+
166
+
167
+ @implementer(IRSSMixerFeed)
168
+ class RSSMixerFeed(object):
169
+ """An RSS feed."""
170
+
171
+ FAILURE_DELAY = 10
172
+
173
+ def __init__(self, url, source, timeout):
174
+ self.url = url
175
+ self.timeout = timeout
176
+ self.source = source
177
+ self._items = []
178
+ self._title = ""
179
+ self._siteurl = ""
180
+ self._loaded = False # is the feed loaded
181
+ self._failed = False # does it fail at the last update?
182
+ self._last_update_time_in_minutes = 0 # when was the feed updated?
183
+ self._last_update_time = None # time as DateTime or Nonw
184
+
185
+ @property
186
+ def last_update_time_in_minutes(self):
187
+ """Return the time the last update was done in minutes."""
188
+ return self._last_update_time_in_minutes
189
+
190
+ @property
191
+ def last_update_time(self):
192
+ """Return the time the last update was done in minutes."""
193
+ return self._last_update_time
194
+
195
+ @property
196
+ def update_failed(self):
197
+ return self._failed
198
+
199
+ @property
200
+ def ok(self):
201
+ return not self._failed and self._loaded
202
+
203
+ @property
204
+ def loaded(self):
205
+ """Return whether this feed is loaded or not."""
206
+ return self._loaded
207
+
208
+ @property
209
+ def needs_update(self):
210
+ """Check if this feed needs updating."""
211
+ now = time() / 6
212
+ return (self.last_update_time_in_minutes + self.timeout) < now
213
+
214
+ def update(self):
215
+ """Update this feed."""
216
+ now = time() / 60 # time in minutes
217
+ # check for failure and retry
218
+ if self.update_failed:
219
+ if (self.last_update_time_in_minutes + self.FAILURE_DELAY) < now:
220
+ return self._retrieveFeed()
221
+ else:
222
+ return False
223
+
224
+ # check for regular update
225
+ if self.needs_update:
226
+ return self._retrieveFeed()
227
+
228
+ return self.ok
229
+
230
+ def _getFeedFromUrl(self, url):
231
+ """
232
+ Use urllib to retrieve an rss feed.
233
+ In this way, we can manage timeouts.
234
+ """
235
+ url = uid_to_url(url)
236
+ headers = {}
237
+ if REQUESTS_USER_AGENT:
238
+ headers["User-Agent"] = REQUESTS_USER_AGENT
239
+ try:
240
+ if RSSMIXER_HTTP_PROXY:
241
+ url = f"{RSSMIXER_HTTP_PROXY}/{url}"
242
+ response = requests.get(
243
+ url,
244
+ headers=headers,
245
+ timeout=REQUESTS_TIMEOUT,
246
+ )
247
+ except (Timeout, RequestException) as e:
248
+ logger.warning("exception %s during %s request", e, url)
249
+ return None
250
+ if response.status_code != 200:
251
+ message = response.text or response.reason
252
+ logger.error(
253
+ "Unable to retrieve feed from {url}: {message}".format(
254
+ url=url, message=message
255
+ )
256
+ )
257
+ return None
258
+ return feedparser.parse(response.content)
259
+
260
+ def _retrieveFeed(self):
261
+ """Do the actual work and try to retrieve the feed."""
262
+ url = self.url
263
+ if not url:
264
+ self._loaded = True
265
+ self._failed = True # no url set means failed
266
+ # no url set, although that actually should not really happen
267
+ return False
268
+ self._last_update_time_in_minutes = time() / 60
269
+ self._last_update_time = DateTime()
270
+ parsed_feed = self._getFeedFromUrl(url)
271
+ if not parsed_feed:
272
+ self._loaded = True # we tried at least but have a failed load
273
+ self._failed = True
274
+ return False
275
+ if parsed_feed.bozo == 1 and not isinstance(
276
+ parsed_feed.get("bozo_exception"),
277
+ ACCEPTED_FEEDPARSER_EXCEPTIONS,
278
+ ):
279
+ self._loaded = True # we tried at least but have a failed load
280
+ self._failed = True
281
+ return False
282
+ self._title = parsed_feed.feed.title
283
+ self._siteurl = parsed_feed.feed.link
284
+ self._items = []
285
+
286
+ for item in parsed_feed["items"]:
287
+ itemdict = {
288
+ "title": item.title,
289
+ "url": item.get("link", ""),
290
+ "contentSnippet": item.get("description", ""),
291
+ "source": getattr(self, "source", ""),
292
+ }
293
+
294
+ date = self.get_item_date(item=item)
295
+ if date:
296
+ itemdict["date"] = date
297
+
298
+ image = self.get_item_image(item=item)
299
+ if image:
300
+ # format needed in blocks to keep compatibility
301
+ itemdict["enclosure"] = image
302
+
303
+ categories = self.get_item_categories(item=item)
304
+ if categories:
305
+ itemdict["categories"] = categories
306
+
307
+ self._items.append(itemdict)
308
+ self._loaded = True
309
+ self._failed = False
310
+ return True
311
+
312
+ def get_item_categories(self, item):
313
+ categories = []
314
+ if getattr(item, "tags", None):
315
+ for tag in item["tags"]:
316
+ term = getattr(tag, "term", None)
317
+ if term:
318
+ categories.append(term)
319
+ return categories
320
+
321
+ def get_item_date(self, item):
322
+ if getattr(item, "updated", None):
323
+ try:
324
+ return json_compatible(DateTime(item.updated))
325
+ except SyntaxError:
326
+ return json_compatible(item.updated)
327
+ elif getattr(item, "published", None):
328
+ try:
329
+ return json_compatible(DateTime(item.published))
330
+ except SyntaxError:
331
+ return json_compatible(item.published)
332
+
333
+ return ""
334
+
335
+ def get_item_image(self, item):
336
+ image = ""
337
+ if item.get("media_thumbnail", []):
338
+ image = item["media_thumbnail"][0].get("url", "")
339
+ elif item.get("media_content", []):
340
+ images = [
341
+ x.get("url", "")
342
+ for x in item.media_content
343
+ if x.get("medium", "") == "image"
344
+ ]
345
+ if images:
346
+ image = images[0]
347
+ elif item.get("links", []):
348
+ images = [
349
+ x.get("url", "")
350
+ for x in item.links
351
+ if x.get("rel", "") == "enclosure" and "image" in x.get("type", "")
352
+ ]
353
+ if images:
354
+ image = images[0]
355
+ if not image:
356
+ return {}
357
+ return {"url": image}
358
+
359
+ @property
360
+ def items(self):
361
+ return self._items
362
+
363
+ # convenience methods for displaying
364
+
365
+ @property
366
+ def feed_link(self):
367
+ """Return rss url of feed for tile."""
368
+ return self.url.replace("http://", "feed://")
369
+
370
+ @property
371
+ def title(self):
372
+ """Return title of feed for tile."""
373
+ return self._title
374
+
375
+ @property
376
+ def siteurl(self):
377
+ """Return the link to the site the RSS feed points to."""
378
+ return self._siteurl
@@ -0,0 +1,78 @@
1
+ # -*- coding: utf-8 -*-
2
+ from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE
3
+ from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE
4
+ from plone.app.testing import applyProfile
5
+ from plone.app.testing import FunctionalTesting
6
+ from plone.app.testing import IntegrationTesting
7
+ from plone.app.testing import PloneSandboxLayer
8
+ from plone.restapi.testing import PloneRestApiDXLayer
9
+ from plone.testing import z2
10
+
11
+ import plone.restapi
12
+ import redturtle.rssservice
13
+
14
+
15
+ class RedTurtleRSSServiceLayer(PloneSandboxLayer):
16
+ defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,)
17
+
18
+ def setUpZope(self, app, configurationContext):
19
+ # Load any other ZCML that is required for your tests.
20
+ # The z3c.autoinclude feature is disabled in the Plone fixture base
21
+ # layer.
22
+ self.loadZCML(package=plone.restapi)
23
+ self.loadZCML(package=redturtle.rssservice)
24
+
25
+ def setUpPloneSite(self, portal):
26
+ applyProfile(portal, "plone.restapi:default")
27
+
28
+
29
+ REDTURTLE_RSSSERVICE_FIXTURE = RedTurtleRSSServiceLayer()
30
+
31
+
32
+ REDTURTLE_RSSSERVICE_INTEGRATION_TESTING = IntegrationTesting(
33
+ bases=(REDTURTLE_RSSSERVICE_FIXTURE,),
34
+ name="RedTurtleRSSServiceLayer:IntegrationTesting",
35
+ )
36
+
37
+
38
+ REDTURTLE_RSSSERVICE_FUNCTIONAL_TESTING = FunctionalTesting(
39
+ bases=(REDTURTLE_RSSSERVICE_FIXTURE,),
40
+ name="RedTurtleRSSServiceLayer:FunctionalTesting",
41
+ )
42
+
43
+
44
+ REDTURTLE_RSSSERVICE_ACCEPTANCE_TESTING = FunctionalTesting(
45
+ bases=(
46
+ REDTURTLE_RSSSERVICE_FIXTURE,
47
+ REMOTE_LIBRARY_BUNDLE_FIXTURE,
48
+ z2.ZSERVER_FIXTURE,
49
+ ),
50
+ name="RedTurtleRSSServiceLayer:AcceptanceTesting",
51
+ )
52
+
53
+
54
+ class RedTurtleRSSServiceRestApiLayer(PloneRestApiDXLayer):
55
+ defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,)
56
+
57
+ def setUpZope(self, app, configurationContext):
58
+ super(RedTurtleRSSServiceRestApiLayer, self).setUpZope(
59
+ app, configurationContext
60
+ )
61
+
62
+ self.loadZCML(package=plone.restapi)
63
+ self.loadZCML(package=redturtle.rssservice)
64
+
65
+ def setUpPloneSite(self, portal):
66
+ applyProfile(portal, "plone.restapi:default")
67
+
68
+
69
+ REDTURTLE_RSSSERVICE_API_FIXTURE = RedTurtleRSSServiceRestApiLayer()
70
+ REDTURTLE_RSSSERVICE_API_INTEGRATION_TESTING = IntegrationTesting(
71
+ bases=(REDTURTLE_RSSSERVICE_API_FIXTURE,),
72
+ name="RedTurtleRSSServiceRestApiLayer:Integration",
73
+ )
74
+
75
+ REDTURTLE_RSSSERVICE_API_FUNCTIONAL_TESTING = FunctionalTesting(
76
+ bases=(REDTURTLE_RSSSERVICE_API_FIXTURE, z2.ZSERVER_FIXTURE),
77
+ name="RedTurtleRSSServiceRestApiLayer:Functional",
78
+ )
File without changes
@@ -0,0 +1,91 @@
1
+ # -*- coding: utf-8 -*-
2
+ import importlib
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ import unittest
7
+ from unittest import mock
8
+
9
+ main = importlib.import_module("redturtle.rssservice.proxycacheserver.main")
10
+
11
+
12
+ class ProxyCacheServerTest(unittest.TestCase):
13
+ def setUp(self):
14
+ self.test_dir = tempfile.mkdtemp()
15
+ main.ACTIVE_REFRESH_THREADS.clear()
16
+ main.LAST_ACCESS_TIMES.clear()
17
+
18
+ def tearDown(self):
19
+ shutil.rmtree(self.test_dir, ignore_errors=True)
20
+ main.ACTIVE_REFRESH_THREADS.clear()
21
+ main.LAST_ACCESS_TIMES.clear()
22
+
23
+ def test_cache_path(self):
24
+ url = "https://example.com/rss.xml"
25
+ path = main.cache_path(url, self.test_dir)
26
+ self.assertTrue(path.startswith(self.test_dir))
27
+ self.assertTrue(path.endswith(".json"))
28
+
29
+ def test_safe_atomic_write_and_load_json(self):
30
+ file_path = os.path.join(self.test_dir, "test.json")
31
+ data = {"url": "https://example.com/rss", "status_code": 200, "body": "OK"}
32
+ main.safe_atomic_write_json(file_path, data)
33
+
34
+ loaded = main.load_json(file_path)
35
+ self.assertEqual(loaded, data)
36
+ # Ensure no temporary file left
37
+ files = os.listdir(self.test_dir)
38
+ self.assertEqual(files, ["test.json"])
39
+
40
+ def test_is_valid_url(self):
41
+ self.assertTrue(main.is_valid_url("https://example.com/feed"))
42
+ self.assertTrue(main.is_valid_url("http://news.google.com/rss"))
43
+ self.assertFalse(main.is_valid_url("ftp://example.com"))
44
+ self.assertFalse(main.is_valid_url("http://localhost:8080"))
45
+ self.assertFalse(main.is_valid_url("http://127.0.0.1/admin"))
46
+ self.assertFalse(main.is_valid_url("http://169.254.169.254/latest/meta-data/"))
47
+
48
+ def test_ensure_refresh_thread_deduplication(self):
49
+ url = "https://example.com/feed"
50
+ with mock.patch("threading.Thread") as mock_thread_cls:
51
+ mock_thread_instance = mock.MagicMock()
52
+ mock_thread_cls.return_value = mock_thread_instance
53
+
54
+ main.ensure_refresh_thread(url, self.test_dir, ttl=60)
55
+ self.assertIn(url, main.ACTIVE_REFRESH_THREADS)
56
+ self.assertEqual(mock_thread_cls.call_count, 1)
57
+
58
+ # Second call for the same URL should be deduplicated
59
+ main.ensure_refresh_thread(url, self.test_dir, ttl=60)
60
+ self.assertEqual(mock_thread_cls.call_count, 1)
61
+
62
+ @mock.patch("requests.get")
63
+ def test_fetch_and_cache_success(self, mock_requests_get):
64
+ mock_response = mock.MagicMock()
65
+ mock_response.status_code = 200
66
+ mock_response.headers = {"Content-Type": "application/rss+xml"}
67
+ mock_response.text = "<rss><channel><title>Test Feed</title></channel></rss>"
68
+ mock_requests_get.return_value = mock_response
69
+
70
+ url = "https://example.com/rss"
71
+ result = main.fetch_and_cache(url, self.test_dir)
72
+
73
+ self.assertEqual(result["status_code"], 200)
74
+ self.assertEqual(result["body"], mock_response.text)
75
+
76
+ # Check cached file
77
+ cache_file = main.cache_path(url, self.test_dir)
78
+ self.assertTrue(os.path.exists(cache_file))
79
+ loaded = main.load_json(cache_file)
80
+ self.assertEqual(loaded["status_code"], 200)
81
+
82
+ @mock.patch("requests.get")
83
+ def test_fetch_and_cache_connection_error_not_persisted(self, mock_requests_get):
84
+ mock_requests_get.side_effect = Exception("Connection refused")
85
+
86
+ url = "https://example.com/failing_rss"
87
+ result = main.fetch_and_cache(url, self.test_dir)
88
+
89
+ self.assertEqual(result["status_code"], 502)
90
+ cache_file = main.cache_path(url, self.test_dir)
91
+ self.assertFalse(os.path.exists(cache_file))