Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Лабораторные. Дагаев / Проект_Кларк_Блинов_Яковлев.docx
Скачиваний:
1
Добавлен:
02.01.2026
Размер:
2.13 Mб
Скачать

Список литературы

  1. Курилов В. А. Технологии и средства проектирования программных систем. — Новосибирск: НГТУ, 2020. — 276 с.

  2. Вигерс К., Битти Д. Разработка требований к программному обеспечению. 3-е изд., доп. — М.: Русская редакция, 2022. — 736 с.

  3. Матвеева Н. Ю., Золотарюк А. В. Технологии создания и применения чат-ботов // Научные записки молодых исследователей. — 2018. — № 1. — С. 136.

  4. Жеребцова Ю. А., Чижик А. В. Создание чат-бота: обзор архитектур и векторных представлений текста // International Journal of Open Information Technologies. — 2020. — Т. 8, № 7. — С. 56.

  5. Фленов М. В. Программирование на Python. Полный курс. — М.: Эксмо, 2022. — 512 с.

  6. Морозов С. В. Telegram-боты: от новичка до профи. — СПб.: Питер, 2023. — 304 с.

  7. Скубриева Е. А., Агеев С. А. Разработка интерактивных систем на Python. — М.: НИЦ ИНФРА-М, 2021. — 198 с.

  8. Aiogram Community. Aiogram Cookbook [Электронный ресурс]. – Режим доступа: https://github.com/aiogram/aiogram/blob/dev-3.x/docs, свободный. – Дата обращения: 09.06.2025.

  9. PEP 492 – Coroutines with async and await syntax [Электронный ресурс]. – Режим доступа: https://peps.python.org/pep-0492/, свободный. – Дата обращения: 09.06.2025.

  10. GitHub. Async SQLite usage examples [Электронный ресурс]. – Режим доступа: https://github.com/omnilib/aiosqlite, свободный. – Дата обращения: 09.06.2025.

Приложение

import os

import io

import re

import json

import uuid

from pathlib import Path

from typing import Optional, Tuple, Dict, Any, List

from dotenv import load_dotenv

from aiogram import Bot, Dispatcher, F

from aiogram.types import Message, BufferedInputFile, ReplyKeyboardRemove

from aiogram.filters import Command

from PIL import Image, ImageFilter, UnidentifiedImageError

import img2pdf

# ENV & рабочие каталоги

load_dotenv()

BOT_TOKEN = os.getenv("BOT_TOKEN")

WORKDIR = Path(os.getenv("WORKDIR", "./data")).resolve()

WORKDIR.mkdir(parents=True, exist_ok=True)

if not BOT_TOKEN:

raise SystemExit("BOT_TOKEN is not set in .env")

# Настройки по умолчанию

DEFAULT_PREFS = {

"quality": 80, # 1..95 (для JPEG)

"target_kb": None, # целевой размер (КБ) — если задан, качество подбираем бинпоиском

"max_w": None, # ограничение ширины (px)

"max_h": None, # ограничение высоты (px)

# «изюминка»

"auto": True, # авто-режим выбора формата (photo -> JPEG, graphics -> PNG-палитра)

"palette_colors": 128, # глубина палитры для графики (PNG-P)

"strip_exif": False # удалять EXIF/ICC для приватности и меньшего веса

}

# Временные PDF-сессии в RAM: {user_id: [bytes]}

PDF_SESSIONS: Dict[int, List[bytes]] = {}

# Работа с prefs на диске

def user_dir(user_id: int) -> Path:

d = WORKDIR / str(user_id)

d.mkdir(parents=True, exist_ok=True)

return d

def prefs_path(user_id: int) -> Path:

return user_dir(user_id) / "prefs.json"

def load_prefs(user_id: int) -> Dict[str, Any]:

p = prefs_path(user_id)

if p.exists():

try:

data = json.loads(p.read_text("utf-8"))

return {**DEFAULT_PREFS, **data}

except Exception:

return DEFAULT_PREFS.copy()

return DEFAULT_PREFS.copy()

def save_prefs(user_id: int, prefs: Dict[str, Any]) -> None:

pp = prefs_path(user_id)

