71

趁着双11,写个京东商品自动下单 · Issue #13 · shaodahong/dahong · GitHub

 6 years ago
source link: https://github.com/shaodahong/dahong/issues/13
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Owner

shaodahong commented on Oct 27, 2017

edited

项目地址 求个 star

在现在,商家一年不卖货,双11卖出一年的货是大家都知道的事实了,总得来说调一调蚊子腿的价格,聊胜于无,但是也会有些神价格会出现,这时候买到就是赚到

本来是想趁着双11组台电脑,买个 Z370 的板U套装,没想到京东的 8700k 一直是无货的状态,这几天有货了,价格涨到了3999,简直不能忍,看了下板U套装比较划算,但是有些板U套装是不支持自动下单的,所以 gayhub 搜搜看有没有爬虫可以监听到货自动下单的,正好有了这哥们的 jd-autobuy Python 脚本,还有 Go 的,看了下接口已经很齐全了,来个 node 版本的助助兴

32089578-6a2fa1ac-bab0-11e7-8b18-db855bf1efb6.png

这次用到的 http 库是 axios,支持客户端和服务端,总得来说语法还是很简洁的,在这之前还有个 superagent 库,看了下也差不多,只不过 superagent 在 response 上多处理了下

因为在 vue 中使用了 axios,这次想试试服务端的能力咋样,还是一如既往的好,滋次一波

先写个 request header ,毕竟是服务端,没有浏览器帮你处理 User-Agent,所以自己去浏览器请求下然后把 header 拿到

const defaultInfo = {
    header: {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36',
        'Content-Type': 'text/plain;charset=utf-8',
        'Accept-Encoding': 'gzip, deflate, br',
        'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.6,en;q=0.4,en-US;q=0.2',
        'Connection': 'keep-alive',
    },
}

header 拿到我们就可以伪装成浏览器去请求二维码图片了,京东的扫码图片地址 https://qr.m.jd.com/show,没有多余的技巧,直接用 axios 来个get请求即可

async function requestScan() {
    const result = await request({
        method: 'get',
        url: 'https://qr.m.jd.com/show',
        headers: defaultInfo.header,
        params: {
            appid: 133,
            size: 147,
            t: new Date().getTime()
        },
        responseType: 'arraybuffer'
    })
}

参数 appid sizet 可以通过抓包拿到的,这里注意我 responseType 用的 arraybuffer,默认值是 json ,buffer 主要是方便我们来像本地写入图片,我们来处理下 res

defaultInfo.cookies = cookieParser(result.headers['set-cookie'])
defaultInfo.cookieData = result.headers['set-cookie'];
const image_file = result.data;
await writeFile('qr.png', image_file)
async function writeFile(fileName, file) {
    return await new Promise((resolve, reject) => {
        fs.writeFile(fileName, file, 'binary', err => {
            opn('qr.png')
            resolve()
        })
    })
}

这一步 cookie 已经拿到了,这里我做了两步处理,一步是自己写的 cookieParser 把参数进行解析,主要是拿到其中的 wlfstk_smdl,接下来会用到,然后直接 writeFile 写入图片就行了,写好了之后利用 opn 打开图片,sindresorhus 大神的 opn 库还是蛮好用的,可以指定程序打开图片,文件等

在扫码之前我们要监听扫码的状态

async function listenScan() {

    let flag = true
    let ticket

    while (flag) {
        const callback = {}
        let name;
        callback[name = ('jQuery' + getRandomInt(100000, 999999))] = data => {
            console.log(`   ${data.msg || '扫码成功,正在登录'}`)
            if (data.code === 200) {
                flag = false;
                ticket = data.ticket
            }
        }

        const result = await request({
            method: 'get',
            url: 'https://qr.m.jd.com/check',
            headers: Object.assign({
                Host: 'qr.m.jd.com',
                Referer: 'https://passport.jd.com/new/login.aspx',
                Cookie: defaultInfo.cookieData.join(';')
            }, defaultInfo.header),
            params: {
                callback: name,
                appid: 133,
                token: defaultInfo.cookies['wlfstk_smdl'],
                _: new Date().getTime()
            },
        })

        eval('callback.' + result.data);
        await sleep(1000)
    }

    return ticket
}

