diff --git a/STATUS.md b/STATUS.md index a676414..f7f07f6 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,8 +1,16 @@ # VESTI — Статус -Обновлено: 2026-09-17 (keep-bulk-checkboxes заархивирован, запушен c8fe5f7; активны: extract-post-sources) +Обновлено: 2026-09-18 (активны: extract-post-sources, manual-direction, post-translate) ## Текущее состояние +- **post-translate — В РАЗРАБОТКЕ** (2026-09-18): в карточках кандидатов и отобранных появилась кнопка **«🌐 Перевести»** — переводит пост на русский локальной моделью (qwen3:8b-nothink, Ollama). Результат сохраняется в `posts.translated_text` (новые посты — колонка добавляется при старте веба), кнопка после перевода скрывается, вместо неё — закладка «Перевод» рядом с «Оригинал» (активна по умолчанию, переключение без перезагрузки на vanilla JS). Повторный клик токены не тратит. Ошибки модели — редирект с `?error=translate:...`. Тест e2e на реальной модели пройден (пост 123: «Kali Purple Essential Training»). - **keep-bulk-checkboxes — ЗААРХИВИРОВАН И ЗАПУШЕН** (2026-09-17, коммит c8fe5f7): проверки массового выбора постов сохраняются между перезагрузками страницы при навигации по постам. Отметки хранятся в sessionStorage браузера (`vesti_bulk_candidates` / `vesti_bulk_selected`); при открытии поста (?selected=N) восстановленные чекбоксы остаются отмеченными, badge sel-count пересчитывается; при submit bulk-формы (Отобрать/Отклонить/Обработать/Опубликовать) отметки очищаются. Изоляция /candidates и /selected независимыми ключами. Попутно исправлен скрытый баг: чекбоксы лежат вне `
` (атрибут form="bulk-form") → селектор `#bulk-form input[name="ids"]` не находил их; заменён на глобальный `input[name="ids"]`. Изменены только web/templates/candidates.html и selected.html (frontend, рестарт не нужен). - **address-field-enhancement — ЗААРХИВИРОВАН** (2026-09-16). Сделано и в проде: форма добавления источника — одно поле Address. Для telegram: `https://t.me/mknewsru` → slug=`mknewsru`, название «Мой Компьютер» (Telethon get_entity); для rss: `https://lwn.net/headlines/rss` → slug=`lwn`, название «LWN.net» (feedparser). Кнопка «🔎 Найти» в форме. direction — datalist из реальных значений (DIRECTIONS + БД), свободный ввод остаётся. БД не менялась (address — только UI-концепция, в yaml по-прежнему url/channel/feed_url). - **fix-sources-add-channel** (2026-09-16): добавление telegram-источника через веб снова работает. Поле `channel` добавлено в форму (required для telegram через JS) и показывается в таблице. Причина: UI-форма не имела поля channel. `web/templates/sources.html` ранее отсутствовал в git — добавлен. diff --git a/openspec/changes/post-translate/.openspec.yaml b/openspec/changes/post-translate/.openspec.yaml new file mode 100644 index 0000000..f2cbbe6 --- /dev/null +++ b/openspec/changes/post-translate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-18 diff --git a/openspec/changes/post-translate/design.md b/openspec/changes/post-translate/design.md new file mode 100644 index 0000000..bd95b85 --- /dev/null +++ b/openspec/changes/post-translate/design.md @@ -0,0 +1,151 @@ +# Design — post-translate + +## БД + +При старте веба (в `_db()` или отдельный хелпер `_ensure_schema()`): идемпотентный ALTER: + +```python +def _ensure_translated_column(conn): + cols = [r[1] for r in conn.execute("PRAGMA table_info(posts)")] + if "translated_text" not in cols: + conn.execute("ALTER TABLE posts ADD COLUMN translated_text TEXT") + conn.commit() +``` + +Вызывать один раз при старте приложения (после `app = FastAPI(...)`, на том же conn, +что и остальные инициализации, если есть; в проекте инициализация схемы идёт в `_db()` +через PRAGMA — достаточно вызвать в startup). + +## web/app.py + +Добавить helper (рядом с `_post_sources`): + +```python +TRANSLATE_MAX_CHARS = 6000 + +def _translate_text(text: str) -> str: + """Перевод текста поста на русский локальной моделью (qwen3:8b-nothink). + Возвращает переведённый текст; при неудаче — raise (обрабатывается в роуте).""" + from classifier.classify import call_ollama + src = (text or "").strip()[:TRANSLATE_MAX_CHARS] + if not src: + raise ValueError("empty text") + prompt = ( + "Переведи новость на русский язык. Сохрани смысл, факты, имена,\n" + "числа и ссылки. Не пересказывай своими словами — это именно перевод.\n" + "Верни ТОЛЬКО перевод, без пояснений, без кавычек вокруг.\n\n" + f"Оригинал:\n{src}" + ) + r = call_ollama(prompt, 0, 0, "") + t = (r.get("text") or r.get("content") or "").strip() + if not t: + raise ValueError("empty translation") + return t +``` + +Роут (рядом с `/posts/{id}/rewrite`): + +```python +@app.post("/posts/{post_id}/translate") +def post_translate(post_id: int, request: Request, + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): + """Перевод поста на русский локальной моделью. Повторный клик при готовом + переводе токены не тратит (translated_text уже заполнен).""" + _require_auth(request) + conn = _db() + p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone() + if not p: + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, error="notfound"), status_code=302) + if not (p["translated_text"] or "").strip(): + try: + t = _translate_text(p["text"] or "") + except Exception as e: + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"translate:{e}"), status_code=302) + conn.execute("UPDATE posts SET translated_text=? WHERE id=?", (t, post_id)) + conn.commit() + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) +``` + +## Шаблоны + +### candidates.html (правая панель) + +Заголовок «Оригинальный пост» → закладки + кнопка перевода: + +```html +
+ + {% if not selected.translated_text %} + + {{ hid }} + + + {% endif %} +
+
+
+ ... (существующий текст + медиа) ... +
+ {% if selected.translated_text %} +
+
{{ selected.translated_text | safe_html }}
+
+ {% endif %} +
+``` + +### selected.html (правая панель, колонка «Оригинальный пост») + +Та же структура, с учётом что блок сейчас — колонка col-md-6 с заголовком: + +```html +
+
+
Оригинальный пост
+ {% if not selected.translated_text %} +
+ {{ hid }} + +
+ {% endif %} +
+ {% if selected.translated_text %} + + {% endif %} +
+
...оригинал+медиа...
+ {% if selected.translated_text %} +
...перевод...
+ {% endif %} +
+
+``` + +Замечание: bootstrap 5.3 локальный (static/bootstrap.min.css) — pills/collapse работают. +JS для табов — встроенный bootstrap.bundle.min.js НЕ подключён (в base.html только CSS). +Поэтому закладки сделаем состоянием без JS? Нет: в проекте нет JS-файлов, но кнопки +«Перевести» — это обычные POST-формы (работают и так). Для показа/скрытия вкладок +добавим минимальный vanilla-JS скрипт в {% block scripts %} (как уже делается для +bulk-чекбоксов) — 15 строк. + +Альтернатива без JS: radio-кнопки + CSS `:checked` сосед. Но проще и поддерживаемее +vanilla JS в scripts-блоке (паттерн уже есть в проекте). + +## Ошибки/границы + +- `call_ollama` использует OLLAMA_URL/MODEL из env (по умолчанию qwen3:8b-nothink). +- Длинный текст (>6000) обрезается; перевод будет неполным для очень длинных — приемлемо + для модерации. +- Повторный клик «Перевести» при готовом переводе — просто редирект, без LLM. +- Кнопка «Перевести» скрывается, когда перевод уже есть (вместо неё — закладка «Перевод»). \ No newline at end of file diff --git a/openspec/changes/post-translate/proposal.md b/openspec/changes/post-translate/proposal.md new file mode 100644 index 0000000..6449c17 --- /dev/null +++ b/openspec/changes/post-translate/proposal.md @@ -0,0 +1,50 @@ +# post-translate + +Перевод поста на русский локальной моделью (кнопка «Перевести» в карточках кандидатов и отобранных). + +## Why + +Многие посты в кандидатах приходят на английском (LWN, bleepingcomputer, omarchy и т.п.). +Чтобы прочитать и отобрать новость, модератору нужен перевод на русский прямо в карточке. +Сейчас поста нет: есть только оригинал на языке источника. + +Ограничение: перевод должен быть ПО ЗАПРОСУ (кнопка), потому что массовый перевод +всех кандидатов будет жечь токены локальной модели без пользы (модератор смотрит +далеко не все посты). Это соответствует существующему паттерну: классификация и +пересказ тоже запускаются только по кнопке. + +## What Changes + +- **БД**: колонка `posts.translated_text TEXT` (через ALTER TABLE при старте, идемпотентно, + как это делает classifier/classify.py для direction/summary). +- **web/app.py**: + - helper `_translate_text(text)` → переводит текст через qwen3:8b-nothink + (Ollama /v1, та же `call_ollama`, что у rewrite/reclassify). Текст обрезается + до 6000 символов (перевод длинного текста + ответ упирается в контекст локальной + модели быстрее, чем классификация). Возвращает str, не падает — при ошибке кидает исключение. + - POST `/posts/{post_id}/translate` (src, group_by, direction, own, q — как у rewrite): + читает пост, переводит, сохраняет в `translated_text` БД, редирект обратно с `selected=post_id`. + Если `translated_text` уже есть — не тратит токены, сразу редирект (повторный клик — бесплатный). + - render candidates/selected — уже передаёт `selected` (sqlite row), колонка подтянется + автоматически (SELECT *). +- **web/templates/candidates.html и selected.html**: + - Заголовок «Оригинальный пост» → становится кликабельной закладкой/аккордеоном + (bootstrap collapse, без JS-файлов — как остальной проект). + - Рядом с ним кнопка «Перевести» — POST-форма на `/posts/{id}/translate`. + - Если `translated_text` есть — вторая закладка «Перевод» с текстом перевода. + - Активная закладка по умолчанию: «Перевод», если перевод уже есть, иначе «Оригинал». + +## Why Not + +- Не переводим автоматически при крауле — жечь токены на все посты (включая неотобранные). +- Не делаем отдельную страницу/ход в БД «переводы» — достаточно одной колонки, + перевод — атрибут поста, а не сущность. +- Не используем внешний API перевода (DeepL и т.п.) — локальная модель уже есть, + бесплатна и подходит для новостного текста. + +## Impact + +- Веб-морда: в карточках кандидатов и отобранных появляется перевод на русский по кнопке. +- Рестарт vesti-web.service после выката (колонка добавляется идемпотентно). +- Никаких изменений в pipeline/publisher/краулере — перевод чисто для модерации, + не для публикации. \ No newline at end of file diff --git a/openspec/changes/post-translate/specs/post-translate/spec.md b/openspec/changes/post-translate/specs/post-translate/spec.md new file mode 100644 index 0000000..740a988 --- /dev/null +++ b/openspec/changes/post-translate/specs/post-translate/spec.md @@ -0,0 +1,58 @@ +# Spec — post-translate + +## ADDED Requirements + +### Requirement: Перевод поста на русский в карточках кандидатов и отобранных + +Модератор может перевести оригинальный пост на русский язык одной кнопкой, +не покидая карточку. Перевод выполняет локальная модель (Ollama qwen3:8b-nothink); +результат сохраняется в БД и показывается в отдельной закладке «Перевод» рядом +с закладкой «Оригинал». + +#### Scenario: Кнопка «Перевести» у поста без перевода + +Given карточка кандидата или отобранного поста, у которого ещё нет перевода +When модератор нажимает «🌐 Перевести» +Then текст поста переводится локальной моделью на русский +And перевод сохраняется в `posts.translated_text` +And страница перезагружается, и рядом с «Оригинал» появляется закладка «Перевод» + +#### Scenario: Перевод уже готов + +Given у поста уже сохранён перевод (`translated_text` заполнен) +When модератор открывает карточку +Then кнопка «Перевести» скрыта +And доступны две закладки: «Оригинал» и «Перевод» + +#### Scenario: Ошибка модели при переводе + +Given локальная модель недоступна или вернула пустой ответ +When модератор нажимает «🌐 Перевести» +Then перевод не сохраняется, пост не меняется +And модератор остаётся на той же карточке с сообщением об ошибке (параметр error) + +#### Scenario: Повторный клик «Перевести» не тратит токены + +Given перевод уже сохранён +When модератор нажимает «🌐 Перевести» (если кнопка видна — например, после ошибки) +Then сервер видит заполненный `translated_text` и не вызывает модель +And просто возвращает на ту же карточку + +### Requirement: Закладки «Оригинал» / «Перевод» + +Оригинальный пост и его перевод показываются в карточке как две переключаемые +закладки (вкладки), переключение — без перезагрузки страницы. + +#### Scenario: Переключение между оригиналом и переводом + +Given карточка с готовым переводом +When модератор кликает «Перевод» +Then показывается текст перевода (перевод отображается тем же стилем, что оригинал) +When модератор кликает «Оригинал» +Then снова показывается исходный текст с медиа + +#### Scenario: Перевод отсутствует — показывается только оригинал + +Given у поста нет перевода +Then карточка показывает закладку/блок «Оригинал» и кнопку «Перевести» +And закладки «Перевод» нет \ No newline at end of file diff --git a/openspec/changes/post-translate/tasks.md b/openspec/changes/post-translate/tasks.md new file mode 100644 index 0000000..41d584a --- /dev/null +++ b/openspec/changes/post-translate/tasks.md @@ -0,0 +1,11 @@ +# Задачи + +- [x] web/app.py: идемпотентный ALTER TABLE posts ADD COLUMN translated_text (при старте) +- [x] web/app.py: helper _translate_text (прямой запрос к Ollama — call_ollama жёстко парсит JSON, для перевода непригоден; лимит 6000) +- [x] web/app.py: POST /posts/{post_id}/translate (повторный клик — без LLM, проверено) +- [x] web/templates/candidates.html: закладки «Оригинал»/«Перевод» + кнопка «🌐 Перевести» +- [x] web/templates/selected.html: то же в колонке оригинала +- [x] vanilla JS переключения вкладок (scripts-блок, паттерн bulk-чекбоксов) +- [x] openspec validate post-translate — чисто +- [x] Рестарт vesti-web, ручная проверка: пост EN → «Перевести» → закладка «Перевод»; повторный клик — без модели +- [ ] Обновить STATUS.md \ No newline at end of file diff --git a/web/app.py b/web/app.py index 9ff52d8..56b1e5a 100644 --- a/web/app.py +++ b/web/app.py @@ -126,6 +126,9 @@ def _migrate(): if "is_read" not in cols: conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0") conn.commit() + if "translated_text" not in cols: + conn.execute("ALTER TABLE posts ADD COLUMN translated_text TEXT") + conn.commit() finally: conn.close() @@ -202,6 +205,65 @@ def _cand_back(group_by: str = "", direction: str = "", own: str = "", q: str = return (f"/{src}?" + "&".join(parts)) if parts else f"/{src}" +def _post_sources(post: dict | None) -> list[dict]: + """Источники, найденные в тексте поста: + флаг known (уже в реестре). Без LLM.""" + if not post: + return [] + from sources.extract import extract_sources_from_text + found = extract_sources_from_text(post.get("text") or "", post.get("url")) + if not found: + return [] + with _db() as conn: + slugs = {r[0] for r in conn.execute("SELECT slug FROM sources")} + for f in found: + f["known"] = f["slug_hint"] in slugs + return found + + +TRANSLATE_MAX_CHARS = 6000 + + +def _translate_text(text: str) -> str: + """Перевод текста поста на русский локальной моделью (qwen3:8b-nothink). + + Возвращает переведённый текст; при неудаче бросает исключение (обрабатывается + в роуте — редирект с error). Текст обрезается до TRANSLATE_MAX_CHARS. + + Прямой запрос к Ollama (не через call_ollama из classifier): call_ollama + захардкожена под классификацию — парсит ответ модели как JSON, а перевод + это обычный текст, и JSON-парсинг его ломает. + """ + import httpx + from classifier.classify import OLLAMA_URL, OLLAMA_MODEL, TIMEOUT + src = (text or "").strip()[:TRANSLATE_MAX_CHARS] + if not src: + raise ValueError("empty text") + prompt = ( + "Переведи новость на русский язык. Сохрани смысл, факты, имена,\n" + "числа и ссылки. Не пересказывай своими словами — это именно перевод.\n" + "Верни ТОЛЬКО перевод, без пояснений, без кавычек вокруг.\n\n" + f"Оригинал:\n{src}" + ) + r = httpx.post( + f"{OLLAMA_URL}/v1/chat/completions", + json={ + "model": OLLAMA_MODEL, + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.2, + "stream": False, + "think": False, + }, + timeout=TIMEOUT, + ) + r.raise_for_status() + data = r.json() + content = (data.get("choices") or [{}])[0].get("message", {}).get("content") or "" + t = content.strip() + if not t: + raise ValueError("empty translation") + return t + + STATUS_LABELS = { "new": "💎 Новые", "selected": "⭐ Отобранные", @@ -337,7 +399,7 @@ def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group @app.get("/candidates", response_class=HTMLResponse) def candidates(request: Request, direction: str = "", status: str = "", own: str = "", - q: str = "", group_by: str = "date", selected: int = 0, error: str = ""): + q: str = "", group_by: str = "date", selected: int = 0, error: str = "", did: str = ""): _require_auth(request) conn = _db() grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by) @@ -370,15 +432,16 @@ def candidates(request: Request, direction: str = "", status: str = "", own: str filters_qs = "&".join(qs) html = tpl.get_template("candidates.html").render( posts=grouped, counts=counts, status=status, direction=direction, own=own, - q=q, group_by=group_by, selected=sel, error=error, filters_qs=filters_qs, + q=q, group_by=group_by, selected=sel, error=error, did=did, filters_qs=filters_qs, directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS, + post_sources=_post_sources(sel), ) return HTMLResponse(html) @app.get("/selected", response_class=HTMLResponse) def selected(request: Request, direction: str = "", own: str = "", q: str = "", - group_by: str = "date", selected: int = 0, error: str = ""): + group_by: str = "date", selected: int = 0, error: str = "", did: str = ""): """Отобранные: только status='selected' (внешние). Полная обработка постов.""" _require_auth(request) conn = _db() @@ -411,8 +474,9 @@ def selected(request: Request, direction: str = "", own: str = "", q: str = "", filters_qs = "&".join(qs) html = tpl.get_template("selected.html").render( posts=grouped, counts=counts, status="selected", direction=direction, own=own, - q=q, group_by=group_by, selected=sel, error=error, filters_qs=filters_qs, + q=q, group_by=group_by, selected=sel, error=error, did=did, filters_qs=filters_qs, directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS, + post_sources=_post_sources(sel), ) return HTMLResponse(html) @@ -572,24 +636,36 @@ def post_comment(post_id: int, request: Request, comment: str = Form(""), @app.post("/posts/{post_id}/reclassify") def post_reclassify(post_id: int, request: Request, group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), - src: str = Form("candidates")): - """Переклассификация локальной моделью (qwen).""" + src: str = Form("candidates"), manual_direction: str = Form("")): + """Переклассификация локальной моделью (qwen) или ручное указание направления.""" _require_auth(request) try: - impl_reclassify(post_id) + impl_reclassify(post_id, manual_direction) except Exception as e: return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"reclassify:{e}"), status_code=302) return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) -def impl_reclassify(post_id: int): - """Перезапуск классификации локальной моделью. Бросает исключение при неудаче.""" - from classifier.classify import classify_text +def impl_reclassify(post_id: int, manual_direction: str = ""): + """Перезапуск классификации локальной моделью. Если manual_direction задан и каноничен — + направление ставится напрямую, без LLM. Бросает исключение при неудаче.""" + from classifier.keywords import DIRECTIONS_CANON conn = _db() p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone() if not p: conn.close() raise ValueError("notfound") + md = (manual_direction or "").strip() + if md in DIRECTIONS_CANON: + # ручное направление: без LLM, как словарное попадание + conn.execute( + "UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=1 WHERE id=?", + (md, "low", 1, "", post_id), + ) + conn.commit() + conn.close() + return + from classifier.classify import classify_text res = classify_text(dict(p)) conn.execute( "UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?", @@ -632,6 +708,30 @@ def post_rewrite(post_id: int, request: Request, return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) +@app.post("/posts/{post_id}/translate") +def post_translate(post_id: int, request: Request, + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form(""), + src: str = Form("candidates")): + """Перевод поста на русский локальной моделью. Повторный клик при готовом + переводе токены не тратит (translated_text уже заполнен).""" + _require_auth(request) + conn = _db() + p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone() + if not p: + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, error="notfound"), status_code=302) + if not (p["translated_text"] or "").strip(): + try: + t = _translate_text(p["text"] or "") + except Exception as e: + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id, error=f"translate:{e}"), status_code=302) + conn.execute("UPDATE posts SET translated_text=? WHERE id=?", (t, post_id)) + conn.commit() + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, src=src, selected=post_id), status_code=302) + + @app.get("/published", response_class=HTMLResponse) def published(request: Request): _require_auth(request) @@ -965,6 +1065,57 @@ async def sources_add(request: Request): return RedirectResponse(url=f"/sources?did=added:{res}", status_code=302) +@app.post("/sources/add-from-post") +async def sources_add_from_post(request: Request): + """Быстрое добавление источника из карточки поста (кнопка «➕» в «Источники из поста»). + + Форма несёт address и crawler; создаём источник как при ручном добавлении, + возвращаемся на страницу кандидатов/отобранных с did-сообщением. + """ + _require_auth(request) + form = await request.form() + address = (form.get("address") or "").strip() + crawler = (form.get("crawler") or "telegram").strip() + src = form.get("src") or "candidates" + selected_id = int(form.get("selected") or 0) + group_by = form.get("group_by") or "" + direction = form.get("direction") or "" + own = form.get("own") or "" + q = form.get("q") or "" + + back = _cand_back(group_by, direction, own, q, src=src, selected=selected_id) + if not address: + return RedirectResponse(url=back + "&did=error:empty_address", status_code=302) + + parsed = parse_address(address, crawler) + if not parsed: + return RedirectResponse(url=back + "&did=error:bad_address", status_code=302) + + slug = parsed.get("slug") + with _db() as conn: + exists = conn.execute("SELECT 1 FROM sources WHERE slug=?", (slug,)).fetchone() + if exists: + return RedirectResponse(url=back + f"&did=exists:{slug}", status_code=302) + + # имя — человекочитаемое: для telegram берём handle, для site — домен + name = address + ok, res = add_or_update_source({ + "slug": slug, + "name": f"Из поста: {name}", + "crawler": crawler, + "url": parsed.get("url"), + "channel": parsed.get("channel"), + "feed_url": parsed.get("feed_url"), + "direction": "", + "lang": "ru", + "priority": "P1", + "enabled": True, + }) + if not ok: + return RedirectResponse(url=back + f"&did=error:{res}", status_code=302) + return RedirectResponse(url=back + f"&did=added:{slug}", status_code=302) + + @app.post("/sources/{source_id}/edit") async def sources_edit(source_id: int, request: Request): """Редактирование источника: обновляем yaml (slug не меняем) + полный ре-синк.""" diff --git a/web/templates/candidates.html b/web/templates/candidates.html index 11e0be3..23ce79c 100644 --- a/web/templates/candidates.html +++ b/web/templates/candidates.html @@ -165,8 +165,22 @@
-
Оригинальный пост
-
+
+ + {% if not selected.translated_text %} +
+ {{ hid }} + +
+ {% endif %} +
+
+
{% if selected.text %}
{{ selected.text | safe_html }}
{% endif %} @@ -185,6 +199,12 @@ {% endif %} {% if selected.summary %}
резюме {{ selected.summary }}
{% endif %}
+ {% if selected.translated_text %} +
+
{{ selected.translated_text | safe_html }}
+
+ {% endif %} +
{% else %}
Выберите кандидата слева
@@ -230,6 +250,18 @@ try { sessionStorage.removeItem(KEY); } catch(e) {} }); upd(); + // Переключение закладок «Оригинал» / «Перевод» (без CDN, vanilla JS) + document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(btn){ + btn.addEventListener('click', function(){ + document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(b){ + b.classList.remove('active'); + }); + this.classList.add('active'); + document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('show','active'); }); + var t = document.querySelector(this.dataset.bsTarget); + if (t) t.classList.add('show','active'); + }); + }); })(); {% endblock %} \ No newline at end of file diff --git a/web/templates/selected.html b/web/templates/selected.html index 3756bbf..7f3d4d2 100644 --- a/web/templates/selected.html +++ b/web/templates/selected.html @@ -177,24 +177,49 @@
-
Оригинальный пост
-
- {% if selected.text %} -
{{ selected.text | safe_html }}
- {% endif %} - {% if selected.media_path %} - {% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %} - {% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %} - {% if media_ext in ['jpg','jpeg','png','gif','webp'] %} - - медиа поста - - {% elif media_ext in ['mp4','webm','mov'] %} - - {% else %} - 📎 Медиафайл +
+
Оригинальный пост
+ {% if not selected.translated_text %} +
+ {{ hid }} + +
{% endif %} +
+ {% if selected.translated_text %} + {% endif %} +
+
+
+ {% if selected.text %} +
{{ selected.text | safe_html }}
+ {% endif %} + {% if selected.media_path %} + {% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %} + {% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %} + {% if media_ext in ['jpg','jpeg','png','gif','webp'] %} + + медиа поста + + {% elif media_ext in ['mp4','webm','mov'] %} + + {% else %} + 📎 Медиафайл + {% endif %} + {% endif %} +
+
+ {% if selected.translated_text %} +
+
+
{{ selected.translated_text | safe_html }}
+
+
+ {% endif %}
@@ -261,6 +286,18 @@ try { sessionStorage.removeItem(KEY); } catch(e) {} }); upd(); + // Переключение закладок «Оригинал» / «Перевод» (без CDN, vanilla JS) + document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(btn){ + btn.addEventListener('click', function(){ + document.querySelectorAll('button[data-bs-toggle="pill"]').forEach(function(b){ + b.classList.remove('active'); + }); + this.classList.add('active'); + document.querySelectorAll('.tab-pane').forEach(function(p){ p.classList.remove('show','active'); }); + var t = document.querySelector(this.dataset.bsTarget); + if (t) t.classList.add('show','active'); + }); + }); })(); {% endblock %} \ No newline at end of file