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 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
  [![npm](https://img.shields.io/npm/v/universal-parcel-scraper)](https://www.npmjs.com/package/universal-parcel-scraper)
12
12
  [![CI](https://github.com/plhery/universal-parcel-scraper/actions/workflows/ci.yml/badge.svg)](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, UpstreamHttpError, } from '../../core/transport/index.js';
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
- // - Live verification 2026-09-10, including a delivered parcel whose whole
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) || !record.Found) {
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
- allowHttpError: true,
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 { response, bytes } = await fetchBounded(MODULE_VERSION_URL, {
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 { response, bytes } = await fetchBounded(url, {
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, UpstreamHttpError, userAgentOf } from '../../core/transport/index.js';
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
- // - Live verification 2026-09-10: GET
16
- // https://inposteasy.com/api/tracking/000000000000000000000000 returns
17
- // HTTP 404 with a structured NOT_FOUND problem body in ~0.2s, no cookies,
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
- allowHttpError: true,
146
+ allowHttpStatuses: [404],
133
147
  fetcher: this.fetcher,
134
148
  });
135
- if (response.status === 404)
136
- throw new NotFoundError('InPost');
137
- if (!response.ok)
138
- throw new UpstreamHttpError('InPost tracking', response.status);
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,2 @@
1
+ import type { SameInstantIdentityPolicy } from '../../core/catalog/eventIdentity.js';
2
+ export declare const sameInstantIdentityPolicy: SameInstantIdentityPolicy;
@@ -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): SameInstantIdentityPolicy | undefined;
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
- const policies = new Map([dpd, indiaPost].map((policy) => [policy.sourceCarrierId, policy]));
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
- return policies.get(sourceCarrierId);
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
  }
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.3.0"
5
+ "version": "0.3.1"
6
6
  },
7
7
  "paths": {
8
8
  "/health": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "universal-parcel-scraper",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-main.312",
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",