From 21f5f07bc6a8d491a52732d603e39d6f08c87ce0 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 19 Jul 2026 08:09:21 +0330 Subject: [PATCH] chore(prompt): record SEO audit and split remaining work P1-P11 of the live SEO audit are implemented and verified against a local production build, so the audit file becomes a reference rather than a task list: it now records what shipped, the root causes that differed from the original hypotheses, and the deliberate trade-offs. Remaining work is split into smaller prompts, ordered by dependency: - seo-post-deploy-verification: the acceptance criteria were "curl on production" but were only run against a local build - blog-city-scoping-activate: blocked on the backend blog city column - sitemap-simplify-with-city: drops the 35-sweep workaround once the doctors list exposes city Each names its blocking dependency and carries reference numbers so a regression is visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/prompt/blog-city-scoping-activate.md | 156 ++++++ .../prompt/seo-critical-fixes-live-audit.md | 464 ++++++++++++++++++ .../prompt/seo-post-deploy-verification.md | 148 ++++++ .claude/prompt/sitemap-simplify-with-city.md | 124 +++++ 4 files changed, 892 insertions(+) create mode 100644 .claude/prompt/blog-city-scoping-activate.md create mode 100644 .claude/prompt/seo-critical-fixes-live-audit.md create mode 100644 .claude/prompt/seo-post-deploy-verification.md create mode 100644 .claude/prompt/sitemap-simplify-with-city.md diff --git a/.claude/prompt/blog-city-scoping-activate.md b/.claude/prompt/blog-city-scoping-activate.md new file mode 100644 index 0000000..03cf7f7 --- /dev/null +++ b/.claude/prompt/blog-city-scoping-activate.md @@ -0,0 +1,156 @@ +
+ +# فعال‌سازی بلاگ شهر-محور (بعد از افزودن شهر در backend) + +## پروژه + +`nobat724_front` + +**پیش‌نیاز قطعی:** `clinicpro/.claude/prompt/blog-city-field.md` اجرا و deploy شده باشد. +تا وقتی `GET /api/v1/blogs` فیلد شهر برنگرداند، این پرامپت کاری برای انجام ندارد. + +## زمینه + +منطق شهر-محورِ بلاگ سمت فرانت **از قبل نوشته شده** ولی چون داده‌اش وجود ندارد غیرفعال است. این پرامپت آن را فعال، تکمیل و تست می‌کند. + +وضعیت داده هنگام نگارش: `GET /api/v1/blogs` صفر رکورد دارد (`totalRecords: 0`) و هیچ فیلد شهری ندارد. + +## وضعیت فعلی (کدی که آمادهٔ فعال‌شدن است) + +`app/blog/[slug]/page.js` — canonical و شهر از همان هلپر مشترک: + +```js +const blogCityId = extractEntityCityId(blog); +const blogCity = findCityById(blogCityId); +const origin = await getEntityOrigin(blogCityId); + +return { + title, + description, + alternates: { canonical: `${origin}/blog/${slug}` }, + // ... +}; +``` + +JSON-LD مقاله فقط با وجود شهر `spatialCoverage` می‌گیرد: + +```js +...(blogCity && { + spatialCoverage: { "@type": "Place", name: blogCity.name }, +}), +``` + +`components/blog/head/index.js` — برچسب بصری، فقط با `cityName`: + +```jsx +{cityName && ( +
+

+ مخصوص شهر: +

+

+ {cityName} +

+
+)} +``` + +`lib/domainHelpers.js` — استخراج شهر، هر سه شکل پاسخ را می‌پذیرد: + +```js +export function extractEntityCityId(entity) { + if (!entity) return null; + const candidates = [ + entity.city_id, + ...(Array.isArray(entity.city) ? entity.city.map((c) => c?.id) : [entity.city?.id]), + ...(Array.isArray(entity.address) ? entity.address.map((a) => a?.city?.id) : []), + ].filter((id) => id != null); + const withDomain = candidates.find((id) => findDomainByCityId(id)); + return withDomain ?? candidates[0] ?? null; +} +``` + +`app/sitemap.js` — مسیریابی پست به sitemap دامنهٔ canonical خودش: + +```js +async function getBlogUrls(baseUrl, scope, currentDomain) { + const blogs = await fetchAllPages('/api/v1/blogs'); + return blogs + .filter((b) => b?.slug || b?.uuid) + .filter((b) => { + const cityDomain = findDomainByCityId(extractEntityCityId(b)); + return cityDomain ? cityDomain === currentDomain : scope.isRoot; + }) + // ... +} +``` + +## وظایف + +### ۱. تأیید سازگاری شکل پاسخ + +اول پاسخ واقعی را ببین: + +```bash +curl -s "https://clinic-pro.ir/api/v1/blogs?page=1&limit=5" | jq '.data.data[0]' +``` + +بررسی کن `extractEntityCityId` روی شکل واقعی جواب می‌دهد. اگر backend شکل دیگری داد (مثلاً `city` رشته به‌جای آبجکت)، `extractEntityCityId` را گسترش بده — **نه** یک استخراج جدا برای بلاگ بنویس. + +`normalizeBlog` در `helper/index.js` نباید فیلد شهر را حذف کند؛ اگر فیلدها را صریح map می‌کند، `city` را اضافه کن. + +### ۲. لیست بلاگ per-domain + +`app/blogs/page.js` روی هر دامنهٔ شهری باید پست‌های آن شهر + سراسری را بگیرد. با پارامتر `city_id` که backend اضافه می‌کند: + +- روی دامنهٔ شهری: `city_id` شهر همان دامنه (از `getStateInfo`) +- روی دامنهٔ ریشه (`isRoot`): بدون پارامتر — همهٔ پست‌ها + +canonical لیست بلاگ **self روی همان دامنه** بماند (C1-a). این از قبل درست است — کانونیکال hardcode شده به دامنهٔ اصلی قبلاً حذف شده: + +```js +// C1-a — لیست بلاگ per-domain است (پست‌های همان شهر + سراسری)؛ +// canonical لایهٔ layout (self روی همان دامنه) درست است. +``` + +### ۳. عنوان و متادیتای شهرمحور + +`generateMetadata` صفحهٔ پست: اگر پست شهر دارد، نام شهر در `title`/`description` بیاید. اگر سراسری است، هیچ نام شهری اضافه نشود (نه نام شهرِ دامنهٔ سرو‌کننده). + +### ۴. تست + +با دادهٔ واقعی (حداقل یک پست شهری + یک پست سراسری) تأیید کن: + +```bash +S= +G= + +# پست شهری: canonical به دامنهٔ شهر، روی هر دامنه‌ای +curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir +curl -s "https://nobat724.com/blog/$S" | grep -o 'rel="canonical" href="[^"]*"' # → yasuj-nobat.ir +curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o "یاسوج" # نام شهر در HTML قابل‌مشاهده +curl -s "https://yasuj-nobat.ir/blog/$S" | grep -o 'spatialCoverage' + +# پست سراسری: self-canonical، بدون نام شهر +curl -s "https://nobat724.com/blog/$G" | grep -o 'rel="canonical" href="[^"]*"' # → nobat724.com +curl -s "https://yasuj-nobat.ir/blog/$G" | grep -c 'spatialCoverage' # → 0 + +# sitemap: هر پست فقط در دامنهٔ canonical خودش +curl -s https://yasuj-nobat.ir/sitemap.xml | grep -c "$S" # → 1 +curl -s https://nobat724.com/sitemap.xml | grep -c "$S" # → 0 +curl -s https://nobat724.com/sitemap.xml | grep -c "$G" # → 1 +curl -s https://nobat724.com/sitemap.xml | grep -c "/blog/" # → بیشتر از ۰ +``` + +تست واحد برای `extractEntityCityId` روی شکل واقعی پاسخ بلاگ به `lib/domainHelpers.test.js` اضافه کن. + +## نکات مهم + +- **پست سراسری حالت دائمی است، نه استثنا.** پستی بدون شهر باید روی دامنهٔ اصلی self-canonical شود و در همهٔ لیست‌ها دیده شود. هرگز شهرِ دامنهٔ سرو‌کننده را به پست بدون شهر نسبت نده. +- `findCityById` رکورد ریشه (`id: 600` = `nobat724.com`) را شهر حساب نمی‌کند و `null` برمی‌گرداند — یعنی پستی که به‌اشتباه به رکورد ریشه وصل شده، سراسری تلقی می‌شود. رفتار درست است. +- برچسب بصری شهر با تایپوگرافی «نویسنده/تاریخ» هم‌خانواده است؛ عنصر بصری ناهماهنگ اضافه نکن. +- الگوی Soft-404 (`app/blog/[slug]/page.js`) بعد از تغییرات همچنان باید کار کند: + `curl -o /dev/null -w "%{http_code}" https://nobat724.com/blog/invalid-xxx` → `404`. + توجه: `app/blog/[slug]/loading.js` عمداً حذف شده — دوباره اضافه‌اش نکن، وگرنه Soft-404 برمی‌گردد. + +
diff --git a/.claude/prompt/seo-critical-fixes-live-audit.md b/.claude/prompt/seo-critical-fixes-live-audit.md new file mode 100644 index 0000000..c7e68d6 --- /dev/null +++ b/.claude/prompt/seo-critical-fixes-live-audit.md @@ -0,0 +1,464 @@ +
+ +# تقسیم ممیزی SEO به پرامپت‌های کوچک اجرایی — nobat724 + +> **وضعیت: P1 تا P11 پیاده و روی build محلی production تأیید شده‌اند (هنوز deploy نشده‌اند).** +> این فایل حالا **مرجع ممیزی** است، نه فهرست کار. کارهای باقی‌مانده به پرامپت‌های کوچک‌تر تقسیم شده‌اند — جدول «کارهای باقی‌مانده» پایین‌تر. +> بخش‌های P1–P11 برای ثبت تصمیم‌ها و یافته‌های اصلی نگه داشته شده‌اند. + +## چک‌لیست کلی — انجام‌شده + +| # | پرامپت | وضعیت | نتیجه | +| --- | --------------------------------------- | ----- | ----- | +| P1 | بررسی robots.txt | ✅ | مشکلی نبود؛ همهٔ مسیرها مجاز، sitemap معرفی شده. بدون تغییر کد | +| P2 | هلپرهای مشترک | ✅ | `lib/domainHelpers.js` (+ `findCityById`، `extractEntityCityId`) | +| P3 | رفع noindex سیستمیک لیست‌ها | ✅ | علت واقعی: **mutate شدن شیء `searchParams` مشترک** بین بدنهٔ صفحه و `generateMetadata` — نه منطق شمارنده. `lib/listingRobots.js` | +| P4 | استراتژی canonical سه‌دسته + pagination | ✅ | `lib/getCanonicalUrl.js` بازنویسی شد؛ `x-search` به `proxy.js` اضافه شد؛ تناقض canonical/JSON-LD رفع شد | +| P5 | بازسازی sitemap | ✅ | معیار «claimed» → `lib/entityQuality.js`؛ **باگ کشف‌شده:** سقف خاموش ۵۰ رکوردی API، یاسوج ۴۸ → ۴۵۴ پزشک | +| P6 | صفحات موجودیت: 404 و noindex | ✅ | علت واقعی Soft-404: **`loading.js`** (Suspense → استریم ۲۰۰). هر سه حذف شدند | +| P7 | اصلاح H1ها | ✅ | همهٔ صفحات دقیقاً یک h1؛ کلینیک ۴ → ۱؛ Title ریشه اصلاح شد | +| P8 | یکپارچه‌سازی Breadcrumb | ✅ | `Pageguide` سمانتیک شد + `BreadcrumbList` از همان منبع داده | +| P9 | صفحات فرود تخصص | ✅ | `app/specialties/[slug]/` + مهاجرت ~۹۰ لینک کارت | +| P10 | بلاگ شهر-محور | ⚠️ | **کد آماده ولی غیرفعال** — API بلاگ صفر رکورد دارد و فیلد شهر ندارد. نیازمند تغییر backend | +| P11 | FAQ schema + متن مقدمه | ✅ | `FAQPage` جدا در صفحات پزشک و تخصص؛ متن مقدمهٔ یکتا با تست شمارش کلمه | + +## کارهای باقی‌مانده — پرامپت‌های کوچک + +به ترتیب وابستگی. **backend اول.** + +| # | پرامپت | چرا | +|---|--------|-----| +| ۱ | `clinicpro/.claude/prompt/doctors-list-city-and-limit.md` | افزودن `city` به پاسخ لیست پزشکان + شفاف‌کردن سقف `limit` | +| ۲ | `clinicpro/.claude/prompt/blog-city-field.md` | افزودن شهر به Entity بلاگ — پیش‌نیاز P10 | +| ۳ | `clinicpro/.claude/prompt/cleanup-polluted-doctor-clinic-records.md` | پاک‌سازی رکوردهای `name = شماره‌تلفن` و `"test"` + اعتبارسنجی | +| ۴ | `nobat724_front/.claude/prompt/seo-post-deploy-verification.md` | اجرای معیارهای پذیرش روی دامنه‌های واقعی بعد از deploy | +| ۵ | `nobat724_front/.claude/prompt/blog-city-scoping-activate.md` | فعال‌سازی P10 — وابسته به ۲ | +| ۶ | `nobat724_front/.claude/prompt/sitemap-simplify-with-city.md` | حذف ۳۵ sweep پرهزینه — وابسته به ۱ | + +پرامپت ۴ مستقل است و می‌تواند بلافاصله بعد از deploy اجرا شود. + +## تصمیم‌های آگاهانه (side-effect نیستند) + +- **M3-B:** صفحات `?page=2+` خودشان canonical دارند و ایندکس‌پذیر می‌مانند (collapse به صفحهٔ ۱ انتخاب نشد). +- **sitemap-index پیاده نشد:** ~۲٬۵۰۰ URL در هر دامنه، بسیار زیر سقف ۵۰k. جزئیات در پرامپت ۶. +- **`loading.js` سه مسیر موجودیت حذف شد:** بهای رفع Soft-404. اسکلت لودینگ هنگام ناوبری کلاینتی از دست رفت. +- **«خانه» به breadcrumb صفحهٔ `/clinics` اضافه شد:** تغییر محتوایی عمدی، هم‌راستا با schema که از قبل روی `/clinic/[id]` بود. +- **h1 صفحهٔ اصلی روی شعار نشست** (نه یک جملهٔ کیوردی جدید): شعار هر شهر در `city.json` از قبل شهرمحور و کیورددار است. + +--- + +## بلوک زمینهٔ مشترک (ابتدای هر پرامپت کپی شود) + +``` +## نقش +تو یک متخصص ارشد Technical SEO + مهندس Next.js 15 App Router هستی. هر تغییر با کمترین ریسک و مطابق آخرین Best Practiceهای Google Search و Next.js. فقط قابلیت‌های رسمی و غیرمنسوخ Next.js. زبان خروجی و رشته‌ها فارسی، RTL، تقویم جلالی. + +## زمینه +پروژه `nobat724_front`: یک deployment واحد Next.js 15 که ۳۵ دامنهٔ شهری مستقل (data/city.json — مثل tehran-nobat.ir, yazd-nobat.ir) + دامنهٔ اصلی nobat724.com را سرو می‌کند. تشخیص شهر از host header (lib/getStateInfo.js سرور، context/ProvinceProvider.js کلاینت). رکورد id:600 در city.json دامنهٔ ریشه است نه شهر (lib/rootCity.js). slug پزشک/کلینیک = uuid. صفحات SSR (streaming با Suspense). + +## فایل‌های کلیدی +lib/getCanonicalUrl.js (تولید canonical — ریشهٔ باگ canonical) · app/sitemap.js (sitemap per-domain) · app/doctors/page.js (metadata لیست + listingRobots — باگ noindex سیستمیک) · app/clinics/page.js + components/clinics/ · app/doctor/[slug]/page.js (Physician schema + noindex برای unclaimed) · app/clinic/[slug]/page.js (canonical متناقض با JSON-LD، بدون noindex برای کلینیک خالی، چند h1) · app/specialties/page.js + components/specialties/ · app/blog/[slug]/page.js + app/blog/page.js · app/page.js + components/home/ · data/city.json + data/specialties.json + data/state.json · next.config.js (security headers — دست‌نخورده) + +## محدودیت‌های عمومی +- security headers و CSP در next.config.js را نشکن. +- هر تغییر canonical/robots را با curl روی حداقل یک دامنهٔ شهری + دامنهٔ اصلی تأیید کن. +- خارج از دامنهٔ اپ اقدام نکن (تنظیمات CDN/ArvanCloud = DevOps، فقط در گزارش یادآوری کن). +- پس از تغییر کد، `graphify update .` را اجرا کن. +- در پایان: گزارش کوتاه تغییرات + خروجی curl معیارهای پذیرش. + +## محدودیت‌های UI (در همهٔ پرامپت‌ها الزامی) +- **UI سایت نباید به‌هم بریزد.** تغییرات SEO (تعویض تگ h1↔span/p/h2، canonical، robots، schema) نباید هیچ تغییر بصری ایجاد کنند — هنگام تعویض تگ، className و استایل‌های عنصر قبلی را عیناً روی تگ جدید نگه‌دار. +- **صفحهٔ جدید = کامپوننت‌های موجود.** برای هر صفحهٔ جدید (صفحات تخصص، breadcrumb، بلاگ) از کامپوننت‌های موجود پروژه استفاده کن (کارت پزشک، لیست، دکمه‌ها، layout و header/footer فعلی) — کامپوننت جدید فقط وقتی بساز که هیچ معادل موجودی نباشد، و در آن صورت با همان design tokenها و کلاس‌های Tailwind پروژه. +- **ساختار صفحات جدید دقیقاً شبیه UI فعلی باشد.** صفحهٔ /specialties/[slug] باید از نظر چیدمان، فاصله‌گذاری، تایپوگرافی و رنگ مثل /doctors فعلی به نظر برسد؛ کاربر نباید حس کند وارد بخش متفاوتی از سایت شده. +- بعد از هر تغییر، صفحات تغییریافته را از نظر بصری با نسخهٔ قبل مقایسه کن (اسکرین‌شات یا بازبینی دستی) و در گزارش تأیید کن که رگرسیون بصری نداریم. + +## نقاط قوت فعلی (تخریب نشوند) +اسکیمای Physician/Review/Geo · هدرهای امنیتی HSTS/CSP · ریدایرکت www→non-www با 301 · صفحهٔ ۴۰۴ عمومی درست. +``` + +--- + +## مرجع: فهرست کامل یافته‌های زندهٔ ممیزی (تأییدشده با HTML واقعی) + +این بخش مرجع است؛ هر پرامپت یافته‌های مرتبط خودش را جداگانه دارد، اما تصویر کامل اینجاست: + +- `yazd-nobat.ir/` و `yazd-nobat.ir/doctors` → canonical به `nobat724.com` (باگ سراسری canonical). +- `yasuj-nobat.ir/doctors` **بدون هیچ query** → `robots: noindex, follow` + canonical به دامنهٔ اصلی. علت: `city`/`state` خودکار از host تزریق می‌شوند و شمارندهٔ `listingRobots` همیشه ۲+ می‌شود ⇒ صفحهٔ اصلی لیست روی **همهٔ ۳۵ دامنه همیشه noindex** است. +- `yasuj-nobat.ir/clinics` → همان دو مشکل (noindex + canonical غلط) ⇒ باگ فقط `/doctors` نیست. +- `yasuj-nobat.ir/specialties` → canonical غلط اما **بدون** noindex ⇒ `listingRobots` بین doctors/clinics مشترک است، specialties مسیر جداست. دو `

` بدون نام شهر. ~۹۰ لینک کارت تخصص به `/doctors?specialty=X&city=…&state=…`. +- `yasuj-nobat.ir/clinic/9c163d69-…` → **تناقض زنده:** canonical به `nobat724.com` اما `MedicalClinic.url` و کل `BreadcrumbList` به `yasuj-nobat.ir`. کلینیک `is_active:false` با نام «09398631203» بدون noindex ایندکس‌پذیر (نامتقارن با پزشکان). چهار `

` (نام + سه عنوان بخش). schema هست ولی breadcrumb بصری نیست (معکوس `/clinics`). Title = «09398631203 | یاسوج نوبت». +- `yasuj-nobat.ir/contact-us` → canonical به دامنهٔ اصلی، **بدون** noindex — برای صفحات استاتیک مشترک این canonical احتمالاً درست است (استثنای C1-c). +- `yasuj-nobat.ir/about-us` → canonical مشابه اما **با** noindex ⇒ robots صفحات استاتیک ناسازگار است. +- دادهٔ embedded یاسوج: `totalRecords: 456, totalPages: 38` ⇒ با query-stripping فعلی، صفحات ۲ تا ۳۸ به صفحهٔ ۱ collapse می‌شوند. +- HTML اولیه ۶ کارت اسکلت با `href="/doctor/undefined"` دارد ⇒ رندر streaming با Suspense است، نه SSR همزمان کامل. +- رکوردهای دادهٔ آلوده: پزشک با `name:"09390039833"` و `name:"test"` با `owner_status:"claimed"` در نتایج عمومی. +- `nobat724.com/doctors` → بدون هیچ `

`. H1 صفحهٔ اصلی = «با نوبت 724». Title روت /doctors = «پزشکان نوبت 724 …» (نام رکورد root به‌جای شهر). +- `/doctor/` → کد 200 (Soft-404). +- `nobat724.com/sitemap.xml` → فقط ۱۳ URL (۷ استاتیک + ۴ پزشک claimed + ۲ کلینیک + ۰ بلاگ). +- هیچ روت تمیز specialty/city و هیچ `FAQPage` schema در کل سایت نیست. +- بلاگ شهر-محور نیست با اینکه هر شهر نمایندهٔ محتوایی دارد. + +--- + +## P1 — بررسی robots.txt (پیش‌نیاز اعتبارسنجی) + +> پیش‌نیاز: هیچ. اجرا: قبل از همه — اگر مسیرها مسدود باشند بقیهٔ کارها بی‌اثرند. + +``` +[بلوک زمینهٔ مشترک] + +## تسک +فقط بررسی و گزارش — بدون تغییر کد مگر مشکل پیدا شود. + +1. robots.txt دامنهٔ اصلی و حداقل دو دامنهٔ شهری را بگیر: + curl -s https://nobat724.com/robots.txt + curl -s https://yasuj-nobat.ir/robots.txt + curl -s https://tehran-nobat.ir/robots.txt +2. تأیید کن: + - مسیرهای /doctors, /clinics, /specialties, /blog, /doctor/, /clinic/ مسدود (Disallow) نیستند. + - sitemap.xml با خط Sitemap: معرفی شده است. + - مسیرهای پنل/لاگین (در صورت وجود) Disallow هستند. +3. اگر مسیری به‌اشتباه مسدود است، فایل/روت تولیدکنندهٔ robots.txt را پیدا و اصلاح کن (احتمالاً app/robots.js یا فایل استاتیک public/robots.txt). + +## معیار پذیرش +خروجی کامل robots.txt هر سه دامنه در گزارش نقل شود + جدول «مسیر × وضعیت (مجاز/مسدود)». اگر اصلاحی انجام شد، curl بعد از deploy هم ضمیمه شود. +``` + +--- + +## P2 — هلپرهای مشترک (زیرساخت) + +> پیش‌نیاز: هیچ. دو تابع که چند پرامپت بعدی مصرف می‌کنند — دو پیاده‌سازی جدا ریسک واگرایی دارد، پس اول و یک‌بار ساخته می‌شوند. + +``` +[بلوک زمینهٔ مشترک] + +## تسک +دو هلپر مشترک بساز (محل پیشنهادی: lib/domainHelpers.js یا کنار getStateInfo): + +### ۱) findDomainByCityId(cityId) +- ورودی: city_id یک موجودیت (پزشک/کلینیک/پست بلاگ). +- خروجی: دامنهٔ شهری متناظر از data/city.json (مثلاً "yasuj-nobat.ir")، یا null اگر شهر دامنهٔ اختصاصی ندارد. +- رکورد id:600 (دامنهٔ ریشه) هرگز به‌عنوان «شهر» برنگردد. +- مصرف‌کنندگان آینده: canonical موجودیت‌ها (P4)، sitemap (P5)، بلاگ (P10). + +### ۲) resolveCityDisplayName(cityInfo) +- ورودی: آبجکت شهر از getStateInfo. +- خروجی: نام قابل‌نمایش شهر برای H1/Title. اگر isRoot (رکورد id:600) → «ایران»، نه نام رکورد root («نوبت 724»). +- مصرف‌کنندگان آینده: H1 صفحات لیست (P7)، صفحات تخصص (P9). + +## نکته +یافتهٔ زنده: Title روت /doctors الان «پزشکان نوبت 724 …» رندر می‌شود چون نام رکورد root به‌جای شهر می‌نشیند — resolveCityDisplayName دقیقاً برای رفع این است. + +## معیار پذیرش +- تست واحد یا اسکریپت کوچک: findDomainByCityId برای city_id یاسوج → "yasuj-nobat.ir"؛ برای city_id بدون دامنه → null؛ برای id:600 → null. +- resolveCityDisplayName برای رکورد root → «ایران»؛ برای یاسوج → «یاسوج». +``` + +--- + +## P3 — رفع noindex سیستمیک صفحات لیست (C5 — بدترین باگ) + +> پیش‌نیاز: P2 (برای تشخیص «شهر host»). مهم‌ترین فیکس تک‌خطی‌نما با بیشترین اثر. + +``` +[بلوک زمینهٔ مشترک] + +## یافتهٔ زندهٔ تأییدشده +- yasuj-nobat.ir/doctors و yasuj-nobat.ir/clinics — هر دو بدون هیچ query string — robots: noindex, follow دارند. +- علت: city/state روی سرور خودکار از host استنتاج و به شیء پارامترها تزریق می‌شوند؛ منطق listingRobots («۲+ پارامتر → noindex») همیشه این دو را می‌شمارد و همیشه ۲+ می‌شود. +- نتیجه: صفحات اصلی لیست پزشکان و کلینیک‌ها روی هر ۳۵ دامنه همیشه noindex هستند — مهم‌ترین صفحات هر دامنه برای «پزشک + شهر». +- /specialties این باگ را ندارد (مسیر جدا) — یعنی listingRobots بین /doctors و /clinics مشترک است؛ همهٔ مصرف‌کنندگانش را پوشش بده، نه فقط app/doctors/page.js. +- یافتهٔ مرتبط: about-us هم noindex دارد ولی contact-us ندارد — robots در کل اپ ناسازگار است؛ هنگام فیکس، همهٔ نقاط اعمال robots را یک‌جا فهرست و بازبینی کن. + +## تسک +1. در listingRobots: پارامترهای city/state وقتی مقدارشان برابر شهر/استان استنتاج‌شده از host فعلی است، از شمارش «۲+ فیلتر» کاملاً حذف شوند — چه از query آمده باشند چه تزریق سمت سرور. +2. فقط فیلترهای صریحاً کاربر-انتخاب‌شده بشمارند: تخصص، جنسیت، مرتب‌سازی غیرپیش‌فرض، city/state متفاوت از host. +3. قاعدهٔ اصلی (noindex برای جستجوی نام یا فیلتر واقعی چندگانه) حفظ شود. +4. robots صفحهٔ about-us را هم به index برگردان (صفحهٔ استاتیک نباید noindex باشد؛ canonical آن در P4 تعیین می‌شود). + +## معیار پذیرش +curl -s https://yasuj-nobat.ir/doctors | grep -i robots # → index یا غایب +curl -s https://yasuj-nobat.ir/clinics | grep -i robots # → index یا غایب +curl -s https://tehran-nobat.ir/doctors | grep -i robots # → index یا غایب +curl -s https://yazd-nobat.ir/doctors | grep -i robots # → index یا غایب +curl -s https://yasuj-nobat.ir/about-us | grep -i robots # → index یا غایب +curl -s "https://yasuj-nobat.ir/doctors?city=تهران&specialty=X" | grep -i robots # → noindex بماند +تست حداقل روی ۳ دامنهٔ متفاوت و هر دو مسیر /doctors و /clinics — باگ سیستمیک است، یک URL کافی نیست. +``` + +--- + +## P4 — استراتژی canonical سه‌دسته + تصمیم pagination (C1-a/b/c + M3-B) + +> پیش‌نیاز: P2 (findDomainByCityId)، P3 (تا canonical و robots هم‌جهت شوند). بزرگ‌ترین پرامپت — قلب فیکس. + +``` +[بلوک زمینهٔ مشترک] + +## یافته‌های زندهٔ تأییدشده +- getCanonicalUrl() در lib/getCanonicalUrl.js همهٔ صفحات روی همهٔ دامنه‌ها را به nobat724.com canonical می‌کند (buildMainCanonicalUrl). تأییدشده روی: /doctors، /clinics، /specialties، /clinic/[id]، /contact-us، /about-us. +- تناقض زنده در clinic/9c163d69: canonical به nobat724.com اما MedicalClinic.url و BreadcrumbList به yasuj-nobat.ir — سیگنال متناقض به گوگل. +- pagination واقعی: یاسوج totalPages: 38 — با query-stripping فعلی همهٔ صفحات ۲+ به صفحهٔ ۱ collapse می‌شوند. + +## قاعدهٔ سه‌دسته +| دسته | صفحات | canonical | +|------|-------|-----------| +| C1-a: per-domain | / ، /doctors ، /clinics ، /specialties ، /specialties/[slug] ، /blog (لیست) | self-canonical روی همان دامنه | +| C1-b: موجودیت | /doctor/[uuid] ، /clinic/[uuid] ، /blog/[slug] شهریافته | دامنهٔ شهرِ همان موجودیت (با findDomainByCityId از P2) | +| C1-c: استاتیک مشترک | /contact-us ، /about-us ، /terms ، /privacy ، … | دامنهٔ اصلی nobat724.com (استثنای عمدی — محتوا روی همهٔ دامنه‌ها یکسان است) | + +## تسک +1. getCanonicalUrl را بازنویسی کن تا searchParams هم بپذیرد (الان فقط x-pathname می‌خواند و query را کامل نادیده می‌گیرد): + - STATIC_SHARED_PAGES (فهرست C1-c، با تیم نهایی شود) → buildMainCanonicalUrl. + - /doctors با searchParams فقط specialty (تک‌فیلتر) → canonical به /specialties/[slug] (مقصد در P9 ساخته می‌شود؛ فعلاً تابع buildSpecialtyCanonicalUrl را آماده کن و پشت feature-flag یا شرط وجود روت بگذار). + - پارامتر page>1 → تصمیم M3-B: گزینهٔ پیشنهادی page را در canonical نگه‌دار (?page=2 خود canonical) تا صفحات ۲+ ایندکس‌پذیر بمانند. اگر گزینهٔ collapse به صفحهٔ ۱ انتخاب شد، در گزارش صریحاً به‌عنوان تصمیم آگاهانه مستند کن، نه side-effect. + - سایر queryها (فیلتر چندگانه، sort، نام) → حذف از canonical (رفتار فعلی درست است). + - سایر مسیرها → self-canonical با host فعلی. +2. برای موجودیت‌ها تابع getEntityCanonical(entity, pathname, currentHost) بساز: + - cityDomain = findDomainByCityId(entity?.city_id)؛ اگر null → self-canonical (fallback؛ هرگز canonical شکسته نده). + - حالت‌های لبه‌ای: پزشک چند-شهری → یک شهر اصلی (اولین/پرترافیک‌ترین مطب) روی همهٔ دامنه‌ها؛ شهر بدون دامنه → nobat724.com و self. + - در app/doctor/[slug]/page.js و app/clinic/[slug]/page.js اعمال کن. +3. JSON-LD را هماهنگ کن: فیلدهای url/@id در Physician و MedicalClinic و آیتم‌های BreadcrumbList باید همان مقصد canonical را منعکس کنند (تناقض زندهٔ بالا را رفع و تست کن). +4. buildMainCanonicalUrl را فقط اگر جای دیگری (JSON-LD/sitemap) مصرف نمی‌شود حذف کن؛ وگرنه دست‌نخورده. +5. گزینهٔ 301 به‌جای canonical برای موجودیت‌ها را فقط در گزارش پیشنهاد بده (عیب‌ها: redirect chain بک‌لینک‌ها، پرش دامنه در UX پورتال). بدون تأیید صریح پیاده نکن. + +## معیار پذیرش +# C1-a +curl -s https://yazd-nobat.ir/doctors | grep canonical # → yazd-nobat.ir/doctors +curl -s https://nobat724.com/doctors | grep canonical # → nobat724.com/doctors +curl -s https://yasuj-nobat.ir/specialties | grep canonical # → yasuj-nobat.ir/specialties +# C1-b +curl -s https://yasuj-nobat.ir/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → self +curl -s https://nobat724.com/doctor/beaca548-816f-4613-937d-360db01bd7c8 | grep canonical # → yasuj-nobat.ir +curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep canonical # → self (شهر=یاسوج) +# C1-c +curl -s https://yasuj-nobat.ir/contact-us | grep canonical # → nobat724.com/contact-us +curl -s https://yasuj-nobat.ir/about-us | grep canonical # → nobat724.com/about-us +# M3-B (اگر گزینهٔ پیشنهادی) +curl -s "https://yasuj-nobat.ir/doctors?page=2" | grep canonical # → …/doctors?page=2 +# هماهنگی JSON-LD +curl -s https://yasuj-nobat.ir/clinic/9c163d69-… | grep -o '"url":"[^"]*"' # → همان دامنهٔ canonical +``` + +--- + +## P5 — بازسازی sitemap (C2) + +> پیش‌نیاز: P4 (sitemap باید فقط URLهای canonical را سابمیت کند). + +``` +[بلوک زمینهٔ مشترک] + +## یافتهٔ زنده +nobat724.com/sitemap.xml فقط ۱۳ URL دارد (۷ استاتیک + ۴ پزشک + ۲ کلینیک + ۰ بلاگ). علت: فیلتر owner_status === 'claimed' در getDoctorUrls (app/sitemap.js). یک مارکت‌پلیس عملاً ۴ صفحهٔ پزشک ایندکس‌پذیر دارد. + +## تسک +1. معیار ورود به sitemap: از «claimed» به «دارای حداقل دادهٔ معنادار» (uuid + حداقل یک تخصص یا آدرس/شهر). +2. در app/doctor/[slug]/page.js شرط isUnclaimed → noindex را نرم کن: فقط پزشکان بدون هیچ محتوای معنادار noindex بمانند. +3. هماهنگی با canonical (P4): getDoctorUrls از همان findDomainByCityId استفاده کند. sitemap هر دامنه فقط موجودیت‌هایی را شامل شود که canonical آن‌ها همان دامنه است. sitemap دامنهٔ اصلی فقط موجودیت‌های بدون دامنهٔ شهری. +4. برای مقیاس بالای ~۵۰k URL، sitemap-index با فایل‌های جدا (doctors/clinics/blogs/static) بساز. + +## معیار پذیرش +curl -s https://nobat724.com/sitemap.xml | grep -c "" # بسیار بیشتر از ۱۳ +curl -s https://nobat724.com/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → خالی (canonical این پزشک یاسوج است) +curl -s https://yasuj-nobat.ir/sitemap.xml | grep "beaca548-816f-4613-937d-360db01bd7c8" # → موجود +پزشک معتبر unclaimed دیگر noindex نگیرد (curl روی یک نمونه). +``` + +--- + +## P6 — صفحات موجودیت: Soft-404 و noindex کلینیک خالی (H4 + H7) + +> پیش‌نیاز: P4 (تا سیاست robots/canonical موجودیت‌ها معلوم باشد). دو فیکس کوچک هم‌حوزه. + +``` +[بلوک زمینهٔ مشترک] + +## یافته‌های زنده +- /doctor/ کد 200 می‌دهد (Soft-404) — API برای uuid ناموجود 200 با data:null برمی‌گرداند و کد به notFound() نمی‌رسد. +- clinic/9c163d69 با is_active:false، نام «09398631203» (شماره‌تلفن)، بدون caption/logo/services — کاملاً ایندکس‌پذیر است. منطق «thin → noindex» که برای پزشک unclaimed هست، برای کلینیک اصلاً وجود ندارد (نامتقارن). + +## تسک +### H4 — Soft-404 +1. در getDoctor (app/doctor/[slug]/page.js): null-check کامل روی json?.data?.data → notFound(). +2. همان الگو برای app/clinic/[slug]/page.js و app/blog/[slug]/page.js. +(روت /specialties/[slug] هنوز ساخته نشده — در P9 همین الگو اعمال می‌شود.) + +### H7 — noindex کلینیک خالی +1. در app/clinic/[slug]/page.js: کلینیک با is_active:false یا بدون محتوای معنادار (نام واقعی/خدمات/توضیح) → noindex. +2. تصمیم بگیر و مستند کن: is_active:false یعنی «حذف‌شده» (→ 404/410) یا «موقتاً غیرفعال» (→ noindex)؟ +3. هماهنگی با P5: موجودیت noindex نباید در sitemap باشد. +4. در گزارش برای تیم عملیات: رکوردهای آلوده (نام = شماره‌تلفن، name:"test" با owner_status:"claimed") باید در لایهٔ داده پاک شوند — خارج از کد. + +## معیار پذیرش +curl -o /dev/null -w "%{http_code}" https://nobat724.com/doctor/invalid-xxx # → 404 +curl -o /dev/null -w "%{http_code}" https://nobat724.com/clinic/invalid-xxx # → 404 +curl -s https://yasuj-nobat.ir/clinic/9c163d69-0051-4745-a423-c830135b1c01 | grep -i robots # → noindex (یا 404/410 طبق تصمیم) +curl -s https://yasuj-nobat.ir/sitemap.xml | grep "9c163d69" # → خالی +``` + +--- + +## P7 — اصلاح H1ها در همهٔ صفحات (H2 + H3) + +> پیش‌نیاز: P2 (resolveCityDisplayName). مستقل از canonical — می‌تواند موازی P4-P6 اجرا شود. + +``` +[بلوک زمینهٔ مشترک] + +## یافته‌های زنده (الگوی مشترک: Title/meta شهرمحور درست‌اند، H1 از منبع جدا و استاتیک می‌آید) +| صفحه | H1 فعلی | مشکل | +|------|---------|-------| +| / (اصلی) | «با نوبت 724» | برندمحور، بدون کیورد | +| /doctors | «کهگیلویه و بویراحمد / یاسوج» | متن دکمهٔ انتخاب موقعیت داخل