universal-parcel-scraper 0.25.1-main.364 → 0.25.3-main.366

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2612,6 +2612,27 @@
2612
2612
  "dpd-fr"
2613
2613
  ]
2614
2614
  },
2615
+ {
2616
+ "input": "2522622410",
2617
+ "carrier": "unknown",
2618
+ "confidence": "low",
2619
+ "candidates": [
2620
+ "dhl-express",
2621
+ "dhl",
2622
+ "mondial-relay",
2623
+ "relais-colis",
2624
+ "tipsa",
2625
+ "estafeta"
2626
+ ],
2627
+ "preferred": [
2628
+ "dhl-express"
2629
+ ],
2630
+ "asked": [
2631
+ "dhl-express",
2632
+ "relais-colis",
2633
+ "tipsa"
2634
+ ]
2635
+ },
2615
2636
  {
2616
2637
  "input": "25631818",
2617
2638
  "carrier": "unknown",
@@ -7666,7 +7666,9 @@ var co = {
7666
7666
  IN: "aberdeen|akron|albany|albion|alexandria|anderson|andrews|angola|arcadia|argos|attica|auburn|aurora|austin|avilla|avon|bargersville|basslake|batesville|battleground|bedford|beechgrove|berne|bicknell|bloomfield|bloomington|bluffton|boonville|bourbon|brazil|bremen|bright|bristol|broadripple|brooklyn|brookston|brookville|brownsburg|brownstown|burnsharbor|butler|cambridgecity|cannelton|carmel|cayuga|cedarlake|centerville|chandler|charlestown|chesterfield|chesterton|churubusco|cicero|clarksville|clermont|clinton|cloverdale|columbiacity|columbus|connersville|converse|cordrysweetwaterlakes|corydon|countrysquirelakes|covington|crawfordsville|crothersville|crownpoint|culver|cumberland|dale|daleville|danville|darmstadt|dayton|decatur|delphi|demotte|dillsboro|dunkirk|dunlap|dyer|eastchicago|eaton|edgewood|edinburgh|elkhart|ellettsville|elwood|english|evansville|fairfieldheights|fairmount|fairviewpark|farmersburg|farmland|ferdinand|fishers|fishlake|flora|fortville|fowler|frankfort|franklin|frankton|fremont|frenchlick|ftbranch|ftwayne|galena|galveston|garrett|gary|gascity|geneva|georgetown|goodland|goshen|grabill|granger|greencastle|greendale|greenfield|greensburg|greentown|greenwood|griffith|grissomairforcebase|gulivoirepark|hagerstown|hamilton|hammond|hanover|harlan|hartfordcity|haubstadt|hebron|henryville|heritagelake|hiddenvalley|highland|hobart|hope|hudsonlake|huntertown|huntingburg|huntington|indianapolis|indianheights|ingalls|jasonville|jasper|jeffersonville|jonesboro|kendallville|kentland|kingsfordheights|knightstown|knox|kokomo|koontzlake|kouts|lafayette|lagrange|lakedalecarlia|lakesofthefourseasons|lakestation|lapel|laporte|lawrence|lawrenceburg|lebanon|leocedarville|liberty|ligonier|linton|logansport|longbeach|loogootee|lowell|lynn|madison|marion|markle|martinsville|mccordsville|melodyhill|meridianhills|merrillville|michigancity|middlebury|middletown|milan|milford|mishawaka|mitchell|monon|monroeville|monrovia|monticello|montpelier|mooresville|morocco|morristown|mtvernon|mulberry|muncie|munster|nappanee|nashville|newalbany|newburgh|newcarlisle|newcastle|newchicago|newhaven|newpalestine|newparis|newpekin|newport|newwhiteland|noblesville|northjudson|northliberty|northmadison|northmanchester|northterrehaute|northvernon|northwebster|notredame|oaklandcity|oakpark|odon|ogdendunes|oolitic|orleans|osceola|osgood|ossian|otis|otterbein|owensville|oxford|paoli|parkercity|pendleton|peru|petersburg|pierceton|pittsboro|plainfield|plymouth|portage|porter|portland|poseyville|princeslakes|princeton|redkey|remington|rensselaer|richmond|risingsun|roanoke|rochester|rockport|rockville|romecity|roselawn|rossville|rushville|russiaville|santaclaus|schererville|scottsburg|seelyville|sellersburg|seymour|shadeland|shelburn|shelbyville|sheridan|shoals|shorewoodforest|simontonlake|smithvillesanders|southbend|southhaven|southport|southwhitley|speedway|spencer|stjohn|stpaul|sullivan|sunman|sweetser|syracuse|tellcity|terrehaute|thorntown|tipton|topeka|trafalgar|trailcreek|trilakes|unioncity|upland|valparaiso|veedersburg|versailles|vevay|vincennes|wabash|wakarusa|walkerton|walton|wanatah|warren|warrenpark|warsaw|washington|waterloo|westfield|westlafayette|westport|westterrehaute|westville|whiteland|whitestown|whiting|williamsport|winamac|winchester|winfield|winonalake|wolcottville|woodburn|worthington|yorktown|zionsville",
7667
7667
  MT: "absarokee|anaconda|baker|belgrade|bigfork|bigsky|bigtimber|billings|bonnerwestriverside|boulder|bozeman|broadus|browning|butte|chester|chinook|choteau|circle|clancy|clinton|colstrip|columbiafalls|columbus|conrad|crowagency|cutbank|deerlodge|dillon|easthelena|eastmissoula|ekalaka|eureka|evergreen|forsyth|fourcorners|frenchtown|ftbelknapagency|ftbenton|glasgow|glendive|greatfalls|hamilton|hardin|harlowton|havre|helena|helenavalleynortheast|helenavalleynorthwest|helenavalleysoutheast|helenavalleywestcentral|helenawestside|hysham|jordan|kalispell|lakeside|lamedeer|laurel|lewistown|libby|lincoln|livingston|lockwood|lolo|malmstromairforcebase|malta|manhattan|milescity|missoula|montanacity|northbrowning|orchardhomes|pablo|philipsburg|plains|plentywood|polson|redlodge|ronan|roundup|ryegate|scobey|seeleylake|shelby|sidney|somers|southbrowning|stanford|stevensville|sunprairie|superior|terry|thompsonfalls|threeforks|townsend|virginiacity|warmsprings|westglendive|westyellowstone|whitefish|whitehall|whitesulphursprings|wibaux|winnett|wolfpoint",
7668
7668
  NL: "airportheights|baieverte|ballyhaly|bannerman|baybulls|bayroberts|baystgeorgesouth|bellsturn|bishopsfalls|bonavista|botwood|burgeo|burin|carbonear|centrevillewarehamtrinity|channelportauxbasques|churchillpark|clarenville|clarenvilleshoalharbour|clarkesbeach|clovellytrails|conceptionbaysouth|cornerbrook|cowanheights|deerlake|donovans|downtownstjohns|eastmeadows|evergreenvillage|fairviewacres|flatrock|fogoisland|fortune|ftwilliam|gambo|gander|georgestown|glendale|glenridgecrescent|glovertown|goulds|grandbank|grandfallswindsor|happyvalleygoosebay|harbourbreton|harbourgrace|harbourmainchapelscovelakeview|holyrood|humberarmsouth|irishtownsummerside|karwoodestates|kenmountterrace|kilbride|kingwilliamestates|kippens|labradorcity|lewisporte|littlecanada|logybaymiddlecoveoutercove|marystown|masseydrive|mtcashel|mtpearl|mundypond|nain|newwesvalley|paradise|pasadena|pearlgate|placentia|pleasantville|portugalcovestphillips|pouchcove|quidividi|rabbittown|sesamepark|sheaheights|sheshatshiu|signalhill|southlands|spaniardsbay|springdale|sprucemeadows|stalbans|stanthony|stephenville|stephenvillecrossing|stgeorges|stjohns|stlawrence|torbay|trinitybaynorth|twillingate|upperislandcove|victoria|virginiapark|wabana|wabush|waterfordvalley|whitbourne|wishingwellpark|witlessbay",
7669
- SK: "adelaidechurchill|albertpark|alritchie|arborcreek|arcolaeast|argylepark|aspenridge|assiniboia|avalon|balgonie|battleford|biggar|blairmore|blairmoreurbancentre|boothill|brevoortpark|briarwood|brighton|buenavista|buffalonarrows|canora|carlyle|caswellhill|cathedral|centralbusinessdistrict|centresquare|citypark|collegepark|collegeparkeast|confederation|confederationpark|confederationurbancentre|coreneighbourhoods|coronationpark|creighton|dalmeny|davidson|delisle|dewdneyeast|dieppewesterra|downtown|dundonald|eastview|erindale|esterhazy|estevan|evergreen|exhibition|fairhaven|foamlake|forestgrove|ftquappelle|gladmerpark|gravelbourg|greystoneheights|grosvenorpark|hamptonvillage|harbourlanding|haultain|heritage|hillsdale|holidaypark|holliston|holmwood|hudsonbay|hudsonbaypark|humboldt|indianhead|kamsack|kensington|kerrobert|kindersley|kinggeorge|lakeridge|lakeview|lakewood|lakewoodurbancentre|langenburg|langham|lanigan|laronge|lawson|lawsonheights|lawsonheightsurbancentre|lumsden|macklin|maplecreek|martensville|masseyplace|mcnab|meadowgreen|meadowlake|melfort|melville|montgomeryplace|moosejaw|moosomin|mtroyal|nipawin|normanview|normanviewwest|northbattleford|northcentral|northeastregina|northpark|nutana|nutanapark|nutanasector|nutanaurbancentre|outlook|oxbow|pacificheights|parkridge|pelicannarrows|pilotbutte|pleasanthill|prairieview|preeceville|princealbert|queenelizabeth|regentpark|regina|reginabeach|riverheights|riversdale|rosemontmtroyal|rosetown|rosewood|rosthern|saskatoon|shaunavon|shellbrook|sherwoodmccarthy|silverspring|silverwoodheights|stonebridge|sutherland|swiftcurrent|tisdale|twinlakes|unity|universityheights|universityheightsurbancentre|uofslandssouthmanagementarea|uplands|varsityview|wadena|waldheim|walshacreslakeridgegardenridge|warman|watrous|westmount|westview|weyburn|whitecity|whitmorepark|wildwood|wilkie|willowgrove|wynyard|yorkton"
7669
+ SK: "adelaidechurchill|albertpark|alritchie|arborcreek|arcolaeast|argylepark|aspenridge|assiniboia|avalon|balgonie|battleford|biggar|blairmore|blairmoreurbancentre|boothill|brevoortpark|briarwood|brighton|buenavista|buffalonarrows|canora|carlyle|caswellhill|cathedral|centralbusinessdistrict|centresquare|citypark|collegepark|collegeparkeast|confederation|confederationpark|confederationurbancentre|coreneighbourhoods|coronationpark|creighton|dalmeny|davidson|delisle|dewdneyeast|dieppewesterra|downtown|dundonald|eastview|erindale|esterhazy|estevan|evergreen|exhibition|fairhaven|foamlake|forestgrove|ftquappelle|gladmerpark|gravelbourg|greystoneheights|grosvenorpark|hamptonvillage|harbourlanding|haultain|heritage|hillsdale|holidaypark|holliston|holmwood|hudsonbay|hudsonbaypark|humboldt|indianhead|kamsack|kensington|kerrobert|kindersley|kinggeorge|lakeridge|lakeview|lakewood|lakewoodurbancentre|langenburg|langham|lanigan|laronge|lawson|lawsonheights|lawsonheightsurbancentre|lumsden|macklin|maplecreek|martensville|masseyplace|mcnab|meadowgreen|meadowlake|melfort|melville|montgomeryplace|moosejaw|moosomin|mtroyal|nipawin|normanview|normanviewwest|northbattleford|northcentral|northeastregina|northpark|nutana|nutanapark|nutanasector|nutanaurbancentre|outlook|oxbow|pacificheights|parkridge|pelicannarrows|pilotbutte|pleasanthill|prairieview|preeceville|princealbert|queenelizabeth|regentpark|regina|reginabeach|riverheights|riversdale|rosemontmtroyal|rosetown|rosewood|rosthern|saskatoon|shaunavon|shellbrook|sherwoodmccarthy|silverspring|silverwoodheights|stonebridge|sutherland|swiftcurrent|tisdale|twinlakes|unity|universityheights|universityheightsurbancentre|uofslandssouthmanagementarea|uplands|varsityview|wadena|waldheim|walshacreslakeridgegardenridge|warman|watrous|westmount|westview|weyburn|whitecity|whitmorepark|wildwood|wilkie|willowgrove|wynyard|yorkton",
7670
+ "ES-CN": "adeje|agaete|aguimes|agulo|alajero|antigua|arafo|arona|arrecife|artenara|arucas|barlovento|brenaalta|brenabaja|buenavistadelnorte|candelaria|carrizal|centro|corralejo|costacalma|costateguise|crucedearinaga|elcotillo|elhierro|elpaso|fasnia|firgas|frontera|fuencalientedelapalma|fuerteventura|galdar|garachico|garafia|granadilladeabona|grancanaria|guia|guiadeisora|guimar|haria|hermigua|icoddelosvinos|ingenio|lagomera|lagraciosa|laguancha|lalaguna|lamatanzadeacentejo|lanzarote|laoliva|laorotava|lapalma|laspalmas|laspalmasdegrancanaria|lasrosas|lavictoriadeacentejo|lomodearico|losgigantes|losllanosdearidane|losrealejos|lossilos|macher|maspalomas|mazo|mogan|nazaret|pajara|playablanca|playadelasamericas|playadelingles|playadesanjuan|puertodelacruz|puertodelcarmen|puertodelrosario|puertorico|puntademujeres|puntagorda|puntallana|realejoalto|sanbartolome|sanbartolomedetirajana|sanjuandelarambla|sanmigueldeabona|sannicolas|sansebastiandelagomera|santabrigida|santacruzdelapalma|santacruzdetenerife|santalucia|santaursula|santiagodelteide|sauzal|tacoronte|tamaraceite|tanque|tazacorte|tegueste|teguise|tejeda|telde|tenerife|teror|tias|tijarafe|tinajo|tuineje|vallehermosa|valleseco|valsequillodegrancanaria|vecindario|vegadesanmateo|vilaflor|yaiza",
7671
+ "PT-20": "aguadepau|angradoheroismo|angustias|arrifes|biscoitos|cabouco|caisdopico|corvo|fajadebaixo|fenaisdaajuda|fenaisdaluz|feteira|flores|fontebastardo|furnas|graciosa|horta|lajes|lajesdasflores|mosteiros|nordeste|pico|pontadelgada|pontagarca|portojudeu|portomartins|povoacao|praiadavitoria|rabodepeixe|relva|ribeiragrande|ribeiraseca|ribeirinha|rostodecao|santabarbara|santacruzdagraciosa|santacruzdasflores|santamaria|saomateus|saomiguel|saoroquedopico|saosebastiao|terceira|velas|vilafrancadocampo"
7670
7672
  };
7671
7673
  //#endregion
7672
7674
  //#region core/transport/text.ts
@@ -1,6 +1,11 @@
1
1
  /**
2
- * The zone of a DHL Express facility, from its "CITY - COUNTRY" location, when
3
- * the country keeps one civil time. Spain and Portugal are left out: a country
4
- * cannot tell their islands' clocks from the mainland's.
2
+ * The zone of a DHL Express facility, from its location: "CITY - COUNTRY", and
3
+ * in the USA and Canada "CITY - REGION - COUNTRY" or "CITY, ST - COUNTRY". Null
4
+ * when the location does not settle one: a country with several clocks and no
5
+ * region DHL names, or a country it writes in a form this does not know.
6
+ *
7
+ * A US state or Canadian province with several clocks takes its majority zone.
8
+ * Spain and Portugal take their mainland's, except for a town or island of the
9
+ * Canary Islands or the Azores.
5
10
  */
6
11
  export declare function facilityZone(location: string): string | null;
@@ -1,14 +1,58 @@
1
- import { countryTimeZone } from '../../core/time/index.js';
1
+ import { canadaProvinceTimeZone, countryTimeZone, regionHasTown, usStateTimeZone } from '../../core/time/index.js';
2
+ // DHL names a US state in full ("Ohio", "New York") or with its code ("VIRGINIA,VA").
3
+ const US_STATES = {
4
+ ALABAMA: 'AL', ALASKA: 'AK', ARIZONA: 'AZ', ARKANSAS: 'AR', CALIFORNIA: 'CA', COLORADO: 'CO', CONNECTICUT: 'CT',
5
+ DELAWARE: 'DE', 'DISTRICT OF COLUMBIA': 'DC', FLORIDA: 'FL', GEORGIA: 'GA', HAWAII: 'HI', IDAHO: 'ID', ILLINOIS: 'IL',
6
+ INDIANA: 'IN', IOWA: 'IA', KANSAS: 'KS', KENTUCKY: 'KY', LOUISIANA: 'LA', MAINE: 'ME', MARYLAND: 'MD',
7
+ MASSACHUSETTS: 'MA', MICHIGAN: 'MI', MINNESOTA: 'MN', MISSISSIPPI: 'MS', MISSOURI: 'MO', MONTANA: 'MT', NEBRASKA: 'NE',
8
+ NEVADA: 'NV', 'NEW HAMPSHIRE': 'NH', 'NEW JERSEY': 'NJ', 'NEW MEXICO': 'NM', 'NEW YORK': 'NY', 'NORTH CAROLINA': 'NC',
9
+ 'NORTH DAKOTA': 'ND', OHIO: 'OH', OKLAHOMA: 'OK', OREGON: 'OR', PENNSYLVANIA: 'PA', 'RHODE ISLAND': 'RI',
10
+ 'SOUTH CAROLINA': 'SC', 'SOUTH DAKOTA': 'SD', TENNESSEE: 'TN', TEXAS: 'TX', UTAH: 'UT', VERMONT: 'VT', VIRGINIA: 'VA',
11
+ WASHINGTON: 'WA', 'WEST VIRGINIA': 'WV', WISCONSIN: 'WI', WYOMING: 'WY',
12
+ };
13
+ // A Canadian province comes as its name or its code: "ONTARIO" and "ON" on scans of one parcel.
14
+ const CANADA_PROVINCES = {
15
+ ALBERTA: 'AB', 'BRITISH COLUMBIA': 'BC', MANITOBA: 'MB', 'NEW BRUNSWICK': 'NB', 'NEWFOUNDLAND AND LABRADOR': 'NL',
16
+ 'NOVA SCOTIA': 'NS', 'NORTHWEST TERRITORIES': 'NT', NUNAVUT: 'NU', ONTARIO: 'ON', 'PRINCE EDWARD ISLAND': 'PE',
17
+ QUEBEC: 'QC', SASKATCHEWAN: 'SK', YUKON: 'YT',
18
+ };
19
+ // DHL's own names for countries the English ones do not cover: the forms on its
20
+ // scans, and those of its country list for the countries whose zone is known.
21
+ const COUNTRIES = {
22
+ UK: 'GB', "PEOPLE'S REPUBLIC OF CHINA": 'CN', 'CHINA, PEOPLES REPUBLIC': 'CN', 'CZECH REPUBLIC': 'CZ',
23
+ 'IRELAND, REPUBLIC OF': 'IE', 'KOREA, REPUBLIC OF (SOUTH K.)': 'KR', TURKEY: 'TR',
24
+ };
25
+ // Island groups an hour behind their mainland, which DHL files under the country.
26
+ const ISLANDS = {
27
+ 'Europe/Madrid': { group: 'ES-CN', zone: 'Atlantic/Canary' },
28
+ 'Europe/Lisbon': { group: 'PT-20', zone: 'Atlantic/Azores' },
29
+ };
30
+ function regionCode(region, names) {
31
+ const parts = region.toUpperCase().split(',').map((part) => part.trim());
32
+ return parts.find((part) => /^[A-Z]{2}$/.test(part)) ?? parts.map((part) => names[part]).find(Boolean) ?? '';
33
+ }
2
34
  /**
3
- * The zone of a DHL Express facility, from its "CITY - COUNTRY" location, when
4
- * the country keeps one civil time. Spain and Portugal are left out: a country
5
- * cannot tell their islands' clocks from the mainland's.
35
+ * The zone of a DHL Express facility, from its location: "CITY - COUNTRY", and
36
+ * in the USA and Canada "CITY - REGION - COUNTRY" or "CITY, ST - COUNTRY". Null
37
+ * when the location does not settle one: a country with several clocks and no
38
+ * region DHL names, or a country it writes in a form this does not know.
39
+ *
40
+ * A US state or Canadian province with several clocks takes its majority zone.
41
+ * Spain and Portugal take their mainland's, except for a town or island of the
42
+ * Canary Islands or the Azores.
6
43
  */
7
44
  export function facilityZone(location) {
8
- const separator = location.lastIndexOf(' - ');
9
- if (separator < 0)
10
- return null;
11
- // DHL writes some countries article last: "NETHERLANDS, THE".
12
- const zone = countryTimeZone(location.slice(separator + 3).replace(/, THE$/i, ''));
13
- return zone && !['Europe/Madrid', 'Europe/Lisbon'].includes(zone) ? zone : null;
45
+ const parts = location.split(' - ').map((part) => part.trim());
46
+ const country = parts.length > 1 ? parts.at(-1).toUpperCase() : '';
47
+ // The region is a part of its own, or the code that ends the city's.
48
+ const region = parts.length > 2 ? parts.at(-2) : /, ([A-Z]{2})$/i.exec(parts[0])?.[1] ?? '';
49
+ if (country === 'USA')
50
+ return usStateTimeZone(regionCode(region, US_STATES));
51
+ if (country === 'CANADA')
52
+ return canadaProvinceTimeZone(regionCode(region, CANADA_PROVINCES));
53
+ // DHL writes the article first or last: "THE PEOPLE'S REPUBLIC OF CHINA", "NETHERLANDS, THE".
54
+ const name = country.replace(/^THE /, '').replace(/, THE$/, '');
55
+ const zone = countryTimeZone(COUNTRIES[name] ?? name.replaceAll(',', ''));
56
+ const islands = zone ? ISLANDS[zone] : undefined;
57
+ return islands && regionHasTown(islands.group, parts[0]) ? islands.zone : zone;
14
58
  }
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "carrier": "dhl-express",
3
3
  "records": [
4
+ {
5
+ "number": "2522622410",
6
+ "role": "shipment",
7
+ "evidence": "open_source_example",
8
+ "source": {
9
+ "url": "https://github.com/iFoxz17/sc-delay-prediction/blob/b9e2f99fcdc157b4fb5ff3b0903e79f75f6b32cb/code/m4estro/LambdaTS/Carrier/sample.json",
10
+ "date": "2025-08-30"
11
+ },
12
+ "expect": {
13
+ "carrier": "unknown",
14
+ "confidence": "low",
15
+ "candidates": [
16
+ "dhl-express",
17
+ "dhl",
18
+ "mondial-relay",
19
+ "relais-colis",
20
+ "tipsa",
21
+ "estafeta"
22
+ ]
23
+ },
24
+ "note": "A provider's reply for a DHL Express waybill, published as sample data with its scans. DHL has since issued the waybill to another shipment."
25
+ },
4
26
  {
5
27
  "number": "3408372365",
6
28
  "role": "shipment",
@@ -72,8 +72,11 @@ export declare function townKey(name: string): string;
72
72
  /**
73
73
  * Whether a town of this name lies in the US state or Canadian province whose
74
74
  * code also names a single-clock country: Chicago is in Illinois, not Israel,
75
- * and Koeln is not in Delaware. Only DE, IL, IN, MT, NL and SK have town lists
76
- * (generated from GeoNames); false for any other code.
75
+ * and Koeln is not in Delaware. Also whether it lies in an island group that
76
+ * keeps another clock than its country, by ISO 3166-2 code: the Canary Islands
77
+ * (`ES-CN`) and the Azores (`PT-20`), without the names their mainland also
78
+ * has. Only DE, IL, IN, MT, NL, SK and these two have town lists (generated
79
+ * from GeoNames); false for any other code.
77
80
  */
78
81
  export declare function regionHasTown(code: string, town: string): boolean;
79
82
  /** Epoch milliseconds (numbers or numeric strings); zero and negatives are rejected. */
@@ -212,8 +212,11 @@ const REGION_TOWN_SETS = new Map(Object.entries(REGION_TOWNS).map(([code, towns]
212
212
  /**
213
213
  * Whether a town of this name lies in the US state or Canadian province whose
214
214
  * code also names a single-clock country: Chicago is in Illinois, not Israel,
215
- * and Koeln is not in Delaware. Only DE, IL, IN, MT, NL and SK have town lists
216
- * (generated from GeoNames); false for any other code.
215
+ * and Koeln is not in Delaware. Also whether it lies in an island group that
216
+ * keeps another clock than its country, by ISO 3166-2 code: the Canary Islands
217
+ * (`ES-CN`) and the Azores (`PT-20`), without the names their mainland also
218
+ * has. Only DE, IL, IN, MT, NL, SK and these two have town lists (generated
219
+ * from GeoNames); false for any other code.
217
220
  */
218
221
  export function regionHasTown(code, town) {
219
222
  return REGION_TOWN_SETS.get(code.trim().toUpperCase())?.has(townKey(town)) ?? false;
@@ -1,2 +1,5 @@
1
- /** Town keys of the US states and Canadian provinces whose code also names a single-clock country. */
1
+ /**
2
+ * Town keys of the US states and Canadian provinces whose code also names a single-clock country,
3
+ * and of the island groups that keep another clock than their country.
4
+ */
2
5
  export declare const REGION_TOWNS: Readonly<Record<string, string>>;
@@ -1,6 +1,9 @@
1
1
  /* This file is generated by scripts/generate-region-towns.mjs. Do not edit. */
2
2
  // Towns from GeoNames (https://www.geonames.org), CC BY 4.0, through places.
3
- /** Town keys of the US states and Canadian provinces whose code also names a single-clock country. */
3
+ /**
4
+ * Town keys of the US states and Canadian provinces whose code also names a single-clock country,
5
+ * and of the island groups that keep another clock than their country.
6
+ */
4
7
  export const REGION_TOWNS = {
5
8
  DE: 'bear|bellefonte|bethanybeach|blades|bridgeville|brookside|camden|cheswold|claymont|clayton|delawarecity|delmar|dover|doverbasehousing|edgemoor|elsmere|felton|georgetown|glasgow|greenville|greenwood|harrington|highlandacres|hockessin|kentacres|laurel|lewes|longneck|middletown|milford|millsboro|milton|newark|newcastle|newport|northstar|oceanview|pikecreek|pikecreekvalley|rehobothbeach|risingsunlebanon|riverview|rodneyvillage|seaford|selbyville|smyrna|townsend|wilmington|wilmingtonmanor|woodsideeast|wyoming',
6
9
  IL: 'abingdon|addison|albanypark|albers|albion|aledo|algonquin|alorton|alsip|altamont|alton|amboy|andalusia|anna|antioch|arcola|arlingtonheights|arthur|ashburn|ashland|assumption|astoria|athens|atlanta|atwood|auburn|auburngresham|aurora|aviston|avondale|bannockburn|barrington|barringtonhills|barry|bartlett|bartonville|batavia|beachpark|beardstown|beckemeyer|beecher|belleville|bellevue|bellwood|belmontcragin|belvidere|bement|benld|bensenville|benton|berkeley|berwyn|bethalto|bethany|bigrock|bloomingdale|bloomington|blueisland|bluemound|bolingbrook|boulderhill|bourbonnais|bradley|braidwood|breese|bridgeport|bridgeview|brighton|brightonpark|broadview|brookfield|buffalogrove|bullvalley|bunkerhill|burbank|burnham|burrridge|bushnell|byron|cahokia|cairo|calumetcity|calumetpark|cambria|cambridge|camppoint|canton|capron|carboncliff|carbondale|carlinville|carlyle|carmi|carolstream|carpentersville|carriermills|carrollton|carterville|carthage|cary|casey|caseyville|catlin|centralcity|centralia|centreville|cerrogordo|champaign|channahon|channellake|charleston|chatham|chatsworth|chebanse|chenoa|cherryvalley|chester|chicago|chicagoheights|chicagolawn|chicagoloop|chicagoridge|chillicothe|chrisman|christopher|cicero|clarendonhills|clifton|clinton|coalcity|coalvalley|cobden|colchester|colfax|collinsville|colona|columbia|cortland|countryclubhills|countryside|crainville|cresthill|crestwood|crete|crevecoeur|crystallake|crystallawns|cuba|danvers|danville|darien|davisjunction|decatur|deerfield|deerpark|dekalb|delavan|depue|desoto|desplaines|diamond|divernon|dixmoor|dixon|dolton|douglas|downersgrove|dunlap|dupo|duquoin|durand|dwight|earlville|eastalton|eastdubuque|eastdundee|eastgarfieldpark|easthazelcrest|eastmoline|eastpeoria|eaststlouis|edgewater|edinburg|edwardsville|effingham|elburn|eldorado|elgin|elizabethtown|elkgrovevillage|elmhurst|elmwood|elmwoodpark|elpaso|elwood|energy|englewood|erie|eureka|evanston|evergreenpark|fairbury|fairfield|fairmont|fairmontcity|fairviewheights|farmercity|farmington|fisher|flanagan|flora|flossmoor|fordheights|forestlake|forestpark|forrest|forreston|forsyth|foxlake|foxlakehills|foxrivergrove|frankfort|frankfortsquare|franklinpark|freeburg|freeport|fulton|gagepark|gageslake|galena|galesburg|galva|gardner|geneseo|geneva|genoa|georgetown|germantown|germantownhills|gibsoncity|gifford|gilberts|gillespie|gilman|girard|glasford|glencarbon|glencoe|glendaleheights|glenellyn|glenview|glenwood|godfrey|golconda|goodingsgrove|goreville|grandboulevard|grandview|grandwoodpark|granitecity|grantpark|granville|grayslake|grayville|greatergrandcrossing|greenfield|greenoaks|greenrock|greenup|greenville|gridley|griggsville|gurnee|hainesville|hamilton|hampshire|hampton|hannacity|hanoverpark|hardin|harrisburg|harristown|hartford|harvard|harvey|harwoodheights|havana|hawthornwoods|hazelcrest|hebron|hennepin|henry|heritagelake|herrin|herscher|heyworth|hickoryhills|highland|highlandpark|highwood|hillcrest|hillsboro|hillside|hinckley|hinsdale|hodgkins|hoffmanestates|holidayshores|homer|homerglen|hometown|homewood|hoopeston|hudson|huntley|hydepark|ina|indianheadpark|ingallspark|inverness|irvingpark|islandlake|itasca|jacksonville|jerome|jerseyville|johnsburg|johnstoncity|joliet|jonesboro|justice|kankakee|kenilworth|kenwood|kewanee|kildeer|kincaid|kingston|kirkland|knollwood|knoxville|lacon|ladd|lagrange|lagrangepark|laharpe|lakebarrington|lakebluff|lakecamelot|lakecatherine|lakeforest|lakeholiday|lakeinthehills|lakemoor|lakeofthewoods|lakesummerset|lakevilla|lakewood|lakewoodshores|lakezurich|lanark|lansing|lasalle|lawrenceville|lebanon|lelandgrove|lemont|lena|leroy|lewistown|lexington|libertyville|lilylake|limestone|lincoln|lincolnpark|lincolnshire|lincolnsquare|lincolnwood|lindenhurst|lisle|litchfield|lockport|logansquare|lombard|longcreek|longgrove|longlake|louisville|lovespark|lovington|lowerwestside|lynwood|lyons|machesneypark|mackinaw|macomb|macon|madison|mahomet|malta|manhattan|manito|manteno|maplepark|marengo|marion|marissa|markham|maroa|marquetteheights|marseilles|marshall|martinsville|maryville|mascoutah|masoncity|matteson|mattoon|maywood|mccullomlake|mchenry|mckinleypark|mcleansboro|medinah|melrosepark|mendota|meredosia|merrionettepark|metamora|metropolis|midlothian|milan|milford|millstadt|minier|minonk|minooka|mitchell|mokena|moline|momence|monee|monmouth|montgomery|monticello|morganpark|morris|morrison|morrisonville|morton|mortongrove|moundcity|moweaqua|mtcarmel|mtcarroll|mtgreenwood|mtmorris|mtolive|mtprospect|mtpulaski|mtsterling|mtvernon|mtzion|mundelein|murphysboro|naperville|nashville|nauvoo|nearnorthside|nearsouthside|neoga|newark|newathens|newbaden|newberlin|newcity|newlenox|newton|niles|nokomis|normal|norridge|norriscity|northaurora|northbarrington|northbrook|northcenter|northchicago|northfield|northlake|northlawndale|northpekin|northpeoria|northriverside|oakbrook|oakbrookterrace|oakforest|oaklawn|oakpark|oakwood|oakwoodhills|oblong|odin|ofallon|oglesby|okawville|olney|olympiafields|onarga|oquawka|oregon|orion|orlandhills|orlandpark|oswego|ottawa|palatine|palestine|palosheights|paloshills|palospark|pana|paris|parkcity|parkforest|parkridge|parkwaygardenhomes|pawnee|paxton|payson|pecatonica|pekin|peoria|peoriaheights|peotone|peru|petersburg|philo|phoenix|pinckneyville|pingreegrove|pistakeehighlands|pittsfield|plainfield|plano|polo|pontiac|pontoonbeach|poplargrove|portagepark|portbarrington|portbyron|posen|prairiegrove|prestbury|prestonheights|princeton|princeville|prophetstown|prospectheights|quincy|ramsey|rantoul|redbud|richmond|richtonpark|riverdale|riverforest|rivergrove|riverside|riverton|riverwoods|roanoke|robbins|robinson|rochelle|rochester|rockdale|rockfalls|rockford|rockisland|rockton|rogerspark|rollingmeadows|rome|romeoville|roodhouse|roscoe|roselle|rosemont|rosewoodheights|rosiclare|rossville|roundlake|roundlakebeach|roundlakeheights|roundlakepark|roxana|royalton|rushville|salem|sandoval|sandwich|saukvillage|savanna|savoy|schaumburg|schillerpark|scottairforcebase|seneca|sesser|shawneetown|shelbyville|sheldon|sheridan|sherman|shiloh|shorewood|sidney|silvis|skokie|sleepyhollow|smithton|somonauk|southbarrington|southbeloit|southchicago|southchicagoheights|southelgin|southernview|southholland|southjacksonville|southlawndale|southpekin|southroxana|southshore|sparta|springfield|springgrove|springvalley|stanne|staunton|stcharles|steeleville|steger|stelmo|sterling|stickney|stillmanvalley|stjacob|stjoseph|stockton|stonepark|streamwood|streator|sugargrove|sullivan|summit|sumner|swansea|sycamore|taylorville|teutopolis|thegalenaterritory|thirdlake|thomasboro|thornton|tilton|tinleypark|toledo|tolono|toluca|toulon|towerlake|tremont|trenton|troy|tuscola|twingrove|ukrainianvillage|universitypark|upperalton|uptown|urbana|valmeyer|vandalia|venetianvillage|venice|vernonhills|vienna|villageofcamptonhills|villagrove|villapark|virden|virginia|volo|wadsworth|walnut|wamac|warren|warrensburg|warrenville|warsaw|wasco|washburn|washington|washingtonpark|waterloo|waterman|watseka|wauconda|waukegan|waverly|wayne|waynecity|westchester|westchicago|westdundee|westelsdon|westenglewood|westernsprings|westfrankfort|westgarfieldpark|westlawn|westmont|westpeoria|westridge|westtown|westville|wheaton|wheeling|whitehall|williamsville|willowbrook|willowsprings|wilmette|wilmington|winchester|windsor|winfield|winnebago|winnetka|winthropharbor|wonderlake|wooddale|woodlawn|woodridge|woodriver|woodstock|worden|worth|wyoming|yorkville|zeigler|zion',
@@ -8,4 +11,6 @@ export const REGION_TOWNS = {
8
11
  MT: 'absarokee|anaconda|baker|belgrade|bigfork|bigsky|bigtimber|billings|bonnerwestriverside|boulder|bozeman|broadus|browning|butte|chester|chinook|choteau|circle|clancy|clinton|colstrip|columbiafalls|columbus|conrad|crowagency|cutbank|deerlodge|dillon|easthelena|eastmissoula|ekalaka|eureka|evergreen|forsyth|fourcorners|frenchtown|ftbelknapagency|ftbenton|glasgow|glendive|greatfalls|hamilton|hardin|harlowton|havre|helena|helenavalleynortheast|helenavalleynorthwest|helenavalleysoutheast|helenavalleywestcentral|helenawestside|hysham|jordan|kalispell|lakeside|lamedeer|laurel|lewistown|libby|lincoln|livingston|lockwood|lolo|malmstromairforcebase|malta|manhattan|milescity|missoula|montanacity|northbrowning|orchardhomes|pablo|philipsburg|plains|plentywood|polson|redlodge|ronan|roundup|ryegate|scobey|seeleylake|shelby|sidney|somers|southbrowning|stanford|stevensville|sunprairie|superior|terry|thompsonfalls|threeforks|townsend|virginiacity|warmsprings|westglendive|westyellowstone|whitefish|whitehall|whitesulphursprings|wibaux|winnett|wolfpoint',
9
12
  NL: 'airportheights|baieverte|ballyhaly|bannerman|baybulls|bayroberts|baystgeorgesouth|bellsturn|bishopsfalls|bonavista|botwood|burgeo|burin|carbonear|centrevillewarehamtrinity|channelportauxbasques|churchillpark|clarenville|clarenvilleshoalharbour|clarkesbeach|clovellytrails|conceptionbaysouth|cornerbrook|cowanheights|deerlake|donovans|downtownstjohns|eastmeadows|evergreenvillage|fairviewacres|flatrock|fogoisland|fortune|ftwilliam|gambo|gander|georgestown|glendale|glenridgecrescent|glovertown|goulds|grandbank|grandfallswindsor|happyvalleygoosebay|harbourbreton|harbourgrace|harbourmainchapelscovelakeview|holyrood|humberarmsouth|irishtownsummerside|karwoodestates|kenmountterrace|kilbride|kingwilliamestates|kippens|labradorcity|lewisporte|littlecanada|logybaymiddlecoveoutercove|marystown|masseydrive|mtcashel|mtpearl|mundypond|nain|newwesvalley|paradise|pasadena|pearlgate|placentia|pleasantville|portugalcovestphillips|pouchcove|quidividi|rabbittown|sesamepark|sheaheights|sheshatshiu|signalhill|southlands|spaniardsbay|springdale|sprucemeadows|stalbans|stanthony|stephenville|stephenvillecrossing|stgeorges|stjohns|stlawrence|torbay|trinitybaynorth|twillingate|upperislandcove|victoria|virginiapark|wabana|wabush|waterfordvalley|whitbourne|wishingwellpark|witlessbay',
10
13
  SK: 'adelaidechurchill|albertpark|alritchie|arborcreek|arcolaeast|argylepark|aspenridge|assiniboia|avalon|balgonie|battleford|biggar|blairmore|blairmoreurbancentre|boothill|brevoortpark|briarwood|brighton|buenavista|buffalonarrows|canora|carlyle|caswellhill|cathedral|centralbusinessdistrict|centresquare|citypark|collegepark|collegeparkeast|confederation|confederationpark|confederationurbancentre|coreneighbourhoods|coronationpark|creighton|dalmeny|davidson|delisle|dewdneyeast|dieppewesterra|downtown|dundonald|eastview|erindale|esterhazy|estevan|evergreen|exhibition|fairhaven|foamlake|forestgrove|ftquappelle|gladmerpark|gravelbourg|greystoneheights|grosvenorpark|hamptonvillage|harbourlanding|haultain|heritage|hillsdale|holidaypark|holliston|holmwood|hudsonbay|hudsonbaypark|humboldt|indianhead|kamsack|kensington|kerrobert|kindersley|kinggeorge|lakeridge|lakeview|lakewood|lakewoodurbancentre|langenburg|langham|lanigan|laronge|lawson|lawsonheights|lawsonheightsurbancentre|lumsden|macklin|maplecreek|martensville|masseyplace|mcnab|meadowgreen|meadowlake|melfort|melville|montgomeryplace|moosejaw|moosomin|mtroyal|nipawin|normanview|normanviewwest|northbattleford|northcentral|northeastregina|northpark|nutana|nutanapark|nutanasector|nutanaurbancentre|outlook|oxbow|pacificheights|parkridge|pelicannarrows|pilotbutte|pleasanthill|prairieview|preeceville|princealbert|queenelizabeth|regentpark|regina|reginabeach|riverheights|riversdale|rosemontmtroyal|rosetown|rosewood|rosthern|saskatoon|shaunavon|shellbrook|sherwoodmccarthy|silverspring|silverwoodheights|stonebridge|sutherland|swiftcurrent|tisdale|twinlakes|unity|universityheights|universityheightsurbancentre|uofslandssouthmanagementarea|uplands|varsityview|wadena|waldheim|walshacreslakeridgegardenridge|warman|watrous|westmount|westview|weyburn|whitecity|whitmorepark|wildwood|wilkie|willowgrove|wynyard|yorkton',
14
+ 'ES-CN': 'adeje|agaete|aguimes|agulo|alajero|antigua|arafo|arona|arrecife|artenara|arucas|barlovento|brenaalta|brenabaja|buenavistadelnorte|candelaria|carrizal|centro|corralejo|costacalma|costateguise|crucedearinaga|elcotillo|elhierro|elpaso|fasnia|firgas|frontera|fuencalientedelapalma|fuerteventura|galdar|garachico|garafia|granadilladeabona|grancanaria|guia|guiadeisora|guimar|haria|hermigua|icoddelosvinos|ingenio|lagomera|lagraciosa|laguancha|lalaguna|lamatanzadeacentejo|lanzarote|laoliva|laorotava|lapalma|laspalmas|laspalmasdegrancanaria|lasrosas|lavictoriadeacentejo|lomodearico|losgigantes|losllanosdearidane|losrealejos|lossilos|macher|maspalomas|mazo|mogan|nazaret|pajara|playablanca|playadelasamericas|playadelingles|playadesanjuan|puertodelacruz|puertodelcarmen|puertodelrosario|puertorico|puntademujeres|puntagorda|puntallana|realejoalto|sanbartolome|sanbartolomedetirajana|sanjuandelarambla|sanmigueldeabona|sannicolas|sansebastiandelagomera|santabrigida|santacruzdelapalma|santacruzdetenerife|santalucia|santaursula|santiagodelteide|sauzal|tacoronte|tamaraceite|tanque|tazacorte|tegueste|teguise|tejeda|telde|tenerife|teror|tias|tijarafe|tinajo|tuineje|vallehermosa|valleseco|valsequillodegrancanaria|vecindario|vegadesanmateo|vilaflor|yaiza',
15
+ 'PT-20': 'aguadepau|angradoheroismo|angustias|arrifes|biscoitos|cabouco|caisdopico|corvo|fajadebaixo|fenaisdaajuda|fenaisdaluz|feteira|flores|fontebastardo|furnas|graciosa|horta|lajes|lajesdasflores|mosteiros|nordeste|pico|pontadelgada|pontagarca|portojudeu|portomartins|povoacao|praiadavitoria|rabodepeixe|relva|ribeiragrande|ribeiraseca|ribeirinha|rostodecao|santabarbara|santacruzdagraciosa|santacruzdasflores|santamaria|saomateus|saomiguel|saoroquedopico|saosebastiao|terceira|velas|vilafrancadocampo',
11
16
  };
@@ -10,7 +10,6 @@
10
10
  import { load } from 'cheerio';
11
11
  import { DateTime } from 'luxon';
12
12
  import timers from 'node:timers/promises';
13
- import { facilityZone } from '../../carriers/dhl-express/clock.js';
14
13
  import { carrierTimezone } from '../../core/catalog/index.js';
15
14
  import { brandTimeZones, carrierIdFromName, carrierNameCountryZone } from '../../core/catalog/hints.js';
16
15
  import { CarrierError, carrierErrorKind, ChallengeError, IndeterminateError, InputRequiredError, NoHistoryError, SchemaError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
@@ -18,6 +17,7 @@ import { runSteps } from '../../core/runner/index.js';
18
17
  import { canadaProvinceTimeZone, countryCode, countryTimeZone, mislabeledLocalTime, mislabeledWallTime, regionHasTown, sharedClockZone, timeZoneCountry, usStateTimeZone, } from '../../core/time/index.js';
19
18
  import { isRecord } from '../../core/types.js';
20
19
  import { capturedBodies, loadCapture } from '../shared/capture.js';
20
+ import { facilityZone, LatestScan, relaysFacilityClock } from '../shared/facilityClock.js';
21
21
  import { universalCarrierHints } from '../shared/hints.js';
22
22
  import { event, isNotice, localEvent, numberOf, result } from '../shared/result.js';
23
23
  import { carrierScan, markReturnLeg } from '../shared/scans.js';
@@ -130,12 +130,9 @@ function scanZone(payload, state, fallback, number, inNorthAmerica = false) {
130
130
  if (zone !== 'UTC' && !(typeof name === 'string' && carrierNameCountryZone(name)))
131
131
  return zone;
132
132
  const location = typeof state.location === 'string' ? state.location.trim() : '';
133
- // DHL Express dates are its facility's clock and its locations read "CITY -
134
- // COUNTRY": its mobile API gives the same clocks scan for scan, and scans
135
- // were relayed before the instant their label claims (checked 2026-10-06).
136
- const facility = carrier === 'dhl-express' ? facilityZone(location) : null;
137
- if (facility)
138
- return facility;
133
+ // A facility's clock, whatever the label: no other step applies to it.
134
+ if (relaysFacilityClock(name))
135
+ return facilityZone(location);
139
136
  const place = location.split(',').at(-1);
140
137
  // A state or province the town confirms beats the country its code also
141
138
  // names ("Chicago, IL"); a merely possible one comes after ("Koeln, DE").
@@ -220,10 +217,14 @@ function parseHistory(payload, trackingNumber, timezone = null) {
220
217
  return wall && text ? `${wall}|${text}` : '';
221
218
  };
222
219
  const localClocks = new Map(entries.filter((entry) => entry.local).map((entry) => [copyKey(entry), entry.zone]).filter(([key]) => key));
220
+ const latest = new LatestScan(SOURCE);
223
221
  for (const entry of entries) {
224
222
  const { raw, name, scan } = entry;
225
223
  const zone = (localClocks.size && !entry.local ? localClocks.get(copyKey(entry)) : undefined) ?? entry.zone;
226
- const parsed = localEvent((zone ? mislabeledLocalTime(raw.date, zone)?.iso : undefined) ?? raw.date, entry.wording);
224
+ const unplaced = !zone && relaysFacilityClock(name);
225
+ const parsed = localEvent((zone ? mislabeledLocalTime(raw.date, zone)?.iso : unplaced ? mislabeledWallTime(raw.date) : undefined) ?? raw.date, entry.wording);
226
+ if (parsed)
227
+ latest.see(mislabeledWallTime(raw.date) ?? '', unplaced);
227
228
  // Nor has an offset-less date that no zone resolves: a wall time, not an instant.
228
229
  if (parsed && !parsed.time) {
229
230
  undated++;
@@ -241,6 +242,7 @@ function parseHistory(payload, trackingNumber, timezone = null) {
241
242
  scans.push({ event: Object.assign(parsed, { stage: scan.stage, stage_source: 'carrier_map' }), scan });
242
243
  }
243
244
  markReturnLeg(scans);
245
+ latest.check();
244
246
  if (!events.length) {
245
247
  const fields = payload.states.flatMap((raw) => isRecord(raw) && Array.isArray(raw.require_fields) ? raw.require_fields : []);
246
248
  if (fields.some((field) => isRecord(field) && field.name === 'zipcode')) {
@@ -293,6 +295,7 @@ export function parseParcelsAppHtml(html, trackingNumber, timezone = null) {
293
295
  const carriers = [];
294
296
  const scans = [];
295
297
  let undated = 0;
298
+ const latest = new LatestScan(SOURCE);
296
299
  nodes.each((_, node) => {
297
300
  const row = $(node);
298
301
  if (row.find('input, select, form').length)
@@ -323,6 +326,13 @@ export function parseParcelsAppHtml(html, trackingNumber, timezone = null) {
323
326
  const state = { date: labeled.toISO(), ...(name ? { carrier: carriers.indexOf(name) } : {}) };
324
327
  const zone = scanZone({ carriers }, state, timezone, numberOf(trackingNumber));
325
328
  const scan = carrierScan(name ? carrierIdFromName(name) : undefined, description);
329
+ // The page prints no location, so no DHL Express scan has a facility to read its clock in.
330
+ const unplaced = !zone && relaysFacilityClock(name);
331
+ latest.see(mislabeledWallTime(state.date) ?? '', unplaced);
332
+ if (unplaced) {
333
+ undated++;
334
+ return;
335
+ }
326
336
  const parsed = event((zone ? mislabeledLocalTime(state.date, zone)?.iso : undefined) ?? state.date, scan?.wording ?? description);
327
337
  if (parsed)
328
338
  events.push(parsed);
@@ -330,6 +340,7 @@ export function parseParcelsAppHtml(html, trackingNumber, timezone = null) {
330
340
  scans.push({ event: Object.assign(parsed, { stage: scan.stage, stage_source: 'carrier_map' }), scan });
331
341
  });
332
342
  markReturnLeg(scans);
343
+ latest.check();
333
344
  return { ...result(events, SOURCE), ...(undated ? { undated_event_count: undated } : {}),
334
345
  ...universalCarrierHints(carriers.slice(0, 20), numberOf(trackingNumber)) };
335
346
  }
@@ -1,13 +1,3 @@
1
- /**
2
- * 17TRACK (t.17track.net), a universal aggregator with richer China Post history.
3
- *
4
- * Transport: the browser service loads the public tracking page and captures
5
- * the page's own `restapi` response; unsigned direct probes answer HTTP 200
6
- * with a rejection code instead of history (verified 2026-09-10). The parser
7
- * keeps only history whose shipment number equals the requested one, and turns
8
- * the provider's own rejection codes into typed errors so routing can tell a
9
- * verification wall from an outage.
10
- */
11
1
  import type { AdapterFactory } from '../../core/adapter/index.js';
12
2
  import { ChallengeError, NoHistoryError, TransportError } from '../../core/errors/index.js';
13
3
  import type { CarrierResult } from '../../core/result/index.js';
@@ -1,8 +1,20 @@
1
+ /**
2
+ * 17TRACK (t.17track.net), a universal aggregator with richer China Post history.
3
+ *
4
+ * Transport: the browser service loads the public tracking page and captures
5
+ * the page's own `restapi` response; unsigned direct probes answer HTTP 200
6
+ * with a rejection code instead of history (verified 2026-09-10). The parser
7
+ * keeps only history whose shipment number equals the requested one, and turns
8
+ * the provider's own rejection codes into typed errors so routing can tell a
9
+ * verification wall from an outage.
10
+ */
11
+ import { DateTime } from 'luxon';
1
12
  import { ChallengeError, NoHistoryError, NotFoundError, SchemaError, TransportError } from '../../core/errors/index.js';
2
13
  import { runSteps } from '../../core/runner/index.js';
3
14
  import { isRecord } from '../../core/types.js';
4
15
  import { universalCarrierHints } from '../shared/hints.js';
5
16
  import { capturedBodies, captureFailure, loadCapture } from '../shared/capture.js';
17
+ import { facilityZone, LatestScan, relaysFacilityClock } from '../shared/facilityClock.js';
6
18
  import { numberOf, result, text } from '../shared/result.js';
7
19
  import { seventeenTrackEvent } from './events.js';
8
20
  const SOURCE = '17TRACK';
@@ -57,6 +69,12 @@ function lookupError(code, providerMessage) {
57
69
  ? new SeventeenTrackVerificationError(code, providerMessage)
58
70
  : new SeventeenTrackLookupError('lookup_unavailable', code, providerMessage);
59
71
  }
72
+ /** The offset-less clock of a scan whose offset 17TRACK added itself. */
73
+ function facilityWall(raw) {
74
+ const clock = isRecord(raw.time_raw) && raw.time_raw.timezone === null ? raw.time_raw : null;
75
+ return clock && typeof clock.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(clock.date)
76
+ && typeof clock.time === 'string' && /^\d{2}:\d{2}:\d{2}$/.test(clock.time) ? `${clock.date}T${clock.time}` : null;
77
+ }
60
78
  export function parse17TrackResponse(payload, trackingNumber) {
61
79
  const number = numberOf(trackingNumber);
62
80
  if (!isRecord(payload) || !isRecord(payload.meta) || !Number.isInteger(payload.meta.code)) {
@@ -91,6 +109,7 @@ export function parse17TrackResponse(payload, trackingNumber) {
91
109
  const events = [];
92
110
  let count = 0;
93
111
  let undated = 0;
112
+ const latest = new LatestScan(SOURCE);
94
113
  for (const provider of tracking.providers) {
95
114
  if (!isRecord(provider) || !Array.isArray(provider.events))
96
115
  throw new SchemaError(SOURCE, '17TRACK returned invalid provider history');
@@ -103,17 +122,29 @@ export function parse17TrackResponse(payload, trackingNumber) {
103
122
  throw new SchemaError(SOURCE, '17TRACK returned invalid events');
104
123
  if (raw.time_utc == null && raw.time_iso == null)
105
124
  undated++;
125
+ // A facility's clock, on which 17TRACK puts one offset for the whole parcel.
126
+ const wall = relaysFacilityClock(name) ? facilityWall(raw) : null;
127
+ const zone = wall ? facilityZone(text(raw.location)) : null;
128
+ const placed = wall && zone ? DateTime.fromISO(wall, { zone }) : null;
106
129
  let parsed;
107
130
  try {
108
- parsed = seventeenTrackEvent(raw, operator);
131
+ parsed = seventeenTrackEvent(placed?.isValid ? { ...raw, time_utc: placed.toUTC().toISO() } : raw, operator);
109
132
  }
110
133
  catch (cause) {
111
134
  throw new SchemaError(SOURCE, '17TRACK returned an invalid tracking event', { cause });
112
135
  }
113
- if (parsed)
136
+ if (!parsed)
137
+ continue;
138
+ const unplaced = wall !== null && !placed?.isValid;
139
+ latest.see(wall ?? String(raw.time_iso ?? raw.time_utc).slice(0, 19), unplaced);
140
+ // With no zone for the facility it is a wall time, counted like a row without a date.
141
+ if (unplaced)
142
+ undated++;
143
+ else
114
144
  events.push(parsed);
115
145
  }
116
146
  }
147
+ latest.check();
117
148
  if (count === 0 && isRecord(shipment.latest_status) && shipment.latest_status.status === 'NotFound') {
118
149
  throw new NotFoundError(SOURCE);
119
150
  }
@@ -0,0 +1,25 @@
1
+ import { facilityZone } from '../../carriers/dhl-express/clock.js';
2
+ import type { UniversalSource } from './result.js';
3
+ /**
4
+ * DHL Express dates a scan with its facility's clock and no offset, and the
5
+ * providers relay that clock under a label of their own: UTC at ParcelsApp, one
6
+ * offset for the whole parcel at 17TRACK. Ship24 relays it with the facility's
7
+ * offset, and DHL's mobile API gives the same clocks scan for scan (checked
8
+ * 2026-10-06). So such a scan is read in its facility's zone, and with no zone
9
+ * for the facility it is a wall time that no label makes an instant.
10
+ */
11
+ export declare function relaysFacilityClock(carrierName: unknown): boolean;
12
+ export { facilityZone };
13
+ /**
14
+ * The newest wall clock of a reply, and whether that scan was left without an
15
+ * instant. A reply whose latest scan has none cannot say where the parcel is,
16
+ * and an older scan must not stand in for it.
17
+ */
18
+ export declare class LatestScan {
19
+ private readonly source;
20
+ private wall;
21
+ private unplaced;
22
+ constructor(source: UniversalSource);
23
+ see(wall: string, unplaced: boolean): void;
24
+ check(): void;
25
+ }
@@ -0,0 +1,36 @@
1
+ import { facilityZone } from '../../carriers/dhl-express/clock.js';
2
+ import { carrierIdFromName } from '../../core/catalog/hints.js';
3
+ import { IndeterminateError } from '../../core/errors/index.js';
4
+ /**
5
+ * DHL Express dates a scan with its facility's clock and no offset, and the
6
+ * providers relay that clock under a label of their own: UTC at ParcelsApp, one
7
+ * offset for the whole parcel at 17TRACK. Ship24 relays it with the facility's
8
+ * offset, and DHL's mobile API gives the same clocks scan for scan (checked
9
+ * 2026-10-06). So such a scan is read in its facility's zone, and with no zone
10
+ * for the facility it is a wall time that no label makes an instant.
11
+ */
12
+ export function relaysFacilityClock(carrierName) {
13
+ return typeof carrierName === 'string' && carrierIdFromName(carrierName) === 'dhl-express';
14
+ }
15
+ export { facilityZone };
16
+ /**
17
+ * The newest wall clock of a reply, and whether that scan was left without an
18
+ * instant. A reply whose latest scan has none cannot say where the parcel is,
19
+ * and an older scan must not stand in for it.
20
+ */
21
+ export class LatestScan {
22
+ source;
23
+ wall = '';
24
+ unplaced = false;
25
+ constructor(source) {
26
+ this.source = source;
27
+ }
28
+ see(wall, unplaced) {
29
+ if (wall > this.wall || (wall === this.wall && unplaced))
30
+ [this.wall, this.unplaced] = [wall, unplaced];
31
+ }
32
+ check() {
33
+ if (this.unplaced)
34
+ throw new IndeterminateError(this.source, `${this.source} cannot place the latest DHL Express scan`);
35
+ }
36
+ }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.25.1"
5
+ "version": "0.25.3"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.25.1-main.364",
3
+ "version": "0.25.3-main.366",
4
4
  "description": "Self-hosted parcel tracking: carrier detection, dedicated scrapers and optional universal providers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",