#!/usr/bin/env node /** * guide-shots.mjs — تصویربردار راهنمای کاربری. * * برای هر نقش لاگین می‌کند، فهرست صفحاتش را می‌پیماید و از هرکدام یک PNG می‌گیرد. * خروجی: //.png به‌همراه shots.json که ترتیب و عنوان‌ها را * برای متن راهنما نگه می‌دارد. * * node guide-shots.mjs --role clinic-owner --out /tmp/guide * node guide-shots.mjs --all --out /tmp/guide * * وابستگی ندارد: Node 22 با WebSocket سراسری مستقیم با CDP حرف می‌زند. */ import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site'; const CHROME = process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const PASSWORD = process.env.GUIDE_PASSWORD ?? 'QaTest@1234'; const PORT = Number(process.env.CDP_PORT ?? 9500); process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; /** * پنج شخصیتِ راهنما. هر صفحه یک گام از داستانِ همان نقش است، پس ترتیب مهم است و * الفبایی نیست: کاربر تازه‌وارد از داشبورد شروع می‌کند، نه از تنظیمات. */ const ROLES = { 'independent-doctor': { title: 'پزشک مستقل', mobile: '0912000101', pages: [ ['dashboard', 'داشبورد'], ['appointments', 'نوبت‌های امروز'], ['appointments/new', 'ثبت نوبت جدید'], ['patients', 'پرونده‌ها'], ['my-patients', 'بیماران من'], ['clinic-services', 'سرویس‌ها'], ['service-categories', 'دسته‌بندی سرویس‌ها'], ['insurance-pricing', 'بیمه و تعرفه'], ['my-payments', 'پرداخت‌ها'], ['claims', 'مطالبات بیمه'], ['treatment-cases', 'دوره‌های درمان'], ['resources', 'منابع'], ['inventory', 'انبارداری'], ['my-secretaries', 'منشی‌ها'], ['staff', 'پرسنل'], ['settings-menu', 'تنظیمات'], ['appointment-settings', 'تنظیمات نوبت‌دهی'], ['holidays', 'تعطیلات'], ['record-number-settings', 'شمارهٔ پرونده'], ['tags-settings', 'برچسب‌ها'], ['sms-wallet', 'کیف پول پیامک'], ['subscription', 'اشتراک'], ['discounts', 'تخفیف‌ها'], ['profile', 'پروفایل پزشک'], ['account-settings', 'حساب کاربری'], ], }, 'clinic-owner-doctor': { title: 'پزشکی که کلینیک دارد', mobile: '0912000201', // این حساب هم مطب شخصی دارد هم کلینیک، پس اپ اول «انتخاب محیط» می‌پرسد. context: 'کلینیک', pages: [ ['dashboard', 'داشبورد'], ['appointments', 'نوبت‌های کلینیک'], ['my-clinic', 'کلینیک من'], ['settings/clinic-doctors', 'پزشکان کلینیک'], ['settings/appointment-settings', 'نوبت‌دهی کلینیک'], ['settings/practice-domain', 'حوزهٔ فعالیت'], ['clinic-services', 'سرویس‌های کلینیک'], ['resources', 'منابع'], ['resources/types', 'نوع منابع'], ['resources/pools', 'استخر منابع'], ['resources/skills', 'مهارت‌ها'], ['staff', 'پرسنل'], ['my-secretaries', 'منشی‌ها'], ['patients', 'پرونده‌ها'], ['treatment-cases', 'دوره‌های درمان'], ['my-payments', 'پرداخت‌ها'], ['my-financial', 'مدیریت پرداخت'], ['claims', 'مطالبات بیمه'], ['inventory', 'انبارداری'], ['settings-menu', 'تنظیمات'], ['subscription', 'اشتراک'], ], }, 'clinic-manager': { title: 'مدیر کلینیک', mobile: '0912000301', pages: [ ['dashboard', 'داشبورد'], ['appointments', 'نوبت‌های درمانگاه'], ['my-clinic', 'کلینیک من'], ['settings/clinic-doctors', 'پزشکان'], ['settings/appointment-settings', 'نوبت‌دهی'], ['clinic-services', 'سرویس‌ها'], ['service-categories', 'دسته‌بندی‌ها'], ['resources', 'منابع'], ['staff', 'پرسنل'], ['my-secretaries', 'منشی‌ها'], ['patients', 'پرونده‌ها'], ['my-payments', 'پرداخت‌ها'], ['claims', 'مطالبات بیمه'], ['insurance-pricing', 'بیمه و تعرفه'], ['inventory', 'انبارداری'], ['treatment-cases', 'دوره‌های درمان'], ['settings-menu', 'تنظیمات'], ['sms-wallet', 'کیف پول پیامک'], ['subscription', 'اشتراک'], ], }, secretary: { title: 'منشی', mobile: '0912000209', pages: [ ['appointments', 'نوبت‌ها'], ['appointments/new', 'ثبت نوبت'], ['appointments/reserve', 'نوبت رزرو'], ['patients', 'پرونده‌ها'], ['patients/new', 'تشکیل پرونده'], ['my-payments', 'پرداخت‌ها'], ['claims', 'مطالبات بیمه'], ['treatment-cases', 'دوره‌های درمان'], ['secretary-earnings', 'درآمد من'], ['secretary-settlement', 'تسویه حساب'], ['account-settings', 'حساب کاربری'], ], }, staff: { title: 'پرسنل', mobile: '09120002105', pages: [ ['my-sessions', 'جلسات امروز من'], ['account-settings', 'حساب کاربری'], ], }, }; const args = process.argv.slice(2); const argOf = (name, fallback = null) => { const i = args.indexOf(`--${name}`); return i === -1 ? fallback : args[i + 1]; }; const OUT = argOf('out', '/tmp/guide'); const WIDTH = Number(argOf('w', 1440)); const HEIGHT = Number(argOf('h', 900)); const WAIT = Number(argOf('wait', 3500)); const wanted = args.includes('--all') ? Object.keys(ROLES) : [argOf('role')].filter(Boolean); if (wanted.length === 0) { console.error('استفاده: --role یا --all'); console.error('نقش‌ها: ' + Object.keys(ROLES).join(', ')); process.exit(1); } async function login(mobile) { const res = await fetch(`${BASE}/api/v1/user/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mobile_number: mobile, password: PASSWORD }), }); const body = await res.json(); if (!body.access_token) { throw new Error(`ورود ${mobile} ناموفق: ${JSON.stringify(body).slice(0, 160)}`); } return body; } async function cdpUrl() { for (let i = 0; i < 50; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); return (await r.json()).webSocketDebuggerUrl; } catch { await new Promise((r) => setTimeout(r, 300)); } } throw new Error('Chrome بالا نیامد'); } const chrome = spawn(CHROME, [ '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars', '--ignore-certificate-errors', '--force-device-scale-factor=2', `--remote-debugging-port=${PORT}`, `--user-data-dir=/tmp/guide-shots-${process.pid}`, `--window-size=${WIDTH},${HEIGHT}`, 'about:blank', ], { stdio: 'ignore' }); const ws = new WebSocket(await cdpUrl()); await new Promise((r) => ws.addEventListener('open', r, { once: true })); let msgId = 0; const pending = new Map(); ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id); } }); const rpc = (method, params, sessionId) => new Promise((resolve) => { const id = ++msgId; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params, sessionId })); }); const { targetId } = await rpc('Target.createTarget', { url: 'about:blank' }); const { sessionId } = await rpc('Target.attachToTarget', { targetId, flatten: true }); const S = (m, p) => rpc(m, p, sessionId); await S('Page.enable'); await S('Runtime.enable'); const evalJs = async (expression) => { const { result } = await S('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); return result?.value; }; // اجرای تک‌نقشی نباید فهرست نقش‌های قبلی را پاک کند. const manifestPath = join(OUT, 'shots.json'); const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {}; for (const roleKey of wanted) { const role = ROLES[roleKey]; const dir = join(OUT, roleKey); mkdirSync(dir, { recursive: true }); const { access_token, refresh_token } = await login(role.mobile); // localStorage به origin وابسته است؛ اول باید روی همان دامنه باشیم. await S('Page.navigate', { url: `${BASE}/admin/login` }); await new Promise((r) => setTimeout(r, 1500)); await evalJs(`localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify({ state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true }, version: 0, }))}); localStorage.setItem('pwa-dismissed','1'); // تور راهنما روی تصویرها می‌افتد و متنِ خودِ راهنما را می‌پوشاند. localStorage.setItem('clinicpro-tours-suppress','1');`); // حسابِ چندمحیطی تا محیطش را انتخاب نکند، هر مسیری به «انتخاب محیط» می‌رود. if (role.context) { await S('Page.navigate', { url: `${BASE}/admin/select-context` }); await new Promise((r) => setTimeout(r, 2500)); const picked = await evalJs(`(() => { const btn = [...document.querySelectorAll('button')] .find(b => b.textContent.includes(${JSON.stringify(role.context)})); if (!btn) return false; btn.click(); return btn.textContent.trim().slice(0, 60); })()`); await new Promise((r) => setTimeout(r, 2500)); console.log(` محیط انتخاب شد: ${picked || 'پیدا نشد ⚠'}`); } manifest[roleKey] = { title: role.title, mobile: role.mobile, shots: [] }; console.log(`\n### ${role.title} (${role.mobile})`); for (const [index, [path, label]] of role.pages.entries()) { const file = `${String(index + 1).padStart(2, '0')}-${path.replace(/\//g, '-')}.png`; await S('Page.navigate', { url: `${BASE}/admin/${path}` }); await new Promise((r) => setTimeout(r, WAIT)); // تور خودکار روی صفحه می‌نشیند؛ بسته می‌شود تا تصویر خودِ صفحه را نشان دهد. await evalJs(`(() => { const close = document.querySelector('.driver-popover-close-btn'); if (close) close.click(); })()`); await new Promise((r) => setTimeout(r, 400)); const landed = await evalJs('location.pathname'); const empty = await evalJs('(document.body.innerText || "").trim().length < 40'); const shot = await S('Page.captureScreenshot', { format: 'png' }); writeFileSync(join(dir, file), Buffer.from(shot.data, 'base64')); const note = landed.endsWith(path) ? '' : ` (منتقل شد به ${landed})`; console.log(` ${empty ? '⚠' : '✓'} ${label} → ${file}${note}`); manifest[roleKey].shots.push({ file, path, label, landed, empty }); } } mkdirSync(OUT, { recursive: true }); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); console.log(`\nفهرست: ${manifestPath}`); ws.close(); chrome.kill();