تشتري الجامعة مقرراً جاهزاً من مزود محتوى، أو تطلب من فريق التصميم التعليمي أن يبني وحدة تفاعلية في أداة مثل Articulate، فيأتي السؤال الأول من المزود: هل تريدونه SCORM 1.2 أم SCORM 2004 أم xAPI أم cmi5؟ والإجابة الخاطئة تعني أن الحزمة تعمل في المتصفح ولكن الدرجة لا تصل إلى سجل الدرجات، أو تصل ناقصة. في مقال أنظمة إدارة التعلم LMS ذكرنا أن المنصة يجب أن تستقبل المحتوى من الخارج، وفي هذا المقال سوف نأخذ هذا الجزء بالتجربة، وسوف نتناول:
- ما الذي يحل كل معيار من الثلاثة، وكيف تنتقل بيانات المتعلم في كل منها.
- حزمة SCORM 1.2 كتبناها بأنفسنا، رفعناها إلى Moodle وحلها طالب، وأين تخزن الدرجة.
- مخزن سجلات التعلم LRS مفتوح المصدر، وإضافة ترسل أحداث Moodle إليه بصيغة xAPI، وخطأ وجدناه في الترجمة.
- جلسة cmi5 كاملة بالترتيب الذي يفرضه المعيار، وما يرفضه ال LRS.
- جدول للاختيار: ماذا تطلب من مزود المحتوى.
لماذا نحتاج معياراً أصلاً؟
المحتوى التعليمي التفاعلي صفحة HTML وJavaScript في النهاية، والطريقة البسيطة أن ترفع الصفحة إلى أي سيرفر وتضع رابطها في المقرر، وهذا يعمل للعرض فقط، فال LMS لا يعرف من فتح الصفحة، ولا ماذا أجاب، ولا متى انتهى. والحل التالي أن يكتب كل مزود محتوى إضافة خاصة لكل LMS، وهذا غير مناسب إطلاقاً، لأن عندنا عشرات المنصات ومئات المزودين. لذلك ظهرت المعايير، وكلها تجيب عن سؤالين: كيف يطلق ال LMS المحتوى، وكيف يرسل المحتوى نتيجة المتعلم. والصورة التالية تبين كيف يجيب كل معيار:

- SCORM: الحزمة ملف ZIP فيه ملف وصف اسمه
imsmanifest.xmlوصفحات HTML، وال LMS يفتح الصفحة داخل إطار iframe، ويضع في النافذة الأم كائن JavaScript اسمهAPIفي الإصدار 1.2، أوAPI_1484_11في الإصدار 2004، والمحتوى يستدعي دواله ليكتب قيماً مثلcmi.core.score.raw. لذلك يعمل SCORM داخل المتصفح وداخل ال LMS فقط. - xAPI: واسمه القديم Tin Can API، لا يشترط متصفحاً ولا LMS، فأي برنامج يرسل جملاً Statements بصيغة JSON عبر HTTP إلى مخزن سجلات التعلم Learning Record Store واختصاراً LRS، وكل جملة فيها من فعل ماذا بماذا وما النتيجة. والإصدار المستخدم على نطاق واسع هو 1.0.3، ومستودع المواصفة يذكر أن الإصدار الحالي هو 2.0 الذي أصبح معياراً من IEEE.
- cmi5: ليس معياراً منفصلاً عن xAPI، وإنما ملف تعريف Profile فوقه، ومواصفته صدرت بإصدار Quartz في 1 يونيو 2016، وتعيد ما فقده xAPI من SCORM: ال LMS يطلق المحتوى، ويعطيه عنوان ال LRS وتوكن مؤقت، والمحتوى يرسل جملاً محددة بترتيب محدد.
وقد يتساءل البعض: إذا كان xAPI أحدث وأعم، فلماذا ما زال SCORM منتشراً؟ والإجابة أن كل أدوات التأليف تصدر SCORM، وكل LMS يقرؤه، وهو يكفي لأكثر حالة شائعة: وحدة واحدة يفتحها الطالب داخل المقرر ويحلها ويأخذ درجة، وأما إذا أردت أن تسجل ما يحدث خارج ال LMS، في تطبيق جوال أو محاكاة أو معمل، فهنا يأتي xAPI.
حزمة SCORM 1.2 للتجربة
بدلاً من حزمة جاهزة من مزود لا نعرف ما بداخلها، كتبنا حزمة صغيرة فيها اختبار من ثلاثة أسئلة عن DNS، ونسبة النجاح فيها 70%. الملف الأول هو imsmanifest.xml:
<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="arabroot.dns.quiz" version="1.0"
xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
<metadata>
<schema>ADL SCORM</schema>
<schemaversion>1.2</schemaversion>
</metadata>
<organizations default="org1">
<organization identifier="org1">
<title>DNS Basics</title>
<item identifier="item1" identifierref="res1">
<title>DNS Quiz</title>
<adlcp:masteryscore>70</adlcp:masteryscore>
</item>
</organization>
</organizations>
<resources>
<resource identifier="res1" type="webcontent" adlcp:scormtype="sco" href="index.html">
<file href="index.html"/>
</resource>
</resources>
</manifest>والملف الثاني index.html، وهو الاختبار نفسه مع الجزء الذي يتكلم مع ال LMS:
<!doctype html>
<html lang="ar" dir="rtl">
<head><meta charset="utf-8"><title>DNS Quiz</title>
<style>body{font-family:sans-serif;max-width:640px;margin:24px auto;line-height:1.8}fieldset{margin:12px 0}#out{font-weight:bold}</style>
</head>
<body>
<h2>اختبار قصير: أساسيات DNS</h2>
<form id="q">
<fieldset><legend>1. ما وظيفة DNS؟</legend>
<label><input type="radio" name="q1" value="a"> تحويل اسم النطاق إلى عنوان IP</label><br>
<label><input type="radio" name="q1" value="b"> توزيع عناوين IP على الأجهزة</label></fieldset>
<fieldset><legend>2. ما نوع السجل الذي يربط الاسم بعنوان IPv4؟</legend>
<label><input type="radio" name="q2" value="a"> MX</label><br>
<label><input type="radio" name="q2" value="b"> A</label></fieldset>
<fieldset><legend>3. ما المنفذ الافتراضي لـ DNS؟</legend>
<label><input type="radio" name="q3" value="a"> 53</label><br>
<label><input type="radio" name="q3" value="b"> 443</label></fieldset>
<button type="submit">سلم الإجابات</button>
</form>
<p id="out"></p>
<script>
// Find the SCORM 1.2 API object that the LMS places in a parent window
function findAPI(w) { for (let i = 0; i < 10 && w; i++) { if (w.API) return w.API; if (w.parent === w) break; w = w.parent; } return null; }
const api = findAPI(window) || (window.opener && findAPI(window.opener));
const started = Date.now();
if (api) api.LMSInitialize('');
const answers = { q1: 'a', q2: 'b', q3: 'a' };
document.getElementById('q').addEventListener('submit', (ev) => {
ev.preventDefault();
const f = new FormData(ev.target);
const correct = Object.keys(answers).filter(k => f.get(k) === answers[k]).length;
const score = Math.round(correct / 3 * 100);
const secs = Math.round((Date.now() - started) / 1000);
const t = `${String(Math.floor(secs / 3600)).padStart(4, '0')}:${String(Math.floor(secs / 60) % 60).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`;
if (api) {
api.LMSSetValue('cmi.core.score.raw', String(score));
api.LMSSetValue('cmi.core.score.min', '0');
api.LMSSetValue('cmi.core.score.max', '100');
api.LMSSetValue('cmi.core.lesson_status', score >= 70 ? 'passed' : 'failed');
api.LMSSetValue('cmi.core.session_time', t);
api.LMSCommit('');
api.LMSFinish('');
}
document.getElementById('out').textContent = `النتيجة: ${score}% (${correct} من 3)` + (api ? '' : ' (لم يعثر على LMS)');
});
</script>
</body>
</html>تفاصيل الحزمة وإصدارات SCORM ودوال ال API شرحناها في المقال التمهيدي عن SCORM، والذي يهمنا هنا للمقارنة نقطتان: الدالة findAPI تصعد في النوافذ الأم حتى تجد الكائن API، لذلك لا تعمل الحزمة خارج ال LMS، فهي تعرض النتيجة ولكنها لا تجد من يرسلها إليه، والحزمة نفسها هي التي تقرر النجاح بمقارنة الدرجة بالرقم 70، وهذا ما سوف يتغير في cmi5.
ثم ضغطنا الملفين في dns-quiz-scorm12.zip، والمهم أن يكون imsmanifest.xml في جذر الملف المضغوط وليس داخل مجلد، وإلا رفض Moodle الحزمة.
الحزمة في Moodle
قبل أن نرفع الحزمة لاحظ ما يقوله سؤال وجواب SCORM في توثيق Moodle: يدعم Moodle الإصدار SCORM 1.2 ويجتاز اختبارات المطابقة له، ويدعم AICC، ولا يدعم SCORM 2004، والتطوير الأصلي له متوقف، والحل المقترح لمن يحتاجه إضافة تجارية تربط بخدمة SCORM Cloud. لذلك إذا كانت منصتك Moodle فاطلب من المزود SCORM 1.2 صراحة.
المختبر هو نفسه الذي شرحناه في دليل تثبيت Moodle مع ثلاثة فروق: الإصدار 5.2.4 وليس 5.3، والسبب سوف يأتي في قسم xAPI، وContainer إضافي لل LRS، والمنفذ 31080. وهذا ملف compose.yaml الكامل:
name: edu5-lms
services:
db:
image: postgres:17.11
container_name: edu5-lms-db
restart: unless-stopped
mem_limit: 512m
environment:
POSTGRES_DB: moodle
POSTGRES_USER: moodle
POSTGRES_PASSWORD: change-me-db
volumes:
- db:/var/lib/postgresql/data
web:
image: moodlehq/moodle-php-apache:8.3-bookworm@sha256:501f19fa75ad3cdc8962f2a742f39d179b8aa8ffdc2ff233d8d27a32f14e0e05
container_name: edu5-lms-web
restart: unless-stopped
mem_limit: 768m
depends_on: [db]
environment:
PHP_INI-max_input_vars: "5000"
PHP_INI-display_errors: "Off"
ports:
- "127.0.0.1:31080:80"
volumes:
- code:/var/www/html
- data:/var/www/moodledata
- ./moodle-router.conf:/etc/apache2/conf-enabled/moodle-router.conf:ro
lrs:
image: yetanalytics/lrsql:v0.9.9
container_name: edu5-lms-lrs
restart: unless-stopped
mem_limit: 512m
environment:
LRSQL_ADMIN_USER_DEFAULT: lrsadmin
LRSQL_ADMIN_PASS_DEFAULT: change-me-lrs-admin
LRSQL_API_KEY_DEFAULT: moodle-key
LRSQL_API_SECRET_DEFAULT: change-me-lrs-secret
LRSQL_ALLOW_ALL_ORIGINS: "true"
ports:
- "127.0.0.1:31090:8080"
volumes:
- lrs:/lrsql/db
volumes:
db:
name: edu5-lms-db
code:
name: edu5-lms-code
data:
name: edu5-lms-data
lrs:
name: edu5-lms-lrsأنشأنا المقرر «مقدمة في الشبكات» والطالب خالد ونشاط SCORM من الحزمة بسكربت PHP يستخدم مولد البيانات في Moodle، وهو ما تفعله أنت من الواجهة بإضافة نشاط من نوع «حزمة سكورم» ورفع الملف. ثم دخلنا بحساب الطالب وفتحنا النشاط، والصورة التالية للحزمة داخل مشغل Moodle قبل التسليم:

أجاب الطالب عن سؤالين صحيحاً واختار 443 في الثالث، فعرضت الحزمة «النتيجة: 67% (2 من 3)»، ثم أعاد المحاولة وأجاب الثلاثة صحيحاً فكانت 100%. والسؤال الآن: أين ذهبت هذه القيم؟ في Moodle 5.2 تخزن قيم SCORM في جدولين، scorm_attempt للمحاولة وscorm_scoes_value للقيم، فاستعلمنا عنها مباشرة:
docker exec edu5-lms-db psql -U moodle -d moodle -c "select a.attempt, e.element, v.value, v.scoid from mdl_scorm_attempt a join mdl_scorm_scoes_value v on v.attemptid=a.id join mdl_scorm_element e on e.id=v.elementid where a.userid=3 and e.element in ('cmi.core.lesson_status','cmi.core.score.raw','cmi.core.total_time') order by a.attempt, e.element;" attempt | element | value | scoid
---------+------------------------+-------------+-------
1 | cmi.core.lesson_status | failed | 2
1 | cmi.core.score.raw | 67 | 2
1 | cmi.core.total_time | 00:00:03.00 | 2
2 | cmi.core.lesson_status | passed | 2
2 | cmi.core.score.raw | 100 | 2
2 | cmi.core.total_time | 00:00:04.00 | 2
(6 rows)في المخرج أعلاه لاحظ التالي:
- الحالة في المحاولة الأولى
failedلأن 67 أقل من 70، وفي الثانيةpassed، والحزمة هي التي قررت ذلك وليس Moodle. - Moodle حول
session_timeالذي أرسلته الحزمة إلىtotal_timeللمحاولة. - العمود
scoidقيمته 2، وهو رقم الوحدة SCO داخل الحزمة، وهو غير رقم النشاط في المقرر، وتذكر هذا الرقم لأنه سبب الخطأ في القسم التالي.
والمعلم يرى المحاولتين في تقرير النشاط، والدرجة المعتمدة 100 لأن طريقة التقييم أعلى محاولة:

ومن رقم المحاولة يفتح المعلم تفاصيل المسار، وهي قيم cmi نفسها التي أرسلتها الحزمة:

وهنا حدود SCORM بوضوح: كل ما يعرفه Moodle هو هذه القيم الخمس، فلا يعرف أي سؤال أخطأ فيه الطالب إلا إذا أرسلت الحزمة عناصر التفاعلات cmi.interactions، وأغلب الحزم لا ترسلها، ولا يعرف شيئاً عما يحدث خارج المتصفح.
xAPI: الجمل Statements ومخزن سجلات التعلم LRS
جملة xAPI تشبه جملة عادية: فاعل Actor وفعل Verb ومفعول Object، ونتيجة Result إن وجدت، مثل «خالد أكمل اختبار DNS بدرجة 67». ويحتاج xAPI إلى مكان يستقبل هذه الجمل ويخزنها ويسمح بالبحث فيها، وهو ال LRS، واخترنا له SQL LRS من Yet Analytics، وهو مفتوح المصدر بترخيص Apache-2.0، يعمل على SQLite أو PostgreSQL أو MariaDB، والإصدار 0.9.0 منه أضاف دعم xAPI 2.0. وأول ما نفعله أن نسأله عن الإصدارات التي يقبلها:
curl -s -u moodle-key:change-me-lrs-secret -H 'X-Experience-API-Version: 1.0.3' http://127.0.0.1:31090/xapi/about{"version":["2.0.0","1.0.3"]}إرسال أحداث Moodle إلى ال LRS
Moodle نفسه لا يرسل xAPI إلى LRS خارجي، والتوثيق يحيل في ذلك إلى إضافات من طرف ثالث، وأشهرها Logstore xAPI، وهي مخزن سجلات Log Store يترجم أحداث Moodle إلى جمل xAPI. وآخر إصدار منها 5.1.0، وملاحظات الإصدار تقول إنه يدعم Moodle من 4.5 إلى 5.2، ولهذا السبب جربنا على 5.2.4 وليس 5.3. فككنا الإضافة في public/admin/tool/log/store/xapi، وثبتنا Moodle، ثم فعلناها وضبطناها من سطر الأوامر:
C="docker exec -u www-data edu5-lms-web php /var/www/html/admin/cli/cfg.php"
$C --component=tool_log --name=enabled_stores --set=logstore_standard,logstore_xapi
$C --component=logstore_xapi --name=endpoint --set=http://lrs:8080/xapi/
$C --component=logstore_xapi --name=username --set=moodle-key
$C --component=logstore_xapi --name=password --set=change-me-lrs-secret
$C --component=logstore_xapi --name=backgroundmode --set=0الخيار backgroundmode بالقيمة 0 يرسل الجملة لحظة حدوث الحدث، وهو مناسب للمختبر لنرى النتيجة فوراً، وأما في الإنتاج فاتركه 1 حتى ترسل المهمة المجدولة الجمل على دفعات Batches، والسبب أن الإرسال اللحظي يجعل كل طلب للطالب ينتظر رد ال LRS، وإذا تعطل ال LRS تأثرت المنصة كلها.
بعد محاولتي الطالب سألنا ال LRS عن الجمل التي وصلته، وهذه هي جمل النشاط بالترتيب، مع اسم حدث Moodle الذي ولد كل جملة:
2026-10-21T13:24:59 completed | \mod_scorm\event\status_submitted | None
2026-10-21T13:24:58 completed | \mod_scorm\event\scoreraw_submitted | {'score': {'raw': 100}}
2026-10-21T13:24:54 launched | \mod_scorm\event\sco_launched | None
2026-10-21T13:24:50 viewed | \mod_scorm\event\course_module_viewed | Noneوهذا جزء من جملة الدرجة في المحاولة الأولى كما خزنها ال LRS، حذفنا منه السياق Context لطوله:
{
"actor": {
"name": "خالد الطالب",
"account": {
"homePage": "http://127.0.0.1:31080",
"name": "3"
}
},
"verb": {
"id": "http://adlnet.gov/expapi/verbs/completed",
"display": {
"en": "Completed"
}
},
"object": {
"objectType": "Activity",
"id": "http://127.0.0.1:31080/mod/scorm/view.php?id=1#sco",
"definition": {
"type": "https://xapi.edlm/profiles/edlm-lms/concepts/activity-types/scorm-content-object",
"name": {
"ar": "اختبار DNS القصير (SCORM 1.2) Content"
}
}
},
"result": {
"score": {
"raw": 67
}
}
}نجرب الترجمة: failed أصبحت completed
لاحظ الفعل في الجملتين: completed، رغم أن Moodle نفسه سجل المحاولة الأولى failed والثانية passed. فتحنا كود الإضافة لنعرف السبب، والدالة get_scorm_verb فيها فعلاً ثلاث حالات: failed وpassed، وأي قيمة أخرى تصبح completed، وهي تقرأ الحالة من الدالة get_scorm_tracks، وتوقيعها كما يلي:
function get_scorm_tracks(array $config, int $userid, int $scormid, int $scoid, int $attempt)ولكن الحدث status_submitted يستدعيها هكذا:
$scormscoestracks = utils\get_scorm_tracks(
$config,
$user->id,
$event->objectid,
$event->contextinstanceid,
$unserializedcmi['attemptid']
);أي أنه يمرر contextinstanceid، وهو رقم النشاط في المقرر وقيمته 1 في المختبر، مكان رقم الوحدة scoid وقيمته 2 كما رأينا في قاعدة البيانات، فلا تجد الدالة أي قيمة، وتعود الحالة الافتراضية completed. والدرس هنا أكبر من هذه الإضافة: الإضافة التي تترجم من معيار إلى آخر قد تفقد معنى البيانات دون أي رسالة خطأ، فالجملة صحيحة في صيغتها وقبلها ال LRS، ولكنها تقول إن طالباً راسباً أكمل. لذلك لا تقم ببناء لوحة مؤشرات على بيانات xAPI قبل أن تقارن عينة منها بما في ال LMS نفسه.
cmi5: xAPI بقواعد الإطلاق والنتيجة
المشكلة في xAPI الحر أن كل مزود يختار الأفعال والترتيب الذي يريده، فمزود يرسل completed وآخر finished، ولا يعرف ال LMS متى ينتهي المحتوى ولا هل نجح الطالب. وcmi5 يحل ذلك بقواعد واضحة من مواصفته:
- ملف المقرر cmi5.xml: بدلاً من
imsmanifest.xml، يصف الوحدات Assignable Units واختصاراً AU، ولكل وحدة شرط النجاحmoveOnونسبة الإتقانmasteryScore. - الإطلاق: ال LMS يفتح الوحدة برابط فيه
endpointعنوان ال LRS، وfetchرابط يعطي توكن مرة واحدة فقط بطلب POST، وregistrationرقم تسجيل الطالب في المقرر، وactivityId. - الأفعال وترتيبها: ال LMS يرسل
launched، والوحدة ترسلinitializedأولاً وterminatedآخراً، وبينهماpassedأوfailedوcompleted، ولا يسمح المعيار بأكثر من جملةpassedواحدة أوcompletedواحدة في التسجيل الواحد، وfailedلا تأتي بعدpassed.
ولا يوجد في Moodle دعم أساسي لcmi5، وتشغيل وحدة cmi5 حقيقية يحتاج LMS يطبق جهة الإطلاق كاملة، لذلك سوف نفعل الجزء الذي يهمك لتفهم المعيار: نرسل جلسة cmi5 كاملة إلى ال LRS بالترتيب والسياق اللذين تطلبهما المواصفة، كما يرسلها LMS ووحدة متوافقان، ثم نقرؤها. والسكربت التالي يفعل ذلك:
// One cmi5 session sent by hand to the LRS, in the order the cmi5 spec requires:
// LMS: launched -> AU: initialized, passed, completed, terminated -> LMS: satisfied (moveOn = CompletedAndPassed).
import { randomUUID } from 'node:crypto';
const LRS = process.env.LRS || 'http://lrs:8080/xapi';
const AUTH = 'Basic ' + Buffer.from(process.env.LRS_AUTH || 'moodle-key:change-me-lrs-secret').toString('base64');
const registration = randomUUID(), sessionid = randomUUID();
const actor = { objectType: 'Agent', name: 'خالد الطالب', account: { homePage: 'https://lms.example.com', name: 'student1' } };
const course = 'https://lms.example.com/cmi5/course/net101';
const au = { objectType: 'Activity', id: 'https://lms.example.com/cmi5/au/net101-dns-quiz',
definition: { type: 'https://w3id.org/xapi/cmi5/activitytype/au', name: { ar: 'اختبار DNS القصير' } } };
const CMI5 = { id: 'https://w3id.org/xapi/cmi5/context/categories/cmi5' };
const MOVEON = { id: 'https://w3id.org/xapi/cmi5/context/categories/moveon' };
const verb = (v, en) => ({ id: v.startsWith('http') ? v : `http://adlnet.gov/expapi/verbs/${v}`, display: { 'en-US': en } });
const ctx = (cats, extra = {}) => ({ registration, contextActivities: { category: cats, grouping: [{ id: course }] },
extensions: { 'https://w3id.org/xapi/cmi5/context/extensions/sessionid': sessionid, ...extra } });
const t0 = Date.now(); const at = (s) => new Date(t0 + s * 1000).toISOString();
const statements = [
{ verb: verb('launched', 'Launched'), object: au, timestamp: at(0),
context: ctx([CMI5], { 'https://w3id.org/xapi/cmi5/context/extensions/launchmode': 'Normal',
'https://w3id.org/xapi/cmi5/context/extensions/moveon': 'CompletedAndPassed',
'https://w3id.org/xapi/cmi5/context/extensions/masteryscore': 0.7 }) },
{ verb: verb('initialized', 'Initialized'), object: au, timestamp: at(2), context: ctx([CMI5]) },
{ verb: verb('passed', 'Passed'), object: au, timestamp: at(40), context: ctx([CMI5, MOVEON]),
result: { score: { scaled: 1.0, raw: 3, min: 0, max: 3 }, success: true, duration: 'PT38S' } },
{ verb: verb('completed', 'Completed'), object: au, timestamp: at(41), context: ctx([CMI5, MOVEON]),
result: { completion: true, duration: 'PT39S' } },
{ verb: verb('terminated', 'Terminated'), object: au, timestamp: at(42), context: ctx([CMI5]), result: { duration: 'PT40S' } },
{ verb: verb('https://w3id.org/xapi/adl/verbs/satisfied', 'Satisfied'), timestamp: at(43), context: ctx([CMI5]),
object: { objectType: 'Activity', id: course, definition: { type: 'https://w3id.org/xapi/cmi5/activitytype/course' } } },
].map(s => ({ id: randomUUID(), actor, ...s }));
const res = await fetch(`${LRS}/statements`, { method: 'POST', body: JSON.stringify(statements),
headers: { Authorization: AUTH, 'Content-Type': 'application/json', 'X-Experience-API-Version': '1.0.3' } });
console.log(`POST /statements -> ${res.status}, ${(await res.json()).length} ids stored, registration ${registration}`);
const back = await (await fetch(`${LRS}/statements?registration=${registration}&ascending=true`,
{ headers: { Authorization: AUTH, 'X-Experience-API-Version': '1.0.3' } })).json();
for (const s of back.statements)
console.log(`${s.verb.display['en-US'].padEnd(12)} ${s.object.id.split('/').pop().padEnd(16)} ${JSON.stringify(s.result || {})}`);docker run --rm --network edu5-lms_default -m 256m -v "$PWD":/app:ro -w /app node:24-alpine node cmi5-session.mjsوالمخرج سوف يكون كما يلي:
POST /statements -> 200, 6 ids stored, registration 19b030a9-f80e-4110-96bc-7705e25dbc4e
Launched net101-dns-quiz {}
Initialized net101-dns-quiz {}
Passed net101-dns-quiz {"score":{"scaled":1,"raw":3,"min":0,"max":3},"success":true,"duration":"PT38S"}
Completed net101-dns-quiz {"completion":true,"duration":"PT39S"}
Terminated net101-dns-quiz {"duration":"PT40S"}
Satisfied net101 {}والصورة التالية تبين الجلسة نفسها، ومن يرسل كل جملة:

في السكربت أعلاه لاحظ التالي:
- رقم التسجيل registration: هو الذي يجمع الجمل الست، وبه استعلمنا عنها من ال LRS بالمعامل
?registration=، وفي SCORM لا يوجد ما يقابله خارج قاعدة بيانات ال LMS. - الدرجة
scaled: قيمة بين -1 و1، وcmi5 يقارنها بقيمةmasteryscoreالتي أرسلها ال LMS في جملةlaunched، فالوحدة لا تقرر نسبة النجاح وحدها كما فعلت حزمة SCORM. - التصنيف
moveon: على جملتيpassedوcompletedفقط، وهو ما يقرأه ال LMS ليقرر أن الوحدة استوفت شرطها، فيرسلsatisfied.
والصورة التالية لمتصفح البيانات في واجهة SQL LRS بعد الإرسال، وفيها جلستان أرسلناهما بالسكربت نفسه:

ماذا يحدث إذا أرسلت الوحدة درجة خاطئة؟
من الأخطاء المعروفة في المحتوى أن يرسل الدرجة 67 في الحقل scaled بدلاً من 0.67، فجربنا ذلك بالملف bad.json:
{"actor": {"objectType": "Agent", "account": {"homePage": "https://lms.example.com", "name": "student1"}},
"verb": {"id": "http://adlnet.gov/expapi/verbs/passed", "display": {"en-US": "Passed"}},
"object": {"objectType": "Activity", "id": "https://lms.example.com/cmi5/au/net101-dns-quiz"},
"result": {"score": {"scaled": 67}, "success": true}}ورد ال LRS كان كما يلي، وهذا أوله:
400 {"error":{"message":"Invalid Statement Data", ... "spec-error":"67.0 - failed: (<= % 1.0) in: [:statement\/result :result\/score :score\/scaled] ...رفض ال LRS الجملة بالرمز 400، والرسالة تحدد المكان بالضبط: result.score.scaled يجب أن يكون 1.0 أو أقل. وهذا الفرق عن خطأ الترجمة في القسم السابق مهم: الخطأ في صيغة الجملة يرفضه ال LRS فتراه، والخطأ في معناها يقبله ولا تراه إلا إذا قارنت.
ماذا تطلب من مزود المحتوى؟
| الموقف | المعيار المناسب | والسبب |
|---|---|---|
| وحدة داخل مقرر في Moodle، تحتاج درجة ونجاحاً فقط | SCORM 1.2 | Moodle يدعمه ويجتاز اختباراته، ولا يدعم SCORM 2004 |
| منصة أخرى تدعم SCORM 2004 وتحتاج تسلسلاً بين الوحدات Sequencing | SCORM 2004 | بعد التأكد من أن ال LMS نفسه يدعمه، وليس فقط يقبل رفع الحزمة |
| تعلم خارج المتصفح: تطبيق جوال، محاكاة، معمل، أو بيانات من عدة أنظمة | xAPI مع LRS | لأنه لا يشترط LMS، ولكن اتفق مع المزود على الأفعال قبل البدء |
| محتوى يطلقه ال LMS وتريد بيانات xAPI غنية بقواعد ثابتة | cmi5 | إذا كان ال LMS يدعمه، وإلا فلن يجد المحتوى من يطلقه |
| تحليلات تعلم من Moodle نفسه | إضافة Logstore xAPI مع LRS | بعد مقارنة عينة من الجمل مع بيانات Moodle كما فعلنا |
وإذا كان المحتوى تفاعلياً تصنعه بنفسك داخل Moodle، فقد يكون H5P أبسط من كل ما سبق، لأنه يعمل داخل المنصة ويرسل نتائجه إلى سجل الدرجات مباشرة.
أخطاء شائعة
- طلب SCORM 2004 لمنصة Moodle: الحزمة ترفع وتعمل أحياناً، ولكن التوثيق يقول إنه غير مدعوم، فلا تعتمد عليه في الدرجات.
- الملف
imsmanifest.xmlداخل مجلد في الملف المضغوط: يجب أن يكون في الجذر. - الثقة في ترجمة xAPI دون مقارنة: كما رأينا،
failedوصلتcompletedدون أي خطأ. - الإرسال اللحظي من ال LMS إلى ال LRS في الإنتاج: اجعله على دفعات، حتى لا يتأثر الطلاب إذا بطء ال LRS.
- ترك مفتاح ال LRS الافتراضي: الواجهة نفسها تحذرك منه.
تنظيف المختبر
docker compose down -vثم احذف ال Docker Images التي حملتها إذا لم تعد تحتاجها، بأمر docker image rm واسمها الكامل.
تدريب عملي
- أضف إلى الحزمة إرسال
cmi.interactions.0.idوcmi.interactions.0.student_responseلكل سؤال، ثم افتح تقرير التفاعلات في Moodle. - أرسل جلسة cmi5 فيها
failedبعدpassed، وتأكد هل يرفضها ال LRS أم يقبلها، ثم فكر: من المسؤول عن فرض قواعد cmi5، ال LRS أم ال LMS؟
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- SCORM يعمل داخل المتصفح وال LMS بكائن JavaScript، وMoodle يدعم 1.2 ولا يدعم 2004، وكل ما يحفظه هو قيم
cmiالتي ترسلها الحزمة. - xAPI جمل JSON يرسلها أي برنامج إلى LRS، وSQL LRS خيار مفتوح المصدر يقبل الإصدارين 1.0.3 و2.0.
- إضافة Logstore xAPI 5.1.0 تدعم Moodle حتى 5.2، وفي تجربتنا ترجمت
failedوpassedإلىcompletedبسبب تمرير رقم النشاط مكان رقم الوحدة. - cmi5 يضع فوق xAPI قواعد للإطلاق والترتيب والنجاح، ولكنه يحتاج LMS يطبقه.
- ال LRS يرفض الجملة الخاطئة في صيغتها، ولا يعرف الجملة الخاطئة في معناها.
سجل التحديثات
- أكتوبر 2026: كتابة المقال وتجربته على Moodle 5.2.4 مع Logstore xAPI 5.1.0 وSQL LRS 0.9.9، وحزمة SCORM 1.2 مكتوبة للمقال.