SEO למפתחי אתר

לדעת בדיוק מה קונטרולר, טופס ו-theme של אתר חייבים לעשות כדי שתגיות ה-SEO, הכתובות הידידותיות, robots, 404/503, sitemaps ו-schema יעבדו עם המנוע.

⏱ 11 דק' קריאה 1941 מילים ערוך דף זה ב-GitHub

העמוד הזה מרכז את מה שמפתח אתר צריך לעשות בצד שלו: ב-application/controllers, בטפסים של פאנלי הניהול וב-theme. מודל הממצאים והמנוע מתוארים ב-חבילת ה-SEO. ה-API המלא של המחלקה SEO נמצא ב-SEO (רפרנס); כאן מתואר מה לעשות איתו.

system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו), ו-{admin} הוא CONFIG::$admin_url.

תגיות הראש: SEO::set ו-tags_summary#

המחלקה SEO (system/collections/SEO.php) היא סטטית ומאותחלת לכל בקשה. הקונטרולר ממלא אותה לפני הרינדור, ו-PAGE מדפיס אותה בראש העמוד דרך SEO::tags_summary(). אתם לא קוראים ל-tags_summary() בעצמכם.

SEO::set()#

static function set($fields, $value)       // כינוי ל-set_var
static function set_var($field, $value)
מפתחמה קורה
titlestrip_tags; ממלא meta_title וגם og_title
descriptionstrip_tags + כיווץ רווחים; ממלא meta_description וגם og_description
keywordsstrip_tags; meta_keywords
image / og_imageמספר שלם ⇒ STORAGE::get_thumb($id, 1200, 627); אחרת כתובת כפי שהיא
url / og_urlog_url; משמש גם לחיפוש ב-seo_center
כל מפתח אחר (og_type, author...)נשמר כפי שהוא ומודפס כ-<meta property="og:*"> או <meta name="*">
// application/controllers/news.php
class news extends wz_controller
{
    function item()
    {
        $row = DB::query("news", ["id" => (int)$_GET["id"], "is_active" => 1])->get_row();
        if (!$row) return "ERRORPAGE";                       // 404 אמיתי, ראו למטה

        $seo = json_decode((string)$row["seo"], true) ?: [];  // FormInput_SEO במצב json_field
        SEO::set("title", $seo["meta_title"] ?: $row["title"]);
        SEO::set("description", $seo["meta_description"] ?: $row["summary"]);
        SEO::set("image", $seo["og_image"] ?: $row["image_id"]);
        SEO::set("url", CONFIG::$site_url . "news/" . (int)$row["id"]);
        SEO::set("og_type", "article");
        SEO::set_key("news_" . (int)$row["id"]);              // מפתח לרשומה ב-seo_center (is_by_url = 0)
        if (!empty($seo["title_include_sitename"])) SEO::$title_include_sitename = true;

        return $this->view("news/item", ["row" => $row]);
    }
}

מה tags_summary עושה#

  1. ממלא ברירות מחדל מ-CONFIG::$platform_data["seo_title" | "seo_description" | "seo_keywords"] לכל שדה ריק. אם לא הוגדרה כותרת, $title_include_sitename מכובה (אחרת שם האתר היה מודפס פעמיים).
  2. מחפש רשומה ב-CRM_seo_center: קודם לפי הנתיב הנוכחי (page_key = הנתיב בלי / מוביל וסוגר, is_by_url = 1), ואם לא נמצא - לפי SEO::$page_key עם is_by_url = 0. רשומה שנמצאה דורסת את מה שהקונטרולר הגדיר, שדה אחר שדה (שדות ריקים ברשומה מדולגים). scripts מוזרק דרך PAGE::add_meta().
  3. מוסיף <meta name="twitter:card" content="summary_large_image"> ואת תגית האימות של Search Console (SeoVerification::meta_tag()).
  4. מדפיס <title> (עם " - <seo_title של הפלטפורמה>" כש-$title_include_sitename דלוק), og:*, twitter:* ו-<meta name="…">.
שתי נקודות שכדאי להכיר

ה-<title> מודפס מהערך הגולמי ולא מהערך שעבר htmlspecialchars - & בכותרת לא יקודד. בנוסף, החיפוש ב-seo_center הוא לפי page_key ו-is_by_url בלבד, בלי platform_id ו-lang: באתר רב-פלטפורמות אותו נתיב מקבל אותו meta בכל הדומיינים.

