From dee9b8d10b4fdb62cd2c0d1eb0dc6a9a6050ed60 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Wed, 7 Oct 2026 09:47:33 +0300 Subject: [PATCH] feat(reader): autonomous Wikipedia import and reading with media, tables and settings --- app/database.py | 6 + app/main.py | 133 +++++++++- app/reader.py | 382 ++++++++++++++++++++++++++- app/static/css/style.css | 444 ++++++++++++++++++++++++++++++++ app/static/index.html | 138 +++++++++- app/static/js/modules/api.js | 20 ++ app/static/js/modules/reader.js | 373 ++++++++++++++++++++++++++- app/static/sw.js | 2 +- tests/test_api.py | 185 +++++++++++++ 9 files changed, 1659 insertions(+), 24 deletions(-) diff --git a/app/database.py b/app/database.py index b316f20..049d2f8 100644 --- a/app/database.py +++ b/app/database.py @@ -79,12 +79,18 @@ def _migrate(conn: sqlite3.Connection): current_chapter INTEGER DEFAULT 0, current_position REAL DEFAULT 0, total_words INTEGER DEFAULT 0, + cover_url TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); """) + # Safe migration: ensure cover_url column exists in books + books_cols = {row[1] for row in conn.execute("PRAGMA table_info(books)").fetchall()} + if "cover_url" not in books_cols: + conn.execute("ALTER TABLE books ADD COLUMN cover_url TEXT DEFAULT ''") + # --- Deduplicate vocabulary_cards (case-insensitive) and enforce UNIQUE index --- dup_rows = conn.execute(""" SELECT user_id, lower(trim(term)) as clean_term, COUNT(*) as cnt diff --git a/app/main.py b/app/main.py index ecbbbd9..cc7bb5e 100644 --- a/app/main.py +++ b/app/main.py @@ -30,7 +30,11 @@ from app.auth import ( from app.dictionary import lookup_term, USER_AGENT, close_http_client from app.export import export_csv, export_json, export_anki_tsv, export_markdown from app.srs import calculate_sm2, generate_quiz_options, build_activity_heatmap, calculate_cefr_level -from app.reader import parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number, fetch_and_parse_rfc, parse_rfc_text +from app.reader import ( + parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number, + fetch_and_parse_rfc, parse_rfc_text, fetch_and_parse_wikipedia, + extract_wikipedia_title_and_lang +) from app.ai import translate_sentence_ai, get_ollama_status from app.phrasal import detect_phrasal_verb from app.cefr import ( @@ -187,6 +191,14 @@ class RfcFetchRequest(BaseModel): force: Optional[bool] = False +class WikipediaFetchRequest(BaseModel): + query: Optional[str] = "" + lang: Optional[str] = "simple" + time_budget: Optional[str] = "10-15" + topic: Optional[str] = "random" + force: Optional[bool] = False + + class SentenceTranslateRequest(BaseModel): sentence: str = Field(..., min_length=1) target_word: Optional[str] = "" @@ -1194,7 +1206,7 @@ async def api_get_books(user: dict = Depends(require_user)): """ SELECT id, user_id, title, author, source_type, json_array_length(chapters_json) AS chapters_count, - total_words, current_chapter, current_position, + total_words, current_chapter, current_position, cover_url, created_at, updated_at FROM books WHERE user_id = ? @@ -1309,7 +1321,7 @@ async def api_create_book( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, + current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? """, @@ -1347,7 +1359,7 @@ async def api_fetch_rfc( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, + current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?) @@ -1388,7 +1400,114 @@ async def api_fetch_rfc( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books WHERE id = ? + """, + (new_id,), + ).fetchone() + + res = dict(row) + res["chapters"] = chapters + res["already_existed"] = False + return res + + +@app.post("/api/books/fetch-wikipedia") +async def api_fetch_wikipedia( + req: WikipediaFetchRequest, + user: dict = Depends(require_user), +): + """ + Fetches a Wikipedia article (by title/URL or random topic & duration), + parses sections into readable chapters with media and tables, + and stores autonomously in the user's SQLite bookshelf (source_type = 'wikipedia'). + """ + raw_query = (req.query or "").strip() + target_lang = (req.lang or "simple").strip() + time_budget = (req.time_budget or "10-15").strip() + topic = (req.topic or "random").strip() + + # If specific title or URL provided and force is False, check for existing book + if raw_query and not req.force: + title_cand, lang_cand = extract_wikipedia_title_and_lang(raw_query, default_lang=target_lang) + search_title = title_cand.replace("_", " ") + with get_db() as conn: + existing = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) + ORDER BY id DESC LIMIT 1 + """, + (user["id"], search_title), + ).fetchone() + if existing: + res = dict(existing) + try: + res["chapters"] = json.loads(res["chapters_json"]) + except Exception: + res["chapters"] = [] + res["already_existed"] = True + return res + + try: + title, author, chapters, cover_url, total_words, wiki_url = fetch_and_parse_wikipedia( + title_or_url=raw_query, + lang=target_lang, + time_budget=time_budget, + topic=topic, + ) + except ValueError as ve: + raise HTTPException(status_code=400, detail=str(ve)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Ошибка загрузки статьи Wikipedia: {e}") + + # Check again by resolved title if not forcing + if not req.force: + with get_db() as conn: + existing = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, + created_at, updated_at + FROM books + WHERE user_id = ? AND source_type = 'wikipedia' AND lower(title) = lower(?) + ORDER BY id DESC LIMIT 1 + """, + (user["id"], title), + ).fetchone() + if existing: + res = dict(existing) + try: + res["chapters"] = json.loads(res["chapters_json"]) + except Exception: + res["chapters"] = [] + res["already_existed"] = True + return res + + chapters_json = json.dumps(chapters, ensure_ascii=False) + + with get_db() as conn: + cursor = conn.execute( + """ + INSERT INTO books ( + user_id, title, author, source_type, chapters_json, + current_chapter, current_position, total_words, cover_url + ) VALUES (?, ?, ?, 'wikipedia', ?, 0, 0.0, ?, ?) + """, + (user["id"], title, author, chapters_json, total_words, cover_url), + ) + new_id = cursor.lastrowid + row = conn.execute( + """ + SELECT id, user_id, title, author, source_type, chapters_json, + json_array_length(chapters_json) AS chapters_count, + current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? """, @@ -1411,7 +1530,7 @@ async def api_get_book(book_id: int, user: dict = Depends(require_user)): """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, + current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? AND user_id = ? @@ -1488,7 +1607,7 @@ async def api_update_book_progress( """ SELECT id, user_id, title, author, source_type, chapters_json, json_array_length(chapters_json) AS chapters_count, - current_chapter, current_position, total_words, + current_chapter, current_position, total_words, cover_url, created_at, updated_at FROM books WHERE id = ? diff --git a/app/reader.py b/app/reader.py index afc9366..55983d7 100644 --- a/app/reader.py +++ b/app/reader.py @@ -11,7 +11,7 @@ import urllib.error from pathlib import Path import xml.etree.ElementTree as ET from html.parser import HTMLParser -from typing import Tuple, List, Dict +from typing import Tuple, List, Dict, Optional def count_words(text: str) -> int: @@ -1087,3 +1087,383 @@ def fetch_and_parse_rfc(query: str) -> Tuple[str, str, List[Dict[str, str]], int return clean_title, clean_author, chapters, rfc_num, total_words +# --------------------------------------------------------------------------- +# Wikipedia Autonomous Import & Reader Module +# --------------------------------------------------------------------------- + +WIKIPEDIA_USER_AGENT = "StudyReaderApp/1.0 (https://study.qpid.pro; admin@study.qpid.pro) edu-reader" + +WIKIPEDIA_TOPIC_POOLS: Dict[str, Dict[str, List[str]]] = { + "science": { + "simple": [ + "Solar_System", "Sun", "Moon", "Mars", "Black_hole", "Milky_Way", "Speed_of_light", + "Gravity", "Atom", "Dinosaur", "Evolution", "Photosynthesis", "Asteroid", "Comet", + "Volcano", "Plate_tectonics", "Hubble_Space_Telescope", "James_Webb_Space_Telescope", + "Planet", "Universe", "Earthquake", "Atmosphere" + ], + "en": [ + "Solar_System", "Sun", "Moon", "Mars", "Black_hole", "Milky_Way", "General_relativity", + "Quantum_mechanics", "Speed_of_light", "Plate_tectonics", "Supernova", "Exoplanet", + "Dark_matter", "Hubble_Space_Telescope", "James_Webb_Space_Telescope", "Pendulum", + "Thermodynamics", "Stellar_evolution", "Gravitational_wave" + ] + }, + "tech": { + "simple": [ + "Computer", "Internet", "World_Wide_Web", "Artificial_intelligence", "Smartphone", + "Robot", "Video_game", "Software", "Linux", "Algorithm", "Satellite", "Electricity", + "Machine_learning", "Computer_network", "Fiber_optic", "Microprocessor" + ], + "en": [ + "Artificial_intelligence", "Computer", "Internet", "World_Wide_Web", "Quantum_computing", + "Machine_learning", "Operating_system", "Cryptography", "Blockchain", "Transistor", + "Linux", "Neural_network", "Optical_fiber", "Semiconductor" + ] + }, + "history": { + "simple": [ + "Ancient_Egypt", "Roman_Empire", "Ancient_Greece", "Middle_Ages", "Industrial_Revolution", + "Silk_Road", "Renaissance", "Aztec", "World_War_I", "French_Revolution", "Great_Wall_of_China", + "Mesopotamia", "Byzantine_Empire", "Inca_Empire", "Voyages_of_Christopher_Columbus" + ], + "en": [ + "Ancient_Egypt", "Roman_Empire", "Renaissance", "Industrial_Revolution", "Silk_Road", + "Byzantine_Empire", "Age_of_Discovery", "Classical_Athens", "Mongol_Empire", "French_Revolution", + "Machu_Picchu", "Hanseatic_League", "Printing_press", "Maya_civilization" + ] + }, + "nature": { + "simple": [ + "Blue_whale", "Tiger", "African_elephant", "Honey_bee", "Amazon_rainforest", "Coral_reef", + "Penguin", "Dolphin", "Wolf", "Sahara", "Giant_panda", "Cheetah", "Kangaroo", "Giraffe", + "Ocean", "Forest", "Rainforest", "Migration" + ], + "en": [ + "Blue_whale", "Tiger", "African_elephant", "Amazon_rainforest", "Great_Barrier_Reef", + "Deep_sea", "Polar_bear", "Galapagos_Islands", "Bioluminescence", "Coast_redwood", + "Honey_bee", "Monarch_butterfly", "Humpback_whale", "Ecosystem" + ] + }, + "psychology": { + "simple": [ + "Memory", "Emotion", "Consciousness", "Sleep", "Brain", "Intelligence", "Dream", + "Perception", "Learning", "Happiness", "Stress_(biology)", "Mind", "Behavior" + ], + "en": [ + "Human_memory", "Emotion", "Consciousness", "Sleep", "Cognitive_bias", "Neuroplasticity", + "Empathy", "Perception", "Motivation", "Decision-making", "Working_memory", + "Attention", "Lateralization_of_brain_function" + ] + } +} + + +def extract_wikipedia_title_and_lang(query: str, default_lang: str = "simple") -> Tuple[str, str]: + """ + Parses a Wikipedia URL or plain title into (article_title, lang). + Examples: + https://simple.wikipedia.org/wiki/Solar_System -> ('Solar_System', 'simple') + https://en.wikipedia.org/wiki/Quantum_mechanics -> ('Quantum_mechanics', 'en') + 'Quantum mechanics' -> ('Quantum_mechanics', default_lang) + """ + raw = (query or "").strip() + if not raw: + return "", default_lang + + # Check URL format + m_url = re.search(r"https?://([a-z]+)\.(?:m\.)?wikipedia\.org/wiki/([^?#]+)", raw, re.IGNORECASE) + if m_url: + lang = m_url.group(1).lower() + title_part = urllib.parse.unquote(m_url.group(2)).strip() + # Convert spaces + clean_title = title_part.replace(" ", "_") + return clean_title, lang + + # Plain title or search query + clean_title = raw.replace(" ", "_") + return clean_title, default_lang + + +def clean_wiki_html(raw_html: str, article_title: str = "") -> Tuple[List[Dict[str, str]], str, int]: + """ + Parses MediaWiki Parsoid HTML into clean, structured chapters for the interactive reader. + Extracts cover image/thumbnail, preserves figures, animated GIFs/SVGs, and clean data tables, + and strips wiki clutter ([1], [citation needed], infobox rows, navboxes). + """ + cover_image = "" + + # 1. Lead image: check infobox or first figure + lead_img_m = re.search( + r']*class=["\x27][^"\x27]*infobox[^"\x27]*["\x27][^>]*>.*?]*)>', + raw_html, + re.DOTALL | re.IGNORECASE + ) + if not lead_img_m: + lead_img_m = re.search(r']*>.*?]*)>', raw_html, re.DOTALL | re.IGNORECASE) + + if lead_img_m: + src_m = re.search(r'src=["\x27]([^\s"\x27]+)["\x27]', lead_img_m.group(1), re.IGNORECASE) + if src_m: + src = src_m.group(1) + if src.startswith("//"): + src = "https:" + src + elif not src.startswith("http"): + src = "https://" + src.lstrip("/") + cover_image = src + + # 2. Extract body + body_m = re.search(r"]*>(.*?)", raw_html, re.DOTALL | re.IGNORECASE) + body = body_m.group(1) if body_m else raw_html + + # Strip style, script, noscript tags + body = re.sub(r']*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + + # 3. Strip unwanted wiki elements + # Remove references: ... and bracketed reference markers + body = re.sub(r']*class=["\x27][^"\x27]*reference[^"\x27]*["\x27][^>]*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*>\s*\[\s*[\d\w\s,-]+\s*\]\s*', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*class=["\x27][^"\x27]*mw-editsection[^"\x27]*["\x27][^>]*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*class=["\x27][^"\x27]*(?:navbox|ambox|metadata|hatnote|sistersitebox|portal)[^"\x27]*["\x27][^>]*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks)[^"\x27]*["\x27][^>]*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + + # Remove infobox entirely from text flow (lead image was already captured) + body = re.sub(r']*class=["\x27][^"\x27]*infobox[^"\x27]*["\x27][^>]*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + body = re.sub(r']*class=["\x27][^"\x27]*infobox[^"\x27]*["\x27][^>]*>.*?', '', body, flags=re.DOTALL | re.IGNORECASE) + + # 4. Standardize figures and images (preserving animated GIFs/SVGs/PNGs) + def clean_figure(m: re.Match) -> str: + f_html = m.group(0) + img_m = re.search(r']*)>', f_html, re.IGNORECASE) + if not img_m: + return "" + attrs = img_m.group(1) + src_m = re.search(r'src=["\x27]([^\s"\x27]+)["\x27]', attrs, re.IGNORECASE) + if not src_m: + return "" + src = src_m.group(1) + if src.startswith("//"): + src = "https:" + src + elif not src.startswith("http"): + src = "https://" + src.lstrip("/") + + alt_m = re.search(r'alt=["\x27]([^\"]*)["\x27]', attrs, re.IGNORECASE) + alt = alt_m.group(1) if alt_m else "" + + cap_m = re.search(r']*>(.*?)', f_html, re.DOTALL | re.IGNORECASE) + cap = "" + if cap_m: + cap_clean = re.sub(r'<[^>]+>', ' ', cap_m.group(1)) + cap = " ".join(cap_clean.split()).strip() + + return f'\n\n
{html.escape(alt)}
{html.escape(cap)}
\n\n' + + body = re.sub(r']*>.*?', clean_figure, body, flags=re.DOTALL | re.IGNORECASE) + + # 5. Clean data tables (tables that are not navboxes/infoboxes) + def clean_data_table(m: re.Match) -> str: + tbl_html = m.group(0) + if re.search(r'class=["\x27][^"\x27]*(?:navbox|ambox|metadata|nowraplinks|infobox)[^"\x27]*["\x27]', tbl_html, re.IGNORECASE): + return "" + cleaned_tbl = re.sub( + r'\s+(?:style|bgcolor|align|valign|width|height|border|cellspacing|cellpadding|id)=["\x27][^"\x27]*["\x27]', + '', + tbl_html, + flags=re.IGNORECASE + ) + cleaned_tbl = re.sub(r']*>', '
', cleaned_tbl, count=1, flags=re.IGNORECASE) + cleaned_tbl = re.sub(r'
', '
', cleaned_tbl, count=1, flags=re.IGNORECASE) + return f'\n\n{cleaned_tbl}\n\n' + + body = re.sub(r']*>.*?', clean_data_table, body, flags=re.DOTALL | re.IGNORECASE) + + # 6. Split into sections by

