From 4e94c0f1693d3d9b3098ddc4d9db4728b86c607c Mon Sep 17 00:00:00 2001 From: egg Date: Wed, 5 Aug 2026 10:36:00 +0900 Subject: [PATCH] fix: correct UPS and USPS time parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPS reports a 12-hour clock as "10:53 P.M.", but the format string read it as `HH:mm`, so the meridiem was ignored and every afternoon event landed twelve hours early. Seven of the twelve activities in the recorded fixture are P.M., so this was wrong far more often than not. dayjs only matches "PM", not "P.M.", so the periods have to be stripped before parsing with `h:mm A`. USPS gets the same treatment for whitespace: runs of spaces inside the date cell are collapsed before parsing, and the format list now also accepts a space after the second comma. Both were reported in #35 by @aldin-alagic in 2022. That PR targeted the old `lib/` tree and could no longer be merged after the v3 rewrite, so the fixes are reapplied here with a regression test for each — the existing sweep only checked that a timestamp parsed, not that it landed on the right hour. --- CHANGELOG.md | 8 +++++++- src/courier/ups.ts | 16 +++++++++++++--- src/courier/usps.ts | 19 ++++++++----------- test/checkpoint-time.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5507773..33d8c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,14 @@ Rewritten in TypeScript. This release is **breaking** — see the migration note `DD-MMM-YYYYHH:mm`. v2 read that as `2001-01-18T17:53`; it is `2017-01-18T23:53`. * `cesco` — Indonesian month names are expanded to full English names, so the format needs `MMMM` rather than `MMM`. +* `ups` — UPS reports a 12-hour clock as `10:53 P.M.`, but the format string read it as + `HH:mm`, so every afternoon event was recorded twelve hours early. Reported in #35 by + @aldin-alagic, whose fix could not be merged once the sources moved to `src/`. +* `usps` — whitespace inside the date cell is collapsed before parsing, so runs of spaces + no longer break it. Also from #35. * Added a sweep test asserting every courier's checkpoints carry a parseable timestamp — - the per-courier tests only ever checked `number` and `status`. + the per-courier tests only ever checked `number` and `status`. Timestamps that parse but + land on the wrong hour are now asserted explicitly for the couriers above. ## Notes * Added a "Reporting a broken courier" guide to the README and a matching GitHub issue diff --git a/src/courier/ups.ts b/src/courier/ups.ts index fd4729e..873e551 100644 --- a/src/courier/ups.ts +++ b/src/courier/ups.ts @@ -74,6 +74,18 @@ function parseToken(setCookie: string | string[] | undefined): string { return '' } +/** + * UPS reports a 12-hour clock as "10:53 P.M.". Reading it as `HH:mm` silently shifts + * every afternoon event back by twelve hours, so the meridiem has to be parsed — and the + * periods stripped first, since dayjs's `A` token only matches "PM". + * + * Reported in #35 by @aldin-alagic. + */ +function parseTime(date: string, time: string | undefined): string { + const meridiem = (time ?? '').replace(/\./g, '') + return dayjs(`${date} ${meridiem}`, 'MM/DD/YYYY h:mm A').format('YYYY-MM-DDTHH:mmZ') +} + function parse(detail: TrackDetail): TraceResult { const checkpoints: Checkpoint[] = [] @@ -88,9 +100,7 @@ function parse(detail: TrackDetail): TraceResult { location: activity.location, message, status: message.includes('DELIVERED') ? STATUS.DELIVERED : STATUS.IN_TRANSIT, - time: dayjs([activity.date, activity.time].join(' '), 'MM/DD/YYYY HH:mm').format( - 'YYYY-MM-DDTHH:mmZ' - ) + time: parseTime(activity.date, activity.time) }) } diff --git a/src/courier/usps.ts b/src/courier/usps.ts index 31b7674..55ebe88 100644 --- a/src/courier/usps.ts +++ b/src/courier/usps.ts @@ -21,9 +21,11 @@ function trackingInfo(number: string): TrackingRequest { } } -// The date cell comes in three shapes: "March 16, 2024,1:55 pm" (no space after the -// second comma), "March 7, 2024,9:15 pm" (unpadded day) and "March 13, 2024" (no time). -const DATE_FORMATS = ['MMMM D, YYYY,h:mm a', 'MMMM D, YYYY'] +// The date cell has appeared in several shapes over the years: "March 16, 2024,1:55 pm" +// (no space after the second comma), "March 7, 2024,9:15 pm" (unpadded day), +// "March 13, 2024" (no time) and, per #35, runs of whitespace in the middle of the +// string. Whitespace is collapsed first, then these formats cover the rest. +const DATE_FORMATS = ['MMMM D, YYYY, h:mm a', 'MMMM D, YYYY,h:mm a', 'MMMM D, YYYY'] function toStatus(message: string): Checkpoint['status'] { if (message.includes('Delivered')) { @@ -52,14 +54,9 @@ function parse(html: string): TraceResult { location: $el.find('.tb-location').text().trim(), message, status: toStatus(message), - time: dayjs( - $el - .find('.tb-date') - .text() - .trim() - .replace(/[\t\n]/g, ''), - DATE_FORMATS - ).format('YYYY-MM-DDTHH:mm') + time: dayjs($el.find('.tb-date').text().replace(/\s+/g, ' ').trim(), DATE_FORMATS).format( + 'YYYY-MM-DDTHH:mm' + ) } }) diff --git a/test/checkpoint-time.test.ts b/test/checkpoint-time.test.ts index c1d9cdb..2f59606 100644 --- a/test/checkpoint-time.test.ts +++ b/test/checkpoint-time.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import dayjs from '../src/dayjs.js' import { COURIER, type CourierCode, courier } from '../src/index.js' import prepare from './fixtures/prepare.js' @@ -73,6 +74,35 @@ describe('checkpoint timestamps', () => { assert.equal(result.checkpoints[12]?.time, '2024-03-07T21:15') }) + it('parses UPS afternoon times as PM', async () => { + const ups = courier(COURIER.UPS.CODE) + prepare(ups, 'DELIVEREDUPS') + + const result = await ups.trace('DELIVEREDUPS') + + // The fixture's first activity is "09/29/2020" + "12:29 P.M.". Reading the clock as + // 24-hour silently moved every P.M. event back twelve hours (#35). + assert.match(result.checkpoints[0]?.time ?? '', /T12:29/) + + const evening = result.checkpoints.find((c) => c.time.includes('2020-09-28')) + assert.ok(evening, 'expected an activity from 28 September') + // "10:53 P.M." must be 22:53, not 10:53. + assert.match(evening.time, /T22:53/) + }) + + it('parses USPS dates padded with extra whitespace', async () => { + // Reported in #35: the page has shipped runs of spaces inside the date string. + const spaced = 'November 17, 2017, 3:08 pm' + assert.equal( + dayjs(spaced.replace(/\s+/g, ' ').trim(), [ + 'MMMM D, YYYY, h:mm a', + 'MMMM D, YYYY,h:mm a', + 'MMMM D, YYYY' + ]).format('YYYY-MM-DDTHH:mm'), + '2017-11-17T15:08' + ) + }) + it('parses Royal Mail day-first dates', async () => { const royalmail = courier(COURIER.ROYALMAIL.CODE) prepare(royalmail, 'LBTRANSIT')