איפה ה-theme נכנס#

PAGE::output() בונה את ה-<head> בעצמו: SEO::tags_summary(), <base href>, הנכסים, PAGE::print_meta_tags("head"), ואז <meta name="ROBOTS" content="…">. theme (class TEMPLATE_<name> extends bgl_theme) אחראי על הגוף; הוא לא צריך (ולא אמור) להדפיס תגיות SEO בעצמו. ראו ערכות נושא ו-PAGE: נכסים, מטא ואירועים.

FormInput_SEO בטפסי הרשומות#

הקובץ system/libraries/forms/FormInputs_SEO.php (שימו לב: שם הקובץ ברבים, המחלקה FormInput_SEO). שני מצבים:

// מצב A - שורה ב-CRM_seo_center לפי מפתח (ברירת המחדל)
$seo = new FormInput_SEO("seo");
$seo->key = "news___NEWID__";          // __NEWID__ מוחלף ב-id אחרי ההוספה
$seo->pageUrl = "news/item?id=__NEWID__";  // אופציונלי: מוסיף שדה SeoUrl ששומר שורה ב-CRM_seoUrl
$seo->is_by_url = "0";
$frm->push($seo);

// מצב B - JSON בעמודה על הרשומה. זה המצב ש-SeoContent/SEOK דורשים.
$seo = new FormInput_SEO("seo", true);  // json_field = true ⇒ save_to_db = true, requireID = false
$seo->title = "SEO";
$frm->push($seo);
מאפייןטיפוסברירת מחדלמשמעות
keystring-page_key של הרשומה ב-seo_center (מצב A)
pageUrlstringnullהכתובת הפנימית; כשמוגדר, נוסף תת-שדה SeoUrl ששומר ב-CRM_seoUrl
is_by_url"0"/"1""0"סוג המפתח ב-seo_center
json_fieldboolfalseמצב B; נקבע בבנאי
seo_fieldsarraytitle/description/content של הטופסהסלקטורים שהאנלייזר קורא
focus_keyphrasestring""ביטוי מפתח לאנלייזר

תתי-השדות: meta_title (60), title_include_sitename, meta_description (50-160), meta_keywords, og_title (60), og_description, og_image (קובץ ב-media/seo), ובמצב A עם pageUrl גם SeoUrl. api_schema() חושף אותם ל-MCP כקבוצה; בשמירה דרך AI/MCP (כותרת AI-Form-Insert) שדה שלא נשלח נשאר כפי שהוא (keep_stored()).

SeoUrl תפוס נבלע בשקט
_saveSeoUrl() מחזירה "ERROR" כשה-slug כבר שייך לכתובת אחרת, אבל get_results() לא מעבירה את זה למשתמש: הרשומה נשמרת בלי כתובת ידידותית ובלי הודעה. בדקו את CRM_seoUrl אחרי שמירה כשמשהו "לא מקבל כתובת".

כתובות ידידותיות ו-canonical#

כתובות ידידותיות הן שורות ב-CRM_seoUrl (sysname = הכתובת החיצונית, pageurl = הכתובת הפנימית, regularexp). הפאנל /{admin}/Seo עורך אותן, FormInput_SEO עם pageUrl יוצר אותן אוטומטית, ו-ROUTER ממפה אותן בכל בקשה. הפרטים ב-כתובות ידידותיות.

הליבה לא מדפיסה <link rel="canonical"> בעצמה. הקונטרולר מוסיף אותו:

PAGE::add_meta('<link rel="canonical" href="' . htmlspecialchars(CONFIG::$site_url . "news/" . (int)$row["id"], ENT_QUOTES) . '">');

מטמון העמודים מסיר פרמטרי מעקב (utm_*, gclid...) מהמפתח שלו, כך ש-canonical שנבנה מהנתיב ולא מ-REQUEST_URI יהיה נכון גם מהמטמון. ראו מטמון עמודים מלא. ממצא A1.9 (canonical לדומיין אחר) הוא מהבדיקות ש"שואלות" (question) ולא מסמנות אוטומטית.

robots#