+ ignore_sections = { + "references", "reference", "see also", "external links", "further reading", + "notes", "footnotes", "sources", "bibliography", "other websites", + "related pages", "citations", "works cited", "navigation menu" + } + + parts = re.split(r'(]*>.*?

)', body, flags=re.DOTALL | re.IGNORECASE) + + raw_chapters: List[Tuple[str, str]] = [] + lead_content = parts[0].strip() + if lead_content: + raw_chapters.append(("Overview", lead_content)) + + idx = 1 + while idx < len(parts): + h2_tag = parts[idx] + h_title = re.sub(r'<[^>]+>', '', h2_tag).strip() + h_title_clean = " ".join(h_title.split()) + sec_content = parts[idx + 1].strip() if idx + 1 < len(parts) else "" + idx += 2 + + if h_title_clean.lower() in ignore_sections: + continue + + raw_chapters.append((h_title_clean, sec_content)) + + # 7. Convert HTML within each chapter into clean reader content + chapters: List[Dict[str, str]] = [] + + def format_section_body(sec_html: str) -> str: + s = re.sub(r']*>(.*?)', r'\n\n### \1\n\n', sec_html, flags=re.DOTALL | re.IGNORECASE) + s = re.sub(r']*>(.*?)', r'\n\n#### \1\n\n', s, flags=re.DOTALL | re.IGNORECASE) + s = re.sub(r']*>(.*?)', r'\n- \1', s, flags=re.DOTALL | re.IGNORECASE) + s = re.sub(r']*>', '\n', s, flags=re.IGNORECASE) + s = re.sub(r']*>(.*?)

