- 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>
743 lines
25 KiB
JavaScript
743 lines
25 KiB
JavaScript
#!/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);
|
||
});
|