AX/T/03 — AX/TUTORIALS
Opublikowano: 6 maj 2026 · 14 min czytania

Jak monitorować zmiany na stronie internetowej

Change detection przez DOM diff, content hashing, screenshot comparison. Alerty gdy konkurencja zmienia ofertę, ceny, copy lub politykę.

BeginnerPlaywrightNode.js / TypeScriptPostgreSQLPixelmatch / sharpWebhooks

Change detection to jeden z najprostszych ale najbardziej impactful use-case-ów automation. Wykrywanie gdy: konkurencja zmienia ceny, dodaje nowy produkt, modyfikuje politykę, aktualizuje copy strony. Lub: regulator publikuje nowy guideline, agencja zmienia formularz, dostawca aktualizuje API docs.

Pokażemy 3 techniki, każda dla innego typu zmian:

  1. Content hashing — dla detected text changes
  2. DOM diff — dla detected structural changes
  3. Screenshot diff — dla visual changes (np. nowy banner, layout)
Co potrzebujesz
  • Node.js 20+ lub Python 3.11+
  • Podstawowa znajomość JS/TS lub Python
  • VPS lub serverless platform (Vercel, Cloudflare Workers)
  • Slack/email/Discord dla alertów
Kroki
  1. 01

    Technika 1: Content hashing

    Najprostszy approach: pobierz tekst, hashuj, compare do poprzedniego hash. Działa świetnie dla "czy treść strony się zmieniła":

    import { chromium } from 'playwright';
    import crypto from 'node:crypto';
    
    async function getContentHash(url, selector) {
      const browser = await chromium.launch({ headless: true });
      const page = await browser.newPage();
      await page.goto(url, { waitUntil: 'networkidle' });
      const text = await page.locator(selector).innerText();
      await browser.close();
      // Normalize whitespace, strip dynamic timestamps
      const normalized = text.replace(/\s+/g, ' ').trim();
      return {
        hash: crypto.createHash('sha256').update(normalized).digest('hex'),
        text: normalized,
      };
    }
    
    async function checkChanges(url, selector) {
      const current = await getContentHash(url, selector);
      const prev = await db.query(
        'SELECT hash, text FROM content_history WHERE url=$1 ORDER BY checked_at DESC LIMIT 1',
        [url]
      );
      if (prev.rows.length === 0 || prev.rows[0].hash !== current.hash) {
        await alertChange(url, prev.rows[0]?.text, current.text);
        await db.query(
          'INSERT INTO content_history (url, hash, text, checked_at) VALUES ($1, $2, $3, NOW())',
          [url, current.hash, current.text]
        );
      }
    }

    Pitfall: dynamic content (timestamps, ad slots, randomized order) generuje false positives. Filter selector mocno (np. main article nie body).

  2. 02

    Technika 2: DOM diff dla structural changes

    Hash łapie content changes ale nie strukturalne (np. dodali pricing table, usunęli sekcję). DOM diff:

    import { diffLines } from 'diff';
    
    async function getDomSnapshot(url, selector) {
      const browser = await chromium.launch({ headless: true });
      const page = await browser.newPage();
      await page.goto(url);
      const html = await page.locator(selector).innerHTML();
      await browser.close();
      // Pretty-print żeby diff był czytelny
      return html.replace(/>\s*</g, '>\n<');
    }
    
    async function findStructuralChanges(url, selector) {
      const current = await getDomSnapshot(url, selector);
      const prev = await getPrevSnapshot(url);
      if (!prev) return saveSnapshot(url, current);
      
      const changes = diffLines(prev, current);
      const significant = changes.filter(c => 
        (c.added || c.removed) && c.value.trim().length > 20
      );
      if (significant.length > 0) {
        await alertStructuralChange(url, significant);
      }
    }
  3. 03

    Technika 3: Screenshot diff dla visual changes

    Hashing i DOM diff omijają visual-only changes — np. zmieniony banner image, kolor CTA, layout shift. Screenshot diff:

    import pixelmatch from 'pixelmatch';
    import { PNG } from 'pngjs';
    import fs from 'node:fs';
    
    async function captureAndCompare(url) {
      const browser = await chromium.launch({ headless: true });
      const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
      await page.goto(url);
      await page.waitForLoadState('networkidle');
      
      const buffer = await page.screenshot({ fullPage: true });
      await browser.close();
      
      const prev = await getPrevScreenshot(url);
      if (!prev) return saveScreenshot(url, buffer);
      
      const img1 = PNG.sync.read(prev);
      const img2 = PNG.sync.read(buffer);
      const { width, height } = img1;
      const diff = new PNG({ width, height });
      
      const numDifferent = pixelmatch(
        img1.data, img2.data, diff.data,
        width, height,
        { threshold: 0.1 } // sensitivity 0-1
      );
      
      const pctChanged = numDifferent / (width * height);
      if (pctChanged > 0.02) { // 2%+ pixels changed
        await alertVisualChange(url, pctChanged, diff);
      }
    }

    threshold: 0.1 = ignore minor anti-aliasing differences. 2% pixel change threshold = ignore tiny shifts.

  4. 04

    Scheduling i alerting

    Cron lub serverless. Dla 50-200 URLs co godzinę wystarczy Vercel cron lub Cloudflare Worker.

    // Vercel cron: vercel.json
    {
      "crons": [
        { "path": "/api/check-changes", "schedule": "0 * * * *" }
      ]
    }
    
    // app/api/check-changes/route.ts
    export async function GET() {
      const urls = await db.query('SELECT url, selector, technique FROM watched_urls');
      for (const { url, selector, technique } of urls.rows) {
        if (technique === 'hash') await checkChanges(url, selector);
        if (technique === 'dom') await findStructuralChanges(url, selector);
        if (technique === 'screenshot') await captureAndCompare(url);
      }
      return Response.json({ checked: urls.rows.length });
    }

    Alert template w Slack:

    const block = {
      type: 'section',
      text: { type: 'mrkdwn',
        text: `*Change detected*: ${url}\n` +
              `Type: ${technique}\n` +
              `Previous: ${prevSnippet}\n` +
              `Current: ${currentSnippet}`
      }
    };
Ile to kosztuje uruchomić

Run cost dla 100 URLs co godzinę (72k checks/mies):

  • Vercel Pro (cron jobs + functions): $20/mies
  • Storage (Vercel Postgres lub Supabase, screenshot history ~1GB): $0-25/mies
  • Proxy (datacenter wystarczy dla publicznych stron, ~5GB/mies): $5-15/mies
  • Slack: $0

Total: ~$25-60/mies. Świetny ROI — wystarczy że wykryje JEDNĄ zmianę cen konkurencji rocznie żeby się zwróciło.

Częste pułapki
  • Cały <body> jako selector — załapie cookie banner, recommendations widget, ad slot rotation. Hashuj wąsko (główny content area).
  • Brak normalization — różnice whitespace, line endings = false positive. Normalizuj zanim hashujesz.
  • Lazy-loaded content — page.goto + immediate read = puste sekcje. Użyj waitForLoadState('networkidle') lub jawnie czekaj na elementy.
  • Screenshot diff bez viewport lock — różne viewporty = różne layouty = ciągłe false positives. Stała widthxheight.
  • Alert fatigue — jeśli alert chodzi za każdym dynamic widget, zaczniesz ignorować. Tune thresholdy aż masz <1 alert/dzień per URL.
Build yourself czy zlecić?

Change detection to chyba najlepszy ROI w automation — niska complexity, niski koszt, wysokie business value. Standardowe deployment dla 50-100 URL-i to weekend projekt.

Gdzie się robi trudno: very dynamic sites z heavy JS, sites za auth wall (banking, internal portals), regulatory sites z RPA-style flow. To tu wchodzimy my.

Najczęściej zadawane pytania
Jak często powinienem checkować zmiany?
Zależy od volatility targetu: ceny e-commerce co 30-60 min, regulatory pages codziennie, marketing copy co tydzień. Zbyt często = false positives od dynamic content. Zbyt rzadko = przegapiasz okno. Standard dla "competitor monitoring": co godzinę.
Czy potrzebuję proxy do change detection?
Dla publicznych stron z normalnym anti-bot — nie. Datacenter proxy ($5-15/mies) wystarczy żeby nie obciążać twojego IP. Dla protected sites (Cloudflare, DataDome) — residential proxy konieczne, tak samo jak przy scrapingu cen.
Jak uniknąć alert fatigue?
Trzy taktyki: 1) wąskie selektory (main content area, nie body), 2) thresholdy (np. screenshot diff > 2% pixels, nie 0.1%), 3) grouping (zbieraj N zmian przez 30 min, wyślij jeden alert nie dziesięć). Aim for <1 alert/dzień per URL — wyżej = ignorowane.
Czy mogę monitorować strony za authentication?
Tak — login w setup flow, save storageState dla persistent session. Dla banking/SaaS internal portals wymaga more care: session expiry handling, MFA flows. Robimy to dla 3 klientów obecnie, każdy custom.