Spaces:
Runtime error
Runtime error
| """ | |
| app.py β Streamlit Web Interface for Media Profiler | |
| Deploy to HuggingFace Spaces with: sdk: streamlit | |
| Usage: | |
| streamlit run app.py | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import time | |
| from datetime import datetime, timezone | |
| import streamlit as st | |
| from urllib.parse import urlparse | |
| from scraper import MediaScraper | |
| from research import MediaProfiler | |
| from storage import StorageManager | |
| from report_generator import ReportGenerator | |
| # --------------------------------------------------------------------------- | |
| # Page config | |
| # --------------------------------------------------------------------------- | |
| st.set_page_config( | |
| page_title="Media Profiler", | |
| page_icon="π°", | |
| layout="wide", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # In-memory log capture for debug trace | |
| # --------------------------------------------------------------------------- | |
| class ListHandler(logging.Handler): | |
| """Captures log records into a list for JSON serialization.""" | |
| def __init__(self): | |
| super().__init__() | |
| self.records: list[dict] = [] | |
| def emit(self, record): | |
| self.records.append({ | |
| "time": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(), | |
| "logger": record.name, | |
| "level": record.levelname, | |
| "message": record.getMessage(), | |
| }) | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def extract_domain(url: str) -> str: | |
| parsed = urlparse(url if url.startswith("http") else f"https://{url}") | |
| domain = parsed.netloc or parsed.path | |
| return domain.replace("www.", "").split("/")[0].lower() | |
| def load_cached_reports() -> list[dict]: | |
| """Load all previously analyzed reports from disk.""" | |
| reports_dir = "reports" | |
| cached = [] | |
| if not os.path.isdir(reports_dir): | |
| return cached | |
| for domain_dir in sorted(os.listdir(reports_dir)): | |
| data_path = os.path.join(reports_dir, domain_dir, "data.json") | |
| if os.path.isfile(data_path): | |
| try: | |
| with open(data_path) as f: | |
| d = json.load(f) | |
| cached.append(d) | |
| except Exception: | |
| pass | |
| return cached | |
| def run_analysis(url: str, force_refresh: bool = False, model: str = None): | |
| """Run the full pipeline with progress updates and debug trace capture.""" | |
| domain = extract_domain(url) | |
| storage = StorageManager() | |
| now_iso = datetime.now(timezone.utc).isoformat() | |
| # Check cache | |
| if not force_refresh and storage.exists(domain): | |
| st.toast("Loaded from cache", icon="β ") | |
| report_text = storage.load_report_text(domain) | |
| data = storage.load_data(domain) | |
| data_dict = data.model_dump() if data else {} | |
| debug_trace = { | |
| "meta": {"url": url, "domain": domain, "cached": True, "timestamp": now_iso}, | |
| "steps": [], | |
| "logs": [], | |
| "report_data": data_dict, | |
| "report_text": report_text or "", | |
| } | |
| return report_text, data_dict, debug_trace | |
| # Set up in-memory log capture | |
| list_handler = ListHandler() | |
| list_handler.setLevel(logging.DEBUG) | |
| root_logger = logging.getLogger() | |
| original_level = root_logger.level | |
| root_logger.setLevel(logging.DEBUG) | |
| root_logger.addHandler(list_handler) | |
| steps = [] | |
| run_t0 = time.time() | |
| report_text = None | |
| report_data_dict = None | |
| try: | |
| progress = st.progress(0, text="Starting analysis...") | |
| # 1. Scrape | |
| progress.progress(5, text="Scraping articles from homepage...") | |
| t0 = time.time() | |
| scraper = MediaScraper(url, max_articles=15) | |
| articles_obj = scraper.scrape_feed() | |
| if not articles_obj: | |
| steps.append({"name": "scrape", "started_at": now_iso, | |
| "duration_seconds": round(time.time() - t0, 2), | |
| "status": "error", "details": {"error": "No articles found"}}) | |
| st.error("No articles found. The site may be blocking requests.") | |
| return None, None, None | |
| articles_data = [{"title": a.title, "text": a.text, "url": a.url} for a in articles_obj] | |
| steps.append({"name": "scrape", | |
| "started_at": datetime.fromtimestamp(t0, tz=timezone.utc).isoformat(), | |
| "duration_seconds": round(time.time() - t0, 2), | |
| "status": "success", | |
| "details": {"articles_found": len(articles_data)}}) | |
| progress.progress(20, text=f"Scraped {len(articles_data)} articles") | |
| # 2. Profile | |
| progress.progress(25, text="Resolving outlet name...") | |
| t0 = time.time() | |
| profiler_kwargs = {} | |
| if model: | |
| profiler_kwargs["model"] = model | |
| profiler = MediaProfiler(**profiler_kwargs) | |
| researcher = profiler.researcher | |
| prof_domain = profiler._extract_domain(url) | |
| outlet_name = researcher.resolve_outlet_name(url, domain=prof_domain) | |
| progress.progress(30, text=f"Analyzing: {outlet_name}") | |
| progress.progress(35, text="Analyzing editorial bias...") | |
| report_data = profiler.profile(url, articles_data, outlet_name=outlet_name) | |
| steps.append({"name": "profile", | |
| "started_at": datetime.fromtimestamp(t0, tz=timezone.utc).isoformat(), | |
| "duration_seconds": round(time.time() - t0, 2), | |
| "status": "success", | |
| "details": {"outlet_name": outlet_name, | |
| "model": model or "gpt-5-mini-2025-08-07"}}) | |
| progress.progress(75, text="Analysis complete") | |
| # 3. Generate report | |
| progress.progress(80, text="Generating narrative report...") | |
| t0 = time.time() | |
| generator_kwargs = {} | |
| if model: | |
| generator_kwargs["model"] = model | |
| generator = ReportGenerator(**generator_kwargs) | |
| report_text = generator.generate(report_data) | |
| steps.append({"name": "generate_report", | |
| "started_at": datetime.fromtimestamp(t0, tz=timezone.utc).isoformat(), | |
| "duration_seconds": round(time.time() - t0, 2), | |
| "status": "success", | |
| "details": {"model": model or "gpt-5-mini-2025-08-07"}}) | |
| progress.progress(90, text="Saving results...") | |
| # 4. Save | |
| t0 = time.time() | |
| storage.save(domain, report_data, report_text) | |
| steps.append({"name": "save", | |
| "started_at": datetime.fromtimestamp(t0, tz=timezone.utc).isoformat(), | |
| "duration_seconds": round(time.time() - t0, 2), | |
| "status": "success"}) | |
| progress.progress(100, text="Done!") | |
| report_data_dict = report_data.model_dump() | |
| except Exception as e: | |
| steps.append({"name": "error", "started_at": now_iso, | |
| "duration_seconds": round(time.time() - run_t0, 2), | |
| "status": "error", "details": {"error": str(e)}}) | |
| raise | |
| finally: | |
| root_logger.removeHandler(list_handler) | |
| root_logger.setLevel(original_level) | |
| list_handler.close() | |
| debug_trace = { | |
| "meta": { | |
| "url": url, | |
| "domain": domain, | |
| "model": model or "default", | |
| "timestamp": now_iso, | |
| "total_duration_seconds": round(time.time() - run_t0, 2), | |
| "cached": False, | |
| }, | |
| "steps": steps, | |
| "logs": list_handler.records, | |
| "report_data": report_data_dict, | |
| "report_text": report_text or "", | |
| } | |
| return report_text, report_data_dict, debug_trace | |
| # --------------------------------------------------------------------------- | |
| # UI | |
| # --------------------------------------------------------------------------- | |
| st.title("π° Media Profiler") | |
| st.caption("Analyze news outlets for political bias, factual reliability, and credibility β MBFC methodology") | |
| # --- Sidebar: input + cached reports --- | |
| with st.sidebar: | |
| st.header("Analyze a Site") | |
| url_input = st.text_input("News site URL", placeholder="https://www.bbc.com") | |
| force_refresh = st.checkbox("Force re-analysis (ignore cache)") | |
| analyze_btn = st.button("Analyze", type="primary", use_container_width=True) | |
| st.divider() | |
| st.header("Previous Reports") | |
| cached = load_cached_reports() | |
| if cached: | |
| for r in cached: | |
| domain = r.get("target_domain", "?") | |
| name = r.get("outlet_name", domain) | |
| bias = r.get("bias_label", "β") | |
| if st.button(f"{name} β {bias}", key=f"cached_{domain}", use_container_width=True): | |
| st.session_state["view_domain"] = domain | |
| else: | |
| st.caption("No reports yet. Analyze a site to get started.") | |
| st.divider() | |
| st.caption("**How it works:**") | |
| st.caption("1. Scrapes articles (prioritizes hard news)") | |
| st.caption("2. 7 LLM analyzers evaluate bias, sourcing, fact-checks") | |
| st.caption("3. Researches history & ownership via about page + web") | |
| st.caption("4. Generates credibility report") | |
| # --- Main area --- | |
| # Handle analyze button | |
| if analyze_btn and url_input: | |
| if not url_input.startswith("http"): | |
| url_input = "https://" + url_input | |
| with st.spinner("Running analysis..."): | |
| report_text, report_data, debug_trace = run_analysis(url_input, force_refresh) | |
| if report_text: | |
| st.session_state["report_text"] = report_text | |
| st.session_state["report_data"] = report_data | |
| st.session_state["debug_trace"] = debug_trace | |
| st.session_state.pop("view_domain", None) | |
| # Handle cached report click | |
| if "view_domain" in st.session_state: | |
| domain = st.session_state["view_domain"] | |
| storage = StorageManager() | |
| report_text = storage.load_report_text(domain) | |
| data = storage.load_data(domain) | |
| if report_text: | |
| st.session_state["report_text"] = report_text | |
| st.session_state["report_data"] = data.model_dump() if data else {} | |
| st.session_state["debug_trace"] = None | |
| st.session_state.pop("view_domain", None) | |
| # Display report if available | |
| if "report_data" in st.session_state and st.session_state.get("report_data"): | |
| rd = st.session_state["report_data"] | |
| rt = st.session_state.get("report_text", "") | |
| # --- Quick stats --- | |
| st.subheader(rd.get("outlet_name", "Unknown")) | |
| st.caption(f'{rd.get("target_url", "")} β Analyzed: {rd.get("analysis_date", "β")} β Articles: {rd.get("articles_analyzed", "β")}') | |
| col1, col2, col3, col4, col5 = st.columns(5) | |
| with col1: | |
| st.metric("Bias Rating", rd.get("bias_label", "β"), f'Score: {rd.get("bias_score", "β")}') | |
| with col2: | |
| st.metric("Factual Reporting", rd.get("factuality_label", "β"), f'Score: {rd.get("factuality_score", "β")}') | |
| with col3: | |
| cred_score = rd.get("credibility_score", 0) | |
| st.metric("Credibility", rd.get("credibility_label", "β"), f"Score: {cred_score:.1f}/10" if isinstance(cred_score, (int, float)) else "β") | |
| with col4: | |
| st.metric("Media Type", rd.get("media_type", "β"), f'Traffic: {rd.get("traffic_tier", "β")}') | |
| with col5: | |
| freedom_label = rd.get("freedom_label", "β") | |
| freedom_score = rd.get("freedom_score") | |
| freedom_rank = rd.get("freedom_rank") | |
| freedom_delta = f'Score: {freedom_score}' + (f' | Rank: #{freedom_rank}' if freedom_rank else '') if freedom_score else "β" | |
| st.metric("Country Freedom", freedom_label, freedom_delta) | |
| # --- Tabs --- | |
| tab_report, tab_bias, tab_facts, tab_data, tab_debug = st.tabs(["π Report", "βοΈ Bias Detail", "β Fact Checks", "π Raw Data", "π Debug Log"]) | |
| with tab_report: | |
| st.markdown(rt) | |
| with tab_bias: | |
| eb = rd.get("editorial_bias_result") | |
| if eb: | |
| st.markdown(f"**Overall Bias:** {eb.get('overall_bias', 'β')} (score: {eb.get('bias_score', 'β')})") | |
| st.markdown(f"**MBFC Label:** {eb.get('mbfc_label', 'β')}") | |
| lang = eb.get("uses_loaded_language", False) | |
| st.markdown(f"**Loaded Language:** {'Yes' if lang else 'No'}") | |
| if lang and eb.get("loaded_language_examples"): | |
| for ex in eb["loaded_language_examples"]: | |
| st.markdown(f'- *"{ex}"*') | |
| # Build article URL lookup for clickable source links | |
| article_urls = {} | |
| for a in rd.get("articles_index", []): | |
| key = f"Article {a.get('number', '')}" | |
| article_urls[key] = a.get("url", "") | |
| positions = eb.get("policy_positions", []) | |
| if positions: | |
| st.markdown("### Policy Positions") | |
| for pp in positions: | |
| domain_name = pp.get("domain", "β") | |
| leaning = pp.get("leaning", "β") | |
| indicators = pp.get("indicators", []) | |
| source_articles = pp.get("source_articles", []) | |
| with st.expander(f"**{domain_name}** β {leaning}"): | |
| for ind in indicators: | |
| st.markdown(f"- {ind}") | |
| if source_articles: | |
| # Render source articles as clickable links | |
| linked_sources = [] | |
| for sa in source_articles: | |
| # Parse "Article N: Title" format | |
| match = re.match(r"(Article \d+)", sa) | |
| if match: | |
| art_key = match.group(1) | |
| url = article_urls.get(art_key, "") | |
| if url: | |
| linked_sources.append(f"[{sa}]({url})") | |
| else: | |
| linked_sources.append(sa) | |
| else: | |
| linked_sources.append(sa) | |
| st.caption("**Sources:** " + " | ".join(linked_sources)) | |
| ideology = eb.get("ideology_summary", "") | |
| economy = eb.get("economy_summary", "") | |
| if ideology or economy: | |
| st.markdown("### Ideology & Economy") | |
| if ideology: | |
| st.markdown(f"**Ideology:** {ideology}") | |
| if economy: | |
| st.markdown(f"**Economy:** {economy}") | |
| st.markdown("### Reasoning") | |
| st.info(eb.get("reasoning", "β")) | |
| else: | |
| st.warning("No editorial bias data available.") | |
| with tab_facts: | |
| fc = rd.get("fact_check_result") | |
| if fc: | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.metric("Total Checks", fc.get("total_checks_count", 0)) | |
| with c2: | |
| st.metric("Failed Checks", fc.get("failed_checks_count", 0)) | |
| with c3: | |
| st.metric("Score", f'{fc.get("score", "β")}/10') | |
| findings = fc.get("findings", []) | |
| if findings: | |
| st.markdown("### Findings") | |
| for f in findings: | |
| verdict = f.get("verdict", "β") | |
| claim = f.get("claim_summary", f.get("claim", "β")) | |
| source = f.get("source_site", "β") | |
| url = f.get("url", "") | |
| st.markdown(f"- **[{verdict}]** {claim} β *{source}*" + (f" [link]({url})" if url else "")) | |
| else: | |
| st.success("No failed fact checks found in IFCN-approved fact-checkers.") | |
| else: | |
| st.warning("No fact check data available.") | |
| with tab_data: | |
| st.json(rd) | |
| with tab_debug: | |
| trace = st.session_state.get("debug_trace") | |
| if trace and trace.get("steps"): | |
| meta = trace["meta"] | |
| st.markdown( | |
| f"**Model:** `{meta.get('model', 'β')}` | " | |
| f"**Duration:** {meta.get('total_duration_seconds', 'β')}s | " | |
| f"**Cached:** {meta.get('cached', False)}" | |
| ) | |
| st.markdown("### Pipeline Steps") | |
| for step in trace["steps"]: | |
| icon = "β " if step["status"] == "success" else "β" | |
| st.markdown(f"{icon} **{step['name']}** β {step['duration_seconds']}s") | |
| if step.get("details"): | |
| st.caption(json.dumps(step["details"])) | |
| with st.expander("Full Logs", expanded=False): | |
| for log_entry in trace.get("logs", []): | |
| level = log_entry["level"] | |
| color = "red" if level == "ERROR" else "orange" if level == "WARNING" else "gray" | |
| st.markdown( | |
| f"<span style='color:{color}'>[{level}]</span> " | |
| f"`{log_entry['logger']}` β {log_entry['message']}", | |
| unsafe_allow_html=True, | |
| ) | |
| st.download_button( | |
| label="Download Debug JSON", | |
| data=json.dumps(trace, indent=2, default=str), | |
| file_name=f"debug_{meta.get('domain', 'unknown')}_{meta.get('timestamp', '')[:10]}.json", | |
| mime="application/json", | |
| ) | |
| elif trace and trace.get("meta", {}).get("cached"): | |
| st.info( | |
| "This report was loaded from cache β no pipeline trace available. " | |
| "Re-run with **Force re-analysis** to capture debug data." | |
| ) | |
| else: | |
| st.info("Run an analysis to see debug trace data here.") | |
| else: | |
| # Landing page | |
| st.markdown("---") | |
| st.markdown("### Enter a news site URL in the sidebar to get started") | |
| st.markdown(""" | |
| **What gets analyzed:** | |
| - **Editorial Bias** β Policy positions across economic, social, environmental, healthcare, immigration domains | |
| - **Factual Reporting** β Fact-check search across PolitiFact, Snopes, FactCheck.org, FullFact | |
| - **Sourcing Quality** β Link extraction and source credibility assessment | |
| - **Pseudoscience** β Detection of anti-vax, climate denial, alternative medicine claims | |
| - **History & Ownership** β Founding year, owner, funding model, headquarters | |
| - **Country Freedom** β RSF Press Freedom Index for the outlet's country | |
| - **External Analyses** β Ad Fontes, NewsGuard, academic reviews | |
| **Methodology:** Follows [Media Bias/Fact Check](https://mediabiasfactcheck.com/methodology/) scoring. | |
| Bias scale: -10 (far left) to +10 (far right). Credibility = FactChecks(40%) + Sourcing(30%) + Pseudoscience(30%). | |
| """) | |