BLOG'A DÖN
Elasticsearch

Elasticsearch İle Gerçek Zamanlı Nüfus Verisi Toplama ve Görselleştirme

Fehu-Zone
Fehu-Zone26 Aralık 2025 9 DK OKUMA
Elasticsearch İle Gerçek Zamanlı Nüfus Verisi Toplama ve Görselleştirme

Bu yazıda, dünya nüfusu verilerini Worldometer üzerinden otomatik olarak çekip, Elasticsearch’e kaydederek Kibana ile görselleştirdiğim dashboardları web sitesine gömüp canlıya aldığım projemi detaylarıyla paylaşıyorum. Projeye Python ile başladım ancak projemi canlıya aldığımda Python’un inanılmaz kaynak tükettiğini farkettim ve Javascript’e geçtim fakat siz isterseniz pyrhon kodlarını da inceleyebilirsiniz. Kodları birebir GitHub üzerinden inceleyebilir, sistemi kendi projelerine kolayca entegre edebilir ve açık kaynak koda katkıda bulunabilirsiniz:

👉Kazıma İşlemleri İçin Tıklayın
👉Web Sitesi Kodları İçin Tıklayın
👉Benimle İletişim İçin Tıklayın

Veri Kazıması İçin Kullandığım Teknolojiler

1. Veri Kazıma (Scraping)

  • Puppeteer (+ Stealth Plugin) ile tarayıcı otomasyonu yapıldı. Anti-bot sistemlerini atlamak için headless tarayıcı davranışı insan benzeri hale getirildi.
  • Adblocker eklentisi ile gereksiz istekler engellendi, performans artırıldı.
  • Cheerio ile statik HTML’ler parse edildi, Axios ile API/HTTP istekleri atıldı.
  • Axios-retry ile hatalı istekler otomatik yeniden denendi.
  • Progress kütüphanesiyle terminalde işlem durumu real-time gösterildi.

2. Veri Depolama ve Sorgu

  • Toplanan veriler Elasticsearch’e indexlendi.
  • LRU Cache ile sık erişilen veriler bellekte tutularak gereksiz istekler önlendi.

3. Görselleştirme ve Dağıtım

  • Kibana ile Elasticsearch’ten gelen veriler dashboard’a döküldü, analiz edildi.
  • Tüm sistem Docker ile containerize edilip AWS EC2’ye deploy edildi.

Neden Bu Teknolojileri tercih ettim?

  • Puppeteer + Stealth: Dinamik içeriklerde sürdürülebilir ve az kaynak kullanarak kazıma.
  • Elasticsearch: Büyük veride full-text search ve hızlı sorgu.
  • Docker + EC2: Kolay scaling(ölçekleme) ve düşük maliyet.

Kazıma Sürecine Genel Bakış

Blog Image

Mimari ve Katmanlar

1. Konfigürasyon: Ortam ve Yapılandırma

Projenin yapılandırma ayarlarını (Elasticsearch ve Kibana bağlantı bilgileri, User-Agent(tarayıcı taklidi), Worldometer URL’leri vb.) çevresel değişkenlerden (dotenv) alıyoruz. Böylece, farklı ortamlarda esnek konfigürasyon yönetimi sağlanıyor.

javascript
import dotenv from "dotenv";
dotenv.config();

export default {
  ELASTICSEARCH_HOST: process.env.ELASTICSEARCH_HOST,
  INDEX_NAME: process.env.INDEX_NAME,
  ELASTIC_USERNAME: process.env.ELASTIC_USERNAME,
  ELASTIC_PASSWORD: process.env.ELASTIC_PASSWORD,
  REQUEST_HEADERS: {
    "User-Agent":
      process.env.USER_AGENT ||
      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
  },
  COUNTRIES_URL:
    process.env.COUNTRIES_URL ||
    "https://www.worldometers.info/world-population/population-by-country/?t=" +
      Date.now(),
  WORLD_URL:
    process.env.WORLD_URL || "https://www.worldometers.info/world-population/",
};
Bu yapı sayesinde; Elasticsearch host, indeks adı, kullanıcı adı, şifre gibi bilgileri .env dosyasından merkezi olarak yönetiyoruz. Güvenlik açısından gerek burada gerek repomda .env dosyamı paylaşamıyorum. Kendi kodunuza en uygun env dosyanızı rahatlıkla oluşturabilirsiniz

2. Elasticsearch Bağlantısı ve İndeks Yönetimi

Elasticsearch’te verileri depolayabilmek için bir istemci oluşturuyoruz. Bu dosyada, Elasticsearch’ün indeksinin var olup olmadığını kontrol edip, yoksa dinamik olarak oluşturuyoruz. Ayrıca, “current” (güncel) snapshot’ları güncellemek için updateByQuery metodu kullanılıyor. Ek olarak, sürekli kazıma işlemi yapılan bu tarz projelerde sık karşılaşılan bir hata olan “Bu indeks zaten mevcut” hatasının önüne geçmiş oluyoruz.

javascript
import { Client } from "@elastic/elasticsearch";
import config from "../config/index.js";

