#!/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: