餐饮市场分析(上)

以茶饮为例 研究某一类餐饮产品的市场概况

Posted by Paradise on August 12, 2022

一、数据需求

使用美团搜索商品返回的数据。

首先进入美团首页,切换到对应城市,并搜索感兴趣的关键词。接下来尝试翻页获取更多数据,点击下一页时发现页面地址没变,并且浏览器发送了一批请求。选定对应的范围,容易找到下图中的数据就是加载出来的商家信息,以 .json 格式返回。

该数据的请求地址为: https://apimobile.meituan.com/group/v4/poi/pcsearch/30 ,参数有:

1
2
3
4
5
6
uuid=8639d6b8d8bf457691b9.1594807338.1.0.0
userid=-1
limit=32
offset=32
cateId=-1
q=%E5%A5%B6%E8%8C%B6

尝试在浏览器请求 API,发现美团会进行 IP 验证,而且 URL 参数 uuid 对请求不产生影响。如果一个 IP 首次访问该 API,会跳转到人机验证。对此,可以使用selenium 完成首次的验证,然后在一段时间内便可以进行 GET 请求。参考以下文章:

https://www.cnblogs.com/test_home_c/p/9619542.html

又或者可以获取 Cookies,并传入详细的 headers 参数,经过尝试,在 Cookie 中传入 uuid 参数即可跳过验证。

二、数据获取

具体的脚本可以分成几个步骤。首先是根据给定的城市名称和搜索关键词,获取对应城市的入口链接和城市 ID,后者用于 API 的拼接。得到 API 链接后,再获取 Cookie 字段,组成 headers,然后便可以发起请求获得数据。

需要注意的问题:

  • 城市 ID 可以在网页源码中的 Script 脚本中得到,但是直接 GET 请求时不加载脚本,需要给定 headers 参数;
  • 获取 Cookies 时并不返回完整的 Cookies 字段,但是经过测试,仅有的字段可以起作用;
  • 当出现 Exception 时,可能是偏移量超出范围或者会话 Expired,需要跳出循环更新 Cookies 重新获取数据;
  • 返回的 json 数据层次较复杂,分成两个关系表储存。

主要代码速览:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class MtSpider:

    def __init__(self, cityname, keyword):

        self.name = cityname
        self.keyword = urllib.parse.quote(keyword)
        self.linux_headers = ...
        self.windows_headers = ...
        self.citylink = self.get_city_link()        # Searching URL
        self.host = self.citylink.split('/')[2]     # City Hostname
        self.cityid = self.get_city_id()
        self.cookies = self.get_cookies()

    def get_city_link(self):

        cities = 'https://www.meituan.com/changecity/'
        res = rq.get(cities, headers=self.windows_headers)
        soup = BeautifulSoup(res.text, features='lxml')
        cities = soup.find_all('a', {'class': 'link city'})
        for c in cities:
            if self.name in c.text or c.text in self.name:
                link = 'https:' + c.attrs['href'] + '/s/' + self.keyword

        return link

    def get_city_id(self):

        headers = dict(self.windows_headers, Host=self.host)
        res = rq.get(self.citylink, headers=headers)
        id = re.findall(r'{"id":(\d+),"name"', res.text)[0]

        return id

    def get_cookies(self):

        jar = http.cookiejar.CookieJar()
        processor = urllib.request.HTTPCookieProcessor(jar)
        _ = urllib.request.build_opener(processor).open(self.citylink)

        cookies = []
        for i in jar:
            cookies.append(i.name + '=' + i.value)
        
        return '; '.join(cookies)

    def get_json(self, page):
        '''Get data of one page'''

        url = 'https://apimobile.meituan.com/group/v4/poi/pcsearch/{}'
        url += '?userid=-1&limit=32&offset={}&cateId=-1&q={}'
        url = url.format(self.cityid, page*32, self.keyword)    # self.cityid
        headers = {
            'Cookie': self.cookies,             # self.cookies
            'Host': 'apimobile.meituan.com',
            'Origin': 'https://' + self.host,   # self.host
            'Referer': self.citylink,           # self.citylink
            'User-Agent': self.windows_headers['User-Agent']
            }
        res = rq.get(url, headers=headers)
        data = json.loads(res.text)

        return data['data']['searchResult']
    

    def parse_data(self, data):
        
        ...

    def main(self, pages):
        '''Entry'''

        ...
        for p in range(pages):
            try:
                df1, df2 = self.parse_data(self.get_json(p))
                ...
            except Exception as e:
                print('ERROR: ' + str(e))
                self.cookies = self.get_cookies()   # Update Cookie
                continue
        ...

if __name__ == "__main__":
    
    ...

这里选择“奶茶”作为关键词,获取主要的省会城市的数据。获取的数据分成两个表,一个是商店列表,一个是推荐的热销商品,两表以 shop_id 作为外键连接。下图为某一个城市的数据:

三、数据清洗

在 MtSpider 中的 parse_data 环节已经进行简单的清洗,主要根据返回的 json 文档的数据结构,将其分割成两个表,方便处理。

获取数据时选择每个城市的数据作为单独的 csv 文件保存,这样出现问题容易处理。因此首先要对数据进行合并汇总。又由于数据较多较复杂,只进行简单的清理,保留大部分的有用信息。在后续进行分析时在根据具体需要重整数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
df = pd.read_csv(f'{PATH}{city}{KEYWORD}_shops.csv')
# 删除店铺标题中的括号内容,例如:一点点(XXX店)
df['title'] = df.title.map(lambda s: s.split('(')[0])
# 替换店铺标题中的特殊字符
df['title'] = df.title.str.replace(S, '·')
df['title'] = df.title.str.replace('一点点', '1點點')
# 提取地址中的区号生成新字段(先大致提取,有些城市可能会出现脏数据,无法全部兼顾)
df['region'] = df.address.map(lambda s: s.split('区')[0] + '区')
# 发现有些不是主营茶饮的,例如深圳的尊宝披萨;但是它又有茶饮产品,不好直接去掉
# 于是增加一个布尔型字段区分一下是否主营奶茶
df['isMain'] = df.backCateName.map(lambda cat: KEYWORD in cat)

# 然后将所有城市的数据汇总得到总的 shops 和 deals 两个表

下一步进行可视化分析前还需要对表格某些数据进行重构处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 商品标题字段,通过分词得到高频词列表
text = ' '.join(deals.title)
words = pd.Series(jieba.cut(text, cut_all=False))
stopwords = ...
wc = wordcloud.WordCloud(..., stopwords=stopwords)
wc.generate_from_text(' '.join(words))
# 借助 WordCloud 对象生成的高频词筛选源列表
level = ' '.join(list(wc.words_.keys())).split(' ')
new_words = words.loc[words.isin(level)]

# 联合两表计算每个店铺的推荐商品平均销量(作为该店铺的销量指数)
sales = deals.groupby('shop_id').sales.mean().reset_index()
shops.merge(sales.rename({'shop_id': 'id'}, axis=1), on='id', inplace=True)

四、可视化分析

对获得的数据进行初步的简单分析,看是否能得到有用的结论。接下来就是全国各地奶茶大赏!

  • 下面图表如无说明,均为全国范围的数据
  • 仅包含已入驻美团的商家数据
  • 关于价格的信息仅包含部分热销商品,仅做参考
  • 最后更新日期为 2022.08.03

(1)CoCo都可 分布最广、口碑最佳

评分这一点,众所周知,水分很大。不过也是可以反映一些信息的,毕竟哪怕是刷的分,也是要成本的。

(2)郑州 —— 杀出来的黑马

具体到每个城市的分布如下,可以看到郑州(2019 GDP 排名 16)的主要品牌奶茶店分布数量领先绝大部分城市。从图中还可以清楚看到每个品牌青睐哪个城市,例如郑州是蜜雪冰城的天下,而天津则被沪上阿姨占领。

(3)奈雪的茶 —— 高端大气

接下来看价格的分布,图中大小表示店铺的数量,颜色表示产品均价。从图中可以看到哪些品牌价格比较亲民,哪些比较高端。奈雪的茶荣登榜首(忽略部分分布数量较少的品牌)。明显的趋势是:价格亲民的品牌,有遍地开花的趋势,反之价格高的品牌店铺数量较少。

(4)沿江沿海地区消费水平较高

这个趋势还是比较明显的,深色数据点主要围绕着北上广和长江沿岸。按照这个结论,有两个省会城市就脱离了趋势,一个是中西部的陕西西安,一个是西南的贵州贵阳。

具体到不同城市的分布如下图。注意到箱形图部分城市的四分位已经到零点,主要是部分商家首页没有推荐的折扣商品,导致计算产品的参考均值的时候得到缺失值。这个后续再进一步处理,目前仅作简单的分析。

(5)各品牌销量均值

这里是对每个店铺的推荐商品销量求均值,然后再对该品牌的店铺求均值,作为销量的参考指标。这里在此前名不见经传的天御皇茶扳下一城,领先大部分品牌。但是,这个销量数据同样还是水分满满,可能需要另找一个替代的指标。

(6)舔屏 —— 推荐商品中出现最多的元素

(7)具体看一下深圳的数据

首先看一下各区的分布(绘制这个图的时候都没发现,现在才惊觉有个逻辑上的错误,影响不大,懒得改了)。

最后来个热力图压压惊。可以看到深圳湾“店满为患”。

五、总结

就这?当然不是!本来很有野心要做一个全面细致的分析的,但是越做发现问题越多,一度想删库跑路。精力有限,草草了事,有灵感再做。