Facility: 051413

U-Haul Casper WY (82609)

Stale Data Warning: This facility has not been successfully scraped in 30 days (threshold: 3 days). Data may be outdated.
Facility Information active
Facility ID
051413
Name
U-Haul Casper WY (82609)
URL
https://www.uhaul.com/Locations/Self-Storage-near-Casper-WY-82609/792073/
Address
N/A
Platform
custom_facility_051413
Parser File
src/parsers/custom/facility_051413_parser.py
Last Scraped
2026-03-23 03:20:39.048830
Created
2026-03-06 23:45:35.865957
Updated
2026-03-23 03:20:39.062483
Parser & Healing Diagnosis working
Parser Status
✓ Working
Status Reason
N/A
Last Healing Attempt
Not attempted
Parser Source (src/parsers/custom/facility_051413_parser.py)
"""Parser for U-Haul Moving & Storage of East Casper (facility 051413).

U-Haul uses a custom layout with three category lists (small/medium/larger),
each containing <li class="divider"> elements — one per available unit type.
Each element holds: dimensions in an <h4>, amenities in a <ul>, and price in a
<b class="text-2x"> element.
"""

from __future__ import annotations

import re

from bs4 import BeautifulSoup

from src.parsers.base import BaseParser, ParseResult, UnitResult

# Matches dimension strings like "5' x 5' x 10'" or "5' x 8' x 7.5'"
_DIM_RE = re.compile(
    r"(\d+(?:\.\d+)?)['\u2019\u2032]\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)['\u2019\u2032]"
    r"(?:\s*[xX\u00d7]\s*(\d+(?:\.\d+)?)['\u2019\u2032])?",
)


class Facility051413Parser(BaseParser):
    """Extract storage units from U-Haul Moving & Storage of East Casper.

    The page has three <ul class="uhjs-unit-list"> sections (small, medium,
    larger units).  Every direct <li class="divider"> child represents one
    unit type.  Within each li:

    - Dimensions: <h4> containing a <span class="nowrap"> or raw text.
    - Amenities: <ul style="list-style-type:disc;"> with one <li> per feature.
    - Price:     <b class="text-2x"> (the "show for medium" rate cell).
    """

    platform = "custom_facility_051413"

    def parse(self, html: str, url: str = "") -> ParseResult:
        soup = BeautifulSoup(html, "lxml")
        result = ParseResult(platform=self.platform, parser_name=self.__class__.__name__)

        unit_lists = soup.find_all(
            "ul", class_=lambda c: c and "uhjs-unit-list" in c
        )

        for unit_list in unit_lists:
            items = unit_list.find_all("li", recursive=False)
            for item in items:
                # All direct li children in these lists are unit rows
                unit = self._parse_item(item, url)
                if unit is not None:
                    result.units.append(unit)

        if not result.units:
            result.warnings.append("No units found — U-Haul page structure may have changed")

        return result

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _parse_item(self, item: BeautifulSoup, url: str) -> UnitResult | None:
        """Parse a single <li> unit row and return a UnitResult, or None."""
        # --- Size / dimensions ---
        size_str = self._extract_size(item)
        if not size_str:
            return None

        # Normalize the display size to "W' x L'" (drop height dimension)
        display_size, width, length = self._format_size(size_str)

        # --- Price ---
        price_b = item.find("b", class_="text-2x")
        price = self.normalize_price(price_b.get_text(strip=True)) if price_b else None

        # --- Description ---
        desc_span = None
        desc_p = item.find("p", class_="medium-collapse")
        if desc_p:
            desc_span = desc_p.find("span")
        description = desc_span.get_text(strip=True) if desc_span else None

        # --- Amenities ---
        amenity_ul = item.find("ul", style=True)
        amenities: list[str] = []
        if amenity_ul:
            amenities = [li.get_text(strip=True) for li in amenity_ul.find_all("li")]

        climate_controlled = "Climate" in amenities or "Heated" in amenities
        drive_up = "Drive Up" in amenities
        interior = "Interior" in amenities
        ada = "ADA" in amenities
        portable = "Portable Container" in amenities

        # --- Promotion ---
        promo_link = item.find("a", class_=lambda c: c and "tag" in c)
        promotion = promo_link.get_text(strip=True) if promo_link else None

        # Build metadata
        metadata: dict = {
            "amenities": amenities,
            "climate_controlled": climate_controlled,
            "drive_up": drive_up,
            "interior": interior,
        }
        if ada:
            metadata["ada"] = True
        if portable:
            metadata["portable"] = True
        if width is not None:
            metadata["width"] = width
            metadata["length"] = length
            metadata["sqft"] = round(width * length, 2)

        return UnitResult(
            size=display_size,
            description=description,
            price=price,
            promotion=promotion,
            url=url,
            metadata=metadata,
        )

    def _extract_size(self, item: BeautifulSoup) -> str | None:
        """Return raw dimension string from the item's <h4>."""
        h4 = item.find("h4")
        if not h4:
            return None

        # Prefer <span class="nowrap"> inside the h4
        nowrap = h4.find("span", class_="nowrap")
        if nowrap:
            return nowrap.get_text(strip=True)

        # Fallback: extract dimension substring from full h4 text
        h4_text = h4.get_text(separator=" ", strip=True)
        m = _DIM_RE.search(h4_text)
        return m.group(0) if m else None

    def _format_size(self, size_str: str) -> tuple[str, float | None, float | None]:
        """Return (display_size, width, length) from a raw dimension string."""
        m = _DIM_RE.search(size_str)
        if not m:
            return size_str, None, None

        width = float(m.group(1))
        length = float(m.group(2))
        # Format as "W' x L'" — drop the height dimension for display
        display = f"{int(width) if width == int(width) else width}' x {int(length) if length == int(length) else length}'"
        return display, width, length

Scrape Runs (5)

Run #997 Details

Status
exported
Parser Used
Facility051413Parser
Platform Detected
storageunitsoftware
Units Found
7
Stage Reached
exported
Timestamp
2026-03-21 19:13:44.684564
Timing
Stage Duration
Fetch8836ms
Detect327ms
Parse148ms
Export7ms

Snapshot: 051413_20260321T191353Z.html · Show Snapshot · Open in New Tab

Parsed Units (7)

5' x 5'

$54.95/mo

5' x 5'

$54.95/mo

5' x 8'

$59.95/mo

5' x 10'

$69.95/mo

10' x 15'

$189.95/mo

10' x 10'

$119.95/mo

10' x 15'

$169.95/mo

← Back to dashboard