nopoh22 commited on
Commit
e0ee4cb
ยท
verified ยท
1 Parent(s): cdb1269

Update renewer.py

Browse files
Files changed (1) hide show
  1. renewer.py +310 -150
renewer.py CHANGED
@@ -2,6 +2,7 @@ import requests
2
  import json
3
  import os
4
  import logging
 
5
  from datetime import datetime, timedelta
6
  from bs4 import BeautifulSoup
7
 
@@ -13,58 +14,77 @@ logger = logging.getLogger(__name__)
13
 
14
  STATE_FILE = "renewal_state.json"
15
 
 
16
 
17
- def load_state():
 
 
 
 
 
 
 
 
 
18
  if os.path.exists(STATE_FILE):
19
- with open(STATE_FILE, "r") as f:
20
- return json.load(f)
 
 
 
21
  return {}
22
 
23
 
24
- def save_state(state):
25
- with open(STATE_FILE, "w") as f:
26
- json.dump(state, f, indent=2)
 
 
 
 
27
 
 
 
 
28
 
29
- def load_accounts():
30
  """
31
- ูŠู‚ุฑุฃ ุงู„ุญุณุงุจุงุช ู…ู† Environment Variables ุจุฃูŠ ู…ู† ุงู„ุตูŠุบ ุงู„ุชุงู„ูŠุฉ:
32
 
33
- ุงู„ุตูŠุบุฉ 1 โ€” ู…ุชุบูŠุฑุงุช ู…ุฑู‚ู‘ู…ุฉ (ุงู„ู…ูˆุตู‰ ุจู‡ุง):
34
  ACCOUNTS_1 = username1:password1
35
  ACCOUNTS_2 = username2:password2
36
- ACCOUNTS_3 = username3:password3
37
 
38
- ุงู„ุตูŠุบุฉ 2 โ€” ู…ุชุบูŠุฑ ูˆุงุญุฏ ูŠุญุชูˆูŠ ุงู„ูƒู„ (ู…ูุตูˆู„ุฉ ุจูุงุตู„ุฉ):
39
  ACCOUNTS = username1:password1,username2:password2
40
-
41
- ูŠู…ูƒู† ุงู„ุฌู…ุน ุจูŠู† ุงู„ุตูŠุบุชูŠู† โ€” ุณูŠุชู… ุฏู…ุฌ ุงู„ูƒู„ ุจุฏูˆู† ุชูƒุฑุงุฑ.
42
  """
43
  state = load_state()
44
  accounts = []
45
  seen = set()
46
 
47
- def add_account(username, password):
 
 
48
  if not username or not password or username in seen:
49
  return
50
  seen.add(username)
51
- user_state = state.get(username, {})
52
  accounts.append({
53
  "username": username,
54
  "password": password,
55
- "host": user_state.get("host", "www.pythonanywhere.com"),
56
- "last_renewed": user_state.get("last_renewed"),
57
- "next_renewal": user_state.get("next_renewal"),
58
- "last_results": user_state.get("last_results", []),
59
  })
60
 
61
- def parse_entry(entry):
62
  entry = entry.strip()
63
  if ":" in entry:
64
  username, password = entry.split(":", 1)
65
- add_account(username.strip(), password.strip())
66
 
67
- # โ”€โ”€ ุงู„ุตูŠุบุฉ 1: ACCOUNTS_1, ACCOUNTS_2, ACCOUNTS_3 ... โ”€โ”€
68
  i = 1
69
  while True:
70
  entry = os.environ.get(f"ACCOUNTS_{i}", "").strip()
@@ -73,10 +93,10 @@ def load_accounts():
73
  parse_entry(entry)
74
  i += 1
75
 
76
- # โ”€โ”€ ุงู„ุตูŠุบุฉ 2: ACCOUNTS = user1:pass1,user2:pass2 โ”€โ”€
77
- accounts_env = os.environ.get("ACCOUNTS", "").strip()
78
- if accounts_env:
79
- for entry in accounts_env.split(","):
80
  parse_entry(entry)
