
算起来这应该是每个做数据分析、爬虫开发或者哪怕是写自动化脚本的人都会碰到的第一课。但它也是被误解最多的一门基础课——数据采集并不是简单地把网页源码下载下来或者对着某个接口发几个请求。我在带实训小组的时候经常遇到这样的情况大家装好了环境、敲得动代码但面对采什么数据、去哪采、以什么形式采、采完怎么处理这几个基本问题还是两眼一抹黑。这篇就是想把数据采集的基础知识用一套完整的实训思路讲清楚从数据类型、请求原理到解析方式、合规边界最后用一个可运行的采集小项目把这些点串起来。内容适合刚接触数据采集的开发者也适合已经能跑通简单脚本但想更系统地理解采集链路的同学。1. 数据采集到底在采什么先分清三种数据形态很多教程一上来就教怎么写Python代码结果学生复制下来能跑换个网站就完全不会改。根子在于没有先建立一个认知数据采集的第一步不是写代码而是判断目标数据属于哪种形态。数据分析类项目里90%的采集目标可以归为三类HTML页面、结构化接口、以及流式数据。理解这三者的区别你才知道该用什么工具去处理。1.1 HTML页面数据最直观但也是最容易误解的HTML页面数据指的是浏览器最终渲染出来的那一堆标签结构。比如你想采一个商品的价格信息那么需求就是从某个商品详情页的HTML里找到标着价格的那个DOM节点再把它里面的文本提取出来。这类数据的特点是非结构化的——信息藏在标签嵌套里需要一层层定位。举个例子一个常见的商品页面结构大致是div classproduct-info span classtitle某某型号液晶显示器/span span classprice1299.00/span span classsales已售300件/span /div你需要做的就是用选择器定位到.price这个class再取出1299.00。但这里有个大坑网站在改版时经常更换class名比如把.price换成.sale-price你的代码就会立刻失效。所以采集HTML数据时一定要为每个字段写一个定位策略而不是只写死一个class名这个后面我详细说。1.2 结构化接口数据真正的主流数据源再说结构化接口数据。现在大多数网站的前端都是前后端分离架构页面上的数据其实是JavaScript从后端接口拿到JSON之后渲染出来的。这类接口通常返回规范的JSON格式比如{ code: 0, data: { list: [ { title: 某公司年报解读, publish_time: 2025-01-15 09:30:00, url: https://example.com/news/12345, read_count: 2300 } ] } }这类数据的采集难度比HTML低不少因为字段结构清晰直接用json.loads()就能解析。真正难的是找接口这一步打开浏览器开发者工具切到Network面板刷新页面筛选XHR或Fetch类型你就能看到网页背后请求了哪些接口。找到能返回完整数据的那一个再分析它的请求参数和加密签名逻辑。我的建议是凡是目标网站有接口优先采接口而不是解析HTML——接口数据干净、字段全、更新及时解析成本低一个量级。1.3 流式数据与增量数据进阶场景的必修课除了HTML和JSON接口还有一类数据叫流式数据。它指的不是一次性返回完整数据集而是通过长连接、WebSocket或Server-Sent Events持续推送的数据。股票行情、实时比分、社交平台的信息流都属于这一类。这类数据的采集方式和前两类完全不一样通常需要建立长连接、维护心跳、处理断线重连。不过作为基础实训我不建议一上来就碰流式数据。先把前两类吃透就够了知道有第三种形态存在以后遇到的时候不会慌。还有一类增量数据值得提一下比如你昨天采集了1000条新闻今天网站又更新了20条你要做的不是重新采1000条而是只采新增的20条。这时候上次采集的最后一条的时间戳或ID就是关键通常叫增量标记字段。这个思路在实训项目里我会重点演示。2. 从URL到数据落地的完整请求链路一次HTTP请求到底发生了什么理解了采集对象之后下一步是搞清楚一次请求的生命周期。数据采集的核心操作本质上就是HTTP客户端向服务器发起请求、接收响应的过程。但发请求这三个字背后有太多可以优化的细节尤其是新手常常在编码、请求头、状态码这些地方栽跟头。2.1 HTTP请求的组成部分拆解一次标准的GET请求由几部分组成URL、请求头Headers、请求参数Params和请求体Body。GET请求的参数通常拼在URL后面用?分隔多个参数之间用连接。比如https://example.com/api/list?page1page_size20typenews这里page、page_size、type是查询参数服务端依靠它们返回对应的数据。采集接口数据时改参数是常态——改page翻页、改page_size调整每页数量、改时间范围筛数据。用Python的requests库写起来大概是import requests url https://example.com/api/list params { page: 2, page_size: 20, type: news } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/, Accept: application/json } resp requests.get(url, paramsparams, headersheaders, timeout10) print(resp.status_code) print(resp.json())如果你要采集的接口需要登录态那就要在请求头里带上Cookie或者用Authorization请求头带Token。我的实训经验是直接填Cookie最简单、见效最快缺点是Cookie有有效期带Token更规范但需要处理Token过期刷新。具体用哪种取决于采集任务是一次性的还是长期定时跑。2.2 请求头里的关键字段User-Agent、Referer与Cookie很多同学在采集时收到403 Forbidden第一反应是IP被封了其实大部分情况下是请求头没带全服务器识别出这不是正常浏览器行为。最重要的三个字段字段作用不带的后果User-Agent告知服务器客户端的设备和浏览器类型服务器可能拒绝响应返回403Referer告知服务器请求来源页面很多接口校验此字段接口返回非法请求Cookie携带会话信息用于身份识别需要登录的接口无法访问有些服务器还会校验Sec-Fetch-*系列字段或者Origin这种情况下直接用浏览器的Network面板里复制的请求头最省事。但我不建议无脑复制全部请求头过多的字段反而可能暴露你的自动化特征带上必备的几个就够了。2.3 状态码与重定向读不懂响应就谈不上采集HTTP响应状态码是服务器和你对话的语言。采集过程中最常见的几个200请求成功数据正常返回301/302重定向说明请求的URL有变化403禁止访问通常是请求头或权限问题404请求的资源不存在429请求太频繁被限流500/502/503服务器内部错误或过载重定向这个细节在采集时极其容易踩坑。比如你请求http://example.com/data服务器返回302Location指向https://example.com/data。requests库默认会自动跟随重定向这会带来两个问题一是最终拿到的URL可能变了导致翻页逻辑出错二是某些采集目标会在重定向过程中种Cookie如果你禁用了重定向跟踪身份状态就不完整。我建议调试阶段把allow_redirectsFalse设上手动观察重定向链条确认无误后再打开自动跟随。3. 拿到响应之后网页解析与JSON清洗的实用套路数据拿到手了真正的工作才刚开始。这段是实训里同学们最容易卡壳的部分因为解析代码写起来不难难的是写的解析逻辑扛不住真实的脏数据。3.1 HTML解析的两套方案XPath与CSS选择器解析HTML的主流工具是lxml和BeautifulSoup。它们的底层都在做同一件事把HTML字符串解析成可查询的节点树然后通过路径或选择器提取内容。以lxml的XPath为例我想提取上面那个商品例子的价格from lxml import html page html.fromstring(resp.text) price page.xpath(//span[contains(class, price)]/text()) print(price[0].strip())XPath的定位逻辑是从根节点按路径找适合字段多、结构复杂的页面。CSS选择器则更贴近前端工程师的思路.price、#product-detail这样的写法更简洁。BeautifulSoup的select方法用的就是CSS选择器from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, html.parser) price soup.select_one(.price).get_text(stripTrue)两套方案用哪个我的看法是页面结构清晰简单时用CSS选择器速度快页面结构复杂、需要按层级条件过滤时用XPath表达能力强。比如找到所有class以item-开头的div里的第二个span这种条件XPath一行搞定CSS选择器就要绕半天。另外强烈建议给所有用到class名的代码都加一层容错。真实场景中网页经常有小改动多写一行判断就能避免整个任务全崩price_elem page.xpath(//span[contains(class, price)]/text()) if price_elem: price price_elem[0].strip() else: price # 记为缺失值而不是直接报错3.2 JSON接口数据解析简单清洗才是重点JSON解析本身没什么好讲的data resp.json() items data[data][list]把数据取出来之后清洗才是重头戏。我归纳了清洗时最常见的四种脏数据形态实训中可以直接对照处理空值和None有的字段在列表中不存在补齐为None或者约定好的占位符。格式不统一的日期2025/01/15和2025-01-15混在一起统一转成同一种格式。HTML实体与转义字符从页面采集的文本里经常有amp;、\u3000这类东西要统一转换和去空白。字段类型漂移同一个字段有时是字符串有时是数字需要做类型归一。我习惯写一个简单的清洗函数把所有采集数据统一过一遍再入库import re from datetime import datetime def clean_date(value): if not value: return None value re.sub(r[/.], -, str(value).strip()) try: return datetime.strptime(value, %Y-%m-%d).date() except ValueError: return None def clean_text(value): return re.sub(r\s, , str(value or )).strip()3.3 解析失败时的排查思路数据解析失败时第一反应不应该检查代码而是检查响应内容。把resp.text的前500个字符打印出来看看服务器到底返回了什么。常见情况是返回了登录页面的HTML说明请求状态丢了返回了一段JSON错误提示说明参数不对返回了空数组说明筛选条件没有匹配到数据返回了压缩乱码说明没有正确处理Content-Encoding这个排查思路看起来简单但能帮你节省至少一半的调试时间。我自己实训中强调过无数遍先打印响应体再谈修代码。4. 采集不是越快越好频率控制、超时设置与重试策略写了不少采集代码之后我发现很多人的问题不是采不到数据而是把目标网站采崩了。这就要谈一个实训里几乎不讲、但实际工作中必考的话题采集纪律。4.1 请求频率控制的底层逻辑服务器怎么判断你是真人还是脚本其中一条就是请求频率。正常人浏览网站两秒一次已经算很快了脚本则可以一秒发几十个请求。目标服务器一旦检测到异常频率轻则限流重则封IP。控制频率最朴素的方法就是睡眠import time import random for page in range(1, 11): fetch_page(page) time.sleep(random.uniform(2, 4)) # 随机间隔模拟人类操作这里我特意用了random.uniform(2, 4)而不是固定的time.sleep(3)。固定间隔在服务器看来也是一种机器特征随机化才是模拟真人。要不要上代理池、加并发那是进阶话题。基础实训只要记住一个原则采集单个网站时默认以自己的浏览器速度作为参考上限。关于频率控制我还看到过用自适应策略的做法——每请求一次观察响应时间如果响应时间变长就自动放慢频率连续收到429就彻底停下来等一段时间恢复。这个思路非常适合长时间运行的采集任务相当于给采集器装了一个油门控制器。4.2 超时设置不让一个坏请求拖垮整个任务requests库如果不设timeout可能因为服务器无响应而一直挂起整个采集任务卡死。设超时是写采集代码的门槛动作resp requests.get(url, headersheaders, timeout(3.05, 10))这里的timeout参数传的是元组前一个是连接超时后一个是读取超时。连接超时代表连不连得上读取超时代表连上了但拿不到数据。建议连接超时设短一点比如3秒读取超时按单条数据的大小来一般10秒足够。4.3 重试策略指数退避比死磕有效请求失败了要不要重试答案是要但要有策略。无脑重试会加重服务器的负担也让自己的错误率居高不下。我常用的策略是指数退避每次重试间隔翻倍并设置最大重试次数import time def fetch_with_retry(url, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, timeout(3.05, 10)) if resp.status_code 200: return resp elif resp.status_code in (429, 500, 502, 503): # 服务器忙或限流等待时间递增 wait_time 2 ** attempt 1 time.sleep(wait_time) else: # 4xx错误一般重试无意义直接返回 return resp except requests.RequestException as e: time.sleep(2 ** attempt 1) return None重试里还有一个细节值得注意重试要区分错误类型。403、404这种重试一百次还是同样的结果白白浪费时间和服务器资源只有429、5xx和网络超时这类临时性错误才值得重试。这个判断逻辑放进代码里采集器的健壮性会明显上一个台阶。5. 合规采集是底线什么能采、什么不能采数据采集的技术问题都好解决真正需要反复强调的是合规问题。我带的每个实训项目开课第一件事就是讲清楚采集的红线。5.1 公开数据与个人信息的分界线技术上你能采到的数据法律上未必允许你采集和使用。两者的分界线不完全在于这个页面要不要登录而在于数据本身的性质。一个比较稳妥的判断标准是涉及可识别到特定自然人的个人信息比如姓名、手机号、身份证号、住址、社交账号等如果没有明确的合法依据和必要的授权就不应该采集。公开的企业资讯、行业报告、市场行情这类数据采集和使用的风险相对小很多但仍然要注意网站的用户协议。很多网站在服务条款里明确写了未经许可不得对本站内容进行批量抓取这种情况下即便数据本身是公开的批量采集也可能构成违约。我给学生定的实训规矩是只采集公开的、非个人的、用于学习研究的数据不使用采集工具突破登录限制、绕过反爬验证、破解加密参数不把采集到的数据用于商业用途或公开传播采集前先看目标网站的robots.txt和用户协议5.2 robots.txt的含义与局限robots.txt是网站根目录下的一个文本文件用来告诉爬虫哪些路径可以采集、哪些不可以。比如某个网站的robots.txt可能长这样User-agent: * Allow: /news/ Disallow: /user/ Disallow: /private/这表示普通爬虫可以访问/news/目录但不应访问/user/和/private/。需要说明的是robots.txt更多是一种君子协定它表达的是网站方的访问意愿。遵守它是采集者的基本素养也是实训里必须养成的习惯。查robots.txt的方式很简单robots_url https://example.com/robots.txt resp requests.get(robots_url, timeout5) print(resp.text)5.3 合规采集的自查清单我在每个实训项目验收的时候都会检查一份清单这里直接分享出来检查项说明数据是否公开确认目标数据属于公开信息而非账号私域数据是否涉及个人信息涉及则必须有明确的合法理由和授权依据是否遵守robots.txt目标路径是否被Disallow是否遵守用户协议用户协议是否禁止批量抓取请求频率是否克制是否设置了合理的请求间隔和限速数据用途是否合法是否仅用于学习研究不做商业运营是否存在绕过行为是否使用自动破解验证码、绕过频率限制等手段不要觉得这些是套话。我见过太多数据采集项目因为合规问题翻车小则数据被清空大则招惹法律纠纷。基础实训阶段就把合规意识植入习惯比任何技术点都重要。6. 实训演示一个财经资讯页面的数据采集小项目理论部分讲完了最后用一个具体的实训项目把整个流程串起来。这个项目模拟真实场景对当前财经公告环境进行数据获取虽然代码不复杂但它完整覆盖了分析目标→构造请求→解析清洗→存储落地的全流程实操性很强。6.1 目标分析与字段设计假设我要采集某个财经资讯网站的公告列表页页面结构大概是每页20条公告每条包含标题、发布时间和详情链接。先别急着写代码第一步是明确需求采集范围公告列表前5页目标字段标题、发布时间、链接存储方式标准CSV文件采集频率单次任务不做定时轮询字段设计上我建议在存储前加一个采集时间字段用来记录这条数据是什么时候采到的。这在后续做数据去重和增量更新时非常有用。6.2 代码实现请求与解析的完整串联下面是完整的实训代码采用requests发送请求、lxml做解析import csv import random import time import requests from lxml import html from datetime import datetime BASE_URL https://example.com/announcement HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example.com/, Accept: text/html,application/xhtmlxml } def fetch_page(page): url f{BASE_URL}?page{page} resp requests.get(url, headersHEADERS, timeout(3.05, 10)) resp.raise_for_status() return resp.text def parse_announcements(html_text): tree html.fromstring(html_text) items [] # 每一行公告的xpath路径实际使用时需要按目标页面调整 rows tree.xpath(//div[contains(class, notice-list)]/div[contains(class, item)]) for row in rows: title_ele row.xpath(.//span[contains(class, title)]/a) time_ele row.xpath(.//span[contains(class, date)]/text()) if not title_ele: continue items.append({ title: title_ele[0].text_content().strip(), publish_time: time_ele[0].strip() if time_ele else , link: title_ele[0].get(href, ), }) return items def main(): all_data [] for page in range(1, 6): print(f正在采集第 {page} 页) try: page_html fetch_page(page) page_data parse_announcements(page_html) all_data.extend(page_data) except requests.RequestException as e: print(f第 {page} 页采集失败: {e}, 跳过) time.sleep(random.uniform(1.5, 3.5)) timestamp datetime.now().strftime(%Y-%m-%d %H:%M:%S) with open(announcements.csv, w, newline, encodingutf-8-sig) as f: writer csv.DictWriter(f, fieldnames[title, publish_time, link, collect_time]) writer.writeheader() for item in all_data: item[collect_time] timestamp writer.writerow(item) print(f采集完成共获取 {len(all_data)} 条数据已保存至 announcements.csv) if __name__ __main__: main()这里有几个设计值得解释一下。encodingutf-8-sig是CSV的中文保险方案带BOM的UTF-8编码能让Excel直接打开不乱码这是实战中特别容易忽略的一环。publish_time字段如果为空就存空字符串而不是丢掉整条数据保证数据量完整。time.sleep(random.uniform(...))则是我们已经聊过的礼貌爬虫策略。6.3 实训过程中最常踩的三个坑第一个坑是XPath路径写死。网站改版或者结构微调路径就失效。我见过最多的情况是title_ele为空但页面其实有数据原因是class名多了个空格或者大小写变了。解决办法是在解析前先打印几行HTML确认结构print(tree.xpath(//div[contains(class, notice)])[0].text_content())看一眼再写路径能少走很多弯路。第二个坑是翻页逻辑。有些网站第一页URL是announcement而不是announcement?page1直接用循环拼接出来的URL会导致第一页重复采集。处理方式是把第一页当成特例或者确认翻页参数的起始值再写循环。第三个坑是数据编码。接口返回的JSON一般是UTF-8但某些老站的HTML是GBK编码requests直接用resp.text会解码成乱码。遇到这种情况要显式指定编码resp.encoding gbk。判断编码的一个小技巧是看响应头里的Content-Type字段比如text/html; charsetgbk。6.4 项目完成后的自我复盘清单实训做完不是交差就完了我一般让同学按这份清单复盘一遍是否所有目标字段都成功采集缺失率是多少是否有重复数据是否需要按标题或链接去重请求失败的页是否做了重试还是直接跳过了CSV文件是否能被Excel或其他工具正常打开采集过程中是否有触碰到合规边界的行为如果页面结构变了当前代码需要改哪些地方按这套思路走下来一个小资产的数据采集任务基本就能稳定落地。我也建议你在此基础上尝试扩展把存储从CSV换成SQLite数据库把单次采集改成增量采集把固定页面改成从入口页动态发现详情页链接。这些都是数据采集基础知识之上很自然的进阶方向也是把基本功转化成生产力的路径。