Spaces:
Running
Running
Commit Β·
d85c750
0
Parent(s):
Add GitHub Actions CI/CD for auto-sync to Hugging Face Spaces
Browse files- .env.example +43 -0
- .github/workflows/deploy-to-hf.yml +37 -0
- .gitignore +43 -0
- DEPLOY_TO_HF_SPACES.md +269 -0
- Dockerfile +24 -0
- FIREBASE_SETUP.md +44 -0
- GITHUB_TO_HF_CICD.md +294 -0
- README.md +61 -0
- app/__init__.py +6 -0
- app/config.py +55 -0
- app/main.py +50 -0
- app/models.py +70 -0
- app/routes/__init__.py +1 -0
- app/routes/analytics.py +37 -0
- app/routes/news.py +112 -0
- app/routes/search.py +40 -0
- app/services/__init__.py +1 -0
- app/services/cache_service.py +100 -0
- app/services/firebase_service.py +96 -0
- app/services/news_aggregator.py +160 -0
- app/services/news_providers.py +322 -0
- app/services/rss_parser.py +206 -0
- app/utils/__init__.py +1 -0
- app/utils/helpers.py +25 -0
- deploy.ps1 +70 -0
- requirements.txt +27 -0
- tests/__init__.py +1 -0
.env.example
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SegmentoPulse Backend - Environment Configuration
|
| 2 |
+
|
| 3 |
+
Copy this file to `.env` and add your API keys.
|
| 4 |
+
|
| 5 |
+
# Environment
|
| 6 |
+
ENVIRONMENT=development
|
| 7 |
+
|
| 8 |
+
# Multi-Provider News APIs
|
| 9 |
+
# Sign up for free API keys at:
|
| 10 |
+
# - GNews: https://gnews.io (100 requests/day)
|
| 11 |
+
# - NewsAPI: https://newsapi.org (100 requests/day)
|
| 12 |
+
# - NewsData: https://newsdata.io (200 requests/day)
|
| 13 |
+
|
| 14 |
+
GNEWS_API_KEY=your_gnews_api_key_here
|
| 15 |
+
NEWSAPI_API_KEY=your_newsapi_key_here
|
| 16 |
+
NEWSDATA_API_KEY=your_newsdata_key_here
|
| 17 |
+
|
| 18 |
+
# Provider Priority (comma-separated, in order of preference)
|
| 19 |
+
# Options: gnews, newsapi, newsdata, google_rss
|
| 20 |
+
NEWS_PROVIDER_PRIORITY=gnews,newsapi,newsdata,google_rss
|
| 21 |
+
|
| 22 |
+
# Legacy News API (optional)
|
| 23 |
+
NEWS_API_KEY=your_newsapi_key_here
|
| 24 |
+
|
| 25 |
+
# Firebase (from seg-pulse project)
|
| 26 |
+
# Database and Project ID are pre-configured
|
| 27 |
+
# You need to download firebase-credentials.json from Firebase Console
|
| 28 |
+
# See FIREBASE_SETUP.md for instructions
|
| 29 |
+
FIREBASE_DATABASE_URL=https://dbsegpulse-fbc35-default-rtdb.asia-southeast1.firebasedatabase.app
|
| 30 |
+
FIREBASE_PROJECT_ID=dbsegpulse-fbc35
|
| 31 |
+
FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
|
| 32 |
+
|
| 33 |
+
# Redis (optional - for caching)
|
| 34 |
+
REDIS_URL=redis://localhost:6379
|
| 35 |
+
REDIS_PASSWORD=
|
| 36 |
+
|
| 37 |
+
# Server
|
| 38 |
+
HOST=0.0.0.0
|
| 39 |
+
PORT=8000
|
| 40 |
+
CORS_ORIGINS=http://localhost:3000,https://segmento.in
|
| 41 |
+
|
| 42 |
+
# Cache Settings
|
| 43 |
+
CACHE_TTL=120 # seconds
|
.github/workflows/deploy-to-hf.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Spaces
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
- master
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
sync-to-huggingface:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
|
| 14 |
+
steps:
|
| 15 |
+
- name: Checkout repository
|
| 16 |
+
uses: actions/checkout@v3
|
| 17 |
+
with:
|
| 18 |
+
fetch-depth: 0
|
| 19 |
+
lfs: true
|
| 20 |
+
|
| 21 |
+
- name: Push to Hugging Face Spaces
|
| 22 |
+
env:
|
| 23 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 24 |
+
run: |
|
| 25 |
+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
| 26 |
+
git config --global user.name "github-actions[bot]"
|
| 27 |
+
|
| 28 |
+
# Add Hugging Face remote
|
| 29 |
+
git remote add hf https://WORKWITHSHAFISK:$HF_TOKEN@huggingface.co/spaces/WORKWITHSHAFISK/segmentopulse-backend || true
|
| 30 |
+
|
| 31 |
+
# Force push to Hugging Face (main branch)
|
| 32 |
+
git push --force hf HEAD:main
|
| 33 |
+
|
| 34 |
+
- name: Deployment status
|
| 35 |
+
run: |
|
| 36 |
+
echo "β
Successfully synced to Hugging Face Spaces!"
|
| 37 |
+
echo "π Space URL: https://huggingface.co/spaces/WORKWITHSHAFISK/segmentopulse-backend"
|
.gitignore
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
ENV/
|
| 10 |
+
.venv
|
| 11 |
+
|
| 12 |
+
# Environment
|
| 13 |
+
.env
|
| 14 |
+
.env.local
|
| 15 |
+
|
| 16 |
+
# Firebase
|
| 17 |
+
firebase-credentials.json
|
| 18 |
+
*-firebase-adminsdk-*.json
|
| 19 |
+
|
| 20 |
+
# IDEs
|
| 21 |
+
.vscode/
|
| 22 |
+
.idea/
|
| 23 |
+
*.swp
|
| 24 |
+
*.swo
|
| 25 |
+
*~
|
| 26 |
+
|
| 27 |
+
# OS
|
| 28 |
+
.DS_Store
|
| 29 |
+
Thumbs.db
|
| 30 |
+
|
| 31 |
+
# Logs
|
| 32 |
+
*.log
|
| 33 |
+
logs/
|
| 34 |
+
|
| 35 |
+
# Testing
|
| 36 |
+
.pytest_cache/
|
| 37 |
+
.coverage
|
| 38 |
+
htmlcov/
|
| 39 |
+
|
| 40 |
+
# Distribution
|
| 41 |
+
dist/
|
| 42 |
+
build/
|
| 43 |
+
*.egg-info/
|
DEPLOY_TO_HF_SPACES.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SegmentoPulse Backend - Hugging Face Spaces Deployment Guide
|
| 2 |
+
|
| 3 |
+
Complete guide to deploying the SegmentoPulse news aggregation backend to Hugging Face Spaces.
|
| 4 |
+
|
| 5 |
+
## Prerequisites
|
| 6 |
+
|
| 7 |
+
β
Hugging Face account (free)
|
| 8 |
+
β
News API keys (GNews, NewsAPI, NewsData.io)
|
| 9 |
+
β
Git installed on your machine
|
| 10 |
+
|
| 11 |
+
## Step 1: Create a New Space on Hugging Face
|
| 12 |
+
|
| 13 |
+
1. **Go to**: https://huggingface.co/new-space
|
| 14 |
+
|
| 15 |
+
2. **Configure your Space**:
|
| 16 |
+
- **Space name**: `segmentopulse-backend` (or your choice)
|
| 17 |
+
- **License**: MIT
|
| 18 |
+
- **SDK**: Select **Docker** π³
|
| 19 |
+
- **Visibility**: Public or Private (your choice)
|
| 20 |
+
|
| 21 |
+
3. **Click "Create Space"**
|
| 22 |
+
|
| 23 |
+
## Step 2: Clone the Space Repository
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# Clone your new Space
|
| 27 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/segmentopulse-backend
|
| 28 |
+
cd segmentopulse-backend
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
## Step 3: Copy Backend Files
|
| 32 |
+
|
| 33 |
+
Copy the entire SegmentoPulse backend to your Space directory:
|
| 34 |
+
|
| 35 |
+
**On Windows (PowerShell)**:
|
| 36 |
+
```powershell
|
| 37 |
+
# Navigate to your Space directory
|
| 38 |
+
cd path\to\segmentopulse-backend
|
| 39 |
+
|
| 40 |
+
# Copy all backend files
|
| 41 |
+
Copy-Item -Path "C:\Users\Dell\Desktop\Segmento-app-website-dev\SegmentoPulse\backend\*" -Destination "." -Recurse -Force
|
| 42 |
+
|
| 43 |
+
# Remove .env file (don't commit secrets!)
|
| 44 |
+
Remove-Item -Path ".env" -ErrorAction SilentlyContinue
|
| 45 |
+
|
| 46 |
+
# Remove __pycache__ directories
|
| 47 |
+
Get-ChildItem -Path "." -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
**Your Space structure should look like**:
|
| 51 |
+
```
|
| 52 |
+
segmentopulse-backend/
|
| 53 |
+
βββ .git/
|
| 54 |
+
βββ .gitignore
|
| 55 |
+
βββ Dockerfile β
HF Spaces compatible
|
| 56 |
+
βββ README.md β
Space description
|
| 57 |
+
βββ requirements.txt
|
| 58 |
+
βββ app/
|
| 59 |
+
β βββ __init__.py
|
| 60 |
+
β βββ main.py
|
| 61 |
+
β βββ config.py
|
| 62 |
+
β βββ models.py
|
| 63 |
+
β βββ routes/
|
| 64 |
+
β βββ services/
|
| 65 |
+
βββ ...
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## Step 4: Configure Environment Secrets
|
| 69 |
+
|
| 70 |
+
1. **Go to your Space settings**: https://huggingface.co/spaces/YOUR_USERNAME/segmentopulse-backend/settings
|
| 71 |
+
|
| 72 |
+
2. **Add Repository Secrets** (under "Variables and secrets" tab):
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
# News API Keys (REQUIRED)
|
| 76 |
+
GNEWS_API_KEY=4a3a51ecd8bed1d10dc2201c86207729
|
| 77 |
+
NEWSAPI_API_KEY=fb8702e8ff9f45b4ab8f2508890d505f
|
| 78 |
+
NEWSDATA_API_KEY=pub_9a26c29ebbdf46a1abde2fbfe05e4196
|
| 79 |
+
|
| 80 |
+
# Provider Priority (OPTIONAL - uses default if not set)
|
| 81 |
+
NEWS_PROVIDER_PRIORITY=gnews,newsapi,newsdata,google_rss
|
| 82 |
+
|
| 83 |
+
# Firebase (OPTIONAL)
|
| 84 |
+
FIREBASE_DATABASE_URL=https://dbsegpulse-fbc35-default-rtdb.asia-southeast1.firebasedatabase.app
|
| 85 |
+
FIREBASE_PROJECT_ID=dbsegpulse-fbc35
|
| 86 |
+
|
| 87 |
+
# Server Config (OPTIONAL - uses defaults)
|
| 88 |
+
ENVIRONMENT=production
|
| 89 |
+
HOST=0.0.0.0
|
| 90 |
+
PORT=7860
|
| 91 |
+
CORS_ORIGINS=http://localhost:3000,https://segmento.in
|
| 92 |
+
CACHE_TTL=120
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
**Important Notes**:
|
| 96 |
+
- β οΈ Never commit API keys to Git - always use Spaces Secrets!
|
| 97 |
+
- β
The app will work with just the 3 news API keys
|
| 98 |
+
- β
Firebase and Redis are optional
|
| 99 |
+
|
| 100 |
+
## Step 5: Push to Hugging Face
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
# Add all files
|
| 104 |
+
git add .
|
| 105 |
+
|
| 106 |
+
# Commit
|
| 107 |
+
git commit -m "Initial SegmentoPulse backend deployment"
|
| 108 |
+
|
| 109 |
+
# Push to Hugging Face
|
| 110 |
+
git push
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
## Step 6: Monitor Deployment
|
| 114 |
+
|
| 115 |
+
1. **Go to your Space**: https://huggingface.co/spaces/YOUR_USERNAME/segmentopulse-backend
|
| 116 |
+
|
| 117 |
+
2. **Watch the build logs** in the "Logs" tab
|
| 118 |
+
|
| 119 |
+
3. **Build time**: ~2-5 minutes (first time)
|
| 120 |
+
|
| 121 |
+
4. **Look for**:
|
| 122 |
+
```
|
| 123 |
+
Application startup complete.
|
| 124 |
+
Uvicorn running on http://0.0.0.0:7860
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
## Step 7: Test Your Deployment
|
| 128 |
+
|
| 129 |
+
Once deployed, your backend will be live at:
|
| 130 |
+
```
|
| 131 |
+
https://YOUR_USERNAME-segmentopulse-backend.hf.space
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
**Test endpoints**:
|
| 135 |
+
|
| 136 |
+
```bash
|
| 137 |
+
# Health check
|
| 138 |
+
curl https://YOUR_USERNAME-segmentopulse-backend.hf.space/health
|
| 139 |
+
|
| 140 |
+
# Provider statistics
|
| 141 |
+
curl https://YOUR_USERNAME-segmentopulse-backend.hf.space/api/news/system/stats
|
| 142 |
+
|
| 143 |
+
# Fetch AI news
|
| 144 |
+
curl https://YOUR_USERNAME-segmentopulse-backend.hf.space/api/news/ai
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
## Step 8: Update Frontend Environment Variable
|
| 148 |
+
|
| 149 |
+
Once your backend is deployed and working:
|
| 150 |
+
|
| 151 |
+
### For Vercel/Netlify Production:
|
| 152 |
+
|
| 153 |
+
Add environment variable:
|
| 154 |
+
```bash
|
| 155 |
+
NEXT_PUBLIC_PULSE_API_URL=https://YOUR_USERNAME-segmentopulse-backend.hf.space
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
### Example URLs:
|
| 159 |
+
```bash
|
| 160 |
+
# If your username is "workwithshafisk" and space is "segmentopulse-backend"
|
| 161 |
+
NEXT_PUBLIC_PULSE_API_URL=https://workwithshafisk-segmentopulse-backend.hf.space
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
## Troubleshooting
|
| 165 |
+
|
| 166 |
+
### Build Fails
|
| 167 |
+
|
| 168 |
+
**Check**:
|
| 169 |
+
1. Dockerfile syntax
|
| 170 |
+
2. All files copied correctly
|
| 171 |
+
3. requirements.txt is valid
|
| 172 |
+
|
| 173 |
+
**Solution**: Check build logs in HF Spaces for specific errors
|
| 174 |
+
|
| 175 |
+
### App Starts But No News
|
| 176 |
+
|
| 177 |
+
**Check**:
|
| 178 |
+
1. API keys are set in Spaces Secrets
|
| 179 |
+
2. API keys are valid (not expired)
|
| 180 |
+
3. Check provider stats endpoint: `/api/news/system/stats`
|
| 181 |
+
|
| 182 |
+
**Solution**: Verify secrets are set correctly in Space settingsimportΓ’ncia
|
| 183 |
+
|
| 184 |
+
### CORS Errors
|
| 185 |
+
|
| 186 |
+
**Check**: `CORS_ORIGINS` in Spaces Secrets includes your frontend domain
|
| 187 |
+
|
| 188 |
+
**Solution**: Add your production domain:
|
| 189 |
+
```bash
|
| 190 |
+
CORS_ORIGINS=https://segmento.in,http://localhost:3000
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
### 429 Rate Limit Errors
|
| 194 |
+
|
| 195 |
+
**This is NORMAL!** The hybrid system will:
|
| 196 |
+
1. Detect rate limit (HTTP 429)
|
| 197 |
+
2. Automatically switch to next provider
|
| 198 |
+
3. Continue serving news seamlessly
|
| 199 |
+
|
| 200 |
+
**Check**: `/api/news/system/stats` to see which providers are active
|
| 201 |
+
|
| 202 |
+
## Maintenance
|
| 203 |
+
|
| 204 |
+
### Update Deployment
|
| 205 |
+
|
| 206 |
+
```bash
|
| 207 |
+
# Make changes locally
|
| 208 |
+
# Commit and push
|
| 209 |
+
git add .
|
| 210 |
+
git commit -m "Update backend"
|
| 211 |
+
git push
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
HF Spaces will **automatically rebuild** and deploy!
|
| 215 |
+
|
| 216 |
+
### Monitor Usage
|
| 217 |
+
|
| 218 |
+
- Check `/api/news/system/stats` for provider health
|
| 219 |
+
- Monitor HF Spaces logs for errors
|
| 220 |
+
- Track rate limit usage
|
| 221 |
+
|
| 222 |
+
### Update API Keys
|
| 223 |
+
|
| 224 |
+
If a provider hits limits:
|
| 225 |
+
1. Get new API key
|
| 226 |
+
2. Update in Spaces Secrets
|
| 227 |
+
3. Restart Space (automatic)
|
| 228 |
+
|
| 229 |
+
## Performance Tips
|
| 230 |
+
|
| 231 |
+
β
**Enable caching**: Redis optional but recommended
|
| 232 |
+
β
**Use provider priority**: Put fastest providers first
|
| 233 |
+
β
**Monitor stats**: Check which providers are used most
|
| 234 |
+
β
**Scale up**: Upgrade HF Spaces tier if needed
|
| 235 |
+
|
| 236 |
+
## Cost
|
| 237 |
+
|
| 238 |
+
π **FREE TIER**:
|
| 239 |
+
- Hugging Face Spaces: FREE for Docker Spaces
|
| 240 |
+
- News APIs: All have free tiers (400 total requests/day)
|
| 241 |
+
- Total cost: **$0/month** β¨
|
| 242 |
+
|
| 243 |
+
## Next Steps
|
| 244 |
+
|
| 245 |
+
After deployment:
|
| 246 |
+
|
| 247 |
+
1. β
Test all API endpoints
|
| 248 |
+
2. β
Update frontend with new backend URL
|
| 249 |
+
3. β
Push frontend to production
|
| 250 |
+
4. β
Monitor provider statistics
|
| 251 |
+
5. β
Enjoy your live news aggregation platform! π
|
| 252 |
+
|
| 253 |
+
## Support
|
| 254 |
+
|
| 255 |
+
- HF Spaces Docs: https://huggingface.co/docs/hub/spaces
|
| 256 |
+
- SegmentoPulse Backend GitHub: [Your repo]
|
| 257 |
+
- Issues: Create issue in your repository
|
| 258 |
+
|
| 259 |
+
---
|
| 260 |
+
|
| 261 |
+
**Deployment checklist**:
|
| 262 |
+
- [ ] Create HF Space (Docker SDK)
|
| 263 |
+
- [ ] Copy backend files
|
| 264 |
+
- [ ] Add API keys to Secrets
|
| 265 |
+
- [ ] Push to HF
|
| 266 |
+
- [ ] Test endpoints
|
| 267 |
+
- [ ] Update frontend env var
|
| 268 |
+
- [ ] Deploy frontend
|
| 269 |
+
- [ ] π Go live!
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# Set working directory
|
| 4 |
+
WORKDIR /app
|
| 5 |
+
|
| 6 |
+
# Install system dependencies
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
gcc \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Copy requirements first for better caching
|
| 12 |
+
COPY requirements.txt .
|
| 13 |
+
|
| 14 |
+
# Install Python dependencies
|
| 15 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 16 |
+
|
| 17 |
+
# Copy application code
|
| 18 |
+
COPY app ./app
|
| 19 |
+
|
| 20 |
+
# Expose Hugging Face Spaces default port
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
# Run application on port 7860 (HF Spaces standard)
|
| 24 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
FIREBASE_SETUP.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Firebase Admin SDK Credentials for SegmentoPulse Backend
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
The SegmentoPulse backend uses Firebase Realtime Database from the `dbsegpulse-fbc35` project (same as seg-pulse).
|
| 5 |
+
|
| 6 |
+
## Configuration Status
|
| 7 |
+
β
**Database URL**: `https://dbsegpulse-fbc35-default-rtdb.asia-southeast1.firebasedatabase.app`
|
| 8 |
+
β
**Project ID**: `dbsegpulse-fbc35`
|
| 9 |
+
|
| 10 |
+
## Service Account Credentials (Optional)
|
| 11 |
+
|
| 12 |
+
For the backend to work with Firebase Admin SDK, you need a service account credentials JSON file.
|
| 13 |
+
|
| 14 |
+
### How to Get Your Service Account Key:
|
| 15 |
+
|
| 16 |
+
1. **Go to Firebase Console**: https://console.firebase.google.com/
|
| 17 |
+
2. **Select your project**: `dbsegpulse-fbc35`
|
| 18 |
+
3. **Navigate to**: Project Settings (βοΈ gear icon) β Service Accounts
|
| 19 |
+
4. **Click**: "Generate new private key" button
|
| 20 |
+
5. **Download** the JSON file
|
| 21 |
+
6. **Rename** it to `firebase-credentials.json`
|
| 22 |
+
7. **Place** it in the backend directory: `c:\Users\Dell\Desktop\Segmento-app-website-dev\SegmentoPulse\backend\firebase-credentials.json`
|
| 23 |
+
|
| 24 |
+
### Important Security Notes:
|
| 25 |
+
|
| 26 |
+
> β οΈ **DO NOT commit** `firebase-credentials.json` to Git
|
| 27 |
+
> β οΈ This file contains sensitive credentials
|
| 28 |
+
> β οΈ It should already be in `.gitignore`
|
| 29 |
+
|
| 30 |
+
## Current Status
|
| 31 |
+
|
| 32 |
+
The backend is configured to use Firebase but will show a warning if the credentials file is missing:
|
| 33 |
+
```
|
| 34 |
+
Firebase initialization error: [Errno 2] No such file or directory: './firebase-credentials.json'
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
This is **optional** - the backend will continue to work without Firebase. Firebase is only needed if you're using:
|
| 38 |
+
- User authentication tracking
|
| 39 |
+
- View count persistence
|
| 40 |
+
- Analytics data storage
|
| 41 |
+
|
| 42 |
+
## Alternative: Use Frontend Firebase Config Only
|
| 43 |
+
|
| 44 |
+
The seg-pulse and frontend already have Firebase initialized with client-side SDK credentials. If you don't need server-side Firebase features, the backend can work without the credentials file.
|
GITHUB_TO_HF_CICD.md
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub β Hugging Face Spaces CI/CD Setup Guide
|
| 2 |
+
|
| 3 |
+
Complete guide to set up automatic deployment from GitHub to Hugging Face Spaces.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
This setup allows you to:
|
| 8 |
+
1. β
Push code to GitHub (your organization repo)
|
| 9 |
+
2. β
GitHub Actions automatically syncs to HF Spaces
|
| 10 |
+
3. β
HF Spaces rebuilds and deploys automatically
|
| 11 |
+
|
| 12 |
+
## Prerequisites
|
| 13 |
+
|
| 14 |
+
- β
GitHub repository: `https://github.com/Segmento-in/SegmentoPulse-Backend`
|
| 15 |
+
- β
HF Space: `https://huggingface.co/spaces/WORKWITHSHAFISK/segmentopulse-backend`
|
| 16 |
+
- β
Hugging Face account
|
| 17 |
+
|
| 18 |
+
## Step 1: Get Your Hugging Face Token
|
| 19 |
+
|
| 20 |
+
1. **Go to Hugging Face Settings**:
|
| 21 |
+
- Navigate to: https://huggingface.co/settings/tokens
|
| 22 |
+
|
| 23 |
+
2. **Create a new token**:
|
| 24 |
+
- Click "New token"
|
| 25 |
+
- **Name**: `github-actions-segmentopulse`
|
| 26 |
+
- **Type**: Select "Write" access
|
| 27 |
+
- Click "Generate a token"
|
| 28 |
+
|
| 29 |
+
3. **Copy the token** (you'll need it in Step 3)
|
| 30 |
+
- Format: `hf_xxxxxxxxxxxxxxxxxxxxx`
|
| 31 |
+
- β οΈ Save it somewhere safe - you can't see it again!
|
| 32 |
+
|
| 33 |
+
## Step 2: Push Backend to GitHub
|
| 34 |
+
|
| 35 |
+
```powershell
|
| 36 |
+
# Navigate to your backend directory
|
| 37 |
+
cd C:\Users\Dell\Desktop\Segmento-app-website-dev\SegmentoPulse\backend
|
| 38 |
+
|
| 39 |
+
# Initialize git (if not already done)
|
| 40 |
+
git init
|
| 41 |
+
|
| 42 |
+
# Add GitHub remote
|
| 43 |
+
git remote add origin https://github.com/Segmento-in/SegmentoPulse-Backend.git
|
| 44 |
+
|
| 45 |
+
# Add all files
|
| 46 |
+
git add .
|
| 47 |
+
|
| 48 |
+
# Commit
|
| 49 |
+
git commit -m "Initial commit: SegmentoPulse Backend"
|
| 50 |
+
|
| 51 |
+
# Push to GitHub
|
| 52 |
+
git push -u origin main
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
**If branch is 'master' instead of 'main'**:
|
| 56 |
+
```powershell
|
| 57 |
+
git branch -M main
|
| 58 |
+
git push -u origin main
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
## Step 3: Add HF Token to GitHub Secrets
|
| 62 |
+
|
| 63 |
+
1. **Go to your GitHub repository**:
|
| 64 |
+
- https://github.com/Segmento-in/SegmentoPulse-Backend
|
| 65 |
+
|
| 66 |
+
2. **Navigate to Settings**:
|
| 67 |
+
- Click "Settings" tab β "Secrets and variables" β "Actions"
|
| 68 |
+
|
| 69 |
+
3. **Add Repository Secret**:
|
| 70 |
+
- Click "New repository secret"
|
| 71 |
+
- **Name**: `HF_TOKEN`
|
| 72 |
+
- **Value**: Paste your Hugging Face token from Step 1
|
| 73 |
+
- Click "Add secret"
|
| 74 |
+
|
| 75 |
+
## Step 4: GitHub Actions Workflow (Already Created!)
|
| 76 |
+
|
| 77 |
+
The workflow file is already created at:
|
| 78 |
+
```
|
| 79 |
+
.github/workflows/deploy-to-hf.yml
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
This workflow:
|
| 83 |
+
- β
Triggers on every push to `main` branch
|
| 84 |
+
- β
Checks out your code
|
| 85 |
+
- β
Pushes to Hugging Face Spaces
|
| 86 |
+
- β
Can be manually triggered from GitHub Actions tab
|
| 87 |
+
|
| 88 |
+
## Step 5: Test the CI/CD Pipeline
|
| 89 |
+
|
| 90 |
+
### Initial Test
|
| 91 |
+
|
| 92 |
+
```powershell
|
| 93 |
+
# Make a small change
|
| 94 |
+
cd C:\Users\Dell\Desktop\Segmento-app-website-dev\SegmentoPulse\backend
|
| 95 |
+
|
| 96 |
+
# Edit a file (e.g., README.md)
|
| 97 |
+
Add-Content -Path README.md -Value "`n## Updated via GitHub"
|
| 98 |
+
|
| 99 |
+
# Commit and push
|
| 100 |
+
git add .
|
| 101 |
+
git commit -m "Test CI/CD pipeline"
|
| 102 |
+
git push
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Monitor Deployment
|
| 106 |
+
|
| 107 |
+
1. **Watch GitHub Actions**:
|
| 108 |
+
- Go to: https://github.com/Segmento-in/SegmentoPulse-Backend/actions
|
| 109 |
+
- You'll see "Sync to Hugging Face Spaces" workflow running
|
| 110 |
+
|
| 111 |
+
2. **Check HF Spaces**:
|
| 112 |
+
- Go to: https://huggingface.co/spaces/WORKWITHSHAFISK/segmentopulse-backend
|
| 113 |
+
- Click "Logs" tab to watch the rebuild
|
| 114 |
+
|
| 115 |
+
## How It Works
|
| 116 |
+
|
| 117 |
+
```mermaid
|
| 118 |
+
graph LR
|
| 119 |
+
A[Developer] -->|git push| B[GitHub Repo]
|
| 120 |
+
B -->|GitHub Actions| C[CI/CD Workflow]
|
| 121 |
+
C -->|Sync Code| D[HF Spaces]
|
| 122 |
+
D -->|Auto Rebuild| E[Live Backend API]
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### Workflow Steps
|
| 126 |
+
|
| 127 |
+
1. **You push to GitHub**:
|
| 128 |
+
```bash
|
| 129 |
+
git push origin main
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
2. **GitHub Actions triggers**:
|
| 133 |
+
- Checks out your code
|
| 134 |
+
- Configures git with HF token
|
| 135 |
+
- Force pushes to HF Spaces
|
| 136 |
+
|
| 137 |
+
3. **HF Spaces detects changes**:
|
| 138 |
+
- Automatically triggers rebuild
|
| 139 |
+
- Runs Dockerfile
|
| 140 |
+
- Deploys updated backend
|
| 141 |
+
|
| 142 |
+
4. **Your API is live**:
|
| 143 |
+
- Updated code is running at: `https://workwithshafisk-segmentopulse-backend.hf.space`
|
| 144 |
+
|
| 145 |
+
## Workflow File Explanation
|
| 146 |
+
|
| 147 |
+
```yaml
|
| 148 |
+
name: Sync to Hugging Face Spaces
|
| 149 |
+
|
| 150 |
+
on:
|
| 151 |
+
push:
|
| 152 |
+
branches:
|
| 153 |
+
- main # Triggers on push to main
|
| 154 |
+
- master # Also triggers on master (if used)
|
| 155 |
+
workflow_dispatch: # Allows manual trigger
|
| 156 |
+
|
| 157 |
+
jobs:
|
| 158 |
+
sync-to-huggingface:
|
| 159 |
+
runs-on: ubuntu-latest
|
| 160 |
+
|
| 161 |
+
steps:
|
| 162 |
+
# Checkout your code
|
| 163 |
+
- uses: actions/checkout@v3
|
| 164 |
+
with:
|
| 165 |
+
fetch-depth: 0 # Full history for proper sync
|
| 166 |
+
|
| 167 |
+
# Sync to HF Spaces
|
| 168 |
+
- env:
|
| 169 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }} # Uses secret
|
| 170 |
+
run: |
|
| 171 |
+
# Configure git
|
| 172 |
+
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
| 173 |
+
git config --global user.name "github-actions[bot]"
|
| 174 |
+
|
| 175 |
+
# Add HF remote with token
|
| 176 |
+
git remote add hf https://WORKWITHSHAFISK:$HF_TOKEN@huggingface.co/spaces/WORKWITHSHAFISK/segmentopulse-backend
|
| 177 |
+
|
| 178 |
+
# Push to HF
|
| 179 |
+
git push --force hf HEAD:main
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
## Development Workflow
|
| 183 |
+
|
| 184 |
+
### Daily Development Cycle
|
| 185 |
+
|
| 186 |
+
```powershell
|
| 187 |
+
# 1. Make changes
|
| 188 |
+
code app/main.py
|
| 189 |
+
|
| 190 |
+
# 2. Test locally
|
| 191 |
+
uvicorn app.main:app --reload
|
| 192 |
+
|
| 193 |
+
# 3. Commit changes
|
| 194 |
+
git add .
|
| 195 |
+
git commit -m "Add new feature"
|
| 196 |
+
|
| 197 |
+
# 4. Push to GitHub
|
| 198 |
+
git push
|
| 199 |
+
|
| 200 |
+
# 5. β¨ Automatic deployment!
|
| 201 |
+
# - GitHub Actions syncs to HF
|
| 202 |
+
# - HF Spaces rebuilds
|
| 203 |
+
# - New version goes live
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
### Manual Trigger
|
| 207 |
+
|
| 208 |
+
If you need to trigger deployment without pushing:
|
| 209 |
+
|
| 210 |
+
1. Go to: https://github.com/Segmento-in/SegmentoPulse-Backend/actions
|
| 211 |
+
2. Select "Sync to Hugging Face Spaces"
|
| 212 |
+
3. Click "Run workflow" β "Run workflow"
|
| 213 |
+
|
| 214 |
+
## Benefits
|
| 215 |
+
|
| 216 |
+
β
**Single Source of Truth**: GitHub is your primary repository
|
| 217 |
+
β
**Automatic Deployment**: Push once, deploys everywhere
|
| 218 |
+
β
**Version Control**: Full Git history on GitHub
|
| 219 |
+
β
**Team Collaboration**: Multiple developers can contribute
|
| 220 |
+
β
**CI/CD Best Practices**: Automated testing and deployment
|
| 221 |
+
β
**Rollback Capability**: Easy to revert to previous versions
|
| 222 |
+
|
| 223 |
+
## Troubleshooting
|
| 224 |
+
|
| 225 |
+
### Workflow Fails: "remote: Permission denied"
|
| 226 |
+
|
| 227 |
+
**Solution**: Check HF_TOKEN secret
|
| 228 |
+
- Ensure token has "Write" access
|
| 229 |
+
- Verify token hasn't expired
|
| 230 |
+
- Re-create token if needed
|
| 231 |
+
|
| 232 |
+
### Workflow Succeeds But HF Doesn't Update
|
| 233 |
+
|
| 234 |
+
**Solution**: Check HF Space logs
|
| 235 |
+
- Space may have build errors
|
| 236 |
+
- Check Dockerfile syntax
|
| 237 |
+
- Verify all files are committed
|
| 238 |
+
|
| 239 |
+
### Push Rejected: "Updates were rejected"
|
| 240 |
+
|
| 241 |
+
**Solution**: Force push from workflow
|
| 242 |
+
- Already configured in workflow with `--force`
|
| 243 |
+
- Ensures HF Spaces always matches GitHub
|
| 244 |
+
|
| 245 |
+
## Security Best Practices
|
| 246 |
+
|
| 247 |
+
β
**Never commit tokens**: Use GitHub Secrets only
|
| 248 |
+
β
**Use write token only for CI/CD**: Don't use admin tokens
|
| 249 |
+
β
**Rotate tokens periodically**: Change tokens every 90 days
|
| 250 |
+
β
**Don't commit .env files**: Already in .gitignore
|
| 251 |
+
|
| 252 |
+
## Monitoring
|
| 253 |
+
|
| 254 |
+
### GitHub Actions Dashboard
|
| 255 |
+
- **URL**: https://github.com/Segmento-in/SegmentoPulse-Backend/actions
|
| 256 |
+
- **Shows**: All workflow runs, status, logs
|
| 257 |
+
|
| 258 |
+
### HF Spaces Logs
|
| 259 |
+
- **URL**: https://huggingface.co/spaces/WORKWITHSHAFISK/segmentopulse-backend
|
| 260 |
+
- **Shows**: Build logs, runtime logs, errors
|
| 261 |
+
|
| 262 |
+
## Complete Setup Checklist
|
| 263 |
+
|
| 264 |
+
- [ ] Created HF token with write access
|
| 265 |
+
- [ ] Added HF_TOKEN to GitHub secrets
|
| 266 |
+
- [ ] Created .github/workflows/deploy-to-hf.yml
|
| 267 |
+
- [ ] Pushed code to GitHub
|
| 268 |
+
- [ ] Verified GitHub Actions workflow runs
|
| 269 |
+
- [ ] Confirmed HF Spaces rebuilds
|
| 270 |
+
- [ ] Tested backend API endpoint
|
| 271 |
+
- [ ] Updated frontend with backend URL
|
| 272 |
+
|
| 273 |
+
## Alternative: Hugging Face Can Also Sync FROM GitHub
|
| 274 |
+
|
| 275 |
+
Instead of GitHub β HF sync, you can also set up HF β Pull from GitHub:
|
| 276 |
+
|
| 277 |
+
1. Go to your HF Space settings
|
| 278 |
+
2. Under "Repository" β Click "Link to a GitHub repository"
|
| 279 |
+
3. Authorize and select your GitHub repo
|
| 280 |
+
|
| 281 |
+
**This creates bidirectional sync!**
|
| 282 |
+
|
| 283 |
+
## Next Steps
|
| 284 |
+
|
| 285 |
+
After setup:
|
| 286 |
+
|
| 287 |
+
1. β
**Test the workflow**: Make a small change and push
|
| 288 |
+
2. β
**Add branch protection**: Protect main branch on GitHub
|
| 289 |
+
3. β
**Add status badge**: Show build status in README
|
| 290 |
+
4. β
**Set up PR checks**: Run tests before merging
|
| 291 |
+
|
| 292 |
+
---
|
| 293 |
+
|
| 294 |
+
**You're all set!** Every push to GitHub will now automatically deploy to Hugging Face Spaces! π
|
README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: SegmentoPulse Backend
|
| 3 |
+
emoji: π°
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# SegmentoPulse Backend API
|
| 12 |
+
|
| 13 |
+
Real-time technology intelligence platform powered by hybrid multi-provider news aggregation.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
|
| 17 |
+
- π **Hybrid News API System**: Automatic failover between GNews, NewsAPI, NewsData.io, and Google News RSS
|
| 18 |
+
- π **Ultra-Low Latency**: Multi-provider approach ensures fast response times
|
| 19 |
+
- π **Smart Caching**: Redis-backed caching for optimal performance
|
| 20 |
+
- π₯ **Firebase Integration**: Real-time database support for view counting
|
| 21 |
+
- π‘ **Multiple Categories**: AI, Data Security, Cloud Computing, and more
|
| 22 |
+
|
| 23 |
+
## API Endpoints
|
| 24 |
+
|
| 25 |
+
- `GET /api/news/{category}` - Fetch news by category
|
| 26 |
+
- `GET /api/news/system/stats` - Monitor provider health and statistics
|
| 27 |
+
- `GET /api/search?q={query}` - Search news articles
|
| 28 |
+
- `GET /health` - Health check endpoint
|
| 29 |
+
|
| 30 |
+
## Configuration
|
| 31 |
+
|
| 32 |
+
This Space requires environment secrets for news API providers:
|
| 33 |
+
|
| 34 |
+
1. **GNEWS_API_KEY** - Get from https://gnews.io
|
| 35 |
+
2. **NEWSAPI_API_KEY** - Get from https://newsapi.org
|
| 36 |
+
3. **NEWSDATA_API_KEY** - Get from https://newsdata.io
|
| 37 |
+
|
| 38 |
+
Firebase credentials (optional):
|
| 39 |
+
- **FIREBASE_DATABASE_URL**
|
| 40 |
+
- **FIREBASE_PROJECT_ID**
|
| 41 |
+
|
| 42 |
+
## Usage
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
# Fetch AI news
|
| 46 |
+
curl https://your-space-name.hf.space/api/news/ai
|
| 47 |
+
|
| 48 |
+
# Check provider stats
|
| 49 |
+
curl https://your-space-name.hf.space/api/news/system/stats
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
## Local Development
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
pip install -r requirements.txt
|
| 56 |
+
uvicorn app.main:app --reload --port 8000
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
Built with FastAPI | Deployed on Hugging Face Spaces
|
app/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Segmento Pulse Backend API
|
| 3 |
+
FastAPI application for real-time technology news aggregation
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "1.0.0"
|
app/config.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
from pydantic import field_validator
|
| 3 |
+
from typing import List, Union
|
| 4 |
+
|
| 5 |
+
class Settings(BaseSettings):
|
| 6 |
+
"""Application settings"""
|
| 7 |
+
|
| 8 |
+
# Environment
|
| 9 |
+
ENVIRONMENT: str = "development"
|
| 10 |
+
|
| 11 |
+
# Server
|
| 12 |
+
HOST: str = "0.0.0.0"
|
| 13 |
+
PORT: int = 8000
|
| 14 |
+
|
| 15 |
+
# CORS
|
| 16 |
+
CORS_ORIGINS: List[str] = ["http://localhost:3000", "https://segmento.in"]
|
| 17 |
+
|
| 18 |
+
# News API
|
| 19 |
+
NEWS_API_KEY: str = ""
|
| 20 |
+
|
| 21 |
+
# Multi-Provider News APIs
|
| 22 |
+
GNEWS_API_KEY: str = ""
|
| 23 |
+
NEWSAPI_API_KEY: str = ""
|
| 24 |
+
NEWSDATA_API_KEY: str = ""
|
| 25 |
+
|
| 26 |
+
# Provider priority (will try in order until successful)
|
| 27 |
+
NEWS_PROVIDER_PRIORITY: List[str] = ["gnews", "newsapi", "newsdata", "google_rss"]
|
| 28 |
+
|
| 29 |
+
# Firebase
|
| 30 |
+
FIREBASE_DATABASE_URL: str = ""
|
| 31 |
+
FIREBASE_PROJECT_ID: str = ""
|
| 32 |
+
FIREBASE_CREDENTIALS_PATH: str = "./firebase-credentials.json"
|
| 33 |
+
|
| 34 |
+
# Redis
|
| 35 |
+
REDIS_URL: str = "redis://localhost:6379"
|
| 36 |
+
REDIS_PASSWORD: str = ""
|
| 37 |
+
|
| 38 |
+
# Cache
|
| 39 |
+
CACHE_TTL: int = 120 # seconds
|
| 40 |
+
|
| 41 |
+
@field_validator('CORS_ORIGINS', 'NEWS_PROVIDER_PRIORITY', mode='before')
|
| 42 |
+
@classmethod
|
| 43 |
+
def parse_comma_separated(cls, v: Union[str, List[str]]) -> List[str]:
|
| 44 |
+
"""Parse comma-separated string into list (for HF Spaces secrets)"""
|
| 45 |
+
if isinstance(v, str):
|
| 46 |
+
return [item.strip() for item in v.split(',') if item.strip()]
|
| 47 |
+
return v
|
| 48 |
+
|
| 49 |
+
model_config = SettingsConfigDict(
|
| 50 |
+
env_file=".env",
|
| 51 |
+
env_file_encoding="utf-8",
|
| 52 |
+
case_sensitive=True
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
settings = Settings()
|
app/main.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.config import settings
|
| 4 |
+
from app.routes import news, search, analytics
|
| 5 |
+
|
| 6 |
+
app = FastAPI(
|
| 7 |
+
title="Segmento Pulse API",
|
| 8 |
+
description="Real-Time Technology Intelligence Platform API",
|
| 9 |
+
version="1.0.0",
|
| 10 |
+
docs_url="/docs",
|
| 11 |
+
redoc_url="/redoc"
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
# CORS middleware
|
| 15 |
+
app.add_middleware(
|
| 16 |
+
CORSMiddleware,
|
| 17 |
+
allow_origins=settings.CORS_ORIGINS,
|
| 18 |
+
allow_credentials=True,
|
| 19 |
+
allow_methods=["*"],
|
| 20 |
+
allow_headers=["*"],
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# Include routers
|
| 24 |
+
app.include_router(news.router, prefix="/api/news", tags=["News"])
|
| 25 |
+
app.include_router(search.router, prefix="/api/search", tags=["Search"])
|
| 26 |
+
app.include_router(analytics.router, prefix="/api/analytics", tags=["Analytics"])
|
| 27 |
+
|
| 28 |
+
@app.get("/")
|
| 29 |
+
async def root():
|
| 30 |
+
"""Root endpoint"""
|
| 31 |
+
return {
|
| 32 |
+
"message": "Segmento Pulse API",
|
| 33 |
+
"version": "1.0.0",
|
| 34 |
+
"status": "operational",
|
| 35 |
+
"docs": "/docs"
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
@app.get("/health")
|
| 39 |
+
async def health_check():
|
| 40 |
+
"""Health check endpoint"""
|
| 41 |
+
return {"status": "healthy"}
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
import uvicorn
|
| 45 |
+
uvicorn.run(
|
| 46 |
+
"app.main:app",
|
| 47 |
+
host=settings.HOST,
|
| 48 |
+
port=settings.PORT,
|
| 49 |
+
reload=settings.ENVIRONMENT == "development"
|
| 50 |
+
)
|
app/models.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, HttpUrl, field_validator
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from email.utils import parsedate_to_datetime
|
| 5 |
+
|
| 6 |
+
class Article(BaseModel):
|
| 7 |
+
"""News article model"""
|
| 8 |
+
title: str
|
| 9 |
+
description: Optional[str] = ""
|
| 10 |
+
url: HttpUrl
|
| 11 |
+
image: Optional[str] = ""
|
| 12 |
+
publishedAt: datetime
|
| 13 |
+
source: Optional[str] = ""
|
| 14 |
+
category: Optional[str] = ""
|
| 15 |
+
|
| 16 |
+
@field_validator('publishedAt', mode='before')
|
| 17 |
+
@classmethod
|
| 18 |
+
def parse_datetime(cls, v):
|
| 19 |
+
"""Parse datetime from various formats including RFC 2822 (RSS feeds)"""
|
| 20 |
+
if isinstance(v, datetime):
|
| 21 |
+
return v
|
| 22 |
+
if isinstance(v, str):
|
| 23 |
+
try:
|
| 24 |
+
# Try RFC 2822 format (used by RSS feeds like Google News)
|
| 25 |
+
# Example: "Tue, 06 Jan 2026 19:14:27 GMT"
|
| 26 |
+
return parsedate_to_datetime(v)
|
| 27 |
+
except:
|
| 28 |
+
try:
|
| 29 |
+
# Try ISO format
|
| 30 |
+
return datetime.fromisoformat(v.replace('Z', '+00:00'))
|
| 31 |
+
except:
|
| 32 |
+
try:
|
| 33 |
+
# Fallback to dateutil parser
|
| 34 |
+
from dateutil import parser
|
| 35 |
+
return parser.parse(v)
|
| 36 |
+
except:
|
| 37 |
+
# Last resort: return current time
|
| 38 |
+
return datetime.now()
|
| 39 |
+
return v
|
| 40 |
+
|
| 41 |
+
class NewsResponse(BaseModel):
|
| 42 |
+
"""Response model for news endpoints"""
|
| 43 |
+
success: bool
|
| 44 |
+
category: str
|
| 45 |
+
count: int
|
| 46 |
+
articles: List[Article]
|
| 47 |
+
cached: bool = False
|
| 48 |
+
|
| 49 |
+
class SearchResponse(BaseModel):
|
| 50 |
+
"""Response model for search endpoints"""
|
| 51 |
+
success: bool
|
| 52 |
+
query: str
|
| 53 |
+
count: int
|
| 54 |
+
articles: List[Article]
|
| 55 |
+
|
| 56 |
+
class ViewCountRequest(BaseModel):
|
| 57 |
+
"""Request model for view count increment"""
|
| 58 |
+
article_url: HttpUrl
|
| 59 |
+
|
| 60 |
+
class ViewCountResponse(BaseModel):
|
| 61 |
+
"""Response model for view count"""
|
| 62 |
+
success: bool
|
| 63 |
+
article_url: str
|
| 64 |
+
view_count: int
|
| 65 |
+
|
| 66 |
+
class ErrorResponse(BaseModel):
|
| 67 |
+
"""Error response model"""
|
| 68 |
+
success: bool = False
|
| 69 |
+
error: str
|
| 70 |
+
detail: Optional[str] = None
|
app/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Routes package"""
|
app/routes/analytics.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from app.models import ViewCountRequest, ViewCountResponse
|
| 3 |
+
from app.services.firebase_service import FirebaseService
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
firebase_service = FirebaseService()
|
| 7 |
+
|
| 8 |
+
@router.post("/view", response_model=ViewCountResponse)
|
| 9 |
+
async def increment_view_count(request: ViewCountRequest):
|
| 10 |
+
"""
|
| 11 |
+
Increment view count for an article
|
| 12 |
+
"""
|
| 13 |
+
try:
|
| 14 |
+
view_count = await firebase_service.increment_view(str(request.article_url))
|
| 15 |
+
return ViewCountResponse(
|
| 16 |
+
success=True,
|
| 17 |
+
article_url=str(request.article_url),
|
| 18 |
+
view_count=view_count
|
| 19 |
+
)
|
| 20 |
+
except Exception as e:
|
| 21 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/views")
|
| 25 |
+
async def get_view_count(article_url: str):
|
| 26 |
+
"""
|
| 27 |
+
Get view count for an article
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
view_count = await firebase_service.get_view_count(article_url)
|
| 31 |
+
return ViewCountResponse(
|
| 32 |
+
success=True,
|
| 33 |
+
article_url=article_url,
|
| 34 |
+
view_count=view_count
|
| 35 |
+
)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/routes/news.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
from app.models import NewsResponse, ErrorResponse
|
| 3 |
+
from app.services.news_aggregator import NewsAggregator
|
| 4 |
+
from app.services.cache_service import CacheService
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
news_aggregator = NewsAggregator()
|
| 8 |
+
cache_service = CacheService()
|
| 9 |
+
|
| 10 |
+
@router.get("/{category}", response_model=NewsResponse)
|
| 11 |
+
async def get_news_by_category(category: str):
|
| 12 |
+
"""
|
| 13 |
+
Get news articles by category
|
| 14 |
+
|
| 15 |
+
Categories:
|
| 16 |
+
- ai: Artificial Intelligence
|
| 17 |
+
- data-security: Data Security
|
| 18 |
+
- data-governance: Data Governance
|
| 19 |
+
- data-privacy: Data Privacy
|
| 20 |
+
- data-engineering: Data Engineering
|
| 21 |
+
- business-intelligence: Business Intelligence
|
| 22 |
+
- business-analytics: Business Analytics
|
| 23 |
+
- customer-data-platform: Customer Data Platform
|
| 24 |
+
- data-centers: Data Centers
|
| 25 |
+
- cloud-computing: Cloud Computing
|
| 26 |
+
- magazines: Tech Magazines
|
| 27 |
+
"""
|
| 28 |
+
try:
|
| 29 |
+
# Check cache first
|
| 30 |
+
cached_data = await cache_service.get(f"news:{category}")
|
| 31 |
+
if cached_data:
|
| 32 |
+
return NewsResponse(
|
| 33 |
+
success=True,
|
| 34 |
+
category=category,
|
| 35 |
+
count=len(cached_data),
|
| 36 |
+
articles=cached_data,
|
| 37 |
+
cached=True
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Fetch fresh data
|
| 41 |
+
articles = await news_aggregator.fetch_by_category(category)
|
| 42 |
+
|
| 43 |
+
# Cache the results
|
| 44 |
+
await cache_service.set(f"news:{category}", articles)
|
| 45 |
+
|
| 46 |
+
return NewsResponse(
|
| 47 |
+
success=True,
|
| 48 |
+
category=category,
|
| 49 |
+
count=len(articles),
|
| 50 |
+
articles=articles,
|
| 51 |
+
cached=False
|
| 52 |
+
)
|
| 53 |
+
except Exception as e:
|
| 54 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/rss/{provider}")
|
| 58 |
+
async def get_rss_feed(provider: str):
|
| 59 |
+
"""
|
| 60 |
+
Get RSS feed from cloud providers
|
| 61 |
+
|
| 62 |
+
Providers: aws, gcp, azure, ibm, oracle, digitalocean
|
| 63 |
+
"""
|
| 64 |
+
try:
|
| 65 |
+
# Check cache
|
| 66 |
+
cached_data = await cache_service.get(f"rss:{provider}")
|
| 67 |
+
if cached_data:
|
| 68 |
+
return NewsResponse(
|
| 69 |
+
success=True,
|
| 70 |
+
category=f"cloud-{provider}",
|
| 71 |
+
count=len(cached_data),
|
| 72 |
+
articles=cached_data,
|
| 73 |
+
cached=True
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Fetch RSS
|
| 77 |
+
articles = await news_aggregator.fetch_rss(provider)
|
| 78 |
+
|
| 79 |
+
# Cache
|
| 80 |
+
await cache_service.set(f"rss:{provider}", articles)
|
| 81 |
+
|
| 82 |
+
return NewsResponse(
|
| 83 |
+
success=True,
|
| 84 |
+
category=f"cloud-{provider}",
|
| 85 |
+
count=len(articles),
|
| 86 |
+
articles=articles,
|
| 87 |
+
cached=False
|
| 88 |
+
)
|
| 89 |
+
except Exception as e:
|
| 90 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.get("/system/stats")
|
| 94 |
+
async def get_provider_stats():
|
| 95 |
+
"""
|
| 96 |
+
Get statistics about news provider usage and health
|
| 97 |
+
|
| 98 |
+
Returns information about:
|
| 99 |
+
- Total requests
|
| 100 |
+
- Provider usage counts
|
| 101 |
+
- Failover counts
|
| 102 |
+
- Available providers
|
| 103 |
+
- Provider status and rate limits
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
stats = news_aggregator.get_stats()
|
| 107 |
+
return {
|
| 108 |
+
"success": True,
|
| 109 |
+
**stats
|
| 110 |
+
}
|
| 111 |
+
except Exception as e:
|
| 112 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/routes/search.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 2 |
+
from app.models import SearchResponse
|
| 3 |
+
from app.services.news_aggregator import NewsAggregator
|
| 4 |
+
from app.services.cache_service import CacheService
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
news_aggregator = NewsAggregator()
|
| 8 |
+
cache_service = CacheService()
|
| 9 |
+
|
| 10 |
+
@router.get("/", response_model=SearchResponse)
|
| 11 |
+
async def search_news(q: str = Query(..., min_length=2, description="Search query")):
|
| 12 |
+
"""
|
| 13 |
+
Search news articles by keyword
|
| 14 |
+
"""
|
| 15 |
+
try:
|
| 16 |
+
# Check cache
|
| 17 |
+
cache_key = f"search:{q.lower()}"
|
| 18 |
+
cached_data = await cache_service.get(cache_key)
|
| 19 |
+
if cached_data:
|
| 20 |
+
return SearchResponse(
|
| 21 |
+
success=True,
|
| 22 |
+
query=q,
|
| 23 |
+
count=len(cached_data),
|
| 24 |
+
articles=cached_data
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Search articles
|
| 28 |
+
articles = await news_aggregator.search(q)
|
| 29 |
+
|
| 30 |
+
# Cache results
|
| 31 |
+
await cache_service.set(cache_key, articles, ttl=300) # 5 min cache for searches
|
| 32 |
+
|
| 33 |
+
return SearchResponse(
|
| 34 |
+
success=True,
|
| 35 |
+
query=q,
|
| 36 |
+
count=len(articles),
|
| 37 |
+
articles=articles
|
| 38 |
+
)
|
| 39 |
+
except Exception as e:
|
| 40 |
+
raise HTTPException(status_code=500, detail=str(e))
|
app/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Services package"""
|
app/services/cache_service.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
try:
|
| 2 |
+
import redis.asyncio as redis
|
| 3 |
+
REDIS_AVAILABLE = True
|
| 4 |
+
except ImportError:
|
| 5 |
+
REDIS_AVAILABLE = False
|
| 6 |
+
print("Redis not available - caching disabled")
|
| 7 |
+
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
+
import json
|
| 10 |
+
from app.config import settings
|
| 11 |
+
|
| 12 |
+
class CacheService:
|
| 13 |
+
"""Redis caching service (optional)"""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.redis_client: Optional[redis.Redis] = None if not REDIS_AVAILABLE else None
|
| 17 |
+
self.ttl = settings.CACHE_TTL if hasattr(settings, 'CACHE_TTL') else 120
|
| 18 |
+
|
| 19 |
+
async def connect(self):
|
| 20 |
+
"""Connect to Redis (if available)"""
|
| 21 |
+
if not REDIS_AVAILABLE:
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
self.redis_client = await redis.from_url(
|
| 26 |
+
settings.REDIS_URL if hasattr(settings, 'REDIS_URL') else 'redis://localhost:6379',
|
| 27 |
+
password=settings.REDIS_PASSWORD if hasattr(settings, 'REDIS_PASSWORD') and settings.REDIS_PASSWORD else None,
|
| 28 |
+
encoding="utf-8",
|
| 29 |
+
decode_responses=True
|
| 30 |
+
)
|
| 31 |
+
except Exception as e:
|
| 32 |
+
print(f"Redis connection failed: {e}")
|
| 33 |
+
self.redis_client = None
|
| 34 |
+
|
| 35 |
+
async def get(self, key: str) -> Optional[Any]:
|
| 36 |
+
"""Get cached value"""
|
| 37 |
+
if not self.redis_client:
|
| 38 |
+
await self.connect()
|
| 39 |
+
|
| 40 |
+
if not self.redis_client:
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
value = await self.redis_client.get(key)
|
| 45 |
+
if value:
|
| 46 |
+
return json.loads(value)
|
| 47 |
+
return None
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"Cache get error: {e}")
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
async def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
| 53 |
+
"""Set cached value"""
|
| 54 |
+
if not self.redis_client:
|
| 55 |
+
await self.connect()
|
| 56 |
+
|
| 57 |
+
if not self.redis_client:
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
cache_ttl = ttl if ttl is not None else self.ttl
|
| 62 |
+
# Convert Pydantic models to dict
|
| 63 |
+
if hasattr(value, 'model_dump'):
|
| 64 |
+
value = [item.model_dump() for item in value]
|
| 65 |
+
|
| 66 |
+
await self.redis_client.setex(
|
| 67 |
+
key,
|
| 68 |
+
cache_ttl,
|
| 69 |
+
json.dumps(value, default=str)
|
| 70 |
+
)
|
| 71 |
+
return True
|
| 72 |
+
except Exception as e:
|
| 73 |
+
print(f"Cache set error: {e}")
|
| 74 |
+
return False
|
| 75 |
+
|
| 76 |
+
async def delete(self, key: str):
|
| 77 |
+
"""Delete cached value"""
|
| 78 |
+
if not self.redis_client:
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
await self.redis_client.delete(key)
|
| 83 |
+
return True
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"Cache delete error: {e}")
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
async def clear_pattern(self, pattern: str):
|
| 89 |
+
"""Clear all keys matching pattern"""
|
| 90 |
+
if not self.redis_client:
|
| 91 |
+
return False
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
keys = await self.redis_client.keys(pattern)
|
| 95 |
+
if keys:
|
| 96 |
+
await self.redis_client.delete(*keys)
|
| 97 |
+
return True
|
| 98 |
+
except Exception as e:
|
| 99 |
+
print(f"Cache clear error: {e}")
|
| 100 |
+
return False
|
app/services/firebase_service.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
try:
|
| 2 |
+
import firebase_admin
|
| 3 |
+
from firebase_admin import credentials, db
|
| 4 |
+
FIREBASE_AVAILABLE = True
|
| 5 |
+
except ImportError:
|
| 6 |
+
FIREBASE_AVAILABLE = False
|
| 7 |
+
print("Firebase not available - analytics disabled")
|
| 8 |
+
|
| 9 |
+
from typing import Optional
|
| 10 |
+
import base64
|
| 11 |
+
from app.config import settings
|
| 12 |
+
|
| 13 |
+
class FirebaseService:
|
| 14 |
+
"""Firebase Realtime Database service for analytics (optional)"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.initialized = False
|
| 18 |
+
self.db_ref = None
|
| 19 |
+
if FIREBASE_AVAILABLE:
|
| 20 |
+
self._initialize()
|
| 21 |
+
|
| 22 |
+
def _initialize(self):
|
| 23 |
+
"""Initialize Firebase Admin SDK"""
|
| 24 |
+
if not FIREBASE_AVAILABLE:
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
if not firebase_admin._apps:
|
| 29 |
+
cred = credentials.Certificate(settings.FIREBASE_CREDENTIALS_PATH if hasattr(settings, 'FIREBASE_CREDENTIALS_PATH') else './firebase-credentials.json')
|
| 30 |
+
firebase_admin.initialize_app(cred, {
|
| 31 |
+
'databaseURL': settings.FIREBASE_DATABASE_URL if hasattr(settings, 'FIREBASE_DATABASE_URL') else ''
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
self.db_ref = db.reference('pulse/article_views')
|
| 35 |
+
self.initialized = True
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"Firebase initialization error: {e}")
|
| 38 |
+
self.initialized = False
|
| 39 |
+
|
| 40 |
+
def _get_article_id(self, article_url: str) -> str:
|
| 41 |
+
"""Generate article ID from URL"""
|
| 42 |
+
# Base64 encode and sanitize
|
| 43 |
+
encoded = base64.b64encode(article_url.encode()).decode()
|
| 44 |
+
# Remove non-alphanumeric characters and limit length
|
| 45 |
+
sanitized = ''.join(c for c in encoded if c.isalnum())[:100]
|
| 46 |
+
return sanitized
|
| 47 |
+
|
| 48 |
+
async def increment_view(self, article_url: str) -> int:
|
| 49 |
+
"""Increment view count for an article"""
|
| 50 |
+
if not self.initialized:
|
| 51 |
+
return 0
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
article_id = self._get_article_id(article_url)
|
| 55 |
+
article_ref = self.db_ref.child(article_id)
|
| 56 |
+
|
| 57 |
+
# Get current data
|
| 58 |
+
current_data = article_ref.get()
|
| 59 |
+
|
| 60 |
+
if current_data:
|
| 61 |
+
# Increment existing count
|
| 62 |
+
new_count = current_data.get('viewCount', 0) + 1
|
| 63 |
+
article_ref.update({
|
| 64 |
+
'viewCount': new_count,
|
| 65 |
+
'url': article_url,
|
| 66 |
+
'lastUpdated': {'.sv': 'timestamp'}
|
| 67 |
+
})
|
| 68 |
+
return new_count
|
| 69 |
+
else:
|
| 70 |
+
# Create new entry
|
| 71 |
+
article_ref.set({
|
| 72 |
+
'url': article_url,
|
| 73 |
+
'viewCount': 1,
|
| 74 |
+
'lastUpdated': {'.sv': 'timestamp'}
|
| 75 |
+
})
|
| 76 |
+
return 1
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"Error incrementing view: {e}")
|
| 79 |
+
return 0
|
| 80 |
+
|
| 81 |
+
async def get_view_count(self, article_url: str) -> int:
|
| 82 |
+
"""Get view count for an article"""
|
| 83 |
+
if not self.initialized:
|
| 84 |
+
return 0
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
article_id = self._get_article_id(article_url)
|
| 88 |
+
article_ref = self.db_ref.child(article_id)
|
| 89 |
+
data = article_ref.get()
|
| 90 |
+
|
| 91 |
+
if data:
|
| 92 |
+
return data.get('viewCount', 0)
|
| 93 |
+
return 0
|
| 94 |
+
except Exception as e:
|
| 95 |
+
print(f"Error getting view count: {e}")
|
| 96 |
+
return 0
|
app/services/news_aggregator.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from typing import List, Dict, Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from app.models import Article
|
| 5 |
+
from app.services.rss_parser import RSSParser
|
| 6 |
+
from app.services.news_providers import (
|
| 7 |
+
NewsProvider,
|
| 8 |
+
GNewsProvider,
|
| 9 |
+
NewsAPIProvider,
|
| 10 |
+
NewsDataProvider,
|
| 11 |
+
GoogleNewsRSSProvider
|
| 12 |
+
)
|
| 13 |
+
from app.config import settings
|
| 14 |
+
|
| 15 |
+
class NewsAggregator:
|
| 16 |
+
"""Service for aggregating news from multiple sources with automatic failover"""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.rss_parser = RSSParser()
|
| 20 |
+
|
| 21 |
+
# Initialize all available providers
|
| 22 |
+
self.providers: Dict[str, NewsProvider] = {}
|
| 23 |
+
|
| 24 |
+
# Initialize GNews if API key is available
|
| 25 |
+
if settings.GNEWS_API_KEY:
|
| 26 |
+
self.providers['gnews'] = GNewsProvider(settings.GNEWS_API_KEY)
|
| 27 |
+
|
| 28 |
+
# Initialize NewsAPI if API key is available
|
| 29 |
+
if settings.NEWSAPI_API_KEY:
|
| 30 |
+
self.providers['newsapi'] = NewsAPIProvider(settings.NEWSAPI_API_KEY)
|
| 31 |
+
|
| 32 |
+
# Initialize NewsData if API key is available
|
| 33 |
+
if settings.NEWSDATA_API_KEY:
|
| 34 |
+
self.providers['newsdata'] = NewsDataProvider(settings.NEWSDATA_API_KEY)
|
| 35 |
+
|
| 36 |
+
# Always include Google News RSS as fallback (no API key needed)
|
| 37 |
+
self.providers['google_rss'] = GoogleNewsRSSProvider()
|
| 38 |
+
|
| 39 |
+
# Provider priority order
|
| 40 |
+
self.provider_priority = settings.NEWS_PROVIDER_PRIORITY
|
| 41 |
+
|
| 42 |
+
# Cloud provider RSS feeds
|
| 43 |
+
self.cloud_rss_urls = {
|
| 44 |
+
"aws": "https://aws.amazon.com/blogs/aws/feed/",
|
| 45 |
+
"gcp": "https://cloudblog.withgoogle.com/rss/",
|
| 46 |
+
"azure": "https://azure.microsoft.com/en-us/blog/feed/",
|
| 47 |
+
"ibm": "https://www.ibm.com/blog/rss",
|
| 48 |
+
"oracle": "https://blogs.oracle.com/cloud-infrastructure/rss",
|
| 49 |
+
"digitalocean": "https://www.digitalocean.com/blog/rss.xml"
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Statistics tracking
|
| 53 |
+
self.stats = {
|
| 54 |
+
'total_requests': 0,
|
| 55 |
+
'provider_usage': {},
|
| 56 |
+
'failover_count': 0
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
async def fetch_by_category(self, category: str) -> List[Article]:
|
| 60 |
+
"""
|
| 61 |
+
Fetch news by category using hybrid approach with automatic failover
|
| 62 |
+
Tries providers in priority order until successful
|
| 63 |
+
"""
|
| 64 |
+
self.stats['total_requests'] += 1
|
| 65 |
+
|
| 66 |
+
# Try each provider in priority order
|
| 67 |
+
for provider_name in self.provider_priority:
|
| 68 |
+
provider = self.providers.get(provider_name)
|
| 69 |
+
|
| 70 |
+
# Skip if provider not configured
|
| 71 |
+
if not provider:
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
# Skip if provider is not available (rate limited)
|
| 75 |
+
if not provider.is_available():
|
| 76 |
+
print(f"Provider {provider_name} is not available (rate limited), trying next...")
|
| 77 |
+
self.stats['failover_count'] += 1
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
print(f"Fetching news for '{category}' from {provider_name}...")
|
| 82 |
+
articles = await provider.fetch_news(category, limit=20)
|
| 83 |
+
|
| 84 |
+
# If we got articles, return them
|
| 85 |
+
if articles:
|
| 86 |
+
print(f"β Successfully fetched {len(articles)} articles from {provider_name}")
|
| 87 |
+
|
| 88 |
+
# Track usage statistics
|
| 89 |
+
if provider_name not in self.stats['provider_usage']:
|
| 90 |
+
self.stats['provider_usage'][provider_name] = 0
|
| 91 |
+
self.stats['provider_usage'][provider_name] += 1
|
| 92 |
+
|
| 93 |
+
return articles
|
| 94 |
+
else:
|
| 95 |
+
print(f"Provider {provider_name} returned no articles, trying next...")
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"Error with provider {provider_name}: {e}, trying next...")
|
| 99 |
+
self.stats['failover_count'] += 1
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
# If all providers failed, return empty list
|
| 103 |
+
print(f"β All providers exhausted for category '{category}'")
|
| 104 |
+
return []
|
| 105 |
+
|
| 106 |
+
async def fetch_rss(self, provider: str) -> List[Article]:
|
| 107 |
+
"""Fetch RSS from cloud providers"""
|
| 108 |
+
url = self.cloud_rss_urls.get(provider)
|
| 109 |
+
if not url:
|
| 110 |
+
return []
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 114 |
+
response = await client.get(url)
|
| 115 |
+
if response.status_code == 200:
|
| 116 |
+
content = response.text
|
| 117 |
+
return await self.rss_parser.parse_provider_rss(content, provider)
|
| 118 |
+
return []
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print(f"Error fetching RSS for {provider}: {e}")
|
| 121 |
+
return []
|
| 122 |
+
|
| 123 |
+
async def search(self, query: str) -> List[Article]:
|
| 124 |
+
"""
|
| 125 |
+
Search news articles using hybrid approach
|
| 126 |
+
Currently uses Google News RSS for search functionality
|
| 127 |
+
"""
|
| 128 |
+
# Use Google News RSS for search
|
| 129 |
+
google_rss = self.providers.get('google_rss')
|
| 130 |
+
if google_rss:
|
| 131 |
+
try:
|
| 132 |
+
# Create a custom search URL
|
| 133 |
+
search_url = f"https://news.google.com/rss/search?q={query}&hl=en-US&gl=US&ceid=US:en"
|
| 134 |
+
|
| 135 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 136 |
+
response = await client.get(search_url)
|
| 137 |
+
if response.status_code == 200:
|
| 138 |
+
return await self.rss_parser.parse_google_news(response.text, "search")
|
| 139 |
+
except Exception as e:
|
| 140 |
+
print(f"Error searching news: {e}")
|
| 141 |
+
|
| 142 |
+
return []
|
| 143 |
+
|
| 144 |
+
def get_stats(self) -> Dict:
|
| 145 |
+
"""Get usage statistics for monitoring"""
|
| 146 |
+
return {
|
| 147 |
+
**self.stats,
|
| 148 |
+
'available_providers': [
|
| 149 |
+
name for name, provider in self.providers.items()
|
| 150 |
+
if provider.is_available()
|
| 151 |
+
],
|
| 152 |
+
'provider_status': {
|
| 153 |
+
name: {
|
| 154 |
+
'status': provider.status.value,
|
| 155 |
+
'request_count': provider.request_count,
|
| 156 |
+
'daily_limit': provider.daily_limit
|
| 157 |
+
}
|
| 158 |
+
for name, provider in self.providers.items()
|
| 159 |
+
}
|
| 160 |
+
}
|
app/services/news_providers.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from typing import List, Optional, Dict
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from abc import ABC, abstractmethod
|
| 5 |
+
from app.models import Article
|
| 6 |
+
import os
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
class ProviderStatus(Enum):
|
| 10 |
+
"""Provider status enum"""
|
| 11 |
+
ACTIVE = "active"
|
| 12 |
+
RATE_LIMITED = "rate_limited"
|
| 13 |
+
ERROR = "error"
|
| 14 |
+
|
| 15 |
+
class NewsProvider(ABC):
|
| 16 |
+
"""Abstract base class for news providers"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 19 |
+
self.api_key = api_key
|
| 20 |
+
self.status = ProviderStatus.ACTIVE
|
| 21 |
+
self.request_count = 0
|
| 22 |
+
self.daily_limit = 0
|
| 23 |
+
self.name = self.__class__.__name__
|
| 24 |
+
|
| 25 |
+
@abstractmethod
|
| 26 |
+
async def fetch_news(self, category: str, limit: int = 20) -> List[Article]:
|
| 27 |
+
"""Fetch news articles for a given category"""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
def is_available(self) -> bool:
|
| 31 |
+
"""Check if provider is available"""
|
| 32 |
+
return self.status == ProviderStatus.ACTIVE and (
|
| 33 |
+
self.daily_limit == 0 or self.request_count < self.daily_limit
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
def mark_rate_limited(self):
|
| 37 |
+
"""Mark provider as rate limited"""
|
| 38 |
+
self.status = ProviderStatus.RATE_LIMITED
|
| 39 |
+
|
| 40 |
+
def reset_daily_quota(self):
|
| 41 |
+
"""Reset daily quota"""
|
| 42 |
+
self.request_count = 0
|
| 43 |
+
self.status = ProviderStatus.ACTIVE
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class GNewsProvider(NewsProvider):
|
| 47 |
+
"""GNews.io API provider"""
|
| 48 |
+
|
| 49 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 50 |
+
super().__init__(api_key)
|
| 51 |
+
self.base_url = "https://gnews.io/api/v4"
|
| 52 |
+
self.daily_limit = 100
|
| 53 |
+
|
| 54 |
+
# Category mapping
|
| 55 |
+
self.category_map = {
|
| 56 |
+
'ai': 'artificial intelligence machine learning',
|
| 57 |
+
'data-security': 'data security cybersecurity',
|
| 58 |
+
'data-governance': 'data governance compliance',
|
| 59 |
+
'data-privacy': 'data privacy GDPR',
|
| 60 |
+
'data-engineering': 'data engineering pipeline',
|
| 61 |
+
'business-intelligence': 'business intelligence BI',
|
| 62 |
+
'business-analytics': 'business analytics',
|
| 63 |
+
'customer-data-platform': 'customer data platform CDP',
|
| 64 |
+
'data-centers': 'data centers infrastructure',
|
| 65 |
+
'cloud-computing': 'cloud computing',
|
| 66 |
+
'magazines': 'technology news',
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
async def fetch_news(self, category: str, limit: int = 20) -> List[Article]:
|
| 70 |
+
"""Fetch news from GNews API"""
|
| 71 |
+
if not self.api_key:
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
query = self.category_map.get(category, category)
|
| 76 |
+
url = f"{self.base_url}/search"
|
| 77 |
+
params = {
|
| 78 |
+
'q': query,
|
| 79 |
+
'lang': 'en',
|
| 80 |
+
'country': 'us',
|
| 81 |
+
'max': min(limit, 10), # GNews free tier max 10
|
| 82 |
+
'apikey': self.api_key
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 86 |
+
response = await client.get(url, params=params)
|
| 87 |
+
|
| 88 |
+
if response.status_code == 429:
|
| 89 |
+
self.mark_rate_limited()
|
| 90 |
+
return []
|
| 91 |
+
|
| 92 |
+
if response.status_code == 200:
|
| 93 |
+
self.request_count += 1
|
| 94 |
+
data = response.json()
|
| 95 |
+
return self._parse_response(data, category)
|
| 96 |
+
|
| 97 |
+
return []
|
| 98 |
+
except Exception as e:
|
| 99 |
+
print(f"GNews API error: {e}")
|
| 100 |
+
return []
|
| 101 |
+
|
| 102 |
+
def _parse_response(self, data: Dict, category: str) -> List[Article]:
|
| 103 |
+
"""Parse GNews API response"""
|
| 104 |
+
articles = []
|
| 105 |
+
for item in data.get('articles', []):
|
| 106 |
+
try:
|
| 107 |
+
article = Article(
|
| 108 |
+
title=item.get('title', ''),
|
| 109 |
+
description=item.get('description', ''),
|
| 110 |
+
url=item.get('url', ''),
|
| 111 |
+
image=item.get('image', ''),
|
| 112 |
+
publishedAt=item.get('publishedAt', datetime.now().isoformat()),
|
| 113 |
+
source=item.get('source', {}).get('name', 'GNews'),
|
| 114 |
+
category=category
|
| 115 |
+
)
|
| 116 |
+
articles.append(article)
|
| 117 |
+
except Exception as e:
|
| 118 |
+
print(f"Error parsing GNews article: {e}")
|
| 119 |
+
continue
|
| 120 |
+
return articles
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class NewsAPIProvider(NewsProvider):
|
| 124 |
+
"""NewsAPI.org provider"""
|
| 125 |
+
|
| 126 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 127 |
+
super().__init__(api_key)
|
| 128 |
+
self.base_url = "https://newsapi.org/v2"
|
| 129 |
+
self.daily_limit = 100
|
| 130 |
+
|
| 131 |
+
# Category keywords
|
| 132 |
+
self.category_keywords = {
|
| 133 |
+
'ai': 'artificial intelligence OR "machine learning" OR "deep learning"',
|
| 134 |
+
'data-security': '"data security" OR cybersecurity OR "data breach"',
|
| 135 |
+
'data-governance': '"data governance" OR "data management" OR compliance',
|
| 136 |
+
'data-privacy': '"data privacy" OR GDPR OR "privacy regulation"',
|
| 137 |
+
'data-engineering': '"data engineering" OR "data pipeline" OR "big data"',
|
| 138 |
+
'business-intelligence': '"business intelligence" OR "BI tools"',
|
| 139 |
+
'business-analytics': '"business analytics" OR analytics',
|
| 140 |
+
'customer-data-platform': '"customer data platform" OR CDP',
|
| 141 |
+
'data-centers': '"data centers" OR "data centre"',
|
| 142 |
+
'cloud-computing': '"cloud computing" OR cloud',
|
| 143 |
+
'magazines': 'technology',
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async def fetch_news(self, category: str, limit: int = 20) -> List[Article]:
|
| 147 |
+
"""Fetch news from NewsAPI"""
|
| 148 |
+
if not self.api_key:
|
| 149 |
+
return []
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
query = self.category_keywords.get(category, category)
|
| 153 |
+
url = f"{self.base_url}/everything"
|
| 154 |
+
params = {
|
| 155 |
+
'q': query,
|
| 156 |
+
'language': 'en',
|
| 157 |
+
'sortBy': 'publishedAt',
|
| 158 |
+
'pageSize': min(limit, 20),
|
| 159 |
+
'apiKey': self.api_key
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 163 |
+
response = await client.get(url, params=params)
|
| 164 |
+
|
| 165 |
+
if response.status_code == 429 or response.status_code == 426:
|
| 166 |
+
self.mark_rate_limited()
|
| 167 |
+
return []
|
| 168 |
+
|
| 169 |
+
if response.status_code == 200:
|
| 170 |
+
self.request_count += 1
|
| 171 |
+
data = response.json()
|
| 172 |
+
return self._parse_response(data, category)
|
| 173 |
+
|
| 174 |
+
return []
|
| 175 |
+
except Exception as e:
|
| 176 |
+
print(f"NewsAPI error: {e}")
|
| 177 |
+
return []
|
| 178 |
+
|
| 179 |
+
def _parse_response(self, data: Dict, category: str) -> List[Article]:
|
| 180 |
+
"""Parse NewsAPI response"""
|
| 181 |
+
articles = []
|
| 182 |
+
for item in data.get('articles', []):
|
| 183 |
+
try:
|
| 184 |
+
article = Article(
|
| 185 |
+
title=item.get('title', ''),
|
| 186 |
+
description=item.get('description', ''),
|
| 187 |
+
url=item.get('url', ''),
|
| 188 |
+
image=item.get('urlToImage', ''),
|
| 189 |
+
publishedAt=item.get('publishedAt', datetime.now().isoformat()),
|
| 190 |
+
source=item.get('source', {}).get('name', 'NewsAPI'),
|
| 191 |
+
category=category
|
| 192 |
+
)
|
| 193 |
+
articles.append(article)
|
| 194 |
+
except Exception as e:
|
| 195 |
+
print(f"Error parsing NewsAPI article: {e}")
|
| 196 |
+
continue
|
| 197 |
+
return articles
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
class NewsDataProvider(NewsProvider):
|
| 201 |
+
"""NewsData.io provider"""
|
| 202 |
+
|
| 203 |
+
def __init__(self, api_key: Optional[str] = None):
|
| 204 |
+
super().__init__(api_key)
|
| 205 |
+
self.base_url = "https://newsdata.io/api/1"
|
| 206 |
+
self.daily_limit = 200
|
| 207 |
+
|
| 208 |
+
# Category keywords
|
| 209 |
+
self.category_keywords = {
|
| 210 |
+
'ai': 'artificial intelligence,machine learning',
|
| 211 |
+
'data-security': 'data security,cybersecurity',
|
| 212 |
+
'data-governance': 'data governance,compliance',
|
| 213 |
+
'data-privacy': 'data privacy,GDPR',
|
| 214 |
+
'data-engineering': 'data engineering,big data',
|
| 215 |
+
'business-intelligence': 'business intelligence',
|
| 216 |
+
'business-analytics': 'business analytics',
|
| 217 |
+
'customer-data-platform': 'customer data platform',
|
| 218 |
+
'data-centers': 'data centers',
|
| 219 |
+
'cloud-computing': 'cloud computing',
|
| 220 |
+
'magazines': 'technology',
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
async def fetch_news(self, category: str, limit: int = 20) -> List[Article]:
|
| 224 |
+
"""Fetch news from NewsData.io"""
|
| 225 |
+
if not self.api_key:
|
| 226 |
+
return []
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
query = self.category_keywords.get(category, category)
|
| 230 |
+
url = f"{self.base_url}/news"
|
| 231 |
+
params = {
|
| 232 |
+
'q': query,
|
| 233 |
+
'language': 'en',
|
| 234 |
+
'country': 'us',
|
| 235 |
+
'apikey': self.api_key
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 239 |
+
response = await client.get(url, params=params)
|
| 240 |
+
|
| 241 |
+
if response.status_code == 429:
|
| 242 |
+
self.mark_rate_limited()
|
| 243 |
+
return []
|
| 244 |
+
|
| 245 |
+
if response.status_code == 200:
|
| 246 |
+
self.request_count += 1
|
| 247 |
+
data = response.json()
|
| 248 |
+
return self._parse_response(data, category, limit)
|
| 249 |
+
|
| 250 |
+
return []
|
| 251 |
+
except Exception as e:
|
| 252 |
+
print(f"NewsData.io error: {e}")
|
| 253 |
+
return []
|
| 254 |
+
|
| 255 |
+
def _parse_response(self, data: Dict, category: str, limit: int) -> List[Article]:
|
| 256 |
+
"""Parse NewsData.io response"""
|
| 257 |
+
articles = []
|
| 258 |
+
for item in data.get('results', [])[:limit]:
|
| 259 |
+
try:
|
| 260 |
+
article = Article(
|
| 261 |
+
title=item.get('title', ''),
|
| 262 |
+
description=item.get('description', ''),
|
| 263 |
+
url=item.get('link', ''),
|
| 264 |
+
image=item.get('image_url', ''),
|
| 265 |
+
publishedAt=item.get('pubDate', datetime.now().isoformat()),
|
| 266 |
+
source=item.get('source_id', 'NewsData'),
|
| 267 |
+
category=category
|
| 268 |
+
)
|
| 269 |
+
articles.append(article)
|
| 270 |
+
except Exception as e:
|
| 271 |
+
print(f"Error parsing NewsData article: {e}")
|
| 272 |
+
continue
|
| 273 |
+
return articles
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class GoogleNewsRSSProvider(NewsProvider):
|
| 277 |
+
"""Google News RSS provider (no API key needed)"""
|
| 278 |
+
|
| 279 |
+
def __init__(self):
|
| 280 |
+
super().__init__(None)
|
| 281 |
+
self.daily_limit = 0 # Unlimited (but rate limited by Google)
|
| 282 |
+
|
| 283 |
+
# RSS feed URLs by category
|
| 284 |
+
self.feed_urls = {
|
| 285 |
+
'ai': 'https://news.google.com/rss/search?q=artificial+intelligence+OR+machine+learning&hl=en-US&gl=US&ceid=US:en',
|
| 286 |
+
'data-security': 'https://news.google.com/rss/search?q=data+security+OR+cybersecurity+OR+data+breach&hl=en-US&gl=US&ceid=US:en',
|
| 287 |
+
'data-governance': 'https://news.google.com/rss/search?q=data+governance+OR+data+management&hl=en-US&gl=US&ceid=US:en',
|
| 288 |
+
'data-privacy': 'https://news.google.com/rss/search?q=data+privacy+OR+GDPR+OR+privacy+regulation&hl=en-US&gl=US&ceid=US:en',
|
| 289 |
+
'data-engineering': 'https://news.google.com/rss/search?q=data+engineering+OR+data+pipeline+OR+big+data&hl=en-US&gl=US&ceid=US:en',
|
| 290 |
+
'business-intelligence': 'https://news.google.com/rss/search?q=business+intelligence+OR+BI+tools&hl=en-US&gl=US&ceid=US:en',
|
| 291 |
+
'business-analytics': 'https://news.google.com/rss/search?q=business+analytics&hl=en-US&gl=US&ceid=US:en',
|
| 292 |
+
'customer-data-platform': 'https://news.google.com/rss/search?q=customer+data+platform+OR+CDP&hl=en-US&gl=US&ceid=US:en',
|
| 293 |
+
'data-centers': 'https://news.google.com/rss/search?q=data+centers+OR+data+centre&hl=en-US&gl=US&ceid=US:en',
|
| 294 |
+
'cloud-computing': 'https://news.google.com/rss/search?q=cloud+computing&hl=en-US&gl=US&ceid=US:en',
|
| 295 |
+
'magazines': 'https://news.google.com/rss/headlines/section/topic/TECHNOLOGY?hl=en-US&gl=US&ceid=US:en',
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
async def fetch_news(self, category: str, limit: int = 20) -> List[Article]:
|
| 299 |
+
"""Fetch news from Google News RSS"""
|
| 300 |
+
from app.services.rss_parser import RSSParser
|
| 301 |
+
|
| 302 |
+
feed_url = self.feed_urls.get(category)
|
| 303 |
+
if not feed_url:
|
| 304 |
+
return []
|
| 305 |
+
|
| 306 |
+
try:
|
| 307 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 308 |
+
response = await client.get(feed_url)
|
| 309 |
+
|
| 310 |
+
if response.status_code == 429:
|
| 311 |
+
self.mark_rate_limited()
|
| 312 |
+
return []
|
| 313 |
+
|
| 314 |
+
if response.status_code == 200:
|
| 315 |
+
self.request_count += 1
|
| 316 |
+
parser = RSSParser()
|
| 317 |
+
return await parser.parse_google_news(response.text, category)
|
| 318 |
+
|
| 319 |
+
return []
|
| 320 |
+
except Exception as e:
|
| 321 |
+
print(f"Google News RSS error: {e}")
|
| 322 |
+
return []
|
app/services/rss_parser.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import feedparser
|
| 2 |
+
from typing import List
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from app.models import Article
|
| 5 |
+
import re
|
| 6 |
+
|
| 7 |
+
class RSSParser:
|
| 8 |
+
"""RSS feed parser for news sources"""
|
| 9 |
+
|
| 10 |
+
async def parse_google_news(self, content: str, category: str) -> List[Article]:
|
| 11 |
+
"""Parse Google News RSS feed with advanced XML parsing"""
|
| 12 |
+
try:
|
| 13 |
+
articles = []
|
| 14 |
+
|
| 15 |
+
# Extract items from XML using regex
|
| 16 |
+
item_regex = r'<item>([\s\S]*?)</item>'
|
| 17 |
+
matches = re.findall(item_regex, content)
|
| 18 |
+
|
| 19 |
+
for item in matches[:20]: # Limit to 20 articles
|
| 20 |
+
title = self._extract_tag(item, 'title') or 'No title'
|
| 21 |
+
link = self._extract_tag(item, 'link') or self._extract_tag(item, 'guid') or ''
|
| 22 |
+
description = self._extract_tag(item, 'description') or self._extract_tag(item, 'content:encoded') or ''
|
| 23 |
+
pub_date = self._extract_tag(item, 'pubDate') or self._extract_tag(item, 'published') or datetime.now().isoformat()
|
| 24 |
+
creator = self._extract_tag(item, 'dc:creator') or self._extract_tag(item, 'author') or 'Google News'
|
| 25 |
+
|
| 26 |
+
# Extract image from multiple sources
|
| 27 |
+
image = self._extract_image_from_xml(item, description, category, title)
|
| 28 |
+
|
| 29 |
+
# Extract source name from description (Google News format: <a href="...">Source</a>)
|
| 30 |
+
source_match = re.search(r'<a[^>]*>([^<]+)</a>', description)
|
| 31 |
+
article_source = source_match.group(1) if source_match else 'Google News'
|
| 32 |
+
|
| 33 |
+
# Clean description (Google News RSS only contains links, not actual content)
|
| 34 |
+
cleaned_description = self._clean_google_news_description(description)
|
| 35 |
+
|
| 36 |
+
article = Article(
|
| 37 |
+
title=self._clean_html(title),
|
| 38 |
+
description=cleaned_description,
|
| 39 |
+
url=link,
|
| 40 |
+
image=image,
|
| 41 |
+
publishedAt=pub_date,
|
| 42 |
+
source=self._clean_html(article_source),
|
| 43 |
+
category=category
|
| 44 |
+
)
|
| 45 |
+
articles.append(article)
|
| 46 |
+
|
| 47 |
+
return articles
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"Error parsing Google News: {e}")
|
| 50 |
+
return []
|
| 51 |
+
|
| 52 |
+
def _extract_image_from_xml(self, item: str, description: str, category: str, title: str) -> str:
|
| 53 |
+
"""Extract image from multiple XML sources with fallbacks"""
|
| 54 |
+
# 1. Try enclosure tag
|
| 55 |
+
enclosure_match = re.search(r'<enclosure[^>]*url="([^"]+)"', item)
|
| 56 |
+
if enclosure_match:
|
| 57 |
+
return enclosure_match.group(1)
|
| 58 |
+
|
| 59 |
+
# 2. Try media:content or media:thumbnail
|
| 60 |
+
media_match = re.search(r'<media:(content|thumbnail)[^>]*url="([^"]+)"', item)
|
| 61 |
+
if media_match:
|
| 62 |
+
return media_match.group(2)
|
| 63 |
+
|
| 64 |
+
# 3. Try img tag in description
|
| 65 |
+
img_match = re.search(r'<img[^>]*src="([^"]+)"', description)
|
| 66 |
+
if img_match:
|
| 67 |
+
return img_match.group(1)
|
| 68 |
+
|
| 69 |
+
# 4. Category-specific fallback images
|
| 70 |
+
fallbacks = {
|
| 71 |
+
'ai': 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=400&h=200&fit=crop',
|
| 72 |
+
'data-security': 'https://images.unsplash.com/photo-1563986768494-4dee2763ff3f?w=400&h=200&fit=crop',
|
| 73 |
+
'data-governance': 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=400&h=200&fit=crop',
|
| 74 |
+
'data-privacy': 'https://images.unsplash.com/photo-1614064641938-3bbee52942c7?w=400&h=200&fit=crop',
|
| 75 |
+
'data-engineering': 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&h=200&fit=crop',
|
| 76 |
+
'business-intelligence': 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=400&h=200&fit=crop',
|
| 77 |
+
'business-analytics': 'https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=400&h=200&fit=crop',
|
| 78 |
+
'customer-data-platform': 'https://images.unsplash.com/photo-1432888622747-4eb9a8d82266?w=400&h=200&fit=crop',
|
| 79 |
+
'data-centers': 'https://images.unsplash.com/photo-1544197150-b99a580bb7a8?w=400&h=200&fit=crop',
|
| 80 |
+
'magazines': 'https://images.unsplash.com/photo-1504711434969-e33886168f5c?w=400&h=200&fit=crop',
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
# Use hash of title for consistent fallback selection
|
| 84 |
+
default_images = list(fallbacks.values())
|
| 85 |
+
index = abs(hash(title)) % len(default_images)
|
| 86 |
+
return default_images[index]
|
| 87 |
+
|
| 88 |
+
def _clean_google_news_description(self, description: str) -> str:
|
| 89 |
+
"""Clean Google News description - they typically only contain links, not actual content"""
|
| 90 |
+
# Check if this is a Google News link-only description
|
| 91 |
+
if 'news.google.com/rss/articles' in description:
|
| 92 |
+
return '' # No real content, just redirect links
|
| 93 |
+
|
| 94 |
+
# Try to extract content after the link
|
| 95 |
+
after_link_match = re.search(r'</a>([\s\S]*)', description)
|
| 96 |
+
if after_link_match:
|
| 97 |
+
extracted = self._clean_html(after_link_match.group(1))
|
| 98 |
+
if len(extracted) > 30:
|
| 99 |
+
return extracted[:200]
|
| 100 |
+
|
| 101 |
+
# Fallback: clean entire description if meaningful
|
| 102 |
+
full_clean = self._clean_html(description)
|
| 103 |
+
if len(full_clean) > 30 and not full_clean.startswith('http'):
|
| 104 |
+
return full_clean[:200]
|
| 105 |
+
|
| 106 |
+
return ''
|
| 107 |
+
|
| 108 |
+
def _extract_tag(self, xml: str, tag_name: str) -> str:
|
| 109 |
+
"""Extract XML tag content"""
|
| 110 |
+
pattern = f'<{tag_name}[^>]*>([\\s\\S]*?)</{tag_name}>'
|
| 111 |
+
match = re.search(pattern, xml, re.IGNORECASE)
|
| 112 |
+
return match.group(1).strip() if match else ''
|
| 113 |
+
|
| 114 |
+
def _clean_html(self, html: str) -> str:
|
| 115 |
+
"""Remove HTML tags and decode entities"""
|
| 116 |
+
text = html
|
| 117 |
+
|
| 118 |
+
# Remove CDATA
|
| 119 |
+
text = re.sub(r'<!\[CDATA\[([\s\S]*?)\]\]>', r'\1', text)
|
| 120 |
+
|
| 121 |
+
# Remove HTML tags (multiple passes for nested tags)
|
| 122 |
+
text = re.sub(r'<[^>]+>', '', text)
|
| 123 |
+
text = re.sub(r'<[^>]*', '', text)
|
| 124 |
+
text = re.sub(r'>', '', text)
|
| 125 |
+
|
| 126 |
+
# Decode HTML entities
|
| 127 |
+
entities = {
|
| 128 |
+
' ': ' ', '&': '&', '<': '<', '>': '>',
|
| 129 |
+
'"': '"', ''': "'", ''': "'",
|
| 130 |
+
'…': '...', '—': 'β', '–': 'β'
|
| 131 |
+
}
|
| 132 |
+
for entity, char in entities.items():
|
| 133 |
+
text = text.replace(entity, char)
|
| 134 |
+
|
| 135 |
+
# Remove numeric entities
|
| 136 |
+
text = re.sub(r'&#\d+;', '', text)
|
| 137 |
+
|
| 138 |
+
# Clean whitespace
|
| 139 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 140 |
+
|
| 141 |
+
return text
|
| 142 |
+
|
| 143 |
+
async def parse_provider_rss(self, content: str, provider: str) -> List[Article]:
|
| 144 |
+
"""Parse cloud provider RSS feed"""
|
| 145 |
+
try:
|
| 146 |
+
feed = feedparser.parse(content)
|
| 147 |
+
articles = []
|
| 148 |
+
|
| 149 |
+
for entry in feed.entries[:20]:
|
| 150 |
+
# Extract image
|
| 151 |
+
image_url = self._extract_image_from_entry(entry)
|
| 152 |
+
|
| 153 |
+
# Parse date
|
| 154 |
+
published_at = self._parse_date(entry.get('published', ''))
|
| 155 |
+
|
| 156 |
+
# Get description
|
| 157 |
+
description = entry.get('summary', '')
|
| 158 |
+
if description:
|
| 159 |
+
# Strip HTML tags
|
| 160 |
+
description = re.sub(r'<[^>]+>', '', description)
|
| 161 |
+
description = description[:200] + '...' if len(description) > 200 else description
|
| 162 |
+
|
| 163 |
+
article = Article(
|
| 164 |
+
title=entry.get('title', ''),
|
| 165 |
+
description=description,
|
| 166 |
+
url=entry.get('link', ''),
|
| 167 |
+
image=image_url,
|
| 168 |
+
publishedAt=published_at,
|
| 169 |
+
source=provider.upper(),
|
| 170 |
+
category=f'cloud-{provider}'
|
| 171 |
+
)
|
| 172 |
+
articles.append(article)
|
| 173 |
+
|
| 174 |
+
return articles
|
| 175 |
+
except Exception as e:
|
| 176 |
+
print(f"Error parsing provider RSS: {e}")
|
| 177 |
+
return []
|
| 178 |
+
|
| 179 |
+
def _extract_image_from_entry(self, entry) -> str:
|
| 180 |
+
"""Extract image URL from feed entry"""
|
| 181 |
+
# Try media:content
|
| 182 |
+
if hasattr(entry, 'media_content') and entry.media_content:
|
| 183 |
+
return entry.media_content[0].get('url', '')
|
| 184 |
+
|
| 185 |
+
# Try media:thumbnail
|
| 186 |
+
if hasattr(entry, 'media_thumbnail') and entry.media_thumbnail:
|
| 187 |
+
return entry.media_thumbnail[0].get('url', '')
|
| 188 |
+
|
| 189 |
+
# Try enclosures
|
| 190 |
+
if hasattr(entry, 'enclosures') and entry.enclosures:
|
| 191 |
+
for enclosure in entry.enclosures:
|
| 192 |
+
if enclosure.get('type', '').startswith('image'):
|
| 193 |
+
return enclosure.get('href', '')
|
| 194 |
+
|
| 195 |
+
# Default fallback image
|
| 196 |
+
return "https://via.placeholder.com/400x300/4F46E5/ffffff?text=Segmento+Pulse"
|
| 197 |
+
|
| 198 |
+
def _parse_date(self, date_str: str) -> datetime:
|
| 199 |
+
"""Parse date string to datetime"""
|
| 200 |
+
try:
|
| 201 |
+
# feedparser usually provides a parsed date
|
| 202 |
+
# but we'll handle string parsing as fallback
|
| 203 |
+
from dateutil import parser
|
| 204 |
+
return parser.parse(date_str)
|
| 205 |
+
except:
|
| 206 |
+
return datetime.now()
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions"""
|
app/utils/helpers.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helper utilities"""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
def generate_id(text: str) -> str:
|
| 8 |
+
"""Generate unique ID from text"""
|
| 9 |
+
return hashlib.md5(text.encode()).hexdigest()
|
| 10 |
+
|
| 11 |
+
def sanitize_filename(filename: str) -> str:
|
| 12 |
+
"""Sanitize filename for safe storage"""
|
| 13 |
+
return "".join(c for c in filename if c.isalnum() or c in (' ', '.', '_')).rstrip()
|
| 14 |
+
|
| 15 |
+
def format_datetime(dt: Optional[datetime] = None) -> str:
|
| 16 |
+
"""Format datetime to ISO string"""
|
| 17 |
+
if dt is None:
|
| 18 |
+
dt = datetime.now()
|
| 19 |
+
return dt.isoformat()
|
| 20 |
+
|
| 21 |
+
def truncate_text(text: str, max_length: int = 200) -> str:
|
| 22 |
+
"""Truncate text to max length"""
|
| 23 |
+
if len(text) <= max_length:
|
| 24 |
+
return text
|
| 25 |
+
return text[:max_length-3] + "..."
|
deploy.ps1
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Quick Deployment Script for Hugging Face Spaces
|
| 2 |
+
# Run this after cloning your HF Space repository
|
| 3 |
+
|
| 4 |
+
Write-Host "π SegmentoPulse Backend - Hugging Face Spaces Deployment" -ForegroundColor Cyan
|
| 5 |
+
Write-Host ""
|
| 6 |
+
|
| 7 |
+
# Check if we're in the right directory
|
| 8 |
+
if (-not (Test-Path "app/main.py")) {
|
| 9 |
+
Write-Host "β Error: app/main.py not found. Make sure you're in the SegmentoPulse/backend directory" -ForegroundColor Red
|
| 10 |
+
exit 1
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
# Check for .env file and warn
|
| 14 |
+
if (Test-Path ".env") {
|
| 15 |
+
Write-Host "β οΈ Warning: .env file found. This should NOT be committed to Git!" -ForegroundColor Yellow
|
| 16 |
+
Write-Host " Make sure .env is in .gitignore" -ForegroundColor Yellow
|
| 17 |
+
Write-Host ""
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# Clean up unnecessary files
|
| 21 |
+
Write-Host "π§Ή Cleaning up..." -ForegroundColor Green
|
| 22 |
+
Get-ChildItem -Path "." -Recurse -Directory -Filter "__pycache__" | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
| 23 |
+
Get-ChildItem -Path "." -Recurse -Filter "*.pyc" | Remove-Item -Force -ErrorAction SilentlyContinue
|
| 24 |
+
Remove-Item -Path ".env" -ErrorAction SilentlyContinue
|
| 25 |
+
|
| 26 |
+
# Check required files
|
| 27 |
+
Write-Host "β
Checking required files..." -ForegroundColor Green
|
| 28 |
+
$required = @("Dockerfile", "README.md", "requirements.txt", "app/main.py", "app/config.py")
|
| 29 |
+
foreach ($file in $required) {
|
| 30 |
+
if (Test-Path $file) {
|
| 31 |
+
Write-Host " β $file" -ForegroundColor Green
|
| 32 |
+
} else {
|
| 33 |
+
Write-Host " β $file MISSING" -ForegroundColor Red
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
Write-Host ""
|
| 37 |
+
|
| 38 |
+
# Git status
|
| 39 |
+
Write-Host "π¦ Preparing for deployment..." -ForegroundColor Cyan
|
| 40 |
+
git status --short
|
| 41 |
+
|
| 42 |
+
Write-Host ""
|
| 43 |
+
Write-Host "π Pre-Deployment Checklist:" -ForegroundColor Yellow
|
| 44 |
+
Write-Host " [ ] Created HF Space with Docker SDK" -ForegroundColor White
|
| 45 |
+
Write-Host " [ ] Cloned Space repository" -ForegroundColor White
|
| 46 |
+
Write-Host " [ ] Copied backend files to Space directory" -ForegroundColor White
|
| 47 |
+
Write-Host " [ ] Added API keys to HF Spaces Secrets" -ForegroundColor White
|
| 48 |
+
Write-Host ""
|
| 49 |
+
|
| 50 |
+
$confirm = Read-Host "Ready to commit and push to Hugging Face? (y/n)"
|
| 51 |
+
if ($confirm -eq 'y' -or $confirm -eq 'Y') {
|
| 52 |
+
Write-Host ""
|
| 53 |
+
Write-Host "π Deploying to Hugging Face..." -ForegroundColor Cyan
|
| 54 |
+
|
| 55 |
+
git add .
|
| 56 |
+
git commit -m "Deploy SegmentoPulse backend to HF Spaces"
|
| 57 |
+
git push
|
| 58 |
+
|
| 59 |
+
Write-Host ""
|
| 60 |
+
Write-Host "β
Deployment initiated!" -ForegroundColor Green
|
| 61 |
+
Write-Host ""
|
| 62 |
+
Write-Host "π Next steps:" -ForegroundColor Cyan
|
| 63 |
+
Write-Host " 1. Monitor build logs in your HF Space" -ForegroundColor White
|
| 64 |
+
Write-Host " 2. Test endpoints once deployed" -ForegroundColor White
|
| 65 |
+
Write-Host " 3. Update frontend environment variable" -ForegroundColor White
|
| 66 |
+
Write-Host " 4. Deploy frontend to production" -ForegroundColor White
|
| 67 |
+
Write-Host ""
|
| 68 |
+
} else {
|
| 69 |
+
Write-Host "Deployment cancelled." -ForegroundColor Yellow
|
| 70 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.109.0
|
| 2 |
+
uvicorn[standard]==0.27.0
|
| 3 |
+
pydantic==2.5.3
|
| 4 |
+
pydantic-settings==2.1.0
|
| 5 |
+
python-dotenv==1.0.0
|
| 6 |
+
|
| 7 |
+
# News & RSS
|
| 8 |
+
feedparser==6.0.11
|
| 9 |
+
requests==2.31.0
|
| 10 |
+
beautifulsoup4==4.12.3
|
| 11 |
+
|
| 12 |
+
# HTTP client (replaces aiohttp)
|
| 13 |
+
httpx==0.26.0
|
| 14 |
+
|
| 15 |
+
# Caching (optional - can work without Redis)
|
| 16 |
+
# redis==5.0.1
|
| 17 |
+
# hiredis==2.3.2
|
| 18 |
+
|
| 19 |
+
# Firebase
|
| 20 |
+
firebase-admin==6.4.0
|
| 21 |
+
|
| 22 |
+
# Data processing
|
| 23 |
+
python-dateutil==2.8.2
|
| 24 |
+
|
| 25 |
+
# CORS & Security
|
| 26 |
+
python-multipart==0.0.6
|
| 27 |
+
email-validator==2.1.0
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests package"""
|