Facility: 001444
Stars & Stripes Storage
- Facility ID
- 001444
- Name
- Stars & Stripes Storage
- URL
- https://www.starsandstripesstorage.com/
- Address
- 103 Luken Rd, Goose Creek, SC 29445, USA, Goose Creek, South Carolina 29445
- Platform
- table_layout
- Parser File
- src/parsers/table_parser.py
- Last Scraped
- 2026-03-27 13:47:50.220378
- Created
- 2026-03-14 16:21:53.706708
- Updated
- 2026-03-27 13:47:50.247195
- Parser Status
- ✓ Working
- Status Reason
- N/A
- Last Healing Attempt
- Not attempted
Parser Source (src/parsers/table_parser.py)
"""Generic fallback parser for table-based storage unit listings."""
from __future__ import annotations
import re
from bs4 import BeautifulSoup, Tag
from src.parsers.base import BaseParser, ParseResult, UnitResult
# Column header patterns used to identify relevant tables and map columns.
_SIZE_PATTERNS = re.compile(r"size|dimension|unit\s*type|width.*length", re.IGNORECASE)
_PRICE_PATTERNS = re.compile(r"price|rate|cost|rent|monthly", re.IGNORECASE)
_PROMO_PATTERNS = re.compile(r"promo|special|discount|sale|web", re.IGNORECASE)
_AVAIL_PATTERNS = re.compile(r"avail|status|vacancy", re.IGNORECASE)
_FEATURE_PATTERNS = re.compile(r"feature|amenity|type|detail|description", re.IGNORECASE)
class TableParser(BaseParser):
"""Extract storage units from HTML tables.
This is the catch-all fallback parser. It scans the page for ``<table>``
elements whose headers suggest they contain unit listings (size/price columns),
then maps rows to :class:`UnitResult` objects.
"""
platform = "table_layout"
def parse(self, html: str, url: str = "") -> ParseResult:
soup = BeautifulSoup(html, "lxml")
result = ParseResult(platform=self.platform, parser_name=self.__class__.__name__)
tables = soup.find_all("table")
if not tables:
result.warnings.append("No HTML tables found on page")
return result
for table in tables:
headers = self._extract_headers(table)
if not headers:
continue
col_map = self._map_columns(headers)
if not col_map.get("size") and not col_map.get("price"):
# This table does not look like a unit listing.
continue
rows = table.select("tbody tr") or table.select("tr")[1:] # skip header row
for row in rows:
cells = row.find_all(["td", "th"])
if not cells:
continue
unit = self._row_to_unit(cells, col_map)
if unit is not None:
result.units.append(unit)
if not result.units:
result.warnings.append("Tables found but none appeared to contain unit listings")
return result
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _extract_headers(table: Tag) -> list[str]:
"""Get header text from the first ``<thead>`` row or the first ``<tr>``."""
thead = table.find("thead")
if thead:
return [th.get_text(strip=True) for th in thead.find_all(["th", "td"])]
first_row = table.find("tr")
if first_row:
ths = first_row.find_all("th")
if ths:
return [th.get_text(strip=True) for th in ths]
return []
@staticmethod
def _map_columns(headers: list[str]) -> dict[str, int]:
"""Map semantic column names to header indices."""
col_map: dict[str, int] = {}
for idx, header in enumerate(headers):
if _SIZE_PATTERNS.search(header) and "size" not in col_map:
col_map["size"] = idx
elif _PROMO_PATTERNS.search(header) and "promo" not in col_map:
col_map["promo"] = idx
elif _PRICE_PATTERNS.search(header) and "price" not in col_map:
col_map["price"] = idx
elif _AVAIL_PATTERNS.search(header) and "avail" not in col_map:
col_map["avail"] = idx
elif _FEATURE_PATTERNS.search(header) and "features" not in col_map:
col_map["features"] = idx
return col_map
def _row_to_unit(self, cells: list[Tag], col_map: dict[str, int]) -> UnitResult | None:
"""Convert a table row into a UnitResult using the column mapping."""
texts = [c.get_text(strip=True) for c in cells]
if all(t == "" for t in texts):
return None
unit = UnitResult()
unit.description = " | ".join(texts)
# Size
size_idx = col_map.get("size")
if size_idx is not None and size_idx < len(texts):
size_text = texts[size_idx]
w, ln, sq = self.normalize_size(size_text)
if w is not None:
meta = unit.metadata or {}
meta["width"] = w
meta["length"] = ln
meta["sqft"] = sq
unit.metadata = meta
unit.size = size_text
# Price (street rate)
price_idx = col_map.get("price")
if price_idx is not None and price_idx < len(texts):
unit.price = self.normalize_price(texts[price_idx])
# Promo — store as raw text in promotion field
promo_idx = col_map.get("promo")
if promo_idx is not None and promo_idx < len(texts):
promo_text = texts[promo_idx].strip()
if promo_text:
unit.promotion = promo_text
# Availability
avail_idx = col_map.get("avail")
if avail_idx is not None and avail_idx < len(texts):
unit.scarcity = texts[avail_idx]
# Features / amenities
features_idx = col_map.get("features")
feature_text = ""
if features_idx is not None and features_idx < len(texts):
feature_text = texts[features_idx].lower()
# Also check full row text for amenity keywords
full_text = " ".join(texts).lower()
combined = f"{feature_text} {full_text}"
meta = unit.metadata or {}
if any(kw in combined for kw in ["climate", "temperature", "heated", "cooled"]):
meta["climateControlled"] = True
if any(kw in combined for kw in ["drive-up", "drive up", "driveup"]):
meta["driveUpAccess"] = True
if "elevator" in combined:
meta["elevatorAccess"] = True
if any(kw in combined for kw in ["ground floor", "ground-floor", "1st floor", "first floor"]):
meta["groundFloor"] = True
if any(kw in combined for kw in ["indoor", "interior"]):
meta["indoor"] = True
if meta:
unit.metadata = meta
return unit
Scrape Runs (5)
-
exported Run #17232026-03-27 13:47:48.023755 | 13 units | TableParser | View Data →
-
exported Run #17222026-03-27 13:47:47.675910 | 13 units | TableParser | View Data →
-
exported Run #11242026-03-23 02:49:36.010460 | 13 units | TableParser | View Data →
-
exported Run #6292026-03-21 18:39:15.618320 | 13 units | TableParser | View Data →
-
exported Run #1802026-03-14 16:22:57.296319 | 13 units | TableParser | View Data →
Run #629 Details
- Status
- exported
- Parser Used
- TableParser
- Platform Detected
- table_layout
- Units Found
- 13
- Stage Reached
- exported
- Timestamp
- 2026-03-21 18:39:15.618320
Timing
| Stage | Duration |
|---|---|
| Fetch | 2660ms |
| Detect | 23ms |
| Parse | 10ms |
| Export | 5ms |
Snapshot: 001444_20260321T183918Z.html · Show Snapshot · Open in New Tab
Parsed Units (13)
Admin Fee
$20.00/mo
5 x 5
$35.00/mo
5 x 10
$67.00/mo
5 x 10 *
$85.00/mo
5 x 15
$77.00/mo
10 x 10
$98.00/mo
10 x 10 *
$149.00/mo
10 x 15
$148.00/mo
10 x 15 *
$193.00/mo
10 x 20
$168.00/mo
10 x 20 *
$231.00/mo
10 x 25
$189.00/mo
10 x 30
$230.00/mo