merged = {**DEFAULT_PREFS, **prefs}

pp.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")

# Хелперы изображений

def _resize_if_needed(img: Image.Image, max_w: Optional[int], max_h: Optional[int]) -> Image.Image:

if not max_w and not max_h:

return img

w, h = img.size

tw = max_w or w

th = max_h or h

scale = min(tw / w, th / h)

if scale >= 1.0: # не апскейлим

return img

new_size = (max(1, int(w * scale)), max(1, int(h * scale)))

return img.resize(new_size, Image.LANCZOS)

def _apply_strip_exif(img: Image.Image, strip: bool) -> None:

# В Pillow EXIF хранится в info["exif"] (если есть)

if strip:

img.info.pop("exif", None)

img.info.pop("icc_profile", None)

def _save_jpeg_bytes(img: Image.Image, quality: int) -> bytes:

buf = io.BytesIO()

img.save(

buf,

format="JPEG",

quality=max(1, min(95, quality)),

optimize=True,

progressive=True, # лучше для «проявки» и часто компактнее

subsampling="4:2:0" # стандартный компромисс для фото

)

return buf.getvalue()

def _save_png_palettized_bytes(img: Image.Image, colors: int) -> bytes:

# Палитризация + дизеринг Флойда–Штайнберга — сильно уменьшает «графику»

pimg = img.convert("P", palette=Image.ADAPTIVE, colors=max(2, min(256, int(colors))),

dither=Image.FLOYDSTEINBERG)

buf = io.BytesIO()

# Для PNG стандартный дефлейт; Pillow сам подберёт фильтры строк (в т.ч. Paeth)

pimg.save(buf, format="PNG", optimize=True)

return buf.getvalue()

def classify_image(img: Image.Image) -> str:

"""

Простая эвристика:

- считаем число уникальных цветов на уменьшенной копии;

- считаем «резкость» краёв (FIND_EDGES) на уменьшенной копии;

Скриншоты/схемы обычно имеют мало цветов и низкую среднюю «резкость».

"""

rgb_small = img.convert("RGB").resize((256, 256))

colors = rgb_small.getcolors(maxcolors=1 << 20)

uniq = len(colors) if colors else (1 << 20)

edges = rgb_small.filter(ImageFilter.FIND_EDGES).convert("L")

edge_mean = sum(edges.getdata()) / (256 * 256)

# Пороговая логика

if uniq < 20000 and edge_mean < 40:

return "graphics"

return "photo"

def compress_image(

raw_bytes: bytes,

*,

quality: int = 80,

target_kb: Optional[int] = None,

max_w: Optional[int] = None,

max_h: Optional[int] = None,

auto: bool = True,

palette_colors: int = 128,

strip_exif: bool = False

) -> Tuple[bytes, str]:

"""

Возвращает: (bytes, filename)

- auto=True: если «graphics» -> PNG палитровый, иначе JPEG (с target_kb/quality).

- auto=False: всегда JPEG (с target_kb/quality).

"""

try:

with Image.open(io.BytesIO(raw_bytes)) as im:

# Приводим к удобному режиму

if im.mode not in ("RGB", "L"):

im = im.convert("RGB")

im = _resize_if_needed(im, max_w, max_h)

_apply_strip_exif(im, strip_exif)

def _jpeg_with_target(img: Image.Image, q_default: int, tgt_kb: Optional[int]) -> bytes:

if tgt_kb is None:

return _save_jpeg_bytes(img, q_default)

target = max(5, tgt_kb) * 1024

lo, hi = 5, 95

best = _save_jpeg_bytes(img, q_default)

# Бинарный поиск по качеству (ограничим ~10 итерациями)

for _ in range(10):

mid = (lo + hi) // 2

cand = _save_jpeg_bytes(img, mid)

if len(cand) > target:

hi = mid - 1

else:

best = cand

lo = mid + 1

return best

if auto:

kind = classify_image(im)

if kind == "graphics":

# PNG-палитра (почти без потерь, резко уменьшает скриншоты/схемы)

data = _save_png_palettized_bytes(im, palette_colors)

name = f"compressed_{uuid.uuid4().hex[:8]}.png"

