From f0dd8788b374d8f76af5ff8f4594a1e2fcb44bd2 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Jul 2026 10:32:38 +0000 Subject: [PATCH] =?UTF-8?q?SEO=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= =?UTF-8?q?,=20=D0=B1=D0=B8=D0=B7=D0=BD=D0=B5=D1=81-API,=20OCR/=D0=BA?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5=D1=82/ZIP,=20=D1=83=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SEO: серверные локализованные страницы /en/ /de/ /fr/ /it/ /es/ /pt/ (title/description/OG/JSON-LD + self-hreflang), sitemap все языки + /developers, OG 1200x630 (static/og.png) - Бизнес-API /api/v1/* (ключи WEB_API_KEYS, rate-limit), страница /developers с самовыдачей ключа через Telegram - OCR-тумблер, загрузка папкой (webkitdirectory) и ZIP (/api/submit-zip-chunked) - Личный кабинет: WEB_API_KEYS/web_owner_id, /api/history - ocr прокинут в submit/submit-chunked/api/v1 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 5 + app/main.py | 1234 ++++++++++- app/static/index.html | 4793 +++++++++++++++++++++++------------------ app/static/og.png | Bin 0 -> 37880 bytes 4 files changed, 3906 insertions(+), 2126 deletions(-) create mode 100644 .gitignore create mode 100644 app/static/og.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2efb6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.bak +*.bak-* +__pycache__/ +*.pyc +app/temp/ diff --git a/app/main.py b/app/main.py index 18a2bcf..6674ee3 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.exceptions import RequestValidationError +from starlette.background import BackgroundTask from starlette.middleware.base import BaseHTTPMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -31,18 +32,27 @@ BOT_USERNAME = os.getenv("BOT_USERNAME", "pdf2scan_pybot") DB_PATH = "/data/bot_db.db" BOT_TEMP = "temp/" # относительный путь (рабочая директория /app) WEB_USER_ID = 0 # фейковый TG user_id для веб-заданий -RESULT_TTL = 1800 # 30 минут +RESULT_TTL = 21600 # 6 часов (большие пачки не успевают за 30 мин) AUTH_TOKEN_TTL = 600 # 10 минут на авторизацию через бота AUTH_REQUIRED_PAGES = 100 AUTH_REQUIRED_MB = 50.0 +# Чанковая загрузка (туннель схлопывает крупные передачи — грузим кусками ≤6 МБ) +UPLOAD_DIR = f"{BOT_TEMP}uploads/" # временные .part-файлы +MAX_CHUNK_BYTES = 12 * 1024 * 1024 # защита: один кусок не больше 12 МБ +MAX_UPLOAD_BYTES = 300 * 1024 * 1024 # суммарный предел собранного файла + _COLOR_MAP = {"color": "цветной", "bw": "черно-белый"} # job_id → {original_name, stitching, created_at} _meta: dict[str, dict] = {} _meta_lock = threading.Lock() +# zip_id → {status, path, name, created_at} — асинхронная сборка больших ZIP +_zip_jobs: dict[str, dict] = {} +_zip_jobs_lock = threading.Lock() + # user_id → unix timestamp of verification (24h TTL) _verified_users: dict[int, float] = {} _verified_users_lock = threading.Lock() @@ -79,6 +89,7 @@ def _convert_doc_to_pdf(src_path: str, dst_path: str) -> None: def _db_insert_job( name_or_f, stitching, title, numeration, flip_side, color, pdfa, quality, original_name, source_size_mb, + ocr=0, web_owner_id=0, ): now = datetime.datetime.now() date_str = now.strftime("%Y.%m.%d") @@ -112,6 +123,12 @@ def _db_insert_job( if "quality" in cols: fields.append("quality") values.append(quality or "high") + if "ocr" in cols: + fields.append("ocr") + values.append(int(ocr)) + if "web_owner_id" in cols: + fields.append("web_owner_id") + values.append(int(web_owner_id or 0)) placeholders = ", ".join(["?"] * len(fields)) sql = f"INSERT INTO FILES ({', '.join(fields)}) VALUES ({placeholders})" @@ -130,6 +147,20 @@ def _db_get_url_res_f(name_or_f: str) -> str | None: return None +def _db_get_job_meta(name_or_f: str): + """(original_name, stitching) из FILES — имена для скачивания/ZIP переживают рестарт.""" + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + cur = con.cursor() + cur.execute("SELECT original_name, stitching FROM FILES WHERE name_or_f = ?", (name_or_f,)) + row = cur.fetchone() + if row: + return (row[0] or None, row[1] or None) + except Exception: + pass + return (None, None) + + def _db_set_url_res_f(name_or_f: str, value: str): try: with sqlite3.connect(DB_PATH, timeout=10) as con: @@ -140,15 +171,23 @@ def _db_set_url_res_f(name_or_f: str, value: str): def _db_queue_position(name_or_f: str) -> int: + """Реальная позиция задания в очереди (1 = бот обрабатывает его сейчас; FIFO по rowid). + Бот не помечает задание 'processing' в БД, поэтому позицию считаем по rowid.""" try: with sqlite3.connect(DB_PATH, timeout=10) as con: cur = con.cursor() + cur.execute("SELECT rowid FROM FILES WHERE name_or_f = ?", (name_or_f,)) + row = cur.fetchone() + if not row: + return 0 + myrow = row[0] cur.execute( "SELECT COUNT(*) FROM FILES " - "WHERE (url_res_f IS NULL OR TRIM(url_res_f) IN ('', '-'))" + "WHERE (url_res_f IS NULL OR TRIM(url_res_f) IN ('', '-')) AND rowid <= ?", + (myrow,), ) - row = cur.fetchone() - return int(row[0]) if row else 0 + r = cur.fetchone() + return int(r[0]) if r else 0 except Exception: return 0 @@ -169,11 +208,73 @@ def _db_ensure_auth_table(): status TEXT DEFAULT 'pending' ) """) + # Подтверждённые веб-пользователи — в БД, чтобы авторизация + # переживала рестарт контейнера (не только in-memory _verified_users). + con.execute(""" + CREATE TABLE IF NOT EXISTS WEB_VERIFIED_USERS ( + user_id INTEGER PRIMARY KEY, + verified_at REAL + ) + """) + # API-ключи для бизнес-доступа (интеграции). owner_id — TG user_id + # владельца ключа (из веб-авторизации). Оплату/квоты прикрутим позже — + # поля plan/quota уже заложены. + con.execute(""" + CREATE TABLE IF NOT EXISTS WEB_API_KEYS ( + api_key TEXT PRIMARY KEY, + owner_id INTEGER, + name TEXT, + plan TEXT DEFAULT 'free', + active INTEGER DEFAULT 1, + req_count INTEGER DEFAULT 0, + created_at REAL, + last_req REAL + ) + """) + # Авто-миграция FILES: колонки для OCR и владельца веб-задания + # (личный кабинет «Мои документы»). ADD COLUMN идемпотентен по факту наличия. + try: + cur = con.execute("PRAGMA table_info(FILES)") + have = {row[1] for row in cur.fetchall()} + if "ocr" not in have: + con.execute("ALTER TABLE FILES ADD COLUMN ocr INTEGER DEFAULT 0") + if "web_owner_id" not in have: + con.execute("ALTER TABLE FILES ADD COLUMN web_owner_id INTEGER DEFAULT 0") + except Exception as _mig: + print(f"FILES migrate error: {_mig}") con.commit() except Exception as e: print(f"WEB_AUTH_TOKENS create error: {e}") +def _db_mark_verified(user_id: int): + if not user_id: + return + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute( + "INSERT INTO WEB_VERIFIED_USERS (user_id, verified_at) VALUES (?, ?) " + "ON CONFLICT(user_id) DO UPDATE SET verified_at=excluded.verified_at", + (int(user_id), time.time()), + ) + con.commit() + except Exception: + pass + + +def _db_is_verified(user_id: int) -> bool: + if not user_id: + return False + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + cur = con.cursor() + cur.execute("SELECT verified_at FROM WEB_VERIFIED_USERS WHERE user_id = ?", (int(user_id),)) + row = cur.fetchone() + return bool(row) and (time.time() - (row[0] or 0) <= 86400) + except Exception: + return False + + def _db_create_auth_token(token: str): with sqlite3.connect(DB_PATH, timeout=10) as con: con.execute( @@ -224,18 +325,40 @@ def _cleanup_loop(): con.commit() except Exception: pass + # Удаляем старые результаты по mtime файла — не зависит от in-memory _meta, + # поэтому переживает рестарт контейнера (пачка не пропадает). + try: + for rp in Path(BOT_TEMP).glob("* скан *.pdf"): + try: + if now - rp.stat().st_mtime > RESULT_TTL: + rp.unlink() + except Exception: + pass + except Exception: + pass with _meta_lock: - expired = [jid for jid, m in _meta.items() if now - m["created_at"] > RESULT_TTL] - for job_id in expired: - with _meta_lock: - meta = _meta.pop(job_id, {}) - stitching = meta.get("stitching", "") - result_path = f"{BOT_TEMP}{job_id} скан {stitching}.pdf" - try: - os.remove(result_path) - except Exception: - pass - _db_set_url_res_f(job_id, "expired") + for jid in [j for j, m in _meta.items() if now - m["created_at"] > RESULT_TTL]: + _meta.pop(jid, None) + # Подчищаем оставшиеся ZIP-архивы (на случай, если их не скачали) + try: + for zp in Path(BOT_TEMP).glob("*.zip"): + if now - zp.stat().st_mtime > RESULT_TTL: + zp.unlink() + except Exception: + pass + # Подчищаем устаревшие записи фоновых zip-сборок + with _zip_jobs_lock: + for zid in [z for z, jj in _zip_jobs.items() if now - jj.get("created_at", now) > RESULT_TTL]: + old = _zip_jobs.pop(zid, {}) + if old.get("path"): + _safe_remove(old["path"]) + # Подчищаем брошенные куски чанковой загрузки (старше часа) + try: + for pp in Path(UPLOAD_DIR).glob("*.part"): + if now - pp.stat().st_mtime > 3600: + pp.unlink() + except Exception: + pass # ── Auth ────────────────────────────────────────────────────────────────────── @@ -281,6 +404,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE @app.on_event("startup") async def _startup(): os.makedirs(BOT_TEMP, exist_ok=True) + os.makedirs(UPLOAD_DIR, exist_ok=True) _db_ensure_auth_table() threading.Thread(target=_cleanup_loop, daemon=True).start() @@ -288,10 +412,199 @@ async def _startup(): app.mount("/static", StaticFiles(directory="/web_app/static"), name="static") +# ── SEO: серверный рендер локализованных языковых страниц ───────────────────── +# Отдельные URL на язык (/, /en/, /de/…) с локализованным — краулеры видят +# правильные title/description/hreflang на своём языке, это открывает западный трафик. +SEO_BASE = "https://pdf2scan.online" +SEO_LANG_PATH = { + "ru": "/", "en": "/en/", "de": "/de/", "fr": "/fr/", + "it": "/it/", "es": "/es/", "pt": "/pt/", +} +SEO_LOCALE = { + "ru": "ru_RU", "en": "en_US", "de": "de_DE", "fr": "fr_FR", + "it": "it_IT", "es": "es_ES", "pt": "pt_PT", +} +SEO = { + "ru": { + "title": "pdf2scan — сделать скан из PDF онлайн бесплатно", + "desc": "Сделайте скан из PDF онлайн — бесплатно, без регистрации. Эффект сканирования, сшивка (нитка, ленточка, пружинка, уголок, дырокол), нумерация страниц и лист «прошито-пронумеровано».", + "og_desc": "Сделайте скан из PDF онлайн — бесплатно, без регистрации. Сшивка, нумерация, эффект сканирования в один клик.", + "keywords": "сделать скан из pdf онлайн, pdf в скан онлайн, сканирование pdf бесплатно, pdf с эффектом сканирования, прошить документ онлайн, сшивка документов онлайн, растровый pdf онлайн, pdf2scan", + "ld_desc": "Бесплатный онлайн-инструмент для создания PDF с эффектом сканирования. Сшивка нитками, ленточкой, уголком, пружинкой и дыроколом. Нумерация страниц, титульный лист, PDF/A.", + }, + "en": { + "title": "pdf2scan — add a scan effect to PDF online, free", + "desc": "Make a PDF look scanned online — free, no signup. Add a realistic scan effect, binding (thread, ribbon, spiral, corner, hole punch), page numbering and a certification page. Word to scanned PDF too.", + "og_desc": "Make your PDF look scanned online — free, no signup. Scan effect, binding and page numbering in one click.", + "keywords": "pdf scanner effect online, make pdf look scanned, add scan effect to pdf, convert pdf to scanned document, scan effect pdf free, pdf to scan online, word to scanned pdf, pdf2scan", + "ld_desc": "Free online tool to give a PDF a realistic scan effect. Thread, ribbon, corner, spiral and hole-punch binding. Page numbering, title page, PDF/A.", + }, + "de": { + "title": "pdf2scan — PDF online mit Scan-Effekt versehen, kostenlos", + "desc": "PDF online wie gescannt aussehen lassen — kostenlos, ohne Anmeldung. Realistischer Scan-Effekt, Bindung (Faden, Band, Spirale, Ecke, Lochung), Seitennummerierung und Zertifizierungsseite. Auch Word zu gescanntem PDF.", + "og_desc": "PDF online wie gescannt aussehen lassen — kostenlos, ohne Anmeldung. Scan-Effekt, Bindung und Nummerierung in einem Klick.", + "keywords": "pdf scan-effekt online, pdf wie gescannt, pdf in scan umwandeln, scan-effekt pdf kostenlos, word in gescanntes pdf, pdf2scan", + "ld_desc": "Kostenloses Online-Tool, um ein PDF wie einen echten Scan aussehen zu lassen. Faden-, Band-, Ecken-, Spiral- und Lochbindung. Seitennummerierung, Titelseite, PDF/A.", + }, + "fr": { + "title": "pdf2scan — ajouter un effet scan à un PDF en ligne, gratuit", + "desc": "Donnez à un PDF l'apparence d'un document scanné en ligne — gratuit, sans inscription. Effet scan réaliste, reliure (fil, ruban, spirale, coin, perforation), numérotation et page de certification. Word vers PDF scanné aussi.", + "og_desc": "Donnez à un PDF l'apparence d'un scan en ligne — gratuit, sans inscription. Effet scan, reliure et numérotation en un clic.", + "keywords": "effet scan pdf en ligne, pdf aspect scanné, convertir pdf en scan, effet scan pdf gratuit, word en pdf scanné, pdf2scan", + "ld_desc": "Outil en ligne gratuit pour donner à un PDF un effet de scan réaliste. Reliure par fil, ruban, coin, spirale et perforateur. Numérotation, page de titre, PDF/A.", + }, + "it": { + "title": "pdf2scan — aggiungi un effetto scansione al PDF online, gratis", + "desc": "Fai sembrare scansionato un PDF online — gratis, senza registrazione. Effetto scansione realistico, rilegatura (filo, nastro, spirale, angolo, foratura), numerazione e pagina di certificazione. Anche da Word a PDF scansionato.", + "og_desc": "Fai sembrare scansionato un PDF online — gratis, senza registrazione. Effetto scansione, rilegatura e numerazione in un clic.", + "keywords": "effetto scansione pdf online, pdf aspetto scansionato, convertire pdf in scansione, effetto scansione pdf gratis, word in pdf scansionato, pdf2scan", + "ld_desc": "Strumento online gratuito per dare a un PDF un effetto scansione realistico. Rilegatura con filo, nastro, angolo, spirale e perforatore. Numerazione, pagina del titolo, PDF/A.", + }, + "es": { + "title": "pdf2scan — añade efecto de escaneo a un PDF online, gratis", + "desc": "Haz que un PDF parezca escaneado online — gratis, sin registro. Efecto de escaneo realista, encuadernación (hilo, cinta, espiral, esquina, perforación), numeración y página de certificación. También de Word a PDF escaneado.", + "og_desc": "Haz que un PDF parezca escaneado online — gratis, sin registro. Efecto de escaneo, encuadernación y numeración en un clic.", + "keywords": "efecto escaneo pdf online, pdf aspecto escaneado, convertir pdf a escaneado, efecto escaneo pdf gratis, word a pdf escaneado, pdf2scan", + "ld_desc": "Herramienta online gratuita para dar a un PDF un efecto de escaneo realista. Encuadernación con hilo, cinta, esquina, espiral y perforadora. Numeración, página de título, PDF/A.", + }, + "pt": { + "title": "pdf2scan — adicione efeito de digitalização ao PDF online, grátis", + "desc": "Faça um PDF parecer digitalizado online — grátis, sem cadastro. Efeito de digitalização realista, encadernação (fio, fita, espiral, canto, furação), numeração e página de certificação. Também de Word para PDF digitalizado.", + "og_desc": "Faça um PDF parecer digitalizado online — grátis, sem cadastro. Efeito de digitalização, encadernação e numeração em um clique.", + "keywords": "efeito digitalização pdf online, pdf aparência digitalizada, converter pdf em digitalizado, efeito scan pdf grátis, word para pdf digitalizado, pdf2scan", + "ld_desc": "Ferramenta online gratuita para dar a um PDF um efeito de digitalização realista. Encadernação com fio, fita, canto, espiral e furador. Numeração, página de título, PDF/A.", + }, +} + +_INDEX_RAW: str | None = None + + +def _load_index_template() -> str: + global _INDEX_RAW + if _INDEX_RAW is None: + with open("/web_app/static/index.html", encoding="utf-8") as f: + _INDEX_RAW = f.read() + return _INDEX_RAW + + +def _esc_attr(s: str) -> str: + return s.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">") + + +def _build_seo_head(lang: str) -> str: + s = SEO[lang] + path = SEO_LANG_PATH[lang] + url = SEO_BASE + path + title = _esc_attr(s["title"]) + desc = _esc_attr(s["desc"]) + og_desc = _esc_attr(s["og_desc"]) + keywords = _esc_attr(s["keywords"]) + og_img = f"{SEO_BASE}/static/og.png" + + hreflangs = "\n".join( + f'' + for lc, p in SEO_LANG_PATH.items() + ) + ld = { + "@context": "https://schema.org", + "@type": "WebApplication", + "name": "pdf2scan", + "url": url, + "description": s["ld_desc"], + "applicationCategory": "UtilitiesApplication", + "operatingSystem": "Any", + "browserRequirements": "Requires JavaScript", + "inLanguage": list(SEO_LANG_PATH.keys()), + "offers": {"@type": "Offer", "price": "0", "priceCurrency": "RUB"}, + "creator": {"@type": "Organization", "name": "pdf2scan", "url": SEO_BASE}, + } + import json as _json + ld_json = _json.dumps(ld, ensure_ascii=False, indent=2) + + return ( + f"{title}\n" + f'\n' + f'\n' + f'\n' + f"{hreflangs}\n" + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'\n' + f'' + ) + + +def _render_index_html(lang: str) -> str: + lang = lang if lang in SEO else "ru" + html = _load_index_template() + # Локализуем язык документа. На корне (/ = ru/x-default) data-prelang оставляем + # пустым, чтобы уважать сохранённый выбор пользователя и авто-детект языка; + # на явных языковых URL (/en/, /de/…) prelang жёсткий — страница на своём языке. + prelang = "" if lang == "ru" else lang + html = html.replace( + '', + f'', + ) + # Заменяем SEO-блок между маркерами на локализованный + start = html.find("") + end = html.find("") + if start != -1 and end != -1: + head = _build_seo_head(lang) + html = html[:start] + "\n" + head + "\n" + html[end:] + return html + + @app.get("/", response_class=HTMLResponse) async def index(): - with open("/web_app/static/index.html", encoding="utf-8") as f: - return f.read() + return _render_index_html("ru") + + +@app.get("/en/", response_class=HTMLResponse) +@app.get("/en", response_class=HTMLResponse) +async def index_en(): + return _render_index_html("en") + + +@app.get("/de/", response_class=HTMLResponse) +@app.get("/de", response_class=HTMLResponse) +async def index_de(): + return _render_index_html("de") + + +@app.get("/fr/", response_class=HTMLResponse) +@app.get("/fr", response_class=HTMLResponse) +async def index_fr(): + return _render_index_html("fr") + + +@app.get("/it/", response_class=HTMLResponse) +@app.get("/it", response_class=HTMLResponse) +async def index_it(): + return _render_index_html("it") + + +@app.get("/es/", response_class=HTMLResponse) +@app.get("/es", response_class=HTMLResponse) +async def index_es(): + return _render_index_html("es") + + +@app.get("/pt/", response_class=HTMLResponse) +@app.get("/pt", response_class=HTMLResponse) +async def index_pt(): + return _render_index_html("pt") @app.get("/favicon.ico", include_in_schema=False) @@ -317,16 +630,38 @@ async def robots(): @app.get("/sitemap.xml") async def sitemap(): today = datetime.date.today().isoformat() + # Все языковые страницы + перекрёстные hreflang-альтернативы (xhtml:link) + alternates = "".join( + f' \n' + for lc, p in SEO_LANG_PATH.items() + ) + alternates += f' \n' + urls = "" + for lc, p in SEO_LANG_PATH.items(): + urls += ( + ' \n' + f' {SEO_BASE}{p}\n' + f'{alternates}' + f' {today}\n' + ' weekly\n' + f' {"1.0" if lc == "ru" else "0.9"}\n' + ' \n' + ) + # Страница API-документации (для запросов «pdf scan api») + urls += ( + ' \n' + f' {SEO_BASE}/developers\n' + f' {today}\n' + ' monthly\n' + ' 0.7\n' + ' \n' + ) return HTMLResponse( content=( '\n' - '\n' - ' \n' - ' https://pdf2scan.online/\n' - f' {today}\n' - ' monthly\n' - ' 1.0\n' - ' \n' + '\n' + f'{urls}' '\n' ), media_type="application/xml", @@ -355,6 +690,7 @@ async def poll_auth_token(token: str): if uid: with _verified_users_lock: _verified_users[uid] = time.time() + _db_mark_verified(uid) return { "status": "verified", "user_id": uid, @@ -380,6 +716,7 @@ async def verify_auth(data: dict): if uid: with _verified_users_lock: _verified_users[uid] = time.time() + _db_mark_verified(uid) except (TypeError, ValueError): pass return {"ok": True, "user_id": data.get("id")} @@ -394,6 +731,114 @@ def _count_pdf_pages(content: bytes) -> int: return 0 +def _write_bytes(path: str, data: bytes) -> None: + """Синхронная запись файла — вызывается через asyncio.to_thread, чтобы не блокировать event-loop.""" + with open(path, "wb") as f_out: + f_out.write(data) + + +def _read_and_count_pdf(path: str) -> int: + """Читает PDF с диска и считает страницы (для запуска в отдельном потоке).""" + try: + with open(path, "rb") as f_in: + return _count_pdf_pages(f_in.read()) + except Exception: + return 0 + + +def _safe_remove(path: str) -> None: + try: + os.remove(path) + except Exception: + pass + + +_UPLOAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]{8,64}$") + + +def _chunk_part_path(upload_id: str): + """Путь к временному .part-файлу чанковой загрузки (с валидацией upload_id).""" + if not upload_id or not _UPLOAD_ID_RE.match(upload_id): + return None + return f"{UPLOAD_DIR}{upload_id}.part" + + +def _write_chunk_at(path: str, data: bytes, offset: int) -> None: + """Пишет кусок по смещению (идемпотентно при ретраях). Вызывается через to_thread.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT, 0o644) + try: + os.pwrite(fd, data, offset) + finally: + os.close(fd) + + +def _finalize_submit(job_id, file_path, original_name, stitching, title, + numeration, flip_side, color, pdfa, quality, + size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None): + """Общий хвост постановки задания: проверка авторизации, запись в БД, _meta. + trusted=True (бизнес-API по ключу) — пропускает порог авторизации по размеру. + web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id).""" + if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES): + verified = False + if user_id: + with _verified_users_lock: + ts = _verified_users.get(user_id) + if ts and time.time() - ts <= 86400: + verified = True + # Фоллбэк на БД — авторизация переживает рестарт контейнера + if not verified and _db_is_verified(user_id): + verified = True + if not verified: + _safe_remove(file_path) + raise HTTPException( + status_code=403, + detail="Для файлов больше 100 страниц или 50 МБ необходима авторизация через Telegram.", + ) + + bot_color = _COLOR_MAP.get(color, "цветной") + owner = web_owner_id if web_owner_id is not None else user_id + try: + _db_insert_job( + name_or_f=job_id, + stitching=stitching, + title=title, + numeration=numeration, + flip_side=flip_side, + color=bot_color, + pdfa=pdfa, + quality=quality, + original_name=original_name, + source_size_mb=size_mb, + ocr=ocr, + web_owner_id=owner, + ) + except Exception as exc: + _safe_remove(file_path) + raise HTTPException(status_code=500, detail=f"Ошибка записи в БД: {exc}") + + with _meta_lock: + _meta[job_id] = { + "original_name": original_name, + "stitching": stitching, + "created_at": time.time(), + } + return {"job_id": job_id} + + +def _build_zip(items) -> str: + """Собирает ZIP из готовых PDF. items = [(src_path, arcname), ...]. Возвращает путь к временному архиву.""" + import tempfile + import zipfile + fd, zip_path = tempfile.mkstemp(suffix=".zip", dir=BOT_TEMP) + os.close(fd) + # ZIP_STORED: PDF уже сжаты, повторное сжатие только грузит CPU без выгоды + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as zf: + for src_path, arcname in items: + zf.write(src_path, arcname=arcname) + return zip_path + + @app.post("/api/submit") async def submit( file: UploadFile = File(...), @@ -404,6 +849,7 @@ async def submit( color: str = Form("color"), pdfa: int = Form(0), quality: str = Form("high"), + ocr: int = Form(0), user_id: int = Form(0), ): original_name = file.filename or "document.pdf" @@ -422,8 +868,7 @@ async def submit( # Сохраняем оригинальный Word-документ во временный файл src_path = f"{BOT_TEMP}{job_id}{src_ext}" try: - with open(src_path, "wb") as f_out: - f_out.write(content) + await asyncio.to_thread(_write_bytes, src_path, content) except Exception as exc: raise HTTPException(status_code=500, detail=f"Не удалось сохранить файл: {exc}") # Конвертируем в PDF через libreoffice @@ -441,67 +886,98 @@ async def submit( except Exception: pass # Считаем страницы из конвертированного PDF - try: - with open(file_path, "rb") as f_in: - pages = _count_pdf_pages(f_in.read()) - except Exception: - pages = 0 + pages = await asyncio.to_thread(_read_and_count_pdf, file_path) else: - pages = _count_pdf_pages(content) + pages = await asyncio.to_thread(_count_pdf_pages, content) try: - with open(file_path, "wb") as f_out: - f_out.write(content) + await asyncio.to_thread(_write_bytes, file_path, content) except Exception as exc: raise HTTPException(status_code=500, detail=f"Не удалось сохранить файл: {exc}") - # Серверная проверка авторизации - if size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES: - verified = False - if user_id: - with _verified_users_lock: - ts = _verified_users.get(user_id) - if ts and time.time() - ts <= 86400: - verified = True - if not verified: - try: - os.remove(file_path) - except Exception: - pass - raise HTTPException( - status_code=403, - detail="Для файлов больше 10 страниц или 10 МБ необходима авторизация через Telegram.", - ) + return _finalize_submit( + job_id, file_path, original_name, stitching, title, + numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id, + ocr=ocr, + ) - bot_color = _COLOR_MAP.get(color, "цветной") + +@app.post("/api/upload-chunk") +async def upload_chunk( + upload_id: str = Form(...), + offset: int = Form(...), + chunk: UploadFile = File(...), +): + """Приём одного куска файла (чанковая загрузка в обход потолка туннеля).""" + part_path = _chunk_part_path(upload_id) + if not part_path: + raise HTTPException(status_code=400, detail="Bad upload_id") + if offset < 0 or offset > MAX_UPLOAD_BYTES: + raise HTTPException(status_code=400, detail="Bad offset") + data = await chunk.read() + if len(data) > MAX_CHUNK_BYTES: + raise HTTPException(status_code=413, detail="Chunk too large") + if offset + len(data) > MAX_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="Upload too large") + await asyncio.to_thread(_write_chunk_at, part_path, data, offset) + return {"ok": True} + + +@app.post("/api/submit-chunked") +async def submit_chunked( + upload_id: str = Form(...), + filename: str = Form("document.pdf"), + total_size: int = Form(0), + stitching: str = Form("без сшивки"), + title: int = Form(0), + numeration: int = Form(0), + flip_side: int = Form(0), + color: str = Form("color"), + pdfa: int = Form(0), + quality: str = Form("high"), + ocr: int = Form(0), + user_id: int = Form(0), +): + """Финализация чанковой загрузки: собранный .part становится заданием в очереди.""" + part_path = _chunk_part_path(upload_id) + if not part_path or not os.path.exists(part_path): + raise HTTPException(status_code=404, detail="Загрузка не найдена") + actual = os.path.getsize(part_path) + if total_size and actual != total_size: + _safe_remove(part_path) + raise HTTPException(status_code=400, detail=f"Размер не совпал ({actual} != {total_size})") + + original_name = filename or "document.pdf" + src_ext = os.path.splitext(original_name)[1].lower() + is_doc = src_ext in (".doc", ".docx") + _stem = os.path.splitext(original_name)[0].replace("'", "").replace(":", "") + job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}" + file_path = f"{BOT_TEMP}{job_id}.pdf" + size_mb = actual / (1024 * 1024) try: - _db_insert_job( - name_or_f=job_id, - stitching=stitching, - title=title, - numeration=numeration, - flip_side=flip_side, - color=bot_color, - pdfa=pdfa, - quality=quality, - original_name=original_name, - source_size_mb=size_mb, - ) + if is_doc: + src_path = f"{BOT_TEMP}{job_id}{src_ext}" + await asyncio.to_thread(os.replace, part_path, src_path) + try: + await asyncio.to_thread(_convert_doc_to_pdf, src_path, file_path) + finally: + _safe_remove(src_path) + pages = await asyncio.to_thread(_read_and_count_pdf, file_path) + else: + await asyncio.to_thread(os.replace, part_path, file_path) + pages = await asyncio.to_thread(_read_and_count_pdf, file_path) + except HTTPException: + raise except Exception as exc: - try: - os.remove(file_path) - except Exception: - pass - raise HTTPException(status_code=500, detail=f"Ошибка записи в БД: {exc}") + _safe_remove(part_path) + _safe_remove(file_path) + raise HTTPException(status_code=500, detail=f"Не удалось обработать файл: {exc}") - with _meta_lock: - _meta[job_id] = { - "original_name": original_name, - "stitching": stitching, - "created_at": time.time(), - } - - return {"job_id": job_id} + return _finalize_submit( + job_id, file_path, original_name, stitching, title, + numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id, + ocr=ocr, + ) @app.get("/api/status/{job_id}") @@ -513,6 +989,9 @@ async def status(job_id: str): if url_res_f in ("-", "", None): pos = _db_queue_position(job_id) + if pos <= 1: + # передний в очереди — бот его сейчас и обрабатывает (он не пишет 'processing' в БД) + return {"status": "processing", "progress": 50, "queue_pos": 0, "error": None} return {"status": "queued", "progress": 0, "queue_pos": pos, "error": None} if url_res_f in ("processing", "uploading"): @@ -539,17 +1018,20 @@ async def status(job_id: str): return {"status": "done", "progress": 100, "queue_pos": 0, "error": None} -@app.get("/api/download/{job_id}") -async def download(job_id: str): +def _make_download_response(job_id: str) -> FileResponse: + """Готовит FileResponse с результатом задания (общий код для веба и бизнес-API).""" url_res_f = _db_get_url_res_f(job_id) if url_res_f != "web_local": raise HTTPException(status_code=409, detail="Not ready") with _meta_lock: meta = _meta.get(job_id, {}) - - original_name = meta.get("original_name", "document.pdf") - stitching = meta.get("stitching", "без сшивки") + original_name = meta.get("original_name") + stitching = meta.get("stitching") + if not original_name or not stitching: + db_name, db_st = _db_get_job_meta(job_id) # фоллбэк на БД (переживает рестарт) + original_name = original_name or db_name or "document.pdf" + stitching = stitching or db_st or "без сшивки" base = os.path.splitext(original_name)[0] download_name = f"{base} скан.pdf" result_path = f"{BOT_TEMP}{job_id} скан {stitching}.pdf" @@ -563,3 +1045,591 @@ async def download(job_id: str): media_type="application/pdf", filename=download_name, ) + + +@app.get("/api/download/{job_id}") +async def download(job_id: str): + return _make_download_response(job_id) + + +def _collect_zip_items(job_ids): + """[(result_path, arcname)] для готовых заданий (имена из _meta или БД).""" + items = [] + used = set() + for jid in job_ids: + if not isinstance(jid, str): + continue + if _db_get_url_res_f(jid) != "web_local": + continue + with _meta_lock: + meta = _meta.get(jid, {}) + original_name = meta.get("original_name") + stitching = meta.get("stitching") + if not original_name or not stitching: + db_name, db_st = _db_get_job_meta(jid) + original_name = original_name or db_name or "document.pdf" + stitching = stitching or db_st or "без сшивки" + result_path = f"{BOT_TEMP}{jid} скан {stitching}.pdf" + if not os.path.exists(result_path): + continue + base = os.path.splitext(os.path.basename(original_name))[0] + arcname = f"{base} скан.pdf" + n = 1 + while arcname in used: + n += 1 + arcname = f"{base} скан ({n}).pdf" + used.add(arcname) + items.append((result_path, arcname)) + return items + + +def _zip_build_worker(zip_id, items): + """Фоновая сборка ZIP (большие архивы не успевают за таймаут заголовков прокси).""" + try: + path = _build_zip(items) + with _zip_jobs_lock: + j = _zip_jobs.get(zip_id) + if j is not None: + j["status"] = "ready" + j["path"] = path + except Exception as exc: + with _zip_jobs_lock: + j = _zip_jobs.get(zip_id) + if j is not None: + j["status"] = "error" + j["error"] = str(exc) + + +@app.post("/api/zip/prepare") +async def zip_prepare(payload: dict): + """Старт фоновой сборки ZIP. Возвращает zip_id; статус — /api/zip/status.""" + job_ids = payload.get("job_ids") if isinstance(payload, dict) else None + if not isinstance(job_ids, list) or not job_ids: + raise HTTPException(status_code=400, detail="job_ids required") + if len(job_ids) > 20: + raise HTTPException(status_code=400, detail="Слишком много файлов (максимум 20)") + items = _collect_zip_items(job_ids) + if not items: + raise HTTPException(status_code=409, detail="Нет готовых файлов для архива") + zip_id = secrets.token_hex(8) + download_name = f"pdf2scan_{datetime.datetime.now().strftime('%Y-%m-%d')}.zip" + with _zip_jobs_lock: + _zip_jobs[zip_id] = {"status": "building", "created_at": time.time(), "name": download_name} + threading.Thread(target=_zip_build_worker, args=(zip_id, items), daemon=True).start() + return {"zip_id": zip_id, "name": download_name} + + +@app.get("/api/zip/status/{zip_id}") +async def zip_status(zip_id: str): + with _zip_jobs_lock: + j = _zip_jobs.get(zip_id) + if not j: + raise HTTPException(status_code=404, detail="not found") + return {"status": j.get("status"), "name": j.get("name"), "error": j.get("error")} + + +@app.get("/api/zip/file/{zip_id}") +async def zip_file(zip_id: str): + with _zip_jobs_lock: + j = _zip_jobs.get(zip_id) + if not j or j.get("status") != "ready" or not j.get("path") or not os.path.exists(j["path"]): + raise HTTPException(status_code=404, detail="Архив не готов") + path = j["path"] + name = j.get("name", "pdf2scan.zip") + + def _cleanup(): + _safe_remove(path) + with _zip_jobs_lock: + _zip_jobs.pop(zip_id, None) + + return FileResponse( + path, + media_type="application/zip", + filename=name, + background=BackgroundTask(_cleanup), + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Бизнес-API (доступ по ключу) — /api/v1/* +# ══════════════════════════════════════════════════════════════════════════════ +API_RATE_PER_MIN = 60 # мягкий лимит запросов в минуту на ключ +_api_rate: dict[str, list] = {} +_api_rate_lock = threading.Lock() + + +def _db_create_api_key(owner_id: int, name: str, plan: str = "free") -> str: + key = "sk_" + secrets.token_hex(24) + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute( + "INSERT INTO WEB_API_KEYS (api_key, owner_id, name, plan, active, req_count, created_at) " + "VALUES (?, ?, ?, ?, 1, 0, ?)", + (key, int(owner_id or 0), (name or "key")[:64], plan, time.time()), + ) + con.commit() + return key + + +def _db_get_api_key(key: str): + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + cur = con.cursor() + cur.execute( + "SELECT api_key, owner_id, name, plan, active, req_count " + "FROM WEB_API_KEYS WHERE api_key = ?", + (key,), + ) + row = cur.fetchone() + if not row: + return None + return { + "api_key": row[0], "owner_id": row[1], "name": row[2], + "plan": row[3], "active": row[4], "req_count": row[5], + } + except Exception: + return None + + +def _db_touch_api_key(key: str): + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + con.execute( + "UPDATE WEB_API_KEYS SET req_count = req_count + 1, last_req = ? WHERE api_key = ?", + (time.time(), key), + ) + con.commit() + except Exception: + pass + + +def _extract_api_key(request: Request) -> str | None: + auth = request.headers.get("authorization") or "" + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return request.headers.get("x-api-key") + + +def _require_api_key(request: Request) -> dict: + key = _extract_api_key(request) + if not key: + raise HTTPException( + status_code=401, + detail="Missing API key. Use 'Authorization: Bearer ' or 'X-API-Key' header.", + ) + row = _db_get_api_key(key) + if not row or not row.get("active"): + raise HTTPException(status_code=401, detail="Invalid or inactive API key.") + now = time.time() + with _api_rate_lock: + bucket = [t for t in _api_rate.get(key, []) if now - t < 60] + if len(bucket) >= API_RATE_PER_MIN: + raise HTTPException(status_code=429, detail=f"Rate limit exceeded ({API_RATE_PER_MIN}/min).") + bucket.append(now) + _api_rate[key] = bucket + _db_touch_api_key(key) + return row + + +@app.post("/api/v1/keys") +async def api_create_key(token: str = Form(...), name: str = Form("api")): + """Самовыдача ключа: нужен подтверждённый auth-токen (после входа через Telegram-бота).""" + row = _db_poll_auth_token(token) + if not row or row.get("status") != "verified" or not row.get("user_id"): + raise HTTPException(status_code=401, detail="Требуется вход через Telegram (verified token).") + key = _db_create_api_key(row["user_id"], name) + return {"api_key": key, "plan": "free", "rate_limit_per_min": API_RATE_PER_MIN, + "note": "Сохраните ключ — он показывается один раз."} + + +@app.post("/api/v1/scan") +async def api_scan( + request: Request, + file: UploadFile = File(...), + stitching: str = Form("без сшивки"), + title: int = Form(0), + numeration: int = Form(0), + flip_side: int = Form(0), + color: str = Form("color"), + pdfa: int = Form(0), + quality: str = Form("high"), + ocr: int = Form(0), +): + """Постановка файла в обработку по API-ключу. Возвращает job_id.""" + key_row = _require_api_key(request) + + original_name = file.filename or "document.pdf" + src_ext = os.path.splitext(original_name)[1].lower() + is_doc = src_ext in (".doc", ".docx") + _stem = os.path.splitext(original_name)[0].replace("'", "").replace(":", "") + job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}" + file_path = f"{BOT_TEMP}{job_id}.pdf" + + content = await file.read() + size_mb = len(content) / (1024 * 1024) + if size_mb > MAX_UPLOAD_BYTES / (1024 * 1024): + raise HTTPException(status_code=413, detail="File too large") + + if is_doc: + src_path = f"{BOT_TEMP}{job_id}{src_ext}" + await asyncio.to_thread(_write_bytes, src_path, content) + try: + await asyncio.to_thread(_convert_doc_to_pdf, src_path, file_path) + except Exception as exc: + _safe_remove(src_path) + raise HTTPException(status_code=500, detail=f"DOC/DOCX convert failed: {exc}") + finally: + _safe_remove(src_path) + pages = await asyncio.to_thread(_read_and_count_pdf, file_path) + else: + pages = await asyncio.to_thread(_count_pdf_pages, content) + await asyncio.to_thread(_write_bytes, file_path, content) + + result = _finalize_submit( + job_id, file_path, original_name, stitching, title, + numeration, flip_side, color, pdfa, quality, size_mb, pages, + user_id=WEB_USER_ID, trusted=True, ocr=ocr, + web_owner_id=key_row.get("owner_id") or 0, + ) + return {"job_id": result["job_id"], "pages": pages, + "status_url": f"/api/v1/scan/{result['job_id']}", + "download_url": f"/api/v1/scan/{result['job_id']}/download"} + + +@app.get("/api/v1/scan/{job_id}") +async def api_scan_status(request: Request, job_id: str): + """Статус задания по API-ключу: queued / processing / done / error.""" + _require_api_key(request) + url_res_f = _db_get_url_res_f(job_id) + if url_res_f is None: + raise HTTPException(status_code=404, detail="Job not found") + if url_res_f in ("-", "", None): + pos = _db_queue_position(job_id) + if pos <= 1: + return {"status": "processing", "queue_pos": 0} + return {"status": "queued", "queue_pos": pos} + if url_res_f in ("processing", "uploading"): + return {"status": "processing", "queue_pos": 0} + if url_res_f in ("ошибка", "error"): + return {"status": "error", "error": "processing failed"} + if url_res_f == "cancelled": + return {"status": "error", "error": "cancelled"} + if url_res_f == "expired": + return {"status": "error", "error": "expired"} + return {"status": "done", "download_url": f"/api/v1/scan/{job_id}/download"} + + +@app.get("/api/v1/scan/{job_id}/download") +async def api_scan_download(request: Request, job_id: str): + """Скачивание результата по API-ключу.""" + _require_api_key(request) + return _make_download_response(job_id) + + +# ── Личный кабинет: история «Мои документы» ─────────────────────────────────── +def _db_history(owner_id: int, limit: int = 40): + """Последние веб-задания владельца (для личного кабинета).""" + rows = [] + try: + with sqlite3.connect(DB_PATH, timeout=10) as con: + cur = con.cursor() + cur.execute("PRAGMA table_info(FILES)") + cols = {r[1] for r in cur.fetchall()} + if "web_owner_id" not in cols: + return rows + oname = "original_name" if "original_name" in cols else "name_or_f" + cur.execute( + f"SELECT name_or_f, {oname}, date, time, url_res_f, stitching " + "FROM FILES WHERE web_owner_id = ? ORDER BY rowid DESC LIMIT ?", + (int(owner_id), limit), + ) + for r in cur.fetchall(): + job_id, name, date, tm, url_res_f, stitching = r + if url_res_f == "web_local": + result_path = f"{BOT_TEMP}{job_id} скан {stitching}.pdf" + st = "available" if os.path.exists(result_path) else "expired" + elif url_res_f in ("-", "", None): + st = "processing" + elif url_res_f in ("ошибка", "error"): + st = "error" + else: + st = "expired" + rows.append({ + "job_id": job_id, "name": name or "document.pdf", + "date": date, "time": tm, "status": st, + }) + except Exception as exc: + print(f"history error: {exc}") + return rows + + +@app.get("/api/history/{user_id}") +async def history(user_id: int): + """История документов пользователя. Только для подтверждённых (вошедших) юзеров.""" + if not user_id or not _db_is_verified(user_id): + raise HTTPException(status_code=403, detail="Требуется вход через Telegram.") + return {"items": _db_history(user_id)} + + +# ── Загрузка ZIP: распаковка и постановка каждого PDF/DOCX в очередь ─────────── +def _unpack_zip_and_enqueue(part_path: str, settings: dict, user_id: int) -> list[dict]: + """Распаковывает ZIP, ставит в очередь каждый вложенный PDF/DOC(X). Возвращает [{job_id,name}].""" + import zipfile + created = [] + verified = bool(user_id) and (_db_is_verified(user_id) or ( + _verified_users.get(user_id, 0) and time.time() - _verified_users[user_id] <= 86400)) + try: + with zipfile.ZipFile(part_path) as zf: + names = [n for n in zf.namelist() + if not n.endswith("/") and os.path.splitext(n)[1].lower() in (".pdf", ".doc", ".docx")] + names = names[:20] # не больше 20 файлов за раз + for inner in names: + ext = os.path.splitext(inner)[1].lower() + base = os.path.basename(inner) or f"file{ext}" + _stem = os.path.splitext(base)[0].replace("'", "").replace(":", "") + job_id = f"{datetime.datetime.now().strftime('%Y.%m.%d_%H-%M-%S')}_{_stem}_{secrets.token_hex(2)}" + file_path = f"{BOT_TEMP}{job_id}.pdf" + data = zf.read(inner) + size_mb = len(data) / (1024 * 1024) + try: + if ext in (".doc", ".docx"): + src_path = f"{BOT_TEMP}{job_id}{ext}" + _write_bytes(src_path, data) + try: + _convert_doc_to_pdf(src_path, file_path) + finally: + _safe_remove(src_path) + pages = _read_and_count_pdf(file_path) + else: + _write_bytes(file_path, data) + pages = _count_pdf_pages(data) + # авторизация по порогу — как в обычном submit (кроме verified) + if not verified and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES): + _safe_remove(file_path) + continue + _finalize_submit( + job_id, file_path, base, settings.get("stitching", "без сшивки"), + settings.get("title", 0), settings.get("numeration", 0), + settings.get("flip_side", 0), settings.get("color", "color"), + settings.get("pdfa", 0), settings.get("quality", "high"), + size_mb, pages, user_id, trusted=True, ocr=settings.get("ocr", 0), + web_owner_id=user_id, + ) + created.append({"job_id": job_id, "name": base}) + except Exception as exc: + print(f"zip entry '{inner}' failed: {exc}") + _safe_remove(file_path) + finally: + _safe_remove(part_path) + return created + + +@app.post("/api/submit-zip-chunked") +async def submit_zip_chunked( + upload_id: str = Form(...), + total_size: int = Form(0), + stitching: str = Form("без сшивки"), + title: int = Form(0), + numeration: int = Form(0), + flip_side: int = Form(0), + color: str = Form("color"), + pdfa: int = Form(0), + quality: str = Form("high"), + ocr: int = Form(0), + user_id: int = Form(0), +): + """Финализация загруженного ZIP: распаковать и поставить вложенные файлы в очередь.""" + part_path = _chunk_part_path(upload_id) + if not part_path or not os.path.exists(part_path): + raise HTTPException(status_code=404, detail="Загрузка не найдена") + actual = os.path.getsize(part_path) + if total_size and actual != total_size: + _safe_remove(part_path) + raise HTTPException(status_code=400, detail=f"Размер не совпал ({actual} != {total_size})") + settings = { + "stitching": stitching, "title": title, "numeration": numeration, + "flip_side": flip_side, "color": color, "pdfa": pdfa, "quality": quality, "ocr": ocr, + } + created = await asyncio.to_thread(_unpack_zip_and_enqueue, part_path, settings, user_id) + if not created: + raise HTTPException(status_code=400, detail="В архиве нет подходящих файлов (PDF/DOC/DOCX) или превышен лимит/порог авторизации.") + return {"jobs": created} + + +_API_DOCS_HTML = """ + + + + +pdf2scan API — scan effect PDF API for business + + + + + +
+ +

pdf2scan API

+
REST · JSON · for business
+

Программно превращайте PDF и Word в «скан»: эффект сканирования, сшивка, нумерация, титульный лист, PDF/A. Идеально для юрфирм, бухгалтерий, документооборота и тендерных отделов.

+

Programmatically turn PDF/Word into scanned documents — scan effect, binding, numbering, PDF/A. Built for integrations.

+ +
+ Базовый URL / Base URL: https://pdf2scan.online/api/v1
+ Аутентификация: заголовок Authorization: Bearer <api_key> или X-API-Key: <api_key>
+ Лимит: 60 запросов/мин на ключ (пробный тариф). +
+ +

1. Получить ключ / Get an API key

+
+

Ключи привязаны к Telegram-аккаунту. Пока сервис бесплатный (тарифы и оплату добавим позже).

+ +
+ +

Либо напишите @berkio — выдадим ключ вручную.

+
+ +

2. Отправить файл / Submit a file

+
+

POST/api/v1/scan  — multipart/form-data

+ + + + + + + + + + +
ПолеТипПо умолчаниюОписание
filefilePDF / DOC / DOCX
stitchingstringбез сшивкинитка · ленточка · уголок · пружинка · дырокол · без сшивки
colorstringcolorcolor или bw
qualitystringhighhigh (300 DPI) · medium (250) · low (200)
numeration0/10нумерация страниц
title0/10титульный лист
flip_side0/10лист «прошито-пронумеровано»
pdfa0/10формат PDF/A
+

Пример / Example

+
curl -X POST https://pdf2scan.online/api/v1/scan \\
+  -H "Authorization: Bearer sk_your_key_here" \\
+  -F "file=@document.pdf" \\
+  -F "stitching=нитка" \\
+  -F "color=bw" \\
+  -F "numeration=1" \\
+  -F "pdfa=1"
+

Ответ / Response

+
{
+  "job_id": "2026.07.02_12-40-11_document",
+  "pages": 14,
+  "status_url": "/api/v1/scan/2026.07.02_12-40-11_document",
+  "download_url": "/api/v1/scan/2026.07.02_12-40-11_document/download"
+}
+
+ +

3. Проверить статус / Check status

+
+

GET/api/v1/scan/{job_id}

+
curl https://pdf2scan.online/api/v1/scan/JOB_ID \\
+  -H "Authorization: Bearer sk_your_key_here"
+
+# { "status": "queued", "queue_pos": 3 }
+# { "status": "processing", "queue_pos": 0 }
+# { "status": "done", "download_url": "/api/v1/scan/JOB_ID/download" }
+
+ +

4. Скачать результат / Download result

+
+

GET/api/v1/scan/{job_id}/download  — возвращает PDF (готов ~6 часов).

+
curl -L https://pdf2scan.online/api/v1/scan/JOB_ID/download \\
+  -H "Authorization: Bearer sk_your_key_here" \\
+  -o scan.pdf
+
+ +

Коды ошибок / Errors

+
+ + + + + + +
КодЗначение
401Нет ключа / ключ неверный или отключён
409Файл ещё не готов (проверьте статус)
413Файл слишком большой
429Превышен лимит запросов
+
+ +
+ pdf2scan.online · Главная · Вопросы по API — @berkio +
+
+ + +""" + + +@app.get("/developers", response_class=HTMLResponse) +@app.get("/api-docs", response_class=HTMLResponse) +async def developers_docs(): + return _API_DOCS_HTML diff --git a/app/static/index.html b/app/static/index.html index 23bc6d8..29f9648 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1,2044 +1,2749 @@ - - - - - -pdf2scan — сделать скан из PDF онлайн бесплатно - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
- - - - - - - -
-
- -
- pdf2scan logo -
-
-
pdf2scan
-
Создание растровых pdf с эффектом сканирования из электронных pdf документов
-
- -
- - -
-
- -
- - -

- 🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан. -

- - -
-
Файл
-
-
- -
- - - -
-
Выберите PDF или Word
-
или перетащите файл сюда
-
-
-
-
- - -
-
Тип сшивки
-
- - - - - - - - - - - - - - - - - - - -
-
- - -
-
Дополнительно
- -
- Режим цвета -
- - - - -
-
- -
- Качество -
- - - - - - -
-
- -
- Титульный лист - -
- -
- Нумерация страниц - -
- -
- Лист «прошито-пронумеровано» - -
- -
- Формат PDF/A - -
-
- - -
-

🔐 Требуется авторизация

-

Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.

- -
- - -
- - -
- - - - - -
-
- Обработка... - -
-
-
-
-
-
- - -
-
-
- - - -
-
Готово!
-
Ваш PDF обработан и готов к скачиванию.
-
- ⏱ Ссылка активна 30 минут — скачайте файл сейчас -
- ⬇ Скачать результат - -
-
- - - - -
- -
-

Как работает pdf2scan

-
-
-
1
-
Загрузите файл
-
PDF или Word — перетащите или выберите с устройства
-
-
-
2
-
Настройте параметры
-
Тип сшивки, цвет, качество, нумерация и другие опции
-
-
-
3
-
Скачайте результат
-
Готовый PDF со сканом доступен для скачивания 30 минут
-
-
-

pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.

-
- -
- pdf2scan — превращаем PDF в скан  |  Telegram-бот -
- - - - + + + + + + +pdf2scan — сделать скан из PDF онлайн бесплатно + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + + + + + + +
+
+ +
+ pdf2scan logo +
+
+
pdf2scan
+
Создание растровых pdf с эффектом сканирования из электронных pdf документов
+
+ +
+ + +
+
+ +
+ + +

+ 🧷 Выберите тип сшивки и при необходимости дополнительные настройки, затем загрузите файл — получите готовый скан. +

+ + +
+
Файл
+
+
+ + +
+ + + +
+
Выберите PDF или Word
+
или перетащите файлы сюда (до 20)
+
+
+
+ +
+
+
+ + + + + +
+
Тип сшивки
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + +
+
Дополнительно
+ +
+ Режим цвета +
+ + + + +
+
+ +
+ Качество +
+ + + + + + +
+
+ +
+ Титульный лист + +
+ +
+ Нумерация страниц + +
+ +
+ Лист «прошито-пронумеровано» + +
+ +
+ Формат PDF/A + +
+ +
+ + Распознать текст (OCR) + Скан станет искомым: текст можно копировать и находить поиском + + +
+
+ + +
+

🔐 Требуется авторизация

+

Для файлов больше 100 страниц или 50 МБ необходим вход через Telegram.

+ +
+ + +
+ + +
+ + + + + +
+
+ Обработка... + +
+
+
+
+
+
+ + +
+
+
+ + + +
+
Готово!
+
Ваш PDF обработан и готов к скачиванию.
+
+ ⏱ Ссылка активна 30 минут — скачайте файл сейчас +
+ ⬇ Скачать результат + +
+
+ + + + + + + +
+ +
+

Как работает pdf2scan

+
+
+
1
+
Загрузите файл
+
PDF или Word — перетащите или выберите с устройства
+
+
+
2
+
Настройте параметры
+
Тип сшивки, цвет, качество, нумерация и другие опции
+
+
+
3
+
Скачайте результат
+
Готовый PDF со сканом доступен для скачивания 30 минут
+
+
+

pdf2scan — бесплатный онлайн-инструмент для создания растрового PDF с эффектом сканирования. Поддерживает сшивку нитками, ленточкой, уголком, пружинкой и дыроколом. Идеально для подготовки документов к сдаче в архив, нотариусу или государственные органы. Без регистрации, без установки программ.

+
+ + + + + + diff --git a/app/static/og.png b/app/static/og.png new file mode 100644 index 0000000000000000000000000000000000000000..cd461ed9e1a80d3623759765c00b32aa6d768354 GIT binary patch literal 37880 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