const client = new Client({
  node: config.ELASTICSEARCH_HOST,
  auth: {
    username: config.ELASTIC_USERNAME,
    password: config.ELASTIC_PASSWORD,
  },
  tls: {
    rejectUnauthorized: false,
  },
});

export const initIndex = async () => {
  try {
    const indexExists = await client.indices.exists({
      index: config.INDEX_NAME,
    });

    if (!indexExists) {
      await client.indices.create({
        index: config.INDEX_NAME,
        body: {
          mappings: {
            dynamic: "strict",
            properties: {
              country: {
                type: "text",
                fields: {
                  keyword: {
                    type: "keyword",
                    ignore_above: 256,
                  },
                },
              },
              country_code: { type: "keyword" },
              continent: { type: "keyword" },
              current_population: { type: "long" },
              yearly_change: { type: "float" },
              net_change: { type: "integer" },
              migrants: { type: "integer" },
              med_age: { type: "float" },
              population_growth: { type: "float" },
              "@timestamp": { type: "date" },
              is_current: { type: "boolean" },
              type: { type: "keyword" },
            },
          },
        },
      });
      console.log(`Index "${config.INDEX_NAME}" oluşturuldu.`);
    } else {
      console.log(`Index "${config.INDEX_NAME}" zaten mevcut.`);
    }

    return { created: !indexExists };
  } catch (error) {
    console.error("Index işlemleri sırasında hata:", error.message);
    throw error;
  }
};

export const updateCurrentSnapshot = async (timestamp) => {
  try {
    // Eski current verileri false yap
    await client.updateByQuery({
      index: config.INDEX_NAME,
      conflicts: "proceed",
      refresh: true,
      body: {
        script: {
          source: "ctx._source.is_current = false",
          lang: "painless",
        },
        query: {
          term: { is_current: true },
        },
      },
    });

    // Yeni verileri güncel (current) yap
    await client.updateByQuery({
      index: config.INDEX_NAME,
      conflicts: "proceed",
      refresh: true,
      body: {
        script: {
          source: "ctx._source.is_current = true",
          lang: "painless",
        },
        query: {
          bool: {
            must: [
              { term: { "@timestamp": timestamp } },
              { terms: { type: ["world", "country"] } },
            ],
          },
        },
      },
    });

    console.log(`Güncel snapshot güncellendi: ${timestamp}`);
  } catch (error) {
    console.error("Snapshot güncelleme hatası:", error.message);
    throw error;
  }
};

export { client };
Bu kod sayesinde, Elasticsearch ortamında dinamik veri modellemesi ve snapshot güncellemeleri yapılabilmekte.

3. Ülke Verilerini Dinamik Olarak Kazıma

Puppeteer-extra (stealth özellikli) kullanılarak Worldometer’da ülke bazlı nüfus verileri çekiliyor. Sayfa tamamen yüklendikten sonra, tablo içerisindeki tüm satırlar için veri ayrıştırılması yapılmaktadır. Puppeteer-extra kullanmadan da gayet sağlıklı kazıma işlemi yaptım ancak fazladan önlem almak bize bir şey kaybettirmez.

javascript
import puppeteer from "puppeteer-extra";
import StealthPlugin from "puppeteer-extra-plugin-stealth";
import { parseNumber, cleanCountryName } from "./utils.js";
import config from "../config/index.js";

puppeteer.use(StealthPlugin());

export const fetchCountryDataDynamic = async () => {
  let browser;
  let page;

  try {
    // Tarayıcı başlatma
    browser = await puppeteer.launch({
      headless: "new",
      args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage",
        "--disable-features=site-per-process",
        "--lang=en-US",
        "--window-size=1920,3000",
      ],
    });

    page = await browser.newPage();
    await page.setViewport({ width: 1920, height: 3000 });

    // Kullanıcı ajanı ayarı
    await page.setUserAgent(config.REQUEST_HEADERS["User-Agent"]);
    await page.setJavaScriptEnabled(true);
    await page.setDefaultNavigationTimeout(120000);

    // Sayfa yükleme
    console.log("Sayfa yükleniyor:", config.COUNTRIES_URL);
    await page.goto(config.COUNTRIES_URL, {
      waitUntil: "networkidle2",
      timeout: 120000,
    });

    // Tablonun doğru yüklenmesi için bekleme
    await page.waitForFunction(
      () => {
        const potentialTables = Array.from(document.querySelectorAll("table"));
        return potentialTables.some((table) => {
          const headers = Array.from(table.querySelectorAll("th"));
          return headers.some((th) => th.textContent.includes("Population"));
        });
      },
      { timeout: 45000 }
    );

    // Tablo verilerinin çekilmesi
    const tableData = await page.evaluate(() => {
      const tables = Array.from(document.querySelectorAll("table"));
      const targetTable = tables.find(
        (table) =>
          table.textContent.includes("Country") &&
          table.textContent.includes("Population")
      );

      return Array.from(targetTable.querySelectorAll("tbody tr")).map((row) => {
        const cells = Array.from(row.querySelectorAll("td"));
        return cells.map((cell) =>
          cell.textContent
            .replace(/\u00a0/g, " ") // Özel boşluk karakterlerini temizle
            .trim()
        );
      });
    });

    // Verilerin işlenmesi
    const processedData = tableData
      .map((row) => ({
        rank: parseNumber(row[0]),
        country: cleanCountryName(row[1]),
        current_population: parseNumber(row[2]),
        yearly_change: parseNumber(row[3], true),
        net_change: parseNumber(row[4]),
        migrants: parseNumber(row[7]),
        med_age: parseNumber(row[9]),
      }))
      .filter((item) => item.rank > 0);

    return processedData;
  } catch (error) {
    console.error("Son hata:", error);
    if (page) {
      await page.screenshot({
        path: `final-error-${Date.now()}.png`,
        fullPage: true,
      });
    }
    return null;
  } finally {
    if (browser) await browser.close();
  }
};

