قصة بناء تطبيق PWA لجدول كلية VKI NSU: من السطر الأول حتى مئات الطلاب يومياً

pwacase-studyfastapireactarchitecturereverse-engineeringparsing

قصة بناء تطبيق PWA لجدول كلية VKI NSU: من السطر الأول حتى مئات الطلاب يومياً

في الحياة الجامعية، هناك مصاعب يومية يعتاد عليها الطلاب كأمر واقع لا مفر منه. بالنسبة لطلاب كلية المعلوماتية العليا التابعة لجامعة نوفوسيبيرسك الحكومية (VKI NSU)، كان جدول الحصص اليومي يمثل هذه المعاناة المستمرة لسنوات طويلة. دأبت إدارة الكلية على نشر الجدول بصيغة ملفات PDF متعددة الصفحات، تم تنسيقها يدوياً دون أي واجهة برمجية (API).

كل صباح، كان مئات الطلاب يكررون الروتين الشاق نفسه: الدخول إلى الموقع الرسمي، تنزيل ملف ثقيل على الهاتف، فتحه في قارئ المستندات، وتكبير الشبكة المعقدة بأصابعهم بحثاً عن رمز مجموعتهم بين عشرات الخلايا المتطابقة. والأسوأ من ذلك أن الجدول لم يكن مطلوباً مرة واحدة صباحاً فقط، بل قبل رنين كل جرس: لمعرفة رقم القاعة، أو التأكد من اسم المدرس، أو التحقق من عدم نقل المحاضرة فجأة إلى مبنى آخر.

وتفاقمت الأزمة بسبب البيئة المادية المحيطة: المبنى التاريخي القديم للكلية بجدرانه الطوبية السميكة التي يصل سمكها إلى متر كامل، وصالة الألعاب الرياضية في القبو حيث تُقام حصص التربية البدنية شتاءً، تحولا إلى ما يشبه “قفص فاراداي” الحقيقي؛ إذ تنعدم شبكة الهاتف المحمول تماماً. وإذا نسي الطالب تنزيل الملف مسبقاً في ذاكرة جهازه، وجد نفسه في عزلة تامة أمام أبواب القاعات المغلقة.

في هذا المقال، سأسرد بالتفصيل القصة الهندسية وراء بناء تطبيق ويب تقدمي مستقل (PWA): من التجارب العفوية الأولى في الليالي المتأخرة والبدايات المتعثرة، وصولاً إلى المعمارية الناضجة، واختيار تقنيات الويب، والهندسة العكسية للمستندات، وبناء عميل فائق الموثوقية يعمل دون اتصال بالإنترنت.


1. عثرة فبراير: واجهة Gravity UI، البرمجة العفوية والانتقال إلى جامعة سانت بطرسبرغ

بدأت فكرة المشروع في الخامس من فبراير. قرابة منتصف الليل، خطر في بالي تساؤل بديهي: لماذا يجب على طلاب إحدى كليات تكنولوجيا المعلومات الرائدة في سيبيريا لعام 2025 أن يجهدوا أعينهم كل صباح أمام مستندات قديمة؟ العالم من حولنا يزخر بواجهات الويب الحديثة والأطر التفاعلية.

تولدت لدي رغبة قوية: قضاء ليلة كاملة في بناء تطبيق ويب سريع وأنيق للجدول الدراسي. اخترت نظام التصميم Gravity UI المطور من قبل Yandex؛ إذ بدت حزمة مكوناته عصرية وبسيطة ومثالية للجداول المعقدة وقوائم المجموعات.

صممت النماذج الأولية معتمداً على بيانات حقيقية لجدول الكلية، وبدأت في بناء الواجهة باستخدام React مع إجراء تجارب أولية على الواجهة الخلفية (Backend). وهنا وقعت في فخ “البرمجة السطحية عبر الذكاء الاصطناعي” (Vibe-Coding).

في ذلك الوقت، لم أكن مطور Python على الإطلاق. كنت قليل المعرفة بمكتبات هذه البيئة، وأجهل أطر العمل غير المتزامنة وتفاصيل معالجة المستندات الرقمية. بدا لي أن فهم كواليس صيغة PDF غير ضروري في عصر النماذج اللغوية الحديثة: يكفي تزويد النموذج بالمستند ومطالبته بكتابة محلل برمجي يستخرج بيانات JSON نقية.

سرعان ما بددت الحقيقة هذا الحماس الساذج بلا رحمة.

ملف PDF ليس قاعدة بيانات، ولا كود HTML مهيكل، ولا جدول بيانات إلكتروني. إنه تسلسل منخفض المستوى لتعليمات موجهة لطابعة افتراضية: ارسم قطعة متجهة عند الإحداثيات (x, y)، وضع حرفاً بخط معين عند نقطة محددة. لا توجد في الملف الأصلي مفاهيم مثل “سطر” أو “عمود” أو “خلية جدول”، بل مجرد خطوط بصرية ونصوص متناثرة تسبح على سطح الإحداثيات.

أنتجت الشفرة البرمجية العشوائية فوضى عارمة:

  • خلايا المحاضرات المدمجة نُسبت بشكل عشوائي إلى مجموعات فرعية خاطئة.
  • إزاحة الأعمدة بنصف مليمتر فقط أفسدت الربط بين الفترات الزمنية وأرقام القاعات.
  • الاختلافات في كتابة الأحرف الأولى لأسماء الأساتذة أدت إلى تكرار عشرات المدخلات الوهمية.

كان الخوض في هندسة الإحداثيات المعقدة والتعامل مع المتجهات في منتصف الليل أمراً مرهقاً. خمد الحماس الأولي وحل الإحباط، ووُضع المشروع جانباً. كان ذلك درساً هندسياً لا يُنسى: من المستحيل بناء نظام مستقر وموثوق إذا لم تكن تفهم الطبيعة الفيزيائية للمدخلات.

ومع ذلك، لم تذهب الجهود سدى. بعد فترة وجيزة، أُتيحت لي فرصة تصميم جدول لجامعة أخرى: جامعة سانت بطرسبرغ الحكومية لتكنولوجيا الصناعة والتصميم (sutd.okak.pw).

كان الفارق شاسعاً؛ فالجدول هناك كان يُنشر بصيغة جداول Excel مهيكلة بدقة (.xlsx)، دون تعديل مستمر من الإدارة. كان استخراج البيانات باستخدام المكتبات القياسية متعة حقيقية: شبكة محددة وخلايا يمكن التنبؤ بها دون أي فوضى متجهة.

خدم التصميم الذي بُني مسبقاً بـ Gravity UI مشروع الجامعة الجديدة بامتياز. أتاحت لي تلك التجربة تحسين إدارة الحالة، وبناء المكونات، والتعامل مع الأسابيع الدراسية والتواصل مع الـ API بكل هدوء. كانت تلك بيئة تدريب مثالية حفظت الأساس للعودة لاحقاً إلى كليتي الأم.

النموذج الأولي للجدول مع مظهر داكن أنيق


2. المحاولة الثانية: بلوغ سن 18، ترك روبوتات Discord واختيار PWA

مع مطلع الصيف، تزامنت عدة محطات مفصلية في حياتي؛ إذ بلغت الثامنة عشرة من عمري، وهي مرحلة تدفعك لإعادة التفكير في كيفية استثمار وقتك. كما أصابني إرهاق تام ورغبة قاطعة في التوقف عن برمجة روبوتات منصة Discord.

أمضيت سنوات في تطوير الروبوتات وإدارة خوادمها، لكنني أدركت في النهاية أن ذلك مجرد دوران في حلقة مفرغة داخل منصة مغلقة يملكها آخرون. كنت أتوق لصنع خدمات ويب مستقلة بالكامل، يستخدمها أناس حقيقيون في العالم الواقعي لحل مشكلاتهم اليومية.

كنت قد اتفقت مسبقاً مع المصمم الموهوب Arlen للعمل على مشروع الروبوت الملغى، ودفعت له دفعة أولى مقابل تصاميم لم ينجز منها سوى مسودات أولية على Figma.

توجهت إليه قائلاً: “مشروع الروبوت أُلغي، لا أريد الاستمرار فيه. دعنا نبني بدلاً من ذلك تطبيقاً راقياً لجدول كليتنا”.

رحب Arlen بالفكرة على الفور، وتحول الأمر من عمل مدفوع إلى شراكة قائمة على الشغف الخالص والتعلم المشترك، وبناء سابقة أعمال متميزة، وتقديم خدمة حقيقية لزملائنا الطلاب.

واجهتنا عقبة عملية طريفة أثناء مرحلة التصميم؛ إذ رسم Arlen الواجهات بالوضع الفاتح فقط؛ نظراً لأن إعداد متغيرات التصميم (Design Tokens) للتبديل التلقائي للمظهر في Figma كان يتطلب اشتراكاً مدفوعاً. لم يكن هناك مبرر لإنفاق المال على اشتراك لمشروع طلابي غير تجاري، لذلك تكفلت لاحقاً برسم وتنسيق الوضع الداكن يدوياً وضبط تباين الألوان بدقة.

الاختيار الهندسي للمنصة: لماذا تطبيق الويب التقدمي (PWA)؟

واجهنا سؤالاً معمارياً جوهرياً: ما المنصة الأمثل لتقديم هذا المنتج؟

تم استبعاد فكرة تطوير تطبيقات أصلية لنظامي iOS (عبر Swift) وAndroid (عبر Kotlin) منذ البداية لعدة أسباب حاسمة:

  1. العوائق المالية والعقوبات: يتطلب برنامج مطوري Apple اشتراكاً سنوياً قدره 99 دولاراً. سداد هذا المبلغ لطالب في روسيا مسألة معقدة للغاية، ولم يكن من المنطقي دفع هذا المبلغ سنوياً لمشروع خدمي غير ربحي.
  2. تأخير المراجعة (App Store Review): عندما تجري الكلية تعديلاً مفاجئاً على تنسيق المستند، يجب أن يصل التحديث البرمجي للمستخدمين خلال دقائق. انتظار يومين أو ثلاثة حتى تنتهي مراجعة Apple كان سيعني حرمان الطلاب من معرفة جدولهم لنصف أسبوع.
  3. أعباء الصيانة: صيانة قاعدتي شفرات برمجية أصليتين منفصلتين لأداة طلابية تطلبت وقتاً ومجهوداً يفوق طاقتنا.

كما أن تصفح موقع عادي عبر متصفح الهاتف لم يكن حلاً ملائماً؛ فمن غير العملي للطالب المتعجل التنقل بين علامات التبويب وكتابة الروابط وانتظار استجابة الشبكة عند باب القاعة.

كان الحل الأمثل هو تطبيق الويب التقدمي (Progressive Web App - PWA) الذي جمع بين مرونة الويب وكفاءة التطبيقات الأصلية:

  • تثبيت فوري على الشاشة الرئيسية: يتم التثبيت مباشرة من المتصفح بنقرة واحدة، ويحصل التطبيق على أيقونته الخاصة، ويعمل في نافذة مستقلة دون شريط عناوين (display: standalone) ليبدو كتطبيق أصلي تماماً.
  • تحديثات صامتة فورية: يتم جلب الحزم البرمجية الجديدة في الخلفية تلقائياً مع كل إعادة تشغيل للتطبيق.
  • العمل دون اتصال بالإنترنت (Offline-First): يضمن التكامل بين Service Worker وCache Storage فتح الجدول في أجزاء من الثانية حتى في وضع الطيران في قبو الكلية.

استعنا بحزمة vite-plugin-pwa مع استراتيجية injectManifest في إعدادات بناء Vite، مما منحنا تحكماً مطلقاً في دورة حياة Service Worker ودمج التخزين المؤقت المسبق مع معالجة الإشعارات:

import { defineConfig } from "vitest/config";
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
    plugins: [
        VitePWA({
            srcDir: 'src',
            filename: 'sw.ts',
            registerType: 'autoUpdate',
            strategies: 'injectManifest',
            manifest: {
                id: 'vki',
                name: 'ВКИ НГУ',
                short_name: 'ВКИ НГУ',
                start_url: '/?v=102',
                display: 'standalone',
                theme_color: '#007AFF',
                background_color: '#007AFF',
                icons: [
                    {
                        src: 'pwa-192x192.png',
                        sizes: '192x192',
                        type: 'image/png'
                    },
                    {
                        src: 'pwa-512x512.png',
                        sizes: '512x512',
                        type: 'image/png'
                    }
                ]
            }
        })
    ]
})

وجاء ملف عامل الخدمة (sw.ts) مقتضباً وفعالاً:

/// <reference lib="webworker" />
import { precacheAndRoute } from "workbox-precaching";

precacheAndRoute(self.__WB_MANIFEST)

declare const self: ServiceWorkerGlobalScope

self.addEventListener('push', (event: PushEvent) => {
    const data = event.data?.json() || {}

    self.registration.showNotification(data.title || 'ВКИ НГУ', {
        body: data.body || 'Новое обновление расписания',
        icon: '/pwa-192x192.png',
        badge: '/pwa-72x72.png'
    })
})

بفضل هذه المنظومة، حصل المستخدم على إقلاع فوري للواجهة وإشعارات تصل مباشرة إلى مركز إشعارات نظام التشغيل.

واجهة الجدول الأسبوعي مع المظهر الداكن وبطاقات الحصص


3. معمارية الجيل الأول: المحلل الخلفي والتخزين المحلي للعميل

في 21 يونيو، انتقلت عملية التطوير إلى مرحلتها النشطة الشاملة. تم تجاوز نقطة اللاعودة: لا مزيد من الأعذار أو التأجيل، فلم يكن يفصلنا عن العام الدراسي الجديد سوى شهرين وبضعة أيام، وكان لزاماً علينا بناء خط إنتاج موثوق من الصفر.

المفهوم الأولي لجدول كلية VKI NSU

بدأت باستكشاف المشهد التقني، ووجدت محاولة سابقة قام بها طلاب سابقون عبر مشروع مفتوح المصدر يُدعى VkiHub.

شكّلت دراسة VkiHub نقطة انطلاق ممتازة لفهم طبيعة المستندات؛ إذ احتوت شفرته على تعابير نمطية (RegEx) أساسية لاكتشاف المجموعات والشذوذ التنسيقي. لكن اعتماده كقاعدة للمشروع كان مستحيلاً:

  • اعتماده على حزمة برمجية قديمة تستهلك موارد الخادم بشكل مفرط.
  • غياب أي مفهوم لتطبيقات الويب التقدمية والعمل دون اتصال.
  • بنيته الأحادية الصلبة منعت التوسع المرن في وظائف العميل.

تقرر بناء الواجهة الخلفية من الصفر بحزمة برمجية حديثة: FastAPI + Uvicorn + PyMuPDF + Camelot.

تشريح محلل الجيل الأول

كانت المهمة الرئيسية للخادم واضحة: فحص موقع الكلية دورياً، وتنزيل ملفات PDF الجديدة، واستخراج الحصص بدقة متناهية وتوليد بيانات JSON منظمة.

وقع الاختيار على مكتبة Camelot لاستخراج الجداول. ولتسريع معالجة الصفحات دون الاعتماد على أدوات خارجية ثقيلة، قمنا ببناء محرك تحويل خفيف الوزن ConversionBackend يعتمد على مكتبة pymupdf السريعة لتحويل صفحات PDF إلى صور نقطية بدقة 120 DPI لتحليل خطوط الشبكة شكلياً:

class ConversionBackend:
    def convert(self, pdf_path, png_path):
        pymupdf.Document(pdf_path)[0].get_pixmap(dpi=120).save(png_path)

class Parser:
    def __init__(self):
        self.conversion_backend = ConversionBackend()

    def extract_teacher_name(self, content):
        teacher_match = re.findall(r'\b[А-ЯЁ][а-яё]*\s[А-ЯЁ]\.\s?[А-ЯЁ]\.?\b', content)
        teacher = teacher_match[0] if teacher_match else ''
        if content.startswith('НГУ') or content.startswith('Нгу'):
            teacher = self._get_teacher(content)

        if teacher:
            formatted_teacher = teacher + '.' if not teacher.endswith('.') else teacher
            if formatted_teacher[-3] == ' ':
                formatted_teacher = formatted_teacher[:-3] + formatted_teacher[-2:]
            return formatted_teacher, content.replace(teacher, formatted_teacher)

        return teacher, content

    def extract_classroom(self, content):
        classroom_match = re.findall(r'\b\d{3}[a-zа-яё]?\b', content)
        classroom = classroom_match[0] if classroom_match else ''

        if content.startswith('НГУ') or content.startswith('Нгу'):
            classroom = f'НГУ {classroom}'

        for special_room in ['Читальный зал', 'Актовый зал', 'Физкультура', 'Физическая культура']:
            if special_room in content:
                classroom = special_room
                break

        return classroom

أولينا تنقية البيانات اهتماماً بالغاً:

  • المدرسون: كانت الأسماء تُكتب بصيغ متباينة (Ivanov I.I. أو Ivanov I. I. أو بدون نقطة نهائية). قامت التعابير النمطية بتوحيدها وفق نسق قياسي صارم مع وضع مسافة قبل الاسم ونقطة نهائية.
  • القاعات: بجانب أرقام القاعات الثلاثية المعتادة (214 أو 308a)، تعرّف المحلل على مواقع خاصة مثل (“قاعة المطالعة”، و“القاعة الكبرى”، و“التربية البدنية”)، والمحاضرات التي تُقام في الحرم الجامعي الرئيسي مسبوقة بـ НГУ.

اعتمد تشغيل Camelot في الجيل الأول على إعدادات متوازنة:

tables = camelot.read_pdf(
    pdf_path,
    pages='all',
    copy_text=['h', 'v'],
    line_scale=55,
    joint_tol=12,
    line_tol=12,
    backend=self.conversion_backend
)

schedule = {}
for table in tables:
    data = table.df.values.tolist()
    if 'время' in data[0]:
        continue

    data = self.process_table_data(data)
    data, week_dates = self.fix_missing_data(data)

    for i in range(1, len(data)):
        row = data[i]
        for j in range(2, len(row)):
            if row[1].endswith('.5') and data[i][j] == data[i-1][j]:
                continue

            content = self.parse_lesson_content(row[j])
            teacher, content = self.extract_teacher_name(content)
            classroom = self.extract_classroom(content)
            group_name = data[0][j]
            day_name = row[0]

            if group_name not in schedule:
                schedule[group_name] = {}

            if day_name not in schedule[group_name]:
                schedule[group_name][day_name] = {
                    'date': week_dates.get(day_name, ''),
                    'lessons': []
                }

            schedule[group_name][day_name]['lessons'].append({
                'content': content,
                'number': row[1],
                'group': group_name,
                'teacher': teacher,
                'classroom': classroom,
                'cancelled': 'отмена' in row[j].lower()
            })

لاحظ التعامل مع الرمز الكسري .5: عندما كانت الإدارة تقسم الحصة بين مجموعتين فرعيتين أو توزعها على أسابيع زوجية وفردية، كانت الحصة تُعطى رقماً كسرياً للحفاظ على التسلسل الزمني الدقيق.

معمارية العميل ونمط Stale-While-Revalidate

على جانب الواجهة الأمامية (React + Redux Toolkit)، كان الهدف هو ضمان فتح التطبيق لحظياً تحت أي ظروف اتصال.

حفظ ملف JSON الكامل بكل أقسامه وسنواته ومجموعاته مباشرة في localStorage كان خياراً سيئاً، وسيتسبب سريعاً في تجاوز حدود الذاكرة وتجميد خيط المعالجة الرئيسي أثناء التحويل البرمجي.

صممنا آلية مزامنة ذكية ومنتخبة للذاكرة المؤقتة syncScheduleCache في الملف util.ts:

export const syncScheduleCache = (schedule: any) => {
    if (!schedule) return
    const storage = getStorage()
    const favorites = storage.favorite || []
    const params = new URLSearchParams(`?${localStorage.getItem('params') || ''}`)
    const selected = params.get('group') || params.get('teacher') || params.get('classroom') || null

    let cache = getScheduleCache() || {}

    if (selected && schedule[selected]) {
        cache[selected] = schedule[selected]
    }
    favorites.forEach((f: string) => {
        if (schedule[f]) cache[f] = schedule[f]
    })

    for (const key of Object.keys(cache)) {
        if (key !== selected && !favorites.includes(key)) {
            delete cache[key]
        }
    }

    if (Object.keys(cache).length) setScheduleCache(cache)
    else localStorage.removeItem('ci-schedule-cache')
}

تحفظ هذه الخوارزمية فقط ما يحتاجه الطالب فعلياً: مجموعته المختارة حالياً والمجموعات أو المعلمين المضافين إلى المفضلة، مع حذف الباقي لإبقاء الذاكرة خفيفة وسريعة.

وتعتمد دورة حياة التطبيق في App.tsx نمط Stale-While-Revalidate (SWR) المعروف:

const cached = getScheduleCache()
if (cached) {
    this.props.setSchedule(cached)
}

const promise = fetch(`${apiUrl}/schedule`).then((r) => r.json()).catch(() => null)

const handleRes = (res: any) => {
    if (res?.ok) {
        this.props.setSchedule(res.result)
        syncScheduleCache(res.result)
    }
}

if (cached) {
    promise.then(handleRes)
} else {
    const res = await promise
    handleRes(res)
}

كيف تعمل هذه المنظومة عملياً؟

  1. إذا فتح الطالب التطبيق مسبقاً، يسترجع getScheduleCache() الحصص المحفوظة في 0 ميلي ثانية، ويتم عرض الواجهة فوراً ودون أي مؤشرات تحميل—حتى في غياب الشبكة تماماً في القبو.
  2. بالتوازي مع ذلك، ينطلق طلب في الخلفية لجلب أحدث البيانات من الخادم.
  3. عند استلام البيانات الجديدة، يُحدث التطبيق مستودع Redux والذاكرة المؤقتة بهدوء دون تعطيل تصفح المستخدم.
  4. في حالة التشغيل لأول مرة فقط، ينتظر التطبيق استجابة الشبكة لتهيئة الواجهة بسلاسة.

شاشة اختيار الأقسام والسنوات والمجموعات الدراسية


4. توسع المنظومة: الملف الشخصي، إشعارات الويب ومنتدى الطلاب المغلق

بعد أن أثبت محرك الجدول استقراره، ظهرت رغبة طبيعية للتوسع؛ إذ بدا الاكتفاء بعرض جدول ثابت أمراً بسيطاً للغاية، وأردنا تحويل المنصة إلى بيئة رقمية موثوقة للكلية بأسرها.

كانت الخطوة الأساسية الأولى هي إضافة نظام تسجيل الدخول والملف الشخصي. كانت معايير الأمان حازمة: فالسماح بالتسجيل عبر البريد العادي أو شبكات التواصل كان سيتسبب بغرق المنصة في الحسابات الوهمية والرسائل المزعجة.

اتخذنا قراراً هندسياً صارماً: حصر تسجيل الدخول في حسابات Google المؤسسية الرسمية للكلية عبر النطاق @mer.ci.nsu.ru. وفر هذا القرار موثوقية كاملة:

  • انحصار المجتمع في الطلاب الفعليين وأعضاء هيئة التدريس بالكلية.
  • ظهور المستخدمين بأسمائهم الحقيقية الموثقة لدى الكلية.
  • القضاء التام على الحسابات الوهمية والمضايقات مجهولة المصدر.

بعد تسجيل الدخول مباشرة، يحصل المستخدم على إمكانية تفعيل إشعارات الويب عبر بروتوكول VAPID (pywebpush). يقوم الخادم بتوليد اشتراك مشفر فريد، ويحفظه في قاعدة البيانات لربطه بملف الطالب.

تطلبت آلية إرسال إشعارات تحديث الجدول تصميماً هندسياً خاصاً؛ فالإدارة لم تكن تنشر الجداول دفعة واحدة، بل على فترات متفرقة خلال المساء—دفعة عند الساعة 18:00، وأخرى عند 19:30، والأخيرة قرابة 22:00. لو انتظر الخادم اكتمال جميع الدفعات، لتأخرت الإشعارات حتى منتصف الليل؛ ولو أرسل إشعاراً مع كل ملف، لأزعج المستخدمين ودفعهم لإلغاء المتابعة.

ابتكرنا خوارزمية للإشعارات المصغرة الموجهة: يطابق الخادم المجموعات والمدرسين المحدثين مع اشتراكات الطلاب الفردية، ويرسل إشعاراً فورياً تم تحديث الجدول للمجموعة 2307i1 في الدقيقة ذاتها التي ينتهي فيها تحليل ملفهم. أما الإشعار العام للكلية، فلا يُرسل إلا عند اكتمال تنزيل جميع ملفات اليوم (is_complete).

نافذة تسجيل الدخول للمجتمع الطلابي عبر حساب Google المؤسسي

منتدى الطلاب المغلق

تلا ذلك إطلاق الفكرة الأكثر طموحاً في الصيف: بناء منتدى طلابي مغلق داخل التطبيق، صممناه بجميع معايير المنصات الاجتماعية الناضجة:

  • تصنيفان أساسيان للمنشورات:
    1. «الأسئلة»: قسم مخصص لطلب المساعدة في الواجبات والمشاريع المخبرية.
    2. «النقاشات»: مساحة حرة للمبادرات الطلابية، والبحث عن زملاء للمشاركة في الهاكاثونات، والحديث العام.
  • وسوم ملونة تفاعلية: إمكانية تصنيف المواضيع حسب المادة (“البرمجة”، “الرياضيات”، “الشبكات”) وفلترة المنشورات بنقرة زر.
  • نظام تعليقات شجري: سلاسل ردود متداخلة ترسل إشعارات فورية لصاحب الموضوع عند ورود ردود جديدة.
  • أوسمة SVG تشجيعية: شارات مخصصة بجانب صور المستخدمين—شارة المطور (badge_dev)، وشارة المصمم (badge_design)، وشارة المشارك في النسخة التجريبية (badge_beta)، مع عدادات مرئية للمشاهدات والتفاعلات.
  • خوارزمية ذكية لترتيب المحتوى: اعتمدنا معادلة للشهرة Score = Likes * 3 + Views لرفع الأسئلة والمواضيع النشطة إلى صدارة القائمة مع خفض المواضيع الخاملة.
  • حماية الخادم من الإغراق: فرض مهلة إجبارية مدتها 15 دقيقة بين كل منشور وآخر. محاولة النشر المبكر تعيد الرمز HTTP 429 مع عداد تنازلي بالثواني المتبقية.

شاشة المنتدى الرئيسية مع تصفية المواضيع والتميمة الرمزية

صمم Arlen واجهة إنشاء المنشورات ببساطة متناهية: اختيار نوع المنشور، وإدخال العنوان والمحتوى، واختيار الوسوم التفاعلية.

نموذج إنشاء منشور جديد مع تحديد النوع والوسوم

أما في صفحة عرض المنشور، فظهرت سلسلة المحادثة الكاملة مع شارات أصحاب الردود والتفرعات الشجرية.

عرض المنشور مع التعليقات وشارات المؤلفين والردود المتداخلة


5. إطلاق الإنتاج في أغسطس: الاصطدام بالواقع وسنة رابعة المنسية

بحلول أواخر أغسطس 2025، كان المشروع مكتملاً ومختبراً محلياً، وجاهزاً للظهور العام. استأجرنا خادم VDS يعمل بنظام Linux، وجهزنا Nginx كخادم وسيط (Reverse Proxy)، وثبتنا شهادات SSL المجانية من Let’s Encrypt، وأطلقنا النسخة العامة الأولى.

شعرنا بنشوة الإنجاز: التصميم متقن، والوضع غير المتصل مجرب، والمنتدى يعمل، والإشعارات تصل بسلاسة.

ثم جاء الأول من سبتمبر، ومعه صدمة الواقع العملي التي بددت كل التوقعات النظرية.

طوال شهري يوليو وأغسطس، اختُبرت أنظمة التحليل والواجهات حصراً على ملفات PDF الصادرة في 21 يونيو. وفي أواخر يونيو، كان طلاب السنة الرابعة قد ناقشوا مشاريع تخرجهم وغادروا الكلية؛ لذا لم تتضمن ملفات الصيف أي ذكر للسنة الرابعة. ووقعنا في خطأ كلاسيكي ساذج: انغمسنا في تفاصيل السنوات الأولى والثانية والثالثة لدرجة أننا نسينا تماماً أن هناك سنة رابعة موجودة في الكلية!

صباح الأول من سبتمبر، عندما نشرت الإدارة جداول الفصل الخريفي، انهالت على الخادم ملفات ومجموعات جديدة كلياً لطلاب السنة الرابعة. تعطل النظام على الفور؛ فتوقفت أدوات التحليل أمام الرموز الغريبة، وانهارت التعابير النمطية، حتى واجهة التطبيق لم تكن تحتوي على أزرار للانتقال إلى السنة الرابعة.

تحول النصف الأول من سبتمبر إلى ماراثون مستمر من الإصلاحات الطارئة؛ فأعدنا كتابة منطق دعم السنة الرابعة في الخادم وقواعد البيانات، وحدّثنا قوائم الاختيار، وعدّلنا التعابير النمطية مباشرة على الخادم الحي، مع اختبار التحليل ليلاً على الملفات الصادرة حديثاً.

لكن هذا الاختبار الشاق أثمر عن نتائج مبهرة؛ إذ أدرك الطلاب سريعاً مدى سهولة استخدام الخدمة. انتشر رابط التطبيق بين المجموعات كالنار في الهشيم، وبحلول منتصف سبتمبر أصبح للتطبيق 50 مستخدماً نشطاً يومياً (DAU)، مسجلاً أول نجاح حقيقي وملموس.


6. أزمة 15 مارس: انهيار الجداول، الموقع الرسمي الشبح والولادة الجديدة عبر Camelot

مع حلول ربيع 2026، وصل التطبيق إلى ذروة استقراره؛ إذ تجاوز عدد المستخدمين النشطين يومياً 250 طالباً (DAU). وبالنظر إلى أن إجمالي طلاب الكلية نحو 800 طالب، فقد كان أكثر من ثلث الطلاب يعتمدون على تطبيقنا في كل استراحة بين الحصص.

ثم جاء 15 مارس 2026—ليشكل نقطة تحول حاسمة في مسار المشروع بأكمله.

في ذلك اليوم، تلاقت حادثتان غيرتا مسار المشروع:

  1. غيّرت إدارة الكلية مولد ملفات PDF جذرياً: تبدلت الخطوط، وتنسيقات الصفحات، وبنية الترويسات، وهندسة الجداول بالكامل.
  2. بالتزامن مع ذلك، دشنت الكلية رسمياً موقعها الإلكتروني الخاص بالجداول: https://table-ci.nsu.ru/.

ظاهرياً، بدا أن إطلاق موقع رسمي سيكتب نهاية خدمتنا المستقلة، إلا أن الواقع سلك اتجاهاً مغايراً تماماً.

كان الموقع الرسمي متعثراً للغاية: تعطل بصورة متكررة، وخلط بين الأسابيع الزوجية والفردية، وأظهر حصصاً وهمية في قاعات خاطئة؛ وبحلول سبتمبر 2026 توقف عن العمل نهائياً معطياً خطأ اتصال دائم.

لكن تطبيقنا أصيب بدوره بنكبة حقيقية؛ فالمحلل القديم المبني على افتراضات VkiHub الهشة والمقياس line_scale=55 عجز تماماً أمام التنسيق الجديد. تفككت الجداول وامتزجت الخلايا في فوضى غير مقروءة، وتدفقت بيانات تالفة إلى قاعدة البيانات. امتلأت حساباتنا بمئات الرسائل القلقة: “أين الجدول؟ ماذا حدث؟”.

نهاية البرمجة السطحية والتحول للهندسة العكسية الواعية

في ذلك اليوم أدركت يقيناً: البرمجة السطحية العفوية عبر الذكاء الاصطناعي لا مكان لها في بيئات الإنتاج.

لم يعد من المجدي إلقاء ملف PDF المعطوب في نافذة محادثة آلية على أمل أن يكتشف النموذج سبب تشوهات المعالجة. نحيت كل المهام جانباً، وفتحت الشفرة المصدرية لمكتبة Camelot ووثائق OpenCV، وتعمقت في الرياضيات وراء رصد الخطوط المتجهة.

تعمل مكتبة Camelot بوضعين أساسيين:

  • stream: يبني الأعمدة بناءً على المسافات البيضاء والفواصل بين الكلمات.
  • lattice: يعتمد على الرؤية الحاسوبية عبر OpenCV لاكتشاف تقاطعات خطوط شبكة الجدول المرئية.

كانت المعضلة الكبرى في تنسيق الكلية الجديد هي أن خطوط حدود الخلايا كانت بالغة الدقة—تُعرف بخطوط الشعر (hairlines) التي يقل سمكها عن 0.5 نقطة. عاملتها الإعدادات الافتراضية لـ Camelot (line_scale=15) وحتى إعدادنا السابق line_scale=55 على أنها تشويش رقمي وتجاهلتها، مما أدى لدمج الحصص المتجاورة في خلية عملاقة واحدة.

وجاء الحل عبر ضبط دقيق للمعايير الهندسية:

  1. مقياس تكبير استثنائي (line_scale=100): جعل نوى OpenCV الشكلية تستجيب لأدق الخطوط المتجهة متناهية الصغر.
  2. سماحية سد الفجوات (joint_tol=4 و line_tol=2): تقبل هوامش خطأ بسيطة عند تقاطعات الخطوط، مما أغلق الفجوات الصغيرة وأعاد بناء حدود الخلايا المغلقة بثبات.
  3. استنساخ محتوى الخلايا المدمجة (copy_text=['h', 'v']): نسخ النص تلقائياً عبر الأعمدة والصفوف المدمجة لمنع فقدان ربط المحاضرات المشتركة بالمجموعات الفرعية.
  4. الاعتماد على محرك Poppler: للحصول على أعلى دقة ممكنة في تنقيط الصفحات.
tables = camelot.read_pdf(
    path,
    pages='all',
    copy_text=['h', 'v'],
    line_scale=100,
    joint_tol=4,
    line_tol=2,
    backend='poppler'
)

for t in tables:
    matrix = self.table_to_matrix(t.df.values.tolist(), file_url)
    data = self.merge(data, matrix)

لكن استخراج مصفوفة نظيفة لم يكن سوى نصف المعركة؛ فتغيير قالب PDF عطّل جميع طرق التنقية السابقة: اختلطت أسماء المدرسين بأسماء المواد، وأصبحت أرقام القاعات داخل نصوص الحصص، وامتلأ المستند بملاحظات متنوعة (“عن بعد”، “استشارة”، “تسوية التخلفات”، “امتحان”).

تطلب الأمر إعادة بناء خط معالجة البيانات الدلالية من الصفر. وكانت الدالة parse_cell(self, text: str) جوهر المحلل الجديد:

def parse_cell(self, text: str):
    if not text or not text.strip():
        return None

    text = self.clean_text(text)

    teacher = self.extract_teacher(text)
    classroom = self.extract_room(text)
    isDistance = 'дистанцион' in text.lower() or 'дистант' in text.lower()
    isLecture = 'лекци' in text.lower()
    lesson_text = self.extract_subject(text)
    lesson = self.normalize_lesson(self.remove_duplicate_words(lesson_text))

    if not lesson:
        return None

    if len(lesson.strip()) <= 3 or re.match(r'^[А-ЯЁA-Z]\.?\s*[А-ЯЁA-Z]?\.?$', lesson.strip(), re.IGNORECASE):
        return None

    if not teacher:
        m = re.search(r'\b([А-ЯЁ][а-яё]+(?:ова|ева|ина|ына|ский|цкий|ов|ев|ин|ын|ич|их|ых|юк|ук|ак))\s*$', lesson)
        if m:
            teacher = m.group(1)
            lesson = lesson[:m.start()].strip()

    formatted_lesson = None
    if lesson:
        if lesson.isupper():
            formatted_lesson = lesson[0].upper() + lesson[1:].lower()
        else:
            formatted_lesson = (lesson[0].upper() + lesson[1:]) if lesson.lower() == lesson else lesson

    if classroom and formatted_lesson:
        formatted_lesson = formatted_lesson.replace(classroom, '').strip()

    if formatted_lesson:
        formatted_lesson = re.sub(r'[\s.,:;_-]+$', '', formatted_lesson)

    isIssusing = 'выставле' in text.lower() or 'задолженност' in text.lower() or ('пар' in text.lower() and 'нет' in text.lower()) or not lesson

    return {
        'line': self.remove_duplicate_words(text) if isIssusing else text,
        'lesson': formatted_lesson,
        'teacher': teacher,
        'classroom': classroom,
        'isLecture': isLecture,
        'isDistance': isDistance,
        'isPractice': bool(re.search(r'\bпракт', text.lower())),
        'isExam': 'экзам' in text.lower(),
        'isIssusing': isIssusing,
        'isConsultation': 'консул' in text.lower(),
        'isCanceled': 'отмен' in text.lower()
    }

حوّلت هذه الخوارزمية النصوص الفوضوية في خلايا PDF إلى بنية قوية ومنظمة:

  • استخراج القاعة وحذف رقمها بدقة من اسم المادة إذا حدث بينهما تداخل أثناء العرض.
  • تحديد اسم الأستاذ، وتفعيل كاشف استنتاجي ذكي للألقاب الروسية بناءً على اللواحق (-ova، و-skiy، و-in، و-ev) في حال عدم وجود اختصارات واضحة.
  • التخلص من الكلمات المكررة والتشويش عبر remove_duplicate_words.
  • تصنيف نوع الحصة بمجموعة من العلامات المنطقية (isLecture، وisPractice، وisExam، وisDistance، وisConsultation، وisCanceled، وisIssusing)؛ مما مكّن واجهة المستخدم من تمييز الحصص بشارات وألوان مخصصة.

عاد المحلل الجديد كحصن منيع، واستعاد التطبيق عافيته خلال أيام معدودة؛ وبنهاية العام الدراسي ارتفعت الأرقام إلى 300 حساب مسجل، و200 مستخدم نشط يومياً، وأكثر من 1000 مستخدم نشط شهرياً (MAU).


7. ترشيد المنتج: التخلي عن المنتدى والانتقال إلى مركز «الدراسة»

في أغسطس 2026، وقبل انطلاق العام الدراسي الجديد، حان وقت المراجعة الصادقة للمنتج، وكان المرشح الأول لإعادة التقييم هو منتدى الطلاب المغلق.

من الناحية الهندسية والجهد المبذول، كان المنتدى رائعاً: واجهات متقنة، وردود شجرية، ونظام أوسمة، وحماية من الإغراق، وتوثيق بالحساب المؤسسي. لكن على أرض الواقع، كان شبه مهجور؛ إذ لم يُنشر فيه طوال العام الدراسي سوى بضع عشرات من المواضيع.

كشف تحليلنا المعمق أربعة أسباب جوهرية لهذا الإخفاق:

  1. السمات الشخصية للمستخدمين: يجتذب تخصص البرمجة العديد من الطلاب الانطوائيين الذين يترددون في المشاركة في نقاشات عامة على منصات غير مألوفة، لدرجة أن إدارة الكلية تنظم باستمرار فعاليات خاصة لمساعدة الطلاب على الاندماج الاجتماعي.
  2. ضغوط المرحلة الانتقالية: يواجه طلاب السنتين الأولى والثانية ضغوطاً هائلة للتكيف مع الامتحانات، ومتطلبات الأساتذة، والعيش المستقل؛ لذا لا يملكون الطاقة لكتابة مقالات طويلة في منتدى جانبي.
  3. الاعتياد الراسخ على تطبيق Telegram: يمتلك كل قسم ودفعة مجموعات محادثة نشطة على Telegram اعتادوا عليها لسنوات، وكانت محاولة استدراجهم إلى منصة أخرى فكرة غير واقعية.
  4. طبيعة الاستخدام السريع: يدخل الطالب إلى تطبيق الجدول لمدة تتراوح بين 5 و10 ثوانٍ فقط: يلقي نظرة خاطفة على رقم القاعة قبل رنين الجرس ثم يعيد الهاتف إلى جيبه. لا أحد يدخل تطبيقاً خدمياً لغرض القراءة المطولة.

كان الإبقاء على آلاف الأسطر البرمجية غير المستخدمة خطأً هندسياً لا يغتفر. في 27 أغسطس 2026، وبأمر إيداع واحد (Commit)، حذفنا 75 ملفاً و4497 سطراً برمجياً تخص المنتدى.

كانت خطوة صعبة من الناحية النفسية، لكنها شكلت نقطة تحول نحو النضج المهني: المهندس الحقيقي هو من يمتلك الشجاعة لحذف كوده متى ما ثبت أنه لا يقدم فائدة ملموسة للناس.

ولادة مركز «الدراسة» الأكاديمي (/study)

شغلت المساحة المركزية المحررة وحدة وظيفية جديدة ركزت كلياً على حل المشكلات الأكاديمية العملية للطلاب:

  1. مراقبة القاعات الشاغرة لحظياً (/classrooms): أداة تفاعلية تتيح بنقرة واحدة معرفة القاعات الشاغرة في الحصة الحالية أو في أي يوم من أيام الأسبوع. تُقسم القائمة بوضوح إلى قاعات شاغرة وأخرى مشغولة مع إمكانية إضافة القاعات إلى المفضلة. وعند انعدام القاعات الشاغرة، تعرض الواجهة رسائل فكاهية لطيفة (“لا توجد قاعات، اصمدوا—كل شيء محجوز عدا أملنا” في أوقات الذروة، أو “بوفيه مفتوح من القاعات—حيث المقاعد أكثر من الطلاب” في الفترات الهادئة).
  2. التوجيه الذكي والتلميحات التوضيحية (ClassroomTooltip و 2GIS):
    • لا تُقام جميع الحصص في قاعات ذات أرقام قياسية؛ إذ يحتوي الجدول غالباً على مسميات نصية طويلة مثل (“قاعة المطالعة-أ”، و“القاعة الكبرى”، و“الصالة الرياضية”، و“مكتب حواسيب الطلاب”). ونظراً لأن النصوص الطويلة لا تتسع داخل بطاقة الحصة المدمجة في شاشات الهواتف، تستبدلها الواجهة بشارات أنيقة (مثل ЧЗ-А) وأيقونات معبرة. وعند الضغط على الشارة، تظهر نافذة ClassroomTooltip لتعرض الاسم الكامل للمكان (“قاعة المطالعة-أ”، “مكتب حواسيب الطلاب”، “الصالة الرياضية”، “القاعة الكبرى”، “عن بُعد”).
    • وإذا كانت الحصة في الحرم الجامعي الرئيسي، تظهر شارة قابلة للنقر تحول الطالب مباشرة إلى تطبيق الخرائط 2GIS (MapNsuLecture)، مما يفتح موقع المبنى المعني ويزيل حيرة الطلاب الجدد قبل التوجه إلى المدينة الجامعية.
  3. نافذة التحقق السريع من الجدول (Verify Schedule Sheet): يشعر الطلاب أحياناً بالقلق: “ماذا لو أخطأ التطبيق أو لم يرصد إلغاء حصة؟”. أضفنا نافذة تحقق تمكّن الطالب بنقرة واحدة من فتح رابط ملف PDF الأصلي من خوادم الكلية للتأكد بنفسه، أو الانتقال للموقع الرسمي.
  4. الفصل بين «دفتر المتابعة» (/journal) و«سجل الدرجات» (/grade): دمج المركز مسارين أكاديميين عبر الربط الآمن مع بوابة جامعة نوفوسيبيرسك (cab.nsu.ru):
    • دفتر المتابعة الإلكتروني: يعكس الإيقاع الأسبوعي للفصل الدراسي—الواجبات المنزلية، ومواضيع الحصص، وتسجيل الغياب، والدرجات الدورية مع تمييز لوني للاختبارات وأسابيع التقييم.
    • سجل الدرجات الرسمي: يستخرج البيانات الرسمية لامتحانات جميع الفصول الدراسية السابقة. تستخرج الدالة grade(self) رقم السجل، والتخصص، ودرجات الاختبارات مع أسماء الأساتذة، وتحسب المعدل التراكمي العام والتقدير المتوقع للشهادة:
async def grade(self):
    try:
        headers = {
            'Cookie': self.cookie,
            'X-Requested-With': 'XMLHttpRequest',
            'Referer': 'https://cab.nsu.ru/student/grade'
        }
        async with self.session.get('/student/grade?load-widget=true', headers=headers) as r:
            if len(r.history) != 0 or r.status == 403:
                return None

            soup = BS(await r.text(), 'html.parser')
            header = soup.find('div', class_='block-header')
            if not header:
                return None

            number = ''
            specialty = ''
            for title in header.find_all('h4', class_='block-header-title'):
                title_text = title.text.strip()
                if 'Номер:' in title_text:
                    number = title_text.replace('Номер:', '').strip()
                elif 'Специальность:' in title_text:
                    specialty = title_text.replace('Специальность:', '').strip()

            average_rating = ''
            diploma_rating = ''
            for store_block in header.find_all('div', class_='average-store'):
                store_title = store_block.find('div', class_='title')
                store_val = store_block.find('div', class_='store')
                if store_title and store_val:
                    st_text = store_title.text.strip()
                    if 'Общий средний балл' in st_text:
                        average_rating = store_val.text.strip()
                    elif 'Средний балл диплома' in st_text:
                        diploma_rating = store_val.text.strip()

            terms = []
            tab_panes = soup.find_all('div', class_='tab-pane')
            for pane in tab_panes:
                pane_header = pane.find('h4', class_='block-header-title')
                pane_title = pane_header.text.strip().replace('Успеваемость за ', '').strip() if pane_header else pane.get('id', '')

                subjects = []
                for item in pane.find_all('div', class_='item-grade'):
                    name_el = item.find('div', class_='name')
                    name = name_el.text.strip() if name_el else ''

                    mark_el = item.find('span', class_='mark')
                    mark = mark_el.text.strip() if mark_el else ''
                    mark_classes = mark_el.get('class', []) if mark_el else []
                    mark_type = next((c for c in mark_classes if c != 'mark'), '')

                    kurs_el = item.find('div', class_='kurs')
                    date = kurs_el.text.replace('Дата:', '').strip() if kurs_el else ''

                    isp_el = item.find('div', class_='isp')
                    attestation_type = isp_el.text.replace('Форма аттестации:', '').strip() if isp_el else ''

                    teacher_el = item.find('div', class_='teachers')
                    teacher = ''
                    if teacher_el:
                        teacher_span = teacher_el.find('span')
                        if teacher_span:
                            teacher = teacher_span.text.strip()
                        else:
                            teacher = teacher_el.text.replace('Преподаватель:', '').strip()

                    subjects.append({
                        'name': name,
                        'mark': mark,
                        'mark_type': mark_type,
                        'date': date,
                        'type': attestation_type,
                        'teacher': teacher
                    })

                terms.append({
                    'title': pane_title,
                    'subjects': subjects
                })

            return {
                'number': number,
                'specialty': specialty,
                'average_rating': average_rating,
                'diploma_rating': diploma_rating,
                'terms': terms
            }
    except Exception:
        return None
  1. القرارات الإدارية الرسمية للكلية (/orders) وجدول رنين الأجراس (/bells): لتلبية كافة الاحتياجات الأكاديمية، قمنا بأتمتة استخراج وتنسيق القرارات الإدارية للكلية (القبول، والمنح، والتحويل، والتخرج)، إلى جانب عرض مواعيد الحصص والاستراحات للفترتين الصباحية والمسائية دون الحاجة للبحث عن الجداول الورقية المعلقة في الممرات.

  2. خدمة المراقبة التلقائية للدرجات الجديدة (NsuMarksNotify): تعمل خدمة غير متزامنة في الخلفية كل 30 دقيقة للتحقق من درجات الطلاب المشتركين. وعند اكتشاف درجات جديدة عبر دالة _detect_new_marks، يُرسل الخادم إشعار Web Push فورياً؛ وكثيراً ما يتفاجأ الطلاب بوصول إشعار الدرجة على شاشة الهاتف المقفلة قبل أن يعلنها الأستاذ داخل القاعة:

changes = self._detect_new_marks(old_term, new_term)
if changes:
    for change in changes:
        title = f'Новая оценка по {change['subject']}'
        text = f'В журнал добавлена оценка {change['mark']} по предмету {change['subject']}. Проверьте детали в приложении.'
        Util.sendWebPush(Util.buildSubscriptionInfo(user), {'title': title}, text)
        await NsuNotificationManager.create(
            user=user,
            notify_type='new_mark',
            title=title,
            message=text,
            data={'subject': change['subject'], 'mark': change['mark']}
        )

وهكذا اكتملت المنظومة لتجمع بين جدول الحصص، ومتابعة الدرجات، وحالة المواد المتعثرة في منصة واحدة متناسقة.

دفتر المتابعة الإلكتروني موضحاً الدرجات وحالة التخلفات الدراسية

ويتيح عرض التفاصيل للطالب الوصول المباشر إلى معدله الفصلي والتراكمي ونسب الحضور والغياب.

شاشة تفاصيل التحصيل الأكاديمي والمعدل التراكمي ونسب الحضور


8. الخلاصة والدروس الهندسية المستفادة

مع حلول سبتمبر 2026، تظل الخدمة منتجاً مستقلاً تماماً؛ ومن غير المرجح أن تتبناها الكلية رسمياً، ولا توجد حاجة عملية لذلك. فبينما يواجه الموقع الرسمي أعطالاً متكررة، يشرق تطبيقنا الويب كل صباح على هواتف مئات الطلاب والأساتذة. لقد تجاوز المشروع كونه دليلاً طلابياً: فالأساتذة يتابعون عبره نصابهم التدريسي وساعات الفراغ بين الحصص، وممثلو المجموعات يحددون القاعات الشاغرة لجلسات المراجعة والمذاكرة الجماعية.

الحزمة البرمجية النهائية للمشروع

النطاق التقنيات المستخدمة
الواجهة الأمامية (Frontend) React 19، TypeScript، Vite، PWA (vite-plugin-pwa، Cache Storage)، Capacitor، Redux Toolkit، React Router 7، SCSS Modules، Tabler Icons
الواجهة الخلفية (Backend) Python، FastAPI، Uvicorn، Tortoise ORM، PostgreSQL (asyncpg)، Pydantic v2
معالجة المستندات و PDF Camelot (lattice، OpenCV)، PyMuPDF، Pandas، BeautifulSoup4
البنية التحتية والتشغيل Linux VPS، Nginx (شهادات Let’s Encrypt SSL، HTTP/2، Gzip)، Web Push (VAPID)

بالنظر إلى رحلة العام والنصف المليئة بالتجارب والأخطاء والليالي الطوال، تتلخص أهم الدروس الهندسية في ثلاث نقاط:

  1. البرمجة السطحية عبر الذكاء الاصطناعي (Vibe-Coding) مفيدة فقط كشرارة أولى للتحقق من الأفكار. إن بناء النماذج الأولية سريعاً أمر رائع، لكن نقل كود غير مفهوم إلى بيئة الإنتاج قنبلة موقوتة. لا تتحقق الموثوقية إلا عندما تستوعب بعمق معاملات مثل line_scale وjoint_tol، وتفهم دورة حياة Service Worker، وبنية الجلسات الشبكية.
  2. القدرة على حذف الأكواد أهم من القدرة على كتابتها. كان التخلص من كود المنتدى بعد أسابيع من الجهد أمراً صعباً، لكن إزالة 4500 سطر من الشفرات الميتة حولت المشروع من كتلة متضخمة إلى أداة خفيفة وسريعة تلبي حاجة حقيقية. تعلّق دائماً بحل مشكلة المستخدم، لا بالأسطر التي كتبتها.
  3. تطبيقات الويب التقدمية (PWA) سلاح خارق للمطور المستقل. الاستغناء عن رسوم المتاجر السنوية البالغة 99 دولاراً، والتحرر من بطء المراجعات، وسرعة نشر التحديثات، والعمل دون اتصال، كل ذلك يجعل الويب المنصة الأسمى لبناء حلول تقنية تخدم المجتمعات المحلية.

الهندسة البرمجية ليست مجرد ملاحقة للصرعات التقنية الزائلة؛ بل هي الشجاعة لأخذ مستند PDF معقد وغير مريح، وتحويله إلى أداة تيسر حياة أناس حقيقيين من حولك كل صباح.

كل الامتنان والشكر للمصمم Arlen على لمساته البصرية الاستثنائية، وصبره اللامحدود مع التعديلات، وإيمانه المشترك بنجاح هذا المشروع.