Facility: 101861

Mod Storage

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
101861
Name
Mod Storage
URL
https://www.modstorage.com/storage-units/wyoming/laramie/skyline-road
Address
N/A
Platform
custom_facility_101861
Parser File
src/parsers/custom/facility_101861_parser.py
Last Scraped
2026-03-23 03:20:46.299171
Created
2026-03-06 23:45:35.865957
Updated
2026-03-23 03:20:46.308768
Parser & Healing Diagnosis working
Parser Status
✓ Working
Status Reason
N/A
Last Healing Attempt
Not attempted
Parser Source (src/parsers/custom/facility_101861_parser.py)
"""Parser for modSTORAGE — Laramie, WY (facility 101861).

This is a React/Next.js SPA site (modstorage.com) that renders unit cards as
mobile-optimised card elements. Each unit is a ``div.flex.md:hidden.flex-col``
card containing size, amenity type, promotional pricing ($0 first month), the
ongoing monthly price, the regular (crossed-out) price, and optional scarcity.
"""

from __future__ import annotations

import re

from bs4 import BeautifulSoup, Tag

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


class Facility101861Parser(BaseParser):
    """Extract storage units from modSTORAGE Laramie WY.

    Page structure (per unit card):
        <div class="flex md:hidden flex-col p-4 gap-3">
          <!-- size -->
          <span class="text-xl font-bold">5' x 5'</span>
          <!-- amenity/type -->
          <span class="text-xs text-muted-foreground">Drive Up</span>
          <!-- optional scarcity badge, e.g. "2 left" or "Only 2 left" -->
          <!-- promotional label, e.g. "Totally FREE Move In" -->
          <p class="text-[11px] text-primary font-medium ...">Totally FREE Move In</p>
          <!-- first-month price (often $0.00) -->
          <span class="text-2xl font-bold text-primary">$0.00</span>
          <!-- "then $XX.XX/mo" ongoing price -->
          <span class="text-sm text-muted-foreground">then $19.99/mo</span>
          <!-- regular/strikethrough price -->
          <span class="text-sm line-through ...">$34.99</span>
        </div>

    Pricing mapping:
        ``price``       = regular (strikethrough) price — the non-promotional rate
        ``sale_price``  = ongoing monthly rate after promotion (``then $XX/mo``)
        ``promotion``   = first-month promo text (e.g. "Totally FREE Move In – $0.00 1st mo")
    """

    platform = "custom_facility_101861"

    # Matches "then $XX.XX/mo" in muted spans
    _THEN_PRICE_RE = re.compile(r"then\s+\$([\d,]+(?:\.\d+)?)\s*/\s*mo", re.IGNORECASE)
    # Matches plain scarcity text such as "2 left" or "Only 2 left"
    _SCARCITY_RE = re.compile(r"(?:only\s+)?(\d+)\s+left", re.IGNORECASE)

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

        # Target the mobile unit cards rendered by the React SPA.
        # The desktop variant (div.md:flex.hidden) duplicates the same data, so
        # we parse only the mobile cards to avoid double-counting.
        cards = soup.find_all(
            "div",
            class_=lambda c: c and "md:hidden" in c and "flex-col" in c and "p-4" in c,
        )

        if not cards:
            result.warnings.append("No unit cards found — page structure may have changed")
            return result

        for card in cards:
            unit = self._parse_card(card, url)
            if unit is not None:
                result.units.append(unit)

        if not result.units:
            result.warnings.append(f"Cards found ({len(cards)}) but no units extracted")

        return result

    def _parse_card(self, card: Tag, url: str) -> UnitResult | None:
        """Extract a single UnitResult from a unit card element."""

        # --- Size ---
        size_span = card.find("span", class_=lambda c: c and "text-xl" in c and "font-bold" in c)
        if not size_span:
            return None
        raw_size = size_span.get_text(strip=True)
        width, length, sqft = self.normalize_size(raw_size)
        if width is None:
            return None
        size = f"{int(width)}' x {int(length)}'"

        # --- Amenity / unit type ---
        amenity_span = card.find(
            "span",
            class_=lambda c: c and "text-xs" in c and "text-muted-foreground" in c,
        )
        amenity = amenity_span.get_text(strip=True) if amenity_span else None

        # --- Scarcity ---
        # The scarcity text may appear in various inline spans; scan all text.
        card_text = card.get_text(separator=" ", strip=True)
        scarcity_match = self._SCARCITY_RE.search(card_text)
        scarcity = scarcity_match.group(0).strip() if scarcity_match else None

        # --- First-month promotional price (e.g. "$0.00 1st mo") ---
        promo_label_el = card.find("p", class_=lambda c: c and "text-primary" in c and "font-medium" in c)
        promo_label = promo_label_el.get_text(strip=True) if promo_label_el else None

        first_price_span = card.find(
            "span",
            class_=lambda c: c and "text-2xl" in c and "font-bold" in c and "text-primary" in c,
        )
        first_price_text = first_price_span.get_text(strip=True) if first_price_span else None
        first_price = self.normalize_price(first_price_text) if first_price_text else None

        # Build promotion string
        promotion: str | None = None
        if promo_label and first_price is not None:
            promotion = f"{promo_label} – {first_price_text} 1st mo"
        elif promo_label:
            promotion = promo_label

        # --- Ongoing monthly price (sale_price) ---
        # Appears in a "text-sm text-muted-foreground" span containing "then $..."
        then_spans = card.find_all(
            "span",
            class_=lambda c: c and "text-sm" in c and "text-muted-foreground" in c,
        )
        sale_price: float | None = None
        for span in then_spans:
            m = self._THEN_PRICE_RE.search(span.get_text(strip=True))
            if m:
                sale_price = self.normalize_price(m.group(1))
                break

        # If no "then" span exists the first_price IS the ongoing price
        if sale_price is None and first_price is not None and first_price > 0:
            sale_price = first_price
            promotion = None  # no special promo in this case

        # --- Regular / strikethrough price ---
        strike_span = card.find("span", class_=lambda c: c and "line-through" in c)
        regular_price: float | None = None
        if strike_span:
            regular_price = self.normalize_price(strike_span.get_text(strip=True))

        return UnitResult(
            size=size,
            description=amenity,
            price=regular_price,
            sale_price=sale_price,
            promotion=promotion,
            scarcity=scarcity,
            url=url,
            metadata={
                "width": width,
                "length": length,
                "sqft": sqft,
                "amenity": amenity,
            },
        )

