""" HandwrittenOCR - مولّد المرجع الدراسي v5.3 (محسّن) ====================================================== مبنية على اقتراحات Gemini: - table_to_markdown(): تحويل الجداول اليدوية إلى Markdown - generate_study_guide(): توليد مرجع دراسي بصيغة Markdown من البيانات المستخرجة من الخط اليدوي - export_study_guide_html(): تصدير المرجع مع تلوين المصطلحات البرمجية - generate_mermaid_diagram(): مخطط Mermaid للعلاقات بين المصطلحات - generate_flashcards(): بطاقات تعليمية من المفردات والملاحظات - export_flashcards_anki(): تصدير بطاقات بتنسيق Anki CSV استخدامه: from src.study_guide import ( generate_study_guide, table_to_markdown, generate_mermaid_diagram, generate_flashcards, export_flashcards_anki, export_study_guide_html, ) """ import os import json import csv import logging import random from datetime import datetime from typing import Optional from collections import defaultdict import pandas as pd logger = logging.getLogger("HandwrittenOCR") # ===================== تحويل الجداول إلى Markdown ===================== def table_to_markdown(cells_data: list[dict], columns: list[str] = None) -> str: """ تحويل البيانات المقطوعة من الجداول إلى تنسيق Markdown. Parameters: cells_data: قائمة بأزواج القاموس {english, arabic, context} columns: أسماء الأعمدة (اختياري) Returns: نص Markdown يمثل الجدول """ if not cells_data: return "" if columns is None: # استخراج المفاتيح المتاحة available_keys = set() for row in cells_data: available_keys.update(row.keys()) columns = [k for k in ["english", "arabic", "context", "page"] if k in available_keys] if not columns: return "" # بناء رأس الجدول header = "| " + " | ".join(columns) + " |" separator = "| " + " | ".join(["---"] * len(columns)) + " |" # بناء الصفوف rows = [] for row in cells_data: cells = [str(row.get(col, "")) for col in columns] rows.append("| " + " | ".join(cells) + " |") return "\n".join([header, separator] + rows) # ===================== تلوين المصطلحات البرمجية ===================== # ألوان ANSI للتلوين في Markdown SYNTAX_COLORS = { "keywords": "#2563EB", # أزرق — الكلمات المحجوزة "types": "#16A34A", # أخضر — أنواع البيانات "functions": "#9333EA", # بنفسجي — الدوال والمكتبات "numbers": "#DC2626", # أحمر — الأرقام } PYTHON_BUILTINS_COLOR = { "print", "input", "len", "range", "type", "int", "str", "float", "list", "dict", "set", "tuple", "bool", "open", "super", "self", "append", "extend", "pop", "sort", "join", "split", "strip", "enumerate", "zip", "map", "filter", "sorted", "reversed", } DATA_TYPES = { "integers", "floating", "points", "strings", "boolean", "list", "dictionary", "tuple", "none", "mutable", "immutable", } def highlight_python_terms(text: str) -> str: """ تمييز المصطلحات البرمجية بالنص. يُستخدم لتلوين الكلمات داخل مرجع Markdown. Parameters: text: النص الأصلي Returns: النص مع HTML spans للتلوين (يعمل في Markdown) """ if not text: return text words = text.split() highlighted = [] for word in words: clean = word.strip(".,;:!?\"'()-*").lower() if clean in PYTHON_BUILTINS_COLOR: highlighted.append( f'{word}' ) elif clean in DATA_TYPES: highlighted.append( f'{word}' ) else: highlighted.append(word) return " ".join(highlighted) # ===================== توليد المرجع الدراسي ===================== def generate_study_guide( db, output_path: Optional[str] = None, title: str = "مرجع دراسة — مستخرج من الملاحظات اليدوية", y_tolerance: int = 25, highlight_terms: bool = True, ) -> str: """ توليد مرجع دراسي بصيغة Markdown من البيانات الموثقة. يحوّل الكلمات المكتوبة بخط اليد إلى مستند منظم يحتوي: 1. عنوان وعنوان فرعي لكل صفحة 2. جداول المصطلحات (إنجليزي-عربي) 3. جمل مُعاد بناؤها 4. تلوين المصطلحات البرمجية (اختياري) Parameters: db: كائن قاعدة البيانات output_path: مسار حفظ الملف (اختياري) title: عنوان المرجع y_tolerance: حد تباعد Y لنفس السطر highlight_terms: تفعيل تلوين المصطلحات البرمجية Returns: محتوى Markdown الكامل """ # جلب البيانات words = db.get_all() if not words: logger.warning("لا توجد بيانات لتوليد المرجع الدراسي") return "" df = pd.DataFrame(words) pages = sorted(df["page_num"].dropna().unique()) guide = [] guide.append(f"# {title}\n") guide.append(f"تاريخ الإنشاء: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n") guide.append(f"عدد الصفحات: {len(pages)}\n") guide.append("---\n") for pg in pages: pg_words = df[df["page_num"] == pg].sort_values(["y", "x"]) if pg_words.empty: continue guide.append(f"\n## صفحة رقم: {int(pg)}\n") # 1. استخراج الجداول (أزواج إنجليزي-عربي) table_data = _extract_table_from_page(pg_words, y_tolerance) if table_data: guide.append("### جداول المصطلحات\n") guide.append(table_to_markdown(table_data)) guide.append("") # 2. إعادة بناء الجمل sentences = _reconstruct_page_sentences(pg_words, y_tolerance) if sentences: guide.append("### الملاحظات والشروحات\n") for sent in sentences: text = sent["text"] if highlight_terms: text = highlight_python_terms(text) lang_indicator = sent.get("lang", "en") if lang_indicator == "ar": guide.append(f"- {text}") else: guide.append(f"- {text} *(EN)*") guide.append("") content = "\n".join(guide) if output_path: os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(content) logger.info(f"تم حفظ المرجع الدراسي في: {output_path}") return content def generate_study_guide_full( db, output_dir: Optional[str] = None, title: str = "مرجع دراسة شامل — مستخرج من الملاحظات اليدوية", y_tolerance: int = 25, highlight_terms: bool = True, include_mermaid: bool = True, mermaid_type: str = "mindmap", include_flashcards: bool = True, flashcard_type: str = "bilingual", max_flashcards: int = 100, ) -> str: """ توليد مرجع دراسي شامل بصيغة Markdown يتضمن: 1. المرجع الأساسي (جداول + ملاحظات) 2. مخطط Mermaid للعلاقات بين المصطلحات 3. بطاقات تعليمية (Flashcards) هذا هو الإصدار المحسّن من generate_study_guide() الذي يجمع كل الميزات الجديدة في ملف واحد. Parameters: db: كائن قاعدة البيانات output_dir: مجلد الحفظ (اختياري) title: عنوان المرجع y_tolerance: حد تباعد Y لنفس السطر highlight_terms: تفعيل تلوين المصطلحات البرمجية include_mermaid: تضمين مخطط Mermaid mermaid_type: نوع المخطط ("mindmap" | "flowchart" | "graph") include_flashcards: تضمين البطاقات التعليمية flashcard_type: نوع البطاقات ("bilingual" | "concept" | "fill_blank") max_flashcards: الحد الأقصى للبطاقات Returns: محتوى Markdown الشامل """ # المرجع الأساسي content = generate_study_guide( db=db, title=title, y_tolerance=y_tolerance, highlight_terms=highlight_terms, ) if not content: return "" # إضافة مخطط Mermaid if include_mermaid: mermaid_code = generate_mermaid_diagram(db, diagram_type=mermaid_type) if mermaid_code: content += "\n\n---\n\n" content += "## خريطة المفردات (Mermaid)\n\n" content += f"```mermaid\n{mermaid_code}\n```\n" # إضافة البطاقات التعليمية if include_flashcards: cards = generate_flashcards( db=db, card_type=flashcard_type, max_cards=max_flashcards, ) if cards: content += "\n\n---\n\n" content += flashcards_to_markdown(cards, title="البطاقات التعليمية") # حفظ الملفات if output_dir: os.makedirs(output_dir, exist_ok=True) # حفظ Markdown الشامل md_path = os.path.join(output_dir, "study_guide_full.md") with open(md_path, "w", encoding="utf-8") as f: f.write(content) logger.info(f"تم حفظ المرجع الشامل في: {md_path}") # حفظ Mermaid منفصل if include_mermaid: mermaid_code = generate_mermaid_diagram(db, diagram_type=mermaid_type) if mermaid_code: mermaid_path = os.path.join(output_dir, "vocab_diagram.mmd") with open(mermaid_path, "w", encoding="utf-8") as f: f.write(mermaid_code) logger.info(f"تم حفظ مخطط Mermaid في: {mermaid_path}") # حفظ Flashcards بصيغة Anki if include_flashcards: cards = generate_flashcards( db=db, card_type=flashcard_type, max_cards=max_flashcards, ) if cards: anki_path = os.path.join(output_dir, "flashcards_anki.csv") export_flashcards_anki(cards, anki_path) # حفظ HTML html_path = os.path.join(output_dir, "study_guide_full.html") export_study_guide_html(content, html_path, title=title) return content def _extract_table_from_page( df_page: pd.DataFrame, y_tolerance: int = 25, ) -> list[dict]: """ استخراج أزواج المفردات (إنجليزي-عربي) من صفحة واحدة. يحاول ربط الكلمات الإنجليزية بالعربية على نفس السطر. """ table_rows = [] # تقسيم إلى أسطر lines = [] current = [df_page.iloc[0].to_dict()] for i in range(1, len(df_page)): row = df_page.iloc[i].to_dict() if abs(row["y"] - current[-1]["y"]) <= y_tolerance: current.append(row) else: lines.append(current) current = [row] lines.append(current) for line in lines: en_words = [] ar_words = [] for w in line: text = str(w.get("predicted_text", "")).strip() if not text: continue # تمييز: إنجليزي (ASCII) مقابل عربي if all(ord(c) < 128 for c in text.replace(" ", "")): en_words.append(text) elif any("\u0600" <= c <= "\u06FF" for c in text): ar_words.append(text) if en_words or ar_words: table_rows.append({ "english": " | ".join(en_words) if en_words else "", "arabic": " | ".join(ar_words) if ar_words else "", }) return table_rows def _extract_all_vocabulary( db, ) -> list[dict]: """ استخراج جميع أزواج المفردات (إنجليزي-عربي) من قاعدة البيانات. يُستخدم لتوليد المخططات والبطاقات التعليمية. Returns: قائمة بأزواج {"english": ..., "arabic": ..., "page": ..., "context": ...} """ words = db.get_all() if not words: return [] df = pd.DataFrame(words) all_vocab = [] pages = sorted(df["page_num"].dropna().unique()) for pg in pages: pg_words = df[df["page_num"] == pg].sort_values(["y", "x"]) if pg_words.empty: continue table_rows = _extract_table_from_page(pg_words) for row in table_rows: if row.get("english") or row.get("arabic"): row["page"] = int(pg) all_vocab.append(row) return all_vocab def _reconstruct_page_sentences( df_page: pd.DataFrame, y_tolerance: int = 25, ) -> list[dict]: """إعادة بناء جمل من صفحة واحدة""" sentences = [] lines = [] current = [df_page.iloc[0].to_dict()] for i in range(1, len(df_page)): row = df_page.iloc[i].to_dict() if abs(row["y"] - current[-1]["y"]) <= y_tolerance: current.append(row) else: lines.append(current) current = [row] lines.append(current) for line in lines: texts = [ str(w.get("predicted_text", "")).strip() for w in line if w.get("predicted_text") and str(w["predicted_text"]).strip() ] if not texts: continue text_preview = " ".join(texts) lang = "en" try: from langdetect import detect lang = detect(text_preview) except Exception: pass # ترتيب حسب اللغة sorted_line = sorted(line, key=lambda k: k["x"], reverse=(lang == "ar")) sentence = " ".join( str(w.get("predicted_text", "")) for w in sorted_line ).strip() if sentence: sentences.append({"text": sentence, "lang": lang}) return sentences # ===================== مخططات Mermaid ===================== def _sanitize_mermaid_id(text: str) -> str: """تنظيف النص ليكون معرّفاً صالحاً في Mermaid.""" clean = text.strip() # استبدال الأحرف غير المسموحة for ch in ['"', "'", '(', ')', '{', '}', '[', ']', '<', '>', '/', '\\', '&', '#', '|', ';']: clean = clean.replace(ch, "_") clean = clean.replace(" ", "_") # إزالة التشكيل العربي arabic_diacritics = set("\u0610\u0611\u0612\u0613\u0614\u0615\u0616\u0617\u0618\u0619\u061A" "\u064B\u064C\u064D\u064E\u064F\u0650\u0651\u0652") clean = "".join(c for c in clean if c not in arabic_diacritics) if not clean: clean = "term" # التأكد من أن المعرّف لا يبدأ برقم if clean[0].isdigit(): clean = "t_" + clean return clean[:40] # حد أقصى للطول def generate_mermaid_diagram( db, diagram_type: str = "mindmap", max_terms: int = 50, ) -> str: """ توليد مخطط Mermaid من المفردات المستخرجة. يدعم ثلاثة أنواع من المخططات: - "mindmap": خريطة ذهنية للمصطلحات الإنجليزية مع ترجماتها العربية - "flowchart": مخطط انسيابي يربط المصطلحات حسب الصفحات - "graph": رسم بياني يعرض العلاقات بين المفردات Parameters: db: كائن قاعدة البيانات diagram_type: نوع المخطط ("mindmap" | "flowchart" | "graph") max_terms: الحد الأقصى للمصطلحات المعروضة Returns: نص Mermaid جاهز للتضمين في Markdown """ vocab = _extract_all_vocabulary(db) if not vocab: logger.warning("لا توجد مفردات لتوليد مخطط Mermaid") return "" vocab = vocab[:max_terms] if diagram_type == "mindmap": return _generate_mindmap(vocab) elif diagram_type == "flowchart": return _generate_flowchart(vocab) elif diagram_type == "graph": return _generate_graph(vocab) else: logger.warning(f"نوع مخطط غير مدعوم: {diagram_type}") return _generate_mindmap(vocab) def _generate_mindmap(vocab: list[dict]) -> str: """ خريطة ذهنية: المصطلح الإنجليزي في الفرع الرئيسي، الترجمة العربية في الفرع الفرعي. يُجمّع المصطلحات حسب الصفحة المصدر. """ lines = ["mindmap", " root((المصطلحات))"] # تجميع حسب الصفحة by_page = defaultdict(list) for v in vocab: page_key = f"صفحة {v.get('page', '?')}" by_page[page_key].append(v) for page_label, terms in by_page.items(): page_id = _sanitize_mermaid_id(page_label) lines.append(f" {page_id}[{page_label}]") for term in terms: en = term.get("english", "").strip() ar = term.get("arabic", "").strip() if en and ar: en_id = _sanitize_mermaid_id(en) ar_id = _sanitize_mermaid_id(ar) lines.append(f" {en_id}[{en}]") lines.append(f" {ar_id}[{ar}]") elif en: en_id = _sanitize_mermaid_id(en) lines.append(f" {en_id}[{en}]") elif ar: ar_id = _sanitize_mermaid_id(ar) lines.append(f" {ar_id}[{ar}]") return "\n".join(lines) def _generate_flowchart(vocab: list[dict]) -> str: """ مخطط انسيابي: يربط المصطلحات حسب الصفحة المصدر. كل صفحة تمثل عقدة رئيسية، والمصطلحات تخرج منها. """ lines = ["flowchart LR"] by_page = defaultdict(list) for v in vocab: page_key = f"صفحة {v.get('page', '?')}" by_page[page_key].append(v) page_ids = [] for page_label, terms in by_page.items(): page_id = _sanitize_mermaid_id(page_label) page_ids.append(page_id) lines.append(f" {page_id}[{page_label}]") for term in terms: en = term.get("english", "").strip() ar = term.get("arabic", "").strip() label = f"{en} = {ar}" if en and ar else (en or ar) if label: term_id = _sanitize_mermaid_id(label) lines.append(f" {page_id} --> {term_id}[{label}]") # ربط الصفحات ببعضها for i in range(len(page_ids) - 1): lines.append(f" {page_ids[i]} -.-> {page_ids[i + 1]}") return "\n".join(lines) def _generate_graph(vocab: list[dict]) -> str: """ رسم بياني يعرض المصطلحات كعقد مرتبطة. يربط المصطلحات الإنجليزية بالعربية بخطوط متجهة. """ lines = ["graph LR"] node_count = 0 for v in vocab: en = v.get("english", "").strip() ar = v.get("arabic", "").strip() if en and ar: en_id = _sanitize_mermaid_id(en) ar_id = _sanitize_mermaid_id(ar) # تجنب تكرار العقد بنفس المعرّف lines.append(f" {en_id}[{en}]") lines.append(f" {ar_id}[{ar}]") lines.append(f" {en_id} -->|ترجمة| {ar_id}") node_count += 1 elif en: en_id = _sanitize_mermaid_id(en) lines.append(f" {en_id}[{en}]") node_count += 1 elif ar: ar_id = _sanitize_mermaid_id(ar) lines.append(f" {ar_id}[{ar}]") node_count += 1 return "\n".join(lines) # ===================== البطاقات التعليمية (Flashcards) ===================== def generate_flashcards( db, card_type: str = "bilingual", max_cards: int = 100, shuffle: bool = True, ) -> list[dict]: """ توليد بطاقات تعليمية (Flashcards) من البيانات المستخرجة. يدعم عدة أنواع من البطاقات: - "bilingual": بطاقات ثنائية اللغة (وجه إنجليزي / ظهر عربي) - "concept": بطاقات مفاهيمية (مصطلح / شرح من السياق) - "fill_blank": بطاقات ملء الفراغ (جملة مع كلمة محجوبة) Parameters: db: كائن قاعدة البيانات card_type: نوع البطاقات ("bilingual" | "concept" | "fill_blank") max_cards: الحد الأقصى للبطاقات shuffle: خلط البطاقات عشوائياً Returns: قائمة بطاقات، كل بطاقة dict{"front": ..., "back": ..., "tags": ...} """ words = db.get_all() if not words: logger.warning("لا توجد بيانات لتوليد البطاقات التعليمية") return [] df = pd.DataFrame(words) pages = sorted(df["page_num"].dropna().unique()) if card_type == "bilingual": cards = _generate_bilingual_flashcards(df, pages) elif card_type == "concept": cards = _generate_concept_flashcards(df, pages) elif card_type == "fill_blank": cards = _generate_fill_blank_flashcards(df, pages) else: logger.warning(f"نوع بطاقات غير مدعوم: {card_type}") cards = _generate_bilingual_flashcards(df, pages) if shuffle: random.shuffle(cards) return cards[:max_cards] def _generate_bilingual_flashcards( df: pd.DataFrame, pages: list, ) -> list[dict]: """ بطاقات ثنائية اللغة: الوجه بالإنجليزية والظهر بالعربية (أو العكس). يُنشئ بطاقتين لكل زوج (EN→AR و AR→EN) لضمان التعلّم في الاتجاهين. """ cards = [] for pg in pages: pg_words = df[df["page_num"] == pg].sort_values(["y", "x"]) if pg_words.empty: continue table_rows = _extract_table_from_page(pg_words) for row in table_rows: en = row.get("english", "").strip() ar = row.get("arabic", "").strip() if en and ar: # بطاقة EN → AR cards.append({ "front": en, "back": ar, "tags": [f"page_{int(pg)}", "EN-AR"], }) # بطاقة AR → EN cards.append({ "front": ar, "back": en, "tags": [f"page_{int(pg)}", "AR-EN"], }) return cards def _generate_concept_flashcards( df: pd.DataFrame, pages: list, ) -> list[dict]: """ بطاقات مفاهيمية: الوجه يحتوي مصطلح، والظهر يحتوي السياق أو الجملة التي ظهر فيها المصطلح في الملاحظات. """ cards = [] for pg in pages: pg_words = df[df["page_num"] == pg].sort_values(["y", "x"]) if pg_words.empty: continue # استخراج المصطلحات من الجداول table_rows = _extract_table_from_page(pg_words) for row in table_rows: en = row.get("english", "").strip() ar = row.get("arabic", "").strip() if en: cards.append({ "front": en, "back": f"الترجمة العربية: {ar}" if ar else "(بدون ترجمة عربية)", "tags": [f"page_{int(pg)}", "concept"], }) # استخراج الجمل التي تحتوي مصطلحات مفيدة sentences = _reconstruct_page_sentences(pg_words) for sent in sentences: text = sent.get("text", "").strip() if not text or len(text.split()) < 3: continue # إنشاء بطاقة من الجملة: الوجه = أول كلمتين، الظهر = بقية الجملة words_list = text.split() if len(words_list) >= 4: front = " ".join(words_list[:2]) + " ___" back = text cards.append({ "front": front, "back": back, "tags": [f"page_{int(pg)}", "sentence", sent.get("lang", "unknown")], }) return cards def _generate_fill_blank_flashcards( df: pd.DataFrame, pages: list, ) -> list[dict]: """ بطاقات ملء الفراغ: تحجب كلمة عشوائية من الجملة. الوجه = الجملة مع فراغ، الظهر = الكلمة المحجوبة. """ cards = [] for pg in pages: pg_words = df[df["page_num"] == pg].sort_values(["y", "x"]) if pg_words.empty: continue sentences = _reconstruct_page_sentences(pg_words) for sent in sentences: text = sent.get("text", "").strip() if not text or len(text.split()) < 3: continue words_list = text.split() # حجب كلمة عشوائية (ليس الأولى أو الأخيرة) blank_idx = random.randint(1, len(words_list) - 2) if len(words_list) > 2 else 0 blanked_word = words_list[blank_idx] words_list[blank_idx] = "______" front = " ".join(words_list) cards.append({ "front": front, "back": blanked_word, "tags": [f"page_{int(pg)}", "fill_blank", sent.get("lang", "unknown")], }) return cards def export_flashcards_anki( cards: list[dict], output_path: str, deck_name: str = "HandwrittenOCR::Study", include_tags: bool = True, ) -> str: """ تصدير البطاقات التعليمية بتنسيق CSV متوافق مع Anki. تنسيق Anki CSV: front;back;tags (مع فاصل منقوطة إذا لم يُحدد) يمكن استيراد هذا الملف مباشرة في Anki عبر: File > Import > اختيار الملف > نوع: "Separated by Semicolon" Parameters: cards: قائمة البطاقات من generate_flashcards() output_path: مسار حفظ ملف CSV deck_name: اسم الباقة في Anki include_tags: تضمين الوسوم Returns: مسار الملف المحفوظ """ if not cards: logger.warning("لا توجد بطاقات للتصدير") return "" os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f, delimiter=";") # رأس Anki if include_tags: writer.writerow(["front", "back", "tags"]) else: writer.writerow(["front", "back"]) for card in cards: front = card.get("front", "").replace(";", ",").replace("\n", " ") back = card.get("back", "").replace(";", ",").replace("\n", " ") if include_tags: tags_str = " ".join(card.get("tags", [])) writer.writerow([front, back, tags_str]) else: writer.writerow([front, back]) logger.info(f"تم حفظ {len(cards)} بطاقة في: {output_path}") return output_path def flashcards_to_markdown(cards: list[dict], title: str = "بطاقات تعليمية") -> str: """ تحويل البطاقات التعليمية إلى تنسيق Markdown. يمكن استخدام هذا مع أدوات مثل Obsidian أو Markdeep. Parameters: cards: قائمة البطاقات title: عنوان القسم Returns: نص Markdown يحتوي البطاقات """ if not cards: return "" lines = [f"### {title}\n"] lines.append(f"العدد الإجمالي: {len(cards)} بطاقة\n") for i, card in enumerate(cards, 1): front = card.get("front", "") back = card.get("back", "") tags = card.get("tags", []) tags_str = f" `{'`, `'.join(tags)}`" if tags else "" lines.append(f"#### بطاقة {i}{tags_str}\n") lines.append(f"**الوجه:** {front}\n") lines.append(f"**الظهر:** ||{back}||\n") return "\n".join(lines) # ===================== تصدير HTML (نسخة مطبوعة) ===================== def export_study_guide_html( markdown_content: str, output_path: str, title: str = "مرجع دراسة", ) -> str: """ تحويل المرجع من Markdown إلى HTML أنيق للطباعة. يتضمن تنسيق CSS احترافي مع دعم RTL. """ html = f"""
| {cell} | " html += "
{stripped}
\n" if in_table: html += "\n" html += "\n" os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(html) logger.info(f"تم حفظ المرجع HTML في: {output_path}") return output_path