一开始的想法是开个定时器来轮询下:"好没好呀",没有我1秒后再来问下,这里使用 async/await
的强大功能实现个 sleep,比 setTimeout 的使用更优雅而且对于异步的处理也能够操控自如

function sleep(ms) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve()
        }, ms)
    })
}

这里我们把 header 组合一下,刚刚拿到的 cookie 带上,并加上 hostreferer 来表明我们从哪里来要到哪里去,参数里面的 token 就是之前解析 cookie 拿到的 wlfstk_smdl ,这个接口应该约定的 jQuery jsonp(京东看了下 jsonp 还是蛮多的),所以我这里使用一个 callback 来模拟一个 jsonp 的执行,看返回的 code 和 msg,code 为 200 的时候说明扫码成功了,这时候 msg 是没有的,所以自定义下,其他状态是有 msg 的,直接输出就 OK 了,扫码成功我们要拿到 ticket,这个从字面上理解就知道了,大兄弟你拿到入场券了,并且 ticket 下单的时候也是需要的,存起来

这时候用你的手机打开京东扫一扫打开的二维码图片,确认后扫码成功,用入场券登录去

async function login(ticket) {
    const result = await request({
        method: 'get',
        url: 'https://passport.jd.com/uc/qrCodeTicketValidation',
        headers: Object.assign({
            Host: 'passport.jd.com',
            Referer: 'https://passport.jd.com/uc/login?ltype=logout',
            Cookie: defaultInfo.cookieData.join('')
        }, defaultInfo.header),
        params: {
            t: ticket
        },
    })

    defaultInfo.header['p3p'] = result.headers['p3p']
    return defaultInfo.cookieData = result.headers['set-cookie']
}

这一步没什么说的,入场券有了,理所应当登录成功了,拿到 p3p 参数并且更新下 cookie 这样一个合法的身份就诞生了

有了身份后就可以去 get 商品页面,这一步需要拿三个请求的信息拼一下

拿到商品页面的 html

function goodInfo(goodId) {

    const stockLink = `http://item.jd.com/${goodId}.html`

    return request({
        method: 'get',
        url: stockLink,
        headers: Object.assign(defaultInfo.header, {
            cookie: defaultInfo.cookieData.join('')
        }),
        responseType: 'arraybuffer'
    })
}

拿到商品的价格

async function goodPrice(stockId) {
    const callback = {}
    let name;
    let price;

    callback[name = ('jQuery' + getRandomInt(100000, 999999))] = data => {
        price = data
    }

    const result = await request({
        method: 'get',
        url: 'http://p.3.cn/prices/mgets',
        headers: Object.assign(defaultInfo.header, {
            cookie: defaultInfo.cookieData.join('')
        }),
        params: {
            type: 1,
            pduid: new Date().getTime(),
            skuIds: 'J_' + stockId,
            callback: name,
        },
    })

    eval('callback.' + result.data)

    return price
}

拿到商品的状态

async function goodStatus(goodId, areaId) {
    const callback = {}
    let name;
    let status

    callback[name = ('jQuery' + getRandomInt(100000, 999999))] = data => {
        status = data[goodId]
    }

    const result = await request({
        method: 'get',
        url: 'http://c0.3.cn/stocks',
        headers: Object.assign(defaultInfo.header, {
            cookie: defaultInfo.cookieData.join('')
        }),
        params: {
            type: 'getstocks',
            area: areaId,
            skuIds: goodId,
            callback: name,
        },
        responseType: 'arraybuffer'
    })

    const data = iconv.decode(result.data, 'gb2312')
    eval('callback.' + data)

    return status
}

最后 Promise.all 一波带走

async function runGoodSearch() {

    let flag = true

    while (flag) {
        const all = await Promise.all([goodPrice(defaultInfo.goodId), goodStatus(defaultInfo.goodId, defaultInfo.areaId), goodInfo(defaultInfo.goodId)])

        const body = $.load(iconv.decode(all[2].data, 'gb2312'))
        outData.name = body('div.sku-name').text().trim()
        const cartLink = body('a#InitCartUrl').attr('href')
        outData.cartLink = cartLink ? 'http:' + cartLink : '无购买链接'
        outData.price = all[0][0].p
        outData.stockStatus = all[1]['StockStateName']
        outData.time = formatDate(new Date(), 'yyyy-MM-dd hh:mm:ss')

        console.log()
        console.log(`   商品详情------------------------------`)
        console.log(`   时间:${outData.time}`)
        console.log(`   商品名:${outData.name}`)
        console.log(`   价格:${outData.price}`)
        console.log(`   状态:${outData.stockStatus}`)
        console.log(`   商品连接:${outData.link}`)
        console.log(`   购买连接:${outData.cartLink}`)

        const statusCode = all[1]['StockState']
        // 如果有货就下单
        // 33 有货  34 无货
        if (+statusCode === 33) {
            flag = false
        } else {
            await sleep(defaultInfo.time)
        }
    }
}

这里要解析 dom,$ 就是有着 Node 版 jQuery 之称的 cheerio,但是如果直接解析会乱码,先转码,转码神器出场 iconv-lite,剩下的就是 jQuery 操作了,很久没写 jQuery 了,写起来还是这么的顺溜

defaultInfo 中的 goodId 是商品的 id,下面会说到,解析命令行的参数获得的,在哪里能看到呢,来图

image

areaId 是对应着区域的信息,毕竟每个城市的库存都是不一样的

image

京东购物的流程购物车先走一波,然后开始下单付款,有货了我们加入购物车

async function addCart() {
    console.log()
    console.log('   开始加入购物车')

    const result = await request({
        method: 'get',
        url: outData.cartLink,
        headers: Object.assign(defaultInfo.header, {
            cookie: defaultInfo.cookieData.join('')
        }),
    })

    const body = $.load(result.data)

    const addCartResult = body('h3.ftx-02')

    if (addCartResult) {
        console.log(`   ${addCartResult.text()}`)
    } else {
        console.log('   添加购物车失败')
    }
}

没什么可说的,加入后开始下单

async function buy() {
    const orderInfo = await request({
        method: 'get',
        url: 'http://trade.jd.com/shopping/order/getOrderInfo.action',
        headers: Object.assign(defaultInfo.header, {
            cookie: defaultInfo.cookieData.join('')
        }),
        params: {
            rid: new Date().getTime(),
        },
        responseType: 'arraybuffer'
    })

    const body = $.load(orderInfo.data)
    const payment = body('span#sumPayPriceId').text().trim()
    const sendAddr = body('span#sendAddr').text().trim()
    const sendMobile = body('span#sendMobile').text().trim()

    console.log()
    console.log(`   订单详情------------------------------`)
    console.log(`   订单总金额:${payment}`)
    console.log(`   ${sendAddr}`)
    console.log(`   ${sendMobile}`)
    console.log()

    console.log('   开始下单')

    const result = await request({
        method: 'post',
        url: 'http://trade.jd.com/shopping/order/submitOrder.action',
        headers: Object.assign(defaultInfo.header, {
            cookie: defaultInfo.cookieData.join('')
        }),
        params: {
            'overseaPurchaseCookies': '',
            'submitOrderParam.btSupport': '1',
            'submitOrderParam.ignorePriceChange': '0',
            'submitOrderParam.sopNotPutInvoice': 'false',
            'submitOrderParam.trackID': defaultInfo.ticket,
            'submitOrderParam.eid': defaultInfo.eid,
            'submitOrderParam.fp': defaultInfo.fp,
        },
    })

    if (result.data.success) {
        console.log(`   下单成功,订单号${result.data.orderId}`)
        console.log('请前往京东商城及时付款,以免订单超时取消')
    } else {
        console.log(`   下单失败,${result.data.message}`)
    }
}

其实这里 post http://trade.jd.com/shopping/order/submitOrder.action 这个就可以了,前面的一个请求是下单页面拿一下订单的信息展示下,这里会有两个注意的点

  1. 商品的数量
    京东下单是把购物车这个商品全部下单,不管数量的,比如你购物车已经有一件这个商品了,那么前面的流程走完后购物车现在有两件这个商品,下单后是下单了两件,当然了这里是可以更改数量的,但是我没写

  2. 订单的参数
    上面下单的请求可以注意到三个陌生的参数 submitOrderParam.trackID submitOrderParam.eid submitOrderParam.fp ,trackID 前面有拿到过,这里直接用就行了,那么 eid 和 fp 是从哪来的呢?答案是登录页面,但是这里有个坑是 request 返回的页面拿到的 dom 元素是不行的,只能通过浏览器来,这也很好办,Node 有 phantomjs,但是这里我用了 Chrome 出品的 puppeteer

puppeteer 使用也很简单,它是基于 Node 的 headless Chrome 工具

puppeteer.launch().then(async browser => {
    console.log('   初始化完成,开始抓取页面')
    const page = await browser.newPage();
    await page.goto('https://passport.jd.com/new/login.aspx');
    await sleep(1000)
    console.log('   页面抓取完成,开始分析页面')
    const inputs = await page.evaluate(res => {
        const result = document.querySelectorAll('input')
        const data = {}

        for (let v of result) {
            switch (v.getAttribute('id')) {
                case 'token':
                    data.token = v.value
                    break
                case 'uuid':
                    data.uuid = v.value
                    break
                case 'eid':
                    data.eid = v.value
                    break
                case 'sessionId':
                    data.fp = v.value
                    break
            }
        }

        return data
    })

    Object.assign(defaultInfo, inputs)
    await browser.close();
    console.log('   页面参数到手,关闭浏览器')

    console.log()
    console.log('   -------------------------------------   ')
    console.log('                请求扫码')
    console.log('   -------------------------------------   ')
    console.log()

})

puppeteer 首先要 launch 后来生成一个 browser 的实例,我们用 browser 来新建一个页面运行我们的网址,并且我们可以在它提供的 evaluate 方法中操作 DOM,上面的代码也是很简单的,一目了然

至此基本上一个自动下单的功能就完成了,再扩展下命令行参数

const args = require('yargs').alias('h', 'help')
    .option('a', {
        alias: 'area',
        demand: true,
        describe: '地区编号',
    })
    .option('g', {
        alias: 'good',
        demand: true,
        describe: '商品编号',
    })
    .option('t', {
        alias: 'time',
        describe: '查询间隔ms',
        default: '10000'
    })
    .option('b', {
        alias: 'buy',
        describe: '是否下单',
        default: true
    })
    .usage('Usage: node index.js -a 地区编号 -g 商品编号')
    .example('node index.js -a 2_2830_51810_0 -g 5008395')
    .argv;

这里我给了两个必需的参数和两个可选的参数,-a 必须要的,地区编号,-g 必要要的,商品编号,-t 商品查询的间隔时间,默认是10s,-b是否自动购买,默认是购买的,这里是 boolean,yargs 还是蛮好用的,也可以用 TJ 大神的 commander,都是一样的

完整的代码可以去下面的项目地址中查看

项目地址 求个 star

KopWang, YanYuanFE, warpcgd, zhengSSge, iHaPBoy, xuhaoxin123, HelloAndyZhang, OTLeon, meoww-bot, qiwang97, and macc6579 reacted with thumbs up emoji

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK