universal-parcel-scraper 0.3.0 → 0.3.1-main.312
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/README.md +3 -3
- package/dist/app.d.ts +1 -1
- package/dist/carriers/ctt/adapter.js +11 -15
- package/dist/carriers/inpost/adapter.js +33 -13
- package/dist/carriers/ups/app.d.ts +2 -0
- package/dist/carriers/ups/app.js +15 -0
- package/dist/core/catalog/eventIdentity.d.ts +11 -1
- package/dist/core/catalog/eventIdentity.js +6 -3
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
**Parcel tracking that asks the carrier directly, from your own machine.**
|
|
8
8
|
|
|
9
|
-
The engine behind [Peek](https://github.com/plhery/delivery-tracker), the open-source parcel tracker for iPhone and the web.
|
|
9
|
+
The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/plhery/peek-delivery-tracker)), the open-source parcel tracker for iPhone and the web.
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/universal-parcel-scraper)
|
|
12
12
|
[](https://github.com/plhery/universal-parcel-scraper/actions/workflows/ci.yml)
|
|
@@ -27,7 +27,7 @@ The engine behind [Peek](https://github.com/plhery/delivery-tracker), the open-s
|
|
|
27
27
|
Give it a tracking number. It finds the carrier, fetches the history from the carrier's own
|
|
28
28
|
site and returns the same JSON for every carrier. No account, no API key.
|
|
29
29
|
|
|
30
|
-
It is the tracking engine of [Peek](https://github.com/plhery/delivery-tracker), usable on
|
|
30
|
+
It is the tracking engine of [Peek](https://github.com/plhery/peek-delivery-tracker), usable on
|
|
31
31
|
its own as a command, a Node library or an HTTP server.
|
|
32
32
|
|
|
33
33
|
## Benchmark
|
|
@@ -120,7 +120,7 @@ settings, such as `SCRAPER_TOKEN` and `SCRAPER_TRUSTED_PROXIES`, in [.env.exampl
|
|
|
120
120
|
|
|
121
121
|
## What you can build with it
|
|
122
122
|
|
|
123
|
-
- A parcel-tracking app, like [Peek](https://github.com/plhery/delivery-tracker).
|
|
123
|
+
- A parcel-tracking app, like [Peek](https://github.com/plhery/peek-delivery-tracker).
|
|
124
124
|
- A [Home Assistant sensor](examples/home-assistant.yaml) for the parcel you are waiting on.
|
|
125
125
|
- Order status inside a shop or help desk, from any backend that speaks HTTP.
|
|
126
126
|
- Tracking numbers pulled out of shipping emails: `detect` reads pasted text and links.
|
package/dist/app.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
export * from './core/catalog/parcel.js';
|
|
9
9
|
export * from './core/catalog/hints.js';
|
|
10
10
|
export * from './core/time/result.js';
|
|
11
|
-
export { sameInstantIdentityPolicy, type SameInstantIdentityPolicy } from './core/catalog/eventIdentity.js';
|
|
11
|
+
export { sameInstantIdentityPolicy, type SameInstantIdentityPolicy, type SameInstantScan } from './core/catalog/eventIdentity.js';
|
|
12
12
|
export { universalCarrierHints } from './providers/shared/hints.js';
|
|
13
13
|
export { recognitionAskedCarriers } from './core/catalog/recognition.js';
|
|
14
14
|
export { countryFlag, countryName, trackingLocationCountry, trackingPlace, type TrackingPlace } from './places/trackingLocation.js';
|
|
@@ -2,7 +2,7 @@ import { lookupBudget } from '../../core/adapter/index.js';
|
|
|
2
2
|
import { IndeterminateError, InvalidInputError, MaintenanceError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
3
|
import { isValidS10TrackingNumber } from '../../core/detection/index.js';
|
|
4
4
|
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
5
|
-
import { clean, decodeText, escapeRegExp, fetchBounded, parseJsonBytes,
|
|
5
|
+
import { clean, decodeText, escapeRegExp, fetchBounded, parseJsonBytes, } from '../../core/transport/index.js';
|
|
6
6
|
import { isRecord } from '../../core/types.js';
|
|
7
7
|
import { classifyCttStatus } from './status.js';
|
|
8
8
|
// Protocol provenance:
|
|
@@ -17,8 +17,7 @@ import { classifyCttStatus } from './status.js';
|
|
|
17
17
|
// rotates on every frontend deploy (derived at runtime from keyless version
|
|
18
18
|
// endpoints and the screen bundle — never pinned). A browser User-Agent is
|
|
19
19
|
// mandatory (Cloudflare error 1010 otherwise).
|
|
20
|
-
// -
|
|
21
|
-
// history mapped: Found:true carries ObjectEventsFromQuery; Found:false is
|
|
20
|
+
// - Found:true carries ObjectEventsFromQuery; Found:false is
|
|
22
21
|
// BOTH genuine unknown and backend outage, told apart only via the sibling
|
|
23
22
|
// DataActionCheckIPLocked call (made solely on Found:false — a found parcel
|
|
24
23
|
// already proves health).
|
|
@@ -178,7 +177,10 @@ export class CttTracker {
|
|
|
178
177
|
IPClient: '',
|
|
179
178
|
}, null, true, true, budget);
|
|
180
179
|
const record = isRecord(payload.data) ? payload.data.ObjectEventsFromQuery : undefined;
|
|
181
|
-
if (!isRecord(record) ||
|
|
180
|
+
if (!isRecord(record) || typeof record.Found !== 'boolean') {
|
|
181
|
+
throw new CttApiError('missing shipment Found flag');
|
|
182
|
+
}
|
|
183
|
+
if (!record.Found) {
|
|
182
184
|
// Found:false is both genuine unknown and backend outage: a found parcel
|
|
183
185
|
// already proves health, so the sibling check runs solely on negatives.
|
|
184
186
|
if (await this.isMaintenance(budget))
|
|
@@ -190,6 +192,8 @@ export class CttTracker {
|
|
|
190
192
|
async isMaintenance(budget) {
|
|
191
193
|
const payload = await this.callAction(MAINTENANCE_ACTION, {}, null, true, true, budget);
|
|
192
194
|
const data = isRecord(payload.data) ? payload.data : {};
|
|
195
|
+
if (typeof data.IsMaintenance !== 'boolean')
|
|
196
|
+
throw new CttApiError('missing maintenance flag');
|
|
193
197
|
return data.IsMaintenance === true;
|
|
194
198
|
}
|
|
195
199
|
async callAction(action, variables, session, retrySession, retryVersion, budget) {
|
|
@@ -217,7 +221,7 @@ export class CttTracker {
|
|
|
217
221
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
218
222
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
219
223
|
retryTransient: true,
|
|
220
|
-
|
|
224
|
+
allowHttpStatuses: [403],
|
|
221
225
|
fetcher: this.fetcher,
|
|
222
226
|
});
|
|
223
227
|
if (response.status === 403) {
|
|
@@ -227,8 +231,6 @@ export class CttTracker {
|
|
|
227
231
|
}
|
|
228
232
|
throw new CttApiError('anonymous session bootstrap failed');
|
|
229
233
|
}
|
|
230
|
-
if (!response.ok)
|
|
231
|
-
throw new UpstreamHttpError('CTT tracking', response.status);
|
|
232
234
|
const payload = parseJsonBytes(bytes, 'CTT tracking');
|
|
233
235
|
if (!isRecord(payload))
|
|
234
236
|
throw new CttApiError('unexpected body (not a JSON object)');
|
|
@@ -256,7 +258,7 @@ export class CttTracker {
|
|
|
256
258
|
async ensureModuleVersion(budget) {
|
|
257
259
|
if (this.moduleVersion !== null)
|
|
258
260
|
return;
|
|
259
|
-
const {
|
|
261
|
+
const { bytes } = await fetchBounded(MODULE_VERSION_URL, {
|
|
260
262
|
signal: budget.signal,
|
|
261
263
|
headers: { Accept: 'application/json', 'User-Agent': BROWSER_USER_AGENT },
|
|
262
264
|
}, {
|
|
@@ -264,11 +266,8 @@ export class CttTracker {
|
|
|
264
266
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
265
267
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
266
268
|
retryTransient: true,
|
|
267
|
-
allowHttpError: true,
|
|
268
269
|
fetcher: this.fetcher,
|
|
269
270
|
});
|
|
270
|
-
if (!response.ok)
|
|
271
|
-
throw new UpstreamHttpError('CTT tracking', response.status);
|
|
272
271
|
const payload = parseJsonBytes(bytes, 'CTT tracking');
|
|
273
272
|
const token = isRecord(payload) && typeof payload.versionToken === 'string' ? payload.versionToken : '';
|
|
274
273
|
if (!token)
|
|
@@ -297,7 +296,7 @@ export class CttTracker {
|
|
|
297
296
|
return parseJsonBytes((await this.fetchBytes(url, budget)).bytes, 'CTT tracking');
|
|
298
297
|
}
|
|
299
298
|
async fetchBytes(url, budget) {
|
|
300
|
-
const {
|
|
299
|
+
const { bytes } = await fetchBounded(url, {
|
|
301
300
|
signal: budget.signal,
|
|
302
301
|
headers: { Accept: 'application/json, text/plain, */*', 'User-Agent': BROWSER_USER_AGENT },
|
|
303
302
|
}, {
|
|
@@ -305,11 +304,8 @@ export class CttTracker {
|
|
|
305
304
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
306
305
|
maxBytes: MAX_SCRIPT_BYTES,
|
|
307
306
|
retryTransient: true,
|
|
308
|
-
allowHttpError: true,
|
|
309
307
|
fetcher: this.fetcher,
|
|
310
308
|
});
|
|
311
|
-
if (!response.ok)
|
|
312
|
-
throw new UpstreamHttpError('CTT tracking', response.status);
|
|
313
309
|
return { bytes };
|
|
314
310
|
}
|
|
315
311
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { accepted, lookupBudget, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
-
import { InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
2
|
+
import { IndeterminateError, InvalidInputError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
3
3
|
import { explicitOffsetTime } from '../../core/time/index.js';
|
|
4
|
-
import { clean, fetchBounded, parseJsonBytes,
|
|
4
|
+
import { clean, fetchBounded, parseJsonBytes, userAgentOf } from '../../core/transport/index.js';
|
|
5
5
|
import { isRecord } from '../../core/types.js';
|
|
6
6
|
import { classifyInpostStatus } from './status.js';
|
|
7
7
|
// Protocol provenance:
|
|
@@ -12,12 +12,9 @@ import { classifyInpostStatus } from './status.js';
|
|
|
12
12
|
// (api-shipx-pl.easypack24.net, keyless, but its success shape is
|
|
13
13
|
// unconfirmed) and the inposteasy.com per-country hubs below. Only the
|
|
14
14
|
// inposteasy hub is implemented here; ShipX remains a future lead.
|
|
15
|
-
// -
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// headers or account. Long-expired corpus numbers return the same 404.
|
|
19
|
-
// - Public cross-border status vocabulary live-confirmed on IT/PT/GB
|
|
20
|
-
// consignments 2026-08-31 by the prior-art client; the map lives in status.ts.
|
|
15
|
+
// - Unknown or expired numbers return an identity-bound NOT_FOUND problem,
|
|
16
|
+
// optionally JSON-encoded inside the tracking-error wrapper's detail field.
|
|
17
|
+
// - The public cross-border status vocabulary lives in status.ts.
|
|
21
18
|
const TRACKING_ENDPOINT = 'https://inposteasy.com/api/tracking';
|
|
22
19
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
23
20
|
/** The pause `fetchBounded` takes before its one retry of a failed request. */
|
|
@@ -34,6 +31,23 @@ export function normalizeInpostTrackingNumber(raw) {
|
|
|
34
31
|
}
|
|
35
32
|
return value;
|
|
36
33
|
}
|
|
34
|
+
function isNotFoundProblem(payload, trackingNumber) {
|
|
35
|
+
if (!isRecord(payload) || payload.status !== 404
|
|
36
|
+
|| payload.instance !== `/api/tracking/${trackingNumber}`)
|
|
37
|
+
return false;
|
|
38
|
+
if (payload.type === '/errors/external/not-found' && payload.title === 'NOT_FOUND')
|
|
39
|
+
return true;
|
|
40
|
+
if (payload.type !== '/errors/external/tracking-error' || typeof payload.detail !== 'string')
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
const nested = JSON.parse(payload.detail.replace(/^Tracking client failed\. /, ''));
|
|
44
|
+
return isRecord(nested) && nested.type === '/errors/external/not-found'
|
|
45
|
+
&& nested.title === 'NOT_FOUND' && nested.status === 404 && nested.instance === payload.instance;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
37
51
|
export function parseInpostTrackingResponse(payload, trackingNumber) {
|
|
38
52
|
const requested = normalizeInpostTrackingNumber(trackingNumber);
|
|
39
53
|
if (!isRecord(payload))
|
|
@@ -129,13 +143,19 @@ export class InpostTracker {
|
|
|
129
143
|
timeoutMs: Math.min(this.timeoutMs, budget.remainingMs()),
|
|
130
144
|
maxBytes: MAX_RESPONSE_BYTES,
|
|
131
145
|
retryTransient: true,
|
|
132
|
-
|
|
146
|
+
allowHttpStatuses: [404],
|
|
133
147
|
fetcher: this.fetcher,
|
|
134
148
|
});
|
|
135
|
-
if (response.status === 404)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
149
|
+
if (response.status === 404) {
|
|
150
|
+
let problem;
|
|
151
|
+
try {
|
|
152
|
+
problem = parseJsonBytes(bytes, 'InPost tracking');
|
|
153
|
+
}
|
|
154
|
+
catch { /* An HTML error page proves no shipment outcome. */ }
|
|
155
|
+
if (isNotFoundProblem(problem, trackingNumber))
|
|
156
|
+
throw new NotFoundError('InPost');
|
|
157
|
+
throw new IndeterminateError('InPost', 'InPost returned an unrecognized not-found response');
|
|
158
|
+
}
|
|
139
159
|
return parseInpostTrackingResponse(parseJsonBytes(bytes, 'InPost tracking'), trackingNumber);
|
|
140
160
|
}
|
|
141
161
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function normalized(value) {
|
|
2
|
+
return value.replace(/\s+/g, ' ').trim().toLocaleLowerCase('en-US');
|
|
3
|
+
}
|
|
4
|
+
// UPS can add a location later. Its wording distinguishes scans sharing a clock.
|
|
5
|
+
export const sameInstantIdentityPolicy = {
|
|
6
|
+
sourceCarrierId: 'ups', storedSources: ['ups'], requireProviderCode: false,
|
|
7
|
+
matches(incoming, stored) {
|
|
8
|
+
const wording = normalized(incoming.description);
|
|
9
|
+
const location = normalized(incoming.location);
|
|
10
|
+
const savedLocation = normalized(stored.location);
|
|
11
|
+
return incoming.stage !== '' && incoming.stage !== 'unknown' && incoming.stage === stored.stage
|
|
12
|
+
&& wording !== '' && wording === normalized(stored.description)
|
|
13
|
+
&& (!location || !savedLocation || location === savedLocation);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -1,8 +1,18 @@
|
|
|
1
|
+
/** Scan evidence available to the app before it reuses a stored identity. */
|
|
2
|
+
export interface SameInstantScan {
|
|
3
|
+
readonly stage: string;
|
|
4
|
+
readonly description: string;
|
|
5
|
+
readonly location: string;
|
|
6
|
+
readonly providerCode: string;
|
|
7
|
+
}
|
|
1
8
|
/** Carrier evidence the parcel app uses when updating a stored scan in place. */
|
|
2
9
|
export interface SameInstantIdentityPolicy {
|
|
3
10
|
readonly sourceCarrierId: string;
|
|
4
11
|
readonly storedSources: readonly string[];
|
|
5
12
|
readonly requireProviderCode: boolean;
|
|
13
|
+
readonly matches?: (incoming: SameInstantScan, stored: SameInstantScan) => boolean;
|
|
6
14
|
}
|
|
7
15
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
8
|
-
export declare function sameInstantIdentityPolicy(sourceCarrierId: string
|
|
16
|
+
export declare function sameInstantIdentityPolicy(sourceCarrierId: string, options?: {
|
|
17
|
+
supportsScanMatching?: boolean;
|
|
18
|
+
}): SameInstantIdentityPolicy | undefined;
|
|
@@ -1,7 +1,10 @@
|
|
|
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
|
+
const policies = new Map([dpd, indiaPost, ups].map((policy) => [policy.sourceCarrierId, policy]));
|
|
4
5
|
/** Unlisted sources cannot identify a reworded scan by its instant alone. */
|
|
5
|
-
export function sameInstantIdentityPolicy(sourceCarrierId) {
|
|
6
|
-
|
|
6
|
+
export function sameInstantIdentityPolicy(sourceCarrierId, options = {}) {
|
|
7
|
+
const policy = policies.get(sourceCarrierId);
|
|
8
|
+
// Older app versions only check the clock and provider code.
|
|
9
|
+
return policy?.matches && !options.supportsScanMatching ? undefined : policy;
|
|
7
10
|
}
|
package/dist/server/openapi.json
CHANGED
package/package.json
CHANGED