universal-parcel-scraper 0.135.0 → 0.136.1-main.1125

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.
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { carrierErrorKind, ChallengeError, IndeterminateError, RateLimitedError, SchemaError, TransportError, UpstreamHttpError } from '../../core/errors/index.js';
2
3
  import { withLocalBrowser } from '../../core/transport/localBrowser.js';
3
4
  import { isRecord } from '../../core/types.js';
@@ -5,6 +6,8 @@ import { parseRoyalMailTrackingResponse, royalMailEventsApiUrl, royalMailSummary
5
6
  const PROVIDER = 'Royal Mail';
6
7
  const ORIGIN = 'https://www.royalmail.com';
7
8
  const MAX_BYTES = 2_000_000;
9
+ // The page keeps this frame hidden off-screen until hCaptcha wants a person to solve it.
10
+ const CAPTCHA_CHALLENGE = 'iframe[src*="hcaptcha.com"][src*="frame=challenge"]:visible';
8
11
  /** The destination the full-history reply adds to the summary, and nothing else from it. */
9
12
  export function royalMailDestination(summary) {
10
13
  const code = isRecord(summary) ? summary.destinationCountryCode : undefined;
@@ -104,11 +107,23 @@ export async function captureRoyalMailReply(page, url, number, phase, signal, ti
104
107
  })().catch(error => { if (active)
105
108
  reject(carrierErrorKind(error) ? error : new TransportError(PROVIDER)); });
106
109
  };
110
+ // A visible CAPTCHA waits for a person, and the page sends no request until it is solved.
111
+ const watchChallenge = async () => {
112
+ while (active && !deadline.aborted) {
113
+ if (await page.locator(CAPTCHA_CHALLENGE).count().catch(() => 0)) {
114
+ if (active)
115
+ reject(new ChallengeError(PROVIDER, 'Royal Mail asked for an interactive CAPTCHA'));
116
+ return;
117
+ }
118
+ await delay(500, undefined, { signal: deadline });
119
+ }
120
+ };
107
121
  page.on('response', received);
108
122
  page.on('requestfailed', failed);
109
123
  deadline.addEventListener('abort', aborted, { once: true });
110
124
  try {
111
125
  deadline.throwIfAborted();
126
+ void watchChallenge().catch(() => { });
112
127
  await Promise.race([trigger(), reply.then(() => { })]);
113
128
  return await reply;
114
129
  }
@@ -3,7 +3,13 @@
3
3
  { "country": "GB", "names": ["Heathrow"], "town": "London", "airport": "LHR" },
4
4
  { "country": "GB", "names": ["East Midlands"], "town": "Castle Donington", "airport": "EMA" },
5
5
  { "country": "GB", "names": ["Langley", "Langley HWDC"], "town": "Langley", "latitude": 51.497, "longitude": -0.532, "source": "https://www.openstreetmap.org/way/23294692" },
6
+ { "country": "GB", "names": ["South Midlands"], "town": "Northampton", "site": "South Midlands Mail Centre", "latitude": 52.216, "longitude": -0.953, "source": "https://www.openstreetmap.org/way/197941572" },
7
+ { "country": "GB", "names": ["Princess Royal"], "town": "London", "site": "Princess Royal Distribution Centre", "latitude": 51.541, "longitude": -0.269, "source": "https://www.openstreetmap.org/way/235281286" },
6
8
  { "country": "PL", "names": ["Sękocin Stary"], "town": "Sękocin Stary", "latitude": 52.108, "longitude": 20.88, "source": "https://download.geonames.org/export/zip/PL.zip" },
7
9
  { "country": "CN", "names": ["Fenggang Town"], "town": "Fenggang", "latitude": 22.757, "longitude": 114.149, "source": "https://www.geonames.org/1916217" },
