Competitor price monitorin real time
Two MarkUDown endpoints, no local scheduler, no browser to manage. /api/monitor creates persistent subscriptions that fire a webhook when content changes. /api/extract pulls the price as a typed number via LLM — regardless of page format.
The scenario
Price is the most sensitive variable in e-commerce. A 5% difference already moves the customer to a competitor. A flash sale on Friday night can drain the weekend's demand before you realize what's happening.
The problem isn't lack of data. The price is there — public, visible, on the competitor's site. The problem is speed. You can't monitor dozens of products across multiple competitors continuously without a dedicated team or an expensive SaaS tool.
The pain
The classic approach:
- Someone opens a spreadsheet every Monday morning
- Manually enters each competitor site
- Notes the prices, compares, updates the table
- Two hours of repetitive work — blind for the other six days
Competitors don't wait for Monday. They change prices on a Thursday afternoon, on a Saturday morning. During those windows, you're exposed. Every lost sale is invisible — you don't know what you didn't see.
The solution
Two endpoints. No browser to configure. No local scheduler.
/api/monitor — persistent subscriptions
Register each competitor URL once. MarkUDown checks that page at your configured interval and fires a webhook to your server when it detects a content change. No cron job. No background server.
/api/extract — price as a typed number
When the monitor detects a change, you call /api/extract to get structured fields. LLM normalizes any format — "R$ 699.90", "699.90", "6x of 116.65" — and returns current_price as float, ready to compare.
Abrasio included — no configuration
Abrasio — our anti-detection browser with Chromium and human fingerprint — is MarkUDown's third internal layer. If faster layers get blocked by the competitor's site, MarkUDown scales automatically. You configure nothing.
How it works
register.py → POST /api/monitor para cada URL (rode uma vez)
↓
MarkUDown → verifica cada URL no intervalo configurado
↓ (quando detecta mudança)
POST /webhook → seu servidor recebe a notificação
↓
webhook.py → POST /api/extract para extrair o preço
↓
Comparação → detecta queda acima do threshold
↓
Slack → alerta com produto, concorrente e preços
Tutorial
You'll need a MarkUDown API key and a server with a public URL for the webhook.
Create your account and get the API key
Go to the MarkUDown dashboard, create a free account and copy your API key. It goes as the header X-API-KEY in all calls.
Install dependencies
pip install requests python-dotenv fastapi uvicornConfigure the .env:
# .env
MARKUDOWN_API_KEY=sua_chave_aqui
WEBHOOK_URL=https://seu-servidor.com/webhook
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ
PRICE_DROP_THRESHOLD=0.05
INTERVAL_MINUTES=60Public webhook URL
Configure the products
products.json defines which products to monitor and the URLs for each competitor. You can have as many products and competitors as you want.
[
{
"id": "nike-air-max-270",
"name": "Nike Air Max 270",
"our_price": 699.90,
"competitors": [
{
"store": "Concorrente A",
"url": "https://concorrente-a.com.br/nike-air-max-270"
},
{
"store": "Concorrente B",
"url": "https://concorrente-b.com.br/tenis/nike-air-max-270"
}
]
},
{
"id": "adidas-ultraboost-22",
"name": "Adidas Ultraboost 22",
"our_price": 849.90,
"competitors": [
{
"store": "Concorrente A",
"url": "https://concorrente-a.com.br/adidas-ultraboost-22"
}
]
}
]Define the extraction schema
The schema tells /api/extract which fields to extract and with what type. The LLM normalizes any price format and returns the values already typed — no regex, no manual parser.
PRICE_SCHEMA = [
{"name": "product_name", "type": "String", "active": True},
{"name": "current_price", "type": "Number", "active": True},
{"name": "original_price", "type": "Number", "active": True},
{"name": "discount_percentage", "type": "Number", "active": True},
{"name": "availability", "type": "String", "active": True},
]Register the monitors (run once)
register.py calls POST /api/monitor for each competitor URL and saves the subscription_ids to subscriptions.json. You run this once — MarkUDown handles scheduling from there.
"""
register.py — Registra um monitor por URL de concorrente via /api/monitor.
Execute uma vez. O MarkUDown cuida do agendamento — sem scheduler local.
"""
import json
import os
import requests
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
MARKUDOWN_URL = "https://api.scrapetechnology.com"
API_KEY = os.getenv("MARKUDOWN_API_KEY")
WEBHOOK_URL = os.getenv("WEBHOOK_URL")
INTERVAL_MINUTES = int(os.getenv("INTERVAL_MINUTES", "60"))
PRODUCTS_FILE = Path("products.json")
SUBSCRIPTIONS_FILE = Path("subscriptions.json")
def register_monitors():
products = json.loads(PRODUCTS_FILE.read_text(encoding="utf-8"))
subscriptions = {}
for product in products:
for competitor in product["competitors"]:
url = competitor["url"]
store = competitor["store"]
print(f"Registrando monitor: {store} — {url}")
resp = requests.post(
f"{MARKUDOWN_URL}/api/monitor",
headers={"X-API-KEY": API_KEY},
json={
"url": url,
"callback_url": WEBHOOK_URL,
"interval_minutes": INTERVAL_MINUTES,
"main_content": True,
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
sub_id = data["subscription_id"]
subscriptions[sub_id] = {
"product_id": product["id"],
"product_name": product["name"],
"our_price": product["our_price"],
"store": store,
"url": url,
}
print(f" OK — subscription_id: {sub_id}")
SUBSCRIPTIONS_FILE.write_text(
json.dumps(subscriptions, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\n{len(subscriptions)} monitors registrados em subscriptions.json")
if __name__ == "__main__":
register_monitors()No local scheduler
Webhook server
webhook.py is a FastAPI server that receives MarkUDown notifications. When the payload has changed: true, it calls /api/extract to get the current price, compares with history, and sends a Slack alert if the drop exceeds the configured threshold.
"""
webhook.py — Recebe notificações do MarkUDown e alerta quando preço cai.
Execute: uvicorn webhook:app --host 0.0.0.0 --port 8090
"""
import json
import os
import time
from datetime import datetime
from pathlib import Path
import requests
from fastapi import FastAPI, Request
from dotenv import load_dotenv
load_dotenv()
MARKUDOWN_URL = "https://api.scrapetechnology.com"
API_KEY = os.getenv("MARKUDOWN_API_KEY")
SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL")
PRICE_DROP_THRESHOLD = float(os.getenv("PRICE_DROP_THRESHOLD", "0.05"))
SUBSCRIPTIONS_FILE = Path("subscriptions.json")
PRICES_DB_FILE = Path("prices_db.json")
PRICE_SCHEMA = [
{"name": "product_name", "type": "String", "active": True},
{"name": "current_price", "type": "Number", "active": True},
{"name": "original_price", "type": "Number", "active": True},
{"name": "discount_percentage", "type": "Number", "active": True},
{"name": "availability", "type": "String", "active": True},
]
app = FastAPI()
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
def save_json(path: Path, data: dict) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def extract_price(url: str) -> dict | None:
"""Chama /api/extract e aguarda o job completar."""
resp = requests.post(
f"{MARKUDOWN_URL}/api/extract",
headers={"X-API-KEY": API_KEY},
json={
"url": url,
"extract_query": "nome do produto, preco atual, preco original, desconto e disponibilidade",
"schema": PRICE_SCHEMA,
"extraction_scope": "single_page",
},
timeout=30,
)
resp.raise_for_status()
job_id = resp.json()["job_id"]
for _ in range(40):
time.sleep(3)
result = requests.get(
f"{MARKUDOWN_URL}/api/extract/{job_id}",
headers={"X-API-KEY": API_KEY},
timeout=10,
).json()
if result.get("status") == "completed":
items = result.get("items", [])
if items:
data = items[0].get("extracted_data", [{}])
return data[0] if data else None
return None
def send_slack_alert(meta: dict, current_price: float, previous_price: float) -> None:
if not SLACK_WEBHOOK_URL:
return
drop_pct = round((previous_price - current_price) / previous_price * 100, 1)
our_price = meta["our_price"]
gap_pct = round((our_price - current_price) / our_price * 100, 1)
lines = [
":rotating_light: *Queda de preco detectada!*",
f"*Produto:* {meta['product_name']}",
f"*Concorrente:* {meta['store']}",
f"*Preco anterior:* R$ {previous_price:.2f}",
f"*Novo preco:* R$ {current_price:.2f} ({drop_pct}% de queda)",
f"*Nosso preco:* R$ {our_price:.2f} ({abs(gap_pct)}% {'acima' if gap_pct > 0 else 'abaixo'})",
f"*Link:* {meta['url']}",
]
requests.post(SLACK_WEBHOOK_URL, json={"text": "\n".join(lines)}, timeout=10)
@app.post("/webhook")
async def handle_monitor_event(request: Request):
payload = await request.json()
if not payload.get("changed"):
return {"ok": True}
subscription_id = payload.get("subscription_id")
url = payload.get("url")
subscriptions = load_json(SUBSCRIPTIONS_FILE)
meta = subscriptions.get(subscription_id)
if not meta:
return {"ok": True}
print(f"Mudanca detectada: {meta['store']} — {url}")
extracted = extract_price(url)
if not extracted:
return {"ok": True}
current_price = extracted.get("current_price")
if not current_price:
return {"ok": True}
print(f" Preco atual: R$ {current_price:.2f}")
db = load_json(PRICES_DB_FILE)
key = f"{meta['product_id']}::{meta['store']}"
previous_price = db.get(key, {}).get("current_price")
if previous_price and previous_price > 0:
change_ratio = (previous_price - current_price) / previous_price
if change_ratio >= PRICE_DROP_THRESHOLD:
print(f" ALERTA: R$ {previous_price:.2f} -> R$ {current_price:.2f}")
send_slack_alert(meta, current_price, previous_price)
db[key] = {
**extracted,
"url": url,
"store": meta["store"],
"product_id": meta["product_id"],
"checked_at": datetime.now().isoformat(),
}
save_json(PRICES_DB_FILE, db)
return {"ok": True}Run it
# 1. Instalar dependências
pip install -r requirements.txt
# 2. Registrar os monitors (rode uma vez)
python register.py
# 3. Subir o servidor de webhook
uvicorn webhook:app --host 0.0.0.0 --port 8090Ready
Alternative: polling with /api/extract
If you don't have a server with a public URL to receive webhooks, use /api/extract directly in polling mode — a local scheduler that checks each URL every hour. Less efficient (extracts even when nothing changed), but works without additional infrastructure.
"""
poll_monitor.py — Alternativa sem webhook: polling com /api/extract.
Útil quando você não tem um servidor com URL pública.
Execute: python poll_monitor.py --once (teste)
python poll_monitor.py (roda a cada hora)
"""
import json, os, time, sys, requests
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
MARKUDOWN_URL = "https://api.scrapetechnology.com"
API_KEY = os.getenv("MARKUDOWN_API_KEY")
SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL")
PRICE_DROP_THRESHOLD = float(os.getenv("PRICE_DROP_THRESHOLD", "0.05"))
PRODUCTS_FILE = Path("products.json")
PRICES_DB_FILE = Path("prices_db.json")
PRICE_SCHEMA = [
{"name": "product_name", "type": "String", "active": True},
{"name": "current_price", "type": "Number", "active": True},
{"name": "original_price", "type": "Number", "active": True},
{"name": "discount_percentage", "type": "Number", "active": True},
{"name": "availability", "type": "String", "active": True},
]
def load_json(path):
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
def save_json(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def extract_price(url):
resp = requests.post(
f"{MARKUDOWN_URL}/api/extract",
headers={"X-API-KEY": API_KEY},
json={
"url": url,
"extract_query": "nome do produto, preco atual, preco original, desconto e disponibilidade",
"schema": PRICE_SCHEMA,
"extraction_scope": "single_page",
},
timeout=30,
)
resp.raise_for_status()
job_id = resp.json()["job_id"]
for _ in range(40):
time.sleep(3)
result = requests.get(
f"{MARKUDOWN_URL}/api/extract/{job_id}",
headers={"X-API-KEY": API_KEY},
).json()
if result.get("status") == "completed":
items = result.get("items", [])
if items:
data = items[0].get("extracted_data", [{}])
return data[0] if data else None
return None
def run():
print(f"Monitor: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
products = load_json(PRODUCTS_FILE)
db = load_json(PRICES_DB_FILE)
for product in products:
print(f"Verificando: {product['name']}")
for comp in product["competitors"]:
print(f" {comp['store']}...", end=" ", flush=True)
extracted = extract_price(comp["url"])
if not extracted:
print("falhou")
continue
current = extracted.get("current_price")
if not current:
print("sem preco")
continue
print(f"R$ {current:.2f}")
key = f"{product['id']}::{comp['store']}"
previous = db.get(key, {}).get("current_price")
if previous and (previous - current) / previous >= PRICE_DROP_THRESHOLD:
print(f" ALERTA: {previous:.2f} -> {current:.2f}")
db[key] = {**extracted, "url": comp["url"], "store": comp["store"],
"checked_at": datetime.now().isoformat()}
save_json(PRICES_DB_FILE, db)
print("Ciclo concluido.")
if __name__ == "__main__":
if "--once" in sys.argv:
run()
else:
from apscheduler.schedulers.blocking import BlockingScheduler
s = BlockingScheduler()
s.add_job(run, "interval", hours=1, next_run_time=datetime.now())
print("Monitor ativo. Ctrl+C para parar.")
try:
s.start()
except KeyboardInterrupt:
passThe Slack alert
When a drop above the threshold is detected, this message arrives:
🚨 Queda de preço detectada!
Produto: Nike Air Max 270
Concorrente: Concorrente A
Preço anterior: R$ 749,90
Novo preço: R$ 599,90 (20,0% de queda)
Nosso preço: R$ 699,90 (14,3% acima)
Link: concorrente-a.com.br/...
Next steps
Full history
Replace prices_db.json with PostgreSQL to store the full history and plot price variation graphs over time.
Live dashboard
Next.js page with a price comparison table by product and competitor, consuming directly from the database.
Category monitoring
Use /api/map to discover all products in a competitor's category and register monitors for each URL automatically.
Cancel inactive monitors
Call DELETE /api/monitor/{subscription_id} to cancel subscriptions for discontinued products without leaving clutter on the server.
Get started with MarkUDown
Create your free account, configure the products, and the monitor is active in less than 30 minutes.