fix teg
This commit is contained in:
190
api/vk_parser.py
190
api/vk_parser.py
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user