ממצאים, טיקטים וריצות

להבין את מודל הממצא של SEOK כפי שהוא מיושם ב-SeoFindings, את חישוב החומרה ומכונת המצבים, את זרימת ההצעות והאישור של SeoContent, ואיך קוראים ומרחיבים את זה מקוד.

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

הממצא (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() עם מקרים אמיתיים מאתרים חיים. שינוי ברצפות משנה סיווג בכל הצי בריצה הבאה.

שדות הממצא#

שדהערכיםמשמעות
scopecode, row, config, networkאיפה התיקון: בקוד/תבנית, ברשומת תוכן, בהגדרה, או מחוץ לאתר
fix_modesingle, per_occurrenceתיקון אחד מכסה הכול, או תיקון לכל עמוד
efforttrivial, small, medium, largeהערכת מאמץ
destinationticket, chat, batch, infoלאן הממצא הולך: טיקט למפתח, שיחה עם המנהל, אישור מרוכז של תוכן, מידע בלבד
traffic_metricimpressions, 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#

עמודהטיפוסמשמעות
idINTמזהה
finding_keyCHAR(40) UNIQUEהמפתח
check_idVARCHAR(16)הבדיקה
root_cause_keyVARCHAR(255)סיבת השורש
scope, fix_mode, severity, effort, destinationENUMראו למעלה; severity ∈ critical, high, medium, low
title_heVARCHAR(500)כותרת בעברית
evidenceMEDIUMTEXTJSON של הראיות
affected_count, affected_trafficINT, BIGINTהיקף
ticket_idINTמזהה הטיקט ב-TODO אחרי SeoTickets::open()
stateENUMopen, question, accepted, ticketed, resolved, regressed
answer, accepted_signature, answered_atתשובת המנהל וחתימת הקבלה (JSON)
owner_note, owner_note_atהערת שוליים של המנהל
rule_version, collector_versionINTגרסאות המדידה
platformINTמזהה הפלטפורמה (0 = ראשית)
first_seen, last_seen, resolved_atDATE
created_at, updated_atDATETIME

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)
stateproposed, 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; מה שיש הוא:

  1. המנוע קורא POST /system/seo_runner/site_checks עם Authorization: Bearer <api_key>:<secret_key>.
  2. 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).
  3. התשובה: {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 למפתחי אתר.

approved_by הוא 0 כשהמנוע מאשר

מאז שהדשבורד עבר ל-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 באתר עם פלטפורמה אחת.

ה-selftest-ים
SeoFindings::selftest(), SeoContent::selftest(), SeoCannibal::selftest() ו-SeoGeo::selftest() מחזירות מערך של מקרי בדיקה עם pass. הן הדרך המהירה לוודא שהחלטה על רצפת חומרה או על גבול רוחב כותרת לא שינתה סיווג בלי כוונה.

מצאתם טעות או חוסר? תקנו את הדף או פתחו Issue בריפו. התיעוד נכתב מתוך הקוד של ליבה 5.0.115.