universal-parcel-scraper 0.735.2-main.3769 → 0.736.0-main.3771

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.
@@ -72,27 +72,30 @@ const DEPOT_PLACES = [
72
72
  ];
73
73
  const COUNTRY_PLACES = [
74
74
  /\barrived in the destination country (.+?)(?: and is prepared\b|\.?$)/i,
75
- /\bprepared for further shipping \(([^()]+)\)/i,
75
+ /\bfor further shipping \(([^()]+)\)/i,
76
76
  ];
77
77
  // The town after the postcode closing a ParcelShop's address, before the
78
78
  // collection deadline the ready-for-pickup wording appends.
79
79
  const SHOP_TOWN = /,\s*\d{5}\s+([^\d,]+?)(?:\s+max\.\s.*|\s+abgegeben)?\.?\s*$/;
80
80
  /**
81
81
  * Where a row's wording puts the parcel. Hermes's own depots and ParcelShops
82
- * are in Germany; a country abroad is named alone.
82
+ * are in Germany. A partner's scan (`INTERNATIONAL_…`) names the country after
83
+ * its depot, "prepared in EXEMPLEVILLE for further shipping (France)",
84
+ * or the country alone.
83
85
  */
84
86
  function wordedPlace(text, code) {
85
87
  const place = (pattern) => clean(pattern.exec(text)?.[1], 120);
88
+ const country = COUNTRY_PLACES.map(place).find(Boolean) ?? '';
89
+ const partner = code.startsWith('INTERNATIONAL_');
86
90
  for (const pattern of DEPOT_PLACES) {
87
91
  const depot = place(pattern);
92
+ if (depot && partner)
93
+ return country ? `${depot}, ${country}` : depot;
88
94
  if (depot)
89
95
  return `${depot}, Germany`;
90
96
  }
91
- for (const pattern of COUNTRY_PLACES) {
92
- const country = place(pattern);
93
- if (country)
94
- return country;
95
- }
97
+ if (country)
98
+ return country;
96
99
  const town = code.startsWith('PARCELSHOP_') ? place(SHOP_TOWN) : '';
97
100
  return town ? `${town}, Germany` : null;
98
101
  }
@@ -4,6 +4,7 @@ import { normalizeCarrierResult } from '../result/index.js';
4
4
  import { runSteps } from '../runner/index.js';
5
5
  import { NOOP_RECORDER } from '../telemetry/index.js';
6
6
  import { dateLocalClocks } from '../../places/clocks.js';
7
+ import { restoreLostLetters } from '../../places/index.js';
7
8
  /**
8
9
  * One carrier lookup, leaving provider selection and scheduling to the consumer.
9
10
  * The adapter gets the normalized number, as from `createTracker()`: a number
@@ -25,7 +26,7 @@ export async function trackCarrier(carrier, typed, options) {
25
26
  : await runSteps({ carrier, budgetMs: options.budgetMs ?? 120_000, signal: options.signal,
26
27
  recorder: options.recorder ?? NOOP_RECORDER }, [{ id: registered.steps[0] ?? 'direct',
27
28
  run: ({ signal, remainingMs }) => registered.track(input, ...(options.signal || options.budgetMs !== undefined ? [{ signal, budgetMs: context.budgetMs === undefined ? undefined : Math.min(context.budgetMs, remainingMs) }] : [])) }]);
28
- return dateLocalClocks(carrier, normalizeCarrierResult(result));
29
+ return dateLocalClocks(carrier, restoreLostLetters(normalizeCarrierResult(result)));
29
30
  }
30
31
  catch (error) {
31
32
  // A caller's cancellation is the caller's reason, not a carrier failure.
@@ -1,3 +1,4 @@
1
+ import type { CarrierResult } from '../core/result/index.js';
1
2
  export { carrierHomeZone, dateLocalClocks, type ZoneBasis } from './clocks.js';
2
3
  /** Where a scan happened, as precisely as its free-text location allows. */
