Приложение закрывает путь от ссылки на товар до готового предложения. Четыре рабочие страницы, одно меню, вход по общему паролю.
| Страница | Что делает |
|---|---|
Проект /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. Менеджер вставляет
ссылку на страницу товара на сайте бренда и получает карточку: производитель,
модель, тип, габариты, объём, отделки, техданные, фото и техлист.
Как устроено:
- Firecrawl доставляет — отрисовывает скрипты и проходит антибот. Только доставка: разбором он не занимается.
- Gemini извлекает — и текст страницы, и техлист страницей вместе с чертежами. Это решающее свойство: у VENICEM размеры нарисованы на схеме кривыми, текстового слоя под ними нет вовсе. Если Gemini не ответил, работает LlamaExtract по текстовому слою — и карточка говорит об этом предупреждением, потому что размеры со схем запасной путь не читает.
- 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.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): спецификации
содержат данные клиентов и внутренние расчёты.