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ış

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.
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.
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.
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:
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.
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.
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.
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. 👇

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


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
ssh -i “~/my-key.pem” ec2-user@EC2_PUBLIC_IPSistem Güncellemesi
sudo yum update -yChrome/Chromium Bağımlılıkları
Puppeteer kullandığımız için gerekli kütüphaneleri ekledim:
sudo amazon-linux-extras install epel -ysudo yum install -y \ wget \ bind-utils \ libX11 \ alsa-lib \ gtk3 \ ipa-gothic-fontsNode.js ve Git Kurulumu
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bashsource ~/.bashrcnvm install --ltsgit --version || sudo yum install git -y3. Proje Kurulumu
Repo Klonlama
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
ELASTICSEARCH_HOST=https://your-es-endpoint:9200INDEX_NAME=world_population_dataELASTIC_USERNAME=elasticELASTIC_PASSWORD=changemeUSER_AGENT=Mozilla/...Bağımlılıkları Yükleme
npm install4. Kesintisiz Çalıştırma ve İzleme
PM2 ile Süreklilik
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:
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 :)

