├── tests ├── InvalidTracker.php ├── mock │ ├── Fedex │ │ └── FedexMock.php │ ├── GLS │ │ ├── in_transit.txt │ │ ├── delivered.txt │ │ └── pick_up.txt │ ├── DHL │ │ ├── delivered.txt │ │ ├── warning.txt │ │ ├── in_transit.txt │ │ └── pickup.txt │ ├── DHLExpress │ │ ├── in_transit.txt │ │ ├── delivered.txt │ │ └── delivered_with_unordered_statuses.txt │ └── PostCH │ │ └── unknown.txt ├── ValidTracker.php ├── TestCase.php ├── Trackers │ ├── FedexTest.php │ ├── USPSTest.php │ ├── PostCHTest.php │ ├── PostATTest.php │ ├── GLSTest.php │ ├── UPSTest.php │ ├── DHLExpressTest.php │ └── DHLTest.php ├── Utils │ ├── UtilsTest.php │ └── AdditionalDetailsTest.php ├── FileMapperDataProvider.php ├── EventTest.php ├── TrackTest.php └── ShipmentTrackerTest.php ├── .travis.yml ├── src ├── DataProviders │ ├── DataProviderInterface.php │ ├── PhpClient.php │ ├── GuzzleClient.php │ └── Registry.php ├── Utils │ ├── Utils.php │ ├── AdditionalDetails.php │ └── XmlHelpers.php ├── Event.php ├── ShipmentTracker.php ├── Track.php └── Trackers │ ├── PostAT.php │ ├── Fedex.php │ ├── AbstractTracker.php │ ├── USPS.php │ ├── GLS.php │ ├── DHLExpress.php │ ├── PostCH.php │ ├── UPS.php │ └── DHL.php ├── .gitignore ├── phpunit.xml ├── composer.json └── readme.md /tests/InvalidTracker.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('fedex'); 19 | } 20 | 21 | public function test_it_resolves_a_delivered_shipment() 22 | { 23 | 24 | $track = $this->tracker->track(746965179400); 25 | 26 | $this->assertTrue($track->delivered()); 27 | $this->assertCount(9, $track->events()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/DataProviders/GuzzleClient.php: -------------------------------------------------------------------------------- 1 | client = new Client(); 21 | } 22 | 23 | 24 | /** 25 | * Request the given url. 26 | * 27 | * @param $url 28 | * @param array $options 29 | * 30 | * @return string 31 | */ 32 | public function get($url, $options = []) 33 | { 34 | return $this->client->get($url, $options)->getBody()->getContents(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sauladam/shipment-tracker", 3 | "description": "Parses tracking information for several carriers, like UPS, USPS, DHL and GLS by simply scraping the data. No need for any kind of API access.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "shipment", 7 | "parcel", 8 | "tracking", 9 | "ups", 10 | "dhl", 11 | "gls", 12 | "usps", 13 | "swiss post" 14 | ], 15 | "authors": [ 16 | { 17 | "name": "Saulius Adamonis" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=5.5.0", 22 | "guzzlehttp/guzzle": ">=3.9", 23 | "nesbot/carbon": ">=1.20", 24 | "ext-json": "*", 25 | "ext-dom": "*" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "~4.7" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Sauladam\\ShipmentTracker\\": "src" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "files": [ 37 | "tests/TestCase.php", 38 | "tests/FileMapperDataProvider.php" 39 | ] 40 | } 41 | } -------------------------------------------------------------------------------- /src/DataProviders/Registry.php: -------------------------------------------------------------------------------- 1 | providers; 21 | } 22 | 23 | 24 | /** 25 | * Register a provider under the given name. 26 | * 27 | * @param string $name 28 | * @param DataProviderInterface $provider 29 | */ 30 | public function register($name, DataProviderInterface $provider) 31 | { 32 | $this->providers[$name] = $provider; 33 | } 34 | 35 | 36 | /** 37 | * Get the provider with the given name. 38 | * 39 | * @param $name 40 | * 41 | * @return DataProviderInterface 42 | */ 43 | public function get($name) 44 | { 45 | return $this->providers[$name]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Utils/UtilsTest.php: -------------------------------------------------------------------------------- 1 | assertFalse(mb_check_encoding($string, 'utf-8')); 11 | 12 | $string = \Sauladam\ShipmentTracker\Utils\Utils::ensureUtf8($string); 13 | 14 | $this->assertTrue(mb_check_encoding($string, 'utf-8')); 15 | } 16 | 17 | 18 | /** @test */ 19 | public function it_encodes_an_array_of_strings() 20 | { 21 | $strings = [ 22 | utf8_decode('motörheád-1'), 23 | utf8_decode('motörheád-2'), 24 | utf8_decode('motörheád-2'), 25 | ]; 26 | 27 | foreach ($strings as $string) { 28 | $this->assertFalse(mb_check_encoding($string, 'utf-8')); 29 | } 30 | 31 | $strings = \Sauladam\ShipmentTracker\Utils\Utils::ensureUtf8($strings); 32 | 33 | foreach ($strings as $string) { 34 | $this->assertTrue(mb_check_encoding($string, 'utf-8')); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Utils/AdditionalDetails.php: -------------------------------------------------------------------------------- 1 | additional[$key] = $data; 24 | 25 | return $this; 26 | } 27 | 28 | 29 | /** 30 | * Get additional information. 31 | * 32 | * @param string|null $key 33 | * @param null $default 34 | * 35 | * @return array 36 | */ 37 | public function getAdditionalDetails($key = null, $default = null) 38 | { 39 | if (!$key) { 40 | return $this->additional; 41 | } 42 | 43 | return array_key_exists($key, $this->additional) 44 | ? $this->additional[$key] 45 | : $default; 46 | } 47 | 48 | 49 | /** 50 | * Check if there is additional information. 51 | * 52 | * @return bool 53 | */ 54 | public function hasAdditionalDetails() 55 | { 56 | return !empty($this->additional); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Utils/AdditionalDetailsTest.php: -------------------------------------------------------------------------------- 1 | addAdditionalDetails('key', 'data'); 11 | 12 | $this->assertTrue($testClass->hasAdditionalDetails()); 13 | } 14 | 15 | 16 | /** @test */ 17 | public function it_retrieves_additional_information_by_key() 18 | { 19 | $testClass = new AdditionalTestClass; 20 | 21 | $testClass->addAdditionalDetails('foo', 'bar'); 22 | 23 | $this->assertSame('bar', $testClass->getAdditionalDetails('foo')); 24 | } 25 | 26 | 27 | /** @test */ 28 | public function it_gets_all_additional_information_if_no_key_is_provided() 29 | { 30 | $testClass = new AdditionalTestClass; 31 | 32 | $testClass->addAdditionalDetails('foo', 'bar'); 33 | $testClass->addAdditionalDetails('bar', 'baz'); 34 | 35 | $this->assertCount(2, $testClass->getAdditionalDetails()); 36 | } 37 | 38 | 39 | /** @test */ 40 | public function it_returns_the_default_value_if_the_given_key_does_not_exist() 41 | { 42 | $testClass = new AdditionalTestClass; 43 | 44 | $this->assertSame('foo', $testClass->getAdditionalDetails('non-existent', 'foo')); 45 | } 46 | } 47 | 48 | 49 | class AdditionalTestClass 50 | { 51 | use \Sauladam\ShipmentTracker\Utils\AdditionalDetails; 52 | } 53 | -------------------------------------------------------------------------------- /tests/FileMapperDataProvider.php: -------------------------------------------------------------------------------- 1 | carrier = $carrier; 32 | 33 | if (is_array($fileName)) { 34 | $this->lookup = $fileName; 35 | } else { 36 | $this->fileName = $fileName; 37 | } 38 | } 39 | 40 | 41 | /** 42 | * Request the given url. 43 | * 44 | * @param string $url 45 | * 46 | * @return string 47 | */ 48 | public function get($url) 49 | { 50 | return file_get_contents($this->mapToFile($url)); 51 | } 52 | 53 | 54 | /** 55 | * Map the url to a path. 56 | * 57 | * @param string $url 58 | * 59 | * @return string 60 | * @throws Exception 61 | */ 62 | protected function mapToFile($url) 63 | { 64 | $basePath = __DIR__ . '/mock/' . $this->carrier . '/'; 65 | 66 | if ($this->fileName) { 67 | return $basePath . $this->fileName; 68 | } 69 | 70 | if (!array_key_exists($url, $this->lookup)) { 71 | throw new Exception("Could not resolve URL [{$url}] to a path."); 72 | } 73 | 74 | return $basePath . $this->lookup[$url]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Trackers/USPSTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('USPS'); 20 | } 21 | 22 | 23 | /** @test */ 24 | public function it_extends_the_abstract_tracker() 25 | { 26 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 27 | } 28 | 29 | 30 | /** @test */ 31 | public function it_builds_the_tracking_url() 32 | { 33 | $url = $this->tracker->trackingUrl('RT654906222DE'); 34 | 35 | $this->assertSame('https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=RT654906222DE', $url); 36 | } 37 | 38 | 39 | /** @test */ 40 | public function it_accepts_additional_url_params() 41 | { 42 | $url = $this->tracker->trackingUrl('RT654906222DE', null, ['foo' => 'bar']); 43 | 44 | $this->assertSame('https://tools.usps.com/go/TrackConfirmAction?qtc_tLabels1=RT654906222DE&foo=bar', $url); 45 | } 46 | 47 | 48 | /** @test */ 49 | public function it_resolves_a_delivered_shipment() 50 | { 51 | $tracker = $this->getTracker('delivered.txt'); 52 | 53 | $track = $tracker->track('RT654906222DE'); 54 | 55 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 56 | $this->assertTrue($track->delivered()); 57 | $this->assertNull($track->getRecipient()); 58 | $this->assertCount(3, $track->events()); 59 | } 60 | 61 | 62 | /** @test */ 63 | public function it_resolves_an_an_in_transit_status_if_the_shipment_is_on_its_way() 64 | { 65 | $this->markTestSkipped("No real world data available at the moment."); 66 | return; 67 | 68 | $tracker = $this->getTracker('in_transit.txt'); 69 | 70 | $track = $tracker->track('RT654907846DE'); 71 | 72 | $this->assertSame(Track::STATUS_IN_TRANSIT, $track->currentStatus()); 73 | $this->assertFalse($track->delivered()); 74 | } 75 | 76 | 77 | /** 78 | * Build the tracker with a custom test client. 79 | * 80 | * @param $fileName 81 | * 82 | * @return AbstractTracker 83 | */ 84 | protected function getTracker($fileName) 85 | { 86 | return $this->getTrackerMock('USPS', $fileName); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/EventTest.php: -------------------------------------------------------------------------------- 1 | event = new Event; 20 | } 21 | 22 | 23 | /** @test */ 24 | public function it_can_access_the_location() 25 | { 26 | $this->event->setLocation('some location'); 27 | 28 | $this->assertSame('some location', $this->event->getLocation()); 29 | } 30 | 31 | 32 | /** @test */ 33 | public function it_can_access_the_date() 34 | { 35 | $this->event->setDate(Carbon::parse('2016-01-31')); 36 | 37 | $this->assertSame('2016-01-31', $this->event->getDate()->toDateString()); 38 | } 39 | 40 | 41 | /** @test */ 42 | public function it_converts_the_date_to_carbon_if_passed_as_string() 43 | { 44 | $this->event->setDate('2016-01-31'); 45 | 46 | $this->assertInstanceOf(Carbon::class, $this->event->getDate()); 47 | $this->assertSame('2016-01-31', $this->event->getDate()->toDateString()); 48 | } 49 | 50 | 51 | /** @test */ 52 | public function it_can_access_the_description() 53 | { 54 | $this->event->setDescription('some description'); 55 | 56 | $this->assertSame('some description', $this->event->getDescription()); 57 | } 58 | 59 | 60 | /** @test */ 61 | public function it_can_access_the_status() 62 | { 63 | $this->event->setStatus('delivered'); 64 | 65 | $this->assertSame('delivered', $this->event->getStatus()); 66 | } 67 | 68 | 69 | /** @test */ 70 | public function it_can_access_additional_information() 71 | { 72 | $this->event->addAdditionalDetails('foo', 'additional info'); 73 | 74 | $this->assertTrue($this->event->hasAdditionalDetails()); 75 | $this->assertSame('additional info', $this->event->getAdditionalDetails('foo')); 76 | } 77 | 78 | 79 | /** @test */ 80 | public function it_can_build_an_event_from_array() 81 | { 82 | $data = [ 83 | 'location' => 'some location', 84 | 'date' => Carbon::parse('2016-01-31'), 85 | 'description' => 'some description', 86 | 'status' => 'delivered', 87 | ]; 88 | 89 | $event = Event::fromArray($data); 90 | 91 | $this->assertInstanceOf(Event::class, $event); 92 | $this->assertSame('some location', $event->getLocation()); 93 | $this->assertSame('2016-01-31', $event->getDate()->toDateString()); 94 | $this->assertSame('some description', $event->getDescription()); 95 | $this->assertSame('delivered', $event->getStatus()); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/TrackTest.php: -------------------------------------------------------------------------------- 1 | track = new Track; 19 | } 20 | 21 | 22 | /** @test */ 23 | public function it_can_access_the_events() 24 | { 25 | $event = $this->getEvents(); 26 | 27 | $this->track->addEvent($event); 28 | 29 | $this->assertTrue($this->track->hasEvents()); 30 | $this->assertCount(1, $this->track->events()); 31 | } 32 | 33 | 34 | /** @test */ 35 | public function it_gets_the_latest_event() 36 | { 37 | $event1 = $this->getEvents(); 38 | $event2 = $this->getEvents(); 39 | $event3 = $this->getEvents(); 40 | 41 | $this->track->addEvent($event1); 42 | $this->track->addEvent($event2); 43 | $this->track->addEvent($event3); 44 | 45 | $this->assertCount(3, $this->track->events()); 46 | $this->assertSame($event3, $this->track->latestEvent()); 47 | } 48 | 49 | 50 | /** @test */ 51 | public function it_sorts_the_events_by_date_in_descending_order() 52 | { 53 | $event1 = Event::fromArray(['date' => '2016-01-31 14:00:00']); 54 | $event2 = Event::fromArray(['date' => '2016-01-31 15:00:00']); 55 | $event3 = Event::fromArray(['date' => '2016-01-31 16:00:00']); 56 | 57 | $this->track->addEvent($event3); 58 | $this->track->addEvent($event1); 59 | $this->track->addEvent($event2); 60 | 61 | $this->assertSame($event3, $this->track->events()[0]); 62 | $this->assertSame($event1, $this->track->events()[1]); 63 | $this->assertSame($event2, $this->track->events()[2]); 64 | 65 | $this->track->sortEvents(); 66 | 67 | $this->assertSame($event3, $this->track->events()[0]); 68 | $this->assertSame($event2, $this->track->events()[1]); 69 | $this->assertSame($event1, $this->track->events()[2]); 70 | } 71 | 72 | 73 | /** 74 | * @param int $count 75 | * 76 | * @return Event|Event[] 77 | */ 78 | protected function getEvents($count = 1) 79 | { 80 | $eventData = [ 81 | 'location' => 'some location', 82 | 'date' => '2016-01-31', 83 | 'description' => 'some description', 84 | 'status' => 'delivered', 85 | ]; 86 | 87 | if ($count == 1) { 88 | return Event::fromArray($eventData); 89 | } 90 | 91 | $events = []; 92 | 93 | for ($x = 1; $x <= $count; $x++) { 94 | $events[] = Event::fromArray($eventData); 95 | } 96 | 97 | return $events; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/mock/GLS/in_transit.txt: -------------------------------------------------------------------------------- 1 | {"tuStatus":[{"arrivalTime":{"name":"Zustellung voraussichtlich:","value":"14:00 - 15:30"},"deliveryOwnerCode":"DE03","history":[{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"18:55:35","evtDscr":"Das Paket hat das Paketzentrum verlassen."},{"address":{"city":"Erding","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"13:56:34","evtDscr":"Das Paket wurde erfolgreich zugestellt."},{"address":{"city":"Erding","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"06:49:33","evtDscr":"Das Paket wird voraussichtlich im Laufe des Tages zugestellt."},{"address":{"city":"Erding","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"06:45:13","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"00:00:49","evtDscr":"Das Paket ist im Paketzentrum eingetroffen; das Paket wurde manuell sortiert."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"00:00:48","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"Santa Perpètua de Mogoda","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-15","time":"00:56:43","evtDscr":"Das Paket hat das Paketzentrum verlassen."},{"address":{"city":"San Juan de Mozarrifar","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-14","time":"19:29:38","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"San Juan de Mozarrifar","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-14","time":"19:29:38","evtDscr":"Das Paket wurde an GLS übergeben."},{"address":{"city":"San Juan de Mozarrifar","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-14","time":"10:26:36","evtDscr":"Die Paketdaten wurden im GLS IT-System erfasst; das Paket wurde noch nicht an GLS übergeben."}],"infos":[{"type":"WEIGHT","name":"Gewicht:","value":"0.2 kg"},{"type":"PRODUCT","name":"Produkt:","value":"EuroBusinessSmallParcel"}],"owners":[{"type":"REQUEST","code":"ES01"},{"type":"DELIVERY","code":"DE03"}],"progressBar":{"retourFlag":false,"statusText":"Unterwegs","statusInfo":"INTRANSIT","evtNos":["1.0","3.0","11.0","2.0","2.106","2.0","1.0","2.29","0.0","0.100"],"colourIndex":3,"level":50,"statusBar":[{"status":"PREADVICE","imageStatus":"COMPLETE","imageText":"Datenerfassung","statusText":""},{"status":"INTRANSIT","imageStatus":"CURRENT","imageText":"Unterwegs","statusText":"Das Paket ist unterwegs zum Ziel-Paketzentrum."},{"status":"INWAREHOUSE","imageStatus":"PENDING","imageText":"Ziel-Paketzentrum","statusText":""},{"status":"INDELIVERY","imageStatus":"PENDING","imageText":"In Zustellung","statusText":""},{"status":"DELIVERED","imageStatus":"PENDING","imageText":"Zugestellt","statusText":""}]},"references":[{"type":"UNITNO","name":"Paketnummer:","value":"32631986451"},{"type":"UNIQUENO","name":"Track ID","value":"Z51UTO2B"},{"type":"GLSREF","name":"Nationale Kundenreferenz","value":"320000433"}],"signature":{"name":"Unterschrift:","value":"true","validate":true},"tuNo":"32631986451","changeDeliveryPossible":false}]} -------------------------------------------------------------------------------- /tests/ShipmentTrackerTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(DHL::class, $tracker); 19 | } 20 | 21 | 22 | /** @test */ 23 | public function it_resolves_the_gls_tracker() 24 | { 25 | $tracker = ShipmentTracker::get('GLS'); 26 | 27 | $this->assertInstanceOf(GLS::class, $tracker); 28 | } 29 | 30 | 31 | /** @test */ 32 | public function it_resolves_the_ups_tracker() 33 | { 34 | $tracker = ShipmentTracker::get('UPS'); 35 | 36 | $this->assertInstanceOf(UPS::class, $tracker); 37 | } 38 | 39 | 40 | /** @test */ 41 | public function it_resolves_the_usps_tracker() 42 | { 43 | $tracker = ShipmentTracker::get('USPS'); 44 | 45 | $this->assertInstanceOf(USPS::class, $tracker); 46 | } 47 | 48 | 49 | /** @test */ 50 | public function it_resolves_the_post_ch_tracker() 51 | { 52 | $tracker = ShipmentTracker::get('PostCH'); 53 | 54 | $this->assertInstanceOf(PostCH::class, $tracker); 55 | } 56 | 57 | /** @test */ 58 | public function it_resolves_the_fedex_tracker() 59 | { 60 | $tracker = ShipmentTracker::get('Fedex'); 61 | 62 | $this->assertInstanceOf(Fedex::class, $tracker); 63 | } 64 | 65 | 66 | /** 67 | * @test 68 | * @expectedException Exception 69 | */ 70 | public function it_throws_an_exception_if_the_carrier_is_unknown() 71 | { 72 | ShipmentTracker::get('some-nonexistent-tracker'); 73 | } 74 | 75 | /** 76 | * @test 77 | */ 78 | public function testSetCustomizeCarrier() 79 | { 80 | try { 81 | ShipmentTracker::get('foo-carrier'); 82 | $this->fail(); 83 | } catch (\Exception $exception) { 84 | $this->assertInstanceOf(Exception::class, $exception); 85 | ShipmentTracker::set('foo-carrier', ValidTracker::class); 86 | $tracker = ShipmentTracker::get('foo-carrier'); 87 | $this->assertInstanceOf(ValidTracker::class, $tracker); 88 | } 89 | try{ 90 | ShipmentTracker::set('bar-carrier', InvalidTracker::class); 91 | } catch (\Exception $exception) { 92 | $this->assertInstanceOf(\InvalidArgumentException::class, $exception); 93 | } 94 | try{ 95 | ShipmentTracker::set('baz-carrier', 'NotExistingCarrier'); 96 | } catch (\Exception $exception) { 97 | $this->assertInstanceOf(\InvalidArgumentException::class, $exception); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Trackers/PostCHTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('PostCH'); 20 | } 21 | 22 | 23 | /** @test */ 24 | public function it_extends_the_abstract_tracker() 25 | { 26 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 27 | } 28 | 29 | 30 | /** @test */ 31 | public function it_builds_the_tracking_url() 32 | { 33 | $url = $this->tracker->trackingUrl('RB592593703DE'); 34 | 35 | $this->assertSame( 36 | 'https://service.post.ch/EasyTrack/submitParcelData.do?formattedParcelCodes=RB592593703DE&lang=de', 37 | $url 38 | ); 39 | } 40 | 41 | 42 | /** @test */ 43 | public function it_can_override_the_language_for_the_url() 44 | { 45 | $url = $this->tracker->trackingUrl('RB592593703DE', 'en'); 46 | 47 | $this->assertSame( 48 | 'https://service.post.ch/EasyTrack/submitParcelData.do?formattedParcelCodes=RB592593703DE&lang=en', 49 | $url 50 | ); 51 | } 52 | 53 | 54 | /** @test */ 55 | public function it_accepts_additional_url_params() 56 | { 57 | $url = $this->tracker->trackingUrl('RB592593703DE', null, ['foo' => 'bar']); 58 | 59 | $this->assertSame( 60 | 'https://service.post.ch/EasyTrack/submitParcelData.do?formattedParcelCodes=RB592593703DE&lang=de&foo=bar', 61 | $url 62 | ); 63 | } 64 | 65 | 66 | /** @test */ 67 | public function it_resolves_a_delivered_shipment() 68 | { 69 | $this->markTestSkipped(); 70 | 71 | $tracker = $this->getTracker('delivered.txt'); 72 | 73 | $track = $tracker->track('RB592593703DE'); 74 | 75 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 76 | $this->assertTrue($track->delivered()); 77 | $this->assertCount(8, $track->events()); 78 | } 79 | 80 | /** @test */ 81 | public function it_resolves_an_unknown_status_if_there_is_not_data_available_yet() 82 | { 83 | $this->markTestSkipped(); 84 | 85 | $tracker = $this->getTracker('unknown.txt'); 86 | 87 | $track = $tracker->track('RB592593703DE'); 88 | 89 | $this->assertSame(Track::STATUS_UNKNOWN, $track->currentStatus()); 90 | $this->assertFalse($track->delivered()); 91 | $this->assertCount(0, $track->events()); 92 | } 93 | 94 | 95 | /** 96 | * Build the tracker with a custom test client. 97 | * 98 | * @param $fileName 99 | * 100 | * @return AbstractTracker 101 | */ 102 | protected function getTracker($fileName) 103 | { 104 | return $this->getTrackerMock('PostCH', $fileName); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Event.php: -------------------------------------------------------------------------------- 1 | location; 42 | } 43 | 44 | 45 | /** 46 | * Set the location. 47 | * 48 | * @param $location 49 | * 50 | * @return $this 51 | */ 52 | public function setLocation($location) 53 | { 54 | $this->location = $location; 55 | 56 | return $this; 57 | } 58 | 59 | 60 | /** 61 | * Get the date. 62 | * 63 | * @return Carbon 64 | */ 65 | public function getDate() 66 | { 67 | return $this->date; 68 | } 69 | 70 | 71 | /** 72 | * Set the date. 73 | * 74 | * @param string|Carbon $date 75 | * 76 | * @return $this 77 | */ 78 | public function setDate($date) 79 | { 80 | $date = $date instanceof Carbon ? $date : Carbon::parse($date); 81 | 82 | $this->date = $date; 83 | 84 | return $this; 85 | } 86 | 87 | 88 | /** 89 | * Get the description. 90 | * 91 | * @return string 92 | */ 93 | public function getDescription() 94 | { 95 | return $this->description; 96 | } 97 | 98 | 99 | /** 100 | * Set the description. 101 | * 102 | * @param $description 103 | * 104 | * @return $this 105 | */ 106 | public function setDescription($description) 107 | { 108 | $this->description = Utils::ensureUtf8($description); 109 | 110 | return $this; 111 | } 112 | 113 | 114 | /** 115 | * Get the status during this event. 116 | * 117 | * @return string 118 | */ 119 | public function getStatus() 120 | { 121 | return $this->status; 122 | } 123 | 124 | 125 | /** 126 | * Set the status. 127 | * 128 | * @param $status 129 | * 130 | * @return $this 131 | */ 132 | public function setStatus($status) 133 | { 134 | $this->status = $status; 135 | 136 | return $this; 137 | } 138 | 139 | 140 | /** 141 | * Create an event from the given array. 142 | * 143 | * @param array $data 144 | * 145 | * @return Event 146 | */ 147 | public static function fromArray(array $data) 148 | { 149 | $event = new self; 150 | 151 | $eligibleKeys = ['date', 'location', 'description', 'status']; 152 | 153 | $data = array_intersect_key($data, array_flip($eligibleKeys)); 154 | 155 | foreach ($data as $key => $value) { 156 | $setter = 'set' . ucfirst($key); 157 | 158 | $event->{$setter}($value); 159 | } 160 | 161 | return $event; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /tests/Trackers/PostATTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('PostAT'); 20 | } 21 | 22 | 23 | /** @test */ 24 | public function it_extends_the_abstract_tracker() 25 | { 26 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 27 | } 28 | 29 | 30 | /** @test */ 31 | public function it_builds_the_tracking_url() 32 | { 33 | $url = $this->tracker->trackingUrl('RC320145308DE'); 34 | 35 | $this->assertSame( 36 | 'https://www.post.at/sendungsverfolgung.php/details?pnum1=RC320145308DE', 37 | $url 38 | ); 39 | } 40 | 41 | 42 | /** @test */ 43 | public function it_can_override_the_language_for_the_url() 44 | { 45 | $url = $this->tracker->trackingUrl('RC320145308DE', 'en'); 46 | 47 | $this->assertSame( 48 | 'https://www.post.at/en/track_trace.php/details?pnum1=RC320145308DE', 49 | $url 50 | ); 51 | } 52 | 53 | 54 | /** @test */ 55 | public function it_accepts_additional_url_params() 56 | { 57 | $url = $this->tracker->trackingUrl('RC320145308DE', null, ['foo' => 'bar']); 58 | 59 | $this->assertSame( 60 | 'https://www.post.at/sendungsverfolgung.php/details?pnum1=RC320145308DE&foo=bar', 61 | $url 62 | ); 63 | } 64 | 65 | 66 | /** @test */ 67 | public function it_resolves_a_delivered_shipment() 68 | { 69 | $tracker = $this->getTracker('delivered.txt'); 70 | 71 | $track = $tracker->track('RC320145342DE'); 72 | 73 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 74 | $this->assertTrue($track->delivered()); 75 | $this->assertCount(6, $track->events()); 76 | } 77 | 78 | 79 | /** @test */ 80 | public function it_resolves_an_in_transit_shipment() 81 | { 82 | $tracker = $this->getTracker('in_transit.txt'); 83 | 84 | $track = $tracker->track('RC320145308DE'); 85 | 86 | $this->assertSame(Track::STATUS_IN_TRANSIT, $track->currentStatus()); 87 | $this->assertFalse($track->delivered()); 88 | $this->assertCount(4, $track->events()); 89 | } 90 | 91 | 92 | /** @test */ 93 | public function it_resolves_an_pick_up_shipment() 94 | { 95 | $tracker = $this->getTracker('pick_up.txt'); 96 | 97 | $track = $tracker->track('RC320145223DE'); 98 | 99 | $this->assertSame(Track::STATUS_PICKUP, $track->currentStatus()); 100 | $this->assertFalse($track->delivered()); 101 | $this->assertCount(7, $track->events()); 102 | } 103 | 104 | 105 | /** 106 | * Build the tracker with a custom test client. 107 | * 108 | * @param $fileName 109 | * 110 | * @return AbstractTracker 111 | */ 112 | protected function getTracker($fileName) 113 | { 114 | return $this->getTrackerMock('PostAT', $fileName); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /tests/mock/GLS/delivered.txt: -------------------------------------------------------------------------------- 1 | {"tuStatus":[{"owners":[{"type":"REQUEST","code":"DE03"},{"type":"DELIVERY","code":"DE03"}],"deliveryOwnerCode":"DE03","tuNo":"50346007538","progressBar":{"retourFlag":false,"statusText":"Zugestellt","statusInfo":"DELIVERED","evtNos":["3.0","2.124","3.124","90.132","4.40","90.132","11.0","2.0","1.0","2.0","2.0","0.0","0.100"],"colourIndex":4,"level":100},"signature":{"name":"Unterschrift:","value":"TANGER(2760236908)"},"changeDeliveryPossible":false,"infos":[{"type":"WEIGHT","name":"Gewicht:","value":"3.5 kg"},{"type":"PRODUCT","name":"Produkt:","value":"BusinessSmallParcel"}],"references":[{"type":"UNITNO","name":"Paketnummer:","value":"50346007538"},{"type":"NOTECARDID","name":"Track-ID","value":"37ETGI"},{"type":"GLSREF","name":"Nationale Kundenreferenz","value":"52285"},{"type":"CUSTREF","name":"Kundeneigene Referenznummer","value":"YH24307-2016"}],"notificationCardId":"37ETGI","parcelShop":{"psNo":2012689312,"psID":"2760236908"},"history":[{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-08","time":"18:09:21","evtDscr":"Das Paket wurde beim Empfänger zugestellt."},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"14:04:10","evtDscr":"Das Paket ist im GLS PaketShop eingetroffen."},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"13:41:04","evtDscr":"Das Paket wurde im GLS PaketShop zugestellt (siehe Sendungsinformationen)"},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"13:23:41","evtDscr":"Der Empfänger wurde mittels Benachrichtigungskarte über den Zustell-/Abholversuch informiert."},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"13:23:41","evtDscr":"Das Paket konnte nicht zugestellt werden, da der Empfänger nicht angetroffen wurde."},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"13:22:00","evtDscr":"Der Empfänger wurde mittels Benachrichtigungskarte über den Zustell-/Abholversuch informiert."},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"07:24:34","evtDscr":"Das Paket wurde auf das GLS Zustellfahrzeug verladen und ist für die Zustellung im Laufe des Tages vorgesehen."},{"address":{"city":"Bernau OT Schönow","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-07","time":"07:18:57","evtDscr":"Das Paket ist im GLS Standort eingetroffen."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-06","time":"19:30:41","evtDscr":"Das Paket hat den GLS Standort verlassen."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-06","time":"19:30:41","evtDscr":"Das Paket ist im GLS Standort eingetroffen."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-06","time":"19:28:58","evtDscr":"Das Paket ist im GLS Standort eingetroffen."},{"address":{"city":"Bornheim","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-05","time":"18:57:40","evtDscr":"Das Paket wurde an das GLS System übergeben."},{"address":{"city":"Bornheim","countryCode":"DE","countryName":"Deutschland"},"date":"2016-01-05","time":"14:19:14","evtDscr":"Die Paketdaten wurden im GLS IT-System erfasst; das Paket wurde noch nicht an GLS übergeben."}]}]} 2 | -------------------------------------------------------------------------------- /src/ShipmentTracker.php: -------------------------------------------------------------------------------- 1 | useDataProvider('custom') : $tracker; 47 | } 48 | 49 | /** 50 | * Registers a customize carrier class 51 | * @param string $carrier 52 | * @param string $carrierClass 53 | * @throws \InvalidArgumentException 54 | */ 55 | public static function set($carrier, $carrierClass) 56 | { 57 | if (!static::isValidCarrierClass($carrierClass)) { 58 | throw new \InvalidArgumentException(sprintf('The carrier class "%s" is invalid', $carrierClass)); 59 | } 60 | static::$customizeTrackerClasses[$carrier] = $carrierClass; 61 | } 62 | 63 | protected static function isValidCarrierClass($carrierClass) 64 | { 65 | return class_exists($carrierClass) && is_subclass_of($carrierClass, AbstractTracker::class); 66 | } 67 | 68 | /** 69 | * Get the registry for the data providers. 70 | * 71 | * @param DataProviderInterface $customProvider 72 | * 73 | * @return Registry 74 | */ 75 | protected static function getDataProviderRegistry(DataProviderInterface $customProvider = null) 76 | { 77 | $registry = new Registry; 78 | 79 | $registry->register('guzzle', new GuzzleClient); 80 | $registry->register('php', new PhpClient); 81 | 82 | if ($customProvider) { 83 | $registry->register('custom', $customProvider); 84 | } 85 | 86 | return $registry; 87 | } 88 | 89 | /** 90 | * Check if a tracker exists for the given carrier. 91 | * 92 | * @param string $carrier 93 | * 94 | * @return bool 95 | */ 96 | protected static function isValidCarrier($carrier) 97 | { 98 | return class_exists(self::$carriersNamespace . '\\' . $carrier); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Trackers/GLSTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('GLS'); 19 | } 20 | 21 | 22 | /** @test */ 23 | public function it_extends_the_abstract_tracker() 24 | { 25 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 26 | } 27 | 28 | 29 | /** @test */ 30 | public function it_builds_the_tracking_url() 31 | { 32 | $url = $this->tracker->trackingUrl('123456789'); 33 | 34 | $this->assertSame('https://gls-group.eu/DE/de/paketverfolgung?match=123456789', $url); 35 | } 36 | 37 | 38 | /** @test */ 39 | public function it_can_override_the_language_for_the_url() 40 | { 41 | $url = $this->tracker->trackingUrl('123456789', 'en'); 42 | 43 | $this->assertSame('https://gls-group.eu/DE/en/parcel-tracking?match=123456789', $url); 44 | } 45 | 46 | 47 | /** @test */ 48 | public function it_resolves_an_in_transit_shipment() 49 | { 50 | $tracker = $this->getTracker('in_transit.txt'); 51 | 52 | $track = $tracker->track('Z51UTO2B'); 53 | 54 | $this->assertSame(\Sauladam\ShipmentTracker\Track::STATUS_IN_TRANSIT, $track->currentStatus()); 55 | $this->assertFalse($track->delivered()); 56 | $this->assertCount(10, $track->events()); 57 | } 58 | 59 | 60 | /** @test */ 61 | public function it_resolves_a_delivered_shipment() 62 | { 63 | $tracker = $this->getTracker('delivered.txt'); 64 | 65 | $track = $tracker->track('50346007538'); 66 | 67 | $this->assertSame(\Sauladam\ShipmentTracker\Track::STATUS_DELIVERED, $track->currentStatus()); 68 | $this->assertTrue($track->delivered()); 69 | $this->assertCount(13, $track->events()); 70 | } 71 | 72 | 73 | /** @test */ 74 | public function it_resolves_the_recipient_for_a_delivered_shipment() 75 | { 76 | $tracker = $this->getTracker('delivered.txt'); 77 | 78 | $track = $tracker->track('50346007538'); 79 | 80 | $this->assertSame('TANGER(2760236908)', $track->getRecipient()); 81 | } 82 | 83 | 84 | /** @test */ 85 | public function it_sets_the_parcel_shop_details_if_it_the_parcel_was_or_is_delivered_to_a_parcel_shop() 86 | { 87 | $tracker = $this->getTracker('pick_up.txt'); 88 | 89 | $track = $tracker->track('50346007538'); 90 | 91 | $this->assertNotEmpty($track->getAdditionalDetails('parcelShop')); 92 | } 93 | 94 | 95 | /** @test */ 96 | public function it_stores_the_gls_event_number_for_each_event() 97 | { 98 | $tracker = $this->getTracker('delivered.txt'); 99 | 100 | $track = $tracker->track('50346007538'); 101 | 102 | foreach ($track->events() as $event) { 103 | $this->assertNotEmpty($event->getAdditionalDetails('eventNumber')); 104 | } 105 | } 106 | 107 | 108 | /** 109 | * Build the tracker with a custom test client. 110 | * 111 | * @param string|array $fileName 112 | * 113 | * @return AbstractTracker 114 | */ 115 | protected function getTracker($fileName) 116 | { 117 | return $this->getTrackerMock('GLS', $fileName); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/Track.php: -------------------------------------------------------------------------------- 1 | events = $events; 43 | } 44 | 45 | 46 | /** 47 | * Get all events. 48 | * 49 | * @return Event[] 50 | */ 51 | public function events() 52 | { 53 | return $this->events; 54 | } 55 | 56 | 57 | /** 58 | * Add an event. 59 | * 60 | * @param Event $event 61 | * 62 | * @return $this 63 | */ 64 | public function addEvent(Event $event) 65 | { 66 | $this->events[] = $event; 67 | 68 | return $this; 69 | } 70 | 71 | 72 | /** 73 | * Check if the shipment has been delivered. 74 | * 75 | * @return bool 76 | */ 77 | public function delivered() 78 | { 79 | $deliveredEvents = array_filter($this->events, function (Event $event) { 80 | return $event->getStatus() == self::STATUS_DELIVERED; 81 | }); 82 | 83 | return !empty($deliveredEvents); 84 | } 85 | 86 | 87 | /** 88 | * Get the current status. 89 | * 90 | * @return string 91 | */ 92 | public function currentStatus() 93 | { 94 | $latestEvent = $this->latestEvent(); 95 | 96 | return $latestEvent ? $latestEvent->getStatus() : self::STATUS_UNKNOWN; 97 | } 98 | 99 | 100 | /** 101 | * Get the latest event. 102 | * 103 | * @return null|Event 104 | */ 105 | public function latestEvent() 106 | { 107 | if (!$this->hasEvents()) { 108 | return null; 109 | } 110 | 111 | return $this->eventsAreSorted ? reset($this->events) : end($this->events); 112 | } 113 | 114 | 115 | /** 116 | * Check if this track has any events. 117 | * 118 | * @return bool 119 | */ 120 | public function hasEvents() 121 | { 122 | return !empty($this->events); 123 | } 124 | 125 | 126 | /** 127 | * Get the recipient. 128 | * 129 | * @return string 130 | */ 131 | public function getRecipient() 132 | { 133 | return $this->recipient; 134 | } 135 | 136 | 137 | /** 138 | * Set the recipient. 139 | * 140 | * @param string $recipient 141 | * 142 | * @return $this 143 | */ 144 | public function setRecipient($recipient) 145 | { 146 | $this->recipient = Utils::ensureUtf8($recipient); 147 | 148 | return $this; 149 | } 150 | 151 | 152 | /** 153 | * Sort the events by date in descending oder, so that the latest event is always 154 | * the first item in the array. 155 | * 156 | * @return $this 157 | */ 158 | public function sortEvents() 159 | { 160 | usort($this->events, function (Event $a, Event $b) { 161 | if ($a->getDate()->toDateTimeString() == $b->getDate()->toDateTimeString()) { 162 | return 0; 163 | } 164 | 165 | return ($a->getDate()->toDateTimeString() > $b->getDate()->toDateTimeString()) ? -1 : 1; 166 | }); 167 | 168 | $this->eventsAreSorted = true; 169 | 170 | return $this; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/Utils/XmlHelpers.php: -------------------------------------------------------------------------------- 1 | getNodeValueWithLineBreaks($element) 30 | : $element->nodeValue; 31 | 32 | $value = trim($value); 33 | 34 | return preg_replace(['/\s\s+/', '/\p{Zs}/u'], ' ', $value); 35 | } 36 | 37 | 38 | /** 39 | * Get the node value but mark line breaks with a '|' (pipe). 40 | * 41 | * @param DOMText|DOMNode $element 42 | * @return string 43 | */ 44 | protected function getNodeValueWithLineBreaks($element) 45 | { 46 | if (!$element->hasChildNodes()) { 47 | return $element->nodeValue; 48 | } 49 | 50 | $value = ''; 51 | 52 | foreach ($element->childNodes as $node) { 53 | if ($node->nodeType != XML_ELEMENT_NODE || $node->nodeName != 'br') { 54 | $value .= $this->getNodeValue($node); 55 | continue; 56 | } 57 | 58 | $value .= "|"; 59 | } 60 | 61 | return rtrim($value, '|'); 62 | } 63 | 64 | 65 | /** 66 | * Get the description for the given terms. 67 | * 68 | * @param $term 69 | * @param DOMXPath $xpath 70 | * @param bool $withLineBreaks 71 | * @param string $termTag 72 | * @param string $descriptionTag 73 | * 74 | * @return null|string 75 | */ 76 | protected function getDescriptionForTerm( 77 | $term, 78 | DOMXPath $xpath, 79 | $withLineBreaks = false, 80 | $termTag = 'dt', 81 | $descriptionTag = 'dd' 82 | ) 83 | { 84 | $terms = is_array($term) ? $term : [$term]; 85 | 86 | $listNodes = $xpath->query("//dl"); 87 | 88 | if (!$listNodes) { 89 | return null; 90 | } 91 | 92 | $descriptionPairBelongsToTerm = false; 93 | 94 | foreach ($listNodes as $list) { 95 | foreach ($list->childNodes as $descriptionNode) { 96 | if (get_class($descriptionNode) != DOMElement::class) { 97 | continue; 98 | } 99 | 100 | if ($descriptionNode->tagName == $descriptionTag && $descriptionPairBelongsToTerm) { 101 | return $this->getNodeValue($descriptionNode, $withLineBreaks); 102 | } 103 | 104 | if ($descriptionNode->tagName != $termTag) { 105 | continue; 106 | } 107 | 108 | $descriptionTerm = $this->getNodeValue($descriptionNode); 109 | 110 | foreach ($terms as $term) { 111 | if ($this->startsWith($term, $descriptionTerm)) { 112 | $descriptionPairBelongsToTerm = true; 113 | break; 114 | } 115 | } 116 | } 117 | } 118 | 119 | return null; 120 | } 121 | 122 | 123 | /** 124 | * Check if the subject starts with the given string. 125 | * 126 | * @param string $start 127 | * @param string $subject 128 | * @return bool 129 | */ 130 | protected function startsWith($start, $subject) 131 | { 132 | return strpos($subject, $start) === 0; 133 | } 134 | 135 | 136 | /** 137 | * Create an XPath instance from the given string. 138 | * 139 | * @param $string 140 | * @return DOMXPath 141 | */ 142 | public function toXpath($string) 143 | { 144 | $dom = new DOMDocument; 145 | @$dom->loadHTML($string); 146 | $dom->preserveWhiteSpace = false; 147 | 148 | return new DOMXPath($dom); 149 | } 150 | 151 | 152 | /** 153 | * Convert the DomNodeList to an array. 154 | * 155 | * @param DOMNodeList $list 156 | * @return array|DOMNodeList 157 | */ 158 | public function nodeListToArray(DOMNodeList $list) 159 | { 160 | return iterator_to_array($list); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Trackers/PostAT.php: -------------------------------------------------------------------------------- 1 | 'https://www.post.at/sendungsverfolgung.php/details', 17 | 'en' => 'https://www.post.at/en/track_trace.php/details', 18 | ]; 19 | 20 | protected $language = 'de'; 21 | 22 | 23 | /** 24 | * Build the url to the user friendly tracking site. In most 25 | * cases this is also the endpoint, but sometimes the tracking 26 | * data must be retrieved from another endpoint. 27 | * 28 | * @param string $trackingNumber 29 | * @param string|null $language 30 | * @param array $params 31 | * 32 | * @return string 33 | */ 34 | public function trackingUrl($trackingNumber, $language = null, $params = []) 35 | { 36 | $params = array_merge($this->trackingUrlParams, [ 37 | 'pnum1' => $trackingNumber, 38 | ], $params); 39 | 40 | $language = in_array($language, array_keys($this->serviceEndpoints)) 41 | ? $language 42 | : $this->language; 43 | 44 | return $this->serviceEndpoints[$language] . '?' . http_build_query($params); 45 | } 46 | 47 | /** 48 | * Build the response array. 49 | * 50 | * @param string $response 51 | * 52 | * @return Track 53 | */ 54 | protected function buildResponse($response) 55 | { 56 | $rows = $this->toXpath($response)->query("//div[@class='sendungsstatus-history']//ul//li"); 57 | 58 | if (!$rows) { 59 | throw new Exception("Could not parse tracking information for [{$this->parcelNumber}]."); 60 | } 61 | 62 | return array_reduce($this->nodeListToArray($rows), function ($track, $row) { 63 | return $track->addEvent($this->eventFromRow($row)); 64 | }, new Track)->sortEvents(); 65 | } 66 | 67 | 68 | /** 69 | * Parse the event data from the given node. 70 | * 71 | * @param \DOMNode $row 72 | * 73 | * @return Event 74 | */ 75 | protected function eventFromRow($row) 76 | { 77 | preg_match( 78 | '/(date|datum): ([\d.:\s]+)(.*?)(?:; (.*)|$)/i', 79 | utf8_decode($this->getNodeValue($row)), 80 | $matches 81 | ); 82 | 83 | return count($matches) < 4 84 | ? new Event 85 | : Event::fromArray([ 86 | 'date' => Carbon::parse($matches[2]), 87 | 'description' => $matches[3], 88 | 'location' => isset($matches[4]) ? $matches[4] : '', 89 | 'status' => $this->resolveStatus($matches[3]), 90 | ]); 91 | } 92 | 93 | 94 | /** 95 | * Match a shipping status from the given description. 96 | * 97 | * @param $statusDescription 98 | * 99 | * @return string 100 | */ 101 | protected function resolveStatus($statusDescription) 102 | { 103 | $statuses = [ 104 | Track::STATUS_DELIVERED => [ 105 | 'Delivered', 106 | 'Zugestellt', 107 | ], 108 | Track::STATUS_IN_TRANSIT => [ 109 | 'Item posted abroad', 110 | 'Postaufgabe im Ausland', 111 | 'Item ready for international transport', 112 | 'Sendung für Auslandstransport bereit', 113 | 'Item in process of delivery', 114 | 'Sendung in Zustellung', 115 | 'Item being processed in Austria', 116 | 'Sendung in Bearbeitung Österreich', 117 | 'Item arrived in Austria', 118 | 'Sendung in Österreich angekommen', 119 | 'soon ready for pick up', 120 | 'In Kürze abholbereit', 121 | ], 122 | Track::STATUS_PICKUP => [ 123 | 'ready for pick up', 124 | 'Sendung abholbereit', 125 | ], 126 | ]; 127 | 128 | foreach ($statuses as $status => $needles) { 129 | foreach ($needles as $needle) { 130 | if (stripos($statusDescription, $needle) !== false) { 131 | return $status; 132 | } 133 | } 134 | } 135 | 136 | return Track::STATUS_UNKNOWN; 137 | } 138 | } -------------------------------------------------------------------------------- /tests/Trackers/UPSTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('UPS'); 20 | } 21 | 22 | 23 | /** @test */ 24 | public function it_extends_the_abstract_tracker() 25 | { 26 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 27 | } 28 | 29 | 30 | /** @test */ 31 | public function it_builds_the_tracking_url() 32 | { 33 | $url = $this->tracker->trackingUrl('1ZW5244V6870200569'); 34 | 35 | $this->assertSame( 36 | 'https://www.ups.com/track?loc=de_DE&tracknum=1ZW5244V6870200569', 37 | $url 38 | ); 39 | } 40 | 41 | 42 | /** @test */ 43 | public function it_can_override_the_language_for_the_url() 44 | { 45 | $url = $this->tracker->trackingUrl('1ZW5244V6870200569', 'en'); 46 | 47 | $this->assertSame( 48 | 'https://www.ups.com/track?loc=en_US&tracknum=1ZW5244V6870200569', 49 | $url 50 | ); 51 | } 52 | 53 | 54 | /** @test */ 55 | public function it_accepts_additional_url_params() 56 | { 57 | $url = $this->tracker->trackingUrl('1ZW5244V6870200569', null, ['foo' => 'bar']); 58 | 59 | $this->assertSame( 60 | 'https://www.ups.com/track?loc=de_DE&tracknum=1ZW5244V6870200569&foo=bar', 61 | $url 62 | ); 63 | } 64 | 65 | 66 | /** @test */ 67 | public function it_resolves_a_delivered_shipment() 68 | { 69 | $this->markTestSkipped("Tests coming soon."); 70 | 71 | $tracker = $this->getTracker('delivered.txt'); 72 | 73 | $track = $tracker->track('1ZW5244V6870200569'); 74 | 75 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 76 | $this->assertSame('PROSENITCH', $track->getRecipient()); 77 | $this->assertTrue($track->delivered()); 78 | $this->assertCount(13, $track->events()); 79 | } 80 | 81 | 82 | /** @test */ 83 | public function it_resolves_an_exception_if_there_is_a_problem() 84 | { 85 | $this->markTestSkipped("Tests coming soon."); 86 | 87 | $tracker = $this->getTracker('exception.txt'); 88 | 89 | $track = $tracker->track('1ZW5244V6870129110'); 90 | 91 | $this->assertSame(Track::STATUS_EXCEPTION, $track->currentStatus()); 92 | $this->assertContains('The street number is incorrect.', $track->latestEvent()->getDescription()); 93 | } 94 | 95 | 96 | /** @test */ 97 | public function it_resolves_an_an_in_transit_status_if_the_shipment_is_on_its_way() 98 | { 99 | $this->markTestSkipped("Tests coming soon."); 100 | 101 | $tracker = $this->getTracker('in_transit.txt'); 102 | 103 | $track = $tracker->track('1ZW5244V6870200470'); 104 | 105 | $this->assertSame(Track::STATUS_IN_TRANSIT, $track->currentStatus()); 106 | $this->assertFalse($track->delivered()); 107 | } 108 | 109 | 110 | /** @test */ 111 | public function it_resolves_a_shipment_that_has_to_be_picked_up() 112 | { 113 | $this->markTestSkipped("Tests coming soon."); 114 | 115 | $tracker = $this->getTracker('pickup.txt'); 116 | 117 | $track = $tracker->track('1ZW5244V6870294478'); 118 | 119 | $this->assertSame(Track::STATUS_PICKUP, $track->currentStatus()); 120 | $this->assertFalse($track->delivered()); 121 | $this->assertNull($track->getRecipient()); 122 | $this->assertCount(10, $track->events()); 123 | } 124 | 125 | 126 | /** @test */ 127 | public function it_parses_the_access_point_details_and_the_pickup_due_date() 128 | { 129 | $this->markTestSkipped("Tests coming soon."); 130 | 131 | $tracker = $this->getTracker('pickup.txt'); 132 | 133 | $track = $tracker->track('1ZW5244V6870294478'); 134 | 135 | $this->assertSame('REITERSHOP|13 WEIMARER STRASSE|WIEN, 1180 AT', $track->getAdditionalDetails('accessPoint')); 136 | $this->assertInstanceOf(\Carbon\Carbon::class, $track->getAdditionalDetails('pickupDueDate')); 137 | $this->assertSame("2017-04-24", $track->getAdditionalDetails('pickupDueDate')->format('Y-m-d')); 138 | } 139 | 140 | 141 | /** 142 | * Build the tracker with a custom test client. 143 | * 144 | * @param $fileName 145 | * 146 | * @return AbstractTracker 147 | */ 148 | protected function getTracker($fileName) 149 | { 150 | return $this->getTrackerMock('UPS', $fileName); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/mock/DHL/delivered.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DHL Sendungsverfolgung 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 71 | 72 | 73 |
74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /tests/mock/DHL/warning.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DHL Sendungsverfolgung 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/Trackers/DHLExpressTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('DHLExpress'); 21 | } 22 | 23 | 24 | /** @test */ 25 | public function it_extends_the_abstract_tracker() 26 | { 27 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 28 | } 29 | 30 | 31 | /** @test */ 32 | public function it_builds_the_correct_tracker_class() 33 | { 34 | $this->assertInstanceOf(DHLExpress::class, $this->tracker); 35 | } 36 | 37 | 38 | /** @test */ 39 | public function it_builds_the_tracking_url() 40 | { 41 | $englishUrl = $this->tracker->trackingUrl('123456789', 'en'); 42 | $germanUrl = $this->tracker->trackingUrl('123456789', 'de'); 43 | 44 | $prefix = 'http://www.dhl.com/en/hidden/component_library/express/local_express/dhl_de_tracking/'; 45 | 46 | $this->assertSame($prefix . 'en/tracking_dhlde.html?AWB=123456789&brand=DHL', $englishUrl); 47 | $this->assertSame($prefix . 'de/sendungsverfolgung_dhlde.html?AWB=123456789&brand=DHL', $germanUrl); 48 | } 49 | 50 | 51 | /** @test */ 52 | public function it_accepts_additional_url_params() 53 | { 54 | $url = $this->tracker->trackingUrl('123456789', null, ['foo' => 'bar']); 55 | 56 | $prefix = 'http://www.dhl.com/en/hidden/component_library/express/local_express/dhl_de_tracking/'; 57 | 58 | $this->assertSame( 59 | $prefix . 'de/sendungsverfolgung_dhlde.html?AWB=123456789&brand=DHL&foo=bar', 60 | $url 61 | ); 62 | } 63 | 64 | 65 | /** @test */ 66 | public function it_resolves_a_delivered_shipment() 67 | { 68 | $tracker = $this->getTracker('delivered.txt'); 69 | 70 | $track = $tracker->track('5765159960'); 71 | 72 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 73 | $this->assertTrue($track->delivered()); 74 | $this->assertCount(16, $track->events()); 75 | } 76 | 77 | 78 | /** @test */ 79 | public function it_resolves_the_recipient_for_a_delivered_shipment() 80 | { 81 | $tracker = $this->getTracker('delivered.txt'); 82 | 83 | $track = $tracker->track('5765159960'); 84 | 85 | $this->assertSame('SDA', $track->getRecipient()); 86 | } 87 | 88 | 89 | /** @test */ 90 | public function it_resolves_a_shipment_that_is_in_transit() 91 | { 92 | $tracker = $this->getTracker('in_transit.txt'); 93 | 94 | $track = $tracker->track('5765159960'); 95 | 96 | $this->assertSame(Track::STATUS_IN_TRANSIT, $track->currentStatus()); 97 | $this->assertFalse($track->delivered()); 98 | $this->assertNull($track->getRecipient()); 99 | $this->assertCount(15, $track->events()); 100 | } 101 | 102 | 103 | /** @test */ 104 | public function it_resolves_a_shipment_as_delivered_even_if_the_statuses_are_not_in_chronological_order() 105 | { 106 | $tracker = $this->getTracker('delivered_with_unordered_statuses.txt'); 107 | 108 | $track = $tracker->track('5765159960'); 109 | 110 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 111 | $this->assertTrue($track->delivered()); 112 | $this->assertSame($track->getRecipient(), 'SDA'); 113 | $this->assertCount(16, $track->events()); 114 | } 115 | 116 | 117 | /** @test */ 118 | public function it_adds_the_pieces_ids_to_the_track() 119 | { 120 | $tracker = $this->getTracker('delivered.txt'); 121 | 122 | $track = $tracker->track('5765159960'); 123 | 124 | $pieces = $track->getAdditionalDetails('pieces'); 125 | 126 | $this->assertCount(1, $pieces); 127 | $this->assertSame('JD014600004444917061', $pieces[0]); 128 | } 129 | 130 | 131 | /** @test */ 132 | public function it_adds_the_pieces_ids_to_the_events() 133 | { 134 | $tracker = $this->getTracker('delivered.txt'); 135 | 136 | $track = $tracker->track('5765159960'); 137 | 138 | foreach ($track->events() as $event) { 139 | if (!$event->hasAdditionalDetails()) { 140 | continue; 141 | } 142 | 143 | $pieces = $event->getAdditionalDetails('pieces'); 144 | 145 | $this->assertCount(1, $pieces); 146 | $this->assertSame('JD014600004444917061', $pieces[0]); 147 | } 148 | } 149 | 150 | 151 | /** 152 | * Build the tracker with a custom test client. 153 | * 154 | * @param $fileName 155 | * 156 | * @return AbstractTracker 157 | */ 158 | protected function getTracker($fileName) 159 | { 160 | return $this->getTrackerMock('DHLExpress', $fileName); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /tests/mock/DHL/in_transit.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DHL Sendungsverfolgung 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/mock/GLS/pick_up.txt: -------------------------------------------------------------------------------- 1 | {"tuStatus":[{"arrivalTime":{"name":"Zugestellt am","value":"19.11.2018 um 10:22 Uhr"},"deliveryOwnerCode":"DE03","history":[{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-19","time":"10:47:21","evtDscr":"Das Paket ist im GLS PaketShop eingetroffen."},{"address":{"city":"Brechen","countryCode":"DE","countryName":"Deutschland","name":"Gläser Tankstellen GmbH"},"date":"2018-11-19","time":"10:22:03","evtDscr":"Das Paket wurde im GLS PaketShop zugestellt (siehe PaketShop-Anzeige)."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-19","time":"10:20:25","evtDscr":"Das Paket konnte nicht zugestellt werden, da der Empfänger nicht angetroffen wurde."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-19","time":"06:40:20","evtDscr":"Das Paket wird voraussichtlich im Laufe des Tages zugestellt."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-19","time":"04:21:40","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"22:38:00","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"16:25:52","evtDscr":"Das Paket befindet sich im Ziel-Paketzentrum. Es konnte nicht zugestellt werden, da der Empfänger nicht angetroffen wurde."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"12:21:47","evtDscr":"Das Paket konnte nicht zugestellt werden, da der Empfänger nicht angetroffen wurde."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"06:49:24","evtDscr":"Das Paket wird voraussichtlich im Laufe des Tages zugestellt."},{"address":{"city":"Polch","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"04:50:51","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"00:00:51","evtDscr":"Das Paket ist im Paketzentrum eingetroffen; das Paket wurde manuell sortiert."},{"address":{"city":"Neuenstein","countryCode":"DE","countryName":"Deutschland"},"date":"2018-11-16","time":"00:00:50","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"Santa Perpètua de Mogoda","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-15","time":"00:56:52","evtDscr":"Das Paket hat das Paketzentrum verlassen."},{"address":{"city":"San Juan de Mozarrifar","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-14","time":"19:29:52","evtDscr":"Das Paket ist im Paketzentrum eingetroffen."},{"address":{"city":"San Juan de Mozarrifar","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-14","time":"19:29:52","evtDscr":"Das Paket wurde an GLS übergeben."},{"address":{"city":"San Juan de Mozarrifar","countryCode":"ES","countryName":"Spanien"},"date":"2018-11-14","time":"14:09:06","evtDscr":"Die Paketdaten wurden im GLS IT-System erfasst; das Paket wurde noch nicht an GLS übergeben."}],"infos":[{"type":"WEIGHT","name":"Gewicht:","value":"0.2 kg"},{"type":"PRODUCT","name":"Produkt:","value":"EuroBusinessSmallParcel"}],"notificationCardId":"4V6YVD","owners":[{"type":"REQUEST","code":"ES01"},{"type":"DELIVERY","code":"DE03"}],"parcelShop":{"psNo":2003273262,"psID":"2760219021","address":{"postalArea":{"city":"Brechen","postalCode":"65611","postalCodeDisplay":"65611","countryCode":"DE","province":""},"phone":{"countryPrefix":"49","number":"06438836974"},"mobile":{"countryPrefix":"","number":""},"fax":{"countryPrefix":"49","number":"06438836975"},"gpsPos":{"latitude":"50.36158","longitude":"8.1707"},"name1":"Gläser Tankstellen GmbH","name2":"ARAL Tankstelle","street1":"Limburger Str.","street2":"","blockNo1":"15-19","blockNo2":""},"openingHours":[{"hours":"060000,,,210000","days":"Mo"},{"hours":"060000,,,210000","days":"Di"},{"hours":"060000,,,210000","days":"Mi"},{"hours":"060000,,,210000","days":"Do"},{"hours":"060000,,,210000","days":"Fr"},{"hours":"070000,,,210000","days":"Sa"},{"hours":"080000,,,210000","days":"So"}]},"progressBar":{"retourFlag":false,"statusText":"Zugestellt im PaketShop","statusInfo":"DELIVEREDPS","evtNos":["2.124","3.124","4.40","11.0","2.0","2.29","35.482","4.40","11.0","2.0","2.106","2.0","1.0","2.29","0.0","0.100"],"colourIndex":4,"level":75,"statusBar":[{"status":"PREADVICE","imageStatus":"COMPLETE","imageText":"Datenerfassung","statusText":""},{"status":"INTRANSIT","imageStatus":"COMPLETE","imageText":"Unterwegs","statusText":""},{"status":"INWAREHOUSE","imageStatus":"COMPLETE","imageText":"Ziel-Paketzentrum","statusText":""},{"status":"INDELIVERY","imageStatus":"COMPLETE","imageText":"In Zustellung","statusText":""},{"status":"DELIVEREDPS","imageStatus":"CURRENT","imageText":"Zugestellt","statusText":"Das Paket wurde erfolgreich im PaketShop zugestellt."}]},"references":[{"type":"UNITNO","name":"Paketnummer:","value":"32631986704"},{"type":"UNIQUENO","name":"Track ID","value":"Z51UTO9C"},{"type":"GLSREF","name":"Nationale Kundenreferenz","value":"320000433"},{"type":"NOTECARDID","name":"Track-ID","value":"4V6YVD"},{"type":"NOTECARDID","name":"Track-ID","value":"4UJRI8"}],"signature":{"name":"Unterschrift:","value":"true","validate":true},"tuNo":"32631986704","changeDeliveryPossible":false}]} -------------------------------------------------------------------------------- /src/Trackers/Fedex.php: -------------------------------------------------------------------------------- 1 | getDataProvider()->client->post($this->serviceEndpoint, $this->buildRequest()) 27 | ->getBody() 28 | ->getContents(); 29 | 30 | } catch (\Exception $e) { 31 | throw new \Exception("Could not fetch tracking data for [{$this->parcelNumber}]."); 32 | } 33 | } 34 | 35 | /** 36 | * @return array 37 | */ 38 | protected function buildRequest() 39 | { 40 | return [ 41 | 'headers' => [ 42 | 'Accept' => 'application/json', 43 | ], 44 | 45 | 'form_params' => [ 46 | 'data' => $this->buildDataArray(), 47 | 'action' => 'trackpackages', 48 | ], 49 | ]; 50 | } 51 | 52 | /** 53 | * @return false|string 54 | */ 55 | protected function buildDataArray() 56 | { 57 | $array = [ 58 | 'TrackPackagesRequest' => [ 59 | 'trackingInfoList' => [ 60 | [ 61 | 'trackNumberInfo' => [ 62 | 'trackingNumber' => $this->parcelNumber, 63 | ] 64 | ] 65 | ] 66 | ] 67 | ]; 68 | 69 | return json_encode($array); 70 | } 71 | 72 | /** 73 | * Build the url to the user friendly tracking site. In most 74 | * cases this is also the endpoint, but sometimes the tracking 75 | * data must be retrieved from another endpoint. 76 | * 77 | * @param string $trackingNumber 78 | * @param string|null $language 79 | * @param array $params 80 | * 81 | * @return string 82 | */ 83 | public function trackingUrl($trackingNumber, $language = null, $params = []) 84 | { 85 | return $this->trackingUrl . '?tracknumbers=' . $trackingNumber; 86 | } 87 | 88 | /** 89 | * Build the response array. 90 | * 91 | * @param string $response 92 | * 93 | * @return \Sauladam\ShipmentTracker\Track 94 | */ 95 | protected function buildResponse($response) 96 | { 97 | $contents = json_decode($response, true)['TrackPackagesResponse']['packageList'][0]; 98 | 99 | $track = new Track; 100 | 101 | foreach ($contents['scanEventList'] as $scanEvent) { 102 | $track->addEvent(Event::fromArray([ 103 | 'location' => $scanEvent['scanLocation'], 104 | 'description' => $scanEvent['status'], 105 | 'date' => $this->getDate($scanEvent), 106 | 'status' => $status = $this->resolveState($scanEvent) 107 | ])); 108 | 109 | if ($status == Track::STATUS_DELIVERED && isset($contents['receivedByNm'])) { 110 | $track->setRecipient($contents['receivedByNm']); 111 | } 112 | } 113 | 114 | if (isset($contents['totalKgsWgt'])) { 115 | $track->addAdditionalDetails('totalKgsWgt', $contents['totalKgsWgt']); 116 | } 117 | 118 | if (isset($contents['totalLbsWgt'])) { 119 | $track->addAdditionalDetails('totalLbsWgt', $contents['totalLbsWgt']); 120 | } 121 | 122 | return $track->sortEvents(); 123 | } 124 | 125 | /** 126 | * Parse the date from the given strings. 127 | * 128 | * @param array $scanEvent 129 | * 130 | * @return \Carbon\Carbon 131 | */ 132 | protected function getDate($scanEvent) 133 | { 134 | return Carbon::parse( 135 | $this->convert("{$scanEvent['date']}T{$scanEvent['time']}{$scanEvent['gmtOffset']}") 136 | ); 137 | } 138 | 139 | /** 140 | * Convert unicode characters 141 | * 142 | * @param string $string 143 | * @return string 144 | */ 145 | protected function convert($string) 146 | { 147 | if (PHP_MAJOR_VERSION >= 7) 148 | return preg_replace('/(?<=\\\u)(.{4})/', '{$1}', $string); 149 | else 150 | return str_replace('\\u002d', '-', $string); 151 | } 152 | 153 | /** 154 | * Match a shipping status from the given short code. 155 | * 156 | * @param $status 157 | * 158 | * @return string 159 | */ 160 | protected function resolveState($status) 161 | { 162 | switch ($status['statusCD']) { 163 | case 'PU': 164 | case 'OC': 165 | case 'AR': 166 | case 'DP': 167 | case 'OD': 168 | return Track::STATUS_IN_TRANSIT; 169 | case 'DL': 170 | return Track::STATUS_DELIVERED; 171 | default: 172 | return Track::STATUS_UNKNOWN; 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/Trackers/AbstractTracker.php: -------------------------------------------------------------------------------- 1 | dataProviderRegistry = $providers; 50 | } 51 | 52 | 53 | /** 54 | * Track the given number. 55 | * 56 | * @param string $number 57 | * @param string|null $language 58 | * @param array $params 59 | * 60 | * @return Track 61 | */ 62 | public function track($number, $language = null, $params = []) 63 | { 64 | $this->parcelNumber = $number; 65 | 66 | $this->extractUrlParams($params); 67 | 68 | if ($language) { 69 | $this->setLanguage($language); 70 | } 71 | 72 | $url = $this->getEndpointUrl($number, $language, $params); 73 | 74 | $contents = $this->fetch($url); 75 | 76 | return $this->buildResponse($contents); 77 | } 78 | 79 | 80 | /** 81 | * Set the tracking-URL and endpoint-URLs params if any were given. 82 | * If non of them were specified explicitly, set the same params 83 | * for bot URLs. 84 | * 85 | * @param $params 86 | */ 87 | protected function extractUrlParams($params) 88 | { 89 | if (!array_key_exists('tracking_url', $params) && !array_key_exists('endpoint_url', $params)) { 90 | $this->trackingUrlParams = $this->endpointUrlParams = $params; 91 | 92 | return; 93 | } 94 | 95 | $this->trackingUrlParams = array_key_exists('tracking_url', $params) ? $params['tracking_url'] : []; 96 | $this->endpointUrlParams = array_key_exists('endpoint_url', $params) ? $params['endpoint_url'] : []; 97 | } 98 | 99 | 100 | /** 101 | * Set the default data provider. 102 | * 103 | * @param string $name 104 | * 105 | * @return $this 106 | */ 107 | public function useDataProvider($name) 108 | { 109 | $this->defaultDataProvider = $name; 110 | 111 | return $this; 112 | } 113 | 114 | 115 | /** 116 | * Get the currently set default data provider. 117 | * 118 | * @return string 119 | */ 120 | public function getDefaultDataProvider() 121 | { 122 | return $this->defaultDataProvider; 123 | } 124 | 125 | 126 | /** 127 | * Set the language iso code for the results. 128 | * 129 | * @param string $lang 130 | * 131 | * @return $this 132 | */ 133 | protected function setLanguage($lang) 134 | { 135 | if (strlen($lang) !== 2) { 136 | $message = "Invalid language [{$lang}]."; 137 | throw new \InvalidArgumentException($message); 138 | } 139 | 140 | $this->language = strtolower($lang); 141 | 142 | return $this; 143 | } 144 | 145 | 146 | /** 147 | * Get the data provider. 148 | * 149 | * @return DataProviderInterface 150 | */ 151 | protected function getDataProvider() 152 | { 153 | return $this->dataProviderRegistry->get($this->defaultDataProvider); 154 | } 155 | 156 | 157 | /** 158 | * Get the contents of the given url. 159 | * 160 | * @param string $url 161 | * 162 | * @return string 163 | */ 164 | protected function fetch($url) 165 | { 166 | return $this->getDataProvider()->get($url); 167 | } 168 | 169 | 170 | /** 171 | * Build the endpoint url 172 | * 173 | * @param string $trackingNumber 174 | * @param string|null $language 175 | * @param array $params 176 | * 177 | * @return string 178 | */ 179 | protected function getEndpointUrl($trackingNumber, $language = null, $params = []) 180 | { 181 | return $this->trackingUrl($trackingNumber, $language, $params); 182 | } 183 | 184 | 185 | /** 186 | * Build the url to the user friendly tracking site. In most 187 | * cases this is also the endpoint, but sometimes the tracking 188 | * data must be retrieved from another endpoint. 189 | * 190 | * @param string $trackingNumber 191 | * @param string|null $language 192 | * @param array $params 193 | * 194 | * @return string 195 | */ 196 | abstract public function trackingUrl($trackingNumber, $language = null, $params = []); 197 | 198 | 199 | /** 200 | * Build the response array. 201 | * 202 | * @param string $response 203 | * 204 | * @return array 205 | */ 206 | abstract protected function buildResponse($response); 207 | } 208 | -------------------------------------------------------------------------------- /tests/Trackers/DHLTest.php: -------------------------------------------------------------------------------- 1 | tracker = ShipmentTracker::get('DHL'); 20 | } 21 | 22 | 23 | /** @test */ 24 | public function it_extends_the_abstract_tracker() 25 | { 26 | $this->assertInstanceOf(AbstractTracker::class, $this->tracker); 27 | } 28 | 29 | 30 | /** @test */ 31 | public function it_builds_the_tracking_url() 32 | { 33 | $url = $this->tracker->trackingUrl('123456789'); 34 | 35 | $this->assertSame('http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=de&idc=123456789', $url); 36 | } 37 | 38 | 39 | /** @test */ 40 | public function it_can_override_the_language_for_the_url() 41 | { 42 | $url = $this->tracker->trackingUrl('123456789', 'en'); 43 | 44 | $this->assertSame('http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=en&idc=123456789', $url); 45 | } 46 | 47 | 48 | /** @test */ 49 | public function it_accepts_additional_url_params() 50 | { 51 | $url = $this->tracker->trackingUrl('123456789', null, ['foo' => 'bar']); 52 | 53 | $this->assertSame( 54 | 'http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=de&idc=123456789&foo=bar', 55 | $url 56 | ); 57 | } 58 | 59 | 60 | /** @test */ 61 | public function it_resolves_a_delivered_shipment() 62 | { 63 | $tracker = $this->getTracker('delivered.txt'); 64 | 65 | $track = $tracker->track('00340434162530533196'); 66 | 67 | $this->assertSame(Track::STATUS_DELIVERED, $track->currentStatus()); 68 | $this->assertTrue($track->delivered()); 69 | $this->assertCount(5, $track->events()); 70 | } 71 | 72 | 73 | /** @test */ 74 | public function it_resolves_the_recipient_for_a_delivered_shipment() 75 | { 76 | $tracker = $this->getTracker('delivered.txt'); 77 | 78 | $track = $tracker->track('00340434162530533196'); 79 | 80 | $this->assertSame('Empfänger (orig.)', $track->getRecipient()); 81 | } 82 | 83 | 84 | /** @test */ 85 | public function it_resolves_a_shipment_that_has_to_be_picked_up() 86 | { 87 | $tracker = $this->getTracker('pickup.txt'); 88 | 89 | $track = $tracker->track('00340434162530531062'); 90 | 91 | $this->assertSame(Track::STATUS_PICKUP, $track->currentStatus()); 92 | $this->assertFalse($track->delivered()); 93 | $this->assertNull($track->getRecipient()); 94 | $this->assertCount(6, $track->events()); 95 | } 96 | 97 | 98 | /** @test */ 99 | public function it_resolves_a_shipment_that_is_in_transit() 100 | { 101 | $tracker = $this->getTracker('in_transit.txt'); 102 | 103 | $track = $tracker->track('00340434162530534551'); 104 | 105 | $this->assertSame(Track::STATUS_IN_TRANSIT, $track->currentStatus()); 106 | $this->assertFalse($track->delivered()); 107 | $this->assertNull($track->getRecipient()); 108 | $this->assertCount(4, $track->events()); 109 | } 110 | 111 | /** @test */ 112 | public function it_resolves_a_shipment_with_a_warning() 113 | { 114 | $tracker = $this->getTracker('warning.txt'); 115 | 116 | $track = $tracker->track('00340434162530584006'); 117 | 118 | $this->assertSame(Track::STATUS_WARNING, $track->currentStatus()); 119 | $this->assertFalse($track->delivered()); 120 | $this->assertNull($track->getRecipient()); 121 | $this->assertCount(5, $track->events()); 122 | } 123 | 124 | /** @test */ 125 | public function it_resolves_a_shipment_with_an_exception() 126 | { 127 | $this->markTestSkipped("No data for testing available yet."); 128 | 129 | $tracker = $this->getTracker('exception.txt'); 130 | 131 | $track = $tracker->track('00340434162530533851'); 132 | 133 | $this->assertSame(Track::STATUS_EXCEPTION, $track->currentStatus()); 134 | $this->assertFalse($track->delivered()); 135 | $this->assertNull($track->getRecipient()); 136 | $this->assertCount(5, $track->events()); 137 | } 138 | 139 | 140 | /** @test */ 141 | public function it_resolves_a_shipment_as_delivered_even_if_the_statuses_are_not_in_chronological_order() 142 | { 143 | $this->markTestSkipped("No data for testing available yet."); 144 | 145 | $tracker = $this->getTracker('delivered_with_unordered_statuses.txt'); 146 | 147 | $track = $tracker->track('00340433924192991025'); 148 | 149 | $this->assertNotSame(Track::STATUS_DELIVERED, $track->currentStatus()); 150 | $this->assertTrue($track->delivered()); 151 | $this->assertSame($track->getRecipient(), 'Neighbor'); 152 | $this->assertCount(5, $track->events()); 153 | } 154 | 155 | 156 | /** 157 | * Build the tracker with a custom test client. 158 | * 159 | * @param $fileName 160 | * 161 | * @return AbstractTracker 162 | */ 163 | protected function getTracker($fileName) 164 | { 165 | return $this->getTrackerMock('DHL', $fileName); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Trackers/USPS.php: -------------------------------------------------------------------------------- 1 | trackingUrlParams; 44 | 45 | $qry = http_build_query(array_merge([ 46 | 'qtc_tLabels1' => $trackingNumber, 47 | ], $additionalParams)); 48 | 49 | return $this->serviceEndpoint . '?' . $qry; 50 | } 51 | 52 | 53 | /** 54 | * Build the track. 55 | * 56 | * @param string $response 57 | * 58 | * @return Track 59 | */ 60 | protected function buildResponse($response) 61 | { 62 | $dom = new DOMDocument; 63 | @$dom->loadHTML($response); 64 | $dom->preserveWhiteSpace = false; 65 | 66 | $domxpath = new DOMXPath($dom); 67 | 68 | return $this->getTrack($domxpath); 69 | } 70 | 71 | 72 | /** 73 | * Get the shipment status history. 74 | * 75 | * @param DOMXPath $xpath 76 | * 77 | * @return Track 78 | * @throws \Exception 79 | */ 80 | protected function getTrack(DOMXPath $xpath) 81 | { 82 | $rowsContainer = $xpath->query("//div[@id='trackingHistory_1']//div[contains(@class,'panel-actions-content')]")->item(0); 83 | 84 | $items = []; 85 | $index = 0; 86 | 87 | foreach ($rowsContainer->childNodes as $child) { 88 | if (isset($child->tagName) && $child->tagName == 'h3') { 89 | continue; 90 | } 91 | 92 | if (isset($child->tagName) && $child->tagName == 'hr') { 93 | $index++; 94 | continue; 95 | } 96 | 97 | $value = $this->getNodeValue($child); 98 | 99 | if (!empty($value)) { 100 | $items[$index][] = $value; 101 | } 102 | } 103 | 104 | $realEvents = array_filter($items, function ($eventRows) { 105 | // filter only those data-portions that are at least 3 lines long, i.e. contain the date, 106 | // the location and a description. Otherwise it's not a real event, maybe just a short 107 | // info text like "Inbound Into Customs" - not sure where to put that, so just leave it alone. 108 | return count($eventRows) >= 3; 109 | }); 110 | 111 | $track = new Track; 112 | 113 | foreach ($realEvents as $eventData) { 114 | $track->addEvent(Event::fromArray([ 115 | 'date' => $this->getDate($eventData[0]), 116 | 'description' => $eventData[1], 117 | 'location' => $eventData[2], 118 | 'status' => $this->resolveState($eventData[1]), 119 | ])); 120 | } 121 | 122 | return $track->sortEvents(); 123 | } 124 | 125 | 126 | /** 127 | * Get the node value. 128 | * 129 | * @param DOMText|DOMNode $element 130 | * @param bool $withLineBreaks 131 | * 132 | * @return string 133 | */ 134 | protected function getNodeValue($element, $withLineBreaks = false) 135 | { 136 | return $this->normalizedNodeValue($element, $withLineBreaks); 137 | } 138 | 139 | 140 | /** 141 | * Parse the date from the given string. 142 | * 143 | * @param $dateString 144 | * 145 | * @return string 146 | */ 147 | protected function getDate($dateString) 148 | { 149 | // The date comes in a format like 150 | // November 9, 2015, 10:50 am 151 | return empty($dateString) ? null : Carbon::parse($dateString); 152 | } 153 | 154 | 155 | /** 156 | * Match a shipping status from the given description. 157 | * 158 | * @param $statusDescription 159 | * 160 | * @return string 161 | */ 162 | protected function resolveState($statusDescription) 163 | { 164 | $statuses = [ 165 | Track::STATUS_DELIVERED => [ 166 | 'Delivered', 167 | ], 168 | Track::STATUS_IN_TRANSIT => [ 169 | 'Notice Left', 170 | 'Arrived at Unit', 171 | 'Departed USPS Facility', 172 | 'Arrived at USPS Facility', 173 | 'Processed Through Sort Facility', 174 | 'Processed Through Facility', 175 | 'Origin Post is Preparing Shipment', 176 | 'Acceptance', 177 | 'Out for Delivery', 178 | 'Sorting Complete', 179 | 'Departed USPS Regional Facility', 180 | 'Arrived at USPS Regional Facility', 181 | ], 182 | Track::STATUS_WARNING => [], 183 | Track::STATUS_EXCEPTION => [], 184 | ]; 185 | 186 | foreach ($statuses as $status => $needles) { 187 | foreach ($needles as $needle) { 188 | if (strpos($statusDescription, $needle) !== false) { 189 | return $status; 190 | } 191 | } 192 | } 193 | 194 | return Track::STATUS_UNKNOWN; 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/mock/DHL/pickup.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | DHL Sendungsverfolgung 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /tests/mock/DHLExpress/in_transit.txt: -------------------------------------------------------------------------------- 1 | { 2 | "results" : [ { 3 | "id" : "5765159960", 4 | "label" : "Waybill", 5 | "type" : "airwaybill", 6 | "duplicate" : false, 7 | "delivery" : { 8 | "code" : "101", 9 | "status" : "not delivered status" 10 | }, 11 | "origin" : { 12 | "value" : "ROTTERDAM - DEN HAAG - NETHERLANDS, THE", 13 | "label" : "Origin Service Area", 14 | "url" : "http://www.dhl.nl/en/country_profile.html" 15 | }, 16 | "destination" : { 17 | "value" : "SAN FRANCISCO, CA - San Francisco - USA", 18 | "label" : "Destination Service Area", 19 | "url" : "http://www.dhl-usa.com/en/country_profile.html" 20 | }, 21 | "description" : "Signed for by: SDA Friday, May 26, 2017 at 14:23", 22 | "hasDuplicateShipment" : false, 23 | "signature" : { 24 | "link" : { 25 | "url" : "https://webpod.dhl.com/webPOD/DHLePODRequest?hwb=cYCJzMUFoiC%2BdPAZG4OgTQ%3D%3D&pudate=GVM71SQC%2Fb7tWrHfRQuSGw%3D%3D&appuid=0N0K7SHOIOS83AxRjX6MPg%3D%3D&language=en&country=G0", 26 | "label" : "Get Signature Proof of Delivery" 27 | }, 28 | "type" : "epod", 29 | "description" : "Friday, May 26, 2017 at 14:23", 30 | "signatory" : "SDA", 31 | "label" : "Signed for by", 32 | "help" : "help" 33 | }, 34 | "pieces" : { 35 | "value" : 1, 36 | "label" : "Piece", 37 | "showSummary" : true, 38 | "pIds" : [ "JD014600004444917061" ] 39 | }, 40 | "checkpoints" : [ { 41 | "counter" : 15, 42 | "description" : "With delivery courier", 43 | "time" : "10:14", 44 | "date" : "Friday, May 26, 2017 ", 45 | "location" : "SAN FRANCISCO, CA - USA", 46 | "totalPieces" : 1, 47 | "pIds" : [ "JD014600004444917061" ] 48 | }, { 49 | "counter" : 14, 50 | "description" : "Arrived at Delivery Facility in SAN FRANCISCO - USA", 51 | "time" : "07:55", 52 | "date" : "Friday, May 26, 2017 ", 53 | "location" : "SAN FRANCISCO, CA - USA", 54 | "totalPieces" : 1, 55 | "pIds" : [ "JD014600004444917061" ] 56 | }, { 57 | "counter" : 13, 58 | "description" : "Departed Facility in SAN FRANCISCO GATEWAY - USA", 59 | "time" : "07:21", 60 | "date" : "Friday, May 26, 2017 ", 61 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 62 | "totalPieces" : 1, 63 | "pIds" : [ "JD014600004444917061" ] 64 | }, { 65 | "counter" : 12, 66 | "description" : "Transferred through SAN FRANCISCO GATEWAY - USA", 67 | "time" : "07:17", 68 | "date" : "Friday, May 26, 2017 ", 69 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 70 | "totalPieces" : 1, 71 | "pIds" : [ "JD014600004444917061" ] 72 | }, { 73 | "counter" : 11, 74 | "description" : "Arrived at Sort Facility SAN FRANCISCO GATEWAY - USA", 75 | "time" : "06:30", 76 | "date" : "Friday, May 26, 2017 ", 77 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 78 | "totalPieces" : 1, 79 | "pIds" : [ "JD014600004444917061" ] 80 | }, { 81 | "counter" : 10, 82 | "description" : "Departed Facility in LOS ANGELES GATEWAY - USA", 83 | "time" : "05:21", 84 | "date" : "Friday, May 26, 2017 ", 85 | "location" : "LOS ANGELES GATEWAY, CA - USA", 86 | "totalPieces" : 1, 87 | "pIds" : [ "JD014600004444917061" ] 88 | }, { 89 | "counter" : 9, 90 | "description" : "Processed at LOS ANGELES GATEWAY - USA", 91 | "time" : "05:08", 92 | "date" : "Friday, May 26, 2017 ", 93 | "location" : "LOS ANGELES GATEWAY, CA - USA", 94 | "totalPieces" : 1, 95 | "pIds" : [ "JD014600004444917061" ] 96 | }, { 97 | "counter" : 8, 98 | "description" : "Clearance processing complete at LOS ANGELES GATEWAY - USA", 99 | "time" : "20:55", 100 | "date" : "Thursday, May 25, 2017 ", 101 | "location" : "LOS ANGELES GATEWAY, CA - USA", 102 | "totalPieces" : 1, 103 | "pIds" : [ "JD014600004444917061" ] 104 | }, { 105 | "counter" : 7, 106 | "description" : "Arrived at Sort Facility LOS ANGELES GATEWAY - USA", 107 | "time" : "20:23", 108 | "date" : "Thursday, May 25, 2017 ", 109 | "location" : "LOS ANGELES GATEWAY, CA - USA", 110 | "totalPieces" : 1, 111 | "pIds" : [ "JD014600004444917061" ] 112 | }, { 113 | "counter" : 6, 114 | "description" : "Customs status updated", 115 | "time" : "07:43", 116 | "date" : "Thursday, May 25, 2017 ", 117 | "location" : "LOS ANGELES GATEWAY, CA - USA" 118 | }, { 119 | "counter" : 5, 120 | "description" : "Departed Facility in AMSTERDAM - NETHERLANDS, THE", 121 | "time" : "08:50", 122 | "date" : "Thursday, May 25, 2017 ", 123 | "location" : "AMSTERDAM - NETHERLANDS, THE", 124 | "totalPieces" : 1, 125 | "pIds" : [ "JD014600004444917061" ] 126 | }, { 127 | "counter" : 4, 128 | "description" : "Processed at AMSTERDAM - NETHERLANDS, THE", 129 | "time" : "05:22", 130 | "date" : "Thursday, May 25, 2017 ", 131 | "location" : "AMSTERDAM - NETHERLANDS, THE", 132 | "totalPieces" : 1, 133 | "pIds" : [ "JD014600004444917061" ] 134 | }, { 135 | "counter" : 3, 136 | "description" : "Departed Facility in ROTTERDAM - NETHERLANDS, THE", 137 | "time" : "20:37", 138 | "date" : "Wednesday, May 24, 2017 ", 139 | "location" : "ROTTERDAM - NETHERLANDS, THE", 140 | "totalPieces" : 1, 141 | "pIds" : [ "JD014600004444917061" ] 142 | }, { 143 | "counter" : 2, 144 | "description" : "Processed at ROTTERDAM - NETHERLANDS, THE", 145 | "time" : "20:37", 146 | "date" : "Wednesday, May 24, 2017 ", 147 | "location" : "ROTTERDAM - NETHERLANDS, THE", 148 | "totalPieces" : 1, 149 | "pIds" : [ "JD014600004444917061" ] 150 | }, { 151 | "counter" : 1, 152 | "description" : "Shipment picked up", 153 | "time" : "17:08", 154 | "date" : "Wednesday, May 24, 2017 ", 155 | "location" : "ROTTERDAM - NETHERLANDS, THE", 156 | "totalPieces" : 1, 157 | "pIds" : [ "JD014600004444917061" ] 158 | } ], 159 | "checkpointLocationLabel" : "Location", 160 | "checkpointTimeLabel" : "Time" 161 | } ] 162 | } 163 | -------------------------------------------------------------------------------- /tests/mock/DHLExpress/delivered.txt: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "results" : [ { 4 | "id" : "5765159960", 5 | "label" : "Waybill", 6 | "type" : "airwaybill", 7 | "duplicate" : false, 8 | "delivery" : { 9 | "code" : "101", 10 | "status" : "delivered" 11 | }, 12 | "origin" : { 13 | "value" : "ROTTERDAM - DEN HAAG - NETHERLANDS, THE", 14 | "label" : "Origin Service Area", 15 | "url" : "http://www.dhl.nl/en/country_profile.html" 16 | }, 17 | "destination" : { 18 | "value" : "SAN FRANCISCO, CA - San Francisco - USA", 19 | "label" : "Destination Service Area", 20 | "url" : "http://www.dhl-usa.com/en/country_profile.html" 21 | }, 22 | "description" : "Signed for by: SDA Friday, May 26, 2017 at 14:23", 23 | "hasDuplicateShipment" : false, 24 | "signature" : { 25 | "link" : { 26 | "url" : "https://webpod.dhl.com/webPOD/DHLePODRequest?hwb=cYCJzMUFoiC%2BdPAZG4OgTQ%3D%3D&pudate=GVM71SQC%2Fb7tWrHfRQuSGw%3D%3D&appuid=0N0K7SHOIOS83AxRjX6MPg%3D%3D&language=en&country=G0", 27 | "label" : "Get Signature Proof of Delivery" 28 | }, 29 | "type" : "epod", 30 | "description" : "Friday, May 26, 2017 at 14:23", 31 | "signatory" : "SDA", 32 | "label" : "Signed for by", 33 | "help" : "help" 34 | }, 35 | "pieces" : { 36 | "value" : 1, 37 | "label" : "Piece", 38 | "showSummary" : true, 39 | "pIds" : [ "JD014600004444917061" ] 40 | }, 41 | "checkpoints" : [ { 42 | "counter" : 16, 43 | "description" : "Delivered - Signed for by : SDA", 44 | "time" : "14:23", 45 | "date" : "Friday, May 26, 2017 ", 46 | "location" : "San Francisco ", 47 | "totalPieces" : 1, 48 | "pIds" : [ "JD014600004444917061" ] 49 | }, { 50 | "counter" : 15, 51 | "description" : "With delivery courier", 52 | "time" : "10:14", 53 | "date" : "Friday, May 26, 2017 ", 54 | "location" : "SAN FRANCISCO, CA - USA", 55 | "totalPieces" : 1, 56 | "pIds" : [ "JD014600004444917061" ] 57 | }, { 58 | "counter" : 14, 59 | "description" : "Arrived at Delivery Facility in SAN FRANCISCO - USA", 60 | "time" : "07:55", 61 | "date" : "Friday, May 26, 2017 ", 62 | "location" : "SAN FRANCISCO, CA - USA", 63 | "totalPieces" : 1, 64 | "pIds" : [ "JD014600004444917061" ] 65 | }, { 66 | "counter" : 13, 67 | "description" : "Departed Facility in SAN FRANCISCO GATEWAY - USA", 68 | "time" : "07:21", 69 | "date" : "Friday, May 26, 2017 ", 70 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 71 | "totalPieces" : 1, 72 | "pIds" : [ "JD014600004444917061" ] 73 | }, { 74 | "counter" : 12, 75 | "description" : "Transferred through SAN FRANCISCO GATEWAY - USA", 76 | "time" : "07:17", 77 | "date" : "Friday, May 26, 2017 ", 78 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 79 | "totalPieces" : 1, 80 | "pIds" : [ "JD014600004444917061" ] 81 | }, { 82 | "counter" : 11, 83 | "description" : "Arrived at Sort Facility SAN FRANCISCO GATEWAY - USA", 84 | "time" : "06:30", 85 | "date" : "Friday, May 26, 2017 ", 86 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 87 | "totalPieces" : 1, 88 | "pIds" : [ "JD014600004444917061" ] 89 | }, { 90 | "counter" : 10, 91 | "description" : "Departed Facility in LOS ANGELES GATEWAY - USA", 92 | "time" : "05:21", 93 | "date" : "Friday, May 26, 2017 ", 94 | "location" : "LOS ANGELES GATEWAY, CA - USA", 95 | "totalPieces" : 1, 96 | "pIds" : [ "JD014600004444917061" ] 97 | }, { 98 | "counter" : 9, 99 | "description" : "Processed at LOS ANGELES GATEWAY - USA", 100 | "time" : "05:08", 101 | "date" : "Friday, May 26, 2017 ", 102 | "location" : "LOS ANGELES GATEWAY, CA - USA", 103 | "totalPieces" : 1, 104 | "pIds" : [ "JD014600004444917061" ] 105 | }, { 106 | "counter" : 8, 107 | "description" : "Clearance processing complete at LOS ANGELES GATEWAY - USA", 108 | "time" : "20:55", 109 | "date" : "Thursday, May 25, 2017 ", 110 | "location" : "LOS ANGELES GATEWAY, CA - USA", 111 | "totalPieces" : 1, 112 | "pIds" : [ "JD014600004444917061" ] 113 | }, { 114 | "counter" : 7, 115 | "description" : "Arrived at Sort Facility LOS ANGELES GATEWAY - USA", 116 | "time" : "20:23", 117 | "date" : "Thursday, May 25, 2017 ", 118 | "location" : "LOS ANGELES GATEWAY, CA - USA", 119 | "totalPieces" : 1, 120 | "pIds" : [ "JD014600004444917061" ] 121 | }, { 122 | "counter" : 6, 123 | "description" : "Customs status updated", 124 | "time" : "07:43", 125 | "date" : "Thursday, May 25, 2017 ", 126 | "location" : "LOS ANGELES GATEWAY, CA - USA" 127 | }, { 128 | "counter" : 5, 129 | "description" : "Departed Facility in AMSTERDAM - NETHERLANDS, THE", 130 | "time" : "08:50", 131 | "date" : "Thursday, May 25, 2017 ", 132 | "location" : "AMSTERDAM - NETHERLANDS, THE", 133 | "totalPieces" : 1, 134 | "pIds" : [ "JD014600004444917061" ] 135 | }, { 136 | "counter" : 4, 137 | "description" : "Processed at AMSTERDAM - NETHERLANDS, THE", 138 | "time" : "05:22", 139 | "date" : "Thursday, May 25, 2017 ", 140 | "location" : "AMSTERDAM - NETHERLANDS, THE", 141 | "totalPieces" : 1, 142 | "pIds" : [ "JD014600004444917061" ] 143 | }, { 144 | "counter" : 3, 145 | "description" : "Departed Facility in ROTTERDAM - NETHERLANDS, THE", 146 | "time" : "20:37", 147 | "date" : "Wednesday, May 24, 2017 ", 148 | "location" : "ROTTERDAM - NETHERLANDS, THE", 149 | "totalPieces" : 1, 150 | "pIds" : [ "JD014600004444917061" ] 151 | }, { 152 | "counter" : 2, 153 | "description" : "Processed at ROTTERDAM - NETHERLANDS, THE", 154 | "time" : "20:37", 155 | "date" : "Wednesday, May 24, 2017 ", 156 | "location" : "ROTTERDAM - NETHERLANDS, THE", 157 | "totalPieces" : 1, 158 | "pIds" : [ "JD014600004444917061" ] 159 | }, { 160 | "counter" : 1, 161 | "description" : "Shipment picked up", 162 | "time" : "17:08", 163 | "date" : "Wednesday, May 24, 2017 ", 164 | "location" : "ROTTERDAM - NETHERLANDS, THE", 165 | "totalPieces" : 1, 166 | "pIds" : [ "JD014600004444917061" ] 167 | } ], 168 | "checkpointLocationLabel" : "Location", 169 | "checkpointTimeLabel" : "Time" 170 | } ] 171 | } 172 | -------------------------------------------------------------------------------- /tests/mock/DHLExpress/delivered_with_unordered_statuses.txt: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "results" : [ { 4 | "id" : "5765159960", 5 | "label" : "Waybill", 6 | "type" : "airwaybill", 7 | "duplicate" : false, 8 | "delivery" : { 9 | "code" : "101", 10 | "status" : "delivered" 11 | }, 12 | "origin" : { 13 | "value" : "ROTTERDAM - DEN HAAG - NETHERLANDS, THE", 14 | "label" : "Origin Service Area", 15 | "url" : "http://www.dhl.nl/en/country_profile.html" 16 | }, 17 | "destination" : { 18 | "value" : "SAN FRANCISCO, CA - San Francisco - USA", 19 | "label" : "Destination Service Area", 20 | "url" : "http://www.dhl-usa.com/en/country_profile.html" 21 | }, 22 | "description" : "Signed for by: SDA Friday, May 26, 2017 at 14:23", 23 | "hasDuplicateShipment" : false, 24 | "signature" : { 25 | "link" : { 26 | "url" : "https://webpod.dhl.com/webPOD/DHLePODRequest?hwb=cYCJzMUFoiC%2BdPAZG4OgTQ%3D%3D&pudate=GVM71SQC%2Fb7tWrHfRQuSGw%3D%3D&appuid=0N0K7SHOIOS83AxRjX6MPg%3D%3D&language=en&country=G0", 27 | "label" : "Get Signature Proof of Delivery" 28 | }, 29 | "type" : "epod", 30 | "description" : "Friday, May 26, 2017 at 14:23", 31 | "signatory" : "SDA", 32 | "label" : "Signed for by", 33 | "help" : "help" 34 | }, 35 | "pieces" : { 36 | "value" : 1, 37 | "label" : "Piece", 38 | "showSummary" : true, 39 | "pIds" : [ "JD014600004444917061" ] 40 | }, 41 | "checkpoints" : [ { 42 | "counter" : 16, 43 | "description" : "With delivery courier", 44 | "time" : "10:14", 45 | "date" : "Friday, May 26, 2017 ", 46 | "location" : "SAN FRANCISCO, CA - USA", 47 | "totalPieces" : 1, 48 | "pIds" : [ "JD014600004444917061" ] 49 | }, { 50 | "counter" : 15, 51 | "description" : "Delivered - Signed for by : SDA", 52 | "time" : "14:23", 53 | "date" : "Friday, May 26, 2017 ", 54 | "location" : "San Francisco ", 55 | "totalPieces" : 1, 56 | "pIds" : [ "JD014600004444917061" ] 57 | }, { 58 | "counter" : 14, 59 | "description" : "Arrived at Delivery Facility in SAN FRANCISCO - USA", 60 | "time" : "07:55", 61 | "date" : "Friday, May 26, 2017 ", 62 | "location" : "SAN FRANCISCO, CA - USA", 63 | "totalPieces" : 1, 64 | "pIds" : [ "JD014600004444917061" ] 65 | }, { 66 | "counter" : 13, 67 | "description" : "Departed Facility in SAN FRANCISCO GATEWAY - USA", 68 | "time" : "07:21", 69 | "date" : "Friday, May 26, 2017 ", 70 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 71 | "totalPieces" : 1, 72 | "pIds" : [ "JD014600004444917061" ] 73 | }, { 74 | "counter" : 12, 75 | "description" : "Transferred through SAN FRANCISCO GATEWAY - USA", 76 | "time" : "07:17", 77 | "date" : "Friday, May 26, 2017 ", 78 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 79 | "totalPieces" : 1, 80 | "pIds" : [ "JD014600004444917061" ] 81 | }, { 82 | "counter" : 11, 83 | "description" : "Arrived at Sort Facility SAN FRANCISCO GATEWAY - USA", 84 | "time" : "06:30", 85 | "date" : "Friday, May 26, 2017 ", 86 | "location" : "SAN FRANCISCO GATEWAY, CA - USA", 87 | "totalPieces" : 1, 88 | "pIds" : [ "JD014600004444917061" ] 89 | }, { 90 | "counter" : 10, 91 | "description" : "Departed Facility in LOS ANGELES GATEWAY - USA", 92 | "time" : "05:21", 93 | "date" : "Friday, May 26, 2017 ", 94 | "location" : "LOS ANGELES GATEWAY, CA - USA", 95 | "totalPieces" : 1, 96 | "pIds" : [ "JD014600004444917061" ] 97 | }, { 98 | "counter" : 9, 99 | "description" : "Processed at LOS ANGELES GATEWAY - USA", 100 | "time" : "05:08", 101 | "date" : "Friday, May 26, 2017 ", 102 | "location" : "LOS ANGELES GATEWAY, CA - USA", 103 | "totalPieces" : 1, 104 | "pIds" : [ "JD014600004444917061" ] 105 | }, { 106 | "counter" : 8, 107 | "description" : "Clearance processing complete at LOS ANGELES GATEWAY - USA", 108 | "time" : "20:55", 109 | "date" : "Thursday, May 25, 2017 ", 110 | "location" : "LOS ANGELES GATEWAY, CA - USA", 111 | "totalPieces" : 1, 112 | "pIds" : [ "JD014600004444917061" ] 113 | }, { 114 | "counter" : 7, 115 | "description" : "Arrived at Sort Facility LOS ANGELES GATEWAY - USA", 116 | "time" : "20:23", 117 | "date" : "Thursday, May 25, 2017 ", 118 | "location" : "LOS ANGELES GATEWAY, CA - USA", 119 | "totalPieces" : 1, 120 | "pIds" : [ "JD014600004444917061" ] 121 | }, { 122 | "counter" : 6, 123 | "description" : "Customs status updated", 124 | "time" : "07:43", 125 | "date" : "Thursday, May 25, 2017 ", 126 | "location" : "LOS ANGELES GATEWAY, CA - USA" 127 | }, { 128 | "counter" : 5, 129 | "description" : "Departed Facility in AMSTERDAM - NETHERLANDS, THE", 130 | "time" : "08:50", 131 | "date" : "Thursday, May 25, 2017 ", 132 | "location" : "AMSTERDAM - NETHERLANDS, THE", 133 | "totalPieces" : 1, 134 | "pIds" : [ "JD014600004444917061" ] 135 | }, { 136 | "counter" : 4, 137 | "description" : "Processed at AMSTERDAM - NETHERLANDS, THE", 138 | "time" : "05:22", 139 | "date" : "Thursday, May 25, 2017 ", 140 | "location" : "AMSTERDAM - NETHERLANDS, THE", 141 | "totalPieces" : 1, 142 | "pIds" : [ "JD014600004444917061" ] 143 | }, { 144 | "counter" : 3, 145 | "description" : "Departed Facility in ROTTERDAM - NETHERLANDS, THE", 146 | "time" : "20:37", 147 | "date" : "Wednesday, May 24, 2017 ", 148 | "location" : "ROTTERDAM - NETHERLANDS, THE", 149 | "totalPieces" : 1, 150 | "pIds" : [ "JD014600004444917061" ] 151 | }, { 152 | "counter" : 2, 153 | "description" : "Processed at ROTTERDAM - NETHERLANDS, THE", 154 | "time" : "20:37", 155 | "date" : "Wednesday, May 24, 2017 ", 156 | "location" : "ROTTERDAM - NETHERLANDS, THE", 157 | "totalPieces" : 1, 158 | "pIds" : [ "JD014600004444917061" ] 159 | }, { 160 | "counter" : 1, 161 | "description" : "Shipment picked up", 162 | "time" : "17:08", 163 | "date" : "Wednesday, May 24, 2017 ", 164 | "location" : "ROTTERDAM - NETHERLANDS, THE", 165 | "totalPieces" : 1, 166 | "pIds" : [ "JD014600004444917061" ] 167 | } ], 168 | "checkpointLocationLabel" : "Location", 169 | "checkpointTimeLabel" : "Time" 170 | } ] 171 | } 172 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Shipment Tracker 2 | 3 | **A simple tool to scrape the parcel tracking data for DHL, DHL Express, GLS, UPS, FedEx USPS, Swiss Post Service and Austria Post Service** 4 | 5 | [![Build Status](https://travis-ci.org/sauladam/shipment-tracker.svg?branch=master)](https://travis-ci.org/sauladam/shipment-tracker) 6 | [![Total Downloads](https://poser.pugx.org/sauladam/shipment-tracker/downloads)](https://packagist.org/packages/sauladam/shipment-tracker) 7 | 8 | 9 | Some parcel services give you a really hard time when it comes to registering some kind of merchant or developer account. 10 | All you actually want is to simply keep track of a shipment and have an eye on its status. Yes, you could keep refreshing 11 | the tracking pages, but sometimes you've just got better stuff to do. 12 | 13 | So here's a tool that does this automatically, without any of the developer-account and API mumbo jumbo. It just simply scrapes the website with the tracking information and transforms the data into an easily consumable format for humans and computers. Let me show you how it works! 14 | 15 | ## Installation 16 | 17 | Just pull this package in through composer or by adding it to your `composer.json` file: 18 | 19 | ```bash 20 | $ composer require sauladam/shipment-tracker 21 | ``` 22 | 23 | Don't forget to run 24 | 25 | $ composer update 26 | 27 | after that. 28 | 29 | ## Supported Carriers 30 | The following carriers and languages are currently supported by this package: 31 | 32 | - DHL (de, en) 33 | - DHL Express (de, en) (so far only for waybill numbers, not for shipment numbers of the individual pieces) 34 | - GLS (de, en) 35 | - UPS (de, en) 36 | - Fedex (en) (hat tip to [@avrahamappel](https://github.com/avrahamappel)) 37 | - USPS (en) 38 | - PostCH (Swiss Post Service) (de, en) 39 | - PostAT (Austria Post Service) (de, en) 40 | 41 | ## Basic Usage 42 | 43 | ```php 44 | require_once 'vendor/autoload.php'; 45 | 46 | use Sauladam\ShipmentTracker\ShipmentTracker; 47 | 48 | $dhlTracker = ShipmentTracker::get('DHL'); 49 | 50 | /* track with the standard settings */ 51 | $track = $dhlTracker->track('00340434127681930812'); 52 | // scrapes from http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=de&idc=00340434127681930812 53 | 54 | /* override the standard language */ 55 | $track = $dhlTracker->track('00340434127681930812', 'en'); 56 | // scrapes from http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=en&idc=00340434127681930812 57 | 58 | /* pass additional params to the URL (or override the default ones) */ 59 | $track = $dhlTracker->track('00340434127681930812', 'en', ['zip' => '12345']); 60 | // scrapes from http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=en&idc=00340434127681930812&zip=12345 61 | ``` 62 | 63 | And that's it. Let's check if this parcel was delivered: 64 | 65 | ```php 66 | if($track->delivered()) 67 | { 68 | echo "Delivered to " . $track->getRecipient(); 69 | } 70 | else 71 | { 72 | echo "Not delivered yet, The current status is " . $track->currentStatus(); 73 | } 74 | ``` 75 | 76 | #### Possible statuses are: 77 | 78 | - `Track::STATUS_IN_TRANSIT` 79 | - `Track::STATUS_DELIVERED` 80 | - `Track::STATUS_PICKUP` 81 | - `Track::STATUS_EXCEPTION` 82 | - `Track::STATUS_WARNING` 83 | - `Track::STATUS_UNKNOWN` 84 | 85 | #### So where is it right now and what's happening with it? 86 | 87 | ```php 88 | $latestEvent = $track->latestEvent(); 89 | 90 | echo "The parcel was last seen in " . $latestEvent->getLocation() . " on " . $latestEvent->getDate()->format('Y-m-d'); 91 | echo "What they did: " . $latestEvent->description(); 92 | echo "The status was " . $latestEvent->getStatus(); 93 | ``` 94 | 95 | You can grab an array with the whole event history with `$track->events()`. The events are sorted by date in descending order. The date is a [Carbon](https://github.com/briannesbitt/Carbon) object. 96 | 97 | ## What else? 98 | You just want to build up the URL for the tracking website? No problem: 99 | ```php 100 | $url = $dhlTracker->trackingUrl('00340434127681930812'); 101 | // http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=de&idc=00340434127681930812 102 | ``` 103 | Oh, you need it to link to the english version? Sure thing: 104 | ```php 105 | $url = $dhlTracker->trackingUrl('00340434127681930812', 'en'); 106 | // http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=en&idc=00340434127681930812 107 | ``` 108 | *"But wait, what if I need that URL with additional parameteres?"* - Well, just pass them: 109 | ```php 110 | $url = $dhlTracker->trackingUrl('00340434127681930812', 'en', ['zip' => '12345']); 111 | // http://nolp.dhl.de/nextt-online-public/set_identcodes.do?lang=en&idc=00340434127681930812&zip=12345 112 | ``` 113 | 114 | ## Other features 115 | ### Additional details 116 | Tracks and Events both can hold additional details, accessible via e.g. `$track->getAdditionalDetails('foo')`. Currently, this is only relevant for GLS and UPS: 117 | - **GLS:** 118 | - `$track->getAdditionalDetails('parcelShop')` gets the parcel shop details and the opening hours if the parcel was delivered to one 119 | 120 | - **UPS:** 121 | - `$track->getAdditionalDetails('accessPoint')` gets the address of the access point if the parcel was delivered to one 122 | - `$track->getAdditionalDetails('pickupDueDate')` gets the pickup due date as a Carbon instance 123 | 124 | - **DHL Express (waybills):** 125 | - `$track->getAdditionalDetails('pieces')` gets the tracking numbers of the individual pieces that belong to this shipment 126 | - `$event->getAdditionalDetails('pieces')` gets the tracking numbers of the individual pieces to which this event applies 127 | 128 | - **FedEx:** 129 | - `$track->getAdditionalDetails('totalKgsWgt')` gets the weight of the shipment in kgs if it's returned 130 | - `$track->getAdditionalDetails('totalLbsWgt')` gets the weight of the shipment in lbs if it's returned 131 | 132 | ### Data Providers 133 | By default, this package uses Guzzle as well as the PHP Http client (a.k.a. `file_get_contents()`) to fetch the data. You can pass your own provider if you need to, e.g. if you have the page contents chillin' somewhere in a cache. Just make sure that it implements `Sauladam\ShipmentTracker\DataProviders\DataProviderInterface`, which only requires a `get()` method. 134 | 135 | Then, you can just pass it to the factory: `$dhlTracker = ShipmentTracker::get('DHL', new CacheDataProvider);` 136 | 137 | If you pass your data provider, it's used by default, but you can swap it out later if you want: 138 | 139 | ```php 140 | $dhlTracker->useDataProvider('guzzle'); 141 | ``` 142 | 143 | Currently available providers are: 144 | - guzzle 145 | - php 146 | - custom (referring to the provider that you've passed) 147 | 148 | 149 | ## Notes 150 | Please keep in mind that this is just a tool to make your life easier. I do not recommend using it in a critical environment, because, due to the way it works, it can break down as soon as the tracking website where the data is pulled from changes its structure or renames/rephrases the event descriptions. So please **use it at your own risk!** 151 | 152 | Also, there's always a chance that a status can not be resolved because the event description is not known by this package, even though the most common events should be resolved correctly. 153 | 154 | Also, the tracking data, therefore the data provided by this package, is the property of the carrier (I guess), so under no circumstances you should use it commercially (like selling it or integrating it in a commercial service). It is intended only for personal use. 155 | -------------------------------------------------------------------------------- /src/Trackers/GLS.php: -------------------------------------------------------------------------------- 1 | 'https://gls-group.eu/DE/de/paketverfolgung', 20 | 'en' => 'https://gls-group.eu/DE/en/parcel-tracking' 21 | ]; 22 | 23 | /** 24 | * @var string 25 | */ 26 | protected $language = 'de'; 27 | 28 | 29 | /** 30 | * Parse the response. 31 | * 32 | * @param string $contents 33 | * 34 | * @return Track 35 | * @throws \Exception 36 | */ 37 | protected function buildResponse($contents) 38 | { 39 | $response = $this->jsonToArray($contents); 40 | 41 | if (isset($response['exceptionText'])) { 42 | $text = $response['exceptionText']; 43 | throw new \Exception("Unable to retrieve tracking data for [{$this->parcelNumber}]: {$text}"); 44 | } 45 | 46 | return $this->getTrack($response); 47 | } 48 | 49 | 50 | /** 51 | * Get the shipment status history. 52 | * 53 | * @param array $response 54 | * 55 | * @return Track 56 | */ 57 | protected function getTrack(array $response) 58 | { 59 | $track = new Track; 60 | 61 | foreach ($response['tuStatus'][0]['history'] as $index => $historyItem) { 62 | $event = new Event; 63 | 64 | $status = $this->resolveStatus($response, $index); 65 | 66 | $event->setStatus($status); 67 | $event->setLocation($this->getLocation($historyItem)); 68 | $event->setDescription($historyItem['evtDscr']); 69 | $event->setDate($this->getDate($historyItem)); 70 | $event->addAdditionalDetails('eventNumber', $response['tuStatus'][0]['progressBar']['evtNos'][$index]); 71 | 72 | $track->addEvent($event); 73 | 74 | if ($status == Track::STATUS_DELIVERED) { 75 | $track->setRecipient($this->getRecipient($response)); 76 | } 77 | 78 | if ($status == Track::STATUS_PICKUP) { 79 | $track->addAdditionalDetails('parcelShop', $this->getParcelShopDetails($response)); 80 | } 81 | } 82 | 83 | return $track->sortEvents(); 84 | } 85 | 86 | 87 | /** 88 | * Get the location. 89 | * 90 | * @param array $historyItem 91 | * 92 | * @return string 93 | */ 94 | protected function getLocation(array $historyItem) 95 | { 96 | return $historyItem['address']['city'] . ', ' . $historyItem['address']['countryName']; 97 | } 98 | 99 | 100 | /** 101 | * Get the formatted date. 102 | * 103 | * @param array $historyItem 104 | * 105 | * @return string 106 | */ 107 | protected function getDate(array $historyItem) 108 | { 109 | return $historyItem['date'] . ' ' . $historyItem['time']; 110 | } 111 | 112 | 113 | /** 114 | * Get the recipient / person who signed the delivery. 115 | * 116 | * @param array $response 117 | * 118 | * @return string 119 | */ 120 | protected function getRecipient(array $response) 121 | { 122 | return $response['tuStatus'][0]['signature']['value']; 123 | } 124 | 125 | 126 | /** 127 | * Match a shipping status from the given description. 128 | * 129 | * @param array $response 130 | * @param int $historyItemIndex 131 | * 132 | * @return string 133 | * 134 | */ 135 | protected function resolveStatus(array $response, $historyItemIndex) 136 | { 137 | $statuses = [ 138 | Track::STATUS_DELIVERED => [ 139 | '3.120', // unconfirmed 140 | '3.121', 141 | '3.0', 142 | ], 143 | Track::STATUS_IN_TRANSIT => [ 144 | '0.0', 145 | '0.100', 146 | '1.0', 147 | '11.0', 148 | '2.0', 149 | '2.106', 150 | '2.29', 151 | '4.40', 152 | '90.132', 153 | '35.40', 154 | '8.0', 155 | '6.211', 156 | ], 157 | Track::STATUS_PICKUP => [ 158 | '2.124', 159 | '3.124', 160 | ], 161 | Track::STATUS_EXCEPTION => [ 162 | ], 163 | ]; 164 | 165 | $eventNumber = $response['tuStatus'][0]['progressBar']['evtNos'][$historyItemIndex]; 166 | $progressStatusInfo = $response['tuStatus'][0]['progressBar']['statusInfo']; // DELIVERED | DELIVEREDPS | INTRANSIT 167 | 168 | foreach ($statuses as $status => $eventNumbers) { 169 | if (in_array($eventNumber, $eventNumbers)) { 170 | // if the event status is delivered but the whole shipment status is not delivered yet, override it with IN_TRANSIT 171 | if (($status === Track::STATUS_DELIVERED) && ($progressStatusInfo !== 'DELIVERED')) { 172 | return Track::STATUS_IN_TRANSIT; 173 | } 174 | 175 | return $status; 176 | } 177 | } 178 | 179 | return Track::STATUS_UNKNOWN; 180 | } 181 | 182 | 183 | /** 184 | * Get the parcel-shop details 185 | * 186 | * @param array $response 187 | * 188 | * @return array 189 | */ 190 | protected function getParcelShopDetails(array $response) 191 | { 192 | return isset($response['tuStatus'][0]['parcelShop']) && isset($response['tuStatus'][0]['parcelShop']['address']) 193 | ? $response['tuStatus'][0]['parcelShop']['address'] 194 | : []; 195 | } 196 | 197 | 198 | /** 199 | * Try to convert the Json string into an array. 200 | * 201 | * @param $string 202 | * 203 | * @return array 204 | * @throws \Exception 205 | */ 206 | protected function jsonToArray($string) 207 | { 208 | $array = json_decode($string, true); 209 | 210 | if (!$array) { 211 | throw new \Exception("Unable to decode GLS Json string [$string] for [{$this->parcelNumber}]."); 212 | } 213 | 214 | return $array; 215 | } 216 | 217 | 218 | /** 219 | * Build the user friendly url for the given tracking number. 220 | * 221 | * @param string $trackingNumber 222 | * @param null $language 223 | * @param array $params 224 | * 225 | * @return string 226 | */ 227 | public function trackingUrl($trackingNumber, $language = null, $params = []) 228 | { 229 | $language = $language ?: $this->language; 230 | 231 | $url = array_key_exists( 232 | $language, 233 | $this->trackingUrls 234 | ) ? $this->trackingUrls[$language] : $this->trackingUrls['de']; 235 | 236 | $additionalParams = !empty($params) ? $params : $this->trackingUrlParams; 237 | 238 | $qry = http_build_query(array_merge([ 239 | 'match' => $trackingNumber, 240 | ], $additionalParams)); 241 | 242 | return $url . '?' . $qry; 243 | } 244 | 245 | 246 | /** 247 | * Get the endpoint url. 248 | * 249 | * @param string $trackingNumber 250 | * @param null $language 251 | * @param array $params 252 | * 253 | * @return string 254 | */ 255 | protected function getEndpointUrl($trackingNumber, $language = null, $params = []) 256 | { 257 | $language = $language ?: $this->language; 258 | 259 | $url = str_replace('{language}', $language, $this->endpointUrl); 260 | 261 | $additionalParams = !empty($params) ? $params : $this->endpointUrlParams; 262 | 263 | $qry = http_build_query(array_merge([ 264 | 'match' => $trackingNumber, 265 | ], $additionalParams)); 266 | 267 | return $url . '?' . $qry; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /src/Trackers/DHLExpress.php: -------------------------------------------------------------------------------- 1 | 'http://www.dhl.com/en/hidden/component_library/express/local_express/dhl_de_tracking/de/sendungsverfolgung_dhlde.html', 21 | 'en' => 'http://www.dhl.com/en/hidden/component_library/express/local_express/dhl_de_tracking/en/tracking_dhlde.html' 22 | ]; 23 | 24 | /** 25 | * @var string 26 | */ 27 | protected $language = 'de'; 28 | 29 | 30 | /** 31 | * Parse the response. 32 | * 33 | * @param string $contents 34 | * 35 | * @return Track 36 | * @throws \Exception 37 | */ 38 | protected function buildResponse($contents) 39 | { 40 | $response = $this->json($contents); 41 | 42 | return $this->getTrack($response); 43 | } 44 | 45 | 46 | /** 47 | * Get the shipment status history. 48 | * 49 | * @param object $response 50 | * 51 | * @return Track 52 | */ 53 | protected function getTrack($response) 54 | { 55 | $track = new Track; 56 | 57 | $shipment = $response->results[0]; 58 | 59 | foreach ($shipment->checkpoints as $checkpoint) { 60 | $event = new Event; 61 | 62 | $event->setStatus($this->resolveStatus($checkpoint->description)); 63 | $event->setLocation($this->getLocation($checkpoint)); 64 | $event->setDescription($checkpoint->description); 65 | $event->setDate($this->getDate($checkpoint)); 66 | 67 | if (isset($checkpoint->pIds)) { 68 | $event->addAdditionalDetails('pieces', $checkpoint->pIds); 69 | } 70 | 71 | $track->addEvent($event); 72 | } 73 | 74 | if ($this->isDelivered($shipment)) { 75 | $track->setRecipient($this->getRecipient($shipment)); 76 | } 77 | 78 | if (isset($shipment->pieces) && isset($shipment->pieces->pIds)) { 79 | $track->addAdditionalDetails('pieces', $shipment->pieces->pIds); 80 | } 81 | 82 | return $track->sortEvents(); 83 | } 84 | 85 | 86 | /** 87 | * Get the location. 88 | * 89 | * @param object $checkpoint 90 | * 91 | * @return string 92 | */ 93 | protected function getLocation($checkpoint) 94 | { 95 | return $checkpoint->location; 96 | } 97 | 98 | 99 | /** 100 | * Get the formatted date. 101 | * 102 | * @param object $checkpoint 103 | * 104 | * @return string 105 | */ 106 | protected function getDate($checkpoint) 107 | { 108 | $date = $this->translatedDate($checkpoint->date); 109 | 110 | return Carbon::createFromFormat('l, F j, Y G:i', $date . $checkpoint->time); 111 | } 112 | 113 | 114 | /** 115 | * Replace German month names and weekday names with the English names 116 | * so it can easily be parsed by Carbon. 117 | * 118 | * @param string $dateString 119 | * 120 | * @return string 121 | */ 122 | protected function translatedDate($dateString) 123 | { 124 | return str_replace([ 125 | 'Januar', 126 | 'Februar', 127 | 'März', 128 | 'April', 129 | 'Mai', 130 | 'Juni', 131 | 'Juli', 132 | 'August', 133 | 'Spetember', 134 | 'Oktober', 135 | 'November', 136 | 'Dezember', 137 | 'Montag', 138 | 'Dienstag', 139 | 'Mittwoch', 140 | 'Donnerstag', 141 | 'Freitag', 142 | 'Samstag', 143 | 'Sonntag', 144 | ], [ 145 | 'January', 146 | 'February', 147 | 'March', 148 | 'April', 149 | 'May', 150 | 'June', 151 | 'July', 152 | 'August', 153 | 'September', 154 | 'October', 155 | 'November', 156 | 'December', 157 | 'Monday', 158 | 'Tuesday', 159 | 'Wednesday', 160 | 'Thursday', 161 | 'Friday', 162 | 'Saturday', 163 | 'Sunday', 164 | ], $dateString); 165 | } 166 | 167 | 168 | /** 169 | * Check if the shipment was delivered. 170 | * 171 | * @param $trackingInformation 172 | * 173 | * @return bool 174 | */ 175 | protected function isDelivered($trackingInformation) 176 | { 177 | return isset($trackingInformation->delivery) 178 | && isset($trackingInformation->delivery->status) 179 | && $trackingInformation->delivery->status == 'delivered'; 180 | } 181 | 182 | 183 | /** 184 | * Get the recipient / person who signed the delivery. 185 | * 186 | * @param object $shipment 187 | * 188 | * @return string 189 | */ 190 | protected function getRecipient($shipment) 191 | { 192 | return $shipment->signature->signatory; 193 | } 194 | 195 | 196 | /** 197 | * Match a shipping status from the given description. 198 | * 199 | * @param string $description 200 | * 201 | * @return string 202 | * 203 | */ 204 | protected function resolveStatus($description) 205 | { 206 | $statuses = [ 207 | Track::STATUS_DELIVERED => [ 208 | 'Delivered - Signed', 209 | 'Sendung zugestellt - übernommen', 210 | ], 211 | Track::STATUS_IN_TRANSIT => [ 212 | 'With delivery courier', 213 | 'Sendung in Zustellung', 214 | 'Arrived at', 215 | 'Ankunft in der', 216 | 'Departed Facility', 217 | 'Verlässt DHL-Niederlassung', 218 | 'Transferred through', 219 | 'Sendung im Transit', 220 | 'Processed at', 221 | 'Sendung sortiert', 222 | 'Clearance processing', 223 | 'Verzollung abgeschlossen', 224 | 'Customs status updated', 225 | 'Verzollungsstatus aktualisiert', 226 | 'Shipment picked up', 227 | 'Sendung abgeholt', 228 | ], 229 | ]; 230 | 231 | foreach ($statuses as $status => $needles) { 232 | foreach ($needles as $needle) { 233 | if (strpos($description, $needle) === 0) { 234 | return $status; 235 | } 236 | } 237 | } 238 | 239 | return Track::STATUS_UNKNOWN; 240 | } 241 | 242 | 243 | /** 244 | * Try to decode the JSON string into an object. 245 | * 246 | * @param $string 247 | * 248 | * @return object 249 | * @throws \Exception 250 | */ 251 | protected function json($string) 252 | { 253 | $json = json_decode($string); 254 | 255 | if (!$json) { 256 | throw new \Exception("Unable to decode DHL Express Json string [$string] for [{$this->parcelNumber}]."); 257 | } 258 | 259 | return $json; 260 | } 261 | 262 | 263 | /** 264 | * Build the user friendly url for the given tracking number. 265 | * 266 | * @param string $trackingNumber 267 | * @param null $language 268 | * @param array $params 269 | * 270 | * @return string 271 | */ 272 | public function trackingUrl($trackingNumber, $language = null, $params = []) 273 | { 274 | $language = $language ?: $this->language; 275 | 276 | $url = array_key_exists( 277 | $language, 278 | $this->trackingUrls 279 | ) ? $this->trackingUrls[$language] : $this->trackingUrls['de']; 280 | 281 | $additionalParams = !empty($params) ? $params : $this->trackingUrlParams; 282 | 283 | $qry = http_build_query(array_merge([ 284 | 'AWB' => $trackingNumber, 285 | 'brand' => 'DHL', 286 | ], $additionalParams)); 287 | 288 | return $url . '?' . $qry; 289 | } 290 | 291 | 292 | /** 293 | * Get the endpoint url. 294 | * 295 | * @param string $trackingNumber 296 | * @param null $language 297 | * @param array $params 298 | * 299 | * @return string 300 | */ 301 | protected function getEndpointUrl($trackingNumber, $language = null, $params = []) 302 | { 303 | $additionalParams = !empty($params) ? $params : $this->endpointUrlParams; 304 | 305 | $qry = http_build_query(array_merge([ 306 | 'AWB' => $trackingNumber, 307 | 'languageCode' => $language ?: $this->language, 308 | ], $additionalParams)); 309 | 310 | return $this->endpointUrl . '?' . $qry; 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/Trackers/PostCH.php: -------------------------------------------------------------------------------- 1 | language; 80 | 81 | $additionalParams = !empty($params) ? $params : $this->trackingUrlParams; 82 | 83 | $qry = http_build_query(array_merge([ 84 | 'formattedParcelCodes' => $trackingNumber, 85 | 'lang' => $language, 86 | ], $additionalParams)); 87 | 88 | return $this->trackingUrl . '?' . $qry; 89 | } 90 | 91 | 92 | /** 93 | * Build the endpoint url 94 | * 95 | * @param string $trackingNumber 96 | * @param string|null $language 97 | * @param array $params 98 | * 99 | * @return string 100 | */ 101 | public function getEndpointUrl($trackingNumber, $language = null, $params = []) 102 | { 103 | $this->createApiUser(); 104 | 105 | $results = json_decode( 106 | $this->fetch($this->searchEndpoint . '/' . $this->getHashForTrackingNumber() . '?' . http_build_query([ 107 | 'userId' => static::$userId, 108 | ])) 109 | ); 110 | 111 | return $this->serviceEndpoint . '/' . $results[0]->identity . '/events'; 112 | } 113 | 114 | 115 | /** 116 | * Build the response array. 117 | * 118 | * @param string $response 119 | * 120 | * @return Track 121 | */ 122 | protected function buildResponse($response) 123 | { 124 | $this->loadMessageCodeLookup(); 125 | 126 | return array_reduce(json_decode($response), function ($track, $event) { 127 | return $track->addEvent(Event::fromArray([ 128 | 'location' => empty($event->city) 129 | ? '' 130 | : sprintf("%s-%s %s", $event->country, $event->zip, $event->city), 131 | 'description' => $this->getDescriptionByCode($event->eventCode), 132 | 'date' => Carbon::parse($event->timestamp), 133 | 'status' => $this->resolveStatus($event->eventCode), 134 | ])); 135 | }, new Track); 136 | } 137 | 138 | 139 | /** 140 | * Load the message lookup array for the current language if 141 | * it doesn't exist yet. 142 | */ 143 | protected function loadMessageCodeLookup() 144 | { 145 | if (array_key_exists($this->language, static::$messageCodeLookup)) { 146 | return; 147 | } 148 | 149 | static::$messageCodeLookup[$this->language] = json_decode($this->fetch( 150 | str_replace('{lang}', $this->language, $this->messagesEndpoint) 151 | ), true); 152 | } 153 | 154 | 155 | /** 156 | * Create an API and get the user ID, the CSRF token and the session cookie. 157 | * Those are required for subsequent requests against the API. 158 | */ 159 | protected function createApiUser() 160 | { 161 | if (null !== static::$userId) { 162 | return; 163 | } 164 | 165 | $response = $this->getDataProvider()->client->request( 166 | 'GET', $this->apiUserEndpoint, [ 167 | 'cookies' => $jar = new CookieJar, 168 | ] 169 | ); 170 | 171 | foreach ($jar->toArray() as $cookie) { 172 | static::$cookies[$cookie['Name']] = $cookie['Value']; 173 | } 174 | 175 | $user = json_decode($response->getBody()->getContents()); 176 | 177 | if (!$user) { 178 | return; 179 | } 180 | 181 | static::$userId = $user->userIdentifier; 182 | static::$CSRFToken = $response->getHeader('X-CSRF-TOKEN'); 183 | } 184 | 185 | 186 | /** 187 | * Get the search hash value for the tracking number. The hash will be used later 188 | * to get the entity id for the tracking number. 189 | * 190 | * @return mixed 191 | */ 192 | protected function getHashForTrackingNumber() 193 | { 194 | $url = $this->trackingNumberHashEndpoint . '?' . http_build_query([ 195 | 'userId' => static::$userId, 196 | ]); 197 | 198 | $response = $this->getDataProvider()->client->post($url, [ 199 | 'headers' => [ 200 | 'Content-Type' => 'application/json', 201 | 'x-csrf-token' => static::$CSRFToken, 202 | 'Cookie' => array_reduce(array_keys(static::$cookies), function ($string, $cookieName) { 203 | return $string .= $cookieName . '=' . static::$cookies[$cookieName]; 204 | }, ''), 205 | ], 206 | 'json' => [ 207 | 'searchQuery' => $this->parcelNumber, 208 | ], 209 | ]); 210 | 211 | return json_decode($response->getBody()->getContents())->hash; 212 | } 213 | 214 | 215 | /** 216 | * Look up the description for the given event code. 217 | * 218 | * @param $code 219 | * 220 | * @return string 221 | */ 222 | public function getDescriptionByCode($code) 223 | { 224 | $haystack = static::$messageCodeLookup[$this->language]['shipment-text--']; 225 | 226 | $pattern = $this->getRegexPattern($code); 227 | 228 | $matches = array_filter(array_keys($haystack), function ($key) use ($pattern) { 229 | return 1 === preg_match($pattern, $key); 230 | }); 231 | 232 | return !empty($matches) ? $haystack[array_values($matches)[0]] : ''; 233 | } 234 | 235 | 236 | /** 237 | * Build a regex pattern for the code so it will match the exact code or wildcards. 238 | * E. g. if the code is 'LETTER.*.88.912', it should also match with 'LETTER.*.*.912' 239 | * or 'LETTER.*.88.912.*' 240 | * 241 | * @param $code 242 | * 243 | * @return string 244 | */ 245 | protected function getRegexPattern($code) 246 | { 247 | $pattern = array_reduce(explode('.', $code), function ($regex, $part) { 248 | if (1 === preg_match('/[a-z]+/i', $part)) { 249 | return $regex .= $part; 250 | } 251 | 252 | if ($part === '*') { 253 | return $regex .= "\.(\*|[a-z]+|-|_)"; 254 | } 255 | 256 | return $regex .= "\.(\*|{$part})"; 257 | }, ''); 258 | 259 | return sprintf("/%s(\.\*)?$/i", $pattern); 260 | } 261 | 262 | 263 | /** 264 | * Match a shipping status from the given event code. 265 | * 266 | * @param $eventCode 267 | * 268 | * @return string 269 | */ 270 | protected function resolveStatus($eventCode) 271 | { 272 | $statuses = [ 273 | Track::STATUS_DELIVERED => [ 274 | 'LETTER.*.88.40', 275 | 'LETTER.*.88.20', 276 | ], 277 | Track::STATUS_IN_TRANSIT => [ 278 | 'LETTER.*.88.912', 279 | 'LETTER.*.88.915', 280 | 'LETTER.*.88.818', 281 | 'LETTER.*.88.819', 282 | 'LETTER.*.88.803', 283 | 'LETTER.*.88.10', 284 | 'LETTER.*.88.13', 285 | 'LETTER.*.88.18', 286 | 'LETTER.*.88.620', 287 | 'LETTER.*.88.820', 288 | 'LETTER.*.88.1300', 289 | 'LETTER.*.88.804', 290 | 'LETTER.*.103.620', 291 | ], 292 | Track::STATUS_PICKUP => [ 293 | 'LETTER.*.88.21', 294 | ], 295 | Track::STATUS_WARNING => [], 296 | Track::STATUS_EXCEPTION => [], 297 | ]; 298 | 299 | foreach ($statuses as $status => $needles) { 300 | if (in_array($eventCode, $needles)) { 301 | return $status; 302 | } 303 | } 304 | 305 | return Track::STATUS_UNKNOWN; 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/Trackers/UPS.php: -------------------------------------------------------------------------------- 1 | getCookies(); 35 | } 36 | 37 | try { 38 | $response = $this->getDataProvider()->client->post($this->serviceUrl(), [ 39 | 'headers' => [ 40 | 'Content-Type' => 'application/json', 41 | 'X-XSRF-TOKEN' => static::$cookies['X-XSRF-TOKEN-ST'], 42 | 'Cookie' => implode(';', array_map(function ($name) { 43 | return $name . '=' . static::$cookies[$name]; 44 | }, array_keys(static::$cookies))), 45 | ], 46 | 'json' => [ 47 | 'Locale' => $this->getLanguageQueryParam($this->language), 48 | 'TrackingNumber' => [ 49 | $this->parcelNumber, 50 | ], 51 | ], 52 | ])->getBody()->getContents(); 53 | 54 | return json_decode($response, true); 55 | } catch (\Exception $e) { 56 | throw new \Exception("Could not fetch tracking data for [{$this->parcelNumber}]."); 57 | } 58 | } 59 | 60 | 61 | protected function getCookies() 62 | { 63 | $this->getDataProvider()->client->request( 64 | 'GET', $this->trackingUrl($this->parcelNumber), [ 65 | 'cookies' => $jar = new CookieJar, 66 | ] 67 | ); 68 | 69 | foreach ($jar->toArray() as $cookie) { 70 | static::$cookies[$cookie['Name']] = $cookie['Value']; 71 | } 72 | } 73 | 74 | 75 | /** 76 | * @param $contents 77 | * 78 | * @return Track 79 | * @throws \Exception 80 | */ 81 | protected function buildResponse($contents) 82 | { 83 | $track = new Track; 84 | 85 | foreach ($contents['trackDetails'][0]['shipmentProgressActivities'] as $progressActivity) { 86 | if (null === $progressActivity['activityScan']) { 87 | continue; 88 | } 89 | 90 | $track->addEvent(Event::fromArray([ 91 | 'location' => $progressActivity['location'], 92 | 'description' => $progressActivity['activityScan'], //$this->getDescription($progressActivity), 93 | 'date' => $this->getDate($progressActivity), 94 | 'status' => $status = $this->resolveState($progressActivity['activityScan']), 95 | ])); 96 | 97 | if ($status == Track::STATUS_DELIVERED && isset($contents['trackDetails'][0]['receivedBy'])) { 98 | $track->setRecipient($contents['trackDetails'][0]['receivedBy']); 99 | } 100 | 101 | if ($status == Track::STATUS_PICKUP && isset($contents['trackDetails'][0]['upsAccessPoint'])) { 102 | $track->addAdditionalDetails('location', $contents['trackDetails'][0]['upsAccessPoint']); 103 | $track->addAdditionalDetails('pickupDueDate', $contents['trackDetails'][0]['upsAccessPoint']['pickupPackageByDate']); 104 | } 105 | } 106 | 107 | return $track->sortEvents(); 108 | } 109 | 110 | 111 | protected function getDescription($activity) 112 | { 113 | if (!isset($activity['milestone'])) { 114 | return null; 115 | } 116 | 117 | if (!$this->descriptionLookup) { 118 | $this->loadDescriptionLookup(); 119 | } 120 | 121 | return array_key_exists($activity['milestone']['name'], $this->descriptionLookup) 122 | ? $this->descriptionLookup[$activity['milestone']['name']] 123 | : null; 124 | } 125 | 126 | 127 | protected function loadDescriptionLookup() 128 | { 129 | try { 130 | $url = $this->descriptionLookupEndpoint 131 | . '?' 132 | . http_build_query(['loc' => $this->getLanguageQueryParam($this->language)]); 133 | 134 | $response = $this->getDataProvider()->get($url); 135 | 136 | $this->descriptionLookup = $this->extractKeysAndValues(json_decode($response, true)); 137 | } catch (\Exception $e) { 138 | $this->descriptionLookup = []; 139 | } 140 | } 141 | 142 | 143 | protected function extractKeysAndValues($array) 144 | { 145 | return array_reduce((array)$array, function ($lookups, $value) { 146 | if (!is_array($value)) { 147 | return $lookups; 148 | } 149 | 150 | if (!array_key_exists('key', $value) && !array_key_exists('value', $value)) { 151 | return array_merge($lookups, $this->extractKeysAndValues($value)); 152 | } 153 | 154 | $lookups[$value['key']] = $value['value']; 155 | 156 | return $lookups; 157 | }, []); 158 | } 159 | 160 | 161 | /** 162 | * Parse the date from the given strings. 163 | * 164 | * @param array $activity 165 | * 166 | * @return Carbon 167 | */ 168 | protected function getDate($activity) 169 | { 170 | return Carbon::parse("{$activity['date']} {$activity['time']}"); 171 | } 172 | 173 | 174 | protected function getStatuses() 175 | { 176 | return [ 177 | Track::STATUS_PICKUP => [ 178 | 'UPS Access Point™ possession', 179 | 'Beim UPS Access Point™', 180 | 'Delivered to UPS Access Point™', 181 | 'An UPS Access Point™ zugestellt', 182 | ], 183 | Track::STATUS_IN_TRANSIT => [ 184 | 'Auftrag verarbeitet', 185 | 'Wird zugestellt', 186 | 'Ready for UPS', 187 | 'Scan', 188 | 'Out For Delivery', 189 | 'receiver requested a hold for a future delivery date', 190 | 'receiver was not available at the time of the first delivery attempt', 191 | 'war beim 1. Zustellversuch nicht anwesend', 192 | 'Adresse wurde korrigiert und die Zustellung neu terminiert', 193 | 'The address has been corrected', 194 | 'A final attempt will be made', 195 | 'ltiger Versuch erfolgt', 196 | 'Will deliver to a nearby UPS Access Point™ for customer pick up', 197 | 'Zustellung wird zur Abholung durch Kunden an nahem UPS Access Point™ abgegeben', 198 | 'Customer was not available when UPS attempted delivery', 199 | 'In Einrichtung eingetroffen', 200 | 'Arrived at Facility', 201 | 'In Einrichtung eingetroffen', 202 | 'Departed from Facility', 203 | 'Hat Einrichtung verlassen', 204 | 'Order Processed', 205 | 'Auftrag verarbeitet', 206 | 'Wird heute zugestellt', 207 | 'Processing at UPS Facility', 208 | 'Bearbeitung in UPS Einrichtung', 209 | ], 210 | Track::STATUS_WARNING => [ 211 | 'attempting to obtain a new delivery address', 212 | 'eine neue Zustelladresse für den Empf', 213 | 'nderung für dieses Paket ist in Bearbeitung', 214 | 'A delivery change for this package is in progress', 215 | 'The receiver was not available at the time of the final delivery attempt', 216 | ], 217 | Track::STATUS_EXCEPTION => [ 218 | 'Exception', 219 | 'Adressfehlers konnte die Sendung nicht zugestellt', 220 | 'nger ist unbekannt', 221 | 'The address is incomplete', 222 | 'ist falsch', 223 | 'is incorrect', 224 | 'ltigen Zustellversuch nicht anwesend', 225 | 'receiver was not available at the time of the final delivery attempt', 226 | 'verweigerte die Annahme', 227 | 'refused the delivery', 228 | ], 229 | Track::STATUS_DELIVERED => [ 230 | 'Delivered', 231 | 'Zugestellt', 232 | ], 233 | ]; 234 | } 235 | 236 | 237 | /** 238 | * Match a shipping status from the given description. 239 | * 240 | * @param $statusDescription 241 | * 242 | * @return string 243 | */ 244 | protected function resolveState($statusDescription) 245 | { 246 | foreach ($this->getStatuses() as $status => $needles) { 247 | foreach ($needles as $needle) { 248 | if (stripos($statusDescription, $needle) !== false) { 249 | return $status; 250 | } 251 | } 252 | } 253 | 254 | return Track::STATUS_UNKNOWN; 255 | } 256 | 257 | 258 | /** 259 | * Build the url for the given tracking number. 260 | * 261 | * @param string $trackingNumber 262 | * @param null $language 263 | * @param array $params 264 | * 265 | * @return string 266 | */ 267 | public function trackingUrl($trackingNumber, $language = null, $params = []) 268 | { 269 | $language = $language ?: $this->language; 270 | 271 | $additionalParams = !empty($params) ? $params : $this->trackingUrlParams; 272 | 273 | $qry = http_build_query(array_merge([ 274 | 'loc' => $this->getLanguageQueryParam($language), 275 | 'tracknum' => $trackingNumber, 276 | ], $additionalParams)); 277 | 278 | return $this->trackingUrl . '?' . $qry; 279 | } 280 | 281 | 282 | public function serviceUrl($language = null) 283 | { 284 | $language = $language ?: $this->language; 285 | 286 | return $this->serviceEndpoint 287 | . '?' 288 | . http_build_query([ 289 | 'loc' => $this->getLanguageQueryParam($language), 290 | ]); 291 | } 292 | 293 | 294 | /** 295 | * Get the language value for the url query 296 | * 297 | * @param string $givenLanguage 298 | * 299 | * @return string 300 | */ 301 | protected function getLanguageQueryParam($givenLanguage) 302 | { 303 | if ($givenLanguage == 'de') { 304 | return 'de_DE'; 305 | } 306 | 307 | return 'en_US'; 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/Trackers/DHL.php: -------------------------------------------------------------------------------- 1 | parsedJson = null; 49 | 50 | return parent::track($number, $language, $params); 51 | } 52 | 53 | 54 | /** 55 | * Get the contents of the given url. 56 | * 57 | * @param string $url 58 | * 59 | * @return string 60 | */ 61 | protected function fetch($url) 62 | { 63 | if ($this->defaultDataProvider !== 'guzzle') { 64 | return $this->getDataProvider()->get($url); 65 | } 66 | 67 | return $this->getDataProvider()->get( 68 | $url, 69 | [ 70 | 'timeout' => 5, 71 | 'headers' => [ 72 | 'User-Agent' => 'tracking/1.0', 73 | 'Accept' => 'text/html', 74 | ] 75 | ] 76 | ); 77 | } 78 | 79 | 80 | /** 81 | * @param string $contents 82 | * 83 | * @return Track 84 | * @throws \Exception 85 | */ 86 | protected function buildResponse($contents) 87 | { 88 | $dom = new DOMDocument; 89 | @$dom->loadHTML($contents); 90 | $dom->preserveWhiteSpace = false; 91 | 92 | $domxpath = new DOMXPath($dom); 93 | 94 | return $this->getTrack($domxpath); 95 | } 96 | 97 | 98 | /** 99 | * Get the shipment status history. 100 | * 101 | * @param DOMXPath $xpath 102 | * 103 | * @return Track 104 | * @throws \Exception 105 | */ 106 | protected function getTrack(DOMXPath $xpath) 107 | { 108 | $track = new Track; 109 | 110 | foreach ($this->getEvents($xpath) as $event) { 111 | $track->addEvent(Event::fromArray([ 112 | 'description' => isset($event->status) ? strip_tags($event->status) : '', 113 | 'status' => $status = isset($event->status) ? $this->resolveStatus(strip_tags($event->status)) : '', 114 | 'date' => isset($event->datum) ? Carbon::parse($event->datum) : null, 115 | 'location' => isset($event->ort) ? $event->ort : '', 116 | ])); 117 | 118 | if ($status == Track::STATUS_DELIVERED && $recipient = $this->getRecipient($xpath)) { 119 | $track->setRecipient($recipient); 120 | } 121 | } 122 | 123 | return $track->sortEvents(); 124 | } 125 | 126 | 127 | /** 128 | * Get the events. 129 | * 130 | * @param DOMXPath $xpath 131 | * @return array 132 | * @throws \Exception 133 | */ 134 | protected function getEvents(DOMXPath $xpath) 135 | { 136 | $progress = $this->parseJson($xpath)->sendungen[0]->sendungsdetails->sendungsverlauf; 137 | 138 | return $progress->fortschritt > 0 139 | ? (array)$progress->events 140 | : []; 141 | } 142 | 143 | 144 | /** 145 | * Parse the recipient. 146 | * 147 | * @param DOMXPath $xpath 148 | * 149 | * @return null|string 150 | * @throws \Exception 151 | */ 152 | protected function getRecipient(DOMXPath $xpath) 153 | { 154 | $deliveryDetails = $this->parseJson($xpath)->sendungen[0]->sendungsdetails->zustellung; 155 | 156 | return isset($deliveryDetails->empfaenger) && isset($deliveryDetails->empfaenger->name) 157 | ? $deliveryDetails->empfaenger->name 158 | : null; 159 | } 160 | 161 | 162 | /** 163 | * Parse the JSON from the script tag. 164 | * 165 | * @param DOMXPath $xpath 166 | * @return mixed|object 167 | * @throws \Exception 168 | */ 169 | protected function parseJson(DOMXPath $xpath) 170 | { 171 | if ($this->parsedJson) { 172 | return $this->parsedJson; 173 | } 174 | 175 | $scriptTags = $xpath->query("//script"); 176 | 177 | if ($scriptTags->length < 1) { 178 | throw new \Exception("Unable to parse DHL tracking data for [{$this->parcelNumber}]."); 179 | } 180 | 181 | $matched = preg_match( 182 | "/initialState: JSON\.parse\((.*)\)\,/m", 183 | $scriptTags->item(2)->nodeValue, 184 | $matches 185 | ); 186 | 187 | if ($matched !== 1) { 188 | throw new \Exception("Unable to parse DHL tracking data for [{$this->parcelNumber}]."); 189 | } 190 | 191 | return $this->parsedJson = json_decode(json_decode($matches[1])); 192 | } 193 | 194 | 195 | /** 196 | * Match a shipping status from the given description. 197 | * 198 | * @param $statusDescription 199 | * 200 | * @return string 201 | */ 202 | protected function resolveStatus($statusDescription) 203 | { 204 | $statuses = [ 205 | Track::STATUS_DELIVERED => [ 206 | 'aus der PACKSTATION abgeholt', 207 | 'erfolgreich zugestellt', 208 | 'hat die Sendung in der Filiale abgeholt', 209 | 'des Nachnahme-Betrags an den Zahlungsempf', 210 | 'Sendung wurde zugestellt', 211 | 'Die Sendung wurde ausgeliefert', 212 | 'shipment has been successfully delivered', 213 | 'recipient has picked up the shipment from the retail outlet', 214 | 'recipient has picked up the shipment from the PACKSTATION', 215 | 'item has been sent', 216 | 'delivered from the delivery depot to the recipient by simplified company delivery', 217 | 'per vereinfachter Firmenzustellung ab Eingangspaketzentrum zugestellt', 218 | 'im Rahmen der kontaktlosen Zustellung zugestellt', 219 | 'Pick-up was successful', 220 | 'Sendung wurde an den Empfänger zugestellt', 221 | 'Shipment has been delivered to the recipient', 222 | 'Shipment delivered', 223 | ], 224 | Track::STATUS_IN_TRANSIT => [ 225 | 'in das Zustellfahrzeug geladen', 226 | 'im Start-Paketzentrum bearbeitet', 227 | 'im Ziel-Paketzentrum bearbeitet', 228 | 'im Paketzentrum bearbeitet', 229 | 'Auftragsdaten zu dieser Sendung wurden vom Absender elektronisch an DHL', 230 | 'auf dem Weg zur PACKSTATION', 231 | 'wird in eine PACKSTATION weitergeleitet', 232 | 'Die Sendung wurde abgeholt', 233 | 'im Export-Paketzentrum bearbeitet', 234 | 'Sendung wird ins Zielland', 235 | 'will be transported to the destination country', 236 | 'vom Absender in der Filiale eingeliefert', 237 | 'Sendung konnte nicht in die PACKSTATION eingestellt werden und wurde in eine Filiale', 238 | 'Sendung konnte nicht zugestellt werden und wird jetzt zur Abholung in die Filiale/Agentur gebracht', 239 | 'shipment has been picked up', 240 | 'instruction data for this shipment have been provided', 241 | 'shipment has been processed', 242 | 'shipment has been posted by the sender', 243 | 'hipment has been loaded onto the delivery vehicle', 244 | 'A 2nd attempt at delivery is being made', 245 | 'shipment is on its way to the PACKSTATION', 246 | 'forwarded to a PACKSTATION', 247 | 'shipment could not be delivered to the PACKSTATION and has been forwarded to a retail outlet', 248 | 'shipment could not be delivered, and the recipient has been notified', 249 | 'A 2nd attempt at delivery is being made', 250 | 'Es erfolgt ein 2. Zustellversuch', 251 | 'Sendung wurde elektronisch angekündigt', 252 | 'sendung wurde an DHL übergeben', 253 | 'Sendung ist in der Region des Empfängers angekommen', 254 | 'The shipment arrived in the region of recipient', 255 | 'Die Zustellung an einen gewünschten Ablageort/Nachbarn wurde gewählt', 256 | 'There is a preferred location/neighbour for this item', 257 | 'Die Sendung wurde für den Weitertransport vorbereitet', 258 | 'The shipment was prepared for onward transport', 259 | 'The shipment has been processed in the parcel center of origin', 260 | 'für den Weitertransport in die Region', 261 | 'Die Zustellung der Sendung verzögert sich', 262 | 'The delivery of the shipment is delayed', 263 | 'Für diese Sendung wurde eine Paketumleitung', 264 | 'Für diese Sendung wurde ein Ablageort', 265 | 'The recipient has selected a', 266 | 'Die Sendung wird für die Verladung ins Zustellfahrzeug vorbereitet', 267 | 'The shipment is being prepared for loading onto the delivery vehicle', 268 | 'Sendung hat die DHL-Station verlassen', 269 | 'Sendung wurde abgeholt', 270 | 'Shipment picked up in', 271 | 'In Auslieferung durch Kurier', 272 | 'With delivery courier', 273 | 'Sendung sortiert', 274 | 'Ankunft in der DHL Zustellstation', 275 | 'Arrival at DHL Delivery Station', 276 | 'Ankunft in DHL Station', 277 | 'Shipment has arrived at hub', 278 | 'Departed Facility', 279 | 'Arrived at sort facility in', 280 | 'Processed at', 281 | 'Shipment has arrived at delivery location', 282 | 'Sendung zur Aufbewahrung in der DHL Station', 283 | 'Die Sendung ist im Zielland/Zielgebiet eingetroffen', 284 | 'The shipment has arrived in the destination country/destination area', 285 | 'Die Sendung ist im Paketzentrum eingetroffen', 286 | 'The shipment has arrived at the parcel center', 287 | 'Die Sendung wird zur Verzollung im Zielland/Zielgebiet vorbereitet', 288 | 'Shipment is prepared for customs clearance in the destination country/destination area', 289 | 'Die Sendung wurde durch den Zoll im Zielland/Zielgebiet freigegeben', 290 | 'The shipment has cleared customs in the destination country/destination area', 291 | 'Die Sendung hat das Import-Paketzentrum im Zielland/Zielgebiet verlassen', 292 | 'The shipment has left the import parcel center in the destination country/destination area', 293 | 'Die Sendung wird im Zustell-Depot für die Zustellung vorbereitet', 294 | 'The shipment is being prepared for delivery in the delivery depot', 295 | 'The recipient was not present', 296 | 'wurde nicht angetroffen', 297 | ], 298 | Track::STATUS_PICKUP => [ 299 | 'Die Sendung liegt in der PACKSTATION', 300 | 'Uhrzeit der Abholung kann der Benachrichtigungskarte entnommen werden', 301 | 'earliest time when it can be picked up can be found on the notification card', 302 | 'shipment is ready for pick-up at the PACKSTATION', 303 | 'Sendung wird zur Abholung in die', 304 | 'Sendung wurde zur Abholung in die', 305 | 'The shipment is being brought to', 306 | 'Die Sendung liegt ab sofort in der', 307 | 'The shipment is available for pick-up', 308 | 'Die Sendung liegt für den Empfänger zur Abholung bereit', 309 | 'The shipment is ready for pick-up by the recipient', 310 | 'Die Sendung liegt zur Abholung in', 311 | 'The shipment is ready for collection', 312 | ], 313 | Track::STATUS_WARNING => [ 314 | 'Sendung konnte nicht zugestellt werden', 315 | 'shipment could not be delivered', 316 | 'attempting to obtain a new delivery address', 317 | 'eine neue Zustelladresse für den Empf', 318 | 'Sendung wurde fehlgeleitet und konnte nicht zugestellt werden. Die Sendung wird umadressiert und an den', 319 | 'shipment was misrouted and could not be delivered. The shipment will be readdressed and forwarded to the recipient', 320 | 'Leider war eine Zustellung der Sendung heute nicht möglich', 321 | 'Die Sendung wurde leider fehlgeleitet', 322 | 'Shipment on hold', 323 | ], 324 | Track::STATUS_EXCEPTION => [ 325 | 'cksendung eingeleitet', 326 | 'Adressfehlers konnte die Sendung nicht zugestellt', 327 | 'nger ist unbekannt', 328 | 'The address is incomplete', 329 | 'ist falsch', 330 | 'is incorrect', 331 | 'recipient has not picked up the shipment', 332 | 'nicht in der Filiale abgeholt', 333 | 'The shipment is being returned', 334 | 'Es erfolgt eine Rücksendung', 335 | 'Zustellung der Sendung nicht möglich', 336 | 'recipient is unknown', 337 | 'Zustellung nicht möglich', 338 | 'Delivery not possible', 339 | ], 340 | ]; 341 | 342 | foreach ($statuses as $status => $needles) { 343 | foreach ($needles as $needle) { 344 | if (stripos($statusDescription, $needle) !== false) { 345 | return $status; 346 | } 347 | } 348 | } 349 | 350 | return Track::STATUS_UNKNOWN; 351 | } 352 | 353 | 354 | /** 355 | * Build the url for the given tracking number. 356 | * 357 | * @param string $trackingNumber 358 | * @param string|null $language 359 | * @param array $params 360 | * 361 | * @return string 362 | */ 363 | public function trackingUrl($trackingNumber, $language = null, $params = []) 364 | { 365 | $language = $language ?: $this->language; 366 | 367 | $additionalParams = !empty($params) ? $params : $this->trackingUrlParams; 368 | 369 | $urlParams = array_merge([ 370 | 'lang' => $language, 371 | 'idc' => $trackingNumber, 372 | ], $additionalParams); 373 | 374 | $qry = http_build_query($urlParams); 375 | 376 | return $this->trackingUrl . '?' . $qry; 377 | } 378 | 379 | 380 | /** 381 | * Build the endpoint url 382 | * 383 | * @param string $trackingNumber 384 | * @param string|null $language 385 | * @param array $params 386 | * 387 | * @return string 388 | */ 389 | protected function getEndpointUrl($trackingNumber, $language = null, $params = []) 390 | { 391 | $language = $language ?: $this->language; 392 | 393 | $additionalParams = !empty($params) ? $params : $this->endpointUrlParams; 394 | 395 | $urlParams = array_merge([ 396 | 'lang' => $language, 397 | 'language' => $language, 398 | 'idc' => $trackingNumber, 399 | 'domain' => 'de', 400 | ], $additionalParams); 401 | 402 | return $this->serviceEndpoint . '%3F' . http_build_query($urlParams); 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /tests/mock/PostCH/unknown.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Track & Trace 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 26 | 58 | 65 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 222 | 308 | 309 | 310 | 311 | 312 | 313 | 314 |
315 | 316 |

Search results

317 | 318 | 319 | 320 | 321 |
322 | 323 | 324 | 325 | 326 | 333 | 334 | 335 |
336 |
337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 |
    349 | 350 |
351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 |
    367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 |
  • RB36689096DE
  • 375 | 376 | 377 | 378 |
379 | 380 |
381 | 382 | 383 | 384 |

Unfortunately your search was unsuccessful. The causes of this could be:
- Consignment has not yet been handed over to Swiss Post or scanned.
- Shipping dates back more than 180 days (public search) or 360 days (search for logged-in business customers with a billing relationship)
- Typing error in the number
- Incorrect format for consignment number (see "Frequently Asked Questions" link)

385 |

Please check your details and/or try again in a few hours.
Useful information can be found above under "Info".

386 |

We would be pleased to help if you have any questions. 387 | 388 | custcare@swisspost.ch 389 | 390 |
Thank you. 391 |

392 |

International consignments can also be tracked via our partners:
- TNT Swiss Post SA, leading provider of international courier and express services
- Swiss Post GLS, transport specialist for business-to-business goods shipments

393 | 394 | 395 |
396 | 397 |
398 |
399 |
400 |

For import consignments, the enquiry must be initiated by the sender abroad via his or her postal company. Please contact the sender to ensure that he or she requests the enquiry.

401 |
402 |
403 |
404 | 405 | 406 | 407 |
408 |
409 | 410 |
411 | 433 | 434 | 435 | 447 | 448 | 449 | --------------------------------------------------------------------------------