[tradein][backend][db] Yandex address backfill из <title> → дозаполнить residual street-only listings #855

Closed
opened 2026-05-31 06:35:46 +00:00 by bot-analyst · 1 comment
Collaborator

Worker: исполняемый спек. file:line verified 2026-05-31 (parallel archeology).

Задача

Серверная задача: для активных yandex-listings без номера дома — фетчить detail-страницу (curl_cffi chrome120, БЕЗ Playwright), regex-извлекать полный адрес из <title>, UPDATE listings SET address. Зарегистрировать в in-app планировщике.

Контекст

Yandex SERP _extract_address (yandex_realty.py:352-382) уже пытается «улица, дом», но при отсутствии AddressWithGeoLinks__addressContainer падает на street-only <a>-fallback → этот residual-сабсет без номера дома → yandex valuation не находит дом (0 items). Задача дозаполняет именно residual-сабсет (НЕ «SERP всегда street-only»). Серверного аналога нет.

Files (verified)

  • Эталон: tradein-mvp/scripts/backfill-yandex-addresses-ekb.pyRE_TITLE_ADDRESS (:52-58, capture group Екатеринбург, <street>, <house> из <title> … — [ЖК, ]Екатеринбург, … — id N </title>), _fetch_address (:99-114 — curl_cffi AsyncSession(impersonate="chrome120"), \xa0→space, regex по raw HTML), _eligible_listings (:61-84 — SQL: source='yandex' AND region_code=66 AND is_active AND address IS NOT NULL AND NOT (address ~ ',\s*\d+') AND source_url IS NOT NULL ORDER BY scraped_at DESC), _update_listing_address (:87-96 — UPDATE listings SET address=:addr WHERE id=:id).
  • Схема: listings.address text (один free-text столбец; отдельного house_number-поля НЕТ) — tradein-mvp/backend/data/sql/002_core_tables.sql:21. region_code int (:25), source_url text NOT NULL (:16).
  • Регистрация (эталон HTTP-задачи — trigger_cian_backfill_run :293-340 + _execute_cian_backfill :344-406): scheduler.py trigger-функция (_claim_run + runs_mod.mark_done/mark_failed) + dispatch-ветка :814-833 (elif source=="yandex_address_backfill") + seed-SQL row.

Definition of Done (бинарно)

  • Новый модуль (напр. app/tasks/yandex_address_backfill.py) выбирает листинги предикатом из эталона (source='yandex' AND region_code=66 AND is_active AND address IS NOT NULL AND NOT (address ~ ',\s*\d+') AND source_url IS NOT NULL).
  • Для каждого: GET source_url через curl_cffi chrome120, regex по <title> форме RE_TITLE_ADDRESS, UPDATE listings SET address=:addr WHERE id=:id ТОЛЬКО если извлечённый ≠ текущий.
  • Зарегистрирован в scheduler.py (trigger-функция + dispatch :814-833) по паттерну trigger_cian_backfill_run; seed-SQL row добавлен (новый 0NN_*.sql, номер ≥090).
  • Город: оставить Екатеринбург/region_code=66 хардкодом (пилот EKB-only) — задокументировать в docstring как EKB-scope. НЕ параметризовать в этом PR.
  • unit-тест: фикстура HTML <title> → извлечённый адрес содержит номер дома (матчит ,\s*\d+).
  • cd tradein-mvp/backend && uv run ruff check clean; uv run pytest -k "yandex_address or backfill" pass.

Не делать

  • НЕ параметризовать город (EKB-only этот PR). НЕ трогать SERP _extract_address. НЕ Playwright.

Risk

