From 3d7485bbfdec366e6c82eaf4156daba3eaf7ec2a Mon Sep 17 00:00:00 2001 From: reza karimi Date: Sat, 17 Oct 2026 17:20:59 +0330 Subject: [PATCH] fix: seo friendly --- README.md | 35 ++++- lib/seo.js | 116 +++++++++++++++++ next.config.js | 5 +- package.json | 1 + pages/News.js | 164 +++++++++--------------- pages/_app.js | 22 +++- pages/_document.js | 15 +++ pages/api/my-sitemap.js | 51 ++++---- pages/appContainer.js | 11 +- pages/components/service.js | 4 +- pages/components/slider.js | 6 +- pages/index.js | 14 +- pages/medical-equipment/index.js | 4 +- pages/page/Directors.js | 4 +- pages/page/contact-us.js | 8 +- pages/page/cooperationsales.js | 4 +- pages/page/docs.js | 4 +- pages/page/news/[slug].js | 18 ++- pages/page/sales.jsx | 2 +- pages/recruitment/index.js | 10 +- pages/recruitment/item/[category_id].js | 12 +- pages/stock-unit/index.jsx | 1 + public/robots.txt | 8 +- public/style/font/font.css | 7 + src/components/Seo.jsx | 36 ++++++ tests/seo.test.cjs | 47 +++++++ 26 files changed, 417 insertions(+), 192 deletions(-) create mode 100644 lib/seo.js create mode 100644 pages/_document.js create mode 100644 src/components/Seo.jsx create mode 100644 tests/seo.test.cjs diff --git a/README.md b/README.md index 02695bc..877eb9d 100644 --- a/README.md +++ b/README.md @@ -1 +1,34 @@ -This is a starter template for [Learn Next.js](https://nextjs.org/learn). \ No newline at end of file +# وب‌سایت بیمارستان مهر پرند + +پروژه با Next.js 11 و Pages Router اجرا می‌شود. + +## اجرا و بررسی + +```sh +npm run dev +npm run build +npm run start +npm test +``` + +تست‌ها با test runner داخلی Node.js اجرا می‌شوند و به Node.js 20 یا جدیدتر نیاز دارند. بیلد فعلی با Node.js 14.21.3 بررسی شده است؛ ارتقای نسخه‌های قدیمی Next.js و Node.js باید جداگانه انجام شود. تنظیمات و نسخه ESLint موجود با فرایند lint این نسخه Next.js سازگار نیست و بیلد هشدار می‌دهد؛ موفقیت بیلد به معنی موفقیت lint نیست. + +## تنظیمات سئو + +- عنوان و توضیحات صفحات عمومی و آدرس اصلی سایت در `lib/seo.js` تعریف شده‌اند. هنگام افزودن صفحه عمومی، آن را به `PUBLIC_PAGES` اضافه کنید؛ این فهرست منبع صفحات ثابت sitemap نیز هست. +- `src/components/Seo.jsx` از طریق `_app.js` متادیتا، canonical، Open Graph، Twitter Card و JSON-LD را در HTML سرور تولید می‌کند. متادیتای محتوای پویا را با `props.seo` از `getServerSideProps` ارسال کنید. +- canonical از دامنه ثابت HTTPS ساخته می‌شود و query string و fragment را حذف می‌کند. عنوان سفارشی صفحات را در registry یا `props.seo` تنظیم کنید؛ prop قدیمی `AppContainer.title` دیگر منبع متادیتا نیست. +- صفحات فنی، کامپوننت‌های مستقل، فرم‌های درخواست و پیگیری و نمودارهایی که محتوایشان صرفاً سمت مرورگر است، به‌صورت پیش‌فرض `noindex, follow` هستند. صفحه عمومی بخش‌ها در `/components/service` استثنا است. `noindex` جایگزین کنترل دسترسی نیست. +- سند HTML دارای `lang="fa"` و `dir="rtl"` است. داده ساختاریافته بیمارستان از اطلاعات تماس موجود سایت ساخته شده؛ ساعت کاری یا تاریخ افتتاح فرضی درج نشده است. + +## نقشه سایت و اخبار + +آدرس عمومی نقشه سایت `https://miphospital.ir/sitemap.xml` است و به handler موجود `/api/my-sitemap` بازنویسی می‌شود. robots.txt همین آدرس HTTPS را معرفی می‌کند. + +نقشه سایت صفحات عمومی و شناسه‌های معتبر اخبار برگشتی از `AppUrl('news')` را شامل می‌شود. اگر API اخبار در دسترس نباشد، صفحات ثابت همچنان برگردانده می‌شوند و زمان cache به ۶۰ ثانیه کاهش می‌یابد. API فعلی باید آرایه اخبار یا آبجکت دارای آرایه `data` برگرداند؛ در صورت اضافه شدن pagination، پیمایش صفحه‌های API نیز باید اضافه شود. + +خبر ناموجود پاسخ ۴۰۴ دارد؛ خرابی موقت API جزئیات خبر به خطای سرور منجر می‌شود. فهرست اخبار هنگام خرابی API پاسخ ۵۰۳ با `Retry-After` می‌دهد تا خطا به‌عنوان محتوای خالی موفق ثبت نشود. + +در بررسی محلی، API فعلی اخبار (`https://mehrara-api.iran.liara.run/api/news`) پاسخ ۴۰۴ داد؛ در نتیجه مسیرهای واقعی اخبار و محتوای زنده آن‌ها تا رفع API قابل بررسی نیستند. بعد از انتشار، sitemap را در Google Search Console ثبت و صفحات اصلی را با URL Inspection بررسی کنید. + +مرجع: [راهنمای فنی سئو برای توسعه‌دهندگان در Google Search Central](https://developers.google.com/search/docs/fundamentals/get-started-developers). diff --git a/lib/seo.js b/lib/seo.js new file mode 100644 index 0000000..e6c239e --- /dev/null +++ b/lib/seo.js @@ -0,0 +1,116 @@ +const SITE_URL = 'https://miphospital.ir' +const SITE_NAME = 'بیمارستان مهر پرند' +const DEFAULT_DESCRIPTION = 'وب‌سایت رسمی بیمارستان مهر ایرانیان پرند؛ معرفی بیمارستان، بخش‌های تخصصی، اخبار افتتاح، فرصت‌های شغلی و راه‌های ارتباط با ما.' +const DEFAULT_IMAGE = `${SITE_URL}/resource/logo/logo-green.png` + +// This registry also defines the public, canonical pages included in the sitemap. +const PUBLIC_PAGES = { + '/': [SITE_NAME, DEFAULT_DESCRIPTION], + '/landing': ['معرفی بیمارستان مهر پرند', 'با بیمارستان مهر پرند، زیرساخت‌های درمانی، بخش‌های تخصصی، امکانات و مسیرهای ارتباط با بیمارستان آشنا شوید.'], + '/page/about': ['درباره بیمارستان', 'معرفی بیمارستان مهر ایرانیان پرند، اهداف، امکانات و آخرین وضعیت پیشرفت پروژه بیمارستان در شهر پرند.'], + '/page/contact-us': ['تماس با ما', 'آدرس بیمارستان مهر پرند در فاز ۵، بلوار شهید قاسم سلیمانی؛ شماره تماس ۰۲۱-۵۳۴۵۷۰۰۰، ایمیل و موقعیت بیمارستان روی نقشه.'], + '/page/Directors': ['تیم پروژه بیمارستان', 'آشنایی با اعضای تیم و مدیران پروژه بیمارستان مهر ایرانیان پرند.'], + '/page/docs': ['اسناد رسمی شرکت', 'دسترسی به اساسنامه و اسناد رسمی شرکت پزشکی مهر ایرانیان پرند.'], + '/page/cooperationsales': ['همکاری در فروش', 'اطلاعات و شرایط همکاری در فروش با شرکت پزشکی مهر ایرانیان پرند.'], + '/Gallery': ['گالری تصاویر بیمارستان', 'تصاویر ساختمان، فضاهای داخلی و روند پیشرفت پروژه بیمارستان مهر پرند.'], + '/News': ['اخبار و اطلاعیه‌ها', 'اخبار، اطلاعیه‌ها و فایل‌های معرفی بیمارستان مهر ایرانیان پرند.'], + '/questions': ['سوالات متداول', 'پاسخ به سوالات متداول درباره بیمارستان مهر پرند و امکانات باشگاه سهامداران.'], + '/recruitment': ['فرصت‌های شغلی و استخدام', 'مشاهده فرصت‌های شغلی و گروه‌های استخدامی بیمارستان مهر ایرانیان پرند و ارسال درخواست همکاری.'], + '/medical-equipment': ['واحد تجهیزات پزشکی', 'آشنایی با واحد تجهیزات پزشکی بیمارستان مهر پرند و راه‌های ارتباط با این واحد.'], + '/components/service': ['کلینیک‌ها و بخش‌ها', 'معرفی کلینیک‌ها و بخش‌های تخصصی بیمارستان مهر پرند و امکانات هر بخش.'], + '/stock-unit': ['واحد امور سهام', 'اطلاعات واحد امور سهام بیمارستان مهر پرند و دسترسی به خدمات و راه‌های ارتباط با این واحد.'] +} + +const UTILITY_TITLES = { + '/CooperationRequest': 'درخواست همکاری', + '/recruitment/inquiry': 'استعلام وضعیت درخواست استخدام', + '/stock-unit/stock-check': 'پیگیری درخواست سهام', + '/stock-unit/stock-sales': 'درخواست خرید سهام', + '/stock-unit/stock-transfer': 'درخواست انتقال سهام', + '/stock-price': 'قیمت سهام بیمارستان مهر پرند', + '/page/stock-price': 'نمودار قیمت سهام', + '/page/sales': 'ارتباط با واحد فروش', + '/smartadvice': 'مشاوره سرمایه‌گذاری' +} + +function canonicalUrl(path = '/') { + const clean = String(path).split(/[?#]/)[0] + if (!clean.startsWith('/') || clean.startsWith('//') || clean.includes('\\')) return `${SITE_URL}/` + return SITE_URL + (clean.replace(/\/+$/, '') || '/') +} + +function getSeo(path = '/', overrides = {}) { + const canonical = canonicalUrl(path) + const pathname = canonical.slice(SITE_URL.length) + const page = PUBLIC_PAGES[pathname] + const dynamicPublic = /^\/page\/news\/[^/]+$/.test(pathname) || /^\/recruitment\/item\/[^/]+$/.test(pathname) + return { + title: page ? page[0] : UTILITY_TITLES[pathname] || (pathname.startsWith('/recruitment/item/request/') ? 'ثبت درخواست استخدام' : SITE_NAME), + description: page ? page[1] : DEFAULT_DESCRIPTION, + image: DEFAULT_IMAGE, + type: 'website', + noindex: !page && !dynamicPublic, + ...overrides, + canonical + } +} + +function newsSeo(post, slug) { + const text = String(post.text || '') + .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '') + .replace(/<[^>]*>/g, ' ') + .replace(/ | /g, ' ') + .replace(/&/g, '&') + .replace(/\s+/g, ' ').trim() + const description = text.slice(0, 160) || post.title + const image = post.cover ? `https://mehrara-api.iran.liara.run/panel-public/uploads/${post.cover}` : DEFAULT_IMAGE + const canonical = canonicalUrl(`/page/news/${encodeURIComponent(slug)}`) + return { + title: post.title, + description, + image, + type: 'article', + structuredData: { + '@context': 'https://schema.org', + '@type': 'NewsArticle', + headline: post.title, + description, + image: [image], + mainEntityOfPage: canonical, + publisher: { '@id': `${SITE_URL}/#hospital` } + } + } +} + +const hospitalSchema = { + '@context': 'https://schema.org', + '@type': 'Hospital', + '@id': `${SITE_URL}/#hospital`, + name: SITE_NAME, + alternateName: 'بیمارستان مهر ایرانیان پرند', + url: `${SITE_URL}/`, + logo: DEFAULT_IMAGE, + telephone: '+98-21-53457000', + email: 'contact-us@miphospital.ir', + address: { + '@type': 'PostalAddress', + addressCountry: 'IR', + addressRegion: 'تهران', + addressLocality: 'پرند', + streetAddress: 'فاز ۵، بلوار شهید قاسم سلیمانی، نبش بلوار رسول اکرم (ص)' + }, + sameAs: ['https://www.instagram.com/miphospital/'] +} + +function serializeJsonLd(value) { + return JSON.stringify(value).replace(/ post && /^\d+$/.test(String(post.id))) + .map(post => `/page/news/${post.id}`) + return [...new Set([...Object.keys(PUBLIC_PAGES), ...articles])] +} + +module.exports = { SITE_URL, SITE_NAME, canonicalUrl, getSeo, newsSeo, hospitalSchema, serializeJsonLd, sitemapLinks } diff --git a/next.config.js b/next.config.js index aea2183..e12ae0e 100644 --- a/next.config.js +++ b/next.config.js @@ -4,13 +4,14 @@ module.exports = { experimental: { outputStandalone: true }, - siteUrl: 'https://miphospital.ir', - generateRobotsTxt: true, // (optional) ...withImages(), // future: { // webpack5: true // }, images: { disableStaticImages: true + }, + async rewrites() { + return [{ source: '/sitemap.xml', destination: '/api/my-sitemap' }] } } diff --git a/package.json b/package.json index 7f41882..4534bdb 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { + "test": "node --test tests/*.test.cjs", "dev": "next dev -p 2000", "build": "next build", "start": "next start -p 2000" diff --git a/pages/News.js b/pages/News.js index 98339a8..93ea835 100644 --- a/pages/News.js +++ b/pages/News.js @@ -1,105 +1,59 @@ -import React from 'react' -import { - MDBCard, - MDBCardImage, - MDBCardBody, - MDBCardTitle, - MDBCardText, - MDBRow, - MDBCol, - MDBContainer, - MDBCardHeader, - MDBBtn, - MDBIcon, - MDBInputGroupText, - MDBTypography -} from 'mdb-react-ui-kit' -import axios from 'axios' -import Link from 'next/link' -import AppContainer from './appContainer' -import { useRouter } from 'next/router' -import { AppUrl } from '../lib/api' -import { DownloadIcon } from '@chakra-ui/icons' - -const News = ({ data, children }) => { - const router = useRouter() - const query = router.query.list - - const dataPage = ( -
-
- اطلاعیه ها{' '} -
-
-
- - - - {' '} - دانلود دفترچه معرفی و فروش سهام عمومی بیمارستان تخصصی مهر پرند - - - - - - {' '} - دانلود طرح توجیهی بیمارستان تخصصی مهر ایرانیان پرند - - -
-
- {/* - {data && data.map(({ id, slug, title }) => ( - - - - {title} - - - {slug} - -
- - مشاهده - -
-
-
- -
- ))} -
*/} - {children} -
- ) - return
{/* {query ? : dataPage} */}
-} - -export default News - -export const getServerSideProps = async context => { -// const allPostsData = await axios.get(AppUrl('news')) -// const data = await allPostsData.data - return { - props: { - data: null, - fallback: false - } // will be passed to the page component as props - } -} +import React from 'react' +import axios from 'axios' +import Link from 'next/link' +import { useRouter } from 'next/router' +import AppContainer from './appContainer' +import { AppUrl } from '../lib/api' +import { newsSeo } from '../lib/seo' + +export default function News({ data = [], unavailable = false, children }) { + const router = useRouter() + const isHome = router.pathname === '/' + const posts = (Array.isArray(data) ? data : []).filter(post => post && post.title && /^\d+$/.test(String(post.id))) + if (isHome && posts.length === 0) return null + + const content = ( +
+ {isHome ?

اخبار و اطلاعیه‌ها

:

اخبار و اطلاعیه‌های بیمارستان مهر پرند

} +
+ {unavailable &&

دریافت اخبار موقتاً امکان‌پذیر نیست. لطفاً کمی بعد دوباره مراجعه کنید.

} +
+ {posts.map(post => ( + + ))} +
+ {!isHome && ( +
+

فایل‌های معرفی بیمارستان

+

دانلود دفترچه معرفی و فروش سهام عمومی بیمارستان مهر پرند

+

دانلود طرح توجیهی بیمارستان مهر پرند

+
+ )} + {children} +
+ ) + + return isHome ? content : {content} +} + +export async function getServerSideProps({ res }) { + try { + const { data } = await axios.get(AppUrl('news'), { timeout: 10000 }) + const posts = Array.isArray(data) ? data : data && data.data + if (!Array.isArray(posts)) throw new Error('Unexpected news response') + return { props: { data: posts } } + } catch (error) { + console.warn('News list: API unavailable:', error.message) + res.statusCode = 503 + res.setHeader('Retry-After', '60') + return { props: { data: [], unavailable: true } } + } +} diff --git a/pages/_app.js b/pages/_app.js index 7960b14..5965ec0 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -7,7 +7,7 @@ import "../styles/simple-grid.css" import 'nprogress/nprogress.css'; import "react-lightbox-pack/dist/index.css"; import Router from "next/router"; -import React, {useState} from "react"; +import React, {useEffect, useState} from "react"; import Loader from "./components/Loader"; import NProgress from 'nprogress'; import {ChakraProvider} from "@chakra-ui/react"; @@ -15,6 +15,7 @@ import { extendTheme } from '@chakra-ui/react' import { StepsStyleConfig as Steps } from 'chakra-ui-steps'; import {Provider} from "react-redux"; import {CoreStore} from "../src/redux/store/store"; +import Seo from '../src/components/Seo'; const theme = extendTheme({ components: { @@ -25,16 +26,27 @@ const theme = extendTheme({ export default function App({ Component, pageProps }) { const [Loading,setLoading] = useState(false); - Router.events.on('routeChangeStart',(url) => { + useEffect(() => { + const start = () => { NProgress.start() setLoading(true) - }) - Router.events.on('routeChangeComplete',(url) => { + } + const complete = () => { NProgress.done() setLoading(false) - }) + } + Router.events.on('routeChangeStart', start) + Router.events.on('routeChangeComplete', complete) + Router.events.on('routeChangeError', complete) + return () => { + Router.events.off('routeChangeStart', start) + Router.events.off('routeChangeComplete', complete) + Router.events.off('routeChangeError', complete) + } + }, []) return ( + = 400 ? { noindex: true } : {})} /> {Loading && } {/* */} diff --git a/pages/_document.js b/pages/_document.js new file mode 100644 index 0000000..a03a954 --- /dev/null +++ b/pages/_document.js @@ -0,0 +1,15 @@ +import Document, { Html, Head, Main, NextScript } from 'next/document' + +export default class MyDocument extends Document { + render() { + return ( + + + +
+ + + + ) + } +} diff --git a/pages/api/my-sitemap.js b/pages/api/my-sitemap.js index a4f2895..7b0c3c6 100644 --- a/pages/api/my-sitemap.js +++ b/pages/api/my-sitemap.js @@ -1,25 +1,26 @@ -const { SitemapStream, streamToPromise } = require("sitemap"); -const { Readable } = require("stream"); - -export default async (req, res) => { - // An array with your links - const links = [ - { url: "/page/news/36/", priority: 0.3 }, - { url: "/page/about", priority: 0.3 }, - { url: "/recruitment", priority: 0.3 }, - { url: "/contact-us", priority: 0.3 } - ]; - - // Create a stream to write to - const stream = new SitemapStream({ hostname: `https://${req.headers.host}` }); - - res.writeHead(200, { - "Content-Type": "application/xml", - }); - - const xmlString = await streamToPromise( - Readable.from(links).pipe(stream) - ).then((data) => data.toString()); - - res.end(xmlString); -}; \ No newline at end of file +import axios from 'axios' +import { SitemapStream, streamToPromise } from 'sitemap' +import { Readable } from 'stream' +import { AppUrl } from '../../lib/api' +import { SITE_URL, sitemapLinks } from '../../lib/seo' + +export default async function sitemap(req, res) { + let news = [] + let newsAvailable = false + try { + const { data } = await axios.get(AppUrl('news'), { timeout: 5000 }) + const posts = Array.isArray(data) ? data : data && data.data + if (!Array.isArray(posts)) throw new Error('Unexpected news response') + news = posts + newsAvailable = true + } catch (error) { + // Keep core pages discoverable during a news API outage; retry soon. + console.warn('Sitemap: news API unavailable:', error.message) + } + + const stream = new SitemapStream({ hostname: SITE_URL }) + const xml = await streamToPromise(Readable.from(sitemapLinks(news).map(url => ({ url }))).pipe(stream)) + res.setHeader('Content-Type', 'application/xml; charset=utf-8') + res.setHeader('Cache-Control', newsAvailable ? 'public, s-maxage=3600, stale-while-revalidate=86400' : 'public, s-maxage=60') + res.status(200).send(xml.toString()) +} diff --git a/pages/appContainer.js b/pages/appContainer.js index 1f54075..72f9ca4 100644 --- a/pages/appContainer.js +++ b/pages/appContainer.js @@ -6,18 +6,11 @@ import dynamic from 'next/dynamic' import { Router } from 'next/router' const Chat = dynamic(() => import('./pack'), { ssr: false }) -const AppContainer = ({ children, title }) => { +const AppContainer = ({ children }) => { return (
- {title ? title : 'بیمارستان مهر پرند '} - - - ', cover: 'news.jpg' }, '36') + assert.equal(seo.title, 'خبر افتتاح') + assert.equal(seo.description, 'خبر بیمارستان') + assert.equal(seo.image, 'https://mehrara-api.iran.liara.run/panel-public/uploads/news.jpg') + assert.equal(seo.structuredData['@type'], 'NewsArticle') + assert.equal(seo.structuredData.mainEntityOfPage, 'https://miphospital.ir/page/news/36') +}) + +test('JSON-LD cannot terminate its HTML script element', () => { + const value = { name: '' } + const serialized = serializeJsonLd(value) + assert.equal(serialized.includes('<'), false) + assert.deepEqual(JSON.parse(serialized), value) +}) + +test('sitemap contains real public routes and unique valid article IDs only', () => { + const links = sitemapLinks([{ id: 36 }, { id: 36 }, { id: '../bad' }, { id: null }, { id: 82 }]) + assert.ok(links.includes('/page/contact-us')) + assert.ok(links.includes('/page/news/36')) + assert.ok(links.includes('/page/news/82')) + assert.equal(links.filter(url => url === '/page/news/36').length, 1) + assert.equal(links.includes('/contact-us'), false) + assert.equal(links.some(url => url.includes('request') || url.includes('../')), false) +})