Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)

This commit is contained in:
kpa39l
2026-09-13 15:58:32 +00:00
commit c3f59f7b7a
113 changed files with 7065 additions and 0 deletions
+30
View File
@@ -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>
+97
View File
@@ -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 %}
+26
View File
@@ -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>
+27
View File
@@ -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 %}
+43
View File
@@ -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 %}