import requests import json import os import logging import time from datetime import datetime, timedelta from bs4 import BeautifulSoup logging.basicConfig( level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s' ) logger = logging.getLogger(__name__) STATE_FILE = "renewal_state.json" HOSTS = ["www.pythonanywhere.com", "eu.pythonanywhere.com"] MAX_RETRIES = 3 RETRY_DELAY = 5 # ثواني بين المحاولات REQUEST_TIMEOUT = 20 # ───────────────────────────────────────── # State helpers # ───────────────────────────────────────── def load_state() -> dict: if os.path.exists(STATE_FILE): try: with open(STATE_FILE, "r") as f: return json.load(f) except (json.JSONDecodeError, OSError) as e: logger.warning(f"تعذّر قراءة ملف الحالة: {e} — سيبدأ من الصفر") return {} def save_state(state: dict): try: with open(STATE_FILE, "w") as f: json.dump(state, f, indent=2, ensure_ascii=False) except OSError as e: logger.error(f"تعذّر حفظ الحالة: {e}") # ───────────────────────────────────────── # Account loading # ───────────────────────────────────────── def load_accounts() -> list[dict]: """ يقرأ الحسابات من متغيرات البيئة بأي من الصيغتين: الصيغة 1 — مرقّمة (موصى بها): ACCOUNTS_1 = username1:password1 ACCOUNTS_2 = username2:password2 الصيغة 2 — متغير واحد مفصول بفاصلة: ACCOUNTS = username1:password1,username2:password2 """ state = load_state() accounts = [] seen = set() def add_account(username: str, password: str): username = username.strip() password = password.strip() if not username or not password or username in seen: return seen.add(username) s = state.get(username, {}) accounts.append({ "username": username, "password": password, "host": s.get("host", "www.pythonanywhere.com"), "last_renewed": s.get("last_renewed"), "next_renewal": s.get("next_renewal"), "last_results": s.get("last_results", []), }) def parse_entry(entry: str): entry = entry.strip() if ":" in entry: username, password = entry.split(":", 1) add_account(username, password) # الصيغة 1 i = 1 while True: entry = os.environ.get(f"ACCOUNTS_{i}", "").strip() if not entry: break parse_entry(entry) i += 1 # الصيغة 2 bulk = os.environ.get("ACCOUNTS", "").strip() if bulk: for entry in bulk.split(","): parse_entry(entry) if accounts: logger.info(f"تم تحميل {len(accounts)} حساب: {[a['username'] for a in accounts]}") else: logger.warning("لم يتم العثور على حسابات — أضف ACCOUNTS_1 أو ACCOUNTS في Secrets") return accounts def save_accounts(accounts: list[dict]): """يحفظ الحالة فقط — كلمات المرور لا تُكتب على القرص.""" state = { a["username"]: { "host": a.get("host", "www.pythonanywhere.com"), "last_renewed": a.get("last_renewed"), "next_renewal": a.get("next_renewal"), "last_results": a.get("last_results", []), } for a in accounts } save_state(state) # ───────────────────────────────────────── # Session builder # ───────────────────────────────────────── def _build_session() -> requests.Session: session = requests.Session() session.headers.update({ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", }) return session # ───────────────────────────────────────── # Login # ───────────────────────────────────────── def _login(session: requests.Session, username: str, password: str, host: str) -> bool: """ يسجّل الدخول بطريقة موثوقة: 1. يجلب الـ CSRF من صفحة اللوجن 2. يرسل البيانات 3. يتحقق من وجود sessionid في الكوكيز 4. يتحقق من وجود "logout" في نص الصفحة """ login_url = f"https://{host}/login/" for attempt in range(1, MAX_RETRIES + 1): try: login_page = session.get(login_url, timeout=REQUEST_TIMEOUT) login_page.raise_for_status() soup = BeautifulSoup(login_page.text, "html.parser") form = soup.find("form") if not form: logger.error(f"[{username}] لم يتم العثور على form في صفحة الدخول") return False form_data: dict = {} for inp in form.find_all("input"): name = inp.get("name") if name: form_data[name] = inp.get("value", "") form_data["auth-username"] = username form_data["auth-password"] = password form_data["login_view-current_step"] = "auth" action = form.get("action") or login_url if not action.startswith("http"): action = f"https://{host}{action}" resp = session.post( action, data=form_data, headers={ "Referer": login_url, "Origin": f"https://{host}", "Content-Type": "application/x-www-form-urlencoded", }, timeout=REQUEST_TIMEOUT, allow_redirects=True, ) cookies = session.cookies.get_dict() # التحقق المزدوج: كوكي sessionid + نص يحتوي logout has_session = "sessionid" in cookies has_logout = ( "log out" in resp.text.lower() or "logout" in resp.text.lower() ) if has_session and has_logout: logger.info(f"[{username}] ✅ تم تسجيل الدخول على {host}") return True if not has_session: logger.warning(f"[{username}] محاولة {attempt}/{MAX_RETRIES} — لا يوجد sessionid") elif not has_logout: logger.warning(f"[{username}] محاولة {attempt}/{MAX_RETRIES} — 'logout' غير موجود في الصفحة") except requests.Timeout: logger.warning(f"[{username}] محاولة {attempt}/{MAX_RETRIES} — انتهت المهلة") except requests.RequestException as e: logger.warning(f"[{username}] محاولة {attempt}/{MAX_RETRIES} — خطأ شبكة: {e}") if attempt < MAX_RETRIES: time.sleep(RETRY_DELAY) logger.error(f"[{username}] ❌ فشل تسجيل الدخول بعد {MAX_RETRIES} محاولات") return False # ───────────────────────────────────────── # Host discovery # ───────────────────────────────────────── def _detect_host(session: requests.Session, username: str) -> str | None: """يكتشف الخادم الصحيح (www أو eu) بعد تسجيل الدخول.""" for host in HOSTS: try: r = session.get( f"https://{host}/user/{username}/webapps/", timeout=REQUEST_TIMEOUT, allow_redirects=True, ) if r.status_code == 200 and username.lower() in r.url.lower(): logger.info(f"[{username}] الخادم: {host}") return host except requests.RequestException: continue return None # ───────────────────────────────────────── # Webapp extension # ───────────────────────────────────────── def _get_domains(soup: BeautifulSoup, username: str, host: str) -> list[str]: """ يكتشف النطاقات بطريقتين: 1. روابط تحتوي /webapps/ (الأكثر دقة) 2. نماذج
تحتوي /extend في action (احتياطي) """ domains: list[str] = [] # الطريقة 1 — روابط الـ webapps for tag in soup.find_all("a", href=True): href: str = tag["href"] marker = f"/user/{username}/webapps/" if marker in href: part = href.split(marker)[-1].strip("/") if part and "." in part and part not in domains: domains.append(part) # الطريقة 2 — نماذج extend (احتياطي) if not domains: for form in soup.find_all("form", action=True): action: str = form.get("action", "") if "/extend" in action.lower(): domain = action.split("/webapps/")[-1].split("/extend")[0].strip("/") if domain and domain not in domains: domains.append(domain) # الافتراضي if not domains: domains = [f"{username}.pythonanywhere.com"] logger.warning(f"[{username}] لم يتم اكتشاف أي نطاق — سيستخدم الافتراضي: {domains[0]}") return domains def extend_webapp(username: str, password: str, host: str = "www.pythonanywhere.com") -> list[dict]: session = _build_session() results: list[dict] = [] # ── تسجيل الدخول ── if not _login(session, username, password, host): return [] # ── صفحة الـ webapps ── webapps_url = f"https://{host}/user/{username}/webapps/" try: page = session.get(webapps_url, timeout=REQUEST_TIMEOUT) page.raise_for_status() except requests.RequestException as e: logger.error(f"[{username}] تعذّر الوصول لصفحة webapps: {e}") return [] soup = BeautifulSoup(page.text, "html.parser") # ── CSRF ── csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"}) csrf = csrf_input["value"] if csrf_input else session.cookies.get("csrftoken", "") if not csrf: logger.error(f"[{username}] لم يتم العثور على CSRF token") return [] # ── النطاقات ── domains = _get_domains(soup, username, host) logger.info(f"[{username}] النطاقات المكتشفة: {domains}") # ── التمديد ── for domain in domains: extend_url = f"https://{host}/user/{username}/webapps/{domain}/extend" success = False for attempt in range(1, MAX_RETRIES + 1): try: resp = session.post( extend_url, data={"csrfmiddlewaretoken": csrf}, headers={ "Referer": webapps_url, "Origin": f"https://{host}", "Content-Type": "application/x-www-form-urlencoded", }, timeout=REQUEST_TIMEOUT, allow_redirects=True, ) logger.info(f"[{username}] Extend {domain}: HTTP {resp.status_code} → {resp.url}") if resp.status_code in (200, 302): success = True break else: logger.warning( f"[{username}] محاولة {attempt}/{MAX_RETRIES} — " f"extend {domain} فشل ({resp.status_code})" ) except requests.Timeout: logger.warning(f"[{username}] محاولة {attempt}/{MAX_RETRIES} — انتهت المهلة أثناء extend {domain}") except requests.RequestException as e: logger.warning(f"[{username}] محاولة {attempt}/{MAX_RETRIES} — خطأ: {e}") if attempt < MAX_RETRIES: time.sleep(RETRY_DELAY) status = "✅ تم التمديد" if success else f"❌ فشل التمديد بعد {MAX_RETRIES} محاولات" results.append({"domain": domain, "status": status}) return results # ───────────────────────────────────────── # Renew single account # ───────────────────────────────────────── def renew_account(account: dict) -> tuple[bool, list[dict]]: username = account["username"] password = account.get("password", "").strip() host = account.get("host") or "www.pythonanywhere.com" if not password: logger.error(f"[{username}] لا توجد كلمة مرور") return False, [] # محاولة الاتصال — اكتشاف الخادم إذا لزم results = extend_webapp(username, password, host) # إذا فشل الخادم المحفوظ، جرّب الآخر if not results: other_host = next((h for h in HOSTS if h != host), None) if other_host: logger.info(f"[{username}] جرب الخادم الآخر: {other_host}") results = extend_webapp(username, password, other_host) if results: host = other_host success = any("✅" in r.get("status", "") for r in results) if results else False account["host"] = host account["last_renewed"] = datetime.utcnow().isoformat() account["next_renewal"] = (datetime.utcnow() + timedelta(days=10)).isoformat() account["last_results"] = results return success, results # ───────────────────────────────────────── # Batch renewal # ───────────────────────────────────────── def run_renewals() -> list[dict] | None: accounts = load_accounts() if not accounts: logger.info("لا توجد حسابات.") return None now = datetime.utcnow() renewed_count = 0 for account in accounts: username = account["username"] next_renewal = account.get("next_renewal") if next_renewal: try: nxt = datetime.fromisoformat(next_renewal) if now < nxt: remaining = (nxt - now).days logger.info(f"[{username}] التجديد القادم بعد {remaining} يوم — لا حاجة الآن") continue except ValueError: logger.warning(f"[{username}] next_renewal غير صالح — سيُجدَّد الآن") logger.info(f"[{username}] جاري التجديد...") success, _ = renew_account(account) if success: renewed_count += 1 else: logger.error(f"[{username}] ❌ فشل التجديد") save_accounts(accounts) logger.info(f"اكتمل: تم تجديد {renewed_count}/{len(accounts)} حساب.") return accounts