Compare commits
2 commits
ac01862a17
...
f9cac0974d
| Author | SHA1 | Date | |
|---|---|---|---|
|
f9cac0974d |
|||
|
603090205a |
7 changed files with 271 additions and 82 deletions
|
|
@ -56,14 +56,18 @@ class Command(BaseCommand):
|
||||||
if count == 0:
|
if count == 0:
|
||||||
msg = f"No entries found for feed: {url}"
|
msg = f"No entries found for feed: {url}"
|
||||||
self.stdout.write(self.style.WARNING(msg))
|
self.stdout.write(self.style.WARNING(msg))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
proceed: bool = True
|
||||||
if not force:
|
if not force:
|
||||||
return self.confirm_and_list_entries(url, entries_qs, count)
|
proceed: bool = self.confirm_and_list_entries(
|
||||||
|
url=url,
|
||||||
entries_qs.delete()
|
entries_qs=entries_qs,
|
||||||
msg = f"Deleted {count} entries for feed: {url}"
|
count=count,
|
||||||
self.stdout.write(self.style.SUCCESS(msg))
|
)
|
||||||
|
if proceed:
|
||||||
|
entries_qs.delete()
|
||||||
|
msg = f"Deleted {count} entries for feed: {url}"
|
||||||
|
self.stdout.write(self.style.SUCCESS(msg))
|
||||||
|
|
||||||
new_entries: int = fetch_and_archive_feed(feed)
|
new_entries: int = fetch_and_archive_feed(feed)
|
||||||
if new_entries:
|
if new_entries:
|
||||||
|
|
@ -73,15 +77,23 @@ class Command(BaseCommand):
|
||||||
else:
|
else:
|
||||||
msg: str = "\tFeed is up to date, but no new entries were archived."
|
msg: str = "\tFeed is up to date, but no new entries were archived."
|
||||||
self.stdout.write(self.style.WARNING(msg))
|
self.stdout.write(self.style.WARNING(msg))
|
||||||
return None
|
|
||||||
|
|
||||||
def confirm_and_list_entries(
|
def confirm_and_list_entries(
|
||||||
self,
|
self,
|
||||||
url: str,
|
url: str,
|
||||||
entries_qs: QuerySet[Entry, Entry],
|
entries_qs: QuerySet[Entry, Entry],
|
||||||
count: int,
|
count: int,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Confirm with the user before deleting entries and list some of them."""
|
"""Confirm with the user before deleting entries and list some of them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url (str): The URL of the feed.
|
||||||
|
entries_qs (QuerySet[Entry, Entry]): The queryset of entries to be deleted.
|
||||||
|
count (int): The total number of entries to be deleted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user confirms, False otherwise.
|
||||||
|
"""
|
||||||
msg: str = f"The following {count} entries will be removed for feed: {url}"
|
msg: str = f"The following {count} entries will be removed for feed: {url}"
|
||||||
self.stdout.write(self.style.WARNING(msg))
|
self.stdout.write(self.style.WARNING(msg))
|
||||||
|
|
||||||
|
|
@ -94,7 +106,8 @@ class Command(BaseCommand):
|
||||||
confirm: str = input("Are you sure you want to proceed? (yes/no): ")
|
confirm: str = input("Are you sure you want to proceed? (yes/no): ")
|
||||||
if confirm.lower() != "yes":
|
if confirm.lower() != "yes":
|
||||||
self.stdout.write(self.style.ERROR("Operation cancelled."))
|
self.stdout.write(self.style.ERROR("Operation cancelled."))
|
||||||
return
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_entry_title(entry: Entry) -> str | None:
|
def get_entry_title(entry: Entry) -> str | None:
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,25 @@ if TYPE_CHECKING:
|
||||||
|
|
||||||
|
|
||||||
urlpatterns: list[URLPattern | URLResolver] = [
|
urlpatterns: list[URLPattern | URLResolver] = [
|
||||||
path("", views.home, name="home"),
|
# /
|
||||||
path("feeds/", views.feed_list, name="feed-list"),
|
path(
|
||||||
path("feeds/<int:feed_id>/", views.feed_detail, name="feed-detail"),
|
"",
|
||||||
|
views.home,
|
||||||
|
name="home",
|
||||||
|
),
|
||||||
|
# /feeds/
|
||||||
|
path(
|
||||||
|
"feeds/",
|
||||||
|
views.feed_list,
|
||||||
|
name="feeds",
|
||||||
|
),
|
||||||
|
# /feeds/<feed_id>/
|
||||||
|
path(
|
||||||
|
"feeds/<int:feed_id>/",
|
||||||
|
views.feed_detail,
|
||||||
|
name="details",
|
||||||
|
),
|
||||||
|
# /feeds/<feed_id>/entries/<entry_id>/
|
||||||
path(
|
path(
|
||||||
"feeds/<int:feed_id>/entries/<int:entry_id>/",
|
"feeds/<int:feed_id>/entries/<int:entry_id>/",
|
||||||
views.entry_detail,
|
views.entry_detail,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import html
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.db.models.query import QuerySet
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
@ -14,6 +14,7 @@ from feeds.models import Feed
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.http import HttpResponse
|
||||||
from pytest_django.asserts import QuerySet
|
from pytest_django.asserts import QuerySet
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,35 +39,16 @@ def feed_detail(request: HttpRequest, feed_id: int) -> HttpResponse:
|
||||||
HttpResponse: An HTML response containing the feed details and its entries.
|
HttpResponse: An HTML response containing the feed details and its entries.
|
||||||
"""
|
"""
|
||||||
feed: Feed = get_object_or_404(Feed, id=feed_id)
|
feed: Feed = get_object_or_404(Feed, id=feed_id)
|
||||||
|
|
||||||
entries: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed).order_by(
|
entries: QuerySet[Entry, Entry] = Entry.objects.filter(feed=feed).order_by(
|
||||||
"-published_at",
|
"-published_at",
|
||||||
"-fetched_at",
|
"-fetched_at",
|
||||||
)[:50]
|
)[:50]
|
||||||
|
|
||||||
html: list[str] = [
|
context: dict[str, Feed | QuerySet[Entry, Entry]] = {
|
||||||
"<!DOCTYPE html>",
|
"feed": feed,
|
||||||
f"<html><head><title>FeedVault - {feed.url}</title></head><body>",
|
"entries": entries,
|
||||||
"<h1>Feed Detail</h1>",
|
}
|
||||||
f"<p><b>URL:</b> {feed.url}</p>",
|
return render(request, "feeds/feed_detail.html", context)
|
||||||
f"<p><b>Domain:</b> {feed.domain}</p>",
|
|
||||||
f"<p><b>Active:</b> {'yes' if feed.is_active else 'no'}</p>",
|
|
||||||
f"<p><b>Created:</b> {feed.created_at}</p>",
|
|
||||||
f"<p><b>Last fetched:</b> {feed.last_fetched_at}</p>",
|
|
||||||
"<h2>Entries (latest 50)</h2>",
|
|
||||||
"<ul>",
|
|
||||||
]
|
|
||||||
for entry in entries:
|
|
||||||
title: str | None = entry.data.get("title") if entry.data else None
|
|
||||||
summary: str | None = entry.data.get("summary") if entry.data else None
|
|
||||||
snippet: str = title or summary or "[no title]"
|
|
||||||
html.append(
|
|
||||||
f"<li><b>{entry.published_at or entry.fetched_at}:</b> "
|
|
||||||
f'<a href="/feeds/{feed.pk}/entries/{entry.pk}/">{snippet}</a> '
|
|
||||||
f"<small>(id: {entry.entry_id})</small></li>",
|
|
||||||
)
|
|
||||||
html.extend(("</ul>", '<p><a href="/">Back to list</a></p>', "</body></html>"))
|
|
||||||
return HttpResponse("\n".join(html))
|
|
||||||
|
|
||||||
|
|
||||||
def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse:
|
def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpResponse:
|
||||||
|
|
@ -83,32 +65,19 @@ def entry_detail(request: HttpRequest, feed_id: int, entry_id: int) -> HttpRespo
|
||||||
feed: Feed = get_object_or_404(Feed, id=feed_id)
|
feed: Feed = get_object_or_404(Feed, id=feed_id)
|
||||||
entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed)
|
entry: Entry = get_object_or_404(Entry, id=entry_id, feed=feed)
|
||||||
|
|
||||||
# Render images if present in entry.data
|
# Prepare entry data for display
|
||||||
entry_data_html: str = ""
|
|
||||||
if entry.data:
|
if entry.data:
|
||||||
formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False)
|
formatted_json: str = json.dumps(entry.data, indent=2, ensure_ascii=False)
|
||||||
escaped_json: str = html.escape(formatted_json)
|
escaped_json: str | None = html.escape(formatted_json)
|
||||||
note: str = '<div style="font-size:small;color:#666;margin-bottom:4px;">Note: HTML in the JSON is escaped for display and will not be rendered as HTML.</div>'
|
|
||||||
entry_data_html: str = f"{note}<pre>{escaped_json}</pre>"
|
|
||||||
else:
|
else:
|
||||||
entry_data_html: str = "<p>[No data]</p>"
|
escaped_json: str | None = None
|
||||||
|
|
||||||
html_lines: list[str] = [
|
context = {
|
||||||
"<!DOCTYPE html>",
|
"feed": feed,
|
||||||
f"<html><head><title>FeedVault - Entry {entry.entry_id}</title></head><body>",
|
"entry": entry,
|
||||||
"<h1>Entry Detail</h1>",
|
"escaped_json": escaped_json,
|
||||||
f"<p><b>Feed:</b> <a href='/feeds/{feed.pk}/'>{feed.url}</a></p>",
|
}
|
||||||
f"<p><b>Entry ID:</b> {entry.entry_id}</p>",
|
return render(request, "feeds/entry_detail.html", context)
|
||||||
f"<p><b>Published:</b> {entry.published_at}</p>",
|
|
||||||
f"<p><b>Fetched:</b> {entry.fetched_at}</p>",
|
|
||||||
f"<p><b>Content Hash:</b> {entry.content_hash}</p>",
|
|
||||||
f"<p><b>Error Message:</b> {entry.error_message or '[none]'} </p>",
|
|
||||||
"<h2>Entry Data</h2>",
|
|
||||||
entry_data_html,
|
|
||||||
f'<p><a href="/feeds/{feed.pk}/">Back to feed</a></p>',
|
|
||||||
"</body></html>",
|
|
||||||
]
|
|
||||||
return HttpResponse("\n".join(html_lines))
|
|
||||||
|
|
||||||
|
|
||||||
def home(request: HttpRequest) -> HttpResponse:
|
def home(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,141 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="keywords" content="feeds, archive, FeedVault" />
|
{% if description %}<meta name="description" content="{{ description }}" />{% endif %}
|
||||||
<title>
|
{% if keywords %}<meta name="keywords" content="{{ keywords }}" />{% endif %}
|
||||||
{% block title %}
|
{% if author %}<meta name="author" content="{{ author }}" />{% endif %}
|
||||||
FeedVault
|
{% if canonical %}<link rel="canonical" href="{{ canonical }}" />{% endif %}
|
||||||
{% endblock title %}
|
<title>{{ title|default:"FeedVault" }}</title>
|
||||||
</title>
|
<style>
|
||||||
|
html {
|
||||||
|
max-width: 88ch;
|
||||||
|
padding: calc(1vmin + 0.5rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
font-size: clamp(1em, 0.909em + 0.45vmin, 1.25em);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leftright {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 10rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
list-style-type: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="messages" role="alert" aria-live="polite">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}
|
||||||
|
role="alert">{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
<header>
|
<header>
|
||||||
<h1>FeedVault</h1>
|
<h1>
|
||||||
<nav>
|
<a href="{% url 'home' %}" aria-label="FeedVault Home">FeedVault</a>
|
||||||
<ul>
|
</h1>
|
||||||
<li>
|
|
||||||
<a href="{% url 'home' %}">Home</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'feed-list' %}">Feeds</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
|
<div class="leftright">
|
||||||
|
<div class="left">
|
||||||
|
<small>Archive of
|
||||||
|
<a href="https://en.wikipedia.org/wiki/Web_feed"
|
||||||
|
aria-label="Wikipedia article on web feeds">web feeds</a>.
|
||||||
|
{{ stats }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<form role="search"
|
||||||
|
action="#"
|
||||||
|
method="get"
|
||||||
|
class="search"
|
||||||
|
aria-label="Search form">
|
||||||
|
<input type="search" name="q" placeholder="Search" aria-label="Search input" />
|
||||||
|
<button type="submit" aria-label="Search button">Search</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Main navigation">
|
||||||
|
<small>
|
||||||
|
<div class="leftright">
|
||||||
|
<div class="left">
|
||||||
|
<a href="{% url 'home' %}" aria-label="Home page">Home</a> |
|
||||||
|
<a href="{% url 'feeds' %}" aria-label="Feeds page">Feeds</a> |
|
||||||
|
<a href="#" aria-label="Contact page">Contact</a>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<a href="https://github.com/TheLovinator1/FeedVault"
|
||||||
|
aria-label="GitHub page">GitHub</a> |
|
||||||
|
<a href="https://github.com/sponsors/TheLovinator1"
|
||||||
|
aria-label="Donate page">Donate</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</small>
|
||||||
|
</nav>
|
||||||
|
<hr />
|
||||||
<main>
|
<main>
|
||||||
{% block content %}
|
{% block content %}<!-- default content -->{% endblock %}
|
||||||
{% endblock content %}
|
|
||||||
</main>
|
</main>
|
||||||
|
<hr />
|
||||||
<footer>
|
<footer>
|
||||||
<p>Web scraping is not a crime. - No rights reserved. - A birthday present for Plipp ❤️</p>
|
<small>
|
||||||
|
<div class="leftright">
|
||||||
|
<div class="left">Web scraping is not a crime.</div>
|
||||||
|
<div class="right">No rights reserved.</div>
|
||||||
|
</div>
|
||||||
|
<div class="leftright">
|
||||||
|
<div class="left">TheLovinator#9276 on Discord</div>
|
||||||
|
<div class="right">A birthday present for Plipp ❤️</div>
|
||||||
|
</div>
|
||||||
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
34
templates/feeds/entry_detail.html
Normal file
34
templates/feeds/entry_detail.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Entry Detail</h1>
|
||||||
|
<p>
|
||||||
|
<b>Feed:</b> <a href="{% url 'details' feed.pk %}">{{ feed.url }}</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Entry ID:</b> {{ entry.entry_id }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Published:</b> {{ entry.published_at }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Fetched:</b> {{ entry.fetched_at }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Content Hash:</b> {{ entry.content_hash }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Error Message:</b> {{ entry.error_message|default:"[none]" }}
|
||||||
|
</p>
|
||||||
|
<h2>Entry Data</h2>
|
||||||
|
{% if escaped_json %}
|
||||||
|
<div style="font-size:small;color:#666;margin-bottom:4px;">
|
||||||
|
Note: HTML in the JSON is escaped for display and will not be rendered as HTML.
|
||||||
|
</div>
|
||||||
|
<pre>{{ escaped_json|safe }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p>[No data]</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
<a href="{% url 'details' feed.pk %}">Back to feed</a>
|
||||||
|
</p>
|
||||||
|
{% endblock content %}
|
||||||
49
templates/feeds/feed_detail.html
Normal file
49
templates/feeds/feed_detail.html
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Feed Detail</h1>
|
||||||
|
<p>
|
||||||
|
<b>URL:</b> {{ feed.url }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Domain:</b> {{ feed.domain }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Active:</b> {{ feed.is_active|yesno:"yes,no" }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Created:</b> {{ feed.created_at }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>Last fetched:</b> {{ feed.last_fetched_at }}
|
||||||
|
</p>
|
||||||
|
<h2>Entries (latest 50)</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title/Description</th>
|
||||||
|
<th>Entry ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in entries %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'entry-detail' feed.pk entry.pk %}">
|
||||||
|
{{ entry.data.title|default:entry.data.description|default:"[no title]" }}
|
||||||
|
</a>
|
||||||
|
<br />
|
||||||
|
<small>{{ entry.published_at|default:entry.fetched_at }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<small>
|
||||||
|
<a href="{{ entry.entry_id }}">{{ entry.entry_id|cut:'https://'|truncatechars:50 }}</a>
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p>
|
||||||
|
<a href="{% url 'feeds' %}">Back to list</a>
|
||||||
|
</p>
|
||||||
|
{% endblock content %}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% for feed in feeds %}
|
{% for feed in feeds %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'feed-detail' feed.pk %}">{{ feed.url }}</a>
|
<a href="{% url 'details' feed.pk %}">{{ feed.url }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue