Initial commit.

This commit is contained in:
retoor 2025-10-02 21:17:36 +02:00
commit 018b4e431a
16 changed files with 3281 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
*.db-shm
*.db
*.db-wal
__pycache__

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# RSS Feed Manager
A FastAPI application for managing RSS feeds with SQLite database.
## Project Structure
```
.
├── app.py # Main FastAPI application
├── routers.py # API routes
├── requirements.txt # Python dependencies
├── feeds.db # SQLite database (auto-created)
└── templates/ # Jinja2 templates
├── base.html # Base template
├── index.html # Main splash screen
├── manage_list.html # Feed list view
├── manage_create.html # Create form
├── manage_upload.html # Upload form
├── manage_sync.html # Sync page with live updates
├── manage_edit.html # Edit form
├── newspapers_list.html # Newspapers list
├── newspaper_view.html # Pure newspaper layout
└── sync_logs_list.html # Detailed sync history
```
## Installation
1. Install dependencies:
```bash
pip install -r requirements.txt
```
2. Run the application:
```bash
python app.py
```
3. Open browser at `http://localhost:8000`
## Features
- **Upload JSON**: Upload RSS feed configuration files
- **Sync by URL**: Automatically upsert feeds based on URL (unique field)
- **CRUD Operations**: Create, Read, Update, Delete feeds
- **Google Search Theme**: Clean, familiar interface
- **Backend Rendered**: All pages rendered server-side with Jinja2
## Database
Uses SQLite with `dataset` library (1.6.2) for simple ORM operations.
### Feeds Table
- id (auto-generated)
- name
- url (unique identifier for upsert)
- type
- category
- structure (JSON field)
- last_synced (timestamp)
### Articles Table
- id (auto-generated)
- feed_name
- feed_url
- title
- link
- description
- content (clean text extracted by trafilatura)
- published
- author
- guid (unique identifier for upsert)
- created_at (timestamp)
- last_synchronized (timestamp)
### Sync Logs Table
- id (auto-generated)
- sync_time (timestamp)
- total_feeds (integer)
- completed_feeds (integer)
- failed_feeds (integer)
- total_articles_processed (integer)
- new_articles (integer)
- elapsed_seconds (float)
- avg_req_per_sec (float)
- timed_out (boolean)
### Newspapers Table
- id (auto-generated)
- created_at (timestamp)
- article_count (integer)
- articles_json (JSON string)
## Routes
- `/` - Main splash screen
- `/manage` - List all feeds
- `/manage/create` - Create new feed
- `/manage/upload` - Upload JSON file
- `/manage/sync` - Synchronize all RSS feeds
- `/manage/edit/{id}` - Edit feed
- `/manage/delete/{id}` - Delete feed
- `/newspapers` - List all newspapers
- `/newspaper/{id}` - View newspaper (pure newspaper layout)
- `/sync-logs` - View complete synchronization history with detailed statistics
- `/ws/sync` - WebSocket endpoint for live sync updates

38
app.py Normal file
View File

@ -0,0 +1,38 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import dataset
import asyncio
from datetime import datetime
app = FastAPI(title="RSS Feed Manager")
# Database setup
db = dataset.connect('sqlite:///feeds.db')
# Templates setup
templates = Jinja2Templates(directory="templates")
# Import routers
from routers import router as manage_router, run_sync_task
app.include_router(manage_router)
@app.on_event("startup")
async def startup_event():
# Ensure feeds table exists
feeds_table = db['feeds']
# Start background sync task
asyncio.create_task(hourly_sync_task())
async def hourly_sync_task():
while True:
await asyncio.sleep(3600) # Wait 1 hour
try:
await run_sync_task()
except Exception as e:
print(f"Error in hourly sync: {e}")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8592)

1749
feeds.json Normal file

File diff suppressed because it is too large Load Diff

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn==0.24.0
jinja2==3.1.2
python-multipart==0.0.6
dataset==1.6.2
aiohttp==3.9.1
feedparser==6.0.10
websockets==12.0
trafilatura==1.6.2

457
routers.py Normal file
View File

