added docker support

This commit is contained in:
Jarian Cottingham 2026-03-31 10:15:50 -05:00
parent 09d81e8cda
commit ff24fd0db5
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 - Flask, Trafilatura, feedparser, APScheduler, requests, beautifulsoup4
- SingleFile CLI (optional, for web page archiving) - 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 ## Stopping Services
To stop all NewsArchiver services: To stop all NewsArchiver services:

View File

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

View File

@ -19,6 +19,7 @@ Options:
import argparse import argparse
import logging import logging
import os
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -38,7 +39,10 @@ logger = logging.getLogger(__name__)
SCRIPT_DIR = Path(__file__).parent.resolve() SCRIPT_DIR = Path(__file__).parent.resolve()
# Path to archival_data directory # 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) # Path to websites folder (only this folder will be scanned in archival_data)
WEBSITES_DIR = ARCHIVAL_DATA_DIR / "websites" 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,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 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 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("WARNING: singlefile_archive module not found")
print("SingleFile integration will not be available") print("SingleFile integration will not be available")
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data' ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
ARCHIVE_DIR.mkdir(exist_ok=True) ARCHIVE_DIR.mkdir(exist_ok=True)

View File

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

View File

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

View File

@ -6,28 +6,30 @@ Flask web server for browsing archived news articles.
import json import json
import logging import logging
import os
import sys import sys
from datetime import datetime import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote
from flask import Flask, jsonify, request, render_template, make_response from flask import Flask, jsonify, make_response, render_template, request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from storage_manager import ( from storage_manager import (
DB_PATH,
get_all_sources, get_all_sources,
get_source_stats,
get_articles_by_source,
get_article, get_article,
get_articles_by_source,
get_latest_articles, get_latest_articles,
DB_PATH get_source_stats,
) )
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data' ARCHIVE_DIR = Path(
RSS_FEEDS_PATH = SCRIPT_DIR / 'rss_feeds.json' os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
).resolve()
RSS_FEEDS_PATH = SCRIPT_DIR / "rss_feeds.json"
RSS_FEEDS = {} RSS_FEEDS = {}
@ -35,163 +37,173 @@ RSS_FEEDS = {}
def load_rss_feeds() -> dict: def load_rss_feeds() -> dict:
"""Load RSS feeds configuration.""" """Load RSS feeds configuration."""
global RSS_FEEDS global RSS_FEEDS
if RSS_FEEDS: if RSS_FEEDS:
return RSS_FEEDS return RSS_FEEDS
if not RSS_FEEDS_PATH.exists(): if not RSS_FEEDS_PATH.exists():
logger.warning("RSS feeds file not found: %s", RSS_FEEDS_PATH) logger.warning("RSS feeds file not found: %s", RSS_FEEDS_PATH)
return {} return {}
try: 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) RSS_FEEDS = json.load(f)
return RSS_FEEDS return RSS_FEEDS
except Exception as e: except Exception as e:
logger.error("Failed to load RSS feeds: %s", str(e)) logger.error("Failed to load RSS feeds: %s", str(e))
return {} return {}
app = Flask( app = Flask(
__name__, __name__,
static_folder=str(SCRIPT_DIR / 'static'), static_folder=str(SCRIPT_DIR / "static"),
template_folder=str(SCRIPT_DIR / 'templates') template_folder=str(SCRIPT_DIR / "templates"),
) )
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s', format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[ handlers=[
logging.StreamHandler(sys.stdout), 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__) logger = logging.getLogger(__name__)
def get_pagination_info(total: int, page: int, per_page: int) -> dict: def get_pagination_info(total: int, page: int, per_page: int) -> dict:
"""Calculate pagination information. """Calculate pagination information.
Args: Args:
total: Total number of items total: Total number of items
page: Current page number page: Current page number
per_page: Items per page per_page: Items per page
Returns: Returns:
Dictionary with pagination details Dictionary with pagination details
""" """
total_pages = (total + per_page - 1) // per_page if total > 0 else 1 total_pages = (total + per_page - 1) // per_page if total > 0 else 1
return { return {
'total': total, "total": total,
'page': page, "page": page,
'per_page': per_page, "per_page": per_page,
'has_next': page < total_pages, "has_next": page < total_pages,
'has_prev': page > 1, "has_prev": page > 1,
'next_num': page + 1 if page < total_pages else None, "next_num": page + 1 if page < total_pages else None,
'prev_num': page - 1 if page > 1 else None, "prev_num": page - 1 if page > 1 else None,
'pages': total_pages "pages": total_pages,
} }
@app.route('/') @app.route("/")
def index(): def index():
"""Newspaper listing page.""" """Newspaper listing page."""
sources = get_all_sources() sources = get_all_sources()
rss_feeds = load_rss_feeds() rss_feeds = load_rss_feeds()
source_list = [] source_list = []
disabled_sources = [] disabled_sources = []
for source_name in sources: for source_name in sources:
stats = get_source_stats(source_name) stats = get_source_stats(source_name)
source_info = { source_info = {
'name': source_name.title(), "name": source_name.title(),
'slug': source_name, "slug": source_name,
'article_count': stats['total_articles'], "article_count": stats["total_articles"],
'last_archived': stats.get('last_archived'), "last_archived": stats.get("last_archived"),
'status': 'success' if stats['total_articles'] > 0 else 'pending' "status": "success" if stats["total_articles"] > 0 else "pending",
} }
if source_name in rss_feeds: if source_name in rss_feeds:
feed_info = rss_feeds[source_name] feed_info = rss_feeds[source_name]
if feed_info.get('disabled', False): if feed_info.get("disabled", False):
source_info['disabled'] = True source_info["disabled"] = True
source_info['disable_reason'] = feed_info.get('disable_reason', 'No reason provided') source_info["disable_reason"] = feed_info.get(
"disable_reason", "No reason provided"
)
disabled_sources.append(source_info) disabled_sources.append(source_info)
continue continue
source_list.append(source_info) source_list.append(source_info)
source_list.extend(disabled_sources) 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): def articles(slug: str):
"""Article listing page for a specific source.""" """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 per_page = 50
sources = get_all_sources() sources = get_all_sources()
source_name = None source_name = None
for s in sources: for s in sources:
if s.lower() == slug.lower(): if s.lower() == slug.lower():
source_name = s source_name = s
break break
if not source_name: 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) stats = get_source_stats(source_name)
total = stats['total_articles'] total = stats["total_articles"]
pagination = get_pagination_info(total, page, per_page) pagination = get_pagination_info(total, page, per_page)
articles_data = [] articles_data = []
for article in articles_list: for article in articles_list:
articles_data.append({ articles_data.append(
'id': getattr(article, 'id', 0), {
'title': article.title or 'Untitled', "id": getattr(article, "id", 0),
'date': article.publish_date or '', "title": article.title or "Untitled",
'summary': article.content_text[:200] if article.content_text else '', "date": article.publish_date or "",
'url': f'/source/{source_name.lower()}/article/{getattr(article, "id", 0)}' "summary": article.content_text[:200] if article.content_text else "",
}) "url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
}
)
return render_template( return render_template(
'articles.html', "articles.html",
source_name=source_name.title(), source_name=source_name.title(),
source_slug=source_name.lower(), source_slug=source_name.lower(),
articles=articles_data, articles=articles_data,
pagination=pagination pagination=pagination,
) )
@app.route('/archive/<path:archive_path>') @app.route("/archive/<path:archive_path>")
def serve_archive(archive_path): def serve_archive(archive_path):
"""Serve archived HTML file.""" """Serve archived HTML file."""
archive_file = ARCHIVE_DIR / archive_path archive_file = ARCHIVE_DIR / archive_path
if archive_file.exists(): if archive_file.exists():
return archive_file.read_text(encoding='utf-8') return archive_file.read_text(encoding="utf-8")
return 'Archive not found', 404 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): def serve_archive_file(encoded_path):
"""Serve archived HTML file from encoded path.""" """Serve archived HTML file from encoded path."""
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
archive_path = urllib.parse.unquote(encoded_path) archive_path = urllib.parse.unquote(encoded_path)
archive_file = ARCHIVE_DIR / archive_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(): if archive_file.exists():
return archive_file.read_text(encoding='utf-8') return archive_file.read_text(encoding="utf-8")
return 'Archive not found', 404 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): def article(slug: str, article_id: int):
"""Individual article page.""" """Individual article page."""
sources = get_all_sources() sources = get_all_sources()
@ -200,289 +212,324 @@ def article(slug: str, article_id: int):
if s.lower() == slug.lower(): if s.lower() == slug.lower():
source_name = s source_name = s
break break
if not source_name: 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) article = get_article(source_name, article_id)
if not article: 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 = { article_data = {
'id': article_id, "id": article_id,
'title': article.title or 'Untitled', "title": article.title or "Untitled",
'publish_date': article.publish_date or '', "publish_date": article.publish_date or "",
'author': article.author or '', "author": article.author or "",
'url': article.url or '', "url": article.url or "",
'content_text': article.content_text or '', "content_text": article.content_text or "",
'archive_file_path': article.archive_file_path or '' "archive_file_path": article.archive_file_path or "",
} }
return render_template( return render_template(
'article.html', "article.html",
source_name=source_name.title(), source_name=source_name.title(),
source_slug=source_name.lower(), source_slug=source_name.lower(),
article=article_data article=article_data,
) )
@app.route('/status') @app.route("/status")
def status(): def status():
"""System status page.""" """System status page."""
sources = get_all_sources() sources = get_all_sources()
rss_feeds = load_rss_feeds() rss_feeds = load_rss_feeds()
sources_info = [] sources_info = []
disabled_sources = [] disabled_sources = []
total_articles = 0 total_articles = 0
failed_jobs = 0 failed_jobs = 0
disabled_count = 0 disabled_count = 0
for source_name in sources: for source_name in sources:
stats = get_source_stats(source_name) stats = get_source_stats(source_name)
source_info = { source_info = {
'name': source_name.title(), "name": source_name.title(),
'slug': source_name, "slug": source_name,
'article_count': stats['total_articles'], "article_count": stats["total_articles"],
'last_archived': stats.get('last_archived'), "last_archived": stats.get("last_archived"),
'status': 'success' if stats['total_articles'] > 0 else 'pending' "status": "success" if stats["total_articles"] > 0 else "pending",
} }
if source_name in rss_feeds: if source_name in rss_feeds:
feed_info = rss_feeds[source_name] feed_info = rss_feeds[source_name]
if feed_info.get('disabled', False): if feed_info.get("disabled", False):
source_info['disabled'] = True source_info["disabled"] = True
disabled_count += 1 disabled_count += 1
disabled_sources.append(source_info) disabled_sources.append(source_info)
continue continue
sources_info.append(source_info) sources_info.append(source_info)
total_articles += stats['total_articles'] total_articles += stats["total_articles"]
failed_jobs += stats['failed'] failed_jobs += stats["failed"]
sources_info.extend(disabled_sources) sources_info.extend(disabled_sources)
return render_template( return render_template(
'status.html', "status.html",
sources=sources_info, sources=sources_info,
total_articles=total_articles, total_articles=total_articles,
failed_jobs=failed_jobs, failed_jobs=failed_jobs,
sources_monitored=len(sources) - disabled_count, 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(): def api_sources():
"""API endpoint for listing all sources.""" """API endpoint for listing all sources."""
sources = get_all_sources() sources = get_all_sources()
rss_feeds = load_rss_feeds() rss_feeds = load_rss_feeds()
source_list = [] source_list = []
disabled_sources = [] disabled_sources = []
for source_name in sources: for source_name in sources:
stats = get_source_stats(source_name) stats = get_source_stats(source_name)
source_info = { source_info = {
'name': source_name.title(), "name": source_name.title(),
'slug': source_name, "slug": source_name,
'article_count': stats['total_articles'], "article_count": stats["total_articles"],
'last_archived': stats.get('last_archived'), "last_archived": stats.get("last_archived"),
'status': 'success' if stats['total_articles'] > 0 else 'pending' "status": "success" if stats["total_articles"] > 0 else "pending",
} }
if source_name in rss_feeds: if source_name in rss_feeds:
feed_info = rss_feeds[source_name] feed_info = rss_feeds[source_name]
if feed_info.get('disabled', False): if feed_info.get("disabled", False):
source_info['disabled'] = True source_info["disabled"] = True
source_info['disable_reason'] = feed_info.get('disable_reason', 'No reason provided') source_info["disable_reason"] = feed_info.get(
"disable_reason", "No reason provided"
)
disabled_sources.append(source_info) disabled_sources.append(source_info)
continue continue
source_list.append(source_info) source_list.append(source_info)
source_list.extend(disabled_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): def api_articles(slug: str):
"""API endpoint for listing articles for a source.""" """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 per_page = 50
sources = get_all_sources() sources = get_all_sources()
source_name = None source_name = None
for s in sources: for s in sources:
if s.lower() == slug.lower(): if s.lower() == slug.lower():
source_name = s source_name = s
break break
if not source_name: 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) stats = get_source_stats(source_name)
total = stats['total_articles'] total = stats["total_articles"]
pagination = get_pagination_info(total, page, per_page) pagination = get_pagination_info(total, page, per_page)
articles_data = [] articles_data = []
for article in articles_list: for article in articles_list:
articles_data.append({ articles_data.append(
'id': getattr(article, 'id', 0), {
'title': article.title or 'Untitled', "id": getattr(article, "id", 0),
'date': article.publish_date or '', "title": article.title or "Untitled",
'summary': article.content_text[:200] if article.content_text else '', "date": article.publish_date or "",
'url': f'/source/{source_name.lower()}/article/{getattr(article, "id", 0)}' "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, return jsonify(
'total': total, {
'page': page, "source_name": source_name.title(),
'per_page': per_page, "articles": articles_data,
'has_next': pagination['has_next'], "total": total,
'has_prev': pagination['has_prev'] "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(): def api_status():
"""API endpoint for system status.""" """API endpoint for system status."""
sources = get_all_sources() sources = get_all_sources()
sources_monitored = len(sources) sources_monitored = len(sources)
total_articles = 0 total_articles = 0
failed_jobs = 0 failed_jobs = 0
last_archive_run = None last_archive_run = None
for source_name in sources: for source_name in sources:
stats = get_source_stats(source_name) stats = get_source_stats(source_name)
total_articles += stats['total_articles'] total_articles += stats["total_articles"]
failed_jobs += stats['failed'] failed_jobs += stats["failed"]
if stats.get('last_archive_run'): if stats.get("last_archive_run"):
if last_archive_run is None or stats['last_archive_run'] > last_archive_run: if last_archive_run is None or stats["last_archive_run"] > last_archive_run:
last_archive_run = stats['last_archive_run'] last_archive_run = stats["last_archive_run"]
return jsonify({ return jsonify(
'status': 'online', {
'last_archive_run': last_archive_run, "status": "online",
'pending_jobs': 0, "last_archive_run": last_archive_run,
'failed_jobs': failed_jobs, "pending_jobs": 0,
'sources_monitored': sources_monitored, "failed_jobs": failed_jobs,
'total_articles': total_articles "sources_monitored": sources_monitored,
}) "total_articles": total_articles,
}
)
@app.route('/rss') @app.route("/rss")
def rss_feed(): def rss_feed():
"""RSS 2.0 endpoint for latest archived articles.""" """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) 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 = [] rss_items = []
for article in articles: 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 pub_date = None
if article.publish_date: if article.publish_date:
try: try:
dt = datetime.fromisoformat(article.publish_date.replace('Z', '+00:00')) dt = datetime.fromisoformat(
pub_date = dt.strftime('%a, %d %b %Y %H:%M:%S %z').strip() article.publish_date.replace("Z", "+00:00")
)
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S %z").strip()
except (ValueError, AttributeError): except (ValueError, AttributeError):
try: try:
dt = datetime.fromisoformat(article.publish_date) 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): 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()) encoded_source = quote(source_name.lower())
item = { item = {
'title': article.title, "title": article.title,
'link': f'{server_url}/source/{encoded_source}/article/{article.id}', "link": f"{server_url}/source/{encoded_source}/article/{article.id}",
'pubDate': pub_date, "pubDate": pub_date,
'description': article.content_text[:500] if article.content_text else '', "description": article.content_text[:500]
'guid': article.url or f'article-{article.id}' if article.content_text
else "",
"guid": article.url or f"article-{article.id}",
} }
if article.author: if article.author:
item['author'] = article.author item["author"] = article.author
rss_items.append(item) rss_items.append(item)
rss_template = render_template( rss_template = render_template(
'rss.xml', "rss.xml",
title='NewsArchiver - Latest Articles', title="NewsArchiver - Latest Articles",
link=server_url, link=server_url,
description='Latest archived news articles', description="Latest archived news articles",
last_build_date=datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S +0000'), last_build_date=datetime.now(timezone.utc).strftime(
items=rss_items "%a, %d %b %Y %H:%M:%S +0000"
),
items=rss_items,
) )
response = make_response(rss_template) 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 return response
@app.route('/atom') @app.route("/atom")
def atom_feed(): def atom_feed():
"""Atom 1.0 endpoint for latest archived articles.""" """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) 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 = [] atom_entries = []
for article in articles: 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 pub_date = None
if article.publish_date: if article.publish_date:
try: try:
dt = datetime.fromisoformat(article.publish_date.replace('Z', '+00:00')) dt = datetime.fromisoformat(
pub_date = dt.strftime('%Y-%m-%dT%H:%M:%S+00:00') article.publish_date.replace("Z", "+00:00")
)
pub_date = dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
except (ValueError, AttributeError): 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()) encoded_source = quote(source_name.lower())
entry = { entry = {
'title': article.title, "title": article.title,
'link': f'{server_url}/source/{encoded_source}/article/{article.id}', "link": f"{server_url}/source/{encoded_source}/article/{article.id}",
'published': pub_date, "published": pub_date,
'summary': article.content_text[:500] if article.content_text else '', "summary": article.content_text[:500] if article.content_text else "",
'id': article.url or f'article-{article.id}' "id": article.url or f"article-{article.id}",
} }
if article.author: if article.author:
entry['author'] = {'name': article.author} entry["author"] = {"name": article.author}
atom_entries.append(entry) atom_entries.append(entry)
atom_template = render_template( atom_template = render_template(
'atom.xml', "atom.xml",
title='NewsArchiver - Latest Articles', title="NewsArchiver - Latest Articles",
link=server_url, link=server_url,
updated=datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00'), updated=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"),
entries=atom_entries entries=atom_entries,
) )
response = make_response(atom_template) 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 return response
if __name__ == '__main__': if __name__ == "__main__":
logger.info("Starting web interface...") logger.info("Starting web interface...")
if not DB_PATH.exists(): if not DB_PATH.exists():
logger.info("Database not found, initializing...") logger.info("Database not found, initializing...")
from storage_manager import initialize_storage from storage_manager import initialize_storage
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)