adamshafishaik commited on
Commit
d85c750
Β·
0 Parent(s):

Add GitHub Actions CI/CD for auto-sync to Hugging Face Spaces

Browse files
.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
+ '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>',
129
+ '&quot;': '"', '&#39;': "'", '&apos;': "'",
130
+ '&hellip;': '...', '&mdash;': 'β€”', '&ndash;': '–'
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"""