@ -0,0 +1,457 @@
from fastapi import APIRouter, Request, UploadFile, File, Form, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import dataset
import json
import aiohttp
import feedparser
import asyncio
from datetime import datetime
import time
import trafilatura
router = APIRouter()
templates = Jinja2Templates(directory="templates")
db = dataset.connect('sqlite:///feeds.db')
# Browser-like headers
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
'Upgrade-Insecure-Requests': '1'
}
async def fetch_article_content(session, url):
try:
async with session.get(url, headers=HEADERS, timeout=aiohttp.ClientTimeout(total=10)) as response:
html = await response.text()
clean_text = trafilatura.extract(html)
return clean_text if clean_text else ""
except:
return ""
async def perform_sync():
feeds_table = db['feeds']
articles_table = db['articles']
feeds = list(feeds_table.all())
total_feeds = len(feeds)
completed = 0
total_articles_added = 0
total_articles_processed = 0
failed_feeds = 0
new_articles = []
start_time = time.time()
timeout = 300 # 5 minutes
# Create newspaper immediately at start
newspapers_table = db['newspapers']
sync_time = datetime.now().isoformat()
newspaper_id = newspapers_table.insert({
'created_at': sync_time,
'article_count': 0,
'articles_json': json.dumps([])
})
connector = aiohttp.TCPConnector(limit=10)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = []
for feed in feeds:
tasks.append(fetch_feed(session, feed['url'], feed['name']))
for coro in asyncio.as_completed(tasks):
elapsed = time.time() - start_time
if elapsed >= timeout:
break
feed_url, feed_name, content, error = await coro
completed += 1
if error:
failed_feeds += 1
continue
parsed = feedparser.parse(content)
for entry in parsed.entries:
total_articles_processed += 1
article_link = entry.get('link', '')
# Extract clean text content
clean_text = await fetch_article_content(session, article_link) if article_link else ""
article_data = {
'feed_name': feed_name,
'feed_url': feed_url,
'title': entry.get('title', ''),
'link': article_link,
'description': entry.get('description', '') or entry.get('summary', ''),
'content': clean_text,
'published': entry.get('published', '') or entry.get('updated', ''),
'author': entry.get('author', ''),
'guid': entry.get('id', '') or entry.get('link', ''),
'created_at': datetime.now().isoformat(),
'last_synchronized': datetime.now().isoformat()
}
existing = articles_table.find_one(guid=article_data['guid'])
if not existing:
new_articles.append(article_data)
total_articles_added += 1
articles_table.upsert(article_data, ['guid'])
feeds_table.update({
'id': [f for f in feeds if f['url'] == feed_url][0]['id'],
'last_synced': datetime.now().isoformat()
}, ['id'])
# Update newspaper immediately after each feed
newspapers_table.update({
'id': newspaper_id,
'article_count': len(new_articles),
'articles_json': json.dumps(new_articles)
}, ['id'])
elapsed_total = time.time() - start_time
avg_req_per_sec = completed / elapsed_total if elapsed_total > 0 else 0
# Record sync log with detailed statistics
sync_logs_table = db['sync_logs']
sync_logs_table.insert({
'sync_time': sync_time,
'total_feeds': total_feeds,
'completed_feeds': completed,
'failed_feeds': failed_feeds,
'total_articles_processed': total_articles_processed,
'new_articles': total_articles_added,
'elapsed_seconds': round(elapsed_total, 2),
'avg_req_per_sec': round(avg_req_per_sec, 2),
'timed_out': elapsed_total >= timeout
})
return {
'total_feeds': total_feeds,
'total_articles': total_articles_added,
'elapsed': elapsed_total,
'new_articles': new_articles
}
async def run_sync_task():
return await perform_sync()
@router.get("/config", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@router.get("/manage", response_class=HTMLResponse)
async def manage_list(request: Request):
feeds_table = db['feeds']
feeds = list(feeds_table.all())
return templates.TemplateResponse("manage_list.html", {
"request": request,
"feeds": feeds,
"total": len(feeds)
})
@router.get("/manage/create", response_class=HTMLResponse)
async def manage_create_page(request: Request):
return templates.TemplateResponse("manage_create.html", {"request": request})
@router.post("/manage/create")
async def manage_create(
name: str = Form(...),
url: str = Form(...),
type: str = Form(...),
category: str = Form(...)
):
feeds_table = db['feeds']
feeds_table.insert({
'name': name,
'url': url,
'type': type,
'category': category
})
return RedirectResponse(url="/manage", status_code=303)
@router.get("/manage/upload", response_class=HTMLResponse)
async def manage_upload_page(request: Request):
return templates.TemplateResponse("manage_upload.html", {"request": request})
@router.post("/manage/upload")
async def manage_upload(file: UploadFile = File(...)):
content = await file.read()
feeds_data = json.loads(content)
feeds_table = db['feeds']
for feed in feeds_data:
feeds_table.upsert(feed, ['url'])
return RedirectResponse(url="/manage", status_code=303)
@router.get("/manage/edit/{feed_id}", response_class=HTMLResponse)
async def manage_edit_page(request: Request, feed_id: int):
feeds_table = db['feeds']
feed = feeds_table.find_one(id=feed_id)
return templates.TemplateResponse("manage_edit.html", {
"request": request,
"feed": feed
})
@router.post("/manage/edit/{feed_id}")
async def manage_edit(
feed_id: int,
name: str = Form(...),
url: str = Form(...),
type: str = Form(...),
category: str = Form(...)
):
feeds_table = db['feeds']
feeds_table.update({
'id': feed_id,
'name': name,
'url': url,
'type': type,
'category': category
}, ['id'])
return RedirectResponse(url="/manage", status_code=303)
@router.post("/manage/delete/{feed_id}")
async def manage_delete(feed_id: int):
feeds_table = db['feeds']
feeds_table.delete(id=feed_id)
return RedirectResponse(url="/manage", status_code=303)
@router.get("/manage/sync", response_class=HTMLResponse)
async def manage_sync_page(request: Request):
feeds_table = db['feeds']
total_feeds = len(list(feeds_table.all()))
articles_table = db['articles']
total_articles = len(list(articles_table.all()))
return templates.TemplateResponse("manage_sync.html", {
"request": request,
"total_feeds": total_feeds,
"total_articles": total_articles
})
async def fetch_feed(session, feed_url, feed_name):
try:
async with session.get(feed_url, headers=HEADERS, timeout=aiohttp.ClientTimeout(total=30)) as response:
content = await response.text()
return feed_url, feed_name, content, None
except Exception as e:
return feed_url, feed_name, None, str(e)
@router.websocket("/ws/sync")
async def websocket_sync(websocket: WebSocket):
await websocket.accept()
try:
feeds_table = db['feeds']
articles_table = db['articles']
feeds = list(feeds_table.all())
total_feeds = len(feeds)
completed = 0
total_articles_added = 0
new_articles = []
start_time = time.time()
timeout = 300 # 5 minutes
await websocket.send_json({
"type": "start",
"total": total_feeds,
"message": f"Starting synchronization of {total_feeds} feeds (5 minute timeout)..."
})
# Create newspaper immediately at start
newspapers_table = db['newspapers']
sync_time = datetime.now().isoformat()
newspaper_id = newspapers_table.insert({
'created_at': sync_time,
'article_count': 0,
'articles_json': json.dumps([])
})
connector = aiohttp.TCPConnector(limit=10)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = []
for feed in feeds:
tasks.append(fetch_feed(session, feed['url'], feed['name']))
for coro in asyncio.as_completed(tasks):
elapsed = time.time() - start_time
if elapsed >= timeout:
await websocket.send_json({
"type": "timeout",
"message": "5 minute timeout reached. Newspaper already contains all articles found so far...",
"completed": completed,
"total": total_feeds
})
break
feed_url, feed_name, content, error = await coro
completed += 1
req_per_sec = completed / elapsed if elapsed > 0 else 0
if error:
await websocket.send_json({
"type": "error",
"feed": feed_name,
"url": feed_url,
"error": error,
"completed": completed,
"total": total_feeds,
"req_per_sec": round(req_per_sec, 2)
})
continue
await websocket.send_json({
"type": "fetching",
"feed": feed_name,
"url": feed_url,
"completed": completed,
"total": total_feeds,
"req_per_sec": round(req_per_sec, 2)
})
parsed = feedparser.parse(content)
articles_count = 0
for entry in parsed.entries:
article_data = {
'feed_name': feed_name,
'feed_url': feed_url,
'title': entry.get('title', ''),
'link': entry.get('link', ''),
'description': entry.get('description', '') or entry.get('summary', ''),
'published': entry.get('published', '') or entry.get('updated', ''),
'author': entry.get('author', ''),
'guid': entry.get('id', '') or entry.get('link', ''),
'created_at': datetime.now().isoformat(),
'last_synchronized': datetime.now().isoformat()
}
existing = articles_table.find_one(guid=article_data['guid'])
if not existing:
new_articles.append(article_data)
articles_count += 1
articles_table.upsert(article_data, ['guid'])
total_articles_added += articles_count
feeds_table.update({
'id': [f for f in feeds if f['url'] == feed_url][0]['id'],
'last_synced': datetime.now().isoformat()
}, ['id'])
# Update newspaper immediately after each feed
newspapers_table.update({
'id': newspaper_id,
'article_count': len(new_articles),
'articles_json': json.dumps(new_articles)
}, ['id'])
await websocket.send_json({
"type": "parsed",
"feed": feed_name,
"articles": articles_count,
"completed": completed,
"total": total_feeds,
"total_articles": total_articles_added,
"req_per_sec": round(req_per_sec, 2)
})
elapsed_total = time.time() - start_time
# Record sync log
sync_logs_table = db['sync_logs']
sync_logs_table.insert({
'sync_time': sync_time,
'total_feeds': total_feeds,
'new_articles': total_articles_added,
'elapsed_seconds': round(elapsed_total, 2)
})
await websocket.send_json({
"type": "complete",
"total_feeds": total_feeds,
"total_articles": total_articles_added,
"elapsed": round(elapsed_total, 2),
"avg_req_per_sec": round(total_feeds / elapsed_total if elapsed_total > 0 else 0, 2)
})
except WebSocketDisconnect:
pass
except Exception as e:
await websocket.send_json({
"type": "error",
"message": str(e)
})
@router.get("/newspapers", response_class=HTMLResponse)
async def newspapers_list(request: Request):
newspapers_table = db['newspapers']
newspapers = list(newspapers_table.all())
newspapers.reverse()
return templates.TemplateResponse("newspapers_list.html", {
"request": request,
"newspapers": newspapers
})
@router.get("/sync-logs", response_class=HTMLResponse)
async def sync_logs_list(request: Request):
sync_logs_table = db['sync_logs']
sync_logs = list(sync_logs_table.all())
sync_logs.reverse()
return templates.TemplateResponse("sync_logs_list.html", {
"request": request,
"sync_logs": sync_logs
})
@router.get("/newspaper/{newspaper_id}", response_class=HTMLResponse)
async def newspaper_view(request: Request, newspaper_id: int):
newspapers_table = db['newspapers']
newspaper = newspapers_table.find_one(id=newspaper_id)
if not newspaper:
return RedirectResponse(url="/newspapers")
articles = json.loads(newspaper['articles_json'])
return templates.TemplateResponse("newspaper_view.html", {
"request": request,
"newspaper": newspaper,
"articles": articles
})
@router.get("/", response_class=HTMLResponse)
async def newspaper_latest(request: Request):
newspapers_table = db['newspapers']
newspaper = None
try:
newspaper = list(db.query("select * from newspapers order by id desc limit 1"))[0]
except IndexError:
pass
if not newspaper:
return RedirectResponse(url="/newspapers")
articles = json.loads(newspaper['articles_json'])
return templates.TemplateResponse("newspaper_view.html", {
"request": request,
"newspaper": newspaper,
"articles": articles
})

162
templates/base.html Normal file
View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}RSS Feed Manager{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: arial, sans-serif;
background-color: #fff;
color: #202124;
}
.header {
padding: 20px 30px;
border-bottom: 1px solid #ebebeb;
}
.header a {
color: #202124;
text-decoration: none;
font-size: 22px;
font-weight: normal;
}
.header a:hover {
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.btn {
background-color: #f8f9fa;
border: 1px solid #f8f9fa;
border-radius: 4px;
color: #3c4043;
font-family: arial, sans-serif;
font-size: 14px;
padding: 8px 16px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin: 5px;
}
.btn:hover {
background-color: #f1f3f4;
border: 1px solid #dadce0;
}
.btn-primary {
background-color: #1a73e8;
border: 1px solid #1a73e8;
color: #fff;
}
.btn-primary:hover {
background-color: #1765cc;
border: 1px solid #1765cc;
}
.btn-danger {
background-color: #d93025;
border: 1px solid #d93025;
color: #fff;
}
.btn-danger:hover {
background-color: #c5221f;
border: 1px solid #c5221f;
}
input[type="text"],
input[type="file"],
select {
border: 1px solid #dfe1e5;
border-radius: 24px;
padding: 10px 20px;
font-size: 14px;
font-family: arial, sans-serif;
width: 100%;
outline: none;
}
input[type="text"]:focus,
select:focus {
border: 1px solid #1a73e8;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #5f6368;
}
table {
width: 100%;
border-collapse: collapse;
background-color: #fff;
margin-top: 20px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ebebeb;
}
th {
background-color: #f8f9fa;
font-weight: 500;
font-size: 14px;
color: #5f6368;
}
tr:hover {
background-color: #f8f9fa;
}
.actions {
display: flex;
gap: 10px;
}
.stats {
background-color: #f8f9fa;
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
font-size: 14px;
color: #5f6368;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="header">
<a href="/">RSS Feed Manager</a>
</div>
<div class="container">
{% block content %}{% endblock %}
</div>
{% block extra_js %}{% endblock %}
</body>
</html>

45
templates/index.html Normal file
View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}RSS Feed Manager{% endblock %}
{% block extra_css %}
<style>
.splash {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 70vh;
text-align: center;
}
.splash h1 {
font-size: 48px;
font-weight: normal;
margin-bottom: 30px;
color: #202124;
}
.splash-actions {
margin-top: 30px;
}
.splash-actions a {
margin: 0 10px;
}
</style>
{% endblock %}
{% block content %}
<div class="splash">
<h1>RSS Feed Manager</h1>
<p style="font-size: 16px; color: #5f6368; margin-bottom: 20px;">
Manage your RSS feeds efficiently
</p>
<div class="splash-actions">
<a href="/manage" class="btn btn-primary">Manage Feeds</a>
<a href="/newspapers" class="btn btn-primary">View Newspapers</a>
<a href="/sync-logs" class="btn btn-primary">Sync Logs</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Create Feed - RSS Feed Manager{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Create New Feed</h1>
<div style="max-width: 600px; margin: 40px auto;">
<form method="post" action="/manage/create">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="url">URL</label>
<input type="text" id="url" name="url" required>
</div>
<div class="form-group">
<label for="type">Type</label>
<input type="text" id="type" name="type" required>
</div>
<div class="form-group">
<label for="category">Category</label>
<input type="text" id="category" name="category" required>
</div>
<div style="margin-top: 30px;">
<button type="submit" class="btn btn-primary">Create Feed</button>
<a href="/manage" class="btn">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}Edit Feed - RSS Feed Manager{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Edit Feed</h1>
<div style="max-width: 600px; margin: 40px auto;">
<form method="post" action="/manage/edit/{{ feed.id }}">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ feed.name }}" required>
</div>
<div class="form-group">
<label for="url">URL</label>
<input type="text" id="url" name="url" value="{{ feed.url }}" required>
</div>
<div class="form-group">
<label for="type">Type</label>
<input type="text" id="type" name="type" value="{{ feed.type }}" required>
</div>
<div class="form-group">
<label for="category">Category</label>
<input type="text" id="category" name="category" value="{{ feed.category }}" required>
</div>
<div style="margin-top: 30px;">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href="/manage" class="btn">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Manage Feeds - RSS Feed Manager{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Manage Feeds</h1>
<div class="stats">
Total Feeds: <strong>{{ total }}</strong>
</div>
<div style="margin-bottom: 20px;">
<a href="/manage/create" class="btn btn-primary">Create New Feed</a>
<a href="/manage/upload" class="btn btn-primary">Upload Feeds</a>
<a href="/manage/sync" class="btn btn-primary">Sync All Feeds</a>
<a href="/newspapers" class="btn btn-primary">View Newspapers</a>
<a href="/sync-logs" class="btn btn-primary">Sync Logs</a>
<a href="/" class="btn">Back to Home</a>
</div>
{% if feeds %}
<table>
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th>Type</th>
<th>Category</th>
<th>Last Synced</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for feed in feeds %}
<tr>
<td>{{ feed.name }}</td>
<td style="font-size: 12px; color: #5f6368;">{{ feed.url }}</td>
<td>{{ feed.type }}</td>
<td>{{ feed.category }}</td>
<td style="font-size: 12px; color: #5f6368;">
{% if feed.last_synced %}
{{ feed.last_synced[:19] }}
{% else %}
Never
{% endif %}
</td>
<td>
<div class="actions">
<a href="/manage/edit/{{ feed.id }}" class="btn">Edit</a>
<form method="post" action="/manage/delete/{{ feed.id }}" style="display: inline;">
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure?')">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="text-align: center; padding: 40px; color: #5f6368;">
No feeds found. Upload a JSON file to get started.
</p>
{% endif %}
{% endblock %}

240
templates/manage_sync.html Normal file
View File

@ -0,0 +1,240 @@
{% extends "base.html" %}
{% block title %}Sync Feeds - RSS Feed Manager{% endblock %}
{% block extra_css %}
<style>
.sync-container {
max-width: 900px;
margin: 0 auto;
}
.sync-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-box {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #1a73e8;
display: block;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
color: #5f6368;
}
.log-container {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
margin-bottom: 20px;
}
.log-entry {
padding: 5px 0;
border-bottom: 1px solid #e8eaed;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: #5f6368;
margin-right: 10px;
}
.log-fetching {
color: #1a73e8;
}
.log-parsed {
color: #1e8e3e;
}
.log-error {
color: #d93025;
}
.log-complete {
color: #1e8e3e;
font-weight: bold;
}
.progress-bar {
background-color: #e8eaed;
border-radius: 4px;
height: 8px;
overflow: hidden;
margin-bottom: 30px;
}
.progress-fill {
background-color: #1a73e8;
height: 100%;
width: 0%;
transition: width 0.3s ease;
}
#syncButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
{% endblock %}
{% block content %}
<div class="sync-container">
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Synchronize RSS Feeds</h1>
<div class="stats" style="background-color: #f8f9fa; border-radius: 8px; padding: 16px; margin-bottom: 20px; font-size: 14px; color: #5f6368;">
Total Feeds: <strong>{{ total_feeds }}</strong> | Total Articles in DB: <strong>{{ total_articles }}</strong>
</div>
<div style="margin-bottom: 30px;">
<button id="syncButton" class="btn btn-primary">Start Synchronization</button>
<a href="/manage" class="btn">Back to Manage</a>
</div>
<div class="progress-bar" id="progressBar" style="display: none;">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="sync-stats" id="statsContainer" style="display: none;">
<div class="stat-box">
<span class="stat-value" id="statCompleted">0</span>
<span class="stat-label">Completed</span>
</div>
<div class="stat-box">
<span class="stat-value" id="statTotal">0</span>
<span class="stat-label">Total Feeds</span>
</div>
<div class="stat-box">
<span class="stat-value" id="statArticles">0</span>
<span class="stat-label">Articles Synced</span>
</div>
<div class="stat-box">
<span class="stat-value" id="statReqPerSec">0</span>
<span class="stat-label">Req/s</span>
</div>
</div>
<div class="log-container" id="logContainer" style="display: none;"></div>
</div>
{% endblock %}
{% block extra_js %}
<script>
const syncButton = document.getElementById('syncButton');
const logContainer = document.getElementById('logContainer');
const statsContainer = document.getElementById('statsContainer');
const progressBar = document.getElementById('progressBar');
const progressFill = document.getElementById('progressFill');
const statCompleted = document.getElementById('statCompleted');
const statTotal = document.getElementById('statTotal');
const statArticles = document.getElementById('statArticles');
const statReqPerSec = document.getElementById('statReqPerSec');
function addLog(message, type = 'info') {
const entry = document.createElement('div');
entry.className = `log-entry log-${type}`;
const time = new Date().toLocaleTimeString();
entry.innerHTML = `<span class="log-time">[${time}]</span> ${message}`;
logContainer.appendChild(entry);
logContainer.scrollTop = logContainer.scrollHeight;
}
function updateStats(completed, total, articles, reqPerSec) {
statCompleted.textContent = completed;
statTotal.textContent = total;
statArticles.textContent = articles;
statReqPerSec.textContent = reqPerSec;
const percentage = (completed / total) * 100;
progressFill.style.width = percentage + '%';
}
syncButton.addEventListener('click', () => {
syncButton.disabled = true;
logContainer.style.display = 'block';
statsContainer.style.display = 'grid';
progressBar.style.display = 'block';
logContainer.innerHTML = '';
progressFill.style.width = '0%';
const ws = new WebSocket('ws://127.0.0.1:8592/ws/sync');
ws.onopen = () => {
addLog('WebSocket connection established', 'info');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type) {
case 'start':
addLog(data.message, 'info');
updateStats(0, data.total, 0, 0);
break;
case 'fetching':
addLog(`Fetching: ${data.feed} (${data.url})`, 'fetching');
updateStats(data.completed, data.total, statArticles.textContent, data.req_per_sec);
break;
case 'parsed':
addLog(`✓ Parsed: ${data.feed} - ${data.articles} articles added`, 'parsed');
updateStats(data.completed, data.total, data.total_articles, data.req_per_sec);
break;
case 'timeout':
addLog(data.message, 'info');
updateStats(data.completed, data.total, statArticles.textContent, statReqPerSec.textContent);
break;
case 'error':
addLog(`✗ Error: ${data.feed} - ${data.error}`, 'error');
if (data.completed) {
updateStats(data.completed, data.total, statArticles.textContent, data.req_per_sec);
}
break;
case 'complete':
addLog(`✓ Synchronization complete! ${data.total_feeds} feeds processed, ${data.total_articles} articles synced in ${data.elapsed}s (avg ${data.avg_req_per_sec} req/s)`, 'complete');
syncButton.disabled = false;
break;
}
};
ws.onerror = (error) => {
addLog('WebSocket error occurred', 'error');
syncButton.disabled = false;
};
ws.onclose = () => {
addLog('WebSocket connection closed', 'info');
syncButton.disabled = false;
};
});
</script>
{% endblock %}