return data, name

else:

data = _jpeg_with_target(im, quality, target_kb)

name = f"compressed_{uuid.uuid4().hex[:8]}.jpg"

return data, name

else:

# Ручной JPEG-путь (как в MVP)

data = _jpeg_with_target(im, quality, target_kb)

name = f"compressed_{uuid.uuid4().hex[:8]}.jpg"

return data, name

except UnidentifiedImageError:

raise ValueError("Не удалось прочитать изображение. Формат не поддерживается.")

def bytes_to_pdf(images: List[bytes]) -> bytes:

"""Собрать PDF из списка байтов изображений. Для совместимости приводим страницы к JPEG (RGB) перед конвертацией."""

jpg_pages = []

for raw in images:

with Image.open(io.BytesIO(raw)) as im:

if im.mode not in ("RGB", "L"):

im = im.convert("RGB")

b = io.BytesIO()

im.save(b, format="JPEG", quality=90, optimize=True)

jpg_pages.append(b.getvalue())

return img2pdf.convert(jpg_pages)

# Telegram bot wiring

bot = Bot(BOT_TOKEN)

dp = Dispatcher()

HELP = (

"PhotoCompressor Bot\n\n"

"/start — привет и помощь\n"

"/quality 60 — JPEG-качество по умолчанию\n"

"/target 200 — целевой размер в КБ (0 = выкл)\n"

"/maxsize 1920x1080 — ограничение W×H\n"

"/pdf_start — начать сбор PDF (шлите картинки)\n"

"/pdf_done — собрать PDF\n"

"/auto on|off — авто-режим выбора формата (фото→JPEG, графика→PNG)\n"

"/palette 64|128|256 — глубина палитры для PNG (авто-режим)\n"

"/strip_exif on|off — удалять EXIF/ICC для приватности\n\n"

"Пришлите изображение — бот сожмёт и вернёт как документ (без доп. сжатия Telegram)."

)

@dp.message(Command("start"))

async def cmd_start(message: Message):

save_prefs(message.from_user.id, load_prefs(message.from_user.id)) # ensure file exists

await message.answer(HELP, reply_markup=ReplyKeyboardRemove())

@dp.message(Command("quality"))

async def cmd_quality(message: Message):

m = re.search(r"/quality\s+(\d+)", message.text or "")

if not m:

return await message.reply("Формат: /quality 1..95")

q = int(m.group(1))

if not (1 <= q <= 95):

return await message.reply("Значение 1..95")

prefs = load_prefs(message.from_user.id)

prefs["quality"] = q

save_prefs(message.from_user.id, prefs)

await message.reply(f"OK. Качество по умолчанию: {q}")

@dp.message(Command("target"))

async def cmd_target(message: Message):

m = re.search(r"/target\s+(\d+)", message.text or "")

if not m:

return await message.reply("Формат: /target <КБ>. Укажи 0 чтобы отключить.")

kb = int(m.group(1))

prefs = load_prefs(message.from_user.id)

prefs["target_kb"] = None if kb == 0 else max(5, kb)

save_prefs(message.from_user.id, prefs)

val = "выключен" if kb == 0 else f"{prefs['target_kb']} КБ"

await message.reply(f"OK. Целевой размер: {val}")

@dp.message(Command("maxsize"))

async def cmd_maxsize(message: Message):

m = re.search(r"/maxsize\s+(\d+)x(\d+)", message.text or "")

if not m:

return await message.reply("Формат: /maxsize <W>x<H>, например 1920x1080. Укажи 0x0 чтобы снять лимит.")

w, h = int(m.group(1)), int(m.group(2))

prefs = load_prefs(message.from_user.id)

prefs["max_w"] = None if w <= 0 else w

prefs["max_h"] = None if h <= 0 else h

save_prefs(message.from_user.id, prefs)

if prefs["max_w"] or prefs["max_h"]:

await message.reply(f"OK. Максимум: {prefs['max_w'] or '—'}x{prefs['max_h'] or '—'}")

else:

await message.reply("OK. Ограничение размера отключено.")

@dp.message(Command("auto"))

async def cmd_auto(message: Message):

m = re.search(r"/auto\s+(on|off)", (message.text or "").lower())

if not m:

return await message.reply("Формат: /auto on|off")

val = m.group(1) == "on"

prefs = load_prefs(message.from_user.id)

prefs["auto"] = val

save_prefs(message.from_user.id, prefs)

await message.reply(f"Авто-режим: {'ON' if val else 'OFF'}")

@dp.message(Command("palette"))

async def cmd_palette(message: Message):

m = re.search(r"/palette\s+(\d+)", message.text or "")

if not m:

return await message.reply("Формат: /palette 64|128|256")

colors = int(m.group(1))

if colors not in (64, 128, 256):

return await message.reply("Допустимо: 64, 128, 256")

prefs = load_prefs(message.from_user.id)

prefs["palette_colors"] = colors

save_prefs(message.from_user.id, prefs)

await message.reply(f"OK. Палитра для PNG: {colors} цветов")

@dp.message(Command("strip_exif"))

async def cmd_strip_exif(message: Message):

m = re.search(r"/strip_exif\s+(on|off)", (message.text or "").lower())

if not m:

return await message.reply("Формат: /strip_exif on|off")

val = m.group(1) == "on"

prefs = load_prefs(message.from_user.id)

prefs["strip_exif"] = val

save_prefs(message.from_user.id, prefs)

await message.reply(f"EXIF/ICC удаление: {'ON' if val else 'OFF'}")

@dp.message(Command("pdf_start"))

async def cmd_pdf_start(message: Message):

PDF_SESSIONS[message.from_user.id] = []

await message.reply("PDF-режим: пришлите картинки в нужном порядке. Завершите /pdf_done")

@dp.message(Command("pdf_done"))

async def cmd_pdf_done(message: Message):

imgs = PDF_SESSIONS.pop(message.from_user.id, [])

if not imgs:

return await message.reply("Нечего собирать — пришлите картинки после /pdf_start")

try:

pdf_bytes = bytes_to_pdf(imgs)

fname = f"photos_{uuid.uuid4().hex[:8]}.pdf"

await message.answer_document(BufferedInputFile(pdf_bytes, filename=fname))

except Exception as e:

await message.reply(f"Ошибка сборки PDF: {e}")

# Приём изображений

async def _download_any_image_bytes(msg: Message) -> Optional[bytes]:

# Универсальная загрузка байтов файла под aiogram v3

async def _dl(file_id: str) -> bytes:

f = await bot.get_file(file_id)

buf = io.BytesIO()

await bot.download(f, destination=buf)

return buf.getvalue()

# Предпочитаем document (без сжатия Telegram)

if msg.document and (msg.document.mime_type or "").startswith("image/"):

return await _dl(msg.document.file_id)

# Иначе photo (Telegram уже сжал). Берём самую большую версию

if msg.photo:

return await _dl(msg.photo[-1].file_id)

return None

@dp.message(F.document | F.photo)

async def on_image(message: Message):

raw = await _download_any_image_bytes(message)

if raw is None:

return # игнорируем нерелевантные документы

# Если пользователь в PDF-режиме — накапливаем оригиналы

if message.from_user.id in PDF_SESSIONS:

PDF_SESSIONS[message.from_user.id].append(raw)

return await message.reply("Добавлено в PDF-сессию")

# Иначе — сжимаем и выдаём готовый файл

prefs = load_prefs(message.from_user.id)

try:

out_bytes, name = compress_image(

raw,

quality=prefs["quality"],

target_kb=prefs["target_kb"],

max_w=prefs["max_w"],

max_h=prefs["max_h"],

auto=prefs["auto"],

palette_colors=prefs["palette_colors"],

strip_exif=prefs["strip_exif"],

)

await message.answer_document(BufferedInputFile(out_bytes, filename=name))

except ValueError as ve:

await message.reply(str(ve))

except Exception as e:

await message.reply(f"Ошибка обработки: {e}")

if __name__ == "__main__":

import asyncio

async def main():

print("PhotoCompressor Bot started")

await dp.start_polling(bot, allowed_updates=["message", "edited_message"])

asyncio.run(main())

Санкт-Петербург 2025