luolongfei 1 年之前
父節點
當前提交
fe8af8325f
共有 5 個文件被更改,包括 660 次插入642 次删除
  1. 74 62
      CHANGELOG.md
  2. 6 5
      README.md
  3. 466 466
      app/Console/FreeNom.php
  4. 5 0
      app/Console/MigrateEnvFile.php
  5. 109 109
      config.php

+ 74 - 62
CHANGELOG.md

@@ -1,63 +1,75 @@
-### 📰 所有更新日志
-
-此处包含了自脚本发布以来的所有更新日志。以前的日志只记录了比较大的变更,以后的日志会尽可能详尽一些。
-
-#### [v0.5.1](https://github.com/luolongfei/freenom/releases/tag/v0.5.1) - 2022-08-29
-
-- 支持一键部署至 Koyeb、Heroku 等平台,虽然 Heroku 马上要收费了,但 Koyeb 依然免费
-- 优化在各种环境下的目录读写权限判断
-- 支持给日志或者命令行输出内容中的敏感信息打马赛克,默认不启用
-
-#### [v0.5](https://github.com/luolongfei/freenom/releases/tag/v0.5) - 2022-05-15
-
-- 增加支持 华为云函数、Railway 等部署方式
-- 支持在消息中显示服务器信息,该功能默认关闭
-- 优化部分代码逻辑
-
-#### [v0.4.5](https://github.com/luolongfei/freenom/releases/tag/v0.4.5) - 2022-02-26
-
-- 支持多语言,中英文切换
-- 支持自建 Telegram 反代地址 [@Mattraks](https://github.com/Mattraks)
-- 更新各种依赖库,PHP 版本最低要求不低于 7.3
-
-#### [v0.4.4](https://github.com/luolongfei/freenom/releases/tag/v0.4.4) - 2021-12-14
-
-- 改进与 Cron 表达式验证相关的正则,兼容各种花里胡哨的表达式
-- 支持自动从 Bark url 中提取有效的 Bark key
-- 支持通过 阿里云函数 部署
-
-#### [v0.4.3](https://github.com/luolongfei/freenom/releases/tag/v0.4.3) - 2021-11-07
-
-- 增加了 企业微信 / Server 酱 / Bark 等送信方式
-- Telegram Bot 支持使用代理,应对国内网络环境问题
-- Freenom 账户支持使用代理,应对国内网络环境问题
-- 支持检测新版,有新版本可用时能第一时间收到通知
-- 支持自动热更新 .env 文件内容,免去每次更新后手动复制配置的繁琐步骤
-- 重构了核心续期代码
-- 重构了送信模块
-- 简化 .env 文件中的配置项
-
-#### [v0.3](https://github.com/luolongfei/freenom/releases/tag/v0.3) - 2021-05-27
-
-- 追加 Docker 版本,支持通过 Docker 方式部署,简化部署流程
-
-#### [v0.2.5](#) - 2020-06-23
-
-- 支持在 Github Actions 上执行(应 GitHub 官方要求,已移除此功能)
-
-#### [v0.2.2](#) - 2020-02-06
-
-- 新增通过 Telegram bot 送信
-- 各种送信方式支持单独开关
-
-#### [v0.2](#) - 2020-02-01
-
-- 支持多个 Freenom 账户进行域名续期
-- 进行了彻底的重构,框架化
-- 优化邮箱模块,支持自动选择合适的邮箱配置
-
-*(版本在 v0.1 到 v0.2 期间代码有过很多次变更,之前没有发布版本,故此处不再赘述相关变更日志)*
-
-#### [v0.1](#) - 2018-8-13
-
+### 📰 所有更新日志
+
+此处包含了自脚本发布以来的所有更新日志。以前的日志只记录了比较大的变更,以后的日志会尽可能详尽一些。
+
+#### [v0.5.4](https://github.com/luolongfei/freenom/releases/tag/v0.5.4) - 2023-12-13
+
+- 将重试次数默认强制改为 200 次
+
+#### [v0.5.3](https://github.com/luolongfei/freenom/releases/tag/v0.5.3) - 2023-10-16
+
+- 增加重试机制,应对 freenom 人机验证
+
+#### [v0.5.2](https://github.com/luolongfei/freenom/releases/tag/v0.5.2)
+
+- 用于共有 freenom 变动,无实质修改
+
+#### [v0.5.1](https://github.com/luolongfei/freenom/releases/tag/v0.5.1) - 2022-08-29
+
+- 支持一键部署至 Koyeb、Heroku 等平台,虽然 Heroku 马上要收费了,但 Koyeb 依然免费
+- 优化在各种环境下的目录读写权限判断
+- 支持给日志或者命令行输出内容中的敏感信息打马赛克,默认不启用
+
+#### [v0.5](https://github.com/luolongfei/freenom/releases/tag/v0.5) - 2022-05-15
+
+- 增加支持 华为云函数、Railway 等部署方式
+- 支持在消息中显示服务器信息,该功能默认关闭
+- 优化部分代码逻辑
+
+#### [v0.4.5](https://github.com/luolongfei/freenom/releases/tag/v0.4.5) - 2022-02-26
+
+- 支持多语言,中英文切换
+- 支持自建 Telegram 反代地址 [@Mattraks](https://github.com/Mattraks)
+- 更新各种依赖库,PHP 版本最低要求不低于 7.3
+
+#### [v0.4.4](https://github.com/luolongfei/freenom/releases/tag/v0.4.4) - 2021-12-14
+
+- 改进与 Cron 表达式验证相关的正则,兼容各种花里胡哨的表达式
+- 支持自动从 Bark url 中提取有效的 Bark key
+- 支持通过 阿里云函数 部署
+
+#### [v0.4.3](https://github.com/luolongfei/freenom/releases/tag/v0.4.3) - 2021-11-07
+
+- 增加了 企业微信 / Server 酱 / Bark 等送信方式
+- Telegram Bot 支持使用代理,应对国内网络环境问题
+- Freenom 账户支持使用代理,应对国内网络环境问题
+- 支持检测新版,有新版本可用时能第一时间收到通知
+- 支持自动热更新 .env 文件内容,免去每次更新后手动复制配置的繁琐步骤
+- 重构了核心续期代码
+- 重构了送信模块
+- 简化 .env 文件中的配置项
+
+#### [v0.3](https://github.com/luolongfei/freenom/releases/tag/v0.3) - 2021-05-27
+
+- 追加 Docker 版本,支持通过 Docker 方式部署,简化部署流程
+
+#### [v0.2.5](#) - 2020-06-23
+
+- 支持在 Github Actions 上执行(应 GitHub 官方要求,已移除此功能)
+
+#### [v0.2.2](#) - 2020-02-06
+
+- 新增通过 Telegram bot 送信
+- 各种送信方式支持单独开关
+
+#### [v0.2](#) - 2020-02-01
+
+- 支持多个 Freenom 账户进行域名续期
+- 进行了彻底的重构,框架化
+- 优化邮箱模块,支持自动选择合适的邮箱配置
+
+*(版本在 v0.1 到 v0.2 期间代码有过很多次变更,之前没有发布版本,故此处不再赘述相关变更日志)*
+
+#### [v0.1](#) - 2018-8-13
+
 - 初版,开源,基础的续期功能

+ 6 - 5
README.md

@@ -15,7 +15,10 @@ Documentation: [English version](https://github.com/luolongfei/freenom/blob/main
 **Freenom 已经加上了 AWS WAF CAPTCHA 用于各个页面的验证,目前脚本追加了重试机制,可在 `.env` 中自行修改 `MAX_REQUEST_RETRY_COUNT`的值以配置最大重试次数,默认最多重试 32 次,每次至少休眠 20 秒,第 5 次后每次休眠时间根据重试次数递增。根据群友反馈,建议大家把最多重试次数设为 200,可极大增加成功率。更多消息可在热心网友的电报群内交流。**
 [https://t.me/freenom_auto_renew](https://t.me/freenom_auto_renew)
 
-如果你想要一台性价比高的 vps,年付 10 多刀,可以参考(含 Aff):[https://go.llfapp.com/cc](https://go.llfapp.com/cc)
+如果你需要一台高性价比的服务器,可以参考 [美国便宜 VPS](https://go.llfapp.com/cc)
+这台 VPS 解锁奈飞迪斯尼:
+
+<a href="https://go.llfapp.com/cc"><img src="images.llfapp.com/cc.png" alt="cc.png" border="0" width="240px" height="300px" /></a>
 
 [📢 公告](#-公告)
 
@@ -542,11 +545,9 @@ PayPal: [https://www.paypal.me/mybsdc](https://www.paypal.me/mybsdc)
 
 - 解决 企业微信 因送信内容过长被截断问题
 
-#### [v0.5.1](https://github.com/luolongfei/freenom/releases/tag/v0.5.1) - 2022-08-29
+#### [v0.5.4](https://github.com/luolongfei/freenom/releases/tag/v0.5.4) - 2023-12-13
 
-- 支持一键部署至 Koyeb、Heroku 等平台,虽然 Heroku 马上要收费了,但 Koyeb 依然免费
-- 优化在各种环境下的目录读写权限判断
-- 支持给日志或者命令行输出内容中的敏感信息打马赛克,默认不启用
+- 重试次数默认改为 200 次
 
 ### 🍅 本项目的其它语言实现
 

+ 466 - 466
app/Console/FreeNom.php

@@ -1,466 +1,466 @@
-<?php
-/**
- * FreeNom域名自动续期
- *
- * @author mybsdc <[email protected]>
- * @date 2020/1/19
- * @time 17:29
- * @link https://github.com/luolongfei/freenom
- */
-
-namespace Luolongfei\App\Console;
-
-use Luolongfei\App\Exceptions\LlfException;
-use Luolongfei\App\Exceptions\WarningException;
-use GuzzleHttp\Client;
-use GuzzleHttp\Cookie\CookieJar;
-use Luolongfei\Libs\Log;
-use Luolongfei\Libs\Message;
-
-class FreeNom extends Base
-{
-    const VERSION = 'v0.5.3';
-
-    const TIMEOUT = 33;
-
-    // FreeNom登录地址
-    const LOGIN_URL = 'https://my.freenom.com/dologin.php';
-
-    // 域名状态地址
-    const DOMAIN_STATUS_URL = 'https://my.freenom.com/domains.php?a=renewals';
-
-    // 域名续期地址
-    const RENEW_DOMAIN_URL = 'https://my.freenom.com/domains.php?submitrenewals=true';
-
-    // 匹配token的正则
-    const TOKEN_REGEX = '/name="token"\svalue="(?P<token>[^"]+)"/i';
-
-    // 匹配域名信息的正则
-    const DOMAIN_INFO_REGEX = '/<tr><td>(?P<domain>[^<]+)<\/td><td>[^<]+<\/td><td>[^<]+<span class="[^"]+">(?P<days>\d+)[^&]+&domain=(?P<id>\d+)"/i';
-
-    // 匹配登录状态的正则
-    const LOGIN_STATUS_REGEX = '/<li.*?Logout.*?<\/li>/i';
-
-    // 匹配无域名的正则
-    const NO_DOMAIN_REGEX = '/<tr\sclass="carttablerow"><td\scolspan="5">(?P<msg>[^<]+)<\/td><\/tr>/i';
-
-    /**
-     * @var Client
-     */
-    protected $client;
-
-    /**
-     * @var CookieJar | bool
-     */
-    protected $jar = true;
-
-    /**
-     * @var string FreeNom 账户
-     */
-    protected $username;
-
-    /**
-     * @var string FreeNom 密码
-     */
-    protected $password;
-
-    /**
-     * @var FreeNom
-     */
-    private static $instance;
-
-    /**
-     * @var int 最大请求重试次数
-     */
-    public $maxRequestRetryCount;
-
-    /**
-     * @return FreeNom
-     */
-    public static function getInstance()
-    {
-        if (!self::$instance instanceof self) {
-            self::$instance = new self();
-        }
-
-        return self::$instance;
-    }
-
-    private function __construct()
-    {
-        $this->client = new Client([
-            'headers' => [
-                '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',
-                'Accept-Encoding' => 'gzip, deflate, br',
-                '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()),
-            ],
-            'timeout' => self::TIMEOUT,
-            CURLOPT_FOLLOWLOCATION => true,
-            CURLOPT_AUTOREFERER => true,
-            'verify' => config('verify_ssl'),
-            'debug' => config('debug'),
-            'proxy' => config('freenom_proxy'),
-        ]);
-
-        $this->maxRequestRetryCount = config('max_request_retry_count');
-
-        system_log(sprintf(lang('100038'), self::VERSION));
-    }
-
-    private function __clone()
-    {
-    }
-
-    /**
-     * 登录
-     *
-     * @param string $username
-     * @param string $password
-     *
-     * @return bool
-     * @throws LlfException
-     */
-    protected function login(string $username, string $password)
-    {
-        try {
-            autoRetry(function ($username, $password, &$jar) {
-                return $this->client->post(self::LOGIN_URL, [
-                    'headers' => [
-                        'Content-Type' => 'application/x-www-form-urlencoded',
-                        'Referer' => 'https://my.freenom.com/clientarea.php'
-                    ],
-                    'form_params' => [
-                        'username' => $username,
-                        'password' => $password
-                    ],
-                    'cookies' => $jar
-                ]);
-            }, $this->maxRequestRetryCount, [$username, $password, &$this->jar]);
-        } catch (\Exception $e) {
-            throw new LlfException(34520002, $e->getMessage());
-        }
-
-        if (empty($this->jar->getCookieByName('WHMCSZH5eHTGhfvzP')->getValue())) {
-            throw new LlfException(34520002, lang('100001'));
-        }
-
-        system_log(sprintf(lang('100138'), $username));
-
-        return true;
-    }
-
-    /**
-     * 匹配获取所有域名
-     *
-     * @param string $domainStatusPage
-     *
-     * @return array
-     * @throws LlfException
-     * @throws WarningException
-     */
-    protected function getAllDomains(string $domainStatusPage)
-    {
-        if (preg_match(self::NO_DOMAIN_REGEX, $domainStatusPage, $m)) {
-            throw new WarningException(34520014, [$this->username, $m['msg']]);
-        }
-
-        if (!preg_match_all(self::DOMAIN_INFO_REGEX, $domainStatusPage, $allDomains, PREG_SET_ORDER)) {
-            throw new LlfException(34520003);
-        }
-
-        return $allDomains;
-    }
-
-    /**
-     * 获取匹配 token
-     *
-     * 据观察,每次登录后此 token 不会改变,故可以只获取一次,多次使用
-     *
-     * @param string $domainStatusPage
-     *
-     * @return string
-     * @throws LlfException
-     */
-    protected function getToken(string $domainStatusPage)
-    {
-        if (!preg_match(self::TOKEN_REGEX, $domainStatusPage, $matches)) {
-            throw new LlfException(34520004);
-        }
-
-        return $matches['token'];
-    }
-
-    /**
-     * 获取域名状态页面
-     *
-     * @return string
-     * @throws LlfException
-     */
-    protected function getDomainStatusPage()
-    {
-        try {
-            $resp = autoRetry(function (&$jar) {
-                return $this->client->get(self::DOMAIN_STATUS_URL, [
-                    'headers' => [
-                        'Referer' => 'https://my.freenom.com/clientarea.php'
-                    ],
-                    'cookies' => $jar
-                ]);
-            }, $this->maxRequestRetryCount, [&$this->jar]);
-
-            $page = (string)$resp->getBody();
-        } catch (\Exception $e) {
-            throw new LlfException(34520013, $e->getMessage());
-        }
-
-        if (!preg_match(self::LOGIN_STATUS_REGEX, $page)) {
-            throw new LlfException(34520009);
-        }
-
-        return $page;
-    }
-
-    /**
-     * 续期所有域名
-     *
-     * @param array $allDomains
-     * @param string $token
-     *
-     * @return bool
-     */
-    public function renewAllDomains(array $allDomains, string $token)
-    {
-        $renewalSuccessArr = [];
-        $renewalFailuresArr = [];
-        $domainStatusArr = [];
-
-        foreach ($allDomains as $d) {
-            $domain = $d['domain'];
-            $days = (int)$d['days'];
-            $id = $d['id'];
-
-            // 免费域名只允许在到期前 14 天内续期
-            if ($days <= 14) {
-                $renewalResult = $this->renew($id, $token);
-
-                sleep(1);
-
-                if ($renewalResult) {
-                    $renewalSuccessArr[] = $domain;
-
-                    continue; // 续期成功的域名无需记录过期天数
-                } else {
-                    $renewalFailuresArr[] = $domain;
-                }
-            }
-
-            // 记录域名过期天数
-            $domainStatusArr[$domain] = $days;
-        }
-
-        // 存在续期操作
-        if ($renewalSuccessArr || $renewalFailuresArr) {
-            $data = [
-                'username' => $this->username,
-                'renewalSuccessArr' => $renewalSuccessArr,
-                'renewalFailuresArr' => $renewalFailuresArr,
-                'domainStatusArr' => $domainStatusArr,
-            ];
-            $result = Message::send('', lang('100039'), 2, $data);
-
-            system_log(sprintf(
-                lang('100040'),
-                count($renewalSuccessArr),
-                count($renewalFailuresArr),
-                $result ? lang('100041') : ''
-            ));
-
-            Log::info(sprintf(lang('100042'), $this->username), $data);
-
-            return true;
-        }
-
-        // 不存在续期操作
-        if (config('notice_freq') === 1) {
-            $data = [
-                'username' => $this->username,
-                'domainStatusArr' => $domainStatusArr,
-            ];
-            Message::send('', lang('100043'), 3, $data);
-        } else {
-            system_log(lang('100044'));
-        }
-
-        system_log(sprintf(lang('100045'), $this->username));
-
-        return true;
-    }
-
-    /**
-     * 续期单个域名
-     *
-     * @param int $id
-     * @param string $token
-     *
-     * @return bool
-     */
-    protected function renew(int $id, string $token)
-    {
-        try {
-            $resp = autoRetry(function ($token, $id, &$jar) {
-                return $this->client->post(self::RENEW_DOMAIN_URL, [
-                    'headers' => [
-                        'Referer' => sprintf('https://my.freenom.com/domains.php?a=renewdomain&domain=%s', $id),
-                        'Content-Type' => 'application/x-www-form-urlencoded'
-                    ],
-                    'form_params' => [
-                        'token' => $token,
-                        'renewalid' => $id,
-                        sprintf('renewalperiod[%s]', $id) => '12M', // 续期一年
-                        'paymentmethod' => 'credit', // 支付方式:信用卡
-                    ],
-                    'cookies' => $jar
-                ]);
-            }, $this->maxRequestRetryCount, [$token, $id, &$this->jar]);
-
-            $resp = (string)$resp->getBody();
-
-            return stripos($resp, 'Order Confirmation') !== false;
-        } catch (\Exception $e) {
-            $errorMsg = sprintf(lang('100046'), $e->getMessage(), $id, $this->username);
-            system_log($errorMsg);
-            Message::send($errorMsg);
-
-            return false;
-        }
-    }
-
-    /**
-     * 二维数组去重
-     *
-     * @param array $array 原始数组
-     * @param array $keys 可指定对应的键联合
-     *
-     * @return bool
-     */
-    public function arrayUnique(array &$array, array $keys = [])
-    {
-        if (!isset($array[0]) || !is_array($array[0])) {
-            return false;
-        }
-
-        if (empty($keys)) {
-            $keys = array_keys($array[0]);
-        }
-
-        $tmp = [];
-        foreach ($array as $k => $items) {
-            $combinedKey = '';
-            foreach ($keys as $key) {
-                $combinedKey .= $items[$key];
-            }
-
-            if (isset($tmp[$combinedKey])) {
-                unset($array[$k]);
-            } else {
-                $tmp[$combinedKey] = $k;
-            }
-        }
-        unset($tmp);
-
-        return true;
-    }
-
-    /**
-     * 获取 FreeNom 账户信息
-     *
-     * @return array
-     * @throws LlfException
-     */
-    protected function getAccounts()
-    {
-        $accounts = [];
-        $multipleAccounts = preg_replace('/\s/', '', env('MULTIPLE_ACCOUNTS'));
-        if (preg_match_all('/<(?P<u>.*?)>@<(?P<p>.*?)>/i', $multipleAccounts, $matches, PREG_SET_ORDER)) {
-            foreach ($matches as $m) {
-                $accounts[] = [
-                    'username' => $m['u'],
-                    'password' => $m['p']
-                ];
-            }
-        }
-
-        $username = env('FREENOM_USERNAME');
-        $password = env('FREENOM_PASSWORD');
-        if ($username && $password) {
-            $accounts[] = [
-                'username' => $username,
-                'password' => $password
-            ];
-        }
-
-        if (empty($accounts)) {
-            throw new LlfException(34520001);
-        }
-
-        // 去重
-        $this->arrayUnique($accounts);
-
-        return $accounts;
-    }
-
-    /**
-     * 发送异常报告
-     *
-     * @param $e \Exception|LlfException
-     */
-    private function sendExceptionReport($e)
-    {
-        Message::send(sprintf(
-            lang('100047'),
-            $e->getFile(),
-            $e->getLine(),
-            $e->getMessage(),
-            $this->username
-        ), lang('100048') . $e->getMessage());
-    }
-
-    /**
-     * @throws LlfException
-     * @throws \Exception
-     */
-    public function handle()
-    {
-        $accounts = $this->getAccounts();
-        $totalAccounts = count($accounts);
-
-        system_log(sprintf(lang('100049'), $totalAccounts));
-
-        foreach ($accounts as $index => $account) {
-            try {
-                $this->username = $account['username'];
-                $this->password = $account['password'];
-
-                $num = $index + 1;
-                system_log(sprintf(lang('100050'), get_local_num($num), $this->username, $num, $totalAccounts));
-
-                $this->jar = new CookieJar(); // 所有请求共用一个 CookieJar 实例
-                $this->login($this->username, $this->password);
-
-                $domainStatusPage = $this->getDomainStatusPage();
-                $allDomains = $this->getAllDomains($domainStatusPage);
-                $token = $this->getToken($domainStatusPage);
-
-                $this->renewAllDomains($allDomains, $token);
-            } catch (WarningException $e) {
-                system_log(sprintf(lang('100129'), $e->getMessage()));
-            } catch (LlfException $e) {
-                system_log(sprintf(lang('100051'), $e->getMessage()));
-                $this->sendExceptionReport($e);
-            } catch (\Exception $e) {
-                system_log(sprintf(lang('100052'), $e->getMessage()), $e->getTrace());
-                $this->sendExceptionReport($e);
-            }
-        }
-    }
-}
+<?php
+/**
+ * FreeNom域名自动续期
+ *
+ * @author mybsdc <[email protected]>
+ * @date 2020/1/19
+ * @time 17:29
+ * @link https://github.com/luolongfei/freenom
+ */
+
+namespace Luolongfei\App\Console;
+
+use Luolongfei\App\Exceptions\LlfException;
+use Luolongfei\App\Exceptions\WarningException;
+use GuzzleHttp\Client;
+use GuzzleHttp\Cookie\CookieJar;
+use Luolongfei\Libs\Log;
+use Luolongfei\Libs\Message;
+
+class FreeNom extends Base
+{
+    const VERSION = 'v0.5.4';
+
+    const TIMEOUT = 33;
+
+    // FreeNom登录地址
+    const LOGIN_URL = 'https://my.freenom.com/dologin.php';
+
+    // 域名状态地址
+    const DOMAIN_STATUS_URL = 'https://my.freenom.com/domains.php?a=renewals';
+
+    // 域名续期地址
+    const RENEW_DOMAIN_URL = 'https://my.freenom.com/domains.php?submitrenewals=true';
+
+    // 匹配token的正则
+    const TOKEN_REGEX = '/name="token"\svalue="(?P<token>[^"]+)"/i';
+
+    // 匹配域名信息的正则
+    const DOMAIN_INFO_REGEX = '/<tr><td>(?P<domain>[^<]+)<\/td><td>[^<]+<\/td><td>[^<]+<span class="[^"]+">(?P<days>\d+)[^&]+&domain=(?P<id>\d+)"/i';
+
+    // 匹配登录状态的正则
+    const LOGIN_STATUS_REGEX = '/<li.*?Logout.*?<\/li>/i';
+
+    // 匹配无域名的正则
+    const NO_DOMAIN_REGEX = '/<tr\sclass="carttablerow"><td\scolspan="5">(?P<msg>[^<]+)<\/td><\/tr>/i';
+
+    /**
+     * @var Client
+     */
+    protected $client;
+
+    /**
+     * @var CookieJar | bool
+     */
+    protected $jar = true;
+
+    /**
+     * @var string FreeNom 账户
+     */
+    protected $username;
+
+    /**
+     * @var string FreeNom 密码
+     */
+    protected $password;
+
+    /**
+     * @var FreeNom
+     */
+    private static $instance;
+
+    /**
+     * @var int 最大请求重试次数
+     */
+    public $maxRequestRetryCount;
+
+    /**
+     * @return FreeNom
+     */
+    public static function getInstance()
+    {
+        if (!self::$instance instanceof self) {
+            self::$instance = new self();
+        }
+
+        return self::$instance;
+    }
+
+    private function __construct()
+    {
+        $this->client = new Client([
+            'headers' => [
+                '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',
+                'Accept-Encoding' => 'gzip, deflate, br',
+                '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()),
+            ],
+            'timeout' => self::TIMEOUT,
+            CURLOPT_FOLLOWLOCATION => true,
+            CURLOPT_AUTOREFERER => true,
+            'verify' => config('verify_ssl'),
+            'debug' => config('debug'),
+            'proxy' => config('freenom_proxy'),
+        ]);
+
+        $this->maxRequestRetryCount = config('max_request_retry_count');
+
+        system_log(sprintf(lang('100038'), self::VERSION));
+    }
+
+    private function __clone()
+    {
+    }
+
+    /**
+     * 登录
+     *
+     * @param string $username
+     * @param string $password
+     *
+     * @return bool
+     * @throws LlfException
+     */
+    protected function login(string $username, string $password)
+    {
+        try {
+            autoRetry(function ($username, $password, &$jar) {
+                return $this->client->post(self::LOGIN_URL, [
+                    'headers' => [
+                        'Content-Type' => 'application/x-www-form-urlencoded',
+                        'Referer' => 'https://my.freenom.com/clientarea.php'
+                    ],
+                    'form_params' => [
+                        'username' => $username,
+                        'password' => $password
+                    ],
+                    'cookies' => $jar
+                ]);
+            }, $this->maxRequestRetryCount, [$username, $password, &$this->jar]);
+        } catch (\Exception $e) {
+            throw new LlfException(34520002, $e->getMessage());
+        }
+
+        if (empty($this->jar->getCookieByName('WHMCSZH5eHTGhfvzP')->getValue())) {
+            throw new LlfException(34520002, lang('100001'));
+        }
+
+        system_log(sprintf(lang('100138'), $username));
+
+        return true;
+    }
+
+    /**
+     * 匹配获取所有域名
+     *
+     * @param string $domainStatusPage
+     *
+     * @return array
+     * @throws LlfException
+     * @throws WarningException
+     */
+    protected function getAllDomains(string $domainStatusPage)
+    {
+        if (preg_match(self::NO_DOMAIN_REGEX, $domainStatusPage, $m)) {
+            throw new WarningException(34520014, [$this->username, $m['msg']]);
+        }
+
+        if (!preg_match_all(self::DOMAIN_INFO_REGEX, $domainStatusPage, $allDomains, PREG_SET_ORDER)) {
+            throw new LlfException(34520003);
+        }
+
+        return $allDomains;
+    }
+
+    /**
+     * 获取匹配 token
+     *
+     * 据观察,每次登录后此 token 不会改变,故可以只获取一次,多次使用
+     *
+     * @param string $domainStatusPage
+     *
+     * @return string
+     * @throws LlfException
+     */
+    protected function getToken(string $domainStatusPage)
+    {
+        if (!preg_match(self::TOKEN_REGEX, $domainStatusPage, $matches)) {
+            throw new LlfException(34520004);
+        }
+
+        return $matches['token'];
+    }
+
+    /**
+     * 获取域名状态页面
+     *
+     * @return string
+     * @throws LlfException
+     */
+    protected function getDomainStatusPage()
+    {
+        try {
+            $resp = autoRetry(function (&$jar) {
+                return $this->client->get(self::DOMAIN_STATUS_URL, [
+                    'headers' => [
+                        'Referer' => 'https://my.freenom.com/clientarea.php'
+                    ],
+                    'cookies' => $jar
+                ]);
+            }, $this->maxRequestRetryCount, [&$this->jar]);
+
+            $page = (string)$resp->getBody();
+        } catch (\Exception $e) {
+            throw new LlfException(34520013, $e->getMessage());
+        }
+
+        if (!preg_match(self::LOGIN_STATUS_REGEX, $page)) {
+            throw new LlfException(34520009);
+        }
+
+        return $page;
+    }
+
+    /**
+     * 续期所有域名
+     *
+     * @param array $allDomains
+     * @param string $token
+     *
+     * @return bool
+     */
+    public function renewAllDomains(array $allDomains, string $token)
+    {
+        $renewalSuccessArr = [];
+        $renewalFailuresArr = [];
+        $domainStatusArr = [];
+
+        foreach ($allDomains as $d) {
+            $domain = $d['domain'];
+            $days = (int)$d['days'];
+            $id = $d['id'];
+
+            // 免费域名只允许在到期前 14 天内续期
+            if ($days <= 14) {
+                $renewalResult = $this->renew($id, $token);
+
+                sleep(1);
+
+                if ($renewalResult) {
+                    $renewalSuccessArr[] = $domain;
+
+                    continue; // 续期成功的域名无需记录过期天数
+                } else {
+                    $renewalFailuresArr[] = $domain;
+                }
+            }
+
+            // 记录域名过期天数
+            $domainStatusArr[$domain] = $days;
+        }
+
+        // 存在续期操作
+        if ($renewalSuccessArr || $renewalFailuresArr) {
+            $data = [
+                'username' => $this->username,
+                'renewalSuccessArr' => $renewalSuccessArr,
+                'renewalFailuresArr' => $renewalFailuresArr,
+                'domainStatusArr' => $domainStatusArr,
+            ];
+            $result = Message::send('', lang('100039'), 2, $data);
+
+            system_log(sprintf(
+                lang('100040'),
+                count($renewalSuccessArr),
+                count($renewalFailuresArr),
+                $result ? lang('100041') : ''
+            ));
+
+            Log::info(sprintf(lang('100042'), $this->username), $data);
+
+            return true;
+        }
+
+        // 不存在续期操作
+        if (config('notice_freq') === 1) {
+            $data = [
+                'username' => $this->username,
+                'domainStatusArr' => $domainStatusArr,
+            ];
+            Message::send('', lang('100043'), 3, $data);
+        } else {
+            system_log(lang('100044'));
+        }
+
+        system_log(sprintf(lang('100045'), $this->username));
+
+        return true;
+    }
+
+    /**
+     * 续期单个域名
+     *
+     * @param int $id
+     * @param string $token
+     *
+     * @return bool
+     */
+    protected function renew(int $id, string $token)
+    {
+        try {
+            $resp = autoRetry(function ($token, $id, &$jar) {
+                return $this->client->post(self::RENEW_DOMAIN_URL, [
+                    'headers' => [
+                        'Referer' => sprintf('https://my.freenom.com/domains.php?a=renewdomain&domain=%s', $id),
+                        'Content-Type' => 'application/x-www-form-urlencoded'
+                    ],
+                    'form_params' => [
+                        'token' => $token,
+                        'renewalid' => $id,
+                        sprintf('renewalperiod[%s]', $id) => '12M', // 续期一年
+                        'paymentmethod' => 'credit', // 支付方式:信用卡
+                    ],
+                    'cookies' => $jar
+                ]);
+            }, $this->maxRequestRetryCount, [$token, $id, &$this->jar]);
+
+            $resp = (string)$resp->getBody();
+
+            return stripos($resp, 'Order Confirmation') !== false;
+        } catch (\Exception $e) {
+            $errorMsg = sprintf(lang('100046'), $e->getMessage(), $id, $this->username);
+            system_log($errorMsg);
+            Message::send($errorMsg);
+
+            return false;
+        }
+    }
+
+    /**
+     * 二维数组去重
+     *
+     * @param array $array 原始数组
+     * @param array $keys 可指定对应的键联合
+     *
+     * @return bool
+     */
+    public function arrayUnique(array &$array, array $keys = [])
+    {
+        if (!isset($array[0]) || !is_array($array[0])) {
+            return false;
+        }
+
+        if (empty($keys)) {
+            $keys = array_keys($array[0]);
+        }
+
+        $tmp = [];
+        foreach ($array as $k => $items) {
+            $combinedKey = '';
+            foreach ($keys as $key) {
+                $combinedKey .= $items[$key];
+            }
+
+            if (isset($tmp[$combinedKey])) {
+                unset($array[$k]);
+            } else {
+                $tmp[$combinedKey] = $k;
+            }
+        }
+        unset($tmp);
+
+        return true;
+    }
+
+    /**
+     * 获取 FreeNom 账户信息
+     *
+     * @return array
+     * @throws LlfException
+     */
+    protected function getAccounts()
+    {
+        $accounts = [];
+        $multipleAccounts = preg_replace('/\s/', '', env('MULTIPLE_ACCOUNTS'));
+        if (preg_match_all('/<(?P<u>.*?)>@<(?P<p>.*?)>/i', $multipleAccounts, $matches, PREG_SET_ORDER)) {
+            foreach ($matches as $m) {
+                $accounts[] = [
+                    'username' => $m['u'],
+                    'password' => $m['p']
+                ];
+            }
+        }
+
+        $username = env('FREENOM_USERNAME');
+        $password = env('FREENOM_PASSWORD');
+        if ($username && $password) {
+            $accounts[] = [
+                'username' => $username,
+                'password' => $password
+            ];
+        }
+
+        if (empty($accounts)) {
+            throw new LlfException(34520001);
+        }
+
+        // 去重
+        $this->arrayUnique($accounts);
+
+        return $accounts;
+    }
+
+    /**
+     * 发送异常报告
+     *
+     * @param $e \Exception|LlfException
+     */
+    private function sendExceptionReport($e)
+    {
+        Message::send(sprintf(
+            lang('100047'),
+            $e->getFile(),
+            $e->getLine(),
+            $e->getMessage(),
+            $this->username
+        ), lang('100048') . $e->getMessage());
+    }
+
+    /**
+     * @throws LlfException
+     * @throws \Exception
+     */
+    public function handle()
+    {
+        $accounts = $this->getAccounts();
+        $totalAccounts = count($accounts);
+
+        system_log(sprintf(lang('100049'), $totalAccounts));
+
+        foreach ($accounts as $index => $account) {
+            try {
+                $this->username = $account['username'];
+                $this->password = $account['password'];
+
+                $num = $index + 1;
+                system_log(sprintf(lang('100050'), get_local_num($num), $this->username, $num, $totalAccounts));
+
+                $this->jar = new CookieJar(); // 所有请求共用一个 CookieJar 实例
+                $this->login($this->username, $this->password);
+
+                $domainStatusPage = $this->getDomainStatusPage();
+                $allDomains = $this->getAllDomains($domainStatusPage);
+                $token = $this->getToken($domainStatusPage);
+
+                $this->renewAllDomains($allDomains, $token);
+            } catch (WarningException $e) {
+                system_log(sprintf(lang('100129'), $e->getMessage()));
+            } catch (LlfException $e) {
+                system_log(sprintf(lang('100051'), $e->getMessage()));
+                $this->sendExceptionReport($e);
+            } catch (\Exception $e) {
+                system_log(sprintf(lang('100052'), $e->getMessage()), $e->getTrace());
+                $this->sendExceptionReport($e);
+            }
+        }
+    }
+}

+ 5 - 0
app/Console/MigrateEnvFile.php

@@ -173,6 +173,11 @@ class MigrateEnvFile extends Base
     public function migrateData(array $allEnvVars)
     {
         foreach ($allEnvVars as $envKey => $envVal) {
+            // 强行覆盖原有的最大请求重试次数
+            if ($envKey === 'MAX_REQUEST_RETRY_COUNT') {
+                continue;
+            }
+
             if ($this->setEnv($envKey, $envVal)) {
                 $this->migrateNum++;
             }

+ 109 - 109
config.php

@@ -1,110 +1,110 @@
-<?php
-/**
- * 配置
- *
- * @author mybsdc <[email protected]>
- * @date 2019/3/2
- * @time 11:39
- */
-
-return [
-    'message' => [
-        /**
-         * 邮箱配置
-         */
-        'mail' => [
-            /**
-             * 目前机器人邮箱账户支持谷歌邮箱、QQ邮箱、163邮箱以及Outlook邮箱,程序会自动判断填入的邮箱类型并使用合适的配置。也可以自定义邮箱配置。
-             * 注意,QQ邮箱与163邮箱均使用账户加授权码的方式登录,谷歌邮箱使用账户加密码的方式登录,请知悉。
-             */
-            'to' => env('TO'), // 用于接收通知的邮箱
-            'recipient_name' => '主人', // 收件人名字
-            'username' => env('MAIL_USERNAME'), // 机器人邮箱账户
-            'password' => env('MAIL_PASSWORD'), // 机器人邮箱密码或授权码
-            'enable' => (int)env('MAIL_ENABLE'), // 是否启用,默认启用
-            'not_enabled_tips' => env('MAIL_USERNAME') && env('MAIL_PASSWORD'), // 提醒未启用
-            // 'reply_to' => '[email protected]', // 接收回复的邮箱
-            // 'reply_to_name' => '作者', // 接收回复的人名
-            'host' => env('MAIL_HOST'), // 邮件 SMTP 服务器
-            'port' => env('MAIL_PORT'), // 邮件 SMTP 端口
-            'encryption' => env('MAIL_ENCRYPTION'), // 邮件加密方式
-            'class' => \Luolongfei\Libs\MessageServices\Mail::class,
-            'name' => lang('100064'),
-        ],
-
-        /**
-         * Telegram Bot
-         */
-        'telegram' => [
-            'chat_id' => env('TELEGRAM_CHAT_ID'), // 你的chat_id,通过发送“/start”给@userinfobot可以获取自己的id
-            'token' => env('TELEGRAM_BOT_TOKEN'), // Telegram Bot 的 token
-            'enable' => (int)env('TELEGRAM_BOT_ENABLE'), // 是否启用,默认不启用
-            'not_enabled_tips' => env('TELEGRAM_CHAT_ID') && env('TELEGRAM_BOT_TOKEN'), // 提醒未启用
-            'class' => \Luolongfei\Libs\MessageServices\TelegramBot::class,
-            'name' => lang('100065'),
-            'proxy' => env('TELEGRAM_PROXY') ?: null,
-            'host' => env('CUSTOM_TELEGRAM_HOST') ?: 'api.telegram.org',
-        ],
-
-        /**
-         * 企业微信
-         */
-        'wechat' => [
-            'corp_id' => env('WECHAT_CORP_ID'), // 企业 ID
-            'corp_secret' => env('WECHAT_CORP_SECRET'), // 企业微信应用的凭证密钥
-            'agent_id' => (int)env('WECHAT_AGENT_ID'), // 企业微信应用 ID
-            'user_id' => env('WECHAT_USER_ID'), // 企业微信用户ID
-            'enable' => (int)env('WECHAT_ENABLE'), // 是否启用,默认不启用
-            'not_enabled_tips' => env('WECHAT_CORP_ID') && env('WECHAT_CORP_SECRET') && env('WECHAT_AGENT_ID'), // 提醒未启用
-            'class' => \Luolongfei\Libs\MessageServices\WeChat::class,
-            'name' => lang('100066'),
-        ],
-
-        /**
-         * Server 酱
-         */
-        'sct' => [
-            'sct_send_key' => env('SCT_SEND_KEY'), // SendKey
-            'enable' => (int)env('SCT_ENABLE'), // 是否启用,默认不启用
-            'not_enabled_tips' => (bool)env('SCT_SEND_KEY'), // 提醒未启用
-            'class' => \Luolongfei\Libs\MessageServices\ServerChan::class,
-            'name' => lang('100067'),
-        ],
-
-        /**
-         * Bark 送信
-         */
-        'bark' => [
-            'bark_key' => (string)env('BARK_KEY'), // 打开 Bark App,注册设备后看到的 Key
-            'bark_url' => (string)env('BARK_URL'), // Bark 域名
-            'bark_is_archive' => env('BARK_IS_ARCHIVE') === '' ? null : (int)env('BARK_IS_ARCHIVE'),
-            'bark_group' => env('BARK_GROUP') === '' ? null : env('BARK_GROUP'),
-            'bark_level' => env('BARK_LEVEL'),
-            'bark_icon' => env('BARK_ICON') === '' ? null : env('BARK_ICON'),
-            'bark_jump_url' => env('BARK_JUMP_URL') === '' ? null : env('BARK_JUMP_URL'),
-            'bark_sound' => env('BARK_SOUND') === '' ? null : env('BARK_SOUND'),
-            'enable' => (int)env('BARK_ENABLE'), // 是否启用,默认不启用
-            'not_enabled_tips' => env('BARK_KEY') && env('BARK_URL'), // 提醒未启用
-            'class' => \Luolongfei\Libs\MessageServices\Bark::class,
-            'name' => lang('100068'),
-        ],
-
-        /**
-         * PUSH PLUS
-         */
-        'pushplus' => [
-            'pushplus_key' => env('PUSHPLUS_KEY'), // SendKey
-            'enable' => (int)env('PUSHPLUS_ENABLE'), // 是否启用,默认不启用
-            'not_enabled_tips' => (bool)env('PUSHPLUS_KEY'), // 提醒未启用
-            'class' => \Luolongfei\Libs\MessageServices\Pushplus::class,
-            'name' => lang('100136'),
-        ],
-    ],
-    'custom_language' => env('CUSTOM_LANGUAGE', 'zh'),
-    'notice_freq' => (int)env('NOTICE_FREQ', 1), // 通知频率 0:仅当有续期操作的时候 1:每次执行
-    'verify_ssl' => (bool)env('VERIFY_SSL', 0), // 请求时验证 SSL 证书行为,默认不验证,防止服务器证书过期或证书颁布者信息不全导致无法发出请求
-    'debug' => (bool)env('DEBUG'),
-    'freenom_proxy' => env('FREENOM_PROXY') ?: null, // FreeNom 代理,针对国内网络情况,可选择代理访问
-    'new_version_detection' => (bool)env('NEW_VERSION_DETECTION', 1),
-    'max_request_retry_count' => (int)env('MAX_REQUEST_RETRY_COUNT', 32), // 最大请求重试次数
+<?php
+/**
+ * 配置
+ *
+ * @author mybsdc <[email protected]>
+ * @date 2019/3/2
+ * @time 11:39
+ */
+
+return [
+    'message' => [
+        /**
+         * 邮箱配置
+         */
+        'mail' => [
+            /**
+             * 目前机器人邮箱账户支持谷歌邮箱、QQ邮箱、163邮箱以及Outlook邮箱,程序会自动判断填入的邮箱类型并使用合适的配置。也可以自定义邮箱配置。
+             * 注意,QQ邮箱与163邮箱均使用账户加授权码的方式登录,谷歌邮箱使用账户加密码的方式登录,请知悉。
+             */
+            'to' => env('TO'), // 用于接收通知的邮箱
+            'recipient_name' => '主人', // 收件人名字
+            'username' => env('MAIL_USERNAME'), // 机器人邮箱账户
+            'password' => env('MAIL_PASSWORD'), // 机器人邮箱密码或授权码
+            'enable' => (int)env('MAIL_ENABLE'), // 是否启用,默认启用
+            'not_enabled_tips' => env('MAIL_USERNAME') && env('MAIL_PASSWORD'), // 提醒未启用
+            // 'reply_to' => '[email protected]', // 接收回复的邮箱
+            // 'reply_to_name' => '作者', // 接收回复的人名
+            'host' => env('MAIL_HOST'), // 邮件 SMTP 服务器
+            'port' => env('MAIL_PORT'), // 邮件 SMTP 端口
+            'encryption' => env('MAIL_ENCRYPTION'), // 邮件加密方式
+            'class' => \Luolongfei\Libs\MessageServices\Mail::class,
+            'name' => lang('100064'),
+        ],
+
+        /**
+         * Telegram Bot
+         */
+        'telegram' => [
+            'chat_id' => env('TELEGRAM_CHAT_ID'), // 你的chat_id,通过发送“/start”给@userinfobot可以获取自己的id
+            'token' => env('TELEGRAM_BOT_TOKEN'), // Telegram Bot 的 token
+            'enable' => (int)env('TELEGRAM_BOT_ENABLE'), // 是否启用,默认不启用
+            'not_enabled_tips' => env('TELEGRAM_CHAT_ID') && env('TELEGRAM_BOT_TOKEN'), // 提醒未启用
+            'class' => \Luolongfei\Libs\MessageServices\TelegramBot::class,
+            'name' => lang('100065'),
+            'proxy' => env('TELEGRAM_PROXY') ?: null,
+            'host' => env('CUSTOM_TELEGRAM_HOST') ?: 'api.telegram.org',
+        ],
+
+        /**
+         * 企业微信
+         */
+        'wechat' => [
+            'corp_id' => env('WECHAT_CORP_ID'), // 企业 ID
+            'corp_secret' => env('WECHAT_CORP_SECRET'), // 企业微信应用的凭证密钥
+            'agent_id' => (int)env('WECHAT_AGENT_ID'), // 企业微信应用 ID
+            'user_id' => env('WECHAT_USER_ID'), // 企业微信用户ID
+            'enable' => (int)env('WECHAT_ENABLE'), // 是否启用,默认不启用
+            'not_enabled_tips' => env('WECHAT_CORP_ID') && env('WECHAT_CORP_SECRET') && env('WECHAT_AGENT_ID'), // 提醒未启用
+            'class' => \Luolongfei\Libs\MessageServices\WeChat::class,
+            'name' => lang('100066'),
+        ],
+
+        /**
+         * Server 酱
+         */
+        'sct' => [
+            'sct_send_key' => env('SCT_SEND_KEY'), // SendKey
+            'enable' => (int)env('SCT_ENABLE'), // 是否启用,默认不启用
+            'not_enabled_tips' => (bool)env('SCT_SEND_KEY'), // 提醒未启用
+            'class' => \Luolongfei\Libs\MessageServices\ServerChan::class,
+            'name' => lang('100067'),
+        ],
+
+        /**
+         * Bark 送信
+         */
+        'bark' => [
+            'bark_key' => (string)env('BARK_KEY'), // 打开 Bark App,注册设备后看到的 Key
+            'bark_url' => (string)env('BARK_URL'), // Bark 域名
+            'bark_is_archive' => env('BARK_IS_ARCHIVE') === '' ? null : (int)env('BARK_IS_ARCHIVE'),
+            'bark_group' => env('BARK_GROUP') === '' ? null : env('BARK_GROUP'),
+            'bark_level' => env('BARK_LEVEL'),
+            'bark_icon' => env('BARK_ICON') === '' ? null : env('BARK_ICON'),
+            'bark_jump_url' => env('BARK_JUMP_URL') === '' ? null : env('BARK_JUMP_URL'),
+            'bark_sound' => env('BARK_SOUND') === '' ? null : env('BARK_SOUND'),
+            'enable' => (int)env('BARK_ENABLE'), // 是否启用,默认不启用
+            'not_enabled_tips' => env('BARK_KEY') && env('BARK_URL'), // 提醒未启用
+            'class' => \Luolongfei\Libs\MessageServices\Bark::class,
+            'name' => lang('100068'),
+        ],
+
+        /**
+         * PUSH PLUS
+         */
+        'pushplus' => [
+            'pushplus_key' => env('PUSHPLUS_KEY'), // SendKey
+            'enable' => (int)env('PUSHPLUS_ENABLE'), // 是否启用,默认不启用
+            'not_enabled_tips' => (bool)env('PUSHPLUS_KEY'), // 提醒未启用
+            'class' => \Luolongfei\Libs\MessageServices\Pushplus::class,
+            'name' => lang('100136'),
+        ],
+    ],
+    'custom_language' => env('CUSTOM_LANGUAGE', 'zh'),
+    'notice_freq' => (int)env('NOTICE_FREQ', 1), // 通知频率 0:仅当有续期操作的时候 1:每次执行
+    'verify_ssl' => (bool)env('VERIFY_SSL', 0), // 请求时验证 SSL 证书行为,默认不验证,防止服务器证书过期或证书颁布者信息不全导致无法发出请求
+    'debug' => (bool)env('DEBUG'),
+    'freenom_proxy' => env('FREENOM_PROXY') ?: null, // FreeNom 代理,针对国内网络情况,可选择代理访问
+    'new_version_detection' => (bool)env('NEW_VERSION_DETECTION', 1),
+    'max_request_retry_count' => (int)env('MAX_REQUEST_RETRY_COUNT', 200), // 最大请求重试次数
 ];