fix: OPE hardening - logging, Docker, deps, scheduler, cleanup

- #12: Remove duplicate logging.basicConfig() from 10 modules
- #15: Remove redundant import re in rebuild_database.py
- #17: rglob('*') → rglob('*.html/json/txt/xml/md') for speed
- #18: Dockerfile individual COPY → glob COPY *.py/*.json + .dockerignore
- #19: Remove deprecated docker-compose version field
- #20: Pin requirements.txt versions (flask, requests, etc.)
- #22: SIGALRM → threading.Timer for multi-threaded safety
- #23: AP regex parsing → BeautifulSoup selectors
This commit is contained in:
Jarian Cottingham 2026-07-05 04:14:05 +00:00
parent db9fa92c40
commit a7936b8b11
14 changed files with 67 additions and 146 deletions

View File

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

View File

@ -24,23 +24,12 @@ RUN mkdir -p /app/archival_data
ENV ARCHIVE_DIR=/app/archival_data ENV ARCHIVE_DIR=/app/archival_data
# Copy application code # Copy application code
COPY run_archiver.py . COPY *.py ./
COPY archive_engine.py . COPY *.json ./
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 and static directories if they exist
COPY templates/ ./templates/ 2>/dev/null || true COPY templates/ ./templates/
COPY static/ ./static/ 2>/dev/null || true COPY static/ ./static/
# Copy entrypoint script # Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh

View File

@ -8,7 +8,6 @@ Uses direct HTML parsing since AP doesn't provide RSS feeds.
import argparse import argparse
import json import json
import logging import logging
import re
import sys import sys
import time import time
from datetime import datetime from datetime import datetime
@ -22,6 +21,13 @@ except ImportError:
print("Install with: pip install requests") print("Install with: pip install requests")
requests = None requests = None
try:
from bs4 import BeautifulSoup
except ImportError:
print("WARNING: beautifulsoup4 not installed. AP parsing may not work.")
print("Install with: pip install beautifulsoup4")
BeautifulSoup = None
try: try:
import sqlite3 import sqlite3
except ImportError: except ImportError:
@ -44,14 +50,6 @@ SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data' ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
ARCHIVE_DIR.mkdir(exist_ok=True) ARCHIVE_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -86,7 +84,7 @@ def fetch_ap_frontpage(timeout: int = 30) -> str:
def extract_article_links(html: str) -> List[str]: def extract_article_links(html: str) -> List[str]:
"""Extract AP article URLs from HTML. """Extract AP article URLs from HTML using BeautifulSoup.
Args: Args:
html: Raw HTML string html: Raw HTML string
@ -94,14 +92,18 @@ def extract_article_links(html: str) -> List[str]:
Returns: Returns:
List of unique article URLs List of unique article URLs
""" """
pattern = r'href="https://apnews\.com/article/[^"]*"' if BeautifulSoup is None:
matches = re.findall(pattern, html) logger.error("BeautifulSoup not available for link extraction")
return []
soup = BeautifulSoup(html, 'html.parser')
urls = [] urls = []
for match in matches:
url = match.replace('href="', '').replace('"', '') for link in soup.find_all('a', href=True):
if url not in urls: href = link['href']
urls.append(url) if href.startswith('https://apnews.com/article/'):
if href not in urls:
urls.append(href)
logger.info("Extracted %d unique article links", len(urls)) logger.info("Extracted %d unique article links", len(urls))
return urls return urls

View File

