universal-parcel-scraper 0.49.2-main.559 → 0.49.2-main.561
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.
- package/dist/carriers/posti/adapter.js +29 -5
- package/dist/carriers/posti/identity.d.ts +8 -0
- package/dist/carriers/posti/identity.js +15 -0
- package/dist/core/catalog/eventIdentity.js +2 -1
- package/dist/providers/parcelsapp/adapter.js +4 -2
- package/dist/providers/shared/app.js +5 -2
- package/dist/providers/shared/result.d.ts +4 -1
- package/dist/providers/shared/result.js +6 -2
- package/package.json +1 -1
|
@@ -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
|
|
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,27 @@ 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
|
+
}
|
|
50
73
|
function measurement(raw, units) {
|
|
51
74
|
if (!isRecord(raw) || typeof raw.unit !== 'string' || !Object.hasOwn(units, raw.unit.toLowerCase()))
|
|
52
75
|
return null;
|
|
@@ -87,17 +110,17 @@ export function parse(payload, trackingNumber) {
|
|
|
87
110
|
if (!isRecord(event) || typeof event.eventDescription !== 'string')
|
|
88
111
|
throw new SchemaError('Posti');
|
|
89
112
|
const description = clean(event.eventDescription);
|
|
113
|
+
const city = clean(event.city, 160);
|
|
90
114
|
return {
|
|
91
115
|
description: [description, clean(event.reasonDescription)].filter(Boolean).join(' '),
|
|
92
116
|
time: explicitOffsetTime(event.timestamp)?.iso ?? '',
|
|
93
|
-
location:
|
|
117
|
+
location: isPostiAbroadLabel(city) ? '' : city,
|
|
94
118
|
stage: postiEventStage(description),
|
|
95
119
|
};
|
|
96
120
|
}).sort((left, right) => (Date.parse(right.time || '') || 0) - (Date.parse(left.time || '') || 0)).slice(0, 100);
|
|
97
121
|
const current = postiStatus(hit.status.main, hit.status.subStatus);
|
|
98
122
|
const measurements = isRecord(hit.measurements) ? hit.measurements : {};
|
|
99
123
|
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
124
|
return {
|
|
102
125
|
status: current?.status ?? 'unknown',
|
|
103
126
|
...(current ? { current_stage: current.stage } : {}),
|
|
@@ -106,7 +129,8 @@ export function parse(payload, trackingNumber) {
|
|
|
106
129
|
provider_status: hit.status.main,
|
|
107
130
|
weight_kg: measurement(measurements.weight, { kg: 1, g: 0.001 }),
|
|
108
131
|
dimensions_text: dimensions.every((value) => value !== null) ? `${dimensions.join(' × ')} cm` : null,
|
|
109
|
-
|
|
132
|
+
// Posti's tracker shows the point at any status, a planned one in transit included.
|
|
133
|
+
pickup_point: current?.stage === 'ready_for_pickup' ? pickupPoint(hit.pickupPoint) : null,
|
|
110
134
|
timezone: 'Europe/Helsinki',
|
|
111
135
|
events,
|
|
112
136
|
};
|
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
3
|
+
"version": "0.49.2-main.561",
|
|
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",
|