This commit is contained in:
jze9
2026-05-15 20:12:44 +05:00
parent 2335497226
commit dd7d8a7ccf
21 changed files with 672 additions and 121 deletions

View File

@@ -15,7 +15,6 @@ ALLOWED_ORIGINS=http://localhost,http://localhost:80
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
# VK импорт (необязательно)
# Сервисный токен приложения: vk.com/apps → Настройки → Сервисный ключ доступа
VK_ACCESS_TOKEN=

149
api/alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -72,6 +72,9 @@ class Media(Base):
media_type: Mapped[str] = mapped_column(String(20)) # image | video
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
article_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("articles.id", ondelete="SET NULL"), nullable=True
)
class VkSource(Base):

View File

@@ -10,7 +10,7 @@ from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from rate_limiter import limiter
from bd.database import wait_for_db, engine, Base
from bd.database import wait_for_db
from route.public import router as public_router
from route.admin_auth import router as auth_router, ensure_default_admin
from route.admin_articles import router as articles_router
@@ -32,11 +32,22 @@ def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
return creds.username
def _run_migrations():
import subprocess, sys
result = subprocess.run(
["alembic", "upgrade", "head"],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"[Alembic] Ошибка миграции:\n{result.stderr}", file=sys.stderr)
else:
print(f"[Alembic] {result.stdout.strip() or 'Миграции применены'}")
@asynccontextmanager
async def lifespan(app: FastAPI):
await wait_for_db()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_run_migrations()
await ensure_default_admin()
scheduler.start()
yield

1
api/migrations/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

63
api/migrations/env.py Normal file
View File

@@ -0,0 +1,63 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Подключаем модели для autogenerate
from bd.database import Base
import bd.models # noqa: F401
target_metadata = Base.metadata
# URL из ENV (приоритет) или из alembic.ini
DATABASE_URL = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url", ""))
config.set_main_option("sqlalchemy.url", DATABASE_URL)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,109 @@
"""initial
Revision ID: 25d6ed4a524a
Revises:
Create Date: 2026-05-15 14:46:07.892008
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '25d6ed4a524a'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('admins',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('password_hash', sa.String(length=300), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('categories',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_categories_slug'), 'categories', ['slug'], unique=True)
op.create_table('tags',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_tags_slug'), 'tags', ['slug'], unique=True)
op.create_table('vk_sources',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('group_id', sa.String(length=50), nullable=False),
sa.Column('group_name', sa.String(length=200), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('last_run', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('group_id')
)
op.create_table('articles',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('slug', sa.String(length=500), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('excerpt', sa.String(length=1000), nullable=False),
sa.Column('cover_url', sa.String(length=1000), nullable=True),
sa.Column('font_family', sa.String(length=100), nullable=False),
sa.Column('source_url', sa.String(length=1000), nullable=True),
sa.Column('status', sa.Enum('draft', 'published', name='articlestatus'), nullable=False),
sa.Column('view_count', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('published_at', sa.DateTime(), nullable=True),
sa.Column('category_id', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_articles_slug'), 'articles', ['slug'], unique=True)
op.create_table('article_tag',
sa.Column('article_id', sa.UUID(), nullable=True),
sa.Column('tag_id', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['article_id'], ['articles.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE')
)
op.create_table('media',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('filename', sa.String(length=500), nullable=False),
sa.Column('url', sa.String(length=1000), nullable=False),
sa.Column('media_type', sa.String(length=20), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('article_id', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['article_id'], ['articles.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('media')
op.drop_table('article_tag')
op.drop_index(op.f('ix_articles_slug'), table_name='articles')
op.drop_table('articles')
op.drop_table('vk_sources')
op.drop_index(op.f('ix_tags_slug'), table_name='tags')
op.drop_table('tags')
op.drop_index(op.f('ix_categories_slug'), table_name='categories')
op.drop_table('categories')
op.drop_table('admins')
# ### end Alembic commands ###

View File

@@ -1,3 +1,4 @@
alembic==1.13.3
fastapi==0.115.0
uvicorn[standard]==0.30.6
sqlalchemy[asyncio]==2.0.36

View File

@@ -1,3 +1,4 @@
import os
import uuid
from datetime import datetime
from typing import Optional
@@ -9,9 +10,11 @@ from sqlalchemy.orm import selectinload
import redis.asyncio as aioredis
from bd.database import get_db, get_redis
from bd.models import Article, Category, Tag, ArticleStatus
from bd.models import Article, Category, Tag, ArticleStatus, Media
from route.deps import require_admin
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
router = APIRouter()
@@ -208,6 +211,26 @@ async def delete_article(
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
if not article:
raise HTTPException(status_code=404, detail="Not found")
# Удаляем все связанные медиафайлы из MinIO и БД
media_items = (await db.execute(
select(Media).where(Media.article_id == article.id)
)).scalars().all()
if media_items:
from route.admin_media import get_minio
try:
client = get_minio()
for m in media_items:
obj = m.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
try:
client.remove_object(MINIO_BUCKET, obj)
except Exception:
pass
await db.delete(m)
except Exception:
pass
await db.delete(article)
await db.commit()
await _invalidate_cache(redis)

View File

@@ -7,9 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from minio import Minio
from minio.error import S3Error
from slugify import slugify
from bd.database import get_db
from bd.models import Media
from bd.models import Media, Article
from route.deps import require_admin
router = APIRouter()
@@ -50,6 +51,7 @@ def ensure_bucket(client: Minio):
@router.post("/upload")
async def upload_media(
file: UploadFile = File(...),
article_id: str | None = Query(None),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
@@ -63,7 +65,27 @@ async def upload_media(
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
ext = (file.filename or "file").rsplit(".", 1)[-1].lower()
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
# Определяем путь в MinIO
art_uuid = None
if article_id:
try:
art_uuid = uuid.UUID(article_id)
# Проверяем существование статьи
art = (await db.execute(select(Article).where(Article.id == art_uuid))).scalar_one_or_none()
if not art:
art_uuid = None
except ValueError:
art_uuid = None
if art_uuid:
folder = "images" if media_type == "image" else "videos"
art_slug = slugify(art.title) if art and art.title else str(art_uuid)[:8]
art_dir = f"{art_slug}-{str(art_uuid)[:8]}"
base_name = f"{art_slug}-{uuid.uuid4().hex[:6]}.{ext}"
object_name = f"articles/{art_dir}/{folder}/{base_name}"
else:
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
client = get_minio()
ensure_bucket(client)
@@ -78,10 +100,11 @@ async def upload_media(
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
media = Media(
filename=file.filename or object_name,
filename=object_name.split("/")[-1],
url=public_url,
media_type=media_type,
size_bytes=len(data),
article_id=art_uuid,
)
db.add(media)
await db.commit()
@@ -101,20 +124,40 @@ async def list_media(
offset: int = Query(0, ge=0),
limit: int = Query(40, ge=1, le=200),
media_type: str | None = Query(None),
q: str | None = Query(None, max_length=200),
db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin),
):
from sqlalchemy import func
from sqlalchemy.orm import outerjoin
base = select(Media)
if media_type:
base = base.where(Media.media_type == media_type)
if q:
base = base.where(Media.filename.ilike(f"%{q}%"))
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
# Fetch article titles for media that have an article_id
art_ids = {m.article_id for m in items if m.article_id}
art_titles: dict = {}
if art_ids:
arts = (await db.execute(select(Article.id, Article.title).where(Article.id.in_(art_ids)))).all()
art_titles = {a.id: a.title for a in arts}
return {
"total": total,
"items": [
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
{
"id": str(m.id),
"url": m.url,
"filename": m.filename,
"media_type": m.media_type,
"size_bytes": m.size_bytes,
"created_at": m.created_at.isoformat(),
"article_id": str(m.article_id) if m.article_id else None,
"article_title": art_titles.get(m.article_id) if m.article_id else None,
}
for m in items
],
}

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException # BackgroundTasks используется в history endpoints
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -88,9 +88,9 @@ async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: s
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
@router.post("/import")
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
background_tasks.add_task(run_import)
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
async def vk_manual_import(_: str = Depends(require_admin)):
result = await run_import()
return result
@router.post("/import/history")

View File

@@ -75,13 +75,15 @@ async def list_news(
if date_from:
try:
base = base.where(Article.published_at >= datetime.fromisoformat(date_from))
dt = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
base = base.where(Article.published_at >= dt.replace(tzinfo=None))
except ValueError:
pass
if date_to:
try:
base = base.where(Article.published_at <= datetime.fromisoformat(date_to))
dt = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
base = base.where(Article.published_at <= dt.replace(tzinfo=None))
except ValueError:
pass

View File

@@ -60,82 +60,60 @@ def _sanitize(html: str) -> str:
# ── Теги по ключевым словам ───────────────────────────────────────────────────
# Ключ = название тега, значение = список подстрок (регистронезависимо)
TAG_KEYWORDS: dict[str, list[str]] = {
# ── Учёба и поступление ───────────────────────────────────────────────────
"экзамены": ["экзамен", "зачёт", "зачет", "сессия", "огэ", "егэ",
"гиа", "защит", "диплом", "аттестац", "промежуточн"],
"абитуриентам": ["абитуриент", "поступ", "приёмн", "приемн", "зачислен",
"набор студент", "подай документ", "приглашаем поступ",
"бюджетн мест", "целевой приём", "контрольные цифры"],
"день открытых дверей": ["день открытых дверей", "открытые двери", "день открытых",
"экскурси", "познакомиться с колледж"],
"практика": ["практик", "стажировк", "производственн", "учебно-производ",
"на предприяти", "работодател", "наставник"],
"достижения": ["победител", "призёр", "призер", "награда", "грамот",
"диплом лауреат", "1 место", "2 место", "3 место",
"лучший студент", "гордост", "поздравляем", "медал"],
"студенческая жизнь": ["студсовет", "студенческ жизн", "общежити", "капустник",
"квн", "студенч", "первокурсник", "посвящение",
"студент год", "актив"],
"профессионалитет": ["профессионалитет", "фп профессионалитет",
"кластер", "федеральн проект"],
# ── Направления колледжей ─────────────────────────────────────────────────
"медицина": ["медицин", "сестринск", "фельдшер", "фармацевт", "лечебн",
"анатоми", "патологи", "здравоохранен", "санитар",
"первая помощ", "реанимац", "клиническ"],
"педагогика": ["педагог", "воспитател", "учител", "начальн класс",
"дошкольн", "детский сад", "логопед", "коррекцион",
"инклюзив", "тьютор"],
"юриспруденция": ["юрист", "юридическ", "правоохранительн", "право",
"законодательств", "суд", "прокурат", "полиц",
"юриспруденц", "правовед"],
"IT и технологии": ["программирован", "it-", "ит-", "информационн технолог",
"цифров", "кибербезопасн", "веб-разраб", "1с",
"компьютерн", "разработчик", "python", "frontend",
"backend", "хакатон", "ворлдскиллс"],
"кулинария и торговля": ["кулинар", "повар", "кондитер", "гастроном", "блюд",
"рецепт", "ресторан", "кафе", "торговл", "продавец",
"мерчандайзинг", "товаровед", "общепит"],
"строительство": ["строительств", "архитектур", "проектирован", "чертёж",
"чертеж", "монтаж", "сварк", "электромонтаж",
"сантехник", "отделочн"],
"техника и механика": ["механик", "двигател", "автомобил", "станок",
"металлообраб", "токар", "слесар", "техническ обслуж",
"ремонт оборудован", "машиностроен", "технолог производ"],
"сельское хозяйство": ["агроном", "агроинженер", "сельск хоз", "животновод",
"агротехник", "землепользован", "агро"],
"экономика и бухучёт": ["бухгалтер", "экономик", "финансов", "налог",
"аудит", "бизнес", "предпринимател", "менеджмент",
"маркетинг", "логистик"],
"социальная работа": ["социальн работ", "соцработник", "психолог",
"реабилитац", "инвалид", "ограниченн возможн",
"социальн помощ", "опека"],
# ── Общественные события ──────────────────────────────────────────────────
"спорт": ["спорт", "соревнован", "олимпиад", "футбол", "баскетбол",
"волейбол", "чемпион", "кубок", "турнир", "атлет",
"фитнес", "зарядк", "ворлдскиллс хайскул"],
"военка": ["военн", "армия", "призыв", "нво", "сво", "оборон",
"патриот", "защитник", "военно-патриот", "стрельб",
"зарниц", "юнармия", "допризывн"],
"воздушная опасность": ["воздушн тревог", "воздушн опасност", "ракет",
"бпла", "дрон", "обстрел", "укрытие", "сирен",
"эвакуац", "бомбоубежищ"],
"день открытых дверей": ["день открытых дверей", "день открытых",
"приходи в колледж", "познакомиться с колледж",
"двери открыты", "приглашаем на экскурс"],
"общественная деятельность": ["волонтёр", "волонтер", "благотвор", "субботник",
"экологическ акц", "посадк дерев", "уборк территор",
"донорств", "гуманитарн", "помощ фронт"],
"праздники и события": ["праздник", "концерт", "фестиваль", "выставк",
"торжеств", "церемони", "день колледж", "юбилей",
"8 марта", "23 февраля", "новый год", "масленица",
"день знаний", "последний звонок"],
"конкурсы и олимпиады": ["конкурс", "олимпиад", "чемпионат профессий",
"worldskills", "ворлдскиллс", "хакатон", "викторин",
"интеллектуальн", "акселератор"],
"донорств", "гуманитарн помощ", "добровольч",
"общественн деятельн", "социальн акц", "акция добра"],
"чемпионаты и конкурсы": ["чемпионат",
"олимпиад",
"хакатон",
"всероссийск конкурс", "региональн конкурс",
"областн конкурс", "международн конкурс",
"городск конкурс", "межвузовск конкурс",
"конкурс профессионал", "конкурс мастерств",
"конкурс молодых специалист",
"заняли 1 место", "заняли 2 место", "заняли 3 место",
"1 место среди", "2 место среди", "3 место среди",
"заняли первое место", "заняли второе место", "заняли третье место",
"золото на", "серебро на", "бронзу на",
"завоевали золот", "завоевали серебр", "завоевали бронз"],
"профессионалы": ["профессионалитет", "worldskills", "ворлдскиллс",
"ворлдскиллс хайскул", "хайскул",
"компетенц", "демонстрацион экзамен",
"федеральн проект профессионалите"],
"абилимпикс": ["абилимпикс", "abilympics",
"ограниченн возможн здоровь",
"особенн образовател потребн",
"инклюзивн образован"],
"экзамены": ["расписани экзамен", "подготовк к экзамен",
"экзаменацион сессия", "зачётн неделя", "зачетн неделя",
"промежуточн аттестац", "государственн итог аттестац",
"государственн экзамен", "итоговая аттестац",
"защит диплом", "защита выпускн",
"огэ", "егэ", "гиа"],
}
# Слова-исключения: если они есть в тексте — пост скипается полностью
# (посты о ВОВ/Победе не относятся ни к одной из 6 категорий)
_SKIP_KEYWORDS = [
"великая отечественная война",
"великой отечественной войн",
"день победы",
"9 мая",
"вов ",
"ветеран войн",
"павш за родин",
]
def _detect_tags(text: str) -> list[str]:
lower = text.lower()
# Пропускаем посты о ВОВ/Победе — они не попадают ни в одну категорию
if any(kw in lower for kw in _SKIP_KEYWORDS):
return []
found = []
for tag_name, keywords in TAG_KEYWORDS.items():
if any(kw in lower for kw in keywords):
@@ -163,7 +141,9 @@ def _ensure_bucket(mc: Minio):
pass
async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | None:
async def _upload_from_url(url: str, db: AsyncSession | None = None,
article_dir: str | None = None,
index: int = 1) -> str | None:
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
r = await client.get(url)
@@ -172,7 +152,13 @@ async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | No
data = r.content
ct = r.headers.get("content-type", "image/jpeg").split(";")[0].strip()
ext = ct.split("/")[-1].replace("jpeg", "jpg")
object_name = f"vk/images/{uuid.uuid4().hex}.{ext}"
if article_dir:
prefix = f"articles/{article_dir}/images"
filename = f"{article_dir}-{index}.{ext}"
else:
prefix = "vk/images"
filename = f"{uuid.uuid4().hex}.{ext}"
object_name = f"{prefix}/{filename}"
mc = _minio_client()
_ensure_bucket(mc)
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct)
@@ -180,10 +166,11 @@ async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | No
if db is not None:
from bd.models import Media
db.add(Media(
filename=object_name.split("/")[-1],
filename=filename,
url=public_url,
media_type="image",
size_bytes=len(data),
# article_id выставляется после коммита статьи (см. _process_post)
))
return public_url
except Exception as exc:
@@ -257,7 +244,9 @@ def _ytdlp_extract_url(vk_url: str, max_mb: int = 150) -> str | None:
async def _download_vk_video(owner_id: int, video_id: int,
db: AsyncSession | None = None,
max_mb: int = 150) -> str | None:
max_mb: int = 150,
article_dir: str | None = None,
index: int = 1) -> str | None:
"""Скачать VK-видео в MinIO и вернуть публичный URL.
Использует yt-dlp для получения прямой mp4-ссылки.
Стриминг — не держит весь файл в памяти.
@@ -303,7 +292,13 @@ async def _download_vk_video(owner_id: int, video_id: int,
return None
chunks.append(chunk)
data = b"".join(chunks)
object_name = f"vk/videos/{uuid.uuid4().hex}.mp4"
if article_dir:
prefix = f"articles/{article_dir}/videos"
filename = f"{article_dir}-{index}.mp4"
else:
prefix = "vk/videos"
filename = f"{uuid.uuid4().hex}.mp4"
object_name = f"{prefix}/{filename}"
mc = _minio_client()
_ensure_bucket(mc)
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4")
@@ -312,10 +307,11 @@ async def _download_vk_video(owner_id: int, video_id: int,
if db is not None:
from bd.models import Media
db.add(Media(
filename=object_name.split("/")[-1],
filename=filename,
url=public_url,
media_type="video",
size_bytes=len(data),
# article_id выставляется после коммита статьи (см. _process_post)
))
return public_url
except Exception as exc:
@@ -397,6 +393,14 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
attachments = post.get("attachments", [])
ts = post["date"]
# Фильтр: импортируем только посты с разрешёнными тегами
tag_names = _detect_tags(text)
if not tag_names:
return False
# Заранее генерируем UUID и slug — используются в путях MinIO
article_id = uuid.uuid4()
# VK Clips и некоторые видео-посты: post.text пустой, описание лежит в video.description
if not text:
for att in attachments:
@@ -406,6 +410,15 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
text = desc
break
# Генерируем slug до загрузки медиа: используем его как имя директории и файлов
_early_title = _extract_title(text, ts)
_early_slug = (slugify(_early_title) or vk_slug)[:60] # макс 60 символов
# Уникальный префикс: slug + 8 символов UUID (защита от коллизий одинаковых заголовков)
article_dir = f"{_early_slug}-{str(article_id)[:8]}"
img_idx = 0 # счётчик изображений для нумерации файлов
vid_idx = 0 # счётчик видео
cover_url = None
content_parts = []
@@ -418,7 +431,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
if att_type == "photo":
url = _best_photo(att["photo"].get("sizes", []))
if url:
minio_url = await _upload_from_url(url, db=db)
img_idx += 1
minio_url = await _upload_from_url(url, db=db, article_dir=article_dir, index=img_idx)
final_url = minio_url or url # fallback на оригинальный URL если MinIO недоступен
if cover_url is None:
cover_url = final_url
@@ -456,7 +470,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
# Всегда скачиваем превью в MinIO
minio_thumb = None
if thumb_url:
minio_thumb = await _upload_from_url(thumb_url, db=db)
img_idx += 1
minio_thumb = await _upload_from_url(thumb_url, db=db, article_dir=article_dir, index=img_idx)
final_thumb = minio_thumb or thumb_url
if cover_url is None:
cover_url = final_thumb
@@ -466,7 +481,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
# Скачиваем само видео через yt-dlp → MinIO
minio_video_url = None
if vid_id and vid_oid:
minio_video_url = await _download_vk_video(vid_oid, vid_id, db=db)
vid_idx += 1
minio_video_url = await _download_vk_video(vid_oid, vid_id, db=db, article_dir=article_dir, index=vid_idx)
if minio_video_url:
# 1. Видео лежит у нас — нативный <video>
@@ -506,15 +522,14 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
if not content.strip():
return False
title = _extract_title(text, ts)
title_slug = slugify(title)
title = _early_title
title_slug = _early_slug
final_slug = title_slug if not await _slug_exists(title_slug, db) else vk_slug
# Категория = название группы
category = await _get_or_create_category(category_name, db)
# Теги по ключевым словам
tag_names = _detect_tags(text)
# Теги уже определены выше (tag_names)
tags = [await _get_or_create_tag(n, db) for n in tag_names]
status = ArticleStatus.published if VK_IMPORT_AS == "published" else ArticleStatus.draft
@@ -525,6 +540,7 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
article = Article(
id=article_id,
title=title,
slug=final_slug,
content=content,
@@ -540,6 +556,16 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
)
db.add(article)
await db.commit()
# Связываем загруженные медиафайлы с этой статьёй по URL-префиксу
from sqlalchemy import update as sa_update
from bd.models import Media as _Media
await db.execute(
sa_update(_Media)
.where(_Media.url.contains(f"/articles/{article_dir}/"))
.values(article_id=article_id)
)
await db.commit()
return True

View File

@@ -73,9 +73,10 @@ export const adminDeleteCategory = (token, id) =>
// ── Медиа (админ) ──────────────────────────────────────────────────────────
export const adminListMedia = (token, { type, offset = 0, limit = 40 } = {}) => {
export const adminListMedia = (token, { type, offset = 0, limit = 40, q } = {}) => {
const p = new URLSearchParams()
if (type) p.set('media_type', type)
if (q) p.set('q', q)
p.set('offset', offset)
p.set('limit', limit)
return req('GET', `admin/media?${p}`, token)

View File

@@ -48,6 +48,8 @@ export default function MediaLibrary() {
const [total, setTotal] = useState(0)
const [type, setType] = useState('')
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [loading, setLoading] = useState(true)
const [confirm, setConfirm] = useState(null)
const [uploading, setUploading] = useState(false)
@@ -58,13 +60,13 @@ export default function MediaLibrary() {
const load = useCallback(async () => {
setLoading(true)
try {
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE })
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE, q: search || undefined })
setItems(data.items ?? [])
setTotal(data.total ?? 0)
} finally {
setLoading(false)
}
}, [token, type, page])
}, [token, type, page, search])
useEffect(() => { load() }, [load])
@@ -129,6 +131,23 @@ export default function MediaLibrary() {
onClick={() => { setType(val); setPage(1) }}
>{label}</button>
))}
<form
onSubmit={e => { e.preventDefault(); setSearch(searchInput); setPage(1) }}
style={{ display: 'flex', gap: '0.25rem', marginLeft: '0.5rem' }}
>
<input
className="form-input"
style={{ width: 200, padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
placeholder="Поиск по имени..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
<button className="btn btn-ghost btn-sm" type="submit">🔍</button>
{search && (
<button className="btn btn-ghost btn-sm" type="button"
onClick={() => { setSearch(''); setSearchInput(''); setPage(1) }}></button>
)}
</form>
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
Всего: {total}
</span>
@@ -160,6 +179,13 @@ export default function MediaLibrary() {
</div>
<div className="media-item__info">
<span className="media-item__name" title={m.filename}>{m.filename}</span>
{m.article_title && (
<span style={{ fontSize: '0.7rem', color: 'var(--c-primary)', overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}
title={m.article_title}>
📄 {m.article_title}
</span>
)}
<span style={{ fontSize: '0.75rem', color: 'var(--c-text-2)' }}>{fmtSize(m.size_bytes)} · {fmtDate(m.created_at)}</span>
<button
className="btn btn-icon btn-sm"

View File

@@ -67,7 +67,11 @@ export default function VkSources() {
setImporting('new')
try {
const r = await adminVkImport(token)
flash('success', `Импорт завершён: +${r.imported ?? 0} новых`)
if (r.imported !== undefined) {
flash('success', `Импорт завершён: +${r.imported} новых, пропущено ${r.skipped ?? 0}`)
} else {
flash('success', r.message ?? 'Импорт запущен')
}
} catch { flash('error', 'Ошибка импорта') }
finally { setImporting('') }
}
@@ -76,7 +80,7 @@ export default function VkSources() {
setImporting('history')
try {
const r = await adminVkImportHistory(token, sourceId)
flash('success', `История загружена: +${r.imported ?? 0} постов`)
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
} catch { flash('error', 'Ошибка загрузки истории') }
finally { setImporting('') }
}

View File

@@ -46,6 +46,7 @@ const DEFAULT_META = {
slug: '',
excerpt: '',
cover_url: null,
source_url: null,
font_family: 'Merriweather',
category_id: null,
tag_names: [],
@@ -67,6 +68,7 @@ export default function App() {
slug: data.slug,
excerpt: data.excerpt || '',
cover_url: data.cover_url || null,
source_url: data.source_url || null,
font_family: data.font_family || 'Merriweather',
category_id: data.category?.id || null,
tag_names: (data.tags || []).map(t => t.name),

View File

@@ -1,24 +1,35 @@
import { useState, useEffect, useRef } from 'react'
import { Upload, X, ImageIcon, Video, Loader2, Check } from 'lucide-react'
import { Upload, X, ImageIcon, Video, Loader2, Check, Search } from 'lucide-react'
import { api } from '../api'
export function MediaModal({ type, token, onSelect, onClose }) {
const [items, setItems] = useState([])
const [allItems, setAllItems] = useState([])
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [search, setSearch] = useState('')
const fileRef = useRef(null)
useEffect(() => {
api.listMedia(token, type)
.then(data => { setItems(data); setLoading(false) })
.then(data => { setAllItems(data); setItems(data); setLoading(false) })
.catch(() => setLoading(false))
}, [])
useEffect(() => {
if (!search.trim()) {
setItems(allItems)
} else {
const q = search.toLowerCase()
setItems(allItems.filter(it => it.filename?.toLowerCase().includes(q)))
}
}, [search, allItems])
const handleUpload = async file => {
setUploading(true)
try {
const res = await api.uploadMedia(file, token)
setItems(prev => [res, ...prev])
setAllItems(prev => [res, ...prev])
} catch {}
setUploading(false)
}
@@ -64,6 +75,29 @@ export function MediaModal({ type, token, onSelect, onClose }) {
</div>
</div>
{/* Search bar */}
<div className="px-5 py-3 border-b border-slate-100">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск по имени файла..."
className="w-full pl-8 pr-4 py-1.5 text-sm border border-slate-200 rounded-lg outline-none focus:border-indigo-400 focus:ring-1 focus:ring-indigo-200"
/>
{search && (
<button
type="button"
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-slate-100 rounded"
>
<X size={12} className="text-slate-400" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
@@ -73,14 +107,16 @@ export function MediaModal({ type, token, onSelect, onClose }) {
) : isEmpty ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-300 gap-3">
{isImage ? <ImageIcon size={36} /> : <Video size={36} />}
<p className="text-sm">Файлы не найдены</p>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
>
Загрузить первый файл
</button>
<p className="text-sm">{search ? 'Ничего не найдено' : 'Файлы не найдены'}</p>
{!search && (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
>
Загрузить первый файл
</button>
)}
</div>
) : (
<div className={`grid gap-2.5 ${isImage ? 'grid-cols-4 sm:grid-cols-5' : 'grid-cols-2 sm:grid-cols-3'}`}>
@@ -101,14 +137,12 @@ export function MediaModal({ type, token, onSelect, onClose }) {
) : (
<video src={item.url} className="w-full h-full object-cover" />
)}
{/* Hover overlay */}
<div className="absolute inset-0 bg-indigo-600/0 group-hover:bg-indigo-600/25 transition-colors flex items-center justify-center">
<Check
size={22}
className="text-white opacity-0 group-hover:opacity-100 drop-shadow-lg transition-opacity"
/>
</div>
{/* Filename tooltip */}
<div className="absolute bottom-0 inset-x-0 px-2 py-1.5 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-[10px] truncate leading-none">{item.filename}</p>
</div>

View File

@@ -48,6 +48,17 @@ export function Sidebar({ meta, categories, onChange, onUploadCover, wordCount,
/>
</div>
{/* Source URL */}
<div>
<Label>Первоисточник</Label>
<input
className="input-dark text-xs"
placeholder="https://vk.com/..."
value={meta.source_url || ''}
onChange={e => onChange({ source_url: e.target.value || null })}
/>
</div>
{/* Excerpt */}
<div>
<Label>Анонс</Label>

View File

@@ -157,7 +157,24 @@ function SizeDropdown({ editor }) {
/* ── Table menu ── */
function TableMenu({ editor }) {
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const [open, setOpen] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const wrapRef = useRef(null)
const toggle = () => {
if (!open && wrapRef.current) {
const r = wrapRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(o => !o)
}
useEffect(() => {
if (!open) return
const close = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [open])
const items = [
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
@@ -174,16 +191,14 @@ function TableMenu({ editor }) {
]
return (
<div className="flex-shrink-0">
<div ref={wrapRef} className="flex-shrink-0">
<Btn
onClick={e => toggle({ preventDefault: () => {}, ...e })}
onClick={toggle}
active={editor.isActive('table')}
title="Таблица"
>
<Table2 size={14} />
</Btn>
{/* Используем ref на обёртке для Btn */}
<span ref={btnRef} style={{ display: 'none' }} />
{open && (
<div