django-rss-filter 0.1.0__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,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-rss-filter
3
+ Version: 0.1.0
4
+ Summary: Filter public RSS feeds, remove articles that contain certain keywords or categories.
5
+ Project-URL: Homepage, https://github.com/loopwerk/django-rss-filter/issues
6
+ Project-URL: Repository, https://github.com/loopwerk/django-rss-filter.git
7
+ Project-URL: Issues, https://github.com/loopwerk/django-rss-filter/issues
8
+ Author-email: Kevin Renskers <kevin@loopwerk.io>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: atom,django,feed,filter,mute,rss
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: django>=3.2.0
14
+ Requires-Dist: fastfeedparser>=0.3.7
15
+ Requires-Dist: feedgen>=1.0.0
16
+ Requires-Dist: httpx>=0.28.1
17
+ Description-Content-Type: text/markdown
18
+
19
+ # django-rss-filter
20
+
21
+ This is a Django app that creates filtered RSS feeds. It does this by filtering exsiting RSS feeds, removing articles that match filtered words and/or filtered categories. It's what powers https://www.rssfilter.com.
22
+
23
+ It comes with one view that returns the filtered feed XML, as well as Django Admin configuration to make it possible and easy to self-host your own instance of django-rss-filter. It does not come with views to create or edit filtered feeds; this can be done using the Django Admin.
24
+
25
+ ## Getting started
26
+
27
+ All instructions use [uv](https://docs.astral.sh/uv/), but should work just as well with pip or Poetry for example.
28
+
29
+ In an existing Django project:
30
+
31
+ 1. Install: `uv add django-rss-filter`
32
+ 2. Add `rssfilter` to `INSTALLED_APPS`
33
+ 3. `uv run ./manage.py migrate`
34
+ 4. Include the URL config: `path("", include("rssfilter.urls"))`
35
+
36
+ If you don't have a Django project yet, these are the steps to get started:
37
+
38
+ 1. `uv init my-django-project`
39
+ 2. `cd my-django-project`
40
+ 3. `uv add django`
41
+ 4. `uv run django-admin startproject my_django_app .`
42
+ 5. `uv run ./manage.py migrate`
43
+ 6. `uv run ./manage.py createsuperuser`
44
+ 7. `uv run ./manage.py runserver`
45
+
46
+ This will get you up and running with a working Admin site at http://127.0.0.1:8000/admin/.
47
+
48
+ Then follow the first four steps to add django-rss-filter to your Django project.
49
+
50
+ ## Example project
51
+
52
+ There's an example Django project using django-rss-filter under `example/`.
53
+
54
+ 1. `cd example`
55
+ 2. `uv run ./manage.py migrate`
56
+ 3. `uv run ./manage.py createsuperuser`
57
+ 4. `uv run ./manage.py runserver`
58
+
59
+ You can then go to http://127.0.0.1:8000/admin/ to create a feed, which you can then access at http://127.0.0.1:8000/[feed_uuid]/.
60
+
61
+ ## Settings
62
+
63
+ There is one setting that can be configured: `RSS_FILTER_CACHE_SECONDS`. The default value is 300 (5 minutes).
64
+
65
+ This setting controls how long a feed will be cached before it's fetched, filtered, and stored again.
66
+
67
+ ## Tests
68
+
69
+ Unit tests can be run with `uv run pytest`.
@@ -0,0 +1,13 @@
1
+ rssfilter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ rssfilter/admin.py,sha256=BBDSbwHkBGI_mb2_kmD9FahvUtIMoeibTUMAGdmLL44,718
3
+ rssfilter/models.py,sha256=YWuks5d-8lyUBtBoyieVCAqiJpuRqoIK5vKgFN-TAJg,2369
4
+ rssfilter/settings.py,sha256=TfwnqH2_JKROr4y2bBXxomNUkjTVunq1hnBptk463ts,115
5
+ rssfilter/urls.py,sha256=WFDTeS3Mb-eoKTT6VrHMsYnsC1v0vKHqnBCk_zWdX5M,143
6
+ rssfilter/utils.py,sha256=qbQ05h7RN6tGSbO9HhohWb_ACAyeSA4fJJKDA6TNAC4,1673
7
+ rssfilter/views.py,sha256=M_7unpE-7Kv3K6GqjAPmPP2sT72SXO5xEMNrrVmsGEg,301
8
+ rssfilter/migrations/0001_initial.py,sha256=hJ5a15sFc-ucHQnYvLssxBDoKaDB0FPS4Efr4fHpUOI,1369
9
+ rssfilter/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ django_rss_filter-0.1.0.dist-info/METADATA,sha256=b_huJhluOTK4Ox5fN1YhHoN2TIkzKyX7nRvKVc5t8MQ,2698
11
+ django_rss_filter-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ django_rss_filter-0.1.0.dist-info/licenses/LICENSE,sha256=k0sRaq6OW62Sd_eEaMGgQAGJd7iscowsUr-Zfaqkilk,1060
13
+ django_rss_filter-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Loopwerk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
rssfilter/__init__.py ADDED
File without changes
rssfilter/admin.py ADDED
@@ -0,0 +1,23 @@
1
+ from django.contrib import admin
2
+ from django.utils.safestring import mark_safe
3
+
4
+ from .models import FilteredFeed
5
+
6
+
7
+ @admin.register(FilteredFeed)
8
+ class FilteredFeedAdmin(admin.ModelAdmin):
9
+ list_display = ["id", "created", "feed_url", "filtered_words", "filtered_categories", "view"]
10
+ ordering = ["-id"]
11
+
12
+ def view(self, obj):
13
+ return mark_safe(f'<a href="{obj.get_absolute_url()}">Open</a>')
14
+
15
+ def get_form(self, request, obj=None, **kwargs):
16
+ if obj is None:
17
+ kwargs["exclude"] = ["uuid"]
18
+ return super().get_form(request, obj, **kwargs)
19
+
20
+ def get_readonly_fields(self, request, obj=None):
21
+ if obj:
22
+ return ["uuid"]
23
+ return self.readonly_fields
@@ -0,0 +1,36 @@
1
+ # Generated by Django 5.1.7 on 2025-03-29 13:44
2
+
3
+ import uuid
4
+
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ initial = True
10
+
11
+ dependencies = []
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name="FeedCache",
16
+ fields=[
17
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
18
+ ("feed_url", models.URLField(db_index=True, unique=True)),
19
+ ("cache_date", models.DateTimeField(null=True)),
20
+ ("feed_body", models.TextField()),
21
+ ],
22
+ ),
23
+ migrations.CreateModel(
24
+ name="FilteredFeed",
25
+ fields=[
26
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
27
+ ("uuid", models.UUIDField(default=uuid.uuid4)),
28
+ ("feed_url", models.URLField()),
29
+ ("filtered_words", models.CharField(blank=True, max_length=1024)),
30
+ ("filtered_categories", models.CharField(blank=True, max_length=1024)),
31
+ ("created", models.DateTimeField(auto_now_add=True)),
32
+ ("cache_date", models.DateTimeField(editable=False, null=True)),
33
+ ("filtered_feed_body", models.TextField(editable=False)),
34
+ ],
35
+ ),
36
+ ]
File without changes
rssfilter/models.py ADDED
@@ -0,0 +1,67 @@
1
+ import uuid
2
+ from datetime import timedelta
3
+
4
+ import httpx
5
+ from django.core.exceptions import ValidationError
6
+ from django.db import models
7
+ from django.urls import reverse
8
+ from django.utils import timezone
9
+
10
+ from .settings import RSS_FILTER_CACHE_SECONDS
11
+ from .utils import filter_feed, validate_feed
12
+
13
+
14
+ class FilteredFeed(models.Model):
15
+ uuid = models.UUIDField(default=uuid.uuid4)
16
+ feed_url = models.URLField()
17
+ filtered_words = models.CharField(max_length=1024, blank=True)
18
+ filtered_categories = models.CharField(max_length=1024, blank=True)
19
+ created = models.DateTimeField(auto_now_add=True)
20
+ cache_date = models.DateTimeField(null=True, editable=False)
21
+ filtered_feed_body = models.TextField(editable=False)
22
+
23
+ def __str__(self):
24
+ return f"Feed {self.uuid}"
25
+
26
+ def clean(self):
27
+ super().clean()
28
+
29
+ # Make sure we have a valid feed.
30
+ # Let's assume it's valid if it's already in FeedCache.
31
+ if not FeedCache.objects.filter(feed_url=self.feed_url).exists():
32
+ if not validate_feed(self.feed_url):
33
+ raise ValidationError({"feed_url": "This doesn't seem to be a valid RSS or Atom feed"})
34
+
35
+ def get_filtered_feed_body(self) -> str:
36
+ five_mins_ago = timezone.now() - timedelta(seconds=RSS_FILTER_CACHE_SECONDS)
37
+ if self.cache_date and self.cache_date > five_mins_ago:
38
+ return self.filtered_feed_body
39
+
40
+ feed_cache, _created = FeedCache.objects.get_or_create(feed_url=self.feed_url)
41
+
42
+ self.filtered_feed_body = filter_feed(feed_cache.get_feed_body(), self.filtered_words, self.filtered_categories)
43
+ self.cache_date = timezone.now()
44
+ self.save()
45
+
46
+ return self.filtered_feed_body
47
+
48
+ def get_absolute_url(self):
49
+ return reverse("rss-filter-feed", args=[self.uuid])
50
+
51
+
52
+ class FeedCache(models.Model):
53
+ feed_url = models.URLField(unique=True, db_index=True)
54
+ cache_date = models.DateTimeField(null=True)
55
+ feed_body = models.TextField()
56
+
57
+ def get_feed_body(self) -> str:
58
+ five_mins_ago = timezone.now() - timedelta(seconds=RSS_FILTER_CACHE_SECONDS)
59
+ if self.cache_date and self.cache_date > five_mins_ago:
60
+ return self.feed_body
61
+
62
+ r = httpx.get(self.feed_url)
63
+ self.feed_body = r.text
64
+ self.cache_date = timezone.now()
65
+ self.save()
66
+
67
+ return self.feed_body
rssfilter/settings.py ADDED
@@ -0,0 +1,3 @@
1
+ from django.conf import settings
2
+
3
+ RSS_FILTER_CACHE_SECONDS = getattr(settings, "RSS_FILTER_CACHE_SECONDS", 5 * 60)
rssfilter/urls.py ADDED
@@ -0,0 +1,7 @@
1
+ from django.urls import path
2
+
3
+ from .views import feed_view
4
+
5
+ urlpatterns = [
6
+ path("<uuid:feed_uuid>", feed_view, name="rss-filter-feed"),
7
+ ]
rssfilter/utils.py ADDED
@@ -0,0 +1,46 @@
1
+ import fastfeedparser
2
+ from feedgen.feed import FeedGenerator
3
+
4
+
5
+ def validate_feed(feed_url: str) -> bool:
6
+ try:
7
+ fastfeedparser.parse(feed_url)
8
+ return True
9
+ except ValueError:
10
+ return False
11
+
12
+
13
+ def filter_feed(feed_body: str, filtered_words: str, filtered_categories: str) -> str:
14
+ feed = fastfeedparser.parse(feed_body)
15
+
16
+ fg = FeedGenerator()
17
+ fg.id(feed.feed.link)
18
+ fg.title(feed.feed.title)
19
+ fg.link(href=feed.feed.link)
20
+ fg.description(feed.feed.get("description", "Filtered feed"))
21
+ fg.language(feed.feed.get("language", "en"))
22
+ fg.updated(feed.feed.updated)
23
+
24
+ filtered_words_list = [item.strip().lower() for item in filtered_words.split(",") if item.strip()]
25
+ filtered_categories_list = [item.strip().lower() for item in filtered_categories.split(",") if item.strip()]
26
+
27
+ for entry in feed.entries:
28
+ # Check if the title contains filtered words
29
+ if len(filtered_words_list):
30
+ if any(word in entry.title.lower() for word in filtered_words_list):
31
+ continue
32
+
33
+ # Check if the categories/terms contain filtered categories
34
+ if len(filtered_categories_list) and hasattr(entry, "tags"):
35
+ terms = [tag["term"].lower() for tag in entry.tags]
36
+ if any(filtered_category in term for term in terms for filtered_category in filtered_categories_list):
37
+ continue
38
+
39
+ fe = fg.add_entry()
40
+ fe.id(entry.link)
41
+ fe.title(entry.title)
42
+ fe.link(href=entry.link)
43
+ fe.description(entry.get("description", ""))
44
+ fe.pubDate(entry.get("published", None))
45
+
46
+ return fg.atom_str(pretty=True).decode("utf-8")
rssfilter/views.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.http import HttpResponse
2
+ from django.shortcuts import get_object_or_404
3
+
4
+ from .models import FilteredFeed
5
+
6
+
7
+ def feed_view(request, feed_uuid):
8
+ feed = get_object_or_404(FilteredFeed, uuid=feed_uuid)
9
+ return HttpResponse(feed.get_filtered_feed_body(), content_type="application/xml")