universal-parcel-scraper 0.7.0-main.326 → 0.7.2-main.328
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/india-post/adapter.js +19 -10
- package/dist/carriers/swiss-post/adapter.js +8 -2
- package/dist/carriers/swiss-post/app.d.ts +2 -0
- package/dist/carriers/swiss-post/app.js +6 -0
- package/dist/core/catalog/eventIdentity.d.ts +2 -0
- package/dist/core/catalog/eventIdentity.js +3 -1
- package/dist/core/catalog/locationIdentity.d.ts +3 -0
- package/dist/core/catalog/locationIdentity.js +10 -0
- package/dist/providers/parcelsapp/adapter.js +4 -0
- package/dist/providers/shared/app.d.ts +2 -0
- package/dist/providers/shared/app.js +6 -0
- package/dist/providers/shared/result.js +8 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
|
@@ -116,16 +116,23 @@ const AIRPORT_PLACES = {
|
|
|
116
116
|
FRA: { name: 'Frankfurt Airport', country: 'Germany' },
|
|
117
117
|
CDG: { name: 'Paris Charles de Gaulle Airport', country: 'France' },
|
|
118
118
|
};
|
|
119
|
-
function
|
|
119
|
+
function officeAirport(office) {
|
|
120
120
|
return /^Office - ([A-Z]{3})\b/.exec(office)?.[1];
|
|
121
121
|
}
|
|
122
122
|
/** Flight remarks have a narrow format; unrelated remarks can contain recipient details. */
|
|
123
|
-
function
|
|
123
|
+
function flightDetails(providerCode, remarks, office) {
|
|
124
|
+
if (providerCode !== TAKE_OFF_CODE && providerCode !== 'MailArrived')
|
|
125
|
+
return null;
|
|
124
126
|
const match = /^Flight No:\s*([A-Z0-9]{2}\d{1,4}[A-Z]?)\s*\(From ([A-Z]{3}) To ([A-Z]{3})\)$/.exec(clean(remarks, 500));
|
|
125
|
-
if (!match || match[2] !==
|
|
126
|
-
return
|
|
127
|
+
if (!match || match[2] !== officeAirport(office))
|
|
128
|
+
return null;
|
|
127
129
|
const airport = (code) => AIRPORT_PLACES[code] ? `${AIRPORT_PLACES[code].name} (${code})` : code;
|
|
128
|
-
|
|
130
|
+
const departed = providerCode === TAKE_OFF_CODE;
|
|
131
|
+
// MailArrived retains the sending office. Its flight route names the arrival airport.
|
|
132
|
+
return {
|
|
133
|
+
description: `Flight ${match[1]} ${departed ? 'departed' : 'arrived'}: ${airport(match[2])} → ${airport(match[3])}`,
|
|
134
|
+
airport: match[departed ? 2 : 3],
|
|
135
|
+
};
|
|
129
136
|
}
|
|
130
137
|
/**
|
|
131
138
|
* The zone a take-off row's clock is kept in. Its `tracked_at` is the
|
|
@@ -138,7 +145,7 @@ function flightDescription(remarks, office) {
|
|
|
138
145
|
function takeOffZone(providerCode, office, trackedAt) {
|
|
139
146
|
if (providerCode !== TAKE_OFF_CODE || !UTC_LABEL.test(trackedAt))
|
|
140
147
|
return null;
|
|
141
|
-
const airport =
|
|
148
|
+
const airport = officeAirport(office);
|
|
142
149
|
return airport ? AIRPORT_ZONES[airport] ?? null : null;
|
|
143
150
|
}
|
|
144
151
|
/**
|
|
@@ -191,14 +198,15 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
|
|
|
191
198
|
? mislabeledLocalTime(rawEvent.tracked_at, zone, 100)
|
|
192
199
|
: isoTime(rawEvent.tracked_at, 'Asia/Kolkata', 100);
|
|
193
200
|
const takeOff = providerCode === TAKE_OFF_CODE;
|
|
194
|
-
const
|
|
201
|
+
const flight = flightDetails(providerCode, rawEvent.remarks, office);
|
|
202
|
+
const description = flight?.description ?? (takeOff ? 'Aircraft Departure' : eventText(clean(rawEvent.event)));
|
|
195
203
|
if (!time || !description)
|
|
196
204
|
return;
|
|
197
205
|
const pincode = /^\d{6}$/.test(clean(rawEvent.pincode, 6))
|
|
198
206
|
? clean(rawEvent.pincode, 6)
|
|
199
207
|
: '';
|
|
200
|
-
const
|
|
201
|
-
const
|
|
208
|
+
const airportCode = flight?.airport ?? (takeOff ? officeAirport(office) : undefined);
|
|
209
|
+
const point = airportCode ? null : officePoint(rawEvent, office);
|
|
202
210
|
const airport = airportCode ? AIRPORT_PLACES[airportCode] : undefined;
|
|
203
211
|
const identity = JSON.stringify([time.iso, description, office, pincode, providerCode]);
|
|
204
212
|
if (seen.has(identity))
|
|
@@ -211,7 +219,8 @@ export function parseIndiaPostTrackingHtml(html, trackingNumber) {
|
|
|
211
219
|
// deliberately never retained.
|
|
212
220
|
event: {
|
|
213
221
|
time: time.iso,
|
|
214
|
-
location: airport ? `${airport.name} (${airportCode}), ${airport.country}`
|
|
222
|
+
location: airport ? `${airport.name} (${airportCode}), ${airport.country}`
|
|
223
|
+
: flight && !takeOff ? `${flight.airport} Airport` : [office, pincode].filter(Boolean).join(' '),
|
|
215
224
|
description,
|
|
216
225
|
stage: classified.stage,
|
|
217
226
|
...(providerCode ? { provider_code: providerCode } : {}),
|
|
@@ -3,6 +3,7 @@ import makeFetchCookie from 'fetch-cookie';
|
|
|
3
3
|
import { CookieJar } from 'tough-cookie';
|
|
4
4
|
import { lookupBudget } from '../../core/adapter/index.js';
|
|
5
5
|
import { NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
6
|
+
import { countryCode } from '../../core/time/index.js';
|
|
6
7
|
import { fetchBounded, parseJsonBytes } from '../../core/transport/index.js';
|
|
7
8
|
import { isRecord } from '../../core/types.js';
|
|
8
9
|
import { FALLBACK_EVENT_LABELS, STAGE_STATUS, STATUS_MAP, swissPostEventStage } from './status.js';
|
|
@@ -117,9 +118,14 @@ function eventDescription(event, translations, internationalType) {
|
|
|
117
118
|
const $ = load(`<span>${value}</span>`);
|
|
118
119
|
return $('span').text().trim().slice(0, 500) || 'Tracking update';
|
|
119
120
|
}
|
|
120
|
-
/** Operational scan location
|
|
121
|
+
/** Operational scan location: city and postcode, or an explicit country when city is absent. */
|
|
121
122
|
function eventLocation(event) {
|
|
122
|
-
|
|
123
|
+
const city = text(event.city, 100);
|
|
124
|
+
const postcode = text(event.zip, 30);
|
|
125
|
+
const country = countryCode(event.country);
|
|
126
|
+
if (!city)
|
|
127
|
+
return country && country !== 'ZZ' ? country : postcode;
|
|
128
|
+
return [city, postcode].filter(Boolean).join(' ').slice(0, 160);
|
|
123
129
|
}
|
|
124
130
|
/**
|
|
125
131
|
* Local ordering policy: event timestamps are read only to sort. Values without
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
export const sameInstantIdentityPolicy = {
|
|
3
|
+
sourceCarrierId: 'swiss-post', storedSources: ['swiss-post'], requireProviderCode: true,
|
|
4
|
+
matchEachScan: true,
|
|
5
|
+
matches: matchesLocationEnrichment,
|
|
6
|
+
};
|
|
@@ -10,6 +10,8 @@ export interface SameInstantIdentityPolicy {
|
|
|
10
10
|
readonly sourceCarrierId: string;
|
|
11
11
|
readonly storedSources: readonly string[];
|
|
12
12
|
readonly requireProviderCode: boolean;
|
|
13
|
+
/** Match distinct scans sharing an instant by their evidence, with unique matches in both directions. */
|
|
14
|
+
readonly matchEachScan?: boolean;
|
|
13
15
|
readonly matches?: (incoming: SameInstantScan, stored: SameInstantScan) => boolean;
|
|
14
16
|
}
|
|
15
17
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { sameInstantIdentityPolicy as dpd } from '../../carriers/dpd/app.js';
|
|
2
2
|
import { sameInstantIdentityPolicy as indiaPost } from '../../carriers/india-post/app.js';
|
|
3
3
|
import { sameInstantIdentityPolicy as ups } from '../../carriers/ups/app.js';
|
|
4
|
-
|
|
4
|
+
import { sameInstantIdentityPolicy as swissPost } from '../../carriers/swiss-post/app.js';
|
|
5
|
+
import { sameInstantIdentityPolicy as universal } from '../../providers/shared/app.js';
|
|
6
|
+
const policies = new Map([dpd, indiaPost, ups, swissPost, universal].map((policy) => [policy.sourceCarrierId, policy]));
|
|
5
7
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
6
8
|
export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
|
|
7
9
|
const policy = policies.get(sourceCarrierId);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const normalized = (value) => value.replace(/\s+/g, ' ').trim().toLocaleLowerCase('en-US');
|
|
2
|
+
/** Adding a place must keep the scan's wording and known stage; conflicting places do not match. */
|
|
3
|
+
export function matchesLocationEnrichment(incoming, stored) {
|
|
4
|
+
const wording = normalized(incoming.description);
|
|
5
|
+
const location = normalized(incoming.location);
|
|
6
|
+
const savedLocation = normalized(stored.location);
|
|
7
|
+
return Boolean(location && wording) && !['', 'unknown'].includes(incoming.stage)
|
|
8
|
+
&& incoming.stage === stored.stage && wording === normalized(stored.description)
|
|
9
|
+
&& (!savedLocation || location === savedLocation);
|
|
10
|
+
}
|
|
@@ -221,6 +221,10 @@ function parseHistory(payload, trackingNumber, timezone = null) {
|
|
|
221
221
|
continue;
|
|
222
222
|
}
|
|
223
223
|
if (parsed) {
|
|
224
|
+
const location = typeof raw.location === 'string' ? raw.location.replace(/\s+/g, ' ').trim().slice(0, 200) : '';
|
|
225
|
+
// Some Chronopost rows put the service label in the location field.
|
|
226
|
+
if (location && !/^Type de livraison\s*:/i.test(location))
|
|
227
|
+
parsed.location = location;
|
|
224
228
|
events.push(parsed);
|
|
225
229
|
scanCarriers.add(name);
|
|
226
230
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { matchesLocationEnrichment } from '../../core/catalog/locationIdentity.js';
|
|
2
|
+
export const sameInstantIdentityPolicy = {
|
|
3
|
+
sourceCarrierId: 'unknown', storedSources: ['unknown'], requireProviderCode: false,
|
|
4
|
+
matchEachScan: true,
|
|
5
|
+
matches: matchesLocationEnrichment,
|
|
6
|
+
};
|
|
@@ -163,7 +163,14 @@ function moment(value) {
|
|
|
163
163
|
export function result(events, source, preserveOrder = false) {
|
|
164
164
|
// Wall times sort alongside UTC instants only approximately; providers that
|
|
165
165
|
// omit offsets everywhere pass preserveOrder instead.
|
|
166
|
-
const
|
|
166
|
+
const scans = new Map();
|
|
167
|
+
for (const event of events) {
|
|
168
|
+
const key = `${moment(event)}|${event.description}`;
|
|
169
|
+
const earlier = scans.get(key);
|
|
170
|
+
// A carrier's copy can lack the place another copy of the same scan reported.
|
|
171
|
+
scans.set(key, !event.location && earlier?.location ? { ...event, location: earlier.location } : event);
|
|
172
|
+
}
|
|
173
|
+
const unique = [...scans.values()]
|
|
167
174
|
.sort((a, b) => preserveOrder ? 0 : moment(b).localeCompare(moment(a))).slice(0, 100);
|
|
168
175
|
// A well-formed reply with no scan left after notices and private details proves nothing about the parcel.
|
|
169
176
|
if (!unique.length)
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-parcel-scraper",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2-main.328",
|
|
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",
|