美团经营宝逻辑记录
起因是帮朋友在店里改商品的价格 但是美团经营宝的后台改太神经病了 就是一个一个点日历 几十个商品点下来手都酸 通用预订这些 节假日要改 周一到周日也要改 老手动干这事太麻烦了他娘的 所以 想了一下 写了这么一个小软件 改价格方便一点
人从美团经营宝登进去 真正改通用预订的页面在 e.dianping.com 业务名 commonbook 商品类型 sputype=2000012 价格在商品 JSON 的 priceList 里 type=1 是周一到周日 startDay / endDay 用 "1" 到 "7" type=2 是一段特殊日期 比如 2026-10-01 到 2026-10-07
列表上的 minPrice 才是筛选字段 日历里某个时段写成 108 和列表最低价不是同一个值 筛的时候按列表 minPrice 做精确相等 填 108 只动 108 的 不会把全店扫进去
改价顺序就四步 Python 和后面的软件都是这个:
GET /v1/scp/merchant/standard/product/query.json拉在售列表GET /v1/scp/merchant/standard/product/load.json读整包商品POST /v1/scp/merchant/standard/product/edit.json提交改过的整包POST /v1/scp/merchant/standard/product/publish.json发布
保存是表单 productid、productstr、bizname、sputype productstr 是整包 JSON 只动价格相关的段 其余字段按原文字节留着 整包重新编码会动到服务端要校验、但业务上不关心的细节
为什么懒得写 mtgsig 纯算
这些请求带 mtgsig Cookie 拿去纯 HTTP 直发 服务端不认 我跟过纯算 跟到进 VM 之前就停了
经营宝页面里抓到的 GET mtgsig 在 URL 查询参数里 是一段 JSON 同一个签名器也能把这段 JSON 写进 headers.mtgsig 供应链接口两种都见过 都是 H5guard 补的
网页这条链:
XHR / fetch hook
→ H5guard.addCommonParams
→ H5guard.sign
→ hY
→ hZ
→ hq
→ aS 自定义字节码 VM
→ JSON.stringify
→ URL 参数或 Header
走 KNB 的时候 hZ 后面会到 hV 让原生签 网页没有 KNB 或者原生签失败 落回 hq 改价走的是网页 所以看 hq
签名前自动补:
yodaReady=h5csecplatform=4csecversion=4.3.0
m-appkey=fe_com.sankuai.gfesupplychain.web 是业务头 不属于 mtgsig
hq 在进 VM 之前把请求收成:
METHOD + 空格 + PATH + 空格 + 排好序的 query
query 排序 已有的 mtgsig 丢掉 +、百分号、空值按页面规则处理 GET 会把 data 和 URL 参数合并 非 GET 把 body 接在后面 对象先 JSON.stringify 最长 16200 字节
这条规范化对得上 给经营宝消息接口 出来就是:
GET /merchant/portal/message csecplatform=4&csecversion=4.3.0&yodaReady=h5
74 字节 拿这行去做 MD5 或者把十六进制当签名贴回去 都不是 mtgsig
完整对象是这些字段 会变的不贴原值:
{
"a1": "1.2",
"a2": 1791141033489,
"a3": "…",
"a5": "…",
"a6": "…",
"a8": "…",
"a9": "4.3.0,9,43",
"a10": "00",
"x0": 4,
"d1": "…"
}
当时对过 4 个成功的 GET 稳定的是 a1、a3、a6、a9、a10、x0 每条请求都变的是 a2、a5、a8、d1
a1是版本标记 样本是1.2a2是毫秒时间戳a3是 DFP 对得上配置里的dfpId也和WEBDFPID绑在一起 换环境就变a6前缀hs1.session 和设备状态 样本长度 133 不是用 URL 算出来的a9样本是4.3.0,9,43a10样本是00x0是4对应csecplatform=4a5是 VM 对这次请求的输出 样本长度 88a8、d1是 32 位十六进制 一条请求一个值
URL / method / body
↓
hq:规范化
↓
aS:自定义字节码 VM
↓
JSON.stringify
↓
完整 mtgsig
规范化只是第一层 a5、a8、d1 出不来
aS 不是单次 MD5 字节码嵌在 hq 里 执行时还吃运行时状态 能调到 MD5 helper aO、MurmurHash2 风格的 hC 再加上压缩和编码 cache-token、cache-token-p、浏览器指纹、随机数、SDK 参数都参与初始化 只喂 URL 和 body 算不出同样的 a6 也算不出当次的 a5
页面上能对上的入口是 init、getfp、getId、initWithKey、sign、xhrResHandle、fetchResHandle、addCommonParams、getSGRandom 配置走 /horn/v1/modules/H5guard_BaseSec/prod 页面一改 字节码和名单都会变
再往下就是把这套 VM 和设备状态搬成一个能跟着改版跑的纯算 改价用不上这个维护成本 所以签名停在规范化 请求放到已经登录的点评页里 fetch 让 H5guard 自己补 a5、a8、d1
那 4 条 GET 还能再看细一点 路径分别是 /merchant/portal/message /merchant/portal/newMenus/showNewMenus /merchant/portal/common/cityshop /gateway/merchant/portal/pc/gray 都是 200 a2 依次是 1791141033489 1791141033494 1791141033551 1791141033554 相邻两条差 5 毫秒 再隔 57 毫秒 再差 3 毫秒 这就是同一轮页面初始化里连续打出去的 时间在走 签名跟着走
query 也不一样 message 和 showNewMenus 只有 csecplatform csecversion yodaReady cityshop 多了 pageSource shopId gray 多了 grayScene 参数集合变了 a1 a3 a6 a9 a10 x0 仍是同一套 a5 长度都是 88 a6 长度都是 133 前缀都是 hs1. a8 和 d1 四条四个值 所以变的是请求相关输出 稳的是这一次页面会话里的设备和 SDK 状态
hq 里实际顺序也能对上 先把 method 转大写 URL 用 aA 补全相对路径 data 是 null 就当成没有 字符串 body 原样用 普通对象先 JSON.stringify 已经是字节的标记出来后面按字节接 GET 时如果 data 是非空对象 先收 data 再把 URL 上 data 里没有的 key 补进来 其它情况只收 URL query 然后按页面自己的比较函数排序 拼的时候丢掉 key 为 mtgsig 的项 文本是 method + 空格 + path + 空格 + query 再按 UTF-8 收成字节 非 GET 且 body 有值就再接一段 对象先转字符串再转字节 截到 16200
出口有两个 k5 为真写 headers.mtgsig 为假就把 JSON.stringify 之后的字符串 encodeURIComponent 挂到 URL 上 已有问号就用 & 没有就用 ? 我抓到的那 4 条 GET 走的是 URL 这条 供应链改价的 fetch 两种都见过 生成函数是同一个
规范化脚本对消息接口打出来的 hex 就是那 74 个字节的 UTF-8 不是签名 完整 mtgsig 还要 aS 吃进去的这些东西 当次毫秒时间 DFP session 块 cache-token cache-token-p 指纹和 guard 内部状态 VM 再吐 a5 a8 d1 缺任何一块 贴回去都过不了
Python 在干什么
脚本做的是特殊日期这一档 打开已登录的通用预订列表页 Cookie 写进浏览器上下文 域名 .dianping.com 列表和改价都不在 Python 进程里直发 函数丢进页面 由页面 fetch 头只带 m-appkey credentials: "include" mtgsig 由页面补
列表按页打 query.json pagesize=50 sputype=2000012,522 bizname=commonbook 只要在架的 用返回的 pageCount 翻完 按商品 id 去重 然后用列表 minPrice 做精确匹配 命中的才进入改价
每个命中商品:load 整包 找 type=1 的工作日时段 抄 startTime、endTime、次日标记和 priceListAttrMap 价格换成目标价 写成 type=2 同一 startDay / endDay 已经有段 就覆盖那一段 不追加第二段 没有工作日时段时 兜一个 10:00–15:00
const week = list.find((x) => x.type === 1) || list[0];
const periods = ((week && week.periodPrice) || []).map((p) => ({
startTime: p.startTime,
endTime: p.endTime,
startTimeNextDay: p.startTimeNextDay || 0,
endTimeNextDay: p.endTimeNextDay || 0,
price: String(price),
priceListAttrMap: p.priceListAttrMap || {},
}));
const block = { type: 2, startDay: start, endDay: end, periodPrice: periods };
const idx = list.findIndex((x) => x.type === 2 && x.startDay === start && x.endDay === end);
if (idx >= 0) list[idx] = block;
else list.push(block);
改完的整包用表单 edit productid 取包里的 productId 没有就用列表 id sputype 取包里的 spuType 没有就用 2000012 编辑成功再 publish productids 是这个商品 id 编辑失败的不发布 商品之间留 150 毫秒
这版只处理特殊日期 周一到周日、修早费、修仙费没在这里 能跑 用的时候还是得自己调脚本 店长不会坐在终端前面干这个 干脆做成软件 店长自己打开就能改 那很舒服了
Go 这边在干什么
桌面程序是 Go + Wails 发请求的是一个 WebView2 和界面不是同一个 WebView 用户数据放在本机目录 进程活着就留着 同一门店、同一份登录 下次直接复用 不再导航一遍
WebView2 跑在单独的 STA 线程上 界面线程要把活丢过去 用窗口消息把那条线程叫醒 再在它的消息循环里执行 Cookie 写入、导航、Eval、读 Cookie 都得在这条线程上 不能跨线程碰 COM
页面创建时注入:
window.__mtCall = async (req) => {
const headers = {
"m-appkey": "fe_com.sankuai.gfesupplychain.web",
accept: "*/*",
};
const method = String(req.method || "GET").toUpperCase();
const opts = { credentials: "include", headers, method };
if (method === "POST") {
opts.headers = Object.assign({}, headers, {
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
});
const body = new URLSearchParams();
Object.keys(req.form || {}).forEach((k) => {
body.set(k, String(req.form[k] ?? ""));
});
opts.body = body;
}
const r = await fetch(req.url, opts);
return JSON.stringify({ status: r.status, text: await r.text() });
};
Go 只组 URL 和表单 Call 在页面里执行 window.__mtCall(...) 把返回的 JSON 解出来 签名仍然是 H5guard 的事
登录不走短信协议 点设置里的登录 把这个webview2窗口移到屏幕上 打开商品列表 没登录时页面自己跳到经营宝 人在官方页登完 再读 Cookie edper 这些是 HttpOnly document.cookie 拿不到 用 CookieManager.GetCookies 空 URI 和点评、美团、epassport 几个源都读一遍 同名优先留 .dianping.com 的值 没有 edper 或者页面还停在登录地址 就当没登完 登完会再打一次只读的 query.json code 不是 200 也不收
收下来的 Cookie 和当前时间一起落盘 普通保存门店、间隔这些参数时 登录时间不动 超过 3 小时 界面标过期 列表和改价都不发 登录按钮只在设置展开时出现 主界面不放 Cookie 输入
列表逻辑和 Python 一样:query.json 按 50 条翻页 去重 minPrice 精确命中 同一门店、同一登录 列表留在内存里 预览和正式改价共用这一份 不各拉一遍
正式改之前先做预览 只读第一条要改的商品 不 edit 特殊日期一列 时段抄工作日 格子里都是目标价 周一到周日两列 周中取已填的周一到周五里第一个有价的那天 周末取周六周日里第一个有价的那天 每个场次按修早、修仙规则算出显示价 确认之后才进入写入
写入按 4 个商品一组 一组先在同一次页面调用里把 load 打完 load 和 load 之间不等 Go 里改 JSON 再把 edit 需要发布的话再加 publish 一组丢回页面 商品组之间才停 150 毫秒 某一条 load 失败就跳过 不拿空包去提交
节假日在 Go 里改 不在页面 JS 里改 AddHolidayRaw 用 gjson 读 用 sjson.SetRaw 把 priceList 换回去 其它字段保持原文字节 工作日时段抄成 type=2 同一日期段覆盖第一段 periodPrice 里的次日标记用原始数字写回 避免 0 / 1 被重编码 缺 productRelationships 时补一个空数组
gjson.Get(productJSON, "priceList").ForEach(func(_, el gjson.Result) bool {
if !replaced && el.Get("type").Int() == 2 &&
el.Get("startDay").String() == start &&
el.Get("endDay").String() == end {
items = append(items, block)
replaced = true
return true
}
if el.Raw != "" {
items = append(items, el.Raw)
}
return true
})
周一到周日走 ApplyWeekRaw 只改 type=1 而且 startDay 必须等于 endDay 值在 1 到 7 type=2 不动 没填价格的那天跳过
价不是只改 priceList 场次 SKU 在 items 里 week 标明周几 period 标明开场时间 price 是这场的标价 itemAttrs 里还有一份嵌进去的 priceList 日历冗余在 priceListRedundance 这三处一起改 只改 priceList 的话 后台日历和列表对不上
修早:当天、startTime 正好 10:00、不是次日开场 在当天底价上加 10
修仙看 22:00 是否落在 [start, end) 里 结束正好 22:00 不算 第一场含 22:00 的是第 1 档 之后同一天开场的每场再加一档 startTimeNextDay=1 的场次不再加 封顶在已经累到的档数
周中 118 17:00–23:00 含 22:00 这场是 128
func coversPoint(start, end, point int, startNext, endNext int) bool {
s := start + 1440*startNext
e := end + 1440*endNext
if e <= s {
e += 1440
}
return s <= point && point < e
}
patchLoaded 按模式分发 周价模式走 ApplyWeekRaw 否则走 AddHolidayRaw 提交时 productid、sputype 从改完的包里取 取不到再用列表上的 id 和默认 2000012
界面只负责填最低价、日期或七天的价、修早修仙开关 以及确认 筛谁、怎么改 JSON、签名放哪一页 都在上面这条链里
节假日改包时次日标记和 priceListAttrMap 不重新生成 从工作日那段的原文贴回去 数字是 0 还是 1 保持抓到的写法
func patchPeriodRaws(productJSON, block, price string) (string, error) {
week := gjson.Get(productJSON, "priceList.#(type==1)")
if !week.Exists() {
week = gjson.Get(productJSON, "priceList.0")
}
var src []gjson.Result
if week.Exists() {
week.Get("periodPrice").ForEach(func(_, p gjson.Result) bool {
src = append(src, p)
return true
})
}
if len(src) == 0 {
return block, nil
}
var rebuilt []string
for _, p := range src {
attr := p.Get("priceListAttrMap").Raw
if attr == "" {
attr = "{}"
}
stn := p.Get("startTimeNextDay").Raw
if stn == "" {
stn = "0"
}
etn := p.Get("endTimeNextDay").Raw
if etn == "" {
etn = "0"
}
one, err := marshalJSON(map[string]any{
"startTime": p.Get("startTime").String(),
"endTime": p.Get("endTime").String(),
"price": price,
})
if err != nil {
return "", err
}
one, err = sjson.SetRaw(one, "startTimeNextDay", stn)
if err != nil {
return "", err
}
one, err = sjson.SetRaw(one, "endTimeNextDay", etn)
if err != nil {
return "", err
}
one, err = sjson.SetRaw(one, "priceListAttrMap", attr)
if err != nil {
return "", err
}
rebuilt = append(rebuilt, one)
}
return sjson.SetRaw(block, "periodPrice", "["+strings.Join(rebuilt, ",")+"]")
}
周几认的是 type=1 且开始日等于结束日 不是 1 到 7 的段直接跳过 特殊日期因此不会被周价改到
func weekDayIndex(block gjson.Result) (int, bool) {
if block.Get("type").Int() != 1 {
return 0, false
}
start := block.Get("startDay").String()
end := block.Get("endDay").String()
if start == "" || start != end {
return 0, false
}
n, err := strconv.Atoi(start)
if err != nil || n < 1 || n > 7 {
return 0, false
}
return n - 1, true
}
一场的价先看修仙档 次日开场直接封顶 没开修仙或档数是 0 就用底价 开了就底价加档数乘 10 还没进夜场再看是不是当天 10 点场
func slotPrice(pp gjson.Result, base float64, rule WeekRule, periods gjson.Result) string {
step := rule.step()
n, capped := nightStep(pp, periods, rule.nightFromMin())
if capped {
if !rule.NightFee || n == 0 {
return formatMoney(base)
}
return formatMoney(base + step*float64(n))
}
if rule.NightFee && n > 0 {
return formatMoney(base + step*float64(n))
}
startMin, err := clockMinutes(pp.Get("startTime").String())
if err == nil && rule.EarlyFee && pp.Get("startTimeNextDay").Int() == 0 && startMin == 10*60 {
return formatMoney(base + step)
}
return formatMoney(base)
}
func nightStep(pp gjson.Result, periods gjson.Result, anchor int) (int, bool) {
if pp.Get("startTimeNextDay").Int() == 1 {
return countNightSlots(periods, anchor), true
}
n := 0
started := false
found := 0
periods.ForEach(func(_, cur gjson.Result) bool {
if cur.Get("startTimeNextDay").Int() == 1 {
return true
}
if coversPoint(cur, anchor) || started {
started = true
n++
}
if samePeriod(cur, pp) {
found = n
return false
}
return true
})
return found, false
}
func coversPoint(pp gjson.Result, point int) bool {
start, err1 := clockMinutes(pp.Get("startTime").String())
end, err2 := clockMinutes(pp.Get("endTime").String())
if err1 != nil || err2 != nil {
return false
}
s := start + 1440*int(pp.Get("startTimeNextDay").Int())
e := end + 1440*int(pp.Get("endTimeNextDay").Int())
if e <= s {
e += 1440
}
return s <= point && point < e
}
items 里每个场次用 week 对周几 用 period 对开场 嵌在 itemAttrs 里的 priceList 和外面的 price 一起改 只改外面日历 场次价还是旧的
func syncItems(productJSON string, rule WeekRule) (string, error) {
out := productJSON
var err error
gjson.Get(productJSON, "items").ForEach(func(i, item gjson.Result) bool {
week := attrValue(item, "week")
day, convErr := strconv.Atoi(week)
if convErr != nil || day < 1 || day > 7 {
return true
}
base, ok := rule.base(day - 1)
if !ok {
return true
}
periods := dayPeriods(productJSON, day)
rawList := attrValue(item, "priceList")
if rawList != "" && gjson.Valid(rawList) {
next, rewriteErr := setEmbeddedPrices(rawList, base, rule, periods)
if rewriteErr != nil {
err = rewriteErr
return false
}
key := attrKey(item, "priceList")
if key != "" {
out, err = sjson.Set(out, fmt.Sprintf("items.%d.itemAttrs.%s.values.0", i.Int(), key), next)
if err != nil {
return false
}
}
}
start := periodStart(attrValue(item, "period"))
if start == "" {
return true
}
price := slotPrice(findPeriod(periods, start), base, rule, periods)
num, parseErr := strconv.ParseFloat(price, 64)
if parseErr != nil {
err = parseErr
return false
}
out, err = sjson.Set(out, fmt.Sprintf("items.%d.price", i.Int()), num)
return err == nil
})
return out, err
}
priceListRedundance 挂在 productEntity.productAttrs 里 值还是一段 priceList JSON 字符串 用同一套周价规则重写再写回 不重写的话商品详情和列表日历会各说各的
func syncRedundance(productJSON string, rule WeekRule) (string, error) {
out := productJSON
var err error
gjson.Get(productJSON, "productEntity.productAttrs").ForEach(func(k, a gjson.Result) bool {
if a.Get("name").String() != "priceListRedundance" {
return true
}
raw := a.Get("values.0").String()
if raw == "" || !gjson.Valid(raw) {
return true
}
next, _, rewriteErr := rewritePriceListArray(raw, rule)
if rewriteErr != nil {
err = rewriteErr
return false
}
out, err = sjson.Set(out, "productEntity.productAttrs."+k.String()+".values.0", next)
return err == nil
})
return out, err
}
一组写入是先并行概念上的连续 load 实际是同一次页面调用里按组 fetch load 之间不睡 改 JSON 在 Go 里做完 再把 edit 和 publish 作为这一条商品的步骤丢回去 组与组之间才用 150 毫秒
loads := make([][]Step, len(chunk))
for i, prod := range chunk {
loads[i] = []Step{{Method: "GET", URL: LoadURL(prod.ID)}}
}
loaded, err := call.CallGroups(ctx, loads, 0)
patched, productID, spu, patchErr := patchLoaded(p, prod, data.Raw)
steps := []Step{{
Method: "POST",
URL: EditURL(),
Form: map[string]string{
"productid": productID,
"productstr": patched,
"bizname": bizName,
"sputype": spu,
},
}}
if !p.SkipPublish {
steps = append(steps, Step{
Method: "POST",
URL: PublishURL(),
Form: map[string]string{
"productids": prod.ID,
"bizname": bizName,
"sputype": spu,
},
})
}
written, err := call.CallGroups(ctx, groups, delay)
页面侧一组请求的间隔也在注入脚本里 第 0 组不等 后面的组先 setTimeout 再逐条 fetch
for (let i = 0; i < groups.length; i++) {
if (i && delay) await new Promise((r) => setTimeout(r, delay));
const steps = [];
for (const req of groups[i]) {
steps.push(await window.__mtCall(req));
}
out.push(steps);
}
登录读回来的 Cookie 同名不简单以后者为准 域名带 dianping.com 的优先 其次 meituan.com 没有 edper 就当这次没登完
func domainScore(domain string) int {
d := strings.ToLower(domain)
if strings.Contains(d, "dianping.com") {
return 3
}
if strings.Contains(d, "meituan.com") {
return 2
}
return 1
}
过期看的是完成获取时写下的 Unix 秒 空 Cookie 是未登录 有 Cookie 但没有时间 或者距离现在超过 3 小时 都是过期 普通保存把磁盘上的这个时间原样抄回去 不会因为改了门店或间隔把时钟拨新
func LoginStatus(p Params, now time.Time) string {
if strings.TrimSpace(p.Cookie) == "" {
return "未登录"
}
if p.CookieSavedAt <= 0 || now.Sub(time.Unix(p.CookieSavedAt, 0)) > loginTTL {
return "登录过期,需重新登录"
}
return "已登录"
}
func keepLoginClock(next, prev Params) Params {
next.CookieSavedAt = prev.CookieSavedAt
if strings.TrimSpace(next.Cookie) == "" {
next.Cookie = prev.Cookie
}
return next
}
文章作者:Linso
文章链接:https://linso.pro//archives/mtjy
版权声明:本博客所有文章除特别声明外,均采用CC BY-NC-SA 4.0 许可协议,转载请注明出处!
评论