main.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. #!/usr/bin/env python3
  2. #
  3. # SPDX-FileCopyrightText: (c) 2020-2021 CokeMine & Its repository contributors
  4. # SPDX-FileCopyrightText: (c) 2021 A beam of light
  5. #
  6. # SPDX-License-Identifier: GPL-3.0-or-later
  7. #
  8. """
  9. euserv auto-renew script
  10. ChangeLog
  11. v2021.09.30
  12. - Captcha automatic recognition using TrueCaptcha API
  13. - Email notification
  14. - Add login failure retry mechanism
  15. - reformat log info
  16. v2021.11.06
  17. - Receive renew PIN(6-digits) using mailparser parsed data download url
  18. workflow: auto-forward your EUserv PIN email to your mailparser inbox
  19. -> parsing PIN via mailparser -> get PIN from mailparser
  20. - Update kc2_security_password_get_token request
  21. v2021.11.26
  22. - Adjust TrueCaptcha constraint parameters for high availability.
  23. Plus, the CAPTCHA of EUserv is currently case-insensitive, so the above adjustment works.
  24. """
  25. import os
  26. import re
  27. import json
  28. import time
  29. import base64
  30. from email.mime.application import MIMEApplication
  31. from email.mime.multipart import MIMEMultipart
  32. from email.mime.text import MIMEText
  33. from smtplib import SMTP_SSL, SMTPDataError
  34. import requests
  35. from bs4 import BeautifulSoup
  36. # 多个账户请使用空格隔开
  37. USERNAME = os.environ["USERNAME"] # 用户名或邮箱
  38. PASSWORD = os.environ["PASSWORD"] # 密码
  39. # default value is TrueCaptcha demo credential,
  40. # you can use your own credential via set environment variables:
  41. # TRUECAPTCHA_USERID and TRUECAPTCHA_APIKEY
  42. # demo: https://apitruecaptcha.org/demo
  43. # demo2: https://apitruecaptcha.org/demo2
  44. # demo apikey also has a limit of 100 times per day
  45. # {
  46. # 'error': '101.0 above free usage limit 100 per day and no balance',
  47. # 'requestId': '7690c065-70e0-4757-839b-5fd8381e65c7'
  48. # }
  49. TRUECAPTCHA_USERID = os.environ.get("TRUECAPTCHA_USERID", "arun56")
  50. TRUECAPTCHA_APIKEY = os.environ.get("TRUECAPTCHA_APIKEY", "wMjXmBIcHcdYqO2RrsVN")
  51. # Extract key data from your emails, automatically. https://mailparser.io
  52. # 30 Emails/Month, 10 inboxes and unlimited downloads for free.
  53. # 多个mailparser下载链接id请使用空格隔开, 顺序与 EUserv 账号/邮箱一一对应
  54. MAILPARSER_DOWNLOAD_URL_ID = os.environ["MAILPARSER_DOWNLOAD_URL_ID"]
  55. # mailparser.io parsed data download base url
  56. MAILPARSER_DOWNLOAD_BASE_URL = "https://files.mailparser.io/d/"
  57. # Telegram Bot Push https://core.telegram.org/bots/api#authorizing-your-bot
  58. TG_BOT_TOKEN = "" # 通过 @BotFather 申请获得,示例:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw
  59. TG_USER_ID = "" # 用户、群组或频道 ID,示例:129xxx206
  60. TG_API_HOST = "https://api.telegram.org" # 自建 API 反代地址,供网络环境无法访问时使用,网络正常则保持默认
  61. # Email notification
  62. RECEIVER_EMAIL = os.environ.get("RECEIVER_EMAIL", "")
  63. YD_EMAIL = os.environ.get("YD_EMAIL", "")
  64. YD_APP_PWD = os.environ.get("YD_APP_PWD", "") # yandex mail 使用第三方 APP 授权码
  65. # Server 酱(ServerChan) https://sct.ftqq.com
  66. # 免费额度: 每天最多发送 5 条, 卡片仅显示标题, 每天 API 最大请求 1000 次, 每分钟最多发送 5 条.
  67. SERVER_CHAN_SENDKEY = os.environ.get("SERVER_CHAN_SENDKEY", "") # Server 酱的 SENDKEY,无需推送可忽略
  68. # Magic internet access
  69. PROXIES = {"http": "http://127.0.0.1:10808", "https": "http://127.0.0.1:10808"}
  70. # Maximum number of login retry
  71. LOGIN_MAX_RETRY_COUNT = 5
  72. # Waiting time of receiving PIN, units are seconds.
  73. WAITING_TIME_OF_PIN = 15
  74. # options: True or False
  75. CHECK_CAPTCHA_SOLVER_USAGE = True
  76. user_agent = (
  77. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
  78. "Chrome/95.0.4638.69 Safari/537.36"
  79. )
  80. desp = "" # 空值
  81. def log(info: str):
  82. print(info)
  83. global desp
  84. desp = desp + info + "\n\n"
  85. def login_retry(*args, **kwargs):
  86. def wrapper(func):
  87. def inner(username, password):
  88. ret, ret_session = func(username, password)
  89. max_retry = kwargs.get("max_retry")
  90. # default retry 3 times
  91. if not max_retry:
  92. max_retry = 3
  93. number = 0
  94. if ret == "-1":
  95. while number < max_retry:
  96. number += 1
  97. if number > 1:
  98. ordinal = lambda n: "{}{}".format(n,"tsnrhtdd"[(n/10%10!=1)*(n%10<4)*n%10::4])
  99. log(f"[EUserv] Login tried the {ordinal(number)} time.")
  100. sess_id, session = func(username, password)
  101. if sess_id != "-1":
  102. return sess_id, session
  103. else:
  104. if number == max_retry:
  105. return sess_id, session
  106. else:
  107. return ret, ret_session
  108. return inner
  109. return wrapper
  110. def captcha_solver(captcha_image_url: str, session: requests.session) -> dict:
  111. """
  112. TrueCaptcha API doc: https://apitruecaptcha.org/api
  113. Free to use 100 requests per day.
  114. -- response::
  115. {
  116. "result": "", ==> Or "result": 0
  117. "conf": 0.85,
  118. "usage": 0,
  119. "requestId": "ed0006e5-69f0-4617-b698-97dc054f9022",
  120. "version": "dev2"
  121. }
  122. """
  123. response = session.get(captcha_image_url)
  124. encoded_string = base64.b64encode(response.content)
  125. url = "https://api.apitruecaptcha.org/one/gettext"
  126. # Since "case": "mixed", "mode": "human"
  127. # can sometimes cause internal errors in the truecaptcha server.
  128. # So a more relaxed constraint(lower/upper & default) is used here.
  129. # Plus, the CAPTCHA of EUserv is currently case-insensitive, so the below adjustment works.
  130. data = {
  131. "userid": TRUECAPTCHA_USERID,
  132. "apikey": TRUECAPTCHA_APIKEY,
  133. # case sensitivity of text (upper | lower| mixed)
  134. "case": "lower",
  135. # use human or AI (human | default)
  136. "mode": "default",
  137. "data": str(encoded_string)[2:-1],
  138. }
  139. r = requests.post(url=url, json=data)
  140. j = json.loads(r.text)
  141. return j
  142. def handle_captcha_solved_result(solved: dict) -> str:
  143. """Since CAPTCHA sometimes appears as a very simple binary arithmetic expression.
  144. But since recognition sometimes doesn't show the result of the calculation directly,
  145. that's what this function is for.
  146. """
  147. if "result" in solved:
  148. solved_result = solved["result"]
  149. if isinstance(solved_result, str):
  150. if "RESULT IS" in solved_result:
  151. log("[Captcha Solver] You are using the demo apikey.")
  152. print("There is no guarantee that demo apikey will work in the future!")
  153. # because using demo apikey
  154. text = re.findall(r"RESULT IS . (.*) .", solved_result)[0]
  155. else:
  156. # using your own apikey
  157. log("[Captcha Solver] You are using your own apikey.")
  158. text = solved_result
  159. operators = ["X", "x", "+", "-"]
  160. if any(x in text for x in operators):
  161. for operator in operators:
  162. operator_pos = text.find(operator)
  163. if operator == "x" or operator == "X":
  164. operator = "*"
  165. if operator_pos != -1:
  166. left_part = text[:operator_pos]
  167. right_part = text[operator_pos + 1 :]
  168. if left_part.isdigit() and right_part.isdigit():
  169. return eval(
  170. "{left} {operator} {right}".format(
  171. left=left_part, operator=operator, right=right_part
  172. )
  173. )
  174. else:
  175. # Because these symbols("X", "x", "+", "-") do not appear at the same time,
  176. # it just contains an arithmetic symbol.
  177. return text
  178. else:
  179. return text
  180. else:
  181. print(f"[Captcha Solver] Returned JSON: {solved}")
  182. log("[Captcha Solver] Service Exception!")
  183. raise ValueError("[Captcha Solver] Service Exception!")
  184. else:
  185. print(f"[Captcha Solver] Returned JSON: {solved}")
  186. log("[Captcha Solver] Failed to find parsed results!")
  187. raise KeyError("[Captcha Solver] Failed to find parsed results!")
  188. def get_captcha_solver_usage() -> dict:
  189. url = "https://api.apitruecaptcha.org/one/getusage"
  190. params = {
  191. "username": TRUECAPTCHA_USERID,
  192. "apikey": TRUECAPTCHA_APIKEY,
  193. }
  194. r = requests.get(url=url, params=params)
  195. j = json.loads(r.text)
  196. return j
  197. def get_pin_from_mailparser(url_id: str) -> str:
  198. """
  199. response format:
  200. [
  201. {
  202. "id": "83b95f50f6202fb03950afbe00975eab",
  203. "received_at": "2021-11-06 02:30:07", ==> up to mailparser account timezone setting, here is UTC 0000.
  204. "processed_at": "2021-11-06 02:30:07",
  205. "pin": "123456"
  206. }
  207. ]
  208. """
  209. response = requests.get(
  210. f"{MAILPARSER_DOWNLOAD_BASE_URL}{url_id}",
  211. # Mailparser parsed data download using Basic Authentication.
  212. # auth=("<your mailparser username>", "<your mailparser password>")
  213. )
  214. pin = response.json()[0]["pin"]
  215. return pin
  216. @login_retry(max_retry=LOGIN_MAX_RETRY_COUNT)
  217. def login(username: str, password: str) -> (str, requests.session):
  218. headers = {"user-agent": user_agent, "origin": "https://www.euserv.com"}
  219. url = "https://support.euserv.com/index.iphp"
  220. captcha_image_url = "https://support.euserv.com/securimage_show.php"
  221. session = requests.Session()
  222. sess = session.get(url, headers=headers)
  223. sess_id = re.findall("PHPSESSID=(\\w{10,100});", str(sess.headers))[0]
  224. # visit png
  225. logo_png_url = "https://support.euserv.com/pic/logo_small.png"
  226. session.get(logo_png_url, headers=headers)
  227. login_data = {
  228. "email": username,
  229. "password": password,
  230. "form_selected_language": "en",
  231. "Submit": "Login",
  232. "subaction": "login",
  233. "sess_id": sess_id,
  234. }
  235. f = session.post(url, headers=headers, data=login_data)
  236. f.raise_for_status()
  237. if (
  238. f.text.find("Hello") == -1
  239. and f.text.find("Confirm or change your customer data here") == -1
  240. ):
  241. if (
  242. f.text.find(
  243. "To finish the login process please solve the following captcha."
  244. )
  245. == -1
  246. ):
  247. return "-1", session
  248. else:
  249. log("[Captcha Solver] 进行验证码识别...")
  250. solved_result = captcha_solver(captcha_image_url, session)
  251. try:
  252. captcha_code = handle_captcha_solved_result(solved_result)
  253. log("[Captcha Solver] 识别的验证码是: {}".format(captcha_code))
  254. if CHECK_CAPTCHA_SOLVER_USAGE:
  255. usage = get_captcha_solver_usage()
  256. log(
  257. "[Captcha Solver] current date {0} api usage count: {1}".format(
  258. usage[0]["date"], usage[0]["count"]
  259. )
  260. )
  261. f2 = session.post(
  262. url,
  263. headers=headers,
  264. data={
  265. "subaction": "login",
  266. "sess_id": sess_id,
  267. "captcha_code": captcha_code,
  268. },
  269. )
  270. if (
  271. f2.text.find(
  272. "To finish the login process please solve the following captcha."
  273. )
  274. == -1
  275. ):
  276. log("[Captcha Solver] 验证通过")
  277. return sess_id, session
  278. else:
  279. log("[Captcha Solver] 验证失败")
  280. return "-1", session
  281. except (KeyError, ValueError):
  282. return "-1", session
  283. else:
  284. return sess_id, session
  285. def get_servers(sess_id: str, session: requests.session) -> {}:
  286. d = {}
  287. url = "https://support.euserv.com/index.iphp?sess_id=" + sess_id
  288. headers = {"user-agent": user_agent, "origin": "https://www.euserv.com"}
  289. f = session.get(url=url, headers=headers)
  290. f.raise_for_status()
  291. soup = BeautifulSoup(f.text, "html.parser")
  292. for tr in soup.select(
  293. "#kc2_order_customer_orders_tab_content_1 .kc2_order_table.kc2_content_table tr"
  294. ):
  295. server_id = tr.select(".td-z1-sp1-kc")
  296. if not len(server_id) == 1:
  297. continue
  298. flag = (
  299. True
  300. if tr.select(".td-z1-sp2-kc .kc2_order_action_container")[0]
  301. .get_text()
  302. .find("Contract extension possible from")
  303. == -1
  304. else False
  305. )
  306. d[server_id[0].get_text()] = flag
  307. return d
  308. def renew(
  309. sess_id: str, session: requests.session, password: str, order_id: str, mailparser_dl_url_id: str
  310. ) -> bool:
  311. url = "https://support.euserv.com/index.iphp"
  312. headers = {
  313. "user-agent": user_agent,
  314. "Host": "support.euserv.com",
  315. "origin": "https://support.euserv.com",
  316. "Referer": "https://support.euserv.com/index.iphp",
  317. }
  318. data = {
  319. "Submit": "Extend contract",
  320. "sess_id": sess_id,
  321. "ord_no": order_id,
  322. "subaction": "choose_order",
  323. "choose_order_subaction": "show_contract_details",
  324. }
  325. session.post(url, headers=headers, data=data)
  326. # pop up 'Security Check' window, it will trigger 'send PIN' automatically.
  327. session.post(
  328. url,
  329. headers=headers,
  330. data={
  331. "sess_id": sess_id,
  332. "subaction": "show_kc2_security_password_dialog",
  333. "prefix": "kc2_customer_contract_details_extend_contract_",
  334. "type": "1",
  335. },
  336. )
  337. # # trigger 'Send new PIN to your Email-Address' (optional),
  338. # new_pin = session.post(url, headers=headers, data={
  339. # "sess_id": sess_id,
  340. # "subaction": "kc2_security_password_send_pin",
  341. # "ident": f"kc2_customer_contract_details_extend_contract_{order_id}"
  342. # })
  343. # if not json.loads(new_pin.text)["rc"] == "100":
  344. # print("new PIN Not Sended")
  345. # return False
  346. # sleep WAITING_TIME_OF_PIN seconds waiting for mailparser email parsed PIN
  347. time.sleep(WAITING_TIME_OF_PIN)
  348. pin = get_pin_from_mailparser(mailparser_dl_url_id)
  349. log(f"[MailParser] PIN: {pin}")
  350. # using PIN instead of password to get token
  351. data = {
  352. "auth": pin,
  353. "sess_id": sess_id,
  354. "subaction": "kc2_security_password_get_token",
  355. "prefix": "kc2_customer_contract_details_extend_contract_",
  356. "type": 1,
  357. "ident": f"kc2_customer_contract_details_extend_contract_{order_id}",
  358. }
  359. f = session.post(url, headers=headers, data=data)
  360. f.raise_for_status()
  361. if not json.loads(f.text)["rs"] == "success":
  362. return False
  363. token = json.loads(f.text)["token"]["value"]
  364. data = {
  365. "sess_id": sess_id,
  366. "ord_id": order_id,
  367. "subaction": "kc2_customer_contract_details_extend_contract_term",
  368. "token": token,
  369. }
  370. session.post(url, headers=headers, data=data)
  371. time.sleep(5)
  372. return True
  373. def check(sess_id: str, session: requests.session):
  374. print("Checking.......")
  375. d = get_servers(sess_id, session)
  376. flag = True
  377. for key, val in d.items():
  378. if val:
  379. flag = False
  380. log("[EUserv] ServerID: %s Renew Failed!" % key)
  381. if flag:
  382. log("[EUserv] ALL Work Done! Enjoy~")
  383. # Telegram Bot Push https://core.telegram.org/bots/api#authorizing-your-bot
  384. def telegram():
  385. data = (("chat_id", TG_USER_ID), ("text", "EUserv续费日志\n\n" + desp))
  386. response = requests.post(
  387. TG_API_HOST + "/bot" + TG_BOT_TOKEN + "/sendMessage", data=data
  388. )
  389. if response.status_code != 200:
  390. print("Telegram Bot 推送失败")
  391. else:
  392. print("Telegram Bot 推送成功")
  393. # Yandex mail notification
  394. def send_mail_by_yandex(
  395. to_email, from_email, subject, text, files, sender_email, sender_password
  396. ):
  397. msg = MIMEMultipart()
  398. msg["Subject"] = subject
  399. msg["From"] = from_email
  400. msg["To"] = to_email
  401. msg.attach(MIMEText(text, _charset="utf-8"))
  402. if files is not None:
  403. for file in files:
  404. file_name, file_content = file
  405. # print(file_name)
  406. part = MIMEApplication(file_content)
  407. part.add_header(
  408. "Content-Disposition", "attachment", filename=("gb18030", "", file_name)
  409. )
  410. msg.attach(part)
  411. s = SMTP_SSL("smtp.yandex.ru", 465)
  412. s.login(sender_email, sender_password)
  413. try:
  414. s.sendmail(msg["From"], msg["To"], msg.as_string())
  415. except SMTPDataError as e:
  416. raise e
  417. finally:
  418. s.close()
  419. # eMail push
  420. def email():
  421. msg = "EUserv 续费日志\n\n" + desp
  422. try:
  423. send_mail_by_yandex(
  424. RECEIVER_EMAIL, YD_EMAIL, "EUserv 续费日志", msg, None, YD_EMAIL, YD_APP_PWD
  425. )
  426. print("eMail 推送成功")
  427. except requests.exceptions.RequestException as e:
  428. print(str(e))
  429. print("eMail 推送失败")
  430. except SMTPDataError as e1:
  431. print(str(e1))
  432. print("eMail 推送失败")
  433. # Server酱 https://sct.ftqq.com
  434. def server_chan():
  435. data = {
  436. "title": "EUserv 续费日志",
  437. "desp": desp
  438. }
  439. response = requests.post(f"https://sctapi.ftqq.com/{SERVER_CHAN_SENDKEY}.send", data=data)
  440. if response.status_code != 200:
  441. print('Server 酱推送失败')
  442. else:
  443. print('Server 酱推送成功')
  444. if __name__ == "__main__":
  445. if not USERNAME or not PASSWORD:
  446. log("[EUserv] 你没有添加任何账户")
  447. exit(1)
  448. user_list = USERNAME.strip().split()
  449. passwd_list = PASSWORD.strip().split()
  450. mailparser_dl_url_id_list = MAILPARSER_DOWNLOAD_URL_ID.strip().split()
  451. if len(user_list) != len(passwd_list):
  452. log("[EUserv] The number of usernames and passwords do not match!")
  453. exit(1)
  454. if len(mailparser_dl_url_id_list) != len(user_list):
  455. log("[Mailparser] The number of mailparser_dl_url_ids and usernames do not match!")
  456. exit(1)
  457. for i in range(len(user_list)):
  458. print("*" * 30)
  459. log("[EUserv] 正在续费第 %d 个账号" % (i + 1))
  460. sessid, s = login(user_list[i], passwd_list[i])
  461. if sessid == "-1":
  462. log("[EUserv] 第 %d 个账号登陆失败,请检查登录信息" % (i + 1))
  463. continue
  464. SERVERS = get_servers(sessid, s)
  465. log("[EUserv] 检测到第 {} 个账号有 {} 台 VPS,正在尝试续期".format(i + 1, len(SERVERS)))
  466. for k, v in SERVERS.items():
  467. if v:
  468. if not renew(sessid, s, passwd_list[i], k, mailparser_dl_url_id_list[i]):
  469. log("[EUserv] ServerID: %s Renew Error!" % k)
  470. else:
  471. log("[EUserv] ServerID: %s has been successfully renewed!" % k)
  472. else:
  473. log("[EUserv] ServerID: %s does not need to be renewed" % k)
  474. time.sleep(15)
  475. check(sessid, s)
  476. time.sleep(5)
  477. TG_BOT_TOKEN and TG_USER_ID and TG_API_HOST and telegram()
  478. RECEIVER_EMAIL and YD_EMAIL and YD_APP_PWD and email()
  479. SERVER_CHAN_SENDKEY and server_chan()
  480. print("*" * 30)