التسويق والمبيعات الأدلة التقنية

SEO التقني للمطورين: موقع عربي قبل الإصلاح وبعده

نبني موقعاً عربياً فيه عشر مشكلات تقنية شائعة، ونقيسه بأدوات مفتوحة المصدر، ثم نصلحه ونقيس الفرق بالأرقام.

SEO التقني للمطورين: موقع عربي قبل الإصلاح وبعده

أغلب مشكلات الظهور في البحث التي نراها في مواقع الشركات التقنية لا يكتبها كاتب المحتوى، وإنما تخرج من الكود: قالب يضع نفس الرابط القانوني Canonical في كل الصفحات، وواجهة React تبني المقال في المتصفح فيصل الزاحف Crawler إلى صفحة فارغة، وصورة بحجم ميغابايت يرفعها أحدهم من هاتفه، وسطر في robots.txt كتب قبل سنتين ونسيه الجميع. وفي مقال كيف تعمل محركات البحث من الداخل شرحنا المراحل التي تمر بها الصفحة من الزحف Crawling إلى الفهرسة Indexing إلى الترتيب Ranking، والآن سوف ننتقل من الشرح إلى التجربة.

دليلكيف تعمل محركات البحث؟ SEO من الداخل للمطورينماذا يحدث لصفحتك من الزحف إلى الفهرسة إلى الترتيب، وأين تتوقف عادة، وما الذي يملكه المطور لإصلاحه.دليلالتسويق بالمحتوى Content Marketing: ماذا تكتب ولمن؟كيف تختار موضوعات تجيب عن أسئلة عملائك قبل أن يعرفوك، وترتبها بحسب ما يعرفه القارئ، وتقيس أثرها في البحث والمبيعات.

في هذا المقال سوف نتناول:

  • بناء موقع عربي صغير في Docker فيه عشر مشكلات تقنية نجدها كثيراً في المواقع الحقيقية.
  • قياسه بثلاث أدوات مفتوحة المصدر: Lighthouse لكل صفحة، والزاحف SEOnaut للموقع كاملاً، وفاحص صغير بلغة Python لما لا تفحصه الأداتان.
  • إصلاح المشكلات واحدة واحدة، مع الكود قبل وبعد.
  • القياس مرة ثانية بنفس الأدوات، وما الذي فاتته الأدوات نفسها، ومنها مشكلة تخص العناوين العربية.

فكرة المختبر

الموقع شركة استضافة وهمية اسمها «استضافة المثال»، فيه صفحة رئيسية ومدونة بمقالين وصفحة أسعار ونسخة إنجليزية للصفحة الرئيسية، ونفس المحتوى موجود في نسختين: site-before بالمشكلات وsite-after بعد الإصلاح، ويخدمهما Container واحد من nginx نبدل المجلد الذي يقرأ منه. والصورة التالية تبين المختبر كاملاً:

مختبر SEO التقني: على اليمين الموقع العربي في nginx 1.31.6 باسم www.example.test بنسختين site-before وsite-after نختار بينهما بالمتغير SITE، وعلى اليسار أدوات القياس Lighthouse 13.5 وSEOnaut مع MySQL 8.4 والفاحص seo-check.py، وفي الأسفل المنفذ 18750 للموقع و18751 لواجهة SEOnaut
نفس المحتوى في النسختين، والفرق في الكود وإعداد nginx فقط

ولاحظ أننا استخدمنا النطاق www.example.test داخل شبكة Docker، والسبب أن اللاحقة .test محجوزة للاختبار فلا تتعارض مع أي نطاق حقيقي، وكل الأدوات تصل إلى الموقع بهذا الاسم كما يصل إليه Googlebot في موقعك.

المتطلبات

  • Docker Engine مع Docker Compose، وذاكرة RAM بحجم 4 GB على الأقل، لأن Chrome الذي يشغله Lighthouse وMySQL الذي يحتاجه SEOnaut يأخذان أغلبها.
  • اتصال بالإنترنت أول مرة لتنزيل الـ Images وحزم npm وpip.
  • لا تحتاج نطاقاً ولا شهادة TLS، فكل شيء داخل شبكة Docker، وسوف نذكر لاحقاً ما يعنيه غياب HTTPS في النتائج.

الموقع قبل الإصلاح: عشر مشكلات

لم نخترع مشكلات غريبة، فكل واحدة منها تظهر في المواقع بسبب قرار برمجي عادي، والجدول التالي يجمعها مع سببها المعتاد:

المشكلةأين في الكودالسبب المعتاد
لغة الصفحة واتجاهها خاطئان<html lang="en"> بلا dir، والاتجاه مفروض من CSSقالب إنجليزي عرب بالـ CSS فقط
كل الصفحات تشير بالرابط القانوني إلى الرئيسية<link rel="canonical" href="/"> في القالب المشتركقيمة ثابتة في الـ Layout بدل رابط الصفحة نفسها
رمز لغة خاطئ في hreflanghreflang="sa" للعربيةالخلط بين رمز الدولة SA ورمز اللغة، وsa في ISO 639-1 هي السنسكريتية
لا يوجد Sitemap/sitemap.xml يرد بالصفحة الرئيسيةلم يضفه أحد
robots.txt يمنع ملفات التصميم والبياناتDisallow: /assets/ وDisallow: /api/محاولة «إخفاء» الملفات التقنية عن محركات البحث
المحتوى يبنى بـ JavaScript فقطالمقال والقائمة والأسعار تأتي من /api/*.json بعد 1.2 ثانيةواجهة SPA دون توليد للصفحات في السيرفر
روابط لا يتبعها الزاحفhref="javascript:void(0)" وonclickتنقل مبرمج بالـ JavaScript
صور ثقيلة بلا أبعادJPEG بعرض 2400 بكسل وحجم 1.2 MB، دون width وheight، والصورة الأولى loading="lazy"رفع الصورة كما هي من الكاميرا
لا توجد بيانات منظمة Structured Dataلا يوجد JSON-LDلا يعرف الفريق أنها موجودة
السيرفر: صفحة 404 ترد بـ 200، ولا ضغط Compression ولا Cachetry_files … /index.html وgzip offإعداد SPA منسوخ لموقع عادي

وأيضاً لا يوجد وصف Meta Description، والعنوان Title نفسه في كل الصفحات. وهذا هو قالب الصفحة في النسخة المعطوبة:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>استضافة المثال</title>
<link rel="canonical" href="http://www.example.test/">
<link rel="alternate" hreflang="sa" href="http://www.example.test/">
<link rel="stylesheet" href="/assets/site.css">
<script src="/assets/app.js"></script>
</head>
<body>
<header><strong>استضافة المثال</strong>
<nav><a href="/">الرئيسية</a><a href="/blog/">المدونة</a><a href="javascript:void(0)" onclick="go('/pricing/')">الأسعار</a><a href="/en/">English</a></nav>
</header>
<main>
<div id="post">جار التحميل...</div>
<script>render('post', '/api/vps-backup.json', d => '<h1>' + d.title + '</h1>' + d.body);</script>
</main>
<footer>© 2026 استضافة المثال</footer>
</body>
</html>

وإعداد nginx للنسخة المعطوبة:

# The broken site: no compression, no cache headers, and every unknown URL answers 200 with the home page (soft 404)
server {
    listen 80;
    server_name www.example.test;
    root /usr/share/nginx/html;
    gzip off;
    location / {
        try_files $uri $uri/ /index.html;
    }
}

تشغيل المختبر

ملف Docker Compose يشغل الموقع وSEOnaut وقاعدة بياناته، والمتغير SITE يختار النسخة:

# Technical SEO lab: the same Arabic site before and after the fixes, plus the SEOnaut crawler.
#   SITE=before docker compose up -d        (then SITE=after docker compose up -d site to switch)
name: mkb-seo
services:
  site:
    image: nginx:1.31.6-alpine
    container_name: mkb-seo-site
    volumes:
      - ./site-${SITE:-before}:/usr/share/nginx/html:ro
      - ./nginx/${SITE:-before}.conf:/etc/nginx/conf.d/default.conf:ro
    ports:
      - "18750:80"
    networks:
      lab:
        aliases: [www.example.test]

  seonaut-db:
    image: mysql:8.4.11
    container_name: mkb-seo-seonaut-db
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?set in .env}
      MYSQL_DATABASE: seonaut
      MYSQL_USER: seonaut
      MYSQL_PASSWORD: ${DB_PASSWORD:?set in .env}
    volumes:
      - mkb-seo-db:/var/lib/mysql
    networks: [lab]

  seonaut:
    image: ghcr.io/stjudewashere/seonaut@${SEONAUT_DIGEST:?set in .env}
    container_name: mkb-seo-seonaut
    depends_on: [seonaut-db]
    command: sh -c "/bin/wait && /app/seonaut"
    environment:
      WAIT_HOSTS: seonaut-db:3306
      WAIT_TIMEOUT: 600
      SEONAUT_DATABASE_SERVER: seonaut-db
      SEONAUT_DATABASE_PASSWORD: ${DB_PASSWORD}
      SEONAUT_SERVER_URL: http://localhost:18751
    ports:
      - "18751:9000"
    networks: [lab]

networks:
  lab:
    name: mkb-seo-net
volumes:
  mkb-seo-db:
    name: mkb-seo-db

في الإعداد أعلاه لاحظ التالي:

  • الاسم البديل Alias www.example.test على شبكة المختبر هو ما يجعل كل الأدوات تصل إلى الموقع بنطاقه، فالروابط القانونية وروابط hreflang في الصفحات مطلقة Absolute وتحتاج نطاقاً ثابتاً.
  • SEOnaut لا ينشر إصدارات مرقمة، والـ Image المنشورة على GitHub Container Registry تحمل الوسم latest فقط، لذلك ثبتناها بالبصمة Digest في ملف .env حتى لا تتغير النتائج بين تشغيل وآخر.
  • الـ Image الرسمية لـ SEOnaut تنتظر قاعدة البيانات بأداة wait، وجعلنا مدة الانتظار 600 ثانية، والسبب أن تهيئة MySQL أول مرة على قرص بطيء قد تتجاوز الخمس دقائق الافتراضية فيتوقف SEOnaut ولا يعود.

الصور الثقيلة لا نحفظها في المستودع، وإنما يولدها سكربت make-images.py بمكتبة Pillow: صور JPEG بعرض 2400 و2000 بكسل وجودة 95 مع تشويش يشبه صور الكاميرا، فيصل حجم الواحدة إلى نحو 1 MB:

python tools/make-images.py site-before/assets
hero.jpg 1273432 bytes
backup.jpg 954755 bytes
vps.jpg 966746 bytes

الآن نولد كلمات المرور في ملف .env ونشغل النسخة المعطوبة:

cat > .env <<EOF
DB_ROOT_PASSWORD=$(openssl rand -hex 16)
DB_PASSWORD=$(openssl rand -hex 16)
SEONAUT_DIGEST=sha256:d18721b6c07f333694ebe241d06442827ad56e343d34103051a3e5028f1697d9
EOF
SITE=before docker compose up -d

القياس الأول

Lighthouse لكل صفحة

Lighthouse أداة Google مفتوحة المصدر بترخيص Apache-2.0، تفتح الصفحة في Chrome وتعطي درجة للأداء Performance وفئة لفحوص SEO وأخرى للممارسات الجيدة Best Practices. وقد استخدمناه في اختبار سهولة الوصول لموقع عربي لفئة سهولة الوصول، وهنا نشغله من نفس Docker Image الخاصة بـ Playwright لأنها تحتوي Chrome جاهزاً. والسكربت التالي يمر على أربع صفحات ويطبع الدرجات والفحوص التي فشلت:

#!/bin/sh
# Runs Lighthouse (mobile, default throttling) on every page and prints the scores and the failed SEO audits.
# Run inside the Playwright container on the lab network:  sh /lab/tools/lighthouse.sh before
set -e
TAG=${1:-before}
export CHROME_PATH=$(ls -d /ms-playwright/chromium-*/chrome-linux*/chrome | head -1)
mkdir -p /tmp/lh && cd /tmp/lh
[ -d node_modules/lighthouse ] || npm i --silent [email protected] >/dev/null
for P in / /blog/ /blog/vps-backup/ /pricing/; do
  OUT="/out/lh-$TAG-$(echo "$P" | tr '/' '_').json"
  npx lighthouse "http://www.example.test$P" --output=json --output-path="$OUT" --quiet \
    --only-categories=performance,seo,best-practices --chrome-flags="--headless=new --no-sandbox"
  node -e '
const r = require(process.argv[1]), a = r.audits, c = r.categories;
const s = k => Math.round(c[k].score * 100);
console.log(process.argv[2].padEnd(20), "perf", s("performance"), " seo", s("seo"), " bp", s("best-practices"),
  " LCP", a["largest-contentful-paint"].displayValue, " CLS", a["cumulative-layout-shift"].displayValue,
  " bytes", Math.round(a["total-byte-weight"].numericValue / 1024) + " KiB");
