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:
Sase Dev
2026-03-02 00:31:15 +00:00
parent f2189a014b
commit 72c0de65de
90 changed files with 12043 additions and 419 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View 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;

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