diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index bd39749c..8ba14f6e 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -1886,6 +1886,50 @@ _NOT_ADDRESS_TAIL_RE = re.compile( flags=re.I, ) +# #1773: Avito DOM иногда выдаёт номер дома СЛИПШИМСЯ с маркером района/нас.пункта +# без разделителя — «…Савкова, 29р-н Академический», «17Ар-н», «35/2р-н», «33с1р-н». +# Это ломает извлечение номера дома и матчинг «того же дома» (см. issue #1773). +# Де-слип вставляет «, » между токеном номера дома и следующим маркером. +# +# Группа 1 — токен номера дома: цифры + опц. кириллическая литера (А/Б/...) + +# опц. корпус/строение (/N, сN, кN, корпус N, стр N) — напр. 29, 29А, 35/2, 33с1, 44Б. +# Группа 2 — маркер района/нас.пункта, начинающийся СРАЗУ за номером без пробела. +# +# Тонкость со слипшейся литерой: в «17Ар-н» литера «А» и маркер «р-н» — соседние +# кириллические буквы без границы. Поэтому литеру дома матчим ТОЛЬКО когда сразу +# за ней идёт известный маркер (lookahead на _GLUE_MARKER) — иначе риск съесть +# первую букву обычного слова. Корпус «сN/кN» требует цифры, поэтому не путается +# с маркерами «с.» (село) / «к» — у тех нет цифры вплотную. +_GLUE_MARKER = ( + r"(?:р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок" + r"|снт|кв-л|квартал|д\.|с\.|пгт)" +) +_GLUE_HOUSE_TOKEN = ( + r"\d+" # дом: цифры + # опц. одиночная литера (29А) — только если за ней сразу маркер + rf"(?:\s?[А-Яа-я](?={_GLUE_MARKER}))?" + r"(?:\s?(?:/\d+|с\d+|к\d+|корп\.?\s?\d+|стр\.?\s?\d+))?" # опц. корпус/строение +) +# Хвост `(?![а-яёa-z])` гарантирует, что маркер — отдельный токен, а не начало слова. +_DEGLUE_RE = re.compile( + rf"({_GLUE_HOUSE_TOKEN})({_GLUE_MARKER}(?![а-яёa-z]))", + flags=re.IGNORECASE, +) + + +def _deglue_house_marker(addr: str) -> str: + """Вставить «, » между слипшимся номером дома и маркером района/нас.пункта. + + «…Савкова, 29р-н Академический» -> «…Савкова, 29, р-н Академический» + «…, 17Ар-н Пионерский» -> «…, 17А, р-н Пионерский» + «…, 35/2р-н …» -> «…, 35/2, р-н …» + «…, 33с1р-н …» -> «…, 33с1, р-н …» + + Идемпотентно: на уже-расклеенной строке («29, р-н») паттерн не срабатывает, + т.к. между номером и маркером уже есть запятая/пробел. + """ + return _DEGLUE_RE.sub(r"\1, \2", addr) + def _clean_address(raw: str | None) -> str | None: """Strip Emotion CSS and post-address noise that Avito DOM leaks via .text(). @@ -1895,11 +1939,14 @@ def _clean_address(raw: str | None) -> str | None: -> "ул. Токарей, 56к1" "ул. Малышева, 1.css-xxx{...}" -> "ул. Малышева, 1" + "ул. Евгения Савкова, 29р-н Академический" + -> "ул. Евгения Савкова, 29, р-н Академический" """ if not raw: return None cleaned = _CSS_NOISE_RE.sub("", raw) cleaned = _NOT_ADDRESS_TAIL_RE.split(cleaned, maxsplit=1)[0] + cleaned = _deglue_house_marker(cleaned) cleaned = cleaned.strip(" ,.\n\t") return cleaned or None diff --git a/tradein-mvp/backend/data/sql/124_deglue_avito_addresses.sql b/tradein-mvp/backend/data/sql/124_deglue_avito_addresses.sql new file mode 100644 index 00000000..edc298a7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/124_deglue_avito_addresses.sql @@ -0,0 +1,44 @@ +-- 124_deglue_avito_addresses.sql +-- #1773 — де-слип номера дома и маркера района/нас.пункта в listings.address (Avito). +-- +-- ПРОБЛЕМА (прод). Avito-адреса хранят номер дома СЛИПШИМСЯ с маркером района без +-- разделителя: «…ул. Евгения Савкова, 29р-н Академический» (номер «29» приклеен к +-- «р-н Академический»). Паттерны из данных: «29р-н», «17Ар-н», «35/2р-н», «33с1р-н», +-- «44Бр-н». Из-за склейки ломается извлечение номера дома и матчинг «того же дома» +-- (41 несматченный avito-листинг по одной улице). +-- +-- ЧТО ДЕЛАЕТ. Вставляет «, » между токеном номера дома и следующим за ним маркером +-- района/нас.пункта без пробела. На ingest то же чинит avito.py::_clean_address; +-- этот backfill чистит УЖЕ сохранённые строки на деплое. +-- +-- ПОЧЕМУ POSIX-ERE без lookahead. Postgres regexp_replace не поддерживает (?=…), +-- поэтому маркер фиксируется хвостовой группой \3 = «не кириллица/не цифра или конец» +-- (гарантирует, что маркер — целый токен, а не начало слова). Литера дома [А-Яа-яЁё]? +-- жадная, но движок обязан затем сматчить целый маркер вплотную — иначе совпадения нет. +-- Корпус «сN/кN» требует цифру, поэтому не путается с маркерами «с.» (село) / «к». +-- +-- Idempotent: regexp_replace на уже-расклеенной строке («29, р-н») — no-op +-- (между номером и маркером уже стоит «, »); WHERE отбирает только ещё-слипшиеся +-- строки. Под gate _schema_migrations (ровно один раз). +-- Apply after: 123_avito_newbuilding_sweep_schedule.sql + +BEGIN; + +UPDATE listings + SET address = regexp_replace( + address, + -- \1 = токен номера дома (цифры + опц. литера + опц. корпус/строение) + -- \2 = маркер района/нас.пункта + -- \3 = хвостовая граница (не буква/цифра или конец строки) + '([0-9]+[А-Яа-яЁё]?(/[0-9]+|с[0-9]+|к[0-9]+|корп\.? ?[0-9]+|стр\.? ?[0-9]+)?)' + || '(р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок|снт|кв-л|квартал|д\.|с\.|пгт)' + || '([^А-Яа-яЁё0-9]|$)', + '\1, \3\4', + 'g' + ) + WHERE source = 'avito' + AND address ~ ('([0-9]+[А-Яа-яЁё]?(/[0-9]+|с[0-9]+|к[0-9]+|корп\.? ?[0-9]+|стр\.? ?[0-9]+)?)' + || '(р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок|снт|кв-л|квартал|д\.|с\.|пгт)' + || '([^А-Яа-яЁё0-9]|$)'); + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_avito_address_clean.py b/tradein-mvp/backend/tests/test_avito_address_clean.py index e3b14a20..c00805cf 100644 --- a/tradein-mvp/backend/tests/test_avito_address_clean.py +++ b/tradein-mvp/backend/tests/test_avito_address_clean.py @@ -1,3 +1,5 @@ +import pytest + from app.services.scrapers.avito import _clean_address @@ -22,3 +24,59 @@ def test_clean_handles_none(): def test_clean_strips_trailing_punctuation(): assert _clean_address("ул. Ленина, 5,") == "ул. Ленина, 5" + + +# ── #1773: де-слип номера дома и маркера района/нас.пункта ──────────────────── +@pytest.mark.parametrize( + "raw,expected", + [ + # реальный кейс с прода + ( + "Свердловская обл., Екатеринбург, ул. Евгения Савкова, 29р-н Академический", + "Свердловская обл., Екатеринбург, ул. Евгения Савкова, 29, р-н Академический", + ), + # литера дома слиплась с маркером + ("ул. X, 17Ар-н Пионерский", "ул. X, 17А, р-н Пионерский"), + ("ул. X, 44Бр-н Запад", "ул. X, 44Б, р-н Запад"), + # корпус через дробь + ("ул. X, 35/2р-н Юг", "ул. X, 35/2, р-н Юг"), + # корпус через «сN» + ("ул. X, 33с1р-н Центр", "ул. X, 33с1, р-н Центр"), + # прочие маркеры + ("ул. X, 12мкр Солнечный", "ул. X, 12, мкр Солнечный"), + ("ул. X, 5район Кировский", "ул. X, 5, район Кировский"), + ("ул. X, 100к2р-н Чкаловский", "ул. X, 100к2, р-н Чкаловский"), + # маркеры населённых пунктов + ("ул. X, 12с. Кашино", "ул. X, 12, с. Кашино"), + ("ул. X, 10д. Малое", "ул. X, 10, д. Малое"), + ("ул. X, 25г. Берёзовский", "ул. X, 25, г. Берёзовский"), + ], +) +def test_deglue_house_marker(raw, expected): + assert _clean_address(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + # уже расклеено — идемпотентность + "ул. Евгения Савкова, 29, р-н Академический", + # нормальные адреса без склейки — разделять НЕ надо + "ул. Малышева, 1", + "ул. Токарей, 56к1", + "ул. Ленина, 5", + "ул. Серова, 45", + "г. Екатеринбург, ул. Ленина, 10", + "снт Дружба, уч. 12", + "ул. Декабристов, 16", + ], +) +def test_deglue_negative_no_change(raw): + # для этих строк де-слип не должен ничего менять (кроме штатного strip пунктуации) + assert _clean_address(raw) == raw.strip(" ,.\n\t") + + +def test_deglue_idempotent(): + once = _clean_address("ул. Евгения Савкова, 29р-н Академический") + twice = _clean_address(once) + assert once == twice == "ул. Евгения Савкова, 29, р-н Академический"