universal-parcel-scraper 0.49.2-main.559 → 0.49.2-main.562

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.
@@ -4,12 +4,14 @@ import { runSteps } from '../../core/runner/index.js';
4
4
  import { explicitOffsetTime } from '../../core/time/index.js';
5
5
  import { clean, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
6
6
  import { isRecord } from '../../core/types.js';
7
+ import { isPostiAbroadLabel } from './identity.js';
7
8
  import { postiEventStage, postiStatus } from './status.js';
8
9
  const TOKEN_URL = 'https://auth-service.posti.fi/api/v1/anonymous_token';
9
10
  const GRAPHQL_URL = 'https://graphql.posti.fi/graphql';
10
11
  const DEFAULT_TIMEOUT_MS = 15_000;
11
12
  // The public tracking page uses this operation with PUBLIC_SHIPMENTS. Select
12
- // only tracking fields; no account, recipient, address, payment or pickup code.
13
+ // only tracking fields and the pickup point's own address; no account,
14
+ // recipient address, payment or pickup code.
13
15
  const QUERY = `query SearchShipments($searchTerms: [String!]!, $locale: String) {
14
16
  consumerSearchShipments(page: 1, pageSize: 20, type: PUBLIC_SHIPMENTS, searchTerms: $searchTerms, locale: $locale) {
15
17
  totalHits
@@ -17,7 +19,7 @@ const QUERY = `query SearchShipments($searchTerms: [String!]!, $locale: String)
17
19
  displayId
18
20
  status { main subStatus }
19
21
  measurements { weight { unit value } height { unit value } width { unit value } length { unit value } }
20
- pickupPoint { address { publicName city } }
22
+ pickupPoint { type address { publicName streetAddress postcode city } }
21
23
  events { city eventDescription reasonDescription timestamp }
22
24
  packages { trackingNumber }
23
25
  }
@@ -47,6 +49,39 @@ function assertGraphql(payload) {
47
49
  if (payload.errors != null && !Array.isArray(payload.errors))
48
50
  throw new SchemaError('Posti');
49
51
  }
52
+ // Pickup point types whose address is a public business's: Posti's lockers and
53
+ // service points. A private locker stands in the recipient's building.
54
+ const PUBLIC_POINTS = new Set(['LOCKER', 'LOCKER_OUTDOOR', 'POSTI_SERVICE_POINT']);
55
+ /**
56
+ * The pickup point's name, then its street and "postcode city", as Posti's
57
+ * tracker lays them out. A private locker, or a point of unknown type, keeps
58
+ * only its town after the name: its street could be the recipient's.
59
+ */
60
+ function pickupPoint(raw) {
61
+ const point = isRecord(raw) ? raw : {};
62
+ const address = isRecord(point.address) ? point.address : {};
63
+ const name = clean(address.publicName, 160);
64
+ if (!name)
65
+ return null;
66
+ const street = clean(address.streetAddress, 120);
67
+ const city = clean(address.city, 80);
68
+ if (street && city && typeof point.type === 'string' && PUBLIC_POINTS.has(point.type)) {
69
+ return [name, street, [clean(address.postcode, 16), city].filter(Boolean).join(' ')].join('\n');
70
+ }
71
+ return [name, city].filter(Boolean).join('\n');
72
+ }
73
+ /**
74
+ * Whether the parcel waits at its pickup point, or was collected there: its
75
+ * last movement before the delivery made it ready for pickup. Notices prove
76
+ * no movement.
77
+ */
78
+ function atPickupPoint(stage, events) {
79
+ if (stage === 'ready_for_pickup')
80
+ return true;
81
+ if (stage !== 'delivered')
82
+ return false;
83
+ return events.find((event) => !['pending', 'delivered'].includes(event.stage ?? ''))?.stage === 'ready_for_pickup';
84
+ }
50
85
  function measurement(raw, units) {
51
86
  if (!isRecord(raw) || typeof raw.unit !== 'string' || !Object.hasOwn(units, raw.unit.toLowerCase()))
52
87
  return null;
@@ -87,17 +122,17 @@ export function parse(payload, trackingNumber) {
87
122
  if (!isRecord(event) || typeof event.eventDescription !== 'string')
88
123
  throw new SchemaError('Posti');
89
124
  const description = clean(event.eventDescription);
125
+ const city = clean(event.city, 160);
90
126
  return {
91
127
  description: [description, clean(event.reasonDescription)].filter(Boolean).join(' '),
92
128
  time: explicitOffsetTime(event.timestamp)?.iso ?? '',
93
- location: clean(event.city, 160),
129
+ location: isPostiAbroadLabel(city) ? '' : city,
94
130
  stage: postiEventStage(description),
95
131
  };
96
132
  }).sort((left, right) => (Date.parse(right.time || '') || 0) - (Date.parse(left.time || '') || 0)).slice(0, 100);