8
- { "country": "FR", "names": ["Terrasson"], "town": "Terrasson-Lavilledieu", "latitude": 45.129, "longitude": 1.32, "source": "https://www.openstreetmap.org/relation/19894168" }
10
+ { "country": "CN", "names": ["Shatian Town"], "town": "Shatian", "latitude": 22.923, "longitude": 113.613, "source": "https://www.openstreetmap.org/relation/5664242" },
11
+ { "country": "FR", "names": ["Terrasson"], "town": "Terrasson-Lavilledieu", "latitude": 45.129, "longitude": 1.32, "source": "https://www.openstreetmap.org/relation/19894168" },
12
+ { "country": "US", "admin1": "MO", "names": ["Earth City"], "town": "Earth City", "latitude": 38.769, "longitude": -90.468, "source": "https://www.openstreetmap.org/node/151874554" },
13
+ { "country": "DE", "carriers": ["dhl", "dhl-ecommerce", "dhl-express"], "names": ["Staufenberg"], "town": "Staufenberg", "latitude": 51.372, "longitude": 9.634, "source": "https://www.openstreetmap.org/way/35537407" },
14
+ { "country": "CH", "admin1": "ZH", "carriers": ["dpd"], "names": ["Buchs"], "town": "Buchs", "latitude": 47.452, "longitude": 8.43, "source": "https://www.openstreetmap.org/way/193889834" }
9
15
  ]
