import hashlib import random from datetime import date from html import escape from urllib.parse import quote import gradio as gr import requests import spaces WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php" CHALLENGES = [ ("Motorcycle", "Black hole"), ("Coffee", "Moon"), ("Rubber duck", "Artificial intelligence"), ("Pizza", "Quantum mechanics"), ("Harley-Davidson", "Computer science"), ("Volcano", "Internet"), ("Shark", "Electricity"), ("Chess", "Space exploration"), ("New York City", "Dinosaur"), ("Heavy metal music", "Ancient Egypt"), ("Video game", "Solar System"), ("Submarine", "World Wide Web"), ] @spaces.GPU(duration=1) def zerogpu_compatibility(): """Allows this lightweight app to start on ZeroGPU hardware.""" return True def wikipedia_request(params: dict) -> dict: response = requests.get( WIKIPEDIA_API, params={ **params, "format": "json", "formatversion": 2, "origin": "*", }, timeout=20, headers={ "User-Agent": "WikiQuest/1.0 (Hugging Face educational game)" }, ) response.raise_for_status() return response.json() def normalize_title(title: str) -> str: return " ".join(title.strip().split()) def resolve_article(title: str) -> str: data = wikipedia_request( { "action": "query", "titles": title, "redirects": 1, } ) pages = data.get("query", {}).get("pages", []) if not pages or pages[0].get("missing"): raise ValueError(f'Wikipedia could not find an article named "{title}".') return pages[0].get("title", title) def get_article_data(title: str, link_limit: int = 120) -> tuple[str, str, list[str]]: data = wikipedia_request( { "action": "query", "prop": "extracts|links", "titles": title, "redirects": 1, "exintro": 1, "explaintext": 1, "plnamespace": 0, "pllimit": link_limit, } ) pages = data.get("query", {}).get("pages", []) if not pages or pages[0].get("missing"): raise ValueError(f'Wikipedia could not load "{title}".') page = pages[0] resolved_title = page.get("title", title) summary = page.get("extract") or "No introductory summary was available." links = [] blocked_prefixes = ( "List of", "Outline of", "Index of", "Glossary of", "Timeline of", ) for item in page.get("links", []): link_title = item.get("title", "").strip() if not link_title: continue if link_title.startswith(blocked_prefixes): continue if link_title.casefold() == resolved_title.casefold(): continue links.append(link_title) return resolved_title, summary[:900], sorted(set(links)) def get_link_titles(title: str, limit: int = 100) -> set[str]: try: _, _, links = get_article_data(title, limit) return {item.casefold() for item in links} except Exception: return set() def title_words(title: str) -> set[str]: ignored = { "a", "an", "and", "the", "of", "in", "on", "to", "for", "with", "by", } cleaned = "".join( character.lower() if character.isalnum() else " " for character in title ) return { word for word in cleaned.split() if len(word) > 2 and word not in ignored } def connection_score( article: str, target: str, article_links: set[str] | None = None, ) -> float: if article.casefold() == target.casefold(): return 100.0 article_words = title_words(article) target_words = title_words(target) word_overlap = len(article_words & target_words) score = word_overlap * 16.0 article_lower = article.casefold() target_lower = target.casefold() if article_lower in target_lower or target_lower in article_lower: score += 25.0 if article_links and target_lower in article_links: score += 55.0 return min(score, 95.0) def select_destinations( links: list[str], target: str, amount: int = 12, ) -> list[str]: if not links: return [] target_lower = target.casefold() target_terms = title_words(target) def rank(link: str) -> tuple[int, int, int]: link_terms = title_words(link) exact_target = int(link.casefold() == target_lower) shared_terms = len(link_terms & target_terms) readable_length = -abs(len(link) - 18) return exact_target, shared_terms, readable_length ranked = sorted(links, key=rank, reverse=True) priority = ranked[:5] remaining = [link for link in links if link not in priority] seed_text = f"{target}|{'|'.join(links[:20])}" seed = int(hashlib.sha256(seed_text.encode()).hexdigest()[:12], 16) local_random = random.Random(seed) local_random.shuffle(remaining) selected = priority + remaining[: max(0, amount - len(priority))] if target in links and target not in selected: selected[-1] = target return selected[:amount] def daily_challenge() -> tuple[str, str]: today_number = date.today().toordinal() return CHALLENGES[today_number % len(CHALLENGES)] def random_challenge() -> tuple[str, str]: return random.choice(CHALLENGES) def make_header( current: str, target: str, clicks: int, temperature: str, ) -> str: return f"""
CURRENT LOCATION