4. Statik HTML Üzerinden Dünya Verilerini Kazıma

Bu yöntemde, Worldometer’ın ana sayfasından Axios ile HTML verisi çekilip, Cheerio kullanılarak ayrıştırma yapılıyor. Verilerin çekileceği alanlarda ilgili rel değerleri kullanılarak sayı değerleri elde ediliyor. Aşağıdaki kod parçası, statik yöntemle dünya nüfus verisini çekip ayrıştırmanın nasıl yapıldığını gösteriyor:

javascript
import axios from "axios";
import * as cheerio from "cheerio";
import config from "../config/index.js";
import { parseNumber } from "./utils.js";

export const fetchWorldData = async () => {
  try {
    const { data } = await axios.get(config.WORLD_URL, {
      headers: config.REQUEST_HEADERS,
      timeout: 15000,
    });

    const $ = cheerio.load(data);

    const extractValue = (relAttr) => {
      const element = $(`span[rel="${relAttr}"]`);
      if (!element.length) return null;

      return parseNumber(
        element
          .find(".rts-nr-int")
          .toArray()
          .map((el) => $(el).text().trim())
          .join("")
      );
    };

    const result = {
      current_population: extractValue("current_population"),
      births_today: extractValue("births_today"),
      // Günlük ölüm verisini "dth1s_today" anahtarıyla çekiyoruz.
      dth1s_today: extractValue("dth1s_today"),
      population_growth: extractValue("absolute_growth"),
      "@timestamp": new Date().toISOString(),
    };

    if (Object.values(result).some((v) => v === null || Number.isNaN(v))) {
      throw new Error("Eksik veya geçersiz dünya verileri");
    }

    return result;
  } catch (error) {
    console.error("Dünya veri hatası:", error.message);
    return null;
  }
};
Not: Bu yöntemde, HTML içeriği üzerinden doğrudan veri kazıyarak hızlı ve basit bir yapı elde edilmiş oluyor. Ancak bazı durumlarda, sayfa dinamik içerik sunduğu için eksik veri alınabilir. Buna karşı dinamik verileri çekmek için yazdığım kodları aşağıda paylaşacağım

5. Dünya Verilerini Dinamik Kazıma

Dinamik veri çekimi yönteminde ise Puppeteer kullanılarak tarayıcı başlatılıyor ve sayfada çalıştırılan JavaScript ile veriler elde ediliyor. Bu yöntem, statik HTML’de sunulmayan veya güncellenmeyen veriler için daha güvenilir sonuçlar verir.

javascript
import puppeteer from "puppeteer";
import config from "../config/index.js";
import { parseNumber } from "./utils.js";

export const fetchWorldDataDynamic = async () => {
  let browser;
  try {
    browser = await puppeteer.launch({
      headless: "new",
      ignoreHTTPSErrors: true,
      args: [
        "--no-sandbox",
        "--disable-setuid-sandbox",
        "--disable-dev-shm-usage",
      ],
    });

    const page = await browser.newPage();
    await page.setUserAgent(config.REQUEST_HEADERS["User-Agent"]);
    await page.setDefaultNavigationTimeout(30000);

    await page.goto(config.WORLD_URL, {
      waitUntil: "networkidle2",
      timeout: 30000,
    });

    const result = await page.evaluate(() => {
      const getValue = (rel) => {
        const el = document.querySelector(`[rel='${rel}']`);
        return el
          ? Array.from(el.querySelectorAll(".rts-nr-int"))
              .map((e) => e.textContent.trim())
              .join("")
          : "";
      };

      return {
        current_population: getValue("current_population"),
        births_today: getValue("births_today"),
        // Anahtar adını, site ile uyumlu olacak şekilde "dth1s_today" olarak ayarlıyoruz.
        dth1s_today: getValue("dth1s_today"),
        population_growth: getValue("absolute_growth"),
        "@timestamp": new Date().toISOString(),
      };
    });

    return {
      current_population: parseNumber(result.current_population),
      births_today: parseNumber(result.births_today),
      dth1s_today: parseNumber(result.dth1s_today),
      population_growth: parseNumber(result.population_growth),
      "@timestamp": result["@timestamp"],
    };
  } catch (error) {
    console.error("Dünya veri hatası:", error);
    return null;
  } finally {
    if (browser) await browser.close();
  }
};
Not: Dinamik kazıma yöntemi, özellikle JavaScript tarafından oluşturulan içerikleri çekmekte çok daha etkin olduğu için, sayfa güncellemelerine daha iyi ayak uydurur ve bu sayede daha güvenilir sonuçlar elde ederiz. Fakat bu yöntem statik yönteme göre daha uzun sürer ve daha çok kaynak tüketir.