View File

@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Upload Feeds - RSS Feed Manager{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Upload Feeds</h1>
<div style="max-width: 600px; margin: 40px auto;">
<form method="post" action="/manage/upload" enctype="multipart/form-data">
<div class="form-group">
<label for="file">Select JSON File</label>
<input type="file" id="file" name="file" accept=".json" required>
</div>
<div style="margin-top: 30px;">
<button type="submit" class="btn btn-primary">Upload and Sync</button>
<a href="/manage" class="btn">Cancel</a>
</div>
</form>
<div style="margin-top: 40px; padding: 20px; background-color: #f8f9fa; border-radius: 8px; font-size: 14px; color: #5f6368;">
<strong>Note:</strong> The upload will synchronize feeds based on their URL.
Existing feeds with the same URL will be updated, and new feeds will be added.
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tech News - {{ newspaper.created_at[:10] }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Georgia', 'Times New Roman', serif;
background-color: #f8f9fa;
color: #202124;
padding: 40px 20px;
}
.newspaper {
max-width: 1200px;
margin: 0 auto;
background-color: #fff;
padding: 60px 80px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.masthead {
text-align: center;
border-bottom: 4px double #202124;
padding-bottom: 20px;
margin-bottom: 30px;
}
.newspaper-title {
font-size: 72px;
font-weight: bold;
font-family: 'Georgia', serif;
letter-spacing: 2px;
margin-bottom: 10px;
}
.newspaper-date {
font-size: 16px;
color: #5f6368;
font-family: arial, sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
}
.article-grid {
column-count: 2;
column-gap: 40px;
margin-top: 40px;
}
.article {
break-inside: avoid;
margin-bottom: 30px;
page-break-inside: avoid;
}
.article-title {
font-size: 24px;
font-weight: bold;
margin-bottom: 8px;
line-height: 1.3;
color: #202124;
}
.article-title a {
color: #202124;
text-decoration: none;
}
.article-title a:hover {
color: #1a73e8;
}
.article-meta {
font-size: 12px;
color: #5f6368;
margin-bottom: 10px;
font-family: arial, sans-serif;
}
.article-source {
font-weight: bold;
color: #1a73e8;
}
.article-description {
font-size: 16px;
line-height: 1.6;
text-align: justify;
color: #3c4043;
word-break: break-all;
}
.article-image {
width: 100%;
max-width: 600px;
height: auto;
margin: 15px 0;
border-radius: 4px;
}
.section-divider {
border-top: 2px solid #e8eaed;
margin: 40px 0;
}
.footer {
text-align: center;
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid #e8eaed;
font-size: 12px;
color: #5f6368;
font-family: arial, sans-serif;
}
@media print {
body {
background-color: #fff;
padding: 0;
}
.newspaper {
box-shadow: none;
padding: 40px;
}
}
@media (max-width: 768px) {
.article-grid {
column-count: 1;
}
.newspaper {
padding: 30px 20px;
}
.newspaper-title {
font-size: 48px;
}
}
</style>
</head>
<body>
<div class="newspaper">
<div class="masthead">
<div class="newspaper-title">TECH NEWS</div>
<div class="newspaper-date">{{ newspaper.created_at[:10] }} - {{ newspaper.created_at[11:19] }}</div>
</div>
<div style="text-align: center; margin-bottom: 40px;">
<p style="font-size: 18px; color: #5f6368; font-family: arial, sans-serif;">
{{ newspaper.article_count }} New Articles from Latest Synchronization
</p>
</div>
<div class="article-grid">
{% for article in articles %}
<article class="article">
<h2 class="article-title">
<a href="{{ article.link }}" target="_blank">{{ article.title }}</a>
</h2>
<div class="article-meta">
<span class="article-source">{{ article.feed_name }}</span>
{% if article.author %}
| By {{ article.author }}
{% endif %}
{% if article.published %}
| {{ article.published[:10] }}
{% endif %}
</div>
<div class="article-description">
{% set full_content = article.content if article.content else article.description %}
{% set clean_text = full_content|striptags %}
{% set display_text = clean_text[:500] if article.content else clean_text[:300] %}
{{ display_text }}{% if clean_text|length > (500 if article.content else 300) %}...{% endif %}
</div>
{% set words = full_content.split() %}
{% for word in words %}
{% if 'https' in word and ('.jpg' in word or '.jpeg' in word or '.png' in word or '.gif' in word or '.webp' in word) %}
<img src="{{ word }}" alt="Article image" class="article-image" onerror="this.style.display='none'">
{% endif %}
{% endfor %}
</article>
{% endfor %}
</div>
<div class="footer">
Generated from RSS Feed Manager | Synchronized at {{ newspaper.created_at[:19].replace('T', ' ') }}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Newspapers - RSS Feed Manager{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Newspapers</h1>
<div style="margin-bottom: 20px;">
<a href="/sync-logs" class="btn btn-primary">View Sync Logs</a>
<a href="/manage" class="btn">Back to Manage</a>
</div>
{% if newspapers %}
<table>
<thead>
<tr>
<th>Date & Time</th>
<th>Articles</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for newspaper in newspapers %}
<tr>
<td>{{ newspaper.created_at[:19].replace('T', ' ') }}</td>
<td>{{ newspaper.article_count }}</td>
<td>
<a href="/newspaper/{{ newspaper.id }}" class="btn btn-primary" target="_blank">Read Newspaper</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="text-align: center; padding: 40px; color: #5f6368;">
No newspapers generated yet. Run a synchronization to generate one.
</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Synchronization Logs - RSS Feed Manager{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px; font-size: 32px; font-weight: normal;">Synchronization Logs</h1>
<div style="margin-bottom: 20px;">
<a href="/newspapers" class="btn">Back to Newspapers</a>
<a href="/manage" class="btn">Back to Manage</a>
</div>
{% if sync_logs %}
<table>
<thead>
<tr>
<th>Sync Time</th>
<th>Total Feeds</th>
<th>Completed</th>
<th>Failed</th>
<th>Total Articles</th>
<th>New Articles</th>
<th>Duration (s)</th>
<th>Req/s</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for log in sync_logs %}
<tr>
<td>{{ log.sync_time[:19].replace('T', ' ') }}</td>
<td>{{ log.total_feeds }}</td>
<td>{{ log.completed_feeds }}</td>
<td style="color: {% if log.failed_feeds > 0 %}#d93025{% else %}#5f6368{% endif %};">
{{ log.failed_feeds }}
</td>
<td>{{ log.total_articles_processed }}</td>
<td style="color: {% if log.new_articles > 0 %}#1e8e3e{% else %}#5f6368{% endif %};">
{{ log.new_articles }}
</td>
<td>{{ log.elapsed_seconds }}</td>
<td>{{ log.avg_req_per_sec }}</td>
<td>
{% if log.timed_out %}
<span style="color: #f9ab00;">Timeout</span>
{% else %}
<span style="color: #1e8e3e;">Complete</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="margin-top: 40px; padding: 20px; background-color: #f8f9fa; border-radius: 8px;">
<h3 style="font-size: 18px; font-weight: normal; margin-bottom: 15px;">Statistics Legend</h3>
<ul style="list-style: none; padding: 0; font-size: 14px; color: #5f6368;">
<li style="margin-bottom: 8px;"><strong>Total Feeds:</strong> Number of RSS feeds configured</li>
<li style="margin-bottom: 8px;"><strong>Completed:</strong> Number of feeds successfully fetched</li>
<li style="margin-bottom: 8px;"><strong>Failed:</strong> Number of feeds that failed to fetch</li>
<li style="margin-bottom: 8px;"><strong>Total Articles:</strong> All articles processed from feeds</li>
<li style="margin-bottom: 8px;"><strong>New Articles:</strong> Articles added (not duplicates)</li>
<li style="margin-bottom: 8px;"><strong>Duration:</strong> Total sync time in seconds</li>
<li style="margin-bottom: 8px;"><strong>Req/s:</strong> Average requests per second</li>
<li style="margin-bottom: 8px;"><strong>Status:</strong> Complete or Timeout (5 min limit reached)</li>
</ul>
</div>
{% else %}
<p style="text-align: center; padding: 40px; color: #5f6368;">
No synchronization logs available. Run a sync to see logs here.
</p>
{% endif %}
{% endblock %}