diff --git a/app/app/Providers/AutopodborServiceProvider.php b/app/app/Providers/AutopodborServiceProvider.php index 2b21536a..87696a7f 100644 --- a/app/app/Providers/AutopodborServiceProvider.php +++ b/app/app/Providers/AutopodborServiceProvider.php @@ -81,7 +81,8 @@ class AutopodborServiceProvider extends ServiceProvider $pages = $sr ?? $xfetch; $reader = $this->twoGisReader(); - $siteFetcher = new CurlPlaywrightFetcher; + // Таймаут 15с (было 45): сайт конкурента отвечает за 1-3с; режем только висяки, не теряя живых. + $siteFetcher = new CurlPlaywrightFetcher(timeout: 15); $fetcher = new CompositeFetcher( siteFetcher: $siteFetcher, directoryFetcher: new XfetchDirectoryFetcher($pages), diff --git a/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php b/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php index 2fe6bb99..b42464a5 100644 --- a/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php +++ b/app/app/Services/Autopodbor/Agent/Study/DeepStudyCollector.php @@ -162,8 +162,10 @@ final class DeepStudyCollector implements SourceCollector } } $firmUrls = array_slice(array_values(array_filter(array_unique($firmUrls), fn ($u) => str_contains((string) $u, '/firm/'))), 0, 12); + // Карточки 2ГИС — ПАЧКОЙ (виртуалка параллелит; для 2ГИС initialState это безопасно, проверено). + $htmls = $firmUrls !== [] ? $this->xfetch->htmlBatch($firmUrls) : []; foreach ($firmUrls as $furl) { - $fh = $this->xfetch->html($furl); + $fh = $htmls[$furl] ?? ''; if ($fh === '') { continue; } diff --git a/app/app/Services/Autopodbor/Agent/Study/SiteOpener.php b/app/app/Services/Autopodbor/Agent/Study/SiteOpener.php index 0a22bf3f..129b2f10 100644 --- a/app/app/Services/Autopodbor/Agent/Study/SiteOpener.php +++ b/app/app/Services/Autopodbor/Agent/Study/SiteOpener.php @@ -23,9 +23,6 @@ final class SiteOpener $url = 'https://'.$this->idn($domain).'/'; try { $site = $this->fetcher->site($url); - if ($site->rawHtml === '') { - $site = $this->fetcher->site($url); // ретрай на флак - } $cands = $this->builder->build([$site], [], $areaCode); $codeDigits = []; foreach ($cands as $c) { diff --git a/docs/superpowers/findings/2026-07-05-phase8-vm-deploy-runbook.md b/docs/superpowers/findings/2026-07-05-phase8-vm-deploy-runbook.md new file mode 100644 index 00000000..381c8451 --- /dev/null +++ b/docs/superpowers/findings/2026-07-05-phase8-vm-deploy-runbook.md @@ -0,0 +1,278 @@ +# Фаза 8 — памятка «поднять виртуалку и включить свой рендер». РАНБУК (05.07.2026) + +> ## ✅ ВЫПОЛНЕНО НА DEV (05.07.2026, вечер) — виртуалка живёт и проверена +> - **ВМ `liderra-render`** создана в YC (тот же фолдер/сеть, что боевой): публичный **51.250.1.97**, внутренний **10.128.0.4**, Ubuntu 22.04, 4 ядра / 8 ГБ. +> - Рендер-сервис под **systemd** (`liderra-render.service`, автозапуск), Node 20 + полный Chromium. +> - Прокси подхватился (`/health` → `proxies:1`). Смоук 2ГИС через прокси — данные идут. +> - **https без домена:** Caddy + `sslip.io` → адрес **`https://51-250-1-97.sslip.io/render`** (настоящий сертификат Let's Encrypt, авто-продление). Домен НЕ понадобился. +> - **Файрвол (ufw):** снаружи только 22/80/443; порт сервиса 8790 закрыт (ходят только через «замочек»). +> - **Сквозная проверка кодом портала:** dev `app/.env` → `SELF_RENDER_ENDPOINT=https://51-250-1-97.sslip.io/render`, `SELF_RENDER_KEY=<в .env>`, `AUTOPODBOR_SELF_RENDER=true`. `SelfRenderClient.html()` + `TwoGisJsonReader.listing()` вернули **12 реальных ломбардов, 0 мусора** (double-tap лечит болезнь «Бургер Кинг»). +> - **Вход на ВМ:** `ssh -i ~/.ssh/liderra_deploy ubuntu@51.250.1.97`. Управление облаком: `yc` в `/c/Users/Administrator/yandex-cloud/bin`. +> - **Осталось (за владельцем, прод):** те же 3 строки в `app/.env` БОЕВОГО портала + `queue:work` перезапуск + сверка на объёме (~127 фирм). Это правка боевого — только с разрешения. Откат — флаг в `false`. +> +> Остальная часть файла ниже — исходный план (как делалось), оставлен для истории/повтора. + +--- + + +**Что это.** Пошаговый сценарий для последнего шага автоподбора: поднять отдельную виртуалку, +поставить на неё рендер-сервис (`render-service/`), воткнуть купленный прокси и секретный ключ, +проверить живьём, включить флаг на портале и сверить результат «фирма в фирму». + +**Готовим «на берегу» (05.07):** сначала просматриваешь этот файл, потом заказываем виртуалку вместе, шаг за шагом. +Ничего из этого пока НЕ выполнено — это план команд, а не отчёт. + +**Правила по ходу:** прод не трогаем; флаг по умолчанию ВЫКЛ; секреты только в `.env` (не в гит). +Ветка `worktree-avtopodbor`. + +--- + +## 0. Что нужно от тебя ОДИН раз (единственное решение) + +Порталу нужно звать виртуалку по защищённому адресу (с «замочком» https). Для «замочка» нужен +**поддомен**. Предлагаю самый простой и надёжный вариант: + +- Завести DNS-запись **`render.liderra.ru`** → на IP новой виртуалки (A-запись, ты добавляешь у регистратора домена). +- На виртуалке ставим **Caddy** — он сам, бесплатно и автоматически, получает и продлевает «замочек». + +Это НЕ трогает боевой сайт — просто ещё одно имя рядом. Если поддомен не хочешь — есть запасной +вариант (без домена, по «голому» IP с ограничением по firewall), опишу в §7. **Рекомендую поддомен.** + +> 🔴 Реши только это: **поддомен `render.liderra.ru` — ок?** Остальное — техника, делаю сам с твоими нажатиями кнопок. + +--- + +## 1. Заказать виртуалку (Yandex Cloud, консоль, вместе) + +- **Регион/зона:** `ru-central1` (Москва) — как весь проект. +- **ОС:** Ubuntu 22.04 LTS. +- **Ресурсы:** 4 vCPU, **8 ГБ ОЗУ** (браузер прожорлив), гарантированная доля vCPU 100%. +- **Диск:** 20 ГБ SSD — с запасом (Chromium + профиль). +- **Публичный IP:** да (нужен и для «замочка», и чтобы прокси-трафик уходил в интернет). +- **SSH-ключ:** твой (или сгенерим при заказе). Логин по паролю — выключить. +- **Имя:** `liderra-render`. +- **Отдельно НЕ нужно:** базы, Redis, ролей БД, доступа к боевой БД. Сервис только «рисует». + +Прикидка по деньгам: небольшая машина такого класса в YC — ориентировочно несколько сотен рублей +в месяц (точную цену покажет калькулятор при заказе). Трафик прокси уже оплачен отдельно (безлимит). + +--- + +## 2. Firewall (security group виртуалки) + +Впускать только нужное: + +| Порт | Откуда | Зачем | +|---|---|---| +| 22 (SSH) | только твой IP (или твой диапазон) | заходить настраивать | +| 443 (https) | откуда зовёт портал | сам рендер (через Caddy) | +| 80 (http) | отовсюду | только чтобы Caddy получил «замочек» (Let's Encrypt) | + +Порт сервиса **8790 наружу НЕ открываем** — он слушает только внутри машины, снаружи ходят через 443/Caddy. + +> На этапе приёмки портал = локальная dev-машина (Windows). Значит на 443 временно впускаем и твой +> рабочий IP. Когда позже подключим боевой портал — добавим IP боевого сервера. + +--- + +## 3. Поставить окружение на виртуалку (по SSH) + +```bash +# обновиться + Node 20 (сервису нужен >=20) +sudo apt update && sudo apt -y upgrade +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs + +# зависимости системного Chromium для Playwright (шрифты, libnss и т.п.) +sudo apt install -y \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libasound2 libpango-1.0-0 libcairo2 fonts-liberation + +node -v # ожидаем v20.x +``` + +--- + +## 4. Залить рендер-сервис и поставить браузер + +```bash +sudo mkdir -p /opt/liderra-render && sudo chown $USER /opt/liderra-render +# скопировать содержимое папки render-service/ из репозитория в /opt/liderra-render +# (scp с рабочей машины ИЛИ git clone нужной ветки и cp -r render-service/* /opt/liderra-render) +cd /opt/liderra-render + +npm install # ставит playwright (по package.json) +npx playwright install chromium # скачивает сам браузер +``` + +> ⚠️ `node_modules` и скачанный Chromium на виртуалке — НЕ коммитить (их и нет в гите: `.gitignore`). + +--- + +## 5. Секреты виртуалки — файл `/opt/liderra-render/.env` + +Взять готовый `render-service/.env` (он уже собран в прошлой сессии: там строка `PROXIES=...` с логином/паролем +купленного прокси и сгенерированный `RENDER_KEY`). Скопировать его на виртуалку как `/opt/liderra-render/.env`: + +``` +PORT=8790 +RENDER_KEY=<секрет из render-service/.env — НЕ печатать в чат/логи> +MAX_CONCURRENCY=8 +PROXIES=http://<логин>:<пароль>@78.107.254.174:30470 +``` + +> 🔑 Значения `RENDER_KEY` и логин/пароль прокси лежат в gitignored `render-service/.env`. В гит и в переписку не выносим. + +--- + +## 6. Запустить сервис как службу (сам поднимается после перезагрузки) + +Создать `/etc/systemd/system/liderra-render.service`: + +```ini +[Unit] +Description=Liderra render service (2GIS/Yandex) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/opt/liderra-render +EnvironmentFile=/opt/liderra-render/.env +ExecStart=/usr/bin/node server.mjs +Restart=always +RestartSec=3 +User=<твой-пользователь> + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now liderra-render +curl -s localhost:8790/health # ждём {"ok":true,"active":0,"max":8,"proxies":1} +``` + +`proxies:1` подтвердит, что прокси подхватился. + +--- + +## 7. «Замочек» (Caddy) — поддомен → сервис + +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update && sudo apt install -y caddy +``` + +`/etc/caddy/Caddyfile` — весь конфиг в две строки: + +``` +render.liderra.ru { + reverse_proxy localhost:8790 +} +``` + +```bash +sudo systemctl restart caddy +# с рабочей машины проверить извне (замок должен встать сам за минуту): +curl -s https://render.liderra.ru/health +``` + +**Запасной вариант без поддомена (если §0 «нет»):** Caddy не ставим, портал зовёт `http://:8790/render`, +а firewall на 8790 впускает ТОЛЬКО IP портала (и твой на приёмку). Минус — ключ и данные идут по «голому» +http; для мобильного трафика 5–6 прогонов/день терпимо, но поддомен+https безопаснее. + +--- + +## 8. Проверить сервис живьём (смоук через прокси, без портала) + +С рабочей машины (подставить настоящий `RENDER_KEY`): + +```bash +curl -s https://render.liderra.ru/render \ + -H "X-Render-Key: " -H "Content-Type: application/json" \ + -d '{"url":"https://2gis.ru/krasnoyarsk/search/ломбард","type":"initialState"}' | head -c 400 +``` + +Ждём непустой JSON (внутри `initialState`), не `{"data":""}`. Пусто → смотреть §11. + +--- + +## 9. Подключить портал — СНАЧАЛА на dev-машине + +В `app/.env` **локальной** машины (не боевой!) добавить: + +``` +SELF_RENDER_ENDPOINT=https://render.liderra.ru/render +SELF_RENDER_KEY=<тот же RENDER_KEY, что на виртуалке> +AUTOPODBOR_SELF_RENDER=true +``` + +🔴 После правки `.env` — **перезапустить очередь**: остановить и заново `php artisan queue:work` +(и `artisan serve`, если крутится). Иначе воркер держит старые настройки в памяти +(память `feedback-queue-worker-restart-after-change`). + +Гранулярно (по желанию): можно включить только 2ГИС (`AUTOPODBOR_SELF_RENDER_2GIS=true`) или только +Яндекс — но для приёмки включаем целиком. + +--- + +## 10. Приёмка владельцем (ты смотришь глазами) + +1. На dev-портале запустить **шаг 1** (поиск конкурентов) по реальному клиенту. +2. Запустить **шаг 2** (изучение) хотя бы по нескольким конкурентам. +3. Сверить руками с живым 2ГИС: + - конкурентов должно быть **много (~127 на тест-запросе), не 51** — признак, что канал реально собирает; + - **ноль мусора** (не «Бургер Кинг» вместо ломбарда — это была болезнь двойного кодирования, лечится double-tap); + - телефоны и сайты — верные, как в карточке 2ГИС. +4. Всё сошлось → флаг оставляем ВКЛ. Что-то не так → §11 и откат (§12). + +--- + +## 11. Если что-то не так (быстрая диагностика) + +| Симптом | Куда смотреть | +|---|---| +| `/health` даёт `proxies:0` | строка `PROXIES` в `.env` виртуалки (формат `http://user:pass@host:port`), перезапустить службу | +| `/render` возвращает `{"data":""}` | логи службы: `journalctl -u liderra-render -n 100`; проверить, что прокси жив (не кончились деньги/не сменился порт) | +| 401 unauthorized | `RENDER_KEY` на виртуалке ≠ `SELF_RENDER_KEY` на портале | +| мусор вместо фирм (Бургер Кинг) | болезнь двойного кодирования; в браузере уже стоит museum double-tap (`browser.mjs`), проверить что это поиск `/search/` | +| мало фирм / пусто у Яндекса | лента ленивая — проверить `scrolls`; смотреть `type:yandex-list` | +| портал будто не видит виртуалку | не перезапущен `queue:work` после правки `.env` | + +Логи службы: `journalctl -u liderra-render -f`. Перезапуск: `sudo systemctl restart liderra-render`. + +--- + +## 12. Откат (мгновенный) + +- На портале в `.env`: `AUTOPODBOR_SELF_RENDER=false` → перезапустить `queue:work`. Автоподбор + сразу вернётся на xf4, как сейчас. Виртуалку можно оставить выключенной. + +--- + +## 13. Позже (после успешной приёмки на dev) + +- Те же три строки (`SELF_RENDER_ENDPOINT/KEY` + флаг) прописать в `app/.env` **боевого** портала — + это уже правка боевого, делаем **только с твоего разрешения** и через обычный деплой-процесс. +- На firewall виртуалки добавить IP боевого сервера на 443. +- По желанию — включить ротацию IP прокси (2–60 мин) в кабинете Proxy.Market (сейчас Disabled), для анти-блока на объёме. + +--- + +## Приложение. Имена настроек (шпаргалка, чтобы не путать) + +| Сторона | Переменная | Смысл | +|---|---|---| +| Виртуалка (`render-service/.env`) | `PORT` | порт сервиса (8790) | +| Виртуалка | `RENDER_KEY` | секрет; должен совпасть с `SELF_RENDER_KEY` портала | +| Виртуалка | `MAX_CONCURRENCY` | сколько вкладок разом (по памяти, 8 ок для 8 ГБ) | +| Виртуалка | `PROXIES` | пул прокси `http://user:pass@host:port` | +| Портал (`app/.env`) | `SELF_RENDER_ENDPOINT` | `https://render.liderra.ru/render` | +| Портал | `SELF_RENDER_KEY` | тот же секрет, что `RENDER_KEY` | +| Портал | `SELF_RENDER_CONCURRENCY` | не обязателен (дефолт 8) | +| Портал | `AUTOPODBOR_SELF_RENDER` | главный тумблер ВКЛ/ВЫКЛ | +| Портал | `AUTOPODBOR_SELF_RENDER_2GIS` / `_YANDEX` | гранулярно (по умолчанию = главному) | diff --git a/render-service/browser.mjs b/render-service/browser.mjs index 82cb61fe..bdfd686b 100644 --- a/render-service/browser.mjs +++ b/render-service/browser.mjs @@ -100,22 +100,35 @@ export async function render(url, type, proxy = null, scrolls = 6) { } await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); - await page.waitForTimeout(2500); - // Пройти заглушку «обновите браузер» (ставит cookie-обход). if (isGis) { + // Пройти заглушку «обновите браузер» (ставит cookie-обход) — кликаем, как только видна, без фикс-паузы. try { const skip = page.getByText(/перейти в 2ГИС|Пропустить/i).first(); - if (await skip.isVisible({ timeout: 2000 })) await skip.click({ timeout: 3000 }); + if (await skip.isVisible({ timeout: 1500 })) { await skip.click({ timeout: 3000 }); await page.waitForTimeout(400); } } catch { /* заглушки нет — ок */ } - await page.waitForTimeout(3500); // museum double-tap: для поиска return_url заглушки дважды закодирован (%20→«20») → повторный // чистый заход по тому же URL (cookie-обход уже стоит) даёт корректный поиск. Для /firm/ не нужно. if (isGisSearch) { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); - await page.waitForTimeout(6000); } + // Умное ожидание вместо фикс-пауз: ждём появления НАСТОЯЩИХ данных в initialState + // (пагинация для поиска, профиль фирмы для карточки), а не заведомо-длинную паузу. §5.4. + await page.waitForFunction((search) => { + const s = window.initialState; + if (!s || !s.data) return false; + return search + ? !!(s.data.search && s.data.search.pagination && Object.keys(s.data.search.pagination).length) + : !!(s.data.entity && s.data.entity.profile && Object.keys(s.data.entity.profile).length); + }, isGisSearch, { timeout: 20000, polling: 200 }).catch(() => { /* фолбэк: отдадим что есть */ }); } else { - await page.waitForTimeout(5000); + // Яндекс-карточка (html): ждём появления блоков карточки (телефоны/сайт), потом короткий добор — + // вместо глухих 5с. Если карточки нет (сайт/иное) — фолбэк по таймауту, не хуже прежнего. + await page.waitForFunction( + () => !!document.querySelector('[class*="orgpage-phones-view"], [class*="business-urls-view"], [class*="orgpage-header"]'), + null, + { timeout: 5000, polling: 200 }, + ).catch(() => { /* нет карточки — отдадим что есть */ }); + await page.waitForTimeout(1200); } if (type === 'initialState') {