TawasoaAi commited on
Commit
eea4855
·
verified ·
1 Parent(s): 58861bb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +208 -237
app.py CHANGED
@@ -2,22 +2,24 @@
2
  import os
3
  import re
4
  import json
5
- import time
6
  import io
 
 
7
  import fitz # PyMuPDF
8
  import requests
9
  import google.generativeai as genai
10
  import mysql.connector
 
11
  from bs4 import BeautifulSoup
12
  from dotenv import load_dotenv
13
  from tqdm import tqdm
14
  from sentence_transformers import SentenceTransformer
15
  import gradio as gr
16
- from urllib.parse import urljoin, urlparse
17
  from collections import deque
18
- import pandas as pd # --- NEW --- لعرض الملفات
19
 
20
- # --- مكتبات جديدة لدعم OCR و DOCX ---
21
  try:
22
  import pytesseract
23
  from PIL import Image
@@ -35,9 +37,8 @@ except ImportError:
35
  DOCX_ENABLED = False
36
  print("⚠️ تحذير: مكتبة python-docx غير موجودة. لن يتم قراءة ملفات وورد.")
37
 
38
-
39
  # ============================================================
40
- # 1. الإعدادات والمتغيرات الأساسية
41
  # ============================================================
42
  print("⏳ جاري تحميل الإعدادات...")
43
  if os.path.exists('.env'):
@@ -48,8 +49,21 @@ DB_CONFIG = {
48
  "user": os.getenv("DB_USER"),
49
  "password": os.getenv("DB_PASSWORD"),
50
  "database": os.getenv("DB_NAME"),
 
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
54
  if GOOGLE_API_KEY:
55
  try:
@@ -63,103 +77,100 @@ else:
63
 
64
  SCRAPE_URL = os.getenv("SCRAPE_URL", "https://fra.gov.eg/")
65
  REQUEST_TIMEOUT = 60
66
- PAGE_LIMIT = 2000
 
67
 
68
- print("⏳ جاري تحميل نموذج Embedding المحلي... (قد يستغرق بعض الوقت أول مرة)")
69
  LOCAL_EMBEDDER = SentenceTransformer("all-MiniLM-L6-v2")
70
  print("✅ تم تحميل نموذج Embedding المحلي.")
71
 
72
  # ============================================================
73
- # 2. إعداد قاعدة البيانات (لا تغيير)
74
  # ============================================================
75
  def setup_database():
 
76
  try:
77
- if not all(DB_CONFIG.values()):
78
- missing_keys = [key for key, value in DB_CONFIG.items() if not value]
79
- print(f" خطأ: بيانات قاعدة البيانات ناقصة في الـ Secrets. الرجاء إضافة: {missing_keys}")
80
- return False, f"بيانات قاعدة البيانات ناقصة: {missing_keys}"
81
-
82
- conn = mysql.connector.connect(**DB_CONFIG)
83
- cursor = conn.cursor()
84
- print("✅ تم الاتصال بقاعدة البيانات بنجاح.")
85
-
86
- cursor.execute("""
87
- CREATE TABLE IF NOT EXISTS documents (
88
- id INT AUTO_INCREMENT PRIMARY KEY,
89
- source VARCHAR(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL UNIQUE,
90
- content LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
91
- summary TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
92
- status VARCHAR(50) DEFAULT 'pending',
93
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
94
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
95
- filename VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL
96
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
97
- """)
98
-
99
- cursor.execute("""
100
- CREATE TABLE IF NOT EXISTS document_chunks (
101
- id INT AUTO_INCREMENT PRIMARY KEY,
102
- document_id INT NOT NULL,
103
- content TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,
104
- embedding JSON,
105
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
106
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
107
- FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
108
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
109
- """)
110
- print(" -> جداول قاعدة البيانات جاهزة.")
111
- conn.commit()
112
- cursor.close()
113
- conn.close()
114
  return True, "قاعدة البيانات جاهزة."
115
  except mysql.connector.Error as err:
116
- error_msg = f"خطأ فادح في قاعدة البيانات: {err}"
117
- print(f"❌ {error_msg}")
118
- return False, error_msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  # ============================================================
121
- # 3. دوال الزحف (لا تغيير)
122
  # ============================================================
123
- def crawl_site(start_url, page_limit=1000):
124
  visited = set([start_url])
125
  queue = deque([start_url])
126
  all_links = set([start_url])
127
  base_domain = urlparse(start_url).netloc
128
-
129
  with tqdm(total=page_limit, desc="🔍 Crawling Progress", unit="page") as pbar:
130
  while queue and len(visited) < page_limit:
131
  url = queue.popleft()
132
  pbar.set_description(f"🔍 Crawling: {url[:70]}...")
133
-
134
  try:
135
- resp = requests.head(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"}, allow_redirects=True)
136
- resp.raise_for_status()
137
- content_type = resp.headers.get("Content-Type", "").lower()
138
-
139
  if "text/html" in content_type:
140
- resp = requests.get(url, timeout=REQUEST_TIMEOUT, headers={"User-Agent": "Mozilla/5.0"})
141
- soup = BeautifulSoup(resp.text, "html.parser")
 
142
  for a_tag in soup.find_all("a", href=True):
143
  href = urljoin(url, a_tag["href"]).split("#")[0].strip()
144
  if not href.startswith("http") or urlparse(href).netloc != base_domain or href in visited:
145
  continue
146
  if len(visited) >= page_limit: break
147
-
148
  visited.add(href)
149
  all_links.add(href)
150
  pbar.update(1)
151
- # لا نضيف ملفات للزحف مرة أخرى
152
  if not any(href.lower().endswith(ext) for ext in ['.pdf', '.docx', '.txt']):
153
  queue.append(href)
154
- except (requests.RequestException, Exception) as e:
155
- tqdm.write(f"⚠️ خطأ أثناء الزحف إلى {url}: {e}")
156
  continue
157
  return list(all_links)
158
 
159
  # ============================================================
160
- # 4. دوال استخلاص النصوص (لا تغيير)
161
  # ============================================================
162
-
163
  def _perform_ocr(image_bytes):
164
  if not OCR_ENABLED: return ""
165
  try:
@@ -194,63 +205,47 @@ def _extract_from_docx(content_bytes):
194
  def _extract_from_html(content_bytes, url):
195
  soup = BeautifulSoup(content_bytes, "html.parser")
196
  text_parts = []
197
- # OCR على الصور داخل الصفحة
198
  if OCR_ENABLED:
199
  for img_tag in soup.find_all('img'):
200
  img_src = img_tag.get('src')
201
  if not img_src: continue
202
  try:
203
  img_url = urljoin(url, img_src)
204
- img_resp = requests.get(img_url, timeout=15)
205
- img_resp.raise_for_status()
206
- tqdm.write(f"📝 OCR on image from URL: {img_url[:80]}...")
207
- text_parts.append(_perform_ocr(img_resp.content))
208
  except Exception as e:
209
  tqdm.write(f"⚠️ فشل تحميل أو معالجة الصورة {img_src}: {e}")
210
 
211
- for element in soup(["script", "style", "nav", "footer", "header", "aside", "form", "button"]):
212
  element.decompose()
213
- text_parts.append(soup.get_text(separator="\n", strip=False))
214
- return "\n".join(text_parts)
 
215
 
216
  def _clean_text(raw_text):
217
  if not raw_text or not isinstance(raw_text, str):
218
  return None
219
-
220
- # إزالة محارف التحكم غير القابلة للطباعة
221
  text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', raw_text)
222
- # إزالة الروابط
223
  text = re.sub(r'https?://\S+', '', text)
224
- # إزالة أي شيء ليس حرفًا أو رقمًا أو مسافة أو علامة ترقيم أساسية
225
- text = re.sub(r'[^a-zA-Z0-9\u0600-\u06FF\s.,-]', '', text)
226
-
227
- # تقسيم السطور وتنظيفها
228
- clean_lines = [line.strip() for line in text.split('\n') if len(line.strip()) > 15]
229
- if not clean_lines:
230
- return None
231
-
232
- final_text = "\n".join(clean_lines)
233
- # تطبيع المسافات والأسطر الجديدة
234
  final_text = re.sub(r" +", " ", final_text)
235
  final_text = re.sub(r"(\n\s*){2,}", "\n\n", final_text)
236
-
237
  return final_text.strip()
238
 
239
  def extract_and_clean_text(url):
240
- """
241
- الدالة الرئيسية الموجهة: تحمل المحتوى وتختار الطريقة المناسبة للاستخلاص.
242
- """
243
  try:
244
- headers = {"User-Agent": "Mozilla/5.0"}
245
- resp = requests.get(url, timeout=REQUEST_TIMEOUT, headers=headers)
246
- resp.raise_for_status()
247
  content_bytes = resp.content
248
  content_type = resp.headers.get('Content-Type', '').lower()
249
-
250
  raw_text = ""
251
  if url.lower().endswith(".pdf") or "application/pdf" in content_type:
252
  raw_text = _extract_from_pdf(content_bytes)
253
- elif url.lower().endswith(".docx") or "application/vnd.openxmlformats-officedocument.wordprocessingml.document" in content_type:
254
  raw_text = _extract_from_docx(content_bytes)
255
  elif url.lower().endswith(".txt") or "text/plain" in content_type:
256
  raw_text = content_bytes.decode('utf-8', errors='ignore')
@@ -258,20 +253,22 @@ def extract_and_clean_text(url):
258
  raw_text = _extract_from_html(content_bytes, url)
259
  else:
260
  tqdm.write(f"⚠️ نوع ملف غير مدعوم في {url} ({content_type})")
261
- return None
262
-
263
- return _clean_text(raw_text)
264
-
 
265
  except Exception as e:
266
  tqdm.write(f"❌ فشل استخراج وتنظيف النص من {url}: {e}")
267
- return None
268
 
269
  # ============================================================
270
- # 5. دوال المعالجة والـ Embedding (لا تغيير)
271
  # ============================================================
272
- def split_into_chunks(text, chunk_size=1000, overlap=150):
273
  if not text: return []
274
- return [text[i:i + chunk_size] for i in range(0, len(text), chunk_size - overlap)]
 
275
 
276
  def get_embedding(text):
277
  if GOOGLE_API_KEY:
@@ -282,208 +279,182 @@ def get_embedding(text):
282
  return LOCAL_EMBEDDER.encode([text])[0].tolist()
283
 
284
  # ============================================================
285
- # 6. التخزين في قاعدة البيانات (لا تغيير)
286
  # ============================================================
287
- def process_and_store_links(links, max_docs):
288
- conn = mysql.connector.connect(**DB_CONFIG)
289
- cursor = conn.cursor()
290
-
291
- saved_docs_count = 0
292
- with tqdm(total=max_docs, desc="💾 Saving Documents") as pbar:
293
- for link in links:
294
- if saved_docs_count >= max_docs:
295
- tqdm.write(f"🏁 تم الوصول للحد الأقصى من المستندات ({max_docs}).")
296
- break
297
-
298
- cursor.execute("SELECT id FROM documents WHERE source = %s", (link,))
299
- if cursor.fetchone():
300
- tqdm.write(f"🔄 الرابط {link} موجود بالفعل، سيتم تخطيه.")
301
- continue
 
 
 
 
 
 
 
 
 
302
 
303
- clean_content = extract_and_clean_text(link)
304
- if not clean_content or len(clean_content) < 150:
305
- tqdm.write(f"⚠️ محتوى غير كافٍ أو غير نظيف في {link}، سيتم تخطيه.")
306
- continue
307
-
308
- try:
309
- summary = clean_content[:500].rsplit(' ', 1)[0] + '...'
310
  cursor.execute(
311
- "INSERT INTO documents (source, content, summary, status) VALUES (%s, %s, %s, 'processing')",
312
- (link, clean_content, summary)
 
 
 
313
  )
314
  document_id = cursor.lastrowid
315
-
316
  chunks = split_into_chunks(clean_content)
317
  for chunk in chunks:
318
  embedding = get_embedding(chunk)
319
  cursor.execute(
320
- "INSERT INTO document_chunks (document_id, content, embedding) VALUES (%s, %s, %s)",
321
- (document_id, chunk, json.dumps(embedding))
322
  )
323
 
324
- cursor.execute("UPDATE documents SET status = 'completed' WHERE id = %s", (document_id,))
325
  conn.commit()
326
- tqdm.write(f"✅ تم حفظ {link} بنجاح مع {len(chunks)} جزء.")
327
- saved_docs_count += 1
328
- pbar.update(1)
329
-
330
- except mysql.connector.Error as err:
331
- tqdm.write(f"❌ خطأ قاعدة بيانات أثناء معالجة {link}: {err}")
332
- conn.rollback()
333
- except Exception as e:
334
- tqdm.write(f"❌ خطأ غير متوقع أثناء معالجة {link}: {e}")
335
- conn.rollback()
336
 
337
- cursor.close()
338
- conn.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
  return f"✅ اكتملت عملية المعالجة. تم حفظ {saved_docs_count} مستند جديد."
340
 
341
  # ============================================================
342
- # 7. الواجهة الرسومية والتشغيل (--- MODIFIED ---)
343
  # ============================================================
 
344
  is_running = False
345
 
346
  def run_full_process_wrapper(max_documents_to_save, progress=gr.Progress(track_tqdm=True)):
347
- global is_running
348
  if is_running:
349
- return "⏳ العملية قيد التشغيل بالفعل، يرجى الانتظار..."
350
-
351
  is_running = True
 
352
  output_log = []
353
-
354
  def log_message(msg):
355
  nonlocal output_log
356
  output_log.append(msg)
357
  return "\n".join(output_log)
358
-
359
  try:
360
- yield log_message("🏁 بدء عملية الإعداد والتحقق...")
361
-
362
  db_ok, db_msg = setup_database()
363
  if not db_ok:
364
  is_running = False
365
- yield log_message(f"❌ فشل إعداد قاعدة البيانات: {db_msg}")
366
  return
367
-
368
- if not GOOGLE_API_KEY:
369
- yield log_message("⚠️ تحذير: مفتاح Google API غير موجود. سيتم الاعتماد على النموذج المحلي.")
370
-
371
- if not OCR_ENABLED:
372
- yield log_message("⚠️ تحذير: ميزة OCR معطلة. لن يتم قراءة النصوص من الصور.")
373
-
374
- if not DOCX_ENABLED:
375
- yield log_message("⚠️ تحذير: ميزة قراءة DOCX معطلة.")
376
-
377
- yield log_message(f"🚀 بدء عملية الزحف للموقع (حد وقائي {PAGE_LIMIT} صفحة)...")
378
-
379
  all_links = crawl_site(SCRAPE_URL, page_limit=PAGE_LIMIT)
380
-
381
- yield log_message(f"✅ تم العثور على {len(all_links)} رابط فريد.")
382
-
383
  if not all_links:
384
  is_running = False
385
- yield log_message("⚠️ لم يتم العثور على أي روابط. انتهت العملية.")
386
  return
387
-
388
  max_docs_int = int(max_documents_to_save)
389
- yield log_message(f"🔄 ستبدأ الآن عملية المعالجة والحفظ (الهدف: {max_docs_int} مستند).")
390
-
391
- final_message = process_and_store_links(all_links, max_docs_int)
392
- yield log_message(f"\n🎉 {final_message}")
393
-
394
  except Exception as e:
395
- yield log_message(f"\n💥 حدث خطأ فادح وغير متوقع: {e}")
396
  finally:
397
  is_running = False
398
- yield log_message("⏹️ العملية انتهت أو تم إيقافها.")
399
-
 
 
 
 
 
400
 
401
- # --- NEW --- Function to list project files
402
  def list_project_files():
403
- """Lists files in the current directory, returning a Pandas DataFrame."""
404
  try:
405
  files = os.listdir('.')
406
  file_details = []
407
- for f in files:
408
  if os.path.isfile(f):
409
  size_bytes = os.path.getsize(f)
410
  size_kb = f"{size_bytes / 1024:.2f} KB"
411
  file_details.append([f, size_kb])
412
-
413
  if not file_details:
414
  return pd.DataFrame(columns=["File Name", "Size"])
415
-
416
  df = pd.DataFrame(file_details, columns=["اسم الملف", "الحجم"])
417
  return df
418
  except Exception as e:
419
  return pd.DataFrame([["خطأ في عرض الملفات", str(e)]], columns=["اسم الملف", "الحجم"])
420
 
421
-
422
  def main():
423
  with gr.Blocks(theme=gr.themes.Soft(), title="Web Scraper & Indexer") as demo:
424
  gr.Markdown(
425
  """
426
- # 🚀 Web Scraper & Intelligent Indexer (V3 - Specialist)
427
  أداة متكاملة للتحكم في عملية كشط المواقع وفهرسة محتواها في قاعدة البيانات.
428
  """
429
  )
430
-
431
- # --- NEW --- Tabbed interface
432
  with gr.Tabs():
433
- # --- Main App Tab ---
434
  with gr.Tab("التطبيق الرئيسي"):
435
- with gr.Column():
436
- with gr.Row():
437
- run_btn = gr.Button("🚀 ابدأ الزحف والفهرسة", variant="primary")
438
- stop_btn = gr.Button("🛑 إيقاف", variant="stop") # --- NEW --- Stop Button
439
-
440
- max_docs_input = gr.Number(
441
- label="الحد الأقصى لعدد المستندات الجديدة للحفظ",
442
- value=50,
443
- minimum=1,
444
- step=10
445
- )
446
-
447
- # --- NEW --- Collapsible log
448
- with gr.Accordion("عرض سجل العمليات (Log)", open=True):
449
- output_log = gr.Textbox(
450
- label="سجل العمليات",
451
- lines=20,
452
- interactive=False,
453
- autoscroll=True
454
- )
455
 
456
- # --- NEW --- Event handling for start and stop buttons
457
- run_event = run_btn.click(
458
- fn=run_full_process_wrapper,
459
- inputs=[max_docs_input],
460
- outputs=output_log
461
- )
462
- stop_btn.click(
463
- fn=None,
464
- inputs=None,
465
- outputs=None,
466
- cancels=[run_event]
467
- )
468
 
469
- # --- Files Tab ---
470
  with gr.Tab("ملفات المشروع"):
471
- gr.Markdown("## عرض الملفات الموجودة في مجلد المشروع على السيرفر.")
472
- refresh_files_btn = gr.Button("🔄 تحديث قائمة الملفات")
473
- file_display = gr.DataFrame(
474
- headers=["اسم الملف", "الحجم"],
475
- datatype=["str", "str"],
476
- label="قائمة الملفات"
477
- )
478
- refresh_files_btn.click(
479
- fn=list_project_files,
480
- inputs=[],
481
- outputs=file_display
482
- )
483
-
484
-
485
  print("\n✅ الواجهة المحسنة جاهزة.")
486
- demo.launch()
487
-
488
  if __name__ == "__main__":
489
  main()
 
2
  import os
3
  import re
4
  import json
 
5
  import io
6
+ import time
7
+ import concurrent.futures
8
  import fitz # PyMuPDF
9
  import requests
10
  import google.generativeai as genai
11
  import mysql.connector
12
+ from mysql.connector import pooling
13
  from bs4 import BeautifulSoup
14
  from dotenv import load_dotenv
15
  from tqdm import tqdm
16
  from sentence_transformers import SentenceTransformer
17
  import gradio as gr
18
+ from urllib.parse import urljoin, urlparse, unquote
19
  from collections import deque
20
+ import pandas as pd
21
 
22
+ # --- OCR and DOCX Libraries ---
23
  try:
24
  import pytesseract
25
  from PIL import Image
 
37
  DOCX_ENABLED = False
38
  print("⚠️ تحذير: مكتبة python-docx غير موجودة. لن يتم قراءة ملفات وورد.")
39
 
 
40
  # ============================================================
41
+ # 1. Settings and Global Variables
42
  # ============================================================
43
  print("⏳ جاري تحميل الإعدادات...")
44
  if os.path.exists('.env'):
 
49
  "user": os.getenv("DB_USER"),
50
  "password": os.getenv("DB_PASSWORD"),
51
  "database": os.getenv("DB_NAME"),
52
+ "charset": "utf8mb4"
53
  }
54
 
55
+ try:
56
+ print("⏳ جاري إنشاء مجمع الاتصالات بقاعدة البيانات...")
57
+ connection_pool = mysql.connector.pooling.MySQLConnectionPool(
58
+ pool_name="scraper_pool",
59
+ pool_size=10,
60
+ **DB_CONFIG
61
+ )
62
+ print("✅ تم إنشاء مجمع الاتصالات بنجاح.")
63
+ except mysql.connector.Error as err:
64
+ print(f"❌ فشل فادح في إنشاء مجمع الاتصالات: {err}")
65
+ exit()
66
+
67
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
68
  if GOOGLE_API_KEY:
69
  try:
 
77
 
78
  SCRAPE_URL = os.getenv("SCRAPE_URL", "https://fra.gov.eg/")
79
  REQUEST_TIMEOUT = 60
80
+ PAGE_LIMIT = 500
81
+ MAX_WORKERS = 5
82
 
83
+ print(f"⏳ جاري تحميل نموذج Embedding المحلي... (قد يستغرق بعض الوقت أول مرة)")
84
  LOCAL_EMBEDDER = SentenceTransformer("all-MiniLM-L6-v2")
85
  print("✅ تم تحميل نموذج Embedding المحلي.")
86
 
87
  # ============================================================
88
+ # 2. Database and Network Helpers
89
  # ============================================================
90
  def setup_database():
91
+ """Checks if the database connection from the pool is valid."""
92
  try:
93
+ with connection_pool.get_connection() as conn:
94
+ with conn.cursor(buffered=True) as cursor:
95
+ cursor.execute("SELECT 1")
96
+ cursor.fetchone()
97
+ print("✅ الاتصال من المجمع (Pool) سليم وجاهز.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  return True, "قاعدة البيانات جاهزة."
99
  except mysql.connector.Error as err:
100
+ error_msg = f"خطأ فادح في قاعدة البيانات: {err}"
101
+ print(error_msg)
102
+ return False, str(err)
103
+
104
+ def get_document_count():
105
+ """Connects to the DB via the pool and returns the current number of documents."""
106
+ try:
107
+ with connection_pool.get_connection() as conn:
108
+ with conn.cursor(buffered=True) as cursor:
109
+ cursor.execute("SELECT COUNT(*) FROM documents")
110
+ count = cursor.fetchone()[0]
111
+ return f"### **العدد الحالي:** {count} مستند"
112
+ except Exception as e:
113
+ print(f"Error getting document count: {e}")
114
+ return "### **العدد الحالي:** خطأ في الاتصال بقاعدة البيانات"
115
+
116
+ def requests_with_retry(url, method='get', retries=3, delay=5, **kwargs):
117
+ headers = kwargs.get("headers", {"User-Agent": "Mozilla/5.0"})
118
+ kwargs["headers"] = headers
119
+ for i in range(retries):
120
+ try:
121
+ if method.lower() == 'get':
122
+ response = requests.get(url, **kwargs)
123
+ elif method.lower() == 'head':
124
+ response = requests.head(url, **kwargs)
125
+ else:
126
+ raise ValueError("Unsupported HTTP method")
127
+ response.raise_for_status()
128
+ return response
129
+ except requests.exceptions.RequestException as e:
130
+ tqdm.write(f"⚠️ فشلت محاولة {i+1} للرابط {url}. خطأ: {e}. سيتم المحاولة مرة أخرى بعد {delay} ثانية.")
131
+ if i < retries - 1:
132
+ time.sleep(delay)
133
+ tqdm.write(f"❌ فشلت كل المحاولات ({retries}) للرابط {url}.")
134
+ return None
135
 
136
  # ============================================================
137
+ # 3. Crawler Functions
138
  # ============================================================
139
+ def crawl_site(start_url, page_limit=100):
140
  visited = set([start_url])
141
  queue = deque([start_url])
142
  all_links = set([start_url])
143
  base_domain = urlparse(start_url).netloc
 
144
  with tqdm(total=page_limit, desc="🔍 Crawling Progress", unit="page") as pbar:
145
  while queue and len(visited) < page_limit:
146
  url = queue.popleft()
147
  pbar.set_description(f"🔍 Crawling: {url[:70]}...")
 
148
  try:
149
+ resp_head = requests_with_retry(url, method='head', timeout=15, allow_redirects=True)
150
+ if not resp_head: continue
151
+ content_type = resp_head.headers.get("Content-Type", "").lower()
 
152
  if "text/html" in content_type:
153
+ resp_get = requests_with_retry(url, timeout=REQUEST_TIMEOUT)
154
+ if not resp_get: continue
155
+ soup = BeautifulSoup(resp_get.text, "html.parser")
156
  for a_tag in soup.find_all("a", href=True):
157
  href = urljoin(url, a_tag["href"]).split("#")[0].strip()
158
  if not href.startswith("http") or urlparse(href).netloc != base_domain or href in visited:
159
  continue
160
  if len(visited) >= page_limit: break
 
161
  visited.add(href)
162
  all_links.add(href)
163
  pbar.update(1)
 
164
  if not any(href.lower().endswith(ext) for ext in ['.pdf', '.docx', '.txt']):
165
  queue.append(href)
166
+ except Exception as e:
167
+ tqdm.write(f"⚠️ خطأ غير متوقع أثناء الزحف إلى {url}: {e}")
168
  continue
169
  return list(all_links)
170
 
171
  # ============================================================
172
+ # 4. Text Extraction Functions
173
  # ============================================================
 
174
  def _perform_ocr(image_bytes):
175
  if not OCR_ENABLED: return ""
176
  try:
 
205
  def _extract_from_html(content_bytes, url):
206
  soup = BeautifulSoup(content_bytes, "html.parser")
207
  text_parts = []
 
208
  if OCR_ENABLED:
209
  for img_tag in soup.find_all('img'):
210
  img_src = img_tag.get('src')
211
  if not img_src: continue
212
  try:
213
  img_url = urljoin(url, img_src)
214
+ img_resp = requests_with_retry(img_url, timeout=15)
215
+ if img_resp:
216
+ tqdm.write(f"📝 OCR on image from URL: {img_url[:80]}...")
217
+ text_parts.append(_perform_ocr(img_resp.content))
218
  except Exception as e:
219
  tqdm.write(f"⚠️ فشل تحميل أو معالجة الصورة {img_src}: {e}")
220
 
221
+ for element in soup(["script", "style", "nav", "footer", "header", "aside", "form", "button", "noscript"]):
222
  element.decompose()
223
+ main_content = soup.find("main") or soup.find("article") or soup.find("body")
224
+ text = main_content.get_text(separator='\n', strip=True) if main_content else ""
225
+ return text + "\n" + "\n".join(text_parts)
226
 
227
  def _clean_text(raw_text):
228
  if not raw_text or not isinstance(raw_text, str):
229
  return None
 
 
230
  text = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', raw_text)
 
231
  text = re.sub(r'https?://\S+', '', text)
232
+ lines = [line.strip() for line in text.split('\n') if len(line.strip()) > 10]
233
+ if not lines: return None
234
+ final_text = "\n".join(lines)
 
 
 
 
 
 
 
235
  final_text = re.sub(r" +", " ", final_text)
236
  final_text = re.sub(r"(\n\s*){2,}", "\n\n", final_text)
 
237
  return final_text.strip()
238
 
239
  def extract_and_clean_text(url):
 
 
 
240
  try:
241
+ resp = requests_with_retry(url, timeout=REQUEST_TIMEOUT)
242
+ if not resp: return None, None
 
243
  content_bytes = resp.content
244
  content_type = resp.headers.get('Content-Type', '').lower()
 
245
  raw_text = ""
246
  if url.lower().endswith(".pdf") or "application/pdf" in content_type:
247
  raw_text = _extract_from_pdf(content_bytes)
248
+ elif url.lower().endswith(".docx") or "vnd.openxmlformats-officedocument.wordprocessingml.document" in content_type:
249
  raw_text = _extract_from_docx(content_bytes)
250
  elif url.lower().endswith(".txt") or "text/plain" in content_type:
251
  raw_text = content_bytes.decode('utf-8', errors='ignore')
 
253
  raw_text = _extract_from_html(content_bytes, url)
254
  else:
255
  tqdm.write(f"⚠️ نوع ملف غير مدعوم في {url} ({content_type})")
256
+ return None, None
257
+ cleaned = _clean_text(raw_text)
258
+ if cleaned and len(cleaned) >= 50:
259
+ return cleaned, url
260
+ return None, None
261
  except Exception as e:
262
  tqdm.write(f"❌ فشل استخراج وتنظيف النص من {url}: {e}")
263
+ return None, None
264
 
265
  # ============================================================
266
+ # 5. Embeddings
267
  # ============================================================
268
+ def split_into_chunks(text, chunk_size=500, overlap=100):
269
  if not text: return []
270
+ step = max(1, chunk_size - overlap)
271
+ return [text[i:i + chunk_size] for i in range(0, len(text), step)]
272
 
273
  def get_embedding(text):
274
  if GOOGLE_API_KEY:
 
279
  return LOCAL_EMBEDDER.encode([text])[0].tolist()
280
 
281
  # ============================================================
282
+ # 6. Database Storage
283
  # ============================================================
284
+ def process_single_link(link):
285
+ """Processes a single link using a connection from the pool."""
286
+ try:
287
+ with connection_pool.get_connection() as conn:
288
+ with conn.cursor(buffered=True) as cursor:
289
+ cursor.execute("SELECT id FROM documents WHERE original_path = %s", (link,))
290
+ if cursor.fetchone():
291
+ return f"🔄 الرابط {link} موجود بالفعل، سيتم تخطيه."
292
+
293
+ clean_content, source_url = extract_and_clean_text(link)
294
+ if not clean_content: return None
295
+
296
+ summary = clean_content[:450] + '...' if len(clean_content) > 450 else clean_content
297
+
298
+ # --- FINAL FIX 1: Decode filename to handle Arabic in URLs ---
299
+ file_name_encoded = os.path.basename(urlparse(link).path)
300
+ file_name = unquote(file_name_encoded)
301
+ if not file_name: # If the path is empty (like for the base domain)
302
+ file_name = link # Use the full link as a fallback
303
+ # ---
304
+
305
+ # --- FINAL FIX 2: Change status to 'completed' to match Laravel ENUM ---
306
+ status_to_insert = 'completed'
307
+ # ---
308
 
 
 
 
 
 
 
 
309
  cursor.execute(
310
+ """
311
+ INSERT INTO documents (filename, original_path, summary, status, created_at, updated_at)
312
+ VALUES (%s, %s, %s, %s, NOW(), NOW())
313
+ """,
314
+ (file_name, link, summary, status_to_insert)
315
  )
316
  document_id = cursor.lastrowid
317
+
318
  chunks = split_into_chunks(clean_content)
319
  for chunk in chunks:
320
  embedding = get_embedding(chunk)
321
  cursor.execute(
322
+ "INSERT INTO document_chunks (document_id, content, embedding, created_at, updated_at) VALUES (%s, %s, %s, NOW(), NOW())",
323
+ (document_id, chunk, json.dumps(embedding) if embedding else None)
324
  )
325
 
 
326
  conn.commit()
327
+ # Use the clean file_name in the success message for clarity
328
+ return f"✅ تم حفظ '{file_name}' بنجاح مع {len(chunks)} جزء."
329
+ except mysql.connector.Error as err:
330
+ return f"❌ خطأ قاعدة بيانات أثناء معالجة {link}: {err}"
331
+ except Exception as e:
332
+ return f"❌ خطأ غير متوقع أثناء معالجة {link}: {e}"
 
 
 
 
333
 
334
+ def process_and_store_links(links, max_docs, stop_flag):
335
+ saved_docs_count = 0
336
+ with tqdm(total=max_docs, desc="💾 Saving Documents") as pbar:
337
+ with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
338
+ future_to_link = {executor.submit(process_single_link, link): link for link in links}
339
+ for future in concurrent.futures.as_completed(future_to_link):
340
+ if stop_flag["stop"] or saved_docs_count >= max_docs:
341
+ for f in future_to_link: f.cancel()
342
+ break
343
+ try:
344
+ result_message = future.result()
345
+ if result_message:
346
+ tqdm.write(result_message)
347
+ if result_message.startswith("✅"):
348
+ saved_docs_count += 1
349
+ pbar.update(1)
350
+ except Exception as exc:
351
+ link = future_to_link[future]
352
+ tqdm.write(f"💥 حدث خطأ فادح في معالج الرابط {link}: {exc}")
353
  return f"✅ اكتملت عملية المعالجة. تم حفظ {saved_docs_count} مستند جديد."
354
 
355
  # ============================================================
356
+ # 7. Gradio UI and Main Execution
357
  # ============================================================
358
+ stop_flag = {"stop": False}
359
  is_running = False
360
 
361
  def run_full_process_wrapper(max_documents_to_save, progress=gr.Progress(track_tqdm=True)):
362
+ global is_running, stop_flag
363
  if is_running:
364
+ yield "⏳ العملية قيد التشغيل بالفعل...", "⏳ قيد التشغيل..."
365
+ return
366
  is_running = True
367
+ stop_flag["stop"] = False
368
  output_log = []
 
369
  def log_message(msg):
370
  nonlocal output_log
371
  output_log.append(msg)
372
  return "\n".join(output_log)
 
373
  try:
374
+ yield log_message("🏁 بدء عملية الإعداد والتحقق..."), "⏳ قيد التشغيل..."
 
375
  db_ok, db_msg = setup_database()
376
  if not db_ok:
377
  is_running = False
378
+ yield log_message(f"❌ فشل إعداد قاعدة البيانات: {db_msg}"), "🔴 فشل"
379
  return
380
+ yield log_message(f"🚀 بدء عملية الزحف للموقع (حد وقائي {PAGE_LIMIT} صفحة)..."), "⏳ قيد التشغيل..."
 
 
 
 
 
 
 
 
 
 
 
381
  all_links = crawl_site(SCRAPE_URL, page_limit=PAGE_LIMIT)
382
+ yield log_message(f"✅ تم العثور على {len(all_links)} رابط فريد."), "⏳ قيد التشغيل..."
 
 
383
  if not all_links:
384
  is_running = False
385
+ yield log_message("⚠️ لم يتم العثور على أي روابط. انتهت العملية."), "✅ مكتمل"
386
  return
 
387
  max_docs_int = int(max_documents_to_save)
388
+ yield log_message(f"🔄 ستبدأ الآن عملية المعالجة والحفظ (الهدف: {max_docs_int} مستند) باستخدام {MAX_WORKERS} معالج متوازي."), "⏳ قيد التشغيل..."
389
+ final_message = process_and_store_links(all_links, max_docs_int, stop_flag)
390
+ yield log_message(f"\n🎉 {final_message}"), "✅ مكتمل"
 
 
391
  except Exception as e:
392
+ yield log_message(f"\n💥 حدث خطأ فادح وغير متوقع: {e}"), "🔴 فشل"
393
  finally:
394
  is_running = False
395
+ if stop_flag["stop"]:
396
+ yield log_message("⏹️ تم إيقاف العملية."), "🛑 متوقف"
397
+
398
+ def stop_process():
399
+ global stop_flag
400
+ stop_flag["stop"] = True
401
+ return "🛑 جاري إيقاف العملية...", "🛑 متوقف"
402
 
 
403
  def list_project_files():
 
404
  try:
405
  files = os.listdir('.')
406
  file_details = []
407
+ for f in sorted(files):
408
  if os.path.isfile(f):
409
  size_bytes = os.path.getsize(f)
410
  size_kb = f"{size_bytes / 1024:.2f} KB"
411
  file_details.append([f, size_kb])
 
412
  if not file_details:
413
  return pd.DataFrame(columns=["File Name", "Size"])
 
414
  df = pd.DataFrame(file_details, columns=["اسم الملف", "الحجم"])
415
  return df
416
  except Exception as e:
417
  return pd.DataFrame([["خطأ في عرض الملفات", str(e)]], columns=["اسم الملف", "الحجم"])
418
 
 
419
  def main():
420
  with gr.Blocks(theme=gr.themes.Soft(), title="Web Scraper & Indexer") as demo:
421
  gr.Markdown(
422
  """
423
+ # 🚀 Web Scraper & Intelligent Indexer (V9 - Final)
424
  أداة متكاملة للتحكم في عملية كشط المواقع وفهرسة محتواها في قاعدة البيانات.
425
  """
426
  )
 
 
427
  with gr.Tabs():
 
428
  with gr.Tab("التطبيق الرئيسي"):
429
+ with gr.Row():
430
+ status_indicator = gr.Textbox(label="الحالة", value="⚪ خامل", interactive=False, scale=3)
431
+ doc_count_display = gr.Markdown("### العدد الحالي: جاري التحميل...")
432
+ refresh_count_btn = gr.Button("🔄 تحديث العدد")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
 
434
+ with gr.Row():
435
+ run_btn = gr.Button("🚀 ابدأ الزحف والفهرسة", variant="primary", scale=2)
436
+ stop_btn = gr.Button("🛑 إيقاف", variant="stop", scale=1)
437
+
438
+ max_docs_input = gr.Number(label="الحد الأقصى لعدد المستندات الجديدة للإضافة", value=50, minimum=1, step=10)
439
+
440
+ with gr.Accordion("عرض سجل العمليات (Log)", open=True):
441
+ output_log = gr.Textbox(label="سجل العمليات", lines=20, interactive=False, autoscroll=True)
442
+
443
+ run_event = run_btn.click(fn=run_full_process_wrapper, inputs=[max_docs_input], outputs=[output_log, status_indicator])
444
+ stop_btn.click(fn=stop_process, inputs=[], outputs=[output_log, status_indicator], cancels=[run_event])
445
+ refresh_count_btn.click(fn=get_document_count, inputs=None, outputs=doc_count_display)
446
 
 
447
  with gr.Tab("ملفات المشروع"):
448
+ gr.Markdown("## عرض الملفات الموجودة في مجلد المشروع.")
449
+ refresh_files_btn = gr.Button("🔄 تحديث قائمة الملفات")
450
+ file_display = gr.DataFrame(headers=["اسم الملف", "الحجم"], datatype=["str", "str"], label="قائمة الملفات")
451
+ refresh_files_btn.click(fn=list_project_files, inputs=[], outputs=file_display)
452
+
453
+ demo.load(fn=get_document_count, inputs=None, outputs=doc_count_display)
454
+ demo.load(fn=list_project_files, inputs=[], outputs=file_display)
455
+
 
 
 
 
 
 
456
  print("\n✅ الواجهة المحسنة جاهزة.")
457
+ demo.launch(server_name="0.0.0.0", share=True)
458
+
459
  if __name__ == "__main__":
460
  main()