PlaybookMarkUDown
MarkUDownPythonE-commerceWebhooksIntermediate

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.

April 30, 202615 min readBy Scrape Technology

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.

1

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.

2

Install dependencies

terminal
pip install requests python-dotenv fastapi uvicorn

Configure the .env:

.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=60

Public webhook URL

The WEBHOOK_URL needs to be accessible from the internet — that's where MarkUDown will send change notifications. In development, use ngrok http 8090 to expose your local server.
3

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.

products.json
[
  {
    "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"
      }
    ]
  }
]
4

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.

schema
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},
]
5

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
"""
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

After running register.py, you don't need to keep any background process for monitoring checks. MarkUDown verifies the URLs at the configured interval and calls your webhook when something changes.
6

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
"""
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}
7

Run it

terminal
# 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 8090

Ready

MarkUDown starts checking the URLs at the configured interval. When it detects a change, it calls /webhook. The server extracts the price and alerts.

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
"""
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:
            pass

The Slack alert

When a drop above the threshold is detected, this message arrives:

Slack — #price-monitoring

🚨 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.