This commit is contained in:
2025-11-12 22:33:56 +01:00
parent 95669fd211
commit 45df834d5b
12 changed files with 1172 additions and 240 deletions

View File

@@ -224,6 +224,43 @@ def send_newsletter():
}), 500 }), 500
@admin_bp.route('/api/admin/recent-articles', methods=['GET'])
def get_recent_articles():
"""Get recently summarized articles"""
try:
from database import articles_collection
# Get last 10 articles with summaries, sorted by when they were summarized
articles = list(articles_collection.find(
{'summary': {'$exists': True, '$ne': None}},
{
'title': 1,
'title_en': 1,
'source': 1,
'category': 1,
'summarized_at': 1,
'created_at': 1,
'summary_word_count': 1,
'_id': 0
}
).sort('summarized_at', -1).limit(10))
# Convert datetime to ISO format
for article in articles:
if 'summarized_at' in article and article['summarized_at']:
article['summarized_at'] = article['summarized_at'].isoformat()
if 'created_at' in article and article['created_at']:
article['created_at'] = article['created_at'].isoformat()
return jsonify({
'articles': articles,
'total': len(articles)
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@admin_bp.route('/api/admin/stats', methods=['GET']) @admin_bp.route('/api/admin/stats', methods=['GET'])
def get_stats(): def get_stats():
"""Get system statistics""" """Get system statistics"""

View File

@@ -1,8 +1,8 @@
"""
RSS Feed management routes
"""
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from datetime import datetime from datetime import datetime
from pymongo.errors import DuplicateKeyError
from bson.objectid import ObjectId
import feedparser
from database import rss_feeds_collection from database import rss_feeds_collection
rss_bp = Blueprint('rss', __name__) rss_bp = Blueprint('rss', __name__)
@@ -10,145 +10,208 @@ rss_bp = Blueprint('rss', __name__)
@rss_bp.route('/api/rss-feeds', methods=['GET']) @rss_bp.route('/api/rss-feeds', methods=['GET'])
def get_rss_feeds(): def get_rss_feeds():
"""Get all RSS feeds, optionally filtered by category""" """Get all RSS feeds"""
try: try:
# Get optional category filter feeds = list(rss_feeds_collection.find(
category = request.args.get('category') {},
{'_id': 0}
).sort('name', 1))
# Build query # Convert datetime to ISO format
query = {} for feed in feeds:
if category: if 'created_at' in feed:
query['category'] = category feed['created_at'] = feed['created_at'].isoformat()
return jsonify({
'feeds': feeds,
'total': len(feeds)
}), 200
cursor = rss_feeds_collection.find(query).sort('created_at', -1)
feeds = []
for feed in cursor:
feeds.append({
'id': str(feed['_id']),
'name': feed.get('name', ''),
'url': feed.get('url', ''),
'category': feed.get('category', 'general'),
'active': feed.get('active', True),
'created_at': feed.get('created_at', '').isoformat() if feed.get('created_at') else ''
})
return jsonify({'feeds': feeds}), 200
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@rss_bp.route('/api/rss-feeds', methods=['POST']) @rss_bp.route('/api/rss-feeds', methods=['POST'])
def add_rss_feed(): def add_rss_feed():
"""Add a new RSS feed with optional category""" """Add a new RSS feed"""
data = request.json
name = data.get('name', '').strip()
url = data.get('url', '').strip()
category = data.get('category', 'general').strip().lower()
if not name or not url:
return jsonify({'error': 'Name and URL are required'}), 400
if not url.startswith('http://') and not url.startswith('https://'):
return jsonify({'error': 'URL must start with http:// or https://'}), 400
# Validate category (optional, but if provided should be reasonable)
valid_categories = ['general', 'local', 'politics', 'sports', 'culture', 'business', 'technology', 'entertainment']
if category and category not in valid_categories:
# Allow custom categories but warn
pass
try: try:
# Test if the RSS feed is valid data = request.get_json()
try:
feed = feedparser.parse(url)
if not feed.entries:
return jsonify({'error': 'Invalid RSS feed or no entries found'}), 400
except Exception as e:
return jsonify({'error': f'Failed to parse RSS feed: {str(e)}'}), 400
# Validate required fields
if not data.get('name'):
return jsonify({'error': 'Feed name is required'}), 400
if not data.get('url'):
return jsonify({'error': 'Feed URL is required'}), 400
if not data.get('category'):
return jsonify({'error': 'Category is required'}), 400
# Category validation - accept any string, but recommend common ones
category = data['category'].strip().lower()
if not category:
return jsonify({'error': 'Category cannot be empty'}), 400
data['category'] = category # Normalize to lowercase
# Check if feed already exists
existing = rss_feeds_collection.find_one({'url': data['url']})
if existing:
return jsonify({'error': 'Feed URL already exists'}), 400
# Create feed document
feed_doc = { feed_doc = {
'name': name, 'name': data['name'].strip(),
'url': url, 'url': data['url'].strip(),
'category': category, 'category': data['category'],
'active': True, 'active': data.get('active', True),
'created_at': datetime.utcnow() 'created_at': datetime.utcnow()
} }
try: rss_feeds_collection.insert_one(feed_doc)
result = rss_feeds_collection.insert_one(feed_doc)
return jsonify({ return jsonify({
'message': 'RSS feed added successfully', 'message': 'RSS feed added successfully',
'id': str(result.inserted_id), 'feed': {
'category': category 'name': feed_doc['name'],
}), 201 'url': feed_doc['url'],
except DuplicateKeyError: 'category': feed_doc['category'],
return jsonify({'error': 'RSS feed URL already exists'}), 409 'active': feed_doc['active']
}
}), 201
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@rss_bp.route('/api/rss-feeds/<feed_id>', methods=['DELETE']) @rss_bp.route('/api/rss-feeds/<feed_name>', methods=['PUT'])
def remove_rss_feed(feed_id): def update_rss_feed(feed_name):
"""Remove an RSS feed""" """Update an RSS feed"""
try: try:
# Validate ObjectId data = request.get_json()
try:
obj_id = ObjectId(feed_id)
except Exception:
return jsonify({'error': 'Invalid feed ID'}), 400
result = rss_feeds_collection.delete_one({'_id': obj_id}) # Build update document
update_doc = {}
if 'url' in data:
update_doc['url'] = data['url'].strip()
if 'category' in data:
category = data['category'].strip().lower()
if not category:
return jsonify({'error': 'Category cannot be empty'}), 400
update_doc['category'] = category
if 'active' in data:
update_doc['active'] = bool(data['active'])
if result.deleted_count > 0: if not update_doc:
return jsonify({'message': 'RSS feed removed successfully'}), 200 return jsonify({'error': 'No fields to update'}), 400
else:
return jsonify({'error': 'RSS feed not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@rss_bp.route('/api/rss-feeds/<feed_id>/toggle', methods=['PATCH'])
def toggle_rss_feed(feed_id):
"""Toggle RSS feed active status"""
try:
# Validate ObjectId
try:
obj_id = ObjectId(feed_id)
except Exception:
return jsonify({'error': 'Invalid feed ID'}), 400
# Get current status
feed = rss_feeds_collection.find_one({'_id': obj_id})
if not feed:
return jsonify({'error': 'RSS feed not found'}), 404
# Toggle status
new_status = not feed.get('active', True)
result = rss_feeds_collection.update_one( result = rss_feeds_collection.update_one(
{'_id': obj_id}, {'name': feed_name},
{'$set': {'active': new_status}} {'$set': update_doc}
) )
if result.modified_count > 0: if result.matched_count > 0:
return jsonify({ return jsonify({'message': 'RSS feed updated successfully'}), 200
'message': f'RSS feed {"activated" if new_status else "deactivated"} successfully',
'active': new_status
}), 200
else: else:
return jsonify({'error': 'Failed to update RSS feed'}), 500 return jsonify({'error': 'Feed not found'}), 404
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@rss_bp.route('/api/rss-feeds/categories', methods=['GET']) @rss_bp.route('/api/rss-feeds/<feed_name>', methods=['DELETE'])
def get_categories(): def delete_rss_feed(feed_name):
"""Get all unique categories from RSS feeds""" """Delete an RSS feed"""
try: try:
categories = rss_feeds_collection.distinct('category') result = rss_feeds_collection.delete_one({'name': feed_name})
# Filter out None/empty and sort
categories = sorted([c for c in categories if c]) if result.deleted_count > 0:
return jsonify({'categories': categories}), 200 return jsonify({'message': f'RSS feed "{feed_name}" deleted successfully'}), 200
else:
return jsonify({'error': 'Feed not found'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@rss_bp.route('/api/rss-feeds/export', methods=['GET'])
def export_rss_feeds():
"""Export all RSS feeds as JSON"""
try:
feeds = list(rss_feeds_collection.find(
{},
{'_id': 0}
).sort('name', 1))
# Convert datetime to ISO format
for feed in feeds:
if 'created_at' in feed:
feed['created_at'] = feed['created_at'].isoformat()
return jsonify({
'feeds': feeds,
'total': len(feeds),
'exported_at': datetime.utcnow().isoformat()
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@rss_bp.route('/api/rss-feeds/import', methods=['POST'])
def import_rss_feeds():
"""Import RSS feeds from JSON"""
try:
data = request.get_json()
if not data or 'feeds' not in data:
return jsonify({'error': 'Invalid import data. Expected {feeds: [...]}'}), 400
feeds = data['feeds']
if not isinstance(feeds, list):
return jsonify({'error': 'feeds must be an array'}), 400
imported = 0
skipped = 0
errors = []
for feed in feeds:
try:
# Validate required fields
if not feed.get('name') or not feed.get('url') or not feed.get('category'):
errors.append(f"Skipped invalid feed: {feed.get('name', 'unknown')}")
skipped += 1
continue
# Check if feed already exists
existing = rss_feeds_collection.find_one({'url': feed['url']})
if existing:
errors.append(f"Skipped duplicate: {feed['name']}")
skipped += 1
continue
# Create feed document
feed_doc = {
'name': feed['name'].strip(),
'url': feed['url'].strip(),
'category': feed['category'].strip().lower(),
'active': feed.get('active', True),
'created_at': datetime.utcnow()
}
rss_feeds_collection.insert_one(feed_doc)
imported += 1
except Exception as e:
errors.append(f"Error importing {feed.get('name', 'unknown')}: {str(e)}")
skipped += 1
return jsonify({
'message': f'Import complete: {imported} imported, {skipped} skipped',
'imported': imported,
'skipped': skipped,
'errors': errors if errors else None
}), 200
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500

View File

@@ -8,36 +8,69 @@ subscription_bp = Blueprint('subscription', __name__)
@subscription_bp.route('/api/subscribe', methods=['POST']) @subscription_bp.route('/api/subscribe', methods=['POST'])
def subscribe(): def subscribe():
"""Subscribe a user to the newsletter""" """Subscribe a user to the newsletter with category preferences"""
data = request.json data = request.json
email = data.get('email', '').strip().lower() email = data.get('email', '').strip().lower()
# Get valid categories from RSS feeds
from database import rss_feeds_collection
valid_categories = rss_feeds_collection.distinct('category')
categories = data.get('categories', valid_categories) # Default: all categories
if not email or '@' not in email: if not email or '@' not in email:
return jsonify({'error': 'Invalid email address'}), 400 return jsonify({'error': 'Invalid email address'}), 400
# Validate categories
if not isinstance(categories, list) or not categories:
categories = valid_categories # Default to all if invalid
else:
# Filter to only valid categories (categories that exist in RSS feeds)
categories = [c for c in categories if c in valid_categories]
if not categories:
categories = valid_categories # Default to all if none valid
try: try:
subscriber_doc = { subscriber_doc = {
'email': email, 'email': email,
'subscribed_at': datetime.utcnow(), 'subscribed_at': datetime.utcnow(),
'status': 'active' 'status': 'active',
'categories': categories
} }
# Try to insert, if duplicate key error, subscriber already exists # Try to insert, if duplicate key error, subscriber already exists
try: try:
subscribers_collection.insert_one(subscriber_doc) subscribers_collection.insert_one(subscriber_doc)
return jsonify({'message': 'Successfully subscribed!'}), 201 return jsonify({
'message': 'Successfully subscribed!',
'categories': categories
}), 201
except DuplicateKeyError: except DuplicateKeyError:
# Check if subscriber is active # Check if subscriber is active
existing = subscribers_collection.find_one({'email': email}) existing = subscribers_collection.find_one({'email': email})
if existing and existing.get('status') == 'active': if existing and existing.get('status') == 'active':
return jsonify({'message': 'Email already subscribed'}), 200 # Update categories even if already subscribed
subscribers_collection.update_one(
{'email': email},
{'$set': {'categories': categories}}
)
return jsonify({
'message': 'Email already subscribed. Preferences updated!',
'categories': categories
}), 200
else: else:
# Reactivate if previously unsubscribed # Reactivate if previously unsubscribed
subscribers_collection.update_one( subscribers_collection.update_one(
{'email': email}, {'email': email},
{'$set': {'status': 'active', 'subscribed_at': datetime.utcnow()}} {'$set': {
'status': 'active',
'subscribed_at': datetime.utcnow(),
'categories': categories
}}
) )
return jsonify({'message': 'Successfully re-subscribed!'}), 200 return jsonify({
'message': 'Successfully re-subscribed!',
'categories': categories
}), 200
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@@ -95,6 +128,34 @@ def list_subscribers():
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@subscription_bp.route('/api/subscribers/<email>', methods=['GET'])
def get_subscriber(email):
"""Get a single subscriber's information"""
try:
email = email.strip().lower()
subscriber = subscribers_collection.find_one(
{'email': email},
{'_id': 0, 'email': 1, 'categories': 1, 'status': 1, 'subscribed_at': 1}
)
if subscriber:
# Convert datetime to ISO format
if 'subscribed_at' in subscriber:
subscriber['subscribed_at'] = subscriber['subscribed_at'].isoformat()
# Ensure categories field exists
if 'categories' not in subscriber:
subscriber['categories'] = ['general', 'local', 'sports']
return jsonify(subscriber), 200
else:
return jsonify({'error': 'Email not found in subscribers'}), 404
except Exception as e:
return jsonify({'error': str(e)}), 500
@subscription_bp.route('/api/subscribers/<email>', methods=['DELETE']) @subscription_bp.route('/api/subscribers/<email>', methods=['DELETE'])
def remove_subscriber(email): def remove_subscriber(email):
"""Permanently remove a subscriber from the database""" """Permanently remove a subscriber from the database"""
@@ -110,3 +171,68 @@ def remove_subscriber(email):
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@subscription_bp.route('/api/admin/subscribers/delete-all', methods=['DELETE'])
def delete_all_subscribers():
"""Delete all subscribers (admin only)"""
try:
result = subscribers_collection.delete_many({})
return jsonify({
'message': f'Successfully deleted {result.deleted_count} subscribers',
'deleted_count': result.deleted_count
}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@subscription_bp.route('/api/categories', methods=['GET'])
def get_categories():
"""Get available newsletter categories from RSS feeds"""
from database import rss_feeds_collection
# Get unique categories from RSS feeds
categories_from_db = rss_feeds_collection.distinct('category')
# Category metadata
category_info = {
'general': {
'name': 'Top Trending',
'description': 'Top trending news and updates',
'icon': '🔥'
},
'local': {
'name': 'Local Events',
'description': 'Local events, culture, and community news',
'icon': '🏛️'
},
'sports': {
'name': 'Sports',
'description': 'Sports news and updates',
'icon': ''
},
'science': {
'name': 'Science & Tech',
'description': 'Science and technology news',
'icon': '🔬'
}
}
# Build category list
categories = []
for cat_id in sorted(categories_from_db):
info = category_info.get(cat_id, {
'name': cat_id.title(),
'description': f'{cat_id.title()} news',
'icon': '📄'
})
categories.append({
'id': cat_id,
'name': info['name'],
'description': info['description'],
'icon': info['icon']
})
return jsonify({'categories': categories}), 200

56
fix-categories.js Normal file
View File

@@ -0,0 +1,56 @@
// MongoDB script to fix article categories
// Run with: docker exec -i munich-news-mongodb mongosh -u admin -p changeme --authenticationDatabase admin < fix-categories.js
use munich_news
print("=== RSS Feeds and their categories ===");
db.rss_feeds.find({}, {name: 1, category: 1, _id: 0}).forEach(feed => {
print(`${feed.name}: ${feed.category || 'NO CATEGORY'}`);
});
print("\n=== Current article category distribution ===");
db.articles.aggregate([
{$group: {_id: "$category", count: {$sum: 1}}},
{$sort: {count: -1}}
]).forEach(result => {
print(`${result._id || 'null'}: ${result.count} articles`);
});
print("\n=== Fixing null categories ===");
// Update articles based on their RSS feed source
var feedsUpdated = 0;
db.rss_feeds.find().forEach(function(feed) {
if (feed.category) {
var result = db.articles.updateMany(
{source: feed.name, category: null},
{$set: {category: feed.category}}
);
if (result.modifiedCount > 0) {
print(`Updated ${result.modifiedCount} articles from ${feed.name} to category: ${feed.category}`);
feedsUpdated += result.modifiedCount;
}
}
});
// Set remaining null categories to 'general'
var remainingNull = db.articles.updateMany(
{category: null},
{$set: {category: "general"}}
);
if (remainingNull.modifiedCount > 0) {
print(`Set ${remainingNull.modifiedCount} remaining null articles to 'general'`);
}
print(`\nTotal articles updated: ${feedsUpdated + remainingNull.modifiedCount}`);
print("\n=== Updated article category distribution ===");
db.articles.aggregate([
{$group: {_id: "$category", count: {$sum: 1}}},
{$sort: {count: -1}}
]).forEach(result => {
print(`${result._id || 'null'}: ${result.count} articles`);
});
print("\n✓ Done!");

View File

@@ -226,6 +226,54 @@
<h3>🔗 AI Clustering & Aggregation</h3> <h3>🔗 AI Clustering & Aggregation</h3>
<div id="clusteringStats" class="loading">Loading...</div> <div id="clusteringStats" class="loading">Loading...</div>
</div> </div>
<!-- Subscriber Management -->
<div class="card">
<h3>👥 Subscriber Management</h3>
<div style="margin-bottom: 20px;">
<button onclick="viewSubscribers()" class="btn btn-primary" style="margin-right: 10px;">
📋 View All Subscribers
</button>
<button onclick="deleteAllSubscribers()" class="btn btn-danger">
🗑️ Delete All Subscribers
</button>
</div>
<div id="subscriberList"></div>
<div id="subscriberMessage"></div>
</div>
<!-- RSS Feed Management -->
<div class="card">
<h3>📡 RSS Feed Management</h3>
<div style="margin-bottom: 20px;">
<button onclick="exportRSSFeeds()" class="btn btn-primary" style="margin-right: 10px;">
📥 Export RSS Feeds
</button>
<label class="btn btn-primary" style="margin-right: 10px; cursor: pointer;">
📤 Import RSS Feeds
<input type="file" id="rssImportFile" accept=".json" style="display: none;" onchange="importRSSFeeds(event)">
</label>
<button onclick="viewRSSFeeds()" class="btn btn-secondary">
📋 View All Feeds
</button>
</div>
<div id="rssFeedList"></div>
<div id="rssFeedMessage"></div>
</div>
<!-- Recent Summarization Activity -->
<div class="card">
<h3>🤖 Recent AI Summarization Activity</h3>
<div style="margin-bottom: 15px;">
<button onclick="loadRecentArticles()" class="btn btn-primary">
🔄 Refresh
</button>
<label style="margin-left: 15px;">
<input type="checkbox" id="autoRefresh" onchange="toggleAutoRefresh()"> Auto-refresh (10s)
</label>
</div>
<div id="recentArticles" class="loading">Loading...</div>
</div>
</div> </div>
<script src="admin.js"></script> <script src="admin.js"></script>

View File

@@ -313,3 +313,262 @@ async function loadClusteringStats() {
document.getElementById('clusteringStats').innerHTML = `<div class="error">Error: ${error.message}</div>`; document.getElementById('clusteringStats').innerHTML = `<div class="error">Error: ${error.message}</div>`;
} }
} }
// Subscriber Management
async function viewSubscribers() {
const listDiv = document.getElementById('subscriberList');
const messageDiv = document.getElementById('subscriberMessage');
listDiv.innerHTML = '<p class="loading">Loading subscribers...</p>';
messageDiv.innerHTML = '';
try {
const response = await fetch('/api/subscribers');
const data = await response.json();
if (data.subscribers && data.subscribers.length > 0) {
let html = '<div style="max-height: 400px; overflow-y: auto; margin-top: 15px;">';
html += '<table style="width: 100%; border-collapse: collapse;">';
html += '<thead><tr style="background: #f5f5f5; position: sticky; top: 0;"><th style="padding: 10px; text-align: left;">Email</th><th style="padding: 10px; text-align: left;">Categories</th><th style="padding: 10px; text-align: left;">Status</th></tr></thead>';
html += '<tbody>';
data.subscribers.forEach(sub => {
const categories = sub.categories ? sub.categories.join(', ') : 'All';
html += `<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px;">${sub.email}</td>
<td style="padding: 10px;">${categories}</td>
<td style="padding: 10px;"><span style="color: ${sub.status === 'active' ? 'green' : 'red'};">${sub.status}</span></td>
</tr>`;
});
html += '</tbody></table></div>';
html += `<p style="margin-top: 10px; color: #666;">Total: ${data.total} subscribers</p>`;
listDiv.innerHTML = html;
} else {
listDiv.innerHTML = '<p style="color: #666;">No subscribers found.</p>';
}
} catch (error) {
listDiv.innerHTML = '<p style="color: red;">Failed to load subscribers</p>';
}
}
async function deleteAllSubscribers() {
const messageDiv = document.getElementById('subscriberMessage');
if (!confirm('⚠️ Are you sure you want to delete ALL subscribers? This cannot be undone!')) {
return;
}
if (!confirm('⚠️ FINAL WARNING: This will permanently delete all subscriber data. Continue?')) {
return;
}
messageDiv.innerHTML = '<p class="loading">Deleting all subscribers...</p>';
try {
const response = await fetch('/api/admin/subscribers/delete-all', {
method: 'DELETE'
});
const data = await response.json();
if (response.ok) {
messageDiv.innerHTML = `<p style="color: green;">✓ ${data.message}</p>`;
document.getElementById('subscriberList').innerHTML = '';
// Refresh stats
loadStats();
} else {
messageDiv.innerHTML = `<p style="color: red;">✗ ${data.error || 'Failed to delete subscribers'}</p>`;
}
} catch (error) {
messageDiv.innerHTML = '<p style="color: red;">✗ Network error</p>';
}
}
// RSS Feed Management
async function exportRSSFeeds() {
const messageDiv = document.getElementById('rssFeedMessage');
messageDiv.innerHTML = '<p class="loading">Exporting RSS feeds...</p>';
try {
const response = await fetch('/api/rss-feeds/export');
const data = await response.json();
if (response.ok) {
// Create download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `rss-feeds-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
messageDiv.innerHTML = `<p style="color: green;">✓ Exported ${data.total} RSS feeds</p>`;
} else {
messageDiv.innerHTML = `<p style="color: red;">✗ ${data.error || 'Export failed'}</p>`;
}
} catch (error) {
messageDiv.innerHTML = '<p style="color: red;">✗ Network error</p>';
}
}
async function importRSSFeeds(event) {
const messageDiv = document.getElementById('rssFeedMessage');
const file = event.target.files[0];
if (!file) return;
messageDiv.innerHTML = '<p class="loading">Importing RSS feeds...</p>';
try {
const text = await file.text();
const data = JSON.parse(text);
const response = await fetch('/api/rss-feeds/import', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
let html = `<p style="color: green;">✓ ${result.message}</p>`;
if (result.errors && result.errors.length > 0) {
html += '<details style="margin-top: 10px;"><summary>Show details</summary><ul>';
result.errors.forEach(err => {
html += `<li style="color: orange;">${err}</li>`;
});
html += '</ul></details>';
}
messageDiv.innerHTML = html;
// Refresh feed list if visible
if (document.getElementById('rssFeedList').innerHTML) {
viewRSSFeeds();
}
} else {
messageDiv.innerHTML = `<p style="color: red;">✗ ${result.error || 'Import failed'}</p>`;
}
} catch (error) {
messageDiv.innerHTML = `<p style="color: red;">✗ Error: ${error.message}</p>`;
}
// Reset file input
event.target.value = '';
}
async function viewRSSFeeds() {
const listDiv = document.getElementById('rssFeedList');
const messageDiv = document.getElementById('rssFeedMessage');
listDiv.innerHTML = '<p class="loading">Loading RSS feeds...</p>';
messageDiv.innerHTML = '';
try {
const response = await fetch('/api/rss-feeds');
const data = await response.json();
if (data.feeds && data.feeds.length > 0) {
let html = '<div style="max-height: 400px; overflow-y: auto; margin-top: 15px;">';
html += '<table style="width: 100%; border-collapse: collapse;">';
html += '<thead><tr style="background: #f5f5f5; position: sticky; top: 0;"><th style="padding: 10px; text-align: left;">Name</th><th style="padding: 10px; text-align: left;">Category</th><th style="padding: 10px; text-align: left;">URL</th><th style="padding: 10px; text-align: left;">Status</th></tr></thead>';
html += '<tbody>';
data.feeds.forEach(feed => {
const statusColor = feed.active ? 'green' : 'gray';
const statusText = feed.active ? 'Active' : 'Inactive';
html += `<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 10px;">${feed.name}</td>
<td style="padding: 10px;">${feed.category}</td>
<td style="padding: 10px; font-size: 12px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${feed.url}</td>
<td style="padding: 10px;"><span style="color: ${statusColor};">${statusText}</span></td>
</tr>`;
});
html += '</tbody></table></div>';
html += `<p style="margin-top: 10px; color: #666;">Total: ${data.total} feeds</p>`;
listDiv.innerHTML = html;
} else {
listDiv.innerHTML = '<p style="color: #666;">No RSS feeds found.</p>';
}
} catch (error) {
listDiv.innerHTML = '<p style="color: red;">Failed to load RSS feeds</p>';
}
}
// Recent Summarization Activity
let autoRefreshInterval = null;
async function loadRecentArticles() {
const container = document.getElementById('recentArticles');
try {
const response = await fetch('/api/admin/recent-articles');
const data = await response.json();
if (data.articles && data.articles.length > 0) {
let html = '<div style="max-height: 500px; overflow-y: auto;">';
html += '<table style="width: 100%; border-collapse: collapse; font-size: 14px;">';
html += '<thead><tr style="background: #f5f5f5; position: sticky; top: 0;"><th style="padding: 8px; text-align: left;">Time</th><th style="padding: 8px; text-align: left;">Title</th><th style="padding: 8px; text-align: left;">Source</th><th style="padding: 8px; text-align: left;">Category</th><th style="padding: 8px; text-align: right;">Words</th></tr></thead>';
html += '<tbody>';
data.articles.forEach(article => {
const time = article.summarized_at ? new Date(article.summarized_at).toLocaleTimeString() : 'N/A';
const title = article.title_en || article.title;
const categoryColors = {
'general': '#667eea',
'local': '#f59e0b',
'sports': '#10b981',
'science': '#8b5cf6'
};
const categoryColor = categoryColors[article.category] || '#6b7280';
html += `<tr style="border-bottom: 1px solid #eee;">
<td style="padding: 8px; white-space: nowrap; color: #666;">${time}</td>
<td style="padding: 8px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${title}">${title}</td>
<td style="padding: 8px;">${article.source}</td>
<td style="padding: 8px;"><span style="background: ${categoryColor}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px;">${article.category || 'N/A'}</span></td>
<td style="padding: 8px; text-align: right;">${article.summary_word_count || 'N/A'}</td>
</tr>`;
});
html += '</tbody></table></div>';
html += `<p style="margin-top: 10px; color: #666; font-size: 12px;">Last updated: ${new Date().toLocaleTimeString()}</p>`;
container.innerHTML = html;
} else {
container.innerHTML = '<p style="color: #666;">No summarized articles found.</p>';
}
} catch (error) {
container.innerHTML = '<p style="color: red;">Failed to load recent articles</p>';
}
}
function toggleAutoRefresh() {
const checkbox = document.getElementById('autoRefresh');
if (checkbox.checked) {
// Start auto-refresh every 10 seconds
loadRecentArticles();
autoRefreshInterval = setInterval(loadRecentArticles, 10000);
} else {
// Stop auto-refresh
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval);
autoRefreshInterval = null;
}
}
}
// Load recent articles on page load
document.addEventListener('DOMContentLoaded', () => {
loadRecentArticles();
});

View File

@@ -11,8 +11,32 @@ document.addEventListener('DOMContentLoaded', () => {
loadNews(); loadNews();
loadStats(); loadStats();
setupInfiniteScroll(); setupInfiniteScroll();
loadCategories();
}); });
async function loadCategories() {
try {
const response = await fetch('/api/categories');
const data = await response.json();
const categories = data.categories || [];
const container = document.getElementById('categoryCheckboxes');
container.innerHTML = '';
categories.forEach(category => {
const label = document.createElement('label');
label.className = 'flex items-center space-x-3 cursor-pointer';
label.innerHTML = `
<input type="checkbox" value="${category.id}" checked class="w-5 h-5 rounded border-2 border-white/30 bg-white/20 checked:bg-white checked:border-white focus:ring-2 focus:ring-white/50">
<span class="text-white text-sm">${category.icon} ${category.name}</span>
`;
container.appendChild(label);
});
} catch (error) {
console.error('Failed to load categories:', error);
}
}
async function loadNews() { async function loadNews() {
const newsGrid = document.getElementById('newsGrid'); const newsGrid = document.getElementById('newsGrid');
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Loading news...</div>'; newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Loading news...</div>';
@@ -291,6 +315,16 @@ async function subscribe() {
return; return;
} }
// Get selected categories
const checkboxes = document.querySelectorAll('#categoryCheckboxes input[type="checkbox"]:checked');
const categories = Array.from(checkboxes).map(cb => cb.value);
if (categories.length === 0) {
formMessage.textContent = 'Please select at least one category';
formMessage.className = 'text-red-200 font-medium';
return;
}
subscribeBtn.disabled = true; subscribeBtn.disabled = true;
subscribeBtn.textContent = 'Subscribing...'; subscribeBtn.textContent = 'Subscribing...';
subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed'); subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed');
@@ -302,7 +336,10 @@ async function subscribe() {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ email: email }) body: JSON.stringify({
email: email,
categories: categories
})
}); });
const data = await response.json(); const data = await response.json();

View File

@@ -41,6 +41,15 @@
class="w-full px-6 py-4 rounded-lg text-gray-800 text-lg focus:outline-none focus:ring-4 focus:ring-white/30 shadow-lg" class="w-full px-6 py-4 rounded-lg text-gray-800 text-lg focus:outline-none focus:ring-4 focus:ring-white/30 shadow-lg"
required required
> >
<!-- Category Selection -->
<div class="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<p class="text-white font-semibold mb-3 text-sm">Choose your interests:</p>
<div class="space-y-2" id="categoryCheckboxes">
<!-- Categories will be loaded dynamically -->
</div>
</div>
<button <button
id="subscribeBtn" id="subscribeBtn"
onclick="subscribe()" onclick="subscribe()"

View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Preferences - Munich News Daily</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#667eea',
secondary: '#764ba2',
}
}
}
}
</script>
</head>
<body class="bg-gradient-to-br from-primary to-secondary min-h-screen">
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<header class="text-center text-white py-12">
<h1 class="text-4xl sm:text-5xl font-extrabold mb-4">📰 Munich News Daily</h1>
<p class="text-xl opacity-95">Manage Your Preferences</p>
</header>
<!-- Preferences Form -->
<div class="bg-white rounded-2xl shadow-2xl p-8 mb-8">
<div id="loadingState" class="text-center py-10">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<p class="mt-4 text-gray-600">Loading your preferences...</p>
</div>
<div id="emailForm" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Enter Your Email</h2>
<p class="text-gray-600 mb-6">Enter your email address to manage your newsletter preferences.</p>
<input
type="email"
id="emailInput"
placeholder="your@email.com"
class="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:border-primary mb-4"
>
<button
onclick="loadPreferences()"
class="w-full px-6 py-3 bg-primary hover:bg-secondary text-white font-semibold rounded-lg transition"
>
Load Preferences
</button>
<p id="emailError" class="mt-4 text-sm text-red-600 hidden"></p>
</div>
<div id="preferencesForm" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-2">Your Preferences</h2>
<p class="text-gray-600 mb-6">Email: <strong id="userEmail"></strong></p>
<div class="mb-6">
<h3 class="text-lg font-semibold text-gray-800 mb-3">Choose your interests:</h3>
<div class="space-y-3" id="categoryCheckboxes">
<!-- Categories will be loaded dynamically -->
</div>
</div>
<button
onclick="savePreferences()"
class="w-full px-6 py-3 bg-primary hover:bg-secondary text-white font-semibold rounded-lg transition mb-3"
>
Save Preferences
</button>
<button
onclick="showEmailForm()"
class="w-full px-6 py-3 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition"
>
Change Email
</button>
<p id="saveMessage" class="mt-4 text-sm min-h-[20px]"></p>
</div>
</div>
<!-- Back to Home -->
<div class="text-center">
<a href="/" class="text-white underline hover:opacity-80">← Back to Home</a>
</div>
</div>
<script>
let currentEmail = '';
let availableCategories = [];
// Load categories from API
async function loadCategories() {
try {
const response = await fetch('/api/categories');
const data = await response.json();
availableCategories = data.categories || [];
renderCategories();
} catch (error) {
console.error('Failed to load categories:', error);
// Fallback to default categories
availableCategories = [
{ id: 'general', name: 'Top Trending', description: 'Top trending news and updates', icon: '🔥' },
{ id: 'local', name: 'Local Events', description: 'Local events and community news', icon: '🏛️' },
{ id: 'sports', name: 'Sports', description: 'Sports news', icon: '⚽' }
];
renderCategories();
}
}
function renderCategories(selectedCategories = []) {
const container = document.getElementById('categoryCheckboxes');
container.innerHTML = '';
availableCategories.forEach(category => {
const isChecked = selectedCategories.length === 0 || selectedCategories.includes(category.id);
const label = document.createElement('label');
label.className = 'flex items-center space-x-3 p-3 border-2 border-gray-200 rounded-lg cursor-pointer hover:border-primary transition';
label.innerHTML = `
<input type="checkbox" value="${category.id}" ${isChecked ? 'checked' : ''} class="w-5 h-5 text-primary rounded focus:ring-2 focus:ring-primary">
<div>
<div class="font-semibold text-gray-800">${category.icon} ${category.name}</div>
<div class="text-sm text-gray-500">${category.description}</div>
</div>
`;
container.appendChild(label);
});
}
// Check if email is in URL parameter
window.addEventListener('DOMContentLoaded', async () => {
// Load categories first
await loadCategories();
const urlParams = new URLSearchParams(window.location.search);
const email = urlParams.get('email');
if (email) {
document.getElementById('emailInput').value = email;
loadPreferences();
} else {
showEmailForm();
}
});
function showEmailForm() {
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('emailForm').classList.remove('hidden');
document.getElementById('preferencesForm').classList.add('hidden');
}
async function loadPreferences() {
const emailInput = document.getElementById('emailInput');
const email = emailInput.value.trim().toLowerCase();
const emailError = document.getElementById('emailError');
if (!email || !email.includes('@')) {
emailError.textContent = 'Please enter a valid email address';
emailError.classList.remove('hidden');
return;
}
emailError.classList.add('hidden');
currentEmail = email;
// Show loading
document.getElementById('emailForm').classList.add('hidden');
document.getElementById('loadingState').classList.remove('hidden');
try {
// Try to get subscriber info (we'll need to add this endpoint)
const response = await fetch(`/api/subscribers/${encodeURIComponent(email)}`);
if (response.ok) {
const data = await response.json();
const categories = data.categories || [];
// Update UI
document.getElementById('userEmail').textContent = email;
// Render categories with user's selections
renderCategories(categories);
// Show preferences form
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('preferencesForm').classList.remove('hidden');
} else if (response.status === 404) {
// Email not found - show preferences form with all categories checked (new subscription)
document.getElementById('userEmail').textContent = email;
// Render all categories checked by default
renderCategories([]);
// Show preferences form
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('preferencesForm').classList.remove('hidden');
// Show info message
const saveMessage = document.getElementById('saveMessage');
saveMessage.textContent = 'New subscriber - select your preferences and save to subscribe!';
saveMessage.className = 'mt-4 text-sm text-blue-600';
} else {
throw new Error('Failed to load preferences');
}
} catch (error) {
emailError.textContent = 'Error loading preferences. Please try again.';
emailError.classList.remove('hidden');
showEmailForm();
}
}
async function savePreferences() {
const saveMessage = document.getElementById('saveMessage');
const checkboxes = document.querySelectorAll('#preferencesForm input[type="checkbox"]:checked');
const categories = Array.from(checkboxes).map(cb => cb.value);
if (categories.length === 0) {
saveMessage.textContent = 'Please select at least one category';
saveMessage.className = 'mt-4 text-sm text-red-600';
return;
}
saveMessage.textContent = 'Saving...';
saveMessage.className = 'mt-4 text-sm text-gray-600';
try {
const response = await fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: currentEmail,
categories: categories
})
});
const data = await response.json();
if (response.ok) {
saveMessage.textContent = '✓ Preferences saved successfully!';
saveMessage.className = 'mt-4 text-sm text-green-600 font-semibold';
} else {
saveMessage.textContent = data.error || 'Failed to save preferences';
saveMessage.className = 'mt-4 text-sm text-red-600';
}
} catch (error) {
saveMessage.textContent = 'Network error. Please try again.';
saveMessage.className = 'mt-4 text-sm text-red-600';
}
}
</script>
</body>
</html>

