import io import os import re import uuid from datetime import datetime, timezone import asyncio import bleach from bleach.css_sanitizer import CSSSanitizer import httpx from minio import Minio from minio.error import S3Error from slugify import slugify from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from bd.database import AsyncSessionLocal from bd.models import Article, ArticleStatus, Category, Tag, VkSource VK_API = "https://api.vk.com/method" VK_VERSION = "5.199" VK_TOKEN = os.getenv("VK_ACCESS_TOKEN", "") VK_IMPORT_AS = os.getenv("VK_IMPORT_STATUS", "published") # published | draft VK_PER_RUN = int(os.getenv("VK_POSTS_PER_RUN", "100")) # макс 100 за запрос MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin") MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media") MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000") _URL_RE = re.compile(r"(https?://[^\s<>\"']+)") # Разрешённые HTML-теги и атрибуты после санитизации _ALLOWED_TAGS = [ "p", "br", "strong", "em", "a", "img", "ul", "ol", "li", "blockquote", "div", "iframe", "span", "video", ] _ALLOWED_ATTRS = { "a": ["href", "target", "rel"], "img": ["src", "style", "alt"], "iframe": ["src", "style", "frameborder", "allowfullscreen"], "video": ["src", "controls", "style", "poster"], "div": ["style"], "span": ["style"], } _CSS = CSSSanitizer(allowed_css_properties=[ "max-width", "width", "height", "position", "top", "left", "padding-bottom", "overflow", "margin", "border-radius", ]) def _sanitize(html: str) -> str: return bleach.clean(html, tags=_ALLOWED_TAGS, attributes=_ALLOWED_ATTRS, css_sanitizer=_CSS, strip=True) # ── Теги по ключевым словам ─────────────────────────────────────────────────── # Ключ = название тега, значение = список подстрок (регистронезависимо) TAG_KEYWORDS: dict[str, list[str]] = { # ── Учёба и поступление ─────────────────────────────────────────────────── "экзамены": ["экзамен", "зачёт", "зачет", "сессия", "огэ", "егэ", "гиа", "защит", "диплом", "аттестац", "промежуточн"], "абитуриентам": ["абитуриент", "поступ", "приёмн", "приемн", "зачислен", "набор студент", "подай документ", "приглашаем поступ", "бюджетн мест", "целевой приём", "контрольные цифры"], "день открытых дверей": ["день открытых дверей", "открытые двери", "день открытых", "экскурси", "познакомиться с колледж"], "практика": ["практик", "стажировк", "производственн", "учебно-производ", "на предприяти", "работодател", "наставник"], "достижения": ["победител", "призёр", "призер", "награда", "грамот", "диплом лауреат", "1 место", "2 место", "3 место", "лучший студент", "гордост", "поздравляем", "медал"], "студенческая жизнь": ["студсовет", "студенческ жизн", "общежити", "капустник", "квн", "студенч", "первокурсник", "посвящение", "студент год", "актив"], "профессионалитет": ["профессионалитет", "фп профессионалитет", "кластер", "федеральн проект"], # ── Направления колледжей ───────────────────────────────────────────────── "медицина": ["медицин", "сестринск", "фельдшер", "фармацевт", "лечебн", "анатоми", "патологи", "здравоохранен", "санитар", "первая помощ", "реанимац", "клиническ"], "педагогика": ["педагог", "воспитател", "учител", "начальн класс", "дошкольн", "детский сад", "логопед", "коррекцион", "инклюзив", "тьютор"], "юриспруденция": ["юрист", "юридическ", "правоохранительн", "право", "законодательств", "суд", "прокурат", "полиц", "юриспруденц", "правовед"], "IT и технологии": ["программирован", "it-", "ит-", "информационн технолог", "цифров", "кибербезопасн", "веб-разраб", "1с", "компьютерн", "разработчик", "python", "frontend", "backend", "хакатон", "ворлдскиллс"], "кулинария и торговля": ["кулинар", "повар", "кондитер", "гастроном", "блюд", "рецепт", "ресторан", "кафе", "торговл", "продавец", "мерчандайзинг", "товаровед", "общепит"], "строительство": ["строительств", "архитектур", "проектирован", "чертёж", "чертеж", "монтаж", "сварк", "электромонтаж", "сантехник", "отделочн"], "техника и механика": ["механик", "двигател", "автомобил", "станок", "металлообраб", "токар", "слесар", "техническ обслуж", "ремонт оборудован", "машиностроен", "технолог производ"], "сельское хозяйство": ["агроном", "агроинженер", "сельск хоз", "животновод", "агротехник", "землепользован", "агро"], "экономика и бухучёт": ["бухгалтер", "экономик", "финансов", "налог", "аудит", "бизнес", "предпринимател", "менеджмент", "маркетинг", "логистик"], "социальная работа": ["социальн работ", "соцработник", "психолог", "реабилитац", "инвалид", "ограниченн возможн", "социальн помощ", "опека"], # ── Общественные события ────────────────────────────────────────────────── "спорт": ["спорт", "соревнован", "олимпиад", "футбол", "баскетбол", "волейбол", "чемпион", "кубок", "турнир", "атлет", "фитнес", "зарядк", "ворлдскиллс хайскул"], "военка": ["военн", "армия", "призыв", "нво", "сво", "оборон", "патриот", "защитник", "военно-патриот", "стрельб", "зарниц", "юнармия", "допризывн"], "воздушная опасность": ["воздушн тревог", "воздушн опасност", "ракет", "бпла", "дрон", "обстрел", "укрытие", "сирен", "эвакуац", "бомбоубежищ"], "общественная деятельность": ["волонтёр", "волонтер", "благотвор", "субботник", "экологическ акц", "посадк дерев", "уборк территор", "донорств", "гуманитарн", "помощ фронт"], "праздники и события": ["праздник", "концерт", "фестиваль", "выставк", "торжеств", "церемони", "день колледж", "юбилей", "8 марта", "23 февраля", "новый год", "масленица", "день знаний", "последний звонок"], "конкурсы и олимпиады": ["конкурс", "олимпиад", "чемпионат профессий", "worldskills", "ворлдскиллс", "хакатон", "викторин", "интеллектуальн", "акселератор"], } def _detect_tags(text: str) -> list[str]: lower = text.lower() found = [] for tag_name, keywords in TAG_KEYWORDS.items(): if any(kw in lower for kw in keywords): found.append(tag_name) return found # ── MinIO helpers ───────────────────────────────────────────────────────────── def _minio_client() -> Minio: return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False) def _ensure_bucket(mc: Minio): try: if not mc.bucket_exists(MINIO_BUCKET): mc.make_bucket(MINIO_BUCKET) policy = ( '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*",' f'"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"]' '}]}' ) mc.set_bucket_policy(MINIO_BUCKET, policy) except S3Error: pass async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | None: try: async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: r = await client.get(url) if r.status_code != 200: return None 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}" mc = _minio_client() _ensure_bucket(mc) mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct) public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}" if db is not None: from bd.models import Media db.add(Media( filename=object_name.split("/")[-1], url=public_url, media_type="image", size_bytes=len(data), )) return public_url except Exception as exc: print(f"[VK] Не удалось загрузить фото {url}: {exc}") return None # ── Video download ──────────────────────────────────────────────────────────── async def _fetch_video_info(owner_id: int, video_id: int) -> dict: """Получить info о видео через video.get. Возвращает thumb_url (лучший кадр превью). files.mp4_* и player возвращаются только если токен имеет scope=video; без scope они None — в этом случае скачивание идёт через yt-dlp. """ if not VK_TOKEN: return {} params = { "videos": f"{owner_id}_{video_id}", "access_token": VK_TOKEN, "v": VK_VERSION, } try: async with httpx.AsyncClient(timeout=15) as client: r = await client.get(f"{VK_API}/video.get", params=params) data = r.json() if "error" in data: print(f"[VK video.get] {data['error'].get('error_msg')}") return {} items = data.get("response", {}).get("items", []) if not items: return {} v = items[0] result = {} # лучший кадр превью (выше качеством чем в wall.get) for src_key in ("image", "first_frame"): frames = v.get(src_key, []) if frames: best = max(frames, key=lambda f: f.get("width", 0)) result["thumb_url"] = best.get("url") break return result except Exception as exc: print(f"[VK] video.get {owner_id}_{video_id}: {exc}") return {} def _ytdlp_extract_url(vk_url: str, max_mb: int = 150) -> str | None: """Синхронно: через yt-dlp достать прямую mp4-ссылку без скачивания файла.""" try: import yt_dlp except ImportError: return None max_bytes = max_mb * 1024 * 1024 ydl_opts = { "format": "best[ext=mp4][filesize{}]/best[ext=mp4]/best".format(max_bytes), "quiet": True, "no_warnings": True, } try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(vk_url, download=False) url = info.get("url") if info else None if url: print(f"[VK] yt-dlp: найден mp4 для {vk_url}") return url except Exception as exc: print(f"[VK] yt-dlp: не удалось получить URL {vk_url}: {exc}") return None async def _download_vk_video(owner_id: int, video_id: int, db: AsyncSession | None = None, max_mb: int = 150) -> str | None: """Скачать VK-видео в MinIO и вернуть публичный URL. Использует yt-dlp для получения прямой mp4-ссылки. Стриминг — не держит весь файл в памяти. """ vk_url = f"https://vk.com/video{owner_id}_{video_id}" max_bytes = max_mb * 1024 * 1024 # yt-dlp работает синхронно — запускаем в пуле потоков loop = asyncio.get_event_loop() mp4_url = await loop.run_in_executor(None, _ytdlp_extract_url, vk_url, max_mb) if not mp4_url: return None _chrome_ua = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ) try: async with httpx.AsyncClient( timeout=300, follow_redirects=True, headers={"User-Agent": _chrome_ua, "Referer": "https://vk.com/"}, ) as client: async with client.stream("GET", mp4_url) as r: if r.status_code != 200: print(f"[VK] Видео HTTP {r.status_code}: {mp4_url[:80]}") return None ct = r.headers.get("content-type", "video/mp4").split(";")[0].strip() # Если content-type не видео — значит получили HTML, не файл if "video" not in ct and "octet" not in ct: print(f"[VK] Неверный content-type видео: {ct}") return None declared = int(r.headers.get("content-length", 0)) if declared and declared > max_bytes: print(f"[VK] Видео слишком большое: {declared/1024/1024:.0f}MB > {max_mb}MB, пропускаем") return None chunks, total = [], 0 async for chunk in r.aiter_bytes(1024 * 256): total += len(chunk) if total > max_bytes: print(f"[VK] Видео превысило {max_mb}MB при скачивании, пропускаем") return None chunks.append(chunk) data = b"".join(chunks) object_name = f"vk/videos/{uuid.uuid4().hex}.mp4" mc = _minio_client() _ensure_bucket(mc) mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4") public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}" print(f"[VK] Видео загружено в MinIO: {object_name} ({total/1024/1024:.1f}MB)") if db is not None: from bd.models import Media db.add(Media( filename=object_name.split("/")[-1], url=public_url, media_type="video", size_bytes=len(data), )) return public_url except Exception as exc: print(f"[VK] Ошибка скачивания видео {vk_url}: {exc}") return None # ── VK content helpers ──────────────────────────────────────────────────────── def _best_photo(sizes: list) -> str | None: for t in ("w", "z", "y", "x", "m", "s"): for s in sizes: if s.get("type") == t: return s["url"] return sizes[-1]["url"] if sizes else None def _text_to_html(text: str) -> str: parts = [] for line in text.split("\n"): line = line.strip() if not line: continue line = _URL_RE.sub(r'\1', line) parts.append(f"
{line}
") return "\n".join(parts) def _extract_title(text: str, ts: int) -> str: for line in text.split("\n"): line = line.strip() if line: return line[:200] return "ВКонтакте " + datetime.utcfromtimestamp(ts).strftime("%d.%m.%Y") def _vk_slug(owner_id: int, post_id: int) -> str: return f"vk-{abs(owner_id)}-{post_id}" # ── DB helpers ──────────────────────────────────────────────────────────────── async def _slug_exists(slug: str, db: AsyncSession) -> bool: r = await db.execute(select(Article.id).where(Article.slug == slug)) return r.scalar_one_or_none() is not None async def _get_or_create_category(name: str, db: AsyncSession) -> Category: s = slugify(name) cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none() if not cat: cat = Category(name=name, slug=s) db.add(cat) await db.flush() return cat async def _get_or_create_tag(name: str, db: AsyncSession) -> Tag: s = slugify(name) tag = (await db.execute(select(Tag).where(Tag.slug == s))).scalar_one_or_none() if not tag: tag = Tag(name=name, slug=s) db.add(tag) await db.flush() return tag # ── Post processing ─────────────────────────────────────────────────────────── async def _process_post(post: dict, category_name: str, db: AsyncSession) -> bool: owner_id = post["owner_id"] post_id = post["id"] vk_slug = _vk_slug(owner_id, post_id) if await _slug_exists(vk_slug, db): return False text = post.get("text", "") attachments = post.get("attachments", []) ts = post["date"] # VK Clips и некоторые видео-посты: post.text пустой, описание лежит в video.description if not text: for att in attachments: if att.get("type") == "video": desc = att["video"].get("description", "").strip() if desc: text = desc break cover_url = None content_parts = [] if text: content_parts.append(_text_to_html(text)) for att in attachments: att_type = att.get("type") if att_type == "photo": url = _best_photo(att["photo"].get("sizes", [])) if url: minio_url = await _upload_from_url(url, db=db) final_url = minio_url or url # fallback на оригинальный URL если MinIO недоступен if cover_url is None: cover_url = final_url else: content_parts.append( f'