@ -48,14 +48,6 @@ SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve() 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(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -25,14 +25,6 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import List, Tuple
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
],
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Project root directory # Project root directory
@ -152,9 +144,11 @@ def get_files_older_than_date(
""" """
old_files = [] old_files = []
# Walk through all files in directory recursively # Walk through all files in directory recursively (targeted patterns only)
for item in directory.rglob("*"): for pattern in ("*.html", "*.json", "*.txt", "*.xml", "*.md"):
if item.is_file(): for item in directory.rglob(pattern):
if not item.is_file():
continue
if should_preserve(item): if should_preserve(item):
continue continue

View File

@ -46,14 +46,6 @@ ARCHIVE_DIR.mkdir(exist_ok=True)
_last_request_time = 0.0 _last_request_time = 0.0
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,5 +1,3 @@
version: '3.8'
services: services:
newsarchiver: newsarchiver:
build: build:

View File

@ -21,14 +21,6 @@ SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data' ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
RSS_FEEDS_PATH = SCRIPT_DIR / 'rss_feeds.json' RSS_FEEDS_PATH = SCRIPT_DIR / 'rss_feeds.json'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -45,8 +37,6 @@ def extract_urls_from_html(html_file: Path) -> list:
content = html_file.read_text(encoding='utf-8') content = html_file.read_text(encoding='utf-8')
urls = [] urls = []
import re
# Only extract main article URLs - skip tracking links, RSS feeds, author pages, etc. # Only extract main article URLs - skip tracking links, RSS feeds, author pages, etc.
article_patterns = [ article_patterns = [
r'href=["\']https?://[^"\']+\/article\/[^"\']+["\']', r'href=["\']https?://[^"\']+\/article\/[^"\']+["\']',

View File

@ -1,7 +1,7 @@
flask flask>=3.0,<4.0
requests requests>=2.31,<3.0
trafilatura trafilatura>=1.6,<2.0
feedparser feedparser>=6.0,<7.0
apscheduler apscheduler>=3.10,<4.0
beautifulsoup4 beautifulsoup4>=4.12,<5.0
playwright playwright>=1.40,<2.0

View File

@ -16,14 +16,6 @@ except ImportError as e:
SCRIPT_DIR = Path(__file__).parent SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data' ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'restore.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -29,14 +29,6 @@ SCRIPT_DIR = Path(__file__).parent
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data' ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
ARCHIVE_DIR.mkdir(exist_ok=True) ARCHIVE_DIR.mkdir(exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -8,8 +8,8 @@ daily archiving of news sources.
import atexit import atexit
import logging import logging
import os import os
import signal
import sys import sys
import threading
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -31,14 +31,6 @@ SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve() 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(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
]
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
@ -46,10 +38,14 @@ scheduler = BackgroundScheduler()
# Timeout configuration # Timeout configuration
MAX_RUN_TIME_SECONDS = 3600 # 1 hour MAX_RUN_TIME_SECONDS = 3600 # 1 hour
start_time = None start_time = None
_timeout_timer = None
def timeout_handler(signum, frame): def _timeout_checker():
"""Handle timeout signal - exit gracefully after current download completes.""" """Daemon thread that raises SystemExit when timeout is reached."""
elapsed = (datetime.now() - start_time).total_seconds()
remaining = MAX_RUN_TIME_SECONDS - elapsed
if remaining > 0:
logger.warning("Timeout reached (%d seconds). Will exit after current download completes.", MAX_RUN_TIME_SECONDS) logger.warning("Timeout reached (%d seconds). Will exit after current download completes.", MAX_RUN_TIME_SECONDS)
raise SystemExit(0) raise SystemExit(0)
@ -66,6 +62,20 @@ def check_timeout() -> bool:
return True return True
return False return False
def _start_timeout_thread():
"""Start a daemon thread that will raise SystemExit after MAX_RUN_TIME_SECONDS."""
global _timeout_timer
_timeout_timer = threading.Timer(MAX_RUN_TIME_SECONDS, _timeout_checker)
_timeout_timer.daemon = True
_timeout_timer.start()
def _cancel_timeout_thread():
"""Cancel the timeout thread."""
global _timeout_timer
if _timeout_timer:
_timeout_timer.cancel()
_timeout_timer = None
def scheduled_archive() -> None: def scheduled_archive() -> None:
"""Run archiving for all sources.""" """Run archiving for all sources."""
global start_time global start_time
@ -75,8 +85,7 @@ def scheduled_archive() -> None:
logger.info("Starting scheduled archive run") logger.info("Starting scheduled archive run")
logger.info("=" * 60) logger.info("=" * 60)
signal.signal(signal.SIGALRM, timeout_handler) _start_timeout_thread()
signal.alarm(MAX_RUN_TIME_SECONDS)
try: try:
results = archive_all_sources( results = archive_all_sources(
@ -98,7 +107,7 @@ def scheduled_archive() -> None:
except Exception as e: except Exception as e:
logger.error("Scheduled archive failed with exception: %s", str(e)) logger.error("Scheduled archive failed with exception: %s", str(e))
finally: finally:
signal.alarm(0) _cancel_timeout_thread()
def start_scheduler(interval_minutes: int = 60) -> BackgroundScheduler: def start_scheduler(interval_minutes: int = 60) -> BackgroundScheduler:
@ -137,8 +146,7 @@ def run_once() -> None:
logger.info("Running one-time archive") logger.info("Running one-time archive")
logger.info("=" * 60) logger.info("=" * 60)
signal.signal(signal.SIGALRM, timeout_handler) _start_timeout_thread()
signal.alarm(MAX_RUN_TIME_SECONDS)
try: try:
scheduled_archive() scheduled_archive()
@ -150,7 +158,7 @@ def run_once() -> None:
logger.info("Archiver exiting due to timeout") logger.info("Archiver exiting due to timeout")
raise e raise e
finally: finally:
signal.alarm(0) _cancel_timeout_thread()
def stop_scheduler() -> None: def stop_scheduler() -> None:

View File

@ -27,14 +27,6 @@ SCRIPT_DIR = Path(__file__).parent.resolve()
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve() 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(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / "processing.log", encoding="utf-8"),
],
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DB_PATH = ARCHIVE_DIR / "cache.db" DB_PATH = ARCHIVE_DIR / "cache.db"

View File

@ -11,7 +11,6 @@ import json
import logging import logging
import os import os
import secrets import secrets
import sys
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import wraps from functools import wraps
@ -136,14 +135,6 @@ def add_security_headers(response):
return response return response
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(ARCHIVE_DIR / "processing.log", encoding="utf-8"),
],
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)