Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.42% covered (success)
98.42%
187 / 190
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
XmlReaderVrSyncParser
98.42% covered (success)
98.42%
187 / 190
50.00% covered (danger)
50.00%
2 / 4
84
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
12
 rejectUnsafeProlog
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 parseListing
98.59% covered (success)
98.59%
140 / 142
0.00% covered (danger)
0.00%
0 / 1
65
 localName
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Importing\Application\Services;
6
7use ImovelZapAi\Importing\Application\Data\ParsedMedia;
8use ImovelZapAi\Importing\Application\Data\ParsedProperty;
9use ImovelZapAi\Importing\Application\Data\ParseIssue;
10use ImovelZapAi\Importing\Application\Data\ParseResult;
11use ImovelZapAi\Importing\Application\Ports\VrSyncParserInterface;
12use ImovelZapAi\Importing\Domain\Enums\ImportIssueSeverity;
13use ImovelZapAi\Importing\Domain\Exceptions\ImportParseException;
14use XMLReader;
15
16final 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}