侧边栏壁纸
博主头像
蚌埠住了捏博主等级

快乐,健康,自由,强大

  • 累计撰写 56 篇文章
  • 累计创建 12 个标签
  • 累计收到 21 条评论

目 录CONTENT

文章目录

都快2025了,为什么秒杀和抢票经久不衰?!代码级调研&讨论

蚌埠住了捏
2024-12-08 / 0 评论 / 2 点赞 / 126 阅读 / 5,495 字

最近接触了库存扣减开发,外加深受抢票抢券折磨,借此机会来研究下自动抢票的底层逻辑,也算是补一下老早之前留下的坑

秒杀活动,多方博弈

🗼🐎D,我国互联网实在有太多的秒杀了,连我这种消费欲望极低的人群都能感觉到烦的地步。符合我国国情还是有底层逻辑?搜了搜确有其事

秒杀的运营逻辑

如果从单纯的经济效益角度来看,秒杀机制并不一定会增加销售量。没有秒杀的机制,商家通过常规销售方式也可以获得相同的经济收益。事实上,固定的价格、稳定的购买通道甚至能提升用户的购买体验,避免因技术问题、网络延迟等问题导致用户不满。

秒杀和抢票机制的价值往往体现在品牌营销和用户参与方面,而不是单纯的利润最大化。它们通过吸引用户参与、增加用户粘性、提高社交话题的热度等方式,帮助产品获得更高的市场知名度和用户忠诚度

说白了就以下几个点:增加话题性和关注度(有益于用户增长),提升品牌的“独特性”和“高端性”(获得后更有快感,更有购物欲),确保特定群体获得(排他性,增加忠实用户粘性)。可见更多是应用了营销和市场心理学。

秒杀系统和抢票活动背后的逻辑,确实不是一个简单的零和博弈。在这种复杂的市场环境中,各方利益相互交织,卖家通过营销提升品牌和流量,黄牛通过倒卖获取额外利润,而消费者则面临着多重挑战和潜在损失,但购买成功快感也更多。

摇号为什么干不过秒杀

秒杀机制的设计能够在短时间内激发消费者的购买欲望、增加品牌曝光和提升市场参与感,而摇号机制虽然公平,但缺乏强烈的紧迫感、参与感和品牌效应。因此,商家更倾向于选择秒杀系统来推动销售和营销效果,特别是在那些需要高需求和短期热度的场景中。

也难怪摇号一般只见于国家机构、教育机构了。

秒杀系统

秒杀系统设计略过,搜索发现讲这个的太多了,听说已经成为一道经典大厂面试题了。。。可见应用之广、影响国民之多

参考高赞:https://juejin.cn/post/6861749238466576397?searchId=20241208115330F064DFFD56FF1AB889B2

关键词:静态资源cdn及预加载;限流,后端IP、用户限流,前端置灰按钮;弹性机器部署,负载均衡;缓存替代走db;消息队列异步;限流&降级&熔断&隔离。

一些后端干几年会听到想吐的词

跨平台自动化脚本

看了一圈高star或者高赞的做法,其实就两大类:模拟API调用 或者 模拟UI跳转点击。再或者就是两者的组合

我们来看几个热点的例子

[Stale] Python + Selenium / API request 实现大麦网抢票

https://github.com/MakiNaruto/Automatic_ticket_purchase
or
https://juejin.cn/post/7328339779506454569 (Pure Selenium)

据作者描述:

之前的版本通过按钮操作,还要等待页面元素加载,效率低下。 此版本仅需登录时用到页面,通过selenium打开页面进行登录。其余操作均通过requests进行请求。

目前大部分购买方式已迁移至手机端,需配合抓包处理。暂无更新计划。

可见UI跳转的局限性还是较大的,只不过API来之不易啊,得通过分析浏览器请求甚至是抓包来反向推理,且无法做到随版本更新向后兼容

脚本在用户登录后采用API调用来模拟用户行为,整体表现为一个简单的workflow。btw,也有部分开源作者用Splinter替代Selenium,搜了下Splinter更简单但功能相对较少且普遍用于自动化测试用途。

我们来看一段提交步骤的代码印象更深,这是最后提交订单的步骤,通过API触发。剩余的确认订单并支付的流程作者通过打log的方式进行提醒,用户看到terminal出现字样后点击链接支付或者打开软件页面支付

    def step3_submit_order(self, submit_order_info, viewer, seat_info=None):
        """
        提交订单所需参数信息
        :param submit_order_info:   最终确认订单所需的所有信息。
        :param viewer:  指定观演人进行购票
        :param seat_info:  座位id
        :return:
        """
        headers = {
            'authority': 'buy.damai.cn',
            'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"',
            'accept': 'application/json, text/plain, */*',
            'content-type': 'application/json;charset=UTF-8',
            'x-requested-with': 'XMLHttpRequest',
            'sec-ch-ua-mobile': '?0',
            'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.109 Safari/537.36',
            'sec-ch-ua-platform': '"macOS"',
            'origin': 'https://buy.damai.cn',
            'sec-fetch-site': 'same-origin',
            'sec-fetch-mode': 'cors',
            'sec-fetch-dest': 'empty',
            'referer': 'https://buy.damai.cn/orderConfirm?',
            'accept-language': 'zh,en;q=0.9,en-US;q=0.8,zh-CN;q=0.7',
        }

        params = (
            ('feature', '{"returnUrl":"https://orders.damai.cn/orderDetail","serviceVersion":"1.8.5"}'),
            ('submitref', 'undefined'),
        )
        dm_viewer_pc = str([k for k, v in submit_order_info.get('data').items()])
        dm_viewer_pc_id_search = re.search('dmViewerPC_[0-9]*', dm_viewer_pc)
        if dm_viewer_pc_id_search:
            dm_viewer_pc_id = dm_viewer_pc_id_search.group()  # 获取到观演人的 key
            user_list = submit_order_info['data'][dm_viewer_pc_id]['fields']['dmViewerList']
            all_available_user = [name.get('viewerName') for name in user_list]
            if len(set(viewer).intersection(set(all_available_user))) != len(viewer):
                print('-' * 10, '请检查输入的观演人信息与大麦网观演人信息是否一致', '-' * 10)
                return False
            for user in user_list:
                if user.get('viewerName') in viewer:
                    user['isUsed'] = True
            # 若为选座购买, 则需要添加座位id。
            if seat_info:
                seat_info = [seat.get('seatId') for seat in seat_info]
                seat_index = 0
                for user in user_list:
                    if seat_index > len(viewer) - 1:
                        break
                    if user.get('viewerName') in viewer:
                        user['seatId'] = seat_info[seat_index]
                        seat_index += 1
        else:
            print("该场次不需要指定观演人")

        submit_order_info = json.dumps(submit_order_info)
        response = self.session.post('https://buy.damai.cn/multi/trans/createOrder',
                                     headers=headers,
                                     params=params,
                                     data=submit_order_info,
                                     cookies=self.login_cookies)
        buy_status = json.loads(response.text)
        if buy_status.get('success') is True and buy_status.get('module').get('alipayOrderId'):
            print('-' * 10, '抢票成功, 请前往 大麦网->我的大麦->交易中心->订单管理 确认订单', '-' * 10)
            print('alipayOrderId: ', buy_status.get('module').get('alipayOrderId'))
            print('支付宝支付链接: ', buy_status.get('module').get('alipayWapCashierUrl'))

JS + Chrome Extension 实现自定义秒杀

https://github.com/gongjunhao/seckill

叨叨两句。在公司脚本其实也挺常见的,我工作两年的观察是,要想别人用你的脚本让别人下载git仓库本地安装环境跑起来用是不现实的,这是个技术发达,人人都预期把答案喂到嘴里的时代。非贬义,GPT这系列产品也是这么火起来的。做成不需要部署,或者一次安装后续不需要动的插件、bot、app才是大众最能接受的方式。个人感觉弄成需要环境依赖跑起来的纯脚本在产品的角度上难以生存,除非性能无可替代。

看下作者给的效果图,主要两个html页面:新增秒杀任务footer div & 插件浮窗当前任务列表。

用户必须提前登录,跳转到指定的购物页面,勾选好各个选项之后停留在当前页面。提交的任务其实很简单,就是告诉脚本在某个时间点点击某个页面元素,仅此而已,没有任何前序后续步骤,两千星。。

毕竟两千星,我们来看下核心代码,任务会存在chrome.storage.local,如果任务时间到了,确认前置条件(当前活跃tab页面匹配)

/**
 * 每隔500ms去检查任务,异步处理任务
 */
function processTask(standerTime) {
    console.log("后端开启轮休任务!");
    var timer = setInterval(function () {
        standerTime += 500;
        chrome.storage.local.get({"tasks": new Array()}, function(value) {
            tasks = value.tasks;
            if(tasks != undefined && tasks.length > 0) {
                for(var i=0; i<tasks.length; i++) {

                    if(tasks[i].status == 0) {
                        if((new Date(tasks[i].killTime) - standerTime) >= tickTime && (new Date(tasks[i].killTime) - standerTime) <= (tickTime+600)){
                            console.log(formatDateTime(new Date(tasks[i].killTime).getTime()));
                            var task = tasks[i];
                            //秒杀开始提醒(检查是否打开相关标签页)没有提示打开
                            chrome.tabs.query({url: task.url}, function(results) {
                                if (results.length == 0) {
                                    chrome.notifications.create("openLinkNotify-"+task.id, {
                                        type:    "basic",
                                        iconUrl: "image/link.png",
                                        title:   "秒杀助手提醒",
                                        message: task.name + "\n任务将在2分钟后开始,抢购页面尚未打开,是否前往相关页面!",
                                        buttons: [{ title: "打开抢购页面" }, { title: "忽略" }]
                                    });
                                } else {
                                    var noActive = true;
                                    for(var j=0; j<results.length; j++){
                                        if(results[j].active){
                                            noActive = false;
                                        }
                                    }
                                    if(noActive){   //已经打开但是未激活
                                        chrome.notifications.create("activeTabNotify-"+task.id, {
                                            type:    "basic",
                                            iconUrl: "image/bell.png",
                                            title:   "秒杀助手提醒",
                                            message: task.name + "\n将在2分钟后开始,请检查登录及商品规格选择验证码等!",
                                            buttons: [{ title: "切换Tab抢购页面" }, { title: "忽略" }]
                                        });
                                    } else {    //已经打开且激活
                                        var opt = { type: "basic", title: "秒杀助手提醒", message: task.name + "\n将在2分钟后开始,请检查登录及商品规格选择验证码等!", iconUrl: "image/bell.png"};
                                        chrome.notifications.create(dialogId+++"", opt);
                                    }
                                }
                            });
                        }
                        if((new Date(tasks[i].killTime) - standerTime) >= 0 && (new Date(tasks[i].killTime) - standerTime) <= 600){
                            //异步执行点击事件
                            var task = tasks[i];
                            var tabId = null;
                            chrome.tabs.query({url: task.url}, function(results) {
                                if (results.length > 0) {
                                    for(var j=0; j<results.length; j++){
                                        if(results[j].active){
                                            tabId = results[j].id;
                                        }
                                    }
                                    if(tabId == null) {
                                        tabId = results[0].id;
                                    }
                                }
                                chrome.tabs.executeScript(tabId, { code: "secKill("+task.id+");"});
                                var opt = { type: "basic", title: "秒杀助手提醒", message: task.name + "\n秒杀任务完成!", iconUrl: "image/bell.png"};
                                chrome.notifications.create(dialogId+++"", opt);
                            });
                        }
                    }
                }
            }
        });
    }, 500);
    if(oldTimer != null) {
        clearInterval(oldTimer);
    }
    oldTimer =  timer;
}

/**
 * 处理任务
 * @param task
 */
function dealTask(task) {
    var count = 1;
    var timer = setInterval(function () {
        if(task.selector == "jQuery") {
            $(task.location).each(function(){
                this.click();
            });
        } else {
            $(getElementsByXPath(task.location)).each(function(){
                this.click();
            });
        }
        count++;
        if(count>task.count) {
            clearInterval(timer);
        }
    }, task.frequency);

}

执行任务其实就是根据实现存的element名称查找元素并点击

Kotlin + Accessibility UI 实现大麦网抢票

https://github.com/RookieTree/DaMaiHelper

通安卓无障碍服务和系统权限来实现自动化的抢票操作。不禁感慨遐想,残障人士/懒人/效率狂热者通过只说一句话来跨app自动化完成任何复杂指令目标且没有安全隐患的时代,何时到来

抓取页面ui控件id,模拟点击。如果页面渲染太慢,就会抓取失败导致点击超时无效,所以可以手动辅助点击。该软件只能起到辅助效果,帮忙快速点击

前提条件:商品已经选好并添加到想看。在此基础上,作者自动化了支付以前的所有步骤,每个步骤基本上就是选控件并点击

核心代码:

class DaMaiHelperService : AccessibilityService(), UserManager.IStartListener {

    companion object {
        //首页-我的
        const val ME_UI = "cn.damai.mine.activity.MineMainActivity"  // 我的
        //演唱会信息页
        const val LIVE_DETAIL_UI =
            "cn.damai.trade.newtradeorder.ui.projectdetail.ui.activity.ProjectDetailActivity"
        //购票选择页
        const val LIVE_SELECT_DETAIL_UI =
            "cn.damai.commonbusiness.seatbiz.sku.qilin.ui.NcovSkuActivity"
        //购票结算页
        const val LIVE_TOTAL_UI = "cn.damai.ultron.view.activity.DmOrderActivity"

        const val ID_LIVE_DETAIL_BUY = "tv_left_main_text"//详情页-开抢
        const val ID_CONFIRM_BUY = "btn_buy" //确认购买

        const val STEP_READY = 0
        const val STEP_FIRST = 1
        const val STEP_SECOND = 2
        const val STEP_THIRD = 3
        const val STEP_FOURTH = 4
        const val STEP_FINISH = 5
    }

    private var isStop = false
    private var step = STEP_READY

    private var overlayView: View? = null
    private var mWindowManager: WindowManager? = null
    private val jiJiangStr = "即将"
    private val queHuoStr = "缺货"
    private val yuYueStr = "预约"
    
    /**
     * 监听窗口变化的回调
     */
    override fun onAccessibilityEvent(event: AccessibilityEvent?) {
        logD("event_name:$event")
        if (event == null || isStop || step == STEP_FINISH) {
            return
        }
        if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
            when (event.className.toString()) {
                ME_UI -> {
                    step = STEP_FIRST
                    event.source?.let { source ->
                        sleep(500)
                        val wantView = source.getNodeByText("想看&想玩")
                        wantView?.click()
                        val buyView = source.getNodeByText(UserManager.singer)
                        buyView?.click()
                    }
                }

                LIVE_DETAIL_UI -> {
                    step = STEP_SECOND
                    startQ(event)
                }

                LIVE_SELECT_DETAIL_UI -> {
                    step = STEP_THIRD
                    confirmOrder(event)
                }

                LIVE_TOTAL_UI -> {
                    step = STEP_FOURTH
                    requestOrder(event)
                }
            }
        } else if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
            fullPrintNode("content_change",event.source)
            when (step) {
                STEP_SECOND -> {
                    startQ(event)
                }
                STEP_THIRD -> {
                    confirmOrder(event)
                }
                STEP_FOURTH -> {
                    requestOrder(event)
                }
            }
        }
    }
    
    private fun startQ(event: AccessibilityEvent) {
        event.source?.let { source ->
            val startBuy = source.getNodeById(dmNodeId(ID_LIVE_DETAIL_BUY))
            startBuy?.text()?.let {
                if (!it.contains(jiJiangStr) && !it.contains(queHuoStr) &&
                    !it.contains(yuYueStr)) {
                    startBuy.click()
                    logD("buy_text:$it")
                }
            }
        }
    }

    private fun confirmOrder(event: AccessibilityEvent) {
        event.source?.let { source ->
            sleep(100)
            val buyView = source.getNodeById(dmNodeId(ID_CONFIRM_BUY))
            buyView?.click()
        }
    }

    private fun requestOrder(event: AccessibilityEvent) {
        event.source?.let { source ->
            val nodeByText = source.getNodeByText("提交订单", true)
            nodeByText?.let {
                it.click()
                step = STEP_FINISH
            }
        }
    }

...
}

.NET 实现12306自动抢票

没开源就不介绍了。这哥们为此写了个windows软件并且维护了10年,不得不服。

https://www.bypass.cn/about

只不过12306为了利益最大化,非全程票放票会存在非常大的时间差,属实🤮。为了省几百块赌概率还是让平台恰烂钱用钱买安心,哪个好还真不一定。

Node.js + API反向工程 实现微信小程序抢票

这是我近几个月在掘金读过的最好文章,强推,如果感兴趣请移步原文。

https://juejin.cn/post/6844903975301595150

这篇神作通过完整的逻辑故事叙述讲解了自己如何解决一个看似不可能的任务的。微信小程序一般mac是不能直接访问的,作者通过Charles proxy抓取手机微信程序访问的网页请求,req&res,解析结构,通过模拟user-agent & cookie通过微信的检查。摸清楚所有步骤的请求后用node.js axios.post & Promise异步调用接口实现,当然用python也是可以的。重点在于API调用的研究和模拟那块的逻辑非常有意思,值得花一个小时读读。

调试分析工具以后可以在安全话题下单独研究下

工具 主要用途 优势 劣势
Charles HTTP/HTTPS 调试 易用,支持 HTTPS 解密,断点调试,流量模拟 付费软件,功能偏向 HTTP/HTTPS
Fiddler HTTP/HTTPS 调试 免费,支持脚本扩展,跨平台支持 界面相对复杂,经典版仅支持 Windows
Wireshark 网络协议分析 支持多种协议,功能强大,完全免费 学习曲线陡峭,非 HTTP 流量分析较复杂

Auto.js/AutoX.js + 安卓无障碍 实现自定义自动化

http://www.autojs.cc/docs/index.htm
http://doc.autoxjs.com/#/
https://github.com/kkevsekk1/AutoX
详细教程见
https://juejin.cn/post/7083512899200614430

在auto.js脚本框架的加持下自动化脚本特别简单,且能够通过vscode插件实现在电脑里run script on device,非常cool的一种方式,只不过时间不多了没有来得及深入研究。

贴一下这个抢菜代码,才几十行!

// 解锁手机屏幕
function unLock() {
  if (!device.isScreenOn()) {
    device.wakeUp();
    sleep(500);
    swipe(500, 2000, 500, 1000, 200);
    sleep(500);
    const password = "123456"; //这里换成自己的手机解锁密码
    for (let i = 0; i < password.length; i++) {
      let position = text(password[i]).findOne().bounds();
      click(position.centerX(), position.centerY());
      sleep(100);
    }
  }
  sleep(1000);
}

//抢菜流程
function robVeg() {
  unLock();
  launchApp("美团买菜");
  waitForPackage("com.meituan.retail.v.android", 200);
  auto.waitFor();
  const btn_skip = id("btn_skip").findOne();
  if (btn_skip) {
    btn_skip.click();
    toast("已跳过首屏广告");
  }
  sleep(2000);
  gotoBuyCar();
  sleep(2000);
  checkAll();
  sleep(2000);
  submitOrder(0);
}

robVeg();

//打开购物车页面
function gotoBuyCar() {
  if (id("img_shopping_cart").exists()) {
    id("img_shopping_cart").findOne().parent().click();
    toast("已进入购物车");
  } else {
    toast("没找到购物车");
    exit;
  }
}

//勾选全部商品
function checkAll() {
  const isCheckedAll = textStartsWith("结算(").exists();
  const checkAllBtn = text("全选").findOne();
  if (!!checkAllBtn) {
    !isCheckedAll && checkAllBtn.parent().click();
    sleep(1000);
  } else {
    toast("没找到全选按钮");
    exit;
  }
}

function submitOrder(count) {
  if (textStartsWith("结算(").exists()) {
    textStartsWith("结算(").findOne().parent().click();
  } else if (text("我知道了").exists()) {
    toast("关闭我知道了");
    text("我知道了").findOne().parent().click();
  } else if (text("重新加载").exists()) {
    toast("重新加载");
    text("重新加载").findOne().parent().click();
  } else if (text("立即支付").exists()) {
    text("立即支付").findOne().parent().click();
  } else if (text("确认支付").exists()) {
    const music =
      "/storage/emulated/0/netease/cloudmusic/Music/Joel Hanson Sara Groves - Traveling Light.mp3";
    media.playMusic(music);
    sleep(media.getMusicDuration());
  } else {
    toast("抢个屁!");
    exit;
  }
  sleep(800);
  if (count > 10000) {
    toast("没抢到");
    exit;
  }

  submitOrder(count++);
}

脚本和专业团队的差距

脚本相对于手动好在准时、重试更快,如果可以破解API调用还能省去UI加载耗时进一步抢先。肯定能通过某些优化来提升抢中的概率,但是过多人力成本投入就没太大必要了。作为曾经试用过python+selenium & 插件抢票的用户角度而言,我感觉市面上公开的脚本成功率真的挺低的,当然也可能一定程度归咎于设备和家里的带宽。

为什么黄牛无法战胜,甚至发展成了巨额的产业,比如摩天轮app以及闲鱼里泛滥的票务商家。专业干这事的到底强在哪。除了黄牛、平台、卖家主办方之间大概率存在的PY交易(我估计占大头,毕竟连稳定内部渠道人脉都没有好意思叫自己黄牛吗😅),从技术策略层面有以下可能原因:

  1. 部署脚本到靠近秒杀系统的机房:通过将抢购脚本部署在距离秒杀服务器最近的机房,减少网络延迟,提升请求响应速度。
  2. 提升网络质量与带宽:购买高带宽、高质量网络线路,保证抢购时网络稳定,避免因网络问题错失机会。
  3. 破解API,绕过前端自动化:通过分析网站API接口,直接提交订单,绕过前端页面的流量控制和反作弊机制。
  4. 多机器,多账号并行作战:使用多个设备并行提交请求,提高抢购的概率,模拟多个用户同时抢购。
  5. 利用多渠道的策略:通过多个电商平台增加抢购的机会。
  6. 利用VPN或代理池绕过IP限制:使用VPN或代理IP池分散请求,避免IP封禁,提升并发请求的成功率。
  7. 自动化抢购与人工辅助结合:结合自动化脚本和人工操作,自动化处理基础任务,人工介入应对验证码等反作弊措施。
  8. 通过图像识别绕过验证码:使用图像识别技术解析验证码并自动提交,绕过图形验证码验证。
  9. 优化秒杀商品的加载时间:提前访问秒杀页面,缓存商品信息,确保秒杀时迅速响应。

AI Agent能帮我们抢票吗?

short answer: not now.

目前Agent主要是基于openai,LlamaIndex和LangChain来实现的。他们主要定位还是生成型工具,适用于生成自然语言响应、查询和任务处理,而不是直接执行任务。当然我相信这一点以后会逐渐变化,从前不久claude发布的能创建github仓库的agent视频来看,还是有不少可以改进的地方

它们能够帮助生成自动化脚本或处理复杂的任务,但其核心还是基于自然语言生成,通过外部工具(如API调用、浏览器控制等)主要是为了增强生成内容的质量。LlamaIndex适用于增强信息检索(RAG)任务,LangChain则可以通过与外部工具的结合来增强复杂任务的执行。

像抢票这种对性能要求极高的场景,固定脚本是更合适的选择,因为它能够直接、快速地执行具体的任务,避免了生成型工具可能带来的延迟或不确定性。自动化脚本能够在秒杀开始前就准备好,精确控制页面的交互操作,从而提高成功率。

2

评论区