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 место", "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): 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, 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) 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") 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) public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}" if db is not None: from bd.models import Media db.add(Media( filename=filename, url=public_url, media_type="image", size_bytes=len(data), # article_id выставляется после коммита статьи (см. _process_post) )) 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 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) 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") 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=filename, url=public_url, media_type="video", size_bytes=len(data), # article_id выставляется после коммита статьи (см. _process_post) )) 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 _vk_post_exists(owner_id: int, post_id: int, db: AsyncSession) -> bool: source_url = f"https://vk.com/wall{owner_id}_{post_id}" r = await db.execute(select(Article.id).where(Article.source_url == source_url)) 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, strict_filter: bool = True) -> bool: owner_id = post["owner_id"] post_id = post["id"] vk_slug = _vk_slug(owner_id, post_id) if await _vk_post_exists(owner_id, post_id, db): return False text = post.get("text", "") attachments = post.get("attachments", []) ts = post["date"] # Обработка репостов (copy_history) copy_history = post.get("copy_history", []) if copy_history: original = copy_history[0] orig_gid = str(abs(original.get("owner_id", 0))) in_db = (await db.execute( select(VkSource.id).where(VkSource.group_id == orig_gid) )).scalar_one_or_none() if in_db: # Оригинал придёт сам из своей группы — пропускаем return False # Берём содержимое оригинала; комментарий репостера добавляем в начало orig_text = original.get("text", "") orig_atts = original.get("attachments", []) text = (text + "\n" + orig_text).strip() if text else orig_text attachments = orig_atts if orig_atts else attachments tag_names = _detect_tags(text) # strict_filter=True: пропускаем пост если нет тегов # strict_filter=False: импортируем всё, теги назначаем если найдены if strict_filter and 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: if att.get("type") == "video": desc = att["video"].get("description", "").strip() if desc: 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 = [] 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: 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 else: content_parts.append( f'

' ) elif att_type == "video": vid = att["video"] player = vid.get("player") vtitle = vid.get("title", "Видео") vid_id = vid.get("id") vid_oid = vid.get("owner_id") vk_video_url = ( f"https://vk.com/video{vid_oid}_{vid_id}" if vid_id and vid_oid else None ) # Кадр превью из wall.get (запасной) thumb_url = None for src_key in ("image", "first_frame"): frames = vid.get(src_key, []) if frames: best = max(frames, key=lambda f: f.get("width", 0)) thumb_url = best.get("url") break # Улучшенный кадр превью из video.get (+ может вернуть player при scope=video) if vid_id and vid_oid: vinfo = await _fetch_video_info(vid_oid, vid_id) if vinfo.get("thumb_url"): thumb_url = vinfo["thumb_url"] # Всегда скачиваем превью в MinIO minio_thumb = None if thumb_url: 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 else: final_thumb = None # Скачиваем само видео через yt-dlp → MinIO minio_video_url = None if vid_id and vid_oid: 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. Видео лежит у нас — нативный