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)) { |
|
|
|
|
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; |
|
|
|
|
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) { |
|
|
|
|
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
|
|
|
|
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.