for (const id of r.categories.seo.auditRefs.map(x => x.id)) if (a[id].score === 0) console.log("    seo fail:", id, "-", a[id].title);
' "$OUT" "$P"
done

ونشغله داخل Container على شبكة المختبر:

mkdir -p out
docker run --rm --network mkb-seo-net --shm-size=1g -v "$PWD:/lab:ro" -v "$PWD/out:/out" \
  -v mkb-seo-npm:/tmp/lh mcr.microsoft.com/playwright:v1.64.0-noble sh /lab/tools/lighthouse.sh before

والمخرج سوف يكون كما يلي:

/                    perf 54  seo 85  bp 78  LCP 11.1 s  CLS 0.22  bytes 3125 KiB
    seo fail: meta-description - Document does not have a meta description
    seo fail: crawlable-anchors - Links are not crawlable
/blog/               perf 95  seo 75  bp 78  LCP 1.5 s  CLS 0.002  bytes 4 KiB
    seo fail: meta-description - Document does not have a meta description
    seo fail: crawlable-anchors - Links are not crawlable
    seo fail: canonical - Document does not have a valid `rel=canonical`
/blog/vps-backup/    perf 73  seo 77  bp 78  LCP 6.0 s  CLS 0.08  bytes 937 KiB
    seo fail: meta-description - Document does not have a meta description
    seo fail: crawlable-anchors - Links are not crawlable
    seo fail: canonical - Document does not have a valid `rel=canonical`
/pricing/            perf 100  seo 75  bp 78  LCP 1.0 s  CLS 0.007  bytes 4 KiB
    seo fail: meta-description - Document does not have a meta description
    seo fail: crawlable-anchors - Links are not crawlable
    seo fail: canonical - Document does not have a valid `rel=canonical`

في المخرج أعلاه لاحظ التالي:

  • الصفحة الرئيسية ثقيلة: 3125 KiB، وأكبر عنصر مرئي LCP (Largest Contentful Paint) ظهر بعد 11.1 ثانية، والحد الذي توصي به Google هو 2.5 ثانية، واهتزاز الصفحة CLS (Cumulative Layout Shift) بلغ 0.22 والحد 0.1، والسبب الصور التي لا تحمل أبعاداً فتدفع النص للأسفل عند تحميلها.
  • صفحة الأسعار أخذت 100 في الأداء، وصفحة المدونة 95، وهذا ليس لأنهما جيدتان، وإنما لأنهما شبه فارغتين عند التحميل الأول. لذلك لا تحكم على صفحة من درجة الأداء وحدها.
  • فحص canonical فشل في كل الصفحات ما عدا الرئيسية، لأن Lighthouse يعتبر الرابط القانوني الذي يشير إلى الصفحة الرئيسية من صفحة أخرى خطأ كما في توثيق الفحص.
  • درجة الممارسات الجيدة 78 بسبب غياب HTTPS، وهذا متوقع في المختبر، وفي موقعك الحقيقي يجب أن يكون HTTPS مفعلاً كما في دليل Nginx Proxy Manager.

والأهم هو ما لم يقله Lighthouse: فحص hreflang نجح مع أن الرمز sa خاطئ، والسبب أنه رمز صحيح في ISO 639-1 للغة السنسكريتية، والأداة تتحقق من الصيغة وليس من المعنى. وفحص robots-txt نجح أيضاً مع أن الملف يمنع ملفات التصميم والبيانات، لأن الملف صحيح في صيغته. ولم يعترض على lang="en" في صفحة عربية.

ونتائج Lighthouse تتغير من تشغيل لآخر على نفس الجهاز، لذلك شغلنا الصفحة الرئيسية خمس مرات وأخذنا الوسيط Median كما في صفحة طريقة حساب الدرجة:

perf scores 51 55 57 64 64
median run: perf 57 seo 85 FCP 1.0 s LCP 16.8 s TBT 330 ms CLS 0.22 SI 1.4 s bytes 3125 KiB

ماذا يرى الزاحف الذي لا يشغل JavaScript؟

يعالج Googlebot الصفحات التي تعتمد على JavaScript على مرحلتين: يزحف إلى الـ HTML أولاً، ثم يضعها في طابور التصيير Render Queue حتى يشغلها Chromium لاحقاً، وبحسب توثيق Google قد تبقى الصفحة في هذا الطابور ثواني، وقد تأخذ أطول من ذلك. وأما أغلب الزواحف الأخرى، ومنها أدوات معاينة الروابط في تطبيقات المحادثة، فتقرأ الـ HTML كما هو. والسكربت التالي يعد كلمات المحتوى داخل <main> في الـ HTML الخام، ثم بعد أن يشغل Chrome السكربتات:

