Skip to content

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

146 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AURRUM — инструмент менеджера

Приложение закрывает путь от ссылки на товар до готового предложения. Четыре рабочие страницы, одно меню, вход по общему паролю.

Страница Что делает
Проект /project собирает позиции, считает цену, выгружает книгу Excel с фотографиями
Позиция по ссылке /lookup ссылка на товар у бренда → карточка с габаритами, отделками, фото и техлистом
Константы /settings восемь величин расчёта — те же, что в скрытых колонках рабочей формы
Каталог /library собранные карточки: сетка с фото, отбор по бренду и типу, поиск, страница товара
Спецификация из Excel / готовая книга → страница на печать в фирменном стиле

Печать — средствами браузера (Cmd/Ctrl + P → «Сохранить как PDF»).

Что попадает в документ

Шапка (номер спецификации, договор, дата, покупатель), таблица позиций с описанием и фото, блок итогов, условия и подписи сторон.

Внутренние данные не выгружаются. В рабочей книге есть служебные колонки (закупка, пошлина, логистика, лестница наценок) и скрытые строки (рабочая скидка, комиссии, оплата напрямую на фабрику). Ориентир — флаги hidden самой книги: что скрыто в Excel, то не попадёт и в документ.

Запуск

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env      # и вписать ключи
python app.py

Приложение поднимется на http://127.0.0.1:5001

Ключи и пароли читаются из .env (в git не попадает, см. .gitignore). Образец с описанием каждой переменной — в .env.example.

Ключ Без него
FIRECRAWL_API_KEY необязателен: страницы берутся обычным запросом. Нужен только сайтам, которые на такой запрос отвечают отказом (VENICEM)
GOOGLE_API_KEY карточка собирается запасным извлекателем: размеры с чертежей теряются, и карточка об этом предупреждает
LLAMA_CLOUD_API_KEY нет запасного пути и кнопки «Распарсить» у техлиста
BLOB_READ_WRITE_TOKEN не работает библиотека товаров (создаётся командой vercel blob create-store)

Конвертер Excel, проект и расчёт работают без всех трёх.

Доступ

Вход по одному общему паролю — без пользователей и регистрации.

Вход закрыт, пока не заданы две переменные. Дефолтов в коде нет намеренно: пока пароль по умолчанию существовал, приложение работало без настройки — и на проде стоял пароль, известный всем, кто видел репозиторий. Отсутствие переменной видно сразу, тихо открытая дверь — нет.

Переменная Назначение
AURRUM_PASSWORD пароль на вход; обязателен
AURRUM_SECRET_KEY ключ подписи сессий; обязателен — с известным ключом куку можно подделать, и пароль уже не спасёт
AURRUM_HTTPS выставьте в 1, если приложение за HTTPS — тогда кука ставится с флагом Secure

Закрыто всё, кроме страницы входа и статики: и загрузка, и готовый документ, и картинки из спецификаций. Сессия живёт 7 дней, есть кнопка «Выйти».

После 5 неверных попыток адрес блокируется на 5 минут. Счётчик хранится в памяти процесса, так что при нескольких воркерах защита ослабевает — для одного процесса этого достаточно.

Пароль сравнивается через hmac.compare_digest, редирект после входа разрешён только на внутренние адреса.

Как читается книга

Разбор привязан к строке заголовка таблицы — той, где в колонке A стоит №. Всё остальное отсчитывается от неё, поэтому сдвиг шапки книги разбор не ломает.

Данные Где ищем
№ позиции колонка A
Производитель колонка C
Описание колонка E (первая строка — модель, остальное — тело)
Количество колонка I
Цена / Сумма колонки J / M
Фото привязка картинки к строке (xl/drawings)
Итоги подпись в J, сумма в M, ниже позиций
Условия колонка A ниже позиций; строка с * — сноска

Строки, скрытые в книге, пропускаются везде.

Условия в книге набраны капсом — приложение приводит их к обычному регистру и чинит типографику (кавычки-ёлочки, тире, даты, «ё»). Точечные замены собраны в _FIXUPS в spec_parser.py — список короткий и правится руками. Если условий в книге нет, подставляются стандартные из DEFAULT_TERMS.

Позиция по ссылке

