fix(tradein/v2): address parse (Cyrillic \b bug) + scatter from listing_date #2049
1 changed files with 64 additions and 26 deletions
|
|
@ -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 — 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
|
||||
.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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue