feat(gst): replace API-key lookup with Playwright microservice
Problem: GST portal's public taxpayer search (services.gst.gov.in/
searchtp) now requires human CAPTCHA verification but no login.
The BIG-IP WAF blocks direct Node.js HTTP clients via TLS
fingerprinting; Playwright (real Chromium) bypasses it successfully.
Confirmed working: GSTIN 27AAHCP5787B1Z6 → full PELAGIA MARINE
SERVICES data including address, jurisdiction, filing status.
GstService/ (new standalone microservice):
- src/index.ts: Express + Playwright singleton browser
GET /health → { ok: true }
GET /captcha → launches browser, loads GST portal, fetches
CAPTCHA image from same origin (sets CaptchaCookie),
stores BrowserContext in session map (3 min TTL)
→ { sessionId, captchaBase64 }
POST /search → { sessionId, gstin, captcha } → submits form
via page.evaluate fetch() using live browser session,
closes context, returns parsed taxpayer data
- package.json, tsconfig.json, npm install
- src/test-lookup.ts: interactive CLI test (prompted user for captcha)
App changes:
- Remove playwright dep from Next.js app (was incorrectly added)
- Remove lib/gst-lookup.ts (sandbox.co.in placeholder — unused)
- Remove lib/gst-browser.ts (Playwright singleton — moved to service)
- app/api/gst/captcha/route.ts: thin proxy → GST_SERVICE_URL/captcha
- app/api/gst/route.ts: thin proxy POST → GST_SERVICE_URL/search
- vendor-form.tsx: two-step captcha UI
Step 1: "Look up" → calls /api/gst/captcha → shows PNG inline
Step 2: user types 6 digits → "Verify" → calls /api/gst → fills
form (name, address, lat/lng from Nominatim geocoding)
Wrong captcha → SWEB_9034 error with retry option
- .env.example: GST_SERVICE_URL=http://localhost:3002
Start the microservice: cd GstService && npm run dev
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bea798324c
commit
f372fae953
15 changed files with 2038 additions and 96 deletions
|
|
@ -34,3 +34,9 @@ R2_PUBLIC_URL=https://your-bucket.your-account.r2.cloudflarestorage.com
|
|||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
|
||||
EMAIL_FROM=noreply@pelagiaportal.com
|
||||
EMAIL_FROM_NAME="Pelagia Portal"
|
||||
|
||||
# ── GST Lookup microservice ───────────────────────────────────
|
||||
# Run the GstService/ microservice alongside the app.
|
||||
# Development default (localhost:3002) is used if this is unset.
|
||||
# Start the service with: cd GstService && npm run dev
|
||||
GST_SERVICE_URL=http://localhost:3002
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ type VendorRow = {
|
|||
|
||||
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||
|
||||
type GstData = { legalName: string; tradeName: string; address: string; state: string; pincode: string; lat: number | null; lng: number | null; error?: string };
|
||||
type GstResult = {
|
||||
legalName: string; tradeName: string; address: string; state: string;
|
||||
pincode: string; gstin: string; status: string; businessType: string;
|
||||
registrationDate: string; lat: number | null; lng: number | null;
|
||||
};
|
||||
|
||||
function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
|
||||
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
||||
|
|
@ -21,39 +25,131 @@ function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
|
|||
const [address, setAddress] = useState(vendor?.address ?? "");
|
||||
const [lat, setLat] = useState("");
|
||||
const [lng, setLng] = useState("");
|
||||
const [looking, setLooking] = useState(false);
|
||||
const [gstError, setGstError] = useState("");
|
||||
|
||||
async function lookupGst() {
|
||||
// CAPTCHA flow state
|
||||
const [captchaStep, setCaptchaStep] = useState<"idle" | "loading" | "ready" | "verifying">("idle");
|
||||
const [captchaB64, setCaptchaB64] = useState("");
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [gstError, setGstError] = useState("");
|
||||
const [gstSuccess, setGstSuccess] = useState("");
|
||||
|
||||
async function fetchCaptcha() {
|
||||
if (gstin.length < 15) { setGstError("Enter a valid 15-character GSTIN"); return; }
|
||||
setLooking(true); setGstError("");
|
||||
setCaptchaStep("loading"); setGstError(""); setGstSuccess(""); setCaptchaAnswer("");
|
||||
try {
|
||||
const res = await fetch(`/api/gst?gstin=${encodeURIComponent(gstin)}`);
|
||||
const data: GstData = await res.json();
|
||||
if (data.error) { setGstError(data.error); return; }
|
||||
if (!name) setName(data.tradeName || data.legalName);
|
||||
setAddress([data.address, data.state, data.pincode].filter(Boolean).join(", "));
|
||||
const res = await fetch("/api/gst/captcha");
|
||||
const data = await res.json();
|
||||
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
|
||||
setCaptchaB64(data.captchaB64);
|
||||
setSessionId(data.sessionId);
|
||||
setCaptchaStep("ready");
|
||||
} catch { setGstError("Failed to load CAPTCHA"); setCaptchaStep("idle"); }
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
if (!captchaAnswer || captchaAnswer.length !== 6) { setGstError("Enter the 6-digit CAPTCHA"); return; }
|
||||
setCaptchaStep("verifying"); setGstError("");
|
||||
try {
|
||||
const res = await fetch("/api/gst", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
||||
});
|
||||
const data: GstResult & { error?: string } = await res.json();
|
||||
if (data.error) {
|
||||
setGstError(data.error);
|
||||
// Wrong captcha → allow retry with fresh captcha
|
||||
if (data.error.includes("CAPTCHA") || data.error.includes("captcha")) {
|
||||
setCaptchaStep("idle");
|
||||
} else {
|
||||
setCaptchaStep("idle");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Fill form fields
|
||||
setName(data.tradeName || data.legalName);
|
||||
setAddress(data.address);
|
||||
if (data.lat) setLat(String(data.lat));
|
||||
if (data.lng) setLng(String(data.lng));
|
||||
} catch { setGstError("Lookup failed — check network and API key"); }
|
||||
finally { setLooking(false); }
|
||||
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
||||
setCaptchaStep("idle");
|
||||
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* GSTIN + captcha flow */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">
|
||||
GSTIN <span className="text-neutral-400 font-normal">(recommended — auto-fills name, address & location)</span>
|
||||
GSTIN <span className="text-neutral-400 font-normal">(auto-fills name, address & location from GST portal)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input name="gstin" value={gstin} onChange={(e) => setGstin(e.target.value.toUpperCase())}
|
||||
className={INPUT + " font-mono tracking-widest"} placeholder="e.g. 27AAACG1840M1ZL" maxLength={15} />
|
||||
<button type="button" onClick={lookupGst} disabled={looking || gstin.length < 15}
|
||||
className="shrink-0 rounded-lg border border-primary-300 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50 whitespace-nowrap">
|
||||
{looking ? "Looking up…" : "Look up"}
|
||||
<input
|
||||
name="gstin"
|
||||
value={gstin}
|
||||
onChange={(e) => { setGstin(e.target.value.toUpperCase()); setCaptchaStep("idle"); setGstError(""); setGstSuccess(""); }}
|
||||
className={INPUT + " font-mono tracking-widest"}
|
||||
placeholder="e.g. 27AAHCP5787B1Z6"
|
||||
maxLength={15}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchCaptcha}
|
||||
disabled={captchaStep === "loading" || captchaStep === "verifying" || gstin.length < 15}
|
||||
className="shrink-0 rounded-lg border border-primary-300 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50 whitespace-nowrap"
|
||||
>
|
||||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CAPTCHA challenge */}
|
||||
{captchaStep === "ready" && captchaB64 && (
|
||||
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
||||
<p className="text-xs text-neutral-600">Enter the code shown in the image to verify with the GST portal:</p>
|
||||
<img
|
||||
src={`data:image/png;base64,${captchaB64}`}
|
||||
alt="CAPTCHA"
|
||||
className="rounded border border-neutral-200 bg-white"
|
||||
style={{ imageRendering: "pixelated", height: 48 }}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={captchaAnswer}
|
||||
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="6 digits"
|
||||
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitSearch}
|
||||
disabled={captchaAnswer.length !== 6}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchCaptcha}
|
||||
className="text-xs text-neutral-500 hover:underline"
|
||||
>
|
||||
New image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{captchaStep === "verifying" && (
|
||||
<p className="mt-1 text-xs text-neutral-500">Verifying with GST portal…</p>
|
||||
)}
|
||||
|
||||
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
||||
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
|
|
|||
18
App/pelagia-portal/app/api/gst/captcha/route.ts
Normal file
18
App/pelagia-portal/app/api/gst/captcha/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { auth } from "@/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const GST_SERVICE = process.env.GST_SERVICE_URL ?? "http://localhost:3002";
|
||||
|
||||
/** Proxy: load GST portal page + fetch CAPTCHA → { sessionId, captchaBase64 } */
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GST_SERVICE}/captcha`, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.ok ? 200 : 502 });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: `GST service unavailable: ${String(e)}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,28 @@
|
|||
import { auth } from "@/auth";
|
||||
import { lookupGstin } from "@/lib/gst-lookup";
|
||||
import { geocodePincode } from "@/lib/geo";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const GST_SERVICE = process.env.GST_SERVICE_URL ?? "http://localhost:3002";
|
||||
|
||||
/** Proxy: submit GSTIN + captcha answer → taxpayer data */
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const gstin = req.nextUrl.searchParams.get("gstin")?.trim().toUpperCase() ?? "";
|
||||
if (!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(gstin)) {
|
||||
return NextResponse.json({ error: "Invalid GSTIN format" }, { status: 400 });
|
||||
const body = await req.json();
|
||||
if (!body.sessionId || !body.gstin || !body.captcha) {
|
||||
return NextResponse.json({ error: "sessionId, gstin and captcha are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await lookupGstin(gstin);
|
||||
if ("error" in result) return NextResponse.json(result, { status: 422 });
|
||||
|
||||
// Geocode the pincode to get coordinates
|
||||
let lat: number | null = null;
|
||||
let lng: number | null = null;
|
||||
if (result.pincode) {
|
||||
const geo = await geocodePincode(result.pincode);
|
||||
if (geo) { lat = geo.lat; lng = geo.lng; }
|
||||
try {
|
||||
const res = await fetch(`${GST_SERVICE}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: `GST service unavailable: ${String(e)}` }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...result, lat, lng });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
/**
|
||||
* AbhiAPI GST verification.
|
||||
* Env: ABHIAPI_KEY
|
||||
*/
|
||||
|
||||
export type GstLookupResult = {
|
||||
legalName: string;
|
||||
tradeName: string;
|
||||
address: string;
|
||||
state: string;
|
||||
pincode: string;
|
||||
status: string;
|
||||
businessType: string;
|
||||
};
|
||||
|
||||
const STATE_CODES: Record<string, string> = {
|
||||
"01": "Jammu & Kashmir", "02": "Himachal Pradesh", "03": "Punjab",
|
||||
"04": "Chandigarh", "05": "Uttarakhand", "06": "Haryana",
|
||||
"07": "Delhi", "08": "Rajasthan", "09": "Uttar Pradesh",
|
||||
"10": "Bihar", "11": "Sikkim", "12": "Arunachal Pradesh",
|
||||
"13": "Nagaland", "14": "Manipur", "15": "Mizoram",
|
||||
"16": "Tripura", "17": "Meghalaya", "18": "Assam",
|
||||
"19": "West Bengal", "20": "Jharkhand", "21": "Odisha",
|
||||
"22": "Chhattisgarh", "23": "Madhya Pradesh", "24": "Gujarat",
|
||||
"26": "Dadra & Nagar Haveli", "27": "Maharashtra", "28": "Andhra Pradesh",
|
||||
"29": "Karnataka", "30": "Goa", "31": "Lakshadweep",
|
||||
"32": "Kerala", "33": "Tamil Nadu", "34": "Puducherry",
|
||||
"35": "Andaman & Nicobar", "36": "Telangana", "37": "Andhra Pradesh (new)",
|
||||
};
|
||||
|
||||
export function parseGstinState(gstin: string): string {
|
||||
return STATE_CODES[gstin.substring(0, 2)] ?? "Unknown";
|
||||
}
|
||||
|
||||
export async function lookupGstin(gstin: string): Promise<GstLookupResult | { error: string }> {
|
||||
const apiKey = process.env.ABHIAPI_KEY;
|
||||
if (!apiKey) return { error: "GST lookup API key not configured (ABHIAPI_KEY)" };
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.abhiapi.com/gst/search?gstin=${encodeURIComponent(gstin)}`, {
|
||||
headers: { "x-api-key": apiKey },
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.success) {
|
||||
return { error: json.message ?? "GST lookup failed" };
|
||||
}
|
||||
const d = json.data;
|
||||
return {
|
||||
legalName: d.legalName ?? d.lgnm ?? "",
|
||||
tradeName: d.tradeName ?? d.tradeNam ?? d.legalName ?? "",
|
||||
address: d.address ?? d.pradr?.adr ?? "",
|
||||
state: d.state ?? parseGstinState(gstin),
|
||||
pincode: d.pincode ?? "",
|
||||
status: d.status ?? d.sts ?? "Unknown",
|
||||
businessType: d.businessType ?? d.dty ?? "",
|
||||
};
|
||||
} catch (e) {
|
||||
return { error: String(e) };
|
||||
}
|
||||
}
|
||||
BIN
App/pelagia-portal/scripts/captcha-area.png
Normal file
BIN
App/pelagia-portal/scripts/captcha-area.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
App/pelagia-portal/scripts/captcha.png
Normal file
BIN
App/pelagia-portal/scripts/captcha.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
App/pelagia-portal/scripts/page-before-fill.png
Normal file
BIN
App/pelagia-portal/scripts/page-before-fill.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
App/pelagia-portal/scripts/result.png
Normal file
BIN
App/pelagia-portal/scripts/result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
BIN
App/pelagia-portal/scripts/searchtp-page.png
Normal file
BIN
App/pelagia-portal/scripts/searchtp-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
1559
GstService/package-lock.json
generated
Normal file
1559
GstService/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
GstService/package.json
Normal file
21
GstService/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "gst-service",
|
||||
"version": "1.0.0",
|
||||
"description": "GST portal CAPTCHA proxy — fetches taxpayer details from services.gst.gov.in via Playwright",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
182
GstService/src/index.ts
Normal file
182
GstService/src/index.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import express from "express";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3002);
|
||||
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}`);
|
||||
});
|
||||
108
GstService/src/test-lookup.ts
Normal file
108
GstService/src/test-lookup.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Interactive GST taxpayer lookup via services.gst.gov.in/services/searchtp
|
||||
* Run: npx tsx scripts/test-gst-scrape.ts <GSTIN>
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load the search page (establishes session)
|
||||
* 2. Fetch the CAPTCHA image → save to scripts/captcha.png
|
||||
* 3. Prompt you to open the image and type the 6 digits
|
||||
* 4. Submit GSTIN + captcha → print the result
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import * as fs from "fs";
|
||||
import * as readline from "readline";
|
||||
|
||||
const GSTIN = (process.argv[2] ?? "").toUpperCase();
|
||||
if (!GSTIN) { console.error("Usage: npx tsx scripts/test-gst-scrape.ts <GSTIN>"); process.exit(1); }
|
||||
|
||||
function ask(q: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise(resolve => rl.question(q, a => { rl.close(); resolve(a.trim()); }));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log(`\nGSTIN: ${GSTIN}\n`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
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();
|
||||
|
||||
// ── Step 1: load search page ──────────────────────────────────────────────
|
||||
process.stdout.write("Loading GST portal… ");
|
||||
await page.goto("https://services.gst.gov.in/services/searchtp", {
|
||||
waitUntil: "networkidle",
|
||||
timeout: 30000,
|
||||
});
|
||||
console.log("done.");
|
||||
|
||||
// ── Step 2: fetch captcha from same origin (sets CaptchaCookie) ───────────
|
||||
process.stdout.write("Fetching CAPTCHA… ");
|
||||
const captchaB64: string = await page.evaluate(() =>
|
||||
fetch("/services/captcha", { headers: { Accept: "image/png,image/*" } })
|
||||
.then(r => r.blob())
|
||||
.then(blob => new Promise<string>((res, rej) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => res((reader.result as string).split(",")[1]);
|
||||
reader.onerror = rej;
|
||||
reader.readAsDataURL(blob);
|
||||
}))
|
||||
);
|
||||
|
||||
const imgPath = "scripts/captcha.png";
|
||||
fs.writeFileSync(imgPath, Buffer.from(captchaB64, "base64"));
|
||||
console.log(`saved → ${imgPath}`);
|
||||
|
||||
const cookies = await ctx.cookies("https://services.gst.gov.in");
|
||||
const capCookie = cookies.find(c => c.name === "CaptchaCookie");
|
||||
console.log(`CaptchaCookie: ${capCookie?.value ?? "NOT SET"}`);
|
||||
|
||||
// ── Step 3: ask for captcha answer ────────────────────────────────────────
|
||||
console.log("\nOpen scripts/captcha.png and read the 6-digit number.");
|
||||
const captcha = await ask("Enter CAPTCHA (6 digits): ");
|
||||
|
||||
if (!/^\d{6}$/.test(captcha)) {
|
||||
console.error("Expected exactly 6 digits. Got:", captcha);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Step 4: submit search ─────────────────────────────────────────────────
|
||||
process.stdout.write(`\nSubmitting { gstin: "${GSTIN}", captcha: "${captcha}" }… `);
|
||||
|
||||
const result: { status: number; body: unknown } = await page.evaluate(
|
||||
([gstin, cap]: [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, captcha: cap }),
|
||||
})
|
||||
.then(async r => ({ status: r.status, body: await r.json().catch(() => r.text()) }))
|
||||
.catch((e: Error) => ({ status: 0, body: { error: e.message } })),
|
||||
[GSTIN, captcha] as [string, string]
|
||||
);
|
||||
|
||||
console.log("done.\n");
|
||||
console.log("=== Response ===");
|
||||
console.log(JSON.stringify(result.body, null, 2));
|
||||
|
||||
// If wrong captcha, SWEB_9034; if GSTIN not found, different code; on success → data
|
||||
const body = result.body as Record<string, unknown>;
|
||||
if (body.errorCode === "SWEB_9034") {
|
||||
console.log("\n→ Wrong CAPTCHA. Re-run to get a fresh image.");
|
||||
} else if (body.errorCode === "SWEB_9000") {
|
||||
console.log("\n→ SWEB_9000 (session/auth issue — not a captcha problem).");
|
||||
} else if (body.errorCode) {
|
||||
console.log(`\n→ Error code: ${body.errorCode}`);
|
||||
} else {
|
||||
console.log("\n✅ Success — taxpayer data above.");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})().catch(e => { console.error("\nError:", e.message); process.exit(1); });
|
||||
12
GstService/tsconfig.json
Normal file
12
GstService/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue