Spaces:
Runtime error
Runtime error
Arun21102003 commited on
Commit ·
90fe073
1
Parent(s): 4791c03
Deployment preparation (removed binary files)
Browse files- .gitignore +14 -0
- .hfignore +9 -0
- README.md +212 -4
- app.py +662 -0
- brand_analyzer.py +149 -0
- database.py +116 -0
- db_operations.py +273 -0
- requirements.txt +15 -0
- scheduler.py +185 -0
- search_engines.py +197 -0
- web_scraper.py +86 -0
.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
app_old.py
|
| 2 |
+
.env
|
| 3 |
+
.replit
|
| 4 |
+
.streamlit
|
| 5 |
+
replit.md
|
| 6 |
+
__pycache__
|
| 7 |
+
pyproject.toml
|
| 8 |
+
uv.lock
|
| 9 |
+
.venv
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
brandscan.db
|
| 14 |
+
*.db
|
.hfignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
__pycache__
|
| 3 |
+
*.pyc
|
| 4 |
+
.env
|
| 5 |
+
brandscan.db
|
| 6 |
+
.git
|
| 7 |
+
.replit
|
| 8 |
+
.local
|
| 9 |
+
uv.lock
|
README.md
CHANGED
|
@@ -1,10 +1,218 @@
|
|
| 1 |
---
|
| 2 |
title: BrandScanAI
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: BrandScanAI
|
| 3 |
+
emoji: 🔍
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.31.0
|
| 8 |
+
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# BrandScanAI: Open-Source Brand Monitoring with LLM Analysis
|
| 13 |
+
|
| 14 |
+
## Overview
|
| 15 |
+
|
| 16 |
+
BrandScanAI is a comprehensive brand monitoring system that combines web search, content extraction, and AI-powered sentiment analysis to track brand mentions across the internet. Built with Streamlit and powered by open-source LLMs, it provides real-time insights into brand perception and media coverage.
|
| 17 |
+
|
| 18 |
+
## Open-Source LLM APIs Explored
|
| 19 |
+
|
| 20 |
+
### Primary Implementation: Groq + Llama Models
|
| 21 |
+
- **Model**: Llama 3.1 8B Instant (via Groq API)
|
| 22 |
+
- **Tradeoffs**:
|
| 23 |
+
- **Speed**: ⚡ Extremely fast inference (sub-second response times)
|
| 24 |
+
- **Accuracy**: 🎯 Good for sentiment analysis and structured extraction
|
| 25 |
+
- **Documentation**: 📚 Excellent Groq documentation with clear examples
|
| 26 |
+
- **Cost**: 💰 Very affordable ($0.27/1M tokens for Llama 3.1 8B)
|
| 27 |
+
- **Limitations**: Smaller context window compared to larger models
|
| 28 |
+
|
| 29 |
+
### Alternative Models Considered
|
| 30 |
+
- **Llama 3.3 70B**: Higher accuracy but slower inference and higher cost
|
| 31 |
+
- **Code Llama**: Specialized for code analysis but less suitable for general text
|
| 32 |
+
- **Mistral 7B**: Good balance but Groq's Llama 3.1 8B proved more reliable
|
| 33 |
+
|
| 34 |
+
## Technical Challenges & Solutions
|
| 35 |
+
|
| 36 |
+
### 1. Web Crawling Challenges
|
| 37 |
+
- **Anti-bot measures**: Implemented respectful delays (0.5s) and proper User-Agent headers
|
| 38 |
+
- **Content extraction**: Used Trafilatura for robust article extraction vs. basic BeautifulSoup
|
| 39 |
+
- **Rate limiting**: Graceful error handling with informative user feedback
|
| 40 |
+
- **Dynamic content**: Limited JavaScript-heavy sites, focused on static content
|
| 41 |
+
|
| 42 |
+
### 2. LLM Querying Issues
|
| 43 |
+
- **JSON parsing errors**: Enforced `response_format={"type": "json_object"}` in API calls
|
| 44 |
+
- **Inconsistent outputs**: Implemented structured prompts with explicit JSON schema
|
| 45 |
+
- **Context length**: Limited article content to 1000 characters for analysis
|
| 46 |
+
- **API reliability**: Added retry logic and fallback error responses
|
| 47 |
+
|
| 48 |
+
### 3. Context Extraction Problems
|
| 49 |
+
- **Noise removal**: Trafilatura effectively strips ads, navigation, and boilerplate
|
| 50 |
+
- **Metadata extraction**: Combined Trafilatura metadata with BeautifulSoup fallback
|
| 51 |
+
- **Content quality**: Implemented content length validation before analysis
|
| 52 |
+
|
| 53 |
+
## Scalability & Robustness Improvements
|
| 54 |
+
|
| 55 |
+
### Production-Ready Enhancements
|
| 56 |
+
1. **Database Integration**: SQLite/PostgreSQL for persistent storage and historical analysis
|
| 57 |
+
2. **Queue System**: Celery/Redis for background processing of large batches
|
| 58 |
+
3. **Caching Layer**: Redis for API response caching and rate limit management
|
| 59 |
+
4. **Monitoring**: Prometheus/Grafana for system health and performance tracking
|
| 60 |
+
5. **Load Balancing**: Multiple worker processes for concurrent analysis
|
| 61 |
+
6. **Error Recovery**: Retry mechanisms with exponential backoff
|
| 62 |
+
7. **API Rate Limiting**: Intelligent request throttling across multiple providers
|
| 63 |
+
|
| 64 |
+
### Architecture Improvements
|
| 65 |
+
- **Microservices**: Separate services for search, scraping, and analysis
|
| 66 |
+
- **Message Queues**: Asynchronous processing for large-scale monitoring
|
| 67 |
+
- **CDN Integration**: Cached content delivery for faster responses
|
| 68 |
+
- **Multi-region Deployment**: Geographic distribution for global brand monitoring
|
| 69 |
+
|
| 70 |
+
## LLM Comparison: Llama 3.1 8B vs Llama 3.3 70B
|
| 71 |
+
|
| 72 |
+
### Test Case: Brand Sentiment Analysis
|
| 73 |
+
**Input**: "OpenAI's new GPT-4 model shows impressive capabilities but raises concerns about AI safety and job displacement."
|
| 74 |
+
|
| 75 |
+
### Llama 3.1 8B Response:
|
| 76 |
+
```json
|
| 77 |
+
{
|
| 78 |
+
"explicit_mentions": [{
|
| 79 |
+
"mention": "OpenAI's new GPT-4 model",
|
| 80 |
+
"sentiment": "positive",
|
| 81 |
+
"explanation": "Shows impressive capabilities"
|
| 82 |
+
}],
|
| 83 |
+
"indirect_mentions": [],
|
| 84 |
+
"overall_sentiment": "neutral"
|
| 85 |
+
}
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Llama 3.3 70B Response:
|
| 89 |
+
```json
|
| 90 |
+
{
|
| 91 |
+
"explicit_mentions": [{
|
| 92 |
+
"mention": "OpenAI's new GPT-4 model",
|
| 93 |
+
"sentiment": "positive",
|
| 94 |
+
"explanation": "Shows impressive capabilities"
|
| 95 |
+
}],
|
| 96 |
+
"indirect_mentions": [{
|
| 97 |
+
"reference": "AI safety and job displacement",
|
| 98 |
+
"sentiment": "negative",
|
| 99 |
+
"explanation": "Raises concerns about negative impacts"
|
| 100 |
+
}],
|
| 101 |
+
"overall_sentiment": "neutral"
|
| 102 |
+
}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
**Key Differences**:
|
| 106 |
+
- **3.3 70B**: More nuanced analysis, catches indirect negative mentions
|
| 107 |
+
- **3.1 8B**: Faster but misses subtle context and indirect references
|
| 108 |
+
- **Trade-off**: 70B provides better accuracy but 3x slower and 10x more expensive
|
| 109 |
+
|
| 110 |
+
## Setup & Installation
|
| 111 |
+
|
| 112 |
+
### Prerequisites
|
| 113 |
+
- Python 3.11+
|
| 114 |
+
- API keys for Groq and SerpAPI
|
| 115 |
+
|
| 116 |
+
### Installation
|
| 117 |
+
```bash
|
| 118 |
+
# Clone repository
|
| 119 |
+
git clone https://github.com/yourusername/brandscan-ai.git
|
| 120 |
+
cd brandscan-ai
|
| 121 |
+
|
| 122 |
+
# Install dependencies
|
| 123 |
+
pip install -r requirements.txt
|
| 124 |
+
|
| 125 |
+
# Set up environment variables
|
| 126 |
+
cp .env.example .env
|
| 127 |
+
# Edit .env with your API keys
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
### Environment Variables
|
| 131 |
+
```bash
|
| 132 |
+
# Required API Keys
|
| 133 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 134 |
+
SERPAPI_API_KEY=your_serpapi_key_here
|
| 135 |
+
|
| 136 |
+
# Database (optional - defaults to SQLite)
|
| 137 |
+
DATABASE_URL=sqlite:///./brandscan.db
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
### API Key Setup
|
| 141 |
+
1. **Groq API**: Visit [console.groq.com](https://console.groq.com) → Sign up → Get API key
|
| 142 |
+
2. **SerpAPI**: Visit [serpapi.com](https://serpapi.com) → Sign up → Get API key
|
| 143 |
+
|
| 144 |
+
### Running the Application
|
| 145 |
+
```bash
|
| 146 |
+
# Start the Streamlit app
|
| 147 |
+
streamlit run app.py
|
| 148 |
+
|
| 149 |
+
# Access at http://localhost:8501
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
## Deploying to Hugging Face Spaces
|
| 153 |
+
|
| 154 |
+
1. **Create a Space**: Go to [huggingface.co/new-space](https://huggingface.co/new-space).
|
| 155 |
+
2. **Configure**:
|
| 156 |
+
- **Name**: `brandscan-ai`
|
| 157 |
+
- **SDK**: Streamlit
|
| 158 |
+
- **Privacy**: Public (or Private)
|
| 159 |
+
3. **Upload Files**: Upload all project files (except `.venv`, `.env`, and `brandscan.db`). The `.hfignore` file will handle this if you use Git.
|
| 160 |
+
4. **Set Secrets**: Go to **Settings** -> **Variables and secrets** -> **New secret**:
|
| 161 |
+
- `GROQ_API_KEY`: Your Groq API key
|
| 162 |
+
- `SERPAPI_API_KEY`: Your SerpAPI key
|
| 163 |
+
- `DATABASE_URL`: `sqlite:///./brandscan.db` (Note: SQLite is not persistent on HF Spaces. For persistence, use an external PostgreSQL DB or HF Datasets).
|
| 164 |
+
5. **Wait for Build**: Hugging Face will automatically build and deploy your app.
|
| 165 |
+
|
| 166 |
+
## Usage
|
| 167 |
+
|
| 168 |
+
1. **Configure Search**: Enter search query and brand names
|
| 169 |
+
2. **Select Engines**: Choose from Google, Bing, DuckDuckGo
|
| 170 |
+
3. **Run Analysis**: Click "Start Batch Analysis"
|
| 171 |
+
4. **View Results**: Explore mentions, sentiment, and context
|
| 172 |
+
5. **Export Data**: Download CSV reports for further analysis
|
| 173 |
+
|
| 174 |
+
## Dependencies
|
| 175 |
+
|
| 176 |
+
### Core Libraries
|
| 177 |
+
- `streamlit>=1.50.0` - Web application framework
|
| 178 |
+
- `groq>=0.32.0` - Groq API client
|
| 179 |
+
- `serpapi>=0.1.5` - Google Search API
|
| 180 |
+
- `trafilatura>=2.0.0` - Web content extraction
|
| 181 |
+
- `sqlalchemy>=2.0.44` - Database ORM
|
| 182 |
+
- `pandas>=2.3.3` - Data manipulation
|
| 183 |
+
- `plotly>=6.3.1` - Interactive visualizations
|
| 184 |
+
|
| 185 |
+
### Optional Dependencies
|
| 186 |
+
- `psycopg2-binary` - PostgreSQL support
|
| 187 |
+
- `PyMySQL` - MySQL support
|
| 188 |
+
- `duckduckgo-search` - DuckDuckGo search
|
| 189 |
+
- `beautifulsoup4` - HTML parsing fallback
|
| 190 |
+
|
| 191 |
+
## Features
|
| 192 |
+
|
| 193 |
+
- 🔍 **Multi-Engine Search**: Google, Bing, DuckDuckGo
|
| 194 |
+
- 🤖 **AI-Powered Analysis**: Sentiment analysis with context
|
| 195 |
+
- 📊 **Interactive Dashboard**: Real-time analytics and visualizations
|
| 196 |
+
- 💾 **Data Export**: CSV reports and database storage
|
| 197 |
+
- ⏰ **Scheduled Monitoring**: Automated recurring analysis
|
| 198 |
+
- 🕸️ **Co-Mention Network**: Brand relationship visualization
|
| 199 |
+
- 📈 **Historical Tracking**: Trend analysis over time
|
| 200 |
+
|
| 201 |
+
## License
|
| 202 |
+
|
| 203 |
+
MIT License - see LICENSE file for details.
|
| 204 |
+
|
| 205 |
+
## Contributing
|
| 206 |
+
|
| 207 |
+
1. Fork the repository
|
| 208 |
+
2. Create a feature branch
|
| 209 |
+
3. Make your changes
|
| 210 |
+
4. Add tests if applicable
|
| 211 |
+
5. Submit a pull request
|
| 212 |
+
|
| 213 |
+
## Support
|
| 214 |
+
|
| 215 |
+
For issues and questions:
|
| 216 |
+
- Create an issue on GitHub
|
| 217 |
+
- Check the documentation
|
| 218 |
+
- Review the troubleshooting guide
|
app.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from web_scraper import scrape_article_content
|
| 6 |
+
from brand_analyzer import BrandAnalyzer
|
| 7 |
+
from search_engines import multi_engine_search, batch_analyze_brands
|
| 8 |
+
from db_operations import (
|
| 9 |
+
save_analysis_to_db, get_historical_analyses, get_all_mentions,
|
| 10 |
+
save_co_mentions, get_co_mention_network, create_scheduled_job,
|
| 11 |
+
get_scheduled_jobs
|
| 12 |
+
)
|
| 13 |
+
import plotly.express as px
|
| 14 |
+
import plotly.graph_objects as go
|
| 15 |
+
import networkx as nx
|
| 16 |
+
from collections import Counter, defaultdict
|
| 17 |
+
from scheduler import get_scheduler
|
| 18 |
+
from dotenv import load_dotenv
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
# Page configuration
|
| 22 |
+
st.set_page_config(
|
| 23 |
+
page_title="Brand Monitoring Dashboard",
|
| 24 |
+
page_icon="🔍",
|
| 25 |
+
layout="wide"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
# Initialize scheduler
|
| 29 |
+
try:
|
| 30 |
+
scheduler = get_scheduler()
|
| 31 |
+
except Exception as e:
|
| 32 |
+
st.warning(f"Scheduler initialization warning: {e}")
|
| 33 |
+
|
| 34 |
+
# Initialize session state
|
| 35 |
+
if 'batch_results' not in st.session_state:
|
| 36 |
+
st.session_state.batch_results = {}
|
| 37 |
+
if 'current_page' not in st.session_state:
|
| 38 |
+
st.session_state.current_page = 'Analysis'
|
| 39 |
+
if 'selected_analysis_ids' not in st.session_state:
|
| 40 |
+
st.session_state.selected_analysis_ids = []
|
| 41 |
+
|
| 42 |
+
# Sidebar navigation
|
| 43 |
+
st.sidebar.title("🔍 Brand Monitor Pro")
|
| 44 |
+
page = st.sidebar.radio(
|
| 45 |
+
"Navigation",
|
| 46 |
+
["Analysis", "Dashboard", "Co-Mention Network", "Scheduled Monitoring", "History"]
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
def create_csv_export(results: dict) -> str:
|
| 50 |
+
"""Create CSV content from batch analysis results"""
|
| 51 |
+
csv_data = []
|
| 52 |
+
|
| 53 |
+
for brand_name, analysis_results in results.items():
|
| 54 |
+
for result in analysis_results:
|
| 55 |
+
analysis = result.get('analysis', {})
|
| 56 |
+
|
| 57 |
+
# Add explicit mentions
|
| 58 |
+
for mention in analysis.get('explicit_mentions', []):
|
| 59 |
+
csv_data.append({
|
| 60 |
+
'Brand': brand_name,
|
| 61 |
+
'URL': result['url'],
|
| 62 |
+
'Article Title': result['title'],
|
| 63 |
+
'Source': result.get('source', 'unknown'),
|
| 64 |
+
'Mention Type': 'Explicit',
|
| 65 |
+
'Mention Text': mention.get('mention', ''),
|
| 66 |
+
'Context': mention.get('context', ''),
|
| 67 |
+
'Sentiment': mention.get('sentiment', ''),
|
| 68 |
+
'Explanation': mention.get('explanation', ''),
|
| 69 |
+
'Timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
# Add indirect mentions
|
| 73 |
+
for mention in analysis.get('indirect_mentions', []):
|
| 74 |
+
csv_data.append({
|
| 75 |
+
'Brand': brand_name,
|
| 76 |
+
'URL': result['url'],
|
| 77 |
+
'Article Title': result['title'],
|
| 78 |
+
'Source': result.get('source', 'unknown'),
|
| 79 |
+
'Mention Type': 'Indirect',
|
| 80 |
+
'Mention Text': mention.get('reference', ''),
|
| 81 |
+
'Context': mention.get('context', ''),
|
| 82 |
+
'Sentiment': mention.get('sentiment', ''),
|
| 83 |
+
'Explanation': mention.get('explanation', ''),
|
| 84 |
+
'Timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 85 |
+
})
|
| 86 |
+
|
| 87 |
+
if csv_data:
|
| 88 |
+
df = pd.DataFrame(csv_data)
|
| 89 |
+
return df.to_csv(index=False)
|
| 90 |
+
else:
|
| 91 |
+
return "No data to export"
|
| 92 |
+
|
| 93 |
+
def render_analysis_page():
|
| 94 |
+
"""Render the main analysis page with batch processing"""
|
| 95 |
+
st.title("🔍 Brand Mention Analysis")
|
| 96 |
+
st.markdown("Analyze multiple brands across different search engines simultaneously")
|
| 97 |
+
|
| 98 |
+
# Configuration sidebar
|
| 99 |
+
with st.sidebar:
|
| 100 |
+
st.header("🔧 Configuration")
|
| 101 |
+
|
| 102 |
+
search_query = st.text_input(
|
| 103 |
+
"Search Query",
|
| 104 |
+
placeholder="e.g., AI startups 2024",
|
| 105 |
+
help="Base search query to find relevant articles"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
brand_names_input = st.text_area(
|
| 109 |
+
"Brand Names (one per line)",
|
| 110 |
+
placeholder="OpenAI\nAnthropic\nGoogle AI",
|
| 111 |
+
help="Enter brand names to monitor, one per line"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Search engine selection
|
| 115 |
+
st.subheader("🌐 Search Engines")
|
| 116 |
+
use_google = st.checkbox("Google (SerpAPI)", value=True)
|
| 117 |
+
use_bing = st.checkbox("Bing")
|
| 118 |
+
use_duckduckgo = st.checkbox("DuckDuckGo")
|
| 119 |
+
|
| 120 |
+
search_engines = []
|
| 121 |
+
if use_google:
|
| 122 |
+
search_engines.append('google')
|
| 123 |
+
if use_bing:
|
| 124 |
+
search_engines.append('bing')
|
| 125 |
+
if use_duckduckgo:
|
| 126 |
+
search_engines.append('duckduckgo')
|
| 127 |
+
|
| 128 |
+
num_results = st.slider(
|
| 129 |
+
"Results per engine",
|
| 130 |
+
min_value=5,
|
| 131 |
+
max_value=15,
|
| 132 |
+
value=10
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
custom_prompt = st.text_area(
|
| 136 |
+
"Custom Analysis Prompt (Optional)",
|
| 137 |
+
placeholder="Leave empty for default analysis...",
|
| 138 |
+
height=100
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
analyze_button = st.button("🚀 Start Batch Analysis", type="primary", use_container_width=True)
|
| 142 |
+
|
| 143 |
+
# Export section
|
| 144 |
+
if st.session_state.batch_results:
|
| 145 |
+
st.markdown("---")
|
| 146 |
+
st.subheader("📥 Export Results")
|
| 147 |
+
|
| 148 |
+
csv_content = create_csv_export(st.session_state.batch_results)
|
| 149 |
+
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
| 150 |
+
|
| 151 |
+
st.download_button(
|
| 152 |
+
label="💾 Download CSV Report",
|
| 153 |
+
data=csv_content,
|
| 154 |
+
file_name=f"batch_brand_analysis_{timestamp}.csv",
|
| 155 |
+
mime="text/csv",
|
| 156 |
+
use_container_width=True
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Main content
|
| 160 |
+
if analyze_button:
|
| 161 |
+
if not search_query:
|
| 162 |
+
st.error("⚠️ Please enter a search query")
|
| 163 |
+
return
|
| 164 |
+
|
| 165 |
+
if not brand_names_input.strip():
|
| 166 |
+
st.error("⚠️ Please enter at least one brand name")
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
if not search_engines:
|
| 170 |
+
st.error("⚠️ Please select at least one search engine")
|
| 171 |
+
return
|
| 172 |
+
|
| 173 |
+
# Parse brand names
|
| 174 |
+
brand_names = [b.strip() for b in brand_names_input.strip().split('\n') if b.strip()]
|
| 175 |
+
|
| 176 |
+
st.info(f"📊 Analyzing {len(brand_names)} brand(s) across {len(search_engines)} search engine(s)")
|
| 177 |
+
|
| 178 |
+
# Batch analysis
|
| 179 |
+
analyzer = BrandAnalyzer()
|
| 180 |
+
batch_results = batch_analyze_brands(
|
| 181 |
+
search_query,
|
| 182 |
+
brand_names,
|
| 183 |
+
search_engines,
|
| 184 |
+
num_results,
|
| 185 |
+
custom_prompt,
|
| 186 |
+
analyzer,
|
| 187 |
+
scrape_article_content
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# Save to database and session state
|
| 191 |
+
st.session_state.batch_results = batch_results
|
| 192 |
+
|
| 193 |
+
for brand_name, results in batch_results.items():
|
| 194 |
+
if results:
|
| 195 |
+
# Save to database
|
| 196 |
+
for engine in search_engines:
|
| 197 |
+
save_analysis_to_db(search_query, brand_name, engine, results)
|
| 198 |
+
|
| 199 |
+
# Track co-mentions if multiple brands
|
| 200 |
+
if len(brand_names) > 1:
|
| 201 |
+
for result in results:
|
| 202 |
+
if result.get('analysis', {}).get('explicit_mentions') or result.get('analysis', {}).get('indirect_mentions'):
|
| 203 |
+
# Find which brands are mentioned in this article
|
| 204 |
+
mentioned_brands = []
|
| 205 |
+
for other_brand in brand_names:
|
| 206 |
+
if other_brand != brand_name:
|
| 207 |
+
# Check if other brand is mentioned
|
| 208 |
+
article_content = result.get('content', '').lower()
|
| 209 |
+
if other_brand.lower() in article_content:
|
| 210 |
+
mentioned_brands.append(other_brand)
|
| 211 |
+
|
| 212 |
+
if mentioned_brands:
|
| 213 |
+
mentioned_brands.append(brand_name)
|
| 214 |
+
# This will be saved when we have article_id
|
| 215 |
+
|
| 216 |
+
st.success("✅ Batch analysis complete and saved to database!")
|
| 217 |
+
st.rerun()
|
| 218 |
+
|
| 219 |
+
# Display results
|
| 220 |
+
if st.session_state.batch_results:
|
| 221 |
+
st.markdown("---")
|
| 222 |
+
st.subheader("📊 Analysis Results")
|
| 223 |
+
|
| 224 |
+
# Summary metrics
|
| 225 |
+
total_brands = len(st.session_state.batch_results)
|
| 226 |
+
total_articles = sum(len(results) for results in st.session_state.batch_results.values())
|
| 227 |
+
total_mentions = sum(
|
| 228 |
+
sum(r.get('total_mentions', 0) for r in results)
|
| 229 |
+
for results in st.session_state.batch_results.values()
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
col1, col2, col3 = st.columns(3)
|
| 233 |
+
with col1:
|
| 234 |
+
st.metric("Brands Analyzed", total_brands)
|
| 235 |
+
with col2:
|
| 236 |
+
st.metric("Total Articles", total_articles)
|
| 237 |
+
with col3:
|
| 238 |
+
st.metric("Total Mentions", total_mentions)
|
| 239 |
+
|
| 240 |
+
# Display results by brand
|
| 241 |
+
for brand_name, results in st.session_state.batch_results.items():
|
| 242 |
+
with st.expander(f"**{brand_name}** - {len(results)} articles"):
|
| 243 |
+
if not results:
|
| 244 |
+
st.info("No results found")
|
| 245 |
+
continue
|
| 246 |
+
|
| 247 |
+
# Brand-specific metrics
|
| 248 |
+
mentions_count = sum(r.get('total_mentions', 0) for r in results)
|
| 249 |
+
articles_with_mentions = sum(1 for r in results if r.get('total_mentions', 0) > 0)
|
| 250 |
+
|
| 251 |
+
col1, col2 = st.columns(2)
|
| 252 |
+
with col1:
|
| 253 |
+
st.metric("Articles with Mentions", articles_with_mentions)
|
| 254 |
+
with col2:
|
| 255 |
+
st.metric("Total Mentions", mentions_count)
|
| 256 |
+
|
| 257 |
+
# Show top mentions
|
| 258 |
+
for i, result in enumerate(results[:5]): # Show top 5
|
| 259 |
+
analysis = result.get('analysis', {})
|
| 260 |
+
if analysis.get('explicit_mentions') or analysis.get('indirect_mentions'):
|
| 261 |
+
st.markdown(f"**📄 {result['title'][:80]}...**")
|
| 262 |
+
st.caption(f"🔗 {result['url']} | Source: {result.get('source', 'unknown')}")
|
| 263 |
+
|
| 264 |
+
for mention in analysis.get('explicit_mentions', [])[:2]:
|
| 265 |
+
sentiment_emoji = {"positive": "😊", "negative": "😞", "neutral": "😐"}.get(mention.get('sentiment'), "😐")
|
| 266 |
+
st.markdown(f"- {sentiment_emoji} *{mention.get('mention', '')}*")
|
| 267 |
+
|
| 268 |
+
elif not st.session_state.batch_results:
|
| 269 |
+
st.info("👈 Configure your analysis in the sidebar and click 'Start Batch Analysis' to begin")
|
| 270 |
+
|
| 271 |
+
def render_dashboard():
|
| 272 |
+
"""Render the analytics dashboard"""
|
| 273 |
+
st.title("📊 Brand Analytics Dashboard")
|
| 274 |
+
|
| 275 |
+
# Get historical data
|
| 276 |
+
analyses = get_historical_analyses(limit=100)
|
| 277 |
+
|
| 278 |
+
if not analyses:
|
| 279 |
+
st.info("No historical data available. Run some analyses first!")
|
| 280 |
+
return
|
| 281 |
+
|
| 282 |
+
# Filter controls
|
| 283 |
+
st.sidebar.subheader("📊 Dashboard Filters")
|
| 284 |
+
|
| 285 |
+
# Brand filter
|
| 286 |
+
all_brands = list(set(a.brand_name for a in analyses))
|
| 287 |
+
selected_brands = st.sidebar.multiselect(
|
| 288 |
+
"Filter by Brand",
|
| 289 |
+
all_brands,
|
| 290 |
+
default=all_brands[:5] if len(all_brands) > 5 else all_brands
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# Time filter
|
| 294 |
+
time_range = st.sidebar.selectbox(
|
| 295 |
+
"Time Range",
|
| 296 |
+
["Last 24 hours", "Last 7 days", "Last 30 days", "All time"]
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Filter analyses
|
| 300 |
+
filtered_analyses = [a for a in analyses if a.brand_name in selected_brands]
|
| 301 |
+
|
| 302 |
+
# Summary metrics
|
| 303 |
+
col1, col2, col3, col4 = st.columns(4)
|
| 304 |
+
|
| 305 |
+
total_analyses = len(filtered_analyses)
|
| 306 |
+
total_mentions = sum(a.total_mentions for a in filtered_analyses)
|
| 307 |
+
avg_sentiment = sum(a.positive_count for a in filtered_analyses) / max(total_mentions, 1)
|
| 308 |
+
|
| 309 |
+
with col1:
|
| 310 |
+
st.metric("Total Analyses", total_analyses)
|
| 311 |
+
with col2:
|
| 312 |
+
st.metric("Total Mentions", total_mentions)
|
| 313 |
+
with col3:
|
| 314 |
+
st.metric("Avg Positive %", f"{avg_sentiment*100:.1f}%")
|
| 315 |
+
with col4:
|
| 316 |
+
active_brands = len(set(a.brand_name for a in filtered_analyses))
|
| 317 |
+
st.metric("Active Brands", active_brands)
|
| 318 |
+
|
| 319 |
+
# Sentiment Distribution Chart
|
| 320 |
+
st.subheader("📈 Sentiment Distribution")
|
| 321 |
+
|
| 322 |
+
sentiment_data = []
|
| 323 |
+
for analysis in filtered_analyses:
|
| 324 |
+
sentiment_data.append({
|
| 325 |
+
'Positive': analysis.positive_count,
|
| 326 |
+
'Negative': analysis.negative_count,
|
| 327 |
+
'Neutral': analysis.neutral_count
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
if sentiment_data:
|
| 331 |
+
total_positive = sum(d['Positive'] for d in sentiment_data)
|
| 332 |
+
total_negative = sum(d['Negative'] for d in sentiment_data)
|
| 333 |
+
total_neutral = sum(d['Neutral'] for d in sentiment_data)
|
| 334 |
+
|
| 335 |
+
col1, col2 = st.columns(2)
|
| 336 |
+
|
| 337 |
+
with col1:
|
| 338 |
+
# Pie chart
|
| 339 |
+
fig_pie = go.Figure(data=[go.Pie(
|
| 340 |
+
labels=['Positive', 'Negative', 'Neutral'],
|
| 341 |
+
values=[total_positive, total_negative, total_neutral],
|
| 342 |
+
marker=dict(colors=['#00D26A', '#FF5C5C', '#FFD700'])
|
| 343 |
+
)])
|
| 344 |
+
fig_pie.update_layout(title="Overall Sentiment Distribution")
|
| 345 |
+
st.plotly_chart(fig_pie, use_container_width=True)
|
| 346 |
+
|
| 347 |
+
with col2:
|
| 348 |
+
# Bar chart by brand
|
| 349 |
+
brand_sentiment = defaultdict(lambda: {'positive': 0, 'negative': 0, 'neutral': 0})
|
| 350 |
+
for analysis in filtered_analyses:
|
| 351 |
+
brand_sentiment[analysis.brand_name]['positive'] += analysis.positive_count
|
| 352 |
+
brand_sentiment[analysis.brand_name]['negative'] += analysis.negative_count
|
| 353 |
+
brand_sentiment[analysis.brand_name]['neutral'] += analysis.neutral_count
|
| 354 |
+
|
| 355 |
+
brands = list(brand_sentiment.keys())
|
| 356 |
+
positive_vals = [brand_sentiment[b]['positive'] for b in brands]
|
| 357 |
+
negative_vals = [brand_sentiment[b]['negative'] for b in brands]
|
| 358 |
+
neutral_vals = [brand_sentiment[b]['neutral'] for b in brands]
|
| 359 |
+
|
| 360 |
+
fig_bar = go.Figure(data=[
|
| 361 |
+
go.Bar(name='Positive', x=brands, y=positive_vals, marker_color='#00D26A'),
|
| 362 |
+
go.Bar(name='Negative', x=brands, y=negative_vals, marker_color='#FF5C5C'),
|
| 363 |
+
go.Bar(name='Neutral', x=brands, y=neutral_vals, marker_color='#FFD700')
|
| 364 |
+
])
|
| 365 |
+
fig_bar.update_layout(
|
| 366 |
+
title="Sentiment by Brand",
|
| 367 |
+
barmode='stack',
|
| 368 |
+
xaxis_title="Brand",
|
| 369 |
+
yaxis_title="Mentions"
|
| 370 |
+
)
|
| 371 |
+
st.plotly_chart(fig_bar, use_container_width=True)
|
| 372 |
+
|
| 373 |
+
# Trend over time
|
| 374 |
+
st.subheader("📅 Mention Trends Over Time")
|
| 375 |
+
|
| 376 |
+
trend_data = []
|
| 377 |
+
for analysis in filtered_analyses:
|
| 378 |
+
trend_data.append({
|
| 379 |
+
'Date': analysis.created_at.date(),
|
| 380 |
+
'Brand': analysis.brand_name,
|
| 381 |
+
'Mentions': analysis.total_mentions
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
+
if trend_data:
|
| 385 |
+
df_trend = pd.DataFrame(trend_data)
|
| 386 |
+
fig_trend = px.line(
|
| 387 |
+
df_trend,
|
| 388 |
+
x='Date',
|
| 389 |
+
y='Mentions',
|
| 390 |
+
color='Brand',
|
| 391 |
+
title="Brand Mentions Over Time"
|
| 392 |
+
)
|
| 393 |
+
st.plotly_chart(fig_trend, use_container_width=True)
|
| 394 |
+
|
| 395 |
+
# Detailed mentions table with filtering
|
| 396 |
+
st.subheader("🔍 Detailed Mentions")
|
| 397 |
+
|
| 398 |
+
# Get all mentions for filtered analyses
|
| 399 |
+
all_mentions = []
|
| 400 |
+
for analysis in filtered_analyses:
|
| 401 |
+
mentions = get_all_mentions(analysis_id=analysis.id)
|
| 402 |
+
all_mentions.extend(mentions)
|
| 403 |
+
|
| 404 |
+
if all_mentions:
|
| 405 |
+
# Sentiment filter
|
| 406 |
+
sentiment_filter = st.multiselect(
|
| 407 |
+
"Filter by Sentiment",
|
| 408 |
+
["positive", "negative", "neutral"],
|
| 409 |
+
default=["positive", "negative", "neutral"]
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
# Sort options
|
| 413 |
+
sort_by = st.selectbox(
|
| 414 |
+
"Sort by",
|
| 415 |
+
["Date (Newest)", "Date (Oldest)", "Confidence (High to Low)", "Confidence (Low to High)"]
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
# Filter mentions
|
| 419 |
+
filtered_mentions = [m for m in all_mentions if m.sentiment in sentiment_filter]
|
| 420 |
+
|
| 421 |
+
# Sort mentions
|
| 422 |
+
if sort_by == "Date (Newest)":
|
| 423 |
+
filtered_mentions.sort(key=lambda x: x.created_at, reverse=True)
|
| 424 |
+
elif sort_by == "Date (Oldest)":
|
| 425 |
+
filtered_mentions.sort(key=lambda x: x.created_at)
|
| 426 |
+
elif sort_by == "Confidence (High to Low)":
|
| 427 |
+
filtered_mentions.sort(key=lambda x: x.confidence, reverse=True)
|
| 428 |
+
else:
|
| 429 |
+
filtered_mentions.sort(key=lambda x: x.confidence)
|
| 430 |
+
|
| 431 |
+
# Display mentions
|
| 432 |
+
for mention in filtered_mentions[:20]: # Limit to 20
|
| 433 |
+
sentiment_emoji = {"positive": "😊", "negative": "😞", "neutral": "😐"}.get(mention.sentiment, "😐")
|
| 434 |
+
with st.expander(f"{sentiment_emoji} {mention.brand_name} - {mention.mention_type} ({mention.confidence:.0%} confidence)"):
|
| 435 |
+
st.markdown(f"**Mention:** {mention.mention_text}")
|
| 436 |
+
st.markdown(f"**Context:** {mention.context}")
|
| 437 |
+
st.caption(f"**Explanation:** {mention.explanation}")
|
| 438 |
+
st.caption(f"**Date:** {mention.created_at.strftime('%Y-%m-%d %H:%M')}")
|
| 439 |
+
|
| 440 |
+
def render_co_mention_network():
|
| 441 |
+
"""Render co-mention network visualization"""
|
| 442 |
+
st.title("🕸️ Brand Co-Mention Network")
|
| 443 |
+
st.markdown("Visualize which brands are frequently mentioned together in articles")
|
| 444 |
+
|
| 445 |
+
co_mentions = get_co_mention_network()
|
| 446 |
+
|
| 447 |
+
if not co_mentions:
|
| 448 |
+
st.info("No co-mention data available. Analyze multiple brands together to see relationships!")
|
| 449 |
+
return
|
| 450 |
+
|
| 451 |
+
# Build network graph
|
| 452 |
+
G = nx.Graph()
|
| 453 |
+
|
| 454 |
+
# Add edges with weights
|
| 455 |
+
edge_data = defaultdict(int)
|
| 456 |
+
for cm in co_mentions:
|
| 457 |
+
edge_data[(cm.brand1, cm.brand2)] += cm.co_occurrence_count
|
| 458 |
+
|
| 459 |
+
for (brand1, brand2), count in edge_data.items():
|
| 460 |
+
G.add_edge(brand1, brand2, weight=count)
|
| 461 |
+
|
| 462 |
+
# Calculate layout
|
| 463 |
+
pos = nx.spring_layout(G, k=2, iterations=50)
|
| 464 |
+
|
| 465 |
+
# Create edge trace
|
| 466 |
+
edge_traces = []
|
| 467 |
+
for edge in G.edges():
|
| 468 |
+
x0, y0 = pos[edge[0]]
|
| 469 |
+
x1, y1 = pos[edge[1]]
|
| 470 |
+
weight = G[edge[0]][edge[1]]['weight']
|
| 471 |
+
|
| 472 |
+
edge_trace = go.Scatter(
|
| 473 |
+
x=[x0, x1, None],
|
| 474 |
+
y=[y0, y1, None],
|
| 475 |
+
mode='lines',
|
| 476 |
+
line=dict(width=weight*2, color='#888'),
|
| 477 |
+
hoverinfo='text',
|
| 478 |
+
text=f"{edge[0]} ↔ {edge[1]}: {weight} co-mentions",
|
| 479 |
+
showlegend=False
|
| 480 |
+
)
|
| 481 |
+
edge_traces.append(edge_trace)
|
| 482 |
+
|
| 483 |
+
# Create node trace
|
| 484 |
+
node_x = []
|
| 485 |
+
node_y = []
|
| 486 |
+
node_text = []
|
| 487 |
+
node_size = []
|
| 488 |
+
|
| 489 |
+
for node in G.nodes():
|
| 490 |
+
x, y = pos[node]
|
| 491 |
+
node_x.append(x)
|
| 492 |
+
node_y.append(y)
|
| 493 |
+
|
| 494 |
+
# Calculate node size based on connections
|
| 495 |
+
connections = G.degree(node)
|
| 496 |
+
node_size.append(30 + connections * 10)
|
| 497 |
+
node_text.append(f"{node}<br>Connections: {connections}")
|
| 498 |
+
|
| 499 |
+
node_trace = go.Scatter(
|
| 500 |
+
x=node_x,
|
| 501 |
+
y=node_y,
|
| 502 |
+
mode='markers+text',
|
| 503 |
+
text=[node for node in G.nodes()],
|
| 504 |
+
textposition="top center",
|
| 505 |
+
hovertext=node_text,
|
| 506 |
+
hoverinfo='text',
|
| 507 |
+
marker=dict(
|
| 508 |
+
size=node_size,
|
| 509 |
+
color='#1f77b4',
|
| 510 |
+
line=dict(width=2, color='white')
|
| 511 |
+
),
|
| 512 |
+
showlegend=False
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
# Create figure
|
| 516 |
+
fig = go.Figure(data=edge_traces + [node_trace])
|
| 517 |
+
|
| 518 |
+
fig.update_layout(
|
| 519 |
+
title="Brand Co-Mention Network",
|
| 520 |
+
title_font_size=20,
|
| 521 |
+
showlegend=False,
|
| 522 |
+
hovermode='closest',
|
| 523 |
+
margin=dict(b=0, l=0, r=0, t=40),
|
| 524 |
+
xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
| 525 |
+
yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
|
| 526 |
+
height=600
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 530 |
+
|
| 531 |
+
# Network statistics
|
| 532 |
+
st.subheader("📊 Network Statistics")
|
| 533 |
+
|
| 534 |
+
col1, col2, col3 = st.columns(3)
|
| 535 |
+
|
| 536 |
+
with col1:
|
| 537 |
+
st.metric("Total Brands", len(G.nodes()))
|
| 538 |
+
with col2:
|
| 539 |
+
st.metric("Total Relationships", len(G.edges()))
|
| 540 |
+
with col3:
|
| 541 |
+
density = nx.density(G)
|
| 542 |
+
st.metric("Network Density", f"{density:.2%}")
|
| 543 |
+
|
| 544 |
+
# Top co-mentions
|
| 545 |
+
st.subheader("🔝 Top Co-Mentions")
|
| 546 |
+
|
| 547 |
+
top_pairs = sorted(edge_data.items(), key=lambda x: x[1], reverse=True)[:10]
|
| 548 |
+
|
| 549 |
+
for (brand1, brand2), count in top_pairs:
|
| 550 |
+
st.write(f"**{brand1}** ↔ **{brand2}**: {count} co-mentions")
|
| 551 |
+
|
| 552 |
+
def render_scheduled_monitoring():
|
| 553 |
+
"""Render scheduled monitoring page"""
|
| 554 |
+
st.title("⏰ Scheduled Brand Monitoring")
|
| 555 |
+
st.markdown("Set up recurring brand analyses")
|
| 556 |
+
|
| 557 |
+
# Create new schedule
|
| 558 |
+
with st.expander("➕ Create New Schedule", expanded=True):
|
| 559 |
+
col1, col2 = st.columns(2)
|
| 560 |
+
|
| 561 |
+
with col1:
|
| 562 |
+
schedule_query = st.text_input("Search Query", placeholder="AI technology news")
|
| 563 |
+
schedule_brands = st.text_area(
|
| 564 |
+
"Brand Names (one per line)",
|
| 565 |
+
placeholder="OpenAI\nGoogle\nMicrosoft"
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
with col2:
|
| 569 |
+
schedule_engines = st.multiselect(
|
| 570 |
+
"Search Engines",
|
| 571 |
+
["google", "bing", "duckduckgo"],
|
| 572 |
+
default=["google"]
|
| 573 |
+
)
|
| 574 |
+
schedule_frequency = st.selectbox(
|
| 575 |
+
"Frequency",
|
| 576 |
+
["daily", "weekly", "monthly"]
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
if st.button("Create Schedule"):
|
| 580 |
+
if schedule_query and schedule_brands:
|
| 581 |
+
brands = [b.strip() for b in schedule_brands.split('\n') if b.strip()]
|
| 582 |
+
job_id = create_scheduled_job(
|
| 583 |
+
schedule_query,
|
| 584 |
+
brands,
|
| 585 |
+
schedule_engines,
|
| 586 |
+
schedule_frequency
|
| 587 |
+
)
|
| 588 |
+
if job_id:
|
| 589 |
+
st.success(f"✅ Schedule created successfully! (ID: {job_id})")
|
| 590 |
+
st.rerun()
|
| 591 |
+
else:
|
| 592 |
+
st.error("Please fill in all fields")
|
| 593 |
+
|
| 594 |
+
# List existing schedules
|
| 595 |
+
st.subheader("📅 Active Schedules")
|
| 596 |
+
|
| 597 |
+
jobs = get_scheduled_jobs(active_only=True)
|
| 598 |
+
|
| 599 |
+
if not jobs:
|
| 600 |
+
st.info("No active schedules. Create one above!")
|
| 601 |
+
else:
|
| 602 |
+
for job in jobs:
|
| 603 |
+
with st.expander(f"🔔 {job.search_query} - {job.schedule_type}"):
|
| 604 |
+
st.write(f"**Brands:** {job.brand_names}")
|
| 605 |
+
st.write(f"**Engines:** {job.search_engines}")
|
| 606 |
+
st.write(f"**Frequency:** {job.schedule_type}")
|
| 607 |
+
if job.last_run:
|
| 608 |
+
st.write(f"**Last Run:** {job.last_run.strftime('%Y-%m-%d %H:%M')}")
|
| 609 |
+
if job.next_run:
|
| 610 |
+
st.write(f"**Next Run:** {job.next_run.strftime('%Y-%m-%d %H:%M')}")
|
| 611 |
+
st.caption(f"Created: {job.created_at.strftime('%Y-%m-%d')}")
|
| 612 |
+
|
| 613 |
+
def render_history():
|
| 614 |
+
"""Render analysis history"""
|
| 615 |
+
st.title("📚 Analysis History")
|
| 616 |
+
|
| 617 |
+
analyses = get_historical_analyses(limit=50)
|
| 618 |
+
|
| 619 |
+
if not analyses:
|
| 620 |
+
st.info("No historical analyses available")
|
| 621 |
+
return
|
| 622 |
+
|
| 623 |
+
# Create DataFrame
|
| 624 |
+
history_data = []
|
| 625 |
+
for a in analyses:
|
| 626 |
+
history_data.append({
|
| 627 |
+
'Date': a.created_at.strftime('%Y-%m-%d %H:%M'),
|
| 628 |
+
'Brand': a.brand_name,
|
| 629 |
+
'Query': a.search_query,
|
| 630 |
+
'Engine': a.search_engine,
|
| 631 |
+
'Articles': a.total_articles,
|
| 632 |
+
'Mentions': a.total_mentions,
|
| 633 |
+
'Positive': a.positive_count,
|
| 634 |
+
'Negative': a.negative_count,
|
| 635 |
+
'Neutral': a.neutral_count
|
| 636 |
+
})
|
| 637 |
+
|
| 638 |
+
df_history = pd.DataFrame(history_data)
|
| 639 |
+
|
| 640 |
+
# Display with filtering
|
| 641 |
+
brand_filter = st.multiselect(
|
| 642 |
+
"Filter by Brand",
|
| 643 |
+
df_history['Brand'].unique(),
|
| 644 |
+
default=None
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
if brand_filter:
|
| 648 |
+
df_history = df_history[df_history['Brand'].isin(brand_filter)]
|
| 649 |
+
|
| 650 |
+
st.dataframe(df_history, use_container_width=True)
|
| 651 |
+
|
| 652 |
+
# Main routing
|
| 653 |
+
if page == "Analysis":
|
| 654 |
+
render_analysis_page()
|
| 655 |
+
elif page == "Dashboard":
|
| 656 |
+
render_dashboard()
|
| 657 |
+
elif page == "Co-Mention Network":
|
| 658 |
+
render_co_mention_network()
|
| 659 |
+
elif page == "Scheduled Monitoring":
|
| 660 |
+
render_scheduled_monitoring()
|
| 661 |
+
elif page == "History":
|
| 662 |
+
render_history()
|
brand_analyzer.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from groq import Groq
|
| 3 |
+
import json
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class BrandAnalyzer:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
"""Initialize the Groq client with API key from environment."""
|
| 11 |
+
api_key = os.getenv("GROQ_API_KEY", "")
|
| 12 |
+
if not api_key:
|
| 13 |
+
st.error("GROQ_API_KEY environment variable not found. Please set your Groq API key.")
|
| 14 |
+
st.stop()
|
| 15 |
+
|
| 16 |
+
self.client = Groq(api_key=api_key)
|
| 17 |
+
|
| 18 |
+
def analyze_brand_mention(self, article_content: str, brand_name: str, custom_prompt: str = "") -> Dict:
|
| 19 |
+
"""
|
| 20 |
+
Analyze article content for brand mentions and sentiment using Groq LLM.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
article_content: The article content to analyze
|
| 24 |
+
brand_name: The brand name to search for
|
| 25 |
+
custom_prompt: Optional custom prompt from user
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Dictionary with analysis results
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
# Default prompt if no custom prompt provided
|
| 32 |
+
default_prompt = f"""You are a brand monitoring analyst. Analyze the following article content for mentions of the brand "{brand_name}".
|
| 33 |
+
|
| 34 |
+
Your task:
|
| 35 |
+
1. Detect explicit mentions of "{brand_name}" (exact name matches)
|
| 36 |
+
2. Detect indirect mentions (references without exact name, but clearly about the brand)
|
| 37 |
+
3. Extract relevant context (surrounding sentences) for each mention
|
| 38 |
+
4. Determine sentiment (positive, negative, or neutral) for each mention
|
| 39 |
+
5. Provide brief explanation for sentiment classification
|
| 40 |
+
|
| 41 |
+
You MUST respond with ONLY valid JSON in this exact format (no additional text before or after):
|
| 42 |
+
{{
|
| 43 |
+
"explicit_mentions": [
|
| 44 |
+
{{
|
| 45 |
+
"mention": "exact text mentioning the brand",
|
| 46 |
+
"context": "surrounding context with 1-2 sentences",
|
| 47 |
+
"sentiment": "positive/negative/neutral",
|
| 48 |
+
"explanation": "brief explanation of why this sentiment was assigned"
|
| 49 |
+
}}
|
| 50 |
+
],
|
| 51 |
+
"indirect_mentions": [
|
| 52 |
+
{{
|
| 53 |
+
"reference": "text that indirectly references the brand",
|
| 54 |
+
"context": "surrounding context with 1-2 sentences",
|
| 55 |
+
"sentiment": "positive/negative/neutral",
|
| 56 |
+
"explanation": "brief explanation of why this sentiment was assigned"
|
| 57 |
+
}}
|
| 58 |
+
],
|
| 59 |
+
"overall_sentiment": "positive/negative/neutral",
|
| 60 |
+
"summary": "brief summary of how the brand is portrayed in this article"
|
| 61 |
+
}}
|
| 62 |
+
|
| 63 |
+
IMPORTANT: If no mentions are found, return empty arrays for explicit_mentions and indirect_mentions, with overall_sentiment as "neutral" and summary explaining no mentions were found. Do not include any text outside the JSON object."""
|
| 64 |
+
|
| 65 |
+
# Use custom prompt if provided, otherwise use default
|
| 66 |
+
system_prompt = custom_prompt if custom_prompt.strip() else default_prompt
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
# Create the chat completion
|
| 70 |
+
chat_completion = self.client.chat.completions.create(
|
| 71 |
+
messages=[
|
| 72 |
+
{"role": "system", "content": system_prompt},
|
| 73 |
+
{"role": "user", "content": f"Article content to analyze:\n\n{article_content}"}
|
| 74 |
+
],
|
| 75 |
+
model="llama-3.1-8b-instant", # Using Llama 3.3 70B model available on Groq
|
| 76 |
+
temperature=0.1, # Low temperature for consistent analysis
|
| 77 |
+
max_tokens=1500,
|
| 78 |
+
response_format={"type": "json_object"} # Ensure JSON output
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# Extract the response
|
| 82 |
+
response_content = chat_completion.choices[0].message.content
|
| 83 |
+
|
| 84 |
+
# Try to parse as JSON
|
| 85 |
+
try:
|
| 86 |
+
analysis_result = json.loads(response_content)
|
| 87 |
+
return analysis_result
|
| 88 |
+
except json.JSONDecodeError:
|
| 89 |
+
# If JSON parsing fails, return a structured error response
|
| 90 |
+
return {
|
| 91 |
+
"explicit_mentions": [],
|
| 92 |
+
"indirect_mentions": [],
|
| 93 |
+
"overall_sentiment": "neutral",
|
| 94 |
+
"summary": f"Analysis completed but response format was invalid. Raw response: {response_content[:200]}...",
|
| 95 |
+
"error": "JSON parsing failed"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
return {
|
| 100 |
+
"explicit_mentions": [],
|
| 101 |
+
"indirect_mentions": [],
|
| 102 |
+
"overall_sentiment": "neutral",
|
| 103 |
+
"summary": f"Analysis failed due to error: {str(e)}",
|
| 104 |
+
"error": str(e)
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
def batch_analyze_articles(self, articles: List[Dict], brand_name: str, custom_prompt: str = "") -> List[Dict]:
|
| 108 |
+
"""
|
| 109 |
+
Analyze multiple articles for brand mentions.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
articles: List of article dictionaries with 'title', 'content', 'url'
|
| 113 |
+
brand_name: Brand name to analyze
|
| 114 |
+
custom_prompt: Optional custom prompt
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
List of analysis results with article info
|
| 118 |
+
"""
|
| 119 |
+
results = []
|
| 120 |
+
|
| 121 |
+
progress_bar = st.progress(0)
|
| 122 |
+
status_text = st.empty()
|
| 123 |
+
|
| 124 |
+
for i, article in enumerate(articles):
|
| 125 |
+
status_text.text(f"Analyzing article {i+1} of {len(articles)}: {article['title'][:50]}...")
|
| 126 |
+
|
| 127 |
+
# Analyze the article
|
| 128 |
+
analysis = self.analyze_brand_mention(
|
| 129 |
+
article['content'],
|
| 130 |
+
brand_name,
|
| 131 |
+
custom_prompt
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Combine article info with analysis
|
| 135 |
+
result = {
|
| 136 |
+
'url': article['url'],
|
| 137 |
+
'title': article['title'],
|
| 138 |
+
'content': article['content'][:500] + "..." if len(article['content']) > 500 else article['content'],
|
| 139 |
+
'analysis': analysis,
|
| 140 |
+
'total_mentions': len(analysis.get('explicit_mentions', [])) + len(analysis.get('indirect_mentions', []))
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
results.append(result)
|
| 144 |
+
|
| 145 |
+
# Update progress
|
| 146 |
+
progress_bar.progress((i + 1) / len(articles))
|
| 147 |
+
|
| 148 |
+
status_text.text("Analysis complete!")
|
| 149 |
+
return results
|
database.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float, Text, ForeignKey, Boolean
|
| 3 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 4 |
+
from sqlalchemy.orm import sessionmaker, relationship
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
Base = declarative_base()
|
| 8 |
+
|
| 9 |
+
class BrandAnalysis(Base):
|
| 10 |
+
"""Store overall analysis results for a brand search"""
|
| 11 |
+
__tablename__ = 'brand_analyses'
|
| 12 |
+
|
| 13 |
+
id = Column(Integer, primary_key=True)
|
| 14 |
+
search_query = Column(String(500), nullable=False)
|
| 15 |
+
brand_name = Column(String(200), nullable=False)
|
| 16 |
+
search_engine = Column(String(50), nullable=False) # google, bing, duckduckgo
|
| 17 |
+
total_articles = Column(Integer, default=0)
|
| 18 |
+
articles_with_mentions = Column(Integer, default=0)
|
| 19 |
+
total_mentions = Column(Integer, default=0)
|
| 20 |
+
positive_count = Column(Integer, default=0)
|
| 21 |
+
negative_count = Column(Integer, default=0)
|
| 22 |
+
neutral_count = Column(Integer, default=0)
|
| 23 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 24 |
+
|
| 25 |
+
# Relationships
|
| 26 |
+
mentions = relationship("BrandMention", back_populates="analysis", cascade="all, delete-orphan")
|
| 27 |
+
articles = relationship("Article", back_populates="analysis", cascade="all, delete-orphan")
|
| 28 |
+
|
| 29 |
+
class Article(Base):
|
| 30 |
+
"""Store article information"""
|
| 31 |
+
__tablename__ = 'articles'
|
| 32 |
+
|
| 33 |
+
id = Column(Integer, primary_key=True)
|
| 34 |
+
analysis_id = Column(Integer, ForeignKey('brand_analyses.id'), nullable=False)
|
| 35 |
+
url = Column(Text, nullable=False)
|
| 36 |
+
title = Column(Text)
|
| 37 |
+
content = Column(Text)
|
| 38 |
+
overall_sentiment = Column(String(20))
|
| 39 |
+
summary = Column(Text)
|
| 40 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 41 |
+
|
| 42 |
+
# Relationships
|
| 43 |
+
analysis = relationship("BrandAnalysis", back_populates="articles")
|
| 44 |
+
mentions = relationship("BrandMention", back_populates="article", cascade="all, delete-orphan")
|
| 45 |
+
|
| 46 |
+
class BrandMention(Base):
|
| 47 |
+
"""Store individual brand mentions"""
|
| 48 |
+
__tablename__ = 'brand_mentions'
|
| 49 |
+
|
| 50 |
+
id = Column(Integer, primary_key=True)
|
| 51 |
+
analysis_id = Column(Integer, ForeignKey('brand_analyses.id'), nullable=False)
|
| 52 |
+
article_id = Column(Integer, ForeignKey('articles.id'), nullable=False)
|
| 53 |
+
brand_name = Column(String(200), nullable=False)
|
| 54 |
+
mention_type = Column(String(20)) # explicit, indirect
|
| 55 |
+
mention_text = Column(Text)
|
| 56 |
+
context = Column(Text)
|
| 57 |
+
sentiment = Column(String(20)) # positive, negative, neutral
|
| 58 |
+
confidence = Column(Float, default=0.0) # Confidence score for sentiment
|
| 59 |
+
explanation = Column(Text)
|
| 60 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 61 |
+
|
| 62 |
+
# Relationships
|
| 63 |
+
analysis = relationship("BrandAnalysis", back_populates="mentions")
|
| 64 |
+
article = relationship("Article", back_populates="mentions")
|
| 65 |
+
|
| 66 |
+
class ScheduledMonitoring(Base):
|
| 67 |
+
"""Store scheduled monitoring jobs"""
|
| 68 |
+
__tablename__ = 'scheduled_monitoring'
|
| 69 |
+
|
| 70 |
+
id = Column(Integer, primary_key=True)
|
| 71 |
+
search_query = Column(String(500), nullable=False)
|
| 72 |
+
brand_names = Column(Text, nullable=False) # Comma-separated brand names
|
| 73 |
+
search_engines = Column(Text, nullable=False) # Comma-separated search engines
|
| 74 |
+
schedule_type = Column(String(20), default='weekly') # daily, weekly, monthly
|
| 75 |
+
is_active = Column(Boolean, default=True)
|
| 76 |
+
last_run = Column(DateTime)
|
| 77 |
+
next_run = Column(DateTime)
|
| 78 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 79 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 80 |
+
|
| 81 |
+
class CoMention(Base):
|
| 82 |
+
"""Store co-mention relationships between brands"""
|
| 83 |
+
__tablename__ = 'co_mentions'
|
| 84 |
+
|
| 85 |
+
id = Column(Integer, primary_key=True)
|
| 86 |
+
brand1 = Column(String(200), nullable=False)
|
| 87 |
+
brand2 = Column(String(200), nullable=False)
|
| 88 |
+
article_id = Column(Integer, ForeignKey('articles.id'), nullable=False)
|
| 89 |
+
co_occurrence_count = Column(Integer, default=1)
|
| 90 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 91 |
+
|
| 92 |
+
# Database connection and session management
|
| 93 |
+
def get_database_engine():
|
| 94 |
+
"""Create and return database engine"""
|
| 95 |
+
database_url = os.getenv('DATABASE_URL')
|
| 96 |
+
if not database_url:
|
| 97 |
+
raise ValueError("DATABASE_URL environment variable not set")
|
| 98 |
+
return create_engine(database_url)
|
| 99 |
+
|
| 100 |
+
def get_session():
|
| 101 |
+
"""Create and return database session"""
|
| 102 |
+
engine = get_database_engine()
|
| 103 |
+
Session = sessionmaker(bind=engine)
|
| 104 |
+
return Session()
|
| 105 |
+
|
| 106 |
+
def init_database():
|
| 107 |
+
"""Initialize database tables"""
|
| 108 |
+
engine = get_database_engine()
|
| 109 |
+
Base.metadata.create_all(engine)
|
| 110 |
+
print("Database tables created successfully")
|
| 111 |
+
|
| 112 |
+
def drop_all_tables():
|
| 113 |
+
"""Drop all tables (use with caution!)"""
|
| 114 |
+
engine = get_database_engine()
|
| 115 |
+
Base.metadata.drop_all(engine)
|
| 116 |
+
print("All tables dropped")
|
db_operations.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from database import (
|
| 2 |
+
get_session, BrandAnalysis, Article, BrandMention,
|
| 3 |
+
ScheduledMonitoring, CoMention
|
| 4 |
+
)
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import List, Dict
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
def save_analysis_to_db(search_query: str, brand_name: str, search_engine: str,
|
| 10 |
+
analysis_results: List[Dict]) -> int:
|
| 11 |
+
"""
|
| 12 |
+
Save brand analysis results to database
|
| 13 |
+
Returns: analysis_id
|
| 14 |
+
"""
|
| 15 |
+
session = None
|
| 16 |
+
try:
|
| 17 |
+
session = get_session()
|
| 18 |
+
|
| 19 |
+
# Calculate aggregates
|
| 20 |
+
total_articles = len(analysis_results)
|
| 21 |
+
articles_with_mentions = sum(1 for r in analysis_results if r.get('total_mentions', 0) > 0)
|
| 22 |
+
total_mentions = sum(r.get('total_mentions', 0) for r in analysis_results)
|
| 23 |
+
|
| 24 |
+
# Count sentiments
|
| 25 |
+
positive_count = 0
|
| 26 |
+
negative_count = 0
|
| 27 |
+
neutral_count = 0
|
| 28 |
+
|
| 29 |
+
for result in analysis_results:
|
| 30 |
+
analysis = result.get('analysis', {})
|
| 31 |
+
for mention in analysis.get('explicit_mentions', []):
|
| 32 |
+
sentiment = mention.get('sentiment', 'neutral')
|
| 33 |
+
if sentiment == 'positive':
|
| 34 |
+
positive_count += 1
|
| 35 |
+
elif sentiment == 'negative':
|
| 36 |
+
negative_count += 1
|
| 37 |
+
else:
|
| 38 |
+
neutral_count += 1
|
| 39 |
+
|
| 40 |
+
for mention in analysis.get('indirect_mentions', []):
|
| 41 |
+
sentiment = mention.get('sentiment', 'neutral')
|
| 42 |
+
if sentiment == 'positive':
|
| 43 |
+
positive_count += 1
|
| 44 |
+
elif sentiment == 'negative':
|
| 45 |
+
negative_count += 1
|
| 46 |
+
else:
|
| 47 |
+
neutral_count += 1
|
| 48 |
+
|
| 49 |
+
# Create brand analysis record
|
| 50 |
+
brand_analysis = BrandAnalysis(
|
| 51 |
+
search_query=search_query,
|
| 52 |
+
brand_name=brand_name,
|
| 53 |
+
search_engine=search_engine,
|
| 54 |
+
total_articles=total_articles,
|
| 55 |
+
articles_with_mentions=articles_with_mentions,
|
| 56 |
+
total_mentions=total_mentions,
|
| 57 |
+
positive_count=positive_count,
|
| 58 |
+
negative_count=negative_count,
|
| 59 |
+
neutral_count=neutral_count
|
| 60 |
+
)
|
| 61 |
+
session.add(brand_analysis)
|
| 62 |
+
session.flush() # Get the ID
|
| 63 |
+
|
| 64 |
+
# Save articles and mentions
|
| 65 |
+
for result in analysis_results:
|
| 66 |
+
article = Article(
|
| 67 |
+
analysis_id=brand_analysis.id,
|
| 68 |
+
url=result.get('url', ''),
|
| 69 |
+
title=result.get('title', ''),
|
| 70 |
+
content=result.get('content', '')[:1000], # Limit content size
|
| 71 |
+
overall_sentiment=result.get('analysis', {}).get('overall_sentiment', 'neutral'),
|
| 72 |
+
summary=result.get('analysis', {}).get('summary', '')
|
| 73 |
+
)
|
| 74 |
+
session.add(article)
|
| 75 |
+
session.flush()
|
| 76 |
+
|
| 77 |
+
# Save mentions
|
| 78 |
+
analysis_data = result.get('analysis', {})
|
| 79 |
+
|
| 80 |
+
for mention in analysis_data.get('explicit_mentions', []):
|
| 81 |
+
brand_mention = BrandMention(
|
| 82 |
+
analysis_id=brand_analysis.id,
|
| 83 |
+
article_id=article.id,
|
| 84 |
+
brand_name=brand_name,
|
| 85 |
+
mention_type='explicit',
|
| 86 |
+
mention_text=mention.get('mention', ''),
|
| 87 |
+
context=mention.get('context', ''),
|
| 88 |
+
sentiment=mention.get('sentiment', 'neutral'),
|
| 89 |
+
confidence=0.8, # Default confidence for explicit mentions
|
| 90 |
+
explanation=mention.get('explanation', '')
|
| 91 |
+
)
|
| 92 |
+
session.add(brand_mention)
|
| 93 |
+
|
| 94 |
+
for mention in analysis_data.get('indirect_mentions', []):
|
| 95 |
+
brand_mention = BrandMention(
|
| 96 |
+
analysis_id=brand_analysis.id,
|
| 97 |
+
article_id=article.id,
|
| 98 |
+
brand_name=brand_name,
|
| 99 |
+
mention_type='indirect',
|
| 100 |
+
mention_text=mention.get('reference', ''),
|
| 101 |
+
context=mention.get('context', ''),
|
| 102 |
+
sentiment=mention.get('sentiment', 'neutral'),
|
| 103 |
+
confidence=0.6, # Lower confidence for indirect mentions
|
| 104 |
+
explanation=mention.get('explanation', '')
|
| 105 |
+
)
|
| 106 |
+
session.add(brand_mention)
|
| 107 |
+
|
| 108 |
+
session.commit()
|
| 109 |
+
analysis_id = brand_analysis.id
|
| 110 |
+
session.close()
|
| 111 |
+
|
| 112 |
+
return analysis_id
|
| 113 |
+
|
| 114 |
+
except Exception as e:
|
| 115 |
+
st.error(f"Database error: {str(e)}")
|
| 116 |
+
if session:
|
| 117 |
+
session.rollback()
|
| 118 |
+
session.close()
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
def get_historical_analyses(brand_name: str = None, limit: int = 100):
|
| 122 |
+
"""Get historical analyses, optionally filtered by brand name"""
|
| 123 |
+
session = None
|
| 124 |
+
try:
|
| 125 |
+
session = get_session()
|
| 126 |
+
query = session.query(BrandAnalysis)
|
| 127 |
+
|
| 128 |
+
if brand_name:
|
| 129 |
+
query = query.filter(BrandAnalysis.brand_name == brand_name)
|
| 130 |
+
|
| 131 |
+
analyses = query.order_by(BrandAnalysis.created_at.desc()).limit(limit).all()
|
| 132 |
+
session.close()
|
| 133 |
+
return analyses
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
st.error(f"Database query error: {str(e)}")
|
| 137 |
+
return []
|
| 138 |
+
|
| 139 |
+
def get_all_mentions(analysis_id: int = None, sentiment: str = None):
|
| 140 |
+
"""Get mentions, optionally filtered by analysis_id and sentiment"""
|
| 141 |
+
try:
|
| 142 |
+
session = get_session()
|
| 143 |
+
query = session.query(BrandMention)
|
| 144 |
+
|
| 145 |
+
if analysis_id:
|
| 146 |
+
query = query.filter(BrandMention.analysis_id == analysis_id)
|
| 147 |
+
|
| 148 |
+
if sentiment:
|
| 149 |
+
query = query.filter(BrandMention.sentiment == sentiment)
|
| 150 |
+
|
| 151 |
+
mentions = query.order_by(BrandMention.created_at.desc()).all()
|
| 152 |
+
session.close()
|
| 153 |
+
return mentions
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
st.error(f"Database query error: {str(e)}")
|
| 157 |
+
return []
|
| 158 |
+
|
| 159 |
+
def save_co_mentions(article_id: int, brands: List[str]):
|
| 160 |
+
"""Save co-mention relationships for brands in the same article"""
|
| 161 |
+
try:
|
| 162 |
+
session = get_session()
|
| 163 |
+
|
| 164 |
+
# Create co-mentions for each pair of brands
|
| 165 |
+
for i, brand1 in enumerate(brands):
|
| 166 |
+
for brand2 in brands[i+1:]:
|
| 167 |
+
# Ensure consistent ordering (alphabetical)
|
| 168 |
+
b1, b2 = sorted([brand1, brand2])
|
| 169 |
+
|
| 170 |
+
# Check if co-mention already exists
|
| 171 |
+
existing = session.query(CoMention).filter(
|
| 172 |
+
CoMention.brand1 == b1,
|
| 173 |
+
CoMention.brand2 == b2,
|
| 174 |
+
CoMention.article_id == article_id
|
| 175 |
+
).first()
|
| 176 |
+
|
| 177 |
+
if existing:
|
| 178 |
+
existing.co_occurrence_count += 1
|
| 179 |
+
else:
|
| 180 |
+
co_mention = CoMention(
|
| 181 |
+
brand1=b1,
|
| 182 |
+
brand2=b2,
|
| 183 |
+
article_id=article_id,
|
| 184 |
+
co_occurrence_count=1
|
| 185 |
+
)
|
| 186 |
+
session.add(co_mention)
|
| 187 |
+
|
| 188 |
+
session.commit()
|
| 189 |
+
session.close()
|
| 190 |
+
|
| 191 |
+
except Exception as e:
|
| 192 |
+
st.error(f"Error saving co-mentions: {str(e)}")
|
| 193 |
+
if session:
|
| 194 |
+
session.rollback()
|
| 195 |
+
session.close()
|
| 196 |
+
|
| 197 |
+
def get_co_mention_network():
|
| 198 |
+
"""Get all co-mention relationships for network visualization"""
|
| 199 |
+
try:
|
| 200 |
+
session = get_session()
|
| 201 |
+
co_mentions = session.query(CoMention).all()
|
| 202 |
+
session.close()
|
| 203 |
+
return co_mentions
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
st.error(f"Database query error: {str(e)}")
|
| 207 |
+
return []
|
| 208 |
+
|
| 209 |
+
def create_scheduled_job(search_query: str, brand_names: List[str],
|
| 210 |
+
search_engines: List[str], schedule_type: str = 'weekly'):
|
| 211 |
+
"""Create a new scheduled monitoring job"""
|
| 212 |
+
try:
|
| 213 |
+
session = get_session()
|
| 214 |
+
|
| 215 |
+
job = ScheduledMonitoring(
|
| 216 |
+
search_query=search_query,
|
| 217 |
+
brand_names=','.join(brand_names),
|
| 218 |
+
search_engines=','.join(search_engines),
|
| 219 |
+
schedule_type=schedule_type,
|
| 220 |
+
is_active=True
|
| 221 |
+
)
|
| 222 |
+
session.add(job)
|
| 223 |
+
session.commit()
|
| 224 |
+
job_id = job.id
|
| 225 |
+
session.close()
|
| 226 |
+
|
| 227 |
+
return job_id
|
| 228 |
+
|
| 229 |
+
except Exception as e:
|
| 230 |
+
st.error(f"Error creating scheduled job: {str(e)}")
|
| 231 |
+
if session:
|
| 232 |
+
session.rollback()
|
| 233 |
+
session.close()
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
def get_scheduled_jobs(active_only: bool = True):
|
| 237 |
+
"""Get all scheduled monitoring jobs"""
|
| 238 |
+
try:
|
| 239 |
+
session = get_session()
|
| 240 |
+
query = session.query(ScheduledMonitoring)
|
| 241 |
+
|
| 242 |
+
if active_only:
|
| 243 |
+
query = query.filter(ScheduledMonitoring.is_active == True)
|
| 244 |
+
|
| 245 |
+
jobs = query.order_by(ScheduledMonitoring.created_at.desc()).all()
|
| 246 |
+
session.close()
|
| 247 |
+
return jobs
|
| 248 |
+
|
| 249 |
+
except Exception as e:
|
| 250 |
+
st.error(f"Database query error: {str(e)}")
|
| 251 |
+
return []
|
| 252 |
+
|
| 253 |
+
def update_job_schedule(job_id: int, last_run: datetime, next_run: datetime):
|
| 254 |
+
"""Update job schedule after execution"""
|
| 255 |
+
try:
|
| 256 |
+
session = get_session()
|
| 257 |
+
job = session.query(ScheduledMonitoring).filter(
|
| 258 |
+
ScheduledMonitoring.id == job_id
|
| 259 |
+
).first()
|
| 260 |
+
|
| 261 |
+
if job:
|
| 262 |
+
job.last_run = last_run
|
| 263 |
+
job.next_run = next_run
|
| 264 |
+
job.updated_at = datetime.utcnow()
|
| 265 |
+
session.commit()
|
| 266 |
+
|
| 267 |
+
session.close()
|
| 268 |
+
|
| 269 |
+
except Exception as e:
|
| 270 |
+
st.error(f"Error updating job schedule: {str(e)}")
|
| 271 |
+
if session:
|
| 272 |
+
session.rollback()
|
| 273 |
+
session.close()
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apscheduler
|
| 2 |
+
beautifulsoup4
|
| 3 |
+
duckduckgo-search
|
| 4 |
+
google-search-results
|
| 5 |
+
groq
|
| 6 |
+
networkx
|
| 7 |
+
pandas
|
| 8 |
+
plotly
|
| 9 |
+
psycopg2-binary
|
| 10 |
+
PyMySQL
|
| 11 |
+
requests
|
| 12 |
+
sqlalchemy
|
| 13 |
+
streamlit
|
| 14 |
+
trafilatura
|
| 15 |
+
python-dotenv
|
scheduler.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 2 |
+
from apscheduler.triggers.cron import CronTrigger
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from db_operations import get_scheduled_jobs, update_job_schedule, save_analysis_to_db
|
| 5 |
+
from brand_analyzer import BrandAnalyzer
|
| 6 |
+
from search_engines import multi_engine_search
|
| 7 |
+
from web_scraper import scrape_article_content
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
# Configure logging
|
| 11 |
+
logging.basicConfig(level=logging.INFO)
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class BrandMonitorScheduler:
|
| 15 |
+
"""Background scheduler for recurring brand monitoring"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.scheduler = BackgroundScheduler()
|
| 19 |
+
self.analyzer = BrandAnalyzer()
|
| 20 |
+
|
| 21 |
+
def start(self):
|
| 22 |
+
"""Start the scheduler"""
|
| 23 |
+
try:
|
| 24 |
+
if not self.scheduler.running:
|
| 25 |
+
self.scheduler.start()
|
| 26 |
+
logger.info("Brand monitoring scheduler started")
|
| 27 |
+
|
| 28 |
+
# Load and schedule all active jobs
|
| 29 |
+
self.load_scheduled_jobs()
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error(f"Error starting scheduler: {e}")
|
| 32 |
+
|
| 33 |
+
def stop(self):
|
| 34 |
+
"""Stop the scheduler"""
|
| 35 |
+
if self.scheduler.running:
|
| 36 |
+
self.scheduler.shutdown()
|
| 37 |
+
logger.info("Brand monitoring scheduler stopped")
|
| 38 |
+
|
| 39 |
+
def load_scheduled_jobs(self):
|
| 40 |
+
"""Load all active jobs from database and schedule them"""
|
| 41 |
+
jobs = get_scheduled_jobs(active_only=True)
|
| 42 |
+
|
| 43 |
+
for job in jobs:
|
| 44 |
+
self.schedule_job(job)
|
| 45 |
+
|
| 46 |
+
def schedule_job(self, job):
|
| 47 |
+
"""Schedule a single job based on its frequency"""
|
| 48 |
+
try:
|
| 49 |
+
# Determine cron trigger based on schedule type
|
| 50 |
+
if job.schedule_type == 'daily':
|
| 51 |
+
trigger = CronTrigger(hour=9, minute=0) # 9 AM daily
|
| 52 |
+
elif job.schedule_type == 'weekly':
|
| 53 |
+
trigger = CronTrigger(day_of_week='mon', hour=9, minute=0) # Monday 9 AM
|
| 54 |
+
elif job.schedule_type == 'monthly':
|
| 55 |
+
trigger = CronTrigger(day=1, hour=9, minute=0) # 1st of month 9 AM
|
| 56 |
+
else:
|
| 57 |
+
logger.warning(f"Unknown schedule type: {job.schedule_type}")
|
| 58 |
+
return
|
| 59 |
+
|
| 60 |
+
# Add job to scheduler
|
| 61 |
+
self.scheduler.add_job(
|
| 62 |
+
func=self.execute_monitoring_job,
|
| 63 |
+
trigger=trigger,
|
| 64 |
+
args=[job.id],
|
| 65 |
+
id=f"job_{job.id}",
|
| 66 |
+
replace_existing=True,
|
| 67 |
+
max_instances=1
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
logger.info(f"Scheduled job {job.id}: {job.search_query} - {job.schedule_type}")
|
| 71 |
+
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"Error scheduling job {job.id}: {e}")
|
| 74 |
+
|
| 75 |
+
def execute_monitoring_job(self, job_id):
|
| 76 |
+
"""Execute a scheduled monitoring job"""
|
| 77 |
+
try:
|
| 78 |
+
logger.info(f"Executing scheduled job {job_id}")
|
| 79 |
+
|
| 80 |
+
# Get job details from database
|
| 81 |
+
jobs = get_scheduled_jobs(active_only=True)
|
| 82 |
+
job = next((j for j in jobs if j.id == job_id), None)
|
| 83 |
+
|
| 84 |
+
if not job:
|
| 85 |
+
logger.warning(f"Job {job_id} not found or inactive")
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
# Parse job parameters
|
| 89 |
+
brand_names = [b.strip() for b in job.brand_names.split(',')]
|
| 90 |
+
search_engines = [e.strip() for e in job.search_engines.split(',')]
|
| 91 |
+
|
| 92 |
+
# Perform analysis for each brand
|
| 93 |
+
for brand_name in brand_names:
|
| 94 |
+
logger.info(f"Analyzing {brand_name}")
|
| 95 |
+
|
| 96 |
+
# Search
|
| 97 |
+
search_results = multi_engine_search(
|
| 98 |
+
f"{job.search_query} {brand_name}",
|
| 99 |
+
search_engines,
|
| 100 |
+
num_results=10
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
if not search_results:
|
| 104 |
+
logger.warning(f"No results for {brand_name}")
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
# Scrape
|
| 108 |
+
articles = []
|
| 109 |
+
for result in search_results:
|
| 110 |
+
article = scrape_article_content(result['url'])
|
| 111 |
+
article['source'] = result.get('source', 'unknown')
|
| 112 |
+
articles.append(article)
|
| 113 |
+
|
| 114 |
+
# Analyze
|
| 115 |
+
analysis_results = self.analyzer.batch_analyze_articles(
|
| 116 |
+
articles, brand_name, ""
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# Save to database
|
| 120 |
+
for engine in search_engines:
|
| 121 |
+
save_analysis_to_db(
|
| 122 |
+
job.search_query,
|
| 123 |
+
brand_name,
|
| 124 |
+
engine,
|
| 125 |
+
analysis_results
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
logger.info(f"Completed analysis for {brand_name}")
|
| 129 |
+
|
| 130 |
+
# Update job schedule
|
| 131 |
+
now = datetime.utcnow()
|
| 132 |
+
|
| 133 |
+
if job.schedule_type == 'daily':
|
| 134 |
+
next_run = now + timedelta(days=1)
|
| 135 |
+
elif job.schedule_type == 'weekly':
|
| 136 |
+
next_run = now + timedelta(weeks=1)
|
| 137 |
+
elif job.schedule_type == 'monthly':
|
| 138 |
+
next_run = now + timedelta(days=30)
|
| 139 |
+
else:
|
| 140 |
+
next_run = now + timedelta(weeks=1)
|
| 141 |
+
|
| 142 |
+
update_job_schedule(job_id, now, next_run)
|
| 143 |
+
logger.info(f"Job {job_id} completed. Next run: {next_run}")
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"Error executing job {job_id}: {e}")
|
| 147 |
+
|
| 148 |
+
def add_job_on_demand(self, job_id):
|
| 149 |
+
"""Add a specific job to the scheduler"""
|
| 150 |
+
jobs = get_scheduled_jobs(active_only=True)
|
| 151 |
+
job = next((j for j in jobs if j.id == job_id), None)
|
| 152 |
+
|
| 153 |
+
if job:
|
| 154 |
+
self.schedule_job(job)
|
| 155 |
+
|
| 156 |
+
def remove_job(self, job_id):
|
| 157 |
+
"""Remove a job from the scheduler"""
|
| 158 |
+
try:
|
| 159 |
+
self.scheduler.remove_job(f"job_{job_id}")
|
| 160 |
+
logger.info(f"Removed job {job_id} from scheduler")
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(f"Error removing job {job_id}: {e}")
|
| 163 |
+
|
| 164 |
+
# Global scheduler instance
|
| 165 |
+
_scheduler = None
|
| 166 |
+
|
| 167 |
+
def get_scheduler():
|
| 168 |
+
"""Get or create the global scheduler instance"""
|
| 169 |
+
global _scheduler
|
| 170 |
+
if _scheduler is None:
|
| 171 |
+
_scheduler = BrandMonitorScheduler()
|
| 172 |
+
_scheduler.start()
|
| 173 |
+
return _scheduler
|
| 174 |
+
|
| 175 |
+
def start_scheduler():
|
| 176 |
+
"""Start the global scheduler"""
|
| 177 |
+
scheduler = get_scheduler()
|
| 178 |
+
if not scheduler.scheduler.running:
|
| 179 |
+
scheduler.start()
|
| 180 |
+
|
| 181 |
+
def stop_scheduler():
|
| 182 |
+
"""Stop the global scheduler"""
|
| 183 |
+
global _scheduler
|
| 184 |
+
if _scheduler and _scheduler.scheduler.running:
|
| 185 |
+
_scheduler.stop()
|
search_engines.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
from serpapi import GoogleSearch
|
| 4 |
+
from typing import List, Dict
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
def search_google(query: str, num_results: int = 15) -> List[Dict]:
|
| 8 |
+
"""
|
| 9 |
+
Search Google using SerpAPI and return top URLs.
|
| 10 |
+
"""
|
| 11 |
+
api_key = os.getenv("SERPAPI_API_KEY", "")
|
| 12 |
+
if not api_key:
|
| 13 |
+
st.error("SERPAPI_API_KEY environment variable not found.")
|
| 14 |
+
return []
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
search = GoogleSearch({
|
| 18 |
+
"q": query,
|
| 19 |
+
"api_key": api_key,
|
| 20 |
+
"num": num_results,
|
| 21 |
+
"engine": "google"
|
| 22 |
+
})
|
| 23 |
+
results = search.get_dict()
|
| 24 |
+
|
| 25 |
+
urls = []
|
| 26 |
+
if "organic_results" in results:
|
| 27 |
+
for result in results["organic_results"]:
|
| 28 |
+
if "link" in result:
|
| 29 |
+
urls.append({
|
| 30 |
+
'url': result["link"],
|
| 31 |
+
'title': result.get("title", "No title"),
|
| 32 |
+
'snippet': result.get("snippet", "No snippet"),
|
| 33 |
+
'source': 'google'
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
return urls
|
| 37 |
+
|
| 38 |
+
except Exception as e:
|
| 39 |
+
st.error(f"Error searching Google: {str(e)}")
|
| 40 |
+
return []
|
| 41 |
+
|
| 42 |
+
def search_bing(query: str, num_results: int = 15) -> List[Dict]:
|
| 43 |
+
"""
|
| 44 |
+
Search Bing using Bing Search API and return top URLs.
|
| 45 |
+
Note: Requires BING_API_KEY environment variable
|
| 46 |
+
"""
|
| 47 |
+
api_key = os.getenv("BING_API_KEY", "")
|
| 48 |
+
if not api_key:
|
| 49 |
+
st.warning("BING_API_KEY not found. Skipping Bing search.")
|
| 50 |
+
return []
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
endpoint = "https://api.bing.microsoft.com/v7.0/search"
|
| 54 |
+
headers = {"Ocp-Apim-Subscription-Key": api_key}
|
| 55 |
+
params = {
|
| 56 |
+
"q": query,
|
| 57 |
+
"count": min(num_results, 50), # Bing max is 50
|
| 58 |
+
"textDecorations": False,
|
| 59 |
+
"textFormat": "HTML"
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
response = requests.get(endpoint, headers=headers, params=params, timeout=10)
|
| 63 |
+
response.raise_for_status()
|
| 64 |
+
data = response.json()
|
| 65 |
+
|
| 66 |
+
urls = []
|
| 67 |
+
if "webPages" in data and "value" in data["webPages"]:
|
| 68 |
+
for result in data["webPages"]["value"][:num_results]:
|
| 69 |
+
urls.append({
|
| 70 |
+
'url': result.get("url", ""),
|
| 71 |
+
'title': result.get("name", "No title"),
|
| 72 |
+
'snippet': result.get("snippet", "No snippet"),
|
| 73 |
+
'source': 'bing'
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
return urls
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
st.warning(f"Bing search error: {str(e)}")
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
def search_duckduckgo(query: str, num_results: int = 15) -> List[Dict]:
|
| 83 |
+
"""
|
| 84 |
+
Search DuckDuckGo using duckduckgo_search library (no API key needed)
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
from duckduckgo_search import DDGS
|
| 88 |
+
|
| 89 |
+
urls = []
|
| 90 |
+
with DDGS() as ddgs:
|
| 91 |
+
results = list(ddgs.text(query, max_results=num_results))
|
| 92 |
+
|
| 93 |
+
for result in results:
|
| 94 |
+
urls.append({
|
| 95 |
+
'url': result.get('link', ''),
|
| 96 |
+
'title': result.get('title', 'No title'),
|
| 97 |
+
'snippet': result.get('body', 'No snippet'),
|
| 98 |
+
'source': 'duckduckgo'
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
return urls
|
| 102 |
+
|
| 103 |
+
except ImportError:
|
| 104 |
+
st.warning("duckduckgo_search library not installed. Please run: pip install duckduckgo-search")
|
| 105 |
+
return []
|
| 106 |
+
except Exception as e:
|
| 107 |
+
st.warning(f"DuckDuckGo search error: {str(e)}")
|
| 108 |
+
return []
|
| 109 |
+
|
| 110 |
+
def multi_engine_search(query: str, engines: List[str] = None, num_results: int = 15) -> List[Dict]:
|
| 111 |
+
"""
|
| 112 |
+
Search across multiple search engines and combine results.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
query: Search query
|
| 116 |
+
engines: List of engine names ['google', 'bing', 'duckduckgo']
|
| 117 |
+
num_results: Number of results per engine
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Combined list of search results
|
| 121 |
+
"""
|
| 122 |
+
if engines is None:
|
| 123 |
+
engines = ['google']
|
| 124 |
+
|
| 125 |
+
all_results = []
|
| 126 |
+
|
| 127 |
+
for engine in engines:
|
| 128 |
+
if engine.lower() == 'google':
|
| 129 |
+
results = search_google(query, num_results)
|
| 130 |
+
all_results.extend(results)
|
| 131 |
+
elif engine.lower() == 'bing':
|
| 132 |
+
results = search_bing(query, num_results)
|
| 133 |
+
all_results.extend(results)
|
| 134 |
+
elif engine.lower() == 'duckduckgo':
|
| 135 |
+
results = search_duckduckgo(query, num_results)
|
| 136 |
+
all_results.extend(results)
|
| 137 |
+
|
| 138 |
+
# Remove duplicates based on URL
|
| 139 |
+
seen_urls = set()
|
| 140 |
+
unique_results = []
|
| 141 |
+
for result in all_results:
|
| 142 |
+
url = result.get('url', '')
|
| 143 |
+
if url and url not in seen_urls:
|
| 144 |
+
seen_urls.add(url)
|
| 145 |
+
unique_results.append(result)
|
| 146 |
+
|
| 147 |
+
return unique_results
|
| 148 |
+
|
| 149 |
+
def batch_analyze_brands(search_query: str, brand_names: List[str],
|
| 150 |
+
search_engines: List[str], num_results: int,
|
| 151 |
+
custom_prompt: str, analyzer, scraper) -> Dict[str, List]:
|
| 152 |
+
"""
|
| 153 |
+
Analyze multiple brands in batch across multiple search engines.
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
Dictionary with brand names as keys and analysis results as values
|
| 157 |
+
"""
|
| 158 |
+
batch_results = {}
|
| 159 |
+
|
| 160 |
+
for brand_name in brand_names:
|
| 161 |
+
st.write(f"### Analyzing brand: **{brand_name}**")
|
| 162 |
+
|
| 163 |
+
# Search across all engines
|
| 164 |
+
with st.spinner(f"🔍 Searching for {brand_name}..."):
|
| 165 |
+
search_results = multi_engine_search(
|
| 166 |
+
f"{search_query} {brand_name}",
|
| 167 |
+
search_engines,
|
| 168 |
+
num_results
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if not search_results:
|
| 172 |
+
st.warning(f"No search results found for {brand_name}")
|
| 173 |
+
batch_results[brand_name] = []
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
st.success(f"✅ Found {len(search_results)} URLs from {', '.join(search_engines)}")
|
| 177 |
+
|
| 178 |
+
# Scrape articles
|
| 179 |
+
with st.spinner(f"📄 Scraping articles for {brand_name}..."):
|
| 180 |
+
articles = []
|
| 181 |
+
for result in search_results:
|
| 182 |
+
article_content = scraper(result['url'])
|
| 183 |
+
article_content['source'] = result.get('source', 'unknown')
|
| 184 |
+
articles.append(article_content)
|
| 185 |
+
|
| 186 |
+
st.success(f"✅ Scraped {len(articles)} articles")
|
| 187 |
+
|
| 188 |
+
# Analyze with AI
|
| 189 |
+
with st.spinner(f"🤖 Analyzing {brand_name} mentions..."):
|
| 190 |
+
analysis_results = analyzer.batch_analyze_articles(
|
| 191 |
+
articles, brand_name, custom_prompt
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
batch_results[brand_name] = analysis_results
|
| 195 |
+
st.success(f"✅ Analysis complete for {brand_name}!")
|
| 196 |
+
|
| 197 |
+
return batch_results
|
web_scraper.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import trafilatura
|
| 2 |
+
import requests
|
| 3 |
+
from typing import Optional
|
| 4 |
+
import time
|
| 5 |
+
import streamlit as st
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_website_text_content(url: str) -> str:
|
| 9 |
+
"""
|
| 10 |
+
This function takes a url and returns the main text content of the website.
|
| 11 |
+
The text content is extracted using trafilatura and easier to understand.
|
| 12 |
+
The results is not directly readable, better to be summarized by LLM before consume
|
| 13 |
+
by the user.
|
| 14 |
+
"""
|
| 15 |
+
try:
|
| 16 |
+
# Send a request to the website
|
| 17 |
+
downloaded = trafilatura.fetch_url(url)
|
| 18 |
+
if downloaded:
|
| 19 |
+
text = trafilatura.extract(downloaded)
|
| 20 |
+
return text if text else ""
|
| 21 |
+
return ""
|
| 22 |
+
except Exception as e:
|
| 23 |
+
st.warning(f"Failed to scrape {url}: {str(e)}")
|
| 24 |
+
return ""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def scrape_article_content(url: str) -> dict:
|
| 28 |
+
"""
|
| 29 |
+
Scrape article content including headline and main content.
|
| 30 |
+
Returns a dictionary with title, content, and url.
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
# Add a small delay to be respectful to servers
|
| 34 |
+
time.sleep(0.5)
|
| 35 |
+
|
| 36 |
+
# First try to get basic page info
|
| 37 |
+
headers = {
|
| 38 |
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
response = requests.get(url, headers=headers, timeout=10)
|
| 42 |
+
response.raise_for_status()
|
| 43 |
+
|
| 44 |
+
# Extract content using trafilatura
|
| 45 |
+
downloaded = trafilatura.fetch_url(url)
|
| 46 |
+
if downloaded:
|
| 47 |
+
# Extract main content
|
| 48 |
+
content = trafilatura.extract(downloaded)
|
| 49 |
+
# Extract metadata including title
|
| 50 |
+
metadata = trafilatura.extract_metadata(downloaded)
|
| 51 |
+
|
| 52 |
+
title = ""
|
| 53 |
+
if metadata and hasattr(metadata, 'title') and metadata.title:
|
| 54 |
+
title = metadata.title
|
| 55 |
+
else:
|
| 56 |
+
# Fallback: try to extract title from HTML
|
| 57 |
+
from bs4 import BeautifulSoup
|
| 58 |
+
soup = BeautifulSoup(downloaded, 'html.parser')
|
| 59 |
+
title_tag = soup.find('title')
|
| 60 |
+
if title_tag:
|
| 61 |
+
title = title_tag.get_text().strip()
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
'url': url,
|
| 65 |
+
'title': title or "No title found",
|
| 66 |
+
'content': content or "No content extracted"
|
| 67 |
+
}
|
| 68 |
+
else:
|
| 69 |
+
return {
|
| 70 |
+
'url': url,
|
| 71 |
+
'title': "Failed to download",
|
| 72 |
+
'content': "Could not retrieve content"
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
except requests.RequestException as e:
|
| 76 |
+
return {
|
| 77 |
+
'url': url,
|
| 78 |
+
'title': "Network error",
|
| 79 |
+
'content': f"Failed to fetch due to network error: {str(e)}"
|
| 80 |
+
}
|
| 81 |
+
except Exception as e:
|
| 82 |
+
return {
|
| 83 |
+
'url': url,
|
| 84 |
+
'title': "Scraping error",
|
| 85 |
+
'content': f"Failed to scrape content: {str(e)}"
|
| 86 |
+
}
|