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, '>'); html = html.replace(/^### (.*?)$/gm, '
' + p.replace(/\\n/g, '
') + '