PAGE::$robots הוא מחרוזת עם ברירת מחדל "ALL", מודפסת כ-<meta name="ROBOTS" content="…"> בראש כל עמוד:

PAGE::$robots = "noindex, follow";   // עמודי חיפוש פנימי, עמודי תיוג דלילים, תצוגות מקדימות

דפי NEWPAGE מוסיפים noindex, nofollow לתצוגה מקדימה באותה דרך (PAGE::add_meta). אין בליבה מנגנון ל-X-Robots-Tag חוץ מקובץ המפתח של IndexNow; אם צריך, header() בקונטרולר.

robots.txt עצמו הוא קובץ של האתר, לא של הליבה. המנוע קורא אותו ומצפה ל:

  • שורות Sitemap: שמצביעות על כל מפות האתר (בדיקה A8.2). אלה גם המקור היחיד ל-SeoIndexNow::inventory().
  • מדיניות זחלני AI שלא תופסת בטעות את Googlebot (A8.5, רצפת חומרה critical). SeoGeo::parse_crawlers($robots) הוא הפענוח שהמנוע משתמש בו: רשימת CRAWLERS של סוכני AI והאם כל אחד חסום.

404 מול 503#

זה החלק ש-SEO נפגע ממנו הכי מהר, ולכן הליבה מקפידה עליו:

מצבמה לעשותמה הליבה עושה
הרשומה לא קיימתreturn "ERRORPAGE"; מהקונטרולרMODULE::errorpage() מנתב מחדש ל-"404", שולח HTTP/1.1 404 Not Found, מסמן PAGE::$is_404 = true ומכבה את מטמון העמוד
אין שורת seoUrl בשם 404הגדירו אחת ב-/{admin}/Seo (sysname = 404, pageurl = הקונטרולר של עמוד השגיאה)בלעדיה: die("page not found. please set 404 page.")
מסד הנתונים לא עונהכלום - אל תתפסו את זה בעצמכםMODULE::errorpage() בודק DB::had_error() וקורא ל-MISC::service_unavailable(): 503 עם Retry-After במקום 404 שגורם לדה-אינדוקס
תחזוקה יזומהMISC::service_unavailable("maintenance", 600) בתחילת הבקשה503 + Retry-After: 600
public static function service_unavailable($reason = "", $retry_after = 120)
אל תחזירו 200 עם טקסט שגיאה
print $e->getMessage(); die(); הוא HTTP 200 עם גוף שגיאה. גוגל מפרש אותו כ"זה העמוד עכשיו" ומחליף את התוכן באינדקס. אותו דבר ל-header("Location: /") מעמוד שלא קיים. ממצא A1.5 (soft-404) הוא ברצפת critical בדיוק בגלל זה. ראו 404, 503 ושגיאות.

sitemaps#

הליבה לא מייצרת sitemap. הציפיות של המנוע מהאתר:

בדיקהציפייה
A8.2robots.txt מצהיר על כל המפות ב-Sitemap:
A8.3אתר חדשות מגיש news-sitemap עם כתבות מ-48 השעות האחרונות בלבד
K3.4lastmod בפורמט W3C
B11.xהמפות מוגשות ל-Search Console ונקראות; שגיאות שם הן ממצא ברצפת medium

שלד של קונטרולר sitemap באתר (רשמו sitemap.xml כשורת seoUrl שמצביעה ל-sitemap/index):

class sitemap extends wz_controller
{
    function index()
    {
        $_GET["pmode"] = "empg";
        PAGE::$cache_this_page = false;
        header("Content-Type: application/xml; charset=utf-8");

        $rows = DB::get_all("SELECT id, date_updated FROM CRM_news WHERE is_active = 1 ORDER BY id DESC LIMIT 50000");
        $xml = '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
        foreach ((array)$rows as $r)
        {
            $xml .= "<url><loc>" . htmlspecialchars(CONFIG::$site_url . "news/" . (int)$r["id"]) . "</loc>"
                  . "<lastmod>" . date("c", strtotime($r["date_updated"])) . "</lastmod></url>";
        }
        return $xml . "</urlset>";
    }
}

SeoIndexNow::inventory() ו-SeoGeo::fetch_files() הם הקוד שקורא את מה שהאתר מצהיר; אם רוצים לדעת מה המנוע "רואה", זה המקום.

Schema.org#

SEO::get_schema()#