81
 
82
  if accounts:
@@ -87,147 +107,278 @@ def load_accounts():
87
  return accounts
88
 
89
 
90
- def save_accounts(accounts):
91
- """ูŠุญูุธ ุญุงู„ุฉ ุงู„ุชุฌุฏูŠุฏ ูู‚ุท โ€” ูƒู„ู…ุงุช ุงู„ู…ุฑูˆุฑ ุชุจู‚ู‰ ููŠ Secrets"""
92
- state = {}
93
- for a in accounts:
94
- state[a["username"]] = {
95
  "host": a.get("host", "www.pythonanywhere.com"),
96
  "last_renewed": a.get("last_renewed"),
97
  "next_renewal": a.get("next_renewal"),
98
  "last_results": a.get("last_results", []),
99
  }
 
 
100
  save_state(state)
101
 
102
 
103
- def get_host(username, token):
104
- for host in ["www.pythonanywhere.com", "eu.pythonanywhere.com"]:
105
- try:
106
- r = requests.get(
107
- f"https://{host}/api/v0/user/{username}/cpu/",
108
- headers={"Authorization": f"Token {token}"},
109
- timeout=10
110
- )
111
- if r.status_code == 200:
112
- return host
113
- except Exception:
114
- continue
115
- return None
116
-
117
 
118
- def extend_webapp(username, password, host="www.pythonanywhere.com"):
119
  session = requests.Session()
120
- results = []
121
-
122
  session.headers.update({
123
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
124
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
 
 
 
 
125
  "Accept-Language": "en-US,en;q=0.9",
 
 
126
  })
 
127
 
128
- try:
129
- # ุงู„ุฎุทูˆุฉ 1: ุตูุญุฉ ุงู„ุฏุฎูˆู„
130
- login_url = f"https://{host}/login/"
131
- login_page = session.get(login_url, timeout=15)
132
- logger.info(f"[{username}] ุตูุญุฉ ุงู„ุฏุฎูˆู„: {login_page.status_code}")
133
-
134
- soup = BeautifulSoup(login_page.text, "html.parser")
135
- form = soup.find("form")
136
- if not form:
137
- logger.error(f"[{username}] ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ form")
138
- return []
139
-
140
- form_data = {}
141
- for inp in form.find_all("input"):
142
- name = inp.get("name")
143
- if name:
144
- form_data[name] = inp.get("value", "")
145
-
146
- form_data["auth-username"] = username
147
- form_data["auth-password"] = password
148
- if "login_view-current_step" not in form_data:
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  form_data["login_view-current_step"] = "auth"
150
 
151
- # ุงู„ุฎุทูˆุฉ 2: ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„
152
- form_action = form.get("action") or login_url
153
- if not form_action.startswith("http"):
154
- form_action = f"https://{host}{form_action}"
155
-
156
- login_resp = session.post(
157
- form_action,
158
- data=form_data,
159
- headers={
160
- "Referer": login_url,
161
- "Content-Type": "application/x-www-form-urlencoded",
162
- "Origin": f"https://{host}",
163
- },
164
- timeout=15,
165
- allow_redirects=True
166
- )
167
- logger.info(f"[{username}] ู†ุชูŠุฌุฉ ุงู„ุฏุฎูˆู„: {login_resp.status_code} | {login_resp.url}")
168
-
169
- if "sessionid" not in session.cookies.get_dict():
170
- logger.error(f"[{username}] ูุดู„ ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ โ€” ู„ุง ูŠูˆุฌุฏ sessionid")
171
- return []
172
-
173
- logger.info(f"[{username}] โœ… ุชู… ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„")
174
-
175
- # ๏ฟฝ๏ฟฝู„ุฎุทูˆุฉ 3: ุตูุญุฉ ุงู„ูˆูŠุจ ุขุจ
176
- webapps_url = f"https://{host}/user/{username}/webapps/"
177
- page = session.get(webapps_url, timeout=15)
178
-
179
- soup = BeautifulSoup(page.text, "html.parser")
180
- csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
181
- csrf = csrf_input["value"] if csrf_input else ""
182
- if not csrf:
183
- logger.error(f"[{username}] ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ CSRF")
184
- return []
185
-
186
- # ุงู„ุฎุทูˆุฉ 4: ุงูƒุชุดุงู ุงู„ู†ุทุงู‚ุงุช
187
- domains = []
188
- for link in soup.find_all("a", href=True):
189
- href = link["href"]
190
- if f"/user/{username}/webapps/" in href:
191
- part = href.split(f"/user/{username}/webapps/")[-1].strip("/")
192
- if part and "." in part and part not in domains:
193
- domains.append(part)
194
-
195
- if not domains:
196
- domains = [f"{username}.pythonanywhere.com"]
197
-
198
- # ุงู„ุฎุทูˆุฉ 5: ุงู„ุชู…ุฏูŠุฏ
199
- for domain in domains:
200
- extend_url = f"https://{host}/user/{username}/webapps/{domain}/extend"
201
  resp = session.post(
202
- extend_url,
203
- data={"csrfmiddlewaretoken": csrf},
204
  headers={
205
- "Referer": webapps_url,
 
206
  "Content-Type": "application/x-www-form-urlencoded",
207
- "Origin": f"https://{host}",
208
  },
209
- timeout=15
 
210
  )
