diff --git a/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts b/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts index 3d54a23..32c0e07 100644 --- a/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts +++ b/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts @@ -36,6 +36,7 @@ import { GoodsToLuck } from "../carriers/kr.goodstoluck"; import { CainiaoGlobal } from "../carriers/cn.cainiao.global"; import { Pantos } from "../carriers/kr.epantos"; import { LotteGlobal } from "../carriers/kr.lotte.global"; +import { Homepick } from "../carriers/kr.homepick"; interface DefaultCarrierRegistryConfig { carriers: Record< @@ -69,7 +70,7 @@ class DefaultCarrierRegistry implements CarrierRegistry { await this.register(new KoreaPost()); await this.register(new KoreaPostEMS()); await this.register(new GoodsToLuck()); - await this.register(new CarrierAlias("kr.homepick", new Hanjin())); + await this.register(new Homepick()); await this.register(new Hanjin()); await this.register(new HonamLogis()); await this.register(new IlyangLogis()); diff --git a/packages/core/src/carriers/kr.homepick/HomepickAPISchemas.ts b/packages/core/src/carriers/kr.homepick/HomepickAPISchemas.ts new file mode 100644 index 0000000..c7b0919 --- /dev/null +++ b/packages/core/src/carriers/kr.homepick/HomepickAPISchemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +const DeliveryResponseDataDeliveryOrderStatusHistoryListItemSchema = z.object({ + statusDateTime: z.string(), + trackingStatus: z.string(), + trackingStatusName: z.string(), + tmsStatusName: z.string().nullable(), + location: z.string().nullable(), + contents: z.string().nullable(), +}); + +const DeliveryResponseDataDeliverySchema = z.object({ + orderStatusHistoryList: z.array( + DeliveryResponseDataDeliveryOrderStatusHistoryListItemSchema + ), +}); + +const DeliveryResponseDataSchema = z.object({ + cancelTypeList: z.unknown(), + orderCancelType: z.string(), + delivery: DeliveryResponseDataDeliverySchema, +}); + +const DeliveryResponseSchema = z.object({ + success: z.boolean(), + message: z.string().nullable(), + responseDateTime: z.string(), + data: DeliveryResponseDataSchema, +}); + +type DeliveryResponseDataDeliveryOrderStatusHistoryListItem = z.infer< + typeof DeliveryResponseDataDeliveryOrderStatusHistoryListItemSchema +>; +type DeliveryResponse = z.infer; + +export { + DeliveryResponseSchema, + type DeliveryResponse, + DeliveryResponseDataDeliveryOrderStatusHistoryListItemSchema, + type DeliveryResponseDataDeliveryOrderStatusHistoryListItem, +}; diff --git a/packages/core/src/carriers/kr.homepick/index.ts b/packages/core/src/carriers/kr.homepick/index.ts new file mode 100644 index 0000000..c625e79 --- /dev/null +++ b/packages/core/src/carriers/kr.homepick/index.ts @@ -0,0 +1,168 @@ +import { type Logger } from "winston"; +import { + Carrier, + type CarrierTrackInput, + type TrackInfo, + type TrackEvent, + TrackEventStatusCode, +} from "../../core"; +import { rootLogger } from "../../logger"; +import { BadRequestError } from "../../core/errors"; +import { DateTime } from "luxon"; +import { type CarrierUpstreamFetcher } from "../../carrier-upstream-fetcher/CarrierUpstreamFetcher"; +import { + type DeliveryResponse, + type DeliveryResponseDataDeliveryOrderStatusHistoryListItem, + DeliveryResponseSchema, +} from "./HomepickAPISchemas"; + +const carrierLogger = rootLogger.child({ + carrierId: "kr.homepick", +}); + +class Homepick extends Carrier { + readonly carrierId = "kr.homepick"; + + public async track(input: CarrierTrackInput): Promise { + return await new HomepickTrackScraper( + this.upstreamFetcher, + input.trackingNumber + ).track(); + } +} + +class HomepickTrackScraper { + private readonly logger: Logger; + + constructor( + readonly upstreamFetcher: CarrierUpstreamFetcher, + readonly trackingNumber: string + ) { + this.logger = carrierLogger.child({ trackingNumber }); + } + + public async track(): Promise { + const queryString = new URLSearchParams({ + keyword: this.trackingNumber, + }).toString(); + + const response = await this.upstreamFetcher.fetch( + `https://www.homepick.com/user/api/delivery/universalInquiry?${queryString}` + ); + + const universalInquiryResponse = await response.json(); + + if (universalInquiryResponse.success === false) { + throw new BadRequestError(universalInquiryResponse.message); + } + + const orderBoxId = universalInquiryResponse.data; + const deliveryResponse = await this.upstreamFetcher.fetch( + `https://www.homepick.com/user/api/delivery/${orderBoxId}` + ); + + const deliveryResponseBody: DeliveryResponse = + await deliveryResponse.json(); + this.logger.debug("deliveryResponseBody", { + deliveryResponseBody, + }); + + const safeParseResult = + await DeliveryResponseSchema.strict().safeParseAsync( + deliveryResponseBody + ); + + if (!safeParseResult.success) { + this.logger.warn("deliveryResponseBody parse failed (strict)", { + error: safeParseResult.error, + deliveryResponseBody, + }); + } + + const events: TrackEvent[] = []; + for (const event of deliveryResponseBody.data.delivery + .orderStatusHistoryList) { + events.unshift(this.parseEvent(event)); + } + + return { + events, + sender: { + name: null, + location: null, + phoneNumber: null, + carrierSpecificData: new Map(), + }, + recipient: { + name: null, + location: null, + phoneNumber: null, + carrierSpecificData: new Map(), + }, + carrierSpecificData: new Map(), + }; + } + + private parseEvent( + event: DeliveryResponseDataDeliveryOrderStatusHistoryListItem + ): TrackEvent { + return { + status: { + code: this.parseStatusCode(event.trackingStatus), + name: event.tmsStatusName ?? event.trackingStatusName ?? null, + carrierSpecificData: new Map(), + }, + time: this.parseTime(event.statusDateTime), + location: { + name: event.location ?? null, + countryCode: "KR", + postalCode: null, + carrierSpecificData: new Map(), + }, + contact: null, + description: event.contents ?? null, + carrierSpecificData: new Map(), + }; + } + + private parseStatusCode(status: string | null): TrackEventStatusCode { + switch (status) { + case "RECEIVED": + return TrackEventStatusCode.InformationReceived; + case "TERMINAL_IN": + return TrackEventStatusCode.InTransit; + case "MOVING": + return TrackEventStatusCode.InTransit; + case "DLV_START": + return TrackEventStatusCode.OutForDelivery; + case "COMPLETED": + return TrackEventStatusCode.Delivered; + } + + this.logger.warn("Unexpected status code", { + status, + }); + + return TrackEventStatusCode.Unknown; + } + + private parseTime(time: string | null): DateTime | null { + if (time === null) { + return null; + } + + const result = DateTime.fromISO(time, { setZone: true }); + + if (!result.isValid) { + this.logger.warn("time parse error", { + inputTime: time, + invalidReason: result.invalidReason, + }); + return result; + } + + return result; + } +} + +export { Homepick };