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>
182 lines
6 KiB
TypeScript
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}`);
|
|
});
|