implemented client side filters, mobile first styling and modals for articles

This commit is contained in:
2025-08-02 01:33:49 +02:00
parent e1f51794af
commit ccc1a90cbe
14 changed files with 1248 additions and 131 deletions

View File

@@ -4,7 +4,7 @@ from pathlib import Path
DB_PATH = Path(os.getenv("DB_NAME", "owlynews.sqlite3"))
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.5))
MIN_CRON_HOURS = float(os.getenv("MIN_CRON_HOURS", 0.1))
DEFAULT_CRON_HOURS = float(os.getenv("CRON_HOURS", MIN_CRON_HOURS))
CRON_HOURS = max(MIN_CRON_HOURS, DEFAULT_CRON_HOURS)
SYNC_COOLDOWN_MINUTES = int(os.getenv("SYNC_COOLDOWN_MINUTES", 30))
@@ -21,7 +21,7 @@ frontend_path = os.path.join(
)
logging.basicConfig(
level=logging.WARNING,
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

View File

@@ -175,31 +175,53 @@ class DatabaseManager:
A database cursor for executing SQL statements
"""
conn = None
cursor = None
try:
conn = self._get_connection()
cursor = conn.cursor()
if readonly:
conn.execute("BEGIN DEFERRED")
else:
conn.execute("BEGIN IMMEDIATE")
cursor = conn.cursor()
yield cursor
if not readonly:
conn.commit()
else:
# For readonly transactions, we still need to end the transaction
conn.commit()
except sqlite3.OperationalError as e:
if conn:
conn.rollback()
try:
conn.rollback()
except:
pass
if "database is locked" in str(e).lower():
logger.warning(
f"⚠️ Database temporarily locked, operation may need retry: {e}")
raise e
except Exception as e:
if conn:
conn.rollback()
try:
conn.rollback()
except:
pass
raise e
finally:
if cursor:
try:
cursor.close()
except:
pass
if conn:
conn.close()
try:
conn.close()
except:
pass
@contextmanager
def get_cursor_with_retry(self,

View File

@@ -22,6 +22,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from fastapi import Depends, FastAPI, HTTPException, Response, status
from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
from backend.app.config import (
CRON_HOURS,
@@ -65,51 +66,117 @@ scheduler.start()
@app.get("/news", response_model=List[Dict[str, Any]])
async def get_news(
country: str = "DE",
from_: str = "2025-07-01",
to_: str = datetime.now(timezone.utc).strftime("%Y-%m-%d"),
from_: str = None,
to_: str = None,
timezone_name: str = "UTC",
all_countries: bool = False,
all_dates: bool = False,
db: sqlite3.Cursor = Depends(get_db)
):
"""
Get news articles filtered by country and date range.
Now optimized for concurrent access while scheduler is running.
Now handles client timezone properly and supports multiple countries and all news.
Args:
country: Country code to filter by (default: "DE")
from_: Start date in ISO format (default: "2025-07-01")
to: End date in ISO format (default: current date)
country: Country code(s) to filter by (default: "DE").
For multiple countries, use comma-separated values like "DE,US,GB"
from_: Start date in ISO format (optional)
to_: End date in ISO format (optional)
timezone_name: Client timezone for date interpretation (default: "UTC")
all_countries: If True, returns news from all countries (overrides country param)
all_dates: If True, returns news from all dates (overrides date params)
db: Database cursor dependency
Returns:
List of news articles matching the criteria
"""
try:
datetime.fromisoformat(from_)
datetime.fromisoformat(to_)
from zoneinfo import ZoneInfo
from_ts = int(datetime.fromisoformat(from_).timestamp())
to_ts = int(datetime.fromisoformat(to_).timestamp())
# Handle timezone
try:
client_tz = ZoneInfo(timezone_name)
except Exception:
logger.warning(f"Invalid timezone '{timezone_name}', using UTC")
client_tz = timezone.utc
db.execute(
"""
SELECT id, title, description, url, published, country, created_at
FROM news
WHERE country = ?
AND published BETWEEN ? AND ?
ORDER BY published DESC LIMIT 1000
""",
(country, from_ts, to_ts)
)
where_conditions = []
params = []
return [dict(row) for row in db.fetchall()]
if not all_countries:
countries = [c.strip().upper() for c in country.split(',') if c.strip()]
if len(countries) == 1:
where_conditions.append("country = ?")
params.append(countries[0])
elif len(countries) > 1:
placeholders = ','.join(['?' for _ in countries])
where_conditions.append(f"country IN ({placeholders})")
params.extend(countries)
except ValueError:
raise HTTPException(
400, "Invalid date format. Use ISO format (YYYY-MM-DD)")
if not all_dates and (from_ or to_):
if not from_:
from_ = "2025-01-01" # Default start date
if not to_:
to_ = datetime.now(timezone.utc).strftime("%Y-%m-%d") # Default to today
# Parse and convert dates
from_date_naive = datetime.fromisoformat(from_)
to_date_naive = datetime.fromisoformat(to_)
from_date = from_date_naive.replace(tzinfo=client_tz)
to_date = to_date_naive.replace(tzinfo=client_tz)
# Include the entire end date
to_date = to_date.replace(hour=23, minute=59, second=59)
from_ts = int(from_date.timestamp())
to_ts = int(to_date.timestamp())
where_conditions.append("published BETWEEN ? AND ?")
params.extend([from_ts, to_ts])
logger.info(f"Date range: {from_date} to {to_date} (UTC timestamps: {from_ts} to {to_ts})")
# Build the complete SQL query
base_sql = """
SELECT id, title, summary, url, published, country, created_at
FROM news \
"""
if where_conditions:
sql = base_sql + " WHERE " + " AND ".join(where_conditions)
else:
sql = base_sql
sql += " ORDER BY published DESC LIMIT 1000"
# Log query info
if all_countries and all_dates:
logger.info("Querying ALL news articles (no filters)")
elif all_countries:
logger.info(f"Querying news from ALL countries with date filter")
elif all_dates:
logger.info(f"Querying ALL dates for countries: {country}")
else:
logger.info(f"Querying news: countries={country}, timezone={timezone_name}")
logger.info(f"SQL: {sql}")
logger.info(f"Parameters: {params}")
# Execute the query
db.execute(sql, params)
rows = db.fetchall()
result = [dict(row) for row in rows]
logger.info(f"Found {len(result)} news articles")
return result
except ValueError as e:
logger.error(f"Date parsing error: {e}")
raise HTTPException(400, "Invalid date format. Use ISO format (YYYY-MM-DD)")
except Exception as e:
logger.error(f"❌ Error fetching news: {e}")
raise HTTPException(
500, "Internal server error while fetching news"
)
raise HTTPException(500, "Internal server error while fetching news")
@app.get("/feeds", response_model=List[Dict[str, Any]])
@@ -125,7 +192,7 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
"""
try:
db.execute("SELECT * FROM feeds ORDER BY country, url")
return [dict(row) for row in db.fetchall()]
return JSONResponse(content=[dict(row) for row in db.fetchall()])
except Exception as e:
logger.error(f"❌ Error fetching feeds: {e}")
raise HTTPException(
@@ -133,23 +200,6 @@ async def list_feeds(db: sqlite3.Cursor = Depends(get_db)):
)
@app.get("/meta/last-sync", response_model=TimestampResponse)
async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)):
"""
Get the timestamp of the last successful feed synchronization.
Args:
db: Database cursor dependency
Returns:
Object containing the timestamp as a Unix epoch
"""
db.execute("SELECT val FROM meta WHERE key='last_sync'")
row = db.fetchone()
if row is None:
import time
return {"ts": int(time.time())}
return {"ts": int(row["val"])}
@app.post("/feeds", response_model=SuccessResponse)
@@ -271,6 +321,25 @@ async def manual_sync(db: sqlite3.Cursor = Depends(get_db)):
)
@app.get("/meta/last-sync", response_model=TimestampResponse)
async def get_last_sync(db: sqlite3.Cursor = Depends(get_db)):
"""
Get the timestamp of the last successful feed synchronization.
Args:
db: Database cursor dependency
Returns:
Object containing the timestamp as a Unix epoch
"""
db.execute("SELECT val FROM meta WHERE key='last_sync'")
row = db.fetchone()
if row is None:
import time
return {"ts": int(time.time())}
return {"ts": int(row["val"])}
@app.get("/settings/cron", response_model=HoursResponse)
async def get_cron_schedule(db: sqlite3.Cursor = Depends(get_db)):
"""

View File

@@ -4,11 +4,11 @@
CREATE TABLE IF NOT EXISTS news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
summary TEXT,
url TEXT NOT NULL,
published TIMESTAMP NOT NULL,
country TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
-- Index for faster queries on published date

View File

@@ -126,7 +126,7 @@ class NewsFetcher:
def build_prompt(
url: str,
title: str = "",
description: str = "",
summary: str = "",
content: str = "") -> str:
"""
Generate a prompt for the LLM to summarize an article.
@@ -134,7 +134,7 @@ class NewsFetcher:
Args:
url: Public URL of the article to summarize
title: Article title from RSS feed (optional)
description: Article description from RSS feed (optional)
summary: Article summary from RSS feed (optional)
content: Extracted article content (optional)
Returns:
@@ -144,8 +144,8 @@ class NewsFetcher:
context_info = []
if title:
context_info.append(f"RSS-Titel: {title}")
if description:
context_info.append(f"RSS-Beschreibung: {description}")
if summary:
context_info.append(f"RSS-Beschreibung: {summary}")
if content:
content_preview = content[:500] + \
"..." if len(content) > 500 else content
@@ -164,9 +164,9 @@ class NewsFetcher:
"2. Falls kein Artikel-Inhalt verfügbar ist, nutze RSS-Titel und -Beschreibung\n"
"3. Falls keine ausreichenden Informationen vorliegen, erstelle eine plausible Zusammenfassung basierend auf der URL\n"
"4. Gib ausschließlich **gültiges minifiziertes JSON** zurück kein Markdown, keine Kommentare\n"
"5. Struktur: {\"title\":\"\",\"description\":\"\"}\n"
"5. Struktur: {\"title\":\"\",\"summary\":\"\"}\n"
"6. title: Aussagekräftiger deutscher Titel (max 100 Zeichen)\n"
"7. description: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n"
"7. summary: Deutsche Zusammenfassung (zwischen 100 und 160 Wörter)\n"
"8. Kein Text vor oder nach dem JSON\n\n"
"### Ausgabe\n"
"Jetzt antworte mit dem JSON:"
@@ -177,7 +177,7 @@ class NewsFetcher:
client: httpx.AsyncClient,
url: str,
title: str = "",
description: str = ""
summary: str = ""
) -> Optional[ArticleSummary]:
"""
Generate a summary of an article using the LLM.
@@ -187,7 +187,7 @@ class NewsFetcher:
client: An active httpx AsyncClient for making requests
url: URL of the article to summarize
title: Article title from RSS feed
description: Article description from RSS feed
summary: Article summary from RSS feed
Returns:
A dictionary containing the article title and summaries in German and English,
@@ -200,7 +200,7 @@ class NewsFetcher:
f"⚠️ Could not fetch article content, using RSS data only")
prompt = NewsFetcher.build_prompt(
url, title, description, article_content)
url, title, summary, article_content)
payload = {
"model": LLM_MODEL,
"prompt": prompt,
@@ -226,7 +226,7 @@ class NewsFetcher:
summary_data = llm_response
# Validate required fields
required_fields = ["title", "description"]
required_fields = ["title", "summary"]
missing_fields = [
field for field in required_fields if field not in summary_data]
@@ -237,12 +237,12 @@ class NewsFetcher:
return None
# Check summary quality metrics
description = len(summary_data.get("description", "").split())
summary_length = len(summary_data.get("summary", "").split())
if description > 160 or description < 100:
if summary_length > 160:
logger.warning(
f"⚠️ Summary exceeds word limit - "
f"Description: {description}/160"
f"Summary: {summary_length}/160"
)
return cast(ArticleSummary, summary_data)
@@ -373,7 +373,7 @@ class NewsFetcher:
f"⚠️ Database check failed for article {i}, continuing: {db_error}")
rss_title = getattr(entry, 'title', '')
rss_description = getattr(
rss_summary = getattr(
entry, 'description', '') or getattr(
entry, 'summary', '')
@@ -381,9 +381,11 @@ class NewsFetcher:
client,
article_url,
title=rss_title,
description=rss_description
summary=rss_summary
)
logger.info(summary)
if not summary:
logger.warning(
f"❌ Failed to get summary for article {i}: {article_url}")
@@ -398,12 +400,12 @@ class NewsFetcher:
"""
INSERT
OR IGNORE INTO news
(title, description, url, published, country)
(title, summary, url, published, country)
VALUES (?, ?, ?, ?, ?)
""",
(
summary["title"],
summary["description"],
summary["summary"],
article_url,
published_timestamp,
feed_row["country"],