Bug 1: «Завожу клиента и не вижу данных»
Причина: 3 эндпоинта проверяли user.get('role') == 'manager' напрямую,
вместо sheets.has_role(user, 'manager'). У админа теперь multi-role
(manager,measurer,assembler) → проверка падала с only_manager,
/api/measurements возвращало ошибку → таймлайн/файлы пустые.
Backend fixes:
- _handle_measurements_list: has_role + initDataUnsafe fallback + фильтр archived_at + возвращает client_name, client_phone, address, scheduled_at, client_no, contract_no, contract_date, assigned_to_tg_id
- _handle_measurement_request: has_role вместо ==
- _handle_measurement_detail: has_role + поддержка assigned_to_tg_id для мастера
- _handle_clients: возвращает address, contract_date, measurements_count, in_work
in_work=True если: есть лиды ∨ есть не-draft замер ∨ есть сборка
Bug 2: «Не могу удалить клиента»
Причина: была спрятана в expand «Опасная зона» внизу страницы.
Новая логика прав (по запросу):
- Клиент не в работе → ✏️ Редактировать + 🗑 Удалить
- Клиент в работе → только ✏️ Редактировать
- Бэкенд тоже enforce: client_delete отвечает {error: 'in_work'}
если есть лиды/сборки/не-draft замеры
Новые эндпоинты:
- POST /api/client_update — обновляет имя/телефон/адрес/договор
во всех Measurements этого клиента. Возвращает обновлённый client_key
если имя изменилось
Frontend:
- Секция «⚙️ Управление карточкой» вместо «Опасной зоны»
- Кнопка ✏️ Редактировать всегда видна, 🗑 Удалить только если !in_work
- renderEditClient — форма редактирования (имя, тел, адрес, договор № + дата)
- В шапке карточки теперь видны адрес и (если не в работе) бейдж «ещё не в работе»
- Draft-карточки скрыты из таймлайна (это техническая строка, не событие)
index.html: cache bump v=20260514d
Backend:
- sheets.is_master(user) — единая роль measurer ∨ assembler
- grant_role() автоматически выдаёт парную роль (measurer ↔ assembler)
- Новая таблица Assemblies со схемой: client, scope, scheduled_at,
status (created|scheduled|in_progress|completed|cancelled),
photos_before/in_progress/after, signature_file, gcal_event_id
- POST /api/assembly_create — менеджер заводит сборку,
при scheduled_at создаётся событие Google Calendar (4 часа)
- POST /api/assembly_list — фильтр по роли: менеджер видит свои,
мастер — назначенные + неназначенные (created/scheduled)
- POST /api/assembly_detail — карточка с правами доступа
- /api/photo: добавил MIME для pdf/dwg/dxf (для DWG-блока B+E)
Frontend (assembly.js — новый модуль):
- Форма /api/assembly_create с валидацией: имя, адрес, scope
- Pre-fill из карточки клиента (sessionStorage.prefillAssembly,
адрес + measurement_id из последнего замера)
- Список сборок + детальная карточка со статусом и составом работ
- Маршруты: #/assembly, #/assembly/new, #/assembly/<id>
Frontend (app.js + clients.js):
- Кнопка «🔨 Заказать сборку» в карточке клиента
- Quick-action «Сборки» на главной менеджера
- Блок «🔨 Сборки» в кабинете мастера (caps.measurer ∨ assembler)
CSS: .assembly-card / .assembly-card-* (золотой бордер)
index.html: cache bump v=20260514c
#7 — submit-flow:
- При успехе скрываем CTA с «Сохраняем...» (style.display=none)
- Обработчики «Ещё клиент» / «Открыть карточку» прикрепляются к
result-блоку (был form.querySelector — там их нет)
- Backend возвращает client_key = name.lower() — совместимо
с тем как ищет _handle_clients
- clientsCache = null после успеха
#3 — голос дублировался:
- Переписан алгоритм: финальные транскрипты пересчитываются с нуля
из ev.results[0..N] каждое событие (не аккумулируем дельтами).
- confirmedFinal фиксируется в baseText только на onend.
- Применено в measurements.js + clients.js
#2 — телефон:
- Frontend normalizePhone: убирает не-цифры, +7/8 → +7, добавляет +7
к 10-значным; auto-нормализация на blur
- Backend _normalize_phone(): тот же алгоритм
- Валидация: ровно 11 цифр начиная с 7
- Field-error «Введите корректный российский номер...»
#1 — адрес:
- Min 5 chars (улица + дом)
- Backend проверка длины
- Hint «Укажите город, улицу, дом, кв.»
#5 — номер клиента:
- Новая колонка client_no
- _next_client_no() — максимум для текущего менеджера + 1
- Шильд #N рядом с именем в карточке клиента
#6 — номер договора:
- Новые колонки contract_no, contract_date
- Поля в форме «Новый клиент» (опционально)
- Шильд «📋 договор N» в карточке клиента
#4 — удаление клиента:
- Soft-delete через колонку archived_at
- Endpoint /api/client_delete
- «⚠️ Опасная зона» в карточке клиента (collapsible)
- Confirm dialog через Telegram.WebApp.showConfirm
- Архивированные клиенты не показываются в /api/clients
#8 — правило самопроверки:
- docs/SELF_CHECK_RULE.md — 10 пунктов чек-листа перед «готово»
(end-to-end, ключи, UI-состояния, валидация, голос, deploy, логи)
Cache bust v=20260514a.
A — голосовой ввод заметок в мастере замера:
- Кнопка 🎤 Диктовать рядом с textarea «Заметки»
- Web Speech API ru-RU, interimResults показывает диктовку в реальном времени
- Текст накапливается + сохраняется в state
- Красная пульсация во время записи
B — Google Calendar:
- Новый модуль app/gcalendar.py — service account + Calendar API
- Создание/обновление события при /api/measurement_schedule
- 2 новые колонки в Measurements: gcal_event_id, gcal_event_url
- При ошибке (нет API/прав) — fail gracefully, лог warning
- Ссылка «📅 Открыть в Google Calendar» в карточке заявки
- В DM менеджеру при назначении — clickable ссылка на событие
- Требует env: GOOGLE_CALENDAR_ID + SA добавлен в редакторы календаря
ДОПОЛНИТЕЛЬНО — заведение клиента менеджером:
- Новый endpoint /api/client_create
- /api/clients теперь читает И Leads И Measurements (включая draft)
- UI: action card «Новый клиент» в quick-actions + кнопка
«+ Новый клиент» в шапке списка клиентов
- Форма (ФИО / Тел / Адрес / Примечание с 🎤 диктовкой)
- После сохранения — переход в карточку клиента
- has_role проверка вместо устаревшего user.role
Cache bust v=20260513zn.
По цепочке менеджер→замерщик→замер:
Менеджер «Заказать замер»:
- ФИО, телефон, адрес, кому назначить
- Одно поле «Примечание» (рекомендации по дате + особенности)
Убраны radio-buttons specific/this_week/next_week — слишком сложно.
Точную дату всё равно согласует замерщик с клиентом.
Замерщик в карточке заявки — 3 чёткие стадии:
1. ЕСЛИ статус requested (дата не назначена):
- Блок «📞 Согласовать дату с клиентом»
- Подсказка «Позвоните клиенту и зафиксируйте»
- datetime-local + кнопка «Назначить»
2. ЕСЛИ статус scheduled (дата уже есть):
- Блок «📅 Замер назначен» крупно (Newsreader 22pt italic)
- Кнопка «Изменить дату» — разворачивает скрытую форму
- ОСНОВНАЯ кнопка «📐 Начать замер» (большая, primary, 16pt)
- До «Начать замер» чек-листа не видно
Чек-лист (📋 в шапке) теперь живёт ТОЛЬКО в мастере замера
(когда нажали «Начать замер»). До этого момента не отвлекает.
Backend: DM при создании заявки шлёт только примечание
(без расшифровки preferred_type).
Cache bust v=20260513z.
Backend:
- Лист ClientNotes (auto-create через ensure_sheet) — колонки
manager_tg_id, client_key, note, updated_at.
- Ключ клиента: «p:7XXXXXXXXXX» если есть телефон ≥10 цифр,
иначе «n:<имя в lower>». Привязан к менеджеру.
- POST /api/client_note — без поля note читает текущую,
с note — upsert (хард-кап 4000 символов).
Frontend в карточке клиента (#/clients/client/<key>):
- Новый блок «📝 Примечание» сверху над списком подборов
- Textarea + дата обновления в meta
- Кнопка «🎤 Диктовать» — Web Speech API (ru-RU)
· interimResults показывает прямо во время речи
· final-результаты добавляются к baseText
· красная пульсация во время записи
· graceful degrade если SR недоступен (Telegram WebApp на iOS)
- Кнопка «Сохранить» → PUT в /api/client_note + статус «✓ сохранено»
CSS: .client-note-block, .btn-mic, .btn-mic.rec (pulse animation),
.note-status.ok / .err.
Cache bust v=20260513y.
backend/app/geocoder.py — Python-порт хибридного геокодера из
secretary/lib/geocoder.js: Yandex (если есть YANDEX_GEOCODER_API_KEY
в env) → fallback OSM Nominatim (бесплатный, rate-limit 1/sec).
normalize_address — та же логика, что и в JS-версии: расшифровка
сокращений улиц, срез номера квартиры/этажа/подъезда, корпус → к<N>.
POST /api/geocode — текст адреса → {lat, lng, formatted, source}.
Frontend в логистике замера:
- Новая кнопка «🔍 По адресу» — берёт текст адреса заявки и
вызывает геокодер. Заполняет GPS автоматически.
- В сводке ссылка на 📍 теперь ведёт на Я.Карты (а не Google) —
для России лучше: открывает приложение Я.Карты на телефоне.
Cache bust v=20260513w.
Замерщик/сборщик/менеджер при выезде на объект может дополнить
адрес деталями. Эти же данные будут видны и при сборке —
существенно облегчает планирование подъезда и парковки.
Поля:
- Подъезд + этаж
- GPS-координаты (с кнопкой «Сейчас» — забирает с устройства через
navigator.geolocation, ссылка на Google Maps в сводке)
- Парковка: бесплатная / платная / на улице / нет + текст-уточнение
- Заметки логистики: домофон, шлагбаум, размер лифта, узкий проезд
UX:
- В карточке заявки секция «📍 Логистика» свёрнута по умолчанию,
показывает сводку. Кнопка «Заполнить» / «Изменить» раскрывает форму.
- Точка-индикатор после заголовка если есть данные.
- Сводка собирается строкой: подъезд · этаж · GPS-ссылка · парковка · заметка.
Backend:
- 7 новых колонок в Measurements (entrance, floor, gps_lat, gps_lng,
parking_type, parking_note, delivery_notes).
- POST /api/measurement_logistics — точечный апдейт. Право:
назначенный замерщик / менеджер-владелец / любой сборщик.
Cache bust v=20260513v.
По процедуре пользователя: менеджер при создании заявки может не
знать точной даты. Указывает диапазон, замерщик потом созванивается
с клиентом и фиксирует точную дату.
Менеджер при «Заказать замер» — радио-выбор:
○ Конкретная дата (открывает date + утром/днём/вечером)
○ Эта неделя
○ Следующая неделя
● Согласовать с клиентом (default)
+ поле «Уточнение по времени» (свободный текст)
Замерщик в инбоксе:
- Если scheduled_at заполнено → 📅 точная дата
- Иначе → 🕐 приблизительная (эта неделя / след. неделя / 15.05 утром)
+ Note выводится после ·
- В DM-уведомлении строка «Когда: …» подсказывает что согласовать
Замерщик в карточке заявки:
- Если нет точной даты — отдельный блок «⏰ Когда удобно клиенту»
с подсказкой «позвоните клиенту и согласуйте точную дату»
- После назначения через datetime-input → блок исчезает
Backend: 4 новые колонки preferred_type / preferred_date /
preferred_time_of_day / preferred_note, добавлены в schema,
serialize/deserialize в request + detail + inbox.
Cache bust v=20260513u.
1. № замера подбирается автоматически:
- POST /api/measurement_next_no возвращает max(zamer_no) + 1
- Wizard при открытии вызывает endpoint и заполняет input
- Менеджер может переписать вручную (поле редактируемое)
- Подпись «Подобран автоматически — можно изменить»
2. Поле «Стяжка / нулевой пол» удалено из формы:
- По логике пользователя — стяжка пишется на самих фото с замером
- Backend колонка floor_base остаётся для backward compat (старые записи)
3. Чек-лист стал интерактивным:
- Каждый [ ] item теперь .cl-item с cursor:pointer
- Тап переключает галочку (☐ ↔ ☑) + страйкаут текста
- Состояние сохраняется в localStorage по measurement_id (или draft)
- Sticky прогресс-бар сверху: «N из M · X%» + градиентная полоса
- Кнопка ↺ в шапке — сбросить все галочки
- Hapt-фидбэк на каждый тап
Cache bust v=20260513m.
По чек-листу ЗАМЕРОВ (D:\!!! GOOGLE DISK\ЗАМЕРЫ\...\ЧЕКЛИСТ_ЗАМЕРА.md):
каждая стена снимается отдельно, имя файла отражает тип.
Wizard:
- Каждое фото получает dropdown «Что это»:
Стена 1, 2, 3, 4 · План комнаты · Общий вид · Деталь
- Авто-предложение типа: w1 → w2 → w3 → w4 → plan → general
- Добавлены поля общей инфы:
· № замера (опционально)
· Дата замера (auto-сегодня)
· Стяжка / нулевой пол (default «0,000 = +88 мм над плитой»)
- В шапке кнопка 📋 — открывает чек-лист отдельной страницей
- Inline-рендер markdown с поддержкой заголовков, списков, таблиц, code
Backend:
- _save_measurement_photo принимает kind+kind_seq → имена файлов
структурные: w1.jpg, w2.jpg, plan.jpg, general_2.jpg, detail_1.jpg.
Это упрощает дальнейшую обработку для генерации DWG.
- Расширена схема Measurements: zamer_no, zamer_date, floor_base, photos_meta.
- /api/measurement_detail отдаёт новые поля.
Cache bust v=20260513l.
В Telegram Desktop при открытии MiniApp в side-panel (boxed mode)
WebApp.initData приходит пустой. Backend не может проверить подпись.
Временный fallback: если initData пустой, доверяем initDataUnsafe.user
для определения роли. Action-endpoints (grant_role, measurement,
podbor) продолжают требовать подписанный initData.
Cache bust v=20260513i.
При входе менеджером (?role=manager) get_or_create_user обновлял CSV
в Sheets, но в памяти существующего dict обновлял только roles[],
а старое поле role оставалось со старым значением. _handle_me читал
role и не находил 'manager' → fallback на client cabinet.
Теперь после grant_role перечитываем строку из Sheets и обновляем
оба поля (role + roles). Плюс в _handle_me предпочитаем roles[]
если он уже распарсен.
End-to-end поток:
1. Менеджер на главной тапает «Заказать замер» → #/request → форма:
ФИО · телефон · адрес · dropdown «Кому назначить» · заметки.
Submit → POST /api/measurement_request → строка в Measurements
со status=requested + assigned_to_tg_id. Бот шлёт DM замерщику.
2. Замерщик открывает кабинет (?role=staff) → видит inbox с заявкой.
Тап → #/inbox/<id> → карточка с реквизитами + поле datetime-local.
Сохранить дату → POST /api/measurement_schedule → status=scheduled.
Бот уведомляет менеджера.
3. В нужный день замерщик тапает «📐 Сделать замер сейчас» →
wizard открывается в update-mode (#/measure?id=<id>), pre-fill
client_name/phone из заявки, пропускает шаг «Клиент». После submit
→ backend обновляет ту же строку (status=completed) + DM менеджеру.
Backend changes:
- Расширена схема Measurements: assigned_to_tg_id, requested_by_tg_id,
scheduled_at, address, client_name, client_phone (отдельные колонки).
ensure_measurements_sheet() автоматически дополняет колонки.
- _handle_measurement переписан под 2 режима (create/update).
- 3 новые ручки: /api/measurement_request, /api/measurement_inbox,
/api/measurement_schedule. Все с правильной проверкой ролей.
- Telegram-уведомления на каждом переходе статуса.
MiniApp:
- Новый модуль request.js — wizard заявки с dropdown замерщиков
(грузится из /api/staff_list?role=measurer).
- renderStaff теперь грузит реальный инбокс из /api/measurement_inbox.
- renderInboxDetail — карточка заявки с datetime-picker.
- В quick-actions менеджера: «Заказать замер» (primary) +
«Замер сейчас» (legacy direct fill).
- measurements.js поддерживает update-mode через ?id=.
Cache bust v=20260513g.
Users.role теперь хранит CSV-список ролей: 'manager,measurer'.
Парсим, добавляем, отзываем — все через sheets.parse_roles / grant_role /
revoke_role / list_users_with_role. Старые однострочные значения работают
как раньше (legacy compat).
Backend:
- /api/me возвращает roles[] (массив), role (главная для legacy-UI),
plus capabilities {measurer, assembler} для staff
- /api/grant_role (admin-only) — добавить/отозвать роль
- /api/staff_list (manager-only) — список сотрудников по роли
(будет использоваться в dropdown «выбрать замерщика»)
- При role=staff отдаём отдельный кабинет; если у юзера нет measurer/
assembler — возвращаем error=no_staff_role
Bot:
- /start — 3-я reply-кнопка [🔧 Я сотрудник]. При тапе MiniApp получает
?role=staff и решает кабинет по capabilities.
- /whoami — сотрудник присылает свой Telegram ID, пересылает куратору
чтобы тот выдал роль через /api/grant_role.
MiniApp:
- renderStaff() — заглушка кабинета сотрудника с шапкой (имя, аватар,
список ролей) и пустым inbox («Пока пусто»). Если есть measurer —
быстрая кнопка «Сделать новый замер».
- При error=no_staff_role — экран с инструкцией как получить роль.
- CSS .staff-head / .staff-no-role.
Cache bust v=20260513f.
Wizard: new 'photos' step (6 total) — camera/gallery input, client-side
canvas compression to 1600px @ ~78% JPEG, max 12 photos. Thumbnails
with delete in step; preview in summary.
Backend: POST /api/measurement now decodes data-URL photos and saves
to /app/photos/<id>/N.jpg (volume-mounted). New GET /api/photo/{id}/{n}
serves files with path-traversal protection. New POST /api/measurement_detail
returns full measurement record (walls/openings/photos/notes/...).
Clients page: measurement rows now clickable → renderMeasurement detail
view with key-value grid + photo gallery + 'Скачать PDF / Печать'.
Print stylesheet (@media print) hides navigation/buttons/uploaders and
prints clean A4-friendly layout.
Podbor report: existing 'Печать → PDF' now falls back to inline
window.print() inside Telegram WebApp (popups are blocked there).
Cache bust v=20260513a.
5 LAYOUT PICTOGRAMS (podbor.picts.js):
- linear: одна стена с гарнитуром
- l_shape: Г-образная, две стены с подсвеченным углом
- u_shape: П-образная, три стены
- island: линейный гарнитур + отдельный остров посередине
- peninsula: Г-образная + барная стойка-полуостров
Все в стиле D · top-down view, walnut stroke, теплые градиенты
MEASUREMENTS.JS WIZARD (5 шагов):
1. client_info — имя + телефон (валидация)
2. layout — pict-карточки 5 типов
3. size — длины стен (1-3 по layout), площадь, потолок (мм)
4. openings — окно / дверь / коммуникации / заметки
5. summary — обзор + Сохранить → POST /api/measurement
BACKEND (main.py):
- New /api/measurements (POST) для списка замеров менеджера
с опц. фильтрами по client_tg_id
- _handle_measurement теперь дописывает имя+телефон клиента в notes
(если client_tg_id не зарегистрирован — это новый клиент без аккаунта)
- handlers dispatcher: 'measurements' route added
ROUTING (app.js):
- Quick-action 'Новый замер' wired to '#/measure'
- routeByHash: Measurements.mount on #/measure
CLIENT PROFILE (clients.js):
- New section 'Замеры · N' on client history page
- fetchMeasurements() filters by client_tg_id or name match in notes
- layoutLabel() shows Russian label (Прямая / Угловая Г / etc.)
- Cache bump v=20260512c
USER REPORT: 'после прогона всей техники, перед запросом опять вопрос про фильтр для вытяжки в конце'
ROOT CAUSE: Infra-шаг спрашивает 'Вытяжка → вентшахта?', хотя у hood-категории уже есть шаг 'Подключение' с вариантами Отвод/Рециркуляция/Универсальная. Это дубликат.
FIX:
- renderInfra: убран блок vent. Шаг показывается только если выбрана варочная.
- Auto-skip infra если нет hob (раньше требовался hob ИЛИ hood, теперь только hob)
- renderSummary: убрана строка 'Вентиляция'
- summaryBack: 'infra' только если cats.includes('hob')
AI PROMPT:
- Новый блок: режим вытяжки читать из per_cat.hood.answers.mode
- exhaust → обычная установка
- recirc → ОБЯЗАТЕЛЬНО упомянуть 'Угольный фильтр в комплекте/докупаем' в pros
+ в первой строке pros указать 'для квартир без вентшахты'
- combi → упомянуть универсальность
- 'Если recirc и фильтр не предложен — это ОШИБКА'
Cache: v=20260512b
NEW HANDLERS:
- _handle_clients: groups manager's Leads by client (name or tg_id), returns
list with leads_count, last_lead_at, client_phone (extracted from checklist),
and full leads array per client. Sorted by recency desc.
- _handle_lead: fetches single lead with parsed checklist + ai_response JSON.
Validates ownership (manager_tg_id matches caller).
NEW ENDPOINTS:
- POST /api/clients — list of manager's clients with summary
- POST /api/lead — single lead detail with ai_response for re-render
Both accept initData auth, only manager role can call.
Apps Script compat: ?path=clients and ?path=lead also work.
NEW MODULE app/catalog.py:
- refresh_catalog(cats, sources, per_brand, delay) — runs parsers for seed brand+category pairs
- list_catalog(cat, tier, brand) — reads from Sheets
- list_for_ai(cats, tiers) — compact text for AI prompt context
- SEED_BRANDS_BY_TIER + CATEGORY_QUERIES — 22 brands × 8 cats = 176 combos
- Saves top-2 relevant results per (brand × cat), filters by brand presence in title
- Dedup by title hash within (cat, brand) bucket
SHEETS:
- ensure_sheet(name, headers) — auto-creates Catalog tab on first refresh
- Schema: id, category, brand, tier, model_name, search_query, price_min/max, image_url, source, url, last_seen_at
ENDPOINTS:
- POST /api/catalog/refresh?cat=X&per_brand=N — manual refresh (1 cat ~2-5 min)
- GET /api/catalog/list?cat=&tier=&brand= — read with filters
- GET /api/catalog/preview_ai?cats=fridge — debug what AI receives
AI PROMPT:
- Rule #0: if catalog passed in user prompt — MUST select only from there
- _build_catalog_context: filters by checklist.budget_preset → tier subset
(luxe→premium, premium→premium+middle, middle→middle, budget→middle+budget)
_handle_podbor:
- Loads catalog subset, appends to user_prompt as 'ДОСТУПНЫЙ КАТАЛОГ МОДЕЛЕЙ'
- AI 'выбирай ТОЛЬКО из этого списка' rule reinforced
NEXT: trigger refresh manually for 1 category (~3 min), then real podbor test
to verify AI uses catalog models instead of hallucinating SKUs
USER FEEDBACK:
'Особенности везде убрать, их можно в SWOT анализе приводить в качестве примечания
не акцентируя на них особого внимания. Современные фичи на 95% одинаковые.'
REMOVED features step from:
- fridge (NoFrost, Inverter, Wi-Fi, etc.)
- hob (Booster, FlexZone, FFD, Hob2Hood, etc.)
- oven (Wi-Fi, autoprogram, probe, softclose, etc.)
- dw (Wi-Fi, AutoOpen, AutoDose, AquaStop, Inverter)
- hood (touch, LED, auto, silent, turbo, wifi, perimeter)
- microwave (Wi-Fi, humid sensor, defrost, antibac)
- coffee (Wi-Fi, touch, grinder, autoclean)
- washer (inverter, steam, wifi, autodose, silent, aquastop)
KEPT: hood.color (about visible material/aesthetics, not feature)
KEPT: oven.location (where in kitchen — design-relevant)
NEW STEP COUNTS:
- fridge: 3 (was 4)
- hob: 4 (was 5)
- oven: 3-4 (was 4-5)
- dw: 3 (was 4)
- hood: 3-4 (was 4-5)
- microwave: 3 (was 4)
- coffee: 1-4 (was 2-5)
- washer: 5 (was 6)
AI PROMPT updated:
- Features no longer come from user — AI mentions important ones in highlights/pros
- Emphasis on MEASURABLE advantages in pros (N dB quieter, Y l more, N% cheaper)
- Не делать акцент на стандартных фичах — 95% одинаковые
USER WIZARD теперь короче и проще: тип → размер → ключевые параметры → готово
WB sometimes responds with 1-2 unrelated products instead of 429 status.
Was returning 'Платье вечернее' on 'Haier холодильник' query.
Fix: _is_relevant(product, query) checks that at least 1 significant query word (>=3 chars)
appears in product name or brand. Discards full result if zero matches.
Tradeoff: may sometimes reject valid product if query is overly specific (e.g. exact SKU).
But that's OK — we fall through to next query variant.
DISCOVERED in real test:
- WB API v9 (/exactmatch/ru/common/v9/search) теперь возвращает только метаданные
(name, query, shardKey, filters, search_result={}) — products пусто
- WB API v18 (/exactmatch/ru/common/v18/search) — рабочий
Структура: {metadata, products, total} — products НА ВЕРХНЕМ уровне (не data.products)
- Подтверждено: query='Haier холодильник' → 100 products via v18
CHANGES:
1. _SEARCH_URL → v18 endpoint
2. Парсинг products: сначала data.products (legacy fallback), потом products top-level
3. _build_item: цены теперь читаются из sizes[].price.{product, total, basic}
(v18 формат), с fallback на priceU/salePriceU (v9 legacy)
4. _generate_query_variants: добавлен brand+category fallback
('Bosch холодильник' если не нашли по модели)
TEST: Haier холодильник → 100 results (first: 'Холодильник двухкамерный C2F619CFU1')
User reported clicking matrix prices led to 'Произошла ошибка!' on OZON home page.
Cause: parsers captured /product/?sponsored=1&cpc=Jtiito95... links that died after few hours.
Fix:
- ozon.py: skip href with 'sponsored=1', '/promo/', 'cpc='. Strip query string from final URL.
- yamarket.py: skip 'sponsored=1', 'cpc=', 'advUuid' (Я.Маркет sponsored marker)
- citilink.py: strip query string from final URL (defensive)
Now matrix links go to canonical product pages that don't expire.
1. MODEL COUNT SELECTOR (strategy step):
- new PODBOR_MODEL_COUNTS [3/5/7]
- state.model_count default '5'
- UI on strategy page with description (быстро/оптимально/максимум)
2. AI PROMPT EXPANDED:
- new field: manual_search_query — for Google search instruction PDF
- new specs object per model: dimensions_mm/volume_l/weight_kg/noise_db/energy_class/color
- 'specs ОБЯЗАТЕЛЬНЫ для проектирования кухни' explicit rule
- reads checklist.model_count to determine how many models per category
- max_tokens 4000 → 8000 (room for richer responses)
3. MODEL CARD RICHER:
- _renderSpecsBlock — characteristics in 2-col grid, dimensions highlighted
- _renderUtilityLinks — Google search buttons for инструкция (PDF) + Схема установки
- Specs critical for ZOV kitchen design (manager needs to verify niche fits)
4. EXPORT BUTTONS:
- 'Скачать HTML' — generates standalone HTML with inline styles, downloads as file
- 'Печать → PDF' — opens new window with cleaned layout + auto-prints
- User can save as PDF via system print dialog
5. PREVIEW updated with realistic specs/manual_query for all 3 fridges
- DNS: использовали httpx + proxy_pool но Qrator кидал 401 даже с residential
→ теперь Playwright + residential — браузер сам решает JS challenge
- OZON: теперь проверяем только <title>='Доступ ограничен' (точная), а не подстроку '/robotcheck/'
Я.Маркет рендерит SnippetConstructor виджет с JSON-стейтом ВНУТРИ a-тега.
Поэтому link.get_text() возвращает мусор типа {'widgets':{...}}.
Фикс:
- copy.copy(card) и удаление <script>/<noscript>/<noframes>/<template>
- Title теперь берётся из URL slug первым приоритетом (всегда чистый)
- _slug_to_title: транслитерация и капитализация
'bosch-kgn39ul30u-dvukhkamernyy-kholodilnik-no-frost-seryy-metallik' →
'Bosch KGN39UL30U Двухкамерный Холодильник NoFrost Серый Металлик'
- Old /product--{id} URLs deprecated
- Walks up from a[href*='/card/'] to nearest article/zone-div
- Extracts title from link text or h2/h3/itemprop=name
- Price: min from card text (with sanity bounds 100..10M)
- Image filters yastatic / _next placeholders
- Rating: '4.7★' or '4.7 N оценок' pattern
- Reviews: 'N отзывов' / 'N оценок'
- Stores count: 'от N магазинов / предложений'
- New use_proxy param (default True)
- Per-request random proxy from pool
- _parse_proxy_url_for_playwright converts http://user:pass@host:port to playwright.proxy dict
- New env: PROXY_LIST_FILE — path to file with one proxy per line
- _normalize_proxy_entry accepts: http://user:pass@host:port, host:port:user:pass (Proxys.io format), host:port
- _load_from_file reads file, dedup with static list
- /api/proxy_status returns file_path, file_loaded count, sample (first 3 masked)
DIAGNOSTIC RESULTS:
- OZON: 19 product links via Playwright on naked VPS-IP ✓
- Citilink: 112 data-meta-name Snippets ✓
- Wildberries: JSON API works with delays ✓
- Я.Маркет, DNS: blocked by ASN (need residential proxy)
OZON PARSER:
- Pure Playwright DOM (composer-api dropped — was blocked)
- Selects a[href*='/product/'], walks up to card div, extracts title/price/img
- Filters fake 'titles' like Распродажа, Скидка
CITILINK PARSER (new):
- Selects [data-meta-name*='Snippet'] or ProductCard markers
- Multiple title selectors fallback chain
- Filters out non-product hits
PARSERS/__init__.py:
- DEFAULT_SOURCES = (ozon, citilink, wb) — all work without proxy
- Я.Маркет, DNS kept but not default — usable when residential proxy added
NEW ENDPOINT:
- GET /api/parse_citilink?q=...&limit=N