PoshmarkService::getCookieHeader()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 5
c 1
b 0
f 0
dl 0
loc 11
ccs 6
cts 6
cp 1
rs 10
cc 3
nc 3
nop 0
crap 3
1
<?php
2
3
/*
4
 * This file is part of michaelbutler/phposh.
5
 * Source: https://github.com/michaelbutler/phposh
6
 *
7
 * (c) Michael Butler <[email protected]>
8
 *
9
 * This source file is subject to the MIT license that is bundled
10
 * with this source code in the file named LICENSE.
11
 */
12
13
namespace PHPosh\Provider\Poshmark;
14
15
use GuzzleHttp\Client;
16
use GuzzleHttp\Exception\RequestException;
17
use PHPosh\Exception\CookieException;
18
use PHPosh\Exception\DataException;
19
use PHPosh\Exception\ItemNotFoundException;
20
use PHPosh\Exception\OrderNotFoundException;
21
use PHPosh\Shared\Provider;
22
use Psr\Http\Message\ResponseInterface;
23
use Psr\Http\Message\UriInterface;
24
use Safe\Exceptions\SafeExceptionInterface;
25
use sndsgd\Str;
26
use Symfony\Component\DomCrawler\Crawler;
27
28
/**
29
 * Browser-Cookie based PoshmarkProvider.
30
 * The way this works is, it needs the cookie data from your logged-in Poshmark browser session.
31
 * Simple way to do this is:
32
 * - Log in to www.poshmark.com
33
 * - Press Ctrl/Command + Shift + K (Firefox) or Ctrl/Command + Shift + J (Chrome)
34
 * - Type document.cookie and press Enter
35
 * - Copy and Save that entire value shown between the quotes
36
 * - $pmProvider = new PoshmarkProvider("<paste the cookie data here>");
37
 * - $items = $pmProvider->getItems()
38
 * - If & when you get an error, repeat the steps above to get the latest cookie data.
39
 */
