Spaces:
Sleeping
Sleeping
Commit Β·
e363da7
1
Parent(s): 4e35284
Stable state: Localhost fixed with Tailwind v3 restoration
Browse files- STABLE_BACKUP/Dockerfile +30 -0
- STABLE_BACKUP/NewsApex/.gitignore +46 -0
- STABLE_BACKUP/NewsApex/README.md +73 -0
- STABLE_BACKUP/NewsApex/__init__.py +0 -0
- STABLE_BACKUP/NewsApex/app/api/bias/route.js +77 -0
- STABLE_BACKUP/NewsApex/app/api/news/route.js +73 -0
- STABLE_BACKUP/NewsApex/app/components/BiasModal.jsx +260 -0
- STABLE_BACKUP/NewsApex/app/components/CategoryFilter.jsx +41 -0
- STABLE_BACKUP/NewsApex/app/components/LoadingSkeleton.jsx +21 -0
- STABLE_BACKUP/NewsApex/app/components/NewsCard.jsx +100 -0
- STABLE_BACKUP/NewsApex/app/favicon.ico +0 -0
- STABLE_BACKUP/NewsApex/app/globals.css +28 -0
- STABLE_BACKUP/NewsApex/app/layout.jsx +28 -0
- STABLE_BACKUP/NewsApex/app/page.jsx +305 -0
- STABLE_BACKUP/NewsApex/bridge_logic.py +147 -0
- STABLE_BACKUP/NewsApex/eslint.config.mjs +16 -0
- STABLE_BACKUP/NewsApex/jsconfig.json +7 -0
- STABLE_BACKUP/NewsApex/news_service.py +323 -0
- STABLE_BACKUP/NewsApex/next.config.mjs +17 -0
- STABLE_BACKUP/NewsApex/package-lock.json +0 -0
- STABLE_BACKUP/NewsApex/package.json +23 -0
- STABLE_BACKUP/NewsApex/postcss.config.js +6 -0
- STABLE_BACKUP/NewsApex/postcss.config.mjs +7 -0
- STABLE_BACKUP/NewsApex/requirements.txt +12 -0
- STABLE_BACKUP/NewsApex/tailwind.config.js +11 -0
- STABLE_BACKUP/NewsApex/test_category.json +0 -0
- STABLE_BACKUP/NewsApex/test_output.json +0 -0
- STABLE_BACKUP/NewsApex/vercel.json +4 -0
- STABLE_BACKUP/app.py +359 -0
- STABLE_BACKUP/requirements.txt +12 -0
STABLE_BACKUP/Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set environment variables
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE 1
|
| 6 |
+
ENV PYTHONUNBUFFERED 1
|
| 7 |
+
|
| 8 |
+
# Set the working directory in the container
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
# Install system dependencies
|
| 12 |
+
RUN apt-get update && apt-get install -y \
|
| 13 |
+
build-essential \
|
| 14 |
+
curl \
|
| 15 |
+
software-properties-common \
|
| 16 |
+
git \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Copy requirements.txt and install dependencies
|
| 20 |
+
COPY requirements.txt .
|
| 21 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 22 |
+
|
| 23 |
+
# Copy the rest of the application code
|
| 24 |
+
COPY . .
|
| 25 |
+
|
| 26 |
+
# Expose the port the app runs on
|
| 27 |
+
EXPOSE 7860
|
| 28 |
+
|
| 29 |
+
# Command to run the application
|
| 30 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
STABLE_BACKUP/NewsApex/.gitignore
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
| 2 |
+
|
| 3 |
+
# dependencies
|
| 4 |
+
/node_modules
|
| 5 |
+
/.pnp
|
| 6 |
+
.pnp.*
|
| 7 |
+
.yarn/*
|
| 8 |
+
!.yarn/patches
|
| 9 |
+
!.yarn/plugins
|
| 10 |
+
!.yarn/releases
|
| 11 |
+
!.yarn/versions
|
| 12 |
+
|
| 13 |
+
# testing
|
| 14 |
+
/coverage
|
| 15 |
+
|
| 16 |
+
# next.js
|
| 17 |
+
/.next/
|
| 18 |
+
/out/
|
| 19 |
+
|
| 20 |
+
# production
|
| 21 |
+
/build
|
| 22 |
+
|
| 23 |
+
# misc
|
| 24 |
+
.DS_Store
|
| 25 |
+
*.pem
|
| 26 |
+
|
| 27 |
+
# debug
|
| 28 |
+
npm-debug.log*
|
| 29 |
+
yarn-debug.log*
|
| 30 |
+
yarn-error.log*
|
| 31 |
+
.pnpm-debug.log*
|
| 32 |
+
|
| 33 |
+
# env files (can opt-in for committing if needed)
|
| 34 |
+
.env*
|
| 35 |
+
|
| 36 |
+
# vercel
|
| 37 |
+
.vercel
|
| 38 |
+
|
| 39 |
+
# typescript
|
| 40 |
+
*.tsbuildinfo
|
| 41 |
+
next-env.d.ts
|
| 42 |
+
|
| 43 |
+
# model
|
| 44 |
+
bias_module/models/
|
| 45 |
+
bias_module/__pycache__/
|
| 46 |
+
bias_module/venv/
|
STABLE_BACKUP/NewsApex/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NewsApex
|
| 2 |
+
|
| 3 |
+
### Prerequisites
|
| 4 |
+
|
| 5 |
+
- Node.js 18.x or higher
|
| 6 |
+
- A free API key from [NewsAPI.org](https://newsapi.org/)
|
| 7 |
+
|
| 8 |
+
### Installation
|
| 9 |
+
|
| 10 |
+
1. Clone the repository or navigate to the project directory:
|
| 11 |
+
|
| 12 |
+
```bash
|
| 13 |
+
cd news_apex
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
2. Install dependencies:
|
| 17 |
+
|
| 18 |
+
```bash
|
| 19 |
+
npm install
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
3. Set up your environment variables:
|
| 23 |
+
|
| 24 |
+
- Copy `.env.local` and add your NewsAPI key:
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
NEXT_PUBLIC_NEWS_API_KEY=your_actual_api_key_here
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
4. Get your free API key:
|
| 31 |
+
- Visit [https://newsapi.org/](https://newsapi.org/)
|
| 32 |
+
- Sign up for a free account
|
| 33 |
+
- Copy your API key
|
| 34 |
+
- Paste it in the `.env.local` file
|
| 35 |
+
|
| 36 |
+
5. Run the development server:
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
npm run dev
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
6. Open [http://localhost:3000](http://localhost:3000) in your browser
|
| 43 |
+
|
| 44 |
+
## Deployment
|
| 45 |
+
|
| 46 |
+
To build for production:
|
| 47 |
+
|
| 48 |
+
```bash
|
| 49 |
+
npm run build
|
| 50 |
+
npm start
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Deploy to Vercel:
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
npx vercel
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
Make sure to add your `NEXT_PUBLIC_NEWS_API_KEY` environment variable in your Vercel project settings.
|
| 60 |
+
|
| 61 |
+
## Limitations
|
| 62 |
+
|
| 63 |
+
- NewsAPI free tier has limitations (500 requests/day for development)
|
| 64 |
+
- Some news sources may block image loading from external domains
|
| 65 |
+
- Historical news data is limited on the free tier
|
| 66 |
+
|
| 67 |
+
## License
|
| 68 |
+
|
| 69 |
+
MIT
|
| 70 |
+
|
| 71 |
+
## Support
|
| 72 |
+
|
| 73 |
+
For issues or questions, please visit [NewsAPI Documentation](https://newsapi.org/docs)
|
STABLE_BACKUP/NewsApex/__init__.py
ADDED
|
File without changes
|
STABLE_BACKUP/NewsApex/app/api/bias/route.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { spawn } from 'child_process';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
export const maxDuration = 60; // Set max duration to 60 seconds
|
| 6 |
+
|
| 7 |
+
const IS_VERCEL = process.env.VERCEL === '1';
|
| 8 |
+
|
| 9 |
+
export async function POST(request) {
|
| 10 |
+
try {
|
| 11 |
+
const body = await request.json();
|
| 12 |
+
const articleUrl = body.url || body.articleUrl;
|
| 13 |
+
const action = body.action || 'analyze_bias';
|
| 14 |
+
const existingContent = body.content || body.full_content;
|
| 15 |
+
|
| 16 |
+
if (!articleUrl && !existingContent) {
|
| 17 |
+
return NextResponse.json({ error: 'Article URL or content is required' }, { status: 400 });
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// πΉ Restore Python Bridge as the Primary Logic (Fixes Local Host)
|
| 21 |
+
const resultData = await new Promise((resolve, reject) => {
|
| 22 |
+
const scriptPath = path.join(process.cwd(), 'bridge_logic.py');
|
| 23 |
+
const pythonCommand = process.platform === 'win32' ? 'python' : 'python3';
|
| 24 |
+
|
| 25 |
+
const args = [scriptPath, action];
|
| 26 |
+
if (articleUrl) args.push('--url', articleUrl);
|
| 27 |
+
if (existingContent) args.push('--content', existingContent);
|
| 28 |
+
|
| 29 |
+
const pythonProcess = spawn(pythonCommand, args);
|
| 30 |
+
let output = '';
|
| 31 |
+
let error = '';
|
| 32 |
+
|
| 33 |
+
// πΉ Environment-Aware Timeout
|
| 34 |
+
// On Vercel, we must finish in 10s. On Local, we can wait much longer for BERT.
|
| 35 |
+
const timeoutLimit = IS_VERCEL ? 9000 : 120000; // 9s for Vercel, 2 mins for Local
|
| 36 |
+
|
| 37 |
+
const timeout = setTimeout(() => {
|
| 38 |
+
pythonProcess.kill();
|
| 39 |
+
resolve({
|
| 40 |
+
error: IS_VERCEL
|
| 41 |
+
? 'Analysis timed out on the server. The local BERT model is too heavy for Vercel.'
|
| 42 |
+
: 'Local analysis timed out. Check if your Python environment is responsive.'
|
| 43 |
+
});
|
| 44 |
+
}, timeoutLimit);
|
| 45 |
+
|
| 46 |
+
pythonProcess.stdout.on('data', (data) => { output += data.toString(); });
|
| 47 |
+
pythonProcess.stderr.on('data', (data) => { error += data.toString(); });
|
| 48 |
+
|
| 49 |
+
pythonProcess.on('close', (code) => {
|
| 50 |
+
clearTimeout(timeout);
|
| 51 |
+
if (code !== 0) {
|
| 52 |
+
try {
|
| 53 |
+
const errData = JSON.parse(error);
|
| 54 |
+
resolve(errData);
|
| 55 |
+
} catch (e) {
|
| 56 |
+
resolve({ error: `Python Error (Code ${code}): ${error || 'Unknown error'}` });
|
| 57 |
+
}
|
| 58 |
+
} else {
|
| 59 |
+
try {
|
| 60 |
+
resolve(JSON.parse(output));
|
| 61 |
+
} catch (e) {
|
| 62 |
+
console.error(`Failed to parse bias output. Raw output: ${output}`);
|
| 63 |
+
reject(new Error(`Backend error: ${output.substring(0, 100)}...`));
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
});
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
return NextResponse.json(resultData);
|
| 70 |
+
} catch (error) {
|
| 71 |
+
console.error('API Error:', error);
|
| 72 |
+
return NextResponse.json(
|
| 73 |
+
{ error: error.message || 'Analysis failed' },
|
| 74 |
+
{ status: 500 }
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
}
|
STABLE_BACKUP/NewsApex/app/api/news/route.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server';
|
| 2 |
+
import { spawn } from 'child_process';
|
| 3 |
+
import path from 'path';
|
| 4 |
+
|
| 5 |
+
export const maxDuration = 60; // Set max duration to 60 seconds
|
| 6 |
+
|
| 7 |
+
export async function GET(request) {
|
| 8 |
+
const { searchParams } = new URL(request.url);
|
| 9 |
+
const query = searchParams.get('query') || '';
|
| 10 |
+
const category = searchParams.get('category') || '';
|
| 11 |
+
|
| 12 |
+
try {
|
| 13 |
+
const newsData = await new Promise((resolve, reject) => {
|
| 14 |
+
const args = ['bridge_logic.py', 'fetch_news'];
|
| 15 |
+
if (query) {
|
| 16 |
+
args.push('--query', query);
|
| 17 |
+
}
|
| 18 |
+
if (category) {
|
| 19 |
+
args.push('--category', category);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const scriptPath = path.join(process.cwd(), 'bridge_logic.py');
|
| 23 |
+
// Use python3 for Linux/Vercel environment
|
| 24 |
+
const pythonCommand = process.platform === 'win32' ? 'python' : 'python3';
|
| 25 |
+
const pythonProcess = spawn(pythonCommand, [scriptPath, ...args.slice(1)], {});
|
| 26 |
+
|
| 27 |
+
// Generous timeout for local host
|
| 28 |
+
const timeout = setTimeout(() => {
|
| 29 |
+
pythonProcess.kill();
|
| 30 |
+
reject(new Error('Backend process timed out. The news fetch is taking too long.'));
|
| 31 |
+
}, 60000);
|
| 32 |
+
|
| 33 |
+
let output = '';
|
| 34 |
+
let error = '';
|
| 35 |
+
|
| 36 |
+
pythonProcess.stdout.on('data', (data) => {
|
| 37 |
+
output += data.toString();
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
pythonProcess.stderr.on('data', (data) => {
|
| 41 |
+
error += data.toString();
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
pythonProcess.on('close', (code) => {
|
| 45 |
+
clearTimeout(timeout);
|
| 46 |
+
if (code !== 0) {
|
| 47 |
+
console.error(`Python process exited with code ${code}. Error: ${error}`);
|
| 48 |
+
try {
|
| 49 |
+
const errData = JSON.parse(error);
|
| 50 |
+
resolve(errData);
|
| 51 |
+
} catch (e) {
|
| 52 |
+
resolve({ error: `Python Error (Code ${code}): ${error || 'Unknown error'}` });
|
| 53 |
+
}
|
| 54 |
+
} else {
|
| 55 |
+
try {
|
| 56 |
+
resolve(JSON.parse(output));
|
| 57 |
+
} catch (e) {
|
| 58 |
+
console.error(`Failed to parse news output. Raw output: ${output}`);
|
| 59 |
+
reject(new Error(`Backend error: ${output.substring(0, 100)}...`));
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
});
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
return NextResponse.json(newsData);
|
| 66 |
+
} catch (error) {
|
| 67 |
+
console.error('Error fetching news:', error);
|
| 68 |
+
return NextResponse.json(
|
| 69 |
+
{ error: error.message || 'Failed to fetch news articles' },
|
| 70 |
+
{ status: 500 }
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
}
|
STABLE_BACKUP/NewsApex/app/components/BiasModal.jsx
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
|
| 5 |
+
export default function BiasModal({
|
| 6 |
+
isOpen,
|
| 7 |
+
onClose,
|
| 8 |
+
article,
|
| 9 |
+
biasData,
|
| 10 |
+
isLoading,
|
| 11 |
+
loadingStage,
|
| 12 |
+
error,
|
| 13 |
+
onRunBiasAnalysis
|
| 14 |
+
}) {
|
| 15 |
+
if (!isOpen) return null;
|
| 16 |
+
|
| 17 |
+
const handleReadArticle = () => {
|
| 18 |
+
window.open(article.url, '_blank', 'noopener,noreferrer');
|
| 19 |
+
onClose();
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const getBiasColor = (level) => {
|
| 23 |
+
switch (level) {
|
| 24 |
+
case 'Low': return 'text-green-600';
|
| 25 |
+
case 'Medium': return 'text-yellow-600';
|
| 26 |
+
case 'High': return 'text-red-600';
|
| 27 |
+
default: return 'text-gray-600';
|
| 28 |
+
}
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const getBiasBgColor = (level) => {
|
| 32 |
+
switch (level) {
|
| 33 |
+
case 'Low': return 'bg-green-50';
|
| 34 |
+
case 'Medium': return 'bg-yellow-50';
|
| 35 |
+
case 'High': return 'bg-red-50';
|
| 36 |
+
default: return 'bg-gray-50';
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const getBiasBadgeColor = (level) => {
|
| 41 |
+
switch (level) {
|
| 42 |
+
case 'Low': return 'bg-green-100 text-green-800';
|
| 43 |
+
case 'Medium': return 'bg-yellow-100 text-yellow-800';
|
| 44 |
+
case 'High': return 'bg-red-100 text-red-800';
|
| 45 |
+
default: return 'bg-gray-100 text-gray-800';
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
| 51 |
+
<div className="bg-white rounded-xl shadow-2xl max-w-6xl w-full max-h-[90vh] flex flex-col overflow-hidden">
|
| 52 |
+
{/* Header */}
|
| 53 |
+
<div className="bg-white border-b border-gray-200 p-5 flex justify-between items-center shrink-0">
|
| 54 |
+
<div>
|
| 55 |
+
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
|
| 56 |
+
Media Bias Analysis
|
| 57 |
+
</h2>
|
| 58 |
+
<p className="text-xs text-gray-500 mt-0.5">
|
| 59 |
+
Powered by BERT-BABE Linguistic Analysis
|
| 60 |
+
</p>
|
| 61 |
+
</div>
|
| 62 |
+
<button
|
| 63 |
+
onClick={onClose}
|
| 64 |
+
className="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-full"
|
| 65 |
+
aria-label="Close modal"
|
| 66 |
+
>
|
| 67 |
+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
| 68 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
| 69 |
+
</svg>
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{/* Split Content Area */}
|
| 74 |
+
<div className="flex flex-1 overflow-hidden">
|
| 75 |
+
{/* Left Column: Summary & Analysis Results */}
|
| 76 |
+
<div className="w-1/3 border-r border-gray-100 overflow-y-auto p-6 bg-gray-50/50">
|
| 77 |
+
{/* Article Info */}
|
| 78 |
+
<div className="mb-6">
|
| 79 |
+
<h3 className="font-bold text-gray-900 text-base mb-1 leading-tight">
|
| 80 |
+
{article?.title}
|
| 81 |
+
</h3>
|
| 82 |
+
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 83 |
+
{article?.source?.name || 'Unknown Source'}
|
| 84 |
+
</p>
|
| 85 |
+
</div>
|
| 86 |
+
|
| 87 |
+
{/* Summary Section */}
|
| 88 |
+
<div className="mb-6">
|
| 89 |
+
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Executive Summary</h4>
|
| 90 |
+
{biasData?.summary ? (
|
| 91 |
+
<div className="bg-white p-4 rounded-xl border border-blue-100 shadow-sm">
|
| 92 |
+
<p className="text-sm text-gray-700 leading-relaxed italic">
|
| 93 |
+
"{biasData.summary}"
|
| 94 |
+
</p>
|
| 95 |
+
</div>
|
| 96 |
+
) : (
|
| 97 |
+
<div className="bg-white p-4 rounded-xl border border-gray-200 shadow-sm border-dashed">
|
| 98 |
+
<p className="text-sm text-gray-400 italic">
|
| 99 |
+
Summary unavailable. Analysis can still proceed on the full text.
|
| 100 |
+
</p>
|
| 101 |
+
</div>
|
| 102 |
+
)}
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
{/* Overall Analysis Result */}
|
| 106 |
+
{isLoading ? (
|
| 107 |
+
<div className="flex flex-col items-center justify-center py-12 bg-white rounded-xl border border-gray-100 shadow-sm">
|
| 108 |
+
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-red-500 mb-4"></div>
|
| 109 |
+
<p className="text-gray-500 text-xs font-bold uppercase tracking-widest animate-pulse text-center px-4">
|
| 110 |
+
{loadingStage === 'extracting' ? 'Extracting article...' : 'Analyzing article...'}
|
| 111 |
+
</p>
|
| 112 |
+
</div>
|
| 113 |
+
) : error ? (
|
| 114 |
+
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-center">
|
| 115 |
+
<p className="text-red-700 text-sm font-bold mb-1">Analysis Failed</p>
|
| 116 |
+
<p className="text-red-600 text-xs">{error}</p>
|
| 117 |
+
</div>
|
| 118 |
+
) : biasData?.bias_level ? (
|
| 119 |
+
<div className="space-y-6">
|
| 120 |
+
{/* Result Card */}
|
| 121 |
+
<div className={`${getBiasBgColor(biasData.bias_level)} rounded-xl p-5 border border-opacity-20 shadow-sm text-center`}>
|
| 122 |
+
<div className="text-4xl font-black mb-1">
|
| 123 |
+
<span className={getBiasColor(biasData.bias_level)}>
|
| 124 |
+
{biasData.bias_score}%
|
| 125 |
+
</span>
|
| 126 |
+
</div>
|
| 127 |
+
<span className={`inline-block px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${getBiasBadgeColor(biasData.bias_level)} mb-4`}>
|
| 128 |
+
Bias Score
|
| 129 |
+
</span>
|
| 130 |
+
<p className="text-xs text-gray-700 font-medium leading-relaxed">
|
| 131 |
+
{biasData.explanation}
|
| 132 |
+
</p>
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
{/* Top Biased Words */}
|
| 136 |
+
{biasData?.top_words && biasData.top_words.length > 0 && biasData.bias_level !== 'Low' && (
|
| 137 |
+
<div>
|
| 138 |
+
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-3">Key Biased Markers</h4>
|
| 139 |
+
<div className="flex flex-wrap gap-1.5">
|
| 140 |
+
{biasData.top_words.map((item, idx) => (
|
| 141 |
+
<div key={idx} className="bg-white border border-red-100 rounded-lg px-2.5 py-1.5 flex items-center gap-2 shadow-sm">
|
| 142 |
+
<span className="text-xs font-bold text-red-700">{item.word}</span>
|
| 143 |
+
<span className="text-[9px] bg-red-50 text-red-500 px-1 rounded font-mono font-bold">
|
| 144 |
+
{item.score}
|
| 145 |
+
</span>
|
| 146 |
+
</div>
|
| 147 |
+
))}
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
)}
|
| 151 |
+
</div>
|
| 152 |
+
) : (
|
| 153 |
+
<div className="space-y-6">
|
| 154 |
+
<div className="flex justify-center">
|
| 155 |
+
<button
|
| 156 |
+
onClick={onRunBiasAnalysis}
|
| 157 |
+
className="w-full bg-red-600 hover:bg-red-700 text-white font-black text-xs uppercase tracking-widest py-4 px-6 rounded-xl shadow-lg shadow-red-200 transform transition active:scale-95 flex items-center justify-center gap-2"
|
| 158 |
+
>
|
| 159 |
+
Start Deep Analysis
|
| 160 |
+
</button>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
{/* Grading Threshold Guide */}
|
| 164 |
+
<div className="bg-white border border-gray-100 rounded-xl p-4 shadow-sm">
|
| 165 |
+
<h4 className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-3">Bias Grading Scale</h4>
|
| 166 |
+
<div className="space-y-3">
|
| 167 |
+
<div className="w-full bg-gray-100 h-2 rounded-full overflow-hidden flex shadow-inner">
|
| 168 |
+
<div className="bg-green-400 h-full w-1/2"></div>
|
| 169 |
+
<div className="bg-yellow-400 h-full w-[20%]"></div>
|
| 170 |
+
<div className="bg-red-500 h-full w-[30%]"></div>
|
| 171 |
+
</div>
|
| 172 |
+
<div className="space-y-1.5">
|
| 173 |
+
<div className="flex items-center justify-between text-[10px]">
|
| 174 |
+
<span className="text-green-600 font-bold">0% - 50%</span>
|
| 175 |
+
<span className="text-gray-500 font-medium italic">Likely Factual</span>
|
| 176 |
+
</div>
|
| 177 |
+
<div className="flex items-center justify-between text-[10px]">
|
| 178 |
+
<span className="text-yellow-600 font-bold">51% - 70%</span>
|
| 179 |
+
<span className="text-gray-500 font-medium italic text-right">Likely Biased</span>
|
| 180 |
+
</div>
|
| 181 |
+
<div className="flex items-center justify-between text-[10px]">
|
| 182 |
+
<span className="text-red-600 font-bold">71% - 100%</span>
|
| 183 |
+
<span className="text-gray-500 font-medium italic text-right">Strongly Biased</span>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
)}
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
{/* Right Column: Full Article with Highlighting */}
|
| 193 |
+
<div className="flex-1 overflow-y-auto p-8 bg-white">
|
| 194 |
+
<h4 className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-6">Full Article Context</h4>
|
| 195 |
+
|
| 196 |
+
<div className="prose prose-sm max-w-none">
|
| 197 |
+
{!biasData?.sentence_breakdown ? (
|
| 198 |
+
<div className="space-y-4">
|
| 199 |
+
{(biasData?.full_content || article?.description || 'No content available for preview.').split('\n').map((para, i) => (
|
| 200 |
+
<p key={i} className="text-gray-400 leading-relaxed select-none">
|
| 201 |
+
{para}
|
| 202 |
+
</p>
|
| 203 |
+
))}
|
| 204 |
+
{!biasData?.bias_level && !isLoading && (
|
| 205 |
+
<div className="bg-gray-50 rounded-xl p-8 text-center border-2 border-dashed border-gray-200">
|
| 206 |
+
<p className="text-gray-400 text-sm italic">
|
| 207 |
+
Run the analysis to see bias highlighting in the full text.
|
| 208 |
+
</p>
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
</div>
|
| 212 |
+
) : (
|
| 213 |
+
<div className="space-y-1">
|
| 214 |
+
{biasData.sentence_breakdown.map((s, idx) => {
|
| 215 |
+
const isBiased = s.label === 'Biased';
|
| 216 |
+
const highlightClass = isBiased
|
| 217 |
+
? 'bg-red-100/80 border-b-2 border-red-300 hover:bg-red-200 transition-colors cursor-help'
|
| 218 |
+
: 'bg-green-100/60 border-b-2 border-green-200 hover:bg-green-200 transition-colors cursor-help';
|
| 219 |
+
|
| 220 |
+
return (
|
| 221 |
+
<span
|
| 222 |
+
key={idx}
|
| 223 |
+
className={`inline p-0.5 rounded-sm text-gray-900 leading-[1.8] text-sm group relative ${highlightClass}`}
|
| 224 |
+
title={s.reasoning || (isBiased ? 'Flagged as potentially biased' : 'Classified as neutral reporting')}
|
| 225 |
+
>
|
| 226 |
+
{s.text}{' '}
|
| 227 |
+
{/* Tooltip on hover */}
|
| 228 |
+
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-[10px] rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 w-48 text-center font-medium leading-normal">
|
| 229 |
+
{s.label}: {s.score}%
|
| 230 |
+
{s.reasoning && <div className="mt-1 text-gray-400 italic font-normal">"{s.reasoning}"</div>}
|
| 231 |
+
</span>
|
| 232 |
+
</span>
|
| 233 |
+
);
|
| 234 |
+
})}
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
+
</div>
|
| 238 |
+
</div>
|
| 239 |
+
</div>
|
| 240 |
+
|
| 241 |
+
{/* Footer */}
|
| 242 |
+
<div className="sticky bottom-0 bg-white border-t border-gray-100 p-5 flex gap-4 shrink-0">
|
| 243 |
+
<button
|
| 244 |
+
onClick={handleReadArticle}
|
| 245 |
+
disabled={isLoading}
|
| 246 |
+
className="flex-1 bg-gray-900 hover:bg-black disabled:bg-gray-200 text-white font-black text-xs uppercase tracking-widest py-3 rounded-lg transition-all"
|
| 247 |
+
>
|
| 248 |
+
Visit Original Site
|
| 249 |
+
</button>
|
| 250 |
+
<button
|
| 251 |
+
onClick={onClose}
|
| 252 |
+
className="flex-1 border-2 border-gray-100 hover:bg-gray-50 text-gray-500 font-black text-xs uppercase tracking-widest py-3 rounded-lg transition-all"
|
| 253 |
+
>
|
| 254 |
+
Close Analysis
|
| 255 |
+
</button>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
);
|
| 260 |
+
}
|
STABLE_BACKUP/NewsApex/app/components/CategoryFilter.jsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
export default function CategoryFilter({ activeCategory, onCategoryChange }) {
|
| 4 |
+
const categories = [
|
| 5 |
+
{ id: 'general', name: 'General'},
|
| 6 |
+
{ id: 'business', name: 'Business',},
|
| 7 |
+
{ id: 'technology', name: 'Technology'},
|
| 8 |
+
{ id: 'entertainment', name: 'Entertainment'},
|
| 9 |
+
{ id: 'health', name: 'Health'},
|
| 10 |
+
{ id: 'science', name: 'Science'},
|
| 11 |
+
{ id: 'sports', name: 'Sports'},
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<nav className="ml-10 mr-4" aria-label="Categories">
|
| 16 |
+
<ol className="flex items-center justify-center text-sm text-gray-600">
|
| 17 |
+
{categories.map((category, idx) => {
|
| 18 |
+
const isActive = activeCategory === category.id;
|
| 19 |
+
return (
|
| 20 |
+
<li key={category.id} className="flex items-center">
|
| 21 |
+
<button
|
| 22 |
+
onClick={() => onCategoryChange(category.id)}
|
| 23 |
+
className={`px-1 py-1 transition-all focus:outline-none ${
|
| 24 |
+
isActive
|
| 25 |
+
? 'text-blue-600 border-b-2 border-blue-600 font-semibold'
|
| 26 |
+
: 'hover:text-blue-600 hover:border-b-2 hover:border-blue-600 border-b-2 border-transparent'
|
| 27 |
+
}`}
|
| 28 |
+
>
|
| 29 |
+
<span>{category.name}</span>
|
| 30 |
+
</button>
|
| 31 |
+
|
| 32 |
+
{idx < categories.length - 1 && (
|
| 33 |
+
<span className="mx-3 text-gray-400 select-none">βΊ</span>
|
| 34 |
+
)}
|
| 35 |
+
</li>
|
| 36 |
+
);
|
| 37 |
+
})}
|
| 38 |
+
</ol>
|
| 39 |
+
</nav>
|
| 40 |
+
);
|
| 41 |
+
}
|
STABLE_BACKUP/NewsApex/app/components/LoadingSkeleton.jsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function LoadingSkeleton() {
|
| 2 |
+
return (
|
| 3 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 4 |
+
{[...Array(6)].map((_, index) => (
|
| 5 |
+
<div key={index} className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse">
|
| 6 |
+
<div className="w-full h-48 bg-gray-300"></div>
|
| 7 |
+
<div className="p-5">
|
| 8 |
+
<div className="flex items-center justify-between mb-2">
|
| 9 |
+
<div className="h-4 bg-gray-300 rounded w-24"></div>
|
| 10 |
+
<div className="h-3 bg-gray-300 rounded w-20"></div>
|
| 11 |
+
</div>
|
| 12 |
+
<div className="h-6 bg-gray-300 rounded w-full mb-2"></div>
|
| 13 |
+
<div className="h-6 bg-gray-300 rounded w-3/4 mb-3"></div>
|
| 14 |
+
<div className="h-4 bg-gray-300 rounded w-full mb-2"></div>
|
| 15 |
+
<div className="h-4 bg-gray-300 rounded w-5/6"></div>
|
| 16 |
+
</div>
|
| 17 |
+
</div>
|
| 18 |
+
))}
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
STABLE_BACKUP/NewsApex/app/components/NewsCard.jsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
|
| 5 |
+
export default function NewsCard({ article, onArticleClick }) {
|
| 6 |
+
const {
|
| 7 |
+
title,
|
| 8 |
+
description,
|
| 9 |
+
url,
|
| 10 |
+
urlToImage,
|
| 11 |
+
publishedAt,
|
| 12 |
+
source,
|
| 13 |
+
author,
|
| 14 |
+
content
|
| 15 |
+
} = article;
|
| 16 |
+
|
| 17 |
+
const [imgError, setImgError] = useState(false);
|
| 18 |
+
|
| 19 |
+
const formatDate = (dateString) => {
|
| 20 |
+
const date = new Date(dateString);
|
| 21 |
+
return date.toLocaleDateString('en-US', {
|
| 22 |
+
year: 'numeric',
|
| 23 |
+
month: 'short',
|
| 24 |
+
day: 'numeric',
|
| 25 |
+
hour: '2-digit',
|
| 26 |
+
minute: '2-digit'
|
| 27 |
+
});
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const isValidImage =
|
| 31 |
+
urlToImage &&
|
| 32 |
+
typeof urlToImage === "string" &&
|
| 33 |
+
urlToImage.startsWith("http") &&
|
| 34 |
+
!imgError;
|
| 35 |
+
|
| 36 |
+
// We show the card even without a perfect image, using a placeholder
|
| 37 |
+
// if (!isValidImage) return null;
|
| 38 |
+
|
| 39 |
+
const handleClick = (e) => {
|
| 40 |
+
e.preventDefault();
|
| 41 |
+
if (onArticleClick) {
|
| 42 |
+
onArticleClick(article);
|
| 43 |
+
}
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-shadow duration-300 cursor-pointer h-full flex flex-col">
|
| 48 |
+
<button
|
| 49 |
+
onClick={handleClick}
|
| 50 |
+
className="block w-full text-left hover:opacity-95 transition-opacity h-full flex flex-col"
|
| 51 |
+
style={{ border: 'none', background: 'none', padding: 0 }}
|
| 52 |
+
>
|
| 53 |
+
<div className="relative w-full h-48 bg-gray-100 shrink-0">
|
| 54 |
+
{isValidImage ? (
|
| 55 |
+
<img
|
| 56 |
+
src={urlToImage}
|
| 57 |
+
alt={title || 'News image'}
|
| 58 |
+
className="w-full h-full object-cover"
|
| 59 |
+
onError={() => {
|
| 60 |
+
setImgError(true);
|
| 61 |
+
}}
|
| 62 |
+
loading="lazy"
|
| 63 |
+
/>
|
| 64 |
+
) : (
|
| 65 |
+
<div className="flex items-center justify-center h-full text-gray-300 bg-gray-50 border-b border-gray-100">
|
| 66 |
+
<span className="text-2xl opacity-50">π°</span>
|
| 67 |
+
</div>
|
| 68 |
+
)}
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div className="p-5 flex-1 flex flex-col">
|
| 72 |
+
<div className="flex items-center justify-between mb-2">
|
| 73 |
+
<span className="text-sm font-semibold text-blue-600">
|
| 74 |
+
{source?.name || 'Unknown Source'}
|
| 75 |
+
</span>
|
| 76 |
+
<time className="text-xs text-gray-500">
|
| 77 |
+
{formatDate(publishedAt)}
|
| 78 |
+
</time>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<h2 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 hover:text-blue-600 transition-colors">
|
| 82 |
+
{title}
|
| 83 |
+
</h2>
|
| 84 |
+
|
| 85 |
+
{description && (
|
| 86 |
+
<p className="text-gray-600 text-sm line-clamp-3 mb-3">
|
| 87 |
+
{description}
|
| 88 |
+
</p>
|
| 89 |
+
)}
|
| 90 |
+
|
| 91 |
+
{author && (
|
| 92 |
+
<p className="text-xs text-gray-500">
|
| 93 |
+
By {author}
|
| 94 |
+
</p>
|
| 95 |
+
)}
|
| 96 |
+
</div>
|
| 97 |
+
</button>
|
| 98 |
+
</article>
|
| 99 |
+
);
|
| 100 |
+
}
|
STABLE_BACKUP/NewsApex/app/favicon.ico
ADDED
|
|
STABLE_BACKUP/NewsApex/app/globals.css
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--background: #ffffff;
|
| 7 |
+
--foreground: #171717;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
@theme inline {
|
| 11 |
+
--color-background: var(--background);
|
| 12 |
+
--color-foreground: var(--foreground);
|
| 13 |
+
--font-sans: var(--font-geist-sans);
|
| 14 |
+
--font-mono: var(--font-geist-mono);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
@media (prefers-color-scheme: dark) {
|
| 18 |
+
:root {
|
| 19 |
+
--background: #0a0a0a;
|
| 20 |
+
--foreground: #ededed;
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
body {
|
| 25 |
+
background: var(--background);
|
| 26 |
+
color: var(--foreground);
|
| 27 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 28 |
+
}
|
STABLE_BACKUP/NewsApex/app/layout.jsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Geist, Geist_Mono } from "next/font/google";
|
| 2 |
+
import "./globals.css";
|
| 3 |
+
|
| 4 |
+
const geistSans = Geist({
|
| 5 |
+
variable: "--font-geist-sans",
|
| 6 |
+
subsets: ["latin"],
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
const geistMono = Geist_Mono({
|
| 10 |
+
variable: "--font-geist-mono",
|
| 11 |
+
subsets: ["latin"],
|
| 12 |
+
});
|
| 13 |
+
|
| 14 |
+
export const metadata = {
|
| 15 |
+
description: "Stay informed with the latest news from around the world",
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export default function RootLayout({ children }) {
|
| 19 |
+
return (
|
| 20 |
+
<html lang="en">
|
| 21 |
+
<body
|
| 22 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 23 |
+
>
|
| 24 |
+
{children}
|
| 25 |
+
</body>
|
| 26 |
+
</html>
|
| 27 |
+
);
|
| 28 |
+
}
|
STABLE_BACKUP/NewsApex/app/page.jsx
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from "react";
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import NewsCard from './components/NewsCard';
|
| 6 |
+
import CategoryFilter from './components/CategoryFilter';
|
| 7 |
+
import LoadingSkeleton from './components/LoadingSkeleton';
|
| 8 |
+
import BiasModal from './components/BiasModal';
|
| 9 |
+
|
| 10 |
+
export default function Home() {
|
| 11 |
+
const [articles, setArticles] = useState([]);
|
| 12 |
+
const [loading, setLoading] = useState(true);
|
| 13 |
+
const [error, setError] = useState(null);
|
| 14 |
+
const [activeCategory, setActiveCategory] = useState('general');
|
| 15 |
+
const [searchQuery, setSearchQuery] = useState('');
|
| 16 |
+
const [submittedQuery, setSubmittedQuery] = useState('');
|
| 17 |
+
|
| 18 |
+
// Bias Modal State
|
| 19 |
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
| 20 |
+
const [selectedArticle, setSelectedArticle] = useState(null);
|
| 21 |
+
const [biasData, setBiasData] = useState(null);
|
| 22 |
+
const [biasLoading, setBiasLoading] = useState(false);
|
| 23 |
+
const [biasError, setBiasError] = useState(null);
|
| 24 |
+
const [loadingStage, setLoadingStage] = useState('extracting'); // 'extracting' or 'analyzing'
|
| 25 |
+
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
fetchNews();
|
| 28 |
+
}, [activeCategory]);
|
| 29 |
+
|
| 30 |
+
const fetchNews = async (query = '') => {
|
| 31 |
+
setLoading(true);
|
| 32 |
+
setError(null);
|
| 33 |
+
|
| 34 |
+
try {
|
| 35 |
+
const params = new URLSearchParams();
|
| 36 |
+
if (query) {
|
| 37 |
+
params.append('query', query);
|
| 38 |
+
} else {
|
| 39 |
+
params.append('category', activeCategory);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const response = await fetch(`/api/news?${params}`);
|
| 43 |
+
|
| 44 |
+
const text = await response.text();
|
| 45 |
+
let data;
|
| 46 |
+
try {
|
| 47 |
+
data = JSON.parse(text);
|
| 48 |
+
} catch (e) {
|
| 49 |
+
throw new Error(`Server error: ${text.substring(0, 200)}`);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (!response.ok) {
|
| 53 |
+
throw new Error(data.error || 'Failed to fetch news');
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
setArticles(data.articles || []);
|
| 57 |
+
setSearchQuery(query);
|
| 58 |
+
} catch (err) {
|
| 59 |
+
console.error('Fetch error:', err);
|
| 60 |
+
setError(err.message);
|
| 61 |
+
setArticles([]);
|
| 62 |
+
} finally {
|
| 63 |
+
setLoading(false);
|
| 64 |
+
}
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleSearch = (query) => {
|
| 68 |
+
fetchNews(query);
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
const handleCategoryChange = (category) => {
|
| 72 |
+
setActiveCategory(category);
|
| 73 |
+
setSearchQuery('');
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
const handleArticleClick = async (article) => {
|
| 77 |
+
// Open modal with loading state for summary
|
| 78 |
+
setSelectedArticle(article);
|
| 79 |
+
setIsModalOpen(true);
|
| 80 |
+
setBiasLoading(true);
|
| 81 |
+
setLoadingStage('extracting');
|
| 82 |
+
setBiasError(null);
|
| 83 |
+
setBiasData(null);
|
| 84 |
+
|
| 85 |
+
try {
|
| 86 |
+
const response = await fetch('/api/bias', {
|
| 87 |
+
method: 'POST',
|
| 88 |
+
headers: {
|
| 89 |
+
'Content-Type': 'application/json',
|
| 90 |
+
},
|
| 91 |
+
body: JSON.stringify({
|
| 92 |
+
url: article.url,
|
| 93 |
+
action: 'get_summary'
|
| 94 |
+
}),
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
if (!response.ok) {
|
| 98 |
+
const errorData = await response.json();
|
| 99 |
+
throw new Error(errorData.error || 'Failed to get summary');
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const result = await response.json();
|
| 103 |
+
setBiasData(result);
|
| 104 |
+
} catch (err) {
|
| 105 |
+
console.error('Summary error:', err);
|
| 106 |
+
setBiasError(err.message);
|
| 107 |
+
} finally {
|
| 108 |
+
setBiasLoading(false);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handleRunBiasAnalysis = async () => {
|
| 113 |
+
if (!selectedArticle) return;
|
| 114 |
+
|
| 115 |
+
setBiasLoading(true);
|
| 116 |
+
setLoadingStage('analyzing');
|
| 117 |
+
setBiasError(null);
|
| 118 |
+
|
| 119 |
+
try {
|
| 120 |
+
const response = await fetch('/api/bias', {
|
| 121 |
+
method: 'POST',
|
| 122 |
+
headers: {
|
| 123 |
+
'Content-Type': 'application/json',
|
| 124 |
+
},
|
| 125 |
+
body: JSON.stringify({
|
| 126 |
+
url: selectedArticle.url,
|
| 127 |
+
content: biasData?.full_content, // πΉ Pass already extracted content to save time
|
| 128 |
+
action: 'analyze_bias'
|
| 129 |
+
}),
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
if (!response.ok) {
|
| 133 |
+
const errorData = await response.json();
|
| 134 |
+
throw new Error(errorData.error || 'Failed to analyze bias');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const result = await response.json();
|
| 138 |
+
// Merge bias results with existing summary data
|
| 139 |
+
setBiasData(prev => ({
|
| 140 |
+
...prev,
|
| 141 |
+
...result
|
| 142 |
+
}));
|
| 143 |
+
} catch (err) {
|
| 144 |
+
console.error('Bias analysis error:', err);
|
| 145 |
+
setBiasError(err.message);
|
| 146 |
+
} finally {
|
| 147 |
+
setBiasLoading(false);
|
| 148 |
+
}
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const handleCloseModal = () => {
|
| 152 |
+
setIsModalOpen(false);
|
| 153 |
+
setSelectedArticle(null);
|
| 154 |
+
setBiasData(null);
|
| 155 |
+
setBiasError(null);
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
return (
|
| 159 |
+
<div className="min-h-screen bg-white text-gray-800">
|
| 160 |
+
{/* Header */}
|
| 161 |
+
<header className="bg-white shadow sticky top-0 z-50 border-b">
|
| 162 |
+
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between gap-4">
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
{/* Category Filter */}
|
| 166 |
+
{!searchQuery && (
|
| 167 |
+
<CategoryFilter
|
| 168 |
+
activeCategory={activeCategory}
|
| 169 |
+
onCategoryChange={handleCategoryChange}
|
| 170 |
+
/>
|
| 171 |
+
) || (
|
| 172 |
+
<CategoryFilter
|
| 173 |
+
activeCategory={''}
|
| 174 |
+
onCategoryChange={handleCategoryChange}
|
| 175 |
+
/>
|
| 176 |
+
)}
|
| 177 |
+
|
| 178 |
+
{/* Search Bar*/}
|
| 179 |
+
<div className="ml-auto">
|
| 180 |
+
<form onSubmit={(e) => {
|
| 181 |
+
e.preventDefault();
|
| 182 |
+
if (!searchQuery.trim()) return;
|
| 183 |
+
setSubmittedQuery(searchQuery.trim());
|
| 184 |
+
setActiveCategory('');
|
| 185 |
+
handleSearch(searchQuery);
|
| 186 |
+
}} className="w-full max-w-md mb-0">
|
| 187 |
+
<div className="relative">
|
| 188 |
+
<input
|
| 189 |
+
type="text"
|
| 190 |
+
value={searchQuery}
|
| 191 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 192 |
+
placeholder="Search for..."
|
| 193 |
+
className="w-full px-4 py-2 text-gray-800 bg-gray-50 border border-gray-200"
|
| 194 |
+
/>
|
| 195 |
+
|
| 196 |
+
<button
|
| 197 |
+
type="submit"
|
| 198 |
+
className="absolute inset-y-0 right-3 flex items-center text-gray-500 hover:text-gray-800"
|
| 199 |
+
>
|
| 200 |
+
<svg
|
| 201 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 202 |
+
className="w-5 h-5"
|
| 203 |
+
fill="none"
|
| 204 |
+
viewBox="0 0 24 24"
|
| 205 |
+
stroke="currentColor"
|
| 206 |
+
>
|
| 207 |
+
<path
|
| 208 |
+
strokeLinecap="round"
|
| 209 |
+
strokeLinejoin="round"
|
| 210 |
+
strokeWidth={2}
|
| 211 |
+
d="M21 21l-4.35-4.35m1.6-5.65a7 7 0 11-14 0 7 7 0 0114 0z"
|
| 212 |
+
/>
|
| 213 |
+
</svg>
|
| 214 |
+
</button>
|
| 215 |
+
</div>
|
| 216 |
+
</form>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</header>
|
| 220 |
+
|
| 221 |
+
{/* Main Content */}
|
| 222 |
+
<main className="max-w-7xl mx-auto px-4 py-8">
|
| 223 |
+
{/* Search Query Display */}
|
| 224 |
+
{submittedQuery && !loading && activeCategory === '' && (
|
| 225 |
+
<div className="mb-6 text-center">
|
| 226 |
+
<p className="text-gray-700">
|
| 227 |
+
Showing results for <span className="font-bold">{submittedQuery}</span>
|
| 228 |
+
</p>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
)}
|
| 232 |
+
|
| 233 |
+
{/* Error State */}
|
| 234 |
+
{error && (
|
| 235 |
+
<div className="text-center py-12">
|
| 236 |
+
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 text-red-500 mb-4">
|
| 237 |
+
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 238 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 239 |
+
</svg>
|
| 240 |
+
</div>
|
| 241 |
+
<h3 className="text-lg font-semibold text-gray-900 mb-1">Unable to load news</h3>
|
| 242 |
+
<p className="text-gray-600 mb-6 max-w-md mx-auto">{error}</p>
|
| 243 |
+
<button
|
| 244 |
+
onClick={() => fetchNews(searchQuery)}
|
| 245 |
+
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
| 246 |
+
>
|
| 247 |
+
Try Again
|
| 248 |
+
</button>
|
| 249 |
+
</div>
|
| 250 |
+
)}
|
| 251 |
+
|
| 252 |
+
{/* Loading State */}
|
| 253 |
+
{loading && <LoadingSkeleton />}
|
| 254 |
+
|
| 255 |
+
{/* News Grid */}
|
| 256 |
+
{!loading && articles.length > 0 && (
|
| 257 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-2">
|
| 258 |
+
{articles.map((article, index) => (
|
| 259 |
+
<NewsCard
|
| 260 |
+
key={`${article.url}-${index}`}
|
| 261 |
+
article={article}
|
| 262 |
+
onArticleClick={handleArticleClick}
|
| 263 |
+
/>
|
| 264 |
+
))}
|
| 265 |
+
</div>
|
| 266 |
+
)}
|
| 267 |
+
|
| 268 |
+
{/* No Results */}
|
| 269 |
+
{!loading && articles.length === 0 && !error && (
|
| 270 |
+
<div className="text-center py-12">
|
| 271 |
+
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 272 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
| 273 |
+
</svg>
|
| 274 |
+
<h3 className="mt-2 text-sm font-medium text-gray-900">No articles found</h3>
|
| 275 |
+
<p className="mt-1 text-sm text-gray-500">Try searching for something else or choose a different category.</p>
|
| 276 |
+
</div>
|
| 277 |
+
)}
|
| 278 |
+
</main>
|
| 279 |
+
|
| 280 |
+
{/* Bias Modal */}
|
| 281 |
+
<BiasModal
|
| 282 |
+
isOpen={isModalOpen}
|
| 283 |
+
onClose={handleCloseModal}
|
| 284 |
+
article={selectedArticle}
|
| 285 |
+
biasData={biasData}
|
| 286 |
+
isLoading={biasLoading}
|
| 287 |
+
loadingStage={loadingStage}
|
| 288 |
+
error={biasError}
|
| 289 |
+
onRunBiasAnalysis={handleRunBiasAnalysis}
|
| 290 |
+
/>
|
| 291 |
+
|
| 292 |
+
{/* Footer */}
|
| 293 |
+
<footer className="bg-[#1b1b1b] mt-16 border-t border-gray-200">
|
| 294 |
+
<div className="max-w-7xl mx-auto px-4 py-6 text-center text-sm text-gray-300">
|
| 295 |
+
<div>
|
| 296 |
+
<span>
|
| 297 |
+
© {new Date().getFullYear()} NewsApex. All rights reserved.
|
| 298 |
+
</span>
|
| 299 |
+
<span className='text-blue-200 font-bold'> NEXTER </span>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
</footer>
|
| 303 |
+
</div>
|
| 304 |
+
);
|
| 305 |
+
}
|
STABLE_BACKUP/NewsApex/bridge_logic.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
import json
|
| 5 |
+
import argparse
|
| 6 |
+
|
| 7 |
+
# Add current directory to sys.path to access our news_service and model locally
|
| 8 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 9 |
+
if current_dir not in sys.path:
|
| 10 |
+
sys.path.insert(0, current_dir)
|
| 11 |
+
|
| 12 |
+
from news_service import NewsService
|
| 13 |
+
|
| 14 |
+
# Helper to print only valid JSON to stdout
|
| 15 |
+
def print_json(data):
|
| 16 |
+
# Ensure stdout is clean by flushing and then printing our JSON
|
| 17 |
+
sys.stdout.flush()
|
| 18 |
+
print(json.dumps(data))
|
| 19 |
+
sys.stdout.flush()
|
| 20 |
+
|
| 21 |
+
def main():
|
| 22 |
+
try:
|
| 23 |
+
# Check for essential modules before starting
|
| 24 |
+
try:
|
| 25 |
+
import requests
|
| 26 |
+
except ImportError:
|
| 27 |
+
print_json({"error": "Essential Python libraries (requests) are not installed on the server. Deployment environment is incomplete."})
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
parser = argparse.ArgumentParser(description='Bridge logic for NewsApex UI')
|
| 31 |
+
parser.add_argument('action', choices=['fetch_news', 'get_summary', 'analyze_bias'], help='Action to perform')
|
| 32 |
+
parser.add_argument('--query', help='Search query for news')
|
| 33 |
+
parser.add_argument('--category', help='News category')
|
| 34 |
+
parser.add_argument('--url', help='Article URL for analysis')
|
| 35 |
+
parser.add_argument('--content', help='Direct article content')
|
| 36 |
+
|
| 37 |
+
args = parser.parse_args()
|
| 38 |
+
service = NewsService()
|
| 39 |
+
|
| 40 |
+
if args.action == 'fetch_news':
|
| 41 |
+
# Use our filtered news fetching logic with images
|
| 42 |
+
articles = service.fetch_all_news(query=args.query, category=args.category)
|
| 43 |
+
transformed = []
|
| 44 |
+
for a in articles:
|
| 45 |
+
transformed.append({
|
| 46 |
+
"title": a.get("title"),
|
| 47 |
+
"url": a.get("link"),
|
| 48 |
+
"source": {"name": a.get("source_id")},
|
| 49 |
+
"publishedAt": a.get("pubDate"),
|
| 50 |
+
"urlToImage": a.get("image_url"),
|
| 51 |
+
"description": a.get("snippet")
|
| 52 |
+
})
|
| 53 |
+
# Ensure only the JSON is printed to stdout
|
| 54 |
+
print(json.dumps({"articles": transformed}))
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
elif args.action == 'get_summary':
|
| 58 |
+
if not args.url and not args.content:
|
| 59 |
+
print_json({"error": "URL or content required"})
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
# 1. Get content
|
| 63 |
+
content = args.content or service.get_full_content(args.url)
|
| 64 |
+
if not content:
|
| 65 |
+
print_json({"error": "Could not retrieve content for this article."})
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
# 2. Summarization (truncated)
|
| 69 |
+
summary = service.summarize_content(content[:2000])
|
| 70 |
+
|
| 71 |
+
print_json({
|
| 72 |
+
"summary": summary,
|
| 73 |
+
"full_content": content # We keep this for bias analysis later
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
elif args.action == 'analyze_bias':
|
| 77 |
+
if not args.url and not args.content:
|
| 78 |
+
print_json({"error": "URL or content required"})
|
| 79 |
+
return
|
| 80 |
+
|
| 81 |
+
# 1. Get content
|
| 82 |
+
content = args.content or service.get_full_content(args.url)
|
| 83 |
+
if not content:
|
| 84 |
+
print_json({"error": "Could not retrieve content for this article."})
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
# 2. Analyze overall bias
|
| 88 |
+
overall = service.rate_bias(content)
|
| 89 |
+
|
| 90 |
+
# 3. Sentence-by-sentence analysis (Analyze a subset of the article for highlighting)
|
| 91 |
+
sentences = service.split_into_sentences(content)
|
| 92 |
+
# Limit to the first 25 sentences to avoid timeout, while still giving a good breakdown
|
| 93 |
+
analysis_sentences = sentences[:25]
|
| 94 |
+
|
| 95 |
+
batch_results = service.rate_bias_batch(analysis_sentences)
|
| 96 |
+
|
| 97 |
+
sentence_results = []
|
| 98 |
+
factual_count = 0
|
| 99 |
+
biased_count = 0
|
| 100 |
+
|
| 101 |
+
for i, res in enumerate(batch_results):
|
| 102 |
+
label = res["label"]
|
| 103 |
+
if label == "Biased":
|
| 104 |
+
biased_count += 1
|
| 105 |
+
else:
|
| 106 |
+
factual_count += 1
|
| 107 |
+
|
| 108 |
+
# Always show the raw bias score from the model
|
| 109 |
+
display_score = res["score"]
|
| 110 |
+
|
| 111 |
+
sentence_results.append({
|
| 112 |
+
"text": analysis_sentences[i],
|
| 113 |
+
"label": label,
|
| 114 |
+
"score": round(display_score * 100, 1),
|
| 115 |
+
"reasoning": res.get("reasoning", "")
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
# Determine overall level
|
| 119 |
+
bias_prob = overall["score"]
|
| 120 |
+
if bias_prob > 0.7:
|
| 121 |
+
level = "High"
|
| 122 |
+
elif bias_prob > 0.5:
|
| 123 |
+
level = "Medium"
|
| 124 |
+
else:
|
| 125 |
+
level = "Low"
|
| 126 |
+
|
| 127 |
+
# Show the raw bias score from the model
|
| 128 |
+
overall_display_score = bias_prob
|
| 129 |
+
|
| 130 |
+
print_json({
|
| 131 |
+
"bias_level": level,
|
| 132 |
+
"bias_score": round(overall_display_score * 100, 1),
|
| 133 |
+
"explanation": overall.get("reasoning", "No specific reasoning provided."),
|
| 134 |
+
"top_words": overall.get("top_words", []),
|
| 135 |
+
"sentence_breakdown": sentence_results,
|
| 136 |
+
"factual_count": factual_count,
|
| 137 |
+
"biased_count": biased_count,
|
| 138 |
+
"total_sentences_analyzed": len(analysis_sentences),
|
| 139 |
+
"full_content": content
|
| 140 |
+
})
|
| 141 |
+
except Exception as e:
|
| 142 |
+
import sys
|
| 143 |
+
print_json({"error": str(e)})
|
| 144 |
+
print(f"Bridge Error: {str(e)}", file=sys.stderr)
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
main()
|
STABLE_BACKUP/NewsApex/eslint.config.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, globalIgnores } from "eslint/config";
|
| 2 |
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
| 3 |
+
|
| 4 |
+
const eslintConfig = defineConfig([
|
| 5 |
+
...nextVitals,
|
| 6 |
+
// Override default ignores of eslint-config-next.
|
| 7 |
+
globalIgnores([
|
| 8 |
+
// Default ignores of eslint-config-next:
|
| 9 |
+
".next/**",
|
| 10 |
+
"out/**",
|
| 11 |
+
"build/**",
|
| 12 |
+
"next-env.d.ts",
|
| 13 |
+
]),
|
| 14 |
+
]);
|
| 15 |
+
|
| 16 |
+
export default eslintConfig;
|
STABLE_BACKUP/NewsApex/jsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"paths": {
|
| 4 |
+
"@/*": ["./*"]
|
| 5 |
+
}
|
| 6 |
+
}
|
| 7 |
+
}
|
STABLE_BACKUP/NewsApex/news_service.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import re
|
| 4 |
+
import requests
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
from newspaper import Article, Config
|
| 7 |
+
from huggingface_hub import InferenceClient
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
# Move heavy AI imports inside methods to speed up news fetch startup
|
| 12 |
+
# import torch
|
| 13 |
+
# from transformers import BertTokenizer, BertForSequenceClassification
|
| 14 |
+
|
| 15 |
+
# Add current directory to path for imports
|
| 16 |
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
| 17 |
+
sys.path.append(base_dir)
|
| 18 |
+
|
| 19 |
+
# Load .env from current dir or parent dir
|
| 20 |
+
load_dotenv(os.path.join(base_dir, ".env"))
|
| 21 |
+
load_dotenv(os.path.join(os.path.dirname(base_dir), ".env"))
|
| 22 |
+
|
| 23 |
+
class NewsService:
|
| 24 |
+
STOP_WORDS = {
|
| 25 |
+
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'with',
|
| 26 |
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
| 27 |
+
'do', 'does', 'did', 'but', 'if', 'then', 'else', 'when', 'where', 'why',
|
| 28 |
+
'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some',
|
| 29 |
+
'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very',
|
| 30 |
+
's', 't', 'can', 'will', 'just', 'don', "should", "now", "of", "as", "by",
|
| 31 |
+
"it", "its", "they", "them", "their", "this", "that", "these", "those",
|
| 32 |
+
"i", "me", "my", "myself", "we", "our", "ours", "ourselves", "you", "your"
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
def __init__(self):
|
| 36 |
+
self.newsdata_api_key = os.getenv("NEWSDATA_API_KEY", "pub_c319de1ec46240dc912d9b112e01c866")
|
| 37 |
+
self.guardian_api_key = os.getenv("GUARDIAN_API_KEY", "438ab5df-f19b-42b6-9ca9-83b8e971f219")
|
| 38 |
+
self.hf_token = os.getenv("HF_TOKEN")
|
| 39 |
+
|
| 40 |
+
self.session = requests.Session()
|
| 41 |
+
|
| 42 |
+
self.config = Config()
|
| 43 |
+
self.config.browser_user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
|
| 44 |
+
self.config.request_timeout = 20
|
| 45 |
+
self.config.fetch_images = False
|
| 46 |
+
self.config.memoize_articles = False
|
| 47 |
+
self.config.MAX_TEXT = 100000
|
| 48 |
+
|
| 49 |
+
self.hf_client = None
|
| 50 |
+
if self.hf_token:
|
| 51 |
+
try:
|
| 52 |
+
self.hf_client = InferenceClient(token=self.hf_token)
|
| 53 |
+
except:
|
| 54 |
+
self.hf_client = None
|
| 55 |
+
|
| 56 |
+
self.bias_model = None
|
| 57 |
+
self.bias_tokenizer = None
|
| 58 |
+
|
| 59 |
+
def load_local_bias_model(self):
|
| 60 |
+
try:
|
| 61 |
+
import torch
|
| 62 |
+
from transformers import BertTokenizer, BertForSequenceClassification, BertConfig
|
| 63 |
+
from bias_module import config as bias_config
|
| 64 |
+
base_path = os.path.dirname(os.path.abspath(__file__))
|
| 65 |
+
model_path = os.path.join(base_path, "bias_module", "models", "bert_babe.pt")
|
| 66 |
+
model_cache_dir = os.path.join(base_path, "bias_module", "data", "model_cache")
|
| 67 |
+
|
| 68 |
+
if os.path.exists(model_path):
|
| 69 |
+
if os.path.exists(model_cache_dir):
|
| 70 |
+
from transformers import BertConfig
|
| 71 |
+
self.bias_tokenizer = BertTokenizer.from_pretrained(model_cache_dir)
|
| 72 |
+
config = BertConfig.from_pretrained(os.path.join(model_cache_dir, "config.json"))
|
| 73 |
+
self.bias_model = BertForSequenceClassification(config)
|
| 74 |
+
else:
|
| 75 |
+
self.bias_tokenizer = BertTokenizer.from_pretrained(bias_config.MODEL_NAME)
|
| 76 |
+
self.bias_model = BertForSequenceClassification.from_pretrained(
|
| 77 |
+
bias_config.MODEL_NAME,
|
| 78 |
+
num_labels=2
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
self.bias_model.load_state_dict(torch.load(model_path, map_location=torch.device('cpu')))
|
| 82 |
+
self.bias_model.eval()
|
| 83 |
+
print(f"Local bias model loaded successfully.", file=sys.stderr)
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"Error loading local bias model: {e}", file=sys.stderr)
|
| 86 |
+
|
| 87 |
+
def get_top_biased_words_gradient(self, text, top_k=5):
|
| 88 |
+
if not self.bias_model or not self.bias_tokenizer:
|
| 89 |
+
return []
|
| 90 |
+
try:
|
| 91 |
+
import torch
|
| 92 |
+
encoding = self.bias_tokenizer(text, return_tensors="pt", truncation=True, padding="max_length", max_length=128)
|
| 93 |
+
input_ids = encoding["input_ids"]
|
| 94 |
+
attention_mask = encoding["attention_mask"]
|
| 95 |
+
self.bias_model.zero_grad()
|
| 96 |
+
for param in self.bias_model.bert.embeddings.word_embeddings.parameters():
|
| 97 |
+
param.requires_grad = True
|
| 98 |
+
with torch.enable_grad():
|
| 99 |
+
outputs = self.bias_model(input_ids=input_ids, attention_mask=attention_mask)
|
| 100 |
+
logits = outputs.logits
|
| 101 |
+
bias_logit = logits[0, 1]
|
| 102 |
+
bias_logit.backward()
|
| 103 |
+
embedding_grad = self.bias_model.bert.embeddings.word_embeddings.weight.grad
|
| 104 |
+
if embedding_grad is None: return []
|
| 105 |
+
token_ids = input_ids[0]
|
| 106 |
+
token_grads = embedding_grad[token_ids]
|
| 107 |
+
scores = torch.norm(token_grads, dim=1)
|
| 108 |
+
tokens = self.bias_tokenizer.convert_ids_to_tokens(token_ids)
|
| 109 |
+
filtered = []
|
| 110 |
+
for tok, score in zip(tokens, scores):
|
| 111 |
+
if tok in ["[CLS]", "[SEP]", "[PAD]"] or tok.startswith("##"): continue
|
| 112 |
+
if not any(c.isalnum() for c in tok): continue
|
| 113 |
+
if tok.lower() in self.STOP_WORDS: continue
|
| 114 |
+
if len(tok) < 3 and tok.lower() not in ['a', 'i']: continue
|
| 115 |
+
filtered.append((tok, score.item()))
|
| 116 |
+
sorted_tokens = sorted(filtered, key=lambda x: x[1], reverse=True)
|
| 117 |
+
return [{"word": t[0], "score": round(t[1] * 100, 2)} for t in sorted_tokens[:top_k]]
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f"Gradient Calculation Error: {e}", file=sys.stderr)
|
| 120 |
+
return []
|
| 121 |
+
|
| 122 |
+
def get_bias_reasoning(self, text, label, bias_score):
|
| 123 |
+
if bias_score > 0.7: return "Interpretation: Strongly biased"
|
| 124 |
+
elif bias_score > 0.5: return "Interpretation: Likely Biased"
|
| 125 |
+
else: return "Interpretation: Likely Factual"
|
| 126 |
+
|
| 127 |
+
def rate_bias_batch(self, sentences):
|
| 128 |
+
if not sentences: return []
|
| 129 |
+
if not self.bias_model: self.load_local_bias_model()
|
| 130 |
+
if not self.bias_model or not self.bias_tokenizer:
|
| 131 |
+
return [{"label": "Offline", "score": 0.0, "reasoning": "Model not available."} for _ in sentences]
|
| 132 |
+
try:
|
| 133 |
+
import torch
|
| 134 |
+
inputs = self.bias_tokenizer(sentences, return_tensors="pt", truncation=True, padding=True, max_length=128)
|
| 135 |
+
with torch.no_grad():
|
| 136 |
+
outputs = self.bias_model(**inputs)
|
| 137 |
+
probs = torch.nn.functional.softmax(outputs.logits, dim=-1)
|
| 138 |
+
results = []
|
| 139 |
+
for i, prob in enumerate(probs):
|
| 140 |
+
bias_score = prob[1].item()
|
| 141 |
+
label = "Biased" if bias_score > 0.5 else "Factual"
|
| 142 |
+
reasoning = self.get_bias_reasoning(sentences[i], label, bias_score)
|
| 143 |
+
results.append({"label": label, "score": bias_score, "reasoning": reasoning})
|
| 144 |
+
return results
|
| 145 |
+
except Exception as e:
|
| 146 |
+
print(f"Batch Analysis Error: {e}", file=sys.stderr)
|
| 147 |
+
return [{"label": "Error", "score": 0.0, "reasoning": str(e)} for _ in sentences]
|
| 148 |
+
|
| 149 |
+
def rate_bias(self, text):
|
| 150 |
+
if not self.bias_model: self.load_local_bias_model()
|
| 151 |
+
if not text or len(text.strip()) < 10:
|
| 152 |
+
return {"label": "Neutral", "score": 0.0, "reasoning": "Text too short for analysis."}
|
| 153 |
+
filtered_sentences = self.split_into_sentences(text)
|
| 154 |
+
if not filtered_sentences: return {"label": "Neutral", "score": 0.0, "reasoning": "No valid content found."}
|
| 155 |
+
filtered_text = " ".join(filtered_sentences)
|
| 156 |
+
if self.bias_model and self.bias_tokenizer:
|
| 157 |
+
try:
|
| 158 |
+
import torch
|
| 159 |
+
inputs = self.bias_tokenizer(filtered_text, return_tensors="pt", truncation=True, padding=True, max_length=128)
|
| 160 |
+
with torch.no_grad():
|
| 161 |
+
outputs = self.bias_model(**inputs)
|
| 162 |
+
probs = torch.nn.functional.softmax(outputs.logits, dim=-1)
|
| 163 |
+
bias_score = probs[0][1].item()
|
| 164 |
+
label = "Biased" if bias_score > 0.5 else "Factual"
|
| 165 |
+
top_words = self.get_top_biased_words_gradient(filtered_text, top_k=8)
|
| 166 |
+
reasoning = self.get_bias_reasoning(filtered_text, label, bias_score)
|
| 167 |
+
return {"label": label, "score": bias_score, "reasoning": reasoning, "top_words": top_words}
|
| 168 |
+
except Exception as e:
|
| 169 |
+
print(f"Local Model Prediction Error: {e}", file=sys.stderr)
|
| 170 |
+
return {"label": "Offline", "score": 0.0, "reasoning": "Model failed or is offline."}
|
| 171 |
+
|
| 172 |
+
def split_into_sentences(self, text):
|
| 173 |
+
if not text: return []
|
| 174 |
+
sentences = re.split(r'(?<=[.!?])\s+(?=[A-Z])', text.strip())
|
| 175 |
+
ad_keywords = ["sign up for", "subscribe to", "advertisement", "promoted", "sponsored"]
|
| 176 |
+
filtered_sentences = []
|
| 177 |
+
for s in sentences:
|
| 178 |
+
s_clean = s.strip()
|
| 179 |
+
if not s_clean or len(s_clean.split()) < 4: continue
|
| 180 |
+
if any(keyword in s_clean.lower() for keyword in ad_keywords): continue
|
| 181 |
+
filtered_sentences.append(s_clean)
|
| 182 |
+
return filtered_sentences
|
| 183 |
+
|
| 184 |
+
def fetch_newsdata(self, query=None, category=None, language="en"):
|
| 185 |
+
if not self.newsdata_api_key: return []
|
| 186 |
+
url = "https://newsdata.io/api/1/news"
|
| 187 |
+
params = {"apikey": self.newsdata_api_key, "language": "en"}
|
| 188 |
+
if query: params["q"] = query
|
| 189 |
+
if category and category != 'general': params["category"] = category
|
| 190 |
+
try:
|
| 191 |
+
res = self.session.get(url, params=params, timeout=10)
|
| 192 |
+
data = res.json()
|
| 193 |
+
if data.get("status") == "success":
|
| 194 |
+
return [{"title": r.get("title"), "link": r.get("link"), "source_id": r.get("source_id"), "pubDate": r.get("pubDate"), "image_url": r.get("image_url"), "snippet": r.get("description") or r.get("content")} for r in data.get("results", [])]
|
| 195 |
+
return []
|
| 196 |
+
except: return []
|
| 197 |
+
|
| 198 |
+
def fetch_guardian(self, query=None, category=None):
|
| 199 |
+
if not self.guardian_api_key or "your_" in self.guardian_api_key: return []
|
| 200 |
+
url = "https://content.guardianapis.com/search"
|
| 201 |
+
params = {"api-key": self.guardian_api_key, "show-fields": "thumbnail,trailText"}
|
| 202 |
+
if query: params["q"] = query
|
| 203 |
+
category_map = {'business': 'business', 'technology': 'technology', 'entertainment': 'culture', 'health': 'society', 'science': 'science', 'sports': 'sport'}
|
| 204 |
+
if category and category in category_map: params["section"] = category_map[category]
|
| 205 |
+
try:
|
| 206 |
+
res = self.session.get(url, params=params, timeout=10)
|
| 207 |
+
data = res.json()
|
| 208 |
+
results = data.get("response", {}).get("results", [])
|
| 209 |
+
return [{"title": r.get("webTitle"), "link": r.get("webUrl"), "source_id": "The Guardian", "pubDate": r.get("webPublicationDate"), "image_url": r.get("fields", {}).get("thumbnail"), "snippet": r.get("fields", {}).get("trailText")} for r in results]
|
| 210 |
+
except: return []
|
| 211 |
+
|
| 212 |
+
def fetch_all_news(self, query=None, category=None, language="en"):
|
| 213 |
+
# FASTEST POSSIBLE FETCH: Parallelize the two API calls
|
| 214 |
+
all_articles = []
|
| 215 |
+
|
| 216 |
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
| 217 |
+
# Kick off both API calls at the same time
|
| 218 |
+
future_newsdata = executor.submit(self.fetch_newsdata, query, category, language)
|
| 219 |
+
future_guardian = executor.submit(self.fetch_guardian, query, category)
|
| 220 |
+
|
| 221 |
+
# Collect results
|
| 222 |
+
try:
|
| 223 |
+
all_articles.extend(future_newsdata.result())
|
| 224 |
+
except Exception as e:
|
| 225 |
+
print(f"NewsData error: {e}", file=sys.stderr)
|
| 226 |
+
|
| 227 |
+
try:
|
| 228 |
+
all_articles.extend(future_guardian.result())
|
| 229 |
+
except Exception as e:
|
| 230 |
+
print(f"Guardian error: {e}", file=sys.stderr)
|
| 231 |
+
|
| 232 |
+
unique_articles = []
|
| 233 |
+
seen_titles = set()
|
| 234 |
+
for article in all_articles:
|
| 235 |
+
title = article.get("title")
|
| 236 |
+
if title and title.lower() not in seen_titles:
|
| 237 |
+
unique_articles.append(article)
|
| 238 |
+
seen_titles.add(title.lower())
|
| 239 |
+
|
| 240 |
+
try:
|
| 241 |
+
unique_articles.sort(key=lambda x: x.get('pubDate', ''), reverse=True)
|
| 242 |
+
except:
|
| 243 |
+
pass
|
| 244 |
+
|
| 245 |
+
# --- SCRAPABILITY FILTER ---
|
| 246 |
+
# Only show articles that we can actually extract content from.
|
| 247 |
+
filtered_articles = []
|
| 248 |
+
# Limit the number of articles we check to keep it fast
|
| 249 |
+
articles_to_check = unique_articles[:30]
|
| 250 |
+
|
| 251 |
+
def check_article(article):
|
| 252 |
+
url = article.get("link")
|
| 253 |
+
if not url:
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
# Use a slightly shorter timeout for the background check
|
| 257 |
+
content = self.get_full_content(url)
|
| 258 |
+
if content and len(content) > 400:
|
| 259 |
+
# Basic check for error messages in the text
|
| 260 |
+
error_terms = ["javascript is disabled", "enable cookies", "paywall", "subscribe to read"]
|
| 261 |
+
content_lower = content.lower()
|
| 262 |
+
if any(term in content_lower for term in error_terms):
|
| 263 |
+
return None
|
| 264 |
+
return article
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
# Check articles concurrently
|
| 268 |
+
with ThreadPoolExecutor(max_workers=10) as executor:
|
| 269 |
+
results = list(executor.map(check_article, articles_to_check))
|
| 270 |
+
filtered_articles = [r for r in results if r is not None]
|
| 271 |
+
|
| 272 |
+
return filtered_articles[:20]
|
| 273 |
+
|
| 274 |
+
def get_full_content(self, url):
|
| 275 |
+
try:
|
| 276 |
+
headers = {
|
| 277 |
+
'User-Agent': self.config.browser_user_agent,
|
| 278 |
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
| 279 |
+
'Accept-Language': 'en-US,en;q=0.9',
|
| 280 |
+
'Connection': 'keep-alive',
|
| 281 |
+
}
|
| 282 |
+
response = self.session.get(url, headers=headers, timeout=15)
|
| 283 |
+
if response.status_code != 200: return None
|
| 284 |
+
|
| 285 |
+
# Quick check for JS-only pages or common error indicators in HTML
|
| 286 |
+
html_lower = response.text.lower()
|
| 287 |
+
if "javascript is disabled" in html_lower or "enable javascript" in html_lower:
|
| 288 |
+
return None
|
| 289 |
+
|
| 290 |
+
from newspaper import Article
|
| 291 |
+
article = Article(url, config=self.config)
|
| 292 |
+
article.set_html(response.text)
|
| 293 |
+
article.parse()
|
| 294 |
+
|
| 295 |
+
text = article.text.strip()
|
| 296 |
+
|
| 297 |
+
# Final validation of the extracted text
|
| 298 |
+
if not text or len(text) < 300:
|
| 299 |
+
return None
|
| 300 |
+
|
| 301 |
+
# Filter out pages that extracted only UI elements/errors
|
| 302 |
+
system_errors = ["please enable javascript", "browser not supported", "access denied", "forbidden"]
|
| 303 |
+
text_lower = text.lower()
|
| 304 |
+
if any(err in text_lower for err in system_errors):
|
| 305 |
+
return None
|
| 306 |
+
|
| 307 |
+
return text
|
| 308 |
+
except Exception as e:
|
| 309 |
+
# print(f"Extraction error: {e}", file=sys.stderr)
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
def summarize_content(self, text):
|
| 313 |
+
if not self.hf_client or not text or len(text.strip()) < 100: return None
|
| 314 |
+
try:
|
| 315 |
+
truncated_text = text[:3000]
|
| 316 |
+
response = self.hf_client.summarization(truncated_text, model="facebook/bart-large-cnn")
|
| 317 |
+
if hasattr(response, 'summary_text'): return response.summary_text
|
| 318 |
+
if isinstance(response, list) and len(response) > 0: return response[0].get('summary_text') if isinstance(response[0], dict) else str(response[0])
|
| 319 |
+
if isinstance(response, dict): return response.get('summary_text')
|
| 320 |
+
return str(response)
|
| 321 |
+
except Exception as e:
|
| 322 |
+
print(f"Summary error: {e}", file=sys.stderr)
|
| 323 |
+
return None
|
STABLE_BACKUP/NewsApex/next.config.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
images: {
|
| 4 |
+
remotePatterns: [
|
| 5 |
+
{
|
| 6 |
+
protocol: 'https',
|
| 7 |
+
hostname: '**',
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
protocol: 'http',
|
| 11 |
+
hostname: '**',
|
| 12 |
+
},
|
| 13 |
+
],
|
| 14 |
+
},
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
export default nextConfig;
|
STABLE_BACKUP/NewsApex/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
STABLE_BACKUP/NewsApex/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "news_apex",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "pip install -r requirements.txt && next build",
|
| 8 |
+
"start": "next start",
|
| 9 |
+
"lint": "eslint"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"next": "15.1.6",
|
| 13 |
+
"react": "19.0.0",
|
| 14 |
+
"react-dom": "19.0.0"
|
| 15 |
+
},
|
| 16 |
+
"devDependencies": {
|
| 17 |
+
"autoprefixer": "^10.4.20",
|
| 18 |
+
"eslint": "^9",
|
| 19 |
+
"eslint-config-next": "15.1.6",
|
| 20 |
+
"postcss": "^8.4.49",
|
| 21 |
+
"tailwindcss": "^3.4.17"
|
| 22 |
+
}
|
| 23 |
+
}
|
STABLE_BACKUP/NewsApex/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
STABLE_BACKUP/NewsApex/postcss.config.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const config = {
|
| 2 |
+
plugins: {
|
| 3 |
+
"@tailwindcss/postcss": {},
|
| 4 |
+
},
|
| 5 |
+
};
|
| 6 |
+
|
| 7 |
+
export default config;
|
STABLE_BACKUP/NewsApex/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
requests
|
| 2 |
+
python-dotenv
|
| 3 |
+
newspaper3k
|
| 4 |
+
lxml_html_clean
|
| 5 |
+
huggingface_hub
|
| 6 |
+
streamlit
|
| 7 |
+
colorama
|
| 8 |
+
pandas
|
| 9 |
+
torch
|
| 10 |
+
transformers
|
| 11 |
+
datasets
|
| 12 |
+
scikit-learn
|
STABLE_BACKUP/NewsApex/tailwind.config.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
module.exports = {
|
| 3 |
+
content: [
|
| 4 |
+
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
| 5 |
+
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {},
|
| 9 |
+
},
|
| 10 |
+
plugins: [],
|
| 11 |
+
}
|
STABLE_BACKUP/NewsApex/test_category.json
ADDED
|
Binary file (62.5 kB). View file
|
|
|
STABLE_BACKUP/NewsApex/test_output.json
ADDED
|
Binary file (59.5 kB). View file
|
|
|
STABLE_BACKUP/NewsApex/vercel.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "nextjs",
|
| 3 |
+
"buildCommand": "next build"
|
| 4 |
+
}
|
STABLE_BACKUP/app.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from news_service import NewsService
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import html
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
# Page Configuration
|
| 8 |
+
st.set_page_config(
|
| 9 |
+
page_title="NEXTER",
|
| 10 |
+
page_icon="π°",
|
| 11 |
+
layout="wide",
|
| 12 |
+
initial_sidebar_state="expanded"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# Custom CSS for better UI and to hide Deploy/Stop buttons
|
| 16 |
+
st.markdown("""
|
| 17 |
+
<style>
|
| 18 |
+
/* Hide Deploy button */
|
| 19 |
+
.stAppDeployButton {
|
| 20 |
+
display: none !important;
|
| 21 |
+
}
|
| 22 |
+
/* Hide the 'Stop' button and the running status indicator to clean up UI */
|
| 23 |
+
[data-testid="stStatusWidget"] {
|
| 24 |
+
display: none !important;
|
| 25 |
+
}
|
| 26 |
+
/* Hide the Main Menu (three dots/hamburger menu) */
|
| 27 |
+
#MainMenu {
|
| 28 |
+
visibility: hidden !important;
|
| 29 |
+
}
|
| 30 |
+
/* Hide the footer (Made with Streamlit) */
|
| 31 |
+
footer {
|
| 32 |
+
visibility: hidden !important;
|
| 33 |
+
}
|
| 34 |
+
/* Hide the header bar entirely */
|
| 35 |
+
/* header {
|
| 36 |
+
visibility: hidden !important;
|
| 37 |
+
} */
|
| 38 |
+
.main {
|
| 39 |
+
background-color: #f0f2f6;
|
| 40 |
+
}
|
| 41 |
+
.stCard {
|
| 42 |
+
background-color: white;
|
| 43 |
+
padding: 1rem;
|
| 44 |
+
border-radius: 12px;
|
| 45 |
+
border: 1px solid #e0e0e0;
|
| 46 |
+
margin-bottom: 1.5rem;
|
| 47 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
|
| 48 |
+
transition: transform 0.2s ease-in-out;
|
| 49 |
+
height: 480px; /* Fixed height for alignment */
|
| 50 |
+
display: flex;
|
| 51 |
+
flex-direction: column;
|
| 52 |
+
justify-content: space-between;
|
| 53 |
+
}
|
| 54 |
+
.stCard:hover {
|
| 55 |
+
transform: translateY(-5px);
|
| 56 |
+
box-shadow: 0 8px 15px rgba(0,0,0,0.1);
|
| 57 |
+
}
|
| 58 |
+
.card-img {
|
| 59 |
+
width: 100%;
|
| 60 |
+
height: 200px; /* Fixed image height */
|
| 61 |
+
object-fit: cover;
|
| 62 |
+
border-radius: 8px;
|
| 63 |
+
margin-bottom: 0.8rem;
|
| 64 |
+
background-color: #f8f9fa;
|
| 65 |
+
display: block;
|
| 66 |
+
}
|
| 67 |
+
.card-img-placeholder {
|
| 68 |
+
width: 100%;
|
| 69 |
+
height: 200px;
|
| 70 |
+
background-color: #e9ecef;
|
| 71 |
+
border-radius: 8px;
|
| 72 |
+
margin-bottom: 0.8rem;
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
justify-content: center;
|
| 76 |
+
color: #adb5bd;
|
| 77 |
+
font-size: 0.9rem;
|
| 78 |
+
}
|
| 79 |
+
.card-title {
|
| 80 |
+
font-size: 1.1rem;
|
| 81 |
+
font-weight: 700;
|
| 82 |
+
color: #1a1a1a;
|
| 83 |
+
margin-bottom: 0.5rem;
|
| 84 |
+
line-height: 1.4;
|
| 85 |
+
height: 4.2rem; /* Fixed height for 3 lines of text */
|
| 86 |
+
display: -webkit-box;
|
| 87 |
+
-webkit-line-clamp: 3;
|
| 88 |
+
-webkit-box-orient: vertical;
|
| 89 |
+
overflow: hidden;
|
| 90 |
+
}
|
| 91 |
+
.card-meta {
|
| 92 |
+
font-size: 0.8rem;
|
| 93 |
+
color: #6c757d;
|
| 94 |
+
margin-bottom: 0.5rem;
|
| 95 |
+
}
|
| 96 |
+
.stArticle {
|
| 97 |
+
background-color: white;
|
| 98 |
+
padding: 2.5rem;
|
| 99 |
+
border-radius: 15px;
|
| 100 |
+
border: 1px solid #e9ecef;
|
| 101 |
+
box-shadow: 0 10px 25px rgba(0,0,0,0.05);
|
| 102 |
+
}
|
| 103 |
+
.full-content {
|
| 104 |
+
color: #212529;
|
| 105 |
+
font-size: 1.1rem;
|
| 106 |
+
line-height: 1.6;
|
| 107 |
+
margin-top: 1rem;
|
| 108 |
+
margin-bottom: 1rem;
|
| 109 |
+
padding: 0;
|
| 110 |
+
background-color: transparent;
|
| 111 |
+
border-left: none;
|
| 112 |
+
white-space: pre-wrap;
|
| 113 |
+
}
|
| 114 |
+
.summary-content {
|
| 115 |
+
background-color: #e7f3ff;
|
| 116 |
+
padding: 1.5rem;
|
| 117 |
+
border-radius: 8px;
|
| 118 |
+
border: 1px solid #b8daff;
|
| 119 |
+
color: #004085;
|
| 120 |
+
font-style: italic;
|
| 121 |
+
}
|
| 122 |
+
.meta-info {
|
| 123 |
+
color: #6c757d;
|
| 124 |
+
font-size: 0.9rem;
|
| 125 |
+
margin-bottom: 1rem;
|
| 126 |
+
}
|
| 127 |
+
</style>
|
| 128 |
+
""", unsafe_allow_html=True)
|
| 129 |
+
|
| 130 |
+
# --- CACHED FUNCTIONS ---
|
| 131 |
+
@st.cache_resource
|
| 132 |
+
def get_news_service():
|
| 133 |
+
return NewsService()
|
| 134 |
+
|
| 135 |
+
@st.cache_data(show_spinner=False, ttl=3600)
|
| 136 |
+
def cached_fetch_all_news(_service, query, language="en"):
|
| 137 |
+
return _service.fetch_all_news(query=query, language=language)
|
| 138 |
+
|
| 139 |
+
@st.cache_data(show_spinner=False, ttl=86400)
|
| 140 |
+
def cached_get_full_content(_service, url):
|
| 141 |
+
return _service.get_full_content(url)
|
| 142 |
+
|
| 143 |
+
@st.cache_data(show_spinner=False, ttl=86400)
|
| 144 |
+
def cached_summarize_content(_service, text):
|
| 145 |
+
return _service.summarize_content(text)
|
| 146 |
+
|
| 147 |
+
@st.cache_data(show_spinner=False)
|
| 148 |
+
def cached_split_into_sentences(_service, text):
|
| 149 |
+
return _service.split_into_sentences(text)
|
| 150 |
+
|
| 151 |
+
@st.cache_data(show_spinner=False)
|
| 152 |
+
def cached_rate_bias(_service, text):
|
| 153 |
+
return _service.rate_bias(text)
|
| 154 |
+
|
| 155 |
+
def display_article_detail(article, news_service):
|
| 156 |
+
"""Displays the detailed view of a selected article with bias analysis and summary."""
|
| 157 |
+
if st.button("β Back to Feed"):
|
| 158 |
+
st.session_state.selected_article = None
|
| 159 |
+
st.rerun()
|
| 160 |
+
|
| 161 |
+
url = article.get('link')
|
| 162 |
+
if not url:
|
| 163 |
+
st.error("Invalid article link.")
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
st.markdown('<div class="stArticle">', unsafe_allow_html=True)
|
| 167 |
+
st.title(article.get('title', 'No Title'))
|
| 168 |
+
|
| 169 |
+
# Hero image
|
| 170 |
+
img_url = article.get('image_url')
|
| 171 |
+
if img_url:
|
| 172 |
+
st.image(img_url, use_container_width=True)
|
| 173 |
+
|
| 174 |
+
st.markdown(f"""
|
| 175 |
+
<div class="meta-info">
|
| 176 |
+
<b>Source:</b> {article.get('source_id', 'Unknown')} |
|
| 177 |
+
<b>Date:</b> {article.get('pubDate', 'Unknown')} |
|
| 178 |
+
<a href="{url}" target="_blank">Original Link</a>
|
| 179 |
+
</div>
|
| 180 |
+
""", unsafe_allow_html=True)
|
| 181 |
+
|
| 182 |
+
with st.spinner("π§ Analyzing article content..."):
|
| 183 |
+
full_content = cached_get_full_content(news_service, url)
|
| 184 |
+
|
| 185 |
+
# --- ROBUST FALLBACK LOGIC ---
|
| 186 |
+
text_to_analyze = full_content
|
| 187 |
+
status_msg = None
|
| 188 |
+
|
| 189 |
+
# 1. Try Full Content
|
| 190 |
+
if not text_to_analyze or len(text_to_analyze.strip()) < 100:
|
| 191 |
+
# 2. Try Snippet/Description
|
| 192 |
+
text_to_analyze = article.get('snippet')
|
| 193 |
+
if text_to_analyze and len(text_to_analyze.strip()) > 20:
|
| 194 |
+
status_msg = "β οΈ Full content extraction limited. Analyzing article snippet."
|
| 195 |
+
else:
|
| 196 |
+
# 3. Last Resort: Use Title
|
| 197 |
+
text_to_analyze = article.get('title', '')
|
| 198 |
+
status_msg = "β οΈ No content found. Analyzing article headline only."
|
| 199 |
+
|
| 200 |
+
if not text_to_analyze:
|
| 201 |
+
st.error("Could not retrieve any text for this article.")
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
if status_msg:
|
| 205 |
+
st.warning(status_msg)
|
| 206 |
+
|
| 207 |
+
# Display the text being analyzed
|
| 208 |
+
with st.expander("π View Analyzed Text", expanded=True):
|
| 209 |
+
st.markdown(f'<div style="font-size: 1rem; line-height: 1.5; color: #333;">{html.escape(text_to_analyze)}</div>', unsafe_allow_html=True)
|
| 210 |
+
|
| 211 |
+
# AI Summary
|
| 212 |
+
summary = cached_summarize_content(news_service, text_to_analyze)
|
| 213 |
+
if summary:
|
| 214 |
+
st.info(f"π€ **AI Summary:** {summary}")
|
| 215 |
+
|
| 216 |
+
st.divider()
|
| 217 |
+
|
| 218 |
+
# Bias Analysis
|
| 219 |
+
st.subheader("βοΈ Bias Analysis")
|
| 220 |
+
overall_bias = cached_rate_bias(news_service, text_to_analyze)
|
| 221 |
+
|
| 222 |
+
col_b1, col_b2 = st.columns([1, 3])
|
| 223 |
+
with col_b1:
|
| 224 |
+
color = "#dc3545" if overall_bias['label'] == "Biased" else "#28a745"
|
| 225 |
+
st.markdown(f"**Overall Rating:** <span style='color:{color}; font-weight:bold; font-size:1.2rem;'>{overall_bias['label']}</span>", unsafe_allow_html=True)
|
| 226 |
+
with col_b2:
|
| 227 |
+
st.progress(overall_bias['score'], text=f"Confidence: {overall_bias['score']:.1%}")
|
| 228 |
+
|
| 229 |
+
# --- Interpretation Section ---
|
| 230 |
+
if overall_bias['label'] == "Factual":
|
| 231 |
+
st.success("β
**Interpretation: Factual Content**\nThis article primarily uses objective language, reports verifiable events, and avoids subjective modifiers or emotional framing. It aims to inform rather than influence.")
|
| 232 |
+
else:
|
| 233 |
+
st.error("β οΈ **Interpretation: Biased Content**\nThis article contains elements that suggest a non-neutral perspective. This could include the use of loaded language, emotional appeals, or selective framing designed to influence the reader's opinion.")
|
| 234 |
+
|
| 235 |
+
o_reasoning = overall_bias.get('reasoning', 'No specific reasoning provided.')
|
| 236 |
+
st.warning(f"π‘ **Analysis Reasoning:** {o_reasoning}")
|
| 237 |
+
|
| 238 |
+
st.subheader("π Sentence-by-Sentence Breakdown")
|
| 239 |
+
sentences = cached_split_into_sentences(news_service, text_to_analyze)
|
| 240 |
+
|
| 241 |
+
sentence_html = ""
|
| 242 |
+
for i, sentence in enumerate(sentences, 1):
|
| 243 |
+
s_bias = cached_rate_bias(news_service, sentence)
|
| 244 |
+
s_label = s_bias.get('label', 'Factual')
|
| 245 |
+
s_reasoning = s_bias.get('reasoning', '')
|
| 246 |
+
s_color = "rgba(220, 53, 69, 0.08)" if s_label == "Biased" else "transparent"
|
| 247 |
+
|
| 248 |
+
escaped_sentence = html.escape(sentence)
|
| 249 |
+
escaped_reasoning = html.escape(s_reasoning)
|
| 250 |
+
|
| 251 |
+
reasoning_html = f'<div style="font-size: 0.85rem; color: #721c24; margin-top: 4px; font-style: italic;">Why? {escaped_reasoning}</div>' if s_label == "Biased" else ""
|
| 252 |
+
|
| 253 |
+
border_style = "border-left: 4px solid #dc3545;" if s_label == "Biased" else "border-left: 4px solid #e9ecef;"
|
| 254 |
+
sentence_html += f'<div style="margin-bottom: 12px; padding: 12px; background-color: {s_color}; border-radius: 6px; {border_style}"><b>{i}.</b> {escaped_sentence} <span style="font-size: 0.8rem; color: #6c757d; margin-left: 10px; font-weight: bold;">[{s_label}]</span>{reasoning_html}</div>'
|
| 255 |
+
|
| 256 |
+
st.markdown(f'<div class="full-content" style="border:none; padding:0;">{sentence_html}</div>', unsafe_allow_html=True)
|
| 257 |
+
|
| 258 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 259 |
+
|
| 260 |
+
def fetch_and_display_news(query, news_service, title=None):
|
| 261 |
+
"""Fetches and displays news in a grid layout."""
|
| 262 |
+
if title:
|
| 263 |
+
st.subheader(title)
|
| 264 |
+
|
| 265 |
+
spinner_text = f"π Searching for '{query}'..." if query else "π Fetching latest headlines..."
|
| 266 |
+
with st.spinner(spinner_text):
|
| 267 |
+
try:
|
| 268 |
+
articles = cached_fetch_all_news(news_service, query, language="en")
|
| 269 |
+
|
| 270 |
+
if not articles:
|
| 271 |
+
st.error("No articles found for this topic. Please try another one.")
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
# Grid layout: 3 columns
|
| 275 |
+
articles = articles[:12]
|
| 276 |
+
cols = st.columns(3)
|
| 277 |
+
for idx, article in enumerate(articles):
|
| 278 |
+
with cols[idx % 3]:
|
| 279 |
+
st.markdown('<div class="stCard">', unsafe_allow_html=True)
|
| 280 |
+
|
| 281 |
+
# Thumbnail Image handling
|
| 282 |
+
img_url = article.get('image_url')
|
| 283 |
+
if img_url and img_url.startswith('http'):
|
| 284 |
+
st.markdown(f'<img src="{img_url}" class="card-img" onerror="this.style.display=\'none\'; this.nextSibling.style.display=\'flex\';">', unsafe_allow_html=True)
|
| 285 |
+
st.markdown('<div class="card-img-placeholder" style="display:none;">πΌοΈ Image Unavailable</div>', unsafe_allow_html=True)
|
| 286 |
+
else:
|
| 287 |
+
st.markdown('<div class="card-img-placeholder">πΌοΈ No Image</div>', unsafe_allow_html=True)
|
| 288 |
+
|
| 289 |
+
# Source and Date
|
| 290 |
+
date_str = article.get('pubDate', 'Unknown')[:10]
|
| 291 |
+
st.markdown(f'<div class="card-meta">{article.get("source_id", "Unknown")} β’ {date_str}</div>', unsafe_allow_html=True)
|
| 292 |
+
|
| 293 |
+
# Title
|
| 294 |
+
st.markdown(f'<div class="card-title">{article.get("title", "No Title")}</div>', unsafe_allow_html=True)
|
| 295 |
+
|
| 296 |
+
# Analyze Button
|
| 297 |
+
if st.button("Analyze Article", key=f"btn_{idx}", use_container_width=True):
|
| 298 |
+
st.session_state.selected_article = article
|
| 299 |
+
st.rerun()
|
| 300 |
+
|
| 301 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 302 |
+
|
| 303 |
+
except Exception as e:
|
| 304 |
+
st.error(f"Error fetching news: {str(e)}")
|
| 305 |
+
|
| 306 |
+
def main():
|
| 307 |
+
st.title("π° NEXTER")
|
| 308 |
+
st.markdown("Modern AI-Powered News Analysis")
|
| 309 |
+
|
| 310 |
+
# Initialize session states
|
| 311 |
+
if 'search_query' not in st.session_state: st.session_state.search_query = ""
|
| 312 |
+
if 'selected_article' not in st.session_state: st.session_state.selected_article = None
|
| 313 |
+
if 'is_home' not in st.session_state: st.session_state.is_home = True
|
| 314 |
+
|
| 315 |
+
with st.sidebar:
|
| 316 |
+
st.header("Search Settings")
|
| 317 |
+
news_service = get_news_service()
|
| 318 |
+
|
| 319 |
+
query_input = st.text_input("Topic Search", value=st.session_state.search_query, placeholder="e.g. Finance, AI, Sports")
|
| 320 |
+
|
| 321 |
+
if query_input != st.session_state.search_query:
|
| 322 |
+
st.session_state.search_query = query_input
|
| 323 |
+
st.session_state.selected_article = None
|
| 324 |
+
st.session_state.is_home = False
|
| 325 |
+
|
| 326 |
+
if st.button("Fetch News", type="primary"):
|
| 327 |
+
st.session_state.selected_article = None
|
| 328 |
+
st.session_state.is_home = False
|
| 329 |
+
st.rerun()
|
| 330 |
+
|
| 331 |
+
if st.button("π Home Feed"):
|
| 332 |
+
st.session_state.search_query = ""
|
| 333 |
+
st.session_state.selected_article = None
|
| 334 |
+
st.session_state.is_home = True
|
| 335 |
+
st.rerun()
|
| 336 |
+
|
| 337 |
+
st.divider()
|
| 338 |
+
if st.button("π§Ή Clear Cache"):
|
| 339 |
+
st.cache_data.clear()
|
| 340 |
+
st.success("Cache cleared!")
|
| 341 |
+
time.sleep(0.5)
|
| 342 |
+
st.rerun()
|
| 343 |
+
|
| 344 |
+
if news_service.bias_model:
|
| 345 |
+
st.success("β
AI Bias Model Loaded")
|
| 346 |
+
else:
|
| 347 |
+
st.info("βοΈ Cloud Analysis Active")
|
| 348 |
+
|
| 349 |
+
# Display logic
|
| 350 |
+
if st.session_state.selected_article:
|
| 351 |
+
display_article_detail(st.session_state.selected_article, news_service)
|
| 352 |
+
elif st.session_state.is_home:
|
| 353 |
+
fetch_and_display_news(None, news_service, title="Top Headlines")
|
| 354 |
+
else:
|
| 355 |
+
fetch_and_display_news(st.session_state.search_query, news_service, title=f"Results for: {st.session_state.search_query}")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
if __name__ == "__main__":
|
| 359 |
+
main()
|
STABLE_BACKUP/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
requests
|
| 2 |
+
python-dotenv
|
| 3 |
+
newspaper3k
|
| 4 |
+
lxml_html_clean
|
| 5 |
+
huggingface_hub
|
| 6 |
+
streamlit
|
| 7 |
+
colorama
|
| 8 |
+
pandas
|
| 9 |
+
torch
|
| 10 |
+
transformers
|
| 11 |
+
datasets
|
| 12 |
+
scikit-learn
|