做景区运营分析的人,手头最缺的往往是真实的游客反馈。官方介绍写得再漂亮,也抵不过一条"周末排队两小时玩五分钟"的评论。我前阵子接了个活,要整理广州长隆水上乐园的游客评价,看看大家到底在吐槽什么、又在夸什么。数据源选了美团,理由是它的评论量大、带评分、还有游玩时间和同行人数这类结构化字段,比纯文本好处理。这篇文章把整个抓取过程拆开讲,包含接口分析、代理配置、代码实现和踩过的坑,代码都能直接跑。

先把评论接口找出来

美团网页端的评论不是直接写死在 HTML 里的,它走的是 XHR 接口。打开浏览器开发者工具,切到 Network 面板,筛选 XHR,然后慢慢往下翻评论区,就能看到一条名字里带 comment 的请求冒出来。长隆水上乐园那页的接口大概是 https://www.meituan.com/ptapi/poi/getpccomment,请求参数里有几个绕不开的:

  • poiId:景点在美团里的唯一编号,从页面 URL 或者详情接口里能拿到
  • offsetpageSize:分页用,offset 从 0 开始,每页通常 10 条或 15 条
  • sortType:排序方式,按时间或者按热门

请求头要带 User-AgentRefererCookie 里至少得有 uuid_lxsdk,不然接口会直接返回空数据。这部分没什么黑科技,就是老老实实把浏览器里的请求复制出来对照着填。有一点要注意,美团对参数顺序和签名有时候会校验,遇到返回 {"status": 404} 的情况,先确认 Referer 和 poiId 有没有填对,再怀疑签名问题。

代理交给亿牛云来扛

刚开始我图省事,用自己的本机 IP 直接请求,跑了大概两百条就发现响应变慢,后来直接返回 412。美团对单 IP 的请求频率很敏感,短时间高频访问会触发限流。做小规模采集还能靠加延时躲一躲,但你要拉几千条历史评论,光靠 time.sleep 不现实,IP 还是会被盯上,而且一旦被封,连带着同网段的其他请求都受影响。

这次我把代理整件事都交给了亿牛云。它家做代理 IP 服务,对爬虫场景支持得比较到位,主要给我解决两件事:一是把出口 IP 打散,让美团看到的请求来自不同地址;二是提供带鉴权的稳定入口,不用自己去找一堆免费代理凑合用。亿牛云给到的接入方式有两种,我两种都试过,下面分开说。

第一种是隧道代理。后台给你一个固定的隧道入口地址和账号密码,你只要把请求甩给它,它每次自动从 IP 池里换一个出口,对你来说看到的就只是一个固定地址。配起来最简单,适合不想维护 IP 池的人。封装成 dataclass 长这样:

from dataclasses import dataclass


@dataclass
class YiniuTunnel:
    username: str
    password: str
    # 隧道入口地址在亿牛云后台获取,格式由官方给出
    tunnel_host: str
    tunnel_port: int

    @property
    def url(self) -> str:
        # 亿牛云隧道代理:账密写在 URL 里,出口 IP 每次自动轮换
        return f"http://{self.username}:{self.password}@{self.tunnel_host}:{self.tunnel_port}"

第二种是动态短效代理。亿牛云有个提取接口,你按量去拉一批短效 IP 回来自己管,用坏了就丢掉再拉。这种适合你想精确控制每条请求走哪个 IP、或者要做更细的并发调度。拉取逻辑单独写成函数:

import httpx


def fetch_yiniu_ip(api_url: str, auth: str) -> str:
    """从亿牛云短效代理接口拉一条新 IP,返回 host:port。"""
    resp = httpx.get(api_url, params={"num": 1, "auth": auth}, timeout=5)
    resp.raise_for_status()
    return resp.text.strip()

我最后选了隧道代理当主力,短效接口当备胎。原因也实在:隧道代理不用我自己写 IP 池的增删改查,遇到一条代理抽风,换个请求自然就换 IP 了,代码量少一大截。

账号密码这类敏感信息,我习惯从环境变量读,不在脚本里硬编码。上面的 dataclass 可以加一个 from_env 类方法,启动时从 os.environ 把用户名、密码、隧道地址取出来实例化,要交代码给别人也不会把账号带出去。

爬虫主体怎么写

抓取这一层,我把代理、重试、日志三件事提前绑死,后面就省心了。代理对象直接传进 httpx 客户端,遇到 412 或者超时,靠 tenacity 做指数退避重试,重试前顺便从亿牛云换一条新出口,等于把"被限流"的链路也兜住了。

import logging
import os
from typing import Optional

import httpx
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)

BASE_URL = "https://www.meituan.com/ptapi/poi/getpccomment"


@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=10),
    retry=retry_if_exception_type((httpx.HTTPStatusError, httpx.TimeoutException)),
    reraise=True,
)
def fetch_comment_page(
    poi_id: str,
    offset: int,
    proxy: Optional[YiniuTunnel] = None,
) -> dict:
    params = {
        "poiId": poi_id,
        "offset": offset,
        "pageSize": 10,
        "sortType": 1,
    }
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
        "Referer": f"https://www.meituan.com/meishi/{poi_id}/",
    }
    proxies = {"http://": proxy.url, "https://": proxy.url} if proxy else None
    with httpx.Client(timeout=10.0, proxies=proxies) as client:
        resp = client.get(BASE_URL, params=params, headers=headers)
        resp.raise_for_status()
        logger.info("抓取 offset=%s 返回 %s", offset, resp.status_code)
        return resp.json()

tenacity@retry 把重试和退避都收进了装饰器,比手写 try/except 清爽。日志用标准库 logging 而不是到处 print,后面接文件或者接采集监控都方便,出问题时也能反查是哪一条 offset 挂了。配合亿牛云隧道代理,重试时底层出口 IP 已经悄悄换过一轮,等于双重保险。

把评论整理成结构

接口返回的 JSON 里,每条评论裹着用户昵称、评分、正文、发布时间,还有晒图列表。我用一个 dataclass 把它收拢,解析逻辑单独写在 from_raw 里:

from dataclasses import dataclass
from datetime import datetime


@dataclass
class Review:
    review_id: str
    nickname: str
    score: float
    content: str
    publish_time: datetime

    @classmethod
    def from_raw(cls, item: dict) -> "Review":
        return cls(
            review_id=str(item["reviewId"]),
            nickname=item["userInfo"]["nickName"],
            score=float(item["star"]),
            content=item.get("comment", ""),
            publish_time=datetime.fromtimestamp(item["publishTime"] / 1000),
        )

from_raw 单独成方法,解析逻辑和抓取逻辑就分开了。哪天美团改了字段名,只动这一处就行,不用翻整个爬虫。这种隔离在接口经常微调的网站上特别省心。

翻页与主流程

抓一页不够,得写个循环把 offset 往后推,直到接口返回空列表或者达到你想要的数量:

import random
import time


def crawl_all(poi_id: str, proxy: YiniuTunnel, max_pages: int = 600) -> list[Review]:
    reviews: list[Review] = []
    for page in range(max_pages):
        offset = page * 10
        data = fetch_comment_page(poi_id, offset, proxy)
        items = data.get("data", {}).get("commentList", [])
        if not items:
            logger.info("第 %s 页无数据,停止", page)
            break
        reviews.extend(Review.from_raw(i) for i in items)
        time.sleep(random.uniform(0.8, 1.5))
    logger.info("共抓取 %s 条评论", len(reviews))
    return reviews

每页之间建议加一点随机延时,让请求节奏不像机器那么均匀。完全规律的间隔反而更容易被识别成脚本,所以我在循环末尾放了 random.uniform(0.8, 1.5) 的抖动。配合亿牛云的 IP 轮换,单 IP 的命中频率进一步摊薄。

存哪里、怎么清洗

小批量数据我直接丢 CSV,方便用 Excel 或者 Pandas 看。量大的话换成 SQLite,建个唯一索引避免重复插入。清洗时重点处理几件事:把正文里的换行和多余空格压掉,把评分从字符串转成浮点,把发布时间统一成 datetime,再顺手过滤掉字数少于五个字的水帖。长隆水上乐园的评论里经常出现"超级好玩""一般般"这种词,正文短、口语化,做情感分析前最好先去掉表情符号和纯表情的评论。

爬完能干什么

拿到几千条评论之后,我做了两件简单的事。一是按月份统计均分,看淡旺季口碑有没有落差;二是用 jieba 分词跑了个词频,发现"排队"“票价”"项目"出现得最频繁,基本上游客的注意力就在这几件事上。如果想再进一步,可以接个情感词典给每条评论打正负向标签,看哪些项目差评集中在设施老旧,哪些集中在服务态度。这类结论给运营方看,比笼统说"口碑不错"有用得多。

几个提醒

隧道代理不是免死金牌。即便走了亿牛云的 IP 池,单 IP 的请求节奏还是要控制,建议每页之间加一秒左右的随机延时,别把代理出口也跑成规律心跳。账密从环境变量读,别写进代码仓库,亿牛云后台能随时重置密钥,泄露了第一时间去轮换就行。Cookie 有时效,跑长任务记得定时刷新,可以每隔半小时重新从浏览器或者登录态里取一次。最后一点,抓来的评论只做聚合分析,别拿去商用或者搬运,景区数据的使用边界还是得守住,技术问题之外这也是绕不开的一环。

整套流程跑下来,长隆水上乐园那批数据大概六千条,从找接口到出词频图花了一个下午。技术本身不复杂,麻烦的地方都在和反爬较劲、和数据格式较劲。把亿牛云代理、重试、日志这三样提前准备好,后面会顺手很多,真遇到问题也能从日志里快速定位是哪一层在闹脾气。

更多推荐