העמוד הזה מרכז את מה שמפתח אתר צריך לעשות בצד שלו: ב-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)
| מפתח | מה קורה |
|---|---|
title | strip_tags; ממלא meta_title וגם og_title |
description | strip_tags + כיווץ רווחים; ממלא meta_description וגם og_description |
keywords | strip_tags; meta_keywords |
image / og_image | מספר שלם ⇒ STORAGE::get_thumb($id, 1200, 627); אחרת כתובת כפי שהיא |
url / og_url | og_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 עושה#
- ממלא ברירות מחדל מ-
CONFIG::$platform_data["seo_title" | "seo_description" | "seo_keywords"]לכל שדה ריק. אם לא הוגדרה כותרת,$title_include_sitenameמכובה (אחרת שם האתר היה מודפס פעמיים). - מחפש רשומה ב-
CRM_seo_center: קודם לפי הנתיב הנוכחי (page_key= הנתיב בלי/מוביל וסוגר,is_by_url = 1), ואם לא נמצא - לפיSEO::$page_keyעםis_by_url = 0. רשומה שנמצאה דורסת את מה שהקונטרולר הגדיר, שדה אחר שדה (שדות ריקים ברשומה מדולגים).scriptsמוזרק דרךPAGE::add_meta(). - מוסיף
<meta name="twitter:card" content="summary_large_image">ואת תגית האימות של Search Console (SeoVerification::meta_tag()). - מדפיס
<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);
| מאפיין | טיפוס | ברירת מחדל | משמעות |
|---|---|---|---|
key | string | - | page_key של הרשומה ב-seo_center (מצב A) |
pageUrl | string | null | הכתובת הפנימית; כשמוגדר, נוסף תת-שדה SeoUrl ששומר ב-CRM_seoUrl |
is_by_url | "0"/"1" | "0" | סוג המפתח ב-seo_center |
json_field | bool | false | מצב B; נקבע בבנאי |
seo_fields | array | title/description/content של הטופס | הסלקטורים שהאנלייזר קורא |
focus_keyphrase | string | "" | ביטוי מפתח לאנלייזר |
תתי-השדות: 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()).
_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)
print $e->getMessage(); die(); הוא HTTP 200 עם גוף שגיאה. גוגל מפרש אותו כ"זה העמוד עכשיו" ומחליף את התוכן באינדקס. אותו דבר ל-header("Location: /") מעמוד שלא קיים. ממצא A1.5 (soft-404) הוא ברצפת critical בדיוק בגלל זה. ראו 404, 503 ושגיאות.sitemaps#
הליבה לא מייצרת sitemap. הציפיות של המנוע מהאתר:
| בדיקה | ציפייה |
|---|---|
| A8.2 | robots.txt מצהיר על כל המפות ב-Sitemap: |
| A8.3 | אתר חדשות מגיש news-sitemap עם כתבות מ-48 השעות האחרונות בלבד |
| K3.4 | lastmod בפורמט 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"
המימוש מכיל נתוני 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:
- עמודת כותרת מזוהה:
title,subject,headline,header(גם עם קידומתart_,item_,post_,news_), אוname, או עמודה שה-label שלה מתחיל ב"כותרת", "שם" או "נושא". FormInput_SEOבמצב JSON (new FormInput_SEO("seo", true)) באותו טופס. במצב A (seo_center) המנוע לא רואה את השדה.- id הרשומה הוא המספר האחרון בנתיב:
/news/1234או/news/some-slug-1234. זה מה ש-SeoContent::id_from_url()מחלץ כדי לקשר שאילתת GSC לרשומה. - סיומת כותרת עקבית. אם התבנית מוסיפה
" - שם האתר"דרך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בעמודים שלא אמורים להיות באינדקס.