universal-parcel-scraper 0.96.0-main.994 → 0.97.0-main.997
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 +2 -2
- package/data/catalog.json +15 -2
- package/data/coverage-summary.json +1 -1
- package/data/detection-golden.json +4 -0
- package/dist/browser/scraper.js +10 -2
- package/dist/carriers/apc-overnight/adapter.d.ts +14 -0
- package/dist/carriers/apc-overnight/adapter.js +53 -0
- package/dist/carriers/apc-overnight/carrier.json +55 -8
- package/dist/carriers/apc-overnight/numbers.json +7 -7
- package/dist/carriers/apc-overnight/parser.d.ts +17 -0
- package/dist/carriers/apc-overnight/parser.js +166 -0
- package/dist/carriers/apc-overnight/status.d.ts +12 -0
- package/dist/carriers/apc-overnight/status.js +44 -0
- package/dist/carriers/apc-overnight/statuses.json +10 -1
- package/dist/core/catalog/statusMaps.js +2 -1
- package/dist/data/catalog.json +15 -2
- package/dist/generated/catalog.d.ts +9 -2
- package/dist/generated/catalog.js +15 -2
- package/dist/generated/recognition.js +1 -0
- package/dist/generated/registry.js +3 -1
- package/dist/server/openapi.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ The engine behind [Peek](https://peektracker.com) ([GitHub](https://github.com/p
|
|
|
13
13
|
[](LICENSE)
|
|
14
14
|
|
|
15
15
|
<!-- GENERATED:summary -->
|
|
16
|
-
**3,500+ carriers** through **
|
|
16
|
+
**3,500+ carriers** through **156 dedicated adapters** and **5 universal fallbacks**
|
|
17
17
|
|
|
18
18
|
<sub>208 catalog entries · 66 countries represented</sub>
|
|
19
19
|
<!-- /GENERATED:summary -->
|
|
@@ -155,7 +155,7 @@ createTracker({ providers: ['ParcelsApp', 'Ship24', '17TRACK', 'Postal Ninja', '
|
|
|
155
155
|
<!-- GENERATED:stages -->
|
|
156
156
|
<img src="docs/assets/stages.svg" alt="DHL: Die Sendung wurde in das Zustellfahrzeug geladen.; Mondial Relay: En cours de livraison; Correios Brazil: Objeto saiu para entrega ao destinatário; Correos Express: EN REPARTO; Yamato Transport: 配達中; La Poste / Colissimo: DISTOU. All are filed under out_for_delivery." width="760">
|
|
157
157
|
|
|
158
|
-
The carrier folders record 4,
|
|
158
|
+
The carrier folders record 4,357 such statuses, each filed under one stage.
|
|
159
159
|
<!-- /GENERATED:stages -->
|
|
160
160
|
|
|
161
161
|
Wording nobody recorded yet goes through a classifier that reads seven European languages.
|
package/data/catalog.json
CHANGED
|
@@ -4852,11 +4852,24 @@
|
|
|
4852
4852
|
"timezone": "Europe/London",
|
|
4853
4853
|
"tracking": {
|
|
4854
4854
|
"mode": "automatic",
|
|
4855
|
-
"adapter": "
|
|
4855
|
+
"adapter": "apc-overnight",
|
|
4856
|
+
"recognitionRank": 870,
|
|
4857
|
+
"localClocks": true
|
|
4856
4858
|
},
|
|
4857
4859
|
"canaryUrl": "https://apc.co.uk/",
|
|
4860
|
+
"trackingUrlTemplate": "https://apcchoice.apc-overnight.com/track-parcel?id={trackingNumber}",
|
|
4858
4861
|
"trackingSearchUrl": "https://apc.co.uk/",
|
|
4859
|
-
"linkRules": [
|
|
4862
|
+
"linkRules": [
|
|
4863
|
+
{
|
|
4864
|
+
"domains": [
|
|
4865
|
+
"apcchoice.apc-overnight.com"
|
|
4866
|
+
],
|
|
4867
|
+
"pathPattern": "^/track-parcel/?$",
|
|
4868
|
+
"params": [
|
|
4869
|
+
"id"
|
|
4870
|
+
]
|
|
4871
|
+
}
|
|
4872
|
+
],
|
|
4860
4873
|
"detectionRules": [
|
|
4861
4874
|
{
|
|
4862
4875
|
"pattern": "^20[12]\\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\d{14}$",
|
|
@@ -9600,6 +9600,7 @@
|
|
|
9600
9600
|
"apc-overnight"
|
|
9601
9601
|
],
|
|
9602
9602
|
"asked": [
|
|
9603
|
+
"apc-overnight",
|
|
9603
9604
|
"austrian-post"
|
|
9604
9605
|
]
|
|
9605
9606
|
},
|
|
@@ -9663,6 +9664,7 @@
|
|
|
9663
9664
|
"apc-overnight"
|
|
9664
9665
|
],
|
|
9665
9666
|
"asked": [
|
|
9667
|
+
"apc-overnight",
|
|
9666
9668
|
"austrian-post"
|
|
9667
9669
|
]
|
|
9668
9670
|
},
|
|
@@ -9686,6 +9688,7 @@
|
|
|
9686
9688
|
"apc-overnight"
|
|
9687
9689
|
],
|
|
9688
9690
|
"asked": [
|
|
9691
|
+
"apc-overnight",
|
|
9689
9692
|
"austrian-post"
|
|
9690
9693
|
]
|
|
9691
9694
|
},
|
|
@@ -9703,6 +9706,7 @@
|
|
|
9703
9706
|
"apc-overnight"
|
|
9704
9707
|
],
|
|
9705
9708
|
"asked": [
|
|
9709
|
+
"apc-overnight",
|
|
9706
9710
|
"austrian-post"
|
|
9707
9711
|
]
|
|
9708
9712
|
},
|
package/dist/browser/scraper.js
CHANGED
|
@@ -3976,11 +3976,18 @@ var be = {
|
|
|
3976
3976
|
timezone: "Europe/London",
|
|
3977
3977
|
tracking: {
|
|
3978
3978
|
mode: "automatic",
|
|
3979
|
-
adapter: "
|
|
3979
|
+
adapter: "apc-overnight",
|
|
3980
|
+
recognitionRank: 870,
|
|
3981
|
+
localClocks: !0
|
|
3980
3982
|
},
|
|
3981
3983
|
canaryUrl: "https://apc.co.uk/",
|
|
3984
|
+
trackingUrlTemplate: "https://apcchoice.apc-overnight.com/track-parcel?id={trackingNumber}",
|
|
3982
3985
|
trackingSearchUrl: "https://apc.co.uk/",
|
|
3983
|
-
linkRules: [
|
|
3986
|
+
linkRules: [{
|
|
3987
|
+
domains: ["apcchoice.apc-overnight.com"],
|
|
3988
|
+
pathPattern: "^/track-parcel/?$",
|
|
3989
|
+
params: ["id"]
|
|
3990
|
+
}],
|
|
3984
3991
|
detectionRules: [{
|
|
3985
3992
|
pattern: "^20[12]\\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\d{14}$",
|
|
3986
3993
|
confidence: "low",
|
|
@@ -11920,6 +11927,7 @@ var bc = {
|
|
|
11920
11927
|
"acs-courier": 570,
|
|
11921
11928
|
aliexpress: 140,
|
|
11922
11929
|
"allegro-one": 770,
|
|
11930
|
+
"apc-overnight": 870,
|
|
11923
11931
|
"aras-kargo": 670,
|
|
11924
11932
|
"australia-post": 60,
|
|
11925
11933
|
"austrian-post": 390,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AdapterFactory, type TrackingContext } from '../../core/adapter/index.js';
|
|
2
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
3
|
+
import { type StepRecorder } from '../../core/telemetry/index.js';
|
|
4
|
+
export declare const APC_OVERNIGHT_ENDPOINT = "https://apcchoice.apc-overnight.com/track-parcel";
|
|
5
|
+
export declare class ApcOvernightTracker {
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(options?: {
|
|
8
|
+
fetcher?: typeof fetch;
|
|
9
|
+
recorder?: StepRecorder;
|
|
10
|
+
userAgent?: string;
|
|
11
|
+
});
|
|
12
|
+
fetch(raw: string, context?: TrackingContext): Promise<CarrierResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare const adapter: AdapterFactory;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { accepted, recognizeFromLookup } from '../../core/adapter/index.js';
|
|
2
|
+
import { IndeterminateError, TransportError } from '../../core/errors/index.js';
|
|
3
|
+
import { runSteps } from '../../core/runner/index.js';
|
|
4
|
+
import { firstAttemptMs, networkRetryStep } from '../../core/runner/networkRetry.js';
|
|
5
|
+
import { NOOP_RECORDER } from '../../core/telemetry/index.js';
|
|
6
|
+
import { decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
7
|
+
import { APC_OVERNIGHT_MAX_BYTES, normalizeApcOvernightNumber, parseApcOvernight, PROVIDER } from './parser.js';
|
|
8
|
+
// APC Choice's public tracking page, which merchants link to with the waybill as `id`.
|
|
9
|
+
export const APC_OVERNIGHT_ENDPOINT = 'https://apcchoice.apc-overnight.com/track-parcel';
|
|
10
|
+
const REDIRECTS = [301, 302, 303, 307, 308];
|
|
11
|
+
// A missing route is not a missing consignment: the tracker answers unknown waybills with a notice.
|
|
12
|
+
const GONE = [404, 410];
|
|
13
|
+
export class ApcOvernightTracker {
|
|
14
|
+
options;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async fetch(raw, context = {}) {
|
|
19
|
+
const number = normalizeApcOvernightNumber(raw);
|
|
20
|
+
const url = `${APC_OVERNIGHT_ENDPOINT}?${new URLSearchParams({ id: number })}`;
|
|
21
|
+
const read = async (signal, timeoutMs) => {
|
|
22
|
+
const { response, bytes } = await fetchBounded(url, { method: 'GET', signal, headers: {
|
|
23
|
+
Accept: 'text/html,application/xhtml+xml',
|
|
24
|
+
'User-Agent': userAgentOf(this.options.userAgent),
|
|
25
|
+
} }, { provider: PROVIDER, timeoutMs: Math.max(1, Math.floor(timeoutMs)), maxBytes: APC_OVERNIGHT_MAX_BYTES,
|
|
26
|
+
redirect: 'manual', allowHttpStatuses: [...REDIRECTS, ...GONE], fetcher: this.options.fetcher });
|
|
27
|
+
// The site sends its errors to the search page with a message in the address.
|
|
28
|
+
if (REDIRECTS.includes(response.status))
|
|
29
|
+
throw new IndeterminateError(PROVIDER, 'APC Overnight sent the lookup to another page');
|
|
30
|
+
if (GONE.includes(response.status))
|
|
31
|
+
throw new TransportError(PROVIDER, 'APC Overnight tracking page is unavailable');
|
|
32
|
+
const html = decodeText(bytes);
|
|
33
|
+
if (!html.trim())
|
|
34
|
+
throw new IndeterminateError(PROVIDER, 'APC Overnight returned an empty page');
|
|
35
|
+
return parseApcOvernight(html, number);
|
|
36
|
+
};
|
|
37
|
+
// A request that fails to reach APC, or hangs, gets one more try: the
|
|
38
|
+
// first gets half of the budget, so the retry fits in the rest.
|
|
39
|
+
return runSteps({ carrier: 'apc-overnight', budgetMs: context.budgetMs ?? 15_000, signal: context.signal,
|
|
40
|
+
recorder: this.options.recorder ?? NOOP_RECORDER }, [
|
|
41
|
+
{ id: 'direct', run: ({ signal, remainingMs }) => read(signal, firstAttemptMs(remainingMs)) },
|
|
42
|
+
networkRetryStep(({ signal, remainingMs }) => read(signal, remainingMs)),
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export const adapter = (environment) => {
|
|
47
|
+
const tracker = new ApcOvernightTracker({ fetcher: environment.fetcher, recorder: environment.recorder, userAgent: environment.userAgent });
|
|
48
|
+
return {
|
|
49
|
+
id: 'apc-overnight', recordsSteps: true, steps: ['direct', 'retry'],
|
|
50
|
+
track: (input, context) => tracker.fetch(input.number, context),
|
|
51
|
+
recognize: (number, context) => recognizeFromLookup(() => tracker.fetch(number, context), () => accepted(() => normalizeApcOvernightNumber(number))),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
@@ -17,19 +17,66 @@
|
|
|
17
17
|
"timezone": "Europe/London",
|
|
18
18
|
"tracking": {
|
|
19
19
|
"mode": "automatic",
|
|
20
|
-
"adapter": "
|
|
21
|
-
"steps": [
|
|
20
|
+
"adapter": "apc-overnight",
|
|
21
|
+
"steps": [
|
|
22
|
+
"direct",
|
|
23
|
+
"retry"
|
|
24
|
+
],
|
|
25
|
+
"recognition": {
|
|
26
|
+
"rank": 870
|
|
27
|
+
},
|
|
28
|
+
"localClocks": true
|
|
22
29
|
},
|
|
23
|
-
"capabilities": [
|
|
30
|
+
"capabilities": [
|
|
31
|
+
"history",
|
|
32
|
+
"weight",
|
|
33
|
+
"service_name"
|
|
34
|
+
],
|
|
24
35
|
"portal": {
|
|
36
|
+
"url": "https://apcchoice.apc-overnight.com/track-parcel?id={trackingNumber}",
|
|
25
37
|
"searchUrl": "https://apc.co.uk/",
|
|
26
38
|
"canaryUrl": "https://apc.co.uk/",
|
|
27
|
-
"shows": [
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
39
|
+
"shows": [
|
|
40
|
+
"status",
|
|
41
|
+
"history",
|
|
42
|
+
"weight",
|
|
43
|
+
"service_name",
|
|
44
|
+
"parcel_count",
|
|
45
|
+
"customer_reference",
|
|
46
|
+
"office_contact_details",
|
|
47
|
+
"proof_of_delivery"
|
|
48
|
+
],
|
|
49
|
+
"scraped": [
|
|
50
|
+
"status",
|
|
51
|
+
"history",
|
|
52
|
+
"weight",
|
|
53
|
+
"service_name"
|
|
54
|
+
],
|
|
55
|
+
"discarded": [
|
|
56
|
+
"parcel_count",
|
|
57
|
+
"customer_reference",
|
|
58
|
+
"office_contact_details",
|
|
59
|
+
"proof_of_delivery"
|
|
60
|
+
],
|
|
61
|
+
"unavailable": [
|
|
62
|
+
"location",
|
|
63
|
+
"eta",
|
|
64
|
+
"sender_name",
|
|
65
|
+
"recipient_name",
|
|
66
|
+
"offset_timestamps"
|
|
67
|
+
]
|
|
31
68
|
},
|
|
32
|
-
"links": [
|
|
69
|
+
"links": [
|
|
70
|
+
{
|
|
71
|
+
"domains": [
|
|
72
|
+
"apcchoice.apc-overnight.com"
|
|
73
|
+
],
|
|
74
|
+
"pathPattern": "^/track-parcel/?$",
|
|
75
|
+
"params": [
|
|
76
|
+
"id"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
],
|
|
33
80
|
"detection": [
|
|
34
81
|
{
|
|
35
82
|
"id": "apc-overnight-1",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"access": "full_page"
|
|
11
11
|
},
|
|
12
12
|
"context": {
|
|
13
|
-
"assessment": "
|
|
13
|
+
"assessment": "confirmed"
|
|
14
14
|
},
|
|
15
15
|
"expect": {
|
|
16
16
|
"carrier": "unknown",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"estafeta"
|
|
23
23
|
]
|
|
24
24
|
},
|
|
25
|
-
"note": "A seller on Amazon's seller forum gives it as an APC Overnight consignment number."
|
|
25
|
+
"note": "A seller on Amazon's seller forum gives it as an APC Overnight consignment number. APC's tracker returns its history."
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"number": "2026100902014380018924",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "full_page"
|
|
36
36
|
},
|
|
37
37
|
"context": {
|
|
38
|
-
"assessment": "
|
|
38
|
+
"assessment": "confirmed"
|
|
39
39
|
},
|
|
40
40
|
"expect": {
|
|
41
41
|
"carrier": "unknown",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"estafeta"
|
|
48
48
|
]
|
|
49
49
|
},
|
|
50
|
-
"note": "ParcelPanel's APC Overnight page offered it as a sample to try on 11 Oct 2026
|
|
50
|
+
"note": "ParcelPanel's APC Overnight page offered it as a sample to try on 11 Oct 2026. APC's tracker returns its history."
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"number": "2026090302014380017949",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"access": "full_page"
|
|
61
61
|
},
|
|
62
62
|
"context": {
|
|
63
|
-
"assessment": "
|
|
63
|
+
"assessment": "confirmed"
|
|
64
64
|
},
|
|
65
65
|
"expect": {
|
|
66
66
|
"carrier": "unknown",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"estafeta"
|
|
73
73
|
]
|
|
74
74
|
},
|
|
75
|
-
"note": "ParcelPanel's APC Overnight page offered it as a sample to try on 11 Oct 2026
|
|
75
|
+
"note": "ParcelPanel's APC Overnight page offered it as a sample to try on 11 Oct 2026. APC's tracker returns its history."
|
|
76
76
|
},
|
|
77
77
|
{
|
|
78
78
|
"number": "2018041910099660000599",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"estafeta"
|
|
96
96
|
]
|
|
97
97
|
},
|
|
98
|
-
"note": "The Glama listing of an open-source MCP server for APC Overnight's booking API uses it as the waybill in an example prompt; it is not described as a live shipment."
|
|
98
|
+
"note": "The Glama listing of an open-source MCP server for APC Overnight's booking API uses it as the waybill in an example prompt; it is not described as a live shipment. APC's tracker no longer finds it."
|
|
99
99
|
}
|
|
100
100
|
]
|
|
101
101
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CarrierResult } from '../../core/result/index.js';
|
|
2
|
+
export declare const PROVIDER = "APC Overnight";
|
|
3
|
+
export declare const APC_OVERNIGHT_MAX_BYTES: number;
|
|
4
|
+
/**
|
|
5
|
+
* An APC waybill: twenty-two digits that start with the consignment's booking
|
|
6
|
+
* date, as 20YYMMDD, and end with its seven-digit consignment number. The
|
|
7
|
+
* tracker looks a consignment up only by the whole waybill.
|
|
8
|
+
*/
|
|
9
|
+
export declare function normalizeApcOvernightNumber(raw: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The tracking page for one waybill: a consignment summary, then each item's
|
|
12
|
+
* journey through the network and its delivery summary, each row a wording
|
|
13
|
+
* with its day and minute. The page names the consignment by its seven-digit
|
|
14
|
+
* number, the end of the waybill it was asked for. A waybill the tracker does
|
|
15
|
+
* not have, or of another shape, comes back on the search page with a notice.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseApcOvernight(html: string, rawNumber: string): CarrierResult;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { load } from 'cheerio';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { normalizeTrackingNumber } from '../../core/detection/index.js';
|
|
4
|
+
import { ChallengeError, IndeterminateError, InvalidInputError, NoHistoryError, NotFoundError, SchemaError } from '../../core/errors/index.js';
|
|
5
|
+
import { languageStageStatus } from '../../core/status/index.js';
|
|
6
|
+
import { clean } from '../../core/transport/index.js';
|
|
7
|
+
import { apcOvernightStage, isApcOvernightNotice } from './status.js';
|
|
8
|
+
export const PROVIDER = 'APC Overnight';
|
|
9
|
+
// A consignment's page is about 12 kB and the search page about 17 kB.
|
|
10
|
+
export const APC_OVERNIGHT_MAX_BYTES = 512 * 1024;
|
|
11
|
+
const MAX_ITEMS = 200;
|
|
12
|
+
const MAX_ROWS = 2000;
|
|
13
|
+
// The search page's notice for a whole number the tracker does not have.
|
|
14
|
+
const NOT_FOUND = 'Your consignment could not be found';
|
|
15
|
+
// Its notice for an identifier of the wrong shape.
|
|
16
|
+
const INVALID = 'Consignment identifier is invalid';
|
|
17
|
+
// A firewall or bot check's page, which can come back with HTTP 200.
|
|
18
|
+
const BLOCKED = /<title>\s*(?:Request Rejected|Access Denied|Attention Required|Just a moment)|requested URL was rejected|_cf_chl_opt|cf-turnstile/i;
|
|
19
|
+
// "At Hub (10-Oct-2026 00:31)": the wording, then the day and minute in brackets.
|
|
20
|
+
const ROW = /^(.+?) \((\d{2}-[A-Z][a-z]{2}-\d{4} \d{2}:\d{2})\)$/;
|
|
21
|
+
const WEIGHT = /^(\d{1,6}(?:\.\d{1,3})?) kg$/;
|
|
22
|
+
// "NC16 (Next Business Day Non-Conveyable Parcel)": the service's code, then its name.
|
|
23
|
+
const SERVICE = /^[A-Z0-9]{1,8} \((.{1,80})\)$/;
|
|
24
|
+
/**
|
|
25
|
+
* An APC waybill: twenty-two digits that start with the consignment's booking
|
|
26
|
+
* date, as 20YYMMDD, and end with its seven-digit consignment number. The
|
|
27
|
+
* tracker looks a consignment up only by the whole waybill.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeApcOvernightNumber(raw) {
|
|
30
|
+
const number = normalizeTrackingNumber(raw);
|
|
31
|
+
if (/^20\d{20}$/.test(number) && DateTime.fromFormat(number.slice(0, 8), 'yyyyMMdd', { zone: 'UTC' }).isValid)
|
|
32
|
+
return number;
|
|
33
|
+
throw new InvalidInputError(PROVIDER, 'APC Overnight tracks its twenty-two-digit waybills, which start with their booking date');
|
|
34
|
+
}
|
|
35
|
+
/** "09-Oct-2026 17:58" as a wall clock. The page gives no offset. */
|
|
36
|
+
function wallClock(stamp) {
|
|
37
|
+
// UTC only checks the calendar fields. No offset is assigned.
|
|
38
|
+
const parsed = DateTime.fromFormat(stamp, 'dd-LLL-yyyy HH:mm', { zone: 'UTC', locale: 'en-GB' });
|
|
39
|
+
return parsed.isValid ? parsed.toFormat("yyyy-MM-dd'T'HH:mm:ss") : null;
|
|
40
|
+
}
|
|
41
|
+
/** The consignment summary's value next to one label, or '' without one. */
|
|
42
|
+
function summaryValue($, label) {
|
|
43
|
+
const cells = $('div.con-summary tr').filter((_, row) => clean($(row).children('th').text(), 80) === label).children('td');
|
|
44
|
+
return cells.length === 1 ? clean(cells.text(), 200) : '';
|
|
45
|
+
}
|
|
46
|
+
/** The search page APC sends back for a number it has no consignment page for. */
|
|
47
|
+
function searchPage($, html, number) {
|
|
48
|
+
const notices = $('p.login-error');
|
|
49
|
+
if (!notices.length) {
|
|
50
|
+
if (BLOCKED.test(html.slice(0, 64_000)))
|
|
51
|
+
throw new ChallengeError(PROVIDER, 'APC Overnight answered with a block page');
|
|
52
|
+
throw new SchemaError(PROVIDER, 'APC Overnight did not return a consignment page');
|
|
53
|
+
}
|
|
54
|
+
const notice = notices.length === 1 ? clean(notices.text(), 200) : '';
|
|
55
|
+
// The page puts the identifier it was asked for back into its search field.
|
|
56
|
+
const fields = $('input#ConsignmentNumber');
|
|
57
|
+
const echoed = fields.length === 1 ? normalizeTrackingNumber(clean(fields.attr('value'), 80)) : '';
|
|
58
|
+
if (notice === NOT_FOUND || notice === INVALID) {
|
|
59
|
+
if (echoed !== number)
|
|
60
|
+
throw new SchemaError(PROVIDER, 'APC Overnight answered for a different number');
|
|
61
|
+
if (notice === NOT_FOUND)
|
|
62
|
+
throw new NotFoundError(PROVIDER);
|
|
63
|
+
throw new InvalidInputError(PROVIDER, 'APC Overnight does not accept the number');
|
|
64
|
+
}
|
|
65
|
+
throw new IndeterminateError(PROVIDER, 'APC Overnight answered with another notice');
|
|
66
|
+
}
|
|
67
|
+
function row(text, index) {
|
|
68
|
+
const match = ROW.exec(text);
|
|
69
|
+
const time = match ? wallClock(match[2]) : null;
|
|
70
|
+
if (!match || !time)
|
|
71
|
+
throw new SchemaError(PROVIDER, 'APC Overnight returned a row without its wording and clock');
|
|
72
|
+
return { local_time: time, description: match[1], index };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The tracking page for one waybill: a consignment summary, then each item's
|
|
76
|
+
* journey through the network and its delivery summary, each row a wording
|
|
77
|
+
* with its day and minute. The page names the consignment by its seven-digit
|
|
78
|
+
* number, the end of the waybill it was asked for. A waybill the tracker does
|
|
79
|
+
* not have, or of another shape, comes back on the search page with a notice.
|
|
80
|
+
*/
|
|
81
|
+
export function parseApcOvernight(html, rawNumber) {
|
|
82
|
+
const number = normalizeApcOvernightNumber(rawNumber);
|
|
83
|
+
const $ = load(html);
|
|
84
|
+
const summaries = $('div.tracking-container div.con-summary');
|
|
85
|
+
if (!summaries.length)
|
|
86
|
+
searchPage($, html, number);
|
|
87
|
+
if (summaries.length !== 1)
|
|
88
|
+
throw new SchemaError(PROVIDER, 'APC Overnight returned an unexpected consignment page');
|
|
89
|
+
if (summaryValue($, 'Consignment number:') !== number.slice(-7)) {
|
|
90
|
+
throw new SchemaError(PROVIDER, 'APC Overnight answered for a different consignment');
|
|
91
|
+
}
|
|
92
|
+
const items = $('div.details-container > div.item-details');
|
|
93
|
+
if (!items.length || items.length > MAX_ITEMS)
|
|
94
|
+
throw new SchemaError(PROVIDER, 'APC Overnight returned an invalid item list');
|
|
95
|
+
const rows = [];
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
items.each((_, item) => {
|
|
98
|
+
const journey = $(item).children('div.detailed-journey-container').children('ul');
|
|
99
|
+
const delivery = $(item).children('div.detailed-delivery-container');
|
|
100
|
+
if (journey.length !== 1 || delivery.length !== 1)
|
|
101
|
+
throw new SchemaError(PROVIDER, 'APC Overnight item layout changed');
|
|
102
|
+
// Only the first line of a delivery record is read: it is the record's
|
|
103
|
+
// wording and clock, and nothing after it.
|
|
104
|
+
const texts = [
|
|
105
|
+
...journey.children('li').toArray().map(node => clean($(node).text(), 300)),
|
|
106
|
+
...delivery.children('div.pod-details').toArray().map(node => clean($(node).children('p').first().text(), 300)),
|
|
107
|
+
];
|
|
108
|
+
for (const text of texts) {
|
|
109
|
+
if (rows.length >= MAX_ROWS)
|
|
110
|
+
throw new SchemaError(PROVIDER, 'APC Overnight returned too many rows');
|
|
111
|
+
const parsed = row(text, rows.length);
|
|
112
|
+
// Every item repeats the consignment's delivery records, and items
|
|
113
|
+
// travelling together share their scans.
|
|
114
|
+
const identity = JSON.stringify([parsed.local_time, parsed.description]);
|
|
115
|
+
if (seen.has(identity))
|
|
116
|
+
continue;
|
|
117
|
+
seen.add(identity);
|
|
118
|
+
rows.push(parsed);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
if (!rows.length)
|
|
122
|
+
throw new NoHistoryError(PROVIDER);
|
|
123
|
+
// Oldest first, equal clocks in the page's order: a notice keeps the stage
|
|
124
|
+
// before it, and a delivery once the consignment was sent back reaches its
|
|
125
|
+
// sender.
|
|
126
|
+
rows.sort((left, right) => left.local_time < right.local_time ? -1 : left.local_time > right.local_time ? 1 : left.index - right.index);
|
|
127
|
+
const events = [];
|
|
128
|
+
let previous;
|
|
129
|
+
let returning = false;
|
|
130
|
+
for (const { local_time, description } of rows) {
|
|
131
|
+
const event = { local_time, description };
|
|
132
|
+
events.push(event);
|
|
133
|
+
if (isApcOvernightNotice(description)) {
|
|
134
|
+
Object.assign(event, { stage: previous ?? 'registered', stage_source: 'none' });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const classified = apcOvernightStage(description);
|
|
138
|
+
if (!classified)
|
|
139
|
+
continue;
|
|
140
|
+
let stage = classified.stage;
|
|
141
|
+
if (stage === 'delivered' && returning)
|
|
142
|
+
stage = 'returned';
|
|
143
|
+
if (stage === 'returned')
|
|
144
|
+
returning = true;
|
|
145
|
+
Object.assign(event, { stage, stage_source: classified.source });
|
|
146
|
+
previous = stage;
|
|
147
|
+
}
|
|
148
|
+
events.reverse();
|
|
149
|
+
const latest = events[0];
|
|
150
|
+
const decider = events.find(event => event.stage && event.stage_source !== 'none');
|
|
151
|
+
const stage = decider?.stage;
|
|
152
|
+
const weight = WEIGHT.exec(summaryValue($, 'Total weight:'))?.[1];
|
|
153
|
+
const service = summaryValue($, 'Consignment service:');
|
|
154
|
+
const serviceName = SERVICE.exec(service)?.[1]?.trim() ?? '';
|
|
155
|
+
return {
|
|
156
|
+
status: stage ? languageStageStatus(stage) : 'unknown',
|
|
157
|
+
...(stage ? { current_stage: stage, current_stage_source: decider.stage_source } : {}),
|
|
158
|
+
last_status_text: latest.description,
|
|
159
|
+
last_update: null,
|
|
160
|
+
last_update_local: latest.local_time,
|
|
161
|
+
expected_delivery: null,
|
|
162
|
+
...(weight && Number(weight) > 0 ? { weight_kg: Number(weight) } : {}),
|
|
163
|
+
...(serviceName ? { service_name: serviceName } : {}),
|
|
164
|
+
events,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ClassifiedWording, type Stage } from '../../core/status/index.js';
|
|
2
|
+
import { type CarrierStatusMap } from '../../core/status/statusMap.js';
|
|
3
|
+
/** The stage the map gives APC's wording, if it knows it. */
|
|
4
|
+
export declare function apcOvernightScanStage(wording: string): Stage | undefined;
|
|
5
|
+
export declare function isApcOvernightNotice(wording: string): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* A row's stage from the map, else from the shared wording rules. Wording
|
|
8
|
+
* neither knows stays without a stage, so it remains visible for review.
|
|
9
|
+
*/
|
|
10
|
+
export declare function apcOvernightStage(wording: string): ClassifiedWording | null;
|
|
11
|
+
/** The rows carry wording only. */
|
|
12
|
+
export declare const statusMap: CarrierStatusMap;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { classifyWording } from '../../core/status/index.js';
|
|
2
|
+
import { normalizeStatusWording } from '../../core/status/statusMap.js';
|
|
3
|
+
/**
|
|
4
|
+
* The wording of APC's tracking page: each item's journey through the
|
|
5
|
+
* network, then the delivery records the delivering depot keeps for the
|
|
6
|
+
* consignment. The rows carry no code.
|
|
7
|
+
*/
|
|
8
|
+
const STAGES = new Map([
|
|
9
|
+
// The sending depot collected the consignment and scanned it in.
|
|
10
|
+
['at sending depot', 'accepted'],
|
|
11
|
+
['at hub', 'in_transit'],
|
|
12
|
+
['at delivery depot', 'in_transit'],
|
|
13
|
+
['out for delivery', 'out_for_delivery'],
|
|
14
|
+
// Nobody was in, and the driver left a "Sorry we missed you" card.
|
|
15
|
+
['carded', 'failed_attempt'],
|
|
16
|
+
['delivered', 'delivered'],
|
|
17
|
+
['returned to sender', 'returned'],
|
|
18
|
+
]);
|
|
19
|
+
// A delivery the recipient moved to another day or option, not a movement:
|
|
20
|
+
// the consignment keeps the stage it had.
|
|
21
|
+
const NOTICES = new Set(['delivery rearranged by customer']);
|
|
22
|
+
/** The stage the map gives APC's wording, if it knows it. */
|
|
23
|
+
export function apcOvernightScanStage(wording) {
|
|
24
|
+
return STAGES.get(normalizeStatusWording(wording));
|
|
25
|
+
}
|
|
26
|
+
export function isApcOvernightNotice(wording) {
|
|
27
|
+
return NOTICES.has(normalizeStatusWording(wording));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A row's stage from the map, else from the shared wording rules. Wording
|
|
31
|
+
* neither knows stays without a stage, so it remains visible for review.
|
|
32
|
+
*/
|
|
33
|
+
export function apcOvernightStage(wording) {
|
|
34
|
+
const mapped = apcOvernightScanStage(wording);
|
|
35
|
+
if (mapped)
|
|
36
|
+
return { stage: mapped, source: 'carrier_map' };
|
|
37
|
+
const worded = classifyWording(wording, 'in_transit');
|
|
38
|
+
return worded.source === 'none' ? null : worded;
|
|
39
|
+
}
|
|
40
|
+
/** The rows carry wording only. */
|
|
41
|
+
export const statusMap = {
|
|
42
|
+
stage: (code, wording) => (code ? undefined : apcOvernightScanStage(wording)),
|
|
43
|
+
gaps: [...NOTICES].map(wording => ({ wording, note: 'A delivery moved by the recipient, not a movement: the consignment keeps the stage it had.' })),
|
|
44
|
+
};
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"carrier": "apc-overnight",
|
|
3
|
-
"entries": [
|
|
3
|
+
"entries": [
|
|
4
|
+
{"wording": "At sending depot", "stage": "accepted", "language": "en", "confirmedBy": "live", "note": "The sending depot collected the consignment and scanned it in."},
|
|
5
|
+
{"wording": "At Hub", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
|
|
6
|
+
{"wording": "At delivery depot", "stage": "in_transit", "language": "en", "confirmedBy": "live"},
|
|
7
|
+
{"wording": "Out for delivery", "stage": "out_for_delivery", "language": "en", "confirmedBy": "live"},
|
|
8
|
+
{"wording": "Delivery Rearranged By Customer", "language": "en", "confirmedBy": "live", "note": "A delivery record: the recipient moved the delivery to another day or option. Not a movement, so it has no stage of its own and the consignment keeps the one it had."},
|
|
9
|
+
{"wording": "Carded", "stage": "failed_attempt", "language": "en", "confirmedBy": "live", "note": "A delivery record: nobody was in and the driver left a card."},
|
|
10
|
+
{"wording": "Delivered", "stage": "delivered", "language": "en", "confirmedBy": "live", "note": "A delivery record. Once the consignment was sent back, the adapter reads a delivery as returned."},
|
|
11
|
+
{"wording": "Returned To Sender", "stage": "returned", "language": "en", "confirmedBy": "live", "note": "A delivery record."}
|
|
12
|
+
]
|
|
4
13
|
}
|
|
@@ -14,6 +14,7 @@ import { statusMap as acsCourier } from '../../carriers/acs-courier/status.js';
|
|
|
14
14
|
import { statusMap as aliexpress } from '../../carriers/aliexpress/status.js';
|
|
15
15
|
import { statusMap as allegroOne } from '../../carriers/allegro-one/status.js';
|
|
16
16
|
import { statusMap as anPost } from '../../carriers/an-post/status.js';
|
|
17
|
+
import { statusMap as apcOvernight } from '../../carriers/apc-overnight/status.js';
|
|
17
18
|
import { statusMap as arasKargo } from '../../carriers/aras-kargo/status.js';
|
|
18
19
|
import { statusMap as boxNow } from '../../carriers/box-now/status.js';
|
|
19
20
|
import { statusMap as cacesa } from '../../carriers/cacesa/status.js';
|
|
@@ -77,7 +78,7 @@ import { normalizeStatusWording } from '../status/statusMap.js';
|
|
|
77
78
|
import { CARRIER_DEFINITIONS } from './definitions.js';
|
|
78
79
|
/** By adapter: a carrier served by another's adapter, as Delivengo by La Poste's, reads that map. */
|
|
79
80
|
export const STATUS_MAPS = {
|
|
80
|
-
'acs-courier': acsCourier, aliexpress, 'allegro-one': allegroOne, 'an-post': anPost, 'aras-kargo': arasKargo, 'box-now': boxNow, cacesa, cargus,
|
|
81
|
+
'acs-courier': acsCourier, aliexpress, 'allegro-one': allegroOne, 'an-post': anPost, 'apc-overnight': apcOvernight, 'aras-kargo': arasKargo, 'box-now': boxNow, cacesa, cargus,
|
|
81
82
|
'ceska-posta': ceskaPosta, 'china-post': chinaPost, chronopost, 'correos-spain': correosSpain, dao, 'dhl-express': dhlExpress, dpd, 'dpd-de': dpdDe,
|
|
82
83
|
'dpd-fr': dpdFr, 'dpd-ie': dpdIe, 'dpd-pl': dpdPl, econt, elta, emile, 'express-one': expressOne, 'fan-courier': fanCourier, foxpost,
|
|
83
84
|
'geniki-taxydromiki': geniki, 'gls-es': glsEs, 'gls-it': glsIt, 'gofo-fr': gofoFr, 'gofo-it': gofoIt, helthjem, 'hrvatska-posta': hrvatskaPosta,
|
package/dist/data/catalog.json
CHANGED
|
@@ -4852,11 +4852,24 @@
|
|
|
4852
4852
|
"timezone": "Europe/London",
|
|
4853
4853
|
"tracking": {
|
|
4854
4854
|
"mode": "automatic",
|
|
4855
|
-
"adapter": "
|
|
4855
|
+
"adapter": "apc-overnight",
|
|
4856
|
+
"recognitionRank": 870,
|
|
4857
|
+
"localClocks": true
|
|
4856
4858
|
},
|
|
4857
4859
|
"canaryUrl": "https://apc.co.uk/",
|
|
4860
|
+
"trackingUrlTemplate": "https://apcchoice.apc-overnight.com/track-parcel?id={trackingNumber}",
|
|
4858
4861
|
"trackingSearchUrl": "https://apc.co.uk/",
|
|
4859
|
-
"linkRules": [
|
|
4862
|
+
"linkRules": [
|
|
4863
|
+
{
|
|
4864
|
+
"domains": [
|
|
4865
|
+
"apcchoice.apc-overnight.com"
|
|
4866
|
+
],
|
|
4867
|
+
"pathPattern": "^/track-parcel/?$",
|
|
4868
|
+
"params": [
|
|
4869
|
+
"id"
|
|
4870
|
+
]
|
|
4871
|
+
}
|
|
4872
|
+
],
|
|
4860
4873
|
"detectionRules": [
|
|
4861
4874
|
{
|
|
4862
4875
|
"pattern": "^20[12]\\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\d{14}$",
|
|
@@ -3324,11 +3324,18 @@ export declare const CARRIER_CATALOG: {
|
|
|
3324
3324
|
readonly timezone: "Europe/London";
|
|
3325
3325
|
readonly tracking: {
|
|
3326
3326
|
readonly mode: "automatic";
|
|
3327
|
-
readonly adapter: "
|
|
3327
|
+
readonly adapter: "apc-overnight";
|
|
3328
|
+
readonly recognitionRank: 870;
|
|
3329
|
+
readonly localClocks: true;
|
|
3328
3330
|
};
|
|
3329
3331
|
readonly canaryUrl: "https://apc.co.uk/";
|
|
3332
|
+
readonly trackingUrlTemplate: "https://apcchoice.apc-overnight.com/track-parcel?id={trackingNumber}";
|
|
3330
3333
|
readonly trackingSearchUrl: "https://apc.co.uk/";
|
|
3331
|
-
readonly linkRules: readonly [
|
|
3334
|
+
readonly linkRules: readonly [{
|
|
3335
|
+
readonly domains: readonly ["apcchoice.apc-overnight.com"];
|
|
3336
|
+
readonly pathPattern: "^/track-parcel/?$";
|
|
3337
|
+
readonly params: readonly ["id"];
|
|
3338
|
+
}];
|
|
3332
3339
|
readonly detectionRules: readonly [{
|
|
3333
3340
|
readonly pattern: "^20[12]\\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\d{14}$";
|
|
3334
3341
|
readonly confidence: "low";
|
|
@@ -4853,11 +4853,24 @@ export const CARRIER_CATALOG = {
|
|
|
4853
4853
|
"timezone": "Europe/London",
|
|
4854
4854
|
"tracking": {
|
|
4855
4855
|
"mode": "automatic",
|
|
4856
|
-
"adapter": "
|
|
4856
|
+
"adapter": "apc-overnight",
|
|
4857
|
+
"recognitionRank": 870,
|
|
4858
|
+
"localClocks": true
|
|
4857
4859
|
},
|
|
4858
4860
|
"canaryUrl": "https://apc.co.uk/",
|
|
4861
|
+
"trackingUrlTemplate": "https://apcchoice.apc-overnight.com/track-parcel?id={trackingNumber}",
|
|
4859
4862
|
"trackingSearchUrl": "https://apc.co.uk/",
|
|
4860
|
-
"linkRules": [
|
|
4863
|
+
"linkRules": [
|
|
4864
|
+
{
|
|
4865
|
+
"domains": [
|
|
4866
|
+
"apcchoice.apc-overnight.com"
|
|
4867
|
+
],
|
|
4868
|
+
"pathPattern": "^/track-parcel/?$",
|
|
4869
|
+
"params": [
|
|
4870
|
+
"id"
|
|
4871
|
+
]
|
|
4872
|
+
}
|
|
4873
|
+
],
|
|
4861
4874
|
"detectionRules": [
|
|
4862
4875
|
{
|
|
4863
4876
|
"pattern": "^20[12]\\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\\d|3[01])\\d{14}$",
|
|
@@ -4,6 +4,7 @@ import { adapter as aliexpress } from '../carriers/aliexpress/adapter.js';
|
|
|
4
4
|
import { adapter as allegroOne } from '../carriers/allegro-one/adapter.js';
|
|
5
5
|
import { adapter as amazonShipping } from '../carriers/amazon-shipping/adapter.js';
|
|
6
6
|
import { adapter as anPost } from '../carriers/an-post/adapter.js';
|
|
7
|
+
import { adapter as apcOvernight } from '../carriers/apc-overnight/adapter.js';
|
|
7
8
|
import { adapter as aramex } from '../carriers/aramex/adapter.js';
|
|
8
9
|
import { adapter as arasKargo } from '../carriers/aras-kargo/adapter.js';
|
|
9
10
|
import { adapter as asendia } from '../carriers/asendia/adapter.js';
|
|
@@ -161,6 +162,7 @@ export const REGISTRY = {
|
|
|
161
162
|
"allegro-one": allegroOne,
|
|
162
163
|
"amazon-shipping": amazonShipping,
|
|
163
164
|
"an-post": anPost,
|
|
165
|
+
"apc-overnight": apcOvernight,
|
|
164
166
|
"aramex": aramex,
|
|
165
167
|
"aras-kargo": arasKargo,
|
|
166
168
|
"asendia": asendia,
|
|
@@ -321,7 +323,7 @@ export const REGISTRY = {
|
|
|
321
323
|
"amazon-logistics": null,
|
|
322
324
|
"amazon-shipping": "amazon-shipping",
|
|
323
325
|
"an-post": "an-post",
|
|
324
|
-
"apc-overnight": "
|
|
326
|
+
"apc-overnight": "apc-overnight",
|
|
325
327
|
"aramex": "aramex",
|
|
326
328
|
"aras-kargo": "aras-kargo",
|
|
327
329
|
"asendia": "asendia",
|
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.
|
|
3
|
+
"version": "0.97.0-main.997",
|
|
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",
|