| import gradio as gr |
| import requests |
| from bs4 import BeautifulSoup |
| import os |
| import json |
| import logging |
| import pandas as pd |
| import numpy as np |
| import matplotlib.pyplot as plt |
| from typing import Optional, List, Dict, Any |
|
|
| |
| |
| |
| WORDLIFT_API_URL = "https://api.wordlift.io/content-evaluations" |
| WORDLIFT_API_KEY = os.getenv("WORDLIFT_API_KEY") |
|
|
| |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| |
|
|
| css = """ |
| @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap'); |
| body { |
| font-family: 'Open Sans', sans-serif !important; |
| } |
| .primary-btn { |
| background-color: #3452db !important; |
| color: white !important; |
| } |
| .primary-btn:hover { |
| background-color: #2a41af !important; |
| } |
| .gradio-container { |
| max-width: 1200px; /* Limit width for better readability */ |
| margin: auto; |
| } |
| .plot-container { |
| min-height: 400px; /* Ensure plot area is visible */ |
| display: flex; |
| justify-content: center; /* Center the plot */ |
| align-items: center; /* Center vertically if needed */ |
| } |
| /* Specific style for the plot title to potentially reduce overlap */ |
| .plot-container .gradio-html-title { |
| text-align: center; |
| width: 100%; /* Ensure title centers */ |
| } |
| |
| """ |
|
|
| theme = gr.themes.Soft( |
| primary_hue=gr.themes.colors.Color( |
| name="blue", |
| c50="#eef1ff", |
| c100="#e0e5ff", |
| c200="#c3cbff", |
| c300="#a5b2ff", |
| c400="#8798ff", |
| c500="#6a7eff", |
| c600="#3452db", |
| c700="#2a41af", |
| c800="#1f3183", |
| c900="#152156", |
| c950="#0a102b", |
| ) |
| ) |
|
|
| |
| |
| |
|
|
| def fetch_content_from_url(url: str, timeout: int = 15) -> str: |
| """Fetches main text content from a URL.""" |
| logger.info(f"Fetching content from: {url}") |
| try: |
| headers = { |
| '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' |
| } |
| |
| |
| with requests.get(url, headers=headers, timeout=timeout, stream=True) as response: |
| response.raise_for_status() |
|
|
| |
| max_bytes_to_read = 2 * 1024 * 1024 |
| |
| content_bytes = b'' |
| for chunk in response.iter_content(chunk_size=8192): |
| if not chunk: |
| break |
| content_bytes += chunk |
| if len(content_bytes) >= max_bytes_to_read: |
| logger.warning(f"Content for {url} exceeded {max_bytes_to_read} bytes, stopped reading.") |
| break |
|
|
| |
| try: |
| |
| encoding = requests.utils.get_encoding_from_headers(response.headers) or requests.utils.guess_json_utf(content_bytes) |
| content = content_bytes.decode(encoding, errors='replace') |
| except Exception as e: |
| logger.warning(f"Could not detect encoding for {url}, falling back to utf-8: {e}") |
| content = content_bytes.decode('utf-8', errors='replace') |
|
|
|
|
| soup = BeautifulSoup(content, 'html.parser') |
|
|
| |
| |
| |
| main_content = soup.find('article') or soup.find('main') or soup.find(class_=lambda x: x and ('content' in x.lower() or 'article' in x.lower() or 'post' in x.lower() or 'body' in x.lower())) |
|
|
|
|
| if main_content: |
| |
| text_elements = main_content.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'figcaption', 'pre', 'code']) |
| text = ' '.join([elem.get_text() for elem in text_elements]) |
| else: |
| |
| text_elements = soup.body.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'blockquote', 'figcaption', 'pre', 'code']) |
| text = ' '.join([elem.get_text() for elem in text_elements]) |
| logger.warning(f"No specific content tags (<article>, <main>, etc.) or common class names found for {url}, extracting from body.") |
|
|
| |
| text = ' '.join(text.split()) |
|
|
| |
| |
| max_text_length = 1000000 |
| if len(text) > max_text_length: |
| logger.warning(f"Extracted text for {url} is too long ({len(text)} chars), truncating to {max_text_length} chars.") |
| text = text[:max_text_length] |
|
|
| return text.strip() if text and text.strip() else None |
|
|
|
|
| except requests.exceptions.RequestException as e: |
| logger.error(f"Failed to fetch content from {url}: {e}") |
| return None |
| except Exception as e: |
| logger.error(f"Error processing content from {url}: {e}") |
| return None |
|
|
| |
| |
| |
|
|
| def call_wordlift_api(text: str, keywords: Optional[List[str]] = None) -> Optional[Dict[str, Any]]: |
| """Calls the WordLift Content Evaluation API.""" |
| if not WORDLIFT_API_KEY: |
| logger.error("WORDLIFT_API_KEY environment variable not set.") |
| return {"error": "API key not configured."} |
|
|
| if not text or not text.strip(): |
| return {"error": "No significant content to evaluate."} |
|
|
| payload = { |
| "text": text, |
| "keywords": keywords if keywords else [] |
| } |
|
|
| headers = { |
| 'Authorization': f'Key {WORDLIFT_API_KEY}', |
| 'Content-Type': 'application/json', |
| 'Accept': 'application/json' |
| } |
|
|
| logger.info(f"Calling WordLift API with text length {len(text)} and {len(keywords or [])} keywords.") |
|
|
| try: |
| response = requests.post(WORDLIFT_API_URL, headers=headers, json=payload, timeout=90) |
| response.raise_for_status() |
| return response.json() |
|
|
| except requests.exceptions.HTTPError as e: |
| logger.error(f"WordLift API HTTP error for {e.request.url}: {e.response.status_code} - {e.response.text}") |
| try: |
| error_detail = e.response.json() |
| except json.JSONDecodeError: |
| error_detail = e.response.text |
| return {"error": f"API returned status code {e.response.status_code}", "details": error_detail} |
| except requests.exceptions.Timeout as e: |
| logger.error(f"WordLift API request timed out for {e.request.url}: {e}") |
| return {"error": f"API request timed out."} |
| except requests.exceptions.RequestException as e: |
| logger.error(f"WordLift API request error for {e.request.url}: {e}") |
| return {"error": f"API request failed: {e}"} |
| except Exception as e: |
| logger.error(f"Unexpected error during API call: {e}") |
| return {"error": f"An unexpected error occurred: {e}"} |
|
|
|
|
| |
| |
| |
|
|
| def plot_average_radar(average_scores: Dict[str, Optional[float]], avg_overall: Optional[float]) -> Any: |
| """Return a radar (spider) plot as a Matplotlib figure showing average scores.""" |
|
|
| |
| if not average_scores or all(v is None or pd.isna(v) for v in average_scores.values()): |
| |
| fig, ax = plt.subplots(figsize=(6, 6)) |
| ax.text(0.5, 0.5, "No successful evaluations to plot\naverage scores.", horizontalalignment='center', verticalalignment='center', transform=ax.transAxes, fontsize=12) |
| ax.axis('off') |
| plt.title("Average Content Quality Scores", size=16, y=1.05) |
| plt.tight_layout() |
| return fig |
|
|
|
|
| categories = list(average_scores.keys()) |
| |
| values_raw = [average_scores[cat] for cat in categories] |
| values_for_plot = [float(v) if v is not None and pd.notna(v) else 0 for v in values_raw] |
|
|
|
|
| num_vars = len(categories) |
| |
| angles = [n / float(num_vars) * 2 * np.pi for n in range(num_vars)] |
| angles += angles[:1] |
| values_for_plot += values_for_plot[:1] |
|
|
| fig, ax = plt.subplots(figsize=(6, 6), subplot_kw=dict(projection='polar')) |
|
|
| line_color = '#3452DB' |
| fill_color = '#A1A7AF' |
| background_color = '#F6F6F7' |
| annotation_color = '#191919' |
|
|
| |
| ax.plot(angles, values_for_plot, 'o-', linewidth=2, color=line_color, label='Average Scores') |
| ax.fill(angles, values_for_plot, alpha=0.4, color=fill_color) |
|
|
| |
| ax.set_xticks(angles[:-1]) |
| ax.set_xticklabels(categories, color=line_color, fontsize=10) |
|
|
| |
| ax.set_ylim(0, 100) |
| ax.set_yticks([0, 20, 40, 60, 80, 100]) |
|
|
| |
| ax.grid(True, alpha=0.5, color=fill_color) |
| ax.set_facecolor(background_color) |
|
|
| |
| for angle, value_raw, value_plotted in zip(angles[:-1], values_raw, values_for_plot[:-1]): |
| if value_raw is not None and pd.notna(value_raw): |
| |
| |
| |
| radius = value_plotted + 5 |
| |
| ax.text(angle, radius, f'{value_raw:.1f}', color=annotation_color, |
| horizontalalignment='center', verticalalignment='center', fontsize=9) |
|
|
|
|
| |
| overall_title_text = f'Overall: {avg_overall:.1f}/100' if avg_overall is not None and pd.notna(avg_overall) else 'Overall: -' |
| plt.title(overall_title_text, size=16, y=1.1, color=annotation_color) |
|
|
| plt.tight_layout() |
| return fig |
|
|
| |
| |
| |
|
|
| def evaluate_urls_batch(url_data: pd.DataFrame): |
| """ |
| Evaluates a batch of URLs using the WordLift API. |
| |
| Args: |
| url_data: A pandas DataFrame with columns ['URL', 'Target Keywords (comma-separated)']. |
| |
| Returns: |
| A tuple containing: |
| - A pandas DataFrame with the summary results. |
| - A dictionary containing the full results (including errors) keyed by URL. |
| - A Matplotlib figure for the average radar chart. |
| """ |
| |
| if url_data.empty: |
| logger.info("Input DataFrame is empty. Returning empty results.") |
| |
| empty_summary_df = pd.DataFrame(columns=[ |
| 'URL', 'Status', 'Overall Score', 'Content Purpose', |
| 'Content Accuracy', 'Content Depth', 'Readability Score (API)', |
| 'Readability Grade Level', 'SEO Score', 'Word Count', 'Error/Details' |
| ]) |
| return empty_summary_df, {}, plot_average_radar(None, None) |
|
|
| summary_results = [] |
| full_results = {} |
|
|
| |
| |
| purpose_scores = [] |
| accuracy_scores = [] |
| depth_scores = [] |
| readability_scores = [] |
| seo_scores = [] |
| overall_scores = [] |
|
|
| |
| urls = url_data.get('URL', pd.Series(dtype=str)).fillna('') |
| keywords_col = url_data.get('Target Keywords (comma-separated)', pd.Series(dtype=str)).fillna('') |
|
|
|
|
| for index, url in enumerate(urls): |
| url = url.strip() |
| keywords_str = keywords_col.iloc[index].strip() |
| keywords = [kw.strip() for kw in keywords_str.split(',') if kw.strip()] |
|
|
| |
| result_key = f"Row_{index}" + (f": {url}" if url else "") |
|
|
|
|
| if not url: |
| summary_results.append(["", "Skipped", "-", "-", "-", "-", "-", "-", "-", "-", "Empty URL"]) |
| full_results[result_key] = {"status": "Skipped", "error": "Empty URL input."} |
| logger.warning(f"Skipping evaluation for row {index}: Empty URL") |
| |
| purpose_scores.append(np.nan) |
| accuracy_scores.append(np.nan) |
| depth_scores.append(np.nan) |
| readability_scores.append(np.nan) |
| seo_scores.append(np.nan) |
| overall_scores.append(np.nan) |
| continue |
|
|
| logger.info(f"Processing URL: {url} (Row {index}) with keywords: {keywords}") |
|
|
| |
| content = fetch_content_from_url(url) |
|
|
| if content is None or not content.strip(): |
| status = "Failed" |
| error_msg = "Failed to fetch or extract content." |
| summary_results.append([url, status, "-", "-", "-", "-", "-", "-", "-", "-", error_msg]) |
| full_results[result_key] = {"status": status, "error": error_msg} |
| logger.error(f"Processing failed for {url} (Row {index}): {error_msg}") |
| |
| purpose_scores.append(np.nan) |
| accuracy_scores.append(np.nan) |
| depth_scores.append(np.nan) |
| readability_scores.append(np.nan) |
| seo_scores.append(np.nan) |
| overall_scores.append(np.nan) |
| continue |
|
|
| |
| api_result = call_wordlift_api(content, keywords) |
|
|
| |
| summary_row = [url] |
| if api_result and "error" not in api_result: |
| status = "Success" |
| qs = api_result.get('quality_score', {}) |
| breakdown = qs.get('breakdown', {}) |
| content_breakdown = breakdown.get('content', {}) |
| readability_breakdown = breakdown.get('readability', {}) |
| seo_breakdown = breakdown.get('seo', {}) |
| metadata = api_result.get('metadata', {}) |
|
|
| |
| |
| purpose_scores.append(float(content_breakdown.get('purpose')) if content_breakdown.get('purpose') is not None else np.nan) |
| accuracy_scores.append(float(content_breakdown.get('accuracy')) if content_breakdown.get('accuracy') is not None else np.nan) |
| depth_scores.append(float(content_breakdown.get('depth')) if content_breakdown.get('depth') is not None else np.nan) |
| readability_scores.append(float(readability_breakdown.get('score')) if readability_breakdown.get('score') is not None else np.nan) |
| seo_scores.append(float(seo_breakdown.get('score')) if seo_breakdown.get('score') is not None else np.nan) |
| overall_scores.append(float(qs.get('overall')) if qs.get('overall') is not None else np.nan) |
|
|
|
|
| |
| |
| summary_row.extend([ |
| status, |
| f'{qs.get("overall", "-"): .1f}' if qs.get('overall') is not None else "-", |
| f'{content_breakdown.get("purpose", "-"): .0f}' if content_breakdown.get('purpose') is not None else "-", |
| f'{content_breakdown.get("accuracy", "-"): .0f}' if content_breakdown.get('accuracy') is not None else "-", |
| f'{content_breakdown.get("depth", "-"): .0f}' if content_breakdown.get('depth') is not None else "-", |
| f'{readability_breakdown.get("score", "-"): .1f}' if readability_breakdown.get('score') is not None else "-", |
| f'{readability_breakdown.get("grade_level", "-"): .0f}' if readability_breakdown.get('grade_level') is not None else "-", |
| f'{seo_breakdown.get("score", "-"): .1f}' if seo_breakdown.get('score') is not None else "-", |
| f'{metadata.get("word_count", "-"): .0f}' if metadata.get('word_count') is not None else "-", |
| None |
| ]) |
| full_results[result_key] = api_result |
|
|
| else: |
| status = "Failed" |
| error_msg = api_result.get("error", "Unknown API error.") if api_result else "API call failed." |
| details = api_result.get("details", "") if api_result else "" |
| summary_row.extend([ |
| status, |
| "-", "-", "-", "-", "-", "-", "-", "-", |
| f"{error_msg} {details}" |
| ]) |
| full_results[result_key] = {"status": status, "error": error_msg, "details": details} |
| logger.error(f"API call failed for {url} (Row {index}): {error_msg} {details}") |
|
|
| |
| purpose_scores.append(np.nan) |
| accuracy_scores.append(np.nan) |
| depth_scores.append(np.nan) |
| readability_scores.append(np.nan) |
| seo_scores.append(np.nan) |
| overall_scores.append(np.nan) |
|
|
|
|
| summary_results.append(summary_row) |
|
|
| |
| avg_purpose = np.nanmean(purpose_scores) |
| avg_accuracy = np.nanmean(accuracy_scores) |
| avg_depth = np.nanmean(depth_scores) |
| avg_readability = np.nanmean(readability_scores) |
| avg_seo = np.nanmean(seo_scores) |
| avg_overall = np.nanmean(overall_scores) |
|
|
| |
| avg_purpose = avg_purpose if pd.notna(avg_purpose) else None |
| avg_accuracy = avg_accuracy if pd.notna(avg_accuracy) else None |
| avg_depth = avg_depth if pd.notna(avg_depth) else None |
| avg_readability = avg_readability if pd.notna(avg_readability) else None |
| avg_seo = avg_seo if pd.notna(avg_seo) else None |
| avg_overall = avg_overall if pd.notna(avg_overall) else None |
|
|
|
|
| |
| average_scores_dict = { |
| 'Purpose': avg_purpose, |
| 'Accuracy': avg_accuracy, |
| 'Depth': avg_depth, |
| 'Readability': avg_readability, |
| 'SEO': avg_seo |
| } |
|
|
| |
| average_radar_fig = plot_average_radar(average_scores_dict, avg_overall) |
|
|
|
|
| |
| summary_df = pd.DataFrame(summary_results, columns=[ |
| 'URL', 'Status', 'Overall Score', 'Content Purpose', |
| 'Content Accuracy', 'Content Depth', 'Readability Score (API)', |
| 'Readability Grade Level', 'SEO Score', 'Word Count', 'Error/Details' |
| ]) |
|
|
| |
| |
| |
|
|
| return summary_df, full_results, average_radar_fig |
|
|
| |
| |
| |
|
|
| with gr.Blocks(css=css, theme=theme) as demo: |
| gr.Markdown("# WordLift Multi-URL Content Evaluator") |
| gr.Markdown( |
| "Enter up to 30 URLs in the table below. " |
| "Optionally, provide comma-separated target keywords for each URL. " |
| "The app will fetch content from each URL and evaluate it using the WordLift API." |
| ) |
|
|
| with gr.Row(): |
| with gr.Column(scale=1): |
| url_input_df = gr.Dataframe( |
| headers=["URL", "Target Keywords (comma-separated)"], |
| datatype=["str", "str"], |
| row_count=(1, 30), |
| col_count=(2, "fixed"), |
| value=[ |
| ["https://wordlift.io/blog/en/query-fan-out-ai-search/", "query fan out, ai search, google, ai"], |
| ["https://wordlift.io/blog/en/entity/google-knowledge-graph/", "google knowledge graph, entity, semantic web, seo"], |
| ["https://www.example.com/non-existent-page", ""], |
| ["", ""], |
| ["", ""], |
| ["", ""], |
| ["", ""], |
| ], |
| label="URLs and Keywords" |
| ) |
| submit_button = gr.Button("Evaluate All URLs", elem_classes=["primary-btn"]) |
|
|
| with gr.Column(scale=1, elem_classes="plot-container"): |
| |
| average_radar_output = gr.Plot(label="Average Content Quality Scores Radar") |
|
|
|
|
| gr.Markdown("## Detailed Results") |
|
|
| with gr.Column(): |
| summary_output_df = gr.DataFrame( |
| label="Summary Results", |
| |
| headers=['URL', 'Status', 'Overall Score', 'Content Purpose', |
| 'Content Accuracy', 'Content Depth', 'Readability Score (API)', |
| 'Readability Grade Level', 'SEO Score', 'Word Count', 'Error/Details'], |
| datatype=["str"] * 11, |
| wrap=True |
| ) |
| with gr.Accordion("Full JSON Results", open=False): |
| |
| full_results_json = gr.JSON(label="Raw API Results per URL (or Error)") |
|
|
| submit_button.click( |
| fn=evaluate_urls_batch, |
| inputs=[url_input_df], |
| |
| outputs=[summary_output_df, full_results_json, average_radar_output] |
| ) |
|
|
| |
| if __name__ == "__main__": |
| if not WORDLIFT_API_KEY: |
| logger.error("\n----------------------------------------------------------") |
| logger.error("WORDLIFT_API_KEY environment variable is not set.") |
| logger.error("Please set it before running the script:") |
| logger.error(" export WORDLIFT_API_KEY='YOUR_API_KEY'") |
| logger.error("Or if using a .env file and python-dotenv:") |
| logger.error(" pip install python-dotenv") |
| logger.error(" # Add WORDLIFT_API_KEY=YOUR_API_KEY to a .env file") |
| logger.error(" # import dotenv; dotenv.load_dotenv()") |
| logger.error(" # in your script before getting the key.") |
| logger.error("----------------------------------------------------------\n") |
| |
| |
| |
|
|
|
|
| logger.info("Launching Gradio app...") |
| |
| |
| demo.launch() |