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 (!wording || !/^\d{2}\.\d{2}\.\d{4}$/.test(day) || !/^\d{2}:\d{2}$/.test(time))
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: rail?.status ?? statusOf(current.stage), ...(stage ? { current_stage: stage, current_stage_source: 'carrier_map' } : {}),
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),
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Universal Parcel Scraper",
5
- "version": "0.41.0"
5
+ "version": "0.41.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.41.0-main.397",
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",