211
- logger.info(f"[{username}] Extend {domain}: HTTP {resp.status_code}")
212
- status = "โœ… ุชู… ุงู„ุชู…ุฏูŠุฏ" if resp.status_code in (200, 302) else f"โŒ ูุดู„ ({resp.status_code})"
213
- results.append({"domain": domain, "status": status})
214
 
215
- except Exception as e:
216
- logger.error(f"[{username}] ุฎุทุฃ: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
  return results
219
 
220
 
221
- def renew_account(account):
 
 
 
 
222
  username = account["username"]
223
  password = account.get("password", "").strip()
224
- host = account.get("host", "www.pythonanywhere.com")
225
 
226
  if not password:
227
  logger.error(f"[{username}] ู„ุง ุชูˆุฌุฏ ูƒู„ู…ุฉ ู…ุฑูˆุฑ")
228
  return False, []
229
 
 
230
  results = extend_webapp(username, password, host)
 
 
 
 
 
 
 
 
 
 
231
  success = any("โœ…" in r.get("status", "") for r in results) if results else False
232
 
233
  account["host"] = host
@@ -238,31 +389,40 @@ def renew_account(account):
238
  return success, results
239
 
240
 
241
- def run_renewals():
 
 
 
 
242
  accounts = load_accounts()
243
  if not accounts:
244
- logger.info("ู„ุง ุชูˆุฌุฏ ุญุณุงุจุงุช โ€” ุฃุถู ACCOUNTS_1 ุฃูˆ ACCOUNTS ููŠ Secrets.")
245
- return
246
 
247
- now = datetime.utcnow()
248
  renewed_count = 0
249
 
250
  for account in accounts:
251
- username = account["username"]
252
- next_renewal_str = account.get("next_renewal")
253
-
254
- if next_renewal_str:
255
- next_renewal = datetime.fromisoformat(next_renewal_str)
256
- if now < next_renewal:
257
- remaining = (next_renewal - now).days
258
- logger.info(f"[{username}] ุงู„ุชุฌุฏูŠุฏ ุจุนุฏ {remaining} ูŠูˆู…")
259
- continue
260
-
261
- logger.info(f"[{username}] ุฌุงุฑูŠ ุงู„ุชู…ุฏูŠุฏ...")
262
- success, results = renew_account(account)
 
 
 
263
  if success:
264
  renewed_count += 1
 
 
265
 
266
  save_accounts(accounts)
267
- logger.info(f"ุงูƒุชู…ู„. ุชู… ุชู…ุฏูŠุฏ {renewed_count}/{len(accounts)} ุญุณุงุจ.")
268
  return accounts
 
2
  import json
3
  import os
4
  import logging
5
+ import time
6
  from datetime import datetime, timedelta
7
  from bs4 import BeautifulSoup
8
 
 
14
 
15
  STATE_FILE = "renewal_state.json"
16
 
17
+ HOSTS = ["www.pythonanywhere.com", "eu.pythonanywhere.com"]
18
 
19
+ MAX_RETRIES = 3
20
+ RETRY_DELAY = 5 # ุซูˆุงู†ูŠ ุจูŠู† ุงู„ู…ุญุงูˆู„ุงุช
21
+ REQUEST_TIMEOUT = 20
22
+
23
+
24
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
25
+ # State helpers
26
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
27
+
28
+ def load_state() -> dict:
29
  if os.path.exists(STATE_FILE):
30
+ try:
31
+ with open(STATE_FILE, "r") as f:
32
+ return json.load(f)
33
+ except (json.JSONDecodeError, OSError) as e:
34
+ logger.warning(f"ุชุนุฐู‘ุฑ ู‚ุฑุงุกุฉ ู…ู„ู ุงู„ุญุงู„ุฉ: {e} โ€” ุณูŠุจุฏุฃ ู…ู† ุงู„ุตูุฑ")
35
  return {}
36
 
37
 
38
+ def save_state(state: dict):
39
+ try:
40
+ with open(STATE_FILE, "w") as f:
41
+ json.dump(state, f, indent=2, ensure_ascii=False)
42
+ except OSError as e:
43
+ logger.error(f"ุชุนุฐู‘ุฑ ุญูุธ ุงู„ุญุงู„ุฉ: {e}")
44
+
45
 
46
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
47
+ # Account loading
48
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
49
 
50
+ def load_accounts() -> list[dict]:
51
  """
52
+ ูŠู‚ุฑุฃ ุงู„ุญุณุงุจุงุช ู…ู† ู…ุชุบูŠุฑุงุช ุงู„ุจูŠุฆุฉ ุจุฃูŠ ู…ู† ุงู„ุตูŠุบุชูŠู†:
53
 
54
+ ุงู„ุตูŠุบุฉ 1 โ€” ู…ุฑู‚ู‘ู…ุฉ (ู…ูˆุตู‰ ุจู‡ุง):
55
  ACCOUNTS_1 = username1:password1
56
  ACCOUNTS_2 = username2:password2
 
57
 
58
+ ุงู„ุตูŠุบุฉ 2 โ€” ู…ุชุบูŠุฑ ูˆุงุญุฏ ู…ูุตูˆู„ ุจูุงุตู„ุฉ:
59
  ACCOUNTS = username1:password1,username2:password2
 
 
60
  """
61
  state = load_state()
62
  accounts = []
63
  seen = set()
64
 
65
+ def add_account(username: str, password: str):
66
+ username = username.strip()
67
+ password = password.strip()
68
  if not username or not password or username in seen:
69
  return
70
  seen.add(username)
71
+ s = state.get(username, {})
72
  accounts.append({
73
  "username": username,
74
  "password": password,
75
+ "host": s.get("host", "www.pythonanywhere.com"),
76
+ "last_renewed": s.get("last_renewed"),
77
+ "next_renewal": s.get("next_renewal"),
78
+ "last_results": s.get("last_results", []),
79
  })
80
 
81
+ def parse_entry(entry: str):
82
  entry = entry.strip()
83
  if ":" in entry:
84
  username, password = entry.split(":", 1)
85
+ add_account(username, password)
86
 
87
+ # ุงู„ุตูŠุบุฉ 1
88
  i = 1
89
  while True:
90
  entry = os.environ.get(f"ACCOUNTS_{i}", "").strip()
 
93
  parse_entry(entry)
94
  i += 1
95
 
96
+ # ุงู„ุตูŠุบุฉ 2
97
+ bulk = os.environ.get("ACCOUNTS", "").strip()
98
+ if bulk:
99
+ for entry in bulk.split(","):
100
  parse_entry(entry)
101
 
102
  if accounts:
 
107
  return accounts
108
 
109
 
110
+ def save_accounts(accounts: list[dict]):
111
+ """ูŠุญูุธ ุงู„ุญุงู„ุฉ ูู‚ุท โ€” ูƒู„ู…ุงุช ุงู„ู…ุฑูˆุฑ ู„ุง ุชููƒุชุจ ุนู„ู‰ ุงู„ู‚ุฑุต."""
112
+ state = {
113
+ a["username"]: {
 
114
  "host": a.get("host", "www.pythonanywhere.com"),
115
  "last_renewed": a.get("last_renewed"),
116
  "next_renewal": a.get("next_renewal"),
117
  "last_results": a.get("last_results", []),
118
  }
119
+ for a in accounts
120
+ }
121
  save_state(state)
122
 
123
 
124
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
125
+ # Session builder
126
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
 
 
 
 
 
 
 
 
 
 
127
 
128
+ def _build_session() -> requests.Session:
129
  session = requests.Session()
 
 
130
  session.headers.update({
131
+ "User-Agent": (
132
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
133
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
134
+ "Chrome/124.0.0.0 Safari/537.36"
135
+ ),
136
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
137
  "Accept-Language": "en-US,en;q=0.9",
138
+ "Accept-Encoding": "gzip, deflate, br",
139
+ "Connection": "keep-alive",
140
  })
141
+ return session
142
 
143
+
144
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
145
+ # Login
146
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
147
+
148
+ def _login(session: requests.Session, username: str, password: str, host: str) -> bool:
149
+ """
150
+ ูŠุณุฌู‘ู„ ุงู„ุฏุฎูˆู„ ุจุทุฑูŠู‚ุฉ ู…ูˆุซูˆู‚ุฉ:
151
+ 1. ูŠุฌู„ุจ ุงู„ู€ CSRF ู…ู† ุตูุญุฉ ุงู„ู„ูˆุฌู†
152
+ 2. ูŠุฑุณู„ ุงู„ุจูŠุงู†ุงุช
153
+ 3. ูŠุชุญู‚ู‚ ู…ู† ูˆุฌูˆุฏ sessionid ููŠ ุงู„ูƒูˆูƒูŠุฒ
154
+ 4. ูŠุชุญู‚ู‚ ู…ู† ูˆุฌูˆุฏ "logout" ููŠ ู†ุต ุงู„ุตูุญุฉ
155
+ """
156
+ login_url = f"https://{host}/login/"
157
+
158
+ for attempt in range(1, MAX_RETRIES + 1):
159
+ try:
160
+ login_page = session.get(login_url, timeout=REQUEST_TIMEOUT)
161
+ login_page.raise_for_status()
162
+
163
+ soup = BeautifulSoup(login_page.text, "html.parser")
164
+ form = soup.find("form")
165
+ if not form:
166
+ logger.error(f"[{username}] ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ form ููŠ ุตูุญุฉ ุงู„ุฏุฎูˆู„")
167
+ return False
168
+
169
+ form_data: dict = {}
170
+ for inp in form.find_all("input"):
171
+ name = inp.get("name")
172
+ if name:
173
+ form_data[name] = inp.get("value", "")
174
+
175
+ form_data["auth-username"] = username
176
+ form_data["auth-password"] = password
177
  form_data["login_view-current_step"] = "auth"
178
 
179
+ action = form.get("action") or login_url
180
+ if not action.startswith("http"):
181
+ action = f"https://{host}{action}"
182
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  resp = session.post(
184
+ action,
185
+ data=form_data,
186
  headers={
187
+ "Referer": login_url,
188
+ "Origin": f"https://{host}",
189
  "Content-Type": "application/x-www-form-urlencoded",
 
190
  },
191
+ timeout=REQUEST_TIMEOUT,
192
+ allow_redirects=True,
193
  )
 
 
 
194
 
195
+ cookies = session.cookies.get_dict()
196
+
197
+ # ุงู„ุชุญู‚ู‚ ุงู„ู…ุฒุฏูˆุฌ: ูƒูˆูƒูŠ sessionid + ู†ุต ูŠุญุชูˆูŠ logout
198
+ has_session = "sessionid" in cookies
199
+ has_logout = (
200
+ "log out" in resp.text.lower()
201
+ or "logout" in resp.text.lower()
202
+ )
203
+
204
+ if has_session and has_logout:
205
+ logger.info(f"[{username}] โœ… ุชู… ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ุนู„ู‰ {host}")
206
+ return True
207
+
208
+ if not has_session:
209
+ logger.warning(f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” ู„ุง ูŠูˆุฌุฏ sessionid")
210
+ elif not has_logout:
211
+ logger.warning(f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” 'logout' ุบูŠุฑ ู…ูˆุฌูˆุฏ ููŠ ุงู„ุตูุญุฉ")
212
+
213
+ except requests.Timeout:
214
+ logger.warning(f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” ุงู†ุชู‡ุช ุงู„ู…ู‡ู„ุฉ")
215
+ except requests.RequestException as e:
216
+ logger.warning(f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” ุฎุทุฃ ุดุจูƒุฉ: {e}")
217
+
218
+ if attempt < MAX_RETRIES:
219
+ time.sleep(RETRY_DELAY)
220
+
221
+ logger.error(f"[{username}] โŒ ูุดู„ ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ุจุนุฏ {MAX_RETRIES} ู…ุญุงูˆู„ุงุช")
222
+ return False
223
+
224
+
225
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
226
+ # Host discovery
227
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
228
+
229
+ def _detect_host(session: requests.Session, username: str) -> str | None:
230
+ """ูŠูƒุชุดู ุงู„ุฎุงุฏู… ุงู„ุตุญูŠุญ (www ุฃูˆ eu) ุจุนุฏ ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„."""
231
+ for host in HOSTS:
232
+ try:
233
+ r = session.get(
234
+ f"https://{host}/user/{username}/webapps/",
235
+ timeout=REQUEST_TIMEOUT,
236
+ allow_redirects=True,
237
+ )
238
+ if r.status_code == 200 and username.lower() in r.url.lower():
239
+ logger.info(f"[{username}] ุงู„ุฎุงุฏู…: {host}")
240
+ return host
241
+ except requests.RequestException:
242
+ continue
243
+ return None
244
+
245
+
246
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
247
+ # Webapp extension
248
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
249
+
250
+ def _get_domains(soup: BeautifulSoup, username: str, host: str) -> list[str]:
251
+ """
252
+ ูŠูƒุชุดู ุงู„ู†ุทุงู‚ุงุช ุจุทุฑูŠู‚ุชูŠู†:
253
+ 1. ุฑูˆุงุจุท <a> ุชุญุชูˆูŠ /webapps/ (ุงู„ุฃูƒุซุฑ ุฏู‚ุฉ)
254
+ 2. ู†ู…ุงุฐุฌ <form> ุชุญุชูˆูŠ /extend ููŠ action (ุงุญุชูŠุงุทูŠ)
255
+ """
256
+ domains: list[str] = []
257
+
258
+ # ุงู„ุทุฑูŠู‚ุฉ 1 โ€” ุฑูˆุงุจุท ุงู„ู€ webapps
259
+ for tag in soup.find_all("a", href=True):
260
+ href: str = tag["href"]
261
+ marker = f"/user/{username}/webapps/"
262
+ if marker in href:
263
+ part = href.split(marker)[-1].strip("/")
264
+ if part and "." in part and part not in domains:
265
+ domains.append(part)
266
+
267
+ # ุงู„ุทุฑูŠู‚ุฉ 2 โ€” ู†ู…ุงุฐุฌ extend (ุงุญุชูŠุงุทูŠ)
268
+ if not domains:
269
+ for form in soup.find_all("form", action=True):
270
+ action: str = form.get("action", "")
271
+ if "/extend" in action.lower():
272
+ domain = action.split("/webapps/")[-1].split("/extend")[0].strip("/")
273
+ if domain and domain not in domains:
274
+ domains.append(domain)
275
+
276
+ # ุงู„ุงูุชุฑุงุถูŠ
277
+ if not domains:
278
+ domains = [f"{username}.pythonanywhere.com"]
279
+ logger.warning(f"[{username}] ู„ู… ูŠุชู… ุงูƒุชุดุงู ุฃูŠ ู†ุทุงู‚ โ€” ุณูŠุณุชุฎุฏู… ุงู„ุงูุชุฑุงุถูŠ: {domains[0]}")
280
+
281
+ return domains
282
+
283
+
284
+ def extend_webapp(username: str, password: str, host: str = "www.pythonanywhere.com") -> list[dict]:
285
+ session = _build_session()
286
+ results: list[dict] = []
287
+
288
+ # โ”€โ”€ ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ โ”€โ”€
289
+ if not _login(session, username, password, host):
290
+ return []
291
+
292
+ # โ”€โ”€ ุตูุญุฉ ุงู„ู€ webapps โ”€โ”€
293
+ webapps_url = f"https://{host}/user/{username}/webapps/"
294
+ try:
295
+ page = session.get(webapps_url, timeout=REQUEST_TIMEOUT)
296
+ page.raise_for_status()
297
+ except requests.RequestException as e:
298
+ logger.error(f"[{username}] ุชุนุฐู‘ุฑ ุงู„ูˆุตูˆู„ ู„ุตูุญุฉ webapps: {e}")
299
+ return []
300
+
301
+ soup = BeautifulSoup(page.text, "html.parser")
302
+
303
+ # โ”€โ”€ CSRF โ”€โ”€
304
+ csrf_input = soup.find("input", {"name": "csrfmiddlewaretoken"})
305
+ csrf = csrf_input["value"] if csrf_input else session.cookies.get("csrftoken", "")
306
+ if not csrf:
307
+ logger.error(f"[{username}] ู„ู… ูŠุชู… ุงู„ุนุซูˆุฑ ุนู„ู‰ CSRF token")
308
+ return []
309
+
310
+ # โ”€โ”€ ุงู„ู†ุทุงู‚ุงุช โ”€โ”€
311
+ domains = _get_domains(soup, username, host)
312
+ logger.info(f"[{username}] ุงู„ู†ุทุงู‚ุงุช ุงู„ู…ูƒุชุดูุฉ: {domains}")
313
+
314
+ # โ”€โ”€ ุงู„ุชู…ุฏูŠุฏ โ”€โ”€
315
+ for domain in domains:
316
+ extend_url = f"https://{host}/user/{username}/webapps/{domain}/extend"
317
+ success = False
318
+
319
+ for attempt in range(1, MAX_RETRIES + 1):
320
+ try:
321
+ resp = session.post(
322
+ extend_url,
323
+ data={"csrfmiddlewaretoken": csrf},
324
+ headers={
325
+ "Referer": webapps_url,
326
+ "Origin": f"https://{host}",
327
+ "Content-Type": "application/x-www-form-urlencoded",
328
+ },
329
+ timeout=REQUEST_TIMEOUT,
330
+ allow_redirects=True,
331
+ )
332
+ logger.info(f"[{username}] Extend {domain}: HTTP {resp.status_code} โ†’ {resp.url}")
333
+
334
+ if resp.status_code in (200, 302):
335
+ success = True
336
+ break
337
+ else:
338
+ logger.warning(
339
+ f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” "
340
+ f"extend {domain} ูุดู„ ({resp.status_code})"
341
+ )
342
+
343
+ except requests.Timeout:
344
+ logger.warning(f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” ุงู†ุชู‡ุช ุงู„ู…ู‡ู„ุฉ ุฃุซู†ุงุก extend {domain}")
345
+ except requests.RequestException as e:
346
+ logger.warning(f"[{username}] ู…ุญุงูˆู„ุฉ {attempt}/{MAX_RETRIES} โ€” ุฎุทุฃ: {e}")
347
+
348
+ if attempt < MAX_RETRIES:
349
+ time.sleep(RETRY_DELAY)
350
+
351
+ status = "โœ… ุชู… ุงู„ุชู…ุฏูŠุฏ" if success else f"โŒ ูุดู„ ุงู„ุชู…ุฏูŠุฏ ุจุนุฏ {MAX_RETRIES} ู…ุญุงูˆู„ุงุช"
352
+ results.append({"domain": domain, "status": status})
353
 
354
  return results
355
 
356
 
357
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
358
+ # Renew single account
359
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
360
+
361
+ def renew_account(account: dict) -> tuple[bool, list[dict]]:
362
  username = account["username"]
363
  password = account.get("password", "").strip()
364
+ host = account.get("host") or "www.pythonanywhere.com"
365
 
366
  if not password:
367
  logger.error(f"[{username}] ู„ุง ุชูˆุฌุฏ ูƒู„ู…ุฉ ู…ุฑูˆุฑ")
368
  return False, []
369
 
370
+ # ู…ุญุงูˆู„ุฉ ุงู„ุงุชุตุงู„ โ€” ุงูƒุชุดุงู ุงู„ุฎุงุฏู… ุฅุฐุง ู„ุฒู…
371
  results = extend_webapp(username, password, host)
372
+
373
+ # ุฅุฐุง ูุดู„ ุงู„ุฎุงุฏู… ุงู„ู…ุญููˆุธุŒ ุฌุฑู‘ุจ ุงู„ุขุฎุฑ
374
+ if not results:
375
+ other_host = next((h for h in HOSTS if h != host), None)
376
+ if other_host:
377
+ logger.info(f"[{username}] ุฌุฑุจ ุงู„ุฎุงุฏู… ุงู„ุขุฎุฑ: {other_host}")
378
+ results = extend_webapp(username, password, other_host)
379
+ if results:
380
+ host = other_host
381
+
382
  success = any("โœ…" in r.get("status", "") for r in results) if results else False
383
 
384
  account["host"] = host
 
389
  return success, results
390
 
391
 
392
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
393
+ # Batch renewal
394
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
395
+
396
+ def run_renewals() -> list[dict] | None:
397
  accounts = load_accounts()
398
  if not accounts:
399
+ logger.info("ู„ุง ุชูˆุฌุฏ ุญุณุงุจุงุช.")
400
+ return None
401
 
402
+ now = datetime.utcnow()
403
  renewed_count = 0
404
 
405
  for account in accounts:
406
+ username = account["username"]
407
+ next_renewal = account.get("next_renewal")
408
+
409
+ if next_renewal:
410
+ try:
411
+ nxt = datetime.fromisoformat(next_renewal)
412
+ if now < nxt:
413
+ remaining = (nxt - now).days
414
+ logger.info(f"[{username}] ุงู„ุชุฌุฏูŠุฏ ุงู„ู‚ุงุฏู… ุจุนุฏ {remaining} ูŠูˆู… โ€” ู„ุง ุญุงุฌุฉ ุงู„ุขู†")
415
+ continue
416
+ except ValueError:
417
+ logger.warning(f"[{username}] next_renewal ุบูŠุฑ ุตุงู„ุญ โ€” ุณูŠูุฌุฏูŽู‘ุฏ ุงู„ุขู†")
418
+
419
+ logger.info(f"[{username}] ุฌุงุฑูŠ ุงู„ุชุฌุฏูŠุฏ...")
420
+ success, _ = renew_account(account)
421
  if success:
422
  renewed_count += 1
423
+ else:
424
+ logger.error(f"[{username}] โŒ ูุดู„ ุงู„ุชุฌุฏูŠุฏ")
425
 
426
  save_accounts(accounts)
427
+ logger.info(f"ุงูƒุชู…ู„: ุชู… ุชุฌุฏูŠุฏ {renewed_count}/{len(accounts)} ุญุณุงุจ.")
428
  return accounts