feat(reader): добавить парсер RFC из RFC Editor и отдельную группу спецификаций на книжной полке

- Реализован парсер RFC в app/reader.py с авто-загрузкой с rfc-editor.org и datatracker.ietf.org
- Очистка пагинации ([Page X], \x0c) и разбиение спецификаций на структурированные главы (TOC, разделы)
- Эндпоинт POST /api/books/fetch-rfc с защитой от дублирования и поддержкой номеров/URL
- Визуальное разделение на книжной полке через hr-разделитель и блок спецификаций IETF & RFC
- Быстрые пресеты стандартов (HTTP/1.1, HTTP/2, TLS 1.3, OAuth 2.0, WebSocket, QUIC, TCP, DNS)
- Интеграция в интерактивную читалку (перевод по клику, авто-захват контекста, SRS карточки)
- Unit-тесты парсера и API (31/31 passed) и обновление Service Worker кэша
This commit is contained in:
Antigravity committed 2026-10-06 21:46:07 +03:00
1 parent e73d3470b3
commit 82b7a654ca
8 files changed
+877 -66

No files matched your search

+86 -1
View File
@@ -30,7 +30,7 @@ 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
from app.reader import parse_epub, parse_text, parse_pdf, count_words, extract_rfc_number, fetch_and_parse_rfc, parse_rfc_text
from app.ai import translate_sentence_ai, get_ollama_status
from app.phrasal import detect_phrasal_verb
from app.cefr import (
@@ -182,6 +182,11 @@ class BookProgressRequest(BaseModel):
position: Optional[float] = None
class RfcFetchRequest(BaseModel):
rfc: str
force: Optional[bool] = False
class SentenceTranslateRequest(BaseModel):
sentence: str = Field(..., min_length=1)
target_word: Optional[str] = ""
@@ -1315,6 +1320,86 @@ async def api_create_book(
return res
@app.post("/api/books/fetch-rfc")
async def api_fetch_rfc(
req: RfcFetchRequest,
user: dict = Depends(require_user),
):
"""
Fetches an RFC specification by number or URL from RFC Editor & Datatracker,
parses sections into readable chapters, and saves to the user's bookshelf.
If the RFC already exists on user's shelf and force=False, returns the existing book.
"""
raw_query = (req.rfc or "").strip()
if not raw_query:
raise HTTPException(status_code=400, detail="Укажите номер или URL спецификации RFC")
try:
rfc_num = extract_rfc_number(raw_query)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
# Check if user already loaded this RFC
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,
created_at, updated_at
FROM books
WHERE user_id = ? AND source_type = 'rfc' AND (title LIKE ? OR title LIKE ?)
ORDER BY id DESC LIMIT 1
""",
(user["id"], f"RFC {rfc_num}:%", f"RFC {rfc_num}"),
).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, rfc_num, total_words = fetch_and_parse_rfc(raw_query)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка загрузки RFC: {e}")
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
) VALUES (?, ?, ?, 'rfc', ?, 0, 0.0, ?)
""",
(user["id"], title, author, chapters_json, total_words),
)
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,
created_at, updated_at
FROM books WHERE id = ?
""",
(new_id,),
).fetchone()
res = dict(row)
res["chapters"] = chapters
res["already_existed"] = False
return res
@app.get("/api/books/{book_id}")
async def api_get_book(book_id: int, user: dict = Depends(require_user)):
"""