三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Agent应用指南:获取美宜佳全量门店,4万家看透便利店下沉与集聚

Agent应用指南:获取美宜佳全量门店,4万家看透便利店下沉与集聚

从美宜佳全国 4 万家门店数据,我看见了便利店行业的"大湾区密码"

一、引言:一家"楼下小店",藏着 4 万门店的版图

提到便利店,大多数人脑子里先蹦出来的,是 7-11、罗森、全家这些日系招牌。但如果你在华南生活过,一定绕不开另一个名字——美宜佳。红底白字、土里土气,却在你家楼下、公司门口、地铁口反复出现,像空气一样自然。

很少有人意识到它的体量:这家 1997 年起家于东莞的本土品牌,门店数已经悄悄冲到4 万家的量级,把一众日系便利店远远甩在身后,是国内门店数最多的便利店系统。更反直觉的是它的"分布姿势"——不是北上广深全国撒网,而是把大半个华南焊死在了自己的版图里,广东一省就占了全国近一半。

前阵子我偶然发现,美宜佳官网有个门店地图,能按地图看每一家店的位置,这篇就用这 4 万条数据,拆开看看:一家区域便利店,是怎么把"大湾区"做成铁桶的。

先抛结论:美宜佳不是"全国均匀铺开"的连锁,而是一家把大湾区做成了护城河、再沿交通廊道向周边省份溢出的"密度型选手"。下面逐个维度拆给你看。

二、数据从哪来:绕开"单位范围查询",抓到全量门店

先说清楚数据是怎么来的——这关系到后面所有结论的可信度。

美宜佳分店地图: (https://ditu.myj.com.cn/)

美宜佳官网那个门店地图,本质是个"按视口懒加载"的页面:你拖到哪,它就只向后台请求当前屏幕框内的门店。普通人永远只能看到本地一小撮。但看代码发现,后台接口接收的是任意经纬度矩形,压根不校验"你是不是真在屏幕上看这块"——也就是说,只要我把全国地图切成一个个框,它就会老老实实把框里的店全吐出来。

真正的坑在第二步:直接发"全国大框",后台会因为一次性要塞进约四万家门店而直接 500 报错。这不是"禁止大范围查询",纯粹是单条响应体量太大顶不住。解决办法很朴素——把全国拆成一堆小框分别查,再拼起来。具体三步:

  1. 解耦坐标:接口参数就是四个经纬度(左下角、右上角),没有 token、没有签名、没有"必须和视口一致"的校验,直接发任意矩形即可;

  2. 拆框绕过 500:改用 2.5°×2.5° 的小格逐格查,最密的珠三角格都能一次返回上万条,说明瓶颈只是"单响应体量",拆小就稳了;

  3. 网格化 + 自适应细分:全国切成网格逐格请求,某格若仍返回 500(太密)就四等分重试,递归到最小 0.2° 为止,珠三角这类超密区靠这层兜底。每格返回的门店按店号去重合并。

附:完整抓取脚本(可直接运行)

利用requests库发送HTTP请求对应数据标签并获取所有门店数据,并根据标签进行保存,另存为csv。

importjsonimportosimportcsvimporttimeimportmathimportrequestsfromconcurrent.futuresimportThreadPoolExecutor,as_completedfromthreadingimportLock# ============== 配置区 ==============URL="https://ditu.myj.com.cn/WebService.asmx/ExecuteOfficeControlFunction"PAGE="Seed.OfficeControllers.Web.IndexPage"LREF="https://ditu.myj.com.cn/"# 中国大致包围盒LNG_MIN,LNG_MAX=73.5,135.2LAT_MIN,LAT_MAX=3.8,53.6INIT_CELL=2.5# 初始网格边长(度) -> 约 25 x 20 = 500 块MIN_CELL=0.2# 最小细分边长(度)WORKERS=8# 并发线程数REQ_TIMEOUT=60# 单请求超时(秒)SLEEP=0.02# 单请求后极小停顿(并发已控速)HEADERS={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36","Content-Type":"application/json; charset=utf-8","Referer":"https://ditu.myj.com.cn/","X-Requested-With":"XMLHttpRequest","Accept":"application/json, text/javascript, */*; q=0.01",}# 输出:与脚本同级的 myj_stores.csv(保存在运行脚本的目录下)BASE_DIR=os.path.dirname(os.path.abspath(__file__))OUT_CSV=os.path.join(BASE_DIR,"myj_stores.csv")# 字段映射:源字段 -> 输出英文列名(CSV 表头用英文,避免 Excel 编码问题)FIELDS=[("StoreCode","store_code"),("StoreName","store_name"),("ShortName","short_name"),("Telephone","telephone"),("Province","province"),("City","city"),("Town","town"),("Street","street"),("DetailAddr","detail_addr"),("BaiDuJinDu","baidu_lng"),("BaiDuWeiDu","baidu_lat"),("TenXunJinDu","tencent_lng"),("TenXunWeiDu","tencent_lat"),("Hour","business_hours"),("CompanyCode","company_code"),("Company_Name","company_name"),("Status","status"),("OpenExpress","open_express"),("Category","category"),("Introduct","introduction"),("Credential","credential"),("CreateTime","create_time"),("UpdateTime","update_time"),]COLS=[c[1]forcinFIELDS]# ============== 运行时状态 ==============lock=Lock()stores={}# StoreCode -> store dictstats={"ok":0,"empty":0,"err":0,"subdiv":0}seen_cells=set()sess=requests.Session()defclean(v):ifvisNone:return""ifisinstance(v,str)andv.strip()in("0001/01/01 00:00:00",):return""returnvdeffetch_cell(lng1,lat1,lng2,lat2):"""返回 (status, list_of_stores)。status: 'ok'/'empty'/'err'"""body={"paramJson":json.dumps([PAGE,"GetNearStoreList",LREF,lng1,lat1,lng2,lat2,""])}try:r=sess.post(URL,headers=HEADERS,data=json.dumps(body),timeout=REQ_TIMEOUT)exceptException:return"err",[]ifr.status_code!=200:return"err",[]try:obj=json.loads(r.json()["d"])exceptException:return"err",[]res=obj.get("Result")ifnotisinstance(res,list):return"err",[]return("empty"iflen(res)==0else"ok"),resdefprocess_cell(lng1,lat1,lng2,lat2,depth=0):key=(round(lng1,4),round(lat1,4),round(lng2,4),round(lat2,4))withlock:ifkeyinseen_cells:returnseen_cells.add(key)status,data=fetch_cell(lng1,lat1,lng2,lat2)time.sleep(SLEEP)ifstatus=="err":cell_w=lng2-lng1ifcell_w/2>=MIN_CELL:withlock:stats["subdiv"]+=1half=cell_w/2halfh=(lat2-lat1)/2return[(lng1,lat1,lng1+half,lat1+halfh),(lng1+half,lat1,lng2,lat1+halfh),(lng1,lat1+halfh,lng1+half,lat2),(lng1+half,lat1+halfh,lng2,lat2),]else:withlock:stats["err"]+=1return[]# ok or emptywithlock:ifstatus=="empty":stats["empty"]+=1else:stats["ok"]+=1forsindata:code=s.get("StoreCode")ifcodeandcodenotinstores:stores[code]=sreturn[]defmain():# 初始网格lng_steps=math.ceil((LNG_MAX-LNG_MIN)/INIT_CELL)lat_steps=math.ceil((LAT_MAX-LAT_MIN)/INIT_CELL)queue=[]foriinrange(lng_steps):forjinrange(lat_steps):lng1=LNG_MIN+i*INIT_CELL lng2=min(lng1+INIT_CELL,LNG_MAX)lat1=LAT_MIN+j*INIT_CELL lat2=min(lat1+INIT_CELL,LAT_MAX)queue.append((lng1,lat1,lng2,lat2))print(f"初始网格:{lng_steps}x{lat_steps}={len(queue)}块, workers={WORKERS}")whilequeue:batch=queue[:2000]queue=queue[2000:]withThreadPoolExecutor(max_workers=WORKERS)asex:futs=[ex.submit(process_cell,*c)forcinbatch]forfinas_completed(futs):sub=f.result()ifsub:queue.extend(sub)print(f"进度: 已处理块={len(seen_cells)}当前队列={len(queue)}"f"门店={len(stores)}ok={stats['ok']}empty={stats['empty']}"f"err={stats['err']}subdiv={stats['subdiv']}",flush=True,)print(f"\n抓取完成。去重后门店总数:{len(stores)}")print("stats:",stats)# ===== 清洗 + 写出 CSV =====rows=[]forsinstores.values():row={c[1]:clean(s.get(c[0]))forcinFIELDS}# 拼接完整地址addr="".join(str(row[k])forkin("province","city","town","street","detail_addr")ifrow[k])row["full_address"]=addr rows.append(row)# 字段顺序:full_address 放在 detail_addr 之后cols_out=list(COLS)cols_out.insert(cols_out.index("detail_addr")+1,"full_address")withopen(OUT_CSV,"w",encoding="utf-8-sig",newline="")asf:w=csv.DictWriter(f,fieldnames=cols_out,extrasaction="ignore")w.writeheader()forrinrows:w.writerow(r)print(f"已写出 CSV:{OUT_CSV}(共{len(rows)}家门店)")if__name__=="__main__":main()

数据会以csv表格的形式,保存在运行脚本的目录下,表格名:myj_stores.csv;(注:文中数据截至 2026 年 8 月)

数据标签包括:StoreCode(门店id)、StoreName(门店名称)、full_address(门店地址)、Telephone(电话)、business_hours(营业时间)、lon&lat(坐标),其他一些非关键标签,这里省略;

三、数据全景:4 万家店,字段比你想象的全

这一版抓到的全量门店共40,110 家,覆盖22 个省份/直辖市、248 个城市。最让我意外的是字段的完整度:

  • 地址完整率 100%:省、市、区镇、街道、详细门牌一应俱全,连"瑞江路15号附6号"这种级别都给了;
  • 电话覆盖率 99.0%:几乎每家店都带联系电话;
  • 同时带有百度(BD-09)与腾讯(GCJ-02)双坐标系经纬度、营业时段、所属公司代码等。

换句话说,这是一份可以直接拿来做空间分析、选址建模、热力可视化的"干净数据",不是那种只有个名字的残缺表。

四、省份版图:广东一家,独占近半

把门店按省份排个序,第一眼就会被广东省的数值震住:

  • 广东19,136 家,占全国47.7%——也就是说,你每看到两家美宜佳,就有一家在广东;
  • 紧随其后的湖南(2,980)、广西(2,636)、湖北(1,943)、江西(1,706),全是广东的邻居。

这是典型的**“大本营辐射”**:以大湾区为圆心,沿京广线向北(湖南→湖北)、沿西江向西(广西)、沿沪昆向东(江西)层层溢出。美宜佳没有走"北上广深全国撒网"的路线,而是先把家门口的华南啃透,再顺着交通廊道往外渗。这种打法,决定了它的门店密度图会极度"南重北轻"。

五、城市格局:东莞才是隐藏冠军

省份往下看城市,会出现一个反直觉的结论:门店最多的城市不是广州深圳,而是东莞

  • 东莞3,992 家> 深圳 3,028 > 广州 2,556 > 重庆 1,654 > 惠州 1,185;
  • 东莞能压过两座一线城市,原因在于它是制造业名城,镇街经济发达、外来人口密集、夜间消费旺盛,而且——美宜佳的总部就在东莞。主场优势拉满。

上面那张气泡图把 248 个城市的门店量成了气泡,一眼就能看懂美宜佳的"势力范围":华南一片玫红密密麻麻,往华中、华东还能看到成片的紫与蓝,再往北就只剩下零星几个点了。北上广之外的北方省份,在这张图上几乎是空白。

六、区域密度:一个格子,顶一个省

为了更直观地感受"密度差",我把全国按4°×4°切成了网格——这个尺度大概相当于一个浙江省大小。然后给每个网格按门店数上色:

  • 珠三角那个网格,15,698 家,直接爆表(深玫红);
  • 而同样一个"省大小"的格子,放到西北、华北,可能只有零星几家甚至空白。

这意味着:美宜佳在珠三角塞进的门店数,比很多省份全省还多。密度,就是它最深的护城河——密集的门店网络摊薄了供应链成本,也培养了用户的"随手就买"习惯。

七、七大区占比:这是一个关于"南方"的故事

按地理大区归个类,结论更清楚:

  • 华南 22,396 家(55.8%)一家过半;
  • 加上华中(6,355)、华东(5,500)、西南(3,511),这四大区吃掉全国91%
  • 而华北 + 西北 + 东北三个大区,合计不到4%

所以美宜佳的扩张史,本质上是一个"南方故事":它用二十多年时间,把自己焊死在了北纬 30° 以南。

八、开店时间定律:近六成 24 小时营业

便利店对抗电商的核心壁垒,是"即时性"——你想买的时候它就在。数据也印证了这点:

  • 58%(23,096 家)的门店营业时段以00:00起记,多为24 小时营业00:00-24:00);
  • 剩下约 34% 的非全天门店,开门高峰集中在清晨 6—8 点(6 点 3,243 家、7 点 8,094 家、8 点 2,407 家)。

一边是"全天候",一边是"清晨就开门"——前者兜住了夜班族和突发需求,后者接住了通勤早高峰。便利店这门生意,本质上就是在和用户的"时间缝隙"抢生意。

九、集中度:头部红海,长尾蓝海

最后看一个更"战略"的维度——集中度。把 248 个城市按门店数从多到少排,算累计占比:

  • 前 17 个城市就占了全国一半门店;
  • 前 68 个城市占八成,前 108 个占九成;
  • Top10 城市合计 16,506 家,占比 41.2%。

这曲线说明两件事:一是大湾区+长沙武汉重庆成都这批头部城市,已经卷成了红海;二是剩下的140 多个城市只分了全国 10% 的门店——对美宜佳是待开发的腹地,对其他品牌则是弯道超车的缝隙。


文章仅用于分享个人学习成果与个人存档之用,分享知识,如有侵权,请联系作者进行删除。所有信息均基于作者的个人理解和经验,不代表任何官方立场或权威解读。

← 返回列表