Scrape Runs (5)

Run #78 Details

Status
exported
Parser Used
Facility101861Parser
Platform Detected
table_layout
Units Found
19
Stage Reached
exported
Timestamp
2026-03-14 01:01:04.337806
Timing
Stage Duration
Fetch12032ms
Detect42ms
Parse70ms
Export15ms

Snapshot: 101861_20260314T010116Z.html · Show Snapshot · Open in New Tab

Parsed Units (19)

8' x 25'

$19.99/mo
Street: $34.99

5' x 5'

$19.99/mo
Street: $34.99

5' x 5'

$24.99/mo
Street: $39.99
2 left

5' x 10'

$39.99/mo
Street: $69.99

10' x 5'

$41.99/mo
Street: $72.99

5' x 15'

$45.99/mo
Street: $79.99

10' x 10'

$49.99/mo
Street: $84.99

8' x 45'

$54.99/mo
Street: $94.99

8' x 20'

$59.99/mo
Street: $104.99

10' x 15'

$59.99/mo
Street: $104.99

10' x 15'

$64.99/mo
Street: $114.99
1 left

10' x 20'

$79.99/mo
Street: $139.99

10' x 20'

$89.99/mo
Street: $159.99

10' x 20'

$94.99/mo
Street: $169.99
1 left

10' x 25'

$124.99/mo
Street: $194.99
3 left

10' x 30'

$149.99/mo
Street: $234.99
2 left

20' x 20'

$199.99/mo
Street: $314.99
1 left

20' x 30'

$399.99/mo
Street: $554.99
1 left

20' x 50'

$999.99/mo
Street: $1394.99
1 left

← Back to dashboard