Facility: 034190

Storage Star - Sheridan

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
034190
Name
Storage Star - Sheridan
URL
https://www.storagestar.com/storage-units/wyoming/sheridan/coffeen-avenue/
Address
N/A
Platform
custom_facility_034190
Parser File
src/parsers/custom/facility_034190_parser.py
Last Scraped
2026-03-23 03:20:22.460234
Created
2026-03-06 23:45:35.865957
Updated
2026-03-23 03:20:22.468716
Parser & Healing Diagnosis working
Parser Status
✓ Working
Status Reason
N/A
Last Healing Attempt
Not attempted
Parser Source (src/parsers/custom/facility_034190_parser.py)
"""Parser for StorageStar Coffeen Avenue (Sheridan, WY) facility.

This site uses the Storagely platform. Unit rows are <tr> elements with
class names matching the pattern ``unit_NNNNN``. Pricing, dimensions, and
features are available in both structured HTML elements and a hidden
``<p class="tag_input">`` element with data attributes.
"""

from __future__ import annotations

import re

from bs4 import BeautifulSoup, Tag

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

# Matches unit row class names like "unit_28978"
_UNIT_CLASS_RE = re.compile(r"^unit_\d+$")

# Matches dimension text like "5' WIDTH x 10' DEPTH" or "10' x 20'"
_DIM_RE = re.compile(
    r"(\d+(?:\.\d+)?)\s*['\u2032]?\s*(?:WIDTH\s+)?[xX\u00d7]\s*(\d+(?:\.\d+)?)\s*['\u2032]?",
    re.IGNORECASE,
)


def _extract_price(container: Tag | None) -> float | None:
    """Return the first numeric price found in a container element."""
    if container is None:
        return None
    text = container.get_text(separator=" ", strip=True)
    # Match patterns like "$60" or "$60.00" (ignoring "/month")
    match = re.search(r"\$([\d,]+(?:\.\d+)?)", text)
    if match:
        return float(match.group(1).replace(",", ""))
    return None


class Facility034190Parser(BaseParser):
    """Extract storage units from StorageStar Coffeen Ave (Sheridan, WY).

    The page is rendered by the Storagely platform. Each unit is a ``<tr>``
    element whose ``class`` list includes an ID-specific token like
    ``unit_29064``. Key data:

    - Dimensions: ``<h2 class="widthHeight">`` — e.g. "5' WIDTH x 10' DEPTH"
    - Unit type: ``<div class="unit-type-listing-name">``
    - Web/sale price: ``<h3 class="actualMoPrice">``
    - Standard price: ``<h3 class="withoutDiscntprice">``
    - Promotion: ``<span class="offer__content">``
    - Feature flags: ``<p class="tag_input" utype="..." climate="...">``
    - Availability: ``data-listing_status`` attribute on the row
    """

    platform = "custom_facility_034190"

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

        # Find all unit rows
        unit_rows = soup.find_all(
            lambda tag: any(_UNIT_CLASS_RE.match(c) for c in tag.get("class", []))
        )

        if not unit_rows:
            result.warnings.append("No unit rows found matching pattern 'unit_NNNNN'")
            return result

        for row in unit_rows:
            unit = self._parse_row(row, url)
            if unit is not None:
                result.units.append(unit)

        if not result.units:
            result.warnings.append(f"Found {len(unit_rows)} unit rows but extracted 0 units")

        return result

    def _parse_row(self, row: Tag, url: str) -> UnitResult | None:
        """Parse a single unit row into a UnitResult."""
        # --- Availability ---
        status = row.get("data-listing_status", "").strip().lower()
        if status == "occupied":
            return None

        # --- Dimensions ---
        h2 = row.find("h2", class_="widthHeight")
        size_text = h2.get_text(separator=" ", strip=True) if h2 else ""
        dim_match = _DIM_RE.search(size_text)
        if dim_match:
            width = float(dim_match.group(1))
            length = float(dim_match.group(2))
            size = f"{int(width)}' x {int(length)}'"
            sqft = width * length
        else:
            size = size_text or None
            width = length = sqft = None

        # --- Unit type / description ---
        unit_type_el = row.find(class_="unit-type-listing-name")
        unit_type_raw = unit_type_el.get_text(separator=" ", strip=True) if unit_type_el else ""
        # The element often has trailing unit name/number embedded — strip trailing digits
        unit_type = re.sub(r"\s*\w*\d+\w*\s*$", "", unit_type_raw).strip()

        # --- Prices ---
        # actualMoPrice = web/sale rate
        sale_price = _extract_price(row.find(class_="actualMoPrice"))
        # withoutDiscntprice = standard rate (shown with strikethrough)
        std_price = _extract_price(row.find(class_="withoutDiscntprice"))

        # Assign price fields: price = standard rate, sale_price = web rate
        price = std_price
        if sale_price is not None and std_price is not None and sale_price < std_price:
            # There is a genuine discount
            pass  # keep both as-is
        elif sale_price is not None and std_price is None:
            # Only one price; treat as the regular price
            price = sale_price
            sale_price = None

        # --- Promotion ---
        promo_el = row.find(class_="offer__content")
        promotion = promo_el.get_text(strip=True) if promo_el else None

        # --- Feature metadata from hidden tag_input element ---
        tag_input = row.find("p", class_="tag_input")
        utype = ""
        climate = ""
        if tag_input:
            utype = tag_input.get("utype", "").strip()
            climate = tag_input.get("climate", "").strip().rstrip(",")

        is_climate_controlled = (
            "climate" in utype.lower()
            or "climate" in climate.lower()
            or "heated" in climate.lower()
        )
        is_drive_up = "driveup" in utype.lower().replace("-", "").replace(" ", "")
        is_parking = "parking" in utype.lower()

        metadata: dict = {}
        if width is not None:
            metadata["width"] = width
        if length is not None:
            metadata["length"] = length
        if sqft is not None:
            metadata["sqft"] = sqft
        if utype:
            metadata["unit_type"] = utype
        if climate:
            metadata["climate"] = climate
        if is_climate_controlled:
            metadata["climate_controlled"] = True
        if is_drive_up:
            metadata["drive_up"] = True
        if is_parking:
            metadata["parking"] = True

        description_parts = [p for p in [unit_type, climate] if p]
        description = " | ".join(description_parts) if description_parts else unit_type or None

        return UnitResult(
            size=size,
            description=description,
            price=price,
            sale_price=sale_price,
            promotion=promotion,
            url=url or None,
            metadata=metadata if metadata else None,
        )

Scrape Runs (5)

Run #1488 Details

Status
exported
Parser Used
Facility034190Parser
Platform Detected
table_layout
Units Found
10
Stage Reached
exported
Timestamp
2026-03-23 03:20:06.982687
Timing
Stage Duration
Fetch15023ms
Detect283ms
Parse148ms
Export7ms

Snapshot: 034190_20260323T032022Z.html · Show Snapshot · Open in New Tab

Parsed Units (10)

5' x 10'

$57.00/mo
Street: $95.00

5' x 10'

$82.00/mo
Street: $137.00

10' x 10'

$91.00/mo
Street: $152.00

10' x 15'

$96.00/mo
Street: $160.00

10' x 15'

$149.00/mo
Street: $248.00

10' x 20'

$98.00/mo
Street: $163.00

10' x 20'

$102.00/mo
Street: $170.00

10' x 30'

$64.00/mo
Street: $107.00

10' x 30'

$165.00/mo
Street: $275.00

10' x 32'

$220.00/mo
Street: $367.00

All Failures for this Facility (2)

parse _WarningAsException scraper no_units_extracted warning Run #N/A | 2026-03-14 16:55:16.565913

No units extracted for 034190

Stack trace
src.reporting.failure_reporter._WarningAsException: No units extracted for 034190
parse _WarningAsException scraper no_units_extracted warning Run #N/A | 2026-03-14 01:00:37.947804

No units extracted for 034190

Stack trace
src.reporting.failure_reporter._WarningAsException: No units extracted for 034190

← Back to dashboard