package address import ( "regexp" "sort" "strings" ) // The house block is parsed from the end of the string, component by // component, from the outermost (structure) to the innermost (house). Working // right-to-left is what lets us tell a genuine house number apart from a digit // that is part of the street name (e.g. "2-я Станционная улица 1/1 стр."). var ( // "... стр." / "... строение 3" / "... сооружение" / "... с 2" reStructure = regexp.MustCompile(`(?:^|\s)(?:стр|строение|сооружение|с)\.?\s*(\d+)?$`) // "... к9/1" / "... корпус 2а" / "... кД" / "... к 1/4" reBuilding = regexp.MustCompile(`(?:^|\s)(?:к|корп|корпус)\.?\s*(\d*)\s*([а-я])?(?:/(\d+)\s*([а-я])?)?$`) // "... 1/1" / "... 33а" / "... 12 б" / "... 45а/4" / "... 1/1а" reHouse = regexp.MustCompile(`(?:^|\s)(\d+)\s*([а-я])?(?:/(\d+)\s*([а-я])?)?$`) // "2-я", "10-й", "3-е" reOrdinalToken = regexp.MustCompile(`^(\d+)-[а-я]{1,3}$`) // Trailing apartment part: " кв 5", " квартира 12", " кв. 7а". Removed // before house parsing so it cannot leak into the street name. reApartment = regexp.MustCompile(`(?:^|\s)(?:квартира|кв)\.?(?:\s+\d+[а-я]?)?\s*$`) // Spoken fractions: "18 дробь 1" is the same house unit as "18/1". reFractionWord = regexp.MustCompile(`\s+(?:дробь|дробью)\s+`) ) // Parser turns free-form address strings into structured addresses using a // deployment-specific Dictionary. It is immutable and safe for concurrent use. type Parser struct { dict *Dictionary defaultCityName string defaultCityKey string } // NewParser builds a parser. defaultCity is used when the query does not name a // city and the dictionary has no match; pass an empty string for deployments // where the city must be present in every query. func NewParser(dict *Dictionary, defaultCity string) *Parser { if dict == nil { dict = DefaultDictionary() } p := &Parser{} if name := strings.TrimSpace(defaultCity); name != "" { dict = dict.withDefaultCity(name) p.defaultCityName = name p.defaultCityKey = NormalizeText(name) } p.dict = dict return p } // defaultParser is used by the package-level Parse convenience function. It has // no cities and is therefore fully backward compatible: callers that need city // awareness construct their own Parser. var defaultParser = NewParser(DefaultDictionary(), "") // DefaultParser returns the shared city-less parser. func DefaultParser() *Parser { return defaultParser } // Parse decomposes a free-form address with the default (city-less) parser. func Parse(raw string) ParsedAddress { return defaultParser.Parse(raw) } // Parse decomposes a free-form address into its structured components. It is // total: unparseable input still yields a usable ParsedAddress whose street // part is best-effort and whose HasHouse is false. func (p *Parser) Parse(raw string) ParsedAddress { return p.parse(raw, "") } // ParseInCity parses raw and forces cityName as the result's city. It is used // by the seed pipeline, where the city is an explicit field of the source row // and must win over whatever the address text contains. func (p *Parser) ParseInCity(raw, cityName string) ParsedAddress { return p.parse(raw, cityName) } // MatchCity reports whether query is a city name/alias and returns its // canonical form. It does not guess a city from an arbitrary address. func (p *Parser) MatchCity(query string) (City, bool) { return p.dict.MatchCity(query) } func (p *Parser) parse(raw, forcedCity string) ParsedAddress { normalized := NormalizeText(raw) // A trailing apartment ("кв 5") is not part of the address identity. normalized = strings.TrimSpace(reApartment.ReplaceAllString(normalized, "")) // Collapse the spoken fraction marker to the canonical separator before // the right-to-left house parser runs: "18 дробь 1" -> "18/1". normalized = reFractionWord.ReplaceAllString(normalized, "/") parsed := ParsedAddress{Raw: raw, Normalized: normalized} rest := normalized if cityName, cityKey, restTokens, ok := p.dict.matchCity(strings.Fields(rest)); ok { parsed.City = cityName parsed.CityKey = cityKey rest = strings.Join(restTokens, " ") } if forcedCity = strings.TrimSpace(forcedCity); forcedCity != "" { parsed.City = forcedCity parsed.CityKey = NormalizeText(forcedCity) } else if parsed.City == "" && p.defaultCityName != "" { // A single-city deployment can omit the city in queries. In a // multi-city deployment default_city should stay empty: guessing a // city is exactly the kind of silent error this service avoids. parsed.City = p.defaultCityName parsed.CityKey = p.defaultCityKey } if g, start, ok := matchSuffix(reStructure, rest); ok { parsed.Structure = trimLeadingZeros(g[1]) rest = strings.TrimSpace(rest[:start]) } if g, start, ok := matchSuffix(reBuilding, rest); ok { // The regex also matches a bare "к"; only accept it when a digit or a // letter actually follows, otherwise it is not a corpus marker. if g[1] != "" || g[2] != "" { parsed.Building = trimLeadingZeros(g[1]) parsed.BuildingLetter = firstNonEmpty(g[2], g[4]) parsed.BuildingFrac = trimLeadingZeros(g[3]) rest = strings.TrimSpace(rest[:start]) } } if g, start, ok := matchSuffix(reHouse, rest); ok { parsed.House = trimLeadingZeros(g[1]) parsed.HouseLetter = firstNonEmpty(g[2], g[4]) parsed.HouseFrac = trimLeadingZeros(g[3]) parsed.HasHouse = true rest = strings.TrimSpace(rest[:start]) } p.parseStreet(&parsed, rest) return parsed } // firstNonEmpty returns the first non-empty argument. House/building letters // can appear either before or after the fraction ("45а/4" vs "1/1а"). func firstNonEmpty(values ...string) string { for _, v := range values { if v != "" { return v } } return "" } // parseStreet fills the street-related fields of out from the leftover string // (the address with its city and house block removed). // // All recognised type tokens are removed from the name. This matters for // registry rows such as "Гусинобродский Тракт улица 21", where both "Тракт" // and "улица" are type-like: keeping either would mismatch a citizen who simply // says "Гусинобродский 21". The first type token becomes the canonical type. // If removing them would leave the name empty (a street literally called // "Набережная"), the type token is kept as the name. func (p *Parser) parseStreet(out *ParsedAddress, s string) { tokens := strings.Fields(s) name := make([]string, 0, len(tokens)) streetType := "" for _, tok := range tokens { // Filler tokens ("г", "город", "дом", "область", ...) never belong to // the street name. if p.dict.isFiller(tok) { continue } if t, ok := p.dict.streetType(tok); ok { if streetType == "" { streetType = t } continue } name = append(name, tok) } out.StreetType = streetType if len(name) == 0 && streetType != "" { name = []string{streetType} } if len(name) == 0 { out.StreetKey = streetKey(out.CityKey, "", "") return } name = p.extractOrdinal(out, name) out.NameDisplay = strings.Join(name, " ") sorted := append([]string(nil), name...) sort.Strings(sorted) out.NameKey = strings.Join(sorted, " ") out.StreetKey = streetKey(out.CityKey, out.Ordinal, out.NameKey) } // extractOrdinal recognises an ordinal and removes it from the name tokens. // It handles both the usual leading form ("2-я Сухарная") and an ordinal that // appears after the name ("Сухарная 2-я улица"), which both people and models // produce. func (p *Parser) extractOrdinal(out *ParsedAddress, tokens []string) []string { if len(tokens) == 0 { return tokens } // Leading forms first: they are by far the most common. if m := reOrdinalToken.FindStringSubmatch(tokens[0]); m != nil { out.Ordinal = normalizeOrdinal(m[1]) return tokens[1:] } if isDigits(tokens[0]) && len(tokens) >= 2 && p.dict.isOrdinalSuffix(tokens[1]) { out.Ordinal = normalizeOrdinal(tokens[0]) return tokens[2:] } if d, ok := p.dict.ordinalWord(tokens[0]); ok { out.Ordinal = normalizeOrdinal(d) return tokens[1:] } // Otherwise look for an ordinal anywhere among the name tokens. for i, tok := range tokens { if m := reOrdinalToken.FindStringSubmatch(tok); m != nil { out.Ordinal = normalizeOrdinal(m[1]) return removeRange(tokens, i, i+1) } if isDigits(tok) && i+1 < len(tokens) && p.dict.isOrdinalSuffix(tokens[i+1]) { out.Ordinal = normalizeOrdinal(tok) return removeRange(tokens, i, i+2) } } return tokens } // removeRange returns a copy of tokens with [from, to) removed. func removeRange(tokens []string, from, to int) []string { out := make([]string, 0, len(tokens)-(to-from)) out = append(out, tokens[:from]...) out = append(out, tokens[to:]...) return out } // matchSuffix returns the capture groups of the match of re that ends exactly // at the end of s, together with the byte offset where it starts. Go's regexp // is leftmost-first, so we scan all matches and pick the right-anchored one. func matchSuffix(re *regexp.Regexp, s string) (groups []string, start int, ok bool) { for _, loc := range re.FindAllStringSubmatchIndex(s, -1) { if loc[1] != len(s) { continue } n := len(loc) / 2 groups = make([]string, n) for i := 0; i < n; i++ { if loc[2*i] >= 0 { groups[i] = s[loc[2*i]:loc[2*i+1]] } } return groups, loc[0], true } return nil, 0, false }