universal-parcel-scraper 0.49.0-main.523 → 0.49.0-main.526

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.
@@ -76,6 +76,28 @@ function scanTime(scan) {
76
76
  const offset = zone[1] ? `${zone[1]}${zone[2].padStart(2, '0')}:${zone[3] ?? '00'}` : 'Z';
77
77
  return explicitOffsetTime(`${wall.replace(' ', 'T')}${offset}`)?.iso ?? wall;
78
78
  }
79
+ /** The carriers a module names, which a bracket can name too without being a place. */
80
+ function carrierNames(trackingModule, scans) {
81
+ const named = [trackingModule.destCpInfo, ...[trackingModule.latestTrace, trackingModule.globalCombinedLogisticsTraceDTO, ...scans]
82
+ .map((scan) => record(scan).currentStatusCpInfo)];
83
+ return new Set(named.map((info) => text(record(info).cpName).trim().toLocaleLowerCase('en-US')).filter(Boolean));
84
+ }
85
+ /**
86
+ * Cainiao has no place field: it writes a scan's town in front of its standard
87
+ * wording, "[Bordeaux] Out for delivery", in every language. A bracket that
88
+ * reads as a place name becomes the location and the wording keeps the rest.
89
+ * Codes, capitals-only names and the module's own carrier names stay in the text.
90
+ */
91
+ function placedWording(wording, carriers) {
92
+ const match = /^\s*\[([^[\]]{1,80})\]\s*(\S[\s\S]*)$/.exec(wording);
93
+ const place = match?.[1].trim() ?? '';
94
+ const letters = place.match(/\p{L}/gu)?.length ?? 0;
95
+ if (!match || letters < 3 || !/^\p{L}[\p{L}\p{M} .'’-]*$/u.test(place)
96
+ || (/\p{Lu}/u.test(place) && !/\p{Ll}/u.test(place)) || carriers.has(place.toLocaleLowerCase('en-US'))) {
97
+ return { description: wording, location: '' };
98
+ }
99
+ return { description: match[2].trim(), location: place };
100
+ }
79
101
  const BEIJING_OFFSET_MS = 8 * 3_600_000;
80
102
  /**
81
103
  * Whether a zone-less scan is Cainiao's own record on the Beijing clock. Its
@@ -139,13 +161,15 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
139
161
  ?? (rawStatus ? 'in_transit' : details.length === 0 ? 'pending' : 'unknown'));
140
162
  const latestAction = cainiaoActionCode(latest.actionCode);
141
163
  const actionStage = cainiaoStageByStatus(latestAction);
164
+ const carriers = carrierNames(trackingModule, details);
142
165
  const events = details.slice(0, MAX_EVENTS_TO_RETURN).map((event) => {
143
166
  const code = cainiaoActionCode(event.actionCode);
144
167
  const mapped = code ? CAINIAO_ACTION_STATUS.get(code) : undefined;
168
+ const { description, location } = placedWording(text(event.standerdDesc) || text(event.desc), carriers);
145
169
  return {
146
170
  time: scanTime(event),
147
- location: '',
148
- description: text(event.standerdDesc) || text(event.desc),
171
+ location,
172
+ description,
149
173
  ...(mapped ? { stage: actionStage[mapped] ?? 'in_transit' } : {}),
150
174
  };
151
175
  });
@@ -162,7 +186,7 @@ export function parseCainiaoTrackingResponse(value, trackingNumber) {
162
186
  return {
163
187
  status,
164
188
  ...(actionStage[status] || status === 'in_transit' ? { current_stage: actionStage[status] ?? 'in_transit' } : {}),
165
- last_status_text: text(latest.standerdDesc) || text(latest.desc) || rawStatus,
189
+ last_status_text: placedWording(text(latest.standerdDesc) || text(latest.desc), carriers).description || rawStatus,
166
190
  last_update: scanTime(latest) || null,
167
191
  expected_delivery: status === 'delivered' ? null : expected,
168
192
  ...(status === 'delivered' || expectedFrom == null || expectedFrom === expected
@@ -0,0 +1,2 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -0,0 +1,14 @@
1
+ const normalized = (value) => value.replace(/\s+/g, ' ').trim().toLocaleLowerCase('en-US');
2
+ // A scan stored while its town was still part of the wording, "[Bordeaux] Out for
3
+ // delivery" without a location, is the same scan as "Out for delivery" in Bordeaux.
4
+ export const sameInstantIdentityPolicy = {
5
+ sourceCarrierId: 'aliexpress', storedSources: ['aliexpress'], requireProviderCode: false,
6
+ matchEachScan: true,
7
+ matches(incoming, stored) {
8
+ const wording = normalized(incoming.description);
9
+ const location = normalized(incoming.location);
10
+ return Boolean(wording && location) && !['', 'unknown'].includes(incoming.stage)
11
+ && incoming.stage === stored.stage && !normalized(stored.location)
12
+ && normalized(stored.description) === `[${location}] ${wording}`;
13
+ },
14
+ };
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "capabilities": [
30
30
  "history",
31
+ "location",
31
32
  "eta",
32
33
  "eta_window",
33
34
  "delivered_at"
@@ -38,11 +39,13 @@
38
39
  "shows": [
39
40
  "status",
40
41
  "history",
42
+ "location",
41
43
  "eta"
42
44
  ],
43
45
  "scraped": [
44
46
  "status",
45
47
  "history",
48
+ "location",
46
49
  "eta"
47
50
  ],
48
51
  "discarded": [],
@@ -25,7 +25,15 @@ export declare class LaPosteTracker {
25
25
  private readonly fetcher?;
26
26
  private readonly recorder?;
27
27
  private readonly userAgent;
28
+ /** The locator's page for a point is large and its address does not change. */
29
+ private readonly addresses;
28
30
  constructor(options?: LaPosteTrackerOptions);
29
31
  fetch(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
32
+ /**
33
+ * The point's street and town, or nothing: the parcel is found, and its pickup point keeps its
34
+ * name without them. The request gets at most half of what is left, so the result still has
35
+ * time to return.
36
+ */
37
+ private pointAddress;
30
38
  }
31
39
  export declare const adapter: AdapterFactory;
@@ -4,7 +4,7 @@ import { dpdParcelNumber } from '../../core/detection/dpd.js';
4
4
  import { CarrierError, InvalidInputError, SchemaError, UpstreamHttpError } from '../../core/errors/index.js';
5
5
  import { runSteps } from '../../core/runner/index.js';
6
6
  import { isoTime } from '../../core/time/index.js';
7
- import { clean, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
7
+ import { clean, decodeText, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
8
8
  import { isRecord } from '../../core/types.js';
9
9
  import { eventStage, eventStatus } from './status.js';
10
10
  // Protocol provenance:
@@ -20,10 +20,19 @@ import { eventStage, eventStatus } from './status.js';
20
20
  // - Production HTTP 403s carried La Poste's "Site indisponible - Incident en
21
21
  // cours" page and immediately following checks succeeded, so a 403 is
22
22
  // retried up to three times inside the original deadline (see README.md).
23
+ // - The tracking page links the point holding a parcel to La Poste's locator,
24
+ // `localiser.laposte.fr/{idPoint}`, which redirects to the point's page. That
25
+ // page carries the point's record, address included, as JSON in
26
+ // `Yext["profile"]`; `meta.id` repeats the point's id.
23
27
  const TRACKING_API = 'https://www.laposte.fr/ssu/sun/back/suivi-unifie';
24
28
  const TRACKING_PAGE = 'https://www.laposte.fr/outils/suivre-vos-envois';
29
+ const LOCATOR = 'https://localiser.laposte.fr';
25
30
  const TIMEZONE = 'Europe/Paris';
26
31
  const DEFAULT_TIMEOUT_MS = 15_000;
32
+ /** The locator answers from a cache within a fraction of a second. */
33
+ const ADDRESS_TIMEOUT_MS = 3_000;
34
+ /** Points whose address a tracker remembers: a waiting parcel is looked up again and again. */
35
+ const MAX_REMEMBERED_POINTS = 500;
27
36
  const MAX_RESPONSE_BYTES = 2_000_000;
28
37
  const MAX_EVENTS_TO_RETURN = 100;
29
38
  /** The provider's "unknown shipment" return code. */
@@ -116,7 +125,36 @@ export function laPosteTrackingApiUrl(trackingNumber) {
116
125
  url.searchParams.set('lang', 'fr');
117
126
  return url.toString();
118
127
  }
128
+ /**
129
+ * The street and town of the point a locator page describes, a line each, if it is the
130
+ * requested one. Its phone, opening hours, services and coordinates are not read.
131
+ */
132
+ function pointAddress(html, id) {
133
+ const marker = 'Yext["profile"] = ';
134
+ const start = html.indexOf(marker);
135
+ const end = start < 0 ? -1 : html.indexOf('; return Yext;', start);
136
+ if (end < 0)
137
+ return '';
138
+ let profile;
139
+ try {
140
+ profile = JSON.parse(html.slice(start + marker.length, end));
141
+ }
142
+ catch {
143
+ return '';
144
+ }
145
+ if (!isRecord(profile) || !isRecord(profile.meta) || clean(profile.meta.id, 40) !== id || !isRecord(profile.address))
146
+ return '';
147
+ const street = clean(profile.address.line1, 120);
148
+ const city = clean(profile.address.city, 80);
149
+ if (!street || !city)
150
+ return '';
151
+ return `${street}\n${[clean(profile.address.postalCode, 16), city].filter(Boolean).join(' ')}`;
152
+ }
119
153
  export function parseLaPosteTrackingResponse(payload, trackingNumber) {
154
+ return parseShipment(payload, trackingNumber).result;
155
+ }
156
+ /** `point` is the locator id of the pickup point the result names, else empty. */
157
+ function parseShipment(payload, trackingNumber) {
120
158
  const requested = normalizeLaPosteTrackingNumber(trackingNumber);
121
159
  if (!Array.isArray(payload) || payload.length === 0) {
122
160
  throw new SchemaError('La Poste', 'La Poste returned an invalid tracking response');
@@ -180,13 +218,15 @@ export function parseLaPosteTrackingResponse(payload, trackingNumber) {
180
218
  const shipped = clean(shipment.idShip, 64).toLocaleUpperCase('en-US');
181
219
  // The merchant La Poste shows on the tracking page. Recipient blocks stay unread.
182
220
  const sender = clean(context.merchantName, 200);
183
- // The post office, locker or shop holding the parcel; only its name is read.
221
+ // The post office, locker or shop holding the parcel: its name, and its id
222
+ // on La Poste's locator, which gives its address.
184
223
  const removal = isRecord(context.removalPoint) ? context.removalPoint : {};
185
224
  const pickupPoint = latest?.stage === 'ready_for_pickup' && clean(removal.type, 20) ? clean(removal.name, 200) : '';
225
+ const point = clean(removal.idPoint, 40);
186
226
  const partner = isRecord(context.partner) ? context.partner : {};
187
227
  const deliveryCarrier = carrierIdFromPartner(clean(partner.name, 80), clean(partner.url, 2048));
188
228
  const deliveryNumber = clean(partner.reference, 64).toUpperCase();
189
- return {
229
+ const result = {
190
230
  status: eventStatus(latestGroup, latestCode, latestLabel, events.length > 0),
191
231
  // The status vocabulary has no pickup or customs value; without the stage
192
232
  // the sync would re-read the sentence and fall back to "out for delivery".
@@ -207,12 +247,15 @@ export function parseLaPosteTrackingResponse(payload, trackingNumber) {
207
247
  } : {}),
208
248
  events,
209
249
  };
250
+ return { result, point: pickupPoint && /^[A-Za-z0-9]{1,20}$/.test(point) ? point : '' };
210
251
  }
211
252
  export class LaPosteTracker {
212
253
  timeoutMs;
213
254
  fetcher;
214
255
  recorder;
215
256
  userAgent;
257
+ /** The locator's page for a point is large and its address does not change. */
258
+ addresses = new Map();
216
259
  constructor(options = {}) {
217
260
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
261
  if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0) {
@@ -241,7 +284,14 @@ export class LaPosteTracker {
241
284
  maxBytes: MAX_RESPONSE_BYTES,
242
285
  fetcher: this.fetcher,
243
286
  });
244
- return parseLaPosteTrackingResponse(parseJsonBytes(bytes, 'La Poste'), normalized);
287
+ const { result, point } = parseShipment(parseJsonBytes(bytes, 'La Poste'), normalized);
288
+ // The feed names the point holding the parcel without its address, which the locator adds.
289
+ if (point) {
290
+ const address = await this.pointAddress(point, signal, deadline - performance.now());
291
+ if (address)
292
+ result.pickup_point = `${result.pickup_point}\n${address}`;
293
+ }
294
+ return result;
245
295
  };
246
296
  // La Poste's edge answers single lookups with an HTTP 403 "Site indisponible
247
297
  // - Incident en cours" page while another parcel, or the same one a moment
@@ -268,6 +318,43 @@ export class LaPosteTracker {
268
318
  { ...retry },
269
319
  ]);
270
320
  }
321
+ /**
322
+ * The point's street and town, or nothing: the parcel is found, and its pickup point keeps its
323
+ * name without them. The request gets at most half of what is left, so the result still has
324
+ * time to return.
325
+ */
326
+ async pointAddress(id, signal, remainingMs) {
327
+ const known = this.addresses.get(id);
328
+ if (known)
329
+ return known;
330
+ const timeoutMs = Math.floor(Math.min(ADDRESS_TIMEOUT_MS, remainingMs / 2));
331
+ if (timeoutMs < 1)
332
+ return '';
333
+ try {
334
+ const { bytes } = await fetchBounded(`${LOCATOR}/${encodeURIComponent(id)}`, {
335
+ signal,
336
+ headers: { Accept: 'text/html', 'Accept-Language': 'fr-FR,fr;q=0.9', 'User-Agent': this.userAgent },
337
+ }, {
338
+ provider: 'La Poste locator',
339
+ timeoutMs,
340
+ maxBytes: MAX_RESPONSE_BYTES,
341
+ // The point's id redirects to its page, named after its town.
342
+ redirect: 'follow',
343
+ fetcher: this.fetcher,
344
+ });
345
+ const address = pointAddress(decodeText(bytes), id);
346
+ if (address) {
347
+ if (this.addresses.size >= MAX_REMEMBERED_POINTS)
348
+ this.addresses.delete(this.addresses.keys().next().value);
349
+ this.addresses.set(id, address);
350
+ }
351
+ return address;
352
+ }
353
+ catch {
354
+ signal.throwIfAborted();
355
+ return '';
356
+ }
357
+ }
271
358
  }
272
359
  export const adapter = (environment) => {
273
360
  const tracker = new LaPosteTracker({
@@ -6,6 +6,17 @@ export interface SwissPostOptions {
6
6
  fetcher?: typeof fetch;
7
7
  }
8
8
  export declare function swissPostExpectedDelivery(item: JsonObject): string | null;
9
+ /**
10
+ * The site number of the office or My Post 24 terminal holding the parcel, from its pickup
11
+ * notice, else empty. The tracker reads the arrival office first, as its own page does.
12
+ */
13
+ export declare function swissPostPickupOffice(item: JsonObject): string;
14
+ /**
15
+ * The site record's name, then its street and its town on their own lines, as the pickup
16
+ * notice shows them, if it is the requested site. A street that repeats the name, as a
17
+ * terminal's can, is left out; without a town the name stands alone.
18
+ */
19
+ export declare function swissPostPickupPoint(office: unknown, site: string): string;
9
20
  export declare function parseSwissPostShipment(item: JsonObject, rawEvents: unknown[], translations?: Record<string, string>): CarrierResult;
10
21
  export declare class SwissPostTracker {
11
22
  #private;
@@ -14,5 +25,7 @@ export declare class SwissPostTracker {
14
25
  /** Cached for the process: the table is large, static, and shared by every lookup. */
15
26
  loadTranslations(fetcher: typeof fetch, budget?: LookupBudget): Promise<Record<string, string>>;
16
27
  fetch(trackingNumber: string, context?: TrackingContext): Promise<CarrierResult>;
28
+ /** The pickup point, or nothing: the parcel is found without it. Only the caller's cancellation ends the lookup. */
29
+ private pickupPoint;
17
30
  }
18
31
  export declare const adapter: AdapterFactory;
@@ -14,8 +14,8 @@ const API_BASE = 'https://service.post.ch/ekp-web/api';
14
14
  const TRANSLATIONS_URL = 'https://service.post.ch/ekp-web/core/rest/translations/en/shipment-text-messages';
15
15
  const PROVIDER = 'Swiss Post';
16
16
  const REQUEST_TIMEOUT_MS = 10_000;
17
- /** The user, search, result, event and translation calls, each at its own bound. */
18
- const DEFAULT_BUDGET_MS = 5 * REQUEST_TIMEOUT_MS;
17
+ /** The user, search, result, event, translation and pickup office calls, each at its own bound. */
18
+ const DEFAULT_BUDGET_MS = 6 * REQUEST_TIMEOUT_MS;
19
19
  /** A request's own timer can fire a few milliseconds before the budget's clock runs out. */
20
20
  const BUDGET_SLACK_MS = 5;
21
21
  const HEADERS = {
@@ -146,6 +146,33 @@ function eventLocation(event) {
146
146
  return country && country !== 'ZZ' ? country : postcode;
147
147
  return [city, postcode].filter(Boolean).join(' ').slice(0, 160);
148
148
  }
149
+ /**
150
+ * The site number of the office or My Post 24 terminal holding the parcel, from its pickup
151
+ * notice, else empty. The tracker reads the arrival office first, as its own page does.
152
+ */
153
+ export function swissPostPickupOffice(item) {
154
+ const avis = isRecord(item.avis) ? item.avis : {};
155
+ const office = text(avis.arrivalPostOfficeZip, 20) || text(avis.deliveryPostOfficeZip, 20);
156
+ return /^\d{6}$/.test(office) ? office : '';
157
+ }
158
+ /**
159
+ * The site record's name, then its street and its town on their own lines, as the pickup
160
+ * notice shows them, if it is the requested site. A street that repeats the name, as a
161
+ * terminal's can, is left out; without a town the name stands alone.
162
+ */
163
+ export function swissPostPickupPoint(office, site) {
164
+ if (!isRecord(office) || text(office.zip, 20) !== site)
165
+ return '';
166
+ const name = text(office.description, 120);
167
+ if (!name)
168
+ return '';
169
+ const postcode = text(office.zip4, 10);
170
+ const town = text(office.city, 80);
171
+ if (!/^\d{4}$/.test(postcode) || !town)
172
+ return name;
173
+ const street = [text(office.street, 120), text(office.streetNumber, 20)].filter(Boolean).join(' ');
174
+ return [name, street !== name && street, `${postcode} ${town}`].filter(Boolean).join('\n');
175
+ }
149
176
  /**
150
177
  * Local ordering policy: event timestamps are read only to sort. Values without
151
178
  * an offset are treated as UTC for that comparison, which keeps the ordering
@@ -332,7 +359,25 @@ export class SwissPostTracker {
332
359
  }
333
360
  }
334
361
  const translations = events.length > 0 ? await this.loadTranslations(fetcher, budget) : {};
335
- return parseSwissPostShipment(item, events, translations);
362
+ const result = parseSwissPostShipment(item, events, translations);
363
+ // The pickup notice names the site holding the parcel; that site's record gives its name and address.
364
+ const office = result.current_stage === 'ready_for_pickup' ? swissPostPickupOffice(item) : '';
365
+ if (office) {
366
+ const point = await this.pickupPoint(fetcher, headers, office, budget, context.signal);
367
+ if (point)
368
+ result.pickup_point = point;
369
+ }
370
+ return result;
371
+ }
372
+ /** The pickup point, or nothing: the parcel is found without it. Only the caller's cancellation ends the lookup. */
373
+ async pickupPoint(fetcher, headers, office, budget, signal) {
374
+ try {
375
+ return swissPostPickupPoint(await this.readJson(fetcher, `${API_BASE}/autocomplete/postoffice/id/${encodeURIComponent(office)}`, { headers }, budget), office);
376
+ }
377
+ catch {
378
+ signal?.throwIfAborted();
379
+ return '';
380
+ }
336
381
  }
337
382
  }
338
383
  export const adapter = (environment) => {
@@ -29,6 +29,7 @@
29
29
  "history",
30
30
  "location",
31
31
  "eta",
32
+ "pickup_point",
32
33
  "provider_code",
33
34
  "weight",
34
35
  "dimensions",
@@ -43,6 +44,7 @@
43
44
  "location",
44
45
  "eta",
45
46
  "eta_window",
47
+ "pickup_point",
46
48
  "weight",
47
49
  "dimensions",
48
50
  "delivered_at",
@@ -57,6 +59,7 @@
57
59
  "history",
58
60
  "location",
59
61
  "eta",
62
+ "pickup_point",
60
63
  "provider_code",
61
64
  "weight",
62
65
  "dimensions",
@@ -1,10 +1,11 @@
1
+ import { sameInstantIdentityPolicy as aliexpress } from '../../carriers/aliexpress/app.js';
1
2
  import { sameInstantIdentityPolicy as australiaPost } from '../../carriers/australia-post/app.js';
2
3
  import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
3
4
  import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
4
5
  import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
5
6
  import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
6
7
  import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
7
- const policies = new Map([australiaPost, dpd, indiaPost, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
8
+ const policies = new Map([aliexpress, australiaPost, dpd, indiaPost, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
8
9
  /** Unlisted sources cannot identify a reworded scan by its instant alone. */
9
10
  export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
10
11
  const policy = policies.get(sourceCarrierId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.49.0-main.523",
3
+ "version": "0.49.0-main.526",
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",