added docker support

This commit is contained in:
Jarian Cottingham 2026-03-31 10:15:50 -05:00 committed by Jarian
parent 49a657086e
commit aa51dc766b
22 changed files with 596 additions and 236 deletions

Binary file not shown.

BIN
._.git

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

22
.dockerignore Normal file
View File

@ -0,0 +1,22 @@
__pycache__
*.pyc
*.pyo
.git
.gitignore
.env
.env.local
.env.*.local
*.log
nohup.out
opencoder-server.pid
rebuild_*.log
rebuild_*.txt
*.db
archival_data/
*.out
.eggs/
*.egg-info/
dist/
build/
*.bak

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# NewsArchiver Environment Variables
# Copy this file to .env and edit with your values
# Directory where archived files will be stored
# This is perfect for NAS mounting
ARCHIVE_DIR=/data/archives
# Optional: Web server configuration
# WEB_HOST=0.0.0.0
# WEB_PORT=5000
# Optional: Logging level (DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO

56
Dockerfile Normal file
View File

@ -0,0 +1,56 @@
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies for Playwright
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Install Playwright browsers
RUN playwright install chromium --with-deps || true
# Create app directory structure
RUN mkdir -p /app/archival_data
# Set environment variable for archive directory (can be overridden)
ENV ARCHIVE_DIR=/app/archival_data
# Copy application code
COPY run_archiver.py .
COPY archive_engine.py .
COPY rss_processor.py .
COPY content_extractor.py .
COPY storage_manager.py .
COPY web_interface.py .
COPY scheduler.py .
COPY singlefile_archive.py .
COPY ap_processor.py .
COPY cleanup_old_files.py .
COPY rebuild_database.py .
COPY restore_database.py .
COPY rss_feeds.json .
# Copy templates and static directories if they exist
COPY templates/ ./templates/ 2>/dev/null || true
COPY static/ ./static/ 2>/dev/null || true
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Expose Flask port
EXPOSE 5000
# Use entrypoint script
ENTRYPOINT ["/entrypoint.sh"]
# Default command
CMD ["python", "run_archiver.py", "--serve", "--host", "0.0.0.0", "--port", "5000"]

View File

@ -124,6 +124,52 @@ The SQLite database (`archival_data/cache.db`) stores:
- Flask, Trafilatura, feedparser, APScheduler, requests, beautifulsoup4
- SingleFile CLI (optional, for web page archiving)
## Docker Deployment
The NewsArchiver can be deployed using Docker for easier management and isolation.
### Quick Start with Docker
```bash
# Build the Docker image
docker build -t newsarchiver .
# Run with default settings (archives stored in container)
docker run -p 5000:5000 newsarchiver
# Run with NAS storage mount
docker run -p 5000:5000 \
-v /path/to/nas/backup:/data/archives \
-e ARCHIVE_DIR=/data/archives \
newsarchiver
```
### Using Docker Compose
```bash
# Edit docker-compose.yml to configure your NAS mount path
vim docker-compose.yml
# Start the service
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the service
docker-compose down
```
### Configuration
The `ARCHIVE_DIR` environment variable controls where archived files are stored. To use NAS storage:
1. Edit `docker-compose.yml` and update the volume mount path
2. Set `ARCHIVE_DIR` to match the container path (e.g., `/data/archives`)
3. Restart the container
The archived data will persist even if the container is removed, as it's stored in a Docker volume or mounted NAS directory.
## Stopping Services
To stop all NewsArchiver services:

View File

