Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.42% |
187 / 190 |
|
50.00% |
2 / 4 |
CRAP | |
0.00% |
0 / 1 |
| XmlReaderVrSyncParser | |
98.42% |
187 / 190 |
|
50.00% |
2 / 4 |
84 | |
0.00% |
0 / 1 |
| parse | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
12 | |||
| rejectUnsafeProlog | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| parseListing | |
98.59% |
140 / 142 |
|
0.00% |
0 / 1 |
65 | |||
| localName | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace ImovelZapAi\Importing\Application\Services; |
| 6 | |
| 7 | use ImovelZapAi\Importing\Application\Data\ParsedMedia; |
| 8 | use ImovelZapAi\Importing\Application\Data\ParsedProperty; |
| 9 | use ImovelZapAi\Importing\Application\Data\ParseIssue; |
| 10 | use ImovelZapAi\Importing\Application\Data\ParseResult; |
| 11 | use ImovelZapAi\Importing\Application\Ports\VrSyncParserInterface; |
| 12 | use ImovelZapAi\Importing\Domain\Enums\ImportIssueSeverity; |
| 13 | use ImovelZapAi\Importing\Domain\Exceptions\ImportParseException; |
| 14 | use XMLReader; |
| 15 | |
| 16 | final class XmlReaderVrSyncParser implements VrSyncParserInterface |
| 17 | { |
| 18 | public function parse(string $absolutePath): ParseResult |
| 19 | { |
| 20 | if (! is_file($absolutePath) || ! is_readable($absolutePath)) { |
| 21 | throw ImportParseException::malformed('arquivo inacessível'); |
| 22 | } |
| 23 | |
| 24 | $this->rejectUnsafeProlog($absolutePath); |
| 25 | |
| 26 | $maxListings = (int) config('importing.parser.max_listings', 5000); |
| 27 | $maxMedia = (int) config('importing.parser.max_media_per_listing', 50); |
| 28 | $maxText = (int) config('importing.parser.max_text_length', 20000); |
| 29 | $maxErrors = (int) config('importing.parser.max_errors', 500); |
| 30 | |
| 31 | $reader = new XMLReader; |
| 32 | $opened = @$reader->open($absolutePath, 'UTF-8', LIBXML_NONET | LIBXML_COMPACT | LIBXML_NOERROR | LIBXML_NOWARNING); |
| 33 | |
| 34 | // @codeCoverageIgnoreStart |
| 35 | if ($opened !== true) { |
| 36 | throw ImportParseException::malformed('não foi possível abrir o XML'); |
| 37 | } |
| 38 | // @codeCoverageIgnoreEnd |
| 39 | |
| 40 | $properties = []; |
| 41 | $issues = []; |
| 42 | $index = 0; |
| 43 | $bytesRead = (int) filesize($absolutePath); |
| 44 | |
| 45 | try { |
| 46 | while ($reader->read()) { |
| 47 | if ($reader->nodeType === XMLReader::ELEMENT && $this->localName($reader) === 'Listing') { |
| 48 | if ($index >= $maxListings) { |
| 49 | throw ImportParseException::limitExceeded("max_listings={$maxListings}"); |
| 50 | } |
| 51 | |
| 52 | $listingXml = $reader->readOuterXml(); |
| 53 | $parsed = $this->parseListing($listingXml, $index, $maxMedia, $maxText, $issues); |
| 54 | |
| 55 | if ($parsed !== null) { |
| 56 | $properties[] = $parsed; |
| 57 | } |
| 58 | |
| 59 | $index++; |
| 60 | |
| 61 | if (count($issues) >= $maxErrors) { |
| 62 | $issues[] = new ParseIssue( |
| 63 | ImportIssueSeverity::Error, |
| 64 | 'too_many_issues', |
| 65 | "Limite de erros de parse atingido ({$maxErrors}).", |
| 66 | ); |
| 67 | break; |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | } finally { |
| 72 | $reader->close(); |
| 73 | } |
| 74 | |
| 75 | if ($properties === [] && $issues === []) { |
| 76 | $issues[] = new ParseIssue( |
| 77 | ImportIssueSeverity::Error, |
| 78 | 'no_listings', |
| 79 | 'Nenhum Listing encontrado no XML VRSync.', |
| 80 | ); |
| 81 | } |
| 82 | |
| 83 | return new ParseResult($properties, $issues, $bytesRead); |
| 84 | } |
| 85 | |
| 86 | private function rejectUnsafeProlog(string $absolutePath): void |
| 87 | { |
| 88 | $handle = fopen($absolutePath, 'rb'); |
| 89 | // @codeCoverageIgnoreStart |
| 90 | if ($handle === false) { |
| 91 | throw ImportParseException::malformed('arquivo inacessível'); |
| 92 | } |
| 93 | // @codeCoverageIgnoreEnd |
| 94 | |
| 95 | try { |
| 96 | $head = (string) fread($handle, 8192); |
| 97 | } finally { |
| 98 | fclose($handle); |
| 99 | } |
| 100 | |
| 101 | if (preg_match('/<!DOCTYPE/i', $head) === 1) { |
| 102 | throw ImportParseException::unsafeXml('DOCTYPE não permitido'); |
| 103 | } |
| 104 | |
| 105 | if (preg_match('/<!ENTITY/i', $head) === 1) { |
| 106 | throw ImportParseException::unsafeXml('ENTITY não permitido'); |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * @param list<ParseIssue> $issues |
| 112 | */ |
| 113 | private function parseListing( |
| 114 | string $listingXml, |
| 115 | int $index, |
| 116 | int $maxMedia, |
| 117 | int $maxText, |
| 118 | array &$issues, |
| 119 | ): ?ParsedProperty { |
| 120 | $previous = libxml_use_internal_errors(true); |
| 121 | $document = simplexml_load_string($listingXml, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT); |
| 122 | libxml_clear_errors(); |
| 123 | libxml_use_internal_errors($previous); |
| 124 | |
| 125 | // @codeCoverageIgnoreStart |
| 126 | if ($document === false) { |
| 127 | $issues[] = new ParseIssue( |
| 128 | ImportIssueSeverity::Error, |
| 129 | 'malformed_listing', |
| 130 | 'Listing XML inválido.', |
| 131 | listingIndex: $index, |
| 132 | path: 'Listing', |
| 133 | ); |
| 134 | |
| 135 | return null; |
| 136 | } |
| 137 | // @codeCoverageIgnoreEnd |
| 138 | |
| 139 | $raw = []; |
| 140 | $text = static function (string $name) use ($document, &$raw, $maxText): ?string { |
| 141 | $aliases = match ($name) { |
| 142 | 'DetailViewUrl' => ['DetailViewUrl', 'DetailUrl'], |
| 143 | default => [$name], |
| 144 | }; |
| 145 | |
| 146 | foreach ($aliases as $alias) { |
| 147 | if (! isset($document->{$alias})) { |
| 148 | continue; |
| 149 | } |
| 150 | |
| 151 | $value = trim((string) $document->{$alias}); |
| 152 | if ($value === '') { |
| 153 | continue; |
| 154 | } |
| 155 | |
| 156 | if (mb_strlen($value) > $maxText) { |
| 157 | $value = mb_substr($value, 0, $maxText); |
| 158 | } |
| 159 | |
| 160 | $raw[$alias] = $value; |
| 161 | |
| 162 | return $value; |
| 163 | } |
| 164 | |
| 165 | return null; |
| 166 | }; |
| 167 | |
| 168 | $media = []; |
| 169 | if (isset($document->Media->Item)) { |
| 170 | $order = 0; |
| 171 | foreach ($document->Media->Item as $item) { |
| 172 | if ($order >= $maxMedia) { |
| 173 | $issues[] = new ParseIssue( |
| 174 | ImportIssueSeverity::Warning, |
| 175 | 'media_limit', |
| 176 | "Listing excedeu o limite de mídia ({$maxMedia}).", |
| 177 | externalId: $text('ListingID'), |
| 178 | listingIndex: $index, |
| 179 | path: 'Listing/Media', |
| 180 | ); |
| 181 | break; |
| 182 | } |
| 183 | |
| 184 | $url = trim((string) ($item['url'] ?? $item)); |
| 185 | if ($url === '') { |
| 186 | continue; |
| 187 | } |
| 188 | |
| 189 | $caption = isset($item['caption']) ? trim((string) $item['caption']) : null; |
| 190 | $primary = strtolower((string) ($item['primary'] ?? '')) === 'true'; |
| 191 | |
| 192 | $media[] = new ParsedMedia($url, $caption !== '' ? $caption : null, $order, $primary); |
| 193 | $order++; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | $externalId = $text('ListingID'); |
| 198 | $title = $text('Title'); |
| 199 | $transactionType = $text('TransactionType'); |
| 200 | $propertyType = $text('PropertyType'); |
| 201 | $publicationType = $text('PublicationType'); |
| 202 | $listPrice = $text('ListPrice'); |
| 203 | $rentalPrice = $text('RentalPrice'); |
| 204 | $description = $text('Description'); |
| 205 | $detailUrl = $text('DetailViewUrl'); |
| 206 | |
| 207 | $location = $document->Location ?? null; |
| 208 | $country = $location !== null ? trim((string) ($location->Country ?? '')) ?: null : null; |
| 209 | $state = $location !== null ? trim((string) ($location->State ?? '')) ?: null : null; |
| 210 | $city = $location !== null ? trim((string) ($location->City ?? '')) ?: null : null; |
| 211 | $neighborhood = null; |
| 212 | $address = null; |
| 213 | $streetNumber = null; |
| 214 | $complement = null; |
| 215 | $postalCode = null; |
| 216 | $latitude = null; |
| 217 | $longitude = null; |
| 218 | |
| 219 | if ($location !== null) { |
| 220 | foreach (['Neighborhood', 'Zone'] as $alias) { |
| 221 | if (isset($location->{$alias}) && trim((string) $location->{$alias}) !== '') { |
| 222 | $neighborhood = trim((string) $location->{$alias}); |
| 223 | $raw["Location/{$alias}"] = $neighborhood; |
| 224 | break; |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | $address = isset($location->Address) ? trim((string) $location->Address) ?: null : null; |
| 229 | if ($address !== null) { |
| 230 | $raw['Location/Address'] = $address; |
| 231 | } |
| 232 | |
| 233 | foreach (['StreetNumber', 'Number'] as $alias) { |
| 234 | if (isset($location->{$alias}) && trim((string) $location->{$alias}) !== '') { |
| 235 | $streetNumber = trim((string) $location->{$alias}); |
| 236 | $raw["Location/{$alias}"] = $streetNumber; |
| 237 | break; |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | $complement = isset($location->Complement) ? trim((string) $location->Complement) ?: null : null; |
| 242 | $postalCode = isset($location->PostalCode) ? trim((string) $location->PostalCode) ?: null : null; |
| 243 | $latitude = isset($location->Latitude) ? trim((string) $location->Latitude) ?: null : null; |
| 244 | $longitude = isset($location->Longitude) ? trim((string) $location->Longitude) ?: null : null; |
| 245 | |
| 246 | if ($country !== null) { |
| 247 | $raw['Location/Country'] = $country; |
| 248 | } |
| 249 | if ($state !== null) { |
| 250 | $raw['Location/State'] = $state; |
| 251 | } |
| 252 | if ($city !== null) { |
| 253 | $raw['Location/City'] = $city; |
| 254 | } |
| 255 | if ($complement !== null) { |
| 256 | $raw['Location/Complement'] = $complement; |
| 257 | } |
| 258 | if ($postalCode !== null) { |
| 259 | $raw['Location/PostalCode'] = $postalCode; |
| 260 | } |
| 261 | if ($latitude !== null) { |
| 262 | $raw['Location/Latitude'] = $latitude; |
| 263 | } |
| 264 | if ($longitude !== null) { |
| 265 | $raw['Location/Longitude'] = $longitude; |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | $details = $document->Details ?? null; |
| 270 | $bedrooms = $details !== null && isset($details->Bedrooms) ? trim((string) $details->Bedrooms) ?: null : null; |
| 271 | $bathrooms = $details !== null && isset($details->Bathrooms) ? trim((string) $details->Bathrooms) ?: null : null; |
| 272 | $suites = $details !== null && isset($details->Suites) ? trim((string) $details->Suites) ?: null : null; |
| 273 | $garage = $details !== null && isset($details->Garage) ? trim((string) $details->Garage) ?: null : null; |
| 274 | $livingArea = $details !== null && isset($details->LivingArea) ? trim((string) $details->LivingArea) ?: null : null; |
| 275 | $lotArea = $details !== null && isset($details->LotArea) ? trim((string) $details->LotArea) ?: null : null; |
| 276 | |
| 277 | foreach ([ |
| 278 | 'Bedrooms' => $bedrooms, |
| 279 | 'Bathrooms' => $bathrooms, |
| 280 | 'Suites' => $suites, |
| 281 | 'Garage' => $garage, |
| 282 | 'LivingArea' => $livingArea, |
| 283 | 'LotArea' => $lotArea, |
| 284 | ] as $key => $value) { |
| 285 | if ($value !== null) { |
| 286 | $raw["Details/{$key}"] = $value; |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | return new ParsedProperty( |
| 291 | index: $index, |
| 292 | externalId: $externalId, |
| 293 | title: $title, |
| 294 | transactionType: $transactionType, |
| 295 | propertyType: $propertyType, |
| 296 | publicationType: $publicationType, |
| 297 | listPrice: $listPrice, |
| 298 | rentalPrice: $rentalPrice, |
| 299 | description: $description, |
| 300 | detailUrl: $detailUrl, |
| 301 | country: $country, |
| 302 | state: $state, |
| 303 | city: $city, |
| 304 | neighborhood: $neighborhood, |
| 305 | address: $address, |
| 306 | streetNumber: $streetNumber, |
| 307 | complement: $complement, |
| 308 | postalCode: $postalCode, |
| 309 | latitude: $latitude, |
| 310 | longitude: $longitude, |
| 311 | bedrooms: $bedrooms, |
| 312 | bathrooms: $bathrooms, |
| 313 | suites: $suites, |
| 314 | garage: $garage, |
| 315 | livingArea: $livingArea, |
| 316 | lotArea: $lotArea, |
| 317 | media: $media, |
| 318 | raw: $raw, |
| 319 | ); |
| 320 | } |
| 321 | |
| 322 | private function localName(XMLReader $reader): string |
| 323 | { |
| 324 | $name = $reader->localName !== '' ? $reader->localName : $reader->name; |
| 325 | |
| 326 | if (str_contains($name, ':')) { |
| 327 | return substr($name, (int) strrpos($name, ':') + 1); |
| 328 | } |
| 329 | |
| 330 | return $name; |
| 331 | } |
| 332 | } |