', r'\n\n\1\n\n', s, flags=re.DOTALL | re.IGNORECASE) + + # Protect and
+ blocks_protected = [] + def stash_block(match: re.Match) -> str: + token = f"§§BLOCK_{len(blocks_protected)}§§" + blocks_protected.append(match.group(0)) + return f"\n\n{token}\n\n" + + s = re.sub(r']*>.*?', stash_block, s, flags=re.DOTALL | re.IGNORECASE) + s = re.sub(r'
.*?
', stash_block, s, flags=re.DOTALL | re.IGNORECASE) + + s = re.sub(r'<[^>]+>', ' ', s) + s = html.unescape(s) + + lines = [] + for line in s.splitlines(): + trimmed = " ".join(line.split()) + if trimmed: + lines.append(trimmed) + elif lines and lines[-1] != "": + lines.append("") + + res_text = "\n".join(lines).strip() + + for b_idx, block_html in enumerate(blocks_protected): + res_text = res_text.replace(f"§§BLOCK_{b_idx}§§", f"\n\n{block_html}\n\n") + + res_text = re.sub(r'\n{3,}', '\n\n', res_text).strip() + return res_text + + for title, content_html in raw_chapters: + cleaned_body = format_section_body(content_html) + w_count = count_words(cleaned_body) + if w_count >= 15 or " Tuple[str, str, List[Dict[str, str]], str, int, str]: + """ + Fetches a Wikipedia article (by title/URL or by random topic & time budget), + parses into chapters with preserved media and tables, and returns: + (clean_title, author, chapters, cover_url, total_words, wiki_url) + """ + import random + from datetime import datetime + + clean_lang = "en" if (lang or "").lower() in {"en", "standard", "standard english"} else "simple" + target_title = "" + + # Case 1: Specific title or URL provided + if title_or_url and title_or_url.strip(): + extracted_title, detected_lang = extract_wikipedia_title_and_lang(title_or_url, default_lang=clean_lang) + target_title = extracted_title + if detected_lang: + clean_lang = detected_lang + + # Case 2: Pick random article by topic and time budget + if not target_title: + candidates = [] + clean_topic = (topic or "random").lower().strip() + + # Handle 'featured' article of the day + if clean_topic == "featured": + try: + now = datetime.utcnow() + feat_url = f"https://{clean_lang}.wikipedia.org/api/rest_v1/feed/featured/{now.year}/{now.month:02d}/{now.day:02d}" + req = urllib.request.Request(feat_url, headers={"User-Agent": WIKIPEDIA_USER_AGENT}) + with urllib.request.urlopen(req, timeout=5) as r: + feed_data = json.loads(r.read().decode("utf-8")) + if "tfa" in feed_data and feed_data["tfa"].get("title"): + target_title = feed_data["tfa"]["title"] + elif "mostread" in feed_data and feed_data["mostread"].get("articles"): + art_list = [a["title"] for a in feed_data["mostread"]["articles"] if a.get("title")] + if art_list: + target_title = random.choice(art_list[:5]) + except Exception: + pass + + if not target_title: + if clean_topic in WIKIPEDIA_TOPIC_POOLS: + candidates = list(WIKIPEDIA_TOPIC_POOLS[clean_topic].get(clean_lang, [])) + else: + for pool_dict in WIKIPEDIA_TOPIC_POOLS.values(): + candidates.extend(pool_dict.get(clean_lang, [])) + + random.shuffle(candidates) + target_title = candidates[0] if candidates else "Solar_System" + + # Fetch article summary to get clean title and thumbnail + clean_title = target_title.replace("_", " ") + cover_url = "" + wiki_url = f"https://{clean_lang}.wikipedia.org/wiki/{urllib.parse.quote(target_title)}" + + try: + sum_url = f"https://{clean_lang}.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(target_title)}" + req = urllib.request.Request(sum_url, headers={"User-Agent": WIKIPEDIA_USER_AGENT}) + with urllib.request.urlopen(req, timeout=8) as r: + sum_data = json.loads(r.read().decode("utf-8")) + if sum_data.get("title"): + clean_title = sum_data["title"] + if sum_data.get("thumbnail") and sum_data["thumbnail"].get("source"): + cover_url = sum_data["thumbnail"]["source"] + except urllib.error.HTTPError as he: + if he.code == 404: + raise ValueError(f"Статья «{clean_title}» не найдена в Википедии ({clean_lang}.wikipedia.org)") + except Exception: + pass + + # Fetch full Parsoid HTML + raw_html = "" + try: + html_url = f"https://{clean_lang}.wikipedia.org/api/rest_v1/page/html/{urllib.parse.quote(target_title)}" + req = urllib.request.Request(html_url, headers={"User-Agent": WIKIPEDIA_USER_AGENT}) + with urllib.request.urlopen(req, timeout=12) as r: + raw_html = r.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as he: + if he.code == 404: + raise ValueError(f"Статья «{clean_title}» не найдена в Википедии ({clean_lang}.wikipedia.org)") + raise ValueError(f"Ошибка загрузки статьи из Wikipedia: HTTP {he.code}") + except Exception as e: + raise ValueError(f"Не удалось соединиться с сервером Википедии: {e}") + + chapters, html_cover, total_words = clean_wiki_html(raw_html, article_title=clean_title) + if not cover_url and html_cover: + cover_url = html_cover + + if not chapters: + raise ValueError(f"В статье «{clean_title}» не найдено текстового содержимого для чтения") + + edition_name = "Simple English" if clean_lang == "simple" else "Standard English" + author = f"Wikipedia ({edition_name})" + + return clean_title, author, chapters, cover_url, total_words, wiki_url + + + diff --git a/app/static/css/style.css b/app/static/css/style.css index 9f79281..3ffaf00 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -3603,6 +3603,408 @@ body.drawer-open { } } +/* ========================================================================== + Wikipedia Articles Section & Settings Modal + ========================================================================== */ + +.wiki-section { + margin-top: 8px; +} + +.wiki-header { + margin-bottom: 20px; +} + +.wiki-title-row { + display: flex; + align-items: center; + gap: 14px; +} + +.wiki-icon-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + font-size: 24px; + background: rgba(168, 85, 247, 0.12); + border: 1px solid rgba(168, 85, 247, 0.28); + border-radius: var(--radius-md); + flex-shrink: 0; +} + +.wiki-heading { + font-size: 20px; + font-weight: 700; + color: var(--text-main); + margin: 0; +} + +.wiki-action-box { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px 24px; + margin-bottom: 24px; + box-shadow: var(--shadow-sm); +} + +.wiki-primary-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 14px; +} + +.wiki-read-btn { + padding: 10px 24px; + font-weight: 700; + font-size: 15px; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + border: none; + box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35); + transition: all 0.2s ease; +} + +.wiki-read-btn:hover { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(99, 102, 241, 0.45); +} + +.wiki-settings-btn { + padding: 10px 18px; + font-weight: 600; + border-color: rgba(168, 85, 247, 0.35); + color: var(--text-main); +} + +.wiki-settings-btn:hover { + background: rgba(168, 85, 247, 0.12); + border-color: #a855f7; + color: #c084fc; +} + +.wiki-badges-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 13px; +} + +.wiki-badges-label { + color: var(--text-dim); + font-size: 12px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.05em; + margin-right: 4px; +} + +.wiki-badge-chip { + background: rgba(168, 85, 247, 0.12); + color: #c084fc; + border: 1px solid rgba(168, 85, 247, 0.28); + border-radius: var(--radius-full); + padding: 4px 12px; + font-weight: 500; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.wiki-fetch-status { + margin-top: 14px; + padding: 10px 16px; + font-size: 13.5px; + border-radius: var(--radius-md); + transition: all 0.2s ease; +} + +.wiki-status-loading { + background: rgba(99, 102, 241, 0.12); + color: #818cf8; + border: 1px solid rgba(99, 102, 241, 0.3); +} + +.wiki-status-success { + background: rgba(34, 197, 94, 0.12); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.wiki-status-error { + background: rgba(239, 68, 68, 0.12); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.25); +} + +.wiki-empty-state { + padding: 36px 20px; + background: var(--bg-surface); + border: 1px dashed var(--border-color); + border-radius: var(--radius-lg); + margin-bottom: 24px; +} + +/* Card styling for Wikipedia and thumbnails */ +.wiki-card { + border-color: rgba(168, 85, 247, 0.22); +} + +.wiki-card:hover { + border-color: rgba(168, 85, 247, 0.55); + box-shadow: 0 8px 24px rgba(168, 85, 247, 0.15); +} + +.wiki-cover-icon { + background: rgba(168, 85, 247, 0.12); + border-color: rgba(168, 85, 247, 0.3); + color: #c084fc; +} + +.badge-wiki { + background: rgba(168, 85, 247, 0.18); + color: #c084fc; + border: 1px solid rgba(168, 85, 247, 0.35); +} + +.wiki-progress-fill { + background: linear-gradient(90deg, #7c3aed, #a855f7); +} + +.book-cover-img-wrap { + width: 52px; + height: 64px; + border-radius: var(--radius-sm); + overflow: hidden; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: center; +} + +.book-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.badge-time { + display: inline-flex; + align-items: center; + gap: 3px; + background: rgba(255, 255, 255, 0.06); + color: var(--text-dim); + font-size: 11px; + font-weight: 500; + padding: 2px 7px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Wikipedia Settings Modal */ +.wiki-modal-window { + max-width: 620px; + width: 95%; +} + +.wiki-modal-body { + display: flex; + flex-direction: column; + gap: 22px; +} + +.wiki-setting-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.wiki-group-label { + font-size: 13.5px; + font-weight: 700; + color: var(--text-main); + letter-spacing: 0.02em; +} + +.wiki-radio-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.wiki-radio-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.wiki-radio-card:hover { + border-color: rgba(168, 85, 247, 0.4); + background: rgba(168, 85, 247, 0.04); +} + +.wiki-radio-card:has(input:checked) { + border-color: #a855f7; + background: rgba(168, 85, 247, 0.1); + box-shadow: 0 0 0 1px #a855f7; +} + +.wiki-radio-card input[type="radio"] { + margin-top: 3px; + accent-color: #a855f7; +} + +.wiki-radio-card-content { + display: flex; + flex-direction: column; + gap: 5px; +} + +.wiki-card-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.wiki-card-desc { + font-size: 12px; + color: var(--text-dim); + line-height: 1.45; + margin: 0; +} + +.badge-level-pill { + font-size: 10.5px; + font-weight: 700; + padding: 1px 7px; + border-radius: var(--radius-full); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-level-a2 { + background: rgba(34, 197, 94, 0.2); + color: #4ade80; + border: 1px solid rgba(34, 197, 94, 0.35); +} + +.badge-level-b2 { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; + border: 1px solid rgba(59, 130, 246, 0.35); +} + +.wiki-time-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 8px; +} + +.wiki-time-pill { + display: flex; + align-items: center; + position: relative; + cursor: pointer; + user-select: none; +} + +.wiki-time-pill input[type="radio"] { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.wiki-pill-text { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 10px 8px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 600; + color: var(--text-main); + transition: all 0.2s ease; +} + +.wiki-pill-text small { + font-size: 11px; + font-weight: normal; + margin-top: 2px; +} + +.wiki-time-pill:hover .wiki-pill-text { + border-color: rgba(168, 85, 247, 0.4); +} + +.wiki-time-pill input[type="radio"]:checked + .wiki-pill-text { + border-color: #a855f7; + background: rgba(168, 85, 247, 0.12); + color: #c084fc; + box-shadow: 0 0 0 1px #a855f7; +} + +.wiki-select { + width: 100%; + padding: 10px 14px; + font-size: 14px; +} + +.wiki-direct-import-group { + border-top: 1px dashed var(--border-color); + padding-top: 18px; +} + +.wiki-custom-input-wrap { + display: flex; + align-items: center; + gap: 10px; +} + +.wiki-custom-input { + flex: 1; + min-width: 0; +} + +.wiki-custom-fetch-btn { + white-space: nowrap; + padding: 0 18px; + font-weight: 600; +} + +@media (max-width: 640px) { + .wiki-radio-cards { + grid-template-columns: 1fr; + } + .wiki-custom-input-wrap { + flex-direction: column; + } + .wiki-custom-fetch-btn { + width: 100%; + padding: 10px 16px; + } +} + /* Reader Interface */ .reader-interface { background-color: var(--bg-surface); @@ -4273,6 +4675,48 @@ body.drawer-open { vertical-align: middle; } +/* Figures and Media (Images, Diagrams, Animated GIFs, SVGs) */ +.reader-figure { + margin: 28px auto; + padding: 14px; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + align-items: center; + max-width: 95%; + box-sizing: border-box; +} + +.reader-figure-img { + max-width: 100%; + max-height: 480px; + width: auto; + height: auto; + border-radius: var(--radius-md); + object-fit: contain; + display: block; +} + +.reader-figcaption { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + font-size: 13.5px; + line-height: 1.55; + color: var(--text-dim); + text-align: center; + max-width: 90%; + font-style: italic; + user-select: text; +} + +.reader-figcaption .reader-word { + font-style: normal; +} + .reader-content-body p[style*="text-align: center"], .reader-content-body p.text-center { text-indent: 0 !important; diff --git a/app/static/index.html b/app/static/index.html index 734245b..e8edcf4 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -546,7 +546,52 @@
- + +
+ + +
+
+
+
+ 🌐 +
+

Статьи Википедии (Wikipedia Reader)

+

Автономное чтение статей Simple и Standard Wikipedia с иллюстрациями, таблицами и клик-переводом

+
+
+
+
+ + +
+
+ + +
+
+ Текущие фильтры: + Simple English (A2–B1) + ⏱️ ~10-15 мин + 🎲 Любая тема +
+ +
+ + + +
+
+ +
@@ -1253,6 +1298,97 @@ + + +