Merge pull request 'fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date' (#2049) from fix/tradein-v2-fe1-address-scatter into main
All checks were successful
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 48s
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m53s

This commit is contained in:
lekss361 2026-06-28 12:04:59 +00:00
commit 510cf1cb80

View file

@ -327,42 +327,62 @@ function rangeLine(
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
// NB: JS \b word boundaries do NOT work around Cyrillic (only ASCII \w), so the
// street keyword is delimited by start/space/dot/end explicitly, not \b.
const STREET_RE =
/\b(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)\b/i;
const REGION_RE =
/(обл|область|край|респ|республика|р-н|район|округ|\bАО\b)/i;
/(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i;
const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\s+/i;
// House number token ("14", "14/3", "14А", "14 к2", "14 стр1"); leading дом/зд
// prefix stripped by houseClean before the test.
const HOUSE_RE = /^\d+[а-яА-Я]?(\/\d+)?(\s?к\.?\s?\d+)?(\s?стр\.?\s?\d+)?$/i;
// Administrative anchor — the city token typically sits right before one of these.
const ADMIN_ANCHOR_RE =
/городск(ой|ая)|муниципальн|\bобласть\b|\bобл\b|\bкрай\b|\bреспублик/i;
// Explicit city token "г Екатеринбург" / "город …" / "пгт …" (capture name).
const CITY_TOKEN_RE = /^(?:г|город|пгт|гп)\.?\s+([А-Яа-яЁё].*)/;
/**
* Best-effort split of a single address string into {street+house, city}.
* Drops postal index + region parts, finds the street token and treats the
* preceding city-looking token as the city. Falls back to the full string.
* Handles BOTH common geocoder orders (TODO BE-2 structured address):
* OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …"
* DaData: "г Екатеринбург, ул Яскина, д 14/3"
*/
function parseAddress(full: string | null): { address: string; city: string } {
if (!full || !full.trim()) return { address: "—", city: "" };
const parts = full
.split(",")
.map((s) => s.trim())
.filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !REGION_RE.test(p));
.filter((p) => p.length > 0 && !/^\d{6}$/.test(p) && !/^Россия$/i.test(p));
if (parts.length === 0) return { address: full.trim(), city: "" };
let streetStart = parts.findIndex((p) => STREET_RE.test(p));
let cityName = "";
if (streetStart > 0) {
for (let i = streetStart - 1; i >= 0; i--) {
if (CITY_PREFIX_RE.test(parts[i]) || i === 0) {
cityName = parts[i].replace(CITY_PREFIX_RE, "").trim();
break;
}
}
} else if (streetStart === -1) {
// No street token recognised — assume "City, rest…".
cityName = (parts[0] ?? "").replace(CITY_PREFIX_RE, "").trim();
streetStart = 1;
// City: prefer an explicit г./город token; else the token right before the
// first administrative anchor (городской округ / область / край).
let city = "";
const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean);
if (tokenMatch) {
city = tokenMatch[1].trim();
} else {
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
}
const address = parts.slice(streetStart).join(", ") || full.trim();
const city = cityName ? `${cityName}, Россия` : "";
// Address: street token + adjacent house number (house precedes the street in
// OSM order, follows it in DaData order). Fall back to the first part.
const houseClean = (p: string) =>
p.replace(/^(?:д|дом|зд|здание)\.?\s+/i, "").trim();
const streetIdx = parts.findIndex((p) => STREET_RE.test(p));
let address: string;
if (streetIdx >= 0) {
const street = parts[streetIdx];
const house = [parts[streetIdx - 1], parts[streetIdx + 1]]
.filter((p): p is string => p != null)
.map(houseClean)
.find((p) => HOUSE_RE.test(p));
address = house ? `${street}, ${house}` : street;
} else {
address = parts[0];
}
return { address, city };
}
@ -496,13 +516,31 @@ function buildScatterMini(
e: AggregatedEstimate,
sd?: StreetDealsResponse | null,
): ScatterMini {
const withDom = (lots: AnalogLot[]) =>
// Exposure (x-axis). days_on_market is sparsely populated in prod, so for
// ACTIVE analogs fall back to "days listed so far" = today listing_date
// (a valid lower bound on time-to-sell). Deals keep requiring a real
// days_on_market — todaydeal-date is not exposure. TODO BE-1: populate days_on_market.
const daysListed = (l: AnalogLot): number | null => {
if (l.listing_date == null) return null;
const t = new Date(l.listing_date).getTime();
if (Number.isNaN(t)) return null;
const days = Math.round((Date.now() - t) / 86_400_000);
return days >= 0 && days < 3650 ? days : null;
};
const realDom = (l: AnalogLot): number | null =>
l.days_on_market != null && Number.isFinite(l.days_on_market)
? l.days_on_market
: null;
const toPts = (lots: AnalogLot[], dom: (l: AnalogLot) => number | null) =>
lots
.filter((l) => l.days_on_market != null && Number.isFinite(l.price_rub))
.map((l) => ({ x: l.days_on_market as number, y: l.price_rub }));
.map((l) => ({ x: dom(l), y: l.price_rub }))
.filter(
(p): p is { x: number; y: number } =>
p.x != null && Number.isFinite(p.y),
);
const dealsRaw = withDom(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals);
const analogsRaw = withDom(e.analogs);
const dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom);
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
const subjectDays = e.est_days_on_market;