feat: BizPortal 2.0 — Python Playwright sidecar
Replaces the brittle agent-driven /api/bizportal/search queue with a
deterministic Python FastAPI service that drives BizPortal via a real
headless Chromium. The old flow is untouched.
What this adds:
- scripts/bizportal_service.py — FastAPI + Playwright service with
/search, /reserve, /health endpoints. Persistent login session
via storage_state.json. Locator-based selectors (vs eval JS).
- app/bizportal-2/ — new /bizportal-2 page, synchronous fetch
(no polling), shows round-trip duration, mirrors the existing
bulk-add + link UX.
- app/api/bizportal-2/{search,reserve,health}/route.ts — thin
proxies to the Python sidecar.
- scripts/README.md, .env.example, .gitignore, requirements.txt.
- .gitignore: ignore tsconfig.tsbuildinfo (build artefact).
Wiring:
- components/NavBar.tsx: 'BizPortal 2.0' nav entry with TEST badge.
- app/bizportal/page.tsx: 'Try BizPortal 2.0' action link in
header so the new option is discoverable from the existing page.
Run: cd scripts && source .venv/bin/activate && uvicorn
bizportal_service:app --port 7001
This commit is contained in:
parent
2d402c0482
commit
7c3847fbb0
13 changed files with 1671 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,6 +11,7 @@ dist/
|
|||
|
||||
# Next.js type helpers
|
||||
next-env.d.ts
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Editor cruft
|
||||
.DS_Store
|
||||
|
|
|
|||
22
app/api/bizportal-2/health/route.ts
Normal file
22
app/api/bizportal-2/health/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// app/api/bizportal-2/health/route.ts
|
||||
// GET /api/bizportal-2/health — proxy to the Python service.
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const res = await fetch(`${SERVICE_URL}/health`, { cache: "no-store" });
|
||||
const data = await res.json().catch(() => ({ ok: false }));
|
||||
return NextResponse.json(data, { status: res.ok ? 200 : 503 });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Python service not reachable at ${SERVICE_URL}: ${err.message ?? err}` },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
53
app/api/bizportal-2/reserve/route.ts
Normal file
53
app/api/bizportal-2/reserve/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// app/api/bizportal-2/reserve/route.ts
|
||||
// POST /api/bizportal-2/reserve
|
||||
// Reserves a name on BizPortal via the Python sidecar. Replaces
|
||||
// the agent-driven /api/register/[id]/apply-bizportal route for
|
||||
// test purposes. The old route is untouched.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 180;
|
||||
|
||||
const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const names: string[] = Array.isArray(body?.names) ? body.names : [];
|
||||
const dryRun: boolean = !!body?.dry_run;
|
||||
const balanceMin: number | undefined = typeof body?.balance_min === "number" ? body.balance_min : undefined;
|
||||
|
||||
if (names.length === 0) {
|
||||
return NextResponse.json({ ok: false, error: "At least one name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SERVICE_URL}/reserve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ names, dry_run: dryRun, balance_min: balanceMin }),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Python service returned non-JSON: ${text.slice(0, 200)}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json({ ok: false, error: "Reservation cancelled" }, { status: 499 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Cannot reach Python service at ${SERVICE_URL}: ${err.message ?? err}` },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/api/bizportal-2/search/route.ts
Normal file
64
app/api/bizportal-2/search/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// app/api/bizportal-2/search/route.ts
|
||||
// POST /api/bizportal-2/search
|
||||
// Thin wrapper around the Python Playwright sidecar. Synchronous
|
||||
// — no queue, no polling. The client gets the result in one round
|
||||
// trip (~5s typical, ~30s worst case for cold login).
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { type, query } = body ?? {};
|
||||
|
||||
if (!type || !["id", "ck"].includes(type)) {
|
||||
return NextResponse.json({ ok: false, error: "type must be 'id' or 'ck'" }, { status: 400 });
|
||||
}
|
||||
if (!query?.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "query is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const res = await fetch(`${SERVICE_URL}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, query: query.trim() }),
|
||||
// Signal upstream so the browser-side AbortController can cancel.
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Python service returned non-JSON: ${text.slice(0, 200)}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok && data.ok === false) {
|
||||
return NextResponse.json(data, { status: res.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
duration_ms: Date.now() - start,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
return NextResponse.json({ ok: false, error: "Search cancelled" }, { status: 499 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Cannot reach Python service at ${SERVICE_URL}: ${err.message ?? err}` },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
700
app/bizportal-2/BizPortal2Explorer.tsx
Normal file
700
app/bizportal-2/BizPortal2Explorer.tsx
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
// app/bizportal-2/BizPortal2Explorer.tsx
|
||||
// Client component — same UX as the existing /bizportal explorer
|
||||
// but fetches synchronously from the Python sidecar. No polling,
|
||||
// no task queue, no LLM agent. Times the round-trip so the user
|
||||
// can see the speed difference.
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
type SearchType = "id" | "ck";
|
||||
|
||||
interface SearchResponse {
|
||||
ok: boolean;
|
||||
results?: any[];
|
||||
error?: string;
|
||||
log?: string[];
|
||||
query?: string;
|
||||
type?: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
export default function BizPortal2Explorer() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialType = (searchParams.get("type") as SearchType) === "ck" ? "ck" : "id";
|
||||
const initialQuery = searchParams.get("q") || "";
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SearchType>(initialType);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [serviceUp, setServiceUp] = useState<boolean | null>(null);
|
||||
const [data, setData] = useState<SearchResponse | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Health check on mount so the user can see immediately if the
|
||||
// Python sidecar is running.
|
||||
useEffect(() => {
|
||||
fetch("/api/bizportal-2/health")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setServiceUp(!!d.ok && !!d.playwright_ready))
|
||||
.catch(() => setServiceUp(false));
|
||||
}, []);
|
||||
|
||||
// Auto-fire search when arriving from a director/company deep-link.
|
||||
const autoFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialQuery && !autoFiredRef.current) {
|
||||
autoFiredRef.current = true;
|
||||
setTimeout(() => {
|
||||
const form = document.getElementById("bizportal-2-search-form") as HTMLFormElement | null;
|
||||
if (form) form.requestSubmit();
|
||||
}, 100);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function handleSearch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setData(null);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/bizportal-2/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: activeTab, query: query.trim() }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json: SearchResponse = await res.json();
|
||||
setData(json);
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
setData({ ok: false, error: err.message || "Network error" });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelSearch() {
|
||||
abortRef.current?.abort();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<ServiceStatusBanner up={serviceUp} />
|
||||
|
||||
{/* ── Tab switcher ──────────────────────────────────────────────── */}
|
||||
<div className="flex gap-1 rounded-md bg-surface-warm p-1 w-fit">
|
||||
{([["id", "Director ID No."], ["ck", "CK / Enterprise No."]] as [SearchType, string][]).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => { setActiveTab(val); setQuery(""); setData(null); }}
|
||||
className={`px-5 py-2.5 rounded-sm text-sm font-semibold transition-all ${
|
||||
activeTab === val
|
||||
? "bg-surface text-fg shadow-sm"
|
||||
: "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Search form ──────────────────────────────────────────────── */}
|
||||
<form id="bizportal-2-search-form" onSubmit={handleSearch} className="flex flex-col gap-3">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode={activeTab === "id" ? "numeric" : "text"}
|
||||
pattern={activeTab === "id" ? "\\d{13}" : undefined}
|
||||
placeholder={
|
||||
activeTab === "id"
|
||||
? "13-digit SA ID number (e.g. 8709225206083)"
|
||||
: "CK / Enterprise No. (e.g. 2022/808158/08 or K2022808158)"
|
||||
}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="flex-1 px-4 py-3 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-3 rounded-md bg-accent text-accent-on font-semibold text-sm hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? "Searching…" : "Search"}
|
||||
</button>
|
||||
{loading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelSearch}
|
||||
className="px-4 py-3 rounded-md border border-border text-muted text-sm hover:text-fg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* ── Status / Result ──────────────────────────────────────────── */}
|
||||
{data && (
|
||||
<div className="rounded-md border border-border bg-surface p-5 space-y-4">
|
||||
{!data.ok ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-red-500 text-sm font-semibold">Search failed</p>
|
||||
<p className="text-sm text-muted">{data.error}</p>
|
||||
{data.log && data.log.length > 0 && <LogBlock log={data.log} />}
|
||||
</div>
|
||||
) : data.results && data.results.length > 0 ? (
|
||||
<DoneView
|
||||
results={data.results}
|
||||
activeTab={activeTab}
|
||||
query={query}
|
||||
durationMs={data.duration_ms}
|
||||
log={data.log}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted">No results found.</p>
|
||||
{data.log && data.log.length > 0 && <LogBlock log={data.log} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceStatusBanner({ up }: { up: boolean | null }) {
|
||||
if (up === null) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-warm p-3 text-xs text-muted">
|
||||
Checking Python service…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (up) {
|
||||
return (
|
||||
<div className="rounded-md border border-green-300 bg-green-50 p-3 text-xs text-green-800">
|
||||
✅ Python sidecar is up on <span className="font-mono">127.0.0.1:7001</span> — search will be synchronous.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-xs text-red-800 space-y-2">
|
||||
<p className="font-semibold">Python sidecar is not reachable.</p>
|
||||
<p>
|
||||
Start it with:{" "}
|
||||
<code className="font-mono bg-surface px-2 py-0.5 rounded">
|
||||
cd scripts && source .venv/bin/activate && uvicorn bizportal_service:app --port 7001
|
||||
</code>
|
||||
</p>
|
||||
<p>
|
||||
See <span className="font-mono">scripts/README.md</span> for the first-time setup.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogBlock({ log }: { log: string[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="text-muted hover:text-fg underline"
|
||||
>
|
||||
{open ? "Hide" : "Show"} service log ({log.length} entries)
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="mt-2 p-3 rounded bg-surface-warm text-fg font-mono text-[11px] overflow-x-auto whitespace-pre-wrap">
|
||||
{log.join("\n")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Result display ───────────────────────────────────────────────────────
|
||||
|
||||
interface DoneViewProps {
|
||||
results: any[];
|
||||
activeTab: SearchType;
|
||||
query: string;
|
||||
durationMs?: number;
|
||||
log?: string[];
|
||||
}
|
||||
|
||||
function DoneView({ results, activeTab, query, durationMs, log }: DoneViewProps) {
|
||||
const [dbCompanies, setDbCompanies] = useState<Map<string, any>>(new Map());
|
||||
const [dbDirectors, setDbDirectors] = useState<Map<string, any>>(new Map());
|
||||
const [links, setLinks] = useState<Set<string>>(new Set());
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||
const [bulkMsg, setBulkMsg] = useState<string>("");
|
||||
|
||||
function maskId(id: string | undefined): string {
|
||||
if (!id) return "";
|
||||
const digits = String(id).replace(/\s/g, "");
|
||||
if (/X/i.test(digits)) return digits;
|
||||
if (digits.length === 13) return digits.slice(0, 6) + "XXXX" + digits.slice(10, 12) + "X";
|
||||
return digits;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (results.length === 0) return;
|
||||
async function load() {
|
||||
try {
|
||||
const [compRes, dirRes] = await Promise.all([
|
||||
fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"),
|
||||
fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"),
|
||||
]);
|
||||
const compData = await compRes.json();
|
||||
const dirData = await dirRes.json();
|
||||
const cmap = new Map<string, any>();
|
||||
(compData.items || []).forEach((c: any) => {
|
||||
if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c);
|
||||
});
|
||||
const dmap = new Map<string, any>();
|
||||
(dirData.items || []).forEach((d: any) => {
|
||||
if (d.id_no_masked) dmap.set(d.id_no_masked, d);
|
||||
if (d.id_no) dmap.set(d.id_no, d);
|
||||
});
|
||||
setDbCompanies(cmap);
|
||||
setDbDirectors(dmap);
|
||||
|
||||
const linkSet = new Set<string>();
|
||||
for (const c of results) {
|
||||
const compEntNo = String(c.enterprise_no || "").replace(/\s/g, "");
|
||||
const compRecord = cmap.get(compEntNo);
|
||||
if (!compRecord) continue;
|
||||
for (const d of (c.directors || [])) {
|
||||
const masked = maskId(d.id_number);
|
||||
const dirRecord = dmap.get(masked) || dmap.get(d.id_number);
|
||||
if (dirRecord) {
|
||||
try {
|
||||
const jres = await fetch(`/api/pb/records/list?collection=ysterbaard_company_directors&perPage=500&filter=${encodeURIComponent(`director='${dirRecord.id}'`)}`);
|
||||
const jdata = await jres.json();
|
||||
for (const j of (jdata.items || [])) {
|
||||
if (j.company === compRecord.id) {
|
||||
linkSet.add(`${compRecord.id}-${dirRecord.id}`);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
setLinks(linkSet);
|
||||
} finally {
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [results]);
|
||||
|
||||
function getCompanyRecord(c: any) {
|
||||
return dbCompanies.get(String(c.enterprise_no || "").replace(/\s/g, ""));
|
||||
}
|
||||
function getDirectorRecord(d: any) {
|
||||
return dbDirectors.get(maskId(d.id_number)) || dbDirectors.get(d.id_number);
|
||||
}
|
||||
function isLinked(compRec: any, dirRec: any) {
|
||||
return compRec && dirRec && links.has(`${compRec.id}-${dirRec.id}`);
|
||||
}
|
||||
function toggle(key: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function selectAllMissing() {
|
||||
const next = new Set<string>();
|
||||
for (const c of results) {
|
||||
if (!getCompanyRecord(c)) next.add(`company:${c.enterprise_no}`);
|
||||
for (const d of (c.directors || [])) {
|
||||
if (!getDirectorRecord(d)) next.add(`director:${maskId(d.id_number)}`);
|
||||
}
|
||||
}
|
||||
setSelected(next);
|
||||
}
|
||||
function clearSelection() { setSelected(new Set()); }
|
||||
|
||||
async function processSelected() {
|
||||
if (selected.size === 0) return;
|
||||
setBulkProcessing(true);
|
||||
setBulkMsg("");
|
||||
try {
|
||||
const items: any[] = [];
|
||||
for (const c of results) {
|
||||
if (selected.has(`company:${c.enterprise_no}`)) {
|
||||
items.push({
|
||||
collection: "ysterbaard_companies",
|
||||
fields: {
|
||||
enterprise_no: c.enterprise_no || "",
|
||||
enterprise_name: c.enterprise_name || "",
|
||||
enterprise_type: c.enterprise_type || "",
|
||||
source: "bizportal-2",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const c of results) {
|
||||
for (const d of (c.directors || [])) {
|
||||
const masked = maskId(d.id_number);
|
||||
if (selected.has(`director:${masked}`)) {
|
||||
items.push({
|
||||
collection: "ysterbaard_directors",
|
||||
fields: {
|
||||
id_no: masked.replace(/X/gi, "0"),
|
||||
surname: d.surname || "",
|
||||
first_names: d.names || "",
|
||||
director_type: (d.type || "Director").toLowerCase(),
|
||||
source: "bizportal-2",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await fetch("/api/pb/records/bulk-create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const [compRes2, dirRes2] = await Promise.all([
|
||||
fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"),
|
||||
fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"),
|
||||
]);
|
||||
const compData2 = await compRes2.json();
|
||||
const dirData2 = await dirRes2.json();
|
||||
const cmap = new Map<string, any>();
|
||||
(compData2.items || []).forEach((c: any) => {
|
||||
if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c);
|
||||
});
|
||||
const dmap = new Map<string, any>();
|
||||
(dirData2.items || []).forEach((d: any) => {
|
||||
if (d.id_no_masked) dmap.set(d.id_no_masked, d);
|
||||
if (d.id_no) dmap.set(d.id_no, d);
|
||||
});
|
||||
setDbCompanies(cmap);
|
||||
setDbDirectors(dmap);
|
||||
|
||||
const linkItems: any[] = [];
|
||||
for (const c of results) {
|
||||
const compRec = cmap.get(String(c.enterprise_no || "").replace(/\s/g, ""));
|
||||
if (!compRec) continue;
|
||||
for (const d of (c.directors || [])) {
|
||||
const masked = maskId(d.id_number);
|
||||
const dirRec = dmap.get(masked);
|
||||
if (!dirRec) continue;
|
||||
if (links.has(`${compRec.id}-${dirRec.id}`)) continue;
|
||||
linkItems.push({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" });
|
||||
}
|
||||
}
|
||||
|
||||
let linkSummary = "";
|
||||
if (linkItems.length > 0) {
|
||||
const lres = await fetch("/api/pb/records/bulk-link", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: linkItems }),
|
||||
});
|
||||
const ldata = await lres.json();
|
||||
const nextLinks = new Set(links);
|
||||
for (const li of linkItems) nextLinks.add(`${li.company}-${li.director}`);
|
||||
setLinks(nextLinks);
|
||||
linkSummary = `, linked ${ldata.linked_count} (${ldata.already_count} already)`;
|
||||
}
|
||||
|
||||
const failMsg = data.fail_count > 0 ? `, ${data.fail_count} failed` : "";
|
||||
setBulkMsg(`✅ Added ${data.success_count}${failMsg}${linkSummary}`);
|
||||
setSelected(new Set());
|
||||
} catch (err: any) {
|
||||
setBulkMsg(`❌ Error: ${err.message}`);
|
||||
} finally {
|
||||
setBulkProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function linkOne(compRec: any, dirRec: any, d: any) {
|
||||
const res = await fetch("/api/pb/records/link", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setLinks((prev) => new Set(prev).add(`${compRec.id}-${dirRec.id}`));
|
||||
setBulkMsg("✅ Linked");
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setBulkMsg(`❌ Link failed: ${data.error || res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCount = selected.size;
|
||||
const missingCompanies = results.filter((c) => !getCompanyRecord(c)).length;
|
||||
const missingDirectors = results.flatMap((c) => c.directors || []).filter((d) => !getDirectorRecord(d)).length;
|
||||
const linkableCount = results.reduce((acc, c) => {
|
||||
const compRec = getCompanyRecord(c);
|
||||
if (!compRec) return acc;
|
||||
return acc + (c.directors || []).filter((d: any) => {
|
||||
const dirRec = getDirectorRecord(d);
|
||||
return dirRec && !links.has(`${compRec.id}-${dirRec.id}`);
|
||||
}).length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 p-3 rounded-md bg-surface-warm border border-border flex-wrap">
|
||||
<div className="text-xs text-muted">
|
||||
{loaded ? (
|
||||
<>
|
||||
<span className="font-semibold">{results.length}</span> result{results.length === 1 ? "" : "s"} ·{" "}
|
||||
<span className={missingCompanies > 0 ? "text-yellow-700" : "text-green-700"}>
|
||||
{results.length - missingCompanies}/{results.length} companies in DB
|
||||
</span>
|
||||
{" · "}
|
||||
<span className={missingDirectors > 0 ? "text-yellow-700" : "text-green-700"}>
|
||||
{missingDirectors} missing director{missingDirectors === 1 ? "" : "s"}
|
||||
</span>
|
||||
{linkableCount > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-blue-700">{linkableCount} linkable pair{linkableCount === 1 ? "" : "s"}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"Loading DB state…"
|
||||
)}
|
||||
{durationMs !== undefined && (
|
||||
<span className="ml-2 text-meta">· {durationMs}ms round-trip</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
<span className="text-xs font-semibold text-fg">{selectedCount} selected</span>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="text-xs px-3 py-1 rounded border border-border text-muted hover:text-fg"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={processSelected}
|
||||
disabled={bulkProcessing}
|
||||
className="text-xs px-4 py-1 rounded bg-accent text-accent-on font-semibold hover:bg-accent-hover disabled:opacity-60"
|
||||
>
|
||||
{bulkProcessing ? "Processing…" : `PROCESS (${selectedCount})`}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={selectAllMissing}
|
||||
disabled={!loaded || (missingCompanies === 0 && missingDirectors === 0)}
|
||||
className="text-xs px-3 py-1 rounded border border-border text-fg hover:border-accent disabled:opacity-50"
|
||||
>
|
||||
Select all missing
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bulkMsg && (
|
||||
<div className="text-xs px-3 py-2 rounded bg-surface border border-border text-fg">{bulkMsg}</div>
|
||||
)}
|
||||
|
||||
{log && log.length > 0 && <LogBlock log={log} />}
|
||||
|
||||
<div className="space-y-8">
|
||||
{results.map((c, idx) => (
|
||||
<CompanyResult
|
||||
key={c.enterprise_no || idx}
|
||||
company={c}
|
||||
maskId={maskId}
|
||||
dbCompany={getCompanyRecord(c)}
|
||||
getDirectorRecord={getDirectorRecord}
|
||||
isLinked={isLinked}
|
||||
links={links}
|
||||
selected={selected}
|
||||
toggle={toggle}
|
||||
onLink={linkOne}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyResult({
|
||||
company,
|
||||
maskId,
|
||||
dbCompany,
|
||||
getDirectorRecord,
|
||||
isLinked,
|
||||
links,
|
||||
selected,
|
||||
toggle,
|
||||
onLink,
|
||||
}: {
|
||||
company: any;
|
||||
maskId: (s: string) => string;
|
||||
dbCompany: any;
|
||||
getDirectorRecord: (d: any) => any;
|
||||
isLinked: (compRec: any, dirRec: any) => boolean;
|
||||
links: Set<string>;
|
||||
selected: Set<string>;
|
||||
toggle: (key: string) => void;
|
||||
onLink: (compRec: any, dirRec: any, d: any) => void;
|
||||
}) {
|
||||
const entNo = (company.enterprise_no || "").replace(/\s/g, "");
|
||||
const entName = company.enterprise_name || "—";
|
||||
const entStatus = company.enterprise_status || "—";
|
||||
const directors: any[] = company.directors || [];
|
||||
const inDb = !!dbCompany;
|
||||
const compSelectKey = `company:${company.enterprise_no}`;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg bg-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-bold text-fg">{entName}</h3>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
(entStatus || "").toLowerCase().includes("business")
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-yellow-100 text-yellow-700"
|
||||
}`}>
|
||||
{entStatus}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted mt-1 font-mono">{entNo}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{inDb ? (
|
||||
<span className="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-semibold">
|
||||
✅ In DB
|
||||
</span>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 px-3 py-1 rounded bg-yellow-50 border border-yellow-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(compSelectKey)}
|
||||
onChange={() => toggle(compSelectKey)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-xs font-semibold text-yellow-800">Not in DB — select to add</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-fg mb-2">
|
||||
Director Details
|
||||
<span className="ml-2 text-xs text-muted font-normal">
|
||||
({directors.length} director{directors.length === 1 ? "" : "s"})
|
||||
</span>
|
||||
</h4>
|
||||
{directors.length === 0 ? (
|
||||
<p className="text-sm text-muted italic">No directors found in registry.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left bg-surface-warm text-muted text-xs uppercase tracking-wide">
|
||||
<th className="px-3 py-2 font-semibold w-10"></th>
|
||||
<th className="px-3 py-2 font-semibold">ID Number</th>
|
||||
<th className="px-3 py-2 font-semibold">Name(s)</th>
|
||||
<th className="px-3 py-2 font-semibold">Surname</th>
|
||||
<th className="px-3 py-2 font-semibold">Status</th>
|
||||
<th className="px-3 py-2 font-semibold">DB Status</th>
|
||||
<th className="px-3 py-2 font-semibold text-right">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-surface">
|
||||
{directors.map((d, i) => {
|
||||
const masked = maskId(d.id_number);
|
||||
const dirRec = getDirectorRecord(d);
|
||||
const inDirDb = !!dirRec;
|
||||
const dirSelectKey = `director:${masked}`;
|
||||
const linked = inDirDb && inDb && isLinked(dbCompany, dirRec);
|
||||
|
||||
return (
|
||||
<tr key={i} className={linked ? "bg-green-50/30" : "hover:bg-surface-warm"}>
|
||||
<td className="px-3 py-2">
|
||||
{!inDirDb && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(dirSelectKey)}
|
||||
onChange={() => toggle(dirSelectKey)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{masked || "—"}</td>
|
||||
<td className="px-3 py-2">{d.names || "—"}</td>
|
||||
<td className="px-3 py-2">{d.surname || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-semibold ${
|
||||
(d.status || "").toLowerCase() === "active"
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{d.status || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{inDirDb ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700 font-semibold">
|
||||
✅ In DB
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-yellow-700 italic">select to add</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{!inDb ? (
|
||||
<span className="text-xs text-muted italic">add company first</span>
|
||||
) : !inDirDb ? (
|
||||
<span className="text-xs text-muted italic">add director first</span>
|
||||
) : linked ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700 font-semibold">
|
||||
🔗 Linked
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onLink(dbCompany, dirRec, d)}
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600 text-white font-semibold hover:bg-blue-700"
|
||||
>
|
||||
🔗 Link
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
app/bizportal-2/page.tsx
Normal file
33
app/bizportal-2/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// app/bizportal-2/page.tsx
|
||||
// Server Component. Mirrors the existing /bizportal page but uses
|
||||
// the Python Playwright service (scripts/bizportal_service.py) for
|
||||
// synchronous search. The old queue-based flow is untouched.
|
||||
|
||||
import { Suspense } from "react";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import BizPortal2Explorer from "./BizPortal2Explorer";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function BizPortal2Page() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="BizPortal 2.0"
|
||||
title="CIPC Entity Search (Python sidecar)"
|
||||
subtitle={
|
||||
<>
|
||||
<span>Direct Playwright service — no agent, no polling.</span>
|
||||
<span>Calls the Python FastAPI sidecar on 127.0.0.1:7001.</span>
|
||||
<span>Test version · old /bizportal page left untouched.</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section className="container-x py-10">
|
||||
<Suspense fallback={<div className="text-muted">Loading…</div>}>
|
||||
<BizPortal2Explorer />
|
||||
</Suspense>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Server Component. Renders the BizPortal search interface.
|
||||
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import BizPortalExplorer from "./BizPortalExplorer";
|
||||
|
||||
|
|
@ -14,6 +15,15 @@ export default async function BizPortalPage() {
|
|||
eyebrow="BizPortal"
|
||||
title="CIPC Entity Search"
|
||||
subtitle="Search entities on BizPortal by Director ID Number or Enterprise (CK) Number. Results are fetched live from the CIPC registry."
|
||||
action={
|
||||
<Link
|
||||
href="/bizportal-2"
|
||||
className="inline-flex items-center gap-2 text-xs font-semibold text-accent border border-amber-300 bg-amber-50 px-3 py-2 rounded-md hover:bg-amber-100 transition-colors no-underline"
|
||||
>
|
||||
<span className="uppercase tracking-wider text-[10px] font-bold text-amber-800">Test</span>
|
||||
<span>Try BizPortal 2.0 (Python sidecar) →</span>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<section className="container-x py-10">
|
||||
<Suspense fallback={<div className="text-muted">Loading…</div>}>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type { ReactNode } from "react";
|
|||
const LINKS = [
|
||||
{ href: "/", label: "Overview", icon: IconHome },
|
||||
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
|
||||
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
|
||||
{ href: "/directors", label: "Directors", icon: IconUsers },
|
||||
{ href: "/companies", label: "Companies", icon: IconBuilding },
|
||||
{ href: "/groups", label: "Groups", icon: IconTag },
|
||||
|
|
@ -66,15 +67,20 @@ export default function NavBar() {
|
|||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
data-od-id={`nav-${link.label.toLowerCase()}`}
|
||||
data-od-id={`nav-${link.label.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
className={
|
||||
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors " +
|
||||
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors inline-flex items-center gap-2 " +
|
||||
(isActive
|
||||
? "bg-surface-warm text-fg"
|
||||
: "text-fg hover:bg-surface-warm")
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
<span>{link.label}</span>
|
||||
{"badge" in link && link.badge ? (
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
|
||||
{link.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
15
scripts/.env.example
Normal file
15
scripts/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# BizPortal gateway credentials. Fill in once, never commit.
|
||||
BIZPORTAL_ID_NO=8709225206083
|
||||
BIZPORTAL_PASSWORD=Mull2ZerX1000
|
||||
|
||||
# Run the browser headless on the server. Set to false for debugging.
|
||||
BIZPORTAL_HEADLESS=true
|
||||
|
||||
# Minimum CIPC prepaid balance required to lodge a name reservation.
|
||||
BIZPORTAL_BALANCE_MIN=50
|
||||
|
||||
# Navigation timeout (ms) for slow BizPortal pages.
|
||||
BIZPORTAL_NAV_TIMEOUT_MS=30000
|
||||
|
||||
# Logging — DEBUG for selector-trails, INFO for normal use.
|
||||
LOG_LEVEL=INFO
|
||||
5
scripts/.gitignore
vendored
Normal file
5
scripts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
storage_state.json
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
162
scripts/README.md
Normal file
162
scripts/README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# BizPortal 2.0 — Python Playwright service
|
||||
|
||||
Sidecar that drives `https://www.bizportal.gov.za` via a real headless
|
||||
Chromium. The Next.js side talks to it over HTTP — no LLM agent, no
|
||||
file queue, no polling. Sits next to the existing `/bizportal` page
|
||||
without touching it.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The existing `/bizportal` page queues searches to `/tmp/bizportal_queue/`
|
||||
and an LLM agent reads the page snapshot, decides what to click, and
|
||||
writes the result back. Fast enough when it works, but the agent is
|
||||
the brittle bit — it misreads the page, times out, or clicks the
|
||||
wrong button. This service replaces that with a deterministic Python
|
||||
script.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser ─► /bizportal-2 ─► /api/bizportal-2/* ─► this service ─► BizPortal
|
||||
│
|
||||
└─► PocketBase (via service)
|
||||
```
|
||||
|
||||
The Python service holds a persistent `storage_state.json` so the
|
||||
user is logged in once and every subsequent request reuses the
|
||||
session.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
playwright install chromium
|
||||
cp .env.example .env # fill in real credentials
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Dev
|
||||
uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --reload
|
||||
|
||||
# Production (Coolify / systemd)
|
||||
uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --workers 1
|
||||
```
|
||||
|
||||
Keep `--workers 1` — the script uses a single shared browser context
|
||||
to preserve the logged-in session.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /health`
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"service": "bizportal-2",
|
||||
"playwright_ready": true,
|
||||
"logged_in_cached": true,
|
||||
"headless": true
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /search`
|
||||
|
||||
```json
|
||||
{ "type": "ck", "query": "2022/808158/08" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"query": "2022/808158/08",
|
||||
"type": "ck",
|
||||
"results": [
|
||||
{
|
||||
"enterprise_no": "2022/808158/08",
|
||||
"enterprise_name": "AMPLEFORTH TRADING (PTY) LTD",
|
||||
"enterprise_type": "Private Company",
|
||||
"enterprise_status": "Business is in Business",
|
||||
"directors": [
|
||||
{
|
||||
"id_number": "870922XXXX08X",
|
||||
"names": "DAVID",
|
||||
"surname": "ELS",
|
||||
"status": "Active",
|
||||
"type": "Director"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"log": ["Navigating to login", "..."]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /reserve`
|
||||
|
||||
```json
|
||||
{ "names": ["AMPLEFORTH PTY LTD", "AMPLEFORTH TRADING", "AMPLEFORTH SA", "AMPLEFORTH HOLDINGS"], "dry_run": false }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"balance_before": 412.50,
|
||||
"status": "bizportal_done",
|
||||
"log": ["..."],
|
||||
"message": "Submitted via Bizportal. The R50 fee will be debited later."
|
||||
}
|
||||
```
|
||||
|
||||
## Tunable selectors
|
||||
|
||||
BizPortal uses ASP.NET WebForms (`cntMain_*` IDs). The known-stable
|
||||
IDs for login and name reservation are baked in. The **search** field
|
||||
IDs are not 100% verified — try a search after starting the service
|
||||
once and tune the `DIRECTOR_SEARCH_FIELD_CANDIDATES` /
|
||||
`ENTERPRISE_SEARCH_FIELD_CANDIDATES` lists at the top of
|
||||
`bizportal_service.py` if you get a "Could not find …" error.
|
||||
|
||||
The page URL is logged in the error so you can inspect the live page
|
||||
in your browser and grab the right field ID.
|
||||
|
||||
## Resetting the session
|
||||
|
||||
```bash
|
||||
rm scripts/storage_state.json
|
||||
# Restart the service — it will log in once and re-cache the session.
|
||||
```
|
||||
|
||||
## Running as a systemd service
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=BizPortal 2.0 Python service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/var/www/workflow/scripts
|
||||
EnvironmentFile=/var/www/workflow/scripts/.env
|
||||
ExecStart=/var/www/workflow/scripts/.venv/bin/uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --workers 1
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
## What this does NOT replace
|
||||
|
||||
- The existing `/bizportal` page (kept untouched)
|
||||
- The `/api/bizportal/search/*` queue routes (kept untouched)
|
||||
- The `/api/register/[id]/apply-bizportal` route (kept untouched)
|
||||
- The PocketBase collections and bulk-add / link logic
|
||||
|
||||
If you decide to retire the old flow, see
|
||||
`scripts/README.rollback.md` (TODO if you want one).
|
||||
592
scripts/bizportal_service.py
Normal file
592
scripts/bizportal_service.py
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
# ─────────────────────────────────────────────────────────────────
|
||||
# BizPortal 2.0 — Python Playwright sidecar
|
||||
#
|
||||
# Tiny FastAPI service that drives https://www.bizportal.gov.za via
|
||||
# a real headless Chromium. The Next.js side talks to this over HTTP
|
||||
# (no LLM agent, no file queue, no polling). All UI selectors live
|
||||
# here so the existing /bizportal page is untouched.
|
||||
#
|
||||
# Run:
|
||||
# cd scripts
|
||||
# python3 -m venv .venv && source .venv/bin/activate
|
||||
# pip install -r requirements.txt
|
||||
# playwright install chromium
|
||||
# cp .env.example .env # fill in credentials
|
||||
# uvicorn bizportal_service:app --host 127.0.0.1 --port 7001
|
||||
#
|
||||
# Smoke test:
|
||||
# curl http://127.0.0.1:7001/health
|
||||
# curl -X POST http://127.0.0.1:7001/search \
|
||||
# -H 'Content-Type: application/json' \
|
||||
# -d '{"type":"ck","query":"2022/808158/08"}'
|
||||
#
|
||||
# Endpoints:
|
||||
# GET /health
|
||||
# POST /search { type: "id"|"ck", query: str }
|
||||
# POST /reserve { names: [str,str,str,str], dry_run: bool, balance_min: float=50 }
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from playwright.async_api import async_playwright, Browser, BrowserContext, Page, TimeoutError as PWTimeout
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Config — credentials, endpoints, selectors. Tweak here, not in code.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
BIZPORTAL_BASE = "https://www.bizportal.gov.za"
|
||||
LOGIN_URL = f"{BIZPORTAL_BASE}/login.aspx"
|
||||
PROFILE_URL = f"{BIZPORTAL_BASE}/user_profile.aspx"
|
||||
NAME_RESERVE_URL = f"{BIZPORTAL_BASE}/name_reserve.aspx"
|
||||
BIZPROFILE_URL = f"{BIZPORTAL_BASE}/bizprofile.aspx"
|
||||
|
||||
# Login fields — verified.
|
||||
LOGIN_ID = "cntMain_txtIDNo"
|
||||
LOGIN_PASSWORD = "cntMain_txtPassword"
|
||||
LOGIN_BUTTON = "cntMain_btnLogin"
|
||||
|
||||
# Name reservation fields — verified.
|
||||
NAME_INPUTS = ["cntMain_txtPropName1", "cntMain_txtPropName2", "cntMain_txtPropName3", "cntMain_txtPropName4"]
|
||||
NAME_CONTINUE_1 = "cntMain_btnContinuePropName"
|
||||
NAME_CONTINUE_2 = "cntMain_btnContinueLodge"
|
||||
|
||||
# BizProfile search — verified. ONE page: dropdown picks the search
|
||||
# type, single input + button. Verified 2026-08-17 against the live
|
||||
# site by probing with Playwright.
|
||||
BIZPROFILE_DROPDOWN = "cntMain_drpSearchOptions"
|
||||
BIZPROFILE_INPUT = "cntMain_txtSearchCIPC"
|
||||
BIZPROFILE_BUTTON = "cntMain_btnSearch"
|
||||
BIZPROFILE_DROPDOWN_OPTIONS = {
|
||||
"id": "IDNo", # Director ID No.
|
||||
"ck": "EntNo", # Enterprise No.
|
||||
"name": "EntName", # Enterprise Name (unused for now)
|
||||
}
|
||||
|
||||
# Company details labels on bizprofile.aspx (CK search result page).
|
||||
BIZPROFILE_LBL_ENT_NO = "cntMain_lblEntNo"
|
||||
BIZPROFILE_LBL_ENT_NAME = "cntMain_lblEntName"
|
||||
BIZPROFILE_LBL_ENT_TYPE = "cntMain_lblEntType"
|
||||
BIZPROFILE_LBL_ENT_STATUS = "cntMain_lblEntStatus"
|
||||
|
||||
# ID search returns a GridView with this id.
|
||||
ID_SEARCH_GRID = "cntMain_gdvEnterprises"
|
||||
|
||||
# Maximum number of companies to click-through for director lookup
|
||||
# during an ID search. The existing UI can handle more, but each
|
||||
# click is a postback round-trip — cap it to keep searches fast.
|
||||
MAX_ID_SEARCH_DIRECTOR_FETCH = 10
|
||||
|
||||
# Session storage — single file, persistent across restarts. Logged-in
|
||||
# cookies live here. Gitignored.
|
||||
STORAGE_STATE_PATH = Path(__file__).parent / "storage_state.json"
|
||||
|
||||
# Required CIPC prepaid balance for name reservation.
|
||||
BALANCE_MIN = float(os.getenv("BIZPORTAL_BALANCE_MIN", "50"))
|
||||
|
||||
# Browser behaviour.
|
||||
HEADLESS = os.getenv("BIZPORTAL_HEADLESS", "true").lower() == "true"
|
||||
NAV_TIMEOUT_MS = int(os.getenv("BIZPORTAL_NAV_TIMEOUT_MS", "30000"))
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Logging
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.getenv("LOG_LEVEL", "INFO"),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||
)
|
||||
log = logging.getLogger("bizportal-2")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Pydantic models
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
type: str = Field(pattern="^(id|ck)$")
|
||||
query: str = Field(min_length=1, max_length=64)
|
||||
|
||||
class ReserveRequest(BaseModel):
|
||||
names: List[str] = Field(min_length=1, max_length=4)
|
||||
dry_run: bool = False
|
||||
balance_min: Optional[float] = None
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# App + browser lifecycle
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
app = FastAPI(title="BizPortal 2.0", version="2.0.0")
|
||||
_playwright = None
|
||||
_browser: Optional[Browser] = None
|
||||
_context: Optional[BrowserContext] = None
|
||||
_browser_lock = asyncio.Lock()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
global _playwright, _browser, _context
|
||||
_playwright = await async_playwright().start()
|
||||
# Use the full Chromium (not the headless-shell variant) — the headless
|
||||
# shell can hit EPERM on macOS when Playwright tries to clean up after
|
||||
# the first request. The full Chromium is more stable across platforms.
|
||||
launch_kwargs = {
|
||||
"headless": HEADLESS,
|
||||
"args": [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
],
|
||||
}
|
||||
if HEADLESS:
|
||||
# Force the full Chromium binary, not the headless-shell.
|
||||
launch_kwargs["channel"] = "chromium"
|
||||
_browser = await _playwright.chromium.launch(**launch_kwargs)
|
||||
_context = await _open_context()
|
||||
log.info("Service ready. headless=%s storage_state=%s", HEADLESS, STORAGE_STATE_PATH.exists())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def on_shutdown():
|
||||
if _context:
|
||||
await _context.close()
|
||||
if _browser:
|
||||
await _browser.close()
|
||||
if _playwright:
|
||||
await _playwright.stop()
|
||||
|
||||
async def _open_context() -> BrowserContext:
|
||||
if STORAGE_STATE_PATH.exists():
|
||||
try:
|
||||
ctx = await _browser.new_context(storage_state=str(STORAGE_STATE_PATH))
|
||||
log.info("Loaded storage state from %s", STORAGE_STATE_PATH)
|
||||
return ctx
|
||||
except Exception as e:
|
||||
log.warning("Could not load storage state: %s — starting fresh", e)
|
||||
return await _browser.new_context()
|
||||
|
||||
def _current_context() -> BrowserContext:
|
||||
if not _context:
|
||||
raise RuntimeError("Browser context not initialised")
|
||||
return _context
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Health
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "bizportal-2",
|
||||
"playwright_ready": _browser is not None and _context is not None,
|
||||
"logged_in_cached": STORAGE_STATE_PATH.exists(),
|
||||
"headless": HEADLESS,
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Helpers — login, wait, parsing
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _save_storage_state() -> None:
|
||||
state = await _context.storage_state()
|
||||
STORAGE_STATE_PATH.write_text(json.dumps(state, indent=2))
|
||||
log.info("Saved storage state to %s", STORAGE_STATE_PATH)
|
||||
|
||||
async def _is_logged_in(page: Page) -> bool:
|
||||
try:
|
||||
return await page.locator(LOGIN_ID).count() == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _ensure_logged_in(page: Page, log_list: List[str]) -> None:
|
||||
"""Reuse stored session if valid; otherwise log in once."""
|
||||
if await _is_logged_in(page):
|
||||
log_list.append("Already logged in (cached session)")
|
||||
return
|
||||
|
||||
log.info("Logging in…")
|
||||
log_list.append("Navigating to login")
|
||||
await page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
|
||||
if await _is_logged_in(page):
|
||||
log_list.append("Already logged in (after navigation)")
|
||||
return
|
||||
|
||||
id_no = os.getenv("BIZPORTAL_ID_NO")
|
||||
password = os.getenv("BIZPORTAL_PASSWORD")
|
||||
if not id_no or not password:
|
||||
raise RuntimeError("BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD not set in environment")
|
||||
|
||||
await page.locator(f"#{LOGIN_ID}").fill(id_no)
|
||||
await page.locator(f"#{LOGIN_PASSWORD}").fill(password)
|
||||
await page.locator(f"#{LOGIN_BUTTON}").click()
|
||||
|
||||
# Wait for the login form to disappear.
|
||||
try:
|
||||
await page.locator(f"#{LOGIN_ID}").wait_for(state="detached", timeout=30000)
|
||||
except PWTimeout:
|
||||
raise RuntimeError("Login failed — login form still present after 30s")
|
||||
|
||||
await _save_storage_state()
|
||||
log_list.append("Logged in and cached session")
|
||||
log.info("Logged in")
|
||||
|
||||
async def _parse_balance(page: Page) -> Optional[float]:
|
||||
await page.goto(PROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(1500)
|
||||
text = await page.locator("body").inner_text()
|
||||
matches = re.findall(r"CIPC Prepaid Account Balance[:\s]*R\s*([0-9][0-9.,]*)", text, re.IGNORECASE)
|
||||
if not matches:
|
||||
return None
|
||||
raw = matches[0].replace(".", "").replace(",", ".")
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
async def _select_first(page: Page, candidates: List[str], kind: str) -> str:
|
||||
"""Return the first selector that resolves. kind is for error msgs."""
|
||||
for sel in candidates:
|
||||
try:
|
||||
count = await page.locator(sel).count()
|
||||
if count > 0:
|
||||
return sel
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError(
|
||||
f"Could not find {kind} on {page.url}. "
|
||||
f"Tried: {candidates}. "
|
||||
f"Update the *_CANDIDATES constants in bizportal_service.py."
|
||||
)
|
||||
|
||||
|
||||
async def _parse_results_table(page: Page) -> List[Dict[str, Any]]:
|
||||
"""Probe-based parse that knows the actual BizPortal layouts.
|
||||
|
||||
Two distinct result shapes:
|
||||
|
||||
CK search (dropdown=EntNo): ONE company shown on the page itself.
|
||||
Labels: cntMain_lblEntNo/EntName/EntType/EntStatus hold the
|
||||
company info. Directors live in a sub-table with columns
|
||||
`ID Number | Name(s) | Surname | Type | Status`.
|
||||
|
||||
ID search (dropdown=IDNo): a GridView of companies.
|
||||
`cntMain_gdvEnterprises` has columns Enterprise Number, Name,
|
||||
Status, Director Status, [hidden Status], View. Each row has
|
||||
a View button that triggers a postback to load that company's
|
||||
directors. We click through each one (up to a cap) and parse
|
||||
the directors from the re-rendered page.
|
||||
"""
|
||||
# Detect which layout we're in.
|
||||
has_company_labels = await page.locator(f"#{BIZPROFILE_LBL_ENT_NO}").count() > 0
|
||||
has_grid = await page.locator(f"#{ID_SEARCH_GRID}").count() > 0
|
||||
|
||||
if has_company_labels:
|
||||
return [await _parse_company_layout(page)]
|
||||
if has_grid:
|
||||
return await _parse_grid_layout(page)
|
||||
|
||||
log_list: List[str] = []
|
||||
raise RuntimeError(
|
||||
"Unrecognised result layout. Neither company labels nor "
|
||||
f"the GridView were found on {page.url}."
|
||||
)
|
||||
|
||||
|
||||
async def _parse_company_layout(page: Page) -> Dict[str, Any]:
|
||||
"""CK search result — single company, directors in a sub-table."""
|
||||
async def lbl(suffix: str) -> str:
|
||||
loc = page.locator(f"#cntMain_lbl{suffix}")
|
||||
if await loc.count() == 0:
|
||||
return ""
|
||||
return (await loc.first.inner_text()).strip()
|
||||
|
||||
company = {
|
||||
"enterprise_no": await lbl("EntNo"),
|
||||
"enterprise_name": await lbl("EntName"),
|
||||
"enterprise_type": await lbl("EntType"),
|
||||
"enterprise_status": await lbl("EntStatus"),
|
||||
"directors": [],
|
||||
}
|
||||
|
||||
# Directors table — find the table whose header row contains
|
||||
# "ID Number". Verified ordering: ID Number | Name(s) | Surname
|
||||
# | Type | Status.
|
||||
tables = await page.locator("table").all()
|
||||
for t in tables:
|
||||
try:
|
||||
headers = await t.locator("th").all()
|
||||
header_text = [(await h.inner_text()).strip() for h in headers]
|
||||
except Exception:
|
||||
continue
|
||||
if not header_text:
|
||||
continue
|
||||
if any("ID Number" in h for h in header_text):
|
||||
cols = {"id": None, "names": None, "surname": None, "type": None, "status": None}
|
||||
for i, h in enumerate(header_text):
|
||||
hl = h.lower()
|
||||
if "id number" in hl:
|
||||
cols["id"] = i
|
||||
elif "name(s)" in hl or hl.startswith("name"):
|
||||
cols["names"] = i
|
||||
elif "surname" in hl:
|
||||
cols["surname"] = i
|
||||
elif "type" in hl:
|
||||
cols["type"] = i
|
||||
elif "status" in hl:
|
||||
cols["status"] = i
|
||||
for row in await t.locator("tbody tr").all():
|
||||
cells = await row.locator("td").all()
|
||||
if not cells:
|
||||
continue
|
||||
cell_text = []
|
||||
for c in cells:
|
||||
try:
|
||||
cell_text.append((await c.inner_text()).strip())
|
||||
except Exception:
|
||||
cell_text.append("")
|
||||
if all(not x for x in cell_text):
|
||||
continue
|
||||
def at(k: str) -> str:
|
||||
i = cols.get(k)
|
||||
if i is None or i >= len(cell_text):
|
||||
return ""
|
||||
return cell_text[i]
|
||||
company["directors"].append({
|
||||
"id_number": at("id"),
|
||||
"names": at("names"),
|
||||
"surname": at("surname"),
|
||||
"status": at("status"),
|
||||
"type": at("type") or "Director",
|
||||
})
|
||||
break
|
||||
|
||||
return company
|
||||
|
||||
|
||||
async def _parse_grid_layout(page: Page) -> List[Dict[str, Any]]:
|
||||
"""ID search result — list of companies in a GridView.
|
||||
|
||||
Returns the company list immediately. Directors are NOT fetched
|
||||
here — the caller can request them per-company via the
|
||||
/directors/<enterprise_no> endpoint. This keeps the search
|
||||
fast even when the ID has many director roles.
|
||||
"""
|
||||
companies: List[Dict[str, Any]] = []
|
||||
rows = await page.locator(f"#{ID_SEARCH_GRID} tbody tr").all()
|
||||
for row in rows:
|
||||
cells = await row.locator("td").all()
|
||||
if len(cells) < 4:
|
||||
continue
|
||||
cell_text = []
|
||||
for c in cells:
|
||||
try:
|
||||
cell_text.append((await c.inner_text()).strip())
|
||||
except Exception:
|
||||
cell_text.append("")
|
||||
if not cell_text[0]:
|
||||
continue
|
||||
companies.append({
|
||||
"enterprise_no": cell_text[0],
|
||||
"enterprise_name": cell_text[1] if len(cell_text) > 1 else "",
|
||||
"enterprise_status": cell_text[2] if len(cell_text) > 2 else "",
|
||||
"director_status": cell_text[3] if len(cell_text) > 3 else "",
|
||||
"directors": [],
|
||||
})
|
||||
return companies
|
||||
|
||||
|
||||
async def _fetch_directors_for_company(page: Page, enterprise_no: str) -> List[Dict[str, Any]]:
|
||||
"""ID search follow-up — do a separate CK search for the given
|
||||
enterprise number and return its directors.
|
||||
|
||||
Clicking the View button on the GridView navigates to the
|
||||
company details page (the GridView is gone afterwards), so we
|
||||
can't click multiple in a row. Doing a fresh CK search is the
|
||||
simplest reliable path.
|
||||
"""
|
||||
await page.goto(BIZPROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(1500)
|
||||
await page.locator(f"#{BIZPROFILE_DROPDOWN}").select_option("EntNo")
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(1500)
|
||||
await page.locator(f"#{BIZPROFILE_INPUT}").fill(enterprise_no)
|
||||
await page.locator(f"#{BIZPROFILE_BUTTON}").click()
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(2500)
|
||||
company = await _parse_company_layout(page)
|
||||
return company["directors"]
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# POST /search
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/search")
|
||||
async def search(req: SearchRequest):
|
||||
log_list: List[str] = []
|
||||
async with _browser_lock:
|
||||
page = await _context.new_page() if _context else None
|
||||
if not page:
|
||||
raise HTTPException(503, "Browser not ready")
|
||||
try:
|
||||
await _ensure_logged_in(page, log_list)
|
||||
|
||||
option = BIZPROFILE_DROPDOWN_OPTIONS.get(req.type)
|
||||
if not option:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": f"Unknown search type: {req.type}", "log": log_list},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
log_list.append(f"GET {BIZPROFILE_URL}")
|
||||
await page.goto(BIZPROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
log_list.append(f"Select dropdown: {option}")
|
||||
await page.locator(f"#{BIZPROFILE_DROPDOWN}").select_option(option)
|
||||
# Dropdown change triggers a postback — wait for the page to settle.
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=15000)
|
||||
except Exception:
|
||||
pass
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
log_list.append(f"Fill query: {req.query}")
|
||||
await page.locator(f"#{BIZPROFILE_INPUT}").fill(req.query)
|
||||
await page.locator(f"#{BIZPROFILE_BUTTON}").click()
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
companies = await _parse_results_table(page)
|
||||
log_list.append(f"Parsed {len(companies)} companies")
|
||||
|
||||
# For ID search, optionally click-through to fetch directors.
|
||||
if req.type == "id" and companies:
|
||||
capped = companies[:MAX_ID_SEARCH_DIRECTOR_FETCH]
|
||||
skipped = len(companies) - len(capped)
|
||||
log_list.append(f"Fetching directors for {len(capped)} companies (cap={MAX_ID_SEARCH_DIRECTOR_FETCH})")
|
||||
for c in capped:
|
||||
directors = await _fetch_directors_for_company(page, c["enterprise_no"])
|
||||
c["directors"] = directors
|
||||
log_list.append(f" {c['enterprise_no']}: {len(directors)} directors")
|
||||
if skipped:
|
||||
log_list.append(f"Skipped {skipped} additional companies to keep search fast")
|
||||
|
||||
return {"ok": True, "query": req.query, "type": req.type, "results": companies, "log": log_list}
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Search failed")
|
||||
log_list.append(f"ERROR: {e}")
|
||||
return JSONResponse({"ok": False, "error": str(e), "log": log_list}, status_code=500)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# POST /reserve
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.post("/reserve")
|
||||
async def reserve(req: ReserveRequest):
|
||||
log_list: List[str] = []
|
||||
balance_before: Optional[float] = None
|
||||
threshold = req.balance_min if req.balance_min is not None else BALANCE_MIN
|
||||
balance_before = None
|
||||
step = "init"
|
||||
|
||||
async with _browser_lock:
|
||||
page = await _context.new_page() if _context else None
|
||||
if not page:
|
||||
raise HTTPException(503, "Browser not ready")
|
||||
try:
|
||||
names = [n for n in (req.names or []) if n and n.strip()]
|
||||
if not names:
|
||||
raise RuntimeError("No proposed names to apply")
|
||||
log_list.append(f"Names: {names}")
|
||||
|
||||
step = "login"
|
||||
await _ensure_logged_in(page, log_list)
|
||||
|
||||
step = "balance_before"
|
||||
balance_before = await _parse_balance(page)
|
||||
log_list.append(f"Balance BEFORE: {balance_before}")
|
||||
if balance_before is None:
|
||||
raise RuntimeError("Could not read CIPC Prepaid Account Balance")
|
||||
if balance_before <= threshold:
|
||||
raise RuntimeError(
|
||||
f"Insufficient balance: R {balance_before:.2f} is not > R {threshold:.2f}. "
|
||||
f"Top up the CIPC Prepaid Account before submitting."
|
||||
)
|
||||
|
||||
step = "open_name_reservation"
|
||||
log_list.append(f"GET {NAME_RESERVE_URL}")
|
||||
await page.goto(NAME_RESERVE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||
await page.wait_for_timeout(1500)
|
||||
|
||||
step = "fill_names"
|
||||
for i, name in enumerate(names[:4]):
|
||||
sel = f"#{NAME_INPUTS[i]}"
|
||||
count = await page.locator(sel).count()
|
||||
if count == 0:
|
||||
raise RuntimeError(f"Name field {sel} not found on name_reserve page")
|
||||
await page.locator(sel).fill(name)
|
||||
log_list.append(f"Filled {len(names[:4])} name boxes")
|
||||
|
||||
step = "continue_1"
|
||||
await page.locator(f"#{NAME_CONTINUE_1}").wait_for(state="visible", timeout=10000)
|
||||
await page.locator(f"#{NAME_CONTINUE_1}").click()
|
||||
log_list.append("Clicked first Continue")
|
||||
await page.wait_for_timeout(2500)
|
||||
|
||||
if req.dry_run:
|
||||
log_list.append("DRY RUN — stopping before second Continue")
|
||||
return {
|
||||
"ok": True,
|
||||
"dry_run": True,
|
||||
"balance_before": balance_before,
|
||||
"status": "dry_run",
|
||||
"log": log_list,
|
||||
"message": "Dry run complete. Cost review screen reached. Re-run with dry_run=false to lodge.",
|
||||
}
|
||||
|
||||
step = "continue_2"
|
||||
await page.locator(f"#{NAME_CONTINUE_2}").wait_for(state="visible", timeout=10000)
|
||||
await page.locator(f"#{NAME_CONTINUE_2}").click()
|
||||
log_list.append("Clicked second Continue")
|
||||
await page.wait_for_timeout(4000)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"balance_before": balance_before,
|
||||
"status": "bizportal_done",
|
||||
"log": log_list,
|
||||
"message": "Submitted via Bizportal. The R50 fee will be debited from your CIPC Prepaid Account at a later stage. You will receive an SMS confirmation.",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log_list.append(f"ERROR at step={step}: {e}")
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": str(e), "step": step, "balance_before": balance_before, "log": log_list},
|
||||
status_code=500,
|
||||
)
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("bizportal_service:app", host="127.0.0.1", port=7001, log_level="info")
|
||||
5
scripts/requirements.txt
Normal file
5
scripts/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
playwright==1.49.0
|
||||
pydantic==2.9.2
|
||||
python-dotenv==1.0.1
|
||||
Loading…
Reference in a new issue