Initial commit.
This commit is contained in:
commit
018b4e431a
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
*.db-shm
|
||||
*.db
|
||||
*.db-wal
|
||||
__pycache__
|
||||
105
README.md
Normal file
105
README.md
Normal 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
38
app.py
Normal 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
1749
feeds.json
Normal file
File diff suppressed because it is too large
Load Diff
9
requirements.txt
Normal file
9
requirements.txt
Normal 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
457
routers.py
Normal 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
162
templates/base.html
Normal 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
45
templates/index.html
Normal 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 %}
|
||||
36
templates/manage_create.html
Normal file
36
templates/manage_create.html
Normal 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 %}
|
||||
36
templates/manage_edit.html
Normal file
36
templates/manage_edit.html
Normal 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 %}
|
||||
64
templates/manage_list.html
Normal file
64
templates/manage_list.html
Normal 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
240
templates/manage_sync.html
Normal 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 %}
|
||||
26
templates/manage_upload.html
Normal file
26
templates/manage_upload.html
Normal 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 %}
|
||||
198
templates/newspaper_view.html
Normal file
198
templates/newspaper_view.html
Normal 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>
|
||||
39
templates/newspapers_list.html
Normal file
39
templates/newspapers_list.html
Normal 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 %}
|
||||
73
templates/sync_logs_list.html
Normal file
73
templates/sync_logs_list.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user