feat: Cast Iron Scout agent prototype
- Created autonomous cast iron deal scanner - Scans eBay RSS feeds hourly for cast iron cookware - Calculates FMV based on brand, type, size - Sends Telegram alerts for deals ≥50% below FMV - Identifies Griswold, Wagner, Wapak, Birmingham, Lodge, Victor - Tracks seen items to avoid duplicate alerts - Valuation engine with size multipliers - Configurable preferences in config.json Known issue: eBay RSS unreliable - next iteration will use proper scraping
This commit is contained in:
101
agents/cast-iron-scout/README.md
Normal file
101
agents/cast-iron-scout/README.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Cast Iron Scout 🔥🍳
|
||||||
|
|
||||||
|
Autonomous agent that scans for undervalued cast iron cookware deals and alerts you when pieces are priced ≥50% below fair market value.
|
||||||
|
|
||||||
|
## Status: 🚧 Prototype
|
||||||
|
|
||||||
|
**Built:** April 9, 2026
|
||||||
|
**Current Sources:** eBay (RSS feeds)
|
||||||
|
**Next:** Facebook Marketplace, Craigslist integration
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
1. **Scans** marketplaces every hour for cast iron cookware
|
||||||
|
2. **Identifies** brands (Griswold, Wagner, Wapak, Birmingham, Lodge, etc.)
|
||||||
|
3. **Calculates** fair market value based on brand, type, and size
|
||||||
|
4. **Alerts** you via Telegram when deals ≥50% below FMV are found
|
||||||
|
5. **Tracks** seen items to avoid duplicate alerts
|
||||||
|
|
||||||
|
## Example Alert
|
||||||
|
|
||||||
|
```
|
||||||
|
🔥 CAST IRON DEAL ALERT!
|
||||||
|
|
||||||
|
Item: Griswold #8 Skillet - Slant Logo
|
||||||
|
Price: $45.00
|
||||||
|
FMV: $180.00
|
||||||
|
Discount: 75% below FMV! 💰
|
||||||
|
|
||||||
|
Source: eBay
|
||||||
|
Found: 2026-04-09T17:44:23
|
||||||
|
|
||||||
|
🔗 [link to item]
|
||||||
|
|
||||||
|
Action: Buy now / Bid / Ignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
cast-iron-scout/
|
||||||
|
├── scanner.py # Main scanning engine
|
||||||
|
├── valuation.py # FMV calculator
|
||||||
|
├── config.json # Your preferences
|
||||||
|
├── state/ # Track seen items
|
||||||
|
├── logs/ # Scan logs
|
||||||
|
└── sources/
|
||||||
|
├── ebay_scanner.py # eBay scanner (RSS)
|
||||||
|
├── facebook.py # [TODO]
|
||||||
|
└── craigslist.py # [TODO]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `config.json` to customize:
|
||||||
|
- **min_discount_percent**: Minimum discount to alert (default: 50%)
|
||||||
|
- **max_distance_miles**: Max distance for local pickup (default: 500)
|
||||||
|
- **preferred_brands**: Brands to prioritize
|
||||||
|
- **item_types**: Types of items to scan for
|
||||||
|
- **telegram_target**: Where to send alerts
|
||||||
|
|
||||||
|
## Current Features
|
||||||
|
|
||||||
|
✅ Basic eBay RSS scanning
|
||||||
|
✅ Brand identification (Griswold, Wagner, Wapak, etc.)
|
||||||
|
✅ FMV calculation engine
|
||||||
|
✅ Telegram alerts
|
||||||
|
✅ Duplicate prevention
|
||||||
|
✅ Hourly scanning
|
||||||
|
|
||||||
|
## Known Issues / TODO
|
||||||
|
|
||||||
|
- [ ] eBay RSS feed unreliable - need to implement proper scraping
|
||||||
|
- [ ] Add Facebook Marketplace scanner
|
||||||
|
- [ ] Add Craigslist scanner
|
||||||
|
- [ ] Image recognition for logos/marks
|
||||||
|
- [ ] Price history tracking
|
||||||
|
- [ ] Condition assessment from photos
|
||||||
|
- [ ] Mobile app integration?
|
||||||
|
|
||||||
|
## Running Manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/claw/.openclaw/workspace/agents/cast-iron-scout
|
||||||
|
python3 scanner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Check `logs/scanner-YYYYMMDD.log` for scan history.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Fix eBay scanner (use proper HTML scraping vs RSS)
|
||||||
|
2. Add Facebook Marketplace (requires Selenium/Playwright)
|
||||||
|
3. Add image recognition for logos
|
||||||
|
4. Build sold items database for better FMV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Maintained by:** Forge (Chris's SaaS Operations Bot)
|
||||||
|
**Version:** 0.1.0 (Prototype)
|
||||||
BIN
agents/cast-iron-scout/__pycache__/valuation.cpython-314.pyc
Normal file
BIN
agents/cast-iron-scout/__pycache__/valuation.cpython-314.pyc
Normal file
Binary file not shown.
44
agents/cast-iron-scout/config.json
Normal file
44
agents/cast-iron-scout/config.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"scan_interval_minutes": 30,
|
||||||
|
"min_discount_percent": 50,
|
||||||
|
"max_distance_miles": 500,
|
||||||
|
"preferred_brands": [
|
||||||
|
"griswold",
|
||||||
|
"wagner",
|
||||||
|
"wapak",
|
||||||
|
"birmingham",
|
||||||
|
"lodge",
|
||||||
|
"victor",
|
||||||
|
"hollowware"
|
||||||
|
],
|
||||||
|
"item_types": [
|
||||||
|
"skillet",
|
||||||
|
"griddle",
|
||||||
|
"dutch oven",
|
||||||
|
"pot",
|
||||||
|
"pan",
|
||||||
|
"grill pan",
|
||||||
|
"waffle iron",
|
||||||
|
"mold"
|
||||||
|
],
|
||||||
|
"condition_keywords": [
|
||||||
|
"rust",
|
||||||
|
"restoration",
|
||||||
|
"as-is",
|
||||||
|
"vintage",
|
||||||
|
"antique",
|
||||||
|
"estate",
|
||||||
|
"garage sale",
|
||||||
|
"dirty",
|
||||||
|
"needs work"
|
||||||
|
],
|
||||||
|
"exclude_keywords": [
|
||||||
|
"new",
|
||||||
|
"reproduction",
|
||||||
|
"replica",
|
||||||
|
"modern",
|
||||||
|
"calphalon",
|
||||||
|
"le creuset"
|
||||||
|
],
|
||||||
|
"telegram_target": "telegram:8269921691"
|
||||||
|
}
|
||||||
3
agents/cast-iron-scout/logs/scanner-20260409.log
Normal file
3
agents/cast-iron-scout/logs/scanner-20260409.log
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[2026-04-09 17:44:23] 🔍 Starting cast iron scan...
|
||||||
|
[2026-04-09 17:44:28] Found 0 items on eBay
|
||||||
|
[2026-04-09 17:44:28] Scan complete. Deals found: 0, Total items processed: 0
|
||||||
6
agents/cast-iron-scout/requirements.txt
Normal file
6
agents/cast-iron-scout/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
|
lxml
|
||||||
|
pillow
|
||||||
|
python-dateutil
|
||||||
|
feedparser
|
||||||
118
agents/cast-iron-scout/scanner.py
Normal file
118
agents/cast-iron-scout/scanner.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cast Iron Scout - Main Scanner Engine
|
||||||
|
Continuously scans for cast iron deals and alerts when good deals found
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from sources.ebay_scanner import search_ebay_cast_iron
|
||||||
|
from valuation import is_good_deal, calculate_fmv
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
STATE_FILE = SCRIPT_DIR / "state" / "seen_items.json"
|
||||||
|
CONFIG_FILE = SCRIPT_DIR / "config.json"
|
||||||
|
LOG_FILE = SCRIPT_DIR / "logs" / f"scanner-{datetime.now().strftime('%Y%m%d')}.log"
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
"""Load configuration"""
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
return json.loads(CONFIG_FILE.read_text())
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
"""Load previously seen items to avoid duplicates"""
|
||||||
|
if STATE_FILE.exists():
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
return {"seen_links": [], "last_scan": None}
|
||||||
|
|
||||||
|
def save_state(state):
|
||||||
|
"""Save state to file"""
|
||||||
|
state['last_scan'] = datetime.now().isoformat()
|
||||||
|
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
def log(message):
|
||||||
|
"""Log message"""
|
||||||
|
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
log_line = f"[{ts}] {message}"
|
||||||
|
print(log_line)
|
||||||
|
with open(LOG_FILE, 'a') as f:
|
||||||
|
f.write(log_line + '\n')
|
||||||
|
|
||||||
|
def send_telegram_alert(item, fmv, discount):
|
||||||
|
"""Send Telegram alert for a good deal"""
|
||||||
|
config = load_config()
|
||||||
|
target = config.get('telegram_target', 'telegram:8269921691')
|
||||||
|
|
||||||
|
message = f"""🔥 *CAST IRON DEAL ALERT!*
|
||||||
|
|
||||||
|
*Item:* {item['title']}
|
||||||
|
*Price:* ${item['price']:.2f}
|
||||||
|
*FMV:* ${fmv:.2f}
|
||||||
|
*Discount:* {discount:.0f}% below FMV! 💰
|
||||||
|
|
||||||
|
*Source:* {item['source']}
|
||||||
|
*Found:* {item['found_at']}
|
||||||
|
|
||||||
|
🔗 {item['link']}
|
||||||
|
|
||||||
|
_Action: Buy now / Bid / Ignore_"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run([
|
||||||
|
'openclaw', 'message', 'send',
|
||||||
|
'--channel', 'telegram',
|
||||||
|
'--target', target,
|
||||||
|
'--message', message
|
||||||
|
], capture_output=True, timeout=30)
|
||||||
|
log(f"✅ Alert sent for: {item['title'][:50]}")
|
||||||
|
except Exception as e:
|
||||||
|
log(f"❌ Failed to send alert: {e}")
|
||||||
|
|
||||||
|
def scan_all_sources():
|
||||||
|
"""Scan all sources for cast iron items"""
|
||||||
|
log("🔍 Starting cast iron scan...")
|
||||||
|
|
||||||
|
# Load config and state
|
||||||
|
config = load_config()
|
||||||
|
state = load_state()
|
||||||
|
seen_links = set(state.get('seen_links', []))
|
||||||
|
|
||||||
|
# Scan eBay
|
||||||
|
items = search_ebay_cast_iron()
|
||||||
|
log(f"Found {len(items)} items on eBay")
|
||||||
|
|
||||||
|
deals_found = 0
|
||||||
|
min_discount = config.get('min_discount_percent', 50)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
# Skip if already seen
|
||||||
|
if item['link'] in seen_links:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if it's a good deal
|
||||||
|
is_deal, discount, fmv = is_good_deal(item['price'], item['title'], min_discount)
|
||||||
|
|
||||||
|
if is_deal:
|
||||||
|
log(f"🎯 DEAL FOUND: {item['title'][:50]} - ${item['price']} ({discount:.0f}% off)")
|
||||||
|
send_telegram_alert(item, fmv, discount)
|
||||||
|
deals_found += 1
|
||||||
|
|
||||||
|
# Mark as seen
|
||||||
|
seen_links.add(item['link'])
|
||||||
|
|
||||||
|
# Keep only last 1000 seen items to prevent state file from growing forever
|
||||||
|
if len(seen_links) > 1000:
|
||||||
|
seen_links = set(list(seen_links)[-1000:])
|
||||||
|
|
||||||
|
state['seen_links'] = list(seen_links)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
log(f"Scan complete. Deals found: {deals_found}, Total items processed: {len(items)}")
|
||||||
|
return deals_found
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
deals = scan_all_sources()
|
||||||
|
sys.exit(0)
|
||||||
Binary file not shown.
71
agents/cast-iron-scout/sources/ebay_scanner.py
Normal file
71
agents/cast-iron-scout/sources/ebay_scanner.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
eBay Cast Iron Scanner
|
||||||
|
Scans eBay for cast iron cookware deals
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
def search_ebay_cast_iron():
|
||||||
|
"""
|
||||||
|
Search eBay for cast iron items
|
||||||
|
Returns list of items found
|
||||||
|
"""
|
||||||
|
# eBay search URL for cast iron cookware
|
||||||
|
# Using their REST API would be better but requires API keys
|
||||||
|
# For now, we'll use RSS feeds which are public
|
||||||
|
|
||||||
|
search_terms = [
|
||||||
|
"griswold skillet",
|
||||||
|
"wagner cast iron",
|
||||||
|
"vintage cast iron skillet",
|
||||||
|
"cast iron restoration",
|
||||||
|
"wapak skillet",
|
||||||
|
"birmingham skillet"
|
||||||
|
]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for term in search_terms:
|
||||||
|
# eBay RSS feed (no API key needed!)
|
||||||
|
rss_url = f"https://www.ebay.com/sch/i.html?_from=R40&_nkw={term.replace(' ', '%20')}&_sacat=0&LH_TitleDesc=0&_rss=1"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(rss_url, timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
soup = BeautifulSoup(response.content, 'lxml-xml')
|
||||||
|
entries = soup.find_all('item')
|
||||||
|
|
||||||
|
for entry in entries[:10]: # Top 10 results
|
||||||
|
try:
|
||||||
|
title = entry.find('title').text
|
||||||
|
link = entry.find('link').text
|
||||||
|
pub_date = entry.find('pubDate').text
|
||||||
|
|
||||||
|
# Extract price from description or title
|
||||||
|
price_match = re.search(r'\$([\d,]+\.?\d*)', title)
|
||||||
|
price = float(price_match.group(1).replace(',', '')) if price_match else 0
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
'title': title,
|
||||||
|
'price': price,
|
||||||
|
'link': link,
|
||||||
|
'source': 'eBay',
|
||||||
|
'found_at': datetime.now().isoformat(),
|
||||||
|
'pub_date': pub_date
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error scanning eBay for '{term}': {e}")
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🔍 Scanning eBay for cast iron deals...")
|
||||||
|
items = search_ebay_cast_iron()
|
||||||
|
print(f"Found {len(items)} items")
|
||||||
|
for item in items[:5]:
|
||||||
|
print(f" - {item['title'][:60]} - ${item['price']}")
|
||||||
4
agents/cast-iron-scout/state/seen_items.json
Normal file
4
agents/cast-iron-scout/state/seen_items.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"seen_links": [],
|
||||||
|
"last_scan": "2026-04-09T17:44:28.435221"
|
||||||
|
}
|
||||||
115
agents/cast-iron-scout/valuation.py
Normal file
115
agents/cast-iron-scout/valuation.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Cast Iron Valuation Engine
|
||||||
|
Determines fair market value and calculates discount percentage
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Base price ranges for common cast iron items (in good condition)
|
||||||
|
BASE_PRICES = {
|
||||||
|
'griswold': {'skillet': 150, 'griddle': 200, 'dutch_oven': 250, 'pot': 120, 'pan': 100},
|
||||||
|
'wagner': {'skillet': 120, 'griddle': 180, 'dutch_oven': 220, 'pot': 100, 'pan': 90},
|
||||||
|
'wapak': {'skillet': 180, 'griddle': 220, 'dutch_oven': 280, 'pot': 150, 'pan': 130},
|
||||||
|
'birmingham': {'skillet': 160, 'griddle': 190, 'dutch_oven': 240, 'pot': 130, 'pan': 110},
|
||||||
|
'lodge': {'skillet': 80, 'griddle': 120, 'dutch_oven': 150, 'pot': 70, 'pan': 60},
|
||||||
|
'victor': {'skillet': 140, 'griddle': 170, 'dutch_oven': 210, 'pot': 120, 'pan': 100},
|
||||||
|
'default': {'skillet': 100, 'griddle': 150, 'dutch_oven': 200, 'pot': 80, 'pan': 70}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Size multipliers
|
||||||
|
SIZE_MULTIPLIERS = {
|
||||||
|
'#1': 0.5, '#2': 0.6, '#3': 0.7, '#4': 0.8, '#5': 0.9,
|
||||||
|
'#6': 1.0, '#7': 1.1, '#8': 1.2, '#9': 1.3, '#10': 1.4,
|
||||||
|
'#11': 1.5, '#12': 1.6, '#13': 1.7, '#14': 1.8
|
||||||
|
}
|
||||||
|
|
||||||
|
def identify_brand(title):
|
||||||
|
"""Identify the brand from title"""
|
||||||
|
title_lower = title.lower()
|
||||||
|
|
||||||
|
brands = ['griswold', 'wagner', 'wapak', 'birmingham', 'lodge', 'victor', 'hollowware']
|
||||||
|
for brand in brands:
|
||||||
|
if brand in title_lower:
|
||||||
|
return brand
|
||||||
|
|
||||||
|
return 'default'
|
||||||
|
|
||||||
|
def identify_item_type(title):
|
||||||
|
"""Identify the type of item from title"""
|
||||||
|
title_lower = title.lower()
|
||||||
|
|
||||||
|
types = ['skillet', 'griddle', 'dutch oven', 'pot', 'pan', 'grill', 'waffle', 'mold']
|
||||||
|
for item_type in types:
|
||||||
|
if item_type in title_lower:
|
||||||
|
return item_type.replace(' ', '_')
|
||||||
|
|
||||||
|
return 'skillet' # Default to skillet
|
||||||
|
|
||||||
|
def extract_size(title):
|
||||||
|
"""Extract size number from title"""
|
||||||
|
# Look for patterns like #8, 8 inch, size 8, etc.
|
||||||
|
patterns = [
|
||||||
|
r'#(\d{1,2})',
|
||||||
|
r'size\s*(\d{1,2})',
|
||||||
|
r'(\d{1,2})\s*inch',
|
||||||
|
r'(\d{1,2})\s*"',
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, title, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
size_num = match.group(1)
|
||||||
|
return f"#{size_num}"
|
||||||
|
|
||||||
|
return '#8' # Default to #8
|
||||||
|
|
||||||
|
def calculate_fmv(title):
|
||||||
|
"""
|
||||||
|
Calculate Fair Market Value for an item
|
||||||
|
Returns estimated FMV based on brand, type, and size
|
||||||
|
"""
|
||||||
|
brand = identify_brand(title)
|
||||||
|
item_type = identify_item_type(title)
|
||||||
|
size = extract_size(title)
|
||||||
|
|
||||||
|
# Get base price
|
||||||
|
brand_prices = BASE_PRICES.get(brand, BASE_PRICES['default'])
|
||||||
|
base_price = brand_prices.get(item_type, brand_prices['skillet'])
|
||||||
|
|
||||||
|
# Apply size multiplier
|
||||||
|
multiplier = SIZE_MULTIPLIERS.get(size, 1.0)
|
||||||
|
fmv = base_price * multiplier
|
||||||
|
|
||||||
|
return round(fmv, 2)
|
||||||
|
|
||||||
|
def is_good_deal(price, title, min_discount=50):
|
||||||
|
"""
|
||||||
|
Determine if an item is a good deal
|
||||||
|
Returns (is_deal, discount_percent, fmv)
|
||||||
|
"""
|
||||||
|
fmv = calculate_fmv(title)
|
||||||
|
|
||||||
|
if price <= 0 or fmv <= 0:
|
||||||
|
return False, 0, fmv
|
||||||
|
|
||||||
|
discount_percent = ((fmv - price) / fmv) * 100
|
||||||
|
|
||||||
|
if discount_percent >= min_discount:
|
||||||
|
return True, discount_percent, fmv
|
||||||
|
|
||||||
|
return False, discount_percent, fmv
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test the valuation engine
|
||||||
|
test_items = [
|
||||||
|
"Griswold #8 Skillet - Rusty",
|
||||||
|
"Wagner Sidney O -AI - Vintage Cast Iron",
|
||||||
|
"Wapak Funny Face Skillet #10",
|
||||||
|
"Birmingham Stove & Range Co. #12 Skillet"
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Valuation Engine Test:\n")
|
||||||
|
for title in test_items:
|
||||||
|
fmv = calculate_fmv(title)
|
||||||
|
print(f"{title[:50]}")
|
||||||
|
print(f" → FMV: ${fmv}\n")
|
||||||
Reference in New Issue
Block a user