FreeNom.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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 GuzzleHttp\Client;
  13. use GuzzleHttp\Cookie\CookieJar;
  14. use Luolongfei\Libs\Log;
  15. use Luolongfei\Libs\Message;
  16. class FreeNom extends Base
  17. {
  18. const VERSION = 'v0.4.4';
  19. const TIMEOUT = 33;
  20. // FreeNom登录地址
  21. const LOGIN_URL = 'https://my.freenom.com/dologin.php';
  22. // 域名状态地址
  23. const DOMAIN_STATUS_URL = 'https://my.freenom.com/domains.php?a=renewals';
  24. // 域名续期地址
  25. const RENEW_DOMAIN_URL = 'https://my.freenom.com/domains.php?submitrenewals=true';
  26. // 匹配token的正则
  27. const TOKEN_REGEX = '/name="token"\svalue="(?P<token>[^"]+)"/i';
  28. // 匹配域名信息的正则
  29. const DOMAIN_INFO_REGEX = '/<tr><td>(?P<domain>[^<]+)<\/td><td>[^<]+<\/td><td>[^<]+<span class="[^"]+">(?P<days>\d+)[^&]+&domain=(?P<id>\d+)"/i';
  30. // 匹配登录状态的正则
  31. const LOGIN_STATUS_REGEX = '/<li.*?Logout.*?<\/li>/i';
  32. /**
  33. * @var Client
  34. */
  35. protected $client;
  36. /**
  37. * @var CookieJar | bool
  38. */
  39. protected $jar = true;
  40. /**
  41. * @var string FreeNom 账户
  42. */
  43. protected $username;
  44. /**
  45. * @var string FreeNom 密码
  46. */
  47. protected $password;
  48. /**
  49. * @var FreeNom
  50. */
  51. private static $instance;
  52. /**
  53. * @return FreeNom
  54. */
  55. public static function getInstance()
  56. {
  57. if (!self::$instance instanceof self) {
  58. self::$instance = new self();
  59. }
  60. return self::$instance;
  61. }
  62. private function __construct()
  63. {
  64. $this->client = new Client([
  65. 'headers' => [
  66. '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',
  67. 'Accept-Encoding' => 'gzip, deflate, br',
  68. 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36',
  69. ],
  70. 'timeout' => self::TIMEOUT,
  71. CURLOPT_FOLLOWLOCATION => true,
  72. CURLOPT_AUTOREFERER => true,
  73. 'verify' => config('verify_ssl'),
  74. 'debug' => config('debug'),
  75. 'proxy' => config('freenom_proxy'),
  76. ]);
  77. system_log(sprintf('当前程序版本 %s', self::VERSION));
  78. }
  79. private function __clone()
  80. {
  81. }
  82. /**
  83. * 登录
  84. *
  85. * @param string $username
  86. * @param string $password
  87. *
  88. * @return bool
  89. * @throws LlfException
  90. */
  91. protected function login(string $username, string $password)
  92. {
  93. try {
  94. $this->client->post(self::LOGIN_URL, [
  95. 'headers' => [
  96. 'Content-Type' => 'application/x-www-form-urlencoded',
  97. 'Referer' => 'https://my.freenom.com/clientarea.php'
  98. ],
  99. 'form_params' => [
  100. 'username' => $username,
  101. 'password' => $password
  102. ],
  103. 'cookies' => $this->jar
  104. ]);
  105. } catch (\Exception $e) {
  106. throw new LlfException(34520002, $e->getMessage());
  107. }
  108. if (empty($this->jar->getCookieByName('WHMCSZH5eHTGhfvzP')->getValue())) {
  109. throw new LlfException(34520002, lang('error_msg.100001'));
  110. }
  111. return true;
  112. }
  113. /**
  114. * 匹配获取所有域名
  115. *
  116. * @param string $domainStatusPage
  117. *
  118. * @return array
  119. * @throws LlfException
  120. */
  121. protected function getAllDomains(string $domainStatusPage)
  122. {
  123. if (!preg_match_all(self::DOMAIN_INFO_REGEX, $domainStatusPage, $allDomains, PREG_SET_ORDER)) {
  124. throw new LlfException(34520003);
  125. }
  126. return $allDomains;
  127. }
  128. /**
  129. * 获取匹配 token
  130. *
  131. * 据观察,每次登录后此 token 不会改变,故可以只获取一次,多次使用
  132. *
  133. * @param string $domainStatusPage
  134. *
  135. * @return string
  136. * @throws LlfException
  137. */
  138. protected function getToken(string $domainStatusPage)
  139. {
  140. if (!preg_match(self::TOKEN_REGEX, $domainStatusPage, $matches)) {
  141. throw new LlfException(34520004);
  142. }
  143. return $matches['token'];
  144. }
  145. /**
  146. * 获取域名状态页面
  147. *
  148. * @return string
  149. * @throws LlfException
  150. */
  151. protected function getDomainStatusPage()
  152. {
  153. try {
  154. $resp = $this->client->get(self::DOMAIN_STATUS_URL, [
  155. 'headers' => [
  156. 'Referer' => 'https://my.freenom.com/clientarea.php'
  157. ],
  158. 'cookies' => $this->jar
  159. ]);
  160. $page = (string)$resp->getBody();
  161. } catch (\Exception $e) {
  162. throw new LlfException(34520013, $e->getMessage());
  163. }
  164. if (!preg_match(self::LOGIN_STATUS_REGEX, $page)) {
  165. throw new LlfException(34520009);
  166. }
  167. return $page;
  168. }
  169. /**
  170. * 续期所有域名
  171. *
  172. * @param array $allDomains
  173. * @param string $token
  174. *
  175. * @return bool
  176. */
  177. public function renewAllDomains(array $allDomains, string $token)
  178. {
  179. $renewalSuccessArr = [];
  180. $renewalFailuresArr = [];
  181. $domainStatusArr = [];
  182. foreach ($allDomains as $d) {
  183. $domain = $d['domain'];
  184. $days = (int)$d['days'];
  185. $id = $d['id'];
  186. // 免费域名只允许在到期前 14 天内续期
  187. if ($days <= 14) {
  188. $renewalResult = $this->renew($id, $token);
  189. sleep(1);
  190. if ($renewalResult) {
  191. $renewalSuccessArr[] = $domain;
  192. continue; // 续期成功的域名无需记录过期天数
  193. } else {
  194. $renewalFailuresArr[] = $domain;
  195. }
  196. }
  197. // 记录域名过期天数
  198. $domainStatusArr[$domain] = $days;
  199. }
  200. // 存在续期操作
  201. if ($renewalSuccessArr || $renewalFailuresArr) {
  202. $data = [
  203. 'username' => $this->username,
  204. 'renewalSuccessArr' => $renewalSuccessArr,
  205. 'renewalFailuresArr' => $renewalFailuresArr,
  206. 'domainStatusArr' => $domainStatusArr,
  207. ];
  208. $result = Message::send('', '主人,我刚刚帮你续期域名啦~', 2, $data);
  209. system_log(sprintf(
  210. '恭喜,成功续期 <green>%d</green> 个域名,失败 <green>%d</green> 个域名。%s',
  211. count($renewalSuccessArr),
  212. count($renewalFailuresArr),
  213. $result ? '详细的续期结果已送信成功,请注意查收。' : ''
  214. ));
  215. Log::info(sprintf("账户:%s\n续期结果如下:\n", $this->username), $data);
  216. return true;
  217. }
  218. // 不存在续期操作
  219. if (config('notice_freq') === 1) {
  220. $data = [
  221. 'username' => $this->username,
  222. 'domainStatusArr' => $domainStatusArr,
  223. ];
  224. Message::send('', '报告,今天没有域名需要续期', 3, $data);
  225. } else {
  226. system_log('当前通知频率为「仅当有续期操作时」,故本次不会推送通知');
  227. }
  228. system_log(sprintf('%s:<green>执行成功,今次没有需要续期的域名。</green>', $this->username));
  229. return true;
  230. }
  231. /**
  232. * 续期单个域名
  233. *
  234. * @param int $id
  235. * @param string $token
  236. *
  237. * @return bool
  238. */
  239. protected function renew(int $id, string $token)
  240. {
  241. try {
  242. $resp = $this->client->post(self::RENEW_DOMAIN_URL, [
  243. 'headers' => [
  244. 'Referer' => sprintf('https://my.freenom.com/domains.php?a=renewdomain&domain=%s', $id),
  245. 'Content-Type' => 'application/x-www-form-urlencoded'
  246. ],
  247. 'form_params' => [
  248. 'token' => $token,
  249. 'renewalid' => $id,
  250. sprintf('renewalperiod[%s]', $id) => '12M', // 续期一年
  251. 'paymentmethod' => 'credit', // 支付方式:信用卡
  252. ],
  253. 'cookies' => $this->jar
  254. ]);
  255. $resp = (string)$resp->getBody();
  256. return stripos($resp, 'Order Confirmation') !== false;
  257. } catch (\Exception $e) {
  258. $errorMsg = sprintf('续期请求出错:%s,域名 ID:%s(账户:%s)', $e->getMessage(), $id, $this->username);
  259. system_log($errorMsg);
  260. Message::send($errorMsg);
  261. return false;
  262. }
  263. }
  264. /**
  265. * 二维数组去重
  266. *
  267. * @param array $array 原始数组
  268. * @param array $keys 可指定对应的键联合
  269. *
  270. * @return bool
  271. */
  272. public function arrayUnique(array &$array, array $keys = [])
  273. {
  274. if (!isset($array[0]) || !is_array($array[0])) {
  275. return false;
  276. }
  277. if (empty($keys)) {
  278. $keys = array_keys($array[0]);
  279. }
  280. $tmp = [];
  281. foreach ($array as $k => $items) {
  282. $combinedKey = '';
  283. foreach ($keys as $key) {
  284. $combinedKey .= $items[$key];
  285. }
  286. if (isset($tmp[$combinedKey])) {
  287. unset($array[$k]);
  288. } else {
  289. $tmp[$combinedKey] = $k;
  290. }
  291. }
  292. unset($tmp);
  293. return true;
  294. }
  295. /**
  296. * 获取 FreeNom 账户信息
  297. *
  298. * @return array
  299. * @throws LlfException
  300. */
  301. protected function getAccounts()
  302. {
  303. $accounts = [];
  304. $multipleAccounts = preg_replace('/\s/', '', env('MULTIPLE_ACCOUNTS'));
  305. if (preg_match_all('/<(?P<u>.*?)>@<(?P<p>.*?)>/i', $multipleAccounts, $matches, PREG_SET_ORDER)) {
  306. foreach ($matches as $m) {
  307. $accounts[] = [
  308. 'username' => $m['u'],
  309. 'password' => $m['p']
  310. ];
  311. }
  312. }
  313. $username = env('FREENOM_USERNAME');
  314. $password = env('FREENOM_PASSWORD');
  315. if ($username && $password) {
  316. $accounts[] = [
  317. 'username' => $username,
  318. 'password' => $password
  319. ];
  320. }
  321. if (empty($accounts)) {
  322. throw new LlfException(34520001);
  323. }
  324. // 去重
  325. $this->arrayUnique($accounts);
  326. return $accounts;
  327. }
  328. /**
  329. * 发送异常报告
  330. *
  331. * @param $e \Exception|LlfException
  332. */
  333. private function sendExceptionReport($e)
  334. {
  335. Message::send(sprintf(
  336. '具体是在%s文件的第%d行,抛出了一个异常。异常的内容是%s,快去看看吧。(账户:%s)',
  337. $e->getFile(),
  338. $e->getLine(),
  339. $e->getMessage(),
  340. $this->username
  341. ), '主人,出错了,' . $e->getMessage());
  342. }
  343. /**
  344. * @throws LlfException
  345. * @throws \Exception
  346. */
  347. public function handle()
  348. {
  349. $accounts = $this->getAccounts();
  350. system_log(sprintf('共发现 <green>%d</green> 个 freenom 账户,处理中', count($accounts)));
  351. foreach ($accounts as $account) {
  352. try {
  353. $this->username = $account['username'];
  354. $this->password = $account['password'];
  355. $this->jar = new CookieJar(); // 所有请求共用一个 CookieJar 实例
  356. $this->login($this->username, $this->password);
  357. $domainStatusPage = $this->getDomainStatusPage();
  358. $allDomains = $this->getAllDomains($domainStatusPage);
  359. $token = $this->getToken($domainStatusPage);
  360. $this->renewAllDomains($allDomains, $token);
  361. } catch (LlfException $e) {
  362. system_log(sprintf('出错:<red>%s</red>', $e->getMessage()));
  363. $this->sendExceptionReport($e);
  364. } catch (\Exception $e) {
  365. system_log(sprintf('出错:<red>%s</red>', $e->getMessage()), $e->getTrace());
  366. $this->sendExceptionReport($e);
  367. }
  368. }
  369. }
  370. }