PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / trunk
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings vtrunk
7.2.2 7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 All 37 releases
mlsimport / includes / mlsimport-dedupe-address.php

mlsimport-dedupe-address.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings trunk, at includes/mlsimport-dedupe-address.php

144 lines 5.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Dedupe address normalization (issue #282, decision #267).
4 *
5 * WHY THIS FILE EXISTS
6 * --------------------
7 * The #267 policy anchor says two connections carry the SAME physical
8 * property when their normalized addresses (street + unit + city/postal)
9 * match. This file owns that normalization: pure string functions with no
10 * WordPress dependency, so the matching rules are unit-testable in isolation
11 * and every caller (the dedupe evaluator in mlsimport-dedupe.php) builds the
12 * key the same single way. The key is stamped on every imported listing as
13 * the 'mlsimport_address_key' post meta.
14 *
15 * @since 7.2.0
16 * @package Mlsimport
17 */
18
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Build the normalized address key for one incoming MLS property payload.
25 *
26 * Step by step:
27 * 1. Street line: the SaaS-computed 'adr_title' (always present, the same
28 * value the title builder uses); fallback to raw RESO UnparsedAddress cut
29 * at its first comma (the remainder is city/state/zip noise).
30 * 2. Locality: the SaaS-computed 'adr_city' (fallback raw City), and when no
31 * city exists at all, the raw PostalCode — the #267 "city/postal" anchor.
32 * 3. Normalize the street: lowercase, punctuation to spaces, collapse
33 * whitespace, canonicalize directionals (North => n) and street suffixes
34 * (Street => st, Boulevard => blvd, ...) so different feeds' spellings of
35 * one street compare equal.
36 * 4. Unit: an explicit RESO UnitNumber wins; otherwise a trailing
37 * "apt/unit/suite/# X" embedded in the street line is extracted, so
38 * "123 Main St Apt 4B" matches "123 Main St" + UnitNumber "4B".
39 * 5. A property without a street or without any locality returns '' — no
40 * key means it never participates in dedupe (matching would be a guess).
41 *
42 * @param array<string, mixed> $property Raw property payload from the SaaS.
43 * @return string "street|unit|locality" key, or '' when unmatchable.
44 */
45 function mlsimport_dedupe_address_key( array $property ): string {
46 $extra = is_array( $property['extra_meta'] ?? null ) ? $property['extra_meta'] : array();
47
48 // Step 1: resolve the street line.
49 $street = (string) ( $property['adr_title'] ?? '' );
50 if ( '' === trim( $street ) ) {
51 $unparsed = (string) ( $extra['UnparsedAddress'] ?? '' );
52 $comma = strpos( $unparsed, ',' );
53 $street = false === $comma ? $unparsed : substr( $unparsed, 0, $comma );
54 }
55
56 // Step 2: resolve the locality anchor (city first, postal as fallback).
57 $locality = mlsimport_dedupe_squash( (string) ( $property['adr_city'] ?? ( $extra['City'] ?? '' ) ) );
58 if ( '' === $locality ) {
59 $locality = mlsimport_dedupe_squash( (string) ( $extra['PostalCode'] ?? '' ) );
60 }
61
62 // Steps 3+4: canonical street tokens, extracting any embedded unit.
63 $unit = '';
64 $tokens = array();
65 $raw_tokens = explode( ' ', mlsimport_dedupe_squash( $street ) );
66 foreach ( $raw_tokens as $index => $token ) {
67 // A unit marker ends the street: everything after it is the unit.
68 // The leading token is never a marker ("Unit 5 Road" stays a street).
69 if ( $index > 0 && in_array( $token, array( 'apt', 'apartment', 'unit', 'ste', 'suite' ), true ) ) {
70 $unit = implode( ' ', array_slice( $raw_tokens, $index + 1 ) );
71 break;
72 }
73 $tokens[] = mlsimport_dedupe_canonical_word( $token );
74 }
75
76 // An explicit RESO unit field is authoritative over the extracted one.
77 $explicit_unit = mlsimport_dedupe_squash( (string) ( $extra['UnitNumber'] ?? '' ) );
78 if ( '' !== $explicit_unit ) {
79 $unit = $explicit_unit;
80 }
81
82 // Step 5: refuse to build a guessable key.
83 $street_key = trim( implode( ' ', $tokens ) );
84 if ( '' === $street_key || '' === $locality ) {
85 return '';
86 }
87
88 return $street_key . '|' . $unit . '|' . $locality;
89 }
90
91 /**
92 * Lowercase one address part, turn punctuation into spaces, collapse runs.
93 *
94 * '#' becomes the word 'unit' BEFORE punctuation stripping so "# 4B" and
95 * "Apt 4B" normalize identically instead of the marker silently vanishing.
96 *
97 * @param string $part Raw address part.
98 * @return string Normalized single-spaced lowercase text.
99 */
100 function mlsimport_dedupe_squash( string $part ): string {
101 $part = str_replace( '#', ' unit ', strtolower( $part ) );
102 $part = (string) preg_replace( '/[^a-z0-9]+/', ' ', $part );
103 return trim( (string) preg_replace( '/ +/', ' ', $part ) );
104 }
105
106 /**
107 * Map one street token to its canonical short form.
108 *
109 * Only directionals and the common USPS street suffixes are mapped — enough
110 * to make two RESO feeds' spellings of one street compare equal without
111 * guessing at genuinely different names.
112 *
113 * @param string $token Lowercased street token.
114 * @return string Canonical token (unchanged when unmapped).
115 */
116 function mlsimport_dedupe_canonical_word( string $token ): string {
117 static $map = array(
118 'street' => 'st',
119 'avenue' => 'ave',
120 'av' => 'ave',
121 'drive' => 'dr',
122 'road' => 'rd',
123 'boulevard' => 'blvd',
124 'lane' => 'ln',
125 'court' => 'ct',
126 'place' => 'pl',
127 'terrace' => 'ter',
128 'terr' => 'ter',
129 'circle' => 'cir',
130 'highway' => 'hwy',
131 'parkway' => 'pkwy',
132 'square' => 'sq',
133 'north' => 'n',
134 'south' => 's',
135 'east' => 'e',
136 'west' => 'w',
137 'northeast' => 'ne',
138 'northwest' => 'nw',
139 'southeast' => 'se',
140 'southwest' => 'sw',
141 );
142 return $map[ $token ] ?? $token;
143 }
144