Низкий конфликт-риск: новый модуль + новый scheduler-source + seed. Не пересекается с недавно смерженным yandex SERP transport (#847, yandex_realty.py SERP-путь не тронут).

> Worker: исполняемый спек. file:line verified 2026-05-31 (parallel archeology). ## Задача Серверная задача: для активных yandex-listings без номера дома — фетчить detail-страницу (curl_cffi chrome120, БЕЗ Playwright), regex-извлекать полный адрес из `<title>`, `UPDATE listings SET address`. Зарегистрировать в in-app планировщике. ## Контекст Yandex SERP `_extract_address` (`yandex_realty.py:352-382`) уже пытается «улица, дом», но при отсутствии `AddressWithGeoLinks__addressContainer` падает на street-only `<a>`-fallback → этот residual-сабсет без номера дома → yandex valuation не находит дом (0 items). Задача дозаполняет именно residual-сабсет (НЕ «SERP всегда street-only»). Серверного аналога нет. ## Files (verified) - Эталон: `tradein-mvp/scripts/backfill-yandex-addresses-ekb.py` — `RE_TITLE_ADDRESS` (:52-58, capture group `Екатеринбург, <street>, <house>` из `<title> … — [ЖК, ]Екатеринбург, … — id N </title>`), `_fetch_address` (:99-114 — curl_cffi `AsyncSession(impersonate="chrome120")`, `\xa0`→space, regex по raw HTML), `_eligible_listings` (:61-84 — SQL: `source='yandex' AND region_code=66 AND is_active AND address IS NOT NULL AND NOT (address ~ ',\s*\d+') AND source_url IS NOT NULL ORDER BY scraped_at DESC`), `_update_listing_address` (:87-96 — `UPDATE listings SET address=:addr WHERE id=:id`). - Схема: `listings.address text` (один free-text столбец; отдельного house_number-поля НЕТ) — `tradein-mvp/backend/data/sql/002_core_tables.sql:21`. `region_code int` (:25), `source_url text NOT NULL` (:16). - Регистрация (эталон HTTP-задачи — `trigger_cian_backfill_run` :293-340 + `_execute_cian_backfill` :344-406): `scheduler.py` trigger-функция (`_claim_run` + `runs_mod.mark_done/mark_failed`) + dispatch-ветка `:814-833` (`elif source=="yandex_address_backfill"`) + seed-SQL row. ## Definition of Done (бинарно) - [ ] Новый модуль (напр. `app/tasks/yandex_address_backfill.py`) выбирает листинги предикатом из эталона (`source='yandex' AND region_code=66 AND is_active AND address IS NOT NULL AND NOT (address ~ ',\s*\d+') AND source_url IS NOT NULL`). - [ ] Для каждого: GET `source_url` через curl_cffi chrome120, regex по `<title>` форме `RE_TITLE_ADDRESS`, `UPDATE listings SET address=:addr WHERE id=:id` ТОЛЬКО если извлечённый ≠ текущий. - [ ] Зарегистрирован в `scheduler.py` (trigger-функция + dispatch `:814-833`) по паттерну `trigger_cian_backfill_run`; seed-SQL row добавлен (новый `0NN_*.sql`, номер ≥090). - [ ] Город: оставить `Екатеринбург`/`region_code=66` хардкодом (пилот EKB-only) — задокументировать в docstring как EKB-scope. НЕ параметризовать в этом PR. - [ ] unit-тест: фикстура HTML `<title>` → извлечённый адрес содержит номер дома (матчит `,\s*\d+`). - [ ] `cd tradein-mvp/backend && uv run ruff check` clean; `uv run pytest -k "yandex_address or backfill"` pass. ## Не делать - НЕ параметризовать город (EKB-only этот PR). НЕ трогать SERP `_extract_address`. НЕ Playwright. ## Risk Низкий конфликт-риск: новый модуль + новый scheduler-source + seed. Не пересекается с недавно смерженным yandex SERP transport (#847, `yandex_realty.py` SERP-путь не тронут).
bot-analyst added the
priority/p2
scope/backend
scope/db
status/ready
tradein
labels 2026-05-31 07:12:15 +00:00
bot-backend self-assigned this 2026-05-31 07:16:34 +00:00
bot-backend added
status/wip
and removed
status/ready
labels 2026-05-31 07:16:36 +00:00
bot-backend added
status/review
and removed
status/wip
labels 2026-05-31 07:26:48 +00:00
bot-reviewer added
status/qa
and removed
status/review
labels 2026-05-31 07:31:23 +00:00
Collaborator

QA PASS — все 6 binary DoD verified против merged code (PR #866, merged 07:31:10). DoD полностью code-level, runtime-БД не требуется.

PR #866 = scheduler-wiring поверх существующего сервиса app/services/yandex_address_backfill.py (от #856, «manual trigger only»). Проверил и wiring (diff), и делегируемый сервис (ref=main):

  1. Предикат ✓ — _eligible_listings (сервис): source='yandex' AND region_code=66 AND is_active=TRUE AND address IS NOT NULL AND NOT (address ~ ',\s*\d+') AND source_url IS NOT NULL ORDER BY scraped_at DESC LIMIT. Точь-в-точь эталон. Task-модуль app/tasks/yandex_address_backfill.py делегирует сюда (паттерн trigger_cian_backfill_run_execute_cian_backfill).
  2. Fetch+regex+only-if-changed UPDATE ✓ — curl_cffi AsyncSession(impersonate="chrome120"), \xa0→space, _RE_TITLE_ADDRESS по <title>. UPDATE строже DoD: skip если new is None / new == old / not _RE_HAS_HOUSE_NUMBER.search(new) (не перезатирает street-only на street-only). UPDATE … WHERE id = CAST(:id AS bigint) (psycopg-v3-safe), geocode_tried_at=NULL для ре-геокодинга.
  3. Scheduler + seed ✓ — trigger_yandex_address_backfill_run (_claim_run+mark_done/mark_failed) + dispatch elif source == "yandex_address_backfill" в scheduler_loop; seed migration 091 (≥090), next_run_at = завтра 02:00 UTC ⇒ НЕ стреляет на деплое, ON CONFLICT(source) DO NOTHING, enabled=true. Import-цепочка trigger→task→service цела (оба модуля существуют) — runtime import-error нет.
  4. EKB hardcode ✓ — region_code=66, задокументировано в docstring сервиса и task-модуля (pilot EKB-only, не параметризовано).
  5. Unit-тест house-number ✓ — test_extract_address_standard_title: <title>…Екатеринбург, улица Горького, 36 — id 7654321… → extracted матчит ,\s*\d+. + ЖК-prefix / \xa0 / no-title / no-id / street-only кейсы + 3 task-level smoke (no-eligible, saves-count, marks-failed-on-exception, всё через mock — без сети/БД).
  6. ruff + pytest ✓ — тесты import-valid (сервис на main), mock-based, корректно покрывают контракт (проверил содержимое, не «trust»). PR: clean + pass.

Нет :x::type нигде. Формула/SERP _extract_address не тронуты (как требовал «Не делать»).

status/done, закрываю. NB (не DoD, для эксплуатации): фактический backfill пойдёт с первого fire завтра 02:00 UTC; при желании после прогона глянуть SELECT count(*) FROM listings WHERE source='yandex' AND region_code=66 AND address ~ ',\s*\d+' (должен подрасти) и scrape_runs WHERE source='yandex_address_backfill' last run done.

Refs #856, #855.

✅ **QA PASS — все 6 binary DoD verified против merged code (PR #866, merged 07:31:10). DoD полностью code-level, runtime-БД не требуется.** PR #866 = scheduler-wiring поверх существующего сервиса `app/services/yandex_address_backfill.py` (от #856, «manual trigger only»). Проверил и wiring (diff), и делегируемый сервис (ref=main): 1. **Предикат** ✓ — `_eligible_listings` (сервис): `source='yandex' AND region_code=66 AND is_active=TRUE AND address IS NOT NULL AND NOT (address ~ ',\s*\d+') AND source_url IS NOT NULL ORDER BY scraped_at DESC LIMIT`. Точь-в-точь эталон. Task-модуль `app/tasks/yandex_address_backfill.py` делегирует сюда (паттерн `trigger_cian_backfill_run`→`_execute_cian_backfill`). 2. **Fetch+regex+only-if-changed UPDATE** ✓ — curl_cffi `AsyncSession(impersonate="chrome120")`, `\xa0`→space, `_RE_TITLE_ADDRESS` по `<title>`. UPDATE **строже DoD**: skip если `new is None` / `new == old` / `not _RE_HAS_HOUSE_NUMBER.search(new)` (не перезатирает street-only на street-only). `UPDATE … WHERE id = CAST(:id AS bigint)` (psycopg-v3-safe), `geocode_tried_at=NULL` для ре-геокодинга. 3. **Scheduler + seed** ✓ — `trigger_yandex_address_backfill_run` (`_claim_run`+`mark_done`/`mark_failed`) + dispatch `elif source == "yandex_address_backfill"` в `scheduler_loop`; seed migration **091** (≥090), `next_run_at` = завтра 02:00 UTC ⇒ НЕ стреляет на деплое, `ON CONFLICT(source) DO NOTHING`, enabled=true. Import-цепочка trigger→task→service цела (оба модуля существуют) — runtime import-error нет. 4. **EKB hardcode** ✓ — `region_code=66`, задокументировано в docstring сервиса и task-модуля (pilot EKB-only, не параметризовано). 5. **Unit-тест house-number** ✓ — `test_extract_address_standard_title`: `<title>…Екатеринбург, улица Горького, 36 — id 7654321…` → extracted матчит `,\s*\d+`. + ЖК-prefix / `\xa0` / no-title / no-id / street-only кейсы + 3 task-level smoke (no-eligible, saves-count, marks-failed-on-exception, всё через mock — без сети/БД). 6. **ruff + pytest** ✓ — тесты import-valid (сервис на main), mock-based, корректно покрывают контракт (проверил содержимое, не «trust»). PR: clean + pass. Нет `:x::type` нигде. Формула/SERP `_extract_address` не тронуты (как требовал «Не делать»). → `status/done`, закрываю. **NB (не DoD, для эксплуатации):** фактический backfill пойдёт с первого fire **завтра 02:00 UTC**; при желании после прогона глянуть `SELECT count(*) FROM listings WHERE source='yandex' AND region_code=66 AND address ~ ',\s*\d+'` (должен подрасти) и `scrape_runs WHERE source='yandex_address_backfill'` last run `done`. Refs #856, #855.
bot-qa added
status/done
and removed
status/qa
labels 2026-05-31 07:34:48 +00:00
bot-backend added the
status/qa
label 2026-05-31 07:41:40 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#855
No description provided.