PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.0.4
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.0.4
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 6.0.5 All 36 releases
mlsimport / includes / live / live-source.php

live-source.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.0.4, at includes/live/live-source.php

268 lines 8.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Live mode: the read functions everything else calls.
4 *
5 * Only mlsimport_live_search() and mlsimport_live_get() talk HTTP to the
6 * MLS; both go through the cache. Query building and response parsing stay
7 * in the pure modules.
8 *
9 * @package Mlsimport
10 */
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14 }
15
16 /**
17 * Search listings directly on the MLS.
18 *
19 * @param array $params Standalone filter params (city, status, limit, page, …).
20 * @return array{records:array,total:int}|null Null when the MLS is unreachable
21 * and no warm cache exists.
22 */
23 function mlsimport_live_search( array $params ) {
24 $config = mlsimport_live_config();
25 if ( array() === $config ) {
26 return null;
27 }
28
29 // Quantized bbox (~110m outward grid) BEFORE the query is built: nearby
30 // map pans produce the identical query and reuse one cache entry.
31 $params = mlsimport_live_quantize_bbox( $params );
32
33 $query = mlsimport_live_build_query( $params, $config );
34 $key = mlsimport_live_cache_key(
35 'search',
36 array(
37 'q' => $query,
38 'u' => $config['api_import_url'],
39 'm' => (string) ( $config['api_media_url'] ?? '' ),
40 )
41 );
42
43 return mlsimport_live_remember(
44 $key,
45 static function () use ( $query ) {
46 return mlsimport_live_request( $query );
47 }
48 );
49 }
50
51 /**
52 * Fetch one listing by ListingKey.
53 *
54 * @param string $listing_key RESO ListingKey (string — never cast to int).
55 * @return array|null The raw RESO record, or null when not found/unreachable.
56 */
57 function mlsimport_live_get( string $listing_key ) {
58 $listing_key = trim( $listing_key );
59 if ( '' === $listing_key ) {
60 return null;
61 }
62
63 $config = mlsimport_live_config();
64 if ( array() === $config ) {
65 return null;
66 }
67
68 if ( 'bridge' === ( $config['type'] ?? '' ) ) {
69 $query = '?ListingKey=' . rawurlencode( $listing_key );
70 } else {
71 $query = '?';
72 if ( ! empty( $config['expand'] ) ) {
73 $query .= '&$expand=' . $config['expand'];
74 }
75 $query .= '&$top=1&$filter=' . mlsimport_live_filter_list_segment(
76 mlsimport_live_field_alias( 'ListingKey', $config ),
77 array( $listing_key )
78 );
79 $query = preg_replace( '/ and $/', '', $query );
80 }
81
82 // 'q' keys the config shape too (dialect, $expand): a provider-type or
83 // expand change refetches instead of serving the stale-shaped record.
84 $key = mlsimport_live_cache_key(
85 'get',
86 array(
87 'k' => $listing_key,
88 'q' => $query,
89 'u' => $config['api_import_url'],
90 'm' => (string) ( $config['api_media_url'] ?? '' ),
91 )
92 );
93 $result = mlsimport_live_remember(
94 $key,
95 static function () use ( $query ) {
96 return mlsimport_live_request( $query );
97 }
98 );
99
100 return is_array( $result ) && ! empty( $result['records'][0] ) ? $result['records'][0] : null;
101 }
102
103 /**
104 * Fetch an explicit set of listings by ListingKey, in the given order.
105 *
106 * One search request (ListingKey list, no status baseline), reordered to the
107 * input order — keys the MLS no longer knows are simply absent.
108 *
109 * @param string[] $keys ListingKeys (strings — never cast to int).
110 * @return array[]|null Raw RESO records in input order, or null when the MLS
111 * is unreachable and no warm cache exists.
112 */
113 function mlsimport_live_keys( array $keys ) {
114 $keys = array_values( array_filter( array_map( 'trim', array_map( 'strval', $keys ) ), 'strlen' ) );
115 if ( array() === $keys ) {
116 return array();
117 }
118
119 $result = mlsimport_live_search(
120 array(
121 'keys' => $keys,
122 'limit' => count( $keys ),
123 )
124 );
125 if ( ! is_array( $result ) ) {
126 return null;
127 }
128
129 $by_key = array();
130 foreach ( $result['records'] as $record ) {
131 if ( is_array( $record ) && isset( $record['ListingKey'] ) ) {
132 $by_key[ (string) $record['ListingKey'] ] = $record;
133 }
134 }
135
136 $ordered = array();
137 foreach ( $keys as $key ) {
138 if ( isset( $by_key[ $key ] ) ) {
139 $ordered[] = $by_key[ $key ];
140 }
141 }
142 return $ordered;
143 }
144
145 /**
146 * The endpoint live requests hit. For Bridge, the configured OData URL is
147 * rewritten to the native listings API (…/OData/{ds}/Property →
148 * …/{ds}/listings): the native surface returns Media inline where OData
149 * hides it for client tokens (verified vs Stellar 2026-07-03).
150 *
151 * @param array $config Per-MLS config.
152 * @return string
153 */
154 function mlsimport_live_endpoint( array $config ): string {
155 $base = isset( $config['api_import_url'] ) ? (string) $config['api_import_url'] : '';
156 if ( 'bridge' === ( $config['type'] ?? '' ) ) {
157 $native = preg_replace( '#/OData/([^/?]+)/Property/?#i', '/$1/listings', $base );
158 if ( is_string( $native ) && '' !== $native ) {
159 return $native;
160 }
161 }
162 return $base;
163 }
164
165 /**
166 * One direct HTTP GET against the MLS import URL.
167 *
168 * @param string $query OData query string beginning with '?'.
169 * @return array{records:array,total:int}|null Null on transport/auth/parse failure.
170 */
171 function mlsimport_live_request( string $query ) {
172 $config = mlsimport_live_config();
173 $headers = mlsimport_live_auth_headers();
174 if ( array() === $config || array() === $headers ) {
175 return null;
176 }
177
178 $base = mlsimport_live_endpoint( $config );
179 // The base URL may already carry a query part; keep exactly one '?'.
180 $url = false === strpos( $base, '?' )
181 ? $base . $query
182 : $base . '&' . ltrim( $query, '?&' );
183
184 // OData filters carry spaces and quotes; encode the ones URLs can't.
185 $url = str_replace( array( ' ', "'" ), array( '%20', '%27' ), $url );
186
187 $response = wp_remote_get(
188 $url,
189 array(
190 'timeout' => 20,
191 'headers' => $headers,
192 )
193 );
194
195 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
196 return null;
197 }
198
199 $result = mlsimport_live_parse_response( (string) wp_remote_retrieve_body( $response ), $config );
200
201 // BrightMLS media lives on a separate endpoint; attach it here, inside the
202 // cached request path, so the cache stores records WITH their media.
203 if ( is_array( $result ) && 'brightmls' === ( $config['type'] ?? '' ) && array() !== $result['records'] ) {
204 $result['records'] = mlsimport_live_attach_brightmls_media( $result['records'], $config, $headers );
205 }
206
207 return $result;
208 }
209
210 /**
211 * Attach BrightMedia rows as standard Media[] to records that lack media,
212 * fetched from the separate media endpoint in chunks of 100 keys (the same
213 * request the AWS media fetch runs). A failed media chunk degrades to
214 * photo-less cards, never a failed search.
215 *
216 * @param array $records Raw RESO records from the listings response.
217 * @param array $config Per-MLS config (api_media_url).
218 * @param array $headers Auth headers of the listings request.
219 * @return array The records, media attached where the endpoint had it.
220 */
221 function mlsimport_live_attach_brightmls_media( array $records, array $config, array $headers ): array {
222 $keys = array();
223 foreach ( $records as $record ) {
224 if ( is_array( $record ) && empty( $record['Media'] ) && isset( $record['ListingKey'] ) ) {
225 $keys[] = (string) $record['ListingKey'];
226 }
227 }
228 if ( array() === $keys ) {
229 return $records;
230 }
231
232 $base = isset( $config['api_media_url'] ) ? trim( (string) $config['api_media_url'] ) : '';
233 if ( '' === $base ) {
234 // The fixed BrightMedia endpoint — the AWS media fetch's default.
235 $base = 'https://bright-reso.brightmls.com/RESO/OData/bright/BrightMedia';
236 }
237 // BrightMLS's OData server rejects a '/' immediately before '?'.
238 $base = rtrim( $base, '/' );
239
240 $map = array();
241 foreach ( array_chunk( $keys, 100 ) as $chunk ) {
242 $url = $base . mlsimport_live_brightmls_media_query( $chunk );
243 $url = str_replace( array( ' ', "'" ), array( '%20', '%27' ), $url );
244 $response = wp_remote_get(
245 $url,
246 array(
247 'timeout' => 20,
248 'headers' => $headers,
249 )
250 );
251 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
252 continue;
253 }
254 $data = json_decode( (string) wp_remote_retrieve_body( $response ), true );
255 if ( is_array( $data ) && ! empty( $data['value'] ) && is_array( $data['value'] ) ) {
256 $map += mlsimport_live_brightmls_media_map( $data['value'] );
257 }
258 }
259
260 foreach ( $records as $i => $record ) {
261 $key = is_array( $record ) && isset( $record['ListingKey'] ) ? (string) $record['ListingKey'] : '';
262 if ( '' !== $key && isset( $map[ $key ] ) ) {
263 $records[ $i ]['Media'] = $map[ $key ];
264 }
265 }
266 return $records;
267 }
268