Seocontent / app.py
o134's picture
feat: retry/backoff, safety guards, structured SEO logging
9f1960c verified
Raw
History Blame Contribute Delete
58.7 kB
import gradio as gr
import requests
import os
import re
import math
import time
import datetime
import spaces
@spaces.GPU
def dummy_gpu():
pass
MODEL_NAME = "gemini-flash-lite-latest"
GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL_NAME}:generateContent"
def get_api_keys():
keys = []
for i in range(1, 4):
k = os.environ.get(f"GEMINI_API_KEY{i}", "").strip()
if k:
keys.append(k)
fallback = os.environ.get("GEMINI_API_KEY", "").strip()
if fallback and fallback not in keys:
keys.append(fallback)
return keys
def _fetch_with_retry(key: str, payload: dict, max_retries: int = 3) -> requests.Response:
"""
يحاول إرسال طلب لمفتاح واحد حتى max_retries مرات.
- عند 503 أو 'high demand'/'overloaded': ينتظر ويعيد المحاولة (exponential backoff).
- عند quota (429/RESOURCE_EXHAUSTED): يرفع QuotaError فوراً للانتقال للمفتاح التالي.
- عند أي خطأ آخر غير قابل للتكرار: يرفعه فوراً.
"""
headers = {"x-goog-api-key": key, "Content-Type": "application/json"}
backoff = 3 # ثواني
for attempt in range(1, max_retries + 1):
try:
resp = requests.post(GEMINI_URL, headers=headers, json=payload, timeout=120)
except requests.RequestException as e:
if attempt == max_retries:
raise
print(f"[generate] ⚠️ خطأ شبكة (محاولة {attempt}/{max_retries}): {e}، انتظار {backoff}s...")
time.sleep(backoff)
backoff *= 2
continue
if not resp.ok:
try:
err_data = resp.json()
except Exception:
err_data = {}
status_str = err_data.get("error", {}).get("status", "")
msg = err_data.get("error", {}).get("message", "").lower()
is_quota = (
status_str == "RESOURCE_EXHAUSTED"
or resp.status_code == 429
or "quota" in msg
)
if is_quota:
raise _QuotaError()
is_high_demand = (
resp.status_code == 503
or "high demand" in msg
or "overloaded" in msg
)
if is_high_demand:
if attempt == max_retries:
raise Exception(
"الخادم مشغول حالياً بعد عدة محاولات. يرجى المحاولة مرة أخرى بعد قليل."
)
print(f"[generate] 🔄 ضغط عالٍ على الخادم (محاولة {attempt}/{max_retries})، انتظار {backoff}s...")
time.sleep(backoff)
backoff *= 2
continue
# خطأ غير قابل للتكرار
err_msg = err_data.get("error", {}).get("message", f"HTTP {resp.status_code}")
raise Exception(err_msg)
return resp # نجاح
raise Exception("الخادم مشغول حالياً بعد عدة محاولات. يرجى المحاولة مرة أخرى بعد قليل.")
class _QuotaError(Exception):
"""رمز داخلي: المفتاح الحالي استنفد الحصة — انتقل للتالي."""
pass
def call_gemini(prompt: str, keys: list, temperature: float = 0.7, use_thinking: bool = True, max_tokens: int = 15000):
payload = {
"contents": [{"parts": [{"text": prompt}]}],
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
}
}
if use_thinking:
payload["generationConfig"]["thinkingConfig"] = {"thinkingLevel": "low"}
print(f"[generate] 🚀 إرسال طلب لـ Gemini | use_thinking={use_thinking} | tokens={max_tokens}")
last_error = "لا يوجد مفتاح API متاح."
for i, key in enumerate(keys):
try:
resp = _fetch_with_retry(key, payload)
except _QuotaError:
next_i = i + 1
if next_i < len(keys):
print(f"[generate] ⚠️ المفتاح رقم {i+1} استنفد الحصة، الانتقال للمفتاح رقم {next_i+1}...")
last_error = f"المفتاح رقم {i+1} استنفد الحصة."
continue
except Exception as e:
# خطأ شبكة أو خادم مشغول بعد كل المحاولات — يوقف المحاولات الكاملة
raise Exception(str(e))
data = resp.json()
parts = data.get("candidates", [{}])[0].get("content", {}).get("parts", [])
text = "\n".join(p.get("text", "") for p in parts if not p.get("thought") and p.get("text"))
if data.get("candidates", [{}])[0].get("finishReason") == "MAX_TOKENS" and use_thinking:
print("[generate] تحذير: MAX_TOKENS، إعادة المحاولة بدون thinking...")
return call_gemini(prompt, keys, temperature, use_thinking=False, max_tokens=max_tokens)
return text
raise Exception(
f"تم استهلاك الحد اليومي لجميع مفاتيح API المتاحة ({len(keys)} مفاتيح). "
f"يُرجى المحاولة غداً بعد تجديد الحد."
)
def split_keywords(raw: str):
return [k.strip() for k in re.split(r'،|,|\s+-\s+', raw) if k.strip()]
def log_seo_report(
title, primary_keyword, target_wc, final_wc, final_wc_diff,
final_kw_count, dyn_min, dyn_max, final_density, final_in_100,
sec_keys, dyn_min_sec, article, warnings,
was_wc_corrected, wc_before_correction, stable_wc,
was_kw_added, was_kw_reduced, conclusion_corrected,
was_sec_kw_added=False
):
"""طباعة تقرير منظم في console لمراجعته في HuggingFace Logs لاحقاً."""
timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
sep = "=" * 62
wc_status = "✅" if abs(final_wc_diff) <= 30 else "❌"
if dyn_min <= final_kw_count <= dyn_max:
kw_status = "✅"
elif final_kw_count < dyn_min:
kw_status = "❌"
else:
kw_status = "⚠️"
sec_lines = []
for kw in sec_keys:
c = len(re.findall(re.escape(kw), article, re.IGNORECASE))
s = "✅" if c >= dyn_min_sec else "❌"
sec_lines.append(f" {kw}: {c} مرات {s}")
lines = [
f"\n{sep}",
f"📋 SEO ARTICLE LOG | {timestamp}",
sep,
f"📌 العنوان : {title[:65]}{'...' if len(title) > 65 else ''}",
f"🔑 الكلمة الأساسية: {primary_keyword}",
sep,
f"📏 عدد الكلمات:",
f" المستهدف : {target_wc}",
f" الفعلي النهائي : {final_wc} {wc_status}",
f" الفرق : {'+' if final_wc_diff > 0 else ''}{final_wc_diff}",
f" تصحيح WC : {'🔄 تم (' + str(wc_before_correction) + '→' + str(stable_wc) + ')' if was_wc_corrected else '✅ لم يحتج'}",
sep,
f"🎯 الكلمة المفتاحية:",
f" التكرار النهائي: {final_kw_count} (المطلوب: {dyn_min}{dyn_max}) {kw_status}",
f" الكثافة : {final_density}%",
f" ظهور أول 100 : {'نعم ✅' if final_in_100 else 'لا ❌'}",
f" تصحيح إضافة : {'🔄 تم' if was_kw_added else '✅ لم يحتج'}",
f" تصحيح حذف : {'✂️ تم' if was_kw_reduced else '✅ لم يحتج'}",
f" تصحيح خلاصة : {'🔄 تم' if conclusion_corrected else '✅ لم يحتج'}",
]
if sec_lines:
lines.append(sep)
lines.append(f"🔑 الكلمات الثانوية (حد أدنى: {dyn_min_sec} لكل كلمة):")
lines.append(f" تصحيح الكلمات الثانوية: {'🔄 تم' if was_sec_kw_added else '✅ لم يحتج'}")
lines.extend(sec_lines)
lines.append(sep)
if warnings:
lines.append("⚠️ تحذيرات:")
for ln in warnings.strip().split("\n"):
if ln.strip():
lines.append(f" {ln.strip()}")
else:
lines.append("✅ لا توجد تحذيرات — المقال اجتاز جميع الفحوصات")
lines.append(f"{sep}\n")
print("\n".join(lines))
def generate_article(title, outline, primary_keyword, secondary_keywords, target_word_count, is_rewrite, rewrite_notes):
if not title or not outline or not primary_keyword:
return "❌ يرجى ملء جميع الحقول المطلوبة (العنوان، الـ Outline، الكلمة المفتاحية الأساسية).", ""
keys = get_api_keys()
if not keys:
return "❌ لم يتم ضبط أي مفتاح Gemini API. يرجى إضافة GEMINI_API_KEY1 في إعدادات الـ Space.", ""
# 1. Target word count
try:
target_wc = int(target_word_count)
if target_wc < 800 or target_wc > 1500:
target_wc = 900
except (ValueError, TypeError):
target_wc = 900
# 2. Dynamic repeat calculation
min_repeats = math.ceil(target_wc * 0.01)
max_repeats = math.floor(target_wc * 0.015)
min_secondary_repeats = max(3, round((target_wc / 900.0) * 3))
sec_keys = split_keywords(secondary_keywords) if secondary_keywords else []
secondary_list = "، ".join(sec_keys) if sec_keys else "لا يوجد"
if target_word_count and 800 <= target_wc <= 1500:
word_count_instruction = f"يجب أن يكون عدد كلمات المقال {target_wc} كلمة بالضبط (بهامش تفاوت لا يتجاوز ±30 كلمة). هذا رقم دقيق حدده المستخدم، التزم به بدقة."
else:
word_count_instruction = "يجب أن يتراوح عدد كلمات المقال بين 800 و1000 كلمة. هذا الشرط إلزامي بدون استثناء بغض النظر عن طبيعة الموضوع أو بساطته الظاهرية. حتى لو بدا الموضوع يمكن تغطيته بإيجاز، يجب عليك التوسع بأمثلة إضافية، تفاصيل عملية أكثر، أو شرح أعمق لكل نقطة في الـ Outline للوصول إلى العدد المطلوب دون حشو أو تكرار للمعنى نفسه بصياغات مختلفة."
prompt = f"""أنت كاتب محتوى SEO محترف. مهمتك كتابة مقال متكامل وعالي الجودة بناءً على المعطيات التالية:
**عنوان المقال:** {title}
**الكلمة المفتاحية الأساسية:** {primary_keyword}
**الكلمات المفتاحية الثانوية:** {secondary_list}
**الـ Outline (العناوين الفرعية):**
{outline}
## ⚠️ قاعدة العناوين والـ Title (أولوية قصوى — لا استثناء):
العنوان الرئيسي للمقال (H1) يجب أن يكون مطابقاً تماماً لعنوان المقال المُدخل من المستخدم، دون أي إضافة أو توسيع أو صياغة فرعية، حتى لو بدت الإضافة تحسينية من الناحية التسويقية.
يجب استخدام العناوين الفرعية (H2) التالية بالضبط كما وردت، دون أي تعديل أو إضافة أو حذف أو إعادة صياغة:
{outline}
لا تُضف كلمات إلى العنوان الرئيسي أو العناوين الفرعية، ولا تُغيّر ترتيب الكلمات فيها، ولا تدمج كلمة مفتاحية داخلها إذا لم تكن موجودة أصلاً. استخدم العنوان حرفياً كما هو.
## تعليمات الكتابة:
- اكتب المقال كاملاً باللغة العربية الفصحى السلسة.
- {word_count_instruction}
- بعد المقدمة مباشرة وقبل أول عنوان فرعي (H2)، أضف فقرة بعنوان 'نظرة سريعة 👀' تتكون من سطرين يشرحان أهمية الموضوع باختصار، يليهما قائمة نقطية (bullet points) تلخص أهم النقاط التي سيتناولها المقال.
## هيكلة الأقسام (مرنة حسب نوع المحتوى):
لكل قسم فرعي (H2) في المقال، اتبع هذا الهيكل العام:
- فقرة تمهيدية قصيرة (2-3 جمل) تشرح أهمية هذه النقطة
- محتوى القسم الرئيسي
- جملة أو جملتين ختاميتين تلخصان الفقرة وتربطانها بالقسم التالي
إذا كان القسم الفرعي (H2) يحتوي على أكثر من فكرة رئيسية منفصلة، قسّمه إلى H3. لا تُقسّم القسم إلى H3 إذا كانت الفكرة الأساسية فيه واحدة ومترابطة.
بالنسبة لمحتوى القسم الرئيسي، اختر الصيغة الأنسب:
- استخدم نقاطاً (bullet points) للخطوات، المميزات، القوائم، والنصائح المنفصلة.
- استخدم فقرات متصلة (نثر) للشرح والسياق والمقارنات.
- لكل قسم فرعي (H2)، أضف مثالاً عملياً واقعياً أو سيناريو قصيراً يوضح الفكرة المطروحة (مثال: موقف يومي، حالة استخدام شائعة)، بدلاً من الاكتفاء بالشرح النظري فقط. هذا يزيد عمق المحتوى وقيمته الحقيقية للقارئ، وليس حشواً.
## الكتابة بأسلوب بشري طبيعي:
- تجنب تماماً: 'بالإضافة إلى ذلك'، 'علاوة على ذلك'، 'فضلاً عن ذلك'، 'في الختام'، 'يعد من أهم'، 'لا يخفى على أحد'، 'من الجدير بالذكر'.
- اربط الجمل بروابط طبيعية (لذلك، حيث، كما، بينما، غير أن، فإن، إذ).
- استخدم صيغة المبني للمعلوم (Active Voice).
- خاطب القارئ مباشرة باستخدام 'أنت'.
- ممنوع استخدام الكلمة المفتاحية الأساسية كجملة ختامية في نهاية أكثر من قسم واحد متتالٍ.
- اكتب بأسلوب بشري طبيعي وعفوي في اختيار الكلمات وتدفق الجمل، لكن هذا لا يعني كتابة جمل قصيرة أو مختصرة. يمكن للجملة أن تكون متوسطة إلى طويلة (15-25 كلمة) وتظل طبيعية غير مصطنعة، طالما أنها مترابطة منطقياً وتحمل معنى حقيقياً. لا تقصر أو تختصر أي فقرة لمجرد تبسيط الأسلوب - كل فقرة يجب أن تحتوي على 4-6 جمل على الأقل لتغطي الفكرة بعمق كافٍ.
## قواعد السيو:
اكتب المقال مع الالتزام الصارم بقواعد السيو التالية:
1. يجب أن تظهر الكلمة المفتاحية الأساسية '{primary_keyword}' في أول 100 كلمة من المقال.
2. ⚠️ (أولوية قصوى): يجب أن تتكرر الكلمة المفتاحية الأساسية '{primary_keyword}' حرفياً عدد {min_repeats} إلى {max_repeats} مرة بالضبط في المقال (وليس نسبة مئوية - عدّ التكرارات فعلياً أثناء الكتابة). هذا رقم دقيق وليس تقريبياً، التزم به بدقة.
مهم جداً: يجب أن يبقى كل استخدام للكلمة المفتاحية طبيعياً وسلساً ضمن سياق الجملة ويخدم القارئ فعلياً. ممنوع منعاً باتاً حشو الكلمة المفتاحية بشكل مصطنع. الأولوية القصوى دائماً هي أن يشعر القارئ أن كل جملة تقدم له قيمة حقيقية.
عند التوسع في المحتوى، يجب الحفاظ على تكرار الكلمة المفتاحية في الأجزاء الموسّعة أيضاً. إذا كانت الكلمة المفتاحية تحتوي على أكثر من كلمة، لا تستبدلها بصيغة مختصرة أو مرادف، بل استخدم العبارة الكاملة كما وردت حرفياً.
3. وزّع تكرار الكلمة المفتاحية على طول المقال.
4. يجب أن تتكرر كل كلمة مفتاحية ثانوية {min_secondary_repeats} مرات بشكل طبيعي في المقال، هذا شرط إلزامي وليس اختيارياً. وزّع ذكر كل كلمة ثانوية على أقسام مختلفة من المقال.
الكلمات المفتاحية الثانوية المطلوبة هي: {secondary_list}
5. الوصول إلى التكرار المطلوب للكلمة المفتاحية الأساسية هو شرط فني صارم لا تتنازل عنه أبداً.
6. ⚠️ قاعدة الخلاصة الصارمة: قسم الخلاصة تحديداً يجب ألا يحتوي على الكلمة المفتاحية الأساسية أكثر من مرة واحدة بالضبط في كامل القسم، بغض النظر عن طول القسم أو عدد فقراته. هذا حد أقصى صارم خاص بقسم الخلاصة فقط، ولا ينطبق بنفس الصرامة على باقي الأقسام.
"""
if is_rewrite:
if rewrite_notes and rewrite_notes.strip():
prompt += f"\nلاحظ المستخدم على المحاولة السابقة ما يلي: '{rewrite_notes.strip()}'. عند إعادة كتابة المقال، عالج هذه الملاحظة تحديداً مع الحفاظ على كل القواعد الأخرى (السيو، الهيكلة، الأسلوب البشري).\n"
prompt += "\nأعد كتابة هذا المقال من جديد بالكامل بأسلوب صياغة وكلمات مختلفة تماماً عن أي محاولة سابقة، مع الحفاظ على نفس جميع القواعد المذكورة أعلاه.\n"
prompt += """
## ✅ مراجعة ذاتية قبل الإرسال (Self-Check):
بعد الانتهاء من كتابة المقال كاملاً، وقبل إرسال إجابتك، راجعه مقابل هذه القائمة وصحّح أي مخالفة الآن:
1. هل استخدمت العنوان الرئيسي (H1) وكل عنوان فرعي من الـ Outline حرفياً كما ورد دون أي تعديل أو إضافة؟
2. هل تجنبت جميع العبارات الممنوعة ('بالإضافة إلى ذلك'، 'علاوة على ذلك'، 'فضلاً عن ذلك'، 'في الختام'، 'يعد من أهم'، 'لا يخفى على أحد'، 'من الجدير بالذكر')؟
3. هل قسم الخلاصة يحتوي على الكلمة المفتاحية الأساسية مرة واحدة فقط كحد أقصى؟
4. هل الأسلوب العام يبدو طبيعياً وبشرياً، لا مصطنعاً أو قولباً؟
5. راجع تصريف كل فعل يأتي بعد 'مما' أو 'الذي' أو 'التي' بدقة: يجب أن يكون الفعل مضارعاً مرفوعاً متفقاً مع الفاعل (مثال صحيح: 'مما يحسّن'، 'مما يعزز' - مثال خاطئ: 'مما تحسين'، 'مما تعزيز'). اقرأ كل جملة تحتوي على هذه الروابط مرتين قبل إنهاء الفقرة للتأكد من صحة الصياغة النحوية.
إذا وجدت أي مخالفة لأي من هذه النقاط، صحّحها قبل إرسال الإجابة النهائية.
"""
prompt += "\nابدأ الكتابة الآن:"
try:
article = call_gemini(prompt, keys)
article = re.sub(r'\n\n\*\*\*\n\n### تقرير السيو[\s\S]*$', '', article)
escaped_keyword = re.escape(primary_keyword)
strict_reminder = "\n\nتذكير صارم: يُمنع تماماً إضافة الكلمة المفتاحية كجملة ختامية لأي فقرة أثناء هذا التعديل. لا تضف الكلمة المفتاحية في أكثر من جملة واحدة ضمن أي فقرة واحدة. إذا كانت فقرة معينة تحتوي بالفعل على الكلمة المفتاحية، لا تضف تكراراً إضافياً لها في نفس الفقرة حتى لو كنت تضيف جملاً جديدة إليها."
# ── 0. Auto-correction: Conclusion specific rule ──────────────────────────
sections = re.split(r'(?=\n## )', article)
conclusion_corrected = False
for i, section in enumerate(sections):
if 'الخلاصة' in section:
conc_keyword_count = len(re.findall(escaped_keyword, section, re.IGNORECASE))
if conc_keyword_count > 1:
print(f"[generate] قسم الخلاصة يحتوي على {conc_keyword_count} تكرارات للكلمة المفتاحية، جاري التصحيح المخصص...")
conc_prompt = f"قسم الخلاصة هذا يحتوي على الكلمة المفتاحية '{primary_keyword}' عدد {conc_keyword_count} مرات. أعد صياغته بحيث تظهر الكلمة المفتاحية مرة واحدة فقط كحد أقصى، مع الحفاظ على نفس المعنى والرسالة الختامية. أرسل نص القسم المُعاد صياغته فقط.\n\nالنص الحالي:\n{section}"
try:
corrected_conc = call_gemini(conc_prompt, keys, temperature=0.5, max_tokens=2000)
if corrected_conc:
corrected_conc = re.sub(r'\n\n\*\*\*\n\n### تقرير السيو[\s\S]*$', '', corrected_conc)
sections[i] = "\n" + corrected_conc.strip()
article = "".join(sections)
conclusion_corrected = True
print("[generate] ✅ تم تصحيح قسم الخلاصة بنجاح")
except Exception as e:
print(f"[generate] فشل تصحيح قسم الخلاصة: {e}")
break
# ── 1. Auto-correction: word count (Section-based)
words = [w for w in article.strip().split() if w]
article_wc_before = len(words)
wc_diff = article_wc_before - target_wc
was_wc_corrected = False
wc_reached_target = True # سيتم تحديثه إذا فشل التصحيح
wc_before_correction = article_wc_before
if abs(wc_diff) > 30:
print(f"[generate] عدد الكلمات ({article_wc_before}) بعيد عن الهدف ({target_wc})، جاري التصحيح الموجه...")
was_wc_corrected = True
parsed_sections = re.split(r'(?=\n## )', article)
candidates = []
for idx, text in enumerate(parsed_sections):
s_words = len([w for w in text.strip().split() if w])
if '## ' in text and 'الخلاصة' not in text and 'نظرة سريعة' not in text:
candidates.append({"text": text, "idx": idx, "words": s_words})
candidates.sort(key=lambda x: x["words"], reverse=True)
num_targets = 2 if abs(wc_diff) > 300 else 1
targets = candidates[:num_targets]
if targets:
diff_per_section = round(abs(wc_diff) / len(targets))
is_expansion = wc_diff < 0
min_required_gain = round(diff_per_section * 0.70) # 70% من الزيادة المطلوبة
for target in targets:
original_words = target['words']
best_text = target['text'] # الاحتفاظ بأفضل نسخة
best_words = original_words
for attempt in range(1, 3): # محاولتان كحد أقصى
if attempt == 1:
if is_expansion:
wc_prompt = f"وسّع هذا القسم تحديداً بإضافة {diff_per_section} كلمة فقط (بهامش ±10 كلمات لا أكثر) من محتوى ذي قيمة حقيقية. لا تُضف الكلمة المفتاحية '{primary_keyword}' أكثر من مرة واحدة إضافية في هذا القسم. أرسل نص القسم الموسّع فقط.\n\nالقسم الحالي:\n{target['text']}"
else:
wc_prompt = f"اختصر هذا القسم تحديداً بإزالة {diff_per_section} كلمة تقريباً، عبر حذف التكرار أو الإطناب غير الضروري، دون حذف أي معلومة جوهرية. أرسل نص القسم المُختصر فقط.\n\nالقسم الحالي:\n{target['text']}"
else:
if is_expansion:
wc_prompt = f"المحاولة السابقة لم تُضف كلمات كافية. يجب أن يكون القسم الجديد أطول بشكل ملموس من النسخة الأصلية بما لا يقل عن {min_required_gain} كلمة جديدة. أضف تفاصيل عملية وأمثلة واقعية في هذا القسم. الهدف: إضافة {diff_per_section} كلمة (بهامش ±10). احتفظ بجميع المعلومات الأصلية ولا تستخدم العبارات النمطية الممنوعة. أرسل نص القسم الموسّع فقط.\n\nالقسم الأصلي:\n{target['text']}"
else:
wc_prompt = f"المحاولة السابقة لم تختصر الكلمات الكافية. يجب حذف {diff_per_section} كلمة تقريباً من هذا القسم عبر إزالة الإطناب والتكرار، دون حذف أي معلومة جوهرية. أرسل نص القسم المُختصر فقط.\n\nالقسم الأصلي:\n{target['text']}"
try:
wc_corrected = call_gemini(wc_prompt, keys, temperature=0.5 if attempt == 1 else 0.7, max_tokens=10000)
if wc_corrected:
wc_corrected = re.sub(r'\n\n\*\*\*\n\n### تقرير السيو[\s\S]*$', '', wc_corrected)
new_words = len([w for w in wc_corrected.strip().split() if w])
if new_words >= 30:
# التحقق الوظيفي: هل زاد الطول فعلياً بالقدر المطلوب؟
if is_expansion:
actual_gain = new_words - original_words
if actual_gain >= min_required_gain:
best_text = wc_corrected.strip()
best_words = new_words
print(f"[generate] ✅ تصحيح القسم {target['idx']} (محاولة {attempt}): {original_words}{new_words} (+{actual_gain} كلمة)")
break # نجاح — لا داعي لمحاولة ثانية
else:
print(f"[generate] ⚠️ محاولة {attempt}: القسم زاد {actual_gain} كلمة فقط (المطلوب: {min_required_gain}{'إعادة محاولة...' if attempt == 1 else 'الاحتفاظ بالأفضل'}")
if new_words > best_words:
best_text = wc_corrected.strip()
best_words = new_words
else: # اختصار
actual_reduction = original_words - new_words
if actual_reduction >= min_required_gain:
best_text = wc_corrected.strip()
best_words = new_words
print(f"[generate] ✅ تصحيح القسم {target['idx']} (محاولة {attempt}): {original_words}{new_words} (-{actual_reduction} كلمة)")
break
else:
print(f"[generate] ⚠️ محاولة {attempt}: القسم اختُصر {actual_reduction} كلمة فقط (المطلوب: {min_required_gain})")
if new_words < best_words:
best_text = wc_corrected.strip()
best_words = new_words
else:
print(f"[generate] ⚠️ محاولة {attempt}: استجابة قصيرة جداً ({new_words} كلمة) — تم تجاهلها.")
except Exception as e:
print(f"[generate] فشل تصحيح القسم {target['idx']} (محاولة {attempt}): {e}")
break
# استخدام أفضل نسخة حصلنا عليها
parsed_sections[target['idx']] = "\n" + best_text
article = "".join(parsed_sections)
# تحقق نهائي: هل وصلنا للهدف فعلياً؟
final_wc_after_section_corr = len([w for w in article.strip().split() if w])
if abs(final_wc_after_section_corr - target_wc) > 30:
wc_reached_target = False
print(f"[generate] ⚠️ تصحيح عدد الكلمات: لم يصل للهدف الكامل ({final_wc_after_section_corr} بدلاً من {target_wc})")
else:
wc_reached_target = False
print("[generate] ⚠️ لم يتم العثور على أقسام مناسبة للتوسيع الموجه")
# ── 2. Recalculate target limits based on ACTUAL stable word count
stable_words = [w for w in article.strip().split() if w]
stable_wc = len(stable_words)
dyn_min = math.ceil(stable_wc * 0.01)
dyn_max = math.floor(stable_wc * 0.015)
dyn_min_sec = max(3, round((stable_wc / 900.0) * 3))
# ── 3. Auto-correction: keyword density
keyword_count = len(re.findall(escaped_keyword, article, re.IGNORECASE))
was_kw_added = False
was_kw_reduced = False
if keyword_count < dyn_min:
print(f"[generate] عدد التكرارات ({keyword_count}) أقل من الحد ({dyn_min})، جاري الإضافة...")
was_kw_added = True
diff = dyn_min - keyword_count
corr_prompt = f"هذا المقال يحتوي على {keyword_count} تكرار فقط للكلمة المفتاحية '{primary_keyword}'، وهو أقل من المطلوب ({dyn_min} على الأقل). أضف {diff} تكرارات إضافية للكلمة في فقرات مناسبة من المقال، مع الالتزام الصارم بكل القواعد التالية أثناء الإضافة:\n- يجب أن تندمج الإضافة بشكل طبيعي 100% ضمن سياق الجملة، وتقدم قيمة حقيقية للقارئ وليست مجرد حشو.\n- لا تستخدم أياً من العبارات النمطية الممنوعة (بالإضافة إلى ذلك، علاوة على ذلك، في الختام، إلخ).\n- لا تضف الكلمة كجملة ختامية لأكثر من قسم متتالٍ.\n- لا تُعدّل أو تحذف أي معلومة موجودة في النص الأصلي.\nأرسل النص الكامل المُعدّل فقط.\n{strict_reminder}\n\nالمقال الحالي:\n{article}\n"
# حد الأمان: 20% من targetWordCount كحد أدنى للمقال الكامل (لا يقل عن 200)
full_article_min_wc = max(200, round(target_wc * 0.20))
try:
kw_corrected = call_gemini(corr_prompt, keys, temperature=0.7)
# ── حماية أمان: لا نستبدل المقال إلا إذا كانت الاستجابة
# ── تحتوي على نص حقيقي (≥ 20% من targetWC) يشير لمقال كامل
if kw_corrected:
candidate = re.sub(r'\n\n\*\*\*\n\n### تقرير السيو[\s\S]*$', '', kw_corrected)
candidate_wc = len(candidate.strip().split())
if candidate_wc >= full_article_min_wc:
article = candidate
keyword_count = len(re.findall(escaped_keyword, article, re.IGNORECASE))
else:
print(f"[generate] ⚠️ استجابة تصحيح الإضافة قصيرة ({candidate_wc} < {full_article_min_wc} كلمة) — تم تجاهلها للحفاظ على المقال الأصلي.")
except Exception as e:
print(f"[generate] فشل إضافة الكلمات المفتاحية: {e}")
elif keyword_count > dyn_max:
print(f"[generate] الكلمة المفتاحية تجاوزت الحد الأقصى ({keyword_count} > {dyn_max})، جاري الحذف...")
was_kw_reduced = True
diff = keyword_count - dyn_max
corr_prompt = f"هذا المقال يحتوي على {keyword_count} تكرار للكلمة المفتاحية '{primary_keyword}'، وهو أكثر من الحد الأقصى المسموح به ({dyn_max}). احذف {diff} تكرارات زائدة لهذه الكلمة المفتاحية مع الحفاظ على المعنى وصياغة الجملة بشكل طبيعي. استهدف تحديداً الفقرات التي تحتوي على أكثر من ذكر واحد للكلمة المفتاحية، أو الجمل الختامية في نهاية الفقرات. أرسل النص الكامل المُعدّل فقط.\n\nالمقال الحالي:\n{article}"
try:
kw_corrected = call_gemini(corr_prompt, keys, temperature=0.5)
# ── حماية أمان: نفس الـ guard للحذف (نفس الحد المحسوب أعلاه)
if kw_corrected:
candidate = re.sub(r'\n\n\*\*\*\n\n### تقرير السيو[\s\S]*$', '', kw_corrected)
candidate_wc = len(candidate.strip().split())
if candidate_wc >= full_article_min_wc:
article = candidate
keyword_count = len(re.findall(escaped_keyword, article, re.IGNORECASE))
else:
print(f"[generate] ⚠️ استجابة تصحيح الحذف قصيرة ({candidate_wc} < {full_article_min_wc} كلمة) — تم تجاهلها للحفاظ على المقال الأصلي.")
except Exception as e:
print(f"[generate] فشل حذف الكلمات المفتاحية: {e}")
# ── 3.5. Auto-correction: secondary keyword density
was_sec_kw_added = False
sec_kw_reached_target = True # سيتم تحديثه إذا فشل التصحيح
if sec_keys:
missing_sec_kws = []
for kw in sec_keys:
c = len(re.findall(re.escape(kw), article, re.IGNORECASE))
if c < dyn_min_sec:
missing_sec_kws.append({"kw": kw, "count": c, "diff": dyn_min_sec - c})
if missing_sec_kws:
print(f"[generate] بعض الكلمات الثانوية لم تصل للحد ({dyn_min_sec})، جاري الإضافة...")
was_sec_kw_added = True
best_article_for_sec = article # الاحتفاظ بأفضل نسخة
def count_sec_improvements(text, missing_list, min_sec):
"""يُعيد عدد الكلمات الثانوية التي تحسّنت (أي زاد عددها مقارنةً بالقديم)"""
improved = 0
for item in missing_list:
new_c = len(re.findall(re.escape(item['kw']), text, re.IGNORECASE))
if new_c > item['count']:
improved += 1
return improved
best_improvements = count_sec_improvements(best_article_for_sec, missing_sec_kws, dyn_min_sec)
for attempt in range(1, 3): # محاولتان كحد أقصى
if attempt == 1:
instructions = "\n".join([
f"- الكلمة '{item['kw']}': العدد الحالي {item['count']}، المطلوب {dyn_min_sec} (أضف {item['diff']} تكرارات)."
for item in missing_sec_kws
])
sec_prompt = f"هذا المقال ينقصه بعض التكرارات للكلمات المفتاحية الثانوية. المطلوب منك إضافة هذه التكرارات الناقصة في فقرات مناسبة من المقال:\n{instructions}\n\nيجب الالتزام الصارم بما يلي:\n- ادمج الإضافة بشكل طبيعي 100% ضمن سياق الجملة لتقدم قيمة حقيقية.\n- لا تستخدم العبارات النمطية الممنوعة (بالإضافة إلى ذلك، في الختام، إلخ).\n- لا تُعدّل أو تحذف أي معلومة موجودة في النص الأصلي.\n- لا تخل بتكرار الكلمة المفتاحية الأساسية '{primary_keyword}'.\nأرسل النص الكامل المُعدّل فقط.\n\nالمقال الحالي:\n{article}\n"
else:
# تحديث قائمة الناقصة بعد المحاولة الأولى
still_missing = [
{"kw": item['kw'], "count": len(re.findall(re.escape(item['kw']), best_article_for_sec, re.IGNORECASE)), "diff": 0}
for item in missing_sec_kws
]
still_missing = [
{**item, "diff": dyn_min_sec - item['count']}
for item in still_missing if item['count'] < dyn_min_sec
]
if not still_missing:
break # كل الكلمات تحسّنت في المحاولة الأولى
instructions2 = "\n".join([
f"- الكلمة '{item['kw']}': لا تزال عند {item['count']} تكرارات فقط، تحتاج {item['diff']} تكرارات إضافية. يجب إضافتها صراحةً في جمل موضوعية جديدة."
for item in still_missing
])
sec_prompt = f"المحاولة السابقة لم تُضف جميع الكلمات الثانوية الناقصة بشكل كافٍ. هذه المرة يجب أن تُضاف هذه الكلمات صراحةً كجزء من جمل ذات معنى حقيقي:\n{instructions2}\n\n- لا تُعدّل أو تحذف أي معلومة موجودة في النص الأصلي.\n- لا تخل بتكرار الكلمة المفتاحية الأساسية '{primary_keyword}'.\n- لا تستخدم العبارات النمطية الممنوعة.\nأرسل النص الكامل المُعدّل فقط.\n\nالمقال الحالي:\n{best_article_for_sec}\n"
try:
sec_kw_corrected = call_gemini(sec_prompt, keys, temperature=0.7)
if sec_kw_corrected:
candidate = re.sub(r'\n\n\*\*\*\n\n### تقرير السيو[\s\S]*$', '', sec_kw_corrected)
candidate_wc = len(candidate.strip().split())
if candidate_wc >= full_article_min_wc:
# التحقق الوظيفي: هل زاد تكرار الكلمات الثانوية فعلياً؟
improvements = count_sec_improvements(candidate, missing_sec_kws, dyn_min_sec)
if improvements > best_improvements:
best_article_for_sec = candidate
best_improvements = improvements
print(f"[generate] ✅ تصحيح الكلمات الثانوية (محاولة {attempt}): تحسّنت {improvements}/{len(missing_sec_kws)} كلمة")
if improvements == len(missing_sec_kws):
break # نجاح كامل
else:
print(f"[generate] ⚠️ محاولة {attempt}: لم يتحسن تكرار الكلمات الثانوية — {'إعادة محاولة...' if attempt == 1 else 'الاحتفاظ بالأفضل'}")
else:
print(f"[generate] ⚠️ محاولة {attempt}: استجابة قصيرة ({candidate_wc} < {full_article_min_wc}) — تم تجاهلها.")
except Exception as e:
print(f"[generate] فشل إضافة الكلمات الثانوية (محاولة {attempt}): {e}")
break
article = best_article_for_sec
# تحقق نهائي من الكلمات الثانوية
still_failing = []
for item in missing_sec_kws:
final_c = len(re.findall(re.escape(item['kw']), article, re.IGNORECASE))
if final_c < dyn_min_sec:
still_failing.append(item['kw'])
if still_failing:
sec_kw_reached_target = False
print(f"[generate] ⚠️ الكلمات الثانوية التالية لم تصل للهدف بعد المحاولتين: {still_failing}")
# ── 4. Final Verification Pass & SEO Report
final_words = [w for w in article.strip().split() if w]
final_wc = len(final_words)
final_kw_count = len(re.findall(escaped_keyword, article, re.IGNORECASE))
final_density = round((final_kw_count / final_wc * 100), 2) if final_wc > 0 else 0
final_first_100 = " ".join(final_words[:100])
final_in_100 = bool(re.search(escaped_keyword, final_first_100, re.IGNORECASE))
final_wc_diff = final_wc - target_wc
final_wc_status = "✅" if abs(final_wc_diff) <= 30 else "❌"
sec_report = ""
has_sec_warn = False
if sec_keys:
sec_report = f"\n* **تكرار الكلمات المفتاحية الثانوية** (الحد الأدنى المطلوب: {dyn_min_sec} لكل كلمة):\n"
for kw in sec_keys:
c = len(re.findall(re.escape(kw), article, re.IGNORECASE))
status = "✅" if c >= dyn_min_sec else "❌"
if c < dyn_min_sec: has_sec_warn = True
sec_report += f" * {kw}: {c} مرات {status}\n"
if was_wc_corrected:
if wc_reached_target:
wc_corr_status = f"تم بنجاح ✅ ({wc_before_correction}{stable_wc} كلمة)"
else:
wc_corr_status = f"تمت المحاولة لكن لم يصل للهدف الكامل ⚠️ ({wc_before_correction}{stable_wc} كلمة)"
else:
wc_corr_status = "لم يُحتج إليه ✅"
if dyn_min <= final_kw_count <= dyn_max:
kw_status = "✅"
elif final_kw_count < dyn_min:
kw_status = "❌"
else:
kw_status = "⚠️"
kw_add_status = "تم تفعيله 🔄" if was_kw_added else "لم يُحتج إليه ✅"
kw_red_status = "تم تفعيله ✂️" if was_kw_reduced else "لم يُحتج إليه ✅"
if was_sec_kw_added:
sec_kw_add_status = "تم بنجاح ✅" if sec_kw_reached_target else "تمت المحاولة لكن لم يصل للهدف الكامل ⚠️"
else:
sec_kw_add_status = "لم يُحتج إليه ✅"
warnings = ""
if abs(final_wc_diff) > 30:
sign = "+" if final_wc_diff > 0 else ""
warnings += f"\n⚠️ **طول المقال:** المقال النهائي يبعد بـ {sign}{final_wc_diff} كلمة عن الهدف ({target_wc})، وهو خارج هامش التفاوت المسموح (±30)."
if final_kw_count < dyn_min or final_kw_count > dyn_max:
warnings += f"\n⚠️ **كثافة الكلمة الأساسية:** التكرار النهائي ({final_kw_count}) خارج النطاق المقبول ({dyn_min}-{dyn_max})."
if has_sec_warn:
warnings += f"\n⚠️ **الكلمات الثانوية:** بعض الكلمات الثانوية لم تحقق الحد الأدنى من التكرار ({dyn_min_sec})."
if not final_in_100:
warnings += f"\n⚠️ **أول 100 كلمة:** الكلمة المفتاحية الأساسية لم تظهر في أول 100 كلمة."
report = (
f"\n\n***\n\n### تقرير السيو (محسوب آلياً 🤖)\n"
f"* **عدد الكلمات المستهدف:** {target_wc} كلمة\n"
f"* **عدد الكلمات الفعلي النهائي:** {final_wc} كلمة {final_wc_status}\n"
f"* **الفرق عن الهدف:** {'+' if final_wc_diff > 0 else ''}{final_wc_diff} كلمة\n"
f"* **تصحيح عدد الكلمات:** {wc_corr_status}\n"
f"* **التكرار الفعلي للكلمة الأساسية:** {final_kw_count} مرات (المطلوب: {dyn_min}{dyn_max}) {kw_status}\n"
f"* **تصحيح نقص الكلمة المفتاحية:** {kw_add_status}\n"
f"* **تصحيح الكلمات المفتاحية الزائدة:** {kw_red_status}\n"
f"* **تصحيح نقص الكلمات الثانوية:** {sec_kw_add_status}\n"
f"* **نسبة الكثافة الفعلية النهائية:** {final_density}%\n"
f"* **الظهور في أول 100 كلمة:** {'نعم ✅' if final_in_100 else 'لا ❌'}"
+ sec_report
+ (f"\n\n**⚠️ تحذيرات المراجعة النهائية:**{warnings}" if warnings else "")
)
# ── 5. Structured Console Log (visible in HuggingFace Logs) ──────────────
log_seo_report(
title=title,
primary_keyword=primary_keyword,
target_wc=target_wc,
final_wc=final_wc,
final_wc_diff=final_wc_diff,
final_kw_count=final_kw_count,
dyn_min=dyn_min,
dyn_max=dyn_max,
final_density=final_density,
final_in_100=final_in_100,
sec_keys=sec_keys,
dyn_min_sec=dyn_min_sec,
article=article,
warnings=warnings,
was_wc_corrected=was_wc_corrected,
wc_before_correction=wc_before_correction,
stable_wc=stable_wc,
was_kw_added=was_kw_added,
was_kw_reduced=was_kw_reduced,
conclusion_corrected=conclusion_corrected,
was_sec_kw_added=was_sec_kw_added,
)
# فحص إضافي للعبارات الممنوعة
forbidden_phrases = [
'بالإضافة إلى ذلك',
'علاوة على ذلك',
'فضلاً عن ذلك',
'في الختام',
'يعد من أهم',
'لا يخفى على أحد',
'من الجدير بالذكر'
]
found_phrase = next((p for p in forbidden_phrases if p in article), None)
forbidden_check = f"العبارات الممنوعة: وُجدت '{found_phrase}' في النص ❌" if found_phrase else "العبارات الممنوعة: لا توجد ✅"
extra_check_section = f"\n\n### فحص إضافي\n{forbidden_check}"
report += extra_check_section
# تقييم وصفي (طلب منفصل)
eval_prompt = f"""راجع هذا المقال النهائي وقيّمه بصدق حسب المعايير التالية، دون تعديل أي شيء في النص نفسه:
1. الهيكلة الهرمية: هل استُخدمت عناوين H3 بشكل منطقي حيث كانت هناك حاجة فعلية لتقسيم فرعي؟ (نعم / لا / جزئياً - سبب واحد مختصر)
2. استخدام النقاط: هل استُخدمت bullet points في الأماكن المناسبة؟ (نعم / لا / جزئياً)
3. الأسلوب: هل الكتابة بسيطة وطبيعية وخالية من المبالغة ومكتوبة بأسلوب بشري بسيط؟ (نعم / لا / جزئياً)
4. السلامة اللغوية: هل توجد أخطاء نحوية واضحة؟ (اذكرها أو 'لا توجد')
أجب بإيجاز شديد (سطر واحد لكل بند فقط).
المقال:
{article}"""
suggested_rewrite_notes = None
try:
eval_payload = {
"contents": [{"parts": [{"text": eval_prompt}]}],
"generationConfig": {"maxOutputTokens": 250, "temperature": 0.1}
}
for k in keys:
try:
resp = requests.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL_NAME}:generateContent?key={k}",
headers={"Content-Type": "application/json"},
json=eval_payload,
timeout=10
)
if resp.status_code == 200:
eval_data = resp.json()
eval_text = eval_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
if eval_text:
report += f"\n\n### تقييم وصفي\n{eval_text.strip()}"
if "لا" in eval_text or "جزئياً" in eval_text or "جزئيا" in eval_text:
rewrite_prompt = f"""بناءً على هذا التقييم لمقال:
{eval_text.strip()}
اكتب فقرة قصيرة جداً (3 أسطر كحد أقصى) بصيغة تعليمات مباشرة لإعادة صياغة المقال، تعالج فقط المشاكل المذكورة في التقييم أعلاه. لا تضف أي قاعدة عامة جديدة، فقط صف بدقة ماذا يجب تصحيحه."""
rw_payload = {
"contents": [{"parts": [{"text": rewrite_prompt}]}],
"generationConfig": {"maxOutputTokens": 200, "temperature": 0.3}
}
for rw_k in keys:
try:
rw_resp = requests.post(
f"https://generativelanguage.googleapis.com/v1beta/models/{MODEL_NAME}:generateContent?key={rw_k}",
headers={"Content-Type": "application/json"},
json=rw_payload,
timeout=10
)
if rw_resp.status_code == 200:
rw_data = rw_resp.json()
rw_text = rw_data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text", "")
if rw_text:
suggested_rewrite_notes = rw_text.strip()
break
except Exception:
continue
break
except Exception:
continue
except Exception:
pass
return article + report, suggested_rewrite_notes or ""
except Exception as e:
return f"❌ خطأ: {str(e)}", ""
# ── Gradio UI ─────────────────────────────────────────────────────────────────
with gr.Blocks(title="اكتب مقالتك بشكل احترافي", theme=gr.themes.Soft(primary_hue="blue", font=gr.themes.GoogleFont("Cairo"))) as demo:
gr.Markdown(
"""
# 🚀 اكتب مقالتك بشكل احترافي
### أداة متقدمة لكتابة مقالات عربية محسّنة لمحركات البحث باستخدام Gemini AI
"""
)
with gr.Row():
with gr.Column(scale=1):
title_input = gr.Textbox(label="📝 عنوان المقال", placeholder="مثال: أفضل نظارات شمسية رجالية", rtl=True)
outline_input = gr.Textbox(label="📋 الـ Outline (عنوان لكل سطر)", placeholder="معايير اختيار النظارة\nأفضل الماركات\nنصائح للعناية", lines=5, rtl=True)
primary_kw = gr.Textbox(label="🎯 الكلمة المفتاحية الأساسية", placeholder="مثال: نظارات شمسية رجالية", rtl=True)
secondary_kw = gr.Textbox(label="🔑 الكلمات المفتاحية الثانوية (مفصولة بفاصلة أو شرطة)", placeholder="مثال: نظارات شمسية أصلية, حماية العين", rtl=True)
with gr.Row():
target_word_count = gr.Number(label="📏 عدد الكلمات المستهدف (800-1500)", value=900, minimum=800, maximum=1500)
is_rewrite = gr.Checkbox(label="🔄 إعادة صياغة لمحاولة سابقة", value=False, interactive=True)
rewrite_notes = gr.Textbox(label="✍️ ملاحظات لإعادة الصياغة (اختياري)", placeholder="مثال: أضف فقرة عن كذا وكذا...", lines=2, rtl=True, visible=False)
generate_btn = gr.Button("✨ توليد المقال", variant="primary", size="lg")
with gr.Column(scale=2):
output = gr.Textbox(label="📄 المقال المُولَّد + تقرير السيو", lines=35, rtl=True, elem_classes=["output-box"], elem_id="article-output")
copy_btn = gr.Button("📋 نسخ النص", variant="secondary", size="sm", elem_id="copy-btn")
generate_btn.click(
fn=generate_article,
inputs=[title_input, outline_input, primary_kw, secondary_kw, target_word_count, is_rewrite, rewrite_notes],
outputs=[output, rewrite_notes],
)
is_rewrite.change(
fn=lambda checked: gr.update(visible=checked),
inputs=[is_rewrite],
outputs=[rewrite_notes],
)
copy_btn.click(
fn=None,
inputs=[],
outputs=[],
js="""
async () => {
const textarea = document.querySelector('#article-output textarea');
if (!textarea || !textarea.value.trim()) return;
const text = textarea.value;
let html = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
html = html.replace(/^### (.*?)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.*?)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.*?)$/gm, '<h1>$1</h1>');
html = html.replace(/\\*\\*(.*?)\\*\\*/g, '<strong>$1</strong>');
html = html.replace(/^[\\*\\-] (.*?)$/gm, '<li>$1</li>');
html = html.replace(/(<li>.*?<\\/li>(?:\\n<li>.*?<\\/li>)*)/g, '<ul>$1</ul>');
html = html.split('\\n\\n').map(p => {
if (p.startsWith('<h') || p.startsWith('<ul>')) return p;
return '<p>' + p.replace(/\\n/g, '<br>') + '</p>';
}).join('');
const div = document.createElement('div');
div.innerHTML = html;
div.style.position = 'fixed';
div.style.left = '-9999px';
div.style.color = '#000000';
div.style.backgroundColor = '#ffffff';
div.dir = 'rtl';
document.body.appendChild(div);
const range = document.createRange();
range.selectNodeContents(div);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
try {
document.execCommand('copy');
} catch (err) {
navigator.clipboard.writeText(text);
}
sel.removeAllRanges();
document.body.removeChild(div);
const btn = document.querySelector('#copy-btn');
if (btn) {
const original = btn.innerText;
btn.innerText = '✅ تم النسخ!';
setTimeout(() => { btn.innerText = original; }, 2000);
}
}
""",
)
gr.Markdown(
"""
---
**ملاحظات:**
- يتم حساب تقرير السيو آلياً ويحتوي على نظام تصحيح ذاتي لضمان الكثافة والطول.
- النظام يدعم 3 مفاتيح API مع انتقال تلقائي عند استنفاد الحصة.
"""
)
demo.launch(
css="""
.output-box { font-family: 'Cairo', sans-serif; direction: rtl; text-align: right; }
h1 { text-align: center; }
"""
)