太二酸菜鱼全国门店数据实战:从静态表格到交互地图

引言

起因是看到了太二官网的门店列表页。点进去一看,是这样的:

  • 纯静态 HTML 表格:黑底表头 + 灰白行 + 三列文字(店名/地址/地区)
  • 没有任何坐标信息,看不到哪密集、哪空白
  • 想知道"广州和深圳谁的店多"、“哪个省还有空白市场”——得自己拿纸笔对着 498 行表格一条条数
  • 全国分布、扩张趋势、选址逻辑,全都看不出来

在这里插入图片描述

店名、地址、地区"三个字段,静静地躺了 498 行。要从里面看出点东西,得花一下午。

于是想——把它做成一个能交互的动态地图:可以缩放、可以看分布密度、可以按城市点进去、可以看到选址偏好。这才有了这一整套数据淘金。

听起来不复杂 —— 打开高德搜一下不就完了?但真做起来,官网 HTML 解析、坐标补全、地理编码、地图可视化,每一个环节都能让你卡半天。这篇文章把整个过程拆开讲清楚,从数据获取到分析洞察,每一步都有代码和结论。


一、数据获取

目标是拿到太二在全国所有门店的名称、地址、经纬度

打开官方门店查询网址,按 F12 打开开发者工具 → 切换到 Network(网络) 面板 → 刷新页面 → 筛选 Fetch/XHR 请求。

首先,我们找到门店数据的存储位置,然后看3个关键部分标头、负载、 预览;
标头:通常包括URL的连接,也就是目标资源的位置;

在这里插入图片描述
负载:对于POST请求:负载通常包含了传递的参数,因为所有参数都通过URL传递,但这里因为是静态的html,所以这里为空;

预览:指的是对响应内容的快速查看或摘要显示,可以帮助用户快速了解返回的数据结构或内容片段,我们可以看到数据在整个静态html里;
在这里插入图片描述

1.1 官网抓取 —— 拿到了 498 家,但没有坐标

太二的官网 taier.net/Store/index.html 是个纯静态 HTML 页面,里面密密麻麻列了全部门店 —— 每家店一行店名、一行地址、一行城市名,格式极其规律。

用 Python 的 urllib 拉下来、正则解析,不到 50 行代码就把 498 条记录全部抓了下来。数据结构也干净:门店名称、所在城市、详细地址,三列一条。

但问题来了:没有一家店有经纬度。 全量门店列表有了,但没法落在地图上。光有文字地址,后面还得想办法补坐标。

做法:用高德开放平台的地理编码 API:把「广州市天河区花城大道 89 号花城汇南区 D19 铺」这种文字地址丢过去,它返回对应的经纬度。

这里要说明一下数据来源的权重:门店名称、所在城市、详细地址——这些都来自太二官网的静态 HTML 页面,一个字没改。坐标是高德帮忙补的。打个比方,HTML 是骨架,坐标是血肉——没有骨架你根本不知道去哪找这些店,没有血肉你没法把点落到地图上。

在这里插入图片描述

1.2 数据获取脚本

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

完整代码#运行环境 Python 3.11

# -*- coding: utf-8 -*-
import csv
import os
import re
import sys
import urllib.request
from html.parser import HTMLParser

# ====== 配置 ======
URLS = [
    "https://www.taier.net/Store/index.html",
]
OUTPUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "taier_stores.csv")
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}


def fetch(url):
    req = urllib.request.Request(url, headers=HEADERS)
    with urllib.request.urlopen(req, timeout=20) as resp:
        return resp.read().decode("utf-8", errors="ignore")


class StoreExtractor(HTMLParser):
    """HTML 解析器:从 taier.net 页面提取门店。"""
    def __init__(self):
        super().__init__()
        self.in_main = False
        self.tag_stack = []
        self.text_buf = []
        self.lines = []
        self._reset_line()

    def _reset_line(self):
        if self.text_buf:
            line = "".join(self.text_buf).strip()
            if line:
                self.lines.append(line)
            self.text_buf = []

    def handle_starttag(self, tag, attrs):
        self.tag_stack.append(tag)
        if tag in ("br", "li", "p", "div", "h1", "h2", "h3", "h4"):
            self._reset_line()

    def handle_endtag(self, tag):
        if self.tag_stack and self.tag_stack[-1] == tag:
            self.tag_stack.pop()
        if tag in ("br", "li", "p", "div", "h1", "h2", "h3", "h4"):
            self._reset_line()

    def handle_data(self, data):
        text = data.strip()
        if text:
            self.text_buf.append(text)

    def close(self):
        self._reset_line()
        super().close()