View File

@@ -17,6 +17,15 @@ app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html')); res.sendFile(path.join(__dirname, 'public', 'admin.html'));
}); });
// Serve preferences page
app.get('/preferences.html', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'preferences.html'));
});
app.get('/preferences', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'preferences.html'));
});
// Serve static files // Serve static files
app.use(express.static('public')); app.use(express.static('public'));
@@ -61,6 +70,83 @@ app.post('/api/unsubscribe', async (req, res) => {
} }
}); });
app.get('/api/subscribers/:email', async (req, res) => {
try {
const response = await axios.get(`${API_URL}/api/subscribers/${req.params.email}`);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to get subscriber' }
);
}
});
app.get('/api/categories', async (req, res) => {
try {
const response = await axios.get(`${API_URL}/api/categories`);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to get categories' }
);
}
});
app.delete('/api/admin/subscribers/delete-all', async (req, res) => {
try {
const response = await axios.delete(`${API_URL}/api/admin/subscribers/delete-all`);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to delete subscribers' }
);
}
});
app.get('/api/rss-feeds', async (req, res) => {
try {
const response = await axios.get(`${API_URL}/api/rss-feeds`);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to get RSS feeds' }
);
}
});
app.get('/api/rss-feeds/export', async (req, res) => {
try {
const response = await axios.get(`${API_URL}/api/rss-feeds/export`);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to export RSS feeds' }
);
}
});
app.post('/api/rss-feeds/import', async (req, res) => {
try {
const response = await axios.post(`${API_URL}/api/rss-feeds/import`, req.body);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to import RSS feeds' }
);
}
});
app.get('/api/admin/recent-articles', async (req, res) => {
try {
const response = await axios.get(`${API_URL}/api/admin/recent-articles`);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json(
error.response?.data || { error: 'Failed to get recent articles' }
);
}
});
// Ollama API proxy endpoints for admin dashboard // Ollama API proxy endpoints for admin dashboard
app.get('/api/ollama/ping', async (req, res) => { app.get('/api/ollama/ping', async (req, res) => {
try { try {

View File

@@ -83,18 +83,18 @@
</td> </td>
</tr> </tr>
<!-- Top Trending Section --> <!-- Category Sections -->
{% if trending_articles %} {% for section in category_sections %}
<tr> <tr>
<td style="padding: 30px 40px 15px 40px;"> <td style="padding: 30px 40px 15px 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr> <tr>
<td> <td>
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: #1a1a1a; display: flex; align-items: center;"> <h2 style="margin: 0; font-size: 22px; font-weight: 700; color: #1a1a1a;">
🔥 Top Trending in Munich {{ section.icon }} {{ section.name }}
</h2> </h2>
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666666;"> <p style="margin: 8px 0 0 0; font-size: 13px; color: #666666;">
The most talked-about stories today Top stories in {{ section.name.lower() }}
</p> </p>
</td> </td>
</tr> </tr>
@@ -102,8 +102,8 @@
</td> </td>
</tr> </tr>
<!-- Trending Articles --> <!-- Category Articles -->
{% for article in trending_articles %} {% for article in section.articles %}
<tr> <tr>
<td style="padding: 25px 40px;"> <td style="padding: 25px 40px;">
<!-- Article Number Badge --> <!-- Article Number Badge -->
@@ -177,110 +177,16 @@
</tr> </tr>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %}
<!-- Other Articles Section --> <!-- Category Section Divider -->
{% if other_articles %} {% if not loop.last %}
<!-- Section Divider -->
<tr> <tr>
<td style="padding: 25px 40px;"> <td style="padding: 25px 40px;">
<div style="height: 2px; background-color: #e0e0e0;"></div> <div style="height: 2px; background-color: #e0e0e0;"></div>
</td> </td>
</tr> </tr>
<tr>
<td style="padding: 30px 40px 15px 40px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<h2 style="margin: 0; font-size: 22px; font-weight: 700; color: #1a1a1a;">
📰 More Stories
</h2>
<p style="margin: 8px 0 0 0; font-size: 13px; color: #666666;">
Additional news from around Munich
</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- Other Articles -->
{% for article in other_articles %}
<tr>
<td style="padding: 25px 40px;">
<!-- Article Number Badge -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td>
<span style="display: inline-block; background-color: #666666; color: #ffffff; width: 24px; height: 24px; line-height: 24px; text-align: center; border-radius: 50%; font-size: 12px; font-weight: 600;">
{{ loop.index + trending_articles|length }}
</span>
</td>
</tr>
</table>
<!-- Article Title -->
<h2 style="margin: 12px 0 8px 0; font-size: 19px; font-weight: 700; line-height: 1.3; color: #1a1a1a;">
{{ article.title_en if article.title_en else article.title }}
</h2>
<!-- Original German Title (subtitle) -->
{% if article.title_en and article.title_en != article.title %}
<p style="margin: 0 0 12px 0; font-size: 13px; color: #999999; font-style: italic;">
Original: {{ article.title }}
</p>
{% endif %}
<!-- Article Meta -->
<p style="margin: 0 0 12px 0; font-size: 13px; color: #999999;">
{% if article.is_clustered %}
<span style="color: #000000; font-weight: 600;">Multiple sources</span>
{% else %}
<span style="color: #000000; font-weight: 600;">{{ article.source }}</span>
{% if article.author %}
<span> • {{ article.author }}</span>
{% endif %}
{% endif %}
</p>
<!-- Article Summary -->
<p style="margin: 0 0 15px 0; font-size: 15px; line-height: 1.6; color: #333333; text-align: justify;">
{{ article.summary }}
</p>
<!-- Read More Links -->
{% if article.is_clustered and article.sources %}
<!-- Multiple sources -->
<p style="margin: 0 0 8px 0; font-size: 13px; color: #666666;">
📰 Covered by {{ article.article_count }} sources:
</p>
<div style="margin: 0;">
{% for source in article.sources %}
<a href="{{ source.link }}" style="display: inline-block; color: #000000; text-decoration: none; font-size: 13px; font-weight: 600; border-bottom: 2px solid #000000; padding-bottom: 2px; margin-right: 15px; margin-bottom: 8px;">
{{ source.name }} →
</a>
{% endfor %}
</div>
{% else %}
<!-- Single source -->
<a href="{{ article.link }}" style="display: inline-block; color: #000000; text-decoration: none; font-size: 14px; font-weight: 600; border-bottom: 2px solid #000000; padding-bottom: 2px;">
Read more →
</a>
{% endif %}
</td>
</tr>
<!-- Article Divider -->
{% if not loop.last %}
<tr>
<td style="padding: 0 40px;">
<div style="height: 1px; background-color: #f0f0f0;"></div>
</td>
</tr>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %}
<!-- Bottom Divider --> <!-- Bottom Divider -->
<tr> <tr>
@@ -299,8 +205,8 @@
Here's everything in one sentence each: Here's everything in one sentence each:
</p> </p>
{% set all_articles = (trending_articles or []) + (other_articles or []) %} {% for section in category_sections %}
{% for article in all_articles %} {% for article in section.articles %}
<div style="margin-bottom: 12px; padding-left: 20px; position: relative;"> <div style="margin-bottom: 12px; padding-left: 20px; position: relative;">
<span style="position: absolute; left: 0; top: 0; color: #667eea; font-weight: 700;">{{ loop.index }}.</span> <span style="position: absolute; left: 0; top: 0; color: #667eea; font-weight: 700;">{{ loop.index }}.</span>
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #333333;"> <p style="margin: 0; font-size: 14px; line-height: 1.5; color: #333333;">
@@ -309,6 +215,7 @@
</p> </p>
</div> </div>
{% endfor %} {% endfor %}
{% endfor %}
</td> </td>
</tr> </tr>
@@ -355,6 +262,8 @@
<p style="margin: 0; font-size: 12px; color: #666666;"> <p style="margin: 0; font-size: 12px; color: #666666;">
<a href="{{ website_link }}" style="color: #999999; text-decoration: none;">Visit Website</a> <a href="{{ website_link }}" style="color: #999999; text-decoration: none;">Visit Website</a>
<span style="color: #444444;"></span> <span style="color: #444444;"></span>
<a href="{{ preferences_link }}" style="color: #999999; text-decoration: none;">Manage Preferences</a>
<span style="color: #444444;"></span>
<a href="{{ unsubscribe_link }}" style="color: #999999; text-decoration: none;">Unsubscribe</a> <a href="{{ unsubscribe_link }}" style="color: #999999; text-decoration: none;">Unsubscribe</a>
</p> </p>

View File

@@ -162,6 +162,7 @@ def get_latest_articles(max_articles=10, hours=24):
'link': doc.get('link', ''), 'link': doc.get('link', ''),
'summary': cluster.get('neutral_summary', doc.get('summary', '')), 'summary': cluster.get('neutral_summary', doc.get('summary', '')),
'source': doc.get('source', ''), 'source': doc.get('source', ''),
'category': doc.get('category', 'general'),
'published_at': doc.get('published_at', ''), 'published_at': doc.get('published_at', ''),
'is_clustered': True, 'is_clustered': True,
'sources': sources, 'sources': sources,
@@ -177,6 +178,7 @@ def get_latest_articles(max_articles=10, hours=24):
'link': doc.get('link', ''), 'link': doc.get('link', ''),
'summary': doc.get('summary', ''), 'summary': doc.get('summary', ''),
'source': doc.get('source', ''), 'source': doc.get('source', ''),
'category': doc.get('category', 'general'),
'published_at': doc.get('published_at', ''), 'published_at': doc.get('published_at', ''),
'is_clustered': False 'is_clustered': False
}) })
@@ -190,6 +192,7 @@ def get_latest_articles(max_articles=10, hours=24):
'link': doc.get('link', ''), 'link': doc.get('link', ''),
'summary': doc.get('summary', ''), 'summary': doc.get('summary', ''),
'source': doc.get('source', ''), 'source': doc.get('source', ''),
'category': doc.get('category', 'general'),
'published_at': doc.get('published_at', ''), 'published_at': doc.get('published_at', ''),
'is_clustered': False 'is_clustered': False
}) })
@@ -206,22 +209,29 @@ def get_latest_articles(max_articles=10, hours=24):
def get_active_subscribers(): def get_active_subscribers():
""" """
Get all active subscribers from database Get all active subscribers from database with their category preferences
Returns: Returns:
list: Email addresses of active subscribers list: Subscriber dictionaries with email and categories
""" """
cursor = subscribers_collection.find({'status': 'active'}) cursor = subscribers_collection.find({'status': 'active'})
return [doc['email'] for doc in cursor] subscribers = []
for doc in cursor:
subscribers.append({
'email': doc['email'],
'categories': doc.get('categories', None) # None means all categories
})
return subscribers
def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=None, def render_newsletter_html(articles, subscriber_categories=None, tracking_enabled=False,
link_tracking_map=None, api_url=None): pixel_tracking_id=None, link_tracking_map=None, api_url=None):
""" """
Render newsletter HTML from template with optional tracking integration Render newsletter HTML from template with optional tracking integration
Args: Args:
articles: List of article dictionaries articles: List of article dictionaries
subscriber_categories: List of categories the subscriber wants (None = all)
tracking_enabled: Whether to inject tracking pixel and replace links tracking_enabled: Whether to inject tracking pixel and replace links
pixel_tracking_id: Tracking ID for the email open pixel pixel_tracking_id: Tracking ID for the email open pixel
link_tracking_map: Dictionary mapping original URLs to tracking IDs link_tracking_map: Dictionary mapping original URLs to tracking IDs
@@ -237,10 +247,39 @@ def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=N
template = Template(template_content) template = Template(template_content)
# Split articles into sections # Filter articles by subscriber's category preferences
# Top 3 are "trending", rest are "other articles" if subscriber_categories:
trending_articles = articles[:3] if len(articles) >= 3 else articles filtered_articles = [a for a in articles if a.get('category', 'general') in subscriber_categories]
other_articles = articles[3:] if len(articles) > 3 else [] else:
filtered_articles = articles
# Group articles by category (max 3 per category)
from collections import defaultdict
articles_by_category = defaultdict(list)
for article in filtered_articles:
category = article.get('category', 'general')
if len(articles_by_category[category]) < 3:
articles_by_category[category].append(article)
# Convert to list of category sections
category_sections = []
category_names = {
'general': {'name': 'Top Trending', 'icon': '🔥'},
'local': {'name': 'Local Events', 'icon': '🏛️'},
'sports': {'name': 'Sports', 'icon': ''},
'science': {'name': 'Science & Tech', 'icon': '🔬'}
}
for category, category_articles in sorted(articles_by_category.items()):
if category_articles:
cat_info = category_names.get(category, {'name': category.title(), 'icon': '📄'})
category_sections.append({
'id': category,
'name': cat_info['name'],
'icon': cat_info['icon'],
'articles': category_articles
})
# Get weather data # Get weather data
from weather_service import get_munich_weather from weather_service import get_munich_weather
@@ -248,13 +287,14 @@ def render_newsletter_html(articles, tracking_enabled=False, pixel_tracking_id=N
# Prepare template data # Prepare template data
now = datetime.now() now = datetime.now()
total_articles = sum(len(section['articles']) for section in category_sections)
template_data = { template_data = {
'date': now.strftime('%A, %B %d, %Y'), 'date': now.strftime('%A, %B %d, %Y'),
'year': now.year, 'year': now.year,
'article_count': len(articles), 'article_count': total_articles,
'trending_articles': trending_articles, 'category_sections': category_sections,
'other_articles': other_articles,
'unsubscribe_link': f'{Config.WEBSITE_URL}/unsubscribe', 'unsubscribe_link': f'{Config.WEBSITE_URL}/unsubscribe',
'preferences_link': f'{Config.WEBSITE_URL}/preferences.html',
'website_link': Config.WEBSITE_URL, 'website_link': Config.WEBSITE_URL,
'tracking_enabled': tracking_enabled, 'tracking_enabled': tracking_enabled,
'weather': weather 'weather': weather
@@ -358,7 +398,8 @@ def send_newsletter(max_articles=None, test_email=None):
# Get subscribers # Get subscribers
if test_email: if test_email:
subscribers = [test_email] # For test mode, send with all categories
subscribers = [{'email': test_email, 'categories': None}]
print(f"\n🧪 Test mode: Sending to {test_email} only") print(f"\n🧪 Test mode: Sending to {test_email} only")
else: else:
print("\nFetching active subscribers...") print("\nFetching active subscribers...")
@@ -386,7 +427,10 @@ def send_newsletter(max_articles=None, test_email=None):
failed_count = 0 failed_count = 0
errors = [] errors = []
for i, email in enumerate(subscribers, 1): for i, subscriber in enumerate(subscribers, 1):
email = subscriber['email']
categories = subscriber['categories']
print(f"[{i}/{len(subscribers)}] Sending to {email}...", end=' ') print(f"[{i}/{len(subscribers)}] Sending to {email}...", end=' ')
# Generate tracking data for this subscriber if tracking is enabled # Generate tracking data for this subscriber if tracking is enabled
@@ -399,9 +443,10 @@ def send_newsletter(max_articles=None, test_email=None):
tracking_service=tracking_service tracking_service=tracking_service
) )
# Render newsletter with tracking # Render newsletter with tracking and subscriber's category preferences
html_content = render_newsletter_html( html_content = render_newsletter_html(
articles=articles, articles=articles,
subscriber_categories=categories,
tracking_enabled=True, tracking_enabled=True,
pixel_tracking_id=tracking_data['pixel_tracking_id'], pixel_tracking_id=tracking_data['pixel_tracking_id'],
link_tracking_map=tracking_data['link_tracking_map'], link_tracking_map=tracking_data['link_tracking_map'],
@@ -410,10 +455,10 @@ def send_newsletter(max_articles=None, test_email=None):
except Exception as e: except Exception as e:
print(f"⚠ Tracking error: {e}, sending without tracking...", end=' ') print(f"⚠ Tracking error: {e}, sending without tracking...", end=' ')
# Fallback: send without tracking # Fallback: send without tracking
html_content = render_newsletter_html(articles) html_content = render_newsletter_html(articles, subscriber_categories=categories)
else: else:
# Render newsletter without tracking # Render newsletter without tracking but with subscriber's preferences
html_content = render_newsletter_html(articles) html_content = render_newsletter_html(articles, subscriber_categories=categories)
# Send email # Send email
success, error = send_email(email, subject, html_content) success, error = send_email(email, subject, html_content)