# jdmaotai **Repository Path**: hhhsir/jdmaotai ## Basic Information - **Project Name**: jdmaotai - **Description**: jdmaotai - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2021-01-09 - **Last Updated**: 2022-01-21 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 文档 前言 • 因为觉得挺有意思,所以我使用 nestjs 制作了京东抢茅台脚本。 • 为什么使用 nestjs?因为如果后面想做成服务或者弄什么别的东西集成比较方便。 • 代码主要分为四部分,第一部分为 task.service.ts,用来驱动定时任务校对时间,第二部分为 login.service.ts 用来完成登录方面事情。第三部分为 product.service.ts,用来制作抢购方面的逻辑。执行顺序通过 app.service.ts 进行调度。 相关包 • 定时任务模块 @nestjs/schedule @types/cron • 环境变量 dotenv • 解析网页 cheerio • 安装时间处理包 moment • 脚本执行包 shelljs • 前端老朋友 axios,虽然 nestjs 基于 axios 封了个 http,但是为了便于理解,使用 axios 进行操作。 axios 环境变量 • 首先环境变量。依赖于 dotenv。 • 在项目下放入.env 文件,在文件中引入 dotenv/config,即可取得 env 的参数。 SEC=0 MINUTE=0 HOUR=10 DAY=7 MONTH=1 PORT=3000 PRODUCTID=100012043978 BYNUM=1 PAYPASSWORD=123456 EID=SSSS FP=DDDD • 我们会设定如上的环境变量,其中,前面几个就是定时任务所要执行的日期与时间。 • productid 为京东茅台的 id • bynum 就是购买数量,一个月最多只能买 2 瓶。 • paypassword 是付款密码 • eid 和 fp 是需要到控制台随便添加个物品进入结算页,然后控制台输出_JdTdudfp 即可得到。 定时任务部分 • 首先建立 task.service.ts 作为本项目定时任务配置部分。 • 我们需要用到 cron 语法,这个语法从前到后表示秒、分、时、日 、月、年 • 星号代表任意。 • 我们可以制作一个每分钟跑一次的 log 来试试: /** * * 每分钟输出一次,方便观察 * @memberof TasksService */ @Cron('0 * * * * *') displayServerTime() { this.logger.debug(`${moment().format('YYYY年MM月DD日 HH时mm分ss秒')}`); } • moment 是用来格式化时间的,当秒每次到 0 时,则会执行一次 log。 • 对于抢购定时任务需要去处理下时差。好在京东有个链接可以获取其服务器时间,从而进行校准本地时间。 • 拿到时间后计算差值,利用 moment 的加减时间进行计算,再换算成 cron 时间,就为最后真正执行的时间。 /** * * 处理时差 添加任务 * @memberof TasksService */ async handleTimeDiff() { const url = 'https://a.jd.com//ajax/queryServerData.html'; const res = await axios.get(url); // 获得的是时间戳 const now = Date.now(); const diff = (now - res.data.serverTime) / 1000; const differtime = moment.duration(Math.abs(diff), 'seconds'); const origintime = moment( `${env.SEC}-${env.MINUTE}-${env.HOUR}-${env.DAY}-${env.MONTH}`, 's-m-H-D-M', ); this.logger.warn(`您设定时间为${origintime.format('M月D日H点m分s秒')}`); let fixStart: moment.Moment; if (diff > 0) { this.logger.warn(`您电脑时间比京东快${diff}秒`); fixStart = origintime.add(differtime); } else { this.logger.warn(`您的电脑时间比京东慢${-diff}秒`); fixStart = origintime.subtract(differtime); } this.logger.warn(`已修正启动时间为${fixStart.format('M月D日H点m分s秒')}`); const month = fixStart.get('month'); const fixCornTime = fixStart.format('s m H D ') + month + ' *'; this.addCronJob('user', fixCornTime); } • addCronJob 可以让我们动态的添加计划任务: /** * * 添加修正时间后的任务 * @param {string} name * @param {string} coreTime * @memberof TasksService */ async addCronJob(name: string, coreTime: string) { const job = new CronJob(coreTime, () => { this.logger.log(`执行脚本启动`); this.appSrv.main(); }); this.schedulerRegistry.addCronJob(name, job); job.start(); this.logger.log(`任务coretime为${coreTime}`); // 如果要提前登录就放开 this.logger.log('检查登录情况'); this.loginService.init(); } • 如果需要提前登录,就会去 login 里执行登录逻辑。 • 当时间到了后,会执行主流程进行抢购。 登录部分 • 由于 nest 特性使得只会生成一个实例,并且可以在各个类中获取到,所以只要把需要的东西甩到 this 上就行了。 • 京东的登录状态就是靠 cookie,所以在每次请求完响应头有 set-cookie 就得带上。 • 登录部分的 init 是主要的调用逻辑: /** * * cookie判断 * @return {*} * @memberof LoginService */ async init() { const isExist = fs.existsSync(cookiePath); if (isExist) { const data = this.getCookieFromLocal(); this.cookies = data.cookie; } else { this.logger.log('未找到cookie文件'); } const sign = await this.validate(); if (!sign) { // 验证失败不是过期就是未登录。都需要清理cookie this.cookies = []; const fn = async () => { return new Promise(async resolve => { const s = await this.main(); if (!s) { setTimeout(() => { fn(); }, 2000); } else { resolve(''); } }); }; await fn(); } this.logger.log('已在登录状态'); return; } • 它会先去找有没有 cookie 文件,如果有文件那么解析出来去访问下验证下,如果验证失败,那么不是过期就是未登录情况,把 cookie 清空去执行 main 里的登录逻辑。 • 在登录的 main 中,会完整走一遍登录流程,并进行验证是否登录成功: /** * * 主登录流程 * @memberof LoginService */ async main() { this.logger.log('进行登录流程'); const res = await axios.get('https://passport.jd.com/new/login.aspx', { headers: this.getHeaders(), }); this.cookieStore(res.headers); const sign = await this.getQrcode(); if (!sign) { this.logger.log('未获取到登录二维码'); return false; } this.logger.log(`二维码文件位于${qrcodePath}`); this.openQrcode(); const ticket = await this.qrcodeScan(); const result = await this.validateTicket(ticket); if (!result) { this.logger.log('登录票据有误'); return false; } return await this.validate(); } • 其中 getQrcode 为获取登录二维码并下载到本地: /** * * 获取qrcode * @returns * @memberof LoginService */ async getQrcode() { const url = 'https://qr.m.jd.com/show'; const cookie = this.cookieToHeader(); return new Promise(resolve => { axios .get(url, { headers: { 'User-Agent': this.ua, Referer: 'https://passport.jd.com/new/login.aspx', cookie, }, responseType: 'arraybuffer', params: { appid: 133, size: 147, t: Date.now(), }, }) .then(res => { if (res.status === 200) { this.cookieStore(res.headers); fs.writeFile(qrcodePath, res.data, err => { if (err) throw err; resolve(true); }); } }) .catch(e => { this.logger.log(e); resolve(false); }); }); } • 注意需要使用 arraybuffer 形式才可以下载到二维码。 • 二维码下载完成后,执行 shell 打开它: async openQrcode() { shell.exec(qrcodePath); return; } • mac 用户可能需要调整下打开二维码命令。 • 然后就会进入扫码等待。京东的扫码页面就是一直轮询,直到返回的为 200 给你个 ticket。 • 拿到 ticket 后,便进行验证: /** * * 验证ticket * @param {string} ticket * @returns * @memberof LoginService */ async validateTicket(ticket: string) { const url = 'https://passport.jd.com/uc/qrCodeTicketValidation'; const headers = { 'User-Agent': this.ua, Referer: 'https://passport.jd.com/uc/login?ltype=logout', cookie: this.cookieToHeader(), }; const res = await axios.get(url, { headers, params: { t: ticket, }, }); if (res.data.returnCode === 0) { this.cookieStore(res.headers); return true; } return false; } • 这个地址返回 0 就是 ok 了没啥说的,同样要存一下 cookie。 • 此时 cookie 应该是全了,所以拿去验证下进入有权限的页面看是否会发生登录跳转: /** * * 验证是否能登录 * @return {*} * @memberof LoginService */ async validate() { const url = 'https://order.jd.com/center/list.action'; try { await axios.get(url, { headers: { 'User-Agent': this.ua, cookie: this.cookieToHeader(), }, params: { rid: Date.now(), }, maxRedirects: 0, }); this.storeCookieTolocal(); this.logger.log('验证成功'); this.islogin = true; return true; } catch { this.logger.log('验证失败'); this.islogin = false; return false; } } • 注意把 redirects 设置为 0,这样当发生跳转,那么就说明不在登录状态。 抢购部分 • 首先获取商品其实没啥说的: /** * * 访问商品获取名称拿cookie * @returns * @memberof ProductService */ async getProduct() { const url = `https://item.jd.com/${productId}.html`; const res = await axios.get(url, { headers: { 'User-Agent': this.loginService.ua, cookie: this.loginService.cookieToHeader(), }, }); this.loginService.cookieStore(res.headers); const $ = cheerio.load(res.data); const title = $('title').text(); this.logger.log(`您设定的抢购商品为:${title}`); return; } • 会拿到个页面,用 cheerio 提取下 title 就出来了。 • 然后会去拿个秒杀地址: /** * * 获取抢购地址 * @returns * @memberof ProductService */ async getSeckillUrl() { const url = 'https://itemko.jd.com/itemShowBtn'; const params = { callback: `jQuery${randomRange(1000000, 9999999)}`, skuId: productId, from: 'pc', _: Date.now(), }; const headers = { 'User-Agent': this.loginService.ua, Host: 'itemko.jd.com', Referer: `https://item.jd.com/${productId}.html`, cookie: this.loginService.cookieToHeader(), }; const reg = /(?<="url":")(.*)(?=")/; return new Promise(resolve => { const fn = () => { axios .get(url, { params, headers, }) .then(res => { const path = reg.exec(res.data)[0]; console.log(res.data); if (path === '') { this.logger.error( '获取抢购地址失败,请检查是否有权限或者是否处于时间内,2秒后重试', ); setTimeout(() => { fn(); }, 2000); } else { this.loginService.cookieStore(res.headers); this.logger.log(`获取抢购原始地址${path}`); resolve(path); } }) .catch(e => { console.log(e); this.logger.log('京东更新了登录逻辑,请联系作者yehuozhili'); }); }; fn(); }); } • 这个地址只有这种要预约的商品才有,否则返回的都是空字符串。 • 拿到路径之后,还需要对路径进行转换: const newpath = this.productService.resolvePath(path); • 访问转换后的链接: /** * * 访问转换后链接 * @param {string} path * @returns * @memberof ProductService */ async goToKillUrl(path: string) { this.logger.log('正在访问生成的链接'); const headers = { 'User-Agent': this.loginService.ua, Host: 'marathon.jd.com', Referer: `https://item.jd.com/${productId}.html`, cookie: this.loginService.cookieToHeader(), }; return await axios.get(path, { headers, maxRedirects: 0, }); } • 正常来说,是不会进行跳转,也可以放开 maxRedirect 试试到底返回了啥。 • 貌似是跳转后会显示抢购未成功。 • 后面就是订单结算页,跟上面那个同理,不能跳转,可以试试跳转后访问啥: /** * * 订单结算页 * @returns * @memberof ProductService */ async toCheckOut() { this.logger.log('正在访问订单结算页面'); const url = 'https://marathon.jd.com/seckill/seckill.action'; const params = { skuId: productId, num: byNum, rid: Date.now(), }; const headers = { 'User-Agent': this.loginService.ua, Host: 'marathon.jd.com', Referer: `https://item.jd.com/${productId}.html`, cookie: this.loginService.cookieToHeader(), }; return await axios.get(url, { headers, params, maxRedirects: 0, }); } • 当这些 ok 了,就是获取商品信息与提交订单: /** * * 订单结算页 * @returns * @memberof ProductService */ async toCheckOut() { this.logger.log('正在访问订单结算页面'); const url = 'https://marathon.jd.com/seckill/seckill.action'; const params = { skuId: productId, num: byNum, rid: Date.now(), }; const headers = { 'User-Agent': this.loginService.ua, Host: 'marathon.jd.com', Referer: `https://item.jd.com/${productId}.html`, cookie: this.loginService.cookieToHeader(), }; return await axios.get(url, { headers, params, maxRedirects: 0, }); } /** * * 提交订单 * @param {*} jsondata * @returns * @memberof ProductService */ async submitOrder(jsondata) { this.logger.log('正在提交订单'); const defaultAddress = jsondata['addressList'][0]; const invoice = jsondata['invoiceInfo'] || {}; const token = jsondata['token']; const data = { skuId: productId, num: byNum, addressId: defaultAddress['id'], yuShou: 'true', isModifyAddress: 'false', name: defaultAddress['name'], provinceId: defaultAddress['provinceId'], cityId: defaultAddress['cityId'], countyId: defaultAddress['countyId'], townId: defaultAddress['townId'], addressDetail: defaultAddress['addressDetail'], mobile: defaultAddress['mobile'], mobileKey: defaultAddress['mobileKey'], email: defaultAddress['email'] || '', postCode: '', invoiceTitle: invoice['invoiceTitle'] || -1, invoiceCompanyName: '', invoiceContent: invoice['invoiceContentType'] || 1, invoiceTaxpayerNO: '', invoiceEmail: '', invoicePhone: invoice['invoicePhone'] || '', invoicePhoneKey: invoice['invoicePhoneKey'] || '', invoice: invoice ? 'true' : 'false', password: password, codTimeType: 3, paymentType: 4, areaCode: '', overseas: 0, phone: '', eid: eid, fp: fp, token: token, pru: '', }; this.logger.log('提交抢购订单中'); const url = 'https://marathon.jd.com/seckillnew/orderService/pc/submitOrder.action'; const params = { skuId: productId, }; const headers = { 'User-Agent': this.loginService.ua, Host: 'marathon.jd.com', Referer: `https://marathon.jd.com/seckill/seckill.action?skuId=${productId}&num=${byNum}&rid=${Date.now()}`, cookie: this.loginService.cookieToHeader(), }; return await axios.post(url, data, { params, headers, }); } /** * * 获取地址信息 * @returns * @memberof ProductService */ async killInfo() { this.logger.log('正在获取地址发票等信息'); const url = 'https://marathon.jd.com/seckillnew/orderService/pc/init.action'; const data = { sku: productId, num: byNum, isModifyAddress: 'false', }; const headers = { 'User-Agent': this.loginService.ua, Host: 'marathon.jd.com', cookie: this.loginService.cookieToHeader(), }; return await axios.post(url, data, { headers, }); } • 最后会生成付款链接: const final = await this.productService.submitOrder(jsondata); console.log(final.data, "抢购结果"); const result = JSON.parse(final.data); if (result.success) { this.logger.log(`抢购成功,电脑付款链接:https:${result.pcUrl}`); } 作业 • 作业很简单,既然是搞茅台那么就爬取一下茅台的净利润(扣除非经常损益),并保存为 execl 文件。 • 爬取地址:http://quotes.money.163.com/f10/zycwzb_600519.html#01c01