mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)
This commit is contained in:
+296
@@ -0,0 +1,296 @@
|
||||
# VESTI Web — локальный интерфейс управления (FastAPI + Jinja2 + Bootstrap 5.3 + HTMX).
|
||||
# Спека vesti-web: 127.0.0.1:8400, авторизация (пароль в .env), списки кандидатов,
|
||||
# подтверждение/отклонение черновиков (→ tg-publisher + банк), опубликованные с метриками.
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import markdown as md_lib
|
||||
from fastapi import FastAPI, Form, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from markupsafe import Markup, escape
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = BASE_DIR / "db" / "vesti.db"
|
||||
TPL_DIR = Path(__file__).resolve().parent / "templates"
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
# Медиа постов: свежие — media/<file>; старые (исторический баг) — media/media/<file>
|
||||
MEDIA_DIRS = [
|
||||
BASE_DIR / "media",
|
||||
BASE_DIR / "media" / "media",
|
||||
]
|
||||
|
||||
# Авторизация: одна учётка, пароль из .env (VESTI_WEB_PASSWORD или ADMIN_PASSWORD — legacy имя)
|
||||
WEB_PASSWORD = os.getenv("VESTI_WEB_PASSWORD") or os.getenv("ADMIN_PASSWORD") or "admin" # TODO: в проде только из .env
|
||||
SESSION_COOKIE = "vesti_session"
|
||||
|
||||
app = FastAPI(title="VESTI Web")
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
tpl = Environment(loader=FileSystemLoader(str(TPL_DIR)))
|
||||
tpl.filters["from_json"] = lambda s: json.loads(s or "[]")
|
||||
|
||||
|
||||
def md_filter(text: str) -> Markup:
|
||||
"""Markdown → безопасный HTML (экранируем HTML до парсинга, XSS-safe)."""
|
||||
if not text:
|
||||
return Markup("")
|
||||
html = md_lib.markdown(escape(text), extensions=["nl2br", "sane_lists"])
|
||||
return Markup(html)
|
||||
|
||||
|
||||
def dt_filter(value) -> str:
|
||||
"""2026-08-15T15:53 → 15:53 15.08.2026; None/мусор → ''."""
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return value.strftime("%H:%M %d.%m.%Y")
|
||||
except (ValueError, TypeError):
|
||||
return str(value)[:16] if value else ""
|
||||
|
||||
|
||||
tpl.filters["markdown"] = md_filter
|
||||
tpl.filters["dt"] = dt_filter
|
||||
|
||||
|
||||
def _db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=8000")
|
||||
return conn
|
||||
|
||||
|
||||
def _session_ok(request: Request) -> bool:
|
||||
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
||||
|
||||
|
||||
def _require_auth(request: Request):
|
||||
if not _session_ok(request):
|
||||
raise HTTPException(status_code=303, headers={"Location": "/login"})
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(request: Request):
|
||||
_require_auth(request)
|
||||
return RedirectResponse(url="/candidates", status_code=302)
|
||||
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
if _session_ok(request):
|
||||
return RedirectResponse(url="/candidates", status_code=302)
|
||||
return HTMLResponse(tpl.get_template("login.html").render(request=request))
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(request: Request, password: str = Form(...)):
|
||||
if secrets.compare_digest(password, WEB_PASSWORD):
|
||||
sess = secrets.token_hex(16)
|
||||
# сохраняем сессию (в проде — в env; для прототипа — атрибут)
|
||||
os.environ["VESTI_WEB_SESSION"] = sess
|
||||
resp = RedirectResponse(url="/candidates", status_code=302)
|
||||
resp.set_cookie(SESSION_COOKIE, sess, httponly=True)
|
||||
return resp
|
||||
return RedirectResponse(url="/login?error=1", status_code=302)
|
||||
|
||||
|
||||
@app.get("/candidates", response_class=HTMLResponse)
|
||||
def candidates(request: Request, direction: str = "", status: str = "new", own: str = "", error: str = ""):
|
||||
_require_auth(request)
|
||||
conn = _db()
|
||||
q = """SELECT p.*, s.name source_name FROM posts p
|
||||
LEFT JOIN sources s ON s.id=p.source_id
|
||||
WHERE 1=1"""
|
||||
params = []
|
||||
if direction:
|
||||
q += " AND p.direction=?"
|
||||
params.append(direction)
|
||||
if status:
|
||||
q += " AND p.status=?"
|
||||
params.append(status)
|
||||
if own in ("1", "0"):
|
||||
q += " AND p.is_own=?"
|
||||
params.append(int(own))
|
||||
q += " ORDER BY p.published_at DESC LIMIT 100"
|
||||
rows = conn.execute(q, params).fetchall()
|
||||
conn.close()
|
||||
html = tpl.get_template("candidates.html").render(
|
||||
posts=rows,
|
||||
direction=direction,
|
||||
status=status,
|
||||
own=own,
|
||||
directions=["linux", "tech", "politics", "games", "electronics", "llm"],
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/published", response_class=HTMLResponse)
|
||||
def published(request: Request):
|
||||
_require_auth(request)
|
||||
conn = _db()
|
||||
rows = conn.execute(
|
||||
"""SELECT p.*, s.name source_name,
|
||||
pub.tg_message_id, pub.views pub_views, pub.bundle_path, pub.distributed_dirs
|
||||
FROM posts p
|
||||
LEFT JOIN sources s ON s.id=p.source_id
|
||||
LEFT JOIN published pub ON pub.post_id=p.id
|
||||
WHERE p.status='published' ORDER BY p.published_at DESC LIMIT 100"""
|
||||
).fetchall()
|
||||
conn.close()
|
||||
html = tpl.get_template("published.html").render(posts=rows)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.post("/posts/{post_id}/approve")
|
||||
async def approve(post_id: int, request: Request):
|
||||
"""Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published."""
|
||||
_require_auth(request)
|
||||
from web.publisher_client import publish as http_publish, get_views as http_views
|
||||
from publisher.card import make_card
|
||||
from web.store import create_bundle
|
||||
|
||||
conn = _db()
|
||||
post = conn.execute("SELECT p.*, s.name source_name, s.lang FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?", (post_id,)).fetchone()
|
||||
if not post:
|
||||
conn.close()
|
||||
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
|
||||
|
||||
post = dict(post)
|
||||
is_own = int(post.get("is_own") or 0) == 1
|
||||
dirn = post.get("direction") or "linux" # направление поста (используется ниже)
|
||||
lang = post.get("lang") or "ru"
|
||||
|
||||
# направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста
|
||||
form = None
|
||||
dirs_selected = []
|
||||
comment = ""
|
||||
if request.method == "POST":
|
||||
try:
|
||||
form = await request.form() # Starlette: form awaitable
|
||||
dirs_selected = form.getlist("dirs")
|
||||
comment = (form.get("comment") or "").strip()
|
||||
except Exception:
|
||||
dirs_selected = []
|
||||
if not dirs_selected:
|
||||
# по умолчанию: направления классификации (classifications) или направление поста
|
||||
cls = conn.execute(
|
||||
"SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,)
|
||||
).fetchall()
|
||||
dirs_selected = [r["direction"] for r in cls] if cls else [dirn]
|
||||
# дедуп, без пустых
|
||||
dirs_selected = list(dict.fromkeys([d for d in dirs_selected if d]))
|
||||
|
||||
card = make_card(post, comment)
|
||||
|
||||
if is_own:
|
||||
# СВОЙ контент → fan-out: сервис сам берёт каналы из конфига (VESTI_BOT_CHANNELS)
|
||||
# направления передаём в card (для карточки), не как адресаты.
|
||||
card["direction"] = dirn
|
||||
card["lang"] = lang
|
||||
try:
|
||||
res = http_publish(card)
|
||||
except RuntimeError as e:
|
||||
conn.close()
|
||||
return RedirectResponse(url=f"/candidates?error=publisher:{e}", status_code=302)
|
||||
tg_message_id = next(iter((r or {}).get("message_id") or 0 for r in res.get("results", {}).values()), 0) if res else 0
|
||||
views_by_dir = {
|
||||
d: (res.get("results", {}).get(d, {}).get("views") or 0)
|
||||
for d in res.get("results", {})
|
||||
}
|
||||
else:
|
||||
# Внешний → без fan-out: сервис публикует в каналы по умолчанию
|
||||
card["direction"] = dirn
|
||||
card["lang"] = lang
|
||||
try:
|
||||
res = http_publish(card)
|
||||
except RuntimeError as e:
|
||||
conn.close()
|
||||
return RedirectResponse(url=f"/candidates?publisher_error={e}", status_code=302)
|
||||
# результаты ключаются по КАНАЛУ (@dedinit_vesti), а не по направлению — берём первый успешный
|
||||
_res = res.get("results", {})
|
||||
tg_message_id = next((r.get("message_id") or 0 for r in _res.values() if r.get("message_id")), 0)
|
||||
views_by_dir = {d: (r.get("views") or 0) for d, r in _res.items()}
|
||||
|
||||
# банк статей (markdown-бандл + медиа) — по каждому направлению fan-out
|
||||
try:
|
||||
bundle = create_bundle(post, bundles_dir=BASE_DIR / "bundles", directions=dirs_selected)
|
||||
except Exception as e:
|
||||
bundle = {"error": str(e)}
|
||||
|
||||
# 3) статус + запись published (для своего — distributed_dirs + метрики)
|
||||
import json as _json
|
||||
conn.execute("UPDATE posts SET status='published' WHERE id=?", (post_id,))
|
||||
distributed = _json.dumps(dirs_selected, ensure_ascii=False) if is_own else _json.dumps([dirn])
|
||||
conn.execute(
|
||||
"INSERT INTO published (post_id, bundle_path, tg_message_id, distributed_dirs, views) VALUES (?,?,?,?,?)",
|
||||
(post_id, bundle.get("path", ""), tg_message_id, distributed, sum(views_by_dir.values())),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse(url="/published", status_code=302)
|
||||
|
||||
|
||||
@app.post("/posts/{post_id}/reject")
|
||||
def reject(post_id: int, request: Request):
|
||||
_require_auth(request)
|
||||
conn = _db()
|
||||
conn.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse(url="/candidates?status=rejected", status_code=302)
|
||||
|
||||
|
||||
@app.get("/metrics", response_class=HTMLResponse)
|
||||
def metrics(request: Request):
|
||||
_require_auth(request)
|
||||
conn = _db()
|
||||
rows = conn.execute(
|
||||
"""SELECT p.direction, COUNT(*) cnt, SUM(p.views) views, AVG(p.reactions_total) avg_reactions
|
||||
FROM posts p GROUP BY p.direction"""
|
||||
).fetchall()
|
||||
runs = conn.execute("SELECT * FROM runs ORDER BY started_at DESC LIMIT 10").fetchall()
|
||||
conn.close()
|
||||
html = tpl.get_template("metrics.html").render(dirs=rows, runs=runs)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/logout")
|
||||
def logout():
|
||||
resp = RedirectResponse(url="/login", status_code=302)
|
||||
resp.delete_cookie(SESSION_COOKIE)
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/bundle/{bundle_path:path}")
|
||||
def bundle(request: Request, bundle_path: str):
|
||||
"""Показывает markdown-бандл как текст (ссылка из published)."""
|
||||
_require_auth(request)
|
||||
full = (BASE_DIR / "bundles" / bundle_path).resolve()
|
||||
# защита от path traversal
|
||||
if not str(full).startswith(str((BASE_DIR / "bundles").resolve())):
|
||||
return HTMLResponse("bad path", status_code=400)
|
||||
if not full.exists():
|
||||
return HTMLResponse("not found", status_code=404)
|
||||
return Response(full.read_text(encoding="utf-8"), media_type="text/markdown")
|
||||
|
||||
|
||||
@app.get("/media/{filename}")
|
||||
def media(request: Request, filename: str):
|
||||
"""Отдаёт медиа-файл поста (из media/ или media/media/). Авторизация."""
|
||||
_require_auth(request)
|
||||
name = os.path.basename(filename) # защита от path traversal
|
||||
if not name:
|
||||
return HTMLResponse("bad filename", status_code=400)
|
||||
for d in MEDIA_DIRS:
|
||||
f = (d / name).resolve()
|
||||
if f.exists() and f.is_file():
|
||||
return FileResponse(f)
|
||||
return HTMLResponse("not found", status_code=404)
|
||||
@@ -0,0 +1,40 @@
|
||||
# HTTP-клиент к publisher-service (микросервис).
|
||||
# web больше НЕ импортирует publisher.bot — только этот клиент (HTTP POST /api/v1/publish).
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
PUBLISHER_URL = os.getenv("PUBLISHER_URL", "http://127.0.0.1:8410")
|
||||
TIMEOUT = 30.0
|
||||
|
||||
|
||||
def publish(card: dict, channels: list[str] | None = None) -> dict:
|
||||
"""Вызывает publisher-service. Возвращает {"ok", "results", "dry_run"} от сервиса.
|
||||
|
||||
При недоступности сервиса — raise RuntimeError с понятным текстом.
|
||||
"""
|
||||
payload = {"card": card}
|
||||
if channels:
|
||||
payload["channels"] = channels
|
||||
try:
|
||||
r = httpx.post(f"{PUBLISHER_URL}/api/v1/publish", json=payload, timeout=TIMEOUT)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"publisher-service недоступен ({PUBLISHER_URL}): {e}") from e
|
||||
if r.status_code >= 400:
|
||||
try:
|
||||
detail = r.json().get("detail", r.text[:200])
|
||||
except Exception:
|
||||
detail = r.text[:200]
|
||||
raise RuntimeError(f"publisher-service ошибка {r.status_code}: {detail}")
|
||||
return r.json()
|
||||
|
||||
|
||||
def get_views(channel: str, message_id: int) -> int:
|
||||
"""Views по каналу/сообщению из publisher-service."""
|
||||
try:
|
||||
r = httpx.get(f"{PUBLISHER_URL}/api/v1/views/{channel}/{message_id}", timeout=TIMEOUT)
|
||||
if r.status_code == 200:
|
||||
return int(r.json().get("views", 0))
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
+243
@@ -0,0 +1,243 @@
|
||||
# VESTI — Банк статей: markdown-бандлы + медиа.
|
||||
# Спека: openspec/changes/tg-crawler-publisher-prototype/specs/news-store/spec.md
|
||||
# Путь: bundles/<направление>/<YYYY-MM>/<slug>.md
|
||||
# Медиа: bundles/<направление>/<YYYY-MM>/media/<slug>/
|
||||
# frontmatter: title, date, direction, source, url, views, reactions, interest, status
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = BASE / "db" / "vesti.db"
|
||||
BUNDLES_DIR = BASE / "bundles"
|
||||
MEDIA_SRC = BASE / "media" / "media" # каталог, куда краулер складывает файлы
|
||||
|
||||
DIRECTION_LABELS = {
|
||||
"linux": "Linux",
|
||||
"tech": "Технологии",
|
||||
"politics": "Политика",
|
||||
"games": "Игры",
|
||||
"electronics": "Электроника",
|
||||
"llm": "LLM / AI",
|
||||
}
|
||||
|
||||
|
||||
def slugify(text: str, maxlen: int = 60) -> str:
|
||||
"""Транслитерация + slug. Убирает URL/служебное. 'Omarchy — Arch Linux...' -> 'omarchy-arch-linux'."""
|
||||
text = (text or "").strip()
|
||||
# убираем markdown/эмодзи/служебное
|
||||
text = re.sub(r"[*_`#\[\]()>|]", "", text)
|
||||
# убираем URL (http, т.me, ссылки) — из них берём только куски без протокола
|
||||
text = re.sub(r"https?://\S+|t\.me/\S+|ya\.cc/\S+", "", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
# обрезаем до 6 слов — иначе слаг раздувается в белиберду
|
||||
words_ = text.split()
|
||||
if len(words_) > 6:
|
||||
text = " ".join(words_[:6])
|
||||
# транслитерация кириллицы (правильные буквосочетания)
|
||||
tr_map = {
|
||||
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
|
||||
"ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
|
||||
"н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
|
||||
"ф": "f", "х": "h", "ц": "ts", "ч": "ch", "ш": "sh", "щ": "sch",
|
||||
"ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
|
||||
"А": "A", "Б": "B", "В": "V", "Г": "G", "Д": "D", "Е": "E", "Ё": "E",
|
||||
"Ж": "Zh", "З": "Z", "И": "I", "Й": "Y", "К": "K", "Л": "L", "М": "M",
|
||||
"Н": "N", "О": "O", "П": "P", "Р": "R", "С": "S", "Т": "T", "У": "U",
|
||||
"Ф": "F", "Х": "H", "Ц": "Ts", "Ч": "Ch", "Ш": "Sh", "Щ": "Sch",
|
||||
"Ъ": "", "Ы": "Y", "Ь": "", "Э": "E", "Ю": "Yu", "Я": "Ya",
|
||||
}
|
||||
text = "".join(tr_map.get(ch, ch) for ch in text)
|
||||
# нормализация юникода, оставляем латиницу/цифры/дефис
|
||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||
words = re.findall(r"[a-z0-9]+", text.lower())
|
||||
slug = "-".join(words)[:maxlen].strip("-")
|
||||
return slug or "untitled"
|
||||
|
||||
|
||||
def get_post(conn, post_id: int):
|
||||
return conn.execute(
|
||||
"""SELECT p.*, s.slug AS source_slug, s.name AS source_name,
|
||||
s.direction AS source_direction, s.lang AS source_lang
|
||||
FROM posts p JOIN sources s ON s.id = p.source_id WHERE p.id = ?""",
|
||||
(post_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def frontmatter(post, direction: str) -> dict:
|
||||
"""Собирает YAML frontmatter по спеке."""
|
||||
title = (post["text"] or "").replace("\n", " ").strip()
|
||||
if len(title) > 120:
|
||||
title = title[:117] + "..."
|
||||
if not title:
|
||||
title = f"{post['source_name']} — пост {post['tg_post_id']}"
|
||||
reactions = json.loads(post["reactions"] or "{}")
|
||||
# чистим ключи реакций: Telethon-объекты могли сохраниться как repr
|
||||
clean_reactions = {}
|
||||
for k, v in reactions.items():
|
||||
key = k
|
||||
if "emoticon" in str(key):
|
||||
m = re.search(r"emoticon='([^']+)'", str(key))
|
||||
if m:
|
||||
key = m.group(1)
|
||||
clean_reactions[key] = v
|
||||
is_own = int(post.get("is_own") or 0) == 1
|
||||
fm = {
|
||||
"title": title,
|
||||
"date": (post["published_at"] or "")[:10], # YYYY-MM-DD
|
||||
"direction": direction,
|
||||
"source": post["source_slug"],
|
||||
"source_name": post["source_name"],
|
||||
"url": post["url"],
|
||||
"views": post["views"] or 0,
|
||||
"reactions": clean_reactions,
|
||||
"interest": None, # заполняется классификатором
|
||||
"status": post["status"] or "new",
|
||||
"content_type": post["content_type"],
|
||||
"media": post["media_path"],
|
||||
"tg_channel": post["tg_channel"],
|
||||
"tg_post_id": post["tg_post_id"],
|
||||
}
|
||||
# СВОЙ контент: origin: own, source_url на оригинал, автор
|
||||
if is_own:
|
||||
fm["origin"] = "own"
|
||||
if post.get("tg_channel") == "dedinit" and post.get("tg_post_id"):
|
||||
fm["source_url"] = f"https://t.me/dedinit/{post['tg_post_id']}"
|
||||
elif post.get("url"):
|
||||
fm["source_url"] = post["url"]
|
||||
fm["author"] = "Дед в АйТи"
|
||||
else:
|
||||
fm["origin"] = "external"
|
||||
return fm
|
||||
|
||||
|
||||
def make_bundle(post, direction: str, overwrite: bool = False) -> Path:
|
||||
"""Создаёт бандл для поста. Возвращает путь к md или None, если уже есть."""
|
||||
if "id" not in post.keys() and isinstance(post, sqlite3.Row):
|
||||
post = dict(post)
|
||||
slug = slugify(post["text"] or f"post-{post['tg_post_id']}")
|
||||
month = (post["published_at"] or datetime.utcnow().isoformat())[:7] # YYYY-MM
|
||||
bundle_dir = BUNDLES_DIR / direction / month
|
||||
media_dir = bundle_dir / "media" / slug
|
||||
md_path = bundle_dir / f"{slug}.md"
|
||||
|
||||
# коллизия слага (2 поста с одинаковым заголовком) — добавляем суффикс
|
||||
if md_path.exists() and not overwrite:
|
||||
existing = md_path.read_text(encoding="utf-8")
|
||||
if f"tg_post_id: {post['tg_post_id']}" in existing:
|
||||
return md_path # бандл уже создан для этого поста
|
||||
# другой пост с таким же текстом — суффикс
|
||||
i = 2
|
||||
while (bundle_dir / f"{slug}-{i}.md").exists():
|
||||
i += 1
|
||||
slug = f"{slug}-{i}"
|
||||
md_path = bundle_dir / f"{slug}.md"
|
||||
media_dir = bundle_dir / "media" / slug
|
||||
|
||||
bundle_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# копируем медиа, если есть
|
||||
media_rel = None
|
||||
if post.get("media_path"):
|
||||
src = MEDIA_SRC / os.path.basename(post["media_path"])
|
||||
if src.exists():
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
dst = media_dir / src.name
|
||||
if not dst.exists():
|
||||
shutil.copy2(src, dst)
|
||||
media_rel = f"media/{slug}/{src.name}"
|
||||
else:
|
||||
print(f" ! медиа не найдено на диске: {src}")
|
||||
|
||||
fm = frontmatter(post, direction)
|
||||
if media_rel:
|
||||
fm["media_local"] = media_rel
|
||||
body = post["text"] or ""
|
||||
content = (
|
||||
"---\n"
|
||||
+ yaml.safe_dump(fm, allow_unicode=True, sort_keys=False).strip()
|
||||
+ "\n---\n\n"
|
||||
+ body
|
||||
+ "\n\n## Источники\n\n"
|
||||
+ f"- [{post['source_name']}]({post['url']}) — {post['tg_channel']} ({post['tg_post_id']})\n"
|
||||
)
|
||||
md_path.write_text(content, encoding="utf-8")
|
||||
return md_path
|
||||
|
||||
|
||||
def create_bundle(post: dict, bundles_dir: Path | None = None, directions: list[str] | None = None) -> dict:
|
||||
"""Создаёт бандл(ы) для поста (для веб-подтверждения). Возвращает {path, slug} или {paths}.
|
||||
|
||||
directions: список направлений — бандл создаётся по КАЖДОМУ (fan-out).
|
||||
Если directions не задан — направление поста (source_direction/direction/linux).
|
||||
"""
|
||||
global BUNDLES_DIR
|
||||
if bundles_dir is not None:
|
||||
BUNDLES_DIR = Path(bundles_dir)
|
||||
if not directions:
|
||||
directions = [post.get("source_direction") or post.get("direction") or "linux"]
|
||||
dirs = list(dict.fromkeys([d for d in directions if d]))
|
||||
p = dict(post)
|
||||
p.setdefault("source_slug", p.get("source_slug") or p.get("source_name") or "unknown")
|
||||
p.setdefault("source_name", p.get("source_name") or p.get("source_slug") or "")
|
||||
paths = []
|
||||
first = None
|
||||
for direction in dirs:
|
||||
try:
|
||||
path = make_bundle(p, direction)
|
||||
# храним ОТНОСИТЕЛЬНЫЙ путь от bundles/ (для веба: /bundle/<rel>)
|
||||
rel = str(path.relative_to(BUNDLES_DIR))
|
||||
paths.append(rel)
|
||||
if first is None:
|
||||
first = rel
|
||||
except Exception as e:
|
||||
return {"error": str(e), "path": "", "slug": ""}
|
||||
if first is None:
|
||||
return {"error": "no bundles", "path": "", "slug": ""}
|
||||
return {"path": first, "paths": paths, "slug": Path(first).stem}
|
||||
|
||||
|
||||
def build_all(direction: str = "linux", statuses=("new",), limit: int = 0, overwrite: bool = False):
|
||||
"""Собирает бандлы для постов заданного направления."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
ph = ",".join("?" for _ in statuses)
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.*, s.slug AS source_slug, s.name AS source_name,
|
||||
s.direction AS source_direction, s.lang AS source_lang
|
||||
FROM posts p JOIN sources s ON s.id = p.source_id
|
||||
WHERE s.direction = ? AND p.status IN ({ph})
|
||||
ORDER BY p.published_at DESC""",
|
||||
(direction, *statuses),
|
||||
).fetchall()
|
||||
if limit:
|
||||
rows = rows[:limit]
|
||||
made = skipped = 0
|
||||
for row in rows:
|
||||
p = dict(row)
|
||||
direction_eff = p.get("source_direction") or direction
|
||||
try:
|
||||
path = make_bundle(p, direction_eff, overwrite=overwrite)
|
||||
except Exception as e:
|
||||
print(f" ERR post {p.get('id')}: {e}")
|
||||
continue
|
||||
if path.exists():
|
||||
made += 1
|
||||
else:
|
||||
skipped += 1
|
||||
conn.close()
|
||||
print(f"Бандлы: собрано {made}, пропущено {skipped}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
direction = sys.argv[1] if len(sys.argv) > 1 else "linux"
|
||||
build_all(direction)
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VESTI — {% block title %}Управление{% endblock %}</title>
|
||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { padding-top: 2rem; background: #f5f6fa; }
|
||||
.card { box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
||||
.post-text { white-space: pre-wrap; max-height: 140px; overflow-y: auto; font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/candidates">📰 VESTI</a>
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/candidates">Кандидаты</a>
|
||||
<a class="nav-link" href="/published">Опубликовано</a>
|
||||
<a class="nav-link" href="/metrics">Метрики</a>
|
||||
<a class="nav-link text-danger" href="/logout">Выйти</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Кандидаты{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="mb-3">Кандидаты</h4>
|
||||
|
||||
{% if error %}<div class="alert alert-danger py-2">❌ {{ error }}</div>{% endif %}
|
||||
|
||||
<form class="row g-2 mb-3" method="get" action="/candidates">
|
||||
<div class="col-auto">
|
||||
<select name="direction" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">Все направления</option>
|
||||
{% for d in directions %}
|
||||
<option value="{{ d }}" {% if direction == d %}selected{% endif %}>{{ d }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="status" class="form-select" onchange="this.form.submit()">
|
||||
<option value="new" {% if status=='new' %}selected{% endif %}>Черновики (new)</option>
|
||||
<option value="rejected" {% if status=='rejected' %}selected{% endif %}>Отклонённые</option>
|
||||
<option value="published" {% if status=='published' %}selected{% endif %}>Опубликованные</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="own" class="form-select" onchange="this.form.submit()">
|
||||
<option value="" {% if own=='' %}selected{% endif %}>Все посты</option>
|
||||
<option value="1" {% if own=='1' %}selected{% endif %}>⭐ Только свои</option>
|
||||
<option value="0" {% if own=='0' %}selected{% endif %}>Только внешние</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">Применить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="post-list">
|
||||
{% for p in posts %}
|
||||
<div class="card mb-3" id="post-{{ p.id }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge bg-secondary">{{ p.direction or '?' }}</span>
|
||||
<small class="text-muted">{{ p.source_name }} · {{ p.published_at | dt }}</small>
|
||||
</div>
|
||||
<div class="post-text mt-2">{{ (p.text or '')[:2000] | markdown }}</div>
|
||||
{% if p.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
|
||||
{% if p.relevance %}<span class="badge bg-{{ 'success' if p.relevance=='critical' else 'info' }}">{{ p.relevance }}</span>{% endif %}
|
||||
{% if p.interest %}<span class="badge bg-warning text-dark">interest {{ p.interest }}</span>{% endif %}
|
||||
<small class="text-muted ms-2">👁 {{ p.views }} 💬 {{ p.reactions_total }}</small>
|
||||
{% if p.media_path %}
|
||||
<span class="badge bg-light text-dark ms-2">🖼 медиа</span>
|
||||
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
|
||||
{% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.media_path else '' %}
|
||||
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
|
||||
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
|
||||
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:180px" alt="медиа поста">
|
||||
</a>
|
||||
{% elif media_ext in ['mp4','webm','mov'] %}
|
||||
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:180px"></video>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if p.fwd_from_channel_id %}<span class="badge bg-light text-dark ms-2">↩︎ форвард</span>{% endif %}
|
||||
<div class="mt-2">
|
||||
{% if p.status == 'new' %}
|
||||
<form class="d-inline" method="post" action="/posts/{{ p.id }}/approve">
|
||||
<div class="mb-2">
|
||||
<textarea class="form-control form-control-sm" name="comment" rows="2"
|
||||
placeholder="Комментарий к посту (по желанию) — будет первым в канале"></textarea>
|
||||
</div>
|
||||
{% if p.is_own %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Рассылка (fan-out):</small>
|
||||
{% for d in directions %}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="dirs" value="{{ d }}" id="dir-{{ p.id }}-{{ d }}"
|
||||
{% if d == (p.direction or 'linux') %}checked{% endif %}>
|
||||
<label class="form-check-label" for="dir-{{ p.id }}-{{ d }}">{{ d }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
|
||||
</form>
|
||||
<form class="d-inline" method="post" action="/posts/{{ p.id }}/reject">
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||
</form>
|
||||
{% elif p.status == 'rejected' %}
|
||||
<span class="text-danger">Отклонён</span>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ p.url }}" target="_blank">Исходник</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">Нет постов</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VESTI — Вход</title>
|
||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light d-flex align-items-center" style="min-height:100vh">
|
||||
<div class="container" style="max-width:380px">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title mb-3">📰 VESTI — вход</h4>
|
||||
{% if request.query_params.get('error') %}<div class="alert alert-danger py-2">Неверный пароль</div>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Пароль</label>
|
||||
<input type="password" name="password" class="form-control" autofocus required>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Метрики{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="mb-3">Метрики</h4>
|
||||
<div class="row">
|
||||
{% for d in dirs %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{ d.direction or 'без направления' }} / {{ d.cnt }}</h6>
|
||||
<small>👁 просмотров: {{ d.views or 0 }} · средние реакции: {{ "%.1f"|format(d.avg_reactions or 0) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4">Последние запуски</h5>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead><tr><th>Время</th><th>Источник</th><th>Стаус</th><th>Пстов</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in runs %}
|
||||
<tr><td>{{ r.started_at | dt }}</td><td>{{ r.source_id }}</td><td>{{ r.status }}</td><td>{{ r.posts_new }}/{{ r.posts_fetched }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Опубликовано{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="mb-3">Опубликованные посты</h4>
|
||||
{% for p in posts %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge bg-secondary">{{ p.direction or '?' }}</span>
|
||||
<small class="text-muted">{{ p.source_name }} · {{ p.published_at | dt }}</small>
|
||||
</div>
|
||||
<div class="post-text mt-2">{{ (p.summary or p.text or '')[:2000] | markdown }}</div>
|
||||
{% if p.media_path %}
|
||||
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
|
||||
{% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.media_path else '' %}
|
||||
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
|
||||
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
|
||||
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:180px" alt="медиа поста">
|
||||
</a>
|
||||
{% elif media_ext in ['mp4','webm','mov'] %}
|
||||
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:180px"></video>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if p.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
|
||||
<small class="text-muted">👁 {{ p.pub_views or p.views or 0 }}</small>
|
||||
{% if p.distributed_dirs %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Разослан в:</small>
|
||||
{% for d in (p.distributed_dirs | from_json) %}
|
||||
<span class="badge bg-info text-dark">@dedinit_vesti_{{ d }}_ru_bot</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if p.bundle_path %}
|
||||
<a class="btn btn-sm btn-outline-primary ms-2" href="/bundle/{{ p.bundle_path }}" target="_blank">📄 Бандл</a>
|
||||
{% endif %}
|
||||
{% if p.tg_message_id %}<span class="badge bg-light text-dark ms-2">msg #{{ p.tg_message_id }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">Пока ничего не опубликовано</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user