static function get_schema($type)   // "WebPage" | "Organization" | "NewsArticle"
אל תשתמשו ב-get_schema

המימוש מכיל נתוני placeholder של אתר אחר: לוגו מ-rcs.mako.co.il, מחבר "משה משה", ותאריכי datePublished/dateModified קבועים 2021-12-12. אתר שמדפיס get_schema("NewsArticle") מפרסם structured data שקרי, שהמנוע עצמו (משפחת A3) יסמן. המתודה נשארת בקוד לתאימות בלבד.

בנו JSON-LD בעצמכם והזריקו אותו דרך PAGE::add_meta():

$ld = [
    "@context" => "https://schema.org",
    "@type" => "NewsArticle",
    "mainEntityOfPage" => ["@type" => "WebPage", "@id" => CONFIG::$site_url . "news/" . (int)$row["id"]],
    "headline" => $row["title"],
    "image" => [STORAGE::get_thumb((int)$row["image_id"], 1200, 627)],
    "datePublished" => date("c", strtotime($row["date_created"])),
    "dateModified" => date("c", strtotime($row["date_updated"])),
    "author" => ["@type" => "Person", "name" => $row["author_name"]],
    "publisher" => ["@type" => "Organization", "name" => CONFIG::$platform_data["seo_title"], "logo" => ["@type" => "ImageObject", "url" => CONFIG::$site_url . "assets/logo.png"]],
];
PAGE::add_meta('<script type="application/ld+json">' . json_encode($ld, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>');

ממצא A3.1 (אין NewsArticle) ברצפת high; A5.1 (אין max-image-preview:large) גם. את השני פותרים עם PAGE::$robots = "ALL, max-image-preview:large" או <meta name="robots" content="max-image-preview:large"> נוסף.

מה המנוע צריך מפאנל התוכן#

כדי ש-SEOK יוכל לסרוק כותרות (A2.1/A2.2), להציע ולהחיל meta_title/meta_description:

  1. עמודת כותרת מזוהה: title, subject, headline, header (גם עם קידומת art_, item_, post_, news_), או name, או עמודה שה-label שלה מתחיל ב"כותרת", "שם" או "נושא".
  2. FormInput_SEO במצב JSON (new FormInput_SEO("seo", true)) באותו טופס. במצב A (seo_center) המנוע לא רואה את השדה.
  3. id הרשומה הוא המספר האחרון בנתיב: /news/1234 או /news/some-slug-1234. זה מה ש-SeoContent::id_from_url() מחלץ כדי לקשר שאילתת GSC לרשומה.
  4. סיומת כותרת עקבית. אם התבנית מוסיפה " - שם האתר" דרך SEO::$title_include_sitename, המנוע מודד אותה לבד ושומר ב-seo_title_suffix; סיומת שנוספת ב-theme בדרך אחרת תשבש את חישוב רוחב ה-SERP.

הפאנל נבחר דרך seo_runner/content_panel {panel} (המנוע) או SeoContent::set_panel(); SeoContent::columns($panel) מחזירה מה הפאנל מספק ואיזו עמודה זוהתה, וזה הכלי לבדוק פאנל חדש לפני שמחברים אותו.

רשימת בדיקה למפתח#

  • כל קונטרולר של עמוד תוכן קורא ל-SEO::set("title" | "description" | "image" | "url") לפני הרינדור, ומחזיר "ERRORPAGE" כשהרשומה לא קיימת.
  • יש שורת seoUrl 404, ו-MISC::service_unavailable() לא נעקף על ידי try/catch של האתר.
  • <link rel="canonical"> נבנה מהנתיב הקנוני, לא מ-REQUEST_URI.
  • robots.txt מצהיר על כל ה-sitemaps ולא חוסם Googlebot דרך כלל של AI.
  • טופס הכתבות משתמש ב-FormInput_SEO("seo", true), ויש לו עמודת כותרת מזוהה.
  • JSON-LD נבנה מנתוני הרשומה, לא מ-SEO::get_schema().
  • PAGE::$robots מוגדר ל-noindex בעמודים שלא אמורים להיות באינדקס.
מצאתם טעות או חוסר? תקנו את הדף או פתחו Issue בריפו. התיעוד נכתב מתוך הקוד של ליבה 5.0.115.