Fallback Mantığının Uygulanması

Projeme ilk başladığımda dünya bazlı nüfus verilerini çekerken sık karşılaştığım hatalardan birisi ise “null dönen” verilerdi. Statik olarak verileri almak istedim ancak Worldometerin yapısından dolayı bu her zaman mümkün olmuyordu. Puppeteer kullandım ve dinamik kazıma yapısını koduma entegre ettim. Fallback mekanizmasını kurdum

Geliştirdiğim fallback mekanizması sayesinde, eğer dinamik veri çekiminde bir hata oluşursa, otomatik olarak statik veri çekme yöntemi devreye girecek, böylece verilerin sürekliliği sağlanacaktır.

6. Yardımcı Fonksiyonlar

İki temel yardımcı fonksiyon — sayısal verileri parse etmek ve ülke isimlerini temizlemek — aşağıdaki kodlarda yer almaktadır.. Ek olarak, toplu indexleme (bulkIndexCountries) fonksiyonu da Elasticsearch işlemleri için bulunuyor.

javascript
import { client } from "../elastic/client.js";
import config from "../config/index.js";

export const parseNumber = (str, isPercentage = false) => {
  if ([null, undefined, ""].includes(str)) return 0; // Null değerler için 0

  const cleaned = String(str)
    .replace(/[^\d.-]/g, "")
    .replace(/^\-/g, "-");

  const number = parseFloat(cleaned);
  return Number.isNaN(number) ? 0 : number; // NaN durumunda 0
};

export const cleanCountryName = (name) => {
  const COUNTRY_NAME_MAPPING = {
    /* ... */
  };

  return (
    COUNTRY_NAME_MAPPING[name] ||
    name
      .replace(/\[.*?\]/g, "")
      .replace(/\(.*?\)/g, "")
      .trim()
  );
};

export const bulkIndexCountries = async (countries) => {
  try {
    // EKSİK PARANTEZ DÜZELTİLDİ
    if (!Array.isArray(countries)) {
      throw new Error("Geçersiz ülke veri formatı");
    }

    const { body: updateResponse } = await client.updateByQuery({
      /* ... */
    });

    const body = countries.flatMap((country) => [
      /* ... */
    ]);

    const { body: bulkResponse } = await client.bulk({
      /* ... */
    });

    return {
      /* ... */
    };
  } catch (error) {
    return {
      /* ... */
    };
  }
};

7. Ana İşlem Akışı: main.js

Bu ana dosya, projenin merkez üssü diyebiliriz. Veri kazıma işlemlerini koordine etmekle kalmaz; aynı zamanda verilerin doğruluğunu denetler, Elasticsearch’e gönderimini sağlar, snapshot’ları günceller ve sistem kaynaklarını izleyerek hangi kazıma yönteminin tercih edilmesi gerektiğine karar verir. Yani, hem teknik akışı hem de verimliliği yöneten bir kontrol paneli gibi çalışır.

javascript
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
import dotenv from "dotenv";
dotenv.config();

// .env dosyasından bilgileri alıyoruz.
// Eğer ELASTICSEARCH_HOST tanımlı değilse, varsayılan olarak yerel sunucuyu kullan.
const ELASTICSEARCH_HOST =
  process.env.ELASTICSEARCH_HOST || "http://localhost:9200/";

import { fetchCountryDataDynamic } from "./scraper/countryDataDynamic.js";
import { fetchWorldDataDynamic } from "./scraper/worldDataDynamic.js";
import { initIndex, client } from "./elastic/client.js";
import ProgressBar from "progress";
import { updateCurrentSnapshot } from "./elastic/client.js";

// Gelişmiş Loglama Sistemi
const logger = {
  info: (message) =>
    console.log(
      `\x1b[36mℹ️ [${new Date().toLocaleTimeString()}] ${message}\x1b[0m`
    ),
  success: (message) =>
    console.log(
      `\x1b[32m✅ [${new Date().toLocaleTimeString()}] ${message}\x1b[0m`
    ),
  error: (message) =>
    console.log(
      `\x1b[31m❌ [${new Date().toLocaleTimeString()}] ${message}\x1b[0m`
    ),
  warn: (message) =>
    console.log(
      `\x1b[33m⚠️ [${new Date().toLocaleTimeString()}] ${message}\x1b[0m`
    ),
};

