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

@@ -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