Secretary Of State Portal Api Ingestion

Automating the New York DOS Entity Search With a Headless Fallback

This guide is part of the Headless Browser Fallback Strategies area within the Secretary of State Portal & API Ingestion framework. It takes the one jurisdiction that most often forces the last-resort tier — New York — and shows exactly how to drive its Department of State (DOS) corporation and business-entity search when there is no JSON contract to consume: a Playwright fallback that fills the search form, waits for the JavaScript-rendered results grid, reads the standing cell, and degrades to a defensible fallback instead of guessing.

Scope of This Page

This page covers a single-intent read against the public New York DOS entity search: opening the search interface, submitting one entity name, waiting deterministically on the results grid that the portal renders client-side, extracting the status and DOS ID from the first matching row, and returning a typed record with a graceful-degradation path when the grid never arrives. It deliberately excludes the surrounding machinery documented elsewhere — the failure signature that routes a request into the browser tier in the first place, which belongs to the parent area’s Error Categorization & Retry Logic, and the pacing that keeps a sweep of NY reads under the portal’s tolerance, which belongs to Async Polling & Rate Limiting. Anti-bot handling is intentionally a hard stop here, not a subject: a challenge escalates and the read ends.

The Constraint That Forces a Browser at All

New York recognizes no grace window on the biennial Statement under N.Y. BCL § 408: a domestic or authorized foreign corporation must file its statement of the chief executive officer, the address for service of process, and related particulars every two years in the calendar month of original formation or authorization. A statement that lapses moves the entity toward a “past due” condition that surfaces only in the DOS record — and DOS exposes that record through a session-bound public inquiry interface whose results grid is painted by client-side script, not through a stable, documented API you can call for a status field. So verifying a New York entity’s standing on a given date is a browser problem by construction. Because BCL § 408 leaves no cure buffer to absorb a wrong answer, the read must be reproducible and tamper-evident: the extracted standing is stored with a hash over its canonical form so a compliance officer can later prove what the portal showed and when.

Prerequisites

  • Python 3.10+ — for X | Y unions, match, and modern asyncio.
  • Playwright (Python) 1.40+ — async API with explicit waits and bundled Chromium; run playwright install chromium once.
  • Standard library beyond that: asyncio, hashlib, json, logging, datetime, dataclasses, enum.
  • An ephemeral container with a small /dev/shm, launched with --disable-dev-shm-usage, and a cached-snapshot store the degradation path can read from.
  • A request already classified as a structural failure by the parent area’s error taxonomy — the API tier has been exhausted and a browser is the only remaining path.

The diagram traces the whole route: an API probe that finds no usable contract, the classifier that labels the failure structural, and the single-intent headless session that opens the search, fills one field, awaits the JS grid, and either extracts a status or degrades to a cached snapshot. The module beneath it is that session as one runnable page object. Every wait is tied to a real DOM signal — never a fixed sleep, which is both flaky against a slow XHR and a detectable automation tell — and the graceful-degradation branch never fabricates a status when the grid fails to render.

NY DOS headless fallback: from a contract-less API probe through the page-object search to an extracted standing or a degraded cached read An API probe finds no usable NY DOS contract and emits a failure signature to a classifier that routes transient faults to the async queue and hard blocks to human review. A structural failure enters a single-intent headless session that opens the search, fills the entity name, submits, and waits on the JS-rendered grid — extracting a sealed standing record on success, or degrading to a stale cached snapshot on timeout. NY DOS has no usable API → classify the failure → drive the search as one page object API probe · DOS public inquiry no JSON standing contract published classify_failure() classify, then route — never retry blindly transient Async-polling queue API may recover · retry there hard block Human review CAPTCHA → stop, escalate structural SINGLE-INTENT HEADLESS SESSION · one entity · isolated context 1 · open() search page wait_until=domcontentloaded (grid comes later) 2 · fill(entity-name input) deterministic form fill 3 · click(search) · submit fires the results XHR 4 · wait_for_selector(results grid) JS-rendered rows · no fixed sleep rendered 5 · extract status + DOS ID → sealed record SHA-256 over canonical standing timeout Degrade gracefully last cached snapshot, flagged stale never fabricate a status
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum

from playwright.async_api import (
    Page,
    TimeoutError as PlaywrightTimeout,
    async_playwright,
)

# Structured JSON logging — every line is a parseable audit/observability event.
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("ingestion.ny_dos")

# NY exposes a public inquiry UI but no stable JSON standing contract; the biennial
# Statement under N.Y. BCL s 408 is verified by reading this client-rendered grid.
NY_DOS_SEARCH_URL = "https://apps.dos.ny.gov/publicInquiry/"


class SearchOutcome(str, Enum):
    RESOLVED = "resolved"      # grid rendered and a status cell was read
    NOT_FOUND = "not_found"    # grid rendered with zero matching rows
    DEGRADED = "degraded"      # grid never rendered -> serve cached snapshot, flag stale
    CHALLENGED = "challenged"  # anti-bot boundary -> hard stop, escalate to human review


