From 215d807b6ff6254121ddca6d550b65251667bbfb Mon Sep 17 00:00:00 2001 From: Aswin Date: Fri, 4 Sep 2026 09:53:35 +0530 Subject: [PATCH] feat(carriers): add ST Courier tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ST Courier publishes no developer API, so this replicates the two-step CodeIgniter flow their own site uses: POST /track/doCheck stores the AWB against a fresh ci_session and returns only an ack, then GET /track/shipment carries that cookie to get the server-rendered summary table and scan timeline. The AWB lives in the session rather than the URL, so step 2 without step 1's cookie just returns the empty form. Parsing follows the Blue Dart scraper's approach — loose, label-based selectors rather than ST Courier's randomised CSS class names. Not-found is detected via the absence of the "Status of AWB No." heading; the page returns HTTP 200 either way, and the word "Invalid" is unusable as a signal because it appears in inline JS validation on every page, successful ones included. Their render order for multiple scans isn't documented and a single-scan shipment can't reveal it, so parseScans sorts by parsed timestamp to guarantee the oldest-first contract that Timeline and the poller expect. ST Courier exposes no expected-delivery date, so estimatedDelivery is left unset. Co-Authored-By: Claude Opus 5 --- README.md | 18 +++ src/app/page.tsx | 2 + src/carriers/registry.ts | 2 + src/carriers/stcourier.ts | 232 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 src/carriers/stcourier.ts diff --git a/README.md b/README.md index 721cadc..2f097a2 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,24 @@ history shows the request shape. Polling every 15–30 minutes per shipment is plenty. +### ST Courier + +No credentials required. ST Courier publishes no developer API, so the carrier +replicates the two-step flow their own site uses: + +1. `POST https://stcourier.com/track/doCheck` with form field `awb_no`, which + stores the AWB against a new `ci_session` and returns only + `{"code":200,"msg":"Track Shipment"}`. +2. `GET https://stcourier.com/track/shipment` carrying that cookie, which + renders the summary table and scan timeline server-side. + +The AWB lives in the session rather than the URL, so step 2 without the cookie +from step 1 just returns the empty search form. Not-found is signalled by the +absence of the "Status of AWB No." heading — the page still returns HTTP 200. + +AWBs are numeric and capped at 11 digits by their form. ST Courier exposes no +expected-delivery date, so `estimatedDelivery` is always unset. + ## Adding a carrier 1. Create `src/carriers/.ts` exporting a `Carrier` (see `types.ts`). diff --git a/src/app/page.tsx b/src/app/page.tsx index c05e527..a4de814 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,6 +14,7 @@ const CARRIER_LABELS: Record = { bluedart: "Blue Dart", shiprocket: "Shiprocket", delhivery: "Delhivery", + stcourier: "ST Courier", }; function labelForCarrier(id: string): string { return CARRIER_LABELS[id] ?? id; @@ -145,6 +146,7 @@ export default function Home() { + = { [bluedart.id]: bluedart, [shiprocket.id]: shiprocket, [delhivery.id]: delhivery, + [stcourier.id]: stcourier, }; export function getCarrier(id: string): Carrier | undefined { diff --git a/src/carriers/stcourier.ts b/src/carriers/stcourier.ts new file mode 100644 index 0000000..9eafbbf --- /dev/null +++ b/src/carriers/stcourier.ts @@ -0,0 +1,232 @@ +import { Carrier, CarrierError, ShipmentStatus, TrackingEvent, TrackingResult } from "./types"; + +// ST Courier (stcourier.com) publishes no developer API — no docs, no keys, no +// portal. Their own site tracks via a two-step CodeIgniter flow that we +// replicate here: +// +// 1. POST /track/doCheck (form field `awb_no`) +// -> {"code":200,"msg":"Track Shipment"} and a `ci_session` Set-Cookie. +// -> {"code":400,"msg":"

The AWB Number field must contain only +// numbers.

"} for malformed input. +// The POST only *stores* the AWB against the session; it returns no data. +// 2. GET /track/shipment with that `ci_session` cookie +// -> server-rendered HTML holding the summary table and scan timeline. +// +// The AWB lives in the session, not the URL, so step 2 must carry the cookie +// minted by step 1 — a bare GET returns the empty search form. +// +// Not-found is signalled only by the *absence* of the "Status of AWB No." +// heading; the page still returns HTTP 200 and the same shell. Do not key off +// the word "Invalid" — that appears in inline JS validation strings on every +// page, including successful ones. +// +// Risk: same as the Blue Dart scraper — a redesign breaks parsing. Selectors +// are deliberately loose (label text and structural position, not the +// randomised CSS class names like `D2Q59l54` that ST Courier emits). + +const BASE = "https://stcourier.com"; +const CHECK_URL = `${BASE}/track/doCheck`; +const RESULT_URL = `${BASE}/track/shipment`; +const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +function mapStatus(text: string): ShipmentStatus { + const t = text.toLowerCase(); + if (t.includes("delivered") && !t.includes("undelivered") && !t.includes("not delivered")) return "delivered"; + if (t.includes("out for delivery") || t.includes("out for del")) return "out_for_delivery"; + if (t.includes("rto") || t.includes("returned") || t.includes("return to")) return "returned"; + if ( + t.includes("undelivered") || + t.includes("not delivered") || + t.includes("refused") || + t.includes("damage") || + t.includes("hold") || + t.includes("address") || + t.includes("closed") || + t.includes("unable") + ) { + return "exception"; + } + if (t.includes("picked") || t.includes("pickup") || t.includes("pick up") || t.includes("booked")) return "picked_up"; + if ( + t.includes("in transit") || + t.includes("transit") || + t.includes("forwarded") || + t.includes("processed") || + t.includes("received") || + t.includes("dispatch") || + t.includes("bagged") || + t.includes("arrived") || + t.includes("departed") + ) { + return "in_transit"; + } + return "unknown"; +} + +function stripTags(s: string): string { + return s + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/\s+/g, " ") + .trim(); +} + +// The summary table renders as LabelValue. +// Labels carry ST Courier's own typos and trailing spaces ("Orgin SRC", +// "Destination "), so match leniently and let the caller pass what it sees. +function fieldByLabel(html: string, label: string): string | undefined { + const re = new RegExp( + `]*>\\s*${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\s*]*>([\\s\\S]*?)`, + "i", + ); + const m = html.match(re); + if (!m) return undefined; + const v = stripTags(m[1]); + return v.length ? v : undefined; +} + +// Split a fragment on
into its non-empty text lines. +function brLines(html: string): string[] { + return html + .split(//i) + .map(stripTags) + .filter((l) => l.length > 0); +} + +// Each scan is a `tl04` block holding, in document order: a date/time cell, an +// icon cell (strips to empty), and a description cell. Within a cell,
+// separates "Sep 03, 2026" / "10:49 PM" and "" / "". +function parseScans(html: string): TrackingEvent[] { + const blocks = html.split(/class="[^"]*\btl04\b/i).slice(1); + const events: TrackingEvent[] = []; + + for (const block of blocks) { + // Leaf divs only (no nested
), so we get the content cells directly. + const cells = Array.from(block.matchAll(/]*>((?:(?!/gi)) + .map((m) => m[1]) + .filter((c) => stripTags(c).length > 0); + if (cells.length < 2) continue; + + const when = brLines(cells[0]); + const what = brLines(cells[1]); + const description = what[0]; + if (!description) continue; + + events.push({ + timestamp: when.join(" ").trim(), + status: mapStatus(description), + location: what[1] || undefined, + description, + }); + } + + // ST Courier's render order isn't documented and a single-scan shipment can't + // reveal it, so derive the order from the timestamps instead of assuming. + // Callers require oldest-first (events[last] = latest). + const times = events.map((e) => Date.parse(e.timestamp)); + if (times.every((t) => Number.isFinite(t))) { + return events + .map((e, i) => ({ e, t: times[i] })) + .sort((a, b) => a.t - b.t) + .map(({ e }) => e); + } + return events; +} + +// Cloudflare Workers and Node expose getSetCookie(); fall back to the folded +// header for any runtime that doesn't. +function sessionCookie(res: Response): string | undefined { + const raw: string[] = res.headers.getSetCookie?.() ?? []; + const headers = raw.length ? raw : [res.headers.get("set-cookie") ?? ""]; + for (const h of headers) { + const m = h.match(/ci_session=([^;]+)/); + if (m) return m[1]; + } + return undefined; +} + +export const stcourier: Carrier = { + id: "stcourier", + name: "ST Courier", + async track(trackingNumber: string): Promise { + const cleaned = trackingNumber.trim(); + // Their form caps input at 11 chars and the server rejects non-digits. + if (!/^[0-9]{6,11}$/.test(cleaned)) { + throw new CarrierError("Invalid ST Courier AWB format.", "invalid_input", 400); + } + + const checkRes = await fetch(CHECK_URL, { + method: "POST", + headers: { + "User-Agent": UA, + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + Referer: `${BASE}/`, + Origin: BASE, + }, + body: `awb_no=${encodeURIComponent(cleaned)}`, + cache: "no-store", + }); + + if (checkRes.status === 429) throw new CarrierError("ST Courier rate-limited", "rate_limited", 429); + if (!checkRes.ok) throw new CarrierError(`ST Courier upstream error (${checkRes.status})`, "upstream_error", 502); + + const check = (await checkRes.json().catch(() => null)) as { code?: number; msg?: string } | null; + if (check?.code === 400) { + throw new CarrierError(stripTags(check.msg ?? "") || "ST Courier rejected the AWB.", "invalid_input", 400); + } + if (check?.code !== 200) { + throw new CarrierError("Unexpected ST Courier response.", "upstream_error", 502); + } + + const session = sessionCookie(checkRes); + if (!session) { + throw new CarrierError("ST Courier did not issue a tracking session.", "upstream_error", 502); + } + + const res = await fetch(RESULT_URL, { + method: "GET", + headers: { + "User-Agent": UA, + Accept: "text/html", + Referer: `${BASE}/`, + Cookie: `ci_session=${session}`, + }, + cache: "no-store", + }); + + if (res.status === 429) throw new CarrierError("ST Courier rate-limited", "rate_limited", 429); + if (!res.ok) throw new CarrierError(`ST Courier upstream error (${res.status})`, "upstream_error", 502); + + const html = await res.text(); + + // Existence is signalled by the result heading, which echoes the AWB. + const heading = html.match(/Status of AWB No\.[\s\S]{0,200}?]*>\s*([0-9]+)/i); + if (!heading) { + throw new CarrierError("Tracking number not found", "not_found", 404); + } + + const events = parseScans(html); + const current = fieldByLabel(html, "Current Status"); + const origin = fieldByLabel(html, "Orgin SRC") ?? fieldByLabel(html, "Origin SRC"); + const destination = fieldByLabel(html, "Destination"); + const latest = events[events.length - 1]; + + return { + carrier: "stcourier", + trackingNumber: cleaned, + // Prefer the summary cell — it's ST Courier's own rollup — and fall back + // to the newest scan when the table is missing. + status: current ? mapStatus(current) : latest ? latest.status : "unknown", + origin, + destination, + events, + fetchedAt: new Date().toISOString(), + raw: { source: "scrape", url: RESULT_URL, currentStatus: current ?? null }, + }; + }, +};