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:
Dave 2026-08-17 18:33:36 +02:00
parent 2d402c0482
commit 7c3847fbb0
13 changed files with 1671 additions and 3 deletions

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ dist/
# Next.js type helpers
next-env.d.ts
tsconfig.tsbuildinfo
# Editor cruft
.DS_Store

View 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 },
);
}
}

View 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 },
);
}
}

View 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 },
);
}
}

View 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 &amp;&amp; source .venv/bin/activate &amp;&amp; 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
View 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>
</>
);
}

View file

@ -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>}>

View file

@ -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
View 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
View file

@ -0,0 +1,5 @@
storage_state.json
.venv/
__pycache__/
*.pyc
.env

162
scripts/README.md Normal file
View 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).

View 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
View 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