Страница /lookup (ссылка «Позиция по ссылке» на главной) закрывает самую долгую часть работы — сбор данных о товаре до Excel. Менеджер вставляет ссылку на страницу товара на сайте бренда и получает карточку: производитель, модель, тип, габариты, объём, отделки, техданные, фото и техлист.

Как устроено:

  1. Firecrawl доставляет — отрисовывает скрипты и проходит антибот. Только доставка: разбором он не занимается.
  2. Gemini извлекает — и текст страницы, и техлист страницей вместе с чертежами. Это решающее свойство: у VENICEM размеры нарисованы на схеме кривыми, текстового слоя под ними нет вовсе. Если Gemini не ответил, работает LlamaExtract по текстовому слою — и карточка говорит об этом предупреждением, потому что размеры со схем запасной путь не читает.
  3. Python владеет всем перечислимым и проверяемым — оси, объём, списки типов и ролей, адреса фотографий, сверка чисел с источником. Детерминированное модели не отдаётся.

Фотографии отбираются тремя ступенями, сверху вниз: штатная карточка магазина (Shopify отдаёт список сам), правило галереи из config/photo_selectors.json, и только для незнакомого бренда — имя файла. Сегодня все двенадцать рабочих брендов закрыты первыми двумя. Подробности и список брендов — в docs/brands.md.

Тип предмета и роль отделки модель выбирает из фиксированных списков (TYPES_RU, ROLES_RU в product_lookup.py), а не переводит свободно. Названия материалов остаются на языке оригинала — как в рабочих книгах.

Две защиты от выдумок

Извлечение ошибается, и это проверено на живых страницах — поэтому в модуле есть две детерминированные проверки:

  • Оси габаритов считает Python. Модель раскладывает Д/Г/В нестабильно: на одной и той же странице высота уезжала то в ширину, то в глубину. Набор чисел при этом всегда верный, поэтому parse_dims() разбирает строку размеров по нотации источника. Разбор сверен с шестью форматами из рабочих книг (130x47x45Н, D25/31x125Н и т.д.) — все совпали.
  • Сверка с первоисточником. Извлечённые названия материалов проверяются на наличие в тексте страницы; чего там нет — убирается из карточки с предупреждением. Проверка сразу поймала выдуманные отделки.

Объём берётся из техлиста, если производитель его публикует; иначе считается формулой рабочей книги ROUNDUP(Д×Г×В×1,5/1e6; 1). В карточке видно, какой источник сработал.

Карточка не применяется молча: рядом ссылка на источник, все поля редактируемые, спорные места помечены предупреждениями.

Разбор техлиста

У каждого PDF в блоке «Документы» есть кнопка «Распарсить» — она отдаёт документ в LlamaExtract (extract_agent.py). Нужна там, где Firecrawl на таблицах теряет структуру: подписи и значения разъезжаются, и «длина провода» приезжает в габариты. На кровати TRUSSARDI VIBES Firecrawl не вернул ничего.

Приезжает список исполнений, а не одно значение: у изделия бывает несколько версий, и выбирает менеджер. У каждой — свой артикул и признак, по которому выбор и делается:

202x241x92H.  VBE (LE1)   165x200   6,8 м³
222x241x92H.  VBE (LE2)   185x200   7,4 м³
189x246x92H.  VBE (QUSA)  152x205   6,5 м³

Первая строка совпадает с позицией R22 рабочей книги. «Подставить» заполняет Д/Г/В, объём и строку размеров, а производителя, модель и тип — только если эти поля пусты: данные могли прийти со страницы бренда, и затирать их молча нельзя.

Схема и инструкция лежат в config/ и уходят в запросе — сохранённый агент в LlamaCloud не нужен. Подробности и приёмка — в docs/llamacloud-agent.md.

Списки значений проверяет Python. LlamaExtract не соблюдает enum в схеме: он молча возвращает последнее значение списка, из-за чего кровать становилась «Ковёр», а ткань — «Камень». Поэтому type_ru и role_ru приходят свободными строками и сверяются с TYPES_RU / ROLES_RU; чего нет в списке — уходит в предупреждение, а не в карточку.

Если извлечение не сработало, роут откатывается на разбор markdown от LlamaParse регуляркой (doc_parser.py): она находит только числа, без артикулов, но это лучше пустой карточки.

Вызывается только по кнопке, не на каждом поиске.

Строка для книги

Кнопка «Скопировать строку» собирает позицию в строку рабочей книги и кладёт её в буфер — вставляется одним движением. Это то место, где до сих пор всё перепечатывалось руками.

Формулы привязаны к номеру строки, поэтому его указывают заранее и вставляют строго туда: вставка в другую строку уведёт ссылки на соседние позиции. Многострочное описание берётся в кавычки, иначе перевод строки разорвал бы вставку.

По умолчанию собирается видимая часть A…S. Скрытый расчёт T…AI включается галочкой и нужен для пустой строки: в готовой книге эти формулы уже стоят, а коэффициент сборки в AE у каждой позиции свой — встречались /1, /0.95 и /0.9.

Разметка снята с рабочего файла 0000-Offer-…-AUR-FORM.xlsx, и check_lookup.py сверяет все 16 формул с ним посимвольно. Если книга поедет, это увидит проверка, а не менеджер в готовом компреде.

Отбор фото

У каждого фото — галочка; отмеченные идут дальше, снятые приглушаются, в заголовке видно «сколько из скольких». Кнопка «Скачать отмеченные» сохраняет их через свой роут /photo: браузер игнорирует атрибут download на чужом домене, и без прокси фото пришлось бы сохранять по одному руками.

Снимки из салона добавляются кнопкой «Добавить с диска» и встают в ту же сетку с меткой «салон». На сервер они не уходят и живут только во вкладке — как и вся карточка, которая перезагрузку не переживает.

Проект и расчёт

Страница /project — рабочее место менеджера. Найденные карточки ложатся в список позиций, каждая считается по цепочке из рабочей формы:

цена прайса − скидка фабрики        -> цена со скидкой
+ наценка дилера                    -> ЗАКУП
+ РЕНТАБ + ТРАНШ + SWIFT + ТРАНСПОРТ -> СУММА
÷ коэффициент сборки                 -> цена клиенту
далее ДИЗАЙНЕР, УСНО, НДС, FINSERV — уровни цены под условия оплаты

Цепочка восстановлена из рабочего файла и сверена с ним по числам: на всех пяти позициях расчёт сходится с колонкой AE. Модели здесь нет и не может быть — это место, где ошибка стоит прямых денег.

Руками остаётся цена прайса — её со страницы бренда не узнать. Скидка фабрики, наценка дилера и коэффициент сборки у каждой позиции свои, но приходят заполненными числами рабочей формы (0,5 / 0 / 1) — менеджер правит отклонения, а не набирает одно и то же. Начальные числа меняются на странице «Константы». Очищенное поле — не «не задано», а осознанный ноль: пустая скидка означает работу без скидки.

Цена клиенту — предложение: в книге встречаются и округление вниз (5752,2 → 5750), и вверх (2194,7 → 2200), правила нет, решает менеджер.

Константы (/settings) — восемь ставок, одни на все проекты, и отдельно три начальных числа позиции. В выгружаемый файл они попадают первой строкой, поэтому проект, открытый через полгода, покажет те числа, в которых уходил клиенту, а не пересчитается по новым. Исключение — SWIFT: в форме он стоит числом в каждой строке.

Комнаты. Позиции группируются, как в рабочей форме: строка-заголовок в колонке «Описание», нумерация начинается заново в каждой комнате, итоговая сумма охватывает весь блок вместе с заголовками (они пустые и дают ноль). Комнату несёт сама позиция, а порядок — список в проекте: он нужен за пустой комнатой («Этаж 1» без позиций под ним встречается в форме) и за тем, чтобы менеджер расставлял их сам.

Позиции без комнаты идут первыми, до заголовков. Не из вкуса: метки «комната кончилась» в книге нет, и хвост при обратном разборе прилипал бы к последней комнате. Заодно новая позиция видна сразу сверху.

Обратный разбор ищет комнаты только между позициями. Ниже последней из них идут итоги, и подписи у них в той же колонке — иначе девять подписей собственной выгрузки стали бы девятью комнатами.

Выгрузка отдаёт .xlsx с формулами, фотографиями и ставками — файл открывается в Excel и продолжает считаться там.

Проект живёт на сервере — в том же Vercel Blob, что и каталог: projects/<id>.json — истина, projects/index.json — список. В браузере остаётся черновик: он пишется на каждую правку и держит работу между нажатиями «Сохранить», а на сервер проект уходит явным действием.

Автосохранения нет намеренно. Каждая запись переписывает общий список целиком, а он доходит с задержкой; десятки записей в минуту от двух менеджеров съедали бы чужие строки — проект остался бы в файле, но пропал из перечня. Для этого случая есть «Пересобрать список»: он читает сами файлы и возвращает строку.

Затирание чужой работы закрывает счётчик правок. Клиент возвращает номер, с которым открывал; запись с устаревшим номером получает 409, и менеджер решает сам — открыть свежую версию или сохранить поверх. Ставки хранятся снимком внутри записи: в .xlsx они уходят первой строкой и по ним считаются формулы, поэтому файл обязан совпадать с экраном, с которого его собрали, а не с сегодняшними числами чужого браузера.

Константы (ставки и начальные числа позиции) остаются в localStorage: они одни на все проекты.

Каталог — исключение и единственное общее хранилище. Собранная карточка стоит запроса Firecrawl, разбора Gemini и ручной выверки отделок; второй раз платить за неё незачем, и у соседа она должна быть та же. Карточки лежат в Vercel Blob по одному JSON на товар (items/<бренд>-<модель>.json) плюс index.json — короткая выжимка всех карточек. Истина в файлах, индекс — их кэш: каталог на сотни товаров нельзя открывать запросом на карточку. Индекс пересобирается из файлов, если разошёлся.

Хранилище доходит с задержкой: чтение сразу после записи иногда возвращает предыдущее содержимое. Поэтому сохранённую карточку не перечитываем, а показываем ту, что сохранили.

Поиск идёт по всем текстовым полям сразу, плюс отбор по бренду и типу: менеджер помнит товар то по бренду, то по отделке, то по обрывку описания, и заставлять его выбирать поле значит заставлять угадывать. Карточки попадают в каталог по кнопке «В библиотеку» на разобранной позиции или «Всё в каталог» на странице проекта.

Структура

app.py             Flask: роуты, вход по паролю, фильтры шаблонов

  сбор карточки
product_lookup.py  страница товара -> карточка позиции; оси, объём, сверка
extract.py         извлечение: Gemini, при отказе LlamaExtract
llama_extract.py   запасной извлекатель по текстовому слою
extract_agent.py   исполнения из техлиста для кнопки «Распарсить»
doc_parser.py      LlamaParse + регулярка — последний запасной путь
gallery.py         фотографии по правилу галереи бренда
shopify.py         штатная карточка магазина: фото, производитель, название
safe_fetch.py      запросы наружу с защитой от внутренних адресов

  расчёт и выгрузка
library.py         каталог товаров: карточки в Vercel Blob, индекс, поиск
pricing.py         цепочка цены из рабочей формы; ставки по умолчанию
book_row.py        карточка -> строка книги с формулами
book_export.py     проект -> .xlsx с фотографиями и ставками в первой строке
spec_parser.py     готовая книга -> структура Spec для печати

config/            extraction_schema.json, extraction_prompt.txt,
                   photo_selectors.json — правила галерей по брендам
brands/            профиль на источник: где габариты, как отличить техлист
docs/              brands.md — состояние по брендам; llamacloud-agent.md
templates/         _nav.html (общее меню), project, lookup, settings,
                   index (загрузка), spec (документ на печать), login
static/            doc.css, логотип
check_lookup.py    приёмка: сверка с эталонами из рабочих книг

Приёмка:

./.venv/bin/python check_lookup.py            # всё, включая живые ссылки
./.venv/bin/python check_lookup.py --offline  # без сети
./.venv/bin/python check_lookup.py --galleries # галереи на живых страницах

Страницы прогоняются без браузера. Разметку разбирает bs4, скрипт исполняет node в песочнице (check_pages.mjs), новых зависимостей нет. Три слоя: контракт (имена, которыми страница и сервер обязаны совпадать), формулы (одна и та же живёт в четырёх копиях — расхождение тихо меняет цену клиенту) и поведение (отказ расчёта, восстановление единиц, отделки).

Правило для будущих правок: заглушка не выдумывает элементов. Список берётся из разметки; на неизвестный вызов DOM она бросает — это её работа, а не поломка, и означает «допишите пять строк в check_pages.mjs». Присвоенный innerHTML для неё непрозрачная строка.

Что осознанно вне зоны: узлы, рождённые innerHTML (карандаш и жёлтые поля позиции, поля «Констант», плитки фото, подсказки расхождений); focus/blur; запасное копирование через execCommand; скачивание пачкой; перетаскивание файла; скрипт меню. Это проверяется руками.

Две сессии в одном репозитории

Рабочая копия одна, и HEAD у неё тоже один: пока одна сессия создаёт ветку, вторая переключается на main — и коммиты первой ложатся в main. За один день это трижды дало перемешанные коммиты и дважды — нерабочий main (шаблон ссылался на файл, который остался незакоммиченным, и страница падала на TemplateNotFound).

Ветки от этого не спасают. Спасает отдельная рабочая копия — git worktree: репозиторий один, каталогов несколько, HEAD у каждого свой.

git worktree add -b <ветка> ../aurrum_offers-<имя> main
cd ../aurrum_offers-<имя>
ln -s /полный/путь/aurrum_offers/.env    .env
ln -s /полный/путь/aurrum_offers/.venv   .venv
ln -s /полный/путь/aurrum_offers/samples samples

Ключи, окружение и книги-образцы общие — они не в репозитории, и копировать их незачем. .gitignore покрывает и ссылки: правила стоят без слэша на конце, иначе git add -A кладёт в репозиторий ссылку на чужой абсолютный путь.

Обратно в main — из основного каталога, когда он чист:

git merge --ff-only <ветка>

Убрать копию, когда ветка влита: git worktree remove ../aurrum_offers-<имя>.

Перед отправкой полезно прогнать приёмку на чистой копии: она ловит ровно тот случай, когда в коммит попало обращение к файлу, который остался незакоммиченным.

Деплой на Vercel

В репозитории есть vercel.json и точка входа api/index.py — Vercel подхватывает приложение как есть, отдельная сборка не нужна.

Без переменных приложение не пустит внутрь — страница входа честно скажет, чего не хватает. Задайте в Project Settings → Environment Variables:

Переменная Значение
FIRECRAWL_API_KEY ключ Firecrawl — необязателен, запасной путь для сайтов, закрытых от обычного запроса
GOOGLE_API_KEY ключ Google AI — основной извлекатель; без него карточку соберёт запасной путь, теряя размеры с чертежей
LLAMA_CLOUD_API_KEY ключ LlamaParse — запасной извлекатель и «Распарсить» у техлиста
AURRUM_PASSWORD пароль на вход — обязателен
AURRUM_SECRET_KEY длинная случайная строка, например python3 -c "import secrets;print(secrets.token_hex(32))" — обязателен
AURRUM_HTTPS 1

Файл .env на Vercel не заливается — переменные задаются только через Project Settings.

maxDuration в vercel.json — 300 с, это потолок тарифа Pro. Столько нужно из-за худшего случая /lookup: две выемки Firecrawl по 120 с (страница и техлист) плюс накладные. Не теоретический предел — MISURAEMME отвечал 213 с. Разбор техлиста укладывается легче: около 40 с при бюджете опроса 120 с.

Ограничение, о котором стоит знать: блокировка после неудачных попыток входа хранится в памяти процесса, а инстансов может быть несколько — на serverless эта защита слабее, чем на обычном сервере. Пароль от этого не становится менее надёжным, но перебор тормозится хуже.

Данные и приватность

Приложение ничего не пишет на диск. Загруженная книга живёт только в памяти запроса, а фото уезжают прямо в HTML как data-URI — поэтому документ самодостаточен: сохраните страницу одним файлом, и картинки останутся на месте.

Фото ужимаются до 900 px по длинной стороне и пережимаются в JPEG: при печатной ширине колонки 40 мм этого с запасом хватает, а документ остаётся лёгким (в типовой спецификации — десятки килобайт).

Каталог samples/ в git не попадает (см. .gitignore): спецификации содержат данные клиентов и внутренние расчёты.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages