I wanted automated fashion trend research. Not a dashboard or a SaaS product — a pipeline that monitors industry sources, extracts what matters, and hands me a presentation-ready weekly report. The budget was essentially zero.
The result: 6 RSS feeds monitored daily by a local 12B model, weekly analysis by Sonnet and Opus, and a polished Marp slide deck with real article images. Total cost: ~$0.75/week.
The Architecture
RSS Feeds (6 sources) → daily 08:00 → gemma3:12b (FREE)
└→ Signal extraction + image URLs
└→ Accumulates in heartbeat logs all week
Sunday 09:00 → Sonnet 4.6 ($0.25) — "Research Analyst"
└→ Aggregates 7 days, ranks trends, identifies patterns
Sunday 09:30 → Opus 4.6 ($0.50) — "Editor-in-Chief"
└→ Produces Marp slide deck with embedded images
└→ Auto-converts to HTML slides
└→ Telegram notification with summary
Three models, three roles. The local model does the daily grind for free. Sonnet does analytical heavy lifting once a week. Opus polishes it into something you'd show a client.
RSS Image Extraction
The first challenge was getting real images from the articles being discussed. Fashion reporting without visuals is just... text about clothes.
RSS feeds bury images in different places depending on the publisher. I built a priority-based extractor in the Python parser:
def extract_image(item, desc_raw):
# 1. media:thumbnail (Vogue uses this — 100% hit rate)
thumb = item.find('media:thumbnail', media_ns)
if thumb is not None:
u = thumb.get('url', '')
if u: return u
# 2. media:content with medium="image"
for mc in item.findall('media:content', media_ns):
if mc.get('medium') == 'image':
u = mc.get('url', '')
if u: return u
# 3. enclosure with type="image/*"
for enc in item.findall('enclosure'):
if enc.get('type', '').startswith('image/'):
u = enc.get('url', '')
if u: return u
# 4. First <img> in description HTML (fallback)
m = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', desc_raw)
if m: return m.group(1)
return ''The Yahoo MRSS namespace (http://search.yahoo.com/mrss/) is key — most fashion publishers use media:thumbnail or media:content for high-resolution images.
The Token Budget Problem
Here's where it got interesting. Business of Fashion image URLs look like this:
https://img.businessoffashion.com/resizer/v2/25AWNHQDBNGKBCSLZ6GM22N65A.jpg
?auth=8e4bef4b7630a4f67e3bcfa8262f49a0382fe4eb0dfb51c38ed7b0fa1ecb44dd
&smart=true&width=4101&height=2307
That's ~175 characters per image URL, mostly auth tokens. With 10 items, that's 1,750 characters just in URLs. gemma3:12b runs at a 4,096 token context window on my GPU. The prompt template, instructions, and article data were already pushing 3K tokens. Adding full image URLs blew past the limit and the model hung indefinitely.
The solution: strip query params before sending to the LLM, cache full URLs separately.
# Cache full items (with auth tokens) for downstream use
echo "$limited_items" > "$CACHE_DIR/fashion-signals-latest-items"
# Strip query params for the LLM (saves ~1,750 tokens)
trimmed_items=$(echo "$limited_items" \
| sed 's|\(https\?://[^|?]*\)?[^|]*|\1|g')gemma3:12b sees clean base URLs. The weekly report collector reads the cached files and passes full URLs to Opus, which has a much larger context window.
The 403 Problem
This created a second bug I didn't catch until the first report was generated. Opus received trimmed URLs from the daily signal logs (which gemma had already processed) and used those in the slide deck. The IMAGE URL REFERENCE section with full URLs was at the bottom of the prompt — Opus ignored it.
The result: all 5 images in the report returned HTTP 403. BOF's image CDN requires the ?auth= parameter.
Relying on the LLM to cross-reference URLs is unreliable. The fix: deterministic post-processing in the heartbeat engine.
# After Opus writes output, before Marp conversion
url_map = {}
for f in glob.glob(os.path.join(cache_dir, '*-items*')):
for line in open(f):
parts = line.strip().split('|')
if len(parts) >= 8 and parts[0] == 'NEW_ITEM':
img_url = parts[7].strip()
if img_url and '?' in img_url:
base = img_url.split('?')[0]
url_map[base] = img_url
content = open(out_file).read()
for base, full in url_map.items():
if base in content and full not in content:
content = content.replace(base, full)Simple string replacement. No LLM behavior dependency. All 5 images went from 403 to 200.
Marp Slide Deck Generation
The weekly report needed to be a presentation, not a document. Marp converts Markdown to HTML slides — perfect fit since the report was already Markdown.
The Opus prompt specifies Marp frontmatter and slide structure:
marp: true
theme: default
paginate: true
style: |
h2 {
color: #16213e;
border-bottom: 2px solid #e94560;
}Each major section becomes a slide separated by ---. The heartbeat engine detects marp: true in the output and auto-converts:
if echo "$result" | head -5 | grep -q "marp: true"; then
marp --no-stdin "$out_path/$out_filename" \
-o "$out_path/${task_name}-${today}.html" \
--allow-local-files
fiThis produces both .md (viewable in Obsidian) and .html (browser slides with arrow-key navigation) in the outputs directory.
VRAM Contention: The Silent Killer
The hardest bug wasn't code — it was GPU resource management. During testing, the heartbeat timer fired a deps-monitor task (qwen3:8b, 5GB) while fashion-signals (gemma3:12b, 9.5GB) was running. Two models in 16GB VRAM: 14.5GB total. The GPU didn't crash — it just slowed to a crawl as models swapped in and out of VRAM.
Worse: killing the parent curl process doesn't cancel an in-flight Ollama generation. The runner keeps consuming 100% GPU, owned by the ollama system user, requiring sudo systemctl restart ollama to clear.
The fix was operational: stop the heartbeat timer during manual testing, and ensure the 10-minute Ollama keep-alive window prevents model overlap during normal scheduled runs.
The Output
The first weekly report covered London Fashion Week and the BAFTAs. 11 slides, 5 embedded images, 8 trends tracked. The pipeline correctly identified the trench coat as the week's dominant silhouette signal (cross-source, multi-day persistence from Burberry's LFW show) and flagged Trump's tariff whiplash as the top economic disruption.
The Telegram summary hits my phone Sunday morning:
📊 Fashion Weekly Report — Feb 17-23
TOP TRENDS:
1. Trench (Sustained) — Burberry LFW, cross-source
2. Burberry (Sustained) — Daniel Lee's dark LFW closer
3. Trump Tariffs (Spike) — 10%→15%→Supreme Court reversal
...
📎 Full slides: outputs/fashion-weekly-report-2026-02-23.html
Numbers
| Metric | Value |
|---|---|
| RSS sources | 6 (Vogue, WWD, WWD Fashion, FashionNetwork, Fashionista, BOF) |
| Daily extraction | ~10 items, 17-20 seconds (gemma3:12b) |
| Weekly analysis | 68s (Sonnet) + 84s (Opus) |
| Weekly cost | ~$0.75 (Sonnet $0.25 + Opus $0.50) |
| Monthly cost | ~$3 |
| Output formats | Marp markdown + HTML slides + Telegram summary |
| Images per report | 5 (from article thumbnails) |
What I'd Do Differently
The URL trimming + caching architecture works but adds complexity. If I were starting over, I'd either use a model with a larger context window for daily extraction (qwen3:8b at 16K would handle full URLs easily) or store extracted items in a proper SQLite database instead of pipe-delimited text files.
The three-model pipeline (local → Sonnet → Opus) might be overkill. Sonnet alone could probably produce the final report. But the local model keeps daily costs at zero, and the Opus polish is noticeably better — it writes like an editor, not an analyst.