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