From c571595ae90bcea74e55ffd12a61559577ef52bd Mon Sep 17 00:00:00 2001 From: Vint Sanghyeok Lee Date: Sun, 3 Dec 2023 12:56:17 +0000 Subject: [PATCH] carrier: Add kr.goodstoluck --- .../DefaultCarrierRegistry.ts | 2 + .../core/src/carriers/kr.goodstoluck/index.ts | 199 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 packages/core/src/carriers/kr.goodstoluck/index.ts diff --git a/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts b/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts index b39e11d..213bcd7 100644 --- a/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts +++ b/packages/core/src/carrier-registry/DefaultCarrierRegistry.ts @@ -28,6 +28,7 @@ import { UPS } from "../carriers/us.ups"; import { USPS } from "../carriers/us.usps"; import { ActcoreOceanInbound } from "../carriers/kr.actcore.ocean-inbound"; import { CoupangLogisticsServices } from "../carriers/kr.coupangls"; +import { GoodsToLuck } from "../carriers/kr.goodstoluck"; interface DefaultCarrierRegistryConfig { carriers: Record< @@ -56,6 +57,7 @@ class DefaultCarrierRegistry implements CarrierRegistry { await this.register(new Cway()); await this.register(new Daesin()); await this.register(new KoreaPost()); + await this.register(new GoodsToLuck()); await this.register(new CarrierAlias("kr.homepick", new Hanjin())); await this.register(new Hanjin()); await this.register(new HonamLogis()); diff --git a/packages/core/src/carriers/kr.goodstoluck/index.ts b/packages/core/src/carriers/kr.goodstoluck/index.ts new file mode 100644 index 0000000..e4e9174 --- /dev/null +++ b/packages/core/src/carriers/kr.goodstoluck/index.ts @@ -0,0 +1,199 @@ +import { type Logger } from "winston"; +import { parsePhoneNumber } from "libphonenumber-js"; +import { + Carrier, + type CarrierTrackInput, + type TrackInfo, + type TrackEvent, + TrackEventStatusCode, + type ContactInfo, +} from "../../core"; +import { rootLogger } from "../../logger"; +import { NotFoundError } from "../../core/errors"; +import { DateTime } from "luxon"; +import { JSDOM } from "jsdom"; +import { type CarrierUpstreamFetcher } from "../../carrier-upstream-fetcher/CarrierUpstreamFetcher"; + +const carrierLogger = rootLogger.child({ + carrierId: "kr.goodstoluck", +}); + +class GoodsToLuck extends Carrier { + readonly carrierId = "kr.goodstoluck"; + + public async track(input: CarrierTrackInput): Promise { + return await new GoodsToLuckTrackScraper( + this.upstreamFetcher, + input.trackingNumber + ).track(); + } +} + +class GoodsToLuckTrackScraper { + private readonly logger: Logger; + + constructor( + readonly upstreamFetcher: CarrierUpstreamFetcher, + readonly trackingNumber: string + ) { + this.logger = carrierLogger.child({ trackingNumber }); + } + + public async track(): Promise { + const response = await this.upstreamFetcher.fetch( + "http://www.goodstoluck.co.kr/tracking/tracking_proc.php", + { + method: "POST", + headers: [ + ["content-type", "application/x-www-form-urlencoded; charset=UTF-8"], + ], + body: new URLSearchParams({ + RetrieveFlag: "SEARCH", + Txt_word: this.trackingNumber, + }).toString(), + } + ); + + const traceResponseHtmlText = await response.text(); + this.logger.debug("traceResponseHtmlText", { + html: traceResponseHtmlText, + }); + + const dom = new JSDOM(traceResponseHtmlText); + const { document } = dom.window; + + const notFound = document.querySelector("table.result_none_tb"); + if (notFound !== null) { + throw new NotFoundError( + notFound.textContent?.replace(/\s+/g, " ")?.trim() ?? "Not found" + ); + } + + const tables = document.querySelectorAll("table"); + + if (tables.length !== 2) { + this.logger.warn("tables.length !== 2"); + } + + const info = tables[0].querySelectorAll("tr:nth-child(2) > td"); + const eventTrs = tables[1].querySelectorAll("tr:not(:first-child)"); + + const events: TrackEvent[] = []; + for (const tr of eventTrs) { + const event = this.parseEvent(tr); + if (event !== null) { + events.push(event); + } + } + + return { + events, + sender: { + name: info[1].textContent?.replace(/\s+/g, " ")?.trim() ?? null, + location: null, + phoneNumber: null, + carrierSpecificData: new Map(), + }, + recipient: { + name: info[2].textContent?.replace(/\s+/g, " ")?.trim() ?? null, + location: null, + phoneNumber: null, + carrierSpecificData: new Map(), + }, + carrierSpecificData: new Map(), + }; + } + + private parseEvent(tr: Element): TrackEvent | null { + const tds = tr.querySelectorAll("td"); + + const time = this.parseTime( + tds[0].textContent?.replace(/\s+/g, " ")?.trim() ?? null + ); + + const location = tds[1].textContent?.replace(/\s+/g, " ")?.trim() ?? null; + const phone = tds[2].textContent?.replace(/\s+/g, " ")?.trim() ?? null; + const statusText = tds[3].textContent?.replace(/\s+/g, " ")?.trim() ?? null; + + const contact = this.parseEventContact(location, phone); + return { + status: { + code: this.parseStatusCode(statusText), + name: statusText, + carrierSpecificData: new Map(), + }, + time, + location: contact?.location ?? null, + contact, + description: `${statusText ?? ""} - ${location ?? ""}`, + carrierSpecificData: new Map(), + }; + } + + private parseEventContact( + locationName: string | null, + phoneNumber: string | null + ): ContactInfo | null { + let phoneNumberObj = null; + if (phoneNumber != null) { + try { + phoneNumberObj = parsePhoneNumber(phoneNumber, "KR"); + } catch (e) { + this.logger.warn("Failed to parse phone number (parseContact)", { + text: phoneNumber, + error: e, + }); + } + } + + return { + name: locationName ?? null, + phoneNumber: phoneNumberObj, + location: { + name: locationName ?? null, + countryCode: "KR", + postalCode: null, + carrierSpecificData: new Map(), + }, + carrierSpecificData: new Map(), + }; + } + + private parseStatusCode(status: string | null): TrackEventStatusCode { + switch (status) { + case "간선하차": + case "간선상차": + return TrackEventStatusCode.InTransit; + case "배송출발": + return TrackEventStatusCode.OutForDelivery; + case "배송완료": + 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.fromFormat(time, "yyyy-MM-dd HH:mm", { + zone: "Asia/Seoul", + }); + if (!result.isValid) { + this.logger.warn("time parse error", { + inputTime: time, + invalidReason: result.invalidReason, + }); + return result; + } + + return result; + } +} + +export { GoodsToLuck };