michaelbutler /
phposh
| 1 | <?php |
||
| 2 | |||
| 3 | /* |
||
| 4 | * This file is part of michaelbutler/phposh. |
||
| 5 | * Source: https://github.com/michaelbutler/phposh |
||
| 6 | * |
||
| 7 | * (c) Michael Butler <[email protected]> |
||
| 8 | * |
||
| 9 | * This source file is subject to the MIT license that is bundled |
||
| 10 | * with this source code in the file named LICENSE. |
||
| 11 | */ |
||
| 12 | |||
| 13 | namespace PHPosh\Provider\Poshmark; |
||
| 14 | |||
| 15 | use GuzzleHttp\Client; |
||
| 16 | use GuzzleHttp\Exception\RequestException; |
||
| 17 | use PHPosh\Exception\CookieException; |
||
| 18 | use PHPosh\Exception\DataException; |
||
| 19 | use PHPosh\Exception\ItemNotFoundException; |
||
| 20 | use PHPosh\Exception\OrderNotFoundException; |
||
| 21 | use PHPosh\Shared\Provider; |
||
| 22 | use Psr\Http\Message\ResponseInterface; |
||
| 23 | use Psr\Http\Message\UriInterface; |
||
| 24 | use Safe\Exceptions\SafeExceptionInterface; |
||
| 25 | use sndsgd\Str; |
||
| 26 | use Symfony\Component\DomCrawler\Crawler; |
||
| 27 | |||
| 28 | /** |
||
| 29 | * Browser-Cookie based PoshmarkProvider. |
||
| 30 | * The way this works is, it needs the cookie data from your logged-in Poshmark browser session. |
||
| 31 | * Simple way to do this is: |
||
| 32 | * - Log in to www.poshmark.com |
||
| 33 | * - Press Ctrl/Command + Shift + K (Firefox) or Ctrl/Command + Shift + J (Chrome) |
||
| 34 | * - Type document.cookie and press Enter |
||
| 35 | * - Copy and Save that entire value shown between the quotes |
||
| 36 | * - $pmProvider = new PoshmarkProvider("<paste the cookie data here>"); |
||
| 37 | * - $items = $pmProvider->getItems() |
||
| 38 | * - If & when you get an error, repeat the steps above to get the latest cookie data. |
||
| 39 | */ |
||
| 40 | class PoshmarkService implements Provider |
||
| 41 | { |
||
| 42 | /** @var string URL upon which all requests are based */ |
||
| 43 | public const BASE_URL = 'https://poshmark.com'; |
||
| 44 | |||
| 45 | /** @var array Standard options for the Guzzle client */ |
||
| 46 | private const DEFAULT_OPTIONS = [ |
||
| 47 | 'timeout' => 5, |
||
| 48 | 'base_uri' => self::BASE_URL, |
||
| 49 | ]; |
||
| 50 | |||
| 51 | /** @var array Standard headers to send on each request */ |
||
| 52 | private const DEFAULT_HEADERS = [ |
||
| 53 | 'Accept' => 'application/json, text/javascript, */*; q=0.01', |
||
| 54 | 'Accept-Language' => 'en-US,en;q=0.5', |
||
| 55 | 'User-Agent' => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) snap Chromium/81.0.4044.138 Chrome/81.0.4044.138 Safari/537.36', |
||
| 56 | 'Accept-Encoding' => 'gzip', |
||
| 57 | 'Referer' => '', // Replace with actual |
||
| 58 | 'Cookie' => '', // Replace with actual |
||
| 59 | ]; |
||
| 60 | |||
| 61 | /** @var string An HTTP referrer (referer) to use if not specified otherwise */ |
||
| 62 | private const DEFAULT_REFERRER = 'https://poshmark.com/feed'; |
||
| 63 | |||
| 64 | /** @var array Map of cookies to send. Used to exclude unnecessary cruft */ |
||
| 65 | private const COOKIE_WHITELIST = [ |
||
| 66 | '_csrf' => true, |
||
| 67 | '__ssid' => true, |
||
| 68 | 'exp' => true, |
||
| 69 | 'ui' => true, |
||
| 70 | '_uetsid' => true, |
||
| 71 | '_web_session' => true, |
||
| 72 | 'jwt' => true, |
||
| 73 | ]; |
||
| 74 | |||
| 75 | /** @var Client */ |
||
| 76 | private $guzzleClient; |
||
| 77 | |||
| 78 | /** @var array Map of cookies (name => value) to use */ |
||
| 79 | private $cookies = []; |
||
| 80 | |||
| 81 | /** @var string Human readable username. Auto populated from cookie data */ |
||
| 82 | private $username; |
||
| 83 | |||
| 84 | /** @var string Email address (from cookie) */ |
||
| 85 | private $email; |
||
| 86 | |||
| 87 | /** @var string Full name of user (from cookie) */ |
||
| 88 | private $fullname; |
||
| 89 | |||
| 90 | /** @var string Poshmark user id of user (from cookie) */ |
||
| 91 | private $pmUserId; |
||
| 92 | |||
| 93 | /** @var string Timestamp when the cookie was pasted in to this system. If too old, it might not work... */ |
||
| 94 | private $cookieTimestamp; |
||
| 95 | |||
| 96 | /** |
||
| 97 | * Client constructor. Same options as Guzzle. |
||
| 98 | * |
||
| 99 | * @param string $cookieCode Copy+Pasted version of document.cookie on https://poshmark.com |
||
| 100 | * @param array $config Optional Guzzle config overrides (See Guzzle docs for Client constructor) |
||
| 101 | */ |
||
| 102 | 24 | public function __construct($cookieCode, array $config = []) |
|
| 103 | { |
||
| 104 | 24 | $config = array_merge($config, static::DEFAULT_OPTIONS); |
|
| 105 | 24 | $this->setGuzzleClient(new Client($config)); |
|
| 106 | 24 | $this->cookies = $this->parseCookiesFromString($cookieCode); |
|
| 107 | 24 | $this->setupUserFromCookies($this->cookies); |
|
| 108 | 24 | } |
|
| 109 | |||
| 110 | /** |
||
| 111 | * @return $this |
||
| 112 | */ |
||
| 113 | 24 | public function setGuzzleClient(Client $client): self |
|
| 114 | { |
||
| 115 | 24 | $this->guzzleClient = $client; |
|
| 116 | |||
| 117 | 24 | return $this; |
|
| 118 | } |
||
| 119 | |||
| 120 | /** |
||
| 121 | * Get All closet items of a user. Returned items will be sorted by item id. This needs to make multiple HTTP |
||
| 122 | * requests to Poshmark, not in parallel. Only 20 per page is currently supported, so this will take about 3.5 |
||
| 123 | * seconds for every 100 items there are, or about 30 seconds for every 1000. |
||
| 124 | * |
||
| 125 | * @param string $usernameUuid Uuid of user. If empty, will use yourself (from cookie). |
||
| 126 | * @param string $username Display username of user. If empty, will use yourself (from cookie). |
||
| 127 | * |
||
| 128 | * @throws DataException if something unexpected happened, like the user doesn't exist or couldn't fetch items |
||
| 129 | * |
||
| 130 | * @return Item[] |
||
| 131 | */ |
||
| 132 | 4 | public function getItems(string $usernameUuid = '', string $username = ''): array |
|
| 133 | { |
||
| 134 | 4 | if (!$usernameUuid) { |
|
| 135 | 4 | $usernameUuid = $this->pmUserId; |
|
| 136 | } |
||
| 137 | 4 | if (!$username) { |
|
| 138 | 4 | $username = $this->username; |
|
| 139 | } |
||
| 140 | 4 | $dataParser = $this->getDataParser(); |
|
| 141 | |||
| 142 | // Set a sane upper bound; 250 * 20 = 5000 max items to get |
||
| 143 | 4 | $iterations = 250; |
|
| 144 | 4 | $maxId = null; |
|
| 145 | 4 | $items = []; |
|
| 146 | 4 | while ($iterations > 0) { |
|
| 147 | try { |
||
| 148 | 4 | $loopItems = $this->getItemsByMaxId($usernameUuid, $username, $maxId); |
|
| 149 | 2 | } catch (DataException $e) { |
|
| 150 | 2 | if ($items === []) { |
|
| 151 | // If we got this exception on the very first try, we should re-throw it |
||
| 152 | 1 | throw $e; |
|
| 153 | } |
||
| 154 | 1 | $loopItems = []; |
|
| 155 | } |
||
| 156 | 3 | if (!$loopItems || empty($loopItems['data'])) { |
|
| 157 | 2 | break; |
|
| 158 | } |
||
| 159 | 2 | foreach ($loopItems['data'] as $item) { |
|
| 160 | // Convert each raw json to an Item object |
||
| 161 | 2 | $items[] = $dataParser->parseOneItemResponseJson($item); |
|
| 162 | } |
||
| 163 | 2 | $maxId = ($loopItems['more']['next_max_id'] ?? null); |
|
| 164 | 2 | if ($maxId <= 0) { |
|
| 165 | // No next id signifies finished listing |
||
| 166 | 1 | break; |
|
| 167 | } |
||
| 168 | 2 | $maxId = (string) $maxId; |
|
| 169 | 2 | --$iterations; |
|
| 170 | |||
| 171 | // Sleep 100ms |
||
| 172 | 2 | usleep(100000); |
|
| 173 | |||
| 174 | 2 | if ($iterations % 10) { |
|
| 175 | // Every 10th iteration sleep an additional amount |
||
| 176 | 2 | usleep(200000); |
|
| 177 | } |
||
| 178 | } |
||
| 179 | |||
| 180 | usort($items, static function ($a, $b) { |
||
| 181 | // sort array items by their item ids |
||
| 182 | 2 | return strcmp($a->getId(), $b->getId()); |
|
| 183 | 3 | }); |
|
| 184 | |||
| 185 | 3 | return $items; |
|
| 186 | } |
||
| 187 | |||
| 188 | /** |
||
| 189 | * Get a single item on Poshmark by its identifier, full details. |
||
| 190 | * You either get the Item or an exception is thrown. |
||
| 191 | * |
||
| 192 | * @param string $poshmarkItemId Poshmark Item Id |
||
| 193 | * |
||
| 194 | * @throws DataException If a problem occurred while trying to get the Item |
||
| 195 | * @throws ItemNotFoundException If the item was not found (e.g. 404) |
||
| 196 | * @throws \InvalidArgumentException If you passed in an invalid id (request isn't even attempted) |
||
| 197 | */ |
||
| 198 | 11 | public function getItem(string $poshmarkItemId): Item |
|
| 199 | { |
||
| 200 | 11 | if (!$poshmarkItemId) { |
|
| 201 | 1 | throw new \InvalidArgumentException('$poshmarkItemId must be non-empty'); |
|
| 202 | } |
||
| 203 | 10 | $parser = $this->getDataParser(); |
|
| 204 | 10 | $headers = static::DEFAULT_HEADERS; |
|
| 205 | 10 | $headers['Referer'] = static::DEFAULT_REFERRER; |
|
| 206 | 10 | $headers['Cookie'] = $this->getCookieHeader(); |
|
| 207 | |||
| 208 | 10 | $url = '/vm-rest/posts/%s?app_version=2.55&_=%s'; |
|
| 209 | 10 | $url = sprintf($url, rawurlencode($poshmarkItemId), (string) microtime(true)); |
|
| 210 | |||
| 211 | try { |
||
| 212 | 10 | $response = $this->makeRequest('get', $url, [ |
|
| 213 | 10 | 'headers' => $headers, |
|
| 214 | ]); |
||
| 215 | 6 | $data = $this->getJsonData($response); |
|
| 216 | 6 | } catch (DataException $e) { |
|
| 217 | 6 | if (404 === (int) $e->getCode()) { |
|
| 218 | 3 | throw new ItemNotFoundException("Item {$poshmarkItemId} not found"); |
|
| 219 | } |
||
| 220 | |||
| 221 | 3 | throw $e; |
|
| 222 | } |
||
| 223 | |||
| 224 | 4 | return $parser->parseOneItemResponseJson($data); |
|
| 225 | } |
||
| 226 | |||
| 227 | /** |
||
| 228 | * Update data of a single item. Must provide title, description, price, and brand in $itemFields for this to work. |
||
| 229 | * |
||
| 230 | * Example: |
||
| 231 | * |
||
| 232 | * @param string $poshmarkItemId PoshmarkId for the item |
||
| 233 | * @param array $itemFields New item data -- will replace the old data. All fields are optional but you must at least |
||
| 234 | * provide one. |
||
| 235 | * Only these fields currently supported: |
||
| 236 | * [ |
||
| 237 | * 'title' => 'New title', |
||
| 238 | * 'description' => 'New description', |
||
| 239 | * 'price' => '4.95 USD', // Price, with currency code (will default to USD) |
||
| 240 | * 'brand' => 'Nike', // brand name |
||
| 241 | * ] |
||
| 242 | * |
||
| 243 | * @throws DataException update failed |
||
| 244 | * @throws ItemNotFoundException when the item you're trying to update wasn't found |
||
| 245 | * |
||
| 246 | * @return bool returns true on success, throws exception on failure |
||
| 247 | */ |
||
| 248 | 4 | public function updateItem(string $poshmarkItemId, array $itemFields): bool |
|
| 249 | { |
||
| 250 | 4 | if (!$poshmarkItemId) { |
|
| 251 | 1 | throw new \InvalidArgumentException('$poshmarkItemId must be non-empty'); |
|
| 252 | } |
||
| 253 | 3 | $itemObj = $this->getItem($poshmarkItemId); |
|
| 254 | |||
| 255 | 2 | $newItemData = Helper::createItemDataForUpdate($itemFields, $itemObj->getRawData()); |
|
| 256 | |||
| 257 | $postBody = [ |
||
| 258 | 2 | 'post' => $newItemData, |
|
| 259 | ]; |
||
| 260 | 2 | $postBody = json_encode($postBody); |
|
| 261 | |||
| 262 | 2 | $headers = static::DEFAULT_HEADERS; |
|
| 263 | 2 | $headers['Referer'] = self::BASE_URL . '/edit-listing/' . $poshmarkItemId; |
|
| 264 | 2 | $headers['Cookie'] = $this->getCookieHeader(); |
|
| 265 | 2 | $headers['Content-Type'] = 'application/json'; |
|
| 266 | |||
| 267 | 2 | $headers['X-XSRF-TOKEN'] = $this->getXsrfTokenForEditItem($poshmarkItemId); |
|
| 268 | 2 | usleep(200000); |
|
| 269 | |||
| 270 | 2 | $url = '/vm-rest/posts/%s'; |
|
| 271 | 2 | $url = sprintf($url, rawurlencode($poshmarkItemId)); |
|
| 272 | |||
| 273 | 2 | $response = $this->makeRequest('post', $url, [ |
|
| 274 | 2 | 'body' => $postBody, |
|
| 275 | 2 | 'headers' => $headers, |
|
| 276 | ]); |
||
| 277 | |||
| 278 | // Check response code |
||
| 279 | 1 | $this->getHtmlData($response); |
|
| 280 | |||
| 281 | 1 | return true; |
|
| 282 | } |
||
| 283 | |||
| 284 | /** |
||
| 285 | * Get full details on an order, by parsing the item details page. |
||
| 286 | * |
||
| 287 | * @param string $orderId Poshmark OrderID |
||
| 288 | * |
||
| 289 | * @throws DataException |
||
| 290 | * @throws OrderNotFoundException if the order wasn't found on Poshmark, or you're not the seller |
||
| 291 | */ |
||
| 292 | 4 | public function getOrderDetail(string $orderId): Order |
|
| 293 | { |
||
| 294 | 4 | if ('' === $orderId) { |
|
| 295 | 1 | throw new \InvalidArgumentException("Invalid \$orderId: {$orderId}"); |
|
| 296 | } |
||
| 297 | 3 | $headers = static::DEFAULT_HEADERS; |
|
| 298 | 3 | $headers['Referer'] = static::DEFAULT_REFERRER; |
|
| 299 | 3 | $headers['Cookie'] = $this->getCookieHeader(); |
|
| 300 | 3 | $headers['Accept'] = 'text/html'; |
|
| 301 | |||
| 302 | 3 | $dataParser = $this->getDataParser(); |
|
| 303 | |||
| 304 | 3 | $url = '/order/sales/%s?_=%s'; |
|
| 305 | 3 | $url = sprintf($url, $orderId, (string) microtime(true)); |
|
| 306 | |||
| 307 | try { |
||
| 308 | 3 | $response = $this->makeRequest('get', $url, [ |
|
| 309 | 3 | 'headers' => $headers, |
|
| 310 | ]); |
||
| 311 | |||
| 312 | 2 | $html = $this->getHtmlData($response); |
|
| 313 | 2 | $items = $this->getOrderItems($html); |
|
| 314 | |||
| 315 | 2 | return $dataParser->parseFullOrderResponseHtml($orderId, $html, $items); |
|
| 316 | 1 | } catch (DataException $e) { |
|
| 317 | // Of note: Poshmark throws a Server 500 on an invalid order id |
||
| 318 | 1 | throw new OrderNotFoundException( |
|
| 319 | 1 | "Order {$orderId} was not found.", |
|
| 320 | 1 | $e->getCode(), |
|
| 321 | $e |
||
| 322 | ); |
||
| 323 | } |
||
| 324 | } |
||
| 325 | |||
| 326 | /** |
||
| 327 | * Get a list of order summaries. These won't have full details populated. Sorted by newest first. |
||
| 328 | * |
||
| 329 | * @param int $limit Max number of orders to get. Maximum allowed: 10000 |
||
| 330 | * |
||
| 331 | * @throws DataException If we couldn't get any order summaries (e.g. not logged in) |
||
| 332 | * |
||
| 333 | * @return Order[] |
||
| 334 | */ |
||
| 335 | 3 | public function getOrderSummaries(int $limit = 100): array |
|
| 336 | { |
||
| 337 | 3 | if ($limit < 1 || $limit > 10000) { |
|
| 338 | 2 | throw new \InvalidArgumentException('Limit must be between 1 and 10,000 orders'); |
|
| 339 | } |
||
| 340 | 1 | $orders = []; |
|
| 341 | 1 | $numOrders = 0; |
|
| 342 | 1 | $maxId = ''; |
|
| 343 | 1 | $iterations = 0; |
|
| 344 | 1 | while ($iterations++ < 100) { // Safe guard to limit infinite loops |
|
| 345 | 1 | [$loopOrders, $maxId] = $this->getOrdersLoop($maxId); |
|
| 346 | /** @var Order[] $loopOrders */ |
||
| 347 | 1 | if ($loopOrders && is_array($loopOrders)) { |
|
|
0 ignored issues
–
show
|
|||
| 348 | 1 | $orders[] = $loopOrders; |
|
| 349 | 1 | $numOrders += count($loopOrders); |
|
| 350 | } |
||
| 351 | 1 | if (!$loopOrders || $maxId < 0 || $numOrders >= $limit) { |
|
| 352 | 1 | break; |
|
| 353 | } |
||
| 354 | } |
||
| 355 | |||
| 356 | 1 | if ($orders !== []) { |
|
| 357 | 1 | $orders = array_merge(...$orders); |
|
| 358 | } |
||
| 359 | |||
| 360 | 1 | if ($numOrders > $limit) { |
|
| 361 | 1 | $orders = array_slice($orders, 0, $limit); |
|
| 362 | } |
||
| 363 | |||
| 364 | 1 | return $orders; |
|
|
0 ignored issues
–
show
|
|||
| 365 | } |
||
| 366 | |||
| 367 | /** |
||
| 368 | * Makes multiple web requests to get each individual item from an order page. |
||
| 369 | */ |
||
| 370 | 2 | protected function getOrderItems(string $html): array |
|
| 371 | { |
||
| 372 | 2 | $crawler = new Crawler($html); |
|
| 373 | 2 | $contentNode = $crawler->filter('.order-main-con'); |
|
| 374 | 2 | $itemNodes = $contentNode->filter('.listing-details .rw'); |
|
| 375 | |||
| 376 | $itemUrls = $itemNodes->each(static function (Crawler $node, $i) { |
||
| 377 | 2 | return $node->filter('a')->first()->attr('href'); |
|
| 378 | 2 | }); |
|
| 379 | 2 | $items = []; |
|
| 380 | 2 | foreach ($itemUrls as $url) { |
|
| 381 | 2 | $id = DataParser::parseItemIdFromUrl($url); |
|
| 382 | |||
| 383 | try { |
||
| 384 | 2 | $items[] = $this->getItem($id); |
|
| 385 | 1 | } catch (ItemNotFoundException $e) { |
|
| 386 | 1 | $items[] = (new Item()) |
|
| 387 | 1 | ->setId($id) |
|
| 388 | 1 | ->setTitle('Unknown') |
|
| 389 | 1 | ->setDescription('Unknown') |
|
| 390 | 1 | ->setImageUrl('') |
|
| 391 | ; |
||
| 392 | } |
||
| 393 | } |
||
| 394 | |||
| 395 | 2 | return $items; |
|
| 396 | } |
||
| 397 | |||
| 398 | /** |
||
| 399 | * Get a page of closet items by using max_id. Not publicly accessible, use getItems() instead. |
||
| 400 | * |
||
| 401 | * @param string $usernameUuid Obfuscated user id |
||
| 402 | * @param string $username Human readable username |
||
| 403 | * @param mixed $max_id Max ID param for pagination. If null, get first page. |
||
| 404 | * |
||
| 405 | * @throws DataException on an unexpected HTTP response or transfer failure |
||
| 406 | * |
||
| 407 | * @return array ['data' => [...], 'more' => [...]] |
||
| 408 | */ |
||
| 409 | 4 | protected function getItemsByMaxId(string $usernameUuid, string $username, $max_id = null): array |
|
| 410 | { |
||
| 411 | 4 | $headers = static::DEFAULT_HEADERS; |
|
| 412 | 4 | $headers['Referer'] = static::DEFAULT_REFERRER; |
|
| 413 | 4 | $headers['Cookie'] = $this->getCookieHeader(); |
|
| 414 | |||
| 415 | 4 | $url = '/vm-rest/users/%s/posts?app_version=2.55&format=json&username=%s&nm=cl_all&summarize=true&_=%s'; |
|
| 416 | 4 | if ($max_id) { |
|
| 417 | 2 | $url .= '&max_id=' . $max_id; |
|
| 418 | } |
||
| 419 | 4 | $url = sprintf( |
|
| 420 | 4 | $url, |
|
| 421 | 4 | rawurlencode($usernameUuid), |
|
| 422 | 4 | rawurlencode($username), |
|
| 423 | 4 | (string) microtime(true) |
|
| 424 | ); |
||
| 425 | |||
| 426 | 4 | $response = $this->makeRequest('get', $url, [ |
|
| 427 | 4 | 'headers' => $headers, |
|
| 428 | ]); |
||
| 429 | |||
| 430 | 3 | return $this->getJsonData($response) ?: []; |
|
| 431 | } |
||
| 432 | |||
| 433 | /** |
||
| 434 | * Returns the cookie array. |
||
| 435 | */ |
||
| 436 | 19 | protected function getCookies(): array |
|
| 437 | { |
||
| 438 | 19 | return $this->cookies; |
|
| 439 | } |
||
| 440 | |||
| 441 | /** |
||
| 442 | * Convert back the internal cookie map to a string for use in a Cookie: HTTP header. |
||
| 443 | */ |
||
| 444 | 16 | protected function getCookieHeader(): string |
|
| 445 | { |
||
| 446 | // TODO: Memoize this |
||
| 447 | 16 | $cookiesToSend = []; |
|
| 448 | 16 | foreach ($this->getCookies() as $name => $value) { |
|
| 449 | 16 | if (isset(static::COOKIE_WHITELIST[$name])) { |
|
| 450 | 16 | $cookiesToSend[$name] = $value; |
|
| 451 | } |
||
| 452 | } |
||
| 453 | |||
| 454 | 16 | return http_build_query($cookiesToSend, '', '; ', PHP_QUERY_RFC3986); |
|
| 455 | } |
||
| 456 | |||
| 457 | /** |
||
| 458 | * Get a CSRF token (sometimes called XSRF token) for the user, necessary for updates. |
||
| 459 | * |
||
| 460 | * @param string $poshmarkItemId Item id |
||
| 461 | * |
||
| 462 | * @throws DataException |
||
| 463 | */ |
||
| 464 | 2 | protected function getXsrfTokenForEditItem(string $poshmarkItemId): string |
|
| 465 | { |
||
| 466 | 2 | $headers = static::DEFAULT_HEADERS; |
|
| 467 | 2 | $headers['Referer'] = static::DEFAULT_REFERRER; |
|
| 468 | 2 | $headers['Cookie'] = $this->getCookieHeader(); |
|
| 469 | 2 | $headers['Accept'] = 'text/html'; |
|
| 470 | |||
| 471 | 2 | $url = '/edit-listing/%s?_=%s'; |
|
| 472 | 2 | $url = sprintf($url, rawurlencode($poshmarkItemId), (string) microtime(true)); |
|
| 473 | |||
| 474 | 2 | $response = $this->makeRequest('get', $url, [ |
|
| 475 | 2 | 'headers' => $headers, |
|
| 476 | ]); |
||
| 477 | |||
| 478 | 2 | $html = $this->getHtmlData($response); |
|
| 479 | |||
| 480 | 2 | $crawler = new Crawler($html); |
|
| 481 | 2 | $node = $crawler->filter('#csrftoken')->eq(0); |
|
| 482 | 2 | if (!$node) { |
|
|
0 ignored issues
–
show
|
|||
| 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.