import re import requests import matplotlib.pyplot as plt import numpy as np from bs4 import BeautifulSoup from transformers import pipeline import streamlit as st import torch import spacy from wordcloud import WordCloud import pandas as pd from collections import defaultdict # --- Streamlit Page Config (MUST BE FIRST) --- st.set_page_config( page_title="National Park Review Analyzer", page_icon="🏞️", layout="wide" ) # --- NLP Setup --- @st.cache_resource def load_nlp_models(): # Try to load spacy model, download if not available try: nlp = spacy.load("en_core_web_sm") except OSError: import subprocess subprocess.run([sys.executable, "-m", "spacy", "download", "en_core_web_sm"]) nlp = spacy.load("en_core_web_sm") sentiment_analyzer = pipeline( "sentiment-analysis", model="distilbert-base-uncased-finetuned-sst-2-english", device=0 if torch.cuda.is_available() else -1 ) return nlp, sentiment_analyzer try: nlp, sentiment_analyzer = load_nlp_models() except Exception as e: st.error(f"Error loading NLP models: {str(e)}") nlp, sentiment_analyzer = None, None # --- Constants --- ALLOWED_DOMAINS = ['recreation.gov', 'nps.gov', 'nationalparks.org'] # Define categories for analysis CATEGORIES = { 'hiking': ['hiking', 'trail', 'hike', 'trek', 'trekking', 'paths', 'walk', 'walking'], 'fees': ['fee', 'price', 'cost', 'payment', 'dollar', 'money', 'expensive', 'cheap', 'affordable'], 'equipment': ['equipment', 'gear', 'supplies', 'tent', 'backpack', 'boots', 'poles', 'shoes'], 'water': ['water', 'lake', 'river', 'stream', 'pond', 'waterfall', 'creek', 'swimming'], 'facilities': ['facilities', 'restroom', 'bathroom', 'shower', 'toilet', 'visitor center', 'parking'] } class RecreationGovScraper: def __init__(self): self.session = requests.Session() self.session.headers.update({ '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', 'Accept': 'text/html,application/xhtml+xml,application/xml', 'Accept-Language': 'en-US,en;q=0.9' }) self.session.mount('https://', requests.adapters.HTTPAdapter(max_retries=3)) def validate_url(self, url): return any(domain in url for domain in ALLOWED_DOMAINS) def extract_content(self, url): try: if not self.validate_url(url): return {'error': 'Domain not allowed. Please use a URL from recreation.gov, nps.gov, or nationalparks.org'} response = self.session.get(url, timeout=15) response.raise_for_status() soup = BeautifulSoup(response.content, 'html.parser') # Extract reviews specifically for Recreation.gov reviews = [] review_elements = soup.select('.rec-reviews-card') if not review_elements and 'recreation.gov' in url: # Try alternative selectors for Recreation.gov review_elements = soup.select('.review-content') or soup.select('[data-component="review"]') for review_elem in review_elements: review_text = review_elem.get_text(strip=True) if review_text: reviews.append(review_text) # If no reviews found through specific selectors, fallback to paragraphs if not reviews: all_text = ' '.join(p.get_text(strip=True) for p in soup.find_all('p') if len(p.get_text(strip=True)) > 20) reviews = [all_text] return { 'reviews': reviews, 'fees': self._extract_fees(soup), 'facilities': self._extract_facilities(soup), 'activities': self._extract_activities(soup), 'title': self._extract_title(soup) } except Exception as e: return {'error': str(e)} def _extract_title(self, soup): title = soup.find('h1') if title: return title.get_text(strip=True) return "Unknown Park" def _extract_fees(self, soup): fees = [] fee_patterns = [ r'\$\d+\.?\d*(?:\s*-\s*\$\d+\.?\d*)?(?:\s*per\s*(?:person|vehicle|night|day|site|entrance))?', r'(?:Fee|Price|Cost):\s*\$\d+\.?\d*' ] for pattern in fee_patterns: fees.extend(re.findall(pattern, soup.text)) return fees[:5] # Return up to 5 fee matches def _extract_facilities(self, soup): facilities = [] facility_keywords = ['restroom', 'shower', 'campsite', 'picnic', 'visitor center', 'parking', 'trailhead', 'lodging', 'camping', 'cabin'] for keyword in facility_keywords: if keyword.lower() in soup.text.lower(): facilities.append(keyword) # Also look for lists that might contain facilities for list_item in soup.find_all('li'): item_text = list_item.get_text(strip=True).lower() if any(keyword in item_text for keyword in facility_keywords): facilities.append(item_text[:50] + "..." if len(item_text) > 50 else item_text) return list(set(facilities))[:5] # Deduplicate and limit to 5 def _extract_activities(self, soup): activities = [] activity_keywords = ['hiking', 'swimming', 'fishing', 'boating', 'camping', 'wildlife viewing', 'biking', 'kayaking', 'canoeing', 'photography'] for keyword in activity_keywords: if keyword.lower() in soup.text.lower(): activities.append(keyword) return list(set(activities)) # Deduplicate def map_sentiment_label(sentiment): """Maps sentiment labels to standardized format""" if sentiment == 'POSITIVE': return 'positive' elif sentiment == 'NEGATIVE': return 'negative' else: return 'neutral' def categorize_text(text): """Identify which categories the text belongs to""" text_lower = text.lower() categories_found = [] for category, keywords in CATEGORIES.items(): if any(keyword in text_lower for keyword in keywords): categories_found.append(category) # If no categories found, mark as 'general' if not categories_found: categories_found.append('general') return categories_found def analyze_content(url): scraper = RecreationGovScraper() data = scraper.extract_content(url) if 'error' in data: return None, f"Error: {data['error']}" if not data['reviews']: return None, "Error: No review content found on the page." # Prepare for analysis all_sentiments = [] category_sentiments = defaultdict(list) sentences = [] # Process each review for review in data['reviews']: # Split review into sentences for more granular analysis review_sentences = re.split(r'(?<=[.!?])\s+', review) for sentence in review_sentences: if len(sentence.strip()) < 10: # Skip very short sentences continue sentences.append(sentence) # Determine categories this sentence belongs to sentence_categories = categorize_text(sentence) # Break long sentences into chunks for the sentiment analyzer text_chunks = [sentence[i:i+512] for i in range(0, len(sentence), 512)] try: for chunk in text_chunks: sentiment_result = sentiment_analyzer(chunk)[0] sentiment_label = sentiment_result['label'] confidence = sentiment_result['score'] # Add neutrality for mid-range confidence scores if 0.55 <= confidence <= 0.70: sentiment_label = 'NEUTRAL' confidence = 0.5 + (confidence - 0.55) * 0.5 # Store the sentiment standardized_label = map_sentiment_label(sentiment_label) sentiment_entry = { 'text': chunk, 'sentiment': standardized_label, 'confidence': confidence, 'categories': sentence_categories } all_sentiments.append(sentiment_entry) # Categorize by topics for category in sentence_categories: category_sentiments[category].append(sentiment_entry) except Exception as e: return None, f"Sentiment analysis failed: {str(e)}" if not all_sentiments: return None, "Error: Could not perform sentiment analysis." # Create overall sentiment distribution sentiment_df = pd.DataFrame([{'sentiment': s['sentiment']} for s in all_sentiments]) sentiment_counts = sentiment_df['sentiment'].value_counts() # Add missing sentiment categories if any are absent for sentiment in ['positive', 'negative', 'neutral']: if sentiment not in sentiment_counts: sentiment_counts[sentiment] = 0 # Create category sentiment distribution category_data = [] for category, sentiments in category_sentiments.items(): # Count sentiment by category cat_sentiment_counts = defaultdict(int) for s in sentiments: cat_sentiment_counts[s['sentiment']] += 1 # Ensure all sentiments are represented for sentiment in ['positive', 'negative', 'neutral']: if sentiment not in cat_sentiment_counts: cat_sentiment_counts[sentiment] = 0 # Add to dataset for plotting for sentiment, count in cat_sentiment_counts.items(): category_data.append({ 'category': category, 'sentiment': sentiment, 'count': count }) category_df = pd.DataFrame(category_data) # Create visualizations # 1. Overall Sentiment Distribution plt.figure(figsize=(10, 6)) colors = {'positive': 'green', 'neutral': 'gold', 'negative': 'red'} overall_sentiment_fig = plt.figure(figsize=(10, 6)) ax = overall_sentiment_fig.add_subplot(111) bars = ax.bar(sentiment_counts.index, sentiment_counts.values, color=[colors[s] for s in sentiment_counts.index]) ax.set_title('Overall Sentiment Distribution', fontsize=16) ax.set_ylabel('Number of Reviews', fontsize=12) ax.grid(axis='y', linestyle='--', alpha=0.7) # Add counts as text on bars for bar in bars: height = bar.get_height() ax.text(bar.get_x() + bar.get_width()/2., height + 0.1, f'{int(height)}', ha='center', va='bottom') plt.tight_layout() # 2. Sentiment Distribution by Category if category_sentiments: # Pivot the data for easier plotting pivot_df = category_df.pivot_table(index='category', columns='sentiment', values='count', fill_value=0) cat_fig = plt.figure(figsize=(12, 7)) ax = cat_fig.add_subplot(111) # Set width of bars bar_width = 0.25 index = np.arange(len(pivot_df.index)) # Plot bars for each sentiment for i, sentiment in enumerate(['positive', 'neutral', 'negative']): if sentiment in pivot_df.columns: bars = ax.bar(index + i*bar_width, pivot_df[sentiment], bar_width, label=sentiment, color=colors[sentiment]) # Add count labels on bars for bar in bars: height = bar.get_height() if height > 0: ax.text(bar.get_x() + bar.get_width()/2., height + 0.1, f'{int(height)}', ha='center', va='bottom', fontsize=9) # Set plot attributes ax.set_title('Sentiment Distribution by Category', fontsize=16) ax.set_ylabel('Number of Mentions', fontsize=12) ax.set_xticks(index + bar_width) ax.set_xticklabels(pivot_df.index, rotation=30, ha='right') ax.legend(title='Sentiment') ax.grid(axis='y', linestyle='--', alpha=0.7) plt.tight_layout() else: cat_fig = None # 3. Sentiment Confidence Distribution conf_fig = plt.figure(figsize=(10, 6)) ax = conf_fig.add_subplot(111) # Get confidence values for each sentiment pos_conf = [s['confidence'] for s in all_sentiments if s['sentiment'] == 'positive'] neu_conf = [s['confidence'] for s in all_sentiments if s['sentiment'] == 'neutral'] neg_conf = [s['confidence'] for s in all_sentiments if s['sentiment'] == 'negative'] # Create histogram if pos_conf: ax.hist(pos_conf, bins=10, alpha=0.7, label='Positive', color='green') if neu_conf: ax.hist(neu_conf, bins=10, alpha=0.7, label='Neutral', color='gold') if neg_conf: ax.hist(neg_conf, bins=10, alpha=0.7, label='Negative', color='red') ax.set_title('Sentiment Confidence Distribution', fontsize=16) ax.set_xlabel('Confidence Score', fontsize=12) ax.set_ylabel('Frequency', fontsize=12) ax.legend() ax.grid(alpha=0.3) plt.tight_layout() # 4. Word Cloud combined_text = ' '.join(data['reviews']) if combined_text: try: wordcloud = WordCloud(width=800, height=400, background_color='white', colormap='viridis', max_words=100, contour_width=1).generate(combined_text) wordcloud_fig = plt.figure(figsize=(10, 5)) ax = wordcloud_fig.add_subplot(111) ax.imshow(wordcloud, interpolation='bilinear') ax.set_title('Most Common Words in Reviews', fontsize=16) ax.axis('off') plt.tight_layout() except Exception as e: wordcloud_fig = None else: wordcloud_fig = None # 5. Top Positive and Negative Sentences # Sort by confidence positive_sentences = sorted( [s for s in all_sentiments if s['sentiment'] == 'positive'], key=lambda x: x['confidence'], reverse=True ) negative_sentences = sorted( [s for s in all_sentiments if s['sentiment'] == 'negative'], key=lambda x: x['confidence'], reverse=True ) # Prepare report data positive_count = sum(1 for s in all_sentiments if s['sentiment'] == 'positive') negative_count = sum(1 for s in all_sentiments if s['sentiment'] == 'negative') neutral_count = sum(1 for s in all_sentiments if s['sentiment'] == 'neutral') total_count = len(all_sentiments) # Calculate percentages if total_count > 0: positive_pct = (positive_count / total_count) * 100 negative_pct = (negative_count / total_count) * 100 neutral_pct = (neutral_count / total_count) * 100 else: positive_pct = negative_pct = neutral_pct = 0 report = { 'title': data['title'], 'url': url, 'positive_count': positive_count, 'negative_count': negative_count, 'neutral_count': neutral_count, 'total_count': total_count, 'positive_pct': positive_pct, 'negative_pct': negative_pct, 'neutral_pct': neutral_pct, 'fees': data['fees'], 'facilities': data['facilities'], 'activities': data['activities'], 'overall_sentiment_fig': overall_sentiment_fig, 'category_sentiment_fig': cat_fig, 'confidence_fig': conf_fig, 'wordcloud': wordcloud_fig, 'top_positive': positive_sentences[:5] if positive_sentences else [], 'top_negative': negative_sentences[:5] if negative_sentences else [], 'category_sentiments': category_sentiments } return report, None # Streamlit Interface st.title("🏞️ National Park Review Analyzer") st.write(""" This tool analyzes reviews and information from national park websites. Enter a URL from Recreation.gov, NPS.gov, or NationalParks.org to get started. """) url_input = st.text_input( "Enter National Park URL", placeholder="https://www.recreation.gov/gateways/2584" ) if st.button("Analyze", type="primary"): if not url_input: st.error("Please enter a URL to analyze") else: with st.spinner("Analyzing... This may take a minute"): report, error = analyze_content(url_input) if error: st.error(error) elif report: # Display report st.header(f"Analysis Report: {report['title']}") # Overall metrics st.subheader("Overall Sentiment") cols = st.columns(3) with cols[0]: st.metric("Positive", f"{report['positive_count']} ({report['positive_pct']:.1f}%)") with cols[1]: st.metric("Neutral", f"{report['neutral_count']} ({report['neutral_pct']:.1f}%)") with cols[2]: st.metric("Negative", f"{report['negative_count']} ({report['negative_pct']:.1f}%)") # Display overall sentiment distribution st.pyplot(report['overall_sentiment_fig']) # Display category sentiment distribution if available if report['category_sentiment_fig']: st.subheader("Sentiment by Category") st.pyplot(report['category_sentiment_fig']) # Show detailed category breakdown st.subheader("Category Details") for category in CATEGORIES.keys(): if category in report['category_sentiments']: with st.expander(f"{category.title()} - {len(report['category_sentiments'][category])} mentions"): cat_sentiments = report['category_sentiments'][category] pos = sum(1 for s in cat_sentiments if s['sentiment'] == 'positive') neg = sum(1 for s in cat_sentiments if s['sentiment'] == 'negative') neu = sum(1 for s in cat_sentiments if s['sentiment'] == 'neutral') st.write(f"👍 Positive: {pos} ({pos/len(cat_sentiments)*100:.1f}%)") st.write(f"👎 Negative: {neg} ({neg/len(cat_sentiments)*100:.1f}%)") st.write(f"😐 Neutral: {neu} ({neu/len(cat_sentiments)*100:.1f}%)") # Show top sentence for this category if cat_sentiments: top_positive = next((s for s in cat_sentiments if s['sentiment'] == 'positive'), None) top_negative = next((s for s in cat_sentiments if s['sentiment'] == 'negative'), None) if top_positive: st.write("**Sample positive mention:**") st.write(f"*\"{top_positive['text']}\"*") if top_negative: st.write("**Sample negative mention:**") st.write(f"*\"{top_negative['text']}\"*") else: with st.expander(f"{category.title()} - 0 mentions"): st.write("No mentions found for this category.") # Display confidence distribution st.subheader("Sentiment Confidence") st.pyplot(report['confidence_fig']) # Display word cloud if report['wordcloud']: st.subheader("Word Cloud") st.pyplot(report['wordcloud']) # Display top positive and negative sentences col1, col2 = st.columns(2) with col1: st.subheader("Top Positive Mentions") if report['top_positive']: for i, sentence in enumerate(report['top_positive'], 1): st.write(f"**{i}.** *\"{sentence['text']}\"* (Confidence: {sentence['confidence']:.2f})") else: st.write("No positive mentions found.") with col2: st.subheader("Top Negative Mentions") if report['top_negative']: for i, sentence in enumerate(report['top_negative'], 1): st.write(f"**{i}.** *\"{sentence['text']}\"* (Confidence: {sentence['confidence']:.2f})") else: st.write("No negative mentions found.") # Display extracted information st.subheader("Park Information") if report['fees']: with st.expander("Fees Mentioned"): for fee in report['fees']: st.write(f"- {fee}") if report['facilities']: with st.expander("Facilities"): for facility in report['facilities']: st.write(f"- {facility}") if report['activities']: with st.expander("Activities"): for activity in report['activities']: st.write(f"- {activity}") st.sidebar.header("About") st.sidebar.write(""" This tool uses natural language processing to analyze reviews and content from national park websites. It extracts information about fees, facilities, and visitor sentiments. """) st.sidebar.subheader("Categories Analyzed") for category in CATEGORIES: st.sidebar.write(f"- {category.title()}") st.sidebar.subheader("Supported Websites") for domain in ALLOWED_DOMAINS: st.sidebar.write(f"- {domain}")