97
133
  const current = postiStatus(hit.status.main, hit.status.subStatus);
98
134
  const measurements = isRecord(hit.measurements) ? hit.measurements : {};
99
135
  const dimensions = ['length', 'width', 'height'].map((field) => measurement(measurements[field], { cm: 1, mm: 0.1, m: 100 }));
100
- const pickup = isRecord(hit.pickupPoint) && isRecord(hit.pickupPoint.address) ? hit.pickupPoint.address : {};
101
136
  return {
102
137
  status: current?.status ?? 'unknown',
103
138
  ...(current ? { current_stage: current.stage } : {}),
@@ -106,7 +141,8 @@ export function parse(payload, trackingNumber) {
106
141
  provider_status: hit.status.main,
107
142
  weight_kg: measurement(measurements.weight, { kg: 1, g: 0.001 }),
108
143
  dimensions_text: dimensions.every((value) => value !== null) ? `${dimensions.join(' × ')} cm` : null,
109
- pickup_point: [clean(pickup.publicName, 160), clean(pickup.city, 80)].filter(Boolean).join(', ') || null,
144
+ // Posti's tracker shows the point at any status, a planned one in transit included.
145
+ pickup_point: atPickupPoint(current?.stage, events) ? pickupPoint(hit.pickupPoint) : null,
110
146
  timezone: 'Europe/Helsinki',
111
147
  events,
112
148
  };
@@ -0,0 +1,8 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ /**
3
+ * Whether a scan's town is Posti's "ULKOMAILLA" ("abroad"), which it gives,
4
+ * untranslated, to scans outside Finland. It names no place, and the public
5
+ * reply carries no country to put in its stead.
6
+ */
7
+ export declare function isPostiAbroadLabel(location: string): boolean;
8
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -0,0 +1,15 @@
1
+ import { matchesDroppedLocation } from '../../core/catalog/locationIdentity.js';
2
+ /**
3
+ * Whether a scan's town is Posti's "ULKOMAILLA" ("abroad"), which it gives,
4
+ * untranslated, to scans outside Finland. It names no place, and the public
5
+ * reply carries no country to put in its stead.
6
+ */
7
+ export function isPostiAbroadLabel(location) {
8
+ return location.replace(/\s+/g, ' ').trim().toLocaleUpperCase('fi-FI') === 'ULKOMAILLA';
9
+ }
10
+ // Scans stored with that label as their location lose it and keep their row.
11
+ export const sameInstantIdentityPolicy = {
12
+ sourceCarrierId: 'posti', storedSources: ['posti'], requireProviderCode: false,
13
+ matchEachScan: true,
14
+ matches: (incoming, stored) => matchesDroppedLocation(incoming, stored, isPostiAbroadLabel),
15
+ };
@@ -5,10 +5,11 @@ import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
5
5
  import { sameInstantIdentityPolicy as dpdFrance } from '../../carriers/dpd-fr/identity.js';
6
6
  import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
7
7
  import { sameInstantIdentityPolicy as mondialRelay } from '../../carriers/mondial-relay/identity.js';
8
+ import { sameInstantIdentityPolicy as posti } from '../../carriers/posti/identity.js';
8
9
  import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
9
10
  import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
10
11
  import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
11
- const policies = new Map([aliexpress, australiaPost, chronopost, dpd, dpdFrance, indiaPost, mondialRelay, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
12
+ const policies = new Map([aliexpress, australiaPost, chronopost, dpd, dpdFrance, indiaPost, mondialRelay, posti, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
12
13
  /** Unlisted sources cannot identify a reworded scan by its instant alone. */
13
14
  export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
14
15
  const policy = policies.get(sourceCarrierId);
@@ -10,6 +10,7 @@
10
10
  import { load } from 'cheerio';
11
11
  import { DateTime } from 'luxon';
12
12
  import timers from 'node:timers/promises';
13
+ import { isPostiAbroadLabel } from '../../carriers/posti/identity.js';
13
14
  import { carrierTimezone } from '../../core/catalog/index.js';
14
15
  import { brandTimeZones, carrierIdFromName, carrierNameCountryZone } from '../../core/catalog/hints.js';
15
16
  import { CarrierError, carrierErrorKind, ChallengeError, IndeterminateError, InputRequiredError, NoHistoryError, SchemaError, UpstreamHttpError, UpstreamNetworkError } from '../../core/errors/index.js';
@@ -236,8 +237,9 @@ function parseHistory(payload, trackingNumber, timezone = null) {
236
237
  }
237
238
  if (parsed) {
238
239
  const location = typeof raw.location === 'string' ? raw.location.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
239
- // Some Chronopost rows put the service label in the location field.
240
- if (location && !/^Type de livraison\s*:/i.test(location))
240
+ // Some Chronopost rows put the service label in the location field, and
241
+ // Posti names scans outside Finland "ULKOMAILLA" (abroad).
242
+ if (location && !/^Type de livraison\s*:/i.test(location) && !isPostiAbroadLabel(location))
241
243
  parsed.location = location;
242
244
  events.push(parsed);
243
245
  scanCarriers.add(name);
@@ -1,6 +1,9 @@
1
- import { matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
1
+ import { matchesDroppedLocation, matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
2
+ import { isPostiAbroadLabel } from '../../carriers/posti/identity.js';
3
+ // A copy may add a place, or drop Posti's "abroad" label it was once stored with.
2
4
  export const sameInstantIdentityPolicy = {
3
5
  sourceCarrierId: 'unknown', storedSources: ['unknown'], requireProviderCode: false,
4
6
  matchEachScan: true,
5
- matches: matchesLocationEnrichment,
7
+ matches: (incoming, stored) => matchesLocationEnrichment(incoming, stored)
8
+ || matchesDroppedLocation(incoming, stored, isPostiAbroadLabel),
6
9
  };
@@ -5,7 +5,10 @@ export declare function numberOf(raw: string): string;
5
5
  export declare function text(value: unknown): string;
6
6
  export declare function isNotice(description: string): boolean;
7
7
  export declare function hasPrivateDeliveryDetails(description: string): boolean;
8
- /** The place a provider reports for a scan, unless it holds private delivery details. */
8
+ /**
9
+ * The place a provider reports for a scan, unless it holds private delivery
10
+ * details or is Posti's "abroad" label.
11
+ */
9
12
  export declare function place(value: unknown): string;
10
13
  export declare function eventStage(description: string): Stage | undefined;
11
14
  /** Retain the decision before delivery wording is redacted or a stage is projected. */
@@ -12,6 +12,7 @@ import { DateTime } from 'luxon';
12
12
  import { trackingLanguageStage } from '../../core/status/index.js';
13
13
  import { deliveryForecastRemainder, nonterminalEnglishReturn } from '../../core/status/language.js';
14
14
  import { IndeterminateError, InvalidInputError } from '../../core/errors/index.js';
15
+ import { isPostiAbroadLabel } from '../../carriers/posti/identity.js';
15
16
  const STAGES = {
16
17
  InfoReceived: 'registered', InTransit: 'in_transit', AvailableForPickup: 'ready_for_pickup',
17
18
  OutForDelivery: 'out_for_delivery', DeliveryFailure: 'failed_attempt', Delivered: 'delivered',
@@ -35,10 +36,13 @@ export function isNotice(description) {
35
36
  export function hasPrivateDeliveryDetails(description) {
36
37
  return /\bpin\s*:|(?:access|security|pickup|collection) code|(?:door|house) (?:no\b|number)|signed (?:for )?by|signature|numero civico|firmato da|signe par|signé par|code (?:de retrait|d['’]acc[eè]s)|num[eé]ro de (?:rue|maison)|abholcode|zugangscode|hausnummer|unterschrieben von|codice (?:di ritiro|di accesso)|c[oó]digo (?:pin|secreto|de (?:recogida|retirada|retiro|acceso|acesso|entrega|seguridad|seguran[cç]a|levantamento|recolha|desbloqueo|desbloqueio|apertura|abertura))|firmad[oa] por|firma del destinatari|assinad[oa] por|assinatura|n[uú]mero (?:de (?:portal|puerta|casa|piso|vivienda|pol[ií]cia)|da (?:porta|casa))|(?:porta|puerta) n\.?\s?[º°]|n\.?\s?[º°] (?:de |da )?porta/i.test(description);
37
38
  }
38
- /** The place a provider reports for a scan, unless it holds private delivery details. */
39
+ /**
40
+ * The place a provider reports for a scan, unless it holds private delivery
41
+ * details or is Posti's "abroad" label.
42
+ */
39
43
  export function place(value) {
40
44
  const location = text(value).slice(0, 200);
41
- return location && !hasPrivateDeliveryDetails(location) ? location : '';
45
+ return location && !hasPrivateDeliveryDetails(location) && !isPostiAbroadLabel(location) ? location : '';
42
46
  }
43
47
  function sourceEventStage(description, includeBroadMovement = true) {
44
48
  // Public aggregators retain the carrier's French wording even in English.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.49.2-main.559",
3
+ "version": "0.49.2-main.562",
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",