הממצא (finding) הוא יחידת העבודה של חבילת ה-SEO. העמוד הזה מתאר את המודל כפי ש-system/libraries/SeoFindings.php מיישם אותו, את הטבלאות, את מכונת המצבים, את הטיקטים (SeoTickets) ואת זרימת תיקוני התוכן (SeoContent). מאז המעבר למנוע המאוחסן המנוע הוא זה שמחזיק את הממצאים; הטבלאות המקומיות הן מראה (mirror) שהמנוע ממלא לפני כל פעולה שדורשת אותן. ראו חבילת ה-SEO לתמונה הכללית.
system/ בעמוד הזה הוא תיקיית הליבה הפרוסה (api/core בריפו).
המודל#
העקרונות, כפי שהם מקודדים:
- ממצא אחד לכל סיבת שורש. המפתח הוא
finding_key = sha1(platform_seed . check_id . "|" . root_cause_key). העמודים שבהם הבעיה נמדדה הםoccurrences, לא ממצאים נפרדים. בפלטפורמה הראשיתplatform_seedריק; בפלטפורמה משנית הוא"p<id>|"(SeoPlatform::key_seed()). - חומרה מחושבת, לא נבחרת:
max(floor, by_traffic)עם תקרות לבדיקות מסוימות. - מצבים עם זיכרון. ממצא שנסגר ונמדד שוב הוא
regressed; ממצא ש"התקבל" (accepted) שותק כל עוד החתימה שלו מחזיקה.
SeoFindings::make_key()#
public static function make_key($check_id, $root_cause_key = "")
מחזירה את ה-finding_key (40 תווים הקסדצימליים). $check_id הוא מזהה הבדיקה מהמפרט (A2.1, K6.6, D8...), $root_cause_key הוא מחרוזת שמבדילה בין סיבות שורש של אותה בדיקה (למשל שם התבנית, או ריק).
SeoFindings::severity()#
public static function severity($check_id, $affected_traffic, $site_total_traffic, $floor_override = "", $template_wide = false)
| שלב | כלל |
|---|---|
| נתח תנועה | share = affected / site_total; > 0.20 = critical, > 0.05 = high, > 0.01 = medium, אחרת low |
| template_wide | אם כל עמודי המדגם נכשלו, by_traffic מורם לפחות ל-medium (המדגם הוא ~40 עמודים, והתנועה שלו לא מייצגת) |
| רצפה | $floor_override אם ניתן, אחרת SeoFindings::$severity_floor[$check_id], אחרת low. דוגמאות: A1.5, A1.9, A8.5 = critical; A3.1, A5.1, A8.3, A9.1, B11.0, C1, C2 = high |
| תוצאה | max(floor, by_traffic) |
| תקרה | SeoFindings::$severity_ceiling[$check_id] מגבילה רק את מה שהתנועה הרימה, לעולם לא את מה שהרצפה הצהירה |
הפונקציה טהורה ויש לה selftest() עם מקרים אמיתיים מאתרים חיים. שינוי ברצפות משנה סיווג בכל הצי בריצה הבאה.
שדות הממצא#
| שדה | ערכים | משמעות |
|---|---|---|
scope | code, row, config, network | איפה התיקון: בקוד/תבנית, ברשומת תוכן, בהגדרה, או מחוץ לאתר |
fix_mode | single, per_occurrence | תיקון אחד מכסה הכול, או תיקון לכל עמוד |
effort | trivial, small, medium, large | הערכת מאמץ |
destination | ticket, chat, batch, info | לאן הממצא הולך: טיקט למפתח, שיחה עם המנהל, אישור מרוכז של תוכן, מידע בלבד |
traffic_metric | impressions, views | במה נמדדת affected_traffic |
rule_version, collector_version | מספרים | הגרסאות שבהן הממצא נמדד; כיום RULE_VERSION = 2, COLLECTOR_VERSION = 7 |
SeoFindings::fix_for(string $check_id, string $root_cause = ""): string מחזירה את טקסט הפתרון המוגדר ב-SeoFindings::$fixes (חיפוש לפי check:cause, אחר כך check, אחר כך משפחת הבדיקה).
הטבלאות#
נוצרות ב-SeoFindings::ensure_tables() ו-SeoContent::ensure_table() (עם CONFIG::$db_fullprefix), בקריאה הראשונה, לא דרך סנכרון הסכמה של עדכון הליבה.
seo_findings#
| עמודה | טיפוס | משמעות |
|---|---|---|
id | INT | מזהה |
finding_key | CHAR(40) UNIQUE | המפתח |
check_id | VARCHAR(16) | הבדיקה |
root_cause_key | VARCHAR(255) | סיבת השורש |
scope, fix_mode, severity, effort, destination | ENUM | ראו למעלה; severity ∈ critical, high, medium, low |
title_he | VARCHAR(500) | כותרת בעברית |
evidence | MEDIUMTEXT | JSON של הראיות |
affected_count, affected_traffic | INT, BIGINT | היקף |
ticket_id | INT | מזהה הטיקט ב-TODO אחרי SeoTickets::open() |
state | ENUM | open, question, accepted, ticketed, resolved, regressed |
answer, accepted_signature, answered_at | תשובת המנהל וחתימת הקבלה (JSON) | |
owner_note, owner_note_at | הערת שוליים של המנהל | |
rule_version, collector_version | INT | גרסאות המדידה |
platform | INT | מזהה הפלטפורמה (0 = ראשית) |
first_seen, last_seen, resolved_at | DATE | |
created_at, updated_at | DATETIME |
seo_occurrences#
id, finding_key, url VARCHAR(1000), url_hash CHAR(40), detail TEXT (JSON), traffic BIGINT, first_seen, last_seen, resolved_at, עם UNIQUE(finding_key, url_hash). ה-hash קיים כי כתובת עברית מקודדת חורגת ממגבלת אורך המפתח של utf8mb4.
seo_profile#
id, topic, fact, signature (JSON), source ENUM(answer, manual, discovery), finding_key, platform, is_active, created_at, updated_at. עובדות על האתר שנלמדו מתשובות ("זה מכוון").
seo_changes#
| עמודה | משמעות |
|---|---|
finding_key, panel, row_id, field | על איזו רשומה ואיזה שדה (meta_title או meta_description) |
headline, context, url | כדי שמסך האישור יראה במה מדובר בלי לפתוח את הרשומה |
before_value, after_value | נשמרים לנצח, כדי שאפשר יהיה לבטל |
before_metrics, after_metrics, measured_at | מדדי GSC לפני ואחרי (JSON) |
state | proposed, applied, rejected, superseded, reverted |
platform, approved_by, created_at, applied_at |
seo_runs ו-seo_metrics#
קיימות בסכמה ומיוצאות ב-SeoHosted::export(), אבל שום קוד בליבה לא כותב אליהן יותר. ההיסטוריה של הריצות חיה במנוע.
מכונת המצבים#
SeoFindings::upsert() מחשב את המצב הבא דרך next_state():
חדש → open (או question אם check_id ∈ $question_checks = ['A1.9','D1.2','D6'])
resolved + נמדד שוב → regressed, אלא אם RULE_VERSION/COLLECTOR_VERSION השתנו → open
accepted + החתימה מחזיקה → accepted (שקט)
accepted + החתימה נשברה → question (לא open: השינוי עשוי להיות מכוון)
open / question / ticketed / regressed → נשארים כמו שהם
לא נמדד בריצה → resolved + resolved_at (close_missing_for_checks; accepted לא נסגר)
ממצא accepted בלי חתימה נחשב "שבור" בכוונה: שתיקה לא יכולה לשרוד בלי ההצדקה שלה.
SeoFindings::capture()#
public static function capture($on)
מצב איסוף (ליבה ≥ 5.0.59). capture(true) מתחיל לאסוף: מעכשיו upsert() לא כותב ל-DB אלא מצרף את הממצא לאוסף ומחזיר 'open'. capture(false) מפסיק ומחזיר את האוסף. כך seo_runner/site_checks מחזיר למנוע ממצאים בצורת upsert בלי לשמור אותם מקומית. במצב איסוף close_missing_for_checks() מחזירה 0 ולא עושה כלום.
SeoFindings::upsert()#
public static function upsert(array $f, $site_total_traffic = 0)
מחזירה את המצב שנקבע. מבנה $f:
$state = SeoFindings::upsert([
"check_id" => "K6.6",
"root_cause_key" => "chain",
"scope" => "config",
"fix_mode" => "per_occurrence",
"title_he" => "שרשרת הפניות בטבלת ההפניות",
"evidence" => ["hops" => 3, "template_wide" => false],
"affected_count" => 2,
"traffic_metric" => "views",
"affected_traffic" => 0,
"effort" => "trivial",
"destination" => "ticket",
"severity_floor" => "", // אופציונלי, דורס את $severity_floor
"occurrences" => [
["url" => "https://www.example.co.il/old-a", "detail" => ["to" => "/old-b"], "traffic" => 0],
],
], $site_total);
sync_occurrences() מעדכן את טבלת ה-occurrences לפי url_hash ומסיר עמודים שלא נמדדו. בשני המצבים (איסוף ושמירה) הצורה זהה - זה החוזה בין האתר למנוע.
SeoFindings::close_missing_for_checks()#
public static function close_missing_for_checks(array $checks_run, array $seen_keys, $run_date = null)
סוגרת (resolved) כל ממצא של הבדיקות שרצו ב-$checks_run שהמפתח שלו לא נמצא ב-$seen_keys. בטוחה יותר מ-close_missing(array $seen_keys), שסוגרת כל מה שלא נראה בלי קשר לאיזו בדיקה רצה.
period_diff(), mark_verified(), accept(), reject(), set_note() ו-answer() נשארו ב-SeoFindings אחרי מחיקת הפאנל המקומי. הן עדיין עובדות על הטבלאות המקומיות, אבל המנוע לא רואה את מה שהן משנות.ריצות (runs)#
ריצה היא קריאת site_checks של המנוע. באתר אין יותר "ריצה" במובן של seo_runs; מה שיש הוא:
- המנוע קורא
POST /system/seo_runner/site_checksעםAuthorization: Bearer <api_key>:<secret_key>. seo_runnerמפעילSeoFindings::capture(true), מריץSeoSiteChecks::site_only()(K6.5-K6.7),SeoContent::scan()(A2.1/A2.2, אם נבחר פאנל תוכן),SeoCannibal::taxonomy_check()(D8) ו-SeoGeo::analyze(["bots" => SeoBots::summary()])(G4).- התשובה:
{ok:true, findings:[…], checks_run:["D8","K6.5","K6.6","K6.7","G4","A2.1"], log:[…]}. המנוע שומר תחת המפתח שלו ושוקל חומרה עם סך התנועה שלו.
לפני פעולה שדורשת את הממצא מקומית (propose, ticket, agent) המנוע שולח את הממצא בגוף (finding, occurrences) ו-seo_runner::mirror() מבצע upsert() רגיל (לא במצב איסוף) - כך SeoContent ו-SeoTickets קוראים מהטבלאות כפי שתמיד קראו.
טיקטים#
SeoTickets::open()#
public static function open(string $finding_key, int $project = 0): array
| תנאי | תוצאה |
|---|---|
Todo::is_configured() שקר, או אין todo_project_ids | {ok:false, error:"מערכת ה-TODO אינה מחוברת..."} |
| הממצא לא קיים מקומית | {ok:false, error:"הממצא לא נמצא"} |
scope = row | סירוב: ממצא תוכן מטופל באישור שינויים, לא בטיקט למפתח |
כבר יש ticket_id | {ok:false, ticket_id} |
$project לא אחד מהפרויקטים שנבחרו באתר | נופל לפרויקט ברירת המחדל (project_id() = הראשון ברשימה), בלי שגיאה |
בהצלחה: Todo::create_ticket($project, "[SEO <check>] <title>, <host>", ["details" => …]), ואז ticket_id נשמר, state = ticketed, והתשובה {ok:true, ticket_id, url, note}. ההגדרות של TODO יושבות ב-CRM_params: todo_api_key, todo_api_secret, todo_user_token, todo_user_id (למפתחים) ו-todo_project_ids (JSON).
תיקוני תוכן: SeoContent#
זו הגבול הקשיח של המוצר: שום רשומה לא נכתבת בלי אישור מפורש (רשימת ids). הזרימה:
scan() A2.1 (רוחב SERP > 600px, מדידת עברית 8.2px/תו) + A2.2 (כותרות זהות), על כל רשומות הפאנל
▼
propose(key) A2.1 → wizzo_ai::json (BATCH=40) → שערים: identical / over_limit / still_wide / reworded_only
propose_query(key, offset) B1/B2/B5 → URL → רשומה (המספר האחרון בנתיב) מאומת מול <title> חי → כותרת+תיאור לפי query/position/ctr
propose_pair(key, offset) B4 → רק המפסיד בזוג
│ store_change(): ההצעה הקודמת → 'superseded'; נשמרים before_value, url, before_metrics
▼
pending(key) מסנן שדות שכבר הוחלו ומחכים למדידה (30 יום), והצעות שה-occurrence שלהן נעלם
▼
apply(ids, dry_run) עד 40 בבת אחת; קורא before_value חי, save_record דרך admin_mcp_tools, קורא חזרה ומוודא; state = applied
revert(ids) save_record(before_value); state = reverted
reject(ids) state = rejected
measure_impact(pages, pairs) נקרא ע"י המנוע אחרי IMPACT_MIN_DAYS = 7; impact_summary() לפי URL
החתימות:
public static function scan(): array
public static function propose(string $finding_key): array
public static function propose_query(string $finding_key, int $offset = 0): array
public static function propose_pair(string $finding_key, int $offset = 0): array
public static function pending(string $finding_key = ""): array
public static function apply(array $ids, bool $dry_run = false): array
public static function revert(array $ids): array
public static function reject(array $ids): array
public static function applied(int $limit = 100, string $finding_key = ""): array
public static function impact_summary(): array
public static function set_admin(int $admin_id): void
public static function set_panel(string $panel): void
public static function columns(string $panel): array
קבועים: SERP_MAX_PX = 600, META_TITLE_MAX = 60, META_DESC_MAX = 160, SCAN_ROWS = 60000, CHUNK_ROWS = 2000, BATCH = 40, QUERY_BATCH = 20, PAIR_BATCH = 10, IMPACT_MIN_DAYS = 7.
פרמטרים ב-CRM_params: seo_content_panel (הפאנל שנסרק), seo_title_suffix (הסיומת שהתבנית מוסיפה לכותרת; פר פלטפורמה), seo_content_panel_map (JSON של קידומת URL → פאנל, נלמד אוטומטית).
SeoContent::detect() מחפש עמודת כותרת בשם title|subject|headline|header (או עם קידומת art_|item_|post_|news_, או name, או label שמתחיל ב"כותרת"/"שם"/"נושא"), ושדה FormInput_SEO במצב json_field (עמודת JSON על הרשומה) עם תת-שדה meta_title. בנוסף, propose_query מזהה רשומה לפי המספר האחרון בנתיב ה-URL (id_from_url()). פאנל שלא עומד בשלושת התנאים יקבל need_panel או "לא נמצאה עמודת כותרת". ראו SEO למפתחי אתר.מאז שהדשבורד עבר ל-SEOK, apply() נקרא בקריאת מנוע ללא סשן, ולכן ADMIN::get_id() מחזיר 0. טבלת seo_changes לא יודעת מי האדם שאישר; המידע הזה קיים רק במנוע.
איך קוראים את זה מקוד#
שאילתות ישירות על הטבלאות המקומיות הן הדרך הפשוטה. זכרו שבאתר hosted הטבלאות הן מראה חלקית - רק ממצאים שהמנוע שיקף לפני פעולה כלשהי יהיו שם.
// ממצאים פתוחים לפי חומרה
$open = DB::query("seo_findings", ["state" => "open"], "FIELD(severity,'critical','high','medium','low'), affected_traffic DESC")->get_all();
// הצעות תוכן שמחכות לאישור, עם לפני/אחרי
require_once(CONFIG::$core_path . "/libraries/SeoContent.php");
$pending = SeoContent::pending(); // [{id, panel, row_id, field, headline, before_value, after_value, url, ...}]
// השפעה של מה שכבר הוחל
$impact = SeoContent::impact_summary();
דרך MCP: הכלי seo_changes ({state:"proposed"|"applied", key?, platform?}) מחזיר את אותם נתונים, קריאה בלבד. ראו קטלוג כלי ה-MCP.
איך מרחיבים#
יש שתי רמות, וחשוב להבדיל ביניהן:
בדיקה חדשה שהמנוע יראה. הרשימה שרצה ב-seo_runner::site_checks() קבועה בקוד הליבה (SeoSiteChecks, SeoContent::scan, SeoCannibal::taxonomy_check, SeoGeo::analyze). אתר לא יכול להוסיף בדיקה לרשימה הזו בלי שינוי בליבה. אם אתם מוסיפים בדיקה לליבה, הדפוס הוא זה של SeoSiteChecks: לאסוף ממצאים בצורת upsert, לקרוא ל-SeoFindings::upsert() (שבמצב איסוף רק אוסף), ולהחזיר גם את רשימת checks_run כדי שהמנוע יוכל לסגור ממצאים שנעלמו.
בדיקה מקומית לאתר. אתר יכול להשתמש ב-API של SeoFindings לשימוש פנימי (למשל דוח בפאנל של האתר), אבל התוצאה לא תגיע לדשבורד של SEOK. דוגמה למשימת cron של אתר שבודקת תמונות בלי alt בטבלת הכתבות:
// application/controllers/site_seo_checks.php
class site_seo_checks extends wz_controller
{
function alt_check()
{
$_GET["pmode"] = "empg";
if (!isset($_GET["tk"]) || !hash_equals((string)PARAMS::get("cron_runner_token"), (string)$_GET["tk"])) return "ERROR: invalid token";
require_once(CONFIG::$core_path . "/libraries/SeoFindings.php");
$rows = DB::get_all("SELECT id, title FROM CRM_news WHERE content LIKE '%<img%' AND content NOT LIKE '%alt=%' LIMIT 500");
$occ = [];
foreach ((array)$rows as $r) $occ[] = ["url" => CONFIG::$site_url . "news/" . (int)$r["id"], "detail" => ["title" => $r["title"]], "traffic" => 0];
$seen = [];
if (count($occ))
{
$seen[] = SeoFindings::make_key("A6.4", "news_body");
SeoFindings::upsert([
"check_id" => "A6.4", "root_cause_key" => "news_body", "scope" => "row", "fix_mode" => "per_occurrence",
"title_he" => "תמונות בלי alt בגוף הכתבה", "evidence" => ["sample" => count($occ)],
"affected_count" => count($occ), "traffic_metric" => "views", "affected_traffic" => 0,
"effort" => "small", "destination" => "batch", "occurrences" => $occ,
]);
}
SeoFindings::close_missing_for_checks(["A6.4"], $seen);
return "OK findings=" . count($seen);
}
}
רשמו את המשימה ב-מנהל המשימות עם url=site_seo_checks/alt_check. שימו לב שהיא כותבת לטבלאות המקומיות בלבד, ושאין צורך ב-SeoPlatform באתר עם פלטפורמה אחת.
SeoFindings::selftest(), SeoContent::selftest(), SeoCannibal::selftest() ו-SeoGeo::selftest() מחזירות מערך של מקרי בדיקה עם pass. הן הדרך המהירה לוודא שהחלטה על רצפת חומרה או על גבול רוחב כותרת לא שינתה סיווג בלי כוונה.