// Geliştirilmiş Veri Doğrulama
const validateData = (worldData, countryData) => {
  const warnings = [];
  const errors = [];
  const EXPECTED_COUNTRIES = 235;

  // Dünya verisi kontrolleri
  if (!worldData?.current_population) {
    errors.push("Dünya nüfus verisi eksik");
  }

  // Ülke verisi kontrolleri
  if (!countryData || countryData.length === 0) {
    errors.push("Hiç ülke verisi alınamadı");
    return { isValid: false, errors, warnings };
  }

  const totalCountries = countryData.length;
  const validCountries = countryData.filter(
    (c) =>
      c.current_population > 0 && !isNaN(c.yearly_change) && !isNaN(c.med_age)
  ).length;

  // Uyarılar
  if (totalCountries < EXPECTED_COUNTRIES) {
    warnings.push(`Eksik ülke: ${EXPECTED_COUNTRIES - totalCountries}`);
  }

  const criticalMissing = ["China", "India", "United States"].filter(
    (c) => !countryData.some((d) => d.country === c)
  );

  if (criticalMissing.length > 0) {
    warnings.push(`Eksik kritik ülkeler: ${criticalMissing.join(", ")}`);
  }

  if (totalCountries - validCountries > 0) {
    warnings.push(
      `Geçersiz veri içeren ülkeler: ${totalCountries - validCountries}`
    );
  }

  // Hatalar
  if (validCountries === 0) {
    errors.push("Hiç geçerli ülke verisi yok");
  }

  return {
    isValid: errors.length === 0,
    errors,
    warnings,
  };
};

// Enerji Tüketimi Ölçüm Fonksiyonu
const measureEnergyConsumption = async (fn, label = "İşlem") => {
  const startTime = process.hrtime();
  const startCpuUsage = process.cpuUsage();

  try {
    const result = await fn();

    const elapsedTime = process.hrtime(startTime);
    const elapsedCpu = process.cpuUsage(startCpuUsage);
    const cpuSeconds = (elapsedCpu.user + elapsedCpu.system) / 1e6;
    const wallSeconds = elapsedTime[0] + elapsedTime[1] / 1e9;
    const cpuWattage = 50;
    const estimatedEnergyJoules = cpuSeconds * cpuWattage;

    logger.info(`\n== ${label} Enerji Tüketim Raporu ==`);
    logger.info(`Duvar saati süresi: ${wallSeconds.toFixed(3)} s`);
    logger.info(`CPU kullanım süresi: ${cpuSeconds.toFixed(3)} s`);
    logger.info(
      `Tahmini enerji tüketimi: ${estimatedEnergyJoules.toFixed(
        2
      )} J (ortalama ${cpuWattage}W kabul edilerek)`
    );
    return result;
  } catch (error) {
    throw error;
  }
};

// Ana İşlem Akışı
const processData = async () => {
  try {
    logger.info("Scraping süreci başlatılıyor...");

    // Elasticsearch hazırlığı
    await initIndex();

    // 1. Dünya verilerini çek
    logger.info("════════════ DÜNYA VERİLERİ ÇEKİLİYOR ════════════");
    const worldData = await fetchWithProgress(
      fetchWorldDataDynamic,
      "🌍 Dünya verisi",
      15,
      120000
    );

    // 2. Bekleme süresi
    logger.info("Dünya verisi alındıktan sonra 20 saniye bekleniyor...");
    await delay(20000);

    // 3. Ülke verilerini çek
    logger.info("════════════ ÜLKE VERİLERİ ÇEKİLİYOR ════════════");
    const countryData = await fetchWithProgress(
      fetchCountryDataDynamic,
      "🌐 Ülke verisi",
      30,
      240000
    );

    // Sonuçları işle
    const results = { world: worldData, country: countryData };
    logResults(results);

    // Validasyon
    const validation = validateData(results.world, results.country);
    handleValidation(validation);

    // Elasticsearch'e gönder
    const { successCount, errorCount } = await sendToElastic(results);

    logger.success(`Başarıyla kaydedildi: ${successCount} kayıt`);
    if (errorCount > 0) {
      logger.warn(`Başarısız kayıtlar: ${errorCount}`);
    }

    // Snapshot güncelleme
    await updateCurrentSnapshot(new Date().toISOString());
  } catch (error) {
    logger.error(`Kritik Hata: ${error.message}`);
    logger.info("5 dakika sonra yeniden denenecek...");
    setTimeout(() => processDataWithEnergy(), 300000);
  }
};

// Enerji ölçümü dahil ana işlem çağrısı
const processDataWithEnergy = async () => {
  await measureEnergyConsumption(processData, "processData");
};

// Yardımcı Fonksiyonlar
const fetchWithProgress = async (fetchFn, label, total, timeout) => {
  const bar = new ProgressBar(`${label} [:bar] :percent :etas`, {
    complete: "=",
    incomplete: " ",
    width: 30,
    total,
  });

  const timer = setInterval(() => bar.tick(), 1000);

  try {
    const result = await Promise.race([
      fetchFn(),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error(`${label} zaman aşımı`)), timeout)
      ),
    ]);

    clearInterval(timer);
    bar.update(1);
    return result;
  } catch (error) {
    clearInterval(timer);
    throw error;
  }
};

const logResults = (results) => {
  logger.info("════════════ DÜNYA VERİLERİ ════════════");
  if (results.world) {
    logger.info(
      `🌍 Nüfus: ${results.world.current_population?.toLocaleString()}`
    );
    logger.info(
      `📈 Günlük Büyüme: ${results.world.population_growth?.toLocaleString()}`
    );
    logger.info(`⏳ Zaman Damgası: ${results.world["@timestamp"]}`);
  } else {
    logger.error("Dünya verisi yok");
  }

  logger.info("════════════ ÜLKE VERİLERİ ════════════");
  if (results.country?.length > 0) {
    logger.info(`✅ Toplam Ülke: ${results.country.length}`);
    logger.info(
      `🏆 İlk 3 Ülke: ${results.country
        .slice(0, 3)
        .map((c) => c.country)
        .join(", ")}`
    );
    logger.info(`📊 Ortalama Yaş: ${calculateAverageAge(results.country)}`);
  } else {
    logger.error("Ülke verisi yok");
  }
};

const handleValidation = ({ isValid, errors, warnings }) => {
  if (!isValid) {
    logger.error("Validasyon Hataları:");
    errors.forEach((e) => logger.error(`❌ ${e}`));
    throw new Error("Kritik validasyon hataları");
  }

  if (warnings.length > 0) {
    logger.warn("Validasyon Uyarıları:");
    warnings.forEach((w) => logger.warn(`⚠️  ${w}`));
  }
};

const sendToElastic = async ({ world, country }) => {
  const body = [];

  try {
    // Dünya verisini ekle
    if (world) {
      body.push(
        { index: { _index: process.env.INDEX_NAME } },
        {
          ...world,
          type: "world",
          is_current: true,
          "@timestamp": new Date().toISOString(),
        }
      );
    }

    // Ülke verilerini ekle
    if (country?.length > 0) {
      country.forEach((c) => {
        body.push(
          { index: { _index: process.env.INDEX_NAME } },
          {
            ...c,
            type: "country",
            is_current: true,
            "@timestamp": new Date().toISOString(),
            current_population: c.current_population || 0,
            yearly_change: c.yearly_change || 0,
            net_change: c.net_change || 0,
            migrants: c.migrants || 0,
            med_age: c.med_age || 0,
          }
        );
      });
    }

    if (body.length === 0) {
      logger.warn("Gönderilecek veri yok");
      return { successCount: 0, errorCount: 0 };
    }

    const { body: response } = await client.bulk({
      refresh: "wait_for",
      body,
    });

    let successCount = 0;
    let errorCount = 0;
    const errors = [];

    if (response?.items) {
      response.items.forEach((item, index) => {
        if (item.index.error) {
          errorCount++;
          errors.push({
            document: body[index * 2 + 1],
            reason: item.index.error.reason,
          });
        } else {
          successCount++;
        }
      });
    }

    if (errorCount > 0) {
      logger.error(`İlk 3 hata detayı:`);
      errors.slice(0, 3).forEach((err, i) => {
        logger.error(`${i + 1}. Hata: ${err.reason}`);
        logger.error(`Belge: ${JSON.stringify(err.document)}`);
      });
    }

    return { successCount, errorCount };
  } catch (error) {
    logger.error("Elasticsearch hatası:");
    if (error.meta) {
      logger.error(`Hata detayı: ${JSON.stringify(error.meta.body.error)}`);
    } else {
      logger.error(error.stack);
    }
    throw error;
  }
};

const calculateAverageAge = (countries) => {
  const validAges = countries
    .map((c) => c.med_age)
    .filter((age) => age > 0 && age < 100);

  return validAges.length > 0
    ? (validAges.reduce((sum, age) => sum + age, 0) / validAges.length).toFixed(
        1
      )
    : "N/A";
};

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

console.clear();
processDataWithEnergy();
setInterval(processDataWithEnergy, 1800000); // Her 30 dakikada bir çalıştır.
Kodlarda olası bir sorun yaşamanıza karşılık soruna daha hızlı müdahale edebilmeniz için hata ile alakalı size ekran görüntüsü gelecektir.

Projenizi İnsanlara Sunmanın En İyi Yolu: Web Sitesi

Kibana’da oluşturduğum gösterge panoları gerçekten hoşuma gitmişti. Ancak yerelde (local) çalışırken, bu etkileyici görselleştirmeleri insanlarla dilediğim gibi paylaşamıyordum. Bu nedenle işe Figma’da bir web sitesi tasarlayarak başladım. Tasarımın ardından Vue.js ile bu tasarımı hayata geçirdim ve siteyi tamamen etkileşimli bir hale getirdim. Projeyi yavaş yavaş canlıya alma fikri de tam olarak burada tomurcuklandı.

Web sitem ve veri kazıma işlemleri, AWS üzerinde çalışan bir sunucu sayesinde kesintisiz şekilde çalışıyor. Yukarıda bahsettiğim “Kazıma Sürecine Genel Bakış” başlığı ve aşağıya koyacağım web sitesi-kibana etkileşimi diagramını tekrar inceleyerek süreci daha iyi anlayabilirsiniz.

Veri kaynağından kullanıcıya kadar olan akışı ve Vue ile Kibana’nın nasıl bir arada çalıştığını sade bir şekilde gözler önüne seriyor. 👇

Blog Image
Web sitesinin yayında olduğu ve kazıma işlemlerinin yapıldığı yer AWS sunucusudur. “Kazıma Sürecine Genel Bakış” başlığına giderek web sitesi ve Kibana arasındaki etkileşimi daha iyi anlayabilirsiniz.

Web Site Entegrasyonu ve Teknolojiler

Web sitemi oluştururken:

  • Vue 3 + Vite altyapısını tercih ettim.
  • Birim ve entegrasyon testlerini Vitest ve Cypress ile yazdım.
  • Temel SEO ayarlarını (meta başlık, açıklama, Open Graph) sağladım.
  • vue-i18n kullanarak 9 dil desteği ekledim ve her dil için özel olarak mobile/desktop responsive tasarımlar yaptım.
  • Stil ve responsive düzenlemeleri için sadece saf CSS kullandım; ekstra kütüphane yüklemedim.
  • Dünya ve ülke sayfalarını, Elasticsearch + Kibana’dan gelen iframe’ler aracılığıyla doğrudan gömme (embed) yöntemiyle oluşturdum.
  • Web sitemi AWS üzerinden canlıya aldım.

Kibana ve Web Sitesi İletişimi

  • Kibana’ya erişin
  • Dashboard tasarımlarınızı yapın
  • Tasarımınız bittikten sonra “share” seçeneğine tıklayarak istediğiniz bağlantı türünü seçin(ben projemde “embed code” seçeneğini tercih ettim)

Aşağıda Elasticsearch’ün örnek verdiği iframe bulunmaktadır. Test edebilirsiniz.

html
<iframe src="https://my-deployment:9243/app/dashboards#/view/7adfa750-4c81-11e8-b3d7-01146121b73d?embed=true&_g=(refreshInterval%3A(pause%3A!t%2Cvalue%3A0)%2Ctime%3A(from%3Anow-1y%2Fd%2Cto%3Anow))&show-top-menu=true&show-query-input=true&show-time-filter=true" height="600" width="800"></iframe>


NOT!!! İframe’i nasıl göstereceğinize dair detaylar sizin gereksinimlerinize göre değişiklik göstermektedir. Ek ayarlar vb. Şeyler için lütfen bu makaleyi okuyun.

Blog Image

Blog Image
Dünya Bazlı Verilerin Kibana Dashboard Üzerinden Görselleştirilmesi

Localde Çalışmak Güzel Ancak Projeyi Canlıya Almalıyız

Projeyi canlıya almak için AWS’in Free Tier kapsamındaki t3.medium EC2 kullandım.

1. EC2 Kurulumu

  • AWS Console’a giriş yaparak EC2 → Launch Instance seçeneğini seçin.
  • Instance türü olarak, Free Tier kapsamında yer alan t3.medium’ı tercih ettim (2 vCPU, 4 GiB RAM).
  • İşletim sistemi olarak Amazon Linux 2 AMI (HVM), SSD Volume Type kullandım. Bu, SSD diskli ve yüksek performanslı sanallaştırma destekli bir Linux dağıtımıdır. Siz ihtiyacınıza göre farklı bir işletim sistemi tercih edebilirsiniz.
  • Depolama (Storage) ayarlarında varsayılan olarak sunulan 8 GiB EBS SSD’yi kullandım. Daha fazla alana ihtiyacınız varsa 16 GiB veya üzerine çıkarabilirsiniz.
  • SSH erişimi için, TCP 22. portu sadece kendi IP adresimle (örneğin 203.0.113.5/32) sınırladım. Böylece sunucuya yalnızca kendi cihazımdan bağlanabiliyorum. Bu sayede başkalarının sunucuma bağlanmasını engellemiş oldum.
  • Güvenlik grubu (Security Group) ayarlarında yalnızca SSH (port 22) erişimine izin verdim.
  • Outbound kurallarını AWS’nin varsayılan haliyle bıraktım (her yere açık). Böylece hem npm paketlerini çekebiliyor hem de Worldometer gibi sitelere veri kazımı için istek atabiliyorum.

2. Sunucuya Giriş ve Temel Kurulum

SSH ile Bağlanma

markdown
ssh -i “~/my-key.pem” ec2-user@EC2_PUBLIC_IP

Sistem Güncellemesi

markdown
sudo yum update -y

Chrome/Chromium Bağımlılıkları

Puppeteer kullandığımız için gerekli kütüphaneleri ekledim:

markdown
sudo amazon-linux-extras install epel -ysudo yum install -y \ wget \ bind-utils \ libX11 \ alsa-lib \ gtk3 \ ipa-gothic-fonts

Node.js ve Git Kurulumu

markdown
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bashsource ~/.bashrcnvm install --ltsgit --version || sudo yum install git -y

3. Proje Kurulumu

Repo Klonlama

markdown
git clone https://github.com/kullanici-adiniz/proje-ismi.gitcd proje-ismi

Çevresel Değişkenler

