universal-parcel-scraper 0.41.0-main.397 → 0.41.1-main.398
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.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
2
3
|
import { CarrierError, ChallengeError, IndeterminateError, SchemaError, TransportError } from '../../core/errors/index.js';
|
|
3
|
-
import { zonedTime } from '../../core/time/index.js';
|
|
4
|
+
import { calendarDay, isoTime, zonedTime } from '../../core/time/index.js';
|
|
4
5
|
import { clean, decodeText, fetchBounded, userAgentOf } from '../../core/transport/index.js';
|
|
5
6
|
import { xmlDocument } from '../../core/transport/xml.js';
|
|
6
7
|
export const DPD_DE_APP_API = 'https://api.paketnavigator.de/services/v1/Navigator3Service.asmx';
|
|
@@ -51,6 +52,63 @@ function scalar(node, name, max = 200) {
|
|
|
51
52
|
invalid();
|
|
52
53
|
return clean(matches[0]?.text ?? '', max);
|
|
53
54
|
}
|
|
55
|
+
function optional(node, name) {
|
|
56
|
+
const matches = children(node, name);
|
|
57
|
+
if (matches.length > 1)
|
|
58
|
+
invalid();
|
|
59
|
+
return matches[0];
|
|
60
|
+
}
|
|
61
|
+
const affirmative = (value) => value === 'true' || value === '1';
|
|
62
|
+
const placeholderDay = (day) => day === '2000-01-01' || day === '0001-01-01';
|
|
63
|
+
/** Validate source digits without assigning a zone to an offset-less estimate. */
|
|
64
|
+
function estimateClock(raw) {
|
|
65
|
+
if (!/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?$/.test(raw)
|
|
66
|
+
|| placeholderDay(raw.slice(0, 10)))
|
|
67
|
+
return null;
|
|
68
|
+
return isoTime(raw, 'UTC');
|
|
69
|
+
}
|
|
70
|
+
function estimateDay(raw) {
|
|
71
|
+
if (estimateClock(raw))
|
|
72
|
+
return raw.slice(0, 10);
|
|
73
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
|
74
|
+
const display = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(raw);
|
|
75
|
+
const day = iso ? calendarDay(Number(iso[1]), Number(iso[2]), Number(iso[3]))
|
|
76
|
+
: display ? calendarDay(Number(display[3]), Number(display[2]), Number(display[1])) : null;
|
|
77
|
+
return day && !placeholderDay(day) ? day : null;
|
|
78
|
+
}
|
|
79
|
+
function announcedDay(wording) {
|
|
80
|
+
const date = /^Your parcel is estimated to be delivered on:\s*(?:[A-Za-z]+,\s*)?(\d{2}\.\d{2}\.\d{4})$/i.exec(wording)?.[1];
|
|
81
|
+
return date ? estimateDay(date) : null;
|
|
82
|
+
}
|
|
83
|
+
/** Specified dates are forecasts, never scans; delivered display dates are actuals. */
|
|
84
|
+
function deliveryEstimate(tracking, announced) {
|
|
85
|
+
const planned = optional(tracking, 'NewDeliveryInfo');
|
|
86
|
+
const day = planned && affirmative(scalar(planned, 'PlannedDeliveryDateSpecified', 8))
|
|
87
|
+
? estimateDay(scalar(planned, 'PlannedDeliveryDate', 64)) : null;
|
|
88
|
+
// The app displays a changed delivery day ahead of its previous forecast.
|
|
89
|
+
if (day && planned && affirmative(scalar(planned, 'DateChanged', 8)))
|
|
90
|
+
return day;
|
|
91
|
+
const live = optional(tracking, 'LiveTracking');
|
|
92
|
+
if (live && affirmative(scalar(live, 'EstimatedDeliveryDateTimeSpecified', 8))) {
|
|
93
|
+
const from = scalar(live, 'EstimatedDeliveryDateTimeFrom', 64);
|
|
94
|
+
const to = scalar(live, 'EstimatedDeliveryDateTimeTo', 64);
|
|
95
|
+
const start = estimateClock(from);
|
|
96
|
+
const end = estimateClock(to);
|
|
97
|
+
const hasOffset = (value) => /(?:Z|[+-]\d{2}:\d{2})$/.test(value);
|
|
98
|
+
if (start && end && hasOffset(from) === hasOffset(to) && end.timestamp >= start.timestamp) {
|
|
99
|
+
// An explicit instant can be shown on the German delivery clock. Unresolved
|
|
100
|
+
// wall clocks retain their digits, without receiving an invented offset.
|
|
101
|
+
const display = (raw, timestamp) => hasOffset(raw)
|
|
102
|
+
? DateTime.fromMillis(timestamp, { zone: 'Europe/Berlin' }).toFormat('yyyy-MM-dd HH:mm')
|
|
103
|
+
: raw.slice(0, 16).replace('T', ' ');
|
|
104
|
+
const first = display(from, start.timestamp);
|
|
105
|
+
const last = display(to, end.timestamp);
|
|
106
|
+
return `${first}–${first.slice(0, 10) === last.slice(0, 10) ? last.slice(11) : last}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// DeliveryDateTime is display text and can omit the year: never invent it.
|
|
110
|
+
return day ?? announced ?? estimateDay(scalar(tracking, 'DeliveryDateTime', 64));
|
|
111
|
+
}
|
|
54
112
|
const escaped = (value) => value.replace(/[<>&"']/g, character => `&#${character.charCodeAt(0)};`);
|
|
55
113
|
const elements = (fields) => Object.entries(fields)
|
|
56
114
|
.map(([name, value]) => `<${name}>${typeof value === 'string' ? escaped(value) : elements(value)}</${name}>`).join('');
|
|
@@ -81,15 +139,20 @@ export function parseDpdDeApp(data, scans, number) {
|
|
|
81
139
|
const events = [];
|
|
82
140
|
const seen = new Set();
|
|
83
141
|
let placed = '';
|
|
142
|
+
let announced = null;
|
|
84
143
|
for (const row of rows) {
|
|
85
144
|
const wording = scalar(row, 'StatusText', 500);
|
|
145
|
+
if (!wording)
|
|
146
|
+
invalid();
|
|
147
|
+
// These rows may carry no scan clock. The date is in the announcement itself.
|
|
148
|
+
if (/^Your parcel is estimated to be delivered\b/i.test(wording)) {
|
|
149
|
+
announced = announcedDay(wording);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
86
152
|
const day = scalar(row, 'ScanDate', 16);
|
|
87
153
|
const time = scalar(row, 'ScanTime', 16);
|
|
88
|
-
if (
|
|
154
|
+
if (!/^\d{2}\.\d{2}\.\d{4}$/.test(day) || !/^\d{2}:\d{2}$/.test(time))
|
|
89
155
|
invalid();
|
|
90
|
-
// An announced delivery day is not a scan.
|
|
91
|
-
if (/^Your parcel is estimated to be delivered\b/i.test(wording))
|
|
92
|
-
continue;
|
|
93
156
|
const place = /^(.{1,120}) \(([A-Z]{2})\)$/.exec(scalar(row, 'Location'));
|
|
94
157
|
if (place)
|
|
95
158
|
placed = place[2];
|
|
@@ -125,10 +188,13 @@ export function parseDpdDeApp(data, scans, number) {
|
|
|
125
188
|
const current = events[0];
|
|
126
189
|
const rail = DPD_DE_APP_RAIL[scalar(one(tracking, 'LastStatusInfo'), 'StatusID', 64)];
|
|
127
190
|
const stage = current.stage ?? rail?.stage;
|
|
191
|
+
const status = rail?.status ?? statusOf(current.stage);
|
|
128
192
|
const kilograms = Number(scalar(one(tracking, 'OrderInfo'), 'Weight', 16).replace(',', '.'));
|
|
129
193
|
return {
|
|
130
|
-
status
|
|
194
|
+
status, ...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
|
|
131
195
|
last_status_text: current.description ?? null, last_update: current.time ?? null,
|
|
196
|
+
expected_delivery: affirmative(scalar(tracking, 'Delivered', 8)) || status === 'delivered' || status === 'exception'
|
|
197
|
+
|| stage === 'delivered' || stage === 'ready_for_pickup' ? null : deliveryEstimate(tracking, announced),
|
|
132
198
|
...(current.time ? {} : { last_update_local: current.local_time }),
|
|
133
199
|
...(Number.isFinite(kilograms) && kilograms > 0 ? { weight_kg: kilograms } : {}),
|
|
134
200
|
events: events.slice(0, 100),
|
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.41.
|
|
3
|
+
"version": "0.41.1-main.398",
|
|
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",
|