@ -43,8 +43,8 @@ except ImportError:
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
logging.basicConfig(

View File

@ -19,6 +19,7 @@ Options:
import argparse
import logging
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
@ -38,7 +39,10 @@ logger = logging.getLogger(__name__)
SCRIPT_DIR = Path(__file__).parent.resolve()
# Path to archival_data directory
ARCHIVAL_DATA_DIR = SCRIPT_DIR / "archival_data"
ARCHIVE_DIR = Path(
os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
).resolve()
ARCHIVAL_DATA_DIR = ARCHIVE_DIR
# Path to websites folder (only this folder will be scanned in archival_data)
WEBSITES_DIR = ARCHIVAL_DATA_DIR / "websites"

View File

@ -0,0 +1,37 @@
# Example docker-compose configuration for NAS storage
# Copy this file to docker-compose.yml and edit the volume path
version: '3.8'
services:
newsarchiver:
build:
context: .
dockerfile: Dockerfile
container_name: newsarchiver
ports:
- "5000:5000"
environment:
- ARCHIVE_DIR=/data/archives
volumes:
# Example: Mount your NAS to /path/to/nas/archives
# Replace with your actual NAS path
- /path/to/nas/archives:/data/archives
# Or use Docker named volume for local storage:
# - newsarchiver_data:/data/archives
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
# Optional: Run as specific UID/GID for NAS permissions
# user: "1000:1000"
volumes:
newsarchiver_data:
driver: local
driver_opts:
type: none
o: bind
device: /path/to/nas/archives # Replace with your NAS path

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
version: '3.8'
services:
newsarchiver:
build:
context: .
dockerfile: Dockerfile
container_name: newsarchiver
ports:
- "5000:5000"
environment:
- ARCHIVE_DIR=/data/archives
volumes:
- newsarchiver_data:/data/archives
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
volumes:
newsarchiver_data:

31
docker_backup.sh Normal file
View File

@ -0,0 +1,31 @@
#!/bin/bash
# Docker backup script for NewsArchiver
# This script backs up archived data from the Docker volume to a local or NAS location
set -e
BACKUP_DIR="${BACKUP_DIR:-./backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="newsarchiver_backup_${TIMESTAMP}.tar.gz"
echo "Starting backup..."
echo "Backup location: ${BACKUP_DIR}/${BACKUP_NAME}"
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Create backup from the Docker volume
docker run --rm \
-v newsarchiver_data:/data:ro \
-v "${BACKUP_DIR}:/backup" \
alpine tar -czf "/backup/${BACKUP_NAME}" -C /data .
echo "Backup complete: ${BACKUP_DIR}/${BACKUP_NAME}"
echo ""
echo "To restore from backup:"
echo " 1. Stop the container: docker-compose down"
echo " 2. Remove the volume: docker volume rm newsarchiver_data"
echo " 3. Create a new volume: docker volume create newsarchiver_data"
echo " 4. Restore: docker run --rm -v newsarchiver_data:/data -v \${BACKUP_DIR}:/backup alpine tar -xzf /backup/${BACKUP_NAME} -C /data"
echo " 5. Start: docker-compose up -d"

47
docker_setup.sh Normal file
View File

@ -0,0 +1,47 @@
#!/bin/bash
# Docker setup script for NewsArchiver
# This script helps configure the Docker environment
set -e
echo "NewsArchiver Docker Setup"
echo "=========================="
echo ""
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed. Please install Docker first."
exit 1
fi
# Check if docker-compose is available
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo "ERROR: docker-compose is not installed. Please install docker-compose first."
exit 1
fi
echo "Docker is installed."
echo ""
# Check if the project has docker-compose.yml
if [ ! -f docker-compose.yml ] && [ ! -f docker-compose.nas.example.yml ]; then
echo "WARNING: docker-compose.yml not found."
echo "Creating from example..."
cp docker-compose.nas.example.yml docker-compose.yml
echo ""
echo "Please edit docker-compose.yml to set your NAS path:"
echo " 1. Find the volume mount path (currently set to /path/to/nas/archives)"
echo " 2. Replace with your actual NAS path"
echo " 3. Save the file"
echo ""
fi
echo "Setup complete!"
echo ""
echo "Next steps:"
echo " 1. Edit docker-compose.yml with your NAS path"
echo " 2. Build and start: docker-compose up -d --build"
echo " 3. Check logs: docker-compose logs -f"
echo " 4. Access web interface at http://localhost:5000"
echo ""

11
entrypoint.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Use ARCHIVE_DIR from environment, default to /app/archival_data
export ARCHIVE_DIR="${ARCHIVE_DIR:-/app/archival_data}"
# Create archive directory if it does not exist
mkdir -p "$ARCHIVE_DIR"
# Run the command passed to docker
exec "$@"

View File

@ -216,3 +216,19 @@ opencode server listening on http://0.0.0.0:4096
2026-03-31 12:16:03,370 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "GET / HTTP/1.1" 200 -
2026-03-31 12:16:03,475 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 13:05:25,475 - INFO - 192.168.8.226 - - [31/Mar/2026 13:05:25] "GET /rss HTTP/1.1" 200 -
2026-03-31 14:38:42,845 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /source/404%20Media HTTP/1.1" 200 -
2026-03-31 14:38:42,872 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 14:38:42,883 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 14:38:44,029 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:44] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
2026-03-31 14:38:44,043 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:44] "GET /static/style.css HTTP/1.1" 304 -
2026-03-31 14:40:43,351 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "GET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 308 -
2026-03-31 14:40:43,358 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
2026-03-31 14:40:43,358 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "GET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 404 -
2026-03-31 14:40:43,374 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 14:51:00,206 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
2026-03-31 14:51:00,220 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /static/style.css HTTP/1.1" 200 -
2026-03-31 14:51:00,225 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /favicon.ico HTTP/1.1" 404 -
2026-03-31 14:51:02,123 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "GET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 308 -
2026-03-31 14:51:02,127 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
2026-03-31 14:51:02,127 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "GET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1" 404 -
2026-03-31 14:51:02,142 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "GET /favicon.ico HTTP/1.1" 404 -

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
flask
requests
trafilatura
feedparser
apscheduler
beautifulsoup4
playwright

View File

@ -62,8 +62,8 @@ except ImportError:
print("WARNING: singlefile_archive module not found")
print("SingleFile integration will not be available")
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)

View File

@ -26,8 +26,8 @@ except ImportError:
print("ERROR: archive_engine is required")
sys.exit(1)
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
logging.basicConfig(

View File

@ -23,8 +23,8 @@ try:
except ImportError:
FEEDGENERATOR_AVAILABLE = False
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / "archival_data"
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True)
logging.basicConfig(

View File

@ -6,28 +6,30 @@ Flask web server for browsing archived news articles.
import json
import logging
import os
import sys
from datetime import datetime
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from urllib.parse import quote
from flask import Flask, jsonify, request, render_template, make_response
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from flask import Flask, jsonify, make_response, render_template, request
from storage_manager import (
DB_PATH,
get_all_sources,
get_source_stats,
get_articles_by_source,
get_article,
get_articles_by_source,
get_latest_articles,
DB_PATH
get_source_stats,
)
SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
RSS_FEEDS_PATH = SCRIPT_DIR / 'rss_feeds.json'
SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(
os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
).resolve()
RSS_FEEDS_PATH = SCRIPT_DIR / "rss_feeds.json"
RSS_FEEDS = {}
@ -44,26 +46,27 @@ def load_rss_feeds() -> dict:
return {}
try:
with open(RSS_FEEDS_PATH, 'r', encoding='utf-8') as f:
with open(RSS_FEEDS_PATH, "r", encoding="utf-8") as f:
RSS_FEEDS = json.load(f)
return RSS_FEEDS
except Exception as e:
logger.error("Failed to load RSS feeds: %s", str(e))
return {}
app = Flask(
__name__,
static_folder=str(SCRIPT_DIR / 'static'),
template_folder=str(SCRIPT_DIR / 'templates')
static_folder=str(SCRIPT_DIR / "static"),
template_folder=str(SCRIPT_DIR / "templates"),
)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
logging.FileHandler(ARCHIVE_DIR / "processing.log", encoding="utf-8"),
],
)
logger = logging.getLogger(__name__)
@ -82,18 +85,18 @@ def get_pagination_info(total: int, page: int, per_page: int) -> dict:
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
return {
'total': total,
'page': page,
'per_page': per_page,
'has_next': page < total_pages,
'has_prev': page > 1,
'next_num': page + 1 if page < total_pages else None,
'prev_num': page - 1 if page > 1 else None,
'pages': total_pages
"total": total,
"page": page,
"per_page": per_page,
"has_next": page < total_pages,
"has_prev": page > 1,
"next_num": page + 1 if page < total_pages else None,
"prev_num": page - 1 if page > 1 else None,
"pages": total_pages,
}
@app.route('/')
@app.route("/")
def index():
"""Newspaper listing page."""
sources = get_all_sources()
@ -106,18 +109,20 @@ def index():
stats = get_source_stats(source_name)
source_info = {
'name': source_name.title(),
'slug': source_name,
'article_count': stats['total_articles'],
'last_archived': stats.get('last_archived'),
'status': 'success' if stats['total_articles'] > 0 else 'pending'
"name": source_name.title(),
"slug": source_name,
"article_count": stats["total_articles"],
"last_archived": stats.get("last_archived"),
"status": "success" if stats["total_articles"] > 0 else "pending",
}
if source_name in rss_feeds:
feed_info = rss_feeds[source_name]
if feed_info.get('disabled', False):
source_info['disabled'] = True
source_info['disable_reason'] = feed_info.get('disable_reason', 'No reason provided')
if feed_info.get("disabled", False):
source_info["disabled"] = True
source_info["disable_reason"] = feed_info.get(
"disable_reason", "No reason provided"
)
disabled_sources.append(source_info)
continue
@ -125,13 +130,13 @@ def index():
source_list.extend(disabled_sources)
return render_template('index.html', sources=source_list)
return render_template("index.html", sources=source_list)
@app.route('/source/<slug>')
@app.route("/source/<slug>")
def articles(slug: str):
"""Article listing page for a specific source."""
page = request.args.get('page', 1, type=int)
page = request.args.get("page", 1, type=int)
per_page = 50
sources = get_all_sources()
@ -142,56 +147,63 @@ def articles(slug: str):
break
if not source_name:
return render_template('article_not_found.html', slug=slug, article_id=0), 404
return render_template("article_not_found.html", slug=slug, article_id=0), 404
articles_list = get_articles_by_source(source_name, limit=per_page, offset=(page - 1) * per_page)
articles_list = get_articles_by_source(
source_name, limit=per_page, offset=(page - 1) * per_page
)
stats = get_source_stats(source_name)
total = stats['total_articles']
total = stats["total_articles"]
pagination = get_pagination_info(total, page, per_page)
articles_data = []
for article in articles_list:
articles_data.append({
'id': getattr(article, 'id', 0),
'title': article.title or 'Untitled',
'date': article.publish_date or '',
'summary': article.content_text[:200] if article.content_text else '',
'url': f'/source/{source_name.lower()}/article/{getattr(article, "id", 0)}'
})
articles_data.append(
{
"id": getattr(article, "id", 0),
"title": article.title or "Untitled",
"date": article.publish_date or "",
"summary": article.content_text[:200] if article.content_text else "",
"url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
}
)
return render_template(
'articles.html',
"articles.html",
source_name=source_name.title(),
source_slug=source_name.lower(),
articles=articles_data,
pagination=pagination
pagination=pagination,
)
@app.route('/archive/<path:archive_path>')
@app.route("/archive/<path:archive_path>")
def serve_archive(archive_path):
"""Serve archived HTML file."""
archive_file = ARCHIVE_DIR / archive_path
if archive_file.exists():
return archive_file.read_text(encoding='utf-8')
return 'Archive not found', 404
return archive_file.read_text(encoding="utf-8")
return "Archive not found", 404
@app.route('/archive-file/<path:encoded_path>')
@app.route("/archive-file/<path:encoded_path>")
def serve_archive_file(encoded_path):
"""Serve archived HTML file from encoded path."""
import urllib.parse
from pathlib import Path
archive_path = urllib.parse.unquote(encoded_path)
archive_file = ARCHIVE_DIR / archive_path
logger.info("Archive file path: %s, exists: %s", str(archive_file), archive_file.exists())
logger.info(
"Archive file path: %s, exists: %s", str(archive_file), archive_file.exists()
)
if archive_file.exists():
return archive_file.read_text(encoding='utf-8')
return 'Archive not found', 404
return archive_file.read_text(encoding="utf-8")
return "Archive not found", 404
@app.route('/source/<slug>/article/<int:article_id>')
@app.route("/source/<slug>/article/<int:article_id>")
def article(slug: str, article_id: int):
"""Individual article page."""
sources = get_all_sources()
@ -202,31 +214,35 @@ def article(slug: str, article_id: int):
break
if not source_name:
return render_template('article_not_found.html', slug=slug, article_id=article_id), 404
return render_template(
"article_not_found.html", slug=slug, article_id=article_id
), 404
article = get_article(source_name, article_id)
if not article:
return render_template('article_not_found.html', slug=slug, article_id=article_id), 404
return render_template(
"article_not_found.html", slug=slug, article_id=article_id
), 404
article_data = {
'id': article_id,
'title': article.title or 'Untitled',
'publish_date': article.publish_date or '',
'author': article.author or '',
'url': article.url or '',
'content_text': article.content_text or '',
'archive_file_path': article.archive_file_path or ''
"id": article_id,
"title": article.title or "Untitled",
"publish_date": article.publish_date or "",
"author": article.author or "",
"url": article.url or "",
"content_text": article.content_text or "",
"archive_file_path": article.archive_file_path or "",
}
return render_template(
'article.html',
"article.html",
source_name=source_name.title(),
source_slug=source_name.lower(),
article=article_data
article=article_data,
)
@app.route('/status')
@app.route("/status")
def status():
"""System status page."""
sources = get_all_sources()
@ -242,38 +258,38 @@ def status():
stats = get_source_stats(source_name)
source_info = {
'name': source_name.title(),
'slug': source_name,
'article_count': stats['total_articles'],
'last_archived': stats.get('last_archived'),
'status': 'success' if stats['total_articles'] > 0 else 'pending'
"name": source_name.title(),
"slug": source_name,
"article_count": stats["total_articles"],
"last_archived": stats.get("last_archived"),
"status": "success" if stats["total_articles"] > 0 else "pending",
}
if source_name in rss_feeds:
feed_info = rss_feeds[source_name]
if feed_info.get('disabled', False):
source_info['disabled'] = True
if feed_info.get("disabled", False):
source_info["disabled"] = True
disabled_count += 1
disabled_sources.append(source_info)
continue
sources_info.append(source_info)
total_articles += stats['total_articles']
failed_jobs += stats['failed']
total_articles += stats["total_articles"]
failed_jobs += stats["failed"]
sources_info.extend(disabled_sources)
return render_template(
'status.html',
"status.html",
sources=sources_info,
total_articles=total_articles,
failed_jobs=failed_jobs,
sources_monitored=len(sources) - disabled_count,
disabled_sources=disabled_count
disabled_sources=disabled_count,
)
@app.route('/api/sources')
@app.route("/api/sources")
def api_sources():
"""API endpoint for listing all sources."""
sources = get_all_sources()
@ -286,18 +302,20 @@ def api_sources():
stats = get_source_stats(source_name)
source_info = {
'name': source_name.title(),
'slug': source_name,
'article_count': stats['total_articles'],
'last_archived': stats.get('last_archived'),
'status': 'success' if stats['total_articles'] > 0 else 'pending'
"name": source_name.title(),
"slug": source_name,
"article_count": stats["total_articles"],
"last_archived": stats.get("last_archived"),
"status": "success" if stats["total_articles"] > 0 else "pending",
}
if source_name in rss_feeds:
feed_info = rss_feeds[source_name]
if feed_info.get('disabled', False):
source_info['disabled'] = True
source_info['disable_reason'] = feed_info.get('disable_reason', 'No reason provided')
if feed_info.get("disabled", False):
source_info["disabled"] = True
source_info["disable_reason"] = feed_info.get(
"disable_reason", "No reason provided"
)
disabled_sources.append(source_info)
continue
@ -305,13 +323,13 @@ def api_sources():
source_list.extend(disabled_sources)
return jsonify({'sources': source_list})
return jsonify({"sources": source_list})
@app.route('/api/source/<slug>/articles')
@app.route("/api/source/<slug>/articles")
def api_articles(slug: str):
"""API endpoint for listing articles for a source."""
page = request.args.get('page', 1, type=int)
page = request.args.get("page", 1, type=int)
per_page = 50
sources = get_all_sources()
@ -322,36 +340,42 @@ def api_articles(slug: str):
break
if not source_name:
return jsonify({'error': 'Source not found'}), 404
return jsonify({"error": "Source not found"}), 404
articles_list = get_articles_by_source(source_name, limit=per_page, offset=(page - 1) * per_page)
articles_list = get_articles_by_source(
source_name, limit=per_page, offset=(page - 1) * per_page
)
stats = get_source_stats(source_name)
total = stats['total_articles']
total = stats["total_articles"]
pagination = get_pagination_info(total, page, per_page)
articles_data = []
for article in articles_list:
articles_data.append({
'id': getattr(article, 'id', 0),
'title': article.title or 'Untitled',
'date': article.publish_date or '',
'summary': article.content_text[:200] if article.content_text else '',
'url': f'/source/{source_name.lower()}/article/{getattr(article, "id", 0)}'
})
articles_data.append(
{
"id": getattr(article, "id", 0),
"title": article.title or "Untitled",
"date": article.publish_date or "",
"summary": article.content_text[:200] if article.content_text else "",
"url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
}
)
return jsonify({
'source_name': source_name.title(),
'articles': articles_data,
'total': total,
'page': page,
'per_page': per_page,
'has_next': pagination['has_next'],
'has_prev': pagination['has_prev']
})
return jsonify(
{
"source_name": source_name.title(),
"articles": articles_data,
"total": total,
"page": page,
"per_page": per_page,
"has_next": pagination["has_next"],
"has_prev": pagination["has_prev"],
}
)
@app.route('/api/status')
@app.route("/api/status")
def api_status():
"""API endpoint for system status."""
sources = get_all_sources()
@ -363,126 +387,149 @@ def api_status():
for source_name in sources:
stats = get_source_stats(source_name)
total_articles += stats['total_articles']
failed_jobs += stats['failed']
total_articles += stats["total_articles"]
failed_jobs += stats["failed"]
if stats.get('last_archive_run'):
if last_archive_run is None or stats['last_archive_run'] > last_archive_run:
last_archive_run = stats['last_archive_run']
if stats.get("last_archive_run"):
if last_archive_run is None or stats["last_archive_run"] > last_archive_run:
last_archive_run = stats["last_archive_run"]
return jsonify({
'status': 'online',
'last_archive_run': last_archive_run,
'pending_jobs': 0,
'failed_jobs': failed_jobs,
'sources_monitored': sources_monitored,
'total_articles': total_articles
})
return jsonify(
{
"status": "online",
"last_archive_run": last_archive_run,
"pending_jobs": 0,
"failed_jobs": failed_jobs,
"sources_monitored": sources_monitored,
"total_articles": total_articles,
}
)
@app.route('/rss')
@app.route("/rss")
def rss_feed():
"""RSS 2.0 endpoint for latest archived articles."""
limit = request.args.get('limit', 50, type=int)
limit = request.args.get("limit", 50, type=int)
articles = get_latest_articles(limit=limit)
server_url = f'http://192.168.8.150:5000'
server_url = f"http://192.168.8.150:5000"
rss_items = []
for article in articles:
if article.title and article.content_text and 'Performing security verification' not in article.content_text:
if (
article.title
and article.content_text
and "Performing security verification" not in article.content_text
):
pub_date = None
if article.publish_date:
try:
dt = datetime.fromisoformat(article.publish_date.replace('Z', '+00:00'))
pub_date = dt.strftime('%a, %d %b %Y %H:%M:%S %z').strip()
dt = datetime.fromisoformat(
article.publish_date.replace("Z", "+00:00")
)
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S %z").strip()
except (ValueError, AttributeError):
try:
dt = datetime.fromisoformat(article.publish_date)
pub_date = dt.strftime('%a, %d %b %Y %H:%M:%S +0000')
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
except (ValueError, AttributeError):
pub_date = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S +0000')
pub_date = datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S +0000"
)
source_name = article.source_name or 'unknown'
source_name = article.source_name or "unknown"
encoded_source = quote(source_name.lower())
item = {
'title': article.title,
'link': f'{server_url}/source/{encoded_source}/article/{article.id}',
'pubDate': pub_date,
'description': article.content_text[:500] if article.content_text else '',
'guid': article.url or f'article-{article.id}'
"title": article.title,
"link": f"{server_url}/source/{encoded_source}/article/{article.id}",
"pubDate": pub_date,
"description": article.content_text[:500]
if article.content_text
else "",
"guid": article.url or f"article-{article.id}",
}
if article.author:
item['author'] = article.author
item["author"] = article.author
rss_items.append(item)
rss_template = render_template(
'rss.xml',
title='NewsArchiver - Latest Articles',
"rss.xml",
title="NewsArchiver - Latest Articles",
link=server_url,
description='Latest archived news articles',
last_build_date=datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S +0000'),
items=rss_items
description="Latest archived news articles",
last_build_date=datetime.now(timezone.utc).strftime(
"%a, %d %b %Y %H:%M:%S +0000"
),
items=rss_items,
)
response = make_response(rss_template)
response.headers['Content-Type'] = 'application/rss+xml; charset=utf-8'
response.headers["Content-Type"] = "application/rss+xml; charset=utf-8"
return response
@app.route('/atom')
@app.route("/atom")
def atom_feed():
"""Atom 1.0 endpoint for latest archived articles."""
limit = request.args.get('limit', 50, type=int)
limit = request.args.get("limit", 50, type=int)
articles = get_latest_articles(limit=limit)
server_url = f'http://192.168.8.150:5000'
server_url = f"http://192.168.8.150:5000"
atom_entries = []
for article in articles:
if article.title and article.content_text and 'Performing security verification' not in article.content_text:
if (
article.title
and article.content_text
and "Performing security verification" not in article.content_text
):
pub_date = None
if article.publish_date:
try:
dt = datetime.fromisoformat(article.publish_date.replace('Z', '+00:00'))
pub_date = dt.strftime('%Y-%m-%dT%H:%M:%S+00:00')
dt = datetime.fromisoformat(
article.publish_date.replace("Z", "+00:00")
)
pub_date = dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
except (ValueError, AttributeError):
pub_date = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00')
pub_date = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S+00:00"
)
source_name = article.source_name or 'unknown'
source_name = article.source_name or "unknown"
encoded_source = quote(source_name.lower())
entry = {
'title': article.title,
'link': f'{server_url}/source/{encoded_source}/article/{article.id}',
'published': pub_date,
'summary': article.content_text[:500] if article.content_text else '',
'id': article.url or f'article-{article.id}'
"title": article.title,
"link": f"{server_url}/source/{encoded_source}/article/{article.id}",
"published": pub_date,
"summary": article.content_text[:500] if article.content_text else "",
"id": article.url or f"article-{article.id}",
}
if article.author:
entry['author'] = {'name': article.author}
entry["author"] = {"name": article.author}
atom_entries.append(entry)
atom_template = render_template(
'atom.xml',
title='NewsArchiver - Latest Articles',
"atom.xml",
title="NewsArchiver - Latest Articles",
link=server_url,
updated=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00'),
entries=atom_entries
updated=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"),
entries=atom_entries,
)
response = make_response(atom_template)
response.headers['Content-Type'] = 'application/atom+xml; charset=utf-8'
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
return response
if __name__ == '__main__':
if __name__ == "__main__":
logger.info("Starting web interface...")
if not DB_PATH.exists():
logger.info("Database not found, initializing...")
from storage_manager import initialize_storage
initialize_storage()
app.run(host='0.0.0.0', port=5000, debug=True)
app.run(host="0.0.0.0", port=5000, debug=True)