Files
addrkit/internal/domain/address/parse.go
T
Maksim Totmin 7b152c7fb3 addrkit: deterministic address resolution service (Go)
- domain: address normalization/parsing, matching with explicit statuses
- adapters: SQLite registry store, ArcGIS outages client, optional LLM rewriter, HTTP API
- seed pipeline with duplicate handling and unparsed reporting
- CLI (seed/verify/resolve), systemd unit, OpenAPI contract, tests
2026-10-09 21:40:46 +07:00

144 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}$`)
)
// Parse decomposes a free-form address into its structured components. It is
// total: unparseable input still yields a non-nil ParsedAddress whose street
// part is best-effort and whose HasHouse is false.
func Parse(raw string) ParsedAddress {
normalized := NormalizeText(raw)
p := ParsedAddress{Raw: raw, Normalized: normalized}
rest := normalized
if g, start, ok := matchSuffix(reStructure, rest); ok {
p.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] != "" {
p.Building = trimLeadingZeros(g[1])
p.BuildingLetter = firstNonEmpty(g[2], g[4])
p.BuildingFrac = trimLeadingZeros(g[3])
rest = strings.TrimSpace(rest[:start])
}
}
if g, start, ok := matchSuffix(reHouse, rest); ok {
p.House = trimLeadingZeros(g[1])
p.HouseLetter = firstNonEmpty(g[2], g[4])
p.HouseFrac = trimLeadingZeros(g[3])
p.HasHouse = true
rest = strings.TrimSpace(rest[:start])
}
parseStreet(&p, rest)
return p
}
// 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 p from the leftover string
// (the address with its house block removed).
func parseStreet(p *ParsedAddress, s string) {
tokens := strings.Fields(s)
if len(tokens) == 0 {
p.StreetKey = streetKey("", "")
return
}
name := make([]string, 0, len(tokens))
typeFound := false
for _, tok := range tokens {
// Only the first recognised type token is treated as the type; a
// second occurrence is part of the name.
if !typeFound {
if t, ok := streetTypes[tok]; ok {
p.StreetType = t
typeFound = true
continue
}
}
name = append(name, tok)
}
name = extractOrdinal(p, name)
p.NameDisplay = strings.Join(name, " ")
sorted := append([]string(nil), name...)
sort.Strings(sorted)
p.NameKey = strings.Join(sorted, " ")
p.StreetKey = streetKey(p.Ordinal, p.NameKey)
}
// extractOrdinal recognises a leading ordinal ("2-я", "2 я", "вторая") and
// removes it from the name tokens.
func extractOrdinal(p *ParsedAddress, tokens []string) []string {
if len(tokens) == 0 {
return tokens
}
if m := reOrdinalToken.FindStringSubmatch(tokens[0]); m != nil {
p.Ordinal = normalizeOrdinal(m[1])
return tokens[1:]
}
if isDigits(tokens[0]) && len(tokens) >= 2 && ordinalSuffixes[tokens[1]] {
p.Ordinal = normalizeOrdinal(tokens[0])
return tokens[2:]
}
if d, ok := ordinalWords[tokens[0]]; ok {
p.Ordinal = normalizeOrdinal(d)
return tokens[1:]
}
return tokens
}
// 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
}