// What does a crawler that does not run JavaScript see? Compares the words inside <main> in the raw HTML
// (what curl, most AI crawlers and the first pass of Googlebot get) with the DOM after Chrome runs the scripts.
//   node text-check.mjs http://www.example.test / /blog/ /blog/vps-backup/ /pricing/
import { execFileSync } from 'node:child_process';
const [base, ...paths] = process.argv.slice(2);
const chrome = execFileSync('sh', ['-c', 'ls -d /ms-playwright/chromium-*/chrome-linux*/chrome | head -1']).toString().trim();
const words = (html) => {
  const main = (html.match(/<main[\s\S]*?<\/main>/) || [''])[0]
    .replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]+>/g, ' ');
  return main.split(/\s+/).filter(w => /[؀-ۿA-Za-z0-9]/.test(w)).length;
};
const links = (html) => [...html.matchAll(/<a [^>]*href="(\/[^"]*)"/g)].map(m => m[1]);
console.log('page'.padEnd(20), 'raw words', ' rendered words', ' raw <a href> links');
for (const p of paths) {
  const raw = await (await fetch(base + p)).text();
  const dom = execFileSync(chrome, ['--headless=new', '--no-sandbox', '--virtual-time-budget=10000', '--dump-dom', base + p],
    { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
  console.log(p.padEnd(20), String(words(raw)).padStart(9), String(words(dom)).padStart(15), '  ' + [...new Set(links(raw))].join(' '));
}
docker run --rm --network mkb-seo-net --shm-size=1g -v "$PWD:/lab:ro" mcr.microsoft.com/playwright:v1.64.0-noble \
  node /lab/tools/text-check.mjs http://www.example.test / /blog/ /blog/vps-backup/ /blog/choose-vps/ /pricing/

والمخرج:

page                 raw words  rendered words  raw <a href> links
/                           39              39   / /blog/ /en/
/blog/                       3              13   / /blog/ /en/
/blog/vps-backup/            2              71   / /blog/ /en/
/blog/choose-vps/            2              58   / /blog/ /en/
/pricing/                    3              11   / /blog/ /en/

المقال الذي يقرؤه الزائر في 71 كلمة يصل إلى الزاحف بكلمتين هما «جار التحميل»، ولا يوجد في أي صفحة رابط حقيقي إلى المقالين أو إلى صفحة الأسعار. والصورة التالية تبين نفس الصفحة في المتصفح ودون JavaScript:

صفحتان جنباً إلى جنب لنفس المقال: على اليسار في المتصفح يظهر العنوان النسخ الاحتياطي لسيرفر VPS خطوة بخطوة والصورة والنص، وعلى اليمين دون JavaScript لا يظهر إلا القائمة وعبارة جار التحميل وسطر حقوق النشر
نفس الصفحة: في المتصفح (يسار) ودون JavaScript (يمين)

وقد يتساءل البعض: إذا كان Google يشغل JavaScript، فلماذا نهتم؟ والإجابة أن المشكلة هنا مركبة، فملف robots.txt يمنع /api/، وبالتالي حتى عندما يصير Googlebot الصفحة لن يستطيع جلب ملف JSON الذي فيه المقال، فيرى نفس الصفحة الفارغة. وهذا مثال على أن كل مشكلة وحدها قد تبدو بسيطة، واجتماعها هو الذي يخفي المحتوى.

الزاحف SEOnaut للموقع كاملاً

SEOnaut زاحف مفتوح المصدر بترخيص MIT مكتوب بلغة Go، يبدأ من الرابط الرئيسي ويتبع الروابط مثل محرك البحث، ثم يعرض المشكلات مقسمة إلى حرجة Critical وتنبيه Alert وتحذير Warning. بعد التشغيل افتح http://localhost:18751، وأنشئ حساباً، وأضف مشروعاً برابط http://www.example.test/ مع تفعيل خيار الزحف عبر الـ Sitemap، ثم اضغط Crawl Now. وجد SEOnaut ثلاث صفحات فقط من ست، هي الرئيسية والمدونة والإنجليزية، والسبب أن المقالين والأسعار لا توجد إليها روابط في الـ HTML ولا يوجد Sitemap. وهذه المشكلات التي عرضها:

صفحة Site Issues في SEOnaut للنسخة المعطوبة: في Critical صفحات تستخدم HTTP لثلاثة روابط، وفي Alert غياب الوصف لثلاث صفحات وصفحات غير قانونية Non-canonical وغياب x-default في hreflang وعنوان قصير وصور بلا نص بديل alt وغياب الإشارة الذاتية في hreflang وعدم تطابق اللغة، وفي Warning محتوى قليل وغياب HSTS وCSP وغياب وسم viewport وصور بلا أبعاد
SEOnaut على النسخة المعطوبة: ثلاث صفحات فقط، ومشكلات لم يرها Lighthouse

ولاحظ أن SEOnaut وجد ما فات Lighthouse: عدم تطابق اللغة Mismatching Language بين lang="en" وقيمة hreflang، وغياب الإشارة الذاتية Self-reference في hreflang، وغياب x-default، وغياب وسم viewport الذي يعني أن الصفحة غير مهيأة للهاتف. ولم يفحص Lighthouse هذه الأمور لأنه يرى صفحة واحدة، أما الزاحف فيقارن الصفحات ببعضها.

فاحص لما لا تفحصه الأداتان

بقيت أمور لا تفحصها الأداتان كما نريد: هل يستطيع Googlebot جلب ملفات CSS والبيانات بحسب robots.txt؟ وهل الـ Sitemap صحيح حسب بروتوكول Sitemaps؟ وهل كل رابط hreflang له رابط عائد Return Link؟ وهل توجد بيانات JSON-LD؟ لذلك كتبنا فاحصاً صغيراً بلغة Python يستخدم Protego، وهو المحلل الذي يستخدمه Scrapy لقراءة robots.txt بحسب RFC 9309، وextruct لاستخراج البيانات المنظمة، وlxml للتحقق من الـ Sitemap بملف sitemap.xsd الرسمي:

# Checks what Lighthouse does not: robots.txt rules (with Protego, the parser Scrapy uses), the sitemap against the
# sitemaps.org XSD, lang/dir, canonical and hreflang return links, JSON-LD (extruct), soft 404, compression and caching.
#   python seo-check.py http://www.example.test / /blog/ /blog/vps-backup/ /blog/choose-vps/ /pricing/ /en/
import sys, json, urllib.request, urllib.error
from lxml import etree, html as lhtml
from protego import Protego
import extruct

base, paths = sys.argv[1], sys.argv[2:]

def get(path, headers=None):
    req = urllib.request.Request(base + path, headers=headers or {'User-Agent': 'Mozilla/5.0 (seo-check)'})
    try:
        r = urllib.request.urlopen(req)
        return r.status, dict(r.headers), r.read()
    except urllib.error.HTTPError as e:
        return e.code, dict(e.headers), e.read()

print('== robots.txt')
st, _, body = get('/robots.txt')
rp = Protego.parse(body.decode() if st == 200 else '')
print('status', st, '| sitemaps declared:', list(rp.sitemaps) or 'none')
for p in ['/blog/vps-backup/', '/assets/site.css', '/assets/app.js', '/api/vps-backup.json']:
    print(f'  Googlebot may fetch {p:24} {rp.can_fetch(base + p, "Googlebot")}')

print('== sitemap.xml')
st, _, body = get('/sitemap.xml')
ctype = _.get('Content-Type', '')
if st != 200 or 'xml' not in ctype:
    print('status', st, ctype, '-> no sitemap')
else:
    xsd = etree.XMLSchema(etree.parse('/lab/tools/sitemap.xsd'))
    doc = etree.fromstring(body)
    ok = xsd.validate(doc)
    locs = doc.findall('.//{http://www.sitemaps.org/schemas/sitemap/0.9}loc')
    print('status', st, '| urls', len(locs), '| valid against sitemap.xsd:', ok, '' if ok else xsd.error_log.last_error)

print('== pages')
for p in paths:
    st, h, body = get(p)
    tree = lhtml.fromstring(body)
    root = tree.getroottree().getroot()
    canon = tree.xpath('//link[@rel="canonical"]/@href')
    alts = {a.get('hreflang'): a.get('href') for a in tree.xpath('//link[@rel="alternate"][@hreflang]')}
    data = extruct.extract(body.decode(), base_url=base + p, syntaxes=['json-ld'])['json-ld']
    print(f'{p:20} lang={root.get("lang")} dir={root.get("dir")} canonical={canon[0].replace(base, "") if canon else None}'
          f' hreflang={ {k: v.replace(base, "") for k, v in alts.items()} } json-ld={[d.get("@type") for d in data]}')

print('== hreflang return links')
pages = {}
for p in paths:
    _, _, body = get(p)
    tree = lhtml.fromstring(body)
    pages[p] = {a.get('hreflang'): a.get('href').replace(base, '') for a in tree.xpath('//link[@rel="alternate"][@hreflang]')}
for p, alts in pages.items():
    for code, target in alts.items():
        if target != p:
            back = target in pages and p in pages[target].values()
            print(f'  {p} -> {target} ({code}): return link {"ok" if back else "MISSING"}')

print('== server')
st, _, _ = get('/no-such-page/')
print('unknown URL status', st, '(soft 404)' if st == 200 else '')
st, h, body = get('/', {'Accept-Encoding': 'gzip, br', 'User-Agent': 'Mozilla/5.0'})
print('home Content-Encoding', h.get('Content-Encoding', 'none'))
for img in ['/assets/hero.jpg', '/assets/hero.webp']:
    st, h, body = get(img)
    if st == 200 and 'image' in h.get('Content-Type', ''):
        print(f'{img} {len(body) // 1024} KiB Cache-Control: {h.get("Cache-Control", "none")}')

نشغله في Container من Python بإصدارات محددة للحزم:

docker run --rm --network mkb-seo-net -v "$PWD:/lab:ro" -v mkb-seo-pip:/venv python:3.13.17-slim sh -c '
  [ -x /venv/bin/python ] || (python -m venv /venv && /venv/bin/pip install -q protego==0.6.0 extruct==0.18.0 lxml==6.0.2)
  /venv/bin/python /lab/tools/seo-check.py http://www.example.test / /blog/ /blog/vps-backup/ /blog/choose-vps/ /pricing/ /en/'

والمخرج سوف يكون كما يلي:

== robots.txt
status 200 | sitemaps declared: none
  Googlebot may fetch /blog/vps-backup/        True
  Googlebot may fetch /assets/site.css         False
  Googlebot may fetch /assets/app.js           False
  Googlebot may fetch /api/vps-backup.json     False
== sitemap.xml
status 200 text/html -> no sitemap
== pages
/                    lang=en dir=None canonical=/ hreflang={'sa': '/'} json-ld=[]
/blog/               lang=en dir=None canonical=/ hreflang={'sa': '/'} json-ld=[]
/blog/vps-backup/    lang=en dir=None canonical=/ hreflang={'sa': '/'} json-ld=[]
/blog/choose-vps/    lang=en dir=None canonical=/ hreflang={'sa': '/'} json-ld=[]
/pricing/            lang=en dir=None canonical=/ hreflang={'sa': '/'} json-ld=[]
/en/                 lang=en dir=None canonical=/ hreflang={} json-ld=[]
== hreflang return links
  /blog/ -> / (sa): return link MISSING
  /blog/vps-backup/ -> / (sa): return link MISSING
  /blog/choose-vps/ -> / (sa): return link MISSING
  /pricing/ -> / (sa): return link MISSING
== server
unknown URL status 200 (soft 404)
home Content-Encoding none
/assets/hero.jpg 1243 KiB Cache-Control: none

في المخرج أعلاه لاحظ التالي:

  • Googlebot ممنوع من ملف التصميم site.css وملف السكربت app.js وملف المقال vps-backup.json، وبالتالي لا يستطيع أن يرى الصفحة كما يراها الزائر. وتذكر أن robots.txt ليس وسيلة لإخفاء صفحة من نتائج البحث، فهو يمنع الزحف فقط، ولإخفاء صفحة تستخدم noindex.
  • الرابط /sitemap.xml يرد بـ 200 ولكن بنوع text/html، فهو الصفحة الرئيسية وليس Sitemap. وهذا أثر جانبي للخطأ التالي.
  • أي رابط غير موجود يرد بـ 200 مع الصفحة الرئيسية، وهذا ما تسميه Google الخطأ اللين Soft 404 في توثيق رموز HTTP، فيضيع الزاحف وقته على صفحات لا وجود لها.
  • لا يوجد ضغط للصفحات، والصورة الرئيسية 1243 KiB دون Cache-Control.

الإصلاحات

الآن سوف نصلح المشكلات بالترتيب الذي نوصي به في موقعك الحقيقي: أولاً ما يمنع محرك البحث من رؤية المحتوى، ثم ما يربك فهمه للصفحات، ثم السرعة، ثم الإضافات.

المحتوى في الـ HTML نفسه

الحل البسيط هو أن نرفع المنع عن /api/ في robots.txt ونترك Googlebot يصير الصفحة، وهذا يعمل مع Google في أغلب الحالات، ولكنه يترك المحتوى رهينة طابور التصيير، ولا يصل إلى الزواحف التي لا تشغل JavaScript. وأما التصيير الديناميكي Dynamic Rendering، أي أن ترسل للزواحف نسخة مصيرة وللزوار نسخة JavaScript، فتقول Google في صفحته إنه حل مؤقت ولا توصي به. والحل الأنسب هو أن يكون المحتوى في الـ HTML الذي يرسله السيرفر، إما بالتصيير في السيرفر Server-Side Rendering في إطار مثل Next.js أو Nuxt، أو بتوليد الصفحات مسبقاً Static Site Generation. وفي المختبر اخترنا التوليد المسبق، فسكربت build-after.py يقرأ نفس ملفات JSON التي كان المتصفح يجلبها ويكتب منها صفحات HTML كاملة، فلم يتغير المحتوى وإنما تغير مكان تجميعه. وأصبحت الروابط عناصر <a href> حقيقية كما في إرشادات Google للروابط:

<nav><a href="/">الرئيسية</a><a href="/blog/">المدونة</a><a href="/pricing/">الأسعار</a><a href="/en/" hreflang="en" lang="en">English</a></nav>

رأس الصفحة: اللغة والعنوان والرابط القانوني

هذا رأس صفحة المقال بعد الإصلاح كما ولده السكربت:

<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>النسخ الاحتياطي لسيرفر VPS خطوة بخطوة | استضافة المثال</title>
<meta name="description" content="كيف تضبط نسخاً احتياطياً يومياً لملفات الموقع وقاعدة البيانات خارج السيرفر، ثم تجرب الاستعادة على سيرفر جديد.">
<link rel="canonical" href="http://www.example.test/blog/vps-backup/">
<link rel="stylesheet" href="/assets/site.css">
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "Article", "headline": "النسخ الاحتياطي لسيرفر VPS خطوة بخطوة", "inLanguage": "ar", "image": ["http://www.example.test/assets/backup.webp"], "datePublished": "2026-10-01", "dateModified": "2026-10-25", "author": {"@type": "Organization", "name": "استضافة المثال", "url": "http://www.example.test/"}}</script>
</head>

في الإعداد أعلاه لاحظ التالي:

  • lang="ar" dir="rtl" في عنصر html نفسه، وأزلنا فرض الاتجاه من CSS. وتذكر أن Google لا يعتمد على lang ولا على hreflang لمعرفة لغة الصفحة وإنما يحددها بخوارزمياته، ولكن المتصفح وقارئ الشاشة وأدوات الترجمة وزواحف أخرى تعتمد عليه، وSEOnaut نفسه قارنه بقيمة hreflang.
  • الرابط القانوني لكل صفحة يشير إلى نفسها، وبرابط مطلق. وتعتبر Google الرابط القانوني إشارة قوية وليس أمراً ملزماً، فإذا أشرت من كل الصفحات إلى الرئيسية فأنت تطلب منها عملياً أن تعتبر صفحاتك نسخاً مكررة من الرئيسية.
  • عنوان ووصف مختلفان لكل صفحة، والوصف لا يرفع الترتيب، ولكن Google قد تستخدمه في المقتطف Snippet الذي يظهر تحت الرابط كما في توثيق المقتطفات.
  • بيانات Article بصيغة JSON-LD، وهي الصيغة التي توصي بها Google في مقدمة البيانات المنظمة، والخصائص التي استخدمناها من توثيق Article. ووضعنا في الصفحة الرئيسية بيانات من نوع Organization.

hreflang بالرمز الصحيح والروابط العائدة

الصفحة الرئيسية لها نسخة إنجليزية، أما المقالات فلا، لذلك أزلنا hreflang من كل صفحة ليس لها نسخة بلغة أخرى، وأبقيناه في الصفحتين المتقابلتين فقط، وفي كل منهما نفس الأسطر الثلاثة:

<link rel="alternate" hreflang="ar" href="http://www.example.test/">
<link rel="alternate" hreflang="en" href="http://www.example.test/en/">
<link rel="alternate" hreflang="x-default" href="http://www.example.test/">

والقواعد من توثيق النسخ المحلية: كل نسخة تذكر نفسها وكل النسخ الأخرى، والرمز الأول للغة بصيغة ISO 639-1 فالعربية ar، والرمز الثاني اختياري للدولة بصيغة ISO 3166-1 فيصبح ar-SA إذا كانت الصفحة موجهة للسعودية تحديداً، وx-default للصفحة التي تعرض لمن لا تطابق لغته أي نسخة. وخطأ hreflang="sa" شائع لأن رمز الدولة SA يأتي إلى الذهن أولاً.

robots.txt وSitemap

أصبح robots.txt يسمح بكل شيء ويذكر مكان الـ Sitemap:

User-agent: *
Allow: /

Sitemap: http://www.example.test/sitemap.xml

والـ Sitemap يحتوي الصفحات الست بروابطها القانونية وتاريخ آخر تعديل:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>http://www.example.test/</loc><lastmod>2026-10-25</lastmod></url>
  <url><loc>http://www.example.test/en/</loc><lastmod>2026-10-25</lastmod></url>
  <url><loc>http://www.example.test/blog/</loc><lastmod>2026-10-25</lastmod></url>
  <url><loc>http://www.example.test/blog/vps-backup/</loc><lastmod>2026-10-25</lastmod></url>
  <url><loc>http://www.example.test/blog/choose-vps/</loc><lastmod>2026-10-25</lastmod></url>
  <url><loc>http://www.example.test/pricing/</loc><lastmod>2026-10-25</lastmod></url>
</urlset>

ولم نضع priority ولا changefreq لأن Google تتجاهلهما، وتستخدم lastmod فقط إذا كان دقيقاً بشكل يمكن التحقق منه، لذلك لا تقم بتحديث lastmod لكل الصفحات في كل عملية نشر، والسبب أن Google سوف تتعلم أن القيمة لا تعني شيئاً.

الصور: الحجم والصيغة والأبعاد

حولنا الصور إلى WebP بعرض أكبر مقاس يعرضه التصميم، والسكربت التالي يقوم بذلك بمكتبة Pillow:

# The fix for the heavy images: resize to the largest size the layout shows and save as WebP.
#   python make-webp.py site-before/assets site-after/assets
import sys
from pathlib import Path
from PIL import Image
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
for name, width in [('hero', 1200), ('backup', 880), ('vps', 880)]:
    im = Image.open(src / f'{name}.jpg')
    h = round(im.height * width / im.width)
    im.resize((width, h), Image.LANCZOS).save(dst / f'{name}.webp', 'WEBP', quality=75)
    print(f'{name}.jpg {(src / f"{name}.jpg").stat().st_size // 1024} KiB -> {name}.webp {width}x{h} {(dst / f"{name}.webp").stat().st_size // 1024} KiB')
hero.jpg 1243 KiB -> hero.webp 1200x675 68 KiB
backup.jpg 932 KiB -> backup.webp 880x528 36 KiB
vps.jpg 944 KiB -> vps.webp 880x528 35 KiB

ثم أضفنا الأبعاد لكل صورة حتى يحجز المتصفح مكانها قبل تحميلها، وهذا ما يمنع اهتزاز الصفحة، ومعه height: auto في CSS حتى لا تتشوه الصورة على الهاتف. والصورة الأولى في الصفحة هي غالباً أكبر عنصر مرئي LCP، لذلك لا تقم بتأجيل تحميلها بـ loading="lazy"، وإنما ارفع أولويتها، وأجل الصور التي تحتها:

<img src="/assets/hero.webp" width="1200" height="675" alt="سيرفرات استضافة المثال في مركز البيانات" fetchpriority="high">
<img src="/assets/backup.webp" width="880" height="528" alt="رسم لسيرفر ونسخة احتياطية" loading="lazy">

واختيار الصيغة نفسها شرحناه في صيغ الصور وكيف تختار بينها.

السيرفر: الضغط والـ Cache وصفحة 404

# The fixed site: compression, long caching for assets, and a real 404 status for unknown URLs
server {
    listen 80;
    server_name www.example.test;
    root /usr/share/nginx/html;

    gzip on;
    gzip_types text/css application/javascript application/json application/xml image/svg+xml;
    gzip_min_length 256;

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
    location / {
        try_files $uri $uri/ =404;
    }
    error_page 404 /404.html;
}

في الإعداد أعلاه لاحظ أن immutable مع مدة سنة يناسب الملفات التي يتغير اسمها عند تغير محتواها، فإذا كنت تعدل site.css باسمه فاجعل المدة أقصر أو أضف بصمة الإصدار إلى اسم الملف. ولاحظ أيضاً أننا أضفنا favicon.ico، لأن غيابه ظهر بعد الإصلاح مباشرة: في النسخة المعطوبة كان طلبه يرد بـ 200 مع الصفحة الرئيسية بسبب الـ Soft 404، فلما أصبح الرد 404 صحيحاً ظهر الخطأ في Console المتصفح وخفض درجة الممارسات الجيدة في Lighthouse. وهذا مثال على أن الإصلاح الصحيح يكشف أخطاء كانت مختفية.

القياس الثاني

نبدل النسخة ونعيد نفس الأوامر:

mkdir -p site-after/assets
python tools/make-webp.py site-before/assets site-after/assets
python tools/build-after.py site-before site-after
SITE=after docker compose up -d site

نتيجة Lighthouse للصفحات الأربع:

/                    perf 99  seo 100  bp 78  LCP 1.6 s  CLS 0  bytes 145 KiB
/blog/               perf 100  seo 100  bp 78  LCP 0.9 s  CLS 0  bytes 2 KiB
/blog/vps-backup/    perf 100  seo 100  bp 78  LCP 1.4 s  CLS 0  bytes 39 KiB
/pricing/            perf 99  seo 100  bp 78  LCP 1.1 s  CLS 0  bytes 2 KiB

والوسيط لخمس محاولات على الصفحة الرئيسية:

perf scores 96 100 100 100 100
median run: perf 100 seo 100 FCP 1.0 s LCP 1.5 s TBT 0 ms CLS 0 SI 1.5 s bytes 145 KiB

ومقارنة النص الخام بالمصير أصبحت متطابقة، وظهرت الروابط إلى كل الصفحات في الـ HTML:

page                 raw words  rendered words  raw <a href> links
/                           39              39   / /blog/ /pricing/ /en/ /blog/vps-backup/ /blog/choose-vps/
/blog/                      13              13   / /blog/ /pricing/ /en/ /blog/vps-backup/ /blog/choose-vps/
/blog/vps-backup/           71              71   / /blog/ /pricing/ /en/
/blog/choose-vps/           58              58   / /blog/ /pricing/ /en/
/pricing/                   11              11   / /blog/ /pricing/ /en/

والفاحص:

== robots.txt
status 200 | sitemaps declared: ['http://www.example.test/sitemap.xml']
  Googlebot may fetch /blog/vps-backup/        True
  Googlebot may fetch /assets/site.css         True
  Googlebot may fetch /assets/app.js           True
  Googlebot may fetch /api/vps-backup.json     True
== sitemap.xml
status 200 | urls 6 | valid against sitemap.xsd: True
== pages
/                    lang=ar dir=rtl canonical=/ hreflang={'ar': '/', 'en': '/en/', 'x-default': '/'} json-ld=['Organization']
/blog/               lang=ar dir=rtl canonical=/blog/ hreflang={} json-ld=[]
/blog/vps-backup/    lang=ar dir=rtl canonical=/blog/vps-backup/ hreflang={} json-ld=['Article']
/blog/choose-vps/    lang=ar dir=rtl canonical=/blog/choose-vps/ hreflang={} json-ld=['Article']
/pricing/            lang=ar dir=rtl canonical=/pricing/ hreflang={} json-ld=[]
/en/                 lang=en dir=ltr canonical=/en/ hreflang={'ar': '/', 'en': '/en/', 'x-default': '/'} json-ld=[]
== hreflang return links
  / -> /en/ (en): return link ok
  /en/ -> / (ar): return link ok
  /en/ -> / (x-default): return link ok
== server
unknown URL status 404
home Content-Encoding gzip
/assets/hero.webp 68 KiB Cache-Control: public, max-age=31536000, immutable

وأعدنا الزحف في SEOnaut، فوجد الصفحات الست والصور الثلاث وملف CSS، واختفت مشكلات اللغة وhreflang والوصف والصور:

صفحة Site Issues في SEOnaut بعد الإصلاح: في Critical صفحات تستخدم HTTP لعشرة روابط، وفي Alert روابط من HTTPS إلى HTTP لستة، ووصف طويل لخمس صفحات، وعنوان طويل لأربع صفحات، وفي Warning غياب HSTS ومحتوى قليل وغياب CSP وغياب X-Content-Type-Options
بعد الإصلاح: ما بقي هو HTTPS وترويسات الأمان، وتنبيهان عن طول العنوان والوصف

والصورة التالية تجمع الأرقام قبل وبعد:

مقارنة قبل وبعد: درجة الأداء من 57 إلى 100، وأكبر عنصر مرئي LCP من 16.8 ثانية إلى 1.5 ثانية، واهتزاز الصفحة CLS من 0.22 إلى 0، وحجم الصفحة من 3125 KiB إلى 145 KiB، ودرجة SEO في Lighthouse من 85 إلى 100، وكلمات المقال دون JavaScript من 2 إلى 71، والصفحات التي وجدها الزاحف من 3 إلى 6
نفس المحتوى، والفرق كله في الكود

تنبيه العنوان الطويل: الأداة تعد البايتات وليس الحروف

بعد الإصلاح ظهر تنبيهان جديدان في SEOnaut: عنوان طويل في أربع صفحات، ووصف طويل في خمس. ولكن أطول عنوان عندنا 54 حرفاً، وأطول وصف 109 حروف، وهذا ضمن الحدود التي تضعها الأداة نفسها وهي 60 للعنوان و160 للوصف. وعند قراءة كود فحص العنوان وكود فحص الوصف وجدنا أنها تستخدم دالة len() في Go، وهي تعد البايتات وليس الحروف، والحرف العربي في UTF-8 بايتان، لذلك يصبح الحد الفعلي للعنوان العربي نحو 30 حرفاً:

index.html                   title 43 chars 75 bytes | desc 109 chars 190 bytes
blog/index.html              title 51 chars 94 bytes | desc 90 chars 163 bytes
blog/vps-backup/index.html   title 54 chars 96 bytes | desc 109 chars 201 bytes
blog/choose-vps/index.html   title 52 chars 92 bytes | desc 98 chars 177 bytes
pricing/index.html           title 34 chars 59 bytes | desc 98 chars 170 bytes
en/index.html                title 50 chars 50 bytes | desc 95 chars 95 bytes

صفحة الأسعار بعنوان 34 حرفاً و59 بايتاً هي الوحيدة التي لم تنبه عليها الأداة، والصفحة الإنجليزية بنفس طول العناوين العربية تقريباً مرت دون تنبيه. لذلك عندما تستخدم أي أداة SEO على موقع عربي، فاختبر حدودها على صفحة تعرف طولها قبل أن تعدل عناوينك من أجلها. ولاحظ أيضاً أن Google لا تضع حداً بعدد الحروف للعنوان، وإنما تقطعه بحسب عرض الشاشة.

ما الذي لا يقيسه هذا المختبر؟

  • بيانات المستخدمين الحقيقيين Field Data: Lighthouse يقيس في المختبر Lab Data بجهاز ومحاكاة شبكة ثابتين، أما Google فتعتمد في Core Web Vitals على قياسات زوارك الفعلية عند النسبة المئوية 75، وتجدها في تقرير Core Web Vitals في Search Console بعد أن يتراكم عدد كاف من الزيارات.
  • HTTPS وترويسات الأمان: كل ما بقي في SEOnaut من الفئة الحرجة سببه HTTP في المختبر، وفي الموقع الحقيقي تفعل HTTPS وتضيف HSTS وX-Content-Type-Options وسياسة CSP على مستوى الـ Reverse Proxy.
  • الفهرسة الفعلية: لا يوجد في المختبر محرك بحث، فالخطوة التالية بعد النشر أن ترسل الـ Sitemap في Search Console وتتابع تقرير الصفحات المفهرسة، وأن تفحص صفحة بأداة فحص الروابط URL Inspection لترى الـ HTML المصير كما رآه Googlebot.
  • المحتوى نفسه: الإصلاحات التقنية تجعل الصفحة قابلة للزحف والفهم، ولكنها لا تجعلها تستحق الترتيب، وهذا موضوع مقال التسويق بالمحتوى.

إزالة المختبر

docker compose down -v
docker volume rm mkb-seo-npm mkb-seo-pip
docker image rm ghcr.io/stjudewashere/seonaut@sha256:d18721b6c07f333694ebe241d06442827ad56e343d34103051a3e5028f1697d9

مشكلات شائعة وحلولها

  • Lighthouse يتوقف بالخطأ NO_FCP: المتصفح لم يرسم شيئاً، وأول ما تتحقق منه أن Chrome يصل إلى الموقع بالاسم الذي تعطيه، بتشغيل chrome --headless=new --dump-dom على نفس الرابط من نفس الـ Container.
  • SEOnaut يتوقف برسالة Timeout! After 300 seconds some hosts are still not reachable: قاعدة البيانات لم تنته من التهيئة الأولى، فزد WAIT_TIMEOUT أو شغل docker start للـ Container بعد أن تظهر رسالة ready for connections في سجل MySQL.
  • SEOnaut يجد صفحة واحدة فقط: الروابط مبنية بـ JavaScript، فهو لا يشغلها، وهذا نفس ما يحدث لأي زاحف لا يصير الصفحات، فالحل في موقعك وليس في الأداة.
  • الدرجات تتغير كثيراً بين التشغيلات: الجهاز مشغول بأشياء أخرى، فخذ الوسيط لخمس محاولات أو أكثر، ولا تقارن درجة من جهاز بدرجة من جهاز آخر.

الخلاصة

  • أخطر المشكلات هي التي تخفي المحتوى: محتوى يبنى بـ JavaScript فقط، وروابط ليست <a href>، وrobots.txt يمنع ملفات التصميم والبيانات. ومجموعها جعل الزاحف يرى ثلاث صفحات من ست، ومقالاً بكلمتين.
  • الرابط القانوني في القالب المشترك خطأ واحد يطال كل الصفحات، فاجعله يشير إلى الصفحة نفسها.
  • hreflang يحتاج رمز اللغة ar وليس رمز الدولة، وروابط عائدة بين النسخ، وx-default.
  • تحويل الصور إلى WebP بالمقاس الصحيح مع الأبعاد خفض الصفحة الرئيسية من 3125 KiB إلى 145 KiB، وLCP من 16.8 ثانية إلى 1.5 ثانية، وCLS من 0.22 إلى صفر.
  • لا تعتمد على أداة واحدة: Lighthouse لم ير خطأ hreflang ولا منع ملفات التصميم، وSEOnaut لم ير المحتوى المخفي ورفع تنبيهات خاطئة على العناوين العربية لأنه يعد البايتات.

تدريب عملي

  • شغل text-check.mjs على ثلاث صفحات من موقعك، وقارن عدد الكلمات في الـ HTML الخام بعد التصيير.
  • أضف إلى seo-check.py فحصاً لطول العنوان بعدد الحروف وليس البايتات، واجعله ينبه فوق 60 حرفاً.

وأقرب المقالات إلى هذا الموضوع في السلسلة:

دليلكيف تعمل محركات البحث؟ SEO من الداخل للمطورينماذا يحدث لصفحتك من الزحف إلى الفهرسة إلى الترتيب، وأين تتوقف عادة، وما الذي يملكه المطور لإصلاحه.دليلأدوات SEO: الأدوات التجارية وبدائلها المجانيةما الذي تقدمه Ahrefs وSemrush وMoz فعلاً، وما الذي تغطيه مجاناً أدوات محركات البحث وأدوات مفتوحة المصدر جربناها على موقع عربي.دليلالتسويق بالمحتوى Content Marketing: ماذا تكتب ولمن؟كيف تختار موضوعات تجيب عن أسئلة عملائك قبل أن يعرفوك، وترتبها بحسب ما يعرفه القارئ، وتقيس أثرها في البحث والمبيعات.

سجل التحديثات

  • أكتوبر 2026: كتابة المختبر وتشغيله على nginx 1.31.6 وLighthouse 13.5.0 وSEOnaut (بصمة d18721b6) وProtego 0.6.0 وextruct 0.18.0 في Docker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.