pelagia-portal/GstService/src/index.ts
Hardik 2e6678f829 fix(gst): correct microservice default port and captcha field name
Default port changed 3002 → 3003 in the GstService and both proxy
routes.  The vendor-form was reading `captchaB64` from the API
response but the GstService returns `captchaBase64`, so the CAPTCHA
image was never displayed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:07:31 +05:30

182 lines
6 KiB
TypeScript

import express from "express";
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
const PORT = Number(process.env.PORT ?? 3003);
const SESSION_TTL_MS = 3 * 60 * 1000; // 3 min
// ── Singleton browser ─────────────────────────────────────────────────────────
let _browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (!_browser || !_browser.isConnected()) {
console.log("[gst-service] Launching Chromium…");
_browser = await chromium.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
console.log("[gst-service] Chromium ready.");
}
return _browser;
}
// ── Session store ─────────────────────────────────────────────────────────────
type Session = {
ctx: BrowserContext;
page: Page;
captchaB64: string;
expires: number;
};
const sessions = new Map<string, Session>();
function makeId() {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
function pruneExpired() {
const now = Date.now();
for (const [id, s] of sessions) {
if (s.expires < now) {
s.ctx.close().catch(() => {});
sessions.delete(id);
}
}
}
// ── Express app ───────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
// Allow the Pelagia app to call us (CORS)
app.use((_req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
next();
});
app.get("/health", (_req, res) => res.json({ ok: true }));
/**
* GET /captcha
* Loads the GST search page, fetches the CAPTCHA image.
* Returns: { sessionId, captchaBase64 }
*/
app.get("/captcha", async (_req, res) => {
pruneExpired();
try {
const browser = await getBrowser();
const ctx = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
viewport: { width: 1280, height: 900 },
});
const page = await ctx.newPage();
await page.goto("https://services.gst.gov.in/services/searchtp", {
waitUntil: "networkidle",
timeout: 30000,
});
const captchaB64: string = await page.evaluate(() =>
fetch("/services/captcha", { headers: { Accept: "image/png,image/*" } })
.then((r) => r.blob())
.then(
(blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve((reader.result as string).split(",")[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
})
)
);
if (!captchaB64) throw new Error("Empty CAPTCHA response");
const sessionId = makeId();
sessions.set(sessionId, {
ctx,
page,
captchaB64,
expires: Date.now() + SESSION_TTL_MS,
});
console.log(`[gst-service] Session ${sessionId} created`);
res.json({ sessionId, captchaBase64: captchaB64 });
} catch (e) {
console.error("[gst-service] /captcha error:", e);
res.status(502).json({ error: String(e) });
}
});
/**
* POST /search
* Body: { sessionId, gstin, captcha }
* Returns taxpayer data or { error }
*/
app.post("/search", async (req, res) => {
const { sessionId, gstin, captcha } = req.body ?? {};
if (!sessionId || !gstin || !captcha) {
return res.status(400).json({ error: "sessionId, gstin and captcha are required" });
}
const session = sessions.get(sessionId);
if (!session || session.expires < Date.now()) {
sessions.delete(sessionId);
return res.status(410).json({ error: "Session expired — please fetch a new CAPTCHA." });
}
try {
const raw: Record<string, unknown> = await session.page.evaluate(
([g, c]: [string, string]) =>
fetch("/services/api/search/tp", {
method: "POST",
headers: {
Accept: "application/json, text/plain",
"Content-Type": "application/json;charset=UTF-8",
},
body: JSON.stringify({ gstin: g, captcha: c }),
})
.then((r) => r.json())
.catch((e: Error) => ({ error: e.message })),
[gstin, captcha] as [string, string]
);
// Always close session after use
session.ctx.close().catch(() => {});
sessions.delete(sessionId);
console.log(`[gst-service] Session ${sessionId} closed`);
if (raw.error) return res.status(502).json({ error: raw.error });
if (raw.errorCode === "SWEB_9034") return res.status(422).json({ error: "Wrong CAPTCHA — please try again." });
if (raw.errorCode) return res.status(422).json({ error: `GST portal error: ${raw.errorCode}` });
if (!raw.gstin) return res.status(422).json({ error: "No data found for that GSTIN." });
// Parse address / pincode
const pradr = (raw.pradr as Record<string, unknown>)?.adr as string ?? "";
const pincodeMatch = pradr.match(/\b(\d{6})\b/);
res.json({
legalName: raw.lgnm ?? "",
tradeName: raw.tradeNam ?? raw.lgnm ?? "",
address: pradr,
state: String(raw.stj ?? "").split(",")[0].replace(/^State\s*-\s*/i, ""),
pincode: pincodeMatch?.[1] ?? "",
gstin: raw.gstin,
status: raw.sts ?? "",
businessType: raw.ctb ?? raw.dty ?? "",
registrationDate: raw.rgdt ?? "",
});
} catch (e) {
console.error("[gst-service] /search error:", e);
res.status(500).json({ error: String(e) });
}
});
app.listen(PORT, () => {
console.log(`[gst-service] Listening on http://localhost:${PORT}`);
});