Kök dizinde bir .env dosyası oluşturun:
Kök dizindeki .env → Geliştirme ortamı için

  • Docker container’larının birbiriyle iletişim kurmasını sağlar
  • Local Elasticsearch bağlantı bilgilerini tutar (http://localhost:9200)
  • Geliştirme sırasında hızlı test yapmamızı sağlar

Ayrıca bir .env daha → Production ortamı için

  • Gerçek Elasticsearch cloud bağlantı bilgileri
  • API key’ler ve güvenlik bilgileri
  • Asla GitHub’a yüklenmemesi gereken hassas veriler
code
ELASTICSEARCH_HOST=https://your-es-endpoint:9200INDEX_NAME=world_population_dataELASTIC_USERNAME=elasticELASTIC_PASSWORD=changemeUSER_AGENT=Mozilla/...

Bağımlılıkları Yükleme

markdown
npm install

4. Kesintisiz Çalıştırma ve İzleme

PM2 ile Süreklilik

markdown
npm install -g pm2pm2 start main.js --name population-scraperpm2 savepm2 startup
  • pm2 startup komutu, EC2 yeniden başlasa bile PM2 servisinin otomatik ayağa kalkmasını sağlar. Bu sayede bu işlemi her seferinde elle başlatmamıza gerek kalmaz
  • pm2 save ise mevcut process listesini kaydeder.

Log Takibi

  • Gerçek zamanlı log izlemek için:
markdown
pm2 logs population-scraper
  • Hatayı veya ilerlemeyi konuya göre renkli ve zaman damgalı görebiliyorsunuz.

5. Canlı Kazıma Döngüsü

  • main.js içinde yazdığımprocessDataWithEnergy() fonksiyonunu inceleyelim:
  • 30 dakikada bir tekrar eden bir zamanlayıcıyla (setInterval) tetikleniyor.
  • Önce dinamik kazıma (fetchWorldDataDynamic, fetchCountryDataDynamic) deneniyor.
  • Eğer dinamik kazımada bir hata oluşursa, kod bloğunda yakalanıp null dönüyor; bu durumda statik kazıma ya da hata yönetimi devreye giriyor.
  • Böylece EC2 üzerinde 7/24 veri akışı kesintisiz olarak devam ediyor.

6. GitHub Pipeline ve Cloudflare Entegrasyonu

Projede domain, pipeline ve otomatik deployment tarafını tamamen otomatize ettim.

Domain & Cloudflare

Domain’i Namecheap’ten(Natro) aldıktan sonra Cloudflare’e taşıdık.
Bu sayede GitHub — Sunucu — Domain arasında bir köprü kuruldu.
Artık main branch’e push attığım anda güncelleme otomatik olarak canlıya geçiyor.

Cloudflare tarafında:

  • CDN sayesinde siteye dünyanın her yerinden hızlı erişim sağlanıyor
  • DDoS koruması ve cache optimizasyonu da bonus oldu
Son Olarak Yaptığımız İşlemleri Kısaca Özetleyelim

Güvenlik grubu sadece gerekli portlara izin veriyor (SSH, Elasticsearch).

Amazon Linux 2 üzerinde Node.js, Chrome bağımlılıkları ve Git kurulumu tamamlandı.

Proje klonlanıp, .env ile konfigürasyonu yapıldı.

PM2 ile kesintisiz, otomatik başlatılan bir servis olarak uygulama ayağa kaldırıldı.

Zamanlayıcı sayesinde 30 dakikada bir, dinamik (ve gerekirse fallback statik) kazıma işlemi çalıştırılıyor.

Cloudflare entegrasyonu ile domain yönetimi, SSL ve CDN yapılandırması tamamlandı. Artık siteye dünyanın her yerinden hızlı ve güvenli şekilde erişilebiliyor.

GitHub Actions pipeline devreye alındı: her push sonrası testler çalışıyor, son sürüm otomatik olarak EC2’ye deploy ediliyor ve PM2 aracılığıyla sıfır kesintiyle güncelleniyor.

Bu adımlarla, t3.medium Free Tier EC2 örneğinde sorunsuz şekilde canlı kazıma ortamı kurmuş oldum. Aynı adımları inceleyerek sizler de rahatlıkla yapabilirsiniz. İhtiyaç duymanız halinde anlık hata bildirimleri alabileceğiniz ayarları da yapabilirsiniz

NOT !!! LÜTFEN AWS FATURALANDIRMA ALARMLARINI ETKİNLEŞTİRMEYİ UNUTMAYIN :)

Sonuç ve Değerlendirme

Bu projede asıl hedefim; kazıdığım nüfus verilerini tek bir Elasticsearch indeksinde toplayarak hem ülke hem de dünya verilerini birlikte yönetilebilir hale getirmekti.

Bu süreçte Elasticsearch’ün ölçeklenebilirliği, güçlü sorgu yapısı ve Kibana ile olan uyumu gerçekten fark yarattı. Proje boyunca elimdeki veriyi sadece saklamadım; anlamlı hale getirip görselleştirerek canlı bir sisteme dönüştürdüm. Umarım projemi ve projemi anlattığım bu yazıyı sevmişsinizdir :)

Benimle İletişim İçin Tıklayın
Fehu-Zone
YAZAR HAKKINDA

Fehu-Zone

Teknoloji ve stratejiyi bir araya getiren, projelerini bir adım öteye taşımak için sürekli yeni yöntemler keşfeden bir dijital uygulayıcı. Dijital süreçleri merakla takip ediyor ve faydalı çözümler üretmek için çalışıyor

Dijital gelecek
rastgele inşa edilmez.

Her adımı düşünülmüş, her detayı anlamlı
dijital yapılar için.