@dataclass(frozen=True)
class EntityStanding:
    entity_name: str
    dos_id: str | None
    status: str | None
    outcome: SearchOutcome
    retrieved_utc: str

    @property
    def evidence_sha256(self) -> str:
        # Hash the canonical record so a stored standing read is tamper-evident.
        body = json.dumps(
            {"name": self.entity_name, "dos_id": self.dos_id, "status": self.status,
             "outcome": self.outcome.value, "retrieved": self.retrieved_utc},
            sort_keys=True,
        ).encode("utf-8")
        return hashlib.sha256(body).hexdigest()


class CaptchaEncountered(RuntimeError):
    """A NY DOS anti-bot challenge — a terminal boundary; escalate, never retry."""


class NyDosSearchPage:
    """Page object for the NY DOS entity search: one method per interaction step."""

    NAME_INPUT = "input#SearchEntityName"        # the entity-name text field
    SEARCH_BUTTON = "button#SearchButton"        # submit control
    RESULTS_GRID = "table#SearchGrid tbody tr"   # JS-rendered result rows
    STATUS_CELL = "td[data-col='EntityStatus']"  # standing cell within a row
    DOS_ID_CELL = "td[data-col='DosId']"
    CHALLENGE = "iframe[src*='captcha'], #challenge-running"

    def __init__(self, page: Page) -> None:
        self._page = page

    async def open(self) -> None:
        # domcontentloaded, not networkidle: the grid is painted later via XHR.
        await self._page.goto(NY_DOS_SEARCH_URL, wait_until="domcontentloaded", timeout=20_000)
        await self._guard_challenge()

    async def query(self, entity_name: str) -> None:
        await self._page.fill(self.NAME_INPUT, entity_name)  # deterministic form fill
        await self._page.click(self.SEARCH_BUTTON)           # fires the results XHR

    async def await_results(self, timeout_ms: int = 12_000) -> bool:
        """Wait on the JS-rendered grid itself, never a timer. False => never rendered."""
        try:
            await self._page.wait_for_selector(self.RESULTS_GRID, timeout=timeout_ms)
            return True
        except PlaywrightTimeout:
            return False

    async def extract_first_row(self) -> tuple[str | None, str | None]:
        row = self._page.locator(self.RESULTS_GRID).first
        status = (await row.locator(self.STATUS_CELL).inner_text()).strip() or None
        dos_id = (await row.locator(self.DOS_ID_CELL).inner_text()).strip() or None
        return dos_id, status

    async def _guard_challenge(self) -> None:
        if await self._page.query_selector(self.CHALLENGE) is not None:
            raise CaptchaEncountered(self._page.url)


async def search_ny_dos(
    entity_name: str, cached: EntityStanding | None = None
) -> EntityStanding:
    """API-less NY DOS standing read with a headless fallback and graceful degradation."""
    now = datetime.now(timezone.utc).isoformat()
    async with async_playwright() as pw:
        browser = await pw.chromium.launch(
            headless=True,
            args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
        )
        # Fresh, isolated context so no prior entity's cookies leak into this read.
        context = await browser.new_context(ignore_https_errors=False)
        page = await context.new_page()
        search = NyDosSearchPage(page)
        try:
            await search.open()
            await search.query(entity_name)
            if not await search.await_results():
                # DEGRADE GRACEFULLY: the grid never rendered. Do not fabricate a
                # status — serve the last cached snapshot flagged stale, or a bare
                # DEGRADED record the orchestrator escalates when no cache exists.
                logger.warning(json.dumps({
                    "event": "ny_dos_grid_timeout", "entity": entity_name,
                    "fell_back_to_cache": cached is not None,
                }))
                return cached or EntityStanding(
                    entity_name, None, None, SearchOutcome.DEGRADED, now
                )
            dos_id, status = await search.extract_first_row()
            outcome = SearchOutcome.RESOLVED if status else SearchOutcome.NOT_FOUND
            result = EntityStanding(entity_name, dos_id, status, outcome, now)
            logger.info(json.dumps({
                "event": "ny_dos_resolved", "entity": entity_name, "dos_id": dos_id,
                "status": status, "outcome": outcome.value,
                "evidence": result.evidence_sha256[:12],
            }))
            return result
        except CaptchaEncountered as exc:
            # Hard block: BCL s 408 standing is not worth crossing an access control.
            logger.warning(json.dumps({
                "event": "ny_dos_challenge", "entity": entity_name,
                "url": str(exc), "classification": "CAPTCHA_DETECTED",
            }))
            return EntityStanding(entity_name, None, None, SearchOutcome.CHALLENGED, now)
        finally:
            await context.close()  # never orphan a Chromium process


if __name__ == "__main__":
    standing = asyncio.run(search_ny_dos("ACME HOLDINGS LLC"))
    print(standing.outcome.value, standing.status, standing.evidence_sha256[:12])

The module is a leaf: it does not decide whether to run the browser and it does not retry. It consumes an entity name that the router has already classified as a structural failure, returns one of four typed SearchOutcomes, and leaves the verdict to the orchestration above it. RESOLVED and NOT_FOUND are both successful reads of the grid — the distinction is whether a row matched — while DEGRADED and CHALLENGED are the two ways the read can fail without lying about the entity’s status.

Configuration Reference

Every wait and selector here is configuration dictated by how the DOS portal behaves, not a branch you tune by feel. Getting these wrong is the difference between a stale-but-honest read and a silent misclassification.

Parameter Suggested value Operational justification
open() wait_until domcontentloaded The grid is XHR-painted; load/networkidle either fire too early or hang on long-poll connections.
open() timeout 20 000 ms DOS landing render is slow under load; below ~15 s the first navigation flaps during peak filing months.
await_results timeout_ms 12 000 ms Ceiling for the results XHR. Exceeding it means degrade, not retry — the grid is genuinely absent.
RESULTS_GRID selector grid row, not container Waiting on the table wrapper resolves before rows exist; wait on tbody tr so a row is actually present.
ignore_https_errors False A forged good-standing page behind a bad cert must fail, never seed the audit trail.
cached snapshot last sealed read The degradation target; without it a grid timeout can only escalate.
Launch args --disable-dev-shm-usage Prevents opaque Chromium crashes when the container /dev/shm is small.

Failure Modes and Fallback Routing

Each fault maps onto the four-tier scheme in the parent area’s Error Categorization & Retry Logic — transient, statutory, data-validation, and system — and the module answers each one differently rather than retrying blindly.

  1. The results grid never renders within the timeout (system). DOS is up but the results XHR stalled or the row selector never matched. await_results returns False, and the module returns the last cached snapshot flagged stale or a bare DEGRADED record. It never fabricates a status; a wrong “active” against a BCL § 408 past-due entity is worse than an honest “unknown.”
  2. A challenge page interrupts navigation (hard block). An interstitial CAPTCHA or WAF check raises CaptchaEncountered, the module logs CAPTCHA_DETECTED and returns CHALLENGED. Solving it would cross the access-authorization line the Headless Browser Fallback Strategies area treats as terminal, so the obligation escalates to human review.
  3. DOS returns a 5xx or resets the connection before the page loads (transient). This is not a browser problem — the underlying service is flapping. The request should never have reached this module; it belongs back on the Async Polling & Rate Limiting queue where the API path can recover, rather than burning a browser session per attempt.
  4. The grid renders but the status cell is empty or the entity name matched nothing (data-validation). extract_first_row yields None for status, so the outcome is NOT_FOUND rather than RESOLVED. The orchestrator treats a genuine zero-row result as a data-quality signal — usually a name normalization mismatch — and quarantines the entity against its expected identifiers instead of recording a false negative as fact.

Frequently Asked Questions

Why wait on the grid row selector instead of `networkidle` after clicking search?

The DOS results page opens long-poll and telemetry connections that keep the network busy well after the grid has painted, so networkidle can hang past your timeout on a page that is already complete. Worse, waiting on the table container resolves the instant the empty <table> exists, before any row arrives, and you extract from a grid that has no data yet. Waiting on table#SearchGrid tbody tr ties the wait to the one signal that actually matters — a rendered result row — which is both faster and correct.

The grid timed out but the entity is real — should the module retry the navigation?

Not inside this module. A timeout here means DEGRADED: the module returns the last cached snapshot flagged stale, and the orchestrator decides whether to re-queue. Retrying navigation in a tight loop against a slow DOS portal earns an IP-reputation penalty and can trip a challenge, converting a recoverable degrade into a hard block. Re-attempts belong on the paced Async Polling & Rate Limiting queue, not in the browser leaf.

How does one row of a JS-rendered grid become defensible evidence of BCL § 408 standing?

The extracted EntityStanding is sealed with a SHA-256 hash over its canonical (sort_keys=True) form, capturing the entity name, DOS ID, status, outcome, and UTC retrieval time together. Stored beside the record and recomputed on read, that digest lets a compliance officer prove the portal showed this exact standing at this exact time — the same provenance control an API-sourced read carries, which is what keeps a browser read admissible under N.Y. BCL § 408.

Can this page object read the biennial statement's actual filing date, not just standing?

Yes, by adding a selector method that navigates from the result row into the entity detail view and waits on the filing-history cell — the page-object shape is designed for exactly that extension. Keep it single-intent: a second objective is a second session with its own isolated context, not a longer navigation on the same page. Harvesting the whole detail record while the page happens to be open drifts toward exceeding the access the portal’s terms of service grant.