|
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.