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(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'?[uo]l[^>]*>', '\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 @@
-
+
+
+
+
+
+
+
+
+
+
+
+ 📖 Читать статью
+
+
+ ⚙️ Настройки
+
+
+
+ Текущие фильтры:
+ Simple English (A2–B1)
+ ⏱️ ~10-15 мин
+ 🎲 Любая тема
+
+
+
+
+
+
+
🌐
+
Нет сохраненных статей из Википедии
+
Нажмите «Читать статью» выше, чтобы подобрать случайную познавательную статью по вашим настройкам, или настройте тему и объем через шестерёнку ⚙️.
+
+
+
+
+
@@ -1253,6 +1298,97 @@
+
+
+
+
+
+
+
+
Сложность языка (Wikipedia Edition):
+
+
+
+
+
+ Simple English
+ A2–B1
+
+
Простая грамматика и базовая лексика (simple.wikipedia.org). Рекомендуется для уверенного чтения без перегрузки редкими терминами.
+
+
+
+
+
+
+ Standard English
+ B2–C1
+
+
Полный оригинальный текст (en.wikipedia.org). Академическая лексика, развернутые описания и глубокая научная терминология.
+
+
+
+
+
+
+
+
+
+
+ Тематика статьи:
+
+ 🎲 Любая / Случайная тема
+ 🚀 Наука, космос и астрономия
+ 💻 Технологии, IT и интернет
+ 🏛️ История и великие цивилизации
+ 🌿 Природа, животные и экология
+ 🧠 Психология, мозг и память
+ ⭐ Статья дня (Featured Article)
+
+
+
+
+
+
Или загрузить конкретную статью по ссылке/названию:
+
+
+
+
+
+
+
diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js
index 63a3c70..e3f046f 100644
--- a/app/static/js/modules/api.js
+++ b/app/static/js/modules/api.js
@@ -230,6 +230,26 @@ export const api = {
detail = errData.detail || detail;
} catch (_) {}
throw new Error(detail);
+ },
+ async fetchWikipedia({ query = '', lang = 'simple', time_budget = '10-15', topic = 'random', force = false } = {}) {
+ const resp = await fetch('/api/books/fetch-wikipedia', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query: query,
+ lang: lang,
+ time_budget: time_budget,
+ topic: topic,
+ force: force
+ })
+ });
+ if (resp.ok) return await resp.json();
+ let detail = 'Не удалось загрузить статью из Wikipedia';
+ try {
+ const errData = await resp.json();
+ detail = errData.detail || detail;
+ } catch (_) {}
+ throw new Error(detail);
}
},
diff --git a/app/static/js/modules/reader.js b/app/static/js/modules/reader.js
index 8852032..6ed71da 100644
--- a/app/static/js/modules/reader.js
+++ b/app/static/js/modules/reader.js
@@ -42,7 +42,10 @@ export function getCurrentChapterIndex() {
function createBookCard(b) {
const card = document.createElement('div');
const isRfc = (b.source_type === 'rfc');
- card.className = isRfc ? 'book-card rfc-card' : 'book-card';
+ const isWiki = (b.source_type === 'wikipedia');
+ if (isWiki) card.className = 'book-card wiki-card';
+ else if (isRfc) card.className = 'book-card rfc-card';
+ else card.className = 'book-card';
const totalCh = Math.max(1, b.chapters_count || 1);
const chIndex = Math.max(0, Math.min(totalCh - 1, b.current_chapter || 0));
@@ -56,22 +59,42 @@ function createBookCard(b) {
? `
1 глава • ${totalPct}% ${totalPct}% `
: `
Глава ${chIndex + 1} из ${totalCh} (${chPct}%) ${totalPct}% `;
- let coverIcon = '📄';
- if (b.source_type === 'epub') coverIcon = '📕';
- else if (b.source_type === 'pdf') coverIcon = '📑';
- else if (b.source_type === 'rfc') coverIcon = '🌐';
+ let coverHtml = '';
+ if (b.cover_url) {
+ coverHtml = `
`;
+ } else {
+ let coverIcon = '📄';
+ if (b.source_type === 'epub') coverIcon = '📕';
+ else if (b.source_type === 'pdf') coverIcon = '📑';
+ else if (isRfc) coverIcon = '📜';
+ else if (isWiki) coverIcon = '🌐';
+ const coverIconClass = isWiki ? 'wiki-cover-icon' : (isRfc ? 'rfc-cover-icon' : '');
+ coverHtml = `
${coverIcon}
`;
+ }
- const badgeClass = isRfc ? 'badge-format badge-rfc' : 'badge-format';
+ let badgeClass = 'badge-format';
+ if (isWiki) badgeClass = 'badge-format badge-wiki';
+ else if (isRfc) badgeClass = 'badge-format badge-rfc';
+
+ const readMinutes = Math.max(1, Math.round((b.total_words || 800) / 180));
+ const timeBadge = (b.total_words && b.total_words > 0)
+ ? `
⏱️ ~${readMinutes} мин `
+ : '';
+ const progressFillClass = isWiki ? 'wiki-progress-fill' : (isRfc ? 'rfc-progress-fill' : '');
+
+ const authorDisplay = b.author || (isWiki ? 'Wikipedia' : (isRfc ? 'IETF' : 'Неизвестный автор'));
+ const sourceLabel = isWiki ? 'WIKIPEDIA' : (isRfc ? 'RFC' : (b.source_type || 'text').toUpperCase());
card.innerHTML = `
-
${coverIcon}
+ ${coverHtml}
${escapeHtml(b.title)}
-
${escapeHtml(b.author || (isRfc ? 'IETF' : 'Неизвестный автор'))}
+
${escapeHtml(authorDisplay)}
- ${escapeHtml((b.source_type || 'text').toUpperCase())}
+ ${escapeHtml(sourceLabel)}
+ ${timeBadge}
${b.total_words ? b.total_words.toLocaleString() + ' слов' : ''}
@@ -81,14 +104,14 @@ function createBookCard(b) {
${progressLabels}
📖 Читать
🎓 Слова книги
- 🗑️
+ 🗑️
`;
@@ -100,7 +123,7 @@ function createBookCard(b) {
window.location.hash = `#srs/${encodeURIComponent(bookTag)}`;
});
card.querySelector('.delete-book-btn').addEventListener('click', async () => {
- const typeName = isRfc ? 'спецификацию' : 'книгу';
+ const typeName = isWiki ? 'статью Википедии' : (isRfc ? 'спецификацию' : 'книгу');
if (confirm(`Удалить ${typeName} "${b.title}" из библиотеки?`)) {
await api.books.delete(b.id);
loadBookshelf();
@@ -117,6 +140,8 @@ export async function loadBookshelf() {
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
+ const wikiGrid = document.getElementById('wiki-grid');
+ const wikiEmptyState = document.getElementById('wiki-empty-state');
if (!bookshelfContainer) return;
if (!window.location.hash.includes('/book/')) {
@@ -125,8 +150,10 @@ export async function loadBookshelf() {
}
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
+ if (wikiGrid) wikiGrid.innerHTML = '';
if (bookshelfEmpty) bookshelfEmpty.classList.add('hidden');
if (rfcEmptyState) rfcEmptyState.classList.add('hidden');
+ if (wikiEmptyState) wikiEmptyState.classList.add('hidden');
try {
const books = await api.books.getAll();
@@ -141,12 +168,16 @@ export function renderBookshelf(books) {
const bookshelfEmpty = document.getElementById('bookshelf-empty');
const rfcGrid = document.getElementById('rfc-grid');
const rfcEmptyState = document.getElementById('rfc-empty-state');
+ const wikiGrid = document.getElementById('wiki-grid');
+ const wikiEmptyState = document.getElementById('wiki-empty-state');
if (bookshelfGrid) bookshelfGrid.innerHTML = '';
if (rfcGrid) rfcGrid.innerHTML = '';
+ if (wikiGrid) wikiGrid.innerHTML = '';
- const generalBooks = (books || []).filter(b => b.source_type !== 'rfc');
+ const generalBooks = (books || []).filter(b => b.source_type !== 'rfc' && b.source_type !== 'wikipedia');
const rfcBooks = (books || []).filter(b => b.source_type === 'rfc');
+ const wikiBooks = (books || []).filter(b => b.source_type === 'wikipedia');
if (bookshelfEmpty) {
bookshelfEmpty.classList.toggle('hidden', generalBooks.length > 0);
@@ -157,6 +188,15 @@ export function renderBookshelf(books) {
});
}
+ if (wikiEmptyState) {
+ wikiEmptyState.classList.toggle('hidden', wikiBooks.length > 0);
+ }
+ if (wikiGrid) {
+ wikiBooks.forEach(b => {
+ wikiGrid.appendChild(createBookCard(b));
+ });
+ }
+
if (rfcEmptyState) {
rfcEmptyState.classList.toggle('hidden', rfcBooks.length > 0);
}
@@ -375,6 +415,24 @@ export function buildChapterDOM(chapterIdx, knownWordsMap, isContinuous = false)
blockEl.appendChild(header);
blockEl.appendChild(pre);
+ } else if (block.type === 'figure') {
+ blockEl = document.createElement('figure');
+ blockEl.className = 'reader-figure';
+
+ const img = document.createElement('img');
+ img.className = 'reader-figure-img';
+ img.src = block.src;
+ img.alt = block.alt || '';
+ img.loading = 'lazy';
+ img.onerror = () => { blockEl.style.display = 'none'; };
+ blockEl.appendChild(img);
+
+ if (block.caption) {
+ const figcaption = document.createElement('figcaption');
+ figcaption.className = 'reader-figcaption';
+ renderInteractiveSpans(figcaption, block.caption, block.caption, knownWordsMap);
+ blockEl.appendChild(figcaption);
+ }
} else if (block.type === 'table') {
blockEl = document.createElement('div');
blockEl.className = 'reader-table-wrap';
@@ -804,6 +862,86 @@ export function parseContentBlocks(content) {
continue;
}
+ // 2a. HTML Figure Block:
...
+ if (/^
/i.test(lines[i])) {
+ i++;
+ break;
+ }
+ i++;
+ }
+ const figHtml = figLines.join('\n');
+ const imgMatch = figHtml.match(/ ]*)>/i);
+ let src = '';
+ let alt = '';
+ if (imgMatch) {
+ const srcM = imgMatch[1].match(/src=["']([^"']+)["']/i);
+ const altM = imgMatch[1].match(/alt=["']([^"']*)["']/i);
+ if (srcM) src = srcM[1];
+ if (altM) alt = altM[1];
+ }
+ let caption = '';
+ const capMatch = figHtml.match(/]*>([\s\S]*?)<\/figcaption>/i);
+ if (capMatch) {
+ caption = capMatch[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+ }
+ if (src) {
+ blocks.push({
+ type: 'figure',
+ src: src,
+ alt: alt,
+ caption: caption
+ });
+ continue;
+ }
+ }
+
+ // 2b. HTML Data Table: or
+ if (/^(?:]*class=["'][^"']*reader-table-wrap[^"']*["'][^>]*>\s*)?
(?:\s*<\/div>)?/i.test(lines[i])) {
+ i++;
+ break;
+ }
+ i++;
+ }
+ const fullTableHtml = tableLines.join('\n');
+ const tpl = document.createElement('template');
+ tpl.innerHTML = fullTableHtml;
+ const tableEl = tpl.content.querySelector('table');
+ if (tableEl) {
+ const headers = [];
+ const ths = tableEl.querySelectorAll('thead th, tr:first-child th');
+ ths.forEach(th => headers.push(th.textContent.trim()));
+
+ const rows = [];
+ const trs = tableEl.querySelectorAll('tbody tr, tr');
+ trs.forEach((tr) => {
+ const tds = tr.querySelectorAll('td');
+ if (tds.length > 0) {
+ const rowCells = [];
+ tds.forEach(td => rowCells.push(td.textContent.trim()));
+ rows.push(rowCells);
+ }
+ });
+
+ if (headers.length > 0 || rows.length > 0) {
+ blocks.push({
+ type: 'table',
+ headers: headers,
+ alignments: headers.map(() => 'left'),
+ rows: rows
+ });
+ continue;
+ }
+ }
+ }
+
// 3. Markdown Table: lines starting with | and ending with |
if (trimmed.startsWith('|') && trimmed.endsWith('|')) {
const tableLines = [];
@@ -950,6 +1088,9 @@ export function parseContentBlocks(content) {
/^[-*+]\s+/.test(curTrim) ||
/^\d+\.\s+/.test(curTrim) ||
/^(\-{3,}|\*{3,}|\_{3,})$/.test(curTrim) ||
+ /^]*align=["']?center/i.test(curTrim)) {
break;
}
@@ -1899,7 +2040,7 @@ export function initReader() {
if (wordSpan) {
e.stopPropagation();
const word = wordSpan.dataset.word;
- const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote')?.textContent || word;
+ const context = wordSpan.dataset.context || wordSpan.closest('p, div, li, td, th, blockquote, figcaption')?.textContent || word;
handleWordClick(word, context, wordSpan);
}
});
@@ -2448,4 +2589,208 @@ export function initReader() {
});
});
}
+
+ // -----------------------------------------------------------------------
+ // Wikipedia Autonomous Import & Settings
+ // -----------------------------------------------------------------------
+ const WIKI_SETTINGS_KEY = 'notes_wiki_settings';
+ const wikiReadBtn = document.getElementById('wiki-read-btn');
+ const wikiSettingsBtn = document.getElementById('wiki-settings-btn');
+ const wikiSettingsModal = document.getElementById('wiki-settings-modal');
+ const wikiSettingsCloseBtn = document.getElementById('wiki-settings-close-btn');
+ const wikiSettingsSaveBtn = document.getElementById('wiki-settings-save-btn');
+ const wikiFetchStatus = document.getElementById('wiki-fetch-status');
+ const wikiCustomForm = document.getElementById('wiki-custom-form');
+ const wikiCustomInput = document.getElementById('wiki-custom-input');
+ const wikiCustomFetchBtn = document.getElementById('wiki-custom-fetch-btn');
+
+ const wikiActiveLangBadge = document.getElementById('wiki-active-lang-badge');
+ const wikiActiveTimeBadge = document.getElementById('wiki-active-time-badge');
+ const wikiActiveTopicBadge = document.getElementById('wiki-active-topic-badge');
+
+ const TOPIC_LABELS = {
+ 'random': '🎲 Любая тема',
+ 'science': '🚀 Наука и космос',
+ 'tech': '💻 Технологии и IT',
+ 'history': '🏛️ История',
+ 'nature': '🌿 Природа',
+ 'psychology': '🧠 Психология',
+ 'featured': '⭐ Статья дня'
+ };
+
+ const TIME_LABELS = {
+ '5': '⏱️ ~5 мин',
+ '10-15': '⏱️ ~10-15 мин',
+ '20+': '⏱️ 20+ мин',
+ 'any': '⏱️ Любой объем'
+ };
+
+ function getStoredWikiSettings() {
+ try {
+ const raw = localStorage.getItem(WIKI_SETTINGS_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') {
+ return {
+ lang: parsed.lang || 'simple',
+ time_budget: parsed.time_budget || '10-15',
+ topic: parsed.topic || 'random'
+ };
+ }
+ }
+ } catch (_) {}
+ return { lang: 'simple', time_budget: '10-15', topic: 'random' };
+ }
+
+ function saveStoredWikiSettings(settings) {
+ try {
+ localStorage.setItem(WIKI_SETTINGS_KEY, JSON.stringify(settings));
+ } catch (_) {}
+ updateWikiUIBadges(settings);
+ }
+
+ function updateWikiUIBadges(settings) {
+ if (wikiActiveLangBadge) {
+ wikiActiveLangBadge.textContent = settings.lang === 'en' ? 'Standard English (B2–C1)' : 'Simple English (A2–B1)';
+ }
+ if (wikiActiveTimeBadge) {
+ wikiActiveTimeBadge.textContent = TIME_LABELS[settings.time_budget] || '⏱️ ~10-15 мин';
+ }
+ if (wikiActiveTopicBadge) {
+ wikiActiveTopicBadge.textContent = TOPIC_LABELS[settings.topic] || '🎲 Любая тема';
+ }
+ }
+
+ function syncWikiModalInputs(settings) {
+ const langRadios = document.querySelectorAll('input[name="wiki-lang"]');
+ langRadios.forEach(r => {
+ r.checked = (r.value === settings.lang);
+ });
+ const timeRadios = document.querySelectorAll('input[name="wiki-time"]');
+ timeRadios.forEach(r => {
+ r.checked = (r.value === settings.time_budget);
+ });
+ const topicSelect = document.getElementById('wiki-topic-select');
+ if (topicSelect) {
+ topicSelect.value = settings.topic || 'random';
+ }
+ }
+
+ function readWikiModalInputs() {
+ const langRadio = document.querySelector('input[name="wiki-lang"]:checked');
+ const timeRadio = document.querySelector('input[name="wiki-time"]:checked');
+ const topicSelect = document.getElementById('wiki-topic-select');
+
+ return {
+ lang: langRadio ? langRadio.value : 'simple',
+ time_budget: timeRadio ? timeRadio.value : '10-15',
+ topic: topicSelect ? topicSelect.value : 'random'
+ };
+ }
+
+ const currentWikiSettings = getStoredWikiSettings();
+ updateWikiUIBadges(currentWikiSettings);
+
+ if (wikiSettingsBtn && wikiSettingsModal) {
+ wikiSettingsBtn.addEventListener('click', () => {
+ syncWikiModalInputs(getStoredWikiSettings());
+ wikiSettingsModal.classList.remove('hidden');
+ });
+ }
+
+ if (wikiSettingsCloseBtn && wikiSettingsModal) {
+ wikiSettingsCloseBtn.addEventListener('click', () => {
+ wikiSettingsModal.classList.add('hidden');
+ });
+ }
+
+ if (wikiSettingsModal) {
+ wikiSettingsModal.addEventListener('click', (e) => {
+ if (e.target === wikiSettingsModal) {
+ wikiSettingsModal.classList.add('hidden');
+ }
+ });
+ }
+
+ if (wikiSettingsSaveBtn && wikiSettingsModal) {
+ wikiSettingsSaveBtn.addEventListener('click', () => {
+ const newSettings = readWikiModalInputs();
+ saveStoredWikiSettings(newSettings);
+ wikiSettingsModal.classList.add('hidden');
+ showToast('Настройки статей Википедии сохранены!');
+ });
+ }
+
+ async function handleWikiFetch(params) {
+ const isCustom = Boolean(params.query);
+ const activeBtn = isCustom ? wikiCustomFetchBtn : wikiReadBtn;
+
+ if (activeBtn) activeBtn.disabled = true;
+ if (wikiReadBtn) wikiReadBtn.disabled = true;
+ if (wikiCustomInput) wikiCustomInput.disabled = true;
+
+ if (wikiFetchStatus) {
+ wikiFetchStatus.className = 'wiki-fetch-status wiki-status-loading';
+ wikiFetchStatus.innerHTML = `⏳ ${isCustom ? 'Загрузка статьи по запросу...' : 'Подбираем и загружаем статью из Википедии...'} `;
+ wikiFetchStatus.classList.remove('hidden');
+ }
+
+ try {
+ const book = await api.books.fetchWikipedia(params);
+ if (wikiFetchStatus) {
+ wikiFetchStatus.className = 'wiki-fetch-status wiki-status-success';
+ const estMin = Math.max(1, Math.round((book.total_words || 800) / 180));
+ wikiFetchStatus.innerHTML = `✅ Статья «${escapeHtml(book.title)}» сохранена (~${estMin} мин, ${book.total_words ? book.total_words.toLocaleString() : ''} слов)! `;
+ }
+ showToast(book.already_existed ? `Открываем ${book.title}` : `Статья Википедии сохранена офлайн!`);
+ if (wikiCustomInput) wikiCustomInput.value = '';
+ await loadBookshelf();
+ setTimeout(() => {
+ if (wikiFetchStatus) wikiFetchStatus.classList.add('hidden');
+ if (wikiSettingsModal) wikiSettingsModal.classList.add('hidden');
+ window.location.hash = `#reader/book/${book.id}`;
+ }, 500);
+ } catch (err) {
+ console.error('Wikipedia fetch error:', err);
+ if (wikiFetchStatus) {
+ wikiFetchStatus.className = 'wiki-fetch-status wiki-status-error';
+ wikiFetchStatus.innerHTML = `❌ Ошибка: ${escapeHtml(err.message || 'Не удалось загрузить статью')} `;
+ wikiFetchStatus.classList.remove('hidden');
+ }
+ showToast(err.message || 'Ошибка загрузки статьи Wikipedia', 4000);
+ } finally {
+ if (activeBtn) activeBtn.disabled = false;
+ if (wikiReadBtn) wikiReadBtn.disabled = false;
+ if (wikiCustomInput) wikiCustomInput.disabled = false;
+ }
+ }
+
+ if (wikiReadBtn) {
+ wikiReadBtn.addEventListener('click', () => {
+ const currentSettings = getStoredWikiSettings();
+ handleWikiFetch({
+ query: '',
+ lang: currentSettings.lang,
+ time_budget: currentSettings.time_budget,
+ topic: currentSettings.topic,
+ force: false
+ });
+ });
+ }
+
+ if (wikiCustomForm) {
+ wikiCustomForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const val = wikiCustomInput ? wikiCustomInput.value.trim() : '';
+ if (!val) return;
+ const currentSettings = getStoredWikiSettings();
+ handleWikiFetch({
+ query: val,
+ lang: currentSettings.lang,
+ time_budget: currentSettings.time_budget,
+ topic: currentSettings.topic,
+ force: false
+ });
+ });
+ }
}
diff --git a/app/static/sw.js b/app/static/sw.js
index 44fa93f..4d9d341 100644
--- a/app/static/sw.js
+++ b/app/static/sw.js
@@ -1,7 +1,7 @@
// Service Worker for Notes English PWA (Stage 2)
// Strategy: Cache-first for static assets, Network-first for API calls.
-const CACHE_NAME = 'notes-v6.2';
+const CACHE_NAME = 'notes-v6.3';
const OFFLINE_URL = '/';
const PRECACHE_URLS = [
diff --git a/tests/test_api.py b/tests/test_api.py
index 5e1249a..65ad701 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2046,6 +2046,191 @@ def test_clean_russian_translation_removes_romanization():
assert clean_like == "подобный, похожий, как, типа"
+def test_extract_wikipedia_title_and_lang():
+ from app.reader import extract_wikipedia_title_and_lang
+
+ t1, l1 = extract_wikipedia_title_and_lang("https://simple.wikipedia.org/wiki/Solar_System")
+ assert t1 == "Solar_System"
+ assert l1 == "simple"
+
+ t2, l2 = extract_wikipedia_title_and_lang("https://en.wikipedia.org/wiki/Quantum_mechanics")
+ assert t2 == "Quantum_mechanics"
+ assert l2 == "en"
+
+ t3, l3 = extract_wikipedia_title_and_lang("Artificial Intelligence", default_lang="simple")
+ assert t3 == "Artificial_Intelligence"
+ assert l3 == "simple"
+
+
+def test_clean_wiki_html_unit():
+ from app.reader import clean_wiki_html
+
+ sample_parsoid_html = """
+
+
+
+
+
+ This article is about astronomy.
+
+ Lead
+
+ Orbital info
+
+ The Sun is the star at the center of the Solar System[1] .
+
+
+ A powerful solar flare erupting from the surface
+
+ It is a nearly perfect ball of hot plasma[citation needed] .
+
+ Characteristics [edit]
+ The Sun is a G-type main-sequence star.
+
+ Parameter Value
+ Radius 696,340 km
+
+
+ References
+ 1. Solar Physics Journal 2024.
+
+ Other websites
+
+
+
+ """
+
+ chapters, cover_url, total_words = clean_wiki_html(sample_parsoid_html, article_title="Sun")
+
+ assert cover_url.startswith("https://thumb.wikimedia.org/")
+ assert len(chapters) == 2 # Overview and Characteristics (References and Other websites ignored)
+
+ ch1 = chapters[0]
+ assert ch1["title"] == "Overview"
+ assert "star at the center of the Solar System" in ch1["content"]
+ assert "[1]" not in ch1["content"]
+ assert "[citation needed]" not in ch1["content"]
+ assert ".some-css" not in ch1["content"]
+ assert "bad" not in ch1["content"]
+ assert "" in ch1["content"]
+ assert "https://thumb.wikimedia.org/wikipedia/commons/thumb/b/b2/Solar_flare.jpg/400px-Solar_flare.jpg" in ch1["content"]
+ assert "A powerful solar flare erupting from the surface" in ch1["content"]
+
+ ch2 = chapters[1]
+ assert ch2["title"] == "Characteristics"
+ assert "[edit]" not in ch2["title"]
+ assert "G-type main-sequence star" in ch2["content"]
+ assert "reader-table" in ch2["content"]
+ assert "696,340 km" in ch2["content"]
+ assert total_words > 20
+
+
+def test_wikipedia_api_endpoints_workflow(monkeypatch):
+ import urllib.request
+ import io
+ from app.reader import clean_wiki_html
+
+ sample_html = """
+
+
+
+
+ Earth is the third planet from the Sun and the only astronomical object known to harbor life.
+ Atmosphere
+ The atmosphere of Earth is composed mostly of nitrogen and oxygen.
+
+ """
+
+ sample_summary = {
+ "title": "Earth",
+ "thumbnail": {"source": "https://thumb.wikimedia.org/test/cover.jpg"}
+ }
+
+ class MockResponse:
+ def __init__(self, data_bytes, status=200):
+ self.data_bytes = data_bytes
+ self.status = status
+
+ def read(self, *args, **kwargs):
+ return self.data_bytes
+
+ def decode(self, *args, **kwargs):
+ return self.data_bytes.decode(*args, **kwargs)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ pass
+
+ def mock_urlopen(req, timeout=None):
+ url = req.full_url if hasattr(req, "full_url") else str(req)
+ if "summary" in url:
+ return MockResponse(json.dumps(sample_summary).encode("utf-8"))
+ elif "html" in url:
+ return MockResponse(sample_html.encode("utf-8"))
+ return MockResponse(b"{}")
+
+ monkeypatch.setattr(urllib.request, "urlopen", mock_urlopen)
+
+ # 1. Login user
+ login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"})
+ assert login_resp.status_code == 200
+ token = login_resp.cookies.get(config.SESSION_COOKIE_NAME)
+ auth_headers = {"Cookie": f"{config.SESSION_COOKIE_NAME}={token}"}
+
+ # 2. Fetch Wikipedia article
+ resp = client.post(
+ "/api/books/fetch-wikipedia",
+ json={"query": "Earth", "lang": "simple", "topic": "science"},
+ headers=auth_headers
+ )
+ assert resp.status_code == 200
+ book_data = resp.json()
+ assert book_data["title"] == "Earth"
+ assert book_data["source_type"] == "wikipedia"
+ assert "cover.jpg" in book_data["cover_url"]
+ assert len(book_data["chapters"]) >= 1
+ assert book_data["already_existed"] is False
+ book_id = book_data["id"]
+
+ # 3. Fetch again without force - should return already_existed=True
+ resp2 = client.post(
+ "/api/books/fetch-wikipedia",
+ json={"query": "Earth", "lang": "simple", "force": False},
+ headers=auth_headers
+ )
+ assert resp2.status_code == 200
+ assert resp2.json()["already_existed"] is True
+ assert resp2.json()["id"] == book_id
+
+ # 4. Check book list includes cover_url and source_type
+ list_resp = client.get("/api/books", headers=auth_headers)
+ assert list_resp.status_code == 200
+ books = list_resp.json()
+ wiki_book = next((b for b in books if b["id"] == book_id), None)
+ assert wiki_book is not None
+ assert wiki_book["source_type"] == "wikipedia"
+ assert "cover.jpg" in wiki_book["cover_url"]
+
+ # 5. Update reading progress
+ prog_resp = client.put(
+ f"/api/books/{book_id}/progress",
+ json={"current_chapter": 1, "current_position": 0.5},
+ headers=auth_headers
+ )
+ assert prog_resp.status_code == 200
+ assert prog_resp.json()["current_chapter"] == 1
+ assert prog_resp.json()["current_position"] == 0.5
+ assert prog_resp.json()["cover_url"] != ""
+
+ # 6. Delete book
+ del_resp = client.delete(f"/api/books/{book_id}", headers=auth_headers)
+ assert del_resp.status_code == 200
+ assert del_resp.json()["deleted_id"] == book_id
+
+
+