Includes: API (Express/TypeScript), Astro site, Python workers, document generators, FCC compliance tools, Canada CRTC formation, Ansible infrastructure, and deployment scripts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
237 lines
8.9 KiB
Python
237 lines
8.9 KiB
Python
"""Wyoming — WyoBiz SOS portal automation.
|
|
|
|
WyoBiz (wyobiz.wyo.gov) uses ASP.NET WebForms with __VIEWSTATE.
|
|
Name search confirmed working with verified CSS selectors.
|
|
LLC/Corp filing requires an active session — selectors TBD during portal walkthrough.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
|
|
from scripts.formation.base import (
|
|
StatePortal, NameSearchResult, FormationOrder, FilingResult,
|
|
FilingStatus, EntityType,
|
|
)
|
|
from .config import CONFIG
|
|
|
|
|
|
class WYPortal(StatePortal):
|
|
STATE_CODE = "WY"
|
|
STATE_NAME = "Wyoming"
|
|
PORTAL_NAME = "WyoBiz"
|
|
PORTAL_URL = CONFIG["portal_url"]
|
|
NWRA_ADDRESS = CONFIG["nwra_address"]
|
|
NWRA_CITY = CONFIG["nwra_city"]
|
|
NWRA_STATE = CONFIG["nwra_state"]
|
|
NWRA_ZIP = CONFIG["nwra_zip"]
|
|
|
|
async def search_name(self, name: str) -> NameSearchResult:
|
|
"""Search Wyoming business name availability via WyoBiz.
|
|
|
|
Uses verified selectors from the FilingSearch.aspx page.
|
|
WyoBiz is ASP.NET WebForms — uses postback for search.
|
|
"""
|
|
try:
|
|
page = await self.start_browser()
|
|
await page.goto(CONFIG["name_search_url"], wait_until="networkidle")
|
|
await self.human_delay(1.5, 3.0)
|
|
|
|
sel = CONFIG["selectors"]
|
|
|
|
# Check for CAPTCHA first
|
|
if await self.detect_captcha():
|
|
return NameSearchResult(
|
|
available=False,
|
|
state_code=self.STATE_CODE,
|
|
searched_name=name,
|
|
raw_response="CAPTCHA detected — manual intervention required",
|
|
)
|
|
|
|
# Click "Contains" radio for broader search
|
|
await page.click(sel["name_search_contains"])
|
|
await self.human_delay(0.3, 0.6)
|
|
|
|
# Type the name slowly
|
|
await self.type_slowly(sel["name_search_input"], name)
|
|
await self.human_delay(0.5, 1.0)
|
|
|
|
# Click search — this triggers ASP.NET postback
|
|
await page.click(sel["name_search_submit"])
|
|
|
|
# Wait for postback to complete (loading indicator disappears)
|
|
try:
|
|
await page.wait_for_selector(
|
|
sel["loading_indicator"],
|
|
state="visible",
|
|
timeout=3000,
|
|
)
|
|
except Exception:
|
|
pass # Loading indicator may flash too fast
|
|
await page.wait_for_load_state("networkidle", timeout=15000)
|
|
await self.human_delay(1.0, 2.0)
|
|
|
|
# Screenshot for audit trail
|
|
screenshot = await self.screenshot("name_search_results")
|
|
|
|
# Get the results area content
|
|
results_el = await page.query_selector(sel["name_results_area"])
|
|
results_html = await results_el.inner_html() if results_el else ""
|
|
results_text = await results_el.inner_text() if results_el else ""
|
|
|
|
# Parse results
|
|
# WyoBiz shows results in a table. If no results, it shows a message.
|
|
similar_names: list[str] = []
|
|
exact_match = False
|
|
|
|
if "No records found" in results_text or not results_text.strip():
|
|
# No matching names — name is likely available
|
|
return NameSearchResult(
|
|
available=True,
|
|
exact_match=False,
|
|
similar_names=[],
|
|
state_code=self.STATE_CODE,
|
|
searched_name=name,
|
|
raw_response=results_text[:2000],
|
|
)
|
|
|
|
# Extract business names from results
|
|
# WyoBiz renders results as links in the results area
|
|
name_links = await page.query_selector_all(f"{sel['name_results_area']} a")
|
|
for link in name_links[:20]:
|
|
link_text = (await link.inner_text()).strip()
|
|
if link_text:
|
|
similar_names.append(link_text)
|
|
if link_text.upper() == name.upper():
|
|
exact_match = True
|
|
|
|
# If there are exact matches, name is taken
|
|
# If only similar names, name might still be available (WY checks distinguishability)
|
|
available = not exact_match
|
|
|
|
return NameSearchResult(
|
|
available=available,
|
|
exact_match=exact_match,
|
|
similar_names=similar_names[:10],
|
|
state_code=self.STATE_CODE,
|
|
searched_name=name,
|
|
raw_response=results_text[:2000],
|
|
)
|
|
|
|
except Exception as e:
|
|
self.log.error("WY name search failed: %s", e)
|
|
screenshot = await self.screenshot("name_search_error")
|
|
return NameSearchResult(
|
|
available=False,
|
|
state_code=self.STATE_CODE,
|
|
searched_name=name,
|
|
raw_response=f"Error: {e}",
|
|
)
|
|
finally:
|
|
await self.close_browser()
|
|
|
|
async def file_llc(self, order: FormationOrder) -> FilingResult:
|
|
"""File LLC Articles of Organization in Wyoming.
|
|
|
|
Wyoming LLC formation requires:
|
|
- Entity name (must be distinguishable from existing names)
|
|
- Registered agent name and address (NW RA)
|
|
- Organizer name and address (person filing)
|
|
- Principal office address
|
|
- Mailing address (optional)
|
|
- Filing fee: $100
|
|
|
|
TODO: Implement actual filing flow. Requires:
|
|
1. Navigate to https://wyobiz.wyo.gov/Business/Default.aspx
|
|
2. Click "File a New Business Entity"
|
|
3. Select "Limited Liability Company"
|
|
4. Fill in formation form fields
|
|
5. Submit payment ($100)
|
|
6. Capture confirmation number and filing ID
|
|
"""
|
|
try:
|
|
page = await self.start_browser()
|
|
await page.goto(CONFIG["filing_url"])
|
|
await self.human_delay()
|
|
await self.screenshot("wy_llc_start")
|
|
|
|
# Verify name is available first
|
|
name_result = await self.search_name(order.entity_name)
|
|
if not name_result.available:
|
|
return FilingResult(
|
|
success=False,
|
|
status=FilingStatus.NAME_UNAVAILABLE,
|
|
state_code=self.STATE_CODE,
|
|
entity_name=order.entity_name,
|
|
error_message=f"Name '{order.entity_name}' is not available in Wyoming. "
|
|
f"Similar names: {', '.join(name_result.similar_names[:5])}",
|
|
)
|
|
|
|
# TODO: Complete the filing flow once portal selectors are mapped
|
|
# The WyoBiz filing wizard has multiple steps:
|
|
# Step 1: Select entity type (LLC)
|
|
# Step 2: Enter entity name
|
|
# Step 3: Enter registered agent info
|
|
# Step 4: Enter organizer info
|
|
# Step 5: Enter principal office address
|
|
# Step 6: Payment ($100)
|
|
# Step 7: Confirmation
|
|
|
|
return FilingResult(
|
|
success=False,
|
|
status=FilingStatus.ERROR,
|
|
state_code=self.STATE_CODE,
|
|
entity_name=order.entity_name,
|
|
error_message="WY LLC filing automation: name search works, "
|
|
"filing form selectors pending portal walkthrough",
|
|
screenshot_path=await self.screenshot("wy_llc_pending"),
|
|
)
|
|
except Exception as e:
|
|
self.log.error("WY LLC filing failed: %s", e)
|
|
return FilingResult(
|
|
success=False,
|
|
status=FilingStatus.ERROR,
|
|
state_code=self.STATE_CODE,
|
|
entity_name=order.entity_name,
|
|
error_message=str(e),
|
|
)
|
|
finally:
|
|
await self.close_browser()
|
|
|
|
async def file_corporation(self, order: FormationOrder) -> FilingResult:
|
|
"""File Articles of Incorporation in Wyoming.
|
|
|
|
Similar flow to LLC but with additional fields:
|
|
- Authorized shares (number and par value)
|
|
- Directors (names and addresses)
|
|
- Incorporator (name and address)
|
|
|
|
TODO: Implement once LLC flow is working.
|
|
"""
|
|
try:
|
|
page = await self.start_browser()
|
|
await page.goto(CONFIG["filing_url"])
|
|
await self.human_delay()
|
|
|
|
return FilingResult(
|
|
success=False,
|
|
status=FilingStatus.ERROR,
|
|
state_code=self.STATE_CODE,
|
|
entity_name=order.entity_name,
|
|
error_message="WY Corp filing automation pending — LLC flow first",
|
|
)
|
|
except Exception as e:
|
|
return FilingResult(
|
|
success=False,
|
|
status=FilingStatus.ERROR,
|
|
state_code=self.STATE_CODE,
|
|
entity_name=order.entity_name,
|
|
error_message=str(e),
|
|
)
|
|
finally:
|
|
await self.close_browser()
|
|
|
|
|
|
def adapter() -> WYPortal:
|
|
return WYPortal()
|