def extract_by_regex(html):
    """方案1:正则匹配 '太二XX店 + 地址 + 城市' 三行模式。"""
    # 紧缩行内空白后拆行
    lines = [re.sub(r"\s+", " ", l.strip()) for l in html.splitlines() if l.strip()]
    # 去掉纯HTML标签行
    lines = [l for l in lines if not l.startswith("<")]

    stores = []
    i = 0
    while i < len(lines) - 2:
        name = lines[i]
        addr = lines[i + 1]
        city = lines[i + 2]
        # 判断:名称含 "太二" 且以 "店" 结尾;城市名只有2-4中文;地址含省市特征
        if ("太二" in name and name.strip().endswith("店") and
                not name.startswith("<") and
                len(city) <= 6 and re.match(r"^[\u4e00-\u9fa5]{2,4}$", city)):
            if "\u5e02" in addr or "\u7701" in addr or "\u533a" in addr or "\u8857" in addr or "\u8def" in addr:
                stores.append((name.strip(), city.strip(), addr.strip()))
                i += 3
                continue
        i += 1

    return stores


def extract_by_html_parser(html):
    """方案2:HTML解析器提取文本行,再正则匹配。"""
    parser = StoreExtractor()
    parser.feed(html)
    lines = parser.lines
    return extract_by_regex("\n".join(lines))


def extract_jiu_mao_jiu(html):
    """方案3:从九毛九集团页面提取(含筛选结构)。"""
    # 匹配门店块:门店名 + 地址
    stores = []
    # 模式:太二XXX店 + 任意文本 + 地址
    pattern = re.compile(
        r"太二[\u4e00-\u9fa5A-Za-z0-9\-·]+?店.*?"
        r"([\u4e00-\u9fa5]{2,}(?:市|省)[\u4e00-\u9fa5A-Za-z0-9\-·\s]{8,60}(?:号|楼|层|铺|室))",
        re.DOTALL,
    )
    for m in pattern.finditer(html):
        full = m.group(0).replace("\n", " ").replace("\r", " ")
        # 提取城市
        city_m = re.search(r"([\u4e00-\u9fa5]{2,4}市)", m.group(1))
        city = city_m.group(1) if city_m else ""
        addr = m.group(1).strip()
        # 提取店名
        name_m = re.search(r"(太二[\u4e00-\u9fa5A-Za-z0-9\-·]+?店)", full)
        name = name_m.group(1) if name_m else ""
        if name and addr:
            stores.append((name, city, addr))

    return stores


def run():
    print("=" * 50)
    print("太二酸菜鱼门店数据抓取")
    print("=" * 50)

    all_stores = []
    for url in URLS:
        print(f"\n抓取: {url}")
        try:
            html = fetch(url)
            print(f"  页面长度: {len(html)} chars")
        except Exception as e:
            print(f"  请求失败: {e}")
            continue

        # 先试正则直接匹配
        stores = extract_by_regex(html)
        print(f"  正则匹配: {len(stores)} 条")

        # 如果正则颗粒太粗,试试 HTML 解析
        if len(stores) < 10:
            stores = extract_by_html_parser(html)
            print(f"  HTML解析: {len(stores)} 条")

        # 九毛九集团页面用特殊解析
        if "jiumaojiu.com" in url and len(stores) < 10:
            stores = extract_jiu_mao_jiu(html)
            print(f"  九毛九解析: {len(stores)} 条")

        if stores:
            all_stores.extend(stores)
            break  # 主源拿到数据就停
        else:
            print("  未提取到数据,切换备选源...")

    if not all_stores:
        print("\n[!] 所有源均未提取到门店数据")
        sys.exit(1)

    # 去重
    seen = set()
    unique = []
    for s in all_stores:
        key = (s[0], s[2])
        if key not in seen:
            seen.add(key)
            unique.append(s)

    print(f"\n去重后: {len(unique)} 家门店")

    # 写 CSV
    with open(OUTPUT, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["门店名称", "城市", "地址"])
        for name, city, addr in unique:
            writer.writerow([name, city, addr])

    print(f"CSV 已保存: {OUTPUT}")

    # 城市分布统计
    city_count = {}
    for _, city, _ in unique:
        city_count[city] = city_count.get(city, 0) + 1
    print("\n城市分布 TOP10:")
    for c, n in sorted(city_count.items(), key=lambda x: -x[1])[:10]:
        print(f"  {c}: {n} 家")

    print(f"\n数据来源: {URLS[0]}")
    print(f"来源说明: 太二酸菜鱼官方网站门店列表页(公开静态页面)")

    return unique


if __name__ == "__main__":
    run()

1.3 坐标补全:拿高德 API 跑一趟

这里用了一个大佬做的转换页面,本质上也是调用高德开放平台的地理编码 API,只是需要输入自己在高德平台申请的key。
地址:https://piliang.cc0.top/geocoding

在这里插入图片描述


二、数据处理:没什么玄学,几条命令搞定

数据到手之后其实没什么花活,就三个动作:

  • 官网 CSV 里那列坐标是 "113.45,23.14" 这种字符串,拆成经度和纬度两个数
  • 确认一下坐标系:高德给的是 GCJ-02,刚好我们用的高德瓦片也是 GCJ-02,不需要做转换
  • 有些城市的名字带着「市」后缀,统一去掉,方便后面统计

Python 脚本一行命令跑通全流程:

python fetch_taier.py          # 从官网 HTML 抓 498 家门店 → CSV
python map_generator.py data.csv  # CSV → Leaflet 交互地图 HTML

交互地图支持三种模式:聚合模式(缩放时自动把近距离标记合并为数字圆圈)、标记模式(全量点位,hover 店名,点击看详细地址)、热力模式(密度分布热力图)。


三、看看数据:太二到底是个什么路数

有了坐标之后就可以瞎看看了。498 家店摊在全国 123 个城市,有没有什么规律。

3.1 城市分布:典型的「广撒网」

在这里插入图片描述

498家门店覆盖了 123 座城市,但分布极不均匀。广州 50 家、深圳 50 家、上海 24 家占据了绝对的头部。城均门店只有 3.9 家 —— 绝大多数城市只有 1-3 家店,属于「先占坑再说」的扩张逻辑。

这种分布模式的好处是快速铺开品牌声量、抢占优质商场点位。代价是单城密度不够,供应链和运营管理成本高。

从城市层级看,门店高度集中在一线与新一线城市。北上广深 + 成都、杭州、武汉等 15 个新一线城市占据了绝大多数门店。二三线及下沉市场的渗透才刚刚开始,这也是太二未来扩张的主要方向。

3.2 选址逻辑:商场几乎是唯一的答案

在这里插入图片描述

把 498 条地址做一个简单的关键词拆解,结果非常直观:

  • 「广场」156 家(33%)—— 万达广场、吾悦广场、万象广场等
  • 「城」139 家(29%)—— 大悦城、万象城、银泰城、印象城等
  • 「中心」39 家(8%)、「汇」37 家(8%)—— 购物中心、万象汇、卓悦汇等

这几个词加起来覆盖了 99% 以上的门店。换句话说,太二几乎全部开在大型购物中心或商业综合体里,几乎没有街边店。

这是一个强约束:太二的扩张速度,很大程度上取决于商业地产的供给速度。哪个城市的购物中心多、招商能力强,太二就去哪。这也是为什么一线和新一线城市密度远高于其他城市 —— 优质商场集中在这些地方。

3.3 几组数字

在这里插入图片描述

把核心指标拉一张看板:

指标 数值 说明
门店总数 498 官网列 498 家,坐标匹配到 498
覆盖城市 123 含澳门、新加坡等
一线城市占比 ~32% 北上广深四城
新一线占比 ~40% 成都/杭州/武汉等 15 城
城均门店 3.9 家 一半以上城市只有 1-3 家
TOP1 城市 广州 · 50 家 和深圳并列

广深是太二的大本营(九毛九集团总部在广州),两地各 50 家门店打底。上海反而只有 24 家 —— 上海的商场竞争激烈、租金成本高,太二在上海的策略相对克制。


四、技术工具链

在这里插入图片描述

整个项目的工具链非常轻量,都在一个目录下:

feidachu_stores/
├── fetch_taier.py        # 官网 HTML 解析 → CSV
├── taier_stores.csv      # 498 家(官网原始数据)
├── map_generator.py      # 通用 CSV → Leaflet 交互地图
├── taier_premium.html    # 交互地图
└── 太二门店列表.xlsx      # Excel 版本

都是 Python 标准库(urllib + csv + json)加上 openpyxl,没有任何重依赖。高德 Key 是免费注册的,粘到脚本里就能跑。


五、可交互的HTML

  1. 官网那页看起来简陋,其实是最完整的数据源。 太二的 taier.net/Store/index.html 没有任何反爬、没有分页、没有 JS 渲染——就是一个裸的 HTML 表格。498 家店整整齐齐列在里面。虽然缺坐标,但店名 + 城市 + 地址这三列,足以支撑后续的所有分析和可视化。

  2. 地址里藏着不少信息。 拆几个关键词就能看出商业逻辑。太二的地址里「广场」+「城」占了六成,就是明牌告诉你是跟着购物中心走的品牌。对商业地产来说,反过来看也成立——知道太二的选址偏好,也就知道哪些商场有可能被它看上。

  3. 数据上图可交互光看表格看不出分布,落到 Leaflet 地图上就完全不一样了。缩放、聚合、热力三种模式随便切,点一个城市看那个城市所有门店,点一个标记弹出地址详情。数据还是那 498 条数据,但交互之后,信息密度和直觉感受完全不是一个级别。

在这里插入图片描述


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

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