3
4
  export interface EventPlace {
@@ -47,6 +48,11 @@ export declare function placeTimeZone(place: EventPlace): string | null;
47
48
  * place we can be confident about. A wrong dot is worse than no dot.
48
49
  */
49
50
  export declare function locatePlace(location: string | null | undefined, hints?: PlaceHints): EventPlace | null;
51
+ /**
52
+ * The result with the letters its scans' "Town, Country" places lost restored
53
+ * (`restoredTownName`), in the place and wherever the wording names it.
54
+ */
55
+ export declare function restoreLostLetters(result: CarrierResult): CarrierResult;
50
56
  /**
51
57
  * Places for a parcel's scans, oldest first. A scan that names no country
52
58
  * borrows one from the scans around it, then the destination and the
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { brotliDecompressSync } from 'node:zlib';
3
3
  import { CARRIER_DEFINITIONS } from '../core/catalog/definitions.js';
4
- import { timeZoneCountry } from '../core/time/index.js';
4
+ import { countryCode, timeZoneCountry } from '../core/time/index.js';
5
5
  import { ambiguousAddressCodes, militaryPostOffice, trackingPlace } from './trackingLocation.js';
6
6
  import facilityList from './facilities.json' with { type: 'json' };
7
7
  import hubList from './hubs.json' with { type: 'json' };
@@ -511,6 +511,70 @@ export function locatePlace(location, hints = {}) {
511
511
  // Alone, a name two towns of a country share alike says neither.
512
512
  return found && !found.twins ? found.place : null;
513
513
  }
514
+ const LOST = '\uFFFD';
515
+ /**
516
+ * A town's name with the letters a carrier lost on the way, each a replacement
517
+ * character, filled from the one spelling of a town of `country` that fits:
518
+ * "SAINT-\uFFFDTIENNE" is Saint-Étienne's. A letter inside a word takes the
519
+ * case of the letters beside it, one starting a word the town's own. Null when
520
+ * no letter is lost or no single spelling fits.
521
+ */
522
+ function restoredTownName(town, country) {
523
+ if (!town.includes(LOST))
524
+ return null;
525
+ const chars = [...town];
526
+ const source = chars.map((char) => char === LOST ? '(\\p{L})' : /[\s-]/u.test(char) ? '[\\s-]'
527
+ : char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('');
528
+ const pattern = new RegExp(`^${source}$`, 'iu');
529
+ const data = gazetteer();
530
+ const fills = new Set();
531
+ for (const index of countryPlaces(country)) {
532
+ const match = pattern.exec(data.names[index]);
533
+ if (match)
534
+ fills.add(match.slice(1).join(' '));
535
+ }
536
+ if (new Set([...fills].map((fill) => fill.toLowerCase())).size !== 1)
537
+ return null;
538
+ const letters = [...fills][0].split(' ');
539
+ const letter = (char) => char !== undefined && char !== LOST && /\p{L}/u.test(char);
540
+ return chars.map((char, at) => {
541
+ if (char !== LOST)
542
+ return char;
543
+ const filled = letters.shift();
544
+ if (!letter(chars[at - 1]))
545
+ return filled;
546
+ const beside = [chars[at - 1], chars[at + 1]].filter(letter);
547
+ return beside.every((other) => other === other.toUpperCase()) ? filled.toUpperCase() : filled.toLowerCase();
548
+ }).join('');
549
+ }
550
+ /**
551
+ * The result with the letters its scans' "Town, Country" places lost restored
552
+ * (`restoredTownName`), in the place and wherever the wording names it.
553
+ */
554
+ export function restoreLostLetters(result) {
555
+ if (!result.events?.some((event) => event.location?.includes(LOST)))
556
+ return result;
557
+ const restored = new Map();
558
+ for (const event of result.events) {
559
+ const [, town, country] = /^(.+), ([^,]+)$/.exec(event.location ?? '') ?? [];
560
+ const code = country ? countryCode(country) : null;
561
+ const name = town && code && !restored.has(town) ? restoredTownName(town, code) : null;
562
+ if (name)
563
+ restored.set(town, name);
564
+ }
565
+ if (!restored.size)
566
+ return result;
567
+ const spell = (text) => [...restored].reduce((spelled, [lost, name]) => spelled.replaceAll(lost, name), text);
568
+ return {
569
+ ...result,
570
+ ...(result.last_status_text ? { last_status_text: spell(result.last_status_text) } : {}),
571
+ events: result.events.map((event) => ({
572
+ ...event,
573
+ ...(event.location ? { location: spell(event.location) } : {}),
574
+ ...(event.description ? { description: spell(event.description) } : {}),
575
+ })),
576
+ };
577
+ }
514
578
  /** `split` reads a country the text names apart from its town; without it the whole text is the town's. */
515
579
  function locate(location, hints, split = true) {
516
580
  const text = location?.trim();
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.735.2"
5
+ "version": "0.736.0"
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.735.2-main.3769",
3
+ "version": "0.736.0-main.3771",
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",