Update app.py
Browse files
app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# app.py (
|
| 2 |
import os
|
| 3 |
import asyncio
|
| 4 |
import requests
|
|
@@ -9,8 +9,6 @@ import time
|
|
| 9 |
from datetime import date
|
| 10 |
from fastapi import FastAPI, Request, BackgroundTasks
|
| 11 |
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse, StreamingResponse
|
| 12 |
-
|
| 13 |
-
# 🟢 ایمپورت رسمی WSGITransport و WSGIMiddleware جهت یکپارچهسازی وبسرور داخلی فلاسک
|
| 14 |
from fastapi.middleware.wsgi import WSGIMiddleware
|
| 15 |
|
| 16 |
# ایمپورت و فراخوانی ماژول مستقل روبیکا بات و صوتی
|
|
@@ -34,7 +32,7 @@ os.makedirs(USAGE_DIR, exist_ok=True)
|
|
| 34 |
CLONE_USAGE_LIMIT = 1
|
| 35 |
EDIT_USAGE_LIMIT = 5
|
| 36 |
|
| 37 |
-
#
|
| 38 |
@app.middleware("http")
|
| 39 |
async def set_space_url_middleware(request: Request, call_next):
|
| 40 |
if not getattr(action, "SPACE_HOST", None):
|
|
@@ -80,7 +78,6 @@ def save_local_clone_usage(key: str, data: dict):
|
|
| 80 |
except: pass
|
| 81 |
|
| 82 |
def check_local_clone_limit(fingerprint: str, ip: str) -> bool:
|
| 83 |
-
"""بررسی محدودیت کلون صدا: اگر اثر انگشت یا آیپی سهمیهاش پر باشد، بلاک میکند"""
|
| 84 |
for key in [fingerprint, ip]:
|
| 85 |
if not key: continue
|
| 86 |
data = get_local_clone_usage(key)
|
|
@@ -89,7 +86,6 @@ def check_local_clone_limit(fingerprint: str, ip: str) -> bool:
|
|
| 89 |
return False
|
| 90 |
|
| 91 |
def use_local_clone(fingerprint: str, ip: str):
|
| 92 |
-
"""ثبت مصرف سهمیه کلون صدا روی هارد دیسک رانفلر"""
|
| 93 |
for key in [fingerprint, ip]:
|
| 94 |
if not key: continue
|
| 95 |
data = get_local_clone_usage(key)
|
|
@@ -119,7 +115,6 @@ def save_local_edit_usage(key: str, data: dict):
|
|
| 119 |
except: pass
|
| 120 |
|
| 121 |
def check_local_edit_limit(fingerprint: str, ip: str) -> bool:
|
| 122 |
-
"""بررسی محدودیت ادیت عکس: اگر اثر انگشت یا آیپی سهمیه هفتگیاش پر باشد، بلاک میکند"""
|
| 123 |
for key in [fingerprint, ip]:
|
| 124 |
if not key: continue
|
| 125 |
data = get_local_edit_usage(key)
|
|
@@ -128,7 +123,6 @@ def check_local_edit_limit(fingerprint: str, ip: str) -> bool:
|
|
| 128 |
return False
|
| 129 |
|
| 130 |
def use_local_edit(fingerprint: str, ip: str):
|
| 131 |
-
"""ثبت مصرف سهمیه ویرایش عکس روی هارد دیسک رانفلر"""
|
| 132 |
for key in [fingerprint, ip]:
|
| 133 |
if not key: continue
|
| 134 |
data = get_local_edit_usage(key)
|
|
@@ -137,18 +131,13 @@ def use_local_edit(fingerprint: str, ip: str):
|
|
| 137 |
|
| 138 |
|
| 139 |
def clean_old_files():
|
| 140 |
-
"""پاکسازی خودکار فایلهای قدیمی برای جلوگیری از پر شدن حافظه سرور"""
|
| 141 |
now = time.time()
|
| 142 |
-
|
| 143 |
-
# فایلهای صوتی و جابها بعد از ۳۰ دقیقه (۱۸۰۰ ثانیه) حذف میشوند
|
| 144 |
try:
|
| 145 |
for f in os.listdir(JOBS_DIR):
|
| 146 |
fpath = os.path.join(JOBS_DIR, f)
|
| 147 |
if os.path.isfile(fpath) and os.stat(fpath).st_mtime < now - 1800:
|
| 148 |
os.remove(fpath)
|
| 149 |
except: pass
|
| 150 |
-
|
| 151 |
-
# لاگهای اعتبار روزانه کاربران بعد از ۴۸ ساعت (۱۷۲۸۰۰ ثانیه) حذف میشوند
|
| 152 |
try:
|
| 153 |
for f in os.listdir(USAGE_DIR):
|
| 154 |
fpath = os.path.join(USAGE_DIR, f)
|
|
@@ -157,9 +146,7 @@ def clean_old_files():
|
|
| 157 |
except: pass
|
| 158 |
|
| 159 |
def save_job_state(job_id: str, status: str, audio_data: bytes = None, error_message: str = None):
|
| 160 |
-
"""ذخیره وضعیت کار در فایل متنی مشترک دیسک"""
|
| 161 |
clean_old_files()
|
| 162 |
-
|
| 163 |
state = {
|
| 164 |
"status": status,
|
| 165 |
"error_message": error_message,
|
|
@@ -175,7 +162,6 @@ def save_job_state(job_id: str, status: str, audio_data: bytes = None, error_mes
|
|
| 175 |
f.write(audio_data)
|
| 176 |
|
| 177 |
def get_job_state(job_id: str):
|
| 178 |
-
"""خواندن وضعیت کار از فایل متنی مشترک دیسک"""
|
| 179 |
state_file = os.path.join(JOBS_DIR, f"{job_id}.json")
|
| 180 |
if not os.path.exists(state_file):
|
| 181 |
return None
|
|
@@ -186,7 +172,6 @@ def get_job_state(job_id: str):
|
|
| 186 |
return None
|
| 187 |
|
| 188 |
def get_job_audio(job_id: str):
|
| 189 |
-
"""خواندن دیتای باینری صوتی ذخیره شده روی دیسک"""
|
| 190 |
audio_file = os.path.join(JOBS_DIR, f"{job_id}.mp3")
|
| 191 |
if os.path.exists(audio_file):
|
| 192 |
try:
|
|
@@ -204,7 +189,7 @@ async def run_tts_background(job_id: str, payload: dict, headers: dict):
|
|
| 204 |
"temperature": payload.get("temperature", 1.5)
|
| 205 |
}
|
| 206 |
|
| 207 |
-
async with httpx.AsyncClient(timeout=180.0
|
| 208 |
resp = await client.post(
|
| 209 |
"https://sada8888-tts2.hf.space/api/generate",
|
| 210 |
json=target_payload,
|
|
@@ -241,7 +226,8 @@ async def serve_ttspro():
|
|
| 241 |
# =================================================================
|
| 242 |
# 2. آینه چت بات (پروکسی خام و بدون بافر برای سرعت حداکثری)
|
| 243 |
# =================================================================
|
| 244 |
-
|
|
|
|
| 245 |
|
| 246 |
@app.get("/chat", response_class=HTMLResponse)
|
| 247 |
@app.get("/chat/", response_class=HTMLResponse)
|
|
@@ -252,7 +238,7 @@ async def serve_chat():
|
|
| 252 |
return HTMLResponse("<h1>خطا: فایل chat.html پیدا نشد!</h1>", status_code=404)
|
| 253 |
|
| 254 |
@app.get("/audio/{filename}")
|
| 255 |
-
async def serve_chat_audio(
|
| 256 |
if filename.startswith("standard_"):
|
| 257 |
job_id = filename.replace("standard_", "").replace(".mp3", "").replace(".wav", "")
|
| 258 |
audio_data = get_job_audio(job_id)
|
|
@@ -263,18 +249,7 @@ async def serve_chat_audio(request: Request, filename: str):
|
|
| 263 |
)
|
| 264 |
return HTMLResponse("<h1>فایل صوتی یافت نشد</h1>", status_code=404)
|
| 265 |
|
| 266 |
-
|
| 267 |
-
try:
|
| 268 |
-
# 🟢 افزودن verify=False برای پایداری دریافت صوت
|
| 269 |
-
async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
|
| 270 |
-
async with client.stream("GET", f"{CHAT_SPACE_URL}/audio/{filename}", headers=get_hf_headers(request)) as r:
|
| 271 |
-
r.raise_for_status()
|
| 272 |
-
async for chunk in r.aiter_bytes(chunk_size=8192):
|
| 273 |
-
if chunk: yield chunk
|
| 274 |
-
except Exception:
|
| 275 |
-
yield b""
|
| 276 |
-
|
| 277 |
-
# 🟢 تعریف هوشمند پسوندها و Mime-Type جهت دانلود درست مقالات (PDF و Word) بدون تبدیل شدن به wav
|
| 278 |
media_type = "audio/wav"
|
| 279 |
if filename.endswith(".pdf"):
|
| 280 |
media_type = "application/pdf"
|
|
@@ -282,7 +257,19 @@ async def serve_chat_audio(request: Request, filename: str):
|
|
| 282 |
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 283 |
elif filename.endswith(".mp3"):
|
| 284 |
media_type = "audio/mpeg"
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
return StreamingResponse(iterfile(), media_type=media_type)
|
| 287 |
|
| 288 |
@app.post("/api/chat_proxy")
|
|
@@ -292,8 +279,7 @@ async def chat_proxy_bridge(request: Request):
|
|
| 292 |
|
| 293 |
async def stream_generator():
|
| 294 |
try:
|
| 295 |
-
|
| 296 |
-
async with httpx.AsyncClient(timeout=60.0, verify=False) as client:
|
| 297 |
async with client.stream(
|
| 298 |
"POST",
|
| 299 |
f"{CHAT_SPACE_URL}/api/chat_proxy",
|
|
@@ -317,39 +303,6 @@ async def chat_proxy_bridge(request: Request):
|
|
| 317 |
except Exception as e:
|
| 318 |
return JSONResponse({"status": "error", "message": "سرور موقتا در دسترس نیست."}, status_code=500)
|
| 319 |
|
| 320 |
-
# 🟢 اندپوینت جدید: پروکسی استریمینگ و فوقالعاده پایدار ساخت فایل (مقاله) از سرور چت اصلی با رفع خطای SSL
|
| 321 |
-
@app.post("/api/create_file")
|
| 322 |
-
async def create_file_proxy_bridge(request: Request):
|
| 323 |
-
try:
|
| 324 |
-
body_bytes = await request.body()
|
| 325 |
-
|
| 326 |
-
async def stream_generator():
|
| 327 |
-
try:
|
| 328 |
-
# 🟢 اتصال مستقیم با تنظیم تایماوت ۵ دقیقهای برای مقالات طولانی و دور زدن گواهی SSL
|
| 329 |
-
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(120.0, read=300.0)) as client:
|
| 330 |
-
async with client.stream(
|
| 331 |
-
"POST",
|
| 332 |
-
f"{CHAT_SPACE_URL}/api/create_file",
|
| 333 |
-
content=body_bytes,
|
| 334 |
-
headers={"Content-Type": "application/json"}
|
| 335 |
-
) as resp:
|
| 336 |
-
async for chunk in resp.aiter_bytes():
|
| 337 |
-
if chunk:
|
| 338 |
-
yield chunk
|
| 339 |
-
except Exception as e:
|
| 340 |
-
err = f"data: {json.dumps({'type': 'error', 'message': f'خطا در ارتباط رانفلر با سرور اصلی: {str(e)}'})}\n\n"
|
| 341 |
-
yield err.encode('utf-8')
|
| 342 |
-
|
| 343 |
-
headers = {
|
| 344 |
-
"Cache-Control": "no-cache, no-transform",
|
| 345 |
-
"X-Accel-Buffering": "no",
|
| 346 |
-
"Connection": "keep-alive",
|
| 347 |
-
"Transfer-Encoding": "chunked"
|
| 348 |
-
}
|
| 349 |
-
return StreamingResponse(stream_generator(), media_type="text/event-stream", headers=headers)
|
| 350 |
-
except Exception as e:
|
| 351 |
-
return JSONResponse({"status": "error", "message": "سرور موقتا در دسترس نیست."}, status_code=500)
|
| 352 |
-
|
| 353 |
|
| 354 |
# =================================================================
|
| 355 |
# 3. دور زدن فیلترینگ برای API های متن به صدا (TTS Proxy) با کش یکپارچه
|
|
@@ -899,6 +852,42 @@ async def proxy_gemma_chat(request: Request):
|
|
| 899 |
return JSONResponse({"status": "error", "message": f"خطا در ارتباط رانفلر: {str(e)}"}, status_code=500)
|
| 900 |
|
| 901 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 902 |
# =================================================================
|
| 903 |
# 7. یکپارچهسازی وبسرور داخلی فلاسک رانفلر و اجرای همزمان (WSGI)
|
| 904 |
# =================================================================
|
|
|
|
| 1 |
+
# app.py (Hugging Face Combined Server Core)
|
| 2 |
import os
|
| 3 |
import asyncio
|
| 4 |
import requests
|
|
|
|
| 9 |
from datetime import date
|
| 10 |
from fastapi import FastAPI, Request, BackgroundTasks
|
| 11 |
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, RedirectResponse, StreamingResponse
|
|
|
|
|
|
|
| 12 |
from fastapi.middleware.wsgi import WSGIMiddleware
|
| 13 |
|
| 14 |
# ایمپورت و فراخوانی ماژول مستقل روبیکا بات و صوتی
|
|
|
|
| 32 |
CLONE_USAGE_LIMIT = 1
|
| 33 |
EDIT_USAGE_LIMIT = 5
|
| 34 |
|
| 35 |
+
# میانافزار هوشمند جهت تنظیم خودکار آدرس دامنه عمومی رانفلر شما در ماژول اکشن
|
| 36 |
@app.middleware("http")
|
| 37 |
async def set_space_url_middleware(request: Request, call_next):
|
| 38 |
if not getattr(action, "SPACE_HOST", None):
|
|
|
|
| 78 |
except: pass
|
| 79 |
|
| 80 |
def check_local_clone_limit(fingerprint: str, ip: str) -> bool:
|
|
|
|
| 81 |
for key in [fingerprint, ip]:
|
| 82 |
if not key: continue
|
| 83 |
data = get_local_clone_usage(key)
|
|
|
|
| 86 |
return False
|
| 87 |
|
| 88 |
def use_local_clone(fingerprint: str, ip: str):
|
|
|
|
| 89 |
for key in [fingerprint, ip]:
|
| 90 |
if not key: continue
|
| 91 |
data = get_local_clone_usage(key)
|
|
|
|
| 115 |
except: pass
|
| 116 |
|
| 117 |
def check_local_edit_limit(fingerprint: str, ip: str) -> bool:
|
|
|
|
| 118 |
for key in [fingerprint, ip]:
|
| 119 |
if not key: continue
|
| 120 |
data = get_local_edit_usage(key)
|
|
|
|
| 123 |
return False
|
| 124 |
|
| 125 |
def use_local_edit(fingerprint: str, ip: str):
|
|
|
|
| 126 |
for key in [fingerprint, ip]:
|
| 127 |
if not key: continue
|
| 128 |
data = get_local_edit_usage(key)
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
def clean_old_files():
|
|
|
|
| 134 |
now = time.time()
|
|
|
|
|
|
|
| 135 |
try:
|
| 136 |
for f in os.listdir(JOBS_DIR):
|
| 137 |
fpath = os.path.join(JOBS_DIR, f)
|
| 138 |
if os.path.isfile(fpath) and os.stat(fpath).st_mtime < now - 1800:
|
| 139 |
os.remove(fpath)
|
| 140 |
except: pass
|
|
|
|
|
|
|
| 141 |
try:
|
| 142 |
for f in os.listdir(USAGE_DIR):
|
| 143 |
fpath = os.path.join(USAGE_DIR, f)
|
|
|
|
| 146 |
except: pass
|
| 147 |
|
| 148 |
def save_job_state(job_id: str, status: str, audio_data: bytes = None, error_message: str = None):
|
|
|
|
| 149 |
clean_old_files()
|
|
|
|
| 150 |
state = {
|
| 151 |
"status": status,
|
| 152 |
"error_message": error_message,
|
|
|
|
| 162 |
f.write(audio_data)
|
| 163 |
|
| 164 |
def get_job_state(job_id: str):
|
|
|
|
| 165 |
state_file = os.path.join(JOBS_DIR, f"{job_id}.json")
|
| 166 |
if not os.path.exists(state_file):
|
| 167 |
return None
|
|
|
|
| 172 |
return None
|
| 173 |
|
| 174 |
def get_job_audio(job_id: str):
|
|
|
|
| 175 |
audio_file = os.path.join(JOBS_DIR, f"{job_id}.mp3")
|
| 176 |
if os.path.exists(audio_file):
|
| 177 |
try:
|
|
|
|
| 189 |
"temperature": payload.get("temperature", 1.5)
|
| 190 |
}
|
| 191 |
|
| 192 |
+
async with httpx.AsyncClient(timeout=180.0) as client:
|
| 193 |
resp = await client.post(
|
| 194 |
"https://sada8888-tts2.hf.space/api/generate",
|
| 195 |
json=target_payload,
|
|
|
|
| 226 |
# =================================================================
|
| 227 |
# 2. آینه چت بات (پروکسی خام و بدون بافر برای سرعت حداکثری)
|
| 228 |
# =================================================================
|
| 229 |
+
# 🟢 بروزرسانی آدرس سرور چتبات به اسپیس فعال و جدید شما
|
| 230 |
+
CHAT_SPACE_URL = "https://opera10-ttslive-chat.hf.space"
|
| 231 |
|
| 232 |
@app.get("/chat", response_class=HTMLResponse)
|
| 233 |
@app.get("/chat/", response_class=HTMLResponse)
|
|
|
|
| 238 |
return HTMLResponse("<h1>خطا: فایل chat.html پیدا نشد!</h1>", status_code=404)
|
| 239 |
|
| 240 |
@app.get("/audio/{filename}")
|
| 241 |
+
async def serve_chat_audio(filename: str):
|
| 242 |
if filename.startswith("standard_"):
|
| 243 |
job_id = filename.replace("standard_", "").replace(".mp3", "").replace(".wav", "")
|
| 244 |
audio_data = get_job_audio(job_id)
|
|
|
|
| 249 |
)
|
| 250 |
return HTMLResponse("<h1>فایل صوتی یافت نشد</h1>", status_code=404)
|
| 251 |
|
| 252 |
+
# 🟢 تشخیص هوشمند نوع فایل برای پشتیبانی از دانلود فایلهای متنی ساخته شده (PDF و Word)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
media_type = "audio/wav"
|
| 254 |
if filename.endswith(".pdf"):
|
| 255 |
media_type = "application/pdf"
|
|
|
|
| 257 |
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 258 |
elif filename.endswith(".mp3"):
|
| 259 |
media_type = "audio/mpeg"
|
| 260 |
+
elif filename.endswith(".mp4"):
|
| 261 |
+
media_type = "video/mp4"
|
| 262 |
+
|
| 263 |
+
async def iterfile():
|
| 264 |
+
try:
|
| 265 |
+
# افزایش تایماوت به ۱۲۰ ثانیه برای دانلود فایلهای بزرگتر بدون قطعی
|
| 266 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 267 |
+
async with client.stream("GET", f"{CHAT_SPACE_URL}/audio/{filename}") as r:
|
| 268 |
+
r.raise_for_status()
|
| 269 |
+
async for chunk in r.aiter_bytes(chunk_size=65536):
|
| 270 |
+
if chunk: yield chunk
|
| 271 |
+
except Exception:
|
| 272 |
+
yield b""
|
| 273 |
return StreamingResponse(iterfile(), media_type=media_type)
|
| 274 |
|
| 275 |
@app.post("/api/chat_proxy")
|
|
|
|
| 279 |
|
| 280 |
async def stream_generator():
|
| 281 |
try:
|
| 282 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
|
|
| 283 |
async with client.stream(
|
| 284 |
"POST",
|
| 285 |
f"{CHAT_SPACE_URL}/api/chat_proxy",
|
|
|
|
| 303 |
except Exception as e:
|
| 304 |
return JSONResponse({"status": "error", "message": "سرور موقتا در دسترس نیست."}, status_code=500)
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
# =================================================================
|
| 308 |
# 3. دور زدن فیلترینگ برای API های متن به صدا (TTS Proxy) با کش یکپارچه
|
|
|
|
| 852 |
return JSONResponse({"status": "error", "message": f"خطا در ارتباط رانفلر: {str(e)}"}, status_code=500)
|
| 853 |
|
| 854 |
|
| 855 |
+
# =================================================================
|
| 856 |
+
# 🟢 پروکسی جدید: استریمینگ و انتقال مستقیم ساخت مقاله (/api/create_file)
|
| 857 |
+
# =================================================================
|
| 858 |
+
@app.post("/api/create_file")
|
| 859 |
+
async def proxy_create_file(request: Request):
|
| 860 |
+
try:
|
| 861 |
+
body_bytes = await request.body()
|
| 862 |
+
headers = get_hf_headers(request)
|
| 863 |
+
|
| 864 |
+
# استریم صریح دادهها به صورت کاملاً غیرهمزمان و بدون بافر کردن در رم
|
| 865 |
+
async def stream_generator():
|
| 866 |
+
try:
|
| 867 |
+
async with httpx.AsyncClient(timeout=300.0) as client:
|
| 868 |
+
async with client.stream(
|
| 869 |
+
"POST",
|
| 870 |
+
f"{CHAT_SPACE_URL}/api/create_file",
|
| 871 |
+
content=body_bytes,
|
| 872 |
+
headers={"Content-Type": "application/json"}
|
| 873 |
+
) as resp:
|
| 874 |
+
async for chunk in resp.aiter_bytes():
|
| 875 |
+
if chunk:
|
| 876 |
+
yield chunk
|
| 877 |
+
except Exception as e:
|
| 878 |
+
yield f"data: {json.dumps({'type': 'error', 'message': f'خطای رانفلر: {str(e)}'})}\n\n".encode('utf-8')
|
| 879 |
+
|
| 880 |
+
headers_to_return = {
|
| 881 |
+
"Cache-Control": "no-cache, no-transform",
|
| 882 |
+
"Connection": "keep-alive",
|
| 883 |
+
"X-Accel-Buffering": "no",
|
| 884 |
+
"Content-Type": "text/event-stream"
|
| 885 |
+
}
|
| 886 |
+
return StreamingResponse(stream_generator(), media_type="text/event-stream", headers=headers_to_return)
|
| 887 |
+
except Exception as e:
|
| 888 |
+
return JSONResponse({"status": "error", "message": str(e)}, status_code=500)
|
| 889 |
+
|
| 890 |
+
|
| 891 |
# =================================================================
|
| 892 |
# 7. یکپارچهسازی وبسرور داخلی فلاسک رانفلر و اجرای همزمان (WSGI)
|
| 893 |
# =================================================================
|