@@ -17,6 +17,8 @@ export interface EventPoint {
17
17
  export interface PlaceHints {
18
18
  /** Countries the parcel is likely in, strongest first: neighbouring scans, destination, carrier. */
19
19
  countries?: readonly string[];
20
+ /** The parcel's carriers, by catalog id: a hub of their own is known by the name they give it. */
21
+ carriers?: readonly string[];
20
22
  }
21
23
  /** Loads the gazetteer ahead of the first request that needs it. */
22
24
  export declare function preloadPlaces(): void;
@@ -29,14 +31,20 @@ export declare function locatePlace(location: string | null | undefined, hints?:
29
31
  /**
30
32
  * Places for a parcel's scans, oldest first. A scan that names no country
31
33
  * borrows one from the scans around it, then the destination and the
32
- * carrier's home countries, so "Buchs" after "Zürich" stays in Switzerland.
33
- * A carrier's own point for a scan, or for another scan with the same text,
34
- * moves it from the town's centre to the facility, if the two are within
35
- * 30 km of each other.
34
+ * carriers' home countries, so "Buchs" after "Zürich" stays in Switzerland.
35
+ * A town only its name places, in a country nothing else puts the parcel in,
36
+ * is a namesake and gets no place when it is thousands of kilometres off the
37
+ * way between the scans around it, or, where the scans stop, that far from
38
+ * them and from the parcel's countries, under a name other towns share,
39
+ * without being a world city. A carrier's own point for a scan, or for
40
+ * another scan with the same text, moves it from the town's centre to the
41
+ * facility, if the two are within 30 km of each other.
36
42
  */
37
- export declare function placesForEvents(locations: readonly (string | null | undefined)[], { destinationCountry, carrierCountries, points }?: {
43
+ export declare function placesForEvents(locations: readonly (string | null | undefined)[], { destinationCountry, carrierCountries, carriers, points }?: {
38
44
  destinationCountry?: string | null;
39
45
  carrierCountries?: readonly string[];
46
+ /** The parcel's carriers by catalog id, the one delivering now first: their countries and their own hubs. */
47
+ carriers?: readonly string[];
40
48
  /** The carrier's own point for each scan, where it gave one. */
41
49
  points?: readonly (EventPoint | null | undefined)[];
42
50
  }): (EventPlace | null)[];
@@ -1,5 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { brotliDecompressSync } from 'node:zlib';
3
+ import { CARRIER_DEFINITIONS } from '../core/catalog/definitions.js';
4
+ import { timeZoneCountry } from '../core/time/index.js';
3
5
  import { ambiguousAddressCodes, trackingPlace } from './trackingLocation.js';
4
6
  import facilityList from './facilities.json' with { type: 'json' };
5
7
  import hubList from './hubs.json' with { type: 'json' };
@@ -49,13 +51,24 @@ const POINT_MAX_KM = 30;
49
51
  const AIRPORT_MAX_KM = 60;
50
52
  // A hub's name ranks like a town of this many thousand people.
51
53
  const HUB_THOUSANDS = 50;
54
+ // A carrier that delivers in more countries than this is at home in none of them.
55
+ const HOME_COUNTRIES_MAX = 6;
56
+ // A town only its name places is a namesake when it takes the parcel this far off its way,
57
+ const DETOUR_MAX_KM = 6000;
58
+ // or, where the scans stop, when it is this far from them and from the parcel's countries.
59
+ const LEAP_MAX_KM = 3000;
60
+ // There, a city of this many thousand people is known wherever it is written.
61
+ const WORLD_CITY_THOUSANDS = 1000;
52
62
  /** The city a region is named after ("Santo Domingo Province") has at least this many thousand people. */
53
63
  const NAMESAKE_THOUSANDS = 100;
54
64
  // Swiss Post scans end in the site's six-digit number: "Zürich Briefzentrum 801050".
55
65
  const FACILITIES = new Map(facilityList.map((facility) => [`${facility.country}:${facility.code}`, facility]));
56
66
  // Names carriers give a hub instead of its town ("ROISSY" is Paris-Charles de Gaulle, not
57
67
  // Roissy-en-Brie), and hubs the gazetteer cannot place by name ("SEKOCIN STARY", "Fenggang Town", "TERRASSON").
58
- const HUBS = new Map(hubList.flatMap((hub) => hub.names.map((name) => [nameKey(name), hub])));
68
+ const HUBS = new Map();
69
+ for (const hub of hubList)
70
+ for (const name of hub.names)
71
+ HUBS.set(nameKey(name), [...HUBS.get(nameKey(name)) ?? [], hub]);
59
72
  let loaded = null;
60
73
  let pincodes = null;
61
74
  function gazetteer() {
@@ -84,7 +97,7 @@ function gazetteer() {
84
97
  names: [], codes: [],
85
98
  country: new Uint16Array(count), admin1: new Uint16Array(count), admin2: new Uint16Array(count),
86
99
  latitude: new Float32Array(count), longitude: new Float32Array(count), population: new Uint32Array(count),
87
- keys: new Map(), postcodes: new Map(), countries: new Map(), regions: new Map(), regionNames: new Map(), airports: new Map(),
100
+ keys: new Map(), postcodes: new Map(), countries: new Map(), regions: new Map(), regionNames: new Map(), airports: new Map(), byCountry: new Map(),
88
101
  };
89
102
  const codeIndex = new Map();
90
103
  const code = (value) => {
@@ -155,6 +168,25 @@ function distanceKm(latitude1, longitude1, latitude2, longitude2) {
155
168
  + Math.cos(latitude1 * radians) * Math.cos(latitude2 * radians) * Math.sin((longitude2 - longitude1) * radians / 2) ** 2;
156
169
  return 12742 * Math.asin(Math.sqrt(a));
157
170
  }
171
+ /** How far a point is from a country: from the nearest of its towns. */
172
+ function countryDistanceKm(country, latitude, longitude) {
173
+ const data = gazetteer();
174
+ if (!data.byCountry.size) {
175
+ data.country.forEach((code, index) => {
176
+ const name = data.codes[code];
177
+ const list = data.byCountry.get(name);
178
+ if (list)
179
+ list.push(index);
180
+ else
181
+ data.byCountry.set(name, [index]);
182
+ });
183
+ }
184
+ let nearest = Infinity;
185
+ for (const index of data.byCountry.get(country) ?? []) {
186
+ nearest = Math.min(nearest, distanceKm(latitude, longitude, data.latitude[index], data.longitude[index]));
187
+ }
188
+ return nearest;
189
+ }
158
190
  const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
159
191
  function tokens(text) {
160
192
  return [...text.matchAll(/[\p{L}\p{N}]+/gu)].flatMap((match) => {
@@ -227,16 +259,20 @@ function cjkPhrases(field) {
227
259
  }
228
260
  return found;
229
261
  }
230
- /** The places a phrase can name. A hub's name replaces the towns of its country that share it. */
231
- function candidates(data, key) {
232
- const hub = HUBS.get(key);
262
+ /**
263
+ * The places a phrase can name. A hub's name replaces the towns of its country that share
264
+ * it; a hub of the parcel's carrier stands beside them, ahead ("Buchs" is DPD's depot in
265
+ * Buchs ZH on a DPD parcel, and still the town of St. Gallen or Aargau that says so).
266
+ */
267
+ function candidates(data, key, carriers) {
268
+ const hub = HUBS.get(key)?.find((entry) => !entry.carriers || entry.carriers.some((carrier) => carriers.includes(carrier)));
233
269
  // At its airport, or at its own point when its village is too small for the gazetteer.
234
270
  const airport = hub?.airport ? data.airports.get(hub.airport) : undefined;
235
271
  const point = airport ?? (hub?.latitude !== undefined && hub.longitude !== undefined ? { latitude: hub.latitude, longitude: hub.longitude } : undefined);
236
272
  const towns = [data.keys.get(key) ?? []].flat().flatMap((entry) => {
237
273
  const index = entry < 0 ? ~entry : entry;
238
274
  const country = data.codes[data.country[index]];
239
- if (point && country === hub.country)
275
+ if (point && country === hub.country && !hub.carriers)
240
276
  return [];
241
277
  return [{
242
278
  country, admin1: data.codes[data.admin1[index]] ?? '', admin2: data.codes[data.admin2[index]] ?? '',
@@ -247,8 +283,10 @@ function candidates(data, key) {
247
283
  if (!hub || !point)
248
284
  return towns;
249
285
  return [...towns, {
250
- country: hub.country, admin1: '', admin2: '', latitude: point.latitude, longitude: point.longitude,
251
- thousands: HUB_THOUSANDS, alternate: false, name: hub.town, ...(airport ? { site: airport.name } : {}), hub: true,
286
+ country: hub.country, admin1: hub.admin1 ?? '', admin2: '', latitude: point.latitude, longitude: point.longitude,
287
+ thousands: HUB_THOUSANDS, alternate: false, name: hub.town, hub: true,
288
+ ...(airport ? { site: airport.name } : hub.site ? { site: hub.site, named: true } : {}),
289
+ ...(hub.carriers ? { own: true } : {}),
252
290
  }];
253
291
  }
254
292
  /**
@@ -257,19 +295,31 @@ function candidates(data, key) {
257
295
  * place we can be confident about. A wrong dot is worse than no dot.
258
296
  */
259
297
  export function locatePlace(location, hints = {}) {
298
+ return locate(location, hints)?.place ?? null;
299
+ }
300
+ function locate(location, hints) {
260
301
  const text = location?.trim();
261
302
  if (!text)
262
303
  return null;
263
304
  const facility = facilityPlace(text);
264
305
  if (facility)
265
- return facility;
306
+ return { place: facility, text: true };
266
307
  const data = gazetteer();
267
308
  const explicit = trackingPlace(text);
268
309
  let country = explicit.country && data.countries.has(explicit.country) ? explicit.country : null;
269
310
  let rest = explicit.country ? explicit.place : text;
270
- const hinted = [...(hints.countries ?? [])];
311
+ const context = hints.countries ?? [];
312
+ const carriers = hints.carriers ?? [];
313
+ const hinted = [...context];
271
314
  const admin = [];
272
315
  const postcodes = [];
316
+ // How the country is written after the place, when it is: "(TO)", ", CH", " - CANADA".
317
+ const written = country && rest && text.startsWith(rest) ? text.slice(rest.length) : '';
318
+ // As its two-letter code, which a province can share: "PINEROLO (TO)" is in Turin's, not Tonga.
319
+ const coded = country !== null && /[A-Z]/.test(written) && written.split(/[^A-Za-z]+/).filter(Boolean).every((word) => word === country);
320
+ const bracketed = coded && written.includes('(');
321
+ if (coded)
322
+ admin.push(country);
273
323
  // A code that ends the text without a comma is the country, unless the town
274
324
  // before it is in a state or province of that code: "CURITIBA - PR" is Paraná.
275
325
  let looseCountry = false;
@@ -292,6 +342,9 @@ export function locatePlace(location, hints = {}) {
292
342
  // French departments and Italian provinces in brackets: "Sausheim 68 (68)", "Cologne (BS)".
293
343
  for (const match of rest.matchAll(/\((\d{2}|[A-Z]{2})\)|(?:^|\s)(\d{2})(?=\s|$)/g))
294
344
  admin.push(match[1] ?? match[2]);
345
+ // A state, province or canton written where one belongs, as a field of its own or at the
346
+ // end of one ("Earth City, MO", "Buchs AG", "LAUREL, MD 20707"): the town is in it.
347
+ const stated = text.split(/[,;|()]/).flatMap((field) => /(?:^|\s)([A-Z]{2})(?:\s+\d{4,5}(?:-\d{4})?)?$/.exec(field.trim())?.[1] ?? []);
295
348
  // Postcodes only confirm a name, and only in a country the scan or the parcel
296
349
  // names: on its own, "2024" is more likely a year than a village.
297
350
  const countries = country ? [country] : hinted;
@@ -340,7 +393,7 @@ export function locatePlace(location, hints = {}) {
340
393
  // is a village, not the agency at Terrasson, and "Roissy-en-Brie" is no airport.
341
394
  const partOfName = list.some((other) => other.start <= phrase.start && other.end >= phrase.end
342
395
  && other.key.length > phrase.key.length && data.keys.has(other.key));
343
- const towns = candidates(data, phrase.key).filter((place) => !place.hub || !partOfName);
396
+ const towns = candidates(data, phrase.key, carriers).filter((place) => !place.hub || !partOfName);
344
397
  namesTown ||= towns.length > 0;
345
398
  for (const place of towns) {
346
399
  // A town may still be named like the region it lies in: Moscow in "…, Moscow",
@@ -360,8 +413,11 @@ export function locatePlace(location, hints = {}) {
360
413
  const adminMatch = admin.some((code) => adminCodes.includes(code) || ADMIN_CODES[place.country]?.[code] === place.admin1)
361
414
  || regions.some((region) => region.country === place.country && region.admin1 === place.admin1
362
415
  && (region.field !== fieldIndex || region.end <= phrase.start || region.start >= phrase.end));
416
+ // "Earth City, MO" is not Earth in Texas: a state its country has, and the town is not in.
417
+ if (!adminMatch && stated.some((code) => data.regionNames.has(`${place.country}.${ADMIN_CODES[place.country]?.[code] ?? code}`)))
418
+ continue;
363
419
  const inCountry = place.country === country;
364
- if (country && !inCountry && !(looseCountry && adminMatch))
420
+ if (country && !inCountry && !((looseCountry || coded) && adminMatch))
365
421
  continue;
366
422
  const hint = hinted.indexOf(place.country);
367
423
  const postcodeMatch = postcode !== null && postcode.country === place.country
@@ -372,35 +428,56 @@ export function locatePlace(location, hints = {}) {
372
428
  const score = Math.log10(Math.max(place.thousands, .3) * 1000)
373
429
  + (place.alternate ? 0 : 1.8)
374
430
  + (inCountry ? 4 : hint >= 0 ? 2.5 - Math.min(hint, 3) * .4 : 0)
375
- + (adminMatch ? 3 : 0) + (postcodeMatch ? 3 : 0)
431
+ + (adminMatch ? 3 : 0) + (postcodeMatch ? 3 : 0) + (place.own ? 3 : 0)
376
432
  - phrase.rank * .3 - fieldIndex * .5;
377
- if (!best || score > best.score)
378
- best = { place, score, text: phrase.text, field: fieldIndex };
433
+ if (!best || score > best.score) {
434
+ best = { place, score, text: phrase.text, field: fieldIndex, backed: inCountry || adminMatch || postcodeMatch, shared: towns.length > 1 };
435
+ }
379
436
  }
380
437
  }
381
438
  }
382
439
  if (best) {
383
- const { place, text: written } = best;
384
- const airport = place.hub ? null : airportFor(data, rest, fields[best.field] ?? rest, place, written, country);
440
+ const { place, text: carrierWords } = best;
441
+ const airport = place.hub ? null : airportFor(data, rest, fields[best.field] ?? rest, place, carrierWords, country);
385
442
  return {
386
- latitude: round(airport?.latitude ?? place.latitude),
387
- longitude: round(airport?.longitude ?? place.longitude),
388
- precision: 'city',
389
- country: place.country,
390
- // Keep the carrier's spelling unless it is shouting: "La Crau", but "Köln" for "KOELN".
391
- name: /\p{Ll}/u.test(written) ? written : place.name,
392
- ...(place.site ? { site: place.site } : airport ? { site: airport.name } : {}),
443
+ text: best.backed,
444
+ thousands: place.thousands,
445
+ shared: best.shared,
446
+ place: {
447
+ latitude: round(airport?.latitude ?? place.latitude),
448
+ longitude: round(airport?.longitude ?? place.longitude),
449
+ precision: 'city',
450
+ country: place.country,
451
+ // Keep the carrier's spelling unless it is shouting: "La Crau", but "Köln" for "KOELN".
452
+ name: !place.named && /\p{Ll}/u.test(carrierWords) ? carrierWords : place.name,
453
+ ...(place.site ? { site: place.site } : airport ? { site: airport.name } : {}),
454
+ },
393
455
  };
394
456
  }
457
+ // A two-letter code with no town of its country before it, on a parcel nothing else puts
458
+ // there, may be no country at all: Royal Mail ends its mail centres with MC and its
459
+ // delivery offices with DO, India's states are written like countries. A town the text
460
+ // names where the parcel is settles it; so does any town, for a code no comma or bracket
461
+ // sets apart. A code of that kind with no town at all is then no place either.
462
+ if (rest && (looseCountry || coded) && !context.includes(country)) {
463
+ // Past a comma or a bracket, only a town known on its own: no hamlet where the parcel is.
464
+ const town = locate(rest, looseCountry ? hints : { carriers });
465
+ if (town?.place.precision === 'city' && (looseCountry || context.includes(town.place.country)))
466
+ return town;
467
+ if ((looseCountry || bracketed) && context.length)
468
+ return null;
469
+ }
395
470
  // India Post ends an office's name with its PIN code, and abbreviates some names
396
471
  // past reading ("KOL AP TMO 700052"): the PIN's town places a scan that names no
397
472
  // town, when the scan or the parcel is in India. A town nothing confirmed is no
398
473
  // abbreviation, and its six digits may be a Swiss Post site ("Daillens … 131070").
399
474
  const pin = !namesTown && (country === 'IN' || (!country && hinted.includes('IN'))) ? pinPlace(data, rest) : null;
400
475
  if (pin)
401
- return pin;
476
+ return { place: pin, text: true };
402
477
  // A region alone places its country: "Guangdong Province", or "Santa Catarina"
403
478
  // on a Brazilian parcel, though Cape Verde has one too.
479
+ // A code nothing backs is a guess: it tells the scans around it nothing.
480
+ const guessed = Boolean(rest) && (looseCountry || bracketed) && !context.includes(country);
404
481
  if (!country) {
405
482
  const named = fieldPhrases.flatMap((list) => list.filter((phrase) => phrase.whole && phrase.key.length >= 4
406
483
  && !VAGUE_REGIONS.has(phrase.key)).map((phrase) => {
@@ -411,7 +488,9 @@ export function locatePlace(location, hints = {}) {
411
488
  country = named.find((set) => set.size === 1)?.values().next().value ?? null;
412
489
  }
413
490
  const area = country ? data.countries.get(country) : null;
414
- return area && country ? { latitude: area.latitude, longitude: area.longitude, precision: 'country', country, name: area.name } : null;
491
+ return area && country
492
+ ? { text: !guessed, place: { latitude: area.latitude, longitude: area.longitude, precision: 'country', country, name: area.name } }
493
+ : null;
415
494
  }
416
495
  /**
417
496
  * The airport a scan names, near the town it names: by its code in brackets
@@ -495,27 +574,40 @@ function atPoint(place, point) {
495
574
  }
496
575
  const cache = new Map();
497
576
  const CACHE_SIZE = 20_000;
498
- function cachedPlace(location, countries) {
499
- const key = `${countries.join(',')}|${location ?? ''}`;
577
+ function cachedPlace(location, countries, carriers) {
578
+ const key = `${countries.join(',')}|${carriers.join(',')}|${location ?? ''}`;
500
579
  if (cache.has(key))
501
580
  return cache.get(key);
502
- const place = locatePlace(location, { countries });
581
+ const place = locate(location, { countries, carriers });
503
582
  if (cache.size >= CACHE_SIZE)
504
583
  cache.clear();
505
584
  cache.set(key, place);
506
585
  return place;
507
586
  }
587
+ /** The countries a carrier delivers in under its own name, home first, and its clock's. */
588
+ function carrierHomes(carrier) {
589
+ const definition = CARRIER_DEFINITIONS[carrier];
590
+ if (!definition)
591
+ return [];
592
+ const countries = definition.countries ?? [];
593
+ const clock = definition.timezone ? timeZoneCountry(definition.timezone) : null;
594
+ return [...(countries.length <= HOME_COUNTRIES_MAX ? countries : []), ...(clock ? [clock] : [])];
595
+ }
508
596
  /**
509
597
  * Places for a parcel's scans, oldest first. A scan that names no country
510
598
  * borrows one from the scans around it, then the destination and the
511
- * carrier's home countries, so "Buchs" after "Zürich" stays in Switzerland.
512
- * A carrier's own point for a scan, or for another scan with the same text,
513
- * moves it from the town's centre to the facility, if the two are within
514
- * 30 km of each other.
599
+ * carriers' home countries, so "Buchs" after "Zürich" stays in Switzerland.
600
+ * A town only its name places, in a country nothing else puts the parcel in,
601
+ * is a namesake and gets no place when it is thousands of kilometres off the
602
+ * way between the scans around it, or, where the scans stop, that far from
603
+ * them and from the parcel's countries, under a name other towns share,
604
+ * without being a world city. A carrier's own point for a scan, or for
605
+ * another scan with the same text, moves it from the town's centre to the
606
+ * facility, if the two are within 30 km of each other.
515
607
  */
516
- export function placesForEvents(locations, { destinationCountry, carrierCountries = [], points = [] } = {}) {
517
- const base = [...new Set([destinationCountry, ...carrierCountries].filter((code) => Boolean(code)))];
518
- const first = locations.map((location) => cachedPlace(location, base));
608
+ export function placesForEvents(locations, { destinationCountry, carrierCountries = [], carriers = [], points = [] } = {}) {
609
+ const base = [...new Set([destinationCountry, ...carrierCountries, ...carriers.flatMap(carrierHomes)].filter((code) => Boolean(code)))];
610
+ const first = locations.map((location) => cachedPlace(location, base, carriers));
519
611
  // A carrier can place some scans of an office and not others: they all share it.
520
612
  const pointAt = new Map();
521
613
  locations.forEach((location, index) => {
@@ -523,15 +615,67 @@ export function placesForEvents(locations, { destinationCountry, carrierCountrie
523
615
  if (location && point && !pointAt.has(location.trim()))
524
616
  pointAt.set(location.trim(), point);
525
617
  });
526
- return locations.map((location, index) => {
618
+ // What holds a scan where it is: its own text, or a country the parcel is known to be in.
619
+ const anchored = (found) => found !== null && (found.text || base.includes(found.place.country));
620
+ const reach = (from, to) => from.precision === 'country'
621
+ ? countryDistanceKm(from.country, to.latitude, to.longitude)
622
+ : distanceKm(from.latitude, from.longitude, to.latitude, to.longitude);
623
+ // The nearest scan before (`step` -1) or after another that `holds`.
624
+ const nearest = (list, index, step, holds) => {
625
+ for (let at = index + step; at >= 0 && at < list.length; at += step)
626
+ if (holds(list[at], at))
627
+ return list[at];
628
+ return null;
629
+ };
630
+ // The scans a town's name alone places, where the scans around them say it is a namesake.
631
+ const namesakes = (list) => {
632
+ const found = new Set();
633
+ const lone = list.flatMap((entry, index) => entry && !anchored(entry) && entry.place.precision === 'city' ? [{ ...entry, index }] : []);
634
+ // A name says where a town is when no other town shares it, or when it is a world city's.
635
+ const known = (entry) => !entry.shared || (entry.thousands ?? 0) >= WORLD_CITY_THOUSANDS;
636
+ const judge = (entries, holds) => {
637
+ for (const { place, index, ...entry } of entries) {
638
+ const before = nearest(list, index, -1, holds)?.place;
639
+ const after = nearest(list, index, 1, holds)?.place;
640
+ const around = [before, after].filter((other) => Boolean(other));
641
+ if (around.some((other) => other.country === place.country))
642
+ continue;
643
+ if (before && after) {
644
+ const detour = reach(before, place) + reach(after, place) - distanceKm(before.latitude, before.longitude, after.latitude, after.longitude);
645
+ if (detour > DETOUR_MAX_KM)
646
+ found.add(index);
647
+ }
648
+ else if (base.length && !known(entry) && around.every((other) => reach(other, place) > LEAP_MAX_KM)
649
+ && base.every((code) => countryDistanceKm(code, place.latitude, place.longitude) > LEAP_MAX_KM)) {
650
+ found.add(index);
651
+ }
652
+ }
653
+ };
654
+ // Such a town stands among the scans that hold, and the towns around it are judged by it too.
655
+ judge(lone.filter(known), anchored);
656
+ judge(lone.filter((entry) => !known(entry)), (entry, index) => entry !== null
657
+ && (anchored(entry) || (entry.place.precision === 'city' && known(entry) && !found.has(index))));
658
+ return found;
659
+ };
660
+ // A namesake tells its neighbours nothing, and neither does a country guessed from a code nothing backs.
661
+ const doubtful = namesakes(first);
662
+ const telling = (found, index) => found !== null && !doubtful.has(index) && (found.text || found.place.precision === 'city');
663
+ const settled = locations.map((location, index) => {
527
664
  if (!location?.trim())
528
665
  return null;
529
- const before = first.slice(0, index).reverse().find(Boolean)?.country;
530
- const after = first.slice(index + 1).find(Boolean)?.country;
666
+ const before = nearest(first, index, -1, telling)?.place.country;
667
+ const after = nearest(first, index, 1, telling)?.place.country;
531
668
  const neighbours = [...new Set([before, after].filter((code) => Boolean(code)))];
532
- const place = !neighbours.length || neighbours.every((code) => base.includes(code))
533
- ? first[index]
534
- : cachedPlace(location, [...neighbours, ...base.filter((code) => !neighbours.includes(code))]) ?? first[index];
535
- return atPoint(place, points[index] ?? pointAt.get(location.trim()));
669
+ if (!neighbours.length || neighbours.every((code) => base.includes(code)))
670
+ return first[index];
671
+ return cachedPlace(location, [...neighbours, ...base.filter((code) => !neighbours.includes(code))], carriers)
672
+ ?? (telling(first[index], index) ? first[index] : null);
673
+ });
674
+ const dropped = namesakes(settled);
675
+ return locations.map((location, index) => {
676
+ const found = settled[index];
677
+ if (!found || !location || dropped.has(index))
678
+ return null;
679
+ return atPoint(found.place, points[index] ?? pointAt.get(location.trim()));
536
680
  });
537
681
  }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.135.0"
5
+ "version": "0.136.1"
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.135.0",
3
+ "version": "0.136.1-main.1125",
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",