fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date (#2038)
QA on real prod estimate 701795d8 surfaced two mapper defects:
- STREET_RE used JS \b which does NOT word-boundary Cyrillic → street token never
matched → object.address fell back to the bare house number ('14/3') and city
mis-parsed ('14/3, Россия'). Rewrote parseAddress to handle both OSM (house-first)
and DaData (city-first) orders; street keyword delimited by space, not \b. Now
'улица Яскина, 14/3' + city 'Екатеринбург'.
- days_on_market is null across prod lots → scatter-mini rendered empty. Active
analogs now fall back to 'days listed so far' = today-listing_date (deals still
require a real days_on_market — today-deal-date is not exposure). TODO BE-1.
Verified via tsx harness against the real estimate (25/25); next build green.
This commit is contained in:
parent
e2ff8958ca
commit
ad5c1913cb
1 changed files with 64 additions and 26 deletions
|
|
@ -327,42 +327,62 @@ function rangeLine(
|
||||||
|
|
||||||
// ── Address parsing (TODO BE-2) ─────────────────────────────────────────────
|
// ── 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 =
|
const STREET_RE =
|
||||||
/\b(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)\b/i;
|
/(^|\s)(ул|улица|пр|пр-?кт|проспект|пер|переулок|б-?р|бул|бульвар|ш|шоссе|наб|набережная|пл|площадь|проезд|тракт|мкр|микрорайон)(\s|\.|$)/i;
|
||||||
const REGION_RE =
|
|
||||||
/(обл|область|край|респ|республика|р-н|район|округ|\bАО\b)/i;
|
|
||||||
const CITY_PREFIX_RE = /^(г|город|пгт|с|село|д|деревня|рп)\.?\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}.
|
* Best-effort split of a single address string into {street+house, city}.
|
||||||
* Drops postal index + region parts, finds the street token and treats the
|
* Handles BOTH common geocoder orders (TODO BE-2 — structured address):
|
||||||
* preceding city-looking token as the city. Falls back to the full string.
|
* OSM/Nominatim: "14/3, улица Яскина, …, Екатеринбург, городской округ …, область, …"
|
||||||
|
* DaData: "г Екатеринбург, ул Яскина, д 14/3"
|
||||||
*/
|
*/
|
||||||
function parseAddress(full: string | null): { address: string; city: string } {
|
function parseAddress(full: string | null): { address: string; city: string } {
|
||||||
if (!full || !full.trim()) return { address: "—", city: "" };
|
if (!full || !full.trim()) return { address: "—", city: "" };
|
||||||
const parts = full
|
const parts = full
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.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: "" };
|
if (parts.length === 0) return { address: full.trim(), city: "" };
|
||||||
|
|
||||||
let streetStart = parts.findIndex((p) => STREET_RE.test(p));
|
// City: prefer an explicit г./город token; else the token right before the
|
||||||
let cityName = "";
|
// first administrative anchor (городской округ / область / край).
|
||||||
if (streetStart > 0) {
|
let city = "";
|
||||||
for (let i = streetStart - 1; i >= 0; i--) {
|
const tokenMatch = parts.map((p) => p.match(CITY_TOKEN_RE)).find(Boolean);
|
||||||
if (CITY_PREFIX_RE.test(parts[i]) || i === 0) {
|
if (tokenMatch) {
|
||||||
cityName = parts[i].replace(CITY_PREFIX_RE, "").trim();
|
city = tokenMatch[1].trim();
|
||||||
break;
|
} else {
|
||||||
}
|
const anchorIdx = parts.findIndex((p) => ADMIN_ANCHOR_RE.test(p));
|
||||||
}
|
if (anchorIdx > 0) city = parts[anchorIdx - 1].replace(CITY_PREFIX_RE, "").trim();
|
||||||
} else if (streetStart === -1) {
|
|
||||||
// No street token recognised — assume "City, rest…".
|
|
||||||
cityName = (parts[0] ?? "").replace(CITY_PREFIX_RE, "").trim();
|
|
||||||
streetStart = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const address = parts.slice(streetStart).join(", ") || full.trim();
|
// Address: street token + adjacent house number (house precedes the street in
|
||||||
const city = cityName ? `${cityName}, Россия` : "";
|
// 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 };
|
return { address, city };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,13 +516,31 @@ function buildScatterMini(
|
||||||
e: AggregatedEstimate,
|
e: AggregatedEstimate,
|
||||||
sd?: StreetDealsResponse | null,
|
sd?: StreetDealsResponse | null,
|
||||||
): ScatterMini {
|
): 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 — today−deal-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
|
lots
|
||||||
.filter((l) => l.days_on_market != null && Number.isFinite(l.price_rub))
|
.map((l) => ({ x: dom(l), y: l.price_rub }))
|
||||||
.map((l) => ({ x: l.days_on_market as number, 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 dealsRaw = toPts(sd && sd.deals.length > 0 ? sd.deals : e.actual_deals, realDom);
|
||||||
const analogsRaw = withDom(e.analogs);
|
const analogsRaw = toPts(e.analogs, (l) => realDom(l) ?? daysListed(l));
|
||||||
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
|
const subjectPrice = e.expected_sold_price_rub ?? e.median_price_rub;
|
||||||
const subjectDays = e.est_days_on_market;
|
const subjectDays = e.est_days_on_market;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue