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.
- django_rss_filter-0.1.0.dist-info/METADATA +69 -0
- django_rss_filter-0.1.0.dist-info/RECORD +13 -0
- django_rss_filter-0.1.0.dist-info/WHEEL +4 -0
- django_rss_filter-0.1.0.dist-info/licenses/LICENSE +21 -0
- rssfilter/__init__.py +0 -0
- rssfilter/admin.py +23 -0
- rssfilter/migrations/0001_initial.py +36 -0
- rssfilter/migrations/__init__.py +0 -0
- rssfilter/models.py +67 -0
- rssfilter/settings.py +3 -0
- rssfilter/urls.py +7 -0
- rssfilter/utils.py +46 -0
- rssfilter/views.py +9 -0
|
@@ -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,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
rssfilter/urls.py
ADDED
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")
|