FreeNom.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. <?php
  2. /**
  3. * FreeNom域名自动续期
  4. *
  5. * @author mybsdc <[email protected]>
  6. * @date 2020/1/19
  7. * @time 17:29
  8. * @link https://github.com/luolongfei/freenom
  9. */
  10. namespace Luolongfei\App\Console;
  11. use Luolongfei\App\Exceptions\LlfException;
  12. use Luolongfei\App\Exceptions\WarningException;
  13. use GuzzleHttp\Client;
  14. use GuzzleHttp\Cookie\CookieJar;
  15. use Luolongfei\Libs\Log;
  16. use Luolongfei\Libs\Message;
  17. use GuzzleHttp\Cookie\SetCookie;
  18. class FreeNom extends Base
  19. {
  20. const VERSION = 'v0.6.2';
  21. const TIMEOUT = 33;
  22. // FreeNom登录地址
  23. const LOGIN_URL = 'https://my.freenom.com/dologin.php';
  24. // 域名状态地址
  25. const DOMAIN_STATUS_URL = 'https://my.freenom.com/domains.php?a=renewals';
  26. // 域名续期地址
  27. const RENEW_DOMAIN_URL = 'https://my.freenom.com/domains.php?submitrenewals=true';
  28. // 匹配token的正则
  29. const TOKEN_REGEX = '/name="token"\svalue="(?P<token>[^"]+)"/i';
  30. // 匹配域名信息的正则
  31. const DOMAIN_INFO_REGEX = '/<tr><td>(?P<domain>[^<]+)<\/td><td>[^<]+<\/td><td>[^<]+<span class="[^"]+">(?P<days>\d+)[^&]+&domain=(?P<id>\d+)"/i';
  32. // 匹配登录状态的正则
  33. const LOGIN_STATUS_REGEX = '/<li.*?Logout.*?<\/li>/i';
  34. // 匹配无域名的正则
  35. const NO_DOMAIN_REGEX = '/<tr\sclass="carttablerow"><td\scolspan="5">(?P<msg>[^<]+)<\/td><\/tr>/i';
  36. /**
  37. * @var Client
  38. */
  39. protected $client;
  40. /**
  41. * @var CookieJar | bool
  42. */
  43. protected $jar = true;
  44. /**
  45. * @var string FreeNom 账户
  46. */
  47. protected $username;
  48. /**
  49. * @var string FreeNom 密码
  50. */
  51. protected $password;
  52. /**
  53. * @var FreeNom
  54. */
  55. private static $instance;
  56. /**
  57. * @var int 最大请求重试次数
  58. */
  59. public $maxRequestRetryCount;
  60. /**
  61. * @return FreeNom
  62. */
  63. public static function getInstance()
  64. {
  65. if (!self::$instance instanceof self) {
  66. self::$instance = new self();
  67. }
  68. return self::$instance;
  69. }
  70. private function __construct()
  71. {
  72. $this->client = new Client([
  73. 'headers' => [
  74. 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
  75. 'Accept-Encoding' => 'gzip, deflate, br',
  76. 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
  77. ],
  78. 'timeout' => self::TIMEOUT,
  79. CURLOPT_FOLLOWLOCATION => true,
  80. CURLOPT_AUTOREFERER => true,
  81. 'verify' => config('verify_ssl'),
  82. 'debug' => config('debug'),
  83. 'proxy' => config('freenom_proxy'),
  84. ]);
  85. $this->maxRequestRetryCount = config('max_request_retry_count');
  86. system_log(sprintf(lang('100038'), self::VERSION));
  87. }
  88. private function __clone()
  89. {
  90. }
  91. /**
  92. * 登录
  93. *
  94. * @param string $username
  95. * @param string $password
  96. *
  97. * @return bool
  98. * @throws LlfException
  99. */
  100. protected function login(string $username, string $password)
  101. {
  102. try {
  103. autoRetry(function ($username, $password, &$jar) {
  104. return $this->client->post(self::LOGIN_URL, [
  105. 'headers' => [
  106. 'Content-Type' => 'application/x-www-form-urlencoded',
  107. 'Referer' => 'https://my.freenom.com/clientarea.php'
  108. ],
  109. 'form_params' => [
  110. 'username' => $username,
  111. 'password' => $password
  112. ],
  113. 'cookies' => $jar
  114. ]);
  115. }, $this->maxRequestRetryCount, [$username, $password, &$this->jar]);
  116. } catch (\Exception $e) {
  117. throw new LlfException(34520002, $e->getMessage());
  118. }
  119. $loginCookie = $this->jar->getCookieByName('WHMCSZH5eHTGhfvzP');
  120. if (!$loginCookie instanceof SetCookie || $loginCookie->getValue() === '') {
  121. throw new LlfException(34520002, lang('100001'));
  122. }
  123. system_log(sprintf(lang('100138'), $username));
  124. return true;
  125. }
  126. /**
  127. * 匹配获取所有域名
  128. *
  129. * @param string $domainStatusPage
  130. *
  131. * @return array
  132. * @throws LlfException
  133. * @throws WarningException
  134. */
  135. protected function getAllDomains(string $domainStatusPage)
  136. {
  137. if (preg_match(self::NO_DOMAIN_REGEX, $domainStatusPage, $m)) {
  138. throw new WarningException(34520014, [$this->username, $m['msg']]);
  139. }
  140. if (!preg_match_all(self::DOMAIN_INFO_REGEX, $domainStatusPage, $allDomains, PREG_SET_ORDER)) {
  141. throw new LlfException(34520003);
  142. }
  143. return $allDomains;
  144. }
  145. /**
  146. * 获取匹配 token
  147. *
  148. * 据观察,每次登录后此 token 不会改变,故可以只获取一次,多次使用
  149. *
  150. * @param string $domainStatusPage
  151. *
  152. * @return string
  153. * @throws LlfException
  154. */
  155. protected function getToken(string $domainStatusPage)
  156. {
  157. if (!preg_match(self::TOKEN_REGEX, $domainStatusPage, $matches)) {
  158. throw new LlfException(34520004);
  159. }
  160. return $matches['token'];
  161. }
  162. /**
  163. * 获取域名状态页面
  164. *
  165. * @return string
  166. * @throws LlfException
  167. */
  168. protected function getDomainStatusPage()
  169. {
  170. try {
  171. $resp = autoRetry(function (&$jar) {
  172. return $this->client->get(self::DOMAIN_STATUS_URL, [
  173. 'headers' => [
  174. 'Referer' => 'https://my.freenom.com/clientarea.php'
  175. ],
  176. 'cookies' => $jar
  177. ]);
  178. }, $this->maxRequestRetryCount, [&$this->jar]);
  179. $page = (string)$resp->getBody();
  180. } catch (\Exception $e) {
  181. throw new LlfException(34520013, $e->getMessage());
  182. }
  183. if (!preg_match(self::LOGIN_STATUS_REGEX, $page)) {
  184. throw new LlfException(34520009);
  185. }
  186. return $page;
  187. }
  188. /**
  189. * 续期所有域名
  190. *
  191. * @param array $allDomains
  192. * @param string $token
  193. *
  194. * @return bool
  195. */
  196. public function renewAllDomains(array $allDomains, string $token)
  197. {
  198. $renewalSuccessArr = [];
  199. $renewalFailuresArr = [];
  200. $domainStatusArr = [];
  201. foreach ($allDomains as $d) {
  202. $domain = $d['domain'];
  203. $days = (int)$d['days'];
  204. $id = $d['id'];
  205. // 免费域名只允许在到期前 14 天内续期
  206. if ($days <= 14) {
  207. $renewalResult = $this->renew($id, $token);
  208. sleep(1);
  209. if ($renewalResult) {
  210. $renewalSuccessArr[] = $domain;
  211. continue; // 续期成功的域名无需记录过期天数
  212. } else {
  213. $renewalFailuresArr[] = $domain;
  214. }
  215. }
  216. // 记录域名过期天数
  217. $domainStatusArr[$domain] = $days;
  218. }
  219. // 存在续期操作
  220. if ($renewalSuccessArr || $renewalFailuresArr) {
  221. $data = [
  222. 'username' => $this->username,
  223. 'renewalSuccessArr' => $renewalSuccessArr,
  224. 'renewalFailuresArr' => $renewalFailuresArr,
  225. 'domainStatusArr' => $domainStatusArr,
  226. ];
  227. $result = Message::send('', lang('100039'), 2, $data);
  228. system_log(sprintf(
  229. lang('100040'),
  230. count($renewalSuccessArr),
  231. count($renewalFailuresArr),
  232. $result ? lang('100041') : ''
  233. ));
  234. Log::info(sprintf(lang('100042'), $this->username), $data);
  235. return true;
  236. }
  237. // 不存在续期操作
  238. if (config('notice_freq') === 1) {
  239. $data = [
  240. 'username' => $this->username,
  241. 'domainStatusArr' => $domainStatusArr,
  242. ];
  243. Message::send('', lang('100043'), 3, $data);
  244. } else {
  245. system_log(lang('100044'));
  246. }
  247. system_log(sprintf(lang('100045'), $this->username));
  248. return true;
  249. }
  250. /**
  251. * 续期单个域名
  252. *
  253. * @param int $id
  254. * @param string $token
  255. *
  256. * @return bool
  257. */
  258. protected function renew(int $id, string $token)
  259. {
  260. try {
  261. $resp = autoRetry(function ($token, $id, &$jar) {
  262. return $this->client->post(self::RENEW_DOMAIN_URL, [
  263. 'headers' => [
  264. 'Referer' => sprintf('https://my.freenom.com/domains.php?a=renewdomain&domain=%s', $id),
  265. 'Content-Type' => 'application/x-www-form-urlencoded'
  266. ],
  267. 'form_params' => [
  268. 'token' => $token,
  269. 'renewalid' => $id,
  270. sprintf('renewalperiod[%s]', $id) => '12M', // 续期一年
  271. 'paymentmethod' => 'credit', // 支付方式:信用卡
  272. ],
  273. 'cookies' => $jar
  274. ]);
  275. }, $this->maxRequestRetryCount, [$token, $id, &$this->jar]);
  276. $resp = (string)$resp->getBody();
  277. return stripos($resp, 'Order Confirmation') !== false;
  278. } catch (\Exception $e) {
  279. $errorMsg = sprintf(lang('100046'), $e->getMessage(), $id, $this->username);
  280. system_log($errorMsg);
  281. Message::send($errorMsg);
  282. return false;
  283. }
  284. }
  285. /**
  286. * 二维数组去重
  287. *
  288. * @param array $array 原始数组
  289. * @param array $keys 可指定对应的键联合
  290. *
  291. * @return bool
  292. */
  293. public function arrayUnique(array &$array, array $keys = [])
  294. {
  295. if (!isset($array[0]) || !is_array($array[0])) {
  296. return false;
  297. }
  298. if (empty($keys)) {
  299. $keys = array_keys($array[0]);
  300. }
  301. $tmp = [];
  302. foreach ($array as $k => $items) {
  303. $combinedValues = [];
  304. foreach ($keys as $key) {
  305. $combinedValues[$key] = $items[$key] ?? null;
  306. }
  307. $combinedKey = json_encode($combinedValues, JSON_UNESCAPED_UNICODE);
  308. if (isset($tmp[$combinedKey])) {
  309. unset($array[$k]);
  310. } else {
  311. $tmp[$combinedKey] = $k;
  312. }
  313. }
  314. unset($tmp);
  315. return true;
  316. }
  317. /**
  318. * 获取 FreeNom 账户信息
  319. *
  320. * @return array
  321. * @throws LlfException
  322. */
  323. protected function getAccounts()
  324. {
  325. $accounts = [];
  326. $multipleAccounts = preg_replace('/\s/', '', env('MULTIPLE_ACCOUNTS'));
  327. if (preg_match_all('/<(?P<u>.*?)>@<(?P<p>.*?)>/i', $multipleAccounts, $matches, PREG_SET_ORDER)) {
  328. foreach ($matches as $m) {
  329. $accounts[] = [
  330. 'username' => $m['u'],
  331. 'password' => $m['p']
  332. ];
  333. }
  334. }
  335. $username = env('FREENOM_USERNAME');
  336. $password = env('FREENOM_PASSWORD');
  337. if ($username && $password) {
  338. $accounts[] = [
  339. 'username' => $username,
  340. 'password' => $password
  341. ];
  342. }
  343. if (empty($accounts)) {
  344. throw new LlfException(34520001);
  345. }
  346. // 去重
  347. $this->arrayUnique($accounts);
  348. return $accounts;
  349. }
  350. /**
  351. * 发送异常报告
  352. *
  353. * @param \Throwable $e
  354. */
  355. private function sendExceptionReport($e)
  356. {
  357. Message::send(sprintf(
  358. lang('100047'),
  359. $e->getFile(),
  360. $e->getLine(),
  361. $e->getMessage(),
  362. $this->username
  363. ), lang('100048') . $e->getMessage());
  364. }
  365. /**
  366. * @throws LlfException
  367. * @throws \Exception
  368. */
  369. public function handle()
  370. {
  371. $accounts = $this->getAccounts();
  372. $totalAccounts = count($accounts);
  373. system_log(sprintf(lang('100049'), $totalAccounts));
  374. foreach ($accounts as $index => $account) {
  375. try {
  376. $this->username = $account['username'];
  377. $this->password = $account['password'];
  378. $num = $index + 1;
  379. system_log(sprintf(lang('100050'), get_local_num($num), $this->username, $num, $totalAccounts));
  380. $this->jar = new CookieJar(); // 所有请求共用一个 CookieJar 实例
  381. if (needAwsWafToken()) {
  382. $awsWafToken = getAwsWafToken();
  383. $this->jar->setCookie(buildAwsWafCookie($awsWafToken));
  384. } else {
  385. system_log(lang('100140'));
  386. }
  387. $this->login($this->username, $this->password);
  388. $domainStatusPage = $this->getDomainStatusPage();
  389. $allDomains = $this->getAllDomains($domainStatusPage);
  390. $token = $this->getToken($domainStatusPage);
  391. $this->renewAllDomains($allDomains, $token);
  392. } catch (WarningException $e) {
  393. system_log(sprintf(lang('100129'), $e->getMessage()));
  394. } catch (LlfException $e) {
  395. system_log(sprintf(lang('100051'), $e->getMessage()));
  396. $this->sendExceptionReport($e);
  397. } catch (\Throwable $e) {
  398. system_log(sprintf(lang('100052'), $e->getMessage()), $e->getTrace());
  399. $this->sendExceptionReport($e);
  400. }
  401. }
  402. }
  403. }