{escape(current)}

TARGET DESTINATION

{escape(target)}

MOVES {clicks}
SIGNAL {escape(temperature)}
""" def make_article_card(title: str, summary: str) -> str: article_url = ( "https://en.wikipedia.org/wiki/" + quote(title.replace(" ", "_"), safe="") ) return f"""
YOU HAVE ARRIVED AT

{escape(title)}

{escape(summary)}

Read the full Wikipedia article ↗
""" def make_path(path: list[str]) -> str: if not path: return """
Your journey will appear here.
""" nodes = [] for index, title in enumerate(path): node_class = "path-node" if index == 0: node_class += " start-node" elif index == len(path) - 1: node_class += " active-node" nodes.append( f"""
{index} {escape(title)}
""" ) if index < len(path) - 1: nodes.append('
') return f"""
{''.join(nodes)}
""" def temperature_message( previous_score: float | None, new_score: float, won: bool, ) -> str: if won: return "TARGET REACHED" if previous_score is None: return "SEARCHING" difference = new_score - previous_score if new_score >= 65: return "BURNING HOT" if difference >= 18: return "MUCH WARMER" if difference >= 4: return "GETTING WARMER" if difference <= -18: return "ICE COLD" if difference <= -4: return "GETTING COLDER" return "UNCHANGED" def victory_screen(path: list[str], target: str) -> str: clicks = max(len(path) - 1, 0) return f"""
MISSION COMPLETE

You found {escape(target)}!

You crossed Wikipedia in {clicks} move{"s" if clicks != 1 else ""}.

{escape(" → ".join(path))}
""" def error_outputs(message: str): return ( f"""

Unable to start this mission

{escape(message)}

""", "", gr.update(choices=[], value=None, interactive=False), "", [], "", "", 0.0, ) def load_location( article: str, target: str, path: list[str], previous_score: float | None, ): resolved_title, summary, links = get_article_data(article) target = resolve_article(target) if path: path[-1] = resolved_title else: path = [resolved_title] link_set = {link.casefold() for link in links} score = connection_score(resolved_title, target, link_set) won = resolved_title.casefold() == target.casefold() temperature = temperature_message(previous_score, score, won) header = make_header( resolved_title, target, max(len(path) - 1, 0), temperature, ) if won: content = victory_screen(path, target) destinations = gr.update( choices=[], value=None, interactive=False, label="Mission complete", ) else: content = make_article_card(resolved_title, summary) choices = select_destinations(links, target) destinations = gr.update( choices=choices, value=None, interactive=True, label="Choose your next destination", ) return ( header, content, destinations, make_path(path), path, resolved_title, target, score, ) def start_game(start: str, target: str): start = normalize_title(start) target = normalize_title(target) if not start or not target: return error_outputs("Enter both a starting article and a target.") try: resolved_start = resolve_article(start) resolved_target = resolve_article(target) return load_location( resolved_start, resolved_target, [resolved_start], None, ) except (requests.RequestException, ValueError) as error: return error_outputs(str(error)) def travel( destination: str, target: str, path: list[str], previous_score: float, ): if not destination: return ( gr.update(), gr.update(), gr.update(), gr.update(), path, path[-1] if path else "", target, previous_score, ) new_path = list(path) new_path.append(destination) try: return load_location( destination, target, new_path, previous_score, ) except (requests.RequestException, ValueError) as error: return error_outputs(str(error)) def undo_move( target: str, path: list[str], ): if len(path) <= 1: current = path[0] if path else "" if not current: return error_outputs("Start a mission first.") return load_location( current, target, path, None, ) new_path = path[:-1] previous_article = new_path[-1] return load_location( previous_article, target, new_path, None, ) def load_daily(): start, target = daily_challenge() return start_game(start, target) def load_random(): start, target = random_challenge() return start_game(start, target) CSS = """ :root { --background: #070b14; --panel: #111827; --panel-light: #182235; --border: rgba(255, 255, 255, 0.09); --text: #f7f9fc; --muted: #9aa8bd; --accent: #7c5cff; --accent-bright: #9c88ff; --cyan: #4dd7ff; --success: #48e3a5; } .gradio-container { max-width: 1180px !important; margin: 0 auto !important; padding: 20px !important; background: radial-gradient(circle at 50% -20%, rgba(124, 92, 255, 0.24), transparent 40%), var(--background) !important; } body, .gradio-container { font-family: Inter, ui-sans-serif, system-ui, sans-serif !important; } footer { display: none !important; } .hero { text-align: center; padding: 38px 10px 24px; } .hero-badge { display: inline-block; color: var(--cyan); border: 1px solid rgba(77, 215, 255, 0.32); background: rgba(77, 215, 255, 0.08); border-radius: 999px; padding: 7px 13px; font-size: 0.74rem; font-weight: 800; letter-spacing: 0.16em; } .hero h1 { margin: 16px 0 6px; color: var(--text); font-size: clamp(2.8rem, 7vw, 5.1rem); line-height: 0.95; letter-spacing: -0.06em; } .hero h1 span { color: var(--accent-bright); } .hero p { color: var(--muted); font-size: 1.05rem; margin: 15px auto 0; max-width: 630px; } .setup-panel, .control-panel { background: rgba(17, 24, 39, 0.9); border: 1px solid var(--border); border-radius: 22px; padding: 20px; box-shadow: 0 20px 55px rgba(0, 0, 0, 0.28); } .setup-panel label span, .destination-grid label span { color: var(--text) !important; font-weight: 800 !important; } .mission-grid { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: stretch; margin: 22px 0 14px; } .mission-card { min-height: 125px; padding: 22px; border-radius: 20px; background: var(--panel); border: 1px solid var(--border); display: flex; flex-direction: column; justify-content: center; } .current-card { border-color: rgba(77, 215, 255, 0.35); } .target-card { border-color: rgba(156, 136, 255, 0.4); } .mission-card h2 { color: var(--text); margin: 6px 0 0; font-size: 1.55rem; } .mission-arrow { display: flex; align-items: center; color: var(--accent-bright); font-size: 2rem; } .eyebrow { color: var(--muted); font-size: 0.7rem; font-weight: 900; letter-spacing: 0.16em; } .game-stats { display: flex; gap: 10px; margin-bottom: 14px; } .stat-pill { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 15px; padding: 13px 17px; display: flex; align-items: center; justify-content: space-between; } .stat-pill strong { color: var(--text); } .stat-label { color: var(--muted); font-size: 0.7rem; font-weight: 900; letter-spacing: 0.12em; } .heat-pill strong { color: var(--cyan); } .article-panel, .victory-panel, .error-panel { background: linear-gradient(135deg, rgba(124, 92, 255, 0.09), transparent 45%), var(--panel); border: 1px solid var(--border); border-radius: 22px; padding: clamp(22px, 5vw, 38px); margin: 14px 0; color: var(--text); } .article-heading { display: flex; gap: 14px; align-items: center; } .location-pin { color: var(--cyan); font-size: 1.7rem; } .article-panel h1, .victory-panel h1 { color: var(--text); margin: 4px 0 0; font-size: clamp(1.8rem, 5vw, 2.7rem); } .article-panel p { color: #c7d0df; line-height: 1.75; font-size: 1rem; } .wiki-link { display: inline-block; color: var(--cyan) !important; font-weight: 750; text-decoration: none !important; margin-top: 8px; } .destination-grid { background: var(--panel); border: 1px solid var(--border); border-radius: 22px; padding: 18px !important; margin-top: 14px; } .destination-grid .wrap { display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)) !important; gap: 10px !important; } .destination-grid label { min-height: 62px !important; border: 1px solid var(--border) !important; border-radius: 14px !important; background: var(--panel-light) !important; color: var(--text) !important; padding: 14px !important; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease !important; } .destination-grid label:hover { transform: translateY(-2px); border-color: rgba(124, 92, 255, 0.8) !important; background: rgba(124, 92, 255, 0.16) !important; } .primary-button { min-height: 52px !important; border-radius: 14px !important; font-weight: 850 !important; } .path-section { background: var(--panel); border: 1px solid var(--border); border-radius: 22px; padding: 22px; margin-top: 14px; } .path-title { color: var(--text); font-size: 0.78rem; letter-spacing: 0.13em; font-weight: 900; margin-bottom: 18px; } .path-wrapper { display: flex; align-items: center; gap: 8px; overflow-x: auto; padding-bottom: 8px; } .path-node { min-width: max-content; background: var(--panel-light); border: 1px solid var(--border); border-radius: 999px; color: var(--text); padding: 8px 13px 8px 8px; display: flex; align-items: center; gap: 8px; } .path-node span { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; background: rgba(255, 255, 255, 0.08); color: var(--muted); font-size: 0.75rem; } .active-node { border-color: rgba(77, 215, 255, 0.55); } .active-node span { background: var(--cyan); color: #07101a; } .start-node span { background: var(--accent); color: white; } .path-line { width: 24px; min-width: 24px; height: 2px; background: rgba(255, 255, 255, 0.14); } .victory-panel { text-align: center; border-color: rgba(72, 227, 165, 0.45); background: radial-gradient(circle at 50% 0%, rgba(72, 227, 165, 0.19), transparent 48%), var(--panel); } .victory-icon { color: var(--success); font-size: 3rem; margin-bottom: 10px; } .victory-panel p { color: var(--muted); font-size: 1.05rem; } .victory-path { color: var(--success); background: rgba(72, 227, 165, 0.08); border: 1px solid rgba(72, 227, 165, 0.2); border-radius: 14px; padding: 15px; margin-top: 18px; overflow-wrap: anywhere; } .empty-path { color: var(--muted); } .error-panel { border-color: rgba(255, 100, 120, 0.38); } @media (max-width: 760px) { .gradio-container { padding: 12px !important; } .mission-grid { grid-template-columns: 1fr; } .mission-arrow { justify-content: center; transform: rotate(90deg); height: 25px; } .destination-grid .wrap { grid-template-columns: 1fr !important; } .game-stats { flex-direction: column; } } """ with gr.Blocks(title="WikiQuest") as demo: path_state = gr.State([]) current_state = gr.State("") target_state = gr.State("") score_state = gr.State(0.0) gr.HTML( """
THE INTERNET RABBIT HOLE

WIKIQUEST

Travel between two completely different ideas using only the links hidden inside Wikipedia.

""" ) with gr.Group(elem_classes=["setup-panel"]): with gr.Row(): start_input = gr.Textbox( label="Starting article", value="Motorcycle", placeholder="Example: Motorcycle", ) target_input = gr.Textbox( label="Target article", value="Black hole", placeholder="Example: Black hole", ) with gr.Row(): start_button = gr.Button( "Start Custom Mission", variant="primary", elem_classes=["primary-button"], ) daily_button = gr.Button( "Play Daily Challenge", elem_classes=["primary-button"], ) random_button = gr.Button( "Generate Random Mission", elem_classes=["primary-button"], ) mission_header = gr.HTML() article_display = gr.HTML( """
READY FOR DEPARTURE

Choose a mission above

Every destination shown during the game is a real link from your current Wikipedia article.

""" ) destination_picker = gr.Radio( label="Choose your next destination", choices=[], interactive=True, elem_classes=["destination-grid"], ) with gr.Row(): travel_button = gr.Button( "Travel to Selected Article", variant="primary", elem_classes=["primary-button"], ) undo_button = gr.Button( "Undo Last Move", elem_classes=["primary-button"], ) gr.HTML('
YOUR JOURNEY
') path_display = gr.HTML( '
Your journey will appear here.
' ) gr.HTML("
") game_outputs = [ mission_header, article_display, destination_picker, path_display, path_state, current_state, target_state, score_state, ] start_button.click( fn=start_game, inputs=[start_input, target_input], outputs=game_outputs, ) daily_button.click( fn=load_daily, outputs=game_outputs, ) random_button.click( fn=load_random, outputs=game_outputs, ) travel_button.click( fn=travel, inputs=[ destination_picker, target_state, path_state, score_state, ], outputs=game_outputs, ) destination_picker.change( fn=travel, inputs=[ destination_picker, target_state, path_state, score_state, ], outputs=game_outputs, ) undo_button.click( fn=undo_move, inputs=[ target_state, path_state, ], outputs=game_outputs, ) demo.launch( theme=gr.themes.Base(), css=CSS, )