docs: update INDEX.md + add catalog module, Ford/PSA legacy catalog, shared vehicles
- CatalogModule: VIN-less PL24 catalog browser (brands, models, categories, parts) - Supports P5 Modern (REST) and P4 Legacy (Ford, PSA) catalog architectures - Ford variant selector (model-year/engine/gearbox), PSA variant selector (body/engine/gearbox) - New API endpoints: ford-config, psa-bodies, psa-engines, psa-gearboxes, brands/:name/catalogs - Shared vehicles: vehicles table decoupled from users via userVehicles junction table - PL24 Ford Legacy service: comprehensive HTML-scraping for Ford/PSA/Hyundai/Kia/Nissan/Opel/Volvo - PL24 types and service updated for P4 Legacy brand support - Categories/parts service updated for dual FK (vehicleId + catalogVehicleId) pattern - Catalog browser frontend routes and components - docs/INDEX.md: updated with all new endpoints, components, hooks, routes (2026-03-02) - docs/pl24-catalog/: per-brand catalog exploration docs - scripts/migration-shared-vehicles.sql, pl24-catalog-explorer.js, posthog-dashboards.sh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
122
scripts/migration-shared-vehicles.sql
Normal file
122
scripts/migration-shared-vehicles.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- Migration: Shared Vehicle Config (car-config-centric architecture)
|
||||
-- Converts per-user vehicle records to shared vehicle configs with junction table.
|
||||
-- IMPORTANT: Take a DB backup before running. Execute inside a transaction.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Create user_vehicles junction table
|
||||
CREATE TABLE user_vehicles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
vehicle_id UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_accessed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 2. For each VIN, pick the canonical record (most recently updated)
|
||||
-- and migrate all userId-vehicleId pairs into the junction table
|
||||
INSERT INTO user_vehicles (user_id, vehicle_id, created_at, last_accessed_at)
|
||||
SELECT
|
||||
v.user_id,
|
||||
canonical.id,
|
||||
v.created_at,
|
||||
v.updated_at
|
||||
FROM vehicles v
|
||||
JOIN (
|
||||
SELECT DISTINCT ON (vin) id, vin
|
||||
FROM vehicles
|
||||
ORDER BY vin, updated_at DESC NULLS LAST
|
||||
) canonical ON canonical.vin = v.vin;
|
||||
|
||||
-- 3. Delete categories on NON-canonical vehicles that already exist on the
|
||||
-- canonical vehicle (same name+source), so the UPDATE in step 4 won't
|
||||
-- hit the unique constraint.
|
||||
WITH canonical AS (
|
||||
SELECT DISTINCT ON (vin) id AS cid, vin
|
||||
FROM vehicles ORDER BY vin, updated_at DESC NULLS LAST
|
||||
)
|
||||
DELETE FROM categories
|
||||
WHERE id IN (
|
||||
SELECT c_dup.id
|
||||
FROM categories c_dup
|
||||
JOIN vehicles v ON c_dup.vehicle_id = v.id
|
||||
JOIN canonical can ON can.vin = v.vin
|
||||
WHERE v.id != can.cid
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM categories c_can
|
||||
WHERE c_can.vehicle_id = can.cid
|
||||
AND c_can.name = c_dup.name
|
||||
AND c_can.source = c_dup.source
|
||||
)
|
||||
);
|
||||
|
||||
-- 4. Move remaining categories from non-canonical vehicles to canonical
|
||||
WITH canonical AS (
|
||||
SELECT DISTINCT ON (vin) id AS cid, vin
|
||||
FROM vehicles ORDER BY vin, updated_at DESC NULLS LAST
|
||||
)
|
||||
UPDATE categories c
|
||||
SET vehicle_id = can.cid
|
||||
FROM vehicles v JOIN canonical can ON can.vin = v.vin
|
||||
WHERE c.vehicle_id = v.id AND v.id != can.cid;
|
||||
|
||||
-- 5. Delete parts on non-canonical vehicles that would conflict after move
|
||||
-- (parts don't have a unique constraint, but let's keep data clean by
|
||||
-- removing duplicates — same oemCode+categoryId on canonical vehicle)
|
||||
WITH canonical AS (
|
||||
SELECT DISTINCT ON (vin) id AS cid, vin
|
||||
FROM vehicles ORDER BY vin, updated_at DESC NULLS LAST
|
||||
)
|
||||
DELETE FROM parts
|
||||
WHERE id IN (
|
||||
SELECT p_dup.id
|
||||
FROM parts p_dup
|
||||
JOIN vehicles v ON p_dup.vehicle_id = v.id
|
||||
JOIN canonical can ON can.vin = v.vin
|
||||
WHERE v.id != can.cid
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM parts p_can
|
||||
WHERE p_can.vehicle_id = can.cid
|
||||
AND p_can.category_id = p_dup.category_id
|
||||
AND p_can.oem_code = p_dup.oem_code
|
||||
)
|
||||
);
|
||||
|
||||
-- 6. Move remaining parts from non-canonical vehicles to canonical
|
||||
WITH canonical AS (
|
||||
SELECT DISTINCT ON (vin) id AS cid, vin
|
||||
FROM vehicles ORDER BY vin, updated_at DESC NULLS LAST
|
||||
)
|
||||
UPDATE parts p
|
||||
SET vehicle_id = can.cid
|
||||
FROM vehicles v JOIN canonical can ON can.vin = v.vin
|
||||
WHERE p.vehicle_id = v.id AND v.id != can.cid;
|
||||
|
||||
-- 7. Delete non-canonical vehicle records (duplicates)
|
||||
DELETE FROM vehicles WHERE id NOT IN (
|
||||
SELECT DISTINCT ON (vin) id FROM vehicles
|
||||
ORDER BY vin, updated_at DESC NULLS LAST
|
||||
);
|
||||
|
||||
-- 8. Drop old user-specific indexes and column
|
||||
DROP INDEX IF EXISTS vehicles_user_vin_idx;
|
||||
DROP INDEX IF EXISTS vehicles_user_id_idx;
|
||||
ALTER TABLE vehicles DROP COLUMN user_id;
|
||||
|
||||
-- 9. Add unique index on VIN (one record per VIN globally)
|
||||
CREATE UNIQUE INDEX vehicles_vin_unique_idx ON vehicles (vin);
|
||||
|
||||
-- 10. Add FK and indexes on junction table
|
||||
ALTER TABLE user_vehicles
|
||||
ADD CONSTRAINT user_vehicles_vehicle_id_fkey
|
||||
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE;
|
||||
CREATE UNIQUE INDEX user_vehicles_user_vehicle_idx ON user_vehicles (user_id, vehicle_id);
|
||||
CREATE INDEX user_vehicles_user_id_idx ON user_vehicles (user_id);
|
||||
|
||||
-- 11. Drop unused vehicle_categories table
|
||||
DROP TABLE IF EXISTS vehicle_categories;
|
||||
|
||||
-- Also drop the old vin-only index if it exists (replaced by unique)
|
||||
DROP INDEX IF EXISTS vehicles_vin_idx;
|
||||
|
||||
COMMIT;
|
||||
742
scripts/pl24-catalog-explorer.js
Normal file
742
scripts/pl24-catalog-explorer.js
Normal file
@@ -0,0 +1,742 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PL24 Katalog Yapısı Keşif Script'i
|
||||
*
|
||||
* PL24 web arayüzüne (brandMenu.do) Playwright ile bağlanır,
|
||||
* sol sidebar'daki her markayı tıklar ve arka planda yapılan
|
||||
* XHR/fetch isteklerini intercept ederek API endpoint yapısını keşfeder.
|
||||
* Her marka için docs/pl24-catalog/{brandSlug}.md oluşturur.
|
||||
*
|
||||
* Kullanım:
|
||||
* node scripts/pl24-catalog-explorer.js # tüm markalar
|
||||
* node scripts/pl24-catalog-explorer.js --brand VW # tek marka
|
||||
* node scripts/pl24-catalog-explorer.js --headed # tarayıcı görünür
|
||||
* node scripts/pl24-catalog-explorer.js --force # mevcut dosyaları yenile
|
||||
* node scripts/pl24-catalog-explorer.js --delay 10000
|
||||
*/
|
||||
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// PL24 .env'den kimlik bilgilerini yükle (dotenv bağımlılığı olmadan)
|
||||
(function loadEnv() {
|
||||
const envPath = path.join(__dirname, "../apps/api/.env");
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx < 1) continue;
|
||||
const key = trimmed.substring(0, eqIdx).trim();
|
||||
const val = trimmed.substring(eqIdx + 1).trim().replace(/^["']|["']$/g, "");
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const CONFIG = {
|
||||
pl24BaseUrl: process.env.PL24_BASE_URL || "https://www.partslink24.com",
|
||||
companyCode: process.env.PL24_COMPANY_CODE || "",
|
||||
username: process.env.PL24_USERNAME || "",
|
||||
password: process.env.PL24_PASSWORD || "",
|
||||
brandMenuUrl: "/partslink24/user/brandMenu.do",
|
||||
headless: true,
|
||||
delayBetweenBrands: 8000, // ms — rate limit koruması
|
||||
delayBetweenClicks: 3000, // ms — marka içi tıklamalar arası
|
||||
navigationTimeout: 60000,
|
||||
actionTimeout: 30000,
|
||||
networkIdleTimeout: 5000, // API isteklerinin gelmesi için bekleme
|
||||
outputDir: path.join(__dirname, "../docs/pl24-catalog"),
|
||||
force: false,
|
||||
filterBrand: null,
|
||||
};
|
||||
|
||||
// ── CLI Args ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const opts = { ...CONFIG };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--headed") opts.headless = false;
|
||||
if (args[i] === "--headless") opts.headless = true;
|
||||
if (args[i] === "--force") opts.force = true;
|
||||
if (args[i] === "--brand" && args[i + 1]) opts.filterBrand = args[++i].toUpperCase();
|
||||
if (args[i] === "--delay" && args[i + 1]) opts.delayBetweenBrands = parseInt(args[++i], 10);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function ts() {
|
||||
return new Date().toISOString().replace("T", " ").substring(0, 19);
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[${ts()}] ${msg}`);
|
||||
}
|
||||
|
||||
function logSection(title) {
|
||||
console.log(`\n${"═".repeat(70)}\n ${title}\n${"═".repeat(70)}`);
|
||||
}
|
||||
|
||||
function slugify(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function isApiRequest(url) {
|
||||
// Sadece JSON API isteklerini filtrele
|
||||
const u = url.toLowerCase();
|
||||
return (
|
||||
u.includes("/extern/") ||
|
||||
u.includes("/p5vwag/") ||
|
||||
u.includes("/p5bmw/") ||
|
||||
u.includes("/p5daimler/") ||
|
||||
u.includes("/p5toyota/") ||
|
||||
u.includes("/p5jlr/") ||
|
||||
u.includes("/p5renault/") ||
|
||||
u.includes("/p5stellantis/") ||
|
||||
u.includes("/p5hyundai/") ||
|
||||
u.includes("/p5nissan/") ||
|
||||
u.includes("/p5volvo/") ||
|
||||
u.includes("/p5opel/") ||
|
||||
u.includes("/ford/") ||
|
||||
u.includes("/pl24-appgtw/") ||
|
||||
u.includes("/pl24-manufacturer/") ||
|
||||
u.includes("/auth/ext/") ||
|
||||
(u.includes("partslink24.com") && (u.includes(".json") || u.includes("/api/")))
|
||||
);
|
||||
}
|
||||
|
||||
// ── Keşif Notları Yardımcısı ─────────────────────────────────────────────────
|
||||
|
||||
class BrandExplorer {
|
||||
constructor(brandName) {
|
||||
this.brandName = brandName;
|
||||
this.requests = [];
|
||||
this.notes = [];
|
||||
this.startedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
addRequest({ method, url, status, responseBody, phase }) {
|
||||
this.requests.push({ method, url, status, responseBody, phase });
|
||||
}
|
||||
|
||||
addNote(note) {
|
||||
this.notes.push(note);
|
||||
log(` NOTE: ${note}`);
|
||||
}
|
||||
|
||||
generateMd() {
|
||||
// Unique URL'leri grupla
|
||||
const byPath = {};
|
||||
for (const r of this.requests) {
|
||||
try {
|
||||
const u = new URL(r.url);
|
||||
const pathKey = u.pathname;
|
||||
if (!byPath[pathKey]) byPath[pathKey] = { ...r, count: 0, params: [] };
|
||||
byPath[pathKey].count++;
|
||||
if (u.search) byPath[pathKey].params.push(u.search);
|
||||
} catch {
|
||||
// geçersiz URL
|
||||
}
|
||||
}
|
||||
|
||||
const endpointList = Object.entries(byPath)
|
||||
.map(([p, r]) => {
|
||||
const exampleUrl = r.url.length > 120 ? r.url.substring(0, 120) + "..." : r.url;
|
||||
const bodyPreview = r.responseBody
|
||||
? r.responseBody.substring(0, 300).replace(/\n/g, " ")
|
||||
: "(yanıt yok)";
|
||||
return `### ${p}\n- **Method:** ${r.method}\n- **Status:** ${r.status || "?"}\n- **Faz:** ${r.phase}\n- **Örnek URL:**\n \`${exampleUrl}\`\n- **Yanıt özeti:**\n \`${bodyPreview}\`\n- **Çağrı sayısı:** ${r.count}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const requestTable = this.requests
|
||||
.map((r) => {
|
||||
const shortUrl = r.url.length > 80 ? r.url.substring(0, 80) + "..." : r.url;
|
||||
const body = r.responseBody
|
||||
? r.responseBody.substring(0, 100).replace(/\|/g, "\\|").replace(/\n/g, " ")
|
||||
: "";
|
||||
return `| ${r.method} | \`${shortUrl}\` | ${r.status || "?"} | ${r.phase} | \`${body}\` |`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const notesList = this.notes.map((n) => `- ${n}`).join("\n") || "- Özel not yok";
|
||||
|
||||
return `# ${this.brandName} — PL24 Katalog Yapısı
|
||||
|
||||
## Keşif Tarihi
|
||||
${this.startedAt}
|
||||
|
||||
## Tespit Edilen Endpoint'ler (Unique Path'ler)
|
||||
|
||||
${endpointList || "_Hiç API isteği yakalanmadı._"}
|
||||
|
||||
## Ham Yakalanan İstekler
|
||||
|
||||
| Method | URL | Status | Faz | Response (ilk 100 char) |
|
||||
|--------|-----|--------|-----|------------------------|
|
||||
${requestTable || "| — | — | — | — | — |"}
|
||||
|
||||
## Notlar
|
||||
|
||||
${notesList}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Login ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* PL24 REST API üzerinden login olup session cookie'yi Playwright context'e enjekte eder.
|
||||
* Web formu yerine doğrudan /pl24-appgtw/ext/api/1.0/login endpoint'i kullanılır.
|
||||
*/
|
||||
async function loginToPL24(page, opts) {
|
||||
log("PL24 REST API login başlıyor...");
|
||||
|
||||
// 1. REST API ile login — web form bypass
|
||||
const loginBody = {
|
||||
authentication: {
|
||||
account: opts.companyCode,
|
||||
user: opts.username,
|
||||
pwd: opts.password,
|
||||
},
|
||||
device: {
|
||||
id: "0",
|
||||
os: "Windows 10",
|
||||
offset: "0",
|
||||
lang: "en-US",
|
||||
"os-version": "0",
|
||||
},
|
||||
"app-version": "",
|
||||
squeezeOut: true,
|
||||
};
|
||||
|
||||
const loginResp = await fetch(
|
||||
`${opts.pl24BaseUrl}/pl24-appgtw/ext/api/1.0/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
body: JSON.stringify(loginBody),
|
||||
signal: AbortSignal.timeout(opts.actionTimeout),
|
||||
},
|
||||
);
|
||||
|
||||
if (!loginResp.ok) {
|
||||
throw new Error(`REST login HTTP ${loginResp.status}`);
|
||||
}
|
||||
|
||||
const loginData = await loginResp.json();
|
||||
if (!loginData.token?.access_token) {
|
||||
throw new Error(`REST login başarısız: ${loginData.status} — ${loginData.message || "token yok"}`);
|
||||
}
|
||||
|
||||
const accessToken = loginData.token.access_token;
|
||||
log(`REST login başarılı. Token: ${accessToken.substring(0, 30)}...`);
|
||||
|
||||
// Session cookie
|
||||
const setCookie = loginResp.headers.get("set-cookie") || "";
|
||||
const pl24TokenMatch = setCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
const jsessionMatch = setCookie.match(/JSESSIONID=([^;]+)/);
|
||||
const pl24Token = pl24TokenMatch ? `PL24TOKEN=${pl24TokenMatch[1]}` : "";
|
||||
const jsession = jsessionMatch ? `JSESSIONID=${jsessionMatch[1]}` : "";
|
||||
log(`Cookie: ${pl24Token || jsession || "(yok)"}`);
|
||||
|
||||
// 2. Cookie'leri Playwright context'e enjekte et
|
||||
const context = page.context();
|
||||
const cookiesToSet = [];
|
||||
|
||||
if (pl24TokenMatch) {
|
||||
cookiesToSet.push({ name: "PL24TOKEN", value: pl24TokenMatch[1], domain: "www.partslink24.com", path: "/" });
|
||||
}
|
||||
if (jsessionMatch) {
|
||||
cookiesToSet.push({ name: "JSESSIONID", value: jsessionMatch[1], domain: "www.partslink24.com", path: "/" });
|
||||
}
|
||||
|
||||
if (cookiesToSet.length > 0) {
|
||||
await context.addCookies(cookiesToSet);
|
||||
log(`${cookiesToSet.length} cookie enjekte edildi`);
|
||||
}
|
||||
|
||||
// 3. Önce ana sayfayı ziyaret et (session kurmak için)
|
||||
log("Ana sayfa ziyaret ediliyor...");
|
||||
await page.goto(`${opts.pl24BaseUrl}/`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: opts.navigationTimeout,
|
||||
});
|
||||
|
||||
// 4. Formu JWT token ile doldurarak login yap
|
||||
const currentUrl = page.url();
|
||||
log(`URL: ${currentUrl}`);
|
||||
|
||||
if (currentUrl.includes("brandMenu")) {
|
||||
log("Giriş yapılmış (cookie çalıştı)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUrl.includes("login")) {
|
||||
// Form görünüyor — doldur ve submit et
|
||||
log("Login formu dolduruluyor...");
|
||||
await page.fill("#login-id", opts.companyCode);
|
||||
await page.fill("#login-name", opts.username);
|
||||
await page.fill("#inputPassword", opts.password);
|
||||
|
||||
// JavaScript ile doLoginAjax çağır
|
||||
log("doLoginAjax çağrılıyor...");
|
||||
await page.evaluate(() => {
|
||||
if (typeof doLoginAjax === "function") {
|
||||
doLoginAjax(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Redirect bekle
|
||||
try {
|
||||
await page.waitForURL(/brandMenu|portal|welcome/, { timeout: 30000 });
|
||||
log("Login başarılı — redirect oldu");
|
||||
} catch {
|
||||
await sleep(3000);
|
||||
const url2 = page.url();
|
||||
log(`Login sonrası URL: ${url2}`);
|
||||
if (url2.includes("login")) {
|
||||
// HTML hata mesajını kaydet
|
||||
const html = await page.content();
|
||||
require("fs").writeFileSync("/tmp/pl24-login-error.html", html);
|
||||
throw new Error(`Login başarısız: ${url2} (HTML → /tmp/pl24-login-error.html)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. brandMenu.do'ya git
|
||||
log("brandMenu.do'ya gidiliyor...");
|
||||
await page.goto(`${opts.pl24BaseUrl}/partslink24/user/brandMenu.do`, {
|
||||
waitUntil: "networkidle",
|
||||
timeout: opts.navigationTimeout,
|
||||
});
|
||||
|
||||
const finalUrl = page.url();
|
||||
log(`brandMenu URL: ${finalUrl}`);
|
||||
|
||||
if (finalUrl.includes("login")) {
|
||||
throw new Error(`brandMenu'ya erişilemedi, login gerekiyor: ${finalUrl}`);
|
||||
}
|
||||
|
||||
log("PL24'e başarıyla giriş yapıldı!");
|
||||
}
|
||||
|
||||
// ── Marka Listesi ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function getBrandList(page) {
|
||||
log("Marka listesi okunuyor...");
|
||||
|
||||
// brandMenu.do'da markalar: <a class="brand-logo" href="/partslink24/launchCatalog.do?service=vw_parts&t=..." title="Volkswagen">
|
||||
try {
|
||||
const count = await page.locator("a.brand-logo").count();
|
||||
if (count > 0) {
|
||||
log(` a.brand-logo selector: ${count} marka bulundu`);
|
||||
const brands = await page.$$eval("a.brand-logo", (els) =>
|
||||
els.map((el) => {
|
||||
const href = el.getAttribute("href") || "";
|
||||
// service adını URL'den çıkar: ?service=vw_parts&...
|
||||
const serviceMatch = href.match(/[?&]service=([^&]+)/);
|
||||
return {
|
||||
name: el.getAttribute("title") || el.textContent?.trim() || "",
|
||||
href,
|
||||
service: serviceMatch ? serviceMatch[1] : "",
|
||||
onclick: el.getAttribute("onclick") || "",
|
||||
};
|
||||
}).filter((b) => b.name.length > 0)
|
||||
);
|
||||
// Servis adına göre deduplicate (brandMenu.do her markayı 2 kez gösteriyor)
|
||||
const seen = new Set();
|
||||
const unique = brands.filter((b) => {
|
||||
if (!b.service || seen.has(b.service)) return false;
|
||||
seen.add(b.service);
|
||||
return true;
|
||||
});
|
||||
log(` ${unique.length} marka listelendi (${brands.length - unique.length} duplicate atlandı):`);
|
||||
for (const b of unique) {
|
||||
log(` ${b.name} (${b.service}) → ${b.href.substring(0, 60)}`);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
} catch (err) {
|
||||
log(` a.brand-logo selector hatası: ${err.message}`);
|
||||
}
|
||||
|
||||
// Fallback: launchCatalog.do içeren linkleri ara
|
||||
log(" Fallback: launchCatalog.do linkleri aranıyor...");
|
||||
try {
|
||||
const brands = await page.$$eval("a[href*='launchCatalog']", (els) =>
|
||||
els.map((el) => {
|
||||
const href = el.getAttribute("href") || "";
|
||||
const serviceMatch = href.match(/[?&]service=([^&]+)/);
|
||||
const title = el.getAttribute("title") || el.textContent?.trim() || "";
|
||||
return {
|
||||
name: title,
|
||||
href,
|
||||
service: serviceMatch ? serviceMatch[1] : "",
|
||||
onclick: el.getAttribute("onclick") || "",
|
||||
};
|
||||
}).filter((b) => b.name.length > 0)
|
||||
);
|
||||
if (brands.length > 0) {
|
||||
log(` ${brands.length} marka (launchCatalog fallback):`);
|
||||
for (const b of brands) {
|
||||
log(` ${b.name} (${b.service})`);
|
||||
}
|
||||
return brands;
|
||||
}
|
||||
} catch { /* devam */ }
|
||||
|
||||
// Son çare: sayfa kaynağını kaydet ve hata ver
|
||||
log(" UYARI: Marka listesi bulunamadı, sayfa HTML kaydediliyor...");
|
||||
const bodyHtml = await page.evaluate(() => document.body?.innerHTML?.substring(0, 5000) || "");
|
||||
log(" Sayfa HTML (ilk 5000 char):");
|
||||
console.log(bodyHtml);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Tek Marka Keşfi ───────────────────────────────────────────────────────────
|
||||
|
||||
async function exploreBrand(page, brand, opts) {
|
||||
const explorer = new BrandExplorer(brand.name);
|
||||
log(`\nKeşif başlıyor: ${brand.name}`);
|
||||
|
||||
// Network intercept'i başlat
|
||||
const requestLog = [];
|
||||
|
||||
const onRequest = (req) => {
|
||||
const url = req.url();
|
||||
if (isApiRequest(url)) {
|
||||
requestLog.push({ method: req.method(), url, phase: "request", status: null, responseBody: null });
|
||||
}
|
||||
};
|
||||
|
||||
const onResponse = async (res) => {
|
||||
const url = res.url();
|
||||
if (!isApiRequest(url)) return;
|
||||
const status = res.status();
|
||||
let responseBody = null;
|
||||
try {
|
||||
const ct = res.headers()["content-type"] || "";
|
||||
if (ct.includes("json") || ct.includes("text")) {
|
||||
const text = await res.text();
|
||||
responseBody = text.substring(0, 1000);
|
||||
}
|
||||
} catch { /* body alınamadı */ }
|
||||
|
||||
// Mevcut request'i güncelle veya yeni ekle
|
||||
const existing = requestLog.find((r) => r.url === url && r.status === null);
|
||||
if (existing) {
|
||||
existing.status = status;
|
||||
existing.responseBody = responseBody;
|
||||
} else {
|
||||
requestLog.push({ method: res.request().method(), url, phase: "response", status, responseBody });
|
||||
}
|
||||
};
|
||||
|
||||
page.on("request", onRequest);
|
||||
page.on("response", onResponse);
|
||||
|
||||
try {
|
||||
// ── Faz 1: Markayı tıkla ──────────────────────────────────────────────
|
||||
log(` Faz 1: Marka tıklanıyor — ${brand.name}`);
|
||||
|
||||
if (brand.href && brand.href !== "#" && brand.href !== "") {
|
||||
const fullUrl = brand.href.startsWith("http")
|
||||
? brand.href
|
||||
: `${opts.pl24BaseUrl}${brand.href}`;
|
||||
await page.goto(fullUrl, { waitUntil: "domcontentloaded", timeout: opts.navigationTimeout });
|
||||
} else if (brand.onclick) {
|
||||
await page.evaluate((onclick) => eval(onclick), brand.onclick);
|
||||
} else {
|
||||
// Metin ile bul ve tıkla
|
||||
const el = page.locator(`a:has-text("${brand.name}")`).first();
|
||||
await el.click();
|
||||
}
|
||||
|
||||
// API isteklerinin gelmesi için bekle
|
||||
await sleep(opts.networkIdleTimeout);
|
||||
|
||||
// Sayfanın yapısını incele
|
||||
const pageTitle = await page.title().catch(() => "");
|
||||
log(` Sayfa başlığı: ${pageTitle}`);
|
||||
explorer.addNote(`Sayfa başlığı: ${pageTitle}`);
|
||||
explorer.addNote(`URL: ${page.url()}`);
|
||||
|
||||
// Faz 1 isteklerini kaydet
|
||||
const phase1Requests = [...requestLog];
|
||||
for (const r of phase1Requests) {
|
||||
explorer.addRequest({ ...r, phase: "marka-ana-sayfa" });
|
||||
}
|
||||
requestLog.length = 0;
|
||||
|
||||
log(` Faz 1: ${phase1Requests.length} API isteği yakalandı`);
|
||||
|
||||
// ── Faz 2: İlk model/araç'ı tıkla ──────────────────────────────────────
|
||||
log(" Faz 2: İlk model aranıyor...");
|
||||
|
||||
const modelSelectors = [
|
||||
".model-list li:first-child a",
|
||||
".vehicleList li:first-child a",
|
||||
".modelSeries li:first-child a",
|
||||
"table.models tr:nth-child(2) td:first-child a",
|
||||
".content a:first-child",
|
||||
"ul li a:first-child",
|
||||
];
|
||||
|
||||
let modelClicked = false;
|
||||
for (const sel of modelSelectors) {
|
||||
try {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 2000 })) {
|
||||
const modelName = await el.textContent();
|
||||
log(` İlk model tıklanıyor: "${modelName?.trim()}" (${sel})`);
|
||||
await el.click();
|
||||
await sleep(opts.delayBetweenClicks);
|
||||
modelClicked = true;
|
||||
explorer.addNote(`İlk model tıklandı: "${modelName?.trim()}"`);
|
||||
break;
|
||||
}
|
||||
} catch { /* devam */ }
|
||||
}
|
||||
|
||||
if (!modelClicked) {
|
||||
explorer.addNote("İlk model tıklanamadı — selector bulunamadı");
|
||||
log(" Faz 2: Model selector bulunamadı, atlanıyor");
|
||||
}
|
||||
|
||||
// Faz 2 isteklerini kaydet
|
||||
const phase2Requests = [...requestLog];
|
||||
for (const r of phase2Requests) {
|
||||
explorer.addRequest({ ...r, phase: "model-secim" });
|
||||
}
|
||||
requestLog.length = 0;
|
||||
|
||||
log(` Faz 2: ${phase2Requests.length} ek API isteği yakalandı`);
|
||||
|
||||
// ── Faz 3: Kategoriler ───────────────────────────────────────────────────
|
||||
log(" Faz 3: Kategori yapısı inceleniyor...");
|
||||
|
||||
const categorySelectors = [
|
||||
".mainGroup li:first-child a",
|
||||
".categoryList li:first-child a",
|
||||
".groups li:first-child a",
|
||||
"ul.mainGroups li:first-child a",
|
||||
];
|
||||
|
||||
for (const sel of categorySelectors) {
|
||||
try {
|
||||
const el = page.locator(sel).first();
|
||||
if (await el.isVisible({ timeout: 2000 })) {
|
||||
const catName = await el.textContent();
|
||||
log(` İlk kategori tıklanıyor: "${catName?.trim()}" (${sel})`);
|
||||
await el.click();
|
||||
await sleep(opts.delayBetweenClicks);
|
||||
explorer.addNote(`İlk kategori tıklandı: "${catName?.trim()}"`);
|
||||
break;
|
||||
}
|
||||
} catch { /* devam */ }
|
||||
}
|
||||
|
||||
const phase3Requests = [...requestLog];
|
||||
for (const r of phase3Requests) {
|
||||
explorer.addRequest({ ...r, phase: "kategori" });
|
||||
}
|
||||
requestLog.length = 0;
|
||||
|
||||
log(` Faz 3: ${phase3Requests.length} ek API isteği yakalandı`);
|
||||
|
||||
} catch (err) {
|
||||
explorer.addNote(`HATA: ${err.message}`);
|
||||
log(` HATA: ${err.message}`);
|
||||
} finally {
|
||||
page.off("request", onRequest);
|
||||
page.off("response", onResponse);
|
||||
}
|
||||
|
||||
return explorer;
|
||||
}
|
||||
|
||||
// ── Ana Akış ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (!opts.companyCode || !opts.username || !opts.password) {
|
||||
console.error("HATA: PL24 kimlik bilgileri eksik. apps/api/.env dosyasını kontrol edin.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Çıktı dizinini oluştur
|
||||
if (!fs.existsSync(opts.outputDir)) {
|
||||
fs.mkdirSync(opts.outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
logSection("PL24 KATALOG KEŞİF ARACI");
|
||||
log(`PL24 URL: ${opts.pl24BaseUrl}`);
|
||||
log(`Headless: ${opts.headless}, Delay: ${opts.delayBetweenBrands}ms, Force: ${opts.force}`);
|
||||
if (opts.filterBrand) log(`Marka filtresi: ${opts.filterBrand}`);
|
||||
|
||||
// Tarayıcı başlat
|
||||
const browser = await chromium.launch({
|
||||
headless: opts.headless,
|
||||
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
||||
});
|
||||
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1440, height: 900 },
|
||||
ignoreHTTPSErrors: true,
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
context.setDefaultTimeout(opts.actionTimeout);
|
||||
context.setDefaultNavigationTimeout(opts.navigationTimeout);
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
// Login
|
||||
logSection("LOGIN");
|
||||
await loginToPL24(page, opts);
|
||||
|
||||
// Screenshot: brandMenu
|
||||
await page.screenshot({
|
||||
path: path.join(opts.outputDir, "_brandmenu-screenshot.png"),
|
||||
});
|
||||
log("brandMenu.do ekran görüntüsü kaydedildi");
|
||||
|
||||
// Marka listesi
|
||||
logSection("MARKA LİSTESİ");
|
||||
let brands = await getBrandList(page);
|
||||
|
||||
if (brands.length === 0) {
|
||||
log("UYARI: Marka listesi boş — sayfa yapısı analiz ediliyor...");
|
||||
// Sayfa kaynağını kaydet
|
||||
const html = await page.content();
|
||||
fs.writeFileSync(path.join(opts.outputDir, "_brandmenu-source.html"), html);
|
||||
log("Sayfa kaynağı _brandmenu-source.html olarak kaydedildi");
|
||||
}
|
||||
|
||||
log(`Toplam ${brands.length} marka bulundu`);
|
||||
|
||||
// Filtrele (hem marka adına hem service adına bak)
|
||||
if (opts.filterBrand) {
|
||||
brands = brands.filter((b) =>
|
||||
b.name.toUpperCase().includes(opts.filterBrand) ||
|
||||
(b.service || "").toUpperCase().includes(opts.filterBrand)
|
||||
);
|
||||
log(`Filtre sonrası: ${brands.length} marka`);
|
||||
}
|
||||
|
||||
if (brands.length === 0) {
|
||||
log("İşlenecek marka yok. Çıkılıyor.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Özet için marka bilgilerini tut
|
||||
const summaryData = [];
|
||||
|
||||
// ── Her Marka ─────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < brands.length; i++) {
|
||||
const brand = brands[i];
|
||||
const slug = slugify(brand.name);
|
||||
const outFile = path.join(opts.outputDir, `${slug}.md`);
|
||||
|
||||
logSection(`[${i + 1}/${brands.length}] ${brand.name}`);
|
||||
|
||||
// Resume: zaten keşfedildiyse atla
|
||||
if (!opts.force && fs.existsSync(outFile)) {
|
||||
log(`Atlanıyor (zaten mevcut): ${outFile}`);
|
||||
summaryData.push({ name: brand.name, slug, status: "atlandı (mevcut)" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// brandMenu.do'ya dön (her marka keşfinden önce)
|
||||
if (i > 0) {
|
||||
try {
|
||||
await page.goto(`${opts.pl24BaseUrl}${opts.brandMenuUrl}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: opts.navigationTimeout,
|
||||
});
|
||||
await sleep(2000);
|
||||
} catch (err) {
|
||||
log(`brandMenu.do'ya dönülemedi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Keşfet
|
||||
const explorer = await exploreBrand(page, brand, opts);
|
||||
const md = explorer.generateMd();
|
||||
|
||||
// Kaydet
|
||||
fs.writeFileSync(outFile, md, "utf-8");
|
||||
log(`Kaydedildi: ${outFile}`);
|
||||
|
||||
summaryData.push({
|
||||
name: brand.name,
|
||||
slug,
|
||||
status: "tamamlandı",
|
||||
requestCount: explorer.requests.length,
|
||||
});
|
||||
|
||||
// Rate limit
|
||||
if (i < brands.length - 1) {
|
||||
log(`Bekleniyor: ${opts.delayBetweenBrands}ms...`);
|
||||
await sleep(opts.delayBetweenBrands);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Özet Dosyası ──────────────────────────────────────────────────────
|
||||
logSection("ÖZET");
|
||||
const summaryLines = summaryData.map((s) =>
|
||||
`| ${s.name} | ${s.slug} | ${s.status} | ${s.requestCount ?? "-"} |`
|
||||
).join("\n");
|
||||
|
||||
const summaryMd = `# PL24 Katalog Keşif Özeti
|
||||
|
||||
## Keşif Tarihi
|
||||
${new Date().toISOString()}
|
||||
|
||||
## Sonuçlar
|
||||
|
||||
| Marka | Slug | Durum | API İstek Sayısı |
|
||||
|-------|------|-------|-----------------|
|
||||
${summaryLines}
|
||||
|
||||
## Notlar
|
||||
- Bu dosya otomatik olarak oluşturulmuştur
|
||||
- Her marka için ayrıntı: \`{slug}.md\`
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(opts.outputDir, "_summary.md"), summaryMd, "utf-8");
|
||||
log(`Özet kaydedildi: ${path.join(opts.outputDir, "_summary.md")}`);
|
||||
|
||||
log(`\nKeşif tamamlandı. ${summaryData.length} marka işlendi.`);
|
||||
log(`Çıktı dizini: ${opts.outputDir}`);
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`FATAL: ${err.message}`);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
280
scripts/posthog-dashboards.sh
Executable file
280
scripts/posthog-dashboards.sh
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
API_KEY="${POSTHOG_API_KEY:?Set POSTHOG_API_KEY env var}"
|
||||
BASE="https://eu.posthog.com/api/projects/127747"
|
||||
HEADERS=(-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json")
|
||||
|
||||
create_insight() {
|
||||
local payload="$1"
|
||||
local response
|
||||
response=$(curl -s -X POST "$BASE/insights/" "${HEADERS[@]}" -d "$payload")
|
||||
local id
|
||||
id=$(echo "$response" | jq -r '.id')
|
||||
if [[ "$id" == "null" || -z "$id" ]]; then
|
||||
echo "ERROR creating insight: $response" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
add_to_dashboard() {
|
||||
local insight_id="$1"
|
||||
local dashboard_id="$2"
|
||||
curl -s -X PATCH "$BASE/insights/$insight_id/" "${HEADERS[@]}" \
|
||||
-d "{\"dashboards\":[$dashboard_id]}" | jq -r '.id' > /dev/null
|
||||
}
|
||||
|
||||
create_dashboard() {
|
||||
local name="$1"
|
||||
local response
|
||||
response=$(curl -s -X POST "$BASE/dashboards/" "${HEADERS[@]}" \
|
||||
-d "{\"name\":\"$name\"}")
|
||||
local id
|
||||
id=$(echo "$response" | jq -r '.id')
|
||||
if [[ "$id" == "null" || -z "$id" ]]; then
|
||||
echo "ERROR creating dashboard: $response" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$id"
|
||||
}
|
||||
|
||||
echo "=== PostHog Dashboard Oluşturucu ==="
|
||||
echo ""
|
||||
|
||||
# ─── Insight'lar ─────────────────────────────────────────────────────
|
||||
|
||||
echo "--- Insight'lar oluşturuluyor ---"
|
||||
|
||||
I1=$(create_insight '{
|
||||
"name": "Günlük Aktif Kullanıcılar",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [{"kind":"EventsNode","event":"$pageview","math":"dau","name":"DAU"}],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [1/11] Günlük Aktif Kullanıcılar → $I1"
|
||||
|
||||
I2=$(create_insight '{
|
||||
"name": "Kayıt Trendi",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [{"kind":"EventsNode","event":"user_signed_up","math":"total","name":"Kayıt"}],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [2/11] Kayıt Trendi → $I2"
|
||||
|
||||
I3=$(create_insight '{
|
||||
"name": "Giriş / Çıkış",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"user_logged_in","math":"total","name":"Giriş"},
|
||||
{"kind":"EventsNode","event":"user_logged_out","math":"total","name":"Çıkış"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [3/11] Giriş / Çıkış → $I3"
|
||||
|
||||
I4=$(create_insight '{
|
||||
"name": "VIN Arama Hacmi",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"vin_decoded","math":"total","name":"Arama"},
|
||||
{"kind":"EventsNode","event":"vin_decode_success","math":"total","name":"Başarılı"},
|
||||
{"kind":"EventsNode","event":"vin_decode_error","math":"total","name":"Hata"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [4/11] VIN Arama Hacmi → $I4"
|
||||
|
||||
I5=$(create_insight '{
|
||||
"name": "Onboarding Funnel",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "FunnelsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"user_signed_up","name":"Kayıt"},
|
||||
{"kind":"EventsNode","event":"trial_started","name":"Trial"},
|
||||
{"kind":"EventsNode","event":"vin_decoded","name":"VIN Arama"},
|
||||
{"kind":"EventsNode","event":"vin_decode_success","name":"Başarılı Sonuç"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"funnelsFilter": {"funnelWindowInterval": 14, "funnelWindowIntervalUnit": "day"}
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [5/11] Onboarding Funnel → $I5"
|
||||
|
||||
I6=$(create_insight '{
|
||||
"name": "Monetization Funnel",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "FunnelsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"vin_decoded","name":"VIN Arama"},
|
||||
{"kind":"EventsNode","event":"plan_selected","name":"Plan Seçimi"},
|
||||
{"kind":"EventsNode","event":"checkout_started","name":"Checkout"},
|
||||
{"kind":"EventsNode","event":"payment_initiated","name":"Ödeme"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"funnelsFilter": {"funnelWindowInterval": 14, "funnelWindowIntervalUnit": "day"}
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [6/11] Monetization Funnel → $I6"
|
||||
|
||||
I7=$(create_insight '{
|
||||
"name": "Core Value Funnel",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "FunnelsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"user_logged_in","name":"Giriş"},
|
||||
{"kind":"EventsNode","event":"vin_decoded","name":"VIN Arama"},
|
||||
{"kind":"EventsNode","event":"vin_decode_success","name":"Başarılı Sonuç"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"funnelsFilter": {"funnelWindowInterval": 7, "funnelWindowIntervalUnit": "day"}
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [7/11] Core Value Funnel → $I7"
|
||||
|
||||
I8=$(create_insight '{
|
||||
"name": "Ödeme Trendi",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"payment_initiated","math":"total","name":"Ödeme Başlatıldı"},
|
||||
{"kind":"EventsNode","event":"receipt_uploaded","math":"total","name":"Dekont Yüklendi"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [8/11] Ödeme Trendi → $I8"
|
||||
|
||||
I9=$(create_insight '{
|
||||
"name": "Trial Başlatma Trendi",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [{"kind":"EventsNode","event":"trial_started","math":"total","name":"Trial"}],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [9/11] Trial Başlatma Trendi → $I9"
|
||||
|
||||
I10=$(create_insight '{
|
||||
"name": "İptal / Devam",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"subscription_cancelled","math":"total","name":"İptal"},
|
||||
{"kind":"EventsNode","event":"subscription_resumed","math":"total","name":"Devam"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"interval": "day"
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [10/11] İptal / Devam → $I10"
|
||||
|
||||
I11=$(create_insight '{
|
||||
"name": "Ödeme Funnel",
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "FunnelsQuery",
|
||||
"series": [
|
||||
{"kind":"EventsNode","event":"plan_selected","name":"Plan Seçimi"},
|
||||
{"kind":"EventsNode","event":"checkout_started","name":"Checkout"},
|
||||
{"kind":"EventsNode","event":"payment_initiated","name":"Ödeme"},
|
||||
{"kind":"EventsNode","event":"receipt_uploaded","name":"Dekont"}
|
||||
],
|
||||
"dateRange": {"date_from": "-30d"},
|
||||
"funnelsFilter": {"funnelWindowInterval": 7, "funnelWindowIntervalUnit": "day"}
|
||||
}
|
||||
}
|
||||
}')
|
||||
echo " [11/11] Ödeme Funnel → $I11"
|
||||
|
||||
# ─── Dashboard'lar ───────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "--- Dashboard'lar oluşturuluyor ---"
|
||||
|
||||
D1=$(create_dashboard "SASE — Genel Bakış")
|
||||
echo " Dashboard 1: SASE — Genel Bakış → $D1"
|
||||
|
||||
D2=$(create_dashboard "SASE — Funnel'lar")
|
||||
echo " Dashboard 2: SASE — Funnel'lar → $D2"
|
||||
|
||||
D3=$(create_dashboard "SASE — Abonelik")
|
||||
echo " Dashboard 3: SASE — Abonelik → $D3"
|
||||
|
||||
# ─── Insight → Dashboard bağlantıları ────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "--- Insight'lar dashboard'lara bağlanıyor ---"
|
||||
|
||||
# Dashboard 1: Genel Bakış
|
||||
for id in "$I1" "$I2" "$I3" "$I4"; do
|
||||
add_to_dashboard "$id" "$D1"
|
||||
done
|
||||
echo " Genel Bakış: 4 insight bağlandı"
|
||||
|
||||
# Dashboard 2: Funnel'lar
|
||||
for id in "$I5" "$I6" "$I7" "$I8"; do
|
||||
add_to_dashboard "$id" "$D2"
|
||||
done
|
||||
echo " Funnel'lar: 4 insight bağlandı"
|
||||
|
||||
# Dashboard 3: Abonelik
|
||||
for id in "$I9" "$I10" "$I11"; do
|
||||
add_to_dashboard "$id" "$D3"
|
||||
done
|
||||
echo " Abonelik: 3 insight bağlandı"
|
||||
|
||||
echo ""
|
||||
echo "=== Tamamlandı ==="
|
||||
echo ""
|
||||
echo "Dashboard URL'leri:"
|
||||
echo " https://eu.posthog.com/project/127747/dashboard/$D1 (Genel Bakış)"
|
||||
echo " https://eu.posthog.com/project/127747/dashboard/$D2 (Funnel'lar)"
|
||||
echo " https://eu.posthog.com/project/127747/dashboard/$D3 (Abonelik)"
|
||||
echo ""
|
||||
echo "Toplam: 11 insight + 3 dashboard oluşturuldu."
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 584 KiB |
Reference in New Issue
Block a user