Completed
Pull Request — master (#4)
by Michael
13:19
created

PoshmarkService::updateItemRequest()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 41
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 4.0312

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 24
c 1
b 0
f 0
dl 0
loc 41
ccs 21
cts 24
cp 0.875
rs 9.536
cc 4
nc 4
nop 2
crap 4.0312
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\TransferException;
17
use PHPosh\Exception\AuthenticationException;
18
use PHPosh\Exception\CookieException;
19
use PHPosh\Exception\GeneralException;
20
use PHPosh\Exception\ItemNotFoundException;
21
use PHPosh\Exception\OrderNotFoundException;
22
use function PHPosh\Shared\log_error;
23
use PHPosh\Shared\Provider;
24
use Psr\Http\Message\ResponseInterface;
25
use Psr\Http\Message\UriInterface;
26
use sndsgd\Str;
27
use Symfony\Component\DomCrawler\Crawler;
28
29
/**
30
 * Browser-Cookie based PoshmarkProvider.
31
 * The way this works is, it needs the cookie data from your logged-in Poshmark browser session.
32
 * Simple way to do this is:
33
 * - Log in to www.poshmark.com
34
 * - Press Ctrl/Command + Shift + K (Firefox) or Ctrl/Command + Shift + J (Chrome)
35
 * - Type document.cookie and press Enter
36
 * - Copy and Save that entire value shown between the quotes
37
 * - $pmProvider = new PoshmarkProvider("<paste the cookie data here>");
38
 * - $items = $pmProvider->getItems()
39
 * - If & when you get an error, repeat the steps above to get the latest cookie data.
40
 */
41
class PoshmarkService implements Provider
42
{
43
    /** @var string URL upon which all requests are based */
44
    public const BASE_URL = 'https://poshmark.com';
45
46
    /** @var array Standard options for the Guzzle client */
47
    private const DEFAULT_OPTIONS = [
48
        'timeout' => 5,
49
        'base_uri' => self::BASE_URL,
50
    ];
51
52
    /** @var array Standard headers to send on each request */
53
    private const DEFAULT_HEADERS = [
54
        'Accept' => 'application/json, text/javascript, */*; q=0.01',
55
        'Accept-Language' => 'en-US,en;q=0.5',
56
        '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',
57
        'Accept-Encoding' => 'gzip',
58
        'Referer' => '', // Replace with actual
59
        'Cookie' => '', // Replace with actual
60
    ];
61
62
    /** @var string An HTTP referrer (referer) to use if not specified otherwise */
63
    private const DEFAULT_REFERRER = 'https://poshmark.com/feed';
64
65
    /** @var array Map of cookies to send. Used to exclude unnecessary cruft */
66
    private const COOKIE_WHITELIST = [
67
        '_csrf' => true,
68
        '__ssid' => true,
69
        'exp' => true,
70
        'ui' => true,
71
        '_uetsid' => true,
72
        '_web_session' => true,
73
        'jwt' => true,
74
    ];
75
76
    /** @var Client */
77
    private $guzzleClient;
78
79
    /** @var array Map of cookies (name => value) to use */
80
    private $cookies = [];
81
82
    /** @var string Human readable username. Auto populated from cookie data */
83
    private $username;
84
85
    /** @var string Email address (from cookie) */
86
    private $email;
87
88
    /** @var string Full name of user (from cookie) */
89
    private $fullname;
90
91
    /** @var string Poshmark user id of user (from cookie) */
92
    private $pmUserId;
93
94
    /** @var string Timestamp when the cookie was pasted in to this system. If too old, it might not work... */
95
    private $cookieTimestamp;
96
97
    /**
98
     * Client constructor. Same options as Guzzle.
99
     *
100
     * @param string $cookieCode Copy+Pasted version of document.cookie on https://poshmark.com
101
     * @param array  $config     Optional Guzzle config overrides (See Guzzle docs for Client constructor)
102
     */
103 8
    public function __construct($cookieCode, array $config = [])
104
    {
105 8
        $config = array_merge($config, static::DEFAULT_OPTIONS);
106 8
        $this->setGuzzleClient(new Client($config));
107 8
        $this->cookies = $this->parseCookiesFromString($cookieCode);
108 8
        $this->setupUserFromCookies($this->cookies);
109 8
    }
110
111
    /**
112
     * @return $this
113
     */
114 8
    public function setGuzzleClient(Client $client): self
115
    {
116 8
        $this->guzzleClient = $client;
117
118 8
        return $this;
119
    }
120
121
    /**
122
     * Get All closet items of a user. Returned items will be sorted by item id. This needs to make multiple HTTP
123
     * requests to Poshmark, not in parallel. Only 20 per page is currently supported, so this will take about 3.5
124
     * seconds for every 100 items there are, or about 30 seconds for every 1000.
125
     *
126
     * @param string $usernameUuid Uuid of user. If empty, will use yourself (from cookie).
127
     * @param string $username     Display username of user. If empty, will use yourself (from cookie).
128
     *
129
     * @throws AuthenticationException
130
     *
131
     * @return Item[]
132
     */
133 2
    public function getItems(string $usernameUuid = '', string $username = ''): array
134
    {
135 2
        if (!$usernameUuid) {
136 2
            $usernameUuid = $this->pmUserId;
137
        }
138 2
        if (!$username) {
139 2
            $username = $this->username;
140
        }
141 2
        $dataParser = $this->getDataParser();
142
143
        // Set a sane upper bound; 250 * 20 = 5000 max items to get
144 2
        $iterations = 250;
145 2
        $maxId = null;
146 2
        $items = [];
147 2
        while ($iterations > 0) {
148 2
            $loopItems = $this->getItemsByMaxId($usernameUuid, $username, $maxId);
149 2
            if (!$loopItems || empty($loopItems['data'])) {
150 1
                break;
151
            }
152 1
            foreach ($loopItems['data'] as $item) {
153
                // Convert each raw json to an Item object
154 1
                $items[] = $dataParser->parseOneItemResponseJson($item);
155
            }
156 1
            $maxId = ($loopItems['more']['next_max_id'] ?? null);
157 1
            if ($maxId <= 0) {
158
                // No next id signifies finished listing
159 1
                break;
160
            }
161 1
            $maxId = (string) $maxId;
162 1
            --$iterations;
163
164
            // Sleep 100ms
165 1
            usleep(100000);
166
167 1
            if ($iterations % 10) {
168
                // Every 10th iteration sleep an additional amount
169 1
                usleep(200000);
170
            }
171
        }
172
173
        usort($items, static function ($a, $b) {
174
            // sort array items by their item ids
175 1
            return strcmp($a->getId(), $b->getId());
176 2
        });
177
178 2
        return $items;
179
    }
180
181
    /**
182
     * Get data on a single item.
183
     *
184
     * @param string $poshmarkItemId Poshmark Item Id
185
     *
186
     * @throws ItemNotFoundException
187
     */
188 4
    public function getItem(string $poshmarkItemId): Item
189
    {
190 4
        if (!$poshmarkItemId) {
191 1
            throw new \InvalidArgumentException('$poshmarkItemId must be non-empty');
192
        }
193 3
        $parser = $this->getDataParser();
194 3
        $headers = static::DEFAULT_HEADERS;
195 3
        $headers['Referer'] = static::DEFAULT_REFERRER;
196 3
        $headers['Cookie'] = $this->getCookieHeader();
197
198 3
        $url = '/vm-rest/posts/%s?app_version=2.55&_=%s';
199 3
        $url = sprintf($url, rawurlencode($poshmarkItemId), (string) microtime(true));
200
201 3
        $response = $this->makeRequest('get', $url, [
202 3
            'headers' => $headers,
203
        ]);
204
205 3
        if (!$response) {
206
            throw new ItemNotFoundException("Item {$poshmarkItemId} not found");
207
        }
208
209 3
        $data = $this->getJsonData($response);
210
211 3
        return $parser->parseOneItemResponseJson($data);
212
    }
213
214
    /**
215
     * Update data of a single item. Must provide title, description, price, and brand in $itemFields for this to work.
216
     *
217
     * Example:
218
     *
219
     * @param string $poshmarkItemId PoshmarkId for the item
220
     * @param array  $itemFields     New item data -- will replace the old data. All fields are optional but you must at least
221
     *                               provide one.
222
     *                               Only these fields currently supported:
223
     *                               [
224
     *                               'title' => 'New title',
225
     *                               'description' => 'New description',
226
     *                               'price' => '4.95 USD', // Price, with currency code (will default to USD)
227
     *                               'brand' => 'Nike', // brand name
228
     *                               ]
229
     *
230
     * @throws AuthenticationException
231
     *
232
     * @return bool returns true on success, throws exception on failure
233
     */
234 1
    public function updateItemRequest(string $poshmarkItemId, array $itemFields): bool
235
    {
236 1
        if (!$poshmarkItemId) {
237
            throw new \InvalidArgumentException('$poshmarkItemId must be non-empty');
238
        }
239 1
        $itemObj = $this->getItem($poshmarkItemId);
240 1
        if (!$itemObj) {
0 ignored issues
show
introduced by
$itemObj is of type PHPosh\Provider\Poshmark\Item, thus it always evaluated to true.
Loading history...
241
            throw new GeneralException('404 Item not found');
242
        }
243
244 1
        $newItemData = Helper::createItemDataForUpdate($itemFields, $itemObj->getRawData());
245
246
        $postBody = [
247 1
            'post' => $newItemData,
248
        ];
249 1
        $postBody = json_encode($postBody);
250
251 1
        $headers = static::DEFAULT_HEADERS;
252 1
        $headers['Referer'] = self::BASE_URL . '/edit-listing/' . $poshmarkItemId;
253 1
        $headers['Cookie'] = $this->getCookieHeader();
254 1
        $headers['Content-Type'] = 'application/json';
255
256 1
        $headers['X-XSRF-TOKEN'] = $this->getXsrfTokenForEditItem($poshmarkItemId);
257 1
        usleep(200000);
258
259 1
        $url = '/vm-rest/posts/%s';
260 1
        $url = sprintf($url, rawurlencode($poshmarkItemId));
261
262 1
        $response = $this->makeRequest('post', $url, [
263 1
            'body' => $postBody,
264 1
            'headers' => $headers,
265
        ]);
266
267 1
        if (!$response) {
268
            return false;
269
        }
270
271
        // Check response code
272 1
        $this->getHtmlData($response);
273
274 1
        return true;
275
    }
276
277
    /**
278
     * Get full details on an order, by parsing the item details page.
279
     *
280
     * @param string $orderId Poshmark OrderID
281
     */
282 1
    public function getOrderDetails(string $orderId): Order
283
    {
284 1
        $headers = static::DEFAULT_HEADERS;
285 1
        $headers['Referer'] = static::DEFAULT_REFERRER;
286 1
        $headers['Cookie'] = $this->getCookieHeader();
287 1
        $headers['Accept'] = 'text/html';
288
289 1
        $dataParser = $this->getDataParser();
290
291 1
        $url = '/order/sales/%s?_=%s';
292 1
        $url = sprintf($url, $orderId, (string) microtime(true));
293
294 1
        $response = $this->makeRequest('get', $url, [
295 1
            'headers' => $headers,
296
        ]);
297
298 1
        if (!$response) {
299
            throw new OrderNotFoundException("Order {$orderId} was not found.");
300
        }
301
302 1
        $html = $this->getHtmlData($response);
303
304 1
        $items = $this->getOrderItems($html);
305
306 1
        return $dataParser->parseFullOrderResponseHtml($orderId, $html, $items);
307
    }
308
309
    /**
310
     * Get a list of order summaries. These won't have full details populated. Sorted by newest first.
311
     *
312
     * @param int $limit Max number of orders to get. Maximum allowed: 10000
313
     *
314
     * @throws AuthenticationException|GeneralException
315
     *
316
     * @return Order[]
317
     */
318 1
    public function getOrderSummaries(int $limit = 100): array
319
    {
320 1
        if ($limit < 0 || $limit > 10000) {
321
            throw new GeneralException('Limit must be between 1 and 10,000 orders');
322
        }
323 1
        $orders = [];
324 1
        $numOrders = 0;
325 1
        $maxId = '';
326 1
        $iterations = 0;
327 1
        while ($iterations++ < 100) {  // Safe guard to limit infinite loops
328 1
            [$loopOrders, $maxId] = $this->getOrdersLoop($maxId);
329 1
            if ($loopOrders && is_array($loopOrders)) {
330 1
                $orders[] = $loopOrders;
331 1
                $numOrders += count($loopOrders);
332
            }
333 1
            if (!$loopOrders || $maxId < 0 || $numOrders >= $limit) {
334 1
                break;
335
            }
336
        }
337
338 1
        if ($orders !== []) {
339 1
            $orders = array_merge(...$orders);
340
        }
341
342 1
        if ($numOrders > $limit) {
343 1
            $orders = array_slice($orders, 0, $limit);
344
        }
345
346 1
        return $orders;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $orders returns an array which contains values of type array which are incompatible with the documented value type PHPosh\Provider\Poshmark\Order.
Loading history...
347
    }
348
349
    /**
350
     * Makes multiple web requests to get each individual item from an order page.
351
     */
352 1
    protected function getOrderItems(string $html): array
353
    {
354 1
        $crawler = new Crawler($html);
355 1
        $contentNode = $crawler->filter('.order-main-con');
356 1
        $itemNodes = $contentNode->filter('.listing-details .rw');
357
358
        $itemUrls = $itemNodes->each(static function (Crawler $node, $i) {
359 1
            return $node->filter('a')->first()->attr('href');
360 1
        });
361 1
        $items = [];
362 1
        foreach ($itemUrls as $url) {
363 1
            $id = DataParser::parseItemIdFromUrl($url);
364 1
            $items[] = $this->getItem($id);
365
        }
366
367 1
        return $items;
368
    }
369
370
    /**
371
     * Get a page of closet items by using max_id. Not publicly accessible, use getItems() instead.
372
     *
373
     * @param string $usernameUuid Obfuscated user id
374
     * @param string $username     Human readable username
375
     * @param mixed  $max_id       Max ID param for pagination. If null, get first page.
376
     *
377
     * @throws AuthenticationException
378
     *
379
     * @return array ['data' => [...], 'more' => [...]]
380
     */
381 2
    protected function getItemsByMaxId(string $usernameUuid, string $username, $max_id = null): array
382
    {
383 2
        $headers = static::DEFAULT_HEADERS;
384 2
        $headers['Referer'] = static::DEFAULT_REFERRER;
385 2
        $headers['Cookie'] = $this->getCookieHeader();
386
387 2
        $url = '/vm-rest/users/%s/posts?app_version=2.55&format=json&username=%s&nm=cl_all&summarize=true&_=%s';
388 2
        if ($max_id) {
389 1
            $url .= '&max_id=' . $max_id;
390
        }
391 2
        $url = sprintf(
392 2
            $url,
393 2
            rawurlencode($usernameUuid),
394 2
            rawurlencode($username),
395 2
            (string) microtime(true)
396
        );
397
398 2
        $response = $this->makeRequest('get', $url, [
399 2
            'headers' => $headers,
400
        ]);
401
402 2
        return $this->getJsonData($response) ?: [];
0 ignored issues
show
Bug introduced by
It seems like $response can also be of type null; however, parameter $response of PHPosh\Provider\Poshmark...kService::getJsonData() does only seem to accept Psr\Http\Message\ResponseInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

402
        return $this->getJsonData(/** @scrutinizer ignore-type */ $response) ?: [];
Loading history...
403
    }
404
405
    /**
406
     * Returns the cookie array.
407
     */
408 7
    protected function getCookies(): array
409
    {
410 7
        return $this->cookies;
411
    }
412
413
    /**
414
     * Convert back the internal cookie map to a string for use in a Cookie: HTTP header.
415
     */
416 6
    protected function getCookieHeader(): string
417
    {
418
        // TODO: Memoize this
419 6
        $cookiesToSend = [];
420 6
        foreach ($this->getCookies() as $name => $value) {
421 6
            if (isset(static::COOKIE_WHITELIST[$name])) {
422 6
                $cookiesToSend[$name] = $value;
423
            }
424
        }
425
426 6
        return http_build_query($cookiesToSend, '', '; ', PHP_QUERY_RFC3986);
427
    }
428
429
    /**
430
     * Get a CSRF token (sometimes called XSRF token) for the user, necessary for updates.
431
     *
432
     * @param string $poshmarkItemId Item id
433
     */
434 1
    protected function getXsrfTokenForEditItem(string $poshmarkItemId): string
435
    {
436 1
        $headers = static::DEFAULT_HEADERS;
437 1
        $headers['Referer'] = static::DEFAULT_REFERRER;
438 1
        $headers['Cookie'] = $this->getCookieHeader();
439 1
        $headers['Accept'] = 'text/html';
440
441 1
        $url = '/edit-listing/%s?_=%s';
442 1
        $url = sprintf($url, rawurlencode($poshmarkItemId), (string) microtime(true));
443
444 1
        $response = $this->makeRequest('get', $url, [
445 1
            'headers' => $headers,
446
        ]);
447
448 1
        if (!$response) {
449
            return '';
450
        }
451
452 1
        $html = $this->getHtmlData($response);
453
454 1
        $crawler = new Crawler($html);
455 1
        $node = $crawler->filter('#csrftoken')->eq(0);
456 1
        if (!$node) {
0 ignored issues
show
introduced by
$node is of type Symfony\Component\DomCrawler\Crawler, thus it always evaluated to true.
Loading history...
457
            throw new GeneralException('Failed to find a CSRF token on the page');
458
        }
459
460 1
        return (string) $node->attr('content');
461
    }
462
463
    /**
464
     * @param string $maxId Max ID for pagination
465
     *
466
     * @return array [Order[], $nextMaxId]
467
     */
468 1
    protected function getOrdersLoop(string $maxId = ''): array
469
    {
470 1
        $headers = static::DEFAULT_HEADERS;
471 1
        $headers['Referer'] = static::DEFAULT_REFERRER;
472 1
        $headers['Cookie'] = $this->getCookieHeader();
473 1
        $headers['Accept'] = 'application/json';
474 1
        $headers['X-Requested-With'] = 'XMLHttpRequest';
475
476 1
        $dataParser = $this->getDataParser();
477
478 1
        $url = '/order/sales?_=%s';
479 1
        $url = sprintf($url, (string) microtime(true));
480
481 1
        if ('' !== $maxId) {
482
            $url .= '&max_id=' . $maxId;
483
        }
484
485 1
        $response = $this->makeRequest('get', $url, [
486 1
            'headers' => $headers,
487
        ]);
488
489 1
        if (!$response) {
490
            return [null, null];
491
        }
492
493 1
        $json = $this->getJsonData($response);
494
495 1
        $nextMaxId = $json['max_id'] ?? -1;
496 1
        if (!$nextMaxId) {
497
            $nextMaxId = -1;
498
        }
499
500 1
        $html = $json['html'];
501
502
        return [
503 1
            $html ? $dataParser->parseOrdersPagePartialResponse($html) : [],
504 1
            $nextMaxId,
505
        ];
506
    }
507
508 6
    protected function getDataParser(): DataParser
509
    {
510 6
        return new DataParser();
511
    }
512
513
    /**
514
     * Wrapper function for making any HTTP requests. Exceptions will be caught and error logged,
515
     * and null will be returned in that case.
516
     *
517
     * @param string|UriInterface $url
518
     */
519 6
    private function makeRequest(string $method, $url, array $guzzleOptions): ?ResponseInterface
520
    {
521 6
        $method = strtolower($method);
522 6
        if (!in_array($method, ['get', 'post'], true)) {
523
            throw new \InvalidArgumentException('$method must be one of: get, post');
524
        }
525
526
        try {
527
            /** @see Client::__call */
528 6
            $response = $this->guzzleClient->{$method}($url, $guzzleOptions);
529
        } catch (TransferException $e) {
530
            log_error(
531
                sprintf(
532
                    'Got exception when trying to request URL %s: %s ' .
533
                    'Perhaps you need to recreate your input cookie string.',
534
                    $url,
535
                    $e->getMessage()
536
                )
537
            );
538
539
            return null;
540
        }
541
542 6
        return $response;
543
    }
544
545
    /**
546
     * @throws AuthenticationException
547
     */
548 6
    private function getJsonData(ResponseInterface $response): array
549
    {
550 6
        if (200 !== $response->getStatusCode()) {
551
            throw new AuthenticationException('Poshmark: Received non-200 status', $response->getStatusCode());
552
        }
553
554 6
        $content = trim($response->getBody()->getContents());
555
556 6
        if (!isset($content[0]) || '{' !== $content[0]) {
557
            throw new AuthenticationException('Poshmark: Unexpected json body', $response->getStatusCode());
558
        }
559
560 6
        $data = json_decode($content, true);
561 6
        if (!$data || !\is_array($data)) {
562
            throw new AuthenticationException('Poshmark: Unexpected json body', $response->getStatusCode());
563
        }
564
565 6
        return $data;
566
    }
567
568
    /**
569
     * @param string $cookieCode Raw cookie string such as "a=1; b=foo; _c=hello world;"
570
     *
571
     * @return array Map of cookie name => cookie value (already decoded)
572
     */
573 8
    private function parseCookiesFromString(string $cookieCode): array
574
    {
575 8
        $cookieCode = trim($cookieCode);
576 8
        if (Str::beginsWith($cookieCode, '"')) {
577
            // remove double quotes
578
            $cookieCode = trim($cookieCode, '"');
579 8
        } elseif (Str::beginsWith($cookieCode, "'")) {
580
            $cookieCode = trim($cookieCode, "'");
581
        }
582 8
        $cookies = [];
583 8
        parse_str(
584 8
            strtr($cookieCode, ['&' => '%26', '+' => '%2B', ';' => '&']),
585
            $cookies
586
        );
587
588 8
        return $cookies;
589
    }
590
591
    /**
592
     * Sets interval variables (user, email, etc.) from the cookies array.
593
     *
594
     * @param array $cookies Cookie name=>value hashmap
595
     *
596
     * @throws CookieException When a required cookie is not provided
597
     */
598 8
    private function setupUserFromCookies(array $cookies): void
599
    {
600 8
        foreach (static::COOKIE_WHITELIST as $cookieKey => $bool) {
601 8
            if (!isset($cookies[$cookieKey]) || '' === $cookies[$cookieKey]) {
602
                throw new CookieException(
603
                    sprintf('Required cookie %s was not supplied', $cookieKey)
604
                );
605
            }
606
        }
607
608 8
        $ui = $cookies['ui'];
609
610 8
        $userData = json_decode($ui, true);
611 8
        $this->username = $userData['dh'];
612 8
        $this->email = $userData['em'];
613 8
        $this->pmUserId = $userData['uid'];
614 8
        $this->fullname = urldecode($userData['fn']);
615 8
        $this->cookieTimestamp = time();
616 8
    }
617
618
    /**
619
     * Get HTML body, and do some basic error checking.
620
     *
621
     * @throws AuthenticationException
622
     */
623 2
    private function getHtmlData(ResponseInterface $response): string
624
    {
625 2
        if (200 !== $response->getStatusCode()) {
626
            throw new AuthenticationException('Poshmark: Received non-200 status', $response->getStatusCode());
627
        }
628
629 2
        $content = trim($response->getBody()->getContents());
630 2
        if ('' === $content) {
631
            throw new AuthenticationException('Poshmark: Unexpected HTML body', $response->getStatusCode());
632
        }
633
634 2
        return $content;
635
    }
636
}
637