├── .gitignore ├── CLI_GUIDE.md ├── LICENSE ├── README.md ├── config.example.yml ├── package-lock.json ├── package.json ├── run.sh ├── sea-config.json └── src ├── cli.js ├── cr.js ├── index.js ├── notifications.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | node_modules/ 3 | dist/ 4 | 5 | config.yml 6 | log.log -------------------------------------------------------------------------------- /CLI_GUIDE.md: -------------------------------------------------------------------------------- 1 | # 交互式命令行使用指南 2 | 3 | ### 车次选择 4 | 5 | - 使用方向键移动 6 | - 空格键选择/取消选择 7 | - `a` 键全选/取消全选 8 | - `i` 键反选 9 | - 回车确认 10 | 11 | ### 日期格式 12 | 13 | - 格式:YYYYMMDD 14 | - 示例:20250629(表示 2025 年 6 月 29 日) 15 | - 支持范围:今天到未来 15 天 16 | 17 | ### 站点名称 18 | 19 | - 支持主要城市名称(如:北京、上海、广州) 20 | - 支持具体车站名称(如:北京南、上海虹桥) 21 | - 建议使用常见的站点名称 22 | 23 | ### 席别选择 24 | 25 | - 商务座、特等座、一等座、二等座 26 | - 软卧、硬卧、软座、硬座、无座 27 | - 不选择任何席别表示监控所有席别 28 | 29 | ## 🔧 配置示例 30 | 31 | 生成的配置文件格式: 32 | 33 | ```yaml 34 | watch: 35 | - from: "上海" 36 | to: "北京" 37 | date: "20250629" 38 | trains: 39 | - code: "G104" 40 | from: "上海虹桥" 41 | to: "北京南" 42 | seatCategory: 43 | - "二等座" 44 | checkRoundTrip: false 45 | 46 | notifications: 47 | - type: "Lark" 48 | webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/..." 49 | secret: "your-secret-key" # 可选,启用签名校验时必填 50 | - type: "Bark" 51 | deviceKey: "your-device-key" 52 | serverUrl: "https://api.day.app" 53 | group: "火车票监控" 54 | - type: "SMTP" 55 | host: "smtp.gmail.com" 56 | port: 587 57 | user: "your-email@gmail.com" 58 | pass: "your-password" 59 | to: "recipient@example.com" 60 | 61 | interval: 15 62 | delay: 5 63 | ``` 64 | 65 | ## ❓ 常见问题 66 | 67 | ### 1. 找不到车次 68 | 69 | - 检查出发地和目的地名称是否正确 70 | - 确认日期是否在有效范围内(0-15 天) 71 | - 某些路线可能没有直达车次 72 | 73 | ### 2. 站点名称无效 74 | 75 | - 使用标准的城市名称或车站名称 76 | - 例如:北京(而不是 Beijing)、上海虹桥(而不是上海虹桥站) 77 | 78 | ### 3. 配置保存失败 79 | 80 | - 确保有写入权限 81 | - 检查配置目录是否存在 82 | 83 | ### 4. 推送配置失败 84 | 85 | - 检查 Webhook URL 格式是否正确 86 | - 确认机器人 Token 和 Chat ID 有效 87 | 88 | ### 5. 编辑配置时的问题 89 | 90 | - **修改日期后找不到车次**:某些日期可能没有车次运行,请选择其他日期 91 | - **删除任务后配置为空**:如果删除了所有监控任务,程序将无法运行,请至少保留一个任务 92 | - **推送配置丢失**:重置配置会清空所有设置,包括推送配置 93 | - **配置文件损坏**:如果手动编辑 config.yml 导致格式错误,可以删除文件重新配置 94 | 95 | ### 6. 建议 96 | 97 | - **监控任务数量**:建议单个配置文件不超过 10 个监控任务 98 | - **查询间隔**:建议间隔不低于 5 分钟,避免频繁请求 99 | - **车次选择**:只选择真正需要的车次,减少不必要的查询 100 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚄 China Railway Ticket Monitor 2 | 3 | 一个简洁、高效的 12306 余票监控工具,当出现余票时,可通过多种方式推送通知。 4 | 感谢原作者:https://github.com/BobLiu0518/CRTicketMonitor 5 | 6 | > ⚠️ 免责声明 7 | > 本程序仅用于学习和监控 12306 官网的余票信息,并非抢票软件,也不会增加任何抢票相关功能。程序作者不对监控结果的准确性做出任何保证,不为任何因使用本程序而产生的商业或法律纠纷负责。 8 | 9 | ## 🆕 交互式配置功能 10 | 11 | 通过交互式界面可以: 12 | 13 | - 🔍 输入出发地、目的地、日期,实时查询车次列表 14 | - 📋 查看完整的车次信息和余票情况 15 | - ⚙️ 动态选择监控车次和席别 16 | - 📲 配置推送通知方式 17 | - 💾 自动生成和保存配置文件 18 | - 🚀 一键启动监控程序 19 | 20 | 详细使用说明请参考 [CLI_GUIDE.md](CLI_GUIDE.md) 21 | 22 | # 部署 23 | 24 | ## 方式一:使用预编译程序 (推荐) 25 | 26 | ### 下载 27 | 28 | 在项目的 Releases 页面下载对应您操作系统的最新版本。 29 | [CNB(推荐)](https://cnb.cool/wxory/CRTMonitor/-/releases) 30 | [Github](https://github.com/wxory/CRTMonitor/releases) 31 | 32 | ### 配置 33 | 34 | 首次运行程序会自动生成一份 config.yml 模板文件。请根据 [参数配置](#配置-1) 说明修改该文件。 35 | 36 | ### 运行 37 | 38 | 将配置好的 config.yml 文件放置于可执行程序的同一目录下,然后直接运行即可。 39 | 40 | ## 手动部署 41 | 42 | ### 1. 安装 Node.js 43 | 44 | 前往 [Node.js 官网](https://nodejs.org/zh-cn) 下载并安装,或使用 [包管理器](https://nodejs.org/zh-cn/download/package-manager) 安装。 45 | 46 | ### 2. 下载代码 47 | 48 | 直接 [下载 Zip 文件](https://github.com/wxory/CRTicketMonitor/archive/refs/heads/main.zip),或使用 Git: 49 | 50 | ```bash 51 | $ git clone https://github.com/wxory/CRTMonitor.git 52 | $ git clone https://cnb.cool/wxory/CRTMonitor.git 53 | ``` 54 | 55 | ### 3. 安装依赖 56 | 57 | ```bash 58 | $ npm i 59 | ``` 60 | 61 | ### 4. 运行 62 | 63 | #### 前台运行 (适用于所有系统): 64 | 65 | ``` 66 | npm start 67 | ``` 68 | 69 | #### 后台运行 (适用于 Linux 服务器): 70 | 71 | 项目内置了 run.sh 脚本,它使用 screen 来实现后台持久化运行。 72 | 73 | ``` 74 | # 确保已安装 screen: sudo apt install screen (Debian/Ubuntu) 75 | ./run.sh 76 | ``` 77 | 78 | ## 配置 79 | 80 | 程序启动时会查找同目录下的 config.yml 文件。如果文件不存在,将自动创建一个模板。 81 | 82 | 以下是一个完整的配置示例: 83 | 84 | config.yml 示例: 85 | 86 | ```yaml 87 | # 🚄 China Railway Ticket Monitor 配置文件 88 | # 详细配置说明请参考 README.md 89 | 90 | # 查询列表 - 可添加多个查询条件 91 | watch: 92 | - # 基础信息 93 | from: "上海" # 起点站(包含同城站) 94 | to: "北京" # 终点站(包含同城站) 95 | date: "20241001" # 日期(YYYYMMDD 格式) 96 | 97 | # 车次列表(可选)- 不填时默认为全部车次 98 | trains: 99 | - code: "G2" # 车次号 100 | from: "上海" # 指定起点站(可选) 101 | to: "北京南" # 指定终点站(可选) 102 | seatCategory: # 限定席别(可选,详见下文) 103 | - "二等座" 104 | checkRoundTrip: true # 查询全程车票情况(可选) 105 | 106 | # 推送配置 - 支持多种推送方式(详见下文) 107 | notifications: 108 | - # 飞书推送 109 | type: "Lark" 110 | webhook: "" # 飞书机器人 Webhook URL 111 | 112 | - # Telegram推送 113 | type: "Telegram" 114 | botToken: "" # Telegram机器人Token 115 | chatId: "" # 接收消息的Chat ID 116 | 117 | - # Bark推送 118 | type: "Bark" 119 | deviceKey: "" # Bark 设备密钥 120 | serverUrl: "https://api.day.app" # 服务器地址(可选) 121 | group: "火车票监控" # 推送分组(可选) 122 | 123 | - # SMTP邮件推送 124 | type: "SMTP" 125 | host: "smtp.gmail.com" # SMTP服务器地址 126 | port: 587 # SMTP端口号 127 | user: "your-email@gmail.com" # 邮箱用户名 128 | pass: "your-app-password" # 邮箱密码 129 | to: "recipient@example.com" # 收件人邮箱 130 | 131 | # 刷新间隔(分钟,可选,默认 15 分钟) 132 | interval: 15 133 | 134 | # 访问延迟(秒,可选,默认 5 秒) 135 | delay: 5 136 | ``` 137 | 138 | ## 推送通知 139 | 140 | 目前支持飞书推送、Telegram 推送、企业微信推送、Bark 推送和 SMTP 邮件推送通知。 141 | 142 | ### 飞书推送配置 143 | 144 | 获取飞书机器人的 webhook 地址可参考[飞书开发文档](https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot) 145 | 146 | #### 基础配置 147 | 148 | 在 `config.yml` 中填写飞书机器人的 Webhook 地址即可,例如: 149 | 150 | ```yaml 151 | notifications: 152 | - type: "Lark" 153 | webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/your-webhook-url" 154 | ``` 155 | 156 | #### 签名校验配置(推荐) 157 | 158 | 为了提高安全性,建议启用飞书机器人的签名校验功能: 159 | 160 | 1. 在飞书群组中,进入自定义机器人的配置页面 161 | 2. 在安全设置中选择"签名校验" 162 | 3. 复制生成的密钥 163 | 4. 在配置文件中添加 `secret` 字段: 164 | 165 | ```yaml 166 | notifications: 167 | - type: "Lark" 168 | webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/your-webhook-url" 169 | secret: "your-secret-key" # 签名密钥(可选,启用签名校验时必填) 170 | ``` 171 | 172 | **注意事项:** 173 | 174 | - 签名密钥用于验证消息来源的可信性,防止恶意调用 175 | - 启用签名校验后,所有请求都需要通过签名验证 176 | - 签名算法使用 HmacSHA256 + Base64 编码 177 | 178 | ### Telegram 推送配置 179 | 180 | 使用 Telegram 推送需要先创建一个 Telegram 机器人并获取相关信息: 181 | 182 | 1. 在 Telegram 中找到 [@BotFather](https://t.me/BotFather) 并发送 `/newbot` 创建新机器人 183 | 2. 按照提示设置机器人名称和用户名,获取机器人 Token 184 | 3. 将机器人添加到您的聊天中,或直接与机器人私聊 185 | 4. 获取 Chat ID: 186 | - 发送消息给机器人后,访问 `https://api.telegram.org/bot/getUpdates` 187 | - 在返回的 JSON 中找到 `chat.id` 字段 188 | 189 | 在 `config.yml` 中配置 Telegram 推送: 190 | 191 | ```yaml 192 | notifications: 193 | - type: "Telegram" 194 | botToken: "123456789:ABCdefGHIjklMNOpqrsTUVwxyz" # 机器人Token 195 | chatId: "123456789" # Chat ID(可以是个人ID或群组ID) 196 | ``` 197 | 198 | ### 企业微信推送配置 199 | 200 | 使用企业微信推送需要先创建企业微信群机器人: 201 | 202 | 1. 在企业微信群中,点击群设置 → 群机器人 → 添加机器人 203 | 2. 设置机器人名称和头像,获取 Webhook URL 204 | 3. 复制完整的 Webhook URL(包含 key 参数) 205 | 206 | 在 `config.yml` 中配置企业微信推送: 207 | 208 | ```yaml 209 | notifications: 210 | - type: "WechatWork" 211 | webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-webhook-key" 212 | ``` 213 | 214 | ### Bark 推送配置 215 | 216 | Bark 是一个 iOS 推送通知应用,支持通过 API 发送推送到 iPhone/iPad。 217 | 218 | #### 获取 Bark 设备密钥 219 | 220 | 1. 在 App Store 下载并安装 [Bark](https://apps.apple.com/app/bark-customed-notifications/id1403753865) 应用 221 | 2. 打开应用,复制显示的设备密钥(Device Key) 222 | 3. 可选:如果你有自己的 Bark 服务器,也可以修改服务器地址 223 | 224 | #### 配置 Bark 推送 225 | 226 | 在 `config.yml` 中配置 Bark 推送: 227 | 228 | ```yaml 229 | notifications: 230 | - type: "Bark" 231 | deviceKey: "your-device-key" # 必填:设备密钥 232 | serverUrl: "https://api.day.app" # 可选:服务器地址,默认官方服务器 233 | group: "火车票监控" # 可选:推送分组 234 | sound: "default" # 可选:推送声音 235 | # 高级选项(可选) 236 | level: "active" # 推送级别:active(默认)/critical(重要)/timeSensitive(时效)/passive(静默) 237 | icon: "https://example.com/icon.png" # 自定义图标URL 238 | url: "https://example.com" # 点击推送后跳转的URL 239 | autoCopy: false # 是否自动复制推送内容 240 | isArchive: true # 是否保存到推送历史 241 | ``` 242 | 243 | #### Bark 推送级别说明 244 | 245 | - `active`(默认):系统会立即亮屏显示通知 246 | - `critical`:重要警告,在静音模式下也会响铃 247 | - `timeSensitive`:时效性通知,可在专注状态下显示通知 248 | - `passive`:仅将通知添加到通知列表,不会亮屏提醒 249 | 250 | ### SMTP 邮件推送配置 251 | 252 | SMTP 邮件推送支持通过标准邮件服务器发送余票通知邮件。 253 | 254 | #### 配置 SMTP 邮件推送 255 | 256 | 在 `config.yml` 中配置 SMTP 邮件推送: 257 | 258 | ```yaml 259 | notifications: 260 | - type: "SMTP" 261 | host: "smtp.gmail.com" # 必填:SMTP服务器地址 262 | port: 587 # 必填:SMTP端口号 263 | user: "your-email@gmail.com" # 必填:邮箱用户名 264 | pass: "your-app-password" # 必填:邮箱密码或应用密码 265 | to: "recipient@example.com" # 必填:收件人邮箱地址 266 | # 可选配置 267 | from: "12306监控 " # 发件人显示名称 268 | cc: "cc@example.com" # 抄送邮箱 269 | bcc: "bcc@example.com" # 密送邮箱 270 | replyTo: "noreply@example.com" # 回复邮箱 271 | secure: true # 是否使用SSL/TLS (465端口使用true,587端口使用false) 272 | ``` 273 | 274 | #### 常用邮箱服务器配置 275 | 276 | **Gmail:** 277 | 278 | ```yaml 279 | host: "smtp.gmail.com" 280 | port: 587 281 | secure: false # 使用STARTTLS 282 | # 需要开启两步验证并生成应用密码 283 | ``` 284 | 285 | **QQ 邮箱:** 286 | 287 | ```yaml 288 | host: "smtp.qq.com" 289 | port: 587 290 | secure: false 291 | # 需要开启SMTP服务并使用授权码 292 | ``` 293 | 294 | **163 邮箱:** 295 | 296 | ```yaml 297 | host: "smtp.163.com" 298 | port: 587 299 | secure: false 300 | # 需要开启SMTP服务并使用授权码 301 | ``` 302 | 303 | **Outlook:** 304 | 305 | ```yaml 306 | host: "smtp-mail.outlook.com" 307 | port: 587 308 | secure: false 309 | ``` 310 | 311 | #### 邮箱安全设置 312 | 313 | - **Gmail**: 需要开启两步验证,生成应用专用密码 314 | - **QQ 邮箱**: 需要在设置中开启 SMTP 服务,使用授权码作为密码 315 | - **163 邮箱**: 需要开启 SMTP 服务,使用授权码作为密码 316 | - **企业邮箱**: 根据企业邮箱服务商的要求配置 317 | 318 | #### 端口号说明 319 | 320 | - `25`: 标准 SMTP 端口(通常被 ISP 封禁) 321 | - `587`: STARTTLS 加密端口(推荐) 322 | - `465`: SSL/TLS 加密端口 323 | 324 | 这样,当有余票时,程序会通过相应的平台发送通知。 325 | 326 | ## 席别设置 327 | 328 | 可选的席别如下: 329 | 330 | - 卧铺: 331 | - `高级软卧` 332 | - `软卧`(含动卧一等卧) 333 | - `硬卧`(含二等卧) 334 | - 坐票: 335 | - `商务座` 336 | - `特等座` 337 | - `优选一等座` 338 | - `一等座` 339 | - `二等座` 340 | - `软座` 341 | - `硬座` 342 | - `无座` 343 | - 其他: 344 | - `其他`(含包厢硬卧等) 345 | - `YB`(未知类型) 346 | - `SRRB`(未知类型) 347 | -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | # 🚄 China Railway Ticket Monitor 配置文件 2 | # 详细配置说明请参考 README.md 3 | 4 | # 查询列表 - 可添加多个查询条件 5 | watch: 6 | - # 基础信息 7 | from: "上海" # 起点站(包含同城站) 8 | to: "北京" # 终点站(包含同城站) 9 | date: "20241001" # 日期(YYYYMMDD 格式) 10 | 11 | # 车次列表(可选)- 不填时默认为全部车次 12 | trains: 13 | - code: "G2" # 车次号 14 | from: "上海" # 指定起点站(可选) 15 | to: "北京南" # 指定终点站(可选) 16 | seatCategory: # 限定席别(可选,详见 README.md) 17 | - "二等座" 18 | checkRoundTrip: false # 查询全程车票情况(可选) 19 | 20 | # 推送配置 - 支持多种推送方式 21 | notifications: 22 | - # 飞书推送 23 | type: "Lark" 24 | webhook: "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" # 飞书机器人 Webhook URL 25 | secret: "xxxxxxxxxx" # 签名密钥(可选,启用签名校验时填写) 26 | 27 | - # Telegram推送 28 | type: "Telegram" 29 | botToken: "xxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # Telegram机器人Token 30 | chatId: "xxxxxxxxxx" # 接收消息的Chat ID 31 | 32 | - # 企业微信推送 33 | type: "WechatWork" 34 | webhook: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxxxx" # 企业微信机器人 Webhook URL 35 | 36 | - # Bark推送 37 | type: "Bark" 38 | deviceKey: "xxxxxxxxxx" # Bark 设备密钥 39 | serverUrl: "https://api.day.app" # 服务器地址(可选,默认官方服务器) 40 | group: "火车票监控" # 推送分组(可选) 41 | sound: "default" # 推送声音(可选) 42 | level: "active" # 推送级别(可选) 43 | 44 | - # SMTP邮件推送 45 | type: "SMTP" 46 | host: "smtp.gmail.com" # SMTP服务器地址 47 | port: 587 # SMTP端口号 48 | user: "your-email@gmail.com" # 邮箱用户名 49 | pass: "your-app-password" # 邮箱密码或应用密码 50 | to: "recipient@example.com" # 收件人邮箱地址 51 | from: "12306监控 " # 发件人显示名称(可选) 52 | secure: false # 安全连接类型(可选) 53 | 54 | # 刷新间隔(分钟,可选,默认 15 分钟) 55 | interval: 15 56 | 57 | # 访问延迟(秒,可选,默认 5 秒) 58 | delay: 5 59 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cr-ticket-monitor", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "cr-ticket-monitor", 9 | "version": "1.0.0", 10 | "license": "GPL-2.0-only", 11 | "dependencies": { 12 | "chalk": "^5.3.0", 13 | "chalk-table": "^1.0.2", 14 | "fs": "^0.0.1-security", 15 | "http": "^0.0.1-security", 16 | "inquirer": "^12.6.3", 17 | "js-yaml": "^4.1.0", 18 | "moment": "^2.30.1", 19 | "nodemailer": "^7.0.4" 20 | }, 21 | "devDependencies": { 22 | "esbuild": "^0.24.0", 23 | "postject": "^1.0.0-alpha.6" 24 | }, 25 | "engines": { 26 | "node": ">=20.12.0" 27 | } 28 | }, 29 | "node_modules/@esbuild/aix-ppc64": { 30 | "version": "0.24.0", 31 | "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", 32 | "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", 33 | "cpu": [ 34 | "ppc64" 35 | ], 36 | "dev": true, 37 | "license": "MIT", 38 | "optional": true, 39 | "os": [ 40 | "aix" 41 | ], 42 | "engines": { 43 | "node": ">=18" 44 | } 45 | }, 46 | "node_modules/@esbuild/android-arm": { 47 | "version": "0.24.0", 48 | "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.24.0.tgz", 49 | "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", 50 | "cpu": [ 51 | "arm" 52 | ], 53 | "dev": true, 54 | "license": "MIT", 55 | "optional": true, 56 | "os": [ 57 | "android" 58 | ], 59 | "engines": { 60 | "node": ">=18" 61 | } 62 | }, 63 | "node_modules/@esbuild/android-arm64": { 64 | "version": "0.24.0", 65 | "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", 66 | "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", 67 | "cpu": [ 68 | "arm64" 69 | ], 70 | "dev": true, 71 | "license": "MIT", 72 | "optional": true, 73 | "os": [ 74 | "android" 75 | ], 76 | "engines": { 77 | "node": ">=18" 78 | } 79 | }, 80 | "node_modules/@esbuild/android-x64": { 81 | "version": "0.24.0", 82 | "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.24.0.tgz", 83 | "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", 84 | "cpu": [ 85 | "x64" 86 | ], 87 | "dev": true, 88 | "license": "MIT", 89 | "optional": true, 90 | "os": [ 91 | "android" 92 | ], 93 | "engines": { 94 | "node": ">=18" 95 | } 96 | }, 97 | "node_modules/@esbuild/darwin-arm64": { 98 | "version": "0.24.0", 99 | "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", 100 | "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", 101 | "cpu": [ 102 | "arm64" 103 | ], 104 | "dev": true, 105 | "license": "MIT", 106 | "optional": true, 107 | "os": [ 108 | "darwin" 109 | ], 110 | "engines": { 111 | "node": ">=18" 112 | } 113 | }, 114 | "node_modules/@esbuild/darwin-x64": { 115 | "version": "0.24.0", 116 | "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", 117 | "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", 118 | "cpu": [ 119 | "x64" 120 | ], 121 | "dev": true, 122 | "license": "MIT", 123 | "optional": true, 124 | "os": [ 125 | "darwin" 126 | ], 127 | "engines": { 128 | "node": ">=18" 129 | } 130 | }, 131 | "node_modules/@esbuild/freebsd-arm64": { 132 | "version": "0.24.0", 133 | "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", 134 | "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", 135 | "cpu": [ 136 | "arm64" 137 | ], 138 | "dev": true, 139 | "license": "MIT", 140 | "optional": true, 141 | "os": [ 142 | "freebsd" 143 | ], 144 | "engines": { 145 | "node": ">=18" 146 | } 147 | }, 148 | "node_modules/@esbuild/freebsd-x64": { 149 | "version": "0.24.0", 150 | "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", 151 | "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", 152 | "cpu": [ 153 | "x64" 154 | ], 155 | "dev": true, 156 | "license": "MIT", 157 | "optional": true, 158 | "os": [ 159 | "freebsd" 160 | ], 161 | "engines": { 162 | "node": ">=18" 163 | } 164 | }, 165 | "node_modules/@esbuild/linux-arm": { 166 | "version": "0.24.0", 167 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", 168 | "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", 169 | "cpu": [ 170 | "arm" 171 | ], 172 | "dev": true, 173 | "license": "MIT", 174 | "optional": true, 175 | "os": [ 176 | "linux" 177 | ], 178 | "engines": { 179 | "node": ">=18" 180 | } 181 | }, 182 | "node_modules/@esbuild/linux-arm64": { 183 | "version": "0.24.0", 184 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", 185 | "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", 186 | "cpu": [ 187 | "arm64" 188 | ], 189 | "dev": true, 190 | "license": "MIT", 191 | "optional": true, 192 | "os": [ 193 | "linux" 194 | ], 195 | "engines": { 196 | "node": ">=18" 197 | } 198 | }, 199 | "node_modules/@esbuild/linux-ia32": { 200 | "version": "0.24.0", 201 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", 202 | "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", 203 | "cpu": [ 204 | "ia32" 205 | ], 206 | "dev": true, 207 | "license": "MIT", 208 | "optional": true, 209 | "os": [ 210 | "linux" 211 | ], 212 | "engines": { 213 | "node": ">=18" 214 | } 215 | }, 216 | "node_modules/@esbuild/linux-loong64": { 217 | "version": "0.24.0", 218 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", 219 | "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", 220 | "cpu": [ 221 | "loong64" 222 | ], 223 | "dev": true, 224 | "license": "MIT", 225 | "optional": true, 226 | "os": [ 227 | "linux" 228 | ], 229 | "engines": { 230 | "node": ">=18" 231 | } 232 | }, 233 | "node_modules/@esbuild/linux-mips64el": { 234 | "version": "0.24.0", 235 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", 236 | "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", 237 | "cpu": [ 238 | "mips64el" 239 | ], 240 | "dev": true, 241 | "license": "MIT", 242 | "optional": true, 243 | "os": [ 244 | "linux" 245 | ], 246 | "engines": { 247 | "node": ">=18" 248 | } 249 | }, 250 | "node_modules/@esbuild/linux-ppc64": { 251 | "version": "0.24.0", 252 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", 253 | "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", 254 | "cpu": [ 255 | "ppc64" 256 | ], 257 | "dev": true, 258 | "license": "MIT", 259 | "optional": true, 260 | "os": [ 261 | "linux" 262 | ], 263 | "engines": { 264 | "node": ">=18" 265 | } 266 | }, 267 | "node_modules/@esbuild/linux-riscv64": { 268 | "version": "0.24.0", 269 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", 270 | "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", 271 | "cpu": [ 272 | "riscv64" 273 | ], 274 | "dev": true, 275 | "license": "MIT", 276 | "optional": true, 277 | "os": [ 278 | "linux" 279 | ], 280 | "engines": { 281 | "node": ">=18" 282 | } 283 | }, 284 | "node_modules/@esbuild/linux-s390x": { 285 | "version": "0.24.0", 286 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", 287 | "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", 288 | "cpu": [ 289 | "s390x" 290 | ], 291 | "dev": true, 292 | "license": "MIT", 293 | "optional": true, 294 | "os": [ 295 | "linux" 296 | ], 297 | "engines": { 298 | "node": ">=18" 299 | } 300 | }, 301 | "node_modules/@esbuild/linux-x64": { 302 | "version": "0.24.0", 303 | "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", 304 | "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", 305 | "cpu": [ 306 | "x64" 307 | ], 308 | "dev": true, 309 | "license": "MIT", 310 | "optional": true, 311 | "os": [ 312 | "linux" 313 | ], 314 | "engines": { 315 | "node": ">=18" 316 | } 317 | }, 318 | "node_modules/@esbuild/netbsd-x64": { 319 | "version": "0.24.0", 320 | "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", 321 | "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", 322 | "cpu": [ 323 | "x64" 324 | ], 325 | "dev": true, 326 | "license": "MIT", 327 | "optional": true, 328 | "os": [ 329 | "netbsd" 330 | ], 331 | "engines": { 332 | "node": ">=18" 333 | } 334 | }, 335 | "node_modules/@esbuild/openbsd-arm64": { 336 | "version": "0.24.0", 337 | "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", 338 | "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", 339 | "cpu": [ 340 | "arm64" 341 | ], 342 | "dev": true, 343 | "license": "MIT", 344 | "optional": true, 345 | "os": [ 346 | "openbsd" 347 | ], 348 | "engines": { 349 | "node": ">=18" 350 | } 351 | }, 352 | "node_modules/@esbuild/openbsd-x64": { 353 | "version": "0.24.0", 354 | "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", 355 | "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", 356 | "cpu": [ 357 | "x64" 358 | ], 359 | "dev": true, 360 | "license": "MIT", 361 | "optional": true, 362 | "os": [ 363 | "openbsd" 364 | ], 365 | "engines": { 366 | "node": ">=18" 367 | } 368 | }, 369 | "node_modules/@esbuild/sunos-x64": { 370 | "version": "0.24.0", 371 | "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", 372 | "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", 373 | "cpu": [ 374 | "x64" 375 | ], 376 | "dev": true, 377 | "license": "MIT", 378 | "optional": true, 379 | "os": [ 380 | "sunos" 381 | ], 382 | "engines": { 383 | "node": ">=18" 384 | } 385 | }, 386 | "node_modules/@esbuild/win32-arm64": { 387 | "version": "0.24.0", 388 | "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", 389 | "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", 390 | "cpu": [ 391 | "arm64" 392 | ], 393 | "dev": true, 394 | "license": "MIT", 395 | "optional": true, 396 | "os": [ 397 | "win32" 398 | ], 399 | "engines": { 400 | "node": ">=18" 401 | } 402 | }, 403 | "node_modules/@esbuild/win32-ia32": { 404 | "version": "0.24.0", 405 | "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", 406 | "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", 407 | "cpu": [ 408 | "ia32" 409 | ], 410 | "dev": true, 411 | "license": "MIT", 412 | "optional": true, 413 | "os": [ 414 | "win32" 415 | ], 416 | "engines": { 417 | "node": ">=18" 418 | } 419 | }, 420 | "node_modules/@esbuild/win32-x64": { 421 | "version": "0.24.0", 422 | "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", 423 | "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", 424 | "cpu": [ 425 | "x64" 426 | ], 427 | "dev": true, 428 | "license": "MIT", 429 | "optional": true, 430 | "os": [ 431 | "win32" 432 | ], 433 | "engines": { 434 | "node": ">=18" 435 | } 436 | }, 437 | "node_modules/@inquirer/checkbox": { 438 | "version": "4.1.8", 439 | "resolved": "https://registry.npmmirror.com/@inquirer/checkbox/-/checkbox-4.1.8.tgz", 440 | "integrity": "sha512-d/QAsnwuHX2OPolxvYcgSj7A9DO9H6gVOy2DvBTx+P2LH2iRTo/RSGV3iwCzW024nP9hw98KIuDmdyhZQj1UQg==", 441 | "dependencies": { 442 | "@inquirer/core": "^10.1.13", 443 | "@inquirer/figures": "^1.0.12", 444 | "@inquirer/type": "^3.0.7", 445 | "ansi-escapes": "^4.3.2", 446 | "yoctocolors-cjs": "^2.1.2" 447 | }, 448 | "engines": { 449 | "node": ">=18" 450 | }, 451 | "peerDependencies": { 452 | "@types/node": ">=18" 453 | }, 454 | "peerDependenciesMeta": { 455 | "@types/node": { 456 | "optional": true 457 | } 458 | } 459 | }, 460 | "node_modules/@inquirer/confirm": { 461 | "version": "5.1.12", 462 | "resolved": "https://registry.npmmirror.com/@inquirer/confirm/-/confirm-5.1.12.tgz", 463 | "integrity": "sha512-dpq+ielV9/bqgXRUbNH//KsY6WEw9DrGPmipkpmgC1Y46cwuBTNx7PXFWTjc3MQ+urcc0QxoVHcMI0FW4Ok0hg==", 464 | "dependencies": { 465 | "@inquirer/core": "^10.1.13", 466 | "@inquirer/type": "^3.0.7" 467 | }, 468 | "engines": { 469 | "node": ">=18" 470 | }, 471 | "peerDependencies": { 472 | "@types/node": ">=18" 473 | }, 474 | "peerDependenciesMeta": { 475 | "@types/node": { 476 | "optional": true 477 | } 478 | } 479 | }, 480 | "node_modules/@inquirer/core": { 481 | "version": "10.1.13", 482 | "resolved": "https://registry.npmmirror.com/@inquirer/core/-/core-10.1.13.tgz", 483 | "integrity": "sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==", 484 | "dependencies": { 485 | "@inquirer/figures": "^1.0.12", 486 | "@inquirer/type": "^3.0.7", 487 | "ansi-escapes": "^4.3.2", 488 | "cli-width": "^4.1.0", 489 | "mute-stream": "^2.0.0", 490 | "signal-exit": "^4.1.0", 491 | "wrap-ansi": "^6.2.0", 492 | "yoctocolors-cjs": "^2.1.2" 493 | }, 494 | "engines": { 495 | "node": ">=18" 496 | }, 497 | "peerDependencies": { 498 | "@types/node": ">=18" 499 | }, 500 | "peerDependenciesMeta": { 501 | "@types/node": { 502 | "optional": true 503 | } 504 | } 505 | }, 506 | "node_modules/@inquirer/editor": { 507 | "version": "4.2.13", 508 | "resolved": "https://registry.npmmirror.com/@inquirer/editor/-/editor-4.2.13.tgz", 509 | "integrity": "sha512-WbicD9SUQt/K8O5Vyk9iC2ojq5RHoCLK6itpp2fHsWe44VxxcA9z3GTWlvjSTGmMQpZr+lbVmrxdHcumJoLbMA==", 510 | "dependencies": { 511 | "@inquirer/core": "^10.1.13", 512 | "@inquirer/type": "^3.0.7", 513 | "external-editor": "^3.1.0" 514 | }, 515 | "engines": { 516 | "node": ">=18" 517 | }, 518 | "peerDependencies": { 519 | "@types/node": ">=18" 520 | }, 521 | "peerDependenciesMeta": { 522 | "@types/node": { 523 | "optional": true 524 | } 525 | } 526 | }, 527 | "node_modules/@inquirer/expand": { 528 | "version": "4.0.15", 529 | "resolved": "https://registry.npmmirror.com/@inquirer/expand/-/expand-4.0.15.tgz", 530 | "integrity": "sha512-4Y+pbr/U9Qcvf+N/goHzPEXiHH8680lM3Dr3Y9h9FFw4gHS+zVpbj8LfbKWIb/jayIB4aSO4pWiBTrBYWkvi5A==", 531 | "dependencies": { 532 | "@inquirer/core": "^10.1.13", 533 | "@inquirer/type": "^3.0.7", 534 | "yoctocolors-cjs": "^2.1.2" 535 | }, 536 | "engines": { 537 | "node": ">=18" 538 | }, 539 | "peerDependencies": { 540 | "@types/node": ">=18" 541 | }, 542 | "peerDependenciesMeta": { 543 | "@types/node": { 544 | "optional": true 545 | } 546 | } 547 | }, 548 | "node_modules/@inquirer/figures": { 549 | "version": "1.0.12", 550 | "resolved": "https://registry.npmmirror.com/@inquirer/figures/-/figures-1.0.12.tgz", 551 | "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", 552 | "engines": { 553 | "node": ">=18" 554 | } 555 | }, 556 | "node_modules/@inquirer/input": { 557 | "version": "4.1.12", 558 | "resolved": "https://registry.npmmirror.com/@inquirer/input/-/input-4.1.12.tgz", 559 | "integrity": "sha512-xJ6PFZpDjC+tC1P8ImGprgcsrzQRsUh9aH3IZixm1lAZFK49UGHxM3ltFfuInN2kPYNfyoPRh+tU4ftsjPLKqQ==", 560 | "dependencies": { 561 | "@inquirer/core": "^10.1.13", 562 | "@inquirer/type": "^3.0.7" 563 | }, 564 | "engines": { 565 | "node": ">=18" 566 | }, 567 | "peerDependencies": { 568 | "@types/node": ">=18" 569 | }, 570 | "peerDependenciesMeta": { 571 | "@types/node": { 572 | "optional": true 573 | } 574 | } 575 | }, 576 | "node_modules/@inquirer/number": { 577 | "version": "3.0.15", 578 | "resolved": "https://registry.npmmirror.com/@inquirer/number/-/number-3.0.15.tgz", 579 | "integrity": "sha512-xWg+iYfqdhRiM55MvqiTCleHzszpoigUpN5+t1OMcRkJrUrw7va3AzXaxvS+Ak7Gny0j2mFSTv2JJj8sMtbV2g==", 580 | "dependencies": { 581 | "@inquirer/core": "^10.1.13", 582 | "@inquirer/type": "^3.0.7" 583 | }, 584 | "engines": { 585 | "node": ">=18" 586 | }, 587 | "peerDependencies": { 588 | "@types/node": ">=18" 589 | }, 590 | "peerDependenciesMeta": { 591 | "@types/node": { 592 | "optional": true 593 | } 594 | } 595 | }, 596 | "node_modules/@inquirer/password": { 597 | "version": "4.0.15", 598 | "resolved": "https://registry.npmmirror.com/@inquirer/password/-/password-4.0.15.tgz", 599 | "integrity": "sha512-75CT2p43DGEnfGTaqFpbDC2p2EEMrq0S+IRrf9iJvYreMy5mAWj087+mdKyLHapUEPLjN10mNvABpGbk8Wdraw==", 600 | "dependencies": { 601 | "@inquirer/core": "^10.1.13", 602 | "@inquirer/type": "^3.0.7", 603 | "ansi-escapes": "^4.3.2" 604 | }, 605 | "engines": { 606 | "node": ">=18" 607 | }, 608 | "peerDependencies": { 609 | "@types/node": ">=18" 610 | }, 611 | "peerDependenciesMeta": { 612 | "@types/node": { 613 | "optional": true 614 | } 615 | } 616 | }, 617 | "node_modules/@inquirer/prompts": { 618 | "version": "7.5.3", 619 | "resolved": "https://registry.npmmirror.com/@inquirer/prompts/-/prompts-7.5.3.tgz", 620 | "integrity": "sha512-8YL0WiV7J86hVAxrh3fE5mDCzcTDe1670unmJRz6ArDgN+DBK1a0+rbnNWp4DUB5rPMwqD5ZP6YHl9KK1mbZRg==", 621 | "dependencies": { 622 | "@inquirer/checkbox": "^4.1.8", 623 | "@inquirer/confirm": "^5.1.12", 624 | "@inquirer/editor": "^4.2.13", 625 | "@inquirer/expand": "^4.0.15", 626 | "@inquirer/input": "^4.1.12", 627 | "@inquirer/number": "^3.0.15", 628 | "@inquirer/password": "^4.0.15", 629 | "@inquirer/rawlist": "^4.1.3", 630 | "@inquirer/search": "^3.0.15", 631 | "@inquirer/select": "^4.2.3" 632 | }, 633 | "engines": { 634 | "node": ">=18" 635 | }, 636 | "peerDependencies": { 637 | "@types/node": ">=18" 638 | }, 639 | "peerDependenciesMeta": { 640 | "@types/node": { 641 | "optional": true 642 | } 643 | } 644 | }, 645 | "node_modules/@inquirer/rawlist": { 646 | "version": "4.1.3", 647 | "resolved": "https://registry.npmmirror.com/@inquirer/rawlist/-/rawlist-4.1.3.tgz", 648 | "integrity": "sha512-7XrV//6kwYumNDSsvJIPeAqa8+p7GJh7H5kRuxirct2cgOcSWwwNGoXDRgpNFbY/MG2vQ4ccIWCi8+IXXyFMZA==", 649 | "dependencies": { 650 | "@inquirer/core": "^10.1.13", 651 | "@inquirer/type": "^3.0.7", 652 | "yoctocolors-cjs": "^2.1.2" 653 | }, 654 | "engines": { 655 | "node": ">=18" 656 | }, 657 | "peerDependencies": { 658 | "@types/node": ">=18" 659 | }, 660 | "peerDependenciesMeta": { 661 | "@types/node": { 662 | "optional": true 663 | } 664 | } 665 | }, 666 | "node_modules/@inquirer/search": { 667 | "version": "3.0.15", 668 | "resolved": "https://registry.npmmirror.com/@inquirer/search/-/search-3.0.15.tgz", 669 | "integrity": "sha512-YBMwPxYBrADqyvP4nNItpwkBnGGglAvCLVW8u4pRmmvOsHUtCAUIMbUrLX5B3tFL1/WsLGdQ2HNzkqswMs5Uaw==", 670 | "dependencies": { 671 | "@inquirer/core": "^10.1.13", 672 | "@inquirer/figures": "^1.0.12", 673 | "@inquirer/type": "^3.0.7", 674 | "yoctocolors-cjs": "^2.1.2" 675 | }, 676 | "engines": { 677 | "node": ">=18" 678 | }, 679 | "peerDependencies": { 680 | "@types/node": ">=18" 681 | }, 682 | "peerDependenciesMeta": { 683 | "@types/node": { 684 | "optional": true 685 | } 686 | } 687 | }, 688 | "node_modules/@inquirer/select": { 689 | "version": "4.2.3", 690 | "resolved": "https://registry.npmmirror.com/@inquirer/select/-/select-4.2.3.tgz", 691 | "integrity": "sha512-OAGhXU0Cvh0PhLz9xTF/kx6g6x+sP+PcyTiLvCrewI99P3BBeexD+VbuwkNDvqGkk3y2h5ZiWLeRP7BFlhkUDg==", 692 | "dependencies": { 693 | "@inquirer/core": "^10.1.13", 694 | "@inquirer/figures": "^1.0.12", 695 | "@inquirer/type": "^3.0.7", 696 | "ansi-escapes": "^4.3.2", 697 | "yoctocolors-cjs": "^2.1.2" 698 | }, 699 | "engines": { 700 | "node": ">=18" 701 | }, 702 | "peerDependencies": { 703 | "@types/node": ">=18" 704 | }, 705 | "peerDependenciesMeta": { 706 | "@types/node": { 707 | "optional": true 708 | } 709 | } 710 | }, 711 | "node_modules/@inquirer/type": { 712 | "version": "3.0.7", 713 | "resolved": "https://registry.npmmirror.com/@inquirer/type/-/type-3.0.7.tgz", 714 | "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==", 715 | "engines": { 716 | "node": ">=18" 717 | }, 718 | "peerDependencies": { 719 | "@types/node": ">=18" 720 | }, 721 | "peerDependenciesMeta": { 722 | "@types/node": { 723 | "optional": true 724 | } 725 | } 726 | }, 727 | "node_modules/ansi-escapes": { 728 | "version": "4.3.2", 729 | "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz", 730 | "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", 731 | "dependencies": { 732 | "type-fest": "^0.21.3" 733 | }, 734 | "engines": { 735 | "node": ">=8" 736 | }, 737 | "funding": { 738 | "url": "https://github.com/sponsors/sindresorhus" 739 | } 740 | }, 741 | "node_modules/ansi-regex": { 742 | "version": "4.1.1", 743 | "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-4.1.1.tgz", 744 | "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", 745 | "engines": { 746 | "node": ">=6" 747 | } 748 | }, 749 | "node_modules/ansi-styles": { 750 | "version": "4.3.0", 751 | "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", 752 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 753 | "dependencies": { 754 | "color-convert": "^2.0.1" 755 | }, 756 | "engines": { 757 | "node": ">=8" 758 | }, 759 | "funding": { 760 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 761 | } 762 | }, 763 | "node_modules/argparse": { 764 | "version": "2.0.1", 765 | "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", 766 | "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" 767 | }, 768 | "node_modules/chalk": { 769 | "version": "5.3.0", 770 | "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.3.0.tgz", 771 | "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", 772 | "license": "MIT", 773 | "engines": { 774 | "node": "^12.17.0 || ^14.13 || >=16.0.0" 775 | }, 776 | "funding": { 777 | "url": "https://github.com/chalk/chalk?sponsor=1" 778 | } 779 | }, 780 | "node_modules/chalk-table": { 781 | "version": "1.0.2", 782 | "resolved": "https://registry.npmmirror.com/chalk-table/-/chalk-table-1.0.2.tgz", 783 | "integrity": "sha512-lmtmQtr/GCtbiJiiuXPE5lj0arIXJir5hSjIhye/4Uyr7oTQlP+ufPnHzUS3Bre0xS/VWbz9NfeuPnvse9BXoQ==", 784 | "dependencies": { 785 | "chalk": "^2.4.2", 786 | "strip-ansi": "^5.2.0" 787 | } 788 | }, 789 | "node_modules/chalk-table/node_modules/ansi-styles": { 790 | "version": "3.2.1", 791 | "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", 792 | "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", 793 | "dependencies": { 794 | "color-convert": "^1.9.0" 795 | }, 796 | "engines": { 797 | "node": ">=4" 798 | } 799 | }, 800 | "node_modules/chalk-table/node_modules/chalk": { 801 | "version": "2.4.2", 802 | "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", 803 | "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", 804 | "dependencies": { 805 | "ansi-styles": "^3.2.1", 806 | "escape-string-regexp": "^1.0.5", 807 | "supports-color": "^5.3.0" 808 | }, 809 | "engines": { 810 | "node": ">=4" 811 | } 812 | }, 813 | "node_modules/chalk-table/node_modules/color-convert": { 814 | "version": "1.9.3", 815 | "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", 816 | "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", 817 | "dependencies": { 818 | "color-name": "1.1.3" 819 | } 820 | }, 821 | "node_modules/chalk-table/node_modules/color-name": { 822 | "version": "1.1.3", 823 | "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", 824 | "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" 825 | }, 826 | "node_modules/chardet": { 827 | "version": "0.7.0", 828 | "resolved": "https://registry.npmmirror.com/chardet/-/chardet-0.7.0.tgz", 829 | "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" 830 | }, 831 | "node_modules/cli-width": { 832 | "version": "4.1.0", 833 | "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", 834 | "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", 835 | "engines": { 836 | "node": ">= 12" 837 | } 838 | }, 839 | "node_modules/color-convert": { 840 | "version": "2.0.1", 841 | "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", 842 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 843 | "dependencies": { 844 | "color-name": "~1.1.4" 845 | }, 846 | "engines": { 847 | "node": ">=7.0.0" 848 | } 849 | }, 850 | "node_modules/color-name": { 851 | "version": "1.1.4", 852 | "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", 853 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" 854 | }, 855 | "node_modules/commander": { 856 | "version": "9.5.0", 857 | "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", 858 | "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", 859 | "dev": true, 860 | "license": "MIT", 861 | "engines": { 862 | "node": "^12.20.0 || >=14" 863 | } 864 | }, 865 | "node_modules/emoji-regex": { 866 | "version": "8.0.0", 867 | "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", 868 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" 869 | }, 870 | "node_modules/esbuild": { 871 | "version": "0.24.0", 872 | "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.24.0.tgz", 873 | "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", 874 | "dev": true, 875 | "hasInstallScript": true, 876 | "license": "MIT", 877 | "bin": { 878 | "esbuild": "bin/esbuild" 879 | }, 880 | "engines": { 881 | "node": ">=18" 882 | }, 883 | "optionalDependencies": { 884 | "@esbuild/aix-ppc64": "0.24.0", 885 | "@esbuild/android-arm": "0.24.0", 886 | "@esbuild/android-arm64": "0.24.0", 887 | "@esbuild/android-x64": "0.24.0", 888 | "@esbuild/darwin-arm64": "0.24.0", 889 | "@esbuild/darwin-x64": "0.24.0", 890 | "@esbuild/freebsd-arm64": "0.24.0", 891 | "@esbuild/freebsd-x64": "0.24.0", 892 | "@esbuild/linux-arm": "0.24.0", 893 | "@esbuild/linux-arm64": "0.24.0", 894 | "@esbuild/linux-ia32": "0.24.0", 895 | "@esbuild/linux-loong64": "0.24.0", 896 | "@esbuild/linux-mips64el": "0.24.0", 897 | "@esbuild/linux-ppc64": "0.24.0", 898 | "@esbuild/linux-riscv64": "0.24.0", 899 | "@esbuild/linux-s390x": "0.24.0", 900 | "@esbuild/linux-x64": "0.24.0", 901 | "@esbuild/netbsd-x64": "0.24.0", 902 | "@esbuild/openbsd-arm64": "0.24.0", 903 | "@esbuild/openbsd-x64": "0.24.0", 904 | "@esbuild/sunos-x64": "0.24.0", 905 | "@esbuild/win32-arm64": "0.24.0", 906 | "@esbuild/win32-ia32": "0.24.0", 907 | "@esbuild/win32-x64": "0.24.0" 908 | } 909 | }, 910 | "node_modules/escape-string-regexp": { 911 | "version": "1.0.5", 912 | "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 913 | "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", 914 | "engines": { 915 | "node": ">=0.8.0" 916 | } 917 | }, 918 | "node_modules/external-editor": { 919 | "version": "3.1.0", 920 | "resolved": "https://registry.npmmirror.com/external-editor/-/external-editor-3.1.0.tgz", 921 | "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", 922 | "dependencies": { 923 | "chardet": "^0.7.0", 924 | "iconv-lite": "^0.4.24", 925 | "tmp": "^0.0.33" 926 | }, 927 | "engines": { 928 | "node": ">=4" 929 | } 930 | }, 931 | "node_modules/fs": { 932 | "version": "0.0.1-security", 933 | "resolved": "https://registry.npmmirror.com/fs/-/fs-0.0.1-security.tgz", 934 | "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", 935 | "license": "ISC" 936 | }, 937 | "node_modules/has-flag": { 938 | "version": "3.0.0", 939 | "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", 940 | "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", 941 | "engines": { 942 | "node": ">=4" 943 | } 944 | }, 945 | "node_modules/http": { 946 | "version": "0.0.1-security", 947 | "resolved": "https://registry.npmmirror.com/http/-/http-0.0.1-security.tgz", 948 | "integrity": "sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==" 949 | }, 950 | "node_modules/iconv-lite": { 951 | "version": "0.4.24", 952 | "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", 953 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 954 | "dependencies": { 955 | "safer-buffer": ">= 2.1.2 < 3" 956 | }, 957 | "engines": { 958 | "node": ">=0.10.0" 959 | } 960 | }, 961 | "node_modules/inquirer": { 962 | "version": "12.6.3", 963 | "resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-12.6.3.tgz", 964 | "integrity": "sha512-eX9beYAjr1MqYsIjx1vAheXsRk1jbZRvHLcBu5nA9wX0rXR1IfCZLnVLp4Ym4mrhqmh7AuANwcdtgQ291fZDfQ==", 965 | "dependencies": { 966 | "@inquirer/core": "^10.1.13", 967 | "@inquirer/prompts": "^7.5.3", 968 | "@inquirer/type": "^3.0.7", 969 | "ansi-escapes": "^4.3.2", 970 | "mute-stream": "^2.0.0", 971 | "run-async": "^3.0.0", 972 | "rxjs": "^7.8.2" 973 | }, 974 | "engines": { 975 | "node": ">=18" 976 | }, 977 | "peerDependencies": { 978 | "@types/node": ">=18" 979 | }, 980 | "peerDependenciesMeta": { 981 | "@types/node": { 982 | "optional": true 983 | } 984 | } 985 | }, 986 | "node_modules/is-fullwidth-code-point": { 987 | "version": "3.0.0", 988 | "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 989 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 990 | "engines": { 991 | "node": ">=8" 992 | } 993 | }, 994 | "node_modules/js-yaml": { 995 | "version": "4.1.0", 996 | "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", 997 | "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", 998 | "dependencies": { 999 | "argparse": "^2.0.1" 1000 | }, 1001 | "bin": { 1002 | "js-yaml": "bin/js-yaml.js" 1003 | } 1004 | }, 1005 | "node_modules/moment": { 1006 | "version": "2.30.1", 1007 | "resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz", 1008 | "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", 1009 | "license": "MIT", 1010 | "engines": { 1011 | "node": "*" 1012 | } 1013 | }, 1014 | "node_modules/mute-stream": { 1015 | "version": "2.0.0", 1016 | "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-2.0.0.tgz", 1017 | "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", 1018 | "engines": { 1019 | "node": "^18.17.0 || >=20.5.0" 1020 | } 1021 | }, 1022 | "node_modules/nodemailer": { 1023 | "version": "7.0.4", 1024 | "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.4.tgz", 1025 | "integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==", 1026 | "engines": { 1027 | "node": ">=6.0.0" 1028 | } 1029 | }, 1030 | "node_modules/os-tmpdir": { 1031 | "version": "1.0.2", 1032 | "resolved": "https://registry.npmmirror.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz", 1033 | "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", 1034 | "engines": { 1035 | "node": ">=0.10.0" 1036 | } 1037 | }, 1038 | "node_modules/postject": { 1039 | "version": "1.0.0-alpha.6", 1040 | "resolved": "https://registry.npmmirror.com/postject/-/postject-1.0.0-alpha.6.tgz", 1041 | "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", 1042 | "dev": true, 1043 | "license": "MIT", 1044 | "dependencies": { 1045 | "commander": "^9.4.0" 1046 | }, 1047 | "bin": { 1048 | "postject": "dist/cli.js" 1049 | }, 1050 | "engines": { 1051 | "node": ">=14.0.0" 1052 | } 1053 | }, 1054 | "node_modules/run-async": { 1055 | "version": "3.0.0", 1056 | "resolved": "https://registry.npmmirror.com/run-async/-/run-async-3.0.0.tgz", 1057 | "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", 1058 | "engines": { 1059 | "node": ">=0.12.0" 1060 | } 1061 | }, 1062 | "node_modules/rxjs": { 1063 | "version": "7.8.2", 1064 | "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", 1065 | "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", 1066 | "dependencies": { 1067 | "tslib": "^2.1.0" 1068 | } 1069 | }, 1070 | "node_modules/safer-buffer": { 1071 | "version": "2.1.2", 1072 | "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", 1073 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1074 | }, 1075 | "node_modules/signal-exit": { 1076 | "version": "4.1.0", 1077 | "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", 1078 | "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", 1079 | "engines": { 1080 | "node": ">=14" 1081 | }, 1082 | "funding": { 1083 | "url": "https://github.com/sponsors/isaacs" 1084 | } 1085 | }, 1086 | "node_modules/string-width": { 1087 | "version": "4.2.3", 1088 | "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", 1089 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1090 | "dependencies": { 1091 | "emoji-regex": "^8.0.0", 1092 | "is-fullwidth-code-point": "^3.0.0", 1093 | "strip-ansi": "^6.0.1" 1094 | }, 1095 | "engines": { 1096 | "node": ">=8" 1097 | } 1098 | }, 1099 | "node_modules/string-width/node_modules/ansi-regex": { 1100 | "version": "5.0.1", 1101 | "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", 1102 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1103 | "engines": { 1104 | "node": ">=8" 1105 | } 1106 | }, 1107 | "node_modules/string-width/node_modules/strip-ansi": { 1108 | "version": "6.0.1", 1109 | "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", 1110 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1111 | "dependencies": { 1112 | "ansi-regex": "^5.0.1" 1113 | }, 1114 | "engines": { 1115 | "node": ">=8" 1116 | } 1117 | }, 1118 | "node_modules/strip-ansi": { 1119 | "version": "5.2.0", 1120 | "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz", 1121 | "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", 1122 | "dependencies": { 1123 | "ansi-regex": "^4.1.0" 1124 | }, 1125 | "engines": { 1126 | "node": ">=6" 1127 | } 1128 | }, 1129 | "node_modules/supports-color": { 1130 | "version": "5.5.0", 1131 | "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", 1132 | "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", 1133 | "dependencies": { 1134 | "has-flag": "^3.0.0" 1135 | }, 1136 | "engines": { 1137 | "node": ">=4" 1138 | } 1139 | }, 1140 | "node_modules/tmp": { 1141 | "version": "0.0.33", 1142 | "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.0.33.tgz", 1143 | "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", 1144 | "dependencies": { 1145 | "os-tmpdir": "~1.0.2" 1146 | }, 1147 | "engines": { 1148 | "node": ">=0.6.0" 1149 | } 1150 | }, 1151 | "node_modules/tslib": { 1152 | "version": "2.8.1", 1153 | "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", 1154 | "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 1155 | }, 1156 | "node_modules/type-fest": { 1157 | "version": "0.21.3", 1158 | "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", 1159 | "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", 1160 | "engines": { 1161 | "node": ">=10" 1162 | }, 1163 | "funding": { 1164 | "url": "https://github.com/sponsors/sindresorhus" 1165 | } 1166 | }, 1167 | "node_modules/wrap-ansi": { 1168 | "version": "6.2.0", 1169 | "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", 1170 | "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", 1171 | "dependencies": { 1172 | "ansi-styles": "^4.0.0", 1173 | "string-width": "^4.1.0", 1174 | "strip-ansi": "^6.0.0" 1175 | }, 1176 | "engines": { 1177 | "node": ">=8" 1178 | } 1179 | }, 1180 | "node_modules/wrap-ansi/node_modules/ansi-regex": { 1181 | "version": "5.0.1", 1182 | "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", 1183 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1184 | "engines": { 1185 | "node": ">=8" 1186 | } 1187 | }, 1188 | "node_modules/wrap-ansi/node_modules/strip-ansi": { 1189 | "version": "6.0.1", 1190 | "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", 1191 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1192 | "dependencies": { 1193 | "ansi-regex": "^5.0.1" 1194 | }, 1195 | "engines": { 1196 | "node": ">=8" 1197 | } 1198 | }, 1199 | "node_modules/yoctocolors-cjs": { 1200 | "version": "2.1.2", 1201 | "resolved": "https://registry.npmmirror.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", 1202 | "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", 1203 | "engines": { 1204 | "node": ">=18" 1205 | }, 1206 | "funding": { 1207 | "url": "https://github.com/sponsors/sindresorhus" 1208 | } 1209 | } 1210 | } 1211 | } 1212 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cr-ticket-monitor", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node src/index.js", 8 | "cli": "node src/cli.js", 9 | "build-win": "npm run build-pack && npm run build-generate-blob && npm run build-inject-win", 10 | "build-pack": "esbuild src/index.js --bundle --platform=node --outfile=dist/bundle.cjs", 11 | "build-generate-blob": "node --experimental-sea-config sea-config.json", 12 | "build-inject-win": "node -e \"require('fs').copyFileSync(process.execPath, 'dist/CRTM.exe')\" && npx postject dist/CRTM.exe NODE_SEA_BLOB dist/sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2" 13 | }, 14 | "author": "BobLiu&Wxory", 15 | "license": "GPL-2.0-only", 16 | "description": "12306 余票监控程序", 17 | "dependencies": { 18 | "chalk": "^5.3.0", 19 | "chalk-table": "^1.0.2", 20 | "fs": "^0.0.1-security", 21 | "http": "^0.0.1-security", 22 | "inquirer": "^12.6.3", 23 | "js-yaml": "^4.1.0", 24 | "moment": "^2.30.1", 25 | "nodemailer": "^7.0.4" 26 | }, 27 | "devDependencies": { 28 | "esbuild": "^0.24.0", 29 | "postject": "^1.0.0-alpha.6" 30 | }, 31 | "engines": { 32 | "node": ">=20.12.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | screen -S CRTM -L -Logfile log.log npm start 4 | -------------------------------------------------------------------------------- /sea-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/bundle.cjs", 3 | "output": "dist/sea-prep.blob", 4 | "disableExperimentalSEAWarning": true, 5 | "useSnapshot": false, 6 | "useCodeCache": true 7 | } 8 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import inquirer from "inquirer"; 2 | import chalk from "chalk"; 3 | import chalkTable from "chalk-table"; 4 | import ChinaRailway from "./cr.js"; 5 | import fs from "fs"; 6 | import yaml from "js-yaml"; 7 | 8 | // 自定义中文提示语 9 | const chinesePrompts = { 10 | checkbox: { 11 | help: "(使用 ↑↓ 移动,空格 选择,a 全选,i 反选,回车 确认)", 12 | selected: "已选择", 13 | unselected: "未选择", 14 | }, 15 | list: { 16 | help: "(使用 ↑↓ 移动,回车 确认)", 17 | }, 18 | confirm: { 19 | help: "(y/n)", 20 | }, 21 | input: { 22 | help: "请输入后按回车确认", 23 | }, 24 | }; 25 | 26 | // 创建自定义 prompt 函数 27 | async function promptWithChinese(questions) { 28 | // 为每个问题添加中文提示 29 | const processedQuestions = questions.map((q) => { 30 | const newQ = { ...q }; 31 | 32 | switch (q.type) { 33 | case "checkbox": 34 | newQ.message = 35 | q.message + " " + chalk.gray(chinesePrompts.checkbox.help); 36 | break; 37 | case "list": 38 | newQ.message = q.message + " " + chalk.gray(chinesePrompts.list.help); 39 | break; 40 | case "confirm": 41 | newQ.message = 42 | q.message + " " + chalk.gray(chinesePrompts.confirm.help); 43 | break; 44 | case "input": 45 | case "number": 46 | if (!q.message.includes("请输入")) { 47 | newQ.message = q.message + " " + chalk.gray("(请输入后按回车确认)"); 48 | } 49 | break; 50 | } 51 | 52 | return newQ; 53 | }); 54 | 55 | return await inquirer.prompt(processedQuestions); 56 | } 57 | 58 | async function main() { 59 | console.clear(); 60 | console.log(chalk.cyan.bold("\n=== 12306 余票监控交互模式 ===\n")); 61 | 62 | while (true) { 63 | // 主菜单 64 | const { action } = await promptWithChinese([ 65 | { 66 | type: "list", 67 | name: "action", 68 | message: "请选择操作:", 69 | choices: [ 70 | { name: "🔍 查询车次并配置监控", value: "query" }, 71 | { name: "⚙️ 编辑现有配置", value: "edit" }, 72 | { name: "📊 查看当前配置", value: "view" }, 73 | { name: "🚀 直接启动监控", value: "start" }, 74 | { name: "❌ 退出", value: "exit" }, 75 | ], 76 | }, 77 | ]); 78 | 79 | switch (action) { 80 | case "query": 81 | await queryAndConfig(); 82 | break; 83 | case "edit": 84 | await editConfig(); 85 | break; 86 | case "view": 87 | await viewConfig(); 88 | break; 89 | case "start": 90 | await startMonitoring(); 91 | return; // 启动监控后退出交互模式 92 | case "exit": 93 | console.log(chalk.yellow("已退出")); 94 | process.exit(0); 95 | } 96 | 97 | // 操作完成后显示分隔线 98 | console.log(chalk.gray("\n" + "=".repeat(50))); 99 | } 100 | } 101 | 102 | async function queryAndConfig(isFirstTime = true) { 103 | // 1. 输入出发地、目的地、日期 104 | const { from, to, date } = await promptWithChinese([ 105 | { 106 | name: "from", 107 | message: "请输入出发地(如: 上海):", 108 | validate: (v) => (v.trim() ? true : "不能为空"), 109 | }, 110 | { 111 | name: "to", 112 | message: "请输入目的地(如: 北京):", 113 | validate: (v) => (v.trim() ? true : "不能为空"), 114 | }, 115 | { 116 | name: "date", 117 | message: "请输入日期(YYYYMMDD):", 118 | validate: (v) => (/^\d{8}$/.test(v) ? true : "格式错误,请输入8位数字"), 119 | }, 120 | ]); 121 | 122 | // 2. 查询车次 123 | const fromCode = await ChinaRailway.getStationCode(from); 124 | const toCode = await ChinaRailway.getStationCode(to); 125 | if (!fromCode || !toCode) { 126 | console.log(chalk.red("站点名称无效,请检查输入!")); 127 | return; 128 | } 129 | let data; 130 | try { 131 | console.log(chalk.blue("正在查询车次信息...")); 132 | data = await ChinaRailway.checkTickets(date, fromCode, toCode); 133 | } catch (e) { 134 | console.log(chalk.red("查询失败:"), e.message); 135 | return; 136 | } 137 | const trains = data.data.result.map((row) => 138 | ChinaRailway.parseTrainInfo(row) 139 | ); 140 | if (!trains.length) { 141 | console.log(chalk.yellow("无可用车次!")); 142 | return; 143 | } 144 | 145 | // 3. 显示车次列表 146 | console.log(chalk.blue(`\n找到 ${trains.length} 个车次:\n`)); 147 | 148 | const tableData = await Promise.all( 149 | trains.map(async (train) => ({ 150 | 车次: chalk.green(train.station_train_code), 151 | 出发站: await ChinaRailway.getStationName(train.from_station_telecode), 152 | 到达站: await ChinaRailway.getStationName(train.to_station_telecode), 153 | 发车时间: train.start_time, 154 | 到达时间: train.arrive_time, 155 | 历时: train.lishi, 156 | 商务座: train.tickets.商务座 || "--", 157 | 一等座: train.tickets.一等座 || "--", 158 | 二等座: train.tickets.二等座 || "--", 159 | 硬卧: train.tickets.硬卧 || "--", 160 | 硬座: train.tickets.硬座 || "--", 161 | })) 162 | ); 163 | 164 | const table = chalkTable( 165 | { 166 | leftPad: 2, 167 | columns: [ 168 | { field: "车次", name: "车次" }, 169 | { field: "出发站", name: "出发站" }, 170 | { field: "到达站", name: "到达站" }, 171 | { field: "发车时间", name: "发车" }, 172 | { field: "到达时间", name: "到达" }, 173 | { field: "历时", name: "历时" }, 174 | { field: "商务座", name: "商务座" }, 175 | { field: "一等座", name: "一等座" }, 176 | { field: "二等座", name: "二等座" }, 177 | { field: "硬卧", name: "硬卧" }, 178 | { field: "硬座", name: "硬座" }, 179 | ], 180 | }, 181 | tableData 182 | ); 183 | console.log(table); 184 | 185 | // 4. 选择车次并配置详细参数 186 | const { selectedTrains } = await promptWithChinese([ 187 | { 188 | type: "checkbox", 189 | name: "selectedTrains", 190 | message: "请选择要监控的车次(可多选):", 191 | choices: trains.map((t) => ({ 192 | name: `${t.station_train_code} ${t.start_time}-${t.arrive_time}`, 193 | value: t, 194 | })), 195 | validate: (answer) => { 196 | if (answer.length < 1) { 197 | return "至少选择一个车次"; 198 | } 199 | return true; 200 | }, 201 | }, 202 | ]); 203 | 204 | if (!selectedTrains.length) { 205 | console.log(chalk.yellow("未选择任何车次,已退出。")); 206 | return; 207 | } 208 | 209 | // 5. 为所有选中的车次统一配置参数 210 | console.log(chalk.cyan(`\n为 ${selectedTrains.length} 个车次配置参数:`)); 211 | 212 | const { seatTypes, checkRoundTrip } = await promptWithChinese([ 213 | { 214 | type: "checkbox", 215 | name: "seatTypes", 216 | message: "选择要监控的席别(不选择则监控所有席别):", 217 | choices: [ 218 | { name: "商务座", value: "商务座" }, 219 | { name: "特等座", value: "特等座" }, 220 | { name: "一等座", value: "一等座" }, 221 | { name: "二等座", value: "二等座" }, 222 | { name: "软卧", value: "软卧" }, 223 | { name: "硬卧", value: "硬卧" }, 224 | { name: "软座", value: "软座" }, 225 | { name: "硬座", value: "硬座" }, 226 | { name: "无座", value: "无座" }, 227 | ], 228 | }, 229 | { 230 | type: "confirm", 231 | name: "checkRoundTrip", 232 | message: "是否查询全程票情况?", 233 | default: false, 234 | }, 235 | ]); 236 | 237 | // 为每个选中的车次应用相同的配置 238 | const configuredTrains = []; 239 | for (const train of selectedTrains) { 240 | const trainConfig = { 241 | code: train.station_train_code, 242 | from: await ChinaRailway.getStationName(train.from_station_telecode), 243 | to: await ChinaRailway.getStationName(train.to_station_telecode), 244 | checkRoundTrip, 245 | }; 246 | 247 | if (seatTypes.length > 0) { 248 | trainConfig.seatCategory = seatTypes; 249 | } 250 | 251 | configuredTrains.push(trainConfig); 252 | } 253 | 254 | // 6. 配置推送方式 255 | const { useNotifications } = await promptWithChinese([ 256 | { 257 | type: "confirm", 258 | name: "useNotifications", 259 | message: "是否配置推送通知?", 260 | default: false, 261 | }, 262 | ]); 263 | 264 | let notifications = []; 265 | if (useNotifications) { 266 | const { notificationType } = await promptWithChinese([ 267 | { 268 | type: "list", 269 | name: "notificationType", 270 | message: "选择推送方式:", 271 | choices: [ 272 | { name: "飞书推送", value: "Lark" }, 273 | { name: "Telegram推送", value: "Telegram" }, 274 | { name: "企业微信推送", value: "WechatWork" }, 275 | { name: "Bark推送", value: "Bark" }, 276 | { name: "SMTP邮件推送", value: "SMTP" }, 277 | ], 278 | }, 279 | ]); 280 | 281 | if (notificationType === "Lark") { 282 | const { webhook } = await promptWithChinese([ 283 | { 284 | name: "webhook", 285 | message: "请输入飞书机器人Webhook URL:", 286 | validate: (v) => 287 | v.includes("feishu.cn") 288 | ? true 289 | : "URL格式错误,请输入正确的飞书机器人URL", 290 | }, 291 | ]); 292 | 293 | const { needSecret } = await promptWithChinese([ 294 | { 295 | type: "confirm", 296 | name: "needSecret", 297 | message: "是否启用签名校验?(建议启用以提高安全性)", 298 | default: false, 299 | }, 300 | ]); 301 | 302 | let secret = ""; 303 | if (needSecret) { 304 | const secretInput = await promptWithChinese([ 305 | { 306 | name: "secret", 307 | message: "请输入签名密钥(从飞书机器人安全设置中获取):", 308 | validate: (v) => (v.trim() ? true : "密钥不能为空"), 309 | }, 310 | ]); 311 | secret = secretInput.secret; 312 | } 313 | 314 | const larkConfig = { type: "Lark", webhook }; 315 | if (secret) { 316 | larkConfig.secret = secret; 317 | } 318 | notifications.push(larkConfig); 319 | } else if (notificationType === "Telegram") { 320 | const { botToken, chatId } = await promptWithChinese([ 321 | { 322 | name: "botToken", 323 | message: "请输入Telegram Bot Token:", 324 | validate: (v) => 325 | v.includes(":") ? true : "格式错误,Token应包含冒号", 326 | }, 327 | { 328 | name: "chatId", 329 | message: "请输入Chat ID:", 330 | validate: (v) => (v.trim() ? true : "Chat ID不能为空"), 331 | }, 332 | ]); 333 | notifications.push({ type: "Telegram", botToken, chatId }); 334 | } else if (notificationType === "WechatWork") { 335 | const { webhook } = await promptWithChinese([ 336 | { 337 | name: "webhook", 338 | message: "请输入企业微信机器人Webhook URL:", 339 | validate: (v) => 340 | v.includes("qyapi.weixin.qq.com") 341 | ? true 342 | : "URL格式错误,请输入正确的企业微信机器人URL", 343 | }, 344 | ]); 345 | notifications.push({ type: "WechatWork", webhook }); 346 | } else if (notificationType === "Bark") { 347 | const barkConfig = await promptWithChinese([ 348 | { 349 | name: "deviceKey", 350 | message: "请输入Bark设备密钥(Device Key):", 351 | validate: (v) => (v.trim() ? true : "设备密钥不能为空"), 352 | }, 353 | { 354 | name: "serverUrl", 355 | message: "请输入Bark服务器地址(默认: https://api.day.app):", 356 | default: "https://api.day.app", 357 | }, 358 | { 359 | name: "group", 360 | message: "推送分组名称(可选):", 361 | default: "火车票监控", 362 | }, 363 | { 364 | name: "sound", 365 | message: "推送声音(可选, 默认: default):", 366 | default: "default", 367 | }, 368 | ]); 369 | 370 | // 询问是否配置高级选项 371 | const { useAdvanced } = await promptWithChinese([ 372 | { 373 | type: "confirm", 374 | name: "useAdvanced", 375 | message: "是否配置高级选项(推送级别、图标等)?", 376 | default: false, 377 | }, 378 | ]); 379 | 380 | if (useAdvanced) { 381 | const advancedConfig = await promptWithChinese([ 382 | { 383 | type: "list", 384 | name: "level", 385 | message: "推送级别:", 386 | choices: [ 387 | { name: "默认(active)", value: "active" }, 388 | { name: "重要警告(critical)", value: "critical" }, 389 | { name: "时效性通知(timeSensitive)", value: "timeSensitive" }, 390 | { name: "仅添加到列表(passive)", value: "passive" }, 391 | ], 392 | default: "active", 393 | }, 394 | { 395 | name: "icon", 396 | message: "自定义图标URL(可选):", 397 | }, 398 | { 399 | name: "url", 400 | message: "点击跳转URL(可选):", 401 | }, 402 | { 403 | type: "confirm", 404 | name: "autoCopy", 405 | message: "自动复制推送内容?", 406 | default: false, 407 | }, 408 | { 409 | type: "confirm", 410 | name: "isArchive", 411 | message: "保存推送到历史记录?", 412 | default: true, 413 | }, 414 | ]); 415 | 416 | Object.assign(barkConfig, advancedConfig); 417 | } 418 | 419 | notifications.push({ type: "Bark", ...barkConfig }); 420 | } else if (notificationType === "SMTP") { 421 | console.log(chalk.cyan("配置SMTP邮件推送:")); 422 | 423 | const smtpConfig = await promptWithChinese([ 424 | { 425 | name: "host", 426 | message: "SMTP服务器地址(如: smtp.gmail.com):", 427 | validate: (v) => (v.trim() ? true : "SMTP服务器地址不能为空"), 428 | }, 429 | { 430 | type: "number", 431 | name: "port", 432 | message: "SMTP端口号(常用: 587-STARTTLS, 465-SSL, 25-无加密):", 433 | default: 587, 434 | validate: (v) => 435 | v > 0 && v <= 65535 ? true : "端口号必须在1-65535之间", 436 | }, 437 | { 438 | name: "user", 439 | message: "邮箱用户名:", 440 | validate: (v) => (v.trim() ? true : "邮箱用户名不能为空"), 441 | }, 442 | { 443 | type: "password", 444 | name: "pass", 445 | message: "邮箱密码或应用密码:", 446 | validate: (v) => (v.trim() ? true : "密码不能为空"), 447 | }, 448 | { 449 | name: "from", 450 | message: "发件人显示名称(可选, 默认使用用户名):", 451 | }, 452 | { 453 | name: "to", 454 | message: "收件人邮箱地址:", 455 | validate: (v) => { 456 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 457 | return emailRegex.test(v.trim()) ? true : "请输入有效的邮箱地址"; 458 | }, 459 | }, 460 | ]); 461 | 462 | // 询问是否配置高级选项 463 | const { useAdvancedSMTP } = await promptWithChinese([ 464 | { 465 | type: "confirm", 466 | name: "useAdvancedSMTP", 467 | message: "是否配置高级选项(安全连接、抄送等)?", 468 | default: false, 469 | }, 470 | ]); 471 | 472 | if (useAdvancedSMTP) { 473 | const advancedSMTPConfig = await promptWithChinese([ 474 | { 475 | type: "list", 476 | name: "secure", 477 | message: "安全连接类型:", 478 | choices: [ 479 | { name: "自动检测(推荐)", value: undefined }, 480 | { name: "SSL/TLS (端口465)", value: true }, 481 | { name: "STARTTLS (端口587)", value: false }, 482 | ], 483 | default: undefined, 484 | }, 485 | { 486 | name: "cc", 487 | message: "抄送邮箱(多个用逗号分隔, 可选):", 488 | }, 489 | { 490 | name: "bcc", 491 | message: "密送邮箱(多个用逗号分隔, 可选):", 492 | }, 493 | { 494 | name: "replyTo", 495 | message: "回复邮箱(可选):", 496 | }, 497 | ]); 498 | 499 | Object.assign(smtpConfig, advancedSMTPConfig); 500 | } 501 | 502 | notifications.push({ type: "SMTP", ...smtpConfig }); 503 | } 504 | } 505 | 506 | // 7. 配置监控参数 507 | const { interval, delay } = await promptWithChinese([ 508 | { 509 | type: "number", 510 | name: "interval", 511 | message: "查询间隔(分钟):", 512 | default: 15, 513 | validate: (v) => (v > 0 ? true : "间隔时间必须大于0分钟"), 514 | }, 515 | { 516 | type: "number", 517 | name: "delay", 518 | message: "访问延迟(秒):", 519 | default: 5, 520 | validate: (v) => (v >= 0 ? true : "延迟时间必须大于等于0秒"), 521 | }, 522 | ]); 523 | 524 | // 8. 生成配置 525 | const config = { 526 | watch: [ 527 | { 528 | from, 529 | to, 530 | date, 531 | trains: configuredTrains, 532 | }, 533 | ], 534 | notifications, 535 | interval, 536 | delay, 537 | }; 538 | 539 | // 只有首次配置时才直接保存文件 540 | if (isFirstTime) { 541 | fs.writeFileSync( 542 | "config.yml", 543 | yaml.dump(config, { quotingType: '"', forceQuotes: false }), 544 | "utf-8" 545 | ); 546 | console.log(chalk.green("\n✅ 配置已保存到 config.yml")); 547 | } 548 | 549 | console.log(chalk.blue("\n📋 配置摘要:")); 550 | console.log(chalk.white(`📍 监控路线: ${from} → ${to}`)); 551 | console.log(chalk.white(`📅 出行日期: ${date}`)); 552 | console.log(chalk.white(`🚄 监控车次: ${configuredTrains.length} 个`)); 553 | 554 | // 显示车次列表和席别信息 555 | configuredTrains.forEach((train, index) => { 556 | const seatInfo = 557 | train.seatCategory && train.seatCategory.length > 0 558 | ? `(${train.seatCategory.join(", ")})` 559 | : "(所有席别)"; 560 | console.log(chalk.gray(` ${index + 1}. ${train.code} ${seatInfo}`)); 561 | }); 562 | 563 | console.log( 564 | chalk.white( 565 | `📲 推送方式: ${notifications.length ? notifications[0].type : "无"}` 566 | ) 567 | ); 568 | console.log(chalk.white(`⏰ 查询间隔: ${interval} 分钟`)); 569 | 570 | // 9. 只有在首次配置时才询问是否立即开始监控 571 | if (isFirstTime) { 572 | const { startNow } = await promptWithChinese([ 573 | { 574 | type: "confirm", 575 | name: "startNow", 576 | message: "是否立即开始监控?", 577 | default: true, 578 | }, 579 | ]); 580 | 581 | if (startNow) { 582 | console.log(chalk.green("\n正在启动监控程序...\n")); 583 | const { spawn } = await import("child_process"); 584 | spawn("node", ["src/index.js"], { stdio: "inherit", cwd: process.cwd() }); 585 | } else { 586 | // 如果是首次配置且选择不立即启动,询问是否返回主菜单 587 | const { backToMenu } = await promptWithChinese([ 588 | { 589 | type: "confirm", 590 | name: "backToMenu", 591 | message: "是否返回主菜单?", 592 | default: true, 593 | }, 594 | ]); 595 | 596 | if (backToMenu) { 597 | console.log(chalk.cyan("\n返回主菜单...")); 598 | return config; 599 | } 600 | } 601 | } 602 | 603 | return config; 604 | } 605 | 606 | async function editConfig() { 607 | try { 608 | const configContent = fs.readFileSync("config.yml", "utf-8"); 609 | const config = yaml.load(configContent); 610 | 611 | console.log(chalk.blue("\n📋 当前配置预览:")); 612 | console.log(chalk.cyan("监控任务:")); 613 | config.watch.forEach((watch, index) => { 614 | console.log( 615 | chalk.white( 616 | ` ${index + 1}. ${watch.from} → ${watch.to} (${watch.date})` 617 | ) 618 | ); 619 | if (watch.trains && watch.trains.length > 0) { 620 | console.log( 621 | chalk.gray(` 车次: ${watch.trains.map((t) => t.code).join(", ")}`) 622 | ); 623 | } 624 | }); 625 | 626 | console.log(chalk.cyan("推送配置:")); 627 | if (config.notifications && config.notifications.length > 0) { 628 | config.notifications.forEach((notif, index) => { 629 | let details = ""; 630 | if (notif.type === "Lark") { 631 | details = notif.webhook?.match(/^https?:\/\/(.+?)\/.*$/)?.[1] || ""; 632 | if (notif.secret) { 633 | details += " (已启用签名校验)"; 634 | } 635 | } else if (notif.type === "Telegram") { 636 | details = `Chat ID: ${notif.chatId || ""}`; 637 | } else if (notif.type === "WechatWork") { 638 | details = 639 | notif.webhook?.match(/key=([^&]+)/)?.[1]?.substring(0, 8) + "..." || 640 | ""; 641 | } else if (notif.type === "Bark") { 642 | details = `设备: ${notif.deviceKey?.substring(0, 8)}...`; 643 | if (notif.group) details += `, 分组: ${notif.group}`; 644 | } else if (notif.type === "SMTP") { 645 | details = `邮箱: ${notif.to}`; 646 | if (notif.host) details += ` (${notif.host})`; 647 | } 648 | console.log( 649 | chalk.white( 650 | ` ${index + 1}. ${notif.type}${details ? ` (${details})` : ""}` 651 | ) 652 | ); 653 | }); 654 | } else { 655 | console.log(chalk.gray(" 未配置推送")); 656 | } 657 | 658 | console.log(chalk.cyan("查询参数:")); 659 | console.log( 660 | chalk.white( 661 | ` 间隔: ${config.interval || 15}分钟, 延迟: ${config.delay || 5}秒` 662 | ) 663 | ); 664 | console.log(); 665 | 666 | const { editType } = await promptWithChinese([ 667 | { 668 | type: "list", 669 | name: "editType", 670 | message: "选择编辑类型:", 671 | choices: [ 672 | { name: "➕ 添加监控任务", value: "add" }, 673 | { name: "✏️ 修改监控任务", value: "editWatch" }, 674 | { name: "🗑️ 删除监控任务", value: "deleteWatch" }, 675 | { name: "📲 修改推送配置", value: "notification" }, 676 | { name: "⚙️ 修改查询参数", value: "params" }, 677 | { name: "🔄 重置全部配置", value: "reset" }, 678 | { name: "❌ 返回主菜单", value: "back" }, 679 | ], 680 | }, 681 | ]); 682 | 683 | switch (editType) { 684 | case "add": 685 | await addMonitorTask(config); 686 | break; 687 | case "editWatch": 688 | await editMonitorTask(config); 689 | break; 690 | case "deleteWatch": 691 | await deleteMonitorTask(config); 692 | break; 693 | case "notification": 694 | await editNotificationConfig(config); 695 | break; 696 | case "params": 697 | await editQueryParams(config); 698 | break; 699 | case "reset": 700 | await resetConfig(); 701 | break; 702 | case "back": 703 | console.log(chalk.yellow("返回主菜单")); 704 | return; 705 | } 706 | } catch (err) { 707 | console.log(chalk.red("配置文件不存在或格式错误:", err.message)); 708 | } 709 | } 710 | 711 | // 添加监控任务 712 | async function addMonitorTask(config) { 713 | console.log(chalk.cyan("\n➕ 添加新的监控任务")); 714 | const newTask = await queryAndConfig(false); 715 | if (newTask && newTask.watch && newTask.watch[0]) { 716 | // 添加监控任务 717 | config.watch.push(newTask.watch[0]); 718 | 719 | // 合并推送配置(如果新任务包含推送配置) 720 | if (newTask.notifications && newTask.notifications.length > 0) { 721 | if (!config.notifications) { 722 | config.notifications = []; 723 | } 724 | 725 | // 检查是否有重复的推送配置,避免重复添加 726 | for (const newNotif of newTask.notifications) { 727 | const isDuplicate = config.notifications.some((existingNotif) => { 728 | if (existingNotif.type !== newNotif.type) return false; 729 | 730 | // 根据不同类型检查是否重复 731 | switch (newNotif.type) { 732 | case "Lark": 733 | case "WechatWork": 734 | return existingNotif.webhook === newNotif.webhook; 735 | case "Telegram": 736 | return ( 737 | existingNotif.botToken === newNotif.botToken && 738 | existingNotif.chatId === newNotif.chatId 739 | ); 740 | case "Bark": 741 | return existingNotif.deviceKey === newNotif.deviceKey; 742 | case "SMTP": 743 | return ( 744 | existingNotif.host === newNotif.host && 745 | existingNotif.user === newNotif.user && 746 | existingNotif.to === newNotif.to 747 | ); 748 | default: 749 | return false; 750 | } 751 | }); 752 | 753 | if (!isDuplicate) { 754 | config.notifications.push(newNotif); 755 | } else { 756 | console.log( 757 | chalk.yellow(`⚠️ 推送配置 ${newNotif.type} 已存在,跳过添加`) 758 | ); 759 | } 760 | } 761 | } 762 | 763 | // 更新查询参数(如果新任务设置了新的参数) 764 | if (newTask.interval !== undefined) { 765 | config.interval = newTask.interval; 766 | } 767 | if (newTask.delay !== undefined) { 768 | config.delay = newTask.delay; 769 | } 770 | 771 | fs.writeFileSync("config.yml", yaml.dump(config), "utf-8"); 772 | console.log(chalk.green("✅ 监控任务已添加!")); 773 | 774 | // 显示添加的内容摘要 775 | console.log(chalk.blue("\n📋 添加的内容:")); 776 | console.log( 777 | chalk.white( 778 | `📍 监控路线: ${newTask.watch[0].from} → ${newTask.watch[0].to}` 779 | ) 780 | ); 781 | console.log(chalk.white(`📅 出行日期: ${newTask.watch[0].date}`)); 782 | console.log( 783 | chalk.white(`🚄 监控车次: ${newTask.watch[0].trains?.length || 0} 个`) 784 | ); 785 | 786 | // 显示车次和席别信息 787 | if (newTask.watch[0].trains) { 788 | newTask.watch[0].trains.forEach((train, index) => { 789 | const seatInfo = 790 | train.seatCategory && train.seatCategory.length > 0 791 | ? `(${train.seatCategory.join(", ")})` 792 | : "(所有席别)"; 793 | console.log(chalk.gray(` ${index + 1}. ${train.code} ${seatInfo}`)); 794 | }); 795 | } 796 | 797 | if (newTask.notifications && newTask.notifications.length > 0) { 798 | console.log( 799 | chalk.white( 800 | `📲 推送配置: ${newTask.notifications.map((n) => n.type).join(", ")}` 801 | ) 802 | ); 803 | } 804 | } 805 | 806 | // 询问是否继续编辑 807 | const { continueEdit } = await promptWithChinese([ 808 | { 809 | type: "confirm", 810 | name: "continueEdit", 811 | message: "是否继续编辑配置?", 812 | default: true, 813 | }, 814 | ]); 815 | 816 | if (continueEdit) { 817 | await editConfig(); 818 | } 819 | } 820 | 821 | // 修改监控任务 822 | async function editMonitorTask(config) { 823 | if (!config.watch || config.watch.length === 0) { 824 | console.log(chalk.yellow("暂无监控任务")); 825 | return; 826 | } 827 | 828 | const { taskIndex } = await promptWithChinese([ 829 | { 830 | type: "list", 831 | name: "taskIndex", 832 | message: "选择要修改的监控任务:", 833 | choices: config.watch.map((watch, index) => ({ 834 | name: `${index + 1}. ${watch.from} → ${watch.to} (${watch.date})`, 835 | value: index, 836 | })), 837 | }, 838 | ]); 839 | 840 | const task = config.watch[taskIndex]; 841 | const { editField } = await promptWithChinese([ 842 | { 843 | type: "list", 844 | name: "editField", 845 | message: "选择要修改的内容:", 846 | choices: [ 847 | { name: "📅 修改日期", value: "date" }, 848 | { name: "🚄 修改车次配置", value: "trains" }, 849 | { name: "🎫 修改席别配置", value: "seats" }, 850 | { name: "🔄 重新配置整个任务", value: "recreate" }, 851 | ], 852 | }, 853 | ]); 854 | 855 | switch (editField) { 856 | case "date": 857 | const { newDate } = await promptWithChinese([ 858 | { 859 | name: "newDate", 860 | message: "请输入新的日期(YYYYMMDD):", 861 | default: task.date, 862 | validate: (v) => 863 | /^\d{8}$/.test(v) ? true : "格式错误,请输入8位数字", 864 | }, 865 | ]); 866 | task.date = newDate; 867 | break; 868 | 869 | case "trains": 870 | // 重新查询和选择车次 871 | try { 872 | const fromCode = await ChinaRailway.getStationCode(task.from); 873 | const toCode = await ChinaRailway.getStationCode(task.to); 874 | const data = await ChinaRailway.checkTickets( 875 | task.date, 876 | fromCode, 877 | toCode 878 | ); 879 | const trains = data.data.result.map((row) => 880 | ChinaRailway.parseTrainInfo(row) 881 | ); 882 | 883 | const { selectedTrains } = await promptWithChinese([ 884 | { 885 | type: "checkbox", 886 | name: "selectedTrains", 887 | message: "重新选择要监控的车次:", 888 | choices: trains.map((t) => ({ 889 | name: `${t.station_train_code} ${t.start_time}-${t.arrive_time}`, 890 | value: t, 891 | checked: task.trains?.some( 892 | (existing) => existing.code === t.station_train_code 893 | ), 894 | })), 895 | validate: (answer) => 896 | answer.length > 0 ? true : "至少选择一个车次", 897 | }, 898 | ]); 899 | 900 | task.trains = await Promise.all( 901 | selectedTrains.map(async (train) => ({ 902 | code: train.station_train_code, 903 | from: await ChinaRailway.getStationName( 904 | train.from_station_telecode 905 | ), 906 | to: await ChinaRailway.getStationName(train.to_station_telecode), 907 | checkRoundTrip: false, 908 | })) 909 | ); 910 | } catch (e) { 911 | console.log(chalk.red("查询车次失败:", e.message)); 912 | return; 913 | } 914 | break; 915 | 916 | case "seats": 917 | if (!task.trains || task.trains.length === 0) { 918 | console.log(chalk.yellow("请先配置车次")); 919 | return; 920 | } 921 | 922 | // 询问配置方式 923 | const { configMode } = await promptWithChinese([ 924 | { 925 | type: "list", 926 | name: "configMode", 927 | message: "选择席别配置方式:", 928 | choices: [ 929 | { name: "📦 统一配置所有车次", value: "unified" }, 930 | { name: "🔧 单独配置每个车次", value: "individual" }, 931 | ], 932 | }, 933 | ]); 934 | 935 | if (configMode === "unified") { 936 | // 统一配置模式 937 | console.log( 938 | chalk.cyan(`\n为 ${task.trains.length} 个车次统一配置席别:`) 939 | ); 940 | 941 | const { seatTypes } = await promptWithChinese([ 942 | { 943 | type: "checkbox", 944 | name: "seatTypes", 945 | message: "选择要监控的席别(不选择则监控所有席别):", 946 | choices: [ 947 | { name: "商务座", value: "商务座" }, 948 | { name: "特等座", value: "特等座" }, 949 | { name: "一等座", value: "一等座" }, 950 | { name: "二等座", value: "二等座" }, 951 | { name: "软卧", value: "软卧" }, 952 | { name: "硬卧", value: "硬卧" }, 953 | { name: "软座", value: "软座" }, 954 | { name: "硬座", value: "硬座" }, 955 | { name: "无座", value: "无座" }, 956 | ], 957 | }, 958 | ]); 959 | 960 | // 应用到所有车次 961 | for (const train of task.trains) { 962 | if (seatTypes.length > 0) { 963 | train.seatCategory = seatTypes; 964 | } else { 965 | delete train.seatCategory; 966 | } 967 | } 968 | } else { 969 | // 单独配置模式 970 | for (const train of task.trains) { 971 | const { seatTypes } = await promptWithChinese([ 972 | { 973 | type: "checkbox", 974 | name: "seatTypes", 975 | message: `配置车次 ${train.code} 的席别:`, 976 | choices: [ 977 | { 978 | name: "商务座", 979 | value: "商务座", 980 | checked: train.seatCategory?.includes("商务座"), 981 | }, 982 | { 983 | name: "特等座", 984 | value: "特等座", 985 | checked: train.seatCategory?.includes("特等座"), 986 | }, 987 | { 988 | name: "一等座", 989 | value: "一等座", 990 | checked: train.seatCategory?.includes("一等座"), 991 | }, 992 | { 993 | name: "二等座", 994 | value: "二等座", 995 | checked: train.seatCategory?.includes("二等座"), 996 | }, 997 | { 998 | name: "软卧", 999 | value: "软卧", 1000 | checked: train.seatCategory?.includes("软卧"), 1001 | }, 1002 | { 1003 | name: "硬卧", 1004 | value: "硬卧", 1005 | checked: train.seatCategory?.includes("硬卧"), 1006 | }, 1007 | { 1008 | name: "软座", 1009 | value: "软座", 1010 | checked: train.seatCategory?.includes("软座"), 1011 | }, 1012 | { 1013 | name: "硬座", 1014 | value: "硬座", 1015 | checked: train.seatCategory?.includes("硬座"), 1016 | }, 1017 | { 1018 | name: "无座", 1019 | value: "无座", 1020 | checked: train.seatCategory?.includes("无座"), 1021 | }, 1022 | ], 1023 | }, 1024 | ]); 1025 | 1026 | if (seatTypes.length > 0) { 1027 | train.seatCategory = seatTypes; 1028 | } else { 1029 | delete train.seatCategory; 1030 | } 1031 | } 1032 | } 1033 | break; 1034 | 1035 | case "recreate": 1036 | console.log(chalk.cyan("重新配置任务,当前配置将被替换")); 1037 | const newTask = await queryAndConfig(false); 1038 | if (newTask && newTask.watch && newTask.watch[0]) { 1039 | config.watch[taskIndex] = newTask.watch[0]; 1040 | } 1041 | return; 1042 | } 1043 | 1044 | fs.writeFileSync("config.yml", yaml.dump(config), "utf-8"); 1045 | console.log(chalk.green("✅ 监控任务已更新!")); 1046 | 1047 | // 询问是否继续编辑 1048 | const { continueEdit } = await promptWithChinese([ 1049 | { 1050 | type: "confirm", 1051 | name: "continueEdit", 1052 | message: "是否继续编辑配置?", 1053 | default: true, 1054 | }, 1055 | ]); 1056 | 1057 | if (continueEdit) { 1058 | await editConfig(); 1059 | } 1060 | } 1061 | 1062 | // 删除监控任务 1063 | async function deleteMonitorTask(config) { 1064 | if (!config.watch || config.watch.length === 0) { 1065 | console.log(chalk.yellow("暂无监控任务")); 1066 | return; 1067 | } 1068 | 1069 | const { taskIndex } = await promptWithChinese([ 1070 | { 1071 | type: "list", 1072 | name: "taskIndex", 1073 | message: "选择要删除的监控任务:", 1074 | choices: config.watch.map((watch, index) => ({ 1075 | name: `${index + 1}. ${watch.from} → ${watch.to} (${watch.date})`, 1076 | value: index, 1077 | })), 1078 | }, 1079 | ]); 1080 | 1081 | const task = config.watch[taskIndex]; 1082 | const { confirmDelete } = await promptWithChinese([ 1083 | { 1084 | type: "confirm", 1085 | name: "confirmDelete", 1086 | message: `确认删除任务 "${task.from} → ${task.to} (${task.date})" ?`, 1087 | default: false, 1088 | }, 1089 | ]); 1090 | 1091 | if (confirmDelete) { 1092 | config.watch.splice(taskIndex, 1); 1093 | fs.writeFileSync("config.yml", yaml.dump(config), "utf-8"); 1094 | console.log(chalk.green("✅ 监控任务已删除!")); 1095 | } else { 1096 | console.log(chalk.yellow("已取消删除")); 1097 | } 1098 | 1099 | // 询问是否继续编辑 1100 | const { continueEdit } = await promptWithChinese([ 1101 | { 1102 | type: "confirm", 1103 | name: "continueEdit", 1104 | message: "是否继续编辑配置?", 1105 | default: true, 1106 | }, 1107 | ]); 1108 | 1109 | if (continueEdit) { 1110 | await editConfig(); 1111 | } 1112 | } 1113 | 1114 | // 修改推送配置 1115 | async function editNotificationConfig(config) { 1116 | const { notifAction } = await promptWithChinese([ 1117 | { 1118 | type: "list", 1119 | name: "notifAction", 1120 | message: "选择推送配置操作:", 1121 | choices: [ 1122 | { name: "➕ 添加推送配置", value: "add" }, 1123 | { name: "✏️ 修改推送配置", value: "edit" }, 1124 | { name: "🗑️ 删除推送配置", value: "delete" }, 1125 | { name: "🧹 清空所有推送配置", value: "clear" }, 1126 | ], 1127 | }, 1128 | ]); 1129 | 1130 | switch (notifAction) { 1131 | case "add": 1132 | const { notificationType } = await promptWithChinese([ 1133 | { 1134 | type: "list", 1135 | name: "notificationType", 1136 | message: "选择推送方式:", 1137 | choices: [ 1138 | { name: "飞书推送", value: "Lark" }, 1139 | { name: "Telegram推送", value: "Telegram" }, 1140 | { name: "企业微信推送", value: "WechatWork" }, 1141 | { name: "Bark推送", value: "Bark" }, 1142 | { name: "SMTP邮件推送", value: "SMTP" }, 1143 | ], 1144 | }, 1145 | ]); 1146 | 1147 | let newNotification = { type: notificationType }; 1148 | 1149 | if (notificationType === "Lark") { 1150 | const { webhook } = await promptWithChinese([ 1151 | { 1152 | name: "webhook", 1153 | message: "请输入飞书机器人Webhook URL:", 1154 | validate: (v) => (v.includes("feishu.cn") ? true : "URL格式错误"), 1155 | }, 1156 | ]); 1157 | newNotification.webhook = webhook; 1158 | 1159 | const { needSecret } = await promptWithChinese([ 1160 | { 1161 | type: "confirm", 1162 | name: "needSecret", 1163 | message: "是否启用签名校验?(建议启用以提高安全性)", 1164 | default: false, 1165 | }, 1166 | ]); 1167 | 1168 | if (needSecret) { 1169 | const { secret } = await promptWithChinese([ 1170 | { 1171 | name: "secret", 1172 | message: "请输入签名密钥(从飞书机器人安全设置中获取):", 1173 | validate: (v) => (v.trim() ? true : "密钥不能为空"), 1174 | }, 1175 | ]); 1176 | newNotification.secret = secret; 1177 | } 1178 | } else if (notificationType === "Telegram") { 1179 | const { botToken, chatId } = await promptWithChinese([ 1180 | { 1181 | name: "botToken", 1182 | message: "请输入Telegram Bot Token:", 1183 | validate: (v) => (v.includes(":") ? true : "格式错误"), 1184 | }, 1185 | { 1186 | name: "chatId", 1187 | message: "请输入Chat ID:", 1188 | validate: (v) => (v.trim() ? true : "不能为空"), 1189 | }, 1190 | ]); 1191 | newNotification.botToken = botToken; 1192 | newNotification.chatId = chatId; 1193 | } else if (notificationType === "WechatWork") { 1194 | const { webhook } = await promptWithChinese([ 1195 | { 1196 | name: "webhook", 1197 | message: "请输入企业微信机器人Webhook URL:", 1198 | validate: (v) => 1199 | v.includes("qyapi.weixin.qq.com") ? true : "URL格式错误", 1200 | }, 1201 | ]); 1202 | newNotification.webhook = webhook; 1203 | } else if (notificationType === "Bark") { 1204 | const barkConfig = await promptWithChinese([ 1205 | { 1206 | name: "deviceKey", 1207 | message: "请输入Bark设备密钥(Device Key):", 1208 | validate: (v) => (v.trim() ? true : "设备密钥不能为空"), 1209 | }, 1210 | { 1211 | name: "serverUrl", 1212 | message: "请输入Bark服务器地址(默认: https://api.day.app):", 1213 | default: "https://api.day.app", 1214 | }, 1215 | { 1216 | name: "group", 1217 | message: "推送分组名称(可选):", 1218 | default: "火车票监控", 1219 | }, 1220 | { 1221 | name: "sound", 1222 | message: "推送声音(可选, 默认: default):", 1223 | default: "default", 1224 | }, 1225 | ]); 1226 | 1227 | // 询问是否配置高级选项 1228 | const { useAdvanced } = await promptWithChinese([ 1229 | { 1230 | type: "confirm", 1231 | name: "useAdvanced", 1232 | message: "是否配置高级选项(推送级别、图标等)?", 1233 | default: false, 1234 | }, 1235 | ]); 1236 | 1237 | if (useAdvanced) { 1238 | const advancedConfig = await promptWithChinese([ 1239 | { 1240 | type: "list", 1241 | name: "level", 1242 | message: "推送级别:", 1243 | choices: [ 1244 | { name: "默认(active)", value: "active" }, 1245 | { name: "重要警告(critical)", value: "critical" }, 1246 | { name: "时效性通知(timeSensitive)", value: "timeSensitive" }, 1247 | { name: "仅添加到列表(passive)", value: "passive" }, 1248 | ], 1249 | default: "active", 1250 | }, 1251 | { 1252 | name: "icon", 1253 | message: "自定义图标URL(可选):", 1254 | }, 1255 | { 1256 | name: "url", 1257 | message: "点击跳转URL(可选):", 1258 | }, 1259 | { 1260 | type: "confirm", 1261 | name: "autoCopy", 1262 | message: "自动复制推送内容?", 1263 | default: false, 1264 | }, 1265 | { 1266 | type: "confirm", 1267 | name: "isArchive", 1268 | message: "保存推送到历史记录?", 1269 | default: true, 1270 | }, 1271 | ]); 1272 | 1273 | Object.assign(barkConfig, advancedConfig); 1274 | } 1275 | 1276 | Object.assign(newNotification, barkConfig); 1277 | } else if (notificationType === "SMTP") { 1278 | console.log(chalk.cyan("配置SMTP邮件推送:")); 1279 | 1280 | const smtpConfig = await promptWithChinese([ 1281 | { 1282 | name: "host", 1283 | message: "SMTP服务器地址(如: smtp.gmail.com):", 1284 | validate: (v) => (v.trim() ? true : "SMTP服务器地址不能为空"), 1285 | }, 1286 | { 1287 | type: "number", 1288 | name: "port", 1289 | message: "SMTP端口号(常用: 587-STARTTLS, 465-SSL, 25-无加密):", 1290 | default: 587, 1291 | validate: (v) => 1292 | v > 0 && v <= 65535 ? true : "端口号必须在1-65535之间", 1293 | }, 1294 | { 1295 | name: "user", 1296 | message: "邮箱用户名:", 1297 | validate: (v) => (v.trim() ? true : "邮箱用户名不能为空"), 1298 | }, 1299 | { 1300 | type: "password", 1301 | name: "pass", 1302 | message: "邮箱密码或应用密码:", 1303 | validate: (v) => (v.trim() ? true : "密码不能为空"), 1304 | }, 1305 | { 1306 | name: "from", 1307 | message: "发件人显示名称(可选, 默认使用用户名):", 1308 | }, 1309 | { 1310 | name: "to", 1311 | message: "收件人邮箱地址:", 1312 | validate: (v) => { 1313 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 1314 | return emailRegex.test(v.trim()) ? true : "请输入有效的邮箱地址"; 1315 | }, 1316 | }, 1317 | ]); 1318 | 1319 | // 询问是否配置高级选项 1320 | const { useAdvancedSMTP } = await promptWithChinese([ 1321 | { 1322 | type: "confirm", 1323 | name: "useAdvancedSMTP", 1324 | message: "是否配置高级选项(安全连接、抄送等)?", 1325 | default: false, 1326 | }, 1327 | ]); 1328 | 1329 | if (useAdvancedSMTP) { 1330 | const advancedSMTPConfig = await promptWithChinese([ 1331 | { 1332 | type: "list", 1333 | name: "secure", 1334 | message: "安全连接类型:", 1335 | choices: [ 1336 | { name: "自动检测(推荐)", value: undefined }, 1337 | { name: "SSL/TLS (端口465)", value: true }, 1338 | { name: "STARTTLS (端口587)", value: false }, 1339 | ], 1340 | default: undefined, 1341 | }, 1342 | { 1343 | name: "cc", 1344 | message: "抄送邮箱(多个用逗号分隔, 可选):", 1345 | }, 1346 | { 1347 | name: "bcc", 1348 | message: "密送邮箱(多个用逗号分隔, 可选):", 1349 | }, 1350 | { 1351 | name: "replyTo", 1352 | message: "回复邮箱(可选):", 1353 | }, 1354 | ]); 1355 | 1356 | Object.assign(smtpConfig, advancedSMTPConfig); 1357 | } 1358 | 1359 | Object.assign(newNotification, smtpConfig); 1360 | } 1361 | 1362 | if (!config.notifications) config.notifications = []; 1363 | config.notifications.push(newNotification); 1364 | break; 1365 | 1366 | case "edit": 1367 | if (!config.notifications || config.notifications.length === 0) { 1368 | console.log(chalk.yellow("暂无推送配置")); 1369 | return; 1370 | } 1371 | 1372 | const { notifIndex } = await promptWithChinese([ 1373 | { 1374 | type: "list", 1375 | name: "notifIndex", 1376 | message: "选择要修改的推送配置:", 1377 | choices: config.notifications.map((notif, index) => ({ 1378 | name: `${index + 1}. ${notif.type}`, 1379 | value: index, 1380 | })), 1381 | }, 1382 | ]); 1383 | 1384 | const notif = config.notifications[notifIndex]; 1385 | if (notif.type === "Lark") { 1386 | const { webhook } = await promptWithChinese([ 1387 | { 1388 | name: "webhook", 1389 | message: "请输入新的Webhook URL:", 1390 | default: notif.webhook, 1391 | validate: (v) => (v.trim() ? true : "不能为空"), 1392 | }, 1393 | ]); 1394 | notif.webhook = webhook; 1395 | 1396 | // 询问签名校验配置 1397 | const currentHasSecret = notif.secret ? true : false; 1398 | const { secretAction } = await promptWithChinese([ 1399 | { 1400 | type: "list", 1401 | name: "secretAction", 1402 | message: "签名校验配置:", 1403 | choices: [ 1404 | { 1405 | name: currentHasSecret ? "保持当前签名密钥" : "不启用签名校验", 1406 | value: "keep", 1407 | }, 1408 | { 1409 | name: currentHasSecret ? "修改签名密钥" : "启用签名校验", 1410 | value: "edit", 1411 | }, 1412 | ...(currentHasSecret 1413 | ? [{ name: "删除签名密钥", value: "remove" }] 1414 | : []), 1415 | ], 1416 | }, 1417 | ]); 1418 | 1419 | if (secretAction === "edit") { 1420 | const { secret } = await promptWithChinese([ 1421 | { 1422 | name: "secret", 1423 | message: "请输入签名密钥:", 1424 | default: notif.secret || "", 1425 | validate: (v) => (v.trim() ? true : "密钥不能为空"), 1426 | }, 1427 | ]); 1428 | notif.secret = secret; 1429 | } else if (secretAction === "remove") { 1430 | delete notif.secret; 1431 | } 1432 | } else if (notif.type === "WechatWork") { 1433 | const { webhook } = await promptWithChinese([ 1434 | { 1435 | name: "webhook", 1436 | message: "请输入新的Webhook URL:", 1437 | default: notif.webhook, 1438 | validate: (v) => (v.trim() ? true : "不能为空"), 1439 | }, 1440 | ]); 1441 | notif.webhook = webhook; 1442 | } else if (notif.type === "Telegram") { 1443 | const { botToken, chatId } = await promptWithChinese([ 1444 | { 1445 | name: "botToken", 1446 | message: "请输入新的Bot Token:", 1447 | default: notif.botToken, 1448 | validate: (v) => (v.includes(":") ? true : "格式错误"), 1449 | }, 1450 | { 1451 | name: "chatId", 1452 | message: "请输入新的Chat ID:", 1453 | default: notif.chatId, 1454 | validate: (v) => (v.trim() ? true : "不能为空"), 1455 | }, 1456 | ]); 1457 | notif.botToken = botToken; 1458 | notif.chatId = chatId; 1459 | } else if (notif.type === "Bark") { 1460 | console.log(chalk.cyan("当前Bark配置:")); 1461 | console.log(` 设备密钥: ${notif.deviceKey}`); 1462 | console.log(` 服务器: ${notif.serverUrl || "https://api.day.app"}`); 1463 | console.log(` 分组: ${notif.group || "未设置"}`); 1464 | console.log(` 声音: ${notif.sound || "default"}`); 1465 | 1466 | const barkEditConfig = await promptWithChinese([ 1467 | { 1468 | name: "deviceKey", 1469 | message: "设备密钥(Device Key):", 1470 | default: notif.deviceKey, 1471 | validate: (v) => (v.trim() ? true : "设备密钥不能为空"), 1472 | }, 1473 | { 1474 | name: "serverUrl", 1475 | message: "服务器地址:", 1476 | default: notif.serverUrl || "https://api.day.app", 1477 | }, 1478 | { 1479 | name: "group", 1480 | message: "推送分组:", 1481 | default: notif.group || "火车票监控", 1482 | }, 1483 | { 1484 | name: "sound", 1485 | message: "推送声音:", 1486 | default: notif.sound || "default", 1487 | }, 1488 | ]); 1489 | 1490 | // 询问是否修改高级选项 1491 | const { editAdvanced } = await promptWithChinese([ 1492 | { 1493 | type: "confirm", 1494 | name: "editAdvanced", 1495 | message: "是否修改高级选项?", 1496 | default: false, 1497 | }, 1498 | ]); 1499 | 1500 | if (editAdvanced) { 1501 | const advancedEditConfig = await promptWithChinese([ 1502 | { 1503 | type: "list", 1504 | name: "level", 1505 | message: "推送级别:", 1506 | choices: [ 1507 | { name: "默认(active)", value: "active" }, 1508 | { name: "重要警告(critical)", value: "critical" }, 1509 | { name: "时效性通知(timeSensitive)", value: "timeSensitive" }, 1510 | { name: "仅添加到列表(passive)", value: "passive" }, 1511 | ], 1512 | default: notif.level || "active", 1513 | }, 1514 | { 1515 | name: "icon", 1516 | message: "自定义图标URL:", 1517 | default: notif.icon || "", 1518 | }, 1519 | { 1520 | name: "url", 1521 | message: "点击跳转URL:", 1522 | default: notif.url || "", 1523 | }, 1524 | { 1525 | type: "confirm", 1526 | name: "autoCopy", 1527 | message: "自动复制推送内容?", 1528 | default: notif.autoCopy || false, 1529 | }, 1530 | { 1531 | type: "confirm", 1532 | name: "isArchive", 1533 | message: "保存推送到历史记录?", 1534 | default: notif.isArchive !== undefined ? notif.isArchive : true, 1535 | }, 1536 | ]); 1537 | 1538 | Object.assign(barkEditConfig, advancedEditConfig); 1539 | } 1540 | 1541 | Object.assign(notif, barkEditConfig); 1542 | } else if (notif.type === "SMTP") { 1543 | console.log(chalk.cyan("当前SMTP配置:")); 1544 | console.log(` 服务器: ${notif.host}:${notif.port}`); 1545 | console.log(` 用户名: ${notif.user}`); 1546 | console.log(` 收件人: ${notif.to}`); 1547 | if (notif.from) console.log(` 发件人: ${notif.from}`); 1548 | if (notif.cc) console.log(` 抄送: ${notif.cc}`); 1549 | 1550 | const smtpEditConfig = await promptWithChinese([ 1551 | { 1552 | name: "host", 1553 | message: "SMTP服务器地址:", 1554 | default: notif.host, 1555 | validate: (v) => (v.trim() ? true : "SMTP服务器地址不能为空"), 1556 | }, 1557 | { 1558 | type: "number", 1559 | name: "port", 1560 | message: "SMTP端口号:", 1561 | default: notif.port, 1562 | validate: (v) => 1563 | v > 0 && v <= 65535 ? true : "端口号必须在1-65535之间", 1564 | }, 1565 | { 1566 | name: "user", 1567 | message: "邮箱用户名:", 1568 | default: notif.user, 1569 | validate: (v) => (v.trim() ? true : "邮箱用户名不能为空"), 1570 | }, 1571 | { 1572 | type: "password", 1573 | name: "pass", 1574 | message: "邮箱密码或应用密码:", 1575 | default: notif.pass, 1576 | validate: (v) => (v.trim() ? true : "密码不能为空"), 1577 | }, 1578 | { 1579 | name: "from", 1580 | message: "发件人显示名称:", 1581 | default: notif.from || "", 1582 | }, 1583 | { 1584 | name: "to", 1585 | message: "收件人邮箱地址:", 1586 | default: notif.to, 1587 | validate: (v) => { 1588 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 1589 | return emailRegex.test(v.trim()) ? true : "请输入有效的邮箱地址"; 1590 | }, 1591 | }, 1592 | ]); 1593 | 1594 | // 询问是否修改高级选项 1595 | const { editAdvancedSMTP } = await promptWithChinese([ 1596 | { 1597 | type: "confirm", 1598 | name: "editAdvancedSMTP", 1599 | message: "是否修改高级选项?", 1600 | default: false, 1601 | }, 1602 | ]); 1603 | 1604 | if (editAdvancedSMTP) { 1605 | const advancedSMTPEditConfig = await promptWithChinese([ 1606 | { 1607 | type: "list", 1608 | name: "secure", 1609 | message: "安全连接类型:", 1610 | choices: [ 1611 | { name: "自动检测(推荐)", value: undefined }, 1612 | { name: "SSL/TLS (端口465)", value: true }, 1613 | { name: "STARTTLS (端口587)", value: false }, 1614 | ], 1615 | default: notif.secure, 1616 | }, 1617 | { 1618 | name: "cc", 1619 | message: "抄送邮箱:", 1620 | default: notif.cc || "", 1621 | }, 1622 | { 1623 | name: "bcc", 1624 | message: "密送邮箱:", 1625 | default: notif.bcc || "", 1626 | }, 1627 | { 1628 | name: "replyTo", 1629 | message: "回复邮箱:", 1630 | default: notif.replyTo || "", 1631 | }, 1632 | ]); 1633 | 1634 | Object.assign(smtpEditConfig, advancedSMTPEditConfig); 1635 | } 1636 | 1637 | Object.assign(notif, smtpEditConfig); 1638 | } 1639 | break; 1640 | 1641 | case "delete": 1642 | if (!config.notifications || config.notifications.length === 0) { 1643 | console.log(chalk.yellow("暂无推送配置")); 1644 | return; 1645 | } 1646 | 1647 | const { delNotifIndex } = await promptWithChinese([ 1648 | { 1649 | type: "list", 1650 | name: "delNotifIndex", 1651 | message: "选择要删除的推送配置:", 1652 | choices: config.notifications.map((notif, index) => ({ 1653 | name: `${index + 1}. ${notif.type}`, 1654 | value: index, 1655 | })), 1656 | }, 1657 | ]); 1658 | 1659 | config.notifications.splice(delNotifIndex, 1); 1660 | break; 1661 | 1662 | case "clear": 1663 | const { confirmClear } = await promptWithChinese([ 1664 | { 1665 | type: "confirm", 1666 | name: "confirmClear", 1667 | message: "确认清空所有推送配置?", 1668 | default: false, 1669 | }, 1670 | ]); 1671 | 1672 | if (confirmClear) { 1673 | config.notifications = []; 1674 | } 1675 | break; 1676 | } 1677 | 1678 | fs.writeFileSync("config.yml", yaml.dump(config), "utf-8"); 1679 | console.log(chalk.green("✅ 推送配置已更新!")); 1680 | 1681 | // 询问是否继续编辑 1682 | const { continueEdit } = await promptWithChinese([ 1683 | { 1684 | type: "confirm", 1685 | name: "continueEdit", 1686 | message: "是否继续编辑配置?", 1687 | default: true, 1688 | }, 1689 | ]); 1690 | 1691 | if (continueEdit) { 1692 | await editConfig(); 1693 | } 1694 | } 1695 | 1696 | // 修改查询参数 1697 | async function editQueryParams(config) { 1698 | const { interval, delay } = await promptWithChinese([ 1699 | { 1700 | type: "number", 1701 | name: "interval", 1702 | message: "查询间隔(分钟):", 1703 | default: config.interval || 15, 1704 | validate: (v) => (v > 0 ? true : "必须大于0"), 1705 | }, 1706 | { 1707 | type: "number", 1708 | name: "delay", 1709 | message: "访问延迟(秒):", 1710 | default: config.delay || 5, 1711 | validate: (v) => (v >= 0 ? true : "必须大于等于0"), 1712 | }, 1713 | ]); 1714 | 1715 | config.interval = interval; 1716 | config.delay = delay; 1717 | 1718 | fs.writeFileSync("config.yml", yaml.dump(config), "utf-8"); 1719 | console.log(chalk.green("✅ 查询参数已更新!")); 1720 | 1721 | // 询问是否继续编辑 1722 | const { continueEdit } = await promptWithChinese([ 1723 | { 1724 | type: "confirm", 1725 | name: "continueEdit", 1726 | message: "是否继续编辑配置?", 1727 | default: true, 1728 | }, 1729 | ]); 1730 | 1731 | if (continueEdit) { 1732 | await editConfig(); 1733 | } 1734 | } 1735 | 1736 | // 重置配置 1737 | async function resetConfig() { 1738 | const { confirmReset } = await promptWithChinese([ 1739 | { 1740 | type: "confirm", 1741 | name: "confirmReset", 1742 | message: "⚠️ 确认重置全部配置? 当前配置将被完全清除!", 1743 | default: false, 1744 | }, 1745 | ]); 1746 | 1747 | if (confirmReset) { 1748 | fs.unlinkSync("config.yml"); 1749 | console.log(chalk.green("✅ 配置已重置! 请重新配置监控任务")); 1750 | 1751 | // 重置后询问是否立即配置 1752 | const { startConfig } = await promptWithChinese([ 1753 | { 1754 | type: "confirm", 1755 | name: "startConfig", 1756 | message: "是否立即重新配置监控任务?", 1757 | default: true, 1758 | }, 1759 | ]); 1760 | 1761 | if (startConfig) { 1762 | await queryAndConfig(); 1763 | } 1764 | } else { 1765 | console.log(chalk.yellow("已取消重置")); 1766 | 1767 | // 询问是否继续编辑 1768 | const { continueEdit } = await promptWithChinese([ 1769 | { 1770 | type: "confirm", 1771 | name: "continueEdit", 1772 | message: "是否继续编辑配置?", 1773 | default: true, 1774 | }, 1775 | ]); 1776 | 1777 | if (continueEdit) { 1778 | await editConfig(); 1779 | } 1780 | } 1781 | } 1782 | 1783 | async function viewConfig() { 1784 | try { 1785 | const configContent = fs.readFileSync("config.yml", "utf-8"); 1786 | const config = yaml.load(configContent); 1787 | 1788 | console.log(chalk.blue("\n📋 当前配置文件内容:\n")); 1789 | console.log(chalk.white(yaml.dump(config))); 1790 | 1791 | console.log(chalk.green("\n✅ 配置摘要:")); 1792 | config.watch.forEach((watch, index) => { 1793 | console.log( 1794 | chalk.cyan( 1795 | `监控任务 ${index + 1}: ${watch.from} → ${watch.to} (${watch.date})` 1796 | ) 1797 | ); 1798 | if (watch.trains) { 1799 | console.log( 1800 | chalk.white(` 车次: ${watch.trains.map((t) => t.code).join(", ")}`) 1801 | ); 1802 | } 1803 | }); 1804 | 1805 | if (config.notifications && config.notifications.length > 0) { 1806 | console.log( 1807 | chalk.cyan( 1808 | `推送配置: ${config.notifications.map((n) => n.type).join(", ")}` 1809 | ) 1810 | ); 1811 | } 1812 | 1813 | // 询问后续操作 1814 | const { nextAction } = await promptWithChinese([ 1815 | { 1816 | type: "list", 1817 | name: "nextAction", 1818 | message: "接下来要做什么?", 1819 | choices: [ 1820 | { name: "⚙️ 编辑配置", value: "edit" }, 1821 | { name: "🚀 启动监控", value: "start" }, 1822 | { name: "🔙 返回主菜单", value: "back" }, 1823 | ], 1824 | }, 1825 | ]); 1826 | 1827 | switch (nextAction) { 1828 | case "edit": 1829 | await editConfig(); 1830 | break; 1831 | case "start": 1832 | await startMonitoring(); 1833 | break; 1834 | case "back": 1835 | console.log(chalk.cyan("返回主菜单")); 1836 | break; 1837 | } 1838 | } catch (err) { 1839 | console.log(chalk.red("配置文件不存在或格式错误")); 1840 | 1841 | // 配置不存在时询问是否创建 1842 | const { createConfig } = await promptWithChinese([ 1843 | { 1844 | type: "confirm", 1845 | name: "createConfig", 1846 | message: "是否立即创建配置?", 1847 | default: true, 1848 | }, 1849 | ]); 1850 | 1851 | if (createConfig) { 1852 | await queryAndConfig(); 1853 | } 1854 | } 1855 | } 1856 | 1857 | async function startMonitoring() { 1858 | try { 1859 | fs.accessSync("config.yml"); 1860 | console.log(chalk.green("\n🚀 正在启动监控程序...\n")); 1861 | const { spawn } = await import("child_process"); 1862 | spawn("node", ["src/index.js"], { stdio: "inherit", cwd: process.cwd() }); 1863 | } catch (err) { 1864 | console.log(chalk.red("配置文件不存在,请先配置监控任务")); 1865 | } 1866 | } 1867 | 1868 | main(); 1869 | -------------------------------------------------------------------------------- /src/cr.js: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | 3 | class ChinaRailway { 4 | static ticketCache = new Map(); 5 | static stationName; 6 | static stationCode; 7 | 8 | // 重试配置 9 | static retryConfig = { 10 | maxRetries: 3, 11 | retryDelay: 1000, // 1秒 12 | backoffMultiplier: 2, // 指数退避 13 | }; 14 | 15 | // 缓存配置 16 | static cacheConfig = { 17 | ttl: 5 * 60 * 1000, // 5分钟过期时间 18 | maxSize: 1000, // 最大缓存条目数 19 | cleanupInterval: 10 * 60 * 1000, // 10分钟清理一次过期缓存 20 | }; 21 | 22 | // 初始化定时清理 23 | static { 24 | // 定期清理过期缓存 25 | setInterval(() => { 26 | this.cleanExpiredCache(); 27 | }, this.cacheConfig.cleanupInterval); 28 | } 29 | 30 | // 设置缓存 31 | static setCache(key, value) { 32 | const now = Date.now(); 33 | 34 | // 如果缓存已满,清理最旧的条目 35 | if (this.ticketCache.size >= this.cacheConfig.maxSize) { 36 | const firstKey = this.ticketCache.keys().next().value; 37 | this.ticketCache.delete(firstKey); 38 | } 39 | 40 | this.ticketCache.set(key, { 41 | data: value, 42 | timestamp: now, 43 | expireAt: now + this.cacheConfig.ttl, 44 | }); 45 | } 46 | 47 | // 获取缓存 48 | static getCache(key) { 49 | const cached = this.ticketCache.get(key); 50 | if (!cached) { 51 | return null; 52 | } 53 | 54 | const now = Date.now(); 55 | if (now > cached.expireAt) { 56 | this.ticketCache.delete(key); 57 | return null; 58 | } 59 | 60 | return cached.data; 61 | } 62 | 63 | // 清理过期缓存 64 | static cleanExpiredCache() { 65 | const now = Date.now(); 66 | let cleanedCount = 0; 67 | 68 | for (const [key, cached] of this.ticketCache.entries()) { 69 | if (now > cached.expireAt) { 70 | this.ticketCache.delete(key); 71 | cleanedCount++; 72 | } 73 | } 74 | 75 | if (cleanedCount > 0) { 76 | console.log( 77 | `清理了 ${cleanedCount} 个过期缓存条目,当前缓存大小: ${this.ticketCache.size}` 78 | ); 79 | } 80 | } 81 | 82 | // 清空缓存 83 | static clearTicketCache() { 84 | this.ticketCache.clear(); 85 | console.log("已清空所有票务缓存"); 86 | } 87 | 88 | // 获取缓存统计信息 89 | static getCacheStats() { 90 | const now = Date.now(); 91 | let validCount = 0; 92 | let expiredCount = 0; 93 | 94 | for (const cached of this.ticketCache.values()) { 95 | if (now > cached.expireAt) { 96 | expiredCount++; 97 | } else { 98 | validCount++; 99 | } 100 | } 101 | 102 | return { 103 | total: this.ticketCache.size, 104 | valid: validCount, 105 | expired: expiredCount, 106 | maxSize: this.cacheConfig.maxSize, 107 | ttl: this.cacheConfig.ttl / 1000 + "秒", 108 | }; 109 | } 110 | 111 | // 通用重试方法 112 | static async fetchWithRetry( 113 | url, 114 | options = {}, 115 | retries = this.retryConfig.maxRetries 116 | ) { 117 | try { 118 | const response = await fetch(url, options); 119 | if (!response.ok) { 120 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 121 | } 122 | return response; 123 | } catch (error) { 124 | if (retries > 0) { 125 | const delay = 126 | this.retryConfig.retryDelay * 127 | Math.pow( 128 | this.retryConfig.backoffMultiplier, 129 | this.retryConfig.maxRetries - retries 130 | ); 131 | console.warn( 132 | `请求失败,${delay}ms后重试 (剩余重试次数: ${retries}):`, 133 | error.message 134 | ); 135 | await new Promise((resolve) => setTimeout(resolve, delay)); 136 | return this.fetchWithRetry(url, options, retries - 1); 137 | } 138 | throw new Error(`网络请求失败: ${error.message}`); 139 | } 140 | } 141 | 142 | static async getStationName(code) { 143 | if (!this.stationName) { 144 | await this.getStationData(); 145 | } 146 | return this.stationName[code]; 147 | } 148 | 149 | static async getStationCode(name) { 150 | if (!this.stationCode) { 151 | await this.getStationData(); 152 | } 153 | return this.stationCode[name]; 154 | } 155 | 156 | static async getStationData() { 157 | let response = await this.fetchWithRetry( 158 | "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js" 159 | ); 160 | let stationList = (await response.text()) 161 | .match(/(?<=').+(?=')/)[0] 162 | .split("@") 163 | .slice(1); 164 | 165 | this.stationCode = {}; 166 | this.stationName = {}; 167 | stationList.forEach((station) => { 168 | let details = station.split("|"); 169 | this.stationCode[details[1]] = details[2]; 170 | this.stationName[details[2]] = details[1]; 171 | }); 172 | } 173 | 174 | static async checkTickets(date, from, to, delay) { 175 | if ( 176 | moment().isSameOrAfter(moment(date, "YYYYMMDD").add(1, "days")) || 177 | moment().add(15, "days").isBefore(moment(date, "YYYYMMDD")) 178 | ) { 179 | throw new Error("日期需为0~15天内"); 180 | } 181 | 182 | const cacheKey = date + from + to; 183 | const cachedData = this.getCache(cacheKey); 184 | if (cachedData) { 185 | console.log(`使用缓存数据: ${cacheKey}`); 186 | return cachedData; 187 | } 188 | 189 | if (delay) { 190 | await delay; 191 | } 192 | 193 | let api = 194 | "https://kyfw.12306.cn/otn/leftTicket/queryG?leftTicketDTO.train_date=" + 195 | moment(date, "YYYYMMDD").format("YYYY-MM-DD") + 196 | "&leftTicketDTO.from_station=" + 197 | from + 198 | "&leftTicketDTO.to_station=" + 199 | to + 200 | "&purpose_codes=ADULT"; 201 | 202 | let res = await this.fetchWithRetry(api, { 203 | headers: { 204 | Cookie: "JSESSIONID=", 205 | }, 206 | }); 207 | 208 | let data = await res.json(); 209 | if (!data || !data.status) { 210 | throw new Error("获取余票数据失败"); 211 | } 212 | 213 | // 缓存数据 214 | this.setCache(cacheKey, data); 215 | console.log(`缓存新数据: ${cacheKey}`); 216 | 217 | return data; 218 | } 219 | 220 | static parseTrainInfo(str) { 221 | // Ref: https://kyfw.12306.cn/otn/resources/merged/queryLeftTicket_end_js.js 222 | let arr = str.split("|"); 223 | let data = { 224 | secretStr: arr[0], 225 | buttonTextInfo: arr[1], 226 | train_no: arr[2], 227 | station_train_code: arr[3], 228 | start_station_telecode: arr[4], 229 | end_station_telecode: arr[5], 230 | from_station_telecode: arr[6], 231 | to_station_telecode: arr[7], 232 | start_time: arr[8], 233 | arrive_time: arr[9], 234 | lishi: arr[10], 235 | canWebBuy: arr[11], 236 | yp_info: arr[12], 237 | start_train_date: arr[13], 238 | train_seat_feature: arr[14], 239 | location_code: arr[15], 240 | from_station_no: arr[16], 241 | to_station_no: arr[17], 242 | is_support_card: arr[18], 243 | controlled_train_flag: arr[19], 244 | gg_num: arr[20], 245 | gr_num: arr[21], 246 | qt_num: arr[22], 247 | rw_num: arr[23], 248 | rz_num: arr[24], 249 | tz_num: arr[25], 250 | wz_num: arr[26], 251 | yb_num: arr[27], 252 | yw_num: arr[28], 253 | yz_num: arr[29], 254 | ze_num: arr[30], 255 | zy_num: arr[31], 256 | swz_num: arr[32], 257 | srrb_num: arr[33], 258 | yp_ex: arr[34], 259 | seat_types: arr[35], 260 | exchange_train_flag: arr[36], 261 | houbu_train_flag: arr[37], 262 | houbu_seat_limit: arr[38], 263 | yp_info_new: arr[39], 264 | dw_flag: arr[46], 265 | stopcheckTime: arr[48], 266 | country_flag: arr[49], 267 | local_arrive_time: arr[50], 268 | local_start_time: arr[51], 269 | bed_level_info: arr[53], 270 | seat_discount_info: arr[54], 271 | sale_time: arr[55], 272 | }; 273 | data.tickets = { 274 | 优选一等座: data.gg_num, 275 | 高级软卧: data.gr_num, 276 | 其他: data.qt_num, 277 | 软卧: data.rw_num, 278 | 软座: data.rz_num, 279 | 特等座: data.tz_num, 280 | 无座: data.wz_num, 281 | YB: data.yb_num /* ? */, 282 | 硬卧: data.yw_num, 283 | 硬座: data.yz_num, 284 | 二等座: data.ze_num, 285 | 一等座: data.zy_num, 286 | 商务座: data.swz_num, 287 | SRRB: data.srrb_num /* ? */, 288 | }; 289 | return data; 290 | } 291 | } 292 | 293 | export default ChinaRailway; 294 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import yaml from "js-yaml"; 3 | import ChinaRailway from "./cr.js"; 4 | import { Notifications } from "./notifications.js"; 5 | import { sleep, time, log, asset } from "./utils.js"; 6 | 7 | let config; 8 | let notifications = []; 9 | let updateTimer = null; 10 | 11 | function die(err) { 12 | if (err && err != "SIGINT") { 13 | log.error("发生错误:", err); 14 | log.line(); 15 | } 16 | sendMsg({ 17 | time: new Date().toLocaleString(), 18 | content: `车票监控程序异常退出:${err.message || err}`, 19 | }); 20 | log.info("程序已结束,将在 5 秒后退出"); 21 | process.exit(); 22 | } 23 | 24 | function clean() { 25 | for (let notification of notifications) { 26 | notification.die(); 27 | } 28 | if (updateTimer) { 29 | clearInterval(updateTimer); 30 | clearTimeout(updateTimer); 31 | } 32 | Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000); 33 | } 34 | 35 | async function sendMsg(msg) { 36 | for (let notification of notifications) { 37 | if (notification.info.name === "飞书推送") { 38 | const formattedMsg = `[车票监控]\n🕒 时间:${msg.time}\n📝 内容:${msg.content}`; 39 | notification.send(formattedMsg).catch((err) => { 40 | log.error( 41 | `${notification.info.name} (${notification.info.description}) 发送失败:${err}` 42 | ); 43 | }); 44 | } else if (notification.info.name === "Telegram推送") { 45 | const formattedMsg = `🚄 *车票监控*\n\n🕒 *时间:* ${msg.time}\n📝 *内容:* ${msg.content}`; 46 | notification.send(formattedMsg).catch((err) => { 47 | log.error( 48 | `${notification.info.name} (${notification.info.description}) 发送失败:${err}` 49 | ); 50 | }); 51 | } else if (notification.info.name === "企业微信推送") { 52 | const formattedMsg = `[车票监控]\n🕒 时间:${msg.time}\n📝 内容:${msg.content}`; 53 | notification.send(formattedMsg).catch((err) => { 54 | log.error( 55 | `${notification.info.name} (${notification.info.description}) 发送失败:${err}` 56 | ); 57 | }); 58 | } else { 59 | notification.send(msg).catch((err) => { 60 | log.error( 61 | `${notification.info.name} (${notification.info.description}) 发送失败:${err}` 62 | ); 63 | }); 64 | } 65 | } 66 | } 67 | 68 | async function searchTickets(search) { 69 | log.info(`查询 ${search.date} ${search.from}→${search.to} 车票:`); 70 | let data = await ChinaRailway.checkTickets( 71 | search.date, 72 | await ChinaRailway.getStationCode(search.from), 73 | await ChinaRailway.getStationCode(search.to) 74 | ); 75 | for (let row of data.data.result) { 76 | let trainInfo = ChinaRailway.parseTrainInfo(row); 77 | if (!search.trains) { 78 | await determineRemainTickets(trainInfo); 79 | } else { 80 | for (let train of search.trains) { 81 | if ( 82 | train.code == trainInfo.station_train_code && 83 | (train.from === undefined || 84 | train.from == 85 | ChinaRailway.stationName[trainInfo.from_station_telecode]) && 86 | (train.to === undefined || 87 | train.to == ChinaRailway.stationName[trainInfo.to_station_telecode]) 88 | ) { 89 | await determineRemainTickets( 90 | trainInfo, 91 | train.seatCategory, 92 | train.checkRoundTrip ?? false 93 | ); 94 | } 95 | } 96 | } 97 | } 98 | } 99 | 100 | async function determineRemainTickets( 101 | trainInfo, 102 | seatCategory = undefined, 103 | checkRoundTrip = false 104 | ) { 105 | let trainDescription = 106 | trainInfo.station_train_code + 107 | " " + 108 | (await ChinaRailway.getStationName(trainInfo.from_station_telecode)) + 109 | "→" + 110 | (await ChinaRailway.getStationName(trainInfo.to_station_telecode)); 111 | 112 | let { remain, msg } = await checkRemainTickets( 113 | trainInfo, 114 | seatCategory, 115 | checkRoundTrip 116 | ); 117 | 118 | msg = msg || "无剩余票"; 119 | 120 | if (!remain && seatCategory !== undefined) { 121 | msg = seatCategory.join("/") + " " + msg; 122 | } 123 | 124 | log.info("-", trainDescription, msg); 125 | 126 | if (remain) { 127 | const messageToSend = { 128 | time: new Date().toLocaleString(), 129 | content: trainDescription + "\n" + msg, 130 | }; 131 | 132 | sendMsg(messageToSend); 133 | } 134 | } 135 | 136 | async function checkRemainTickets(trainInfo, seatCategory, checkRoundTrip) { 137 | let remainTypes = []; 138 | let remainTotal = 0; 139 | for (let type of Object.keys(trainInfo.tickets)) { 140 | if (seatCategory !== undefined && !seatCategory.includes(type)) { 141 | continue; 142 | } 143 | if (trainInfo.tickets[type] != "" && trainInfo.tickets[type] != "无") { 144 | remainTypes.push(type + " " + trainInfo.tickets[type]); 145 | if (trainInfo.tickets[type] == "有") { 146 | remainTotal += Infinity; 147 | } else { 148 | remainTotal += parseInt(trainInfo.tickets[type]); 149 | } 150 | } 151 | } 152 | if (remainTypes.length) { 153 | return { 154 | remain: true, 155 | total: remainTotal >= 20 ? "≥20" : remainTotal, 156 | msg: remainTypes.join(" / "), 157 | }; 158 | } 159 | if (!checkRoundTrip) { 160 | return { 161 | remain: false, 162 | msg: "区间无票", 163 | }; 164 | } 165 | let roundTripData = await ChinaRailway.checkTickets( 166 | trainInfo.start_train_date, 167 | trainInfo.start_station_telecode, 168 | trainInfo.end_station_telecode, 169 | sleep(config.delay * 1000) 170 | ); 171 | for (let row of roundTripData.data.result) { 172 | let roundTripInfo = ChinaRailway.parseTrainInfo(row); 173 | if ( 174 | trainInfo.station_train_code == roundTripInfo.station_train_code && 175 | trainInfo.start_station_telecode == roundTripInfo.from_station_telecode && 176 | trainInfo.end_station_telecode == roundTripInfo.to_station_telecode 177 | ) { 178 | let { remain: roundTripRemain, total: roundTripRemainTotal } = 179 | await checkRemainTickets(roundTripInfo, seatCategory, false); 180 | return { 181 | remain: false, 182 | msg: `区间无票,全程${ 183 | roundTripRemain ? `有票 (${roundTripRemainTotal}张)` : "无票" 184 | }`, 185 | }; 186 | } 187 | } 188 | return { 189 | remain: false, 190 | msg: "区间无票,全程未知", 191 | }; 192 | } 193 | 194 | async function update() { 195 | log.info("开始查询余票"); 196 | try { 197 | for (let search of config.watch) { 198 | await searchTickets(search); 199 | await sleep(config.delay * 1000); 200 | } 201 | ChinaRailway.clearTicketCache(); 202 | } catch (e) { 203 | log.error(e); 204 | sendMsg({ 205 | time: new Date().toLocaleString(), 206 | content: "错误:" + e.message, 207 | }); 208 | } 209 | log.info("余票查询完成"); 210 | log.line(); 211 | } 212 | 213 | function checkConfig() { 214 | try { 215 | config = fs.readFileSync("config.yml", "UTF-8"); 216 | } catch (err) { 217 | if (err.code == "ENOENT") { 218 | log.error("config.yml 不存在"); 219 | try { 220 | fs.writeFileSync("config.yml", asset("config.example.yml")); 221 | log.info("已自动创建 config.yml"); 222 | log.info("请根据需要修改后重启程序"); 223 | } catch (err) { 224 | log.error("创建 config.yml 失败"); 225 | log.info("请自行创建后重启程序"); 226 | } 227 | } else { 228 | log.error("读取 config.yml 时发生错误:", err); 229 | } 230 | die("配置文件错误"); 231 | } 232 | try { 233 | config = yaml.load(config); 234 | } catch (err) { 235 | log.error("解析 config.yml 时发生错误:", err); 236 | die("配置文件解析错误"); 237 | } 238 | 239 | let configParsing = "当前配置文件:\n\n"; 240 | if (!config.watch || !config.watch.length) { 241 | log.error("未配置搜索条件"); 242 | die(); 243 | } 244 | for (let search of config.watch) { 245 | if (!search.date || !search.from || !search.to) { 246 | log.error("搜索条件不完整"); 247 | die(); 248 | } 249 | configParsing += search.date + " " + search.from + "→" + search.to + "\n"; 250 | if (search.trains && search.trains.length) { 251 | for (let train of search.trains) { 252 | if (!train.code) { 253 | log.error("未填写车次号"); 254 | die(); 255 | } 256 | configParsing += 257 | "- " + 258 | train.code + 259 | " " + 260 | (train.from ?? "(*)") + 261 | "→" + 262 | (train.to ?? "(*)") + 263 | " " + 264 | (train.seatCategory ? train.seatCategory.join("/") : "全部席别") + 265 | " " + 266 | (train.checkRoundTrip ? "[✓]" : "[×]") + 267 | "查询全程票\n"; 268 | } 269 | } else { 270 | configParsing += "- 全部车次\n"; 271 | } 272 | configParsing += "\n"; 273 | } 274 | 275 | // 清理旧的通知实例 276 | for (let notification of notifications) { 277 | notification.die(); 278 | } 279 | notifications = []; 280 | 281 | for (let notification of config.notifications) { 282 | try { 283 | let n = new Notifications[notification.type](notification); // 确保实例化时使用正确的键名 284 | notifications.push(n); 285 | configParsing += 286 | `已配置消息推送:${n.info.name} (${n.info.description})` + "\n"; 287 | } catch (e) { 288 | log.error("配置消息推送时发生错误:", e); 289 | } 290 | } 291 | if (!notifications.length) { 292 | log.warn("未配置消息推送"); 293 | configParsing += "未配置消息推送\n"; 294 | } 295 | configParsing += "\n"; 296 | 297 | if (!config.interval) config.interval = 15; 298 | if (!config.delay) config.delay = 5; 299 | configParsing += `查询间隔:${config.interval}分钟,访问延迟:${config.delay}秒`; 300 | 301 | log.line(); 302 | log.direct(configParsing); 303 | log.line(); 304 | 305 | sendMsg({ 306 | time: new Date().toLocaleString(), 307 | content: configParsing, 308 | }).then(() => { 309 | log.info("已尝试发送提醒,如未收到请检查配置"); 310 | }); 311 | } 312 | 313 | function reloadConfig() { 314 | log.info("检测到配置文件变化,正在重新加载..."); 315 | 316 | // 清除现有定时器 317 | if (updateTimer) { 318 | clearInterval(updateTimer); 319 | clearTimeout(updateTimer); 320 | updateTimer = null; 321 | } 322 | 323 | try { 324 | checkConfig(); 325 | 326 | // 重新启动定时器 327 | startMonitoring(); 328 | 329 | log.info("配置文件重新加载完成"); 330 | sendMsg({ 331 | time: new Date().toLocaleString(), 332 | content: "配置文件已重新加载,监控已重新启动", 333 | }); 334 | } catch (err) { 335 | log.error("重新加载配置文件失败:", err); 336 | sendMsg({ 337 | time: new Date().toLocaleString(), 338 | content: `配置文件重新加载失败:${err.message || err}`, 339 | }); 340 | } 341 | } 342 | 343 | function startMonitoring() { 344 | log.info("5秒后开始首次查询,按 Ctrl+C 中止"); 345 | updateTimer = setInterval(update, config.interval * 60 * 1000); 346 | setTimeout(update, 5 * 1000); 347 | } 348 | 349 | function watchConfigFile() { 350 | try { 351 | fs.watchFile("config.json", { interval: 1000 }, (curr, prev) => { 352 | if (curr.mtime > prev.mtime) { 353 | // 延迟一下,确保文件写入完成 354 | setTimeout(reloadConfig, 500); 355 | } 356 | }); 357 | log.info("已启用配置文件热重载监控"); 358 | } catch (err) { 359 | log.warn("启用配置文件监控失败:", err); 360 | } 361 | } 362 | 363 | process.title = "CR Ticket Monitor"; 364 | process.on("uncaughtException", die); 365 | process.on("unhandledRejection", die); 366 | process.on("SIGINT", die); 367 | process.on("exit", clean); 368 | 369 | process.title = "CR Ticket Monitor"; 370 | process.on("uncaughtException", die); 371 | process.on("unhandledRejection", die); 372 | process.on("SIGINT", die); 373 | process.on("exit", clean); 374 | 375 | async function main() { 376 | console.clear(); 377 | log.title(String.raw` 378 | __________ ________ ___ 379 | / ____/ __ \/_ __/ |/ / 380 | / / / /_/ / / / / /|_/ / 381 | / /___/ _ _/ / / / / / / 382 | \____/_/ |_| /_/ /_/ /_/ 383 | 384 | `); 385 | log.line(); 386 | 387 | // 检查命令行参数 388 | const args = process.argv.slice(2); 389 | if (args.includes("--monitor") || args.includes("-m")) { 390 | // 直接启动监控模式 391 | log.info("直接启动监控模式"); 392 | startMonitoringMode(); 393 | return; 394 | } 395 | 396 | // 检查配置文件是否存在 397 | try { 398 | fs.accessSync("config.yml"); 399 | 400 | // 配置文件存在,询问用户选择模式 401 | log.info("检测到配置文件 config.yml"); 402 | log.info("请选择运行模式:"); 403 | log.info("1. 直接启动监控 (输入 1)"); 404 | log.info("2. 进入交互配置模式 (输入 2)"); 405 | log.info("或者等待 5 秒自动启动监控模式"); 406 | log.line(); 407 | 408 | // 等待用户输入或超时 409 | const { createInterface } = await import("readline"); 410 | const rl = createInterface({ 411 | input: process.stdin, 412 | output: process.stdout, 413 | }); 414 | 415 | let userChoice = false; 416 | const timeout = setTimeout(() => { 417 | if (!userChoice) { 418 | rl.close(); 419 | log.info("自动选择监控模式"); 420 | startMonitoringMode(); 421 | } 422 | }, 5000); 423 | 424 | rl.on("line", (input) => { 425 | userChoice = true; 426 | clearTimeout(timeout); 427 | rl.close(); 428 | 429 | const choice = input.trim(); 430 | if (choice === "1" || choice === "") { 431 | log.info("启动监控模式..."); 432 | startMonitoringMode(); 433 | } else if (choice === "2") { 434 | log.info("进入交互配置模式..."); 435 | import("./cli.js"); 436 | } else { 437 | log.info("无效输入,启动监控模式..."); 438 | startMonitoringMode(); 439 | } 440 | }); 441 | } catch (err) { 442 | // 配置文件不存在,直接启动交互模式 443 | log.warn("未找到配置文件 config.yml"); 444 | log.info("启动交互配置模式..."); 445 | log.line(); 446 | import("./cli.js"); 447 | } 448 | } 449 | 450 | function startMonitoringMode() { 451 | checkConfig(); 452 | watchConfigFile(); 453 | startMonitoring(); 454 | } 455 | 456 | // 启动主程序 457 | main(); 458 | -------------------------------------------------------------------------------- /src/notifications.js: -------------------------------------------------------------------------------- 1 | import { log, time, asset } from "./utils.js"; 2 | import nodemailer from "nodemailer"; 3 | import crypto from "crypto"; 4 | 5 | class NotificationBase { 6 | static info = { 7 | name: "CRTM Notification", 8 | description: "", 9 | }; 10 | 11 | constructor(config, info) { 12 | this.info = info; 13 | this.config = config; 14 | } 15 | 16 | async send(msg) { 17 | console.log(msg); 18 | } 19 | 20 | die() {} 21 | } 22 | 23 | class LarkNotification extends NotificationBase { 24 | constructor(config) { 25 | super(config, { 26 | name: "飞书推送", 27 | description: config.webhook 28 | ? config.webhook.match(/^https?:\/\/(.+?)\/.*$/)[1] 29 | : "飞书机器人", 30 | }); 31 | if (!config.webhook) { 32 | throw new Error(`${this.info.name} 配置不完整:缺少 webhook 地址`); 33 | } 34 | } 35 | 36 | /** 37 | * 生成飞书签名校验 38 | * @param {number} timestamp 时间戳(秒) 39 | * @param {string} secret 密钥 40 | * @returns {string} 签名字符串 41 | */ 42 | _generateSign(timestamp, secret) { 43 | const stringToSign = `${timestamp}\n${secret}`; 44 | const hmac = crypto.createHmac("sha256", stringToSign); 45 | return hmac.update("").digest("base64"); 46 | } 47 | 48 | async send(msg) { 49 | // 构造飞书消息格式 50 | const larkMessage = { 51 | msg_type: "text", 52 | content: { 53 | text: typeof msg === "string" ? msg : JSON.stringify(msg, null, 2), 54 | }, 55 | }; 56 | 57 | // 如果配置了签名密钥,添加签名校验 58 | if (this.config.secret) { 59 | const timestamp = Math.floor(Date.now() / 1000); 60 | const sign = this._generateSign(timestamp, this.config.secret); 61 | 62 | larkMessage.timestamp = timestamp.toString(); 63 | larkMessage.sign = sign; 64 | } 65 | 66 | const response = await fetch(this.config.webhook, { 67 | method: "POST", 68 | headers: { 69 | "Content-Type": "application/json; charset=utf-8", 70 | }, 71 | body: JSON.stringify(larkMessage), 72 | }); 73 | 74 | if (!response.ok) { 75 | throw new Error(`飞书推送 发送失败:HTTP ${response.status}`); 76 | } 77 | 78 | const result = await response.json(); 79 | if (result.code !== 0) { 80 | throw new Error(`飞书推送 发送失败:${result.msg || "未知错误"}`); 81 | } 82 | } 83 | } 84 | 85 | class TelegramNotification extends NotificationBase { 86 | constructor(config) { 87 | super(config, { 88 | name: "Telegram推送", 89 | description: config.chatId 90 | ? `Chat ID: ${config.chatId}` 91 | : "Telegram机器人", 92 | }); 93 | if (!config.botToken || !config.chatId) { 94 | throw new Error(`${this.info.name} 配置不完整:缺少 botToken 或 chatId`); 95 | } 96 | } 97 | 98 | async send(msg) { 99 | const telegramApiUrl = `https://api.telegram.org/bot${this.config.botToken}/sendMessage`; 100 | 101 | const telegramMessage = { 102 | chat_id: this.config.chatId, 103 | text: typeof msg === "string" ? msg : JSON.stringify(msg, null, 2), 104 | parse_mode: "Markdown", // 支持Markdown格式 105 | }; 106 | 107 | const response = await fetch(telegramApiUrl, { 108 | method: "POST", 109 | headers: { 110 | "Content-Type": "application/json; charset=utf-8", 111 | }, 112 | body: JSON.stringify(telegramMessage), 113 | }); 114 | 115 | if (!response.ok) { 116 | throw new Error(`Telegram推送 发送失败:HTTP ${response.status}`); 117 | } 118 | 119 | const result = await response.json(); 120 | if (!result.ok) { 121 | throw new Error( 122 | `Telegram推送 发送失败:${result.description || "未知错误"}` 123 | ); 124 | } 125 | } 126 | } 127 | 128 | class WechatWorkNotification extends NotificationBase { 129 | constructor(config) { 130 | super(config, { 131 | name: "企业微信推送", 132 | description: config.webhook 133 | ? config.webhook.match(/key=([^&]+)/)?.[1]?.substring(0, 8) + "..." 134 | : "企业微信机器人", 135 | }); 136 | if (!config.webhook) { 137 | throw new Error(`${this.info.name} 配置不完整:缺少 webhook 地址`); 138 | } 139 | } 140 | 141 | async send(msg) { 142 | // 构造企业微信消息格式 143 | const wechatMessage = { 144 | msgtype: "text", 145 | text: { 146 | content: typeof msg === "string" ? msg : JSON.stringify(msg, null, 2), 147 | }, 148 | }; 149 | 150 | const response = await fetch(this.config.webhook, { 151 | method: "POST", 152 | headers: { 153 | "Content-Type": "application/json; charset=utf-8", 154 | }, 155 | body: JSON.stringify(wechatMessage), 156 | }); 157 | 158 | if (!response.ok) { 159 | throw new Error(`企业微信推送 发送失败:HTTP ${response.status}`); 160 | } 161 | 162 | const result = await response.json(); 163 | if (result.errcode !== 0) { 164 | throw new Error(`企业微信推送 发送失败:${result.errmsg || "未知错误"}`); 165 | } 166 | } 167 | } 168 | 169 | class BarkNotification extends NotificationBase { 170 | constructor(config) { 171 | super(config, { 172 | name: "Bark推送", 173 | description: config.deviceKey 174 | ? `设备: ${config.deviceKey.substring(0, 8)}...` 175 | : "Bark客户端", 176 | }); 177 | if (!config.deviceKey) { 178 | throw new Error(`${this.info.name} 配置不完整:缺少 deviceKey`); 179 | } 180 | 181 | // 设置默认服务器地址 182 | this.serverUrl = config.serverUrl || "https://api.day.app"; 183 | } 184 | 185 | async send(msg) { 186 | // 解析消息内容 187 | let title = "12306余票监控"; 188 | let body = ""; 189 | 190 | if (typeof msg === "string") { 191 | body = msg; 192 | } else if (msg && typeof msg === "object") { 193 | title = msg.title || title; 194 | body = msg.body || msg.content || JSON.stringify(msg, null, 2); 195 | } 196 | 197 | // 构造 Bark 推送参数 198 | const barkPayload = { 199 | device_key: this.config.deviceKey, 200 | title: title, 201 | body: body, 202 | group: this.config.group || "火车票监控", 203 | sound: this.config.sound || "default", 204 | }; 205 | 206 | // 添加可选参数 207 | if (this.config.badge !== undefined) barkPayload.badge = this.config.badge; 208 | if (this.config.url) barkPayload.url = this.config.url; 209 | if (this.config.icon) barkPayload.icon = this.config.icon; 210 | if (this.config.level) barkPayload.level = this.config.level; 211 | if (this.config.volume !== undefined) 212 | barkPayload.volume = this.config.volume; 213 | if (this.config.copy) barkPayload.copy = this.config.copy; 214 | if (this.config.autoCopy) barkPayload.autoCopy = this.config.autoCopy; 215 | if (this.config.call) barkPayload.call = this.config.call; 216 | if (this.config.isArchive !== undefined) 217 | barkPayload.isArchive = this.config.isArchive; 218 | 219 | try { 220 | // 使用 POST JSON 方式发送 221 | const response = await fetch(`${this.serverUrl}/push`, { 222 | method: "POST", 223 | headers: { 224 | "Content-Type": "application/json; charset=utf-8", 225 | }, 226 | body: JSON.stringify(barkPayload), 227 | }); 228 | 229 | if (!response.ok) { 230 | throw new Error(`Bark推送 发送失败:HTTP ${response.status}`); 231 | } 232 | 233 | const result = await response.json(); 234 | if (result.code !== 200) { 235 | throw new Error(`Bark推送 发送失败:${result.message || "未知错误"}`); 236 | } 237 | } catch (error) { 238 | // 如果 JSON 方式失败,尝试使用 URL 方式 239 | if (error.message.includes("HTTP")) { 240 | throw error; 241 | } 242 | 243 | try { 244 | const urlParams = new URLSearchParams(); 245 | Object.entries(barkPayload).forEach(([key, value]) => { 246 | if (key !== "device_key" && value !== undefined) { 247 | urlParams.append(key, value.toString()); 248 | } 249 | }); 250 | 251 | const getUrl = `${this.serverUrl}/${ 252 | this.config.deviceKey 253 | }/${encodeURIComponent(title)}/${encodeURIComponent( 254 | body 255 | )}?${urlParams.toString()}`; 256 | 257 | const fallbackResponse = await fetch(getUrl, { method: "GET" }); 258 | if (!fallbackResponse.ok) { 259 | throw new Error(`Bark推送 发送失败:HTTP ${fallbackResponse.status}`); 260 | } 261 | } catch (fallbackError) { 262 | throw new Error(`Bark推送 发送失败:${fallbackError.message}`); 263 | } 264 | } 265 | } 266 | } 267 | 268 | class SMTPNotification extends NotificationBase { 269 | constructor(config) { 270 | super(config, { 271 | name: "SMTP邮件推送", 272 | description: config.to ? `发送至: ${config.to}` : "邮件推送", 273 | }); 274 | 275 | // 验证必需配置 276 | if ( 277 | !config.host || 278 | !config.port || 279 | !config.user || 280 | !config.pass || 281 | !config.to 282 | ) { 283 | throw new Error(`${this.info.name} 配置不完整:缺少必需的邮件配置`); 284 | } 285 | 286 | // 创建邮件传输器 287 | this.transporter = nodemailer.createTransport({ 288 | host: config.host, 289 | port: config.port, 290 | secure: config.secure !== undefined ? config.secure : config.port === 465, 291 | auth: { 292 | user: config.user, 293 | pass: config.pass, 294 | }, 295 | // 可选配置 296 | ...(config.ignoreTLS && { ignoreTLS: true }), 297 | ...(config.requireTLS && { requireTLS: true }), 298 | }); 299 | } 300 | 301 | async send(msg) { 302 | // 解析消息内容 303 | let subject = "🚄 12306余票监控通知"; 304 | let text = ""; 305 | let html = ""; 306 | 307 | if (typeof msg === "string") { 308 | text = msg; 309 | html = `
${msg.replace( 310 | /\n/g, 311 | "
" 312 | )}
`; 313 | } else if (msg && typeof msg === "object") { 314 | subject = msg.subject || msg.title || subject; 315 | text = 316 | msg.text || msg.body || msg.content || JSON.stringify(msg, null, 2); 317 | html = 318 | msg.html || 319 | `
${text.replace( 320 | /\n/g, 321 | "
" 322 | )}
`; 323 | } 324 | 325 | // 构造邮件选项 326 | const mailOptions = { 327 | from: this.config.from || this.config.user, 328 | to: this.config.to, 329 | subject: subject, 330 | text: text, 331 | html: html, 332 | }; 333 | 334 | // 添加可选配置 335 | if (this.config.cc) mailOptions.cc = this.config.cc; 336 | if (this.config.bcc) mailOptions.bcc = this.config.bcc; 337 | if (this.config.replyTo) mailOptions.replyTo = this.config.replyTo; 338 | 339 | try { 340 | const info = await this.transporter.sendMail(mailOptions); 341 | console.log(`邮件发送成功: ${info.messageId}`); 342 | return info; 343 | } catch (error) { 344 | throw new Error(`SMTP邮件推送 发送失败:${error.message}`); 345 | } 346 | } 347 | 348 | die() { 349 | if (this.transporter) { 350 | this.transporter.close(); 351 | } 352 | } 353 | } 354 | 355 | export const Notifications = { 356 | Lark: LarkNotification, 357 | Telegram: TelegramNotification, 358 | WechatWork: WechatWorkNotification, 359 | Bark: BarkNotification, 360 | SMTP: SMTPNotification, 361 | }; 362 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import moment from 'moment'; 3 | import chalk from 'chalk'; 4 | import { isSea, getAsset } from 'node:sea'; 5 | 6 | export function time() { 7 | return moment().format('YYYY/MM/DD HH:mm:ss'); 8 | } 9 | 10 | export async function sleep(n) { 11 | return new Promise((resolve) => { 12 | setTimeout(() => { 13 | resolve(); 14 | }, n); 15 | }); 16 | } 17 | 18 | export let log = { 19 | info(...msg) { 20 | console.log(chalk.cyan(time()), chalk.bold('[Info]'), ...msg); 21 | }, 22 | error(...msg) { 23 | console.error(chalk.cyan(time()), chalk.red.bold('[Error]'), ...msg); 24 | }, 25 | warn(...msg) { 26 | console.log(chalk.cyan(time()), chalk.yellow.bold('[Warn]'), ...msg); 27 | }, 28 | success(...msg) { 29 | console.log(chalk.cyan(time()), chalk.green.bold('[Success]'), ...msg); 30 | }, 31 | direct(...msg) { 32 | console.log(chalk.magenta(...msg)); 33 | }, 34 | title(...msg) { 35 | console.log(chalk.cyan.bold(...msg)); 36 | }, 37 | line() { 38 | console.log(); 39 | }, 40 | }; 41 | 42 | export function asset(name) { 43 | if (isSea()) { 44 | return getAsset(name, 'UTF-8'); 45 | } else { 46 | return fs.readFileSync(name); 47 | } 48 | } 49 | --------------------------------------------------------------------------------