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