FreeNom.php 12 KB

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