added docker support
This commit is contained in:
parent
49a657086e
commit
aa51dc766b
BIN
._.DS_Store
BIN
._.DS_Store
Binary file not shown.
BIN
._.gitignore
BIN
._.gitignore
Binary file not shown.
BIN
._README.md
BIN
._README.md
Binary file not shown.
Binary file not shown.
22
.dockerignore
Normal file
22
.dockerignore
Normal 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
13
.env.example
Normal 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
56
Dockerfile
Normal 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"]
|
||||||
46
README.md
46
README.md
@ -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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
37
docker-compose.nas.example.yml
Normal file
37
docker-compose.nas.example.yml
Normal 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
23
docker-compose.yml
Normal 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
31
docker_backup.sh
Normal 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
47
docker_setup.sh
Normal 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
11
entrypoint.sh
Normal 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 "$@"
|
||||||
16
nohup.out
16
nohup.out
@ -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] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
2026-03-31 12:16:03,475 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "[36mGET /static/style.css HTTP/1.1[0m" 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] "[33mGET /favicon.ico HTTP/1.1[0m" 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] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||||
|
2026-03-31 14:40:43,351 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "[32mGET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 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] "[33mGET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 404 -
|
||||||
|
2026-03-31 14:40:43,374 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "[33mGET /favicon.ico HTTP/1.1[0m" 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] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||||
|
2026-03-31 14:51:02,123 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "[32mGET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 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] "[33mGET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 404 -
|
||||||
|
2026-03-31 14:51:02,142 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||||
|
|||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
flask
|
||||||
|
requests
|
||||||
|
trafilatura
|
||||||
|
feedparser
|
||||||
|
apscheduler
|
||||||
|
beautifulsoup4
|
||||||
|
playwright
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
501
web_interface.py
501
web_interface.py
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user