40
class PoshmarkService implements Provider
41
{
42
    /** @var string URL upon which all requests are based */
43
    public const BASE_URL = 'https://poshmark.com';
44
45
    /** @var array Standard options for the Guzzle client */
46
    private const DEFAULT_OPTIONS = [
47
        'timeout' => 5,
48
        'base_uri' => self::BASE_URL,
49
    ];
50
51
    /** @var array Standard headers to send on each request */
52
    private const DEFAULT_HEADERS = [
53
        'Accept' => 'application/json, text/javascript, */*; q=0.01',
54
        'Accept-Language' => 'en-US,en;q=0.5',
55
        'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) snap Chromium/81.0.4044.138 Chrome/81.0.4044.138 Safari/537.36',
56
        'Accept-Encoding' => 'gzip',
57
        'Referer' => '', // Replace with actual
58
        'Cookie' => '', // Replace with actual
59
    ];
60
61
    /** @var string An HTTP referrer (referer) to use if not specified otherwise */
62
    private const DEFAULT_REFERRER = 'https://poshmark.com/feed';
63
64
    /** @var array Map of cookies to send. Used to exclude unnecessary cruft */
65
    private const COOKIE_WHITELIST = [
66
        '_csrf' => true,
67
        '__ssid' => true,
68
        'exp' => true,
69
        'ui' => true,
70
        '_uetsid' => true,
71
        '_web_session' => true,
72
        'jwt' => true,
73
    ];
74
75
    /** @var Client */
76
    private $guzzleClient;
77
78
    /** @var array Map of cookies (name => value) to use */
79
    private $cookies = [];
80
81
    /** @var string Human readable username. Auto populated from cookie data */
82
    private $username;
83
84
    /** @var string Email address (from cookie) */
85
    private $email;
86
87
    /** @var string Full name of user (from cookie) */
88
    private $fullname;
89
90
    /** @var string Poshmark user id of user (from cookie) */
91
    private $pmUserId;
92
93
    /** @var string Timestamp when the cookie was pasted in to this system. If too old, it might not work... */
94
    private $cookieTimestamp;
95
96
    /**
97
     * Client constructor. Same options as Guzzle.
98
     *
99
     * @param string $cookieCode Copy+Pasted version of document.cookie on https://poshmark.com
100
     * @param array  $config     Optional Guzzle config overrides (See Guzzle docs for Client constructor)
101
     */
102 24
    public function __construct($cookieCode, array $config = [])
103
    {
104 24
        $config = array_merge($config, static::DEFAULT_OPTIONS);
105 24
        $this->setGuzzleClient(new Client($config));
106 24
        $this->cookies = $this->parseCookiesFromString($cookieCode);
107 24
        $this->setupUserFromCookies($this->cookies);
108 24
    }
109
110
    /**
111
     * @return $this
112
     */
113 24
    public function setGuzzleClient(Client $client): self
114
    {
115 24
        $this->guzzleClient = $client;
116
117 24
        return $this;
118
    }
119
120
    /**
121
     * Get All closet items of a user. Returned items will be sorted by item id. This needs to make multiple HTTP
122
     * requests to Poshmark, not in parallel. Only 20 per page is currently supported, so this will take about 3.5
123
     * seconds for every 100 items there are, or about 30 seconds for every 1000.
124
     *
125
     * @param string $usernameUuid Uuid of user. If empty, will use yourself (from cookie).
126
     * @param string $username     Display username of user. If empty, will use yourself (from cookie).
127
     *
128
     * @throws DataException if something unexpected happened, like the user doesn't exist or couldn't fetch items
129
     *
130
     * @return Item[]
131
     */
132 4
    public function getItems(string $usernameUuid = '', string $username = ''): array
133
    {
134 4
        if (!$usernameUuid) {
135 4
            $usernameUuid = $this->pmUserId;
136
        }
137 4
        if (!$username) {
138 4
            $username = $this->username;
139
        }
140 4
        $dataParser = $this->getDataParser();
141
142
        // Set a sane upper bound; 250 * 20 = 5000 max items to get
143 4
        $iterations = 250;
144 4
        $maxId = null;
145 4
        $items = [];
146 4
        while ($iterations > 0) {
147
            try {
148 4
                $loopItems = $this->getItemsByMaxId($usernameUuid, $username, $maxId);
149 2
            } catch (DataException $e) {
150 2
                if ($items === []) {
151
                    // If we got this exception on the very first try, we should re-throw it
152 1
                    throw $e;
153
                }
154 1
                $loopItems = [];
155
            }
156 3
            if (!$loopItems || empty($loopItems['data'])) {
157 2
                break;
158
            }
159 2
            foreach ($loopItems['data'] as $item) {
160
                // Convert each raw json to an Item object
161 2
                $items[] = $dataParser->parseOneItemResponseJson($item);
162
            }
163 2
            $maxId = ($loopItems['more']['next_max_id'] ?? null);
164 2
            if ($maxId <= 0) {
165
                // No next id signifies finished listing
166 1
                break;
167
            }
168 2
            $maxId = (string) $maxId;
169 2
            --$iterations;
170
171
            // Sleep 100ms
172 2
            usleep(100000);
173
174 2
            if ($iterations % 10) {
175
                // Every 10th iteration sleep an additional amount
176 2
                usleep(200000);
177
            }
178
        }
179
180
        usort($items, static function ($a, $b) {
181
            // sort array items by their item ids
182 2
            return strcmp($a->getId(), $b->getId());
183 3
        });
184
185 3
        return $items;
186
    }
187
188
    /**
189
     * Get a single item on Poshmark by its identifier, full details.
190
     * You either get the Item or an exception is thrown.
191
     *
192
     * @param string $poshmarkItemId Poshmark Item Id
193
     *
194
     * @throws DataException             If a problem occurred while trying to get the Item
195
     * @throws ItemNotFoundException     If the item was not found (e.g. 404)
196
     * @throws \InvalidArgumentException If you passed in an invalid id (request isn't even attempted)
197
     */
198 11
    public function getItem(string $poshmarkItemId): Item
199
    {
200 11
        if (!$poshmarkItemId) {
201 1
            throw new \InvalidArgumentException('$poshmarkItemId must be non-empty');
202
        }
203 10
        $parser = $this->getDataParser();
204 10
        $headers = static::DEFAULT_HEADERS;
205 10
        $headers['Referer'] = static::DEFAULT_REFERRER;
206 10
        $headers['Cookie'] = $this->getCookieHeader();
207
208 10
        $url = '/vm-rest/posts/%s?app_version=2.55&_=%s';
209 10
        $url = sprintf($url, rawurlencode($poshmarkItemId), (string) microtime(true));
210
211
        try {
212 10
            $response = $this->makeRequest('get', $url, [
213 10
                'headers' => $headers,
214
            ]);
215 6
            $data = $this->getJsonData($response);
216 6
        } catch (DataException $e) {
217 6
            if (404 === (int) $e->getCode()) {
218 3
                throw new ItemNotFoundException("Item {$poshmarkItemId} not found");
219
            }
220
221 3
            throw $e;
222
        }
223
224 4
        return $parser->parseOneItemResponseJson($data);
225
    }
226
227
    /**
228
     * Update data of a single item. Must provide title, description, price, and brand in $itemFields for this to work.
229
     *
230
     * Example:
231
     *
232
     * @param string $poshmarkItemId PoshmarkId for the item
233
     * @param array  $itemFields     New item data -- will replace the old data. All fields are optional but you must at least
234
     *                               provide one.
235
     *                               Only these fields currently supported:
236
     *                               [
237
     *                               'title' => 'New title',
238
     *                               'description' => 'New description',
239
     *                               'price' => '4.95 USD', // Price, with currency code (will default to USD)
240
     *                               'brand' => 'Nike', // brand name
241
     *                               ]
242
     *
243
     * @throws DataException         update failed
244
     * @throws ItemNotFoundException when the item you're trying to update wasn't found
245
     *
246
     * @return bool returns true on success, throws exception on failure
247
     */
248 4
    public function updateItem(string $poshmarkItemId, array $itemFields): bool
249
    {
250 4
        if (!$poshmarkItemId) {
251 1
            throw new \InvalidArgumentException('$poshmarkItemId must be non-empty');
252
        }
253 3
        $itemObj = $this->getItem($poshmarkItemId);
254
255 2
        $newItemData = Helper::createItemDataForUpdate($itemFields, $itemObj->getRawData());
256
257
        $postBody = [
258 2
            'post' => $newItemData,
259
        ];
260 2
        $postBody = json_encode($postBody);
261
262 2
        $headers = static::DEFAULT_HEADERS;
263 2
        $headers['Referer'] = self::BASE_URL . '/edit-listing/' . $poshmarkItemId;
264 2
        $headers['Cookie'] = $this->getCookieHeader();
265 2
        $headers['Content-Type'] = 'application/json';
266
267 2
        $headers['X-XSRF-TOKEN'] = $this->getXsrfTokenForEditItem($poshmarkItemId);
268 2
        usleep(200000);
269
270 2
        $url = '/vm-rest/posts/%s';
271 2
        $url = sprintf($url, rawurlencode($poshmarkItemId));
272
273 2
        $response = $this->makeRequest('post', $url, [
274 2
            'body' => $postBody,
275 2
            'headers' => $headers,
276
        ]);
277
278
        // Check response code
279 1
        $this->getHtmlData($response);
280
281 1
        return true;
282
    }
283
284
    /**
285
     * Get full details on an order, by parsing the item details page.
286
     *
287
     * @param string $orderId Poshmark OrderID
288
     *
289
     * @throws DataException
290
     * @throws OrderNotFoundException if the order wasn't found on Poshmark, or you're not the seller
291
     */
292 4
    public function getOrderDetail(string $orderId): Order
293
    {
294 4
        if ('' === $orderId) {
295 1
            throw new \InvalidArgumentException("Invalid \$orderId: {$orderId}");
296
        }
297 3
        $headers = static::DEFAULT_HEADERS;
298 3
        $headers['Referer'] = static::DEFAULT_REFERRER;
299 3
        $headers['Cookie'] = $this->getCookieHeader();
300 3
        $headers['Accept'] = 'text/html';
301
302 3
        $dataParser = $this->getDataParser();
303
304 3
        $url = '/order/sales/%s?_=%s';
305 3
        $url = sprintf($url, $orderId, (string) microtime(true));
306
307
        try {
308 3
            $response = $this->makeRequest('get', $url, [
309 3
                'headers' => $headers,
310
            ]);
311
312 2
            $html = $this->getHtmlData($response);
313 2
            $items = $this->getOrderItems($html);
314
315 2
            return $dataParser->parseFullOrderResponseHtml($orderId, $html, $items);
316 1
        } catch (DataException $e) {
317
            // Of note: Poshmark throws a Server 500 on an invalid order id
318 1
            throw new OrderNotFoundException(
319 1
                "Order {$orderId} was not found.",
320 1
                $e->getCode(),
321
                $e
322
            );
323
        }
324
    }
325
326
    /**
327
     * Get a list of order summaries. These won't have full details populated. Sorted by newest first.
328
     *
329
     * @param int $limit Max number of orders to get. Maximum allowed: 10000
330
     *
331
     * @throws DataException If we couldn't get any order summaries (e.g. not logged in)
332
     *
333
     * @return Order[]
334
     */
335 3
    public function getOrderSummaries(int $limit = 100): array
336
    {
337 3
        if ($limit < 1 || $limit > 10000) {
338 2
            throw new \InvalidArgumentException('Limit must be between 1 and 10,000 orders');
339
        }
340 1
        $orders = [];
341 1
        $numOrders = 0;
342 1
        $maxId = '';
343 1
        $iterations = 0;
344 1
        while ($iterations++ < 100) {  // Safe guard to limit infinite loops
345 1
            [$loopOrders, $maxId] = $this->getOrdersLoop($maxId);
346
            /** @var Order[] $loopOrders */
347 1
            if ($loopOrders && is_array($loopOrders)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $loopOrders of type PHPosh\Provider\Poshmark\Order[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
348 1
                $orders[] = $loopOrders;
349 1
                $numOrders += count($loopOrders);
350
            }
351 1
            if (!$loopOrders || $maxId < 0 || $numOrders >= $limit) {
352 1
                break;
353
            }
354
        }
355
356 1
        if ($orders !== []) {
357 1
            $orders = array_merge(...$orders);
358
        }
359
360 1
        if ($numOrders > $limit) {
361 1
            $orders = array_slice($orders, 0, $limit);
362
        }
363
364 1
        return $orders;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $orders returns an array which contains values of type PHPosh\Provider\Poshmark\Order[] which are incompatible with the documented value type PHPosh\Provider\Poshmark\Order.
Loading history...
365
    }
366
367
    /**
368
     * Makes multiple web requests to get each individual item from an order page.
369
     */
370 2
    protected function getOrderItems(string $html): array
371
    {
372 2
        $crawler = new Crawler($html);
373 2
        $contentNode = $crawler->filter('.order-main-con');
374 2
        $itemNodes = $contentNode->filter('.listing-details .rw');
375
376
        $itemUrls = $itemNodes->each(static function (Crawler $node, $i) {
377 2
            return $node->filter('a')->first()->attr('href');
378 2
        });
379 2
        $items = [];
380 2
        foreach ($itemUrls as $url) {
381 2
            $id = DataParser::parseItemIdFromUrl($url);
382
383
            try {
384 2
                $items[] = $this->getItem($id);
385 1
            } catch (ItemNotFoundException $e) {
386 1
                $items[] = (new Item())
387 1
                    ->setId($id)
388 1
                    ->setTitle('Unknown')
389 1
                    ->setDescription('Unknown')
390 1
                    ->setImageUrl('')
391
                ;
392
            }
393
        }
394
395 2
        return $items;
396
    }
397
398
    /**
399
     * Get a page of closet items by using max_id. Not publicly accessible, use getItems() instead.
400
     *
401
     * @param string $usernameUuid Obfuscated user id
402
     * @param string $username     Human readable username
403
     * @param mixed  $max_id       Max ID param for pagination. If null, get first page.
404
     *
405
     * @throws DataException on an unexpected HTTP response or transfer failure
406
     *
407
     * @return array ['data' => [...], 'more' => [...]]
408
     */
409 4
    protected function getItemsByMaxId(string $usernameUuid, string $username, $max_id = null): array
410
    {
411 4
        $headers = static::DEFAULT_HEADERS;
412 4
        $headers['Referer'] = static::DEFAULT_REFERRER;
413 4
        $headers['Cookie'] = $this->getCookieHeader();
414
415 4
        $url = '/vm-rest/users/%s/posts?app_version=2.55&format=json&username=%s&nm=cl_all&summarize=true&_=%s';
416 4
        if ($max_id) {
417 2
            $url .= '&max_id=' . $max_id;
418
        }
419 4
        $url = sprintf(
420 4
            $url,
421 4
            rawurlencode($usernameUuid),
422 4
            rawurlencode($username),
423 4
            (string) microtime(true)
424
        );
425
426 4
        $response = $this->makeRequest('get', $url, [
427 4
            'headers' => $headers,
428
        ]);
429
430 3
        return $this->getJsonData($response) ?: [];
431
    }
432
433
    /**
434
     * Returns the cookie array.
435
     */
436 19
    protected function getCookies(): array
437
    {
438 19
        return $this->cookies;
439
    }
440
441
    /**
442
     * Convert back the internal cookie map to a string for use in a Cookie: HTTP header.
443
     */
444 16
    protected function getCookieHeader(): string
445
    {
446
        // TODO: Memoize this
447 16
        $cookiesToSend = [];
448 16
        foreach ($this->getCookies() as $name => $value) {
449 16
            if (isset(static::COOKIE_WHITELIST[$name])) {
450 16
                $cookiesToSend[$name] = $value;
451
            }
452
        }
453
454 16
        return http_build_query($cookiesToSend, '', '; ', PHP_QUERY_RFC3986);
455
    }
456
457
    /**
458
     * Get a CSRF token (sometimes called XSRF token) for the user, necessary for updates.
459
     *
460
     * @param string $poshmarkItemId Item id
461
     *
462
     * @throws DataException
463
     */
464 2
    protected function getXsrfTokenForEditItem(string $poshmarkItemId): string
465
    {
466 2
        $headers = static::DEFAULT_HEADERS;
467 2
        $headers['Referer'] = static::DEFAULT_REFERRER;
468 2
        $headers['Cookie'] = $this->getCookieHeader();
469 2
        $headers['Accept'] = 'text/html';
470
471 2
        $url = '/edit-listing/%s?_=%s';
472 2
        $url = sprintf($url, rawurlencode($poshmarkItemId), (string) microtime(true));
473
474 2
        $response = $this->makeRequest('get', $url, [
475 2
            'headers' => $headers,
476
        ]);
477
478 2
        $html = $this->getHtmlData($response);
479
480 2
        $crawler = new Crawler($html);
481 2
        $node = $crawler->filter('#csrftoken')->eq(0);
482 2
        if (!$node) {
0 ignored issues
show
introduced by
$node is of type Symfony\Component\DomCrawler\Crawler, thus it always evaluated to true.
Loading history...
483
            throw new DataException('Failed to find a CSRF token on the page');
484
        }
485
486 2
        return (string) $node->attr('content');
487
    }
488
489
    /**
490
     * @param string $maxId Max ID for pagination
491
     *
492
     * @throws DataException
493
     *
494
     * @return array [Order[], $nextMaxId]
495
     */
496 1
    protected function getOrdersLoop(string $maxId = ''): array
497
    {
498 1
        $headers = static::DEFAULT_HEADERS;
499 1
        $headers['Referer'] = static::DEFAULT_REFERRER;
500 1
        $headers['Cookie'] = $this->getCookieHeader();
501 1
        $headers['Accept'] = 'application/json';
502 1
        $headers['X-Requested-With'] = 'XMLHttpRequest';
503
504 1
        $dataParser = $this->getDataParser();
505
506 1
        $url = '/order/sales?_=%s';
507 1
        $url = sprintf($url, (string) microtime(true));
508
509 1
        if ('' !== $maxId) {
510
            $url .= '&max_id=' . $maxId;
511
        }
512
513
        try {
514 1
            $response = $this->makeRequest('get', $url, [
515 1
                'headers' => $headers,
516
            ]);
517
518 1
            $json = $this->getJsonData($response);
519
        } catch (DataException $e) {
520
            if ('' === $maxId) {
521
                // If this was the very first request, let's just throw exception.
522
                // Otherwise we'll capture it, and at least return a partial order set.
523
                throw $e;
524
            }
525
526
            return [null, null];
527
        }
528
529 1
        $nextMaxId = $json['max_id'] ?? -1;
530 1
        if (!$nextMaxId) {
531
            $nextMaxId = -1;
532
        }
533
534 1
        $html = $json['html'];
535
536
        return [
537 1
            $html ? $dataParser->parseOrdersPagePartialResponse($html) : [],
538 1
            $nextMaxId,
539
        ];
540
    }
541
542 16
    protected function getDataParser(): DataParser
543
    {
544 16
        return new DataParser();
545
    }
546
547
    /**
548
     * Wrapper function for making any HTTP requests. Any Guzzle or HTTP Exception will be wrapped with DataException,
549
     * and re-thrown.
550
     *
551
     * @param string|UriInterface $url
552
     *
553
     * @throws DataException on an unexpected HTTP Response or failure
554
     */
555 16
    private function makeRequest(string $method, $url, array $guzzleOptions): ResponseInterface
556
    {
557 16
        $method = strtolower($method);
558
559
        try {
560
            /** @see Client::__call */
561 16
            $response = $this->guzzleClient->{$method}($url, $guzzleOptions);
562 8
        } catch (\Exception $e) {
563 8
            $code = 101;
564 8
            if ($e instanceof RequestException && $e->getResponse()) {
565 5
                $code = $e->getResponse()->getStatusCode();
566
            }
567
568 8
            throw new DataException(
569 8
                sprintf(
570 8
                    'Exception occurred whilst making %s request to %s: %s',
571 8
                    strtoupper($method),
572
                    $url,
573 8
                    $e->getMessage()
574
                ),
575
                $code,
576
                $e
577
            );
578
        }
579
580 11
        return $response;
581
    }
582
583
    /**
584
     * @throws DataException On invalid data
585
     */
586 10
    private function getJsonData(ResponseInterface $response): array
587
    {
588 10
        $content = trim($response->getBody()->getContents());
589
590
        try {
591 10
            $data = \Safe\json_decode($content, true);
592 1
        } catch (SafeExceptionInterface $e) {
593 1
            $data = null;
594
        }
595 10
        if (null === $data || !\is_array($data)) {
596 1
            throw new DataException(
597 1
                'Poshmark: Unexpected json body, Resp. code: ' . $response->getStatusCode(),
598 1
                500
599
            );
600
        }
601
602 9
        if (isset($data['error']['statusCode']) && $data['error']['statusCode'] >= 400) {
603
            // Poshmark will return an error response as an actual HTTP 200.
604
            // The http code will be in the `error` array of the data response body,
605
            // so we convert this to a more HTTP-like exception.
606 1
            throw new DataException(
607 1
                sprintf(
608 1
                    'Poshmark: Received %s error (%s) Response code: %s',
609 1
                    $data['error']['errorType'] ?? 'unknownType',
610 1
                    $data['error']['errorMessage'] ?? 'emptyMsg',
611 1
                    (string) $data['error']['statusCode']
612
                ),
613 1
                $data['error']['statusCode']
614
            );
615
        }
616
617 8
        return $data;
618
    }
619
620
    /**
621
     * @param string $cookieCode Raw cookie string such as "a=1; b=foo; _c=hello world;"
622
     *
623
     * @return array Map of cookie name => cookie value (already decoded)
624
     */
625 24
    private function parseCookiesFromString(string $cookieCode): array
626
    {
627 24
        $cookieCode = trim($cookieCode);
628 24
        if (Str::beginsWith($cookieCode, '"')) {
629
            // remove double quotes
630 1
            $cookieCode = trim($cookieCode, '"');
631 23
        } elseif (Str::beginsWith($cookieCode, "'")) {
632 1
            $cookieCode = trim($cookieCode, "'");
633
        }
634 24
        $cookies = [];
635 24
        parse_str(
636 24
            strtr($cookieCode, ['&' => '%26', '+' => '%2B', ';' => '&']),
637
            $cookies
638
        );
639
640 24
        return $cookies;
641
    }
642
643
    /**
644
     * Sets interval variables (user, email, etc.) from the cookies array.
645
     *
646
     * @param array $cookies Cookie name=>value hashmap
647
     *
648
     * @throws CookieException When a required cookie is not provided
649
     */
650 24
    private function setupUserFromCookies(array $cookies): void
651
    {
652 24
        foreach (static::COOKIE_WHITELIST as $cookieKey => $bool) {
653 24
            if (!isset($cookies[$cookieKey]) || '' === $cookies[$cookieKey]) {
654
                throw new CookieException(
655
                    sprintf('Required cookie %s was not supplied', $cookieKey)
656
                );
657
            }
658
        }
659
660 24
        $ui = $cookies['ui'];
661
662 24
        $userData = json_decode($ui, true);
663 24
        $this->username = $userData['dh'];
664 24
        $this->email = $userData['em'];
665 24
        $this->pmUserId = $userData['uid'];
666 24
        $this->fullname = urldecode($userData['fn']);
667 24
        $this->cookieTimestamp = time();
668 24
    }
669
670
    /**
671
     * Get HTML body, and do some basic error checking.
672
     *
673
     * @throws DataException
674
     */
675 4
    private function getHtmlData(ResponseInterface $response): string
676
    {
677 4
        $content = trim($response->getBody()->getContents());
678 4
        if ('' === $content) {
679
            throw new DataException(
680
                'Poshmark: Unexpected HTML body',
681
                500
682
            );
683
        }
684
685 4
        return $content;
686
    }
687
}
688