
1. 为什么SaaS定价页比普通网页更难采集需求边界与方案选型先说说我为什么盯上这个需求。做SaaS竞品分析的人应该都有这种经历今天打开某款项目管理工具的官网Pro版还是49美元/月下周再看悄悄变成了59美元/月中间没有任何邮件通知。更离谱的是有些产品会把新功能拆成独立计费项或者把免费额度从5个席位砍到3个。等到你发现的时候方案已经汇报上去了预算完全对不上。这就是定价页历史版本采集的核心价值——你需要的不是某个时间点的定价快照而是完整的变动时间线。我最早的做法很原始建了一个Excel表格每周手动打开十几个竞品官网把价格、套餐名称、功能描述挨个复制进去。坚持了三周就放弃了原因很现实——手动采集不仅慢而且容易漏。有些SaaS产品的定价页是动态渲染的数据藏在接口里肉眼看到的只是拼装后的结果有些产品的定价页做了A/B测试不同入口进去看到的价格可能不一样还有些产品干脆把定价页做成了PDF或者需要登录才能查看。这些情况决定了SaaS定价页采集这个需求和普通新闻文章采集完全是两回事。1.1 动态渲染问题为什么直接抓目标站点往往拿不到想要的东西大多数现代SaaS官网用的是React或Vue这类前端框架定价页的HTML骨架是空的真正的价格信息通过JavaScript从后端接口拉取再渲染到页面上。你用requests直接请求定价页URL大概率只能拿到一个空壳一堆div标签没有任何价格数字。想破解这个问题要么用Selenium或Playwright模拟真实浏览器执行JavaScript要么直接分析页面前的XHR请求找到背后返回JSON数据的那个接口。两条路都能走但都有代价。Selenium慢而且容易被目标站点的反爬机制识别分析XHR接口则需要你熟悉浏览器DevTools的操作还得祈祷接口没做签名验证。1.2 历史版本从哪来直接爬目标站的最大盲区就算你解决了动态渲染问题还有一个更麻烦的事——历史版本。今天你写了个爬虫抓到了定价页那上周的版本呢上个月的呢目标站点的服务器不会保存历史版本给你调取除非它自己做了改动记录但这种情况极其罕见。我见过一些团队的做法是先上线爬虫从今天开始积累数据这当然可以但意味着你永远拿不到过去的变动数据。如果你需要回溯三个月前的某个定价变更唯一靠谱的数据源是第三方网页存档服务——也就是Internet Archive的Wayback Machine。它从1996年开始持续抓取互联网上的公开页面对主流SaaS官网的定价页有大量历史快照这些快照本身就是天然的历史版本。1.3 三种采集路线对比我最终选择了哪条我把市面上能想到的方案整理了一下一共三条路线。方案优点缺点适用场景直接爬目标站点配合Selenium/Playwright实时性强数据最新拿不到历史版本容易被反爬封禁需要处理动态渲染和接口签名只需要当前价格且目标站点反爬较弱自建定时任务从今天开始每天存档数据完全可控格式自定义没有历史回溯能力需要长期维护初期数据量为零长期监控自有产品定价页或竞品能接受冷启动周期基于Wayback Machine CDX API采集历史快照天然带时间戳历史版本完整快照非实时有延迟某些页面可能没被收录需要处理快照间的重复和噪声回溯历史定价变更、竞品调价分析也是最省事的主干方案我的最终选择是以Wayback Machine CDX API为主干把历史快照拉下来再做内容提取和差异对比同时用一个自建的小型定时脚本每天抓一次目标页面的当前快照补充最新数据。这样既解决了回溯历史的问题也缓解了最新数据滞后的尴尬。整个项目下来核心工作量其实不在爬虫本身而在数据清洗和版本对比这两块——这恰恰是网上大多数爬虫教程没讲透的地方。2. 环境准备与依赖安装这套方案的最低必要配置在动手写代码之前先把环境说清楚。我的开发环境是Windows 11 Python 3.10但下面这套方案在macOS和Linux上完全通用因为核心逻辑不依赖任何平台特性。如果你还在用Python 2建议先升级CDX API返回的JSON解析和后续的文本处理在Python 3下会省掉大量编码相关的坑。2.1 Python版本选择与虚拟环境有条件的话直接用Python 3.8以上版本原因有两个一是3.8之后的f-string调试语法f{var}非常方便二是我后面会用到的zoneinfo时区处理模块在3.9之后才进入标准库。装好Python之后强烈建议建一个干净的虚拟环境别把依赖直接装到全局python -m venv pricing_env # Windows激活 pricing_env\Scripts\activate # macOS/Linux激活 source pricing_env/bin/activate为什么要用虚拟环境因为这个项目依赖的requests、pandas、python-dateutil这些库版本要求比较宽但如果你的机器上还跑着其他爬虫项目某个库的版本升级很有可能把别的项目搞挂。虚拟环境就是给每个项目一个独立的依赖沙箱互不干扰这是我在踩过几次升级依赖导致全网爬虫瘫痪的坑之后养成的习惯。2.2 依赖清单本项目实际用到的库只有四个我把依赖控制在最小范围内核心思想是能不用框架就不用框架尽量用标准库解决问题。最终项目里只用了四个库pip install requests pandas python-dateutilrequests处理HTTP请求向CDX API查询快照列表、拉取存档页面内容pandas处理快照元数据表格做时间戳归组、去重、排序输出版本记录CSVpython-dateutil解析Wayback Machine返回的W3C格式时间戳类似20250315120345这种比手动切字符串安全得多hashlib系统内置不算第三方库用来算页面内容的SHA-256哈希做变化检测这里多说一句网上很多教程喜欢用scrapy或BeautifulSoup但本项目里BeautifulSoup都不是必须的。因为定价页的关键信息往往不是静态HTML里的文本而是嵌在某个JSON结构里或者需要提取特定区块。我直接用正则加json模块就能搞定少一个依赖就少一个维护负担。2.3 最小验证脚本三行代码确认网络连通性在写完整脚本之前先跑一个最小验证确认你的网络可以正常访问Wayback Machine的API。这一步挺重要因为某些办公网络或云服务器IP段访问这个服务可能会被限流或超时早点发现问题总比写完几百行代码才发现跑不通要好。import requests url http://archive.org/wayback/available params { url: notion.so/pricing, timestamp: 20240301 } resp requests.get(url, paramsparams, timeout30) print(resp.json())如果返回结果里有closest字段说明网络通可以把目标URL换成你要采集的SaaS定价页试试。如果没有检查一下是否需要用代理这里说的是常规企业代理不要误解或者换一个网络环境再跑。这个微小验证脚本的价值在于它把网络问题和代码问题两个变量分开排查后面写复杂逻辑时心理会踏实很多。3. 核心实现基于CDX API的版本发现与内容抓取现在进入正题。Wayback Machine的CDX API本质上是一个查询接口它返回的是某个URL在Internet Archive里所有的快照清单。我一开始以为这个API很复杂看了一小时文档差点放弃但实际用起来核心请求就一个GET参数也比想象中简单。先把最关键的请求参数列出来这是整个项目的基石。3.1 CDX API请求参数详解比官方文档更实用的理解方式CDX API的完整端点长这样http://web.archive.org/cdx/search/cdx?urlnotion.so/pricingoutputjsonfltimestamp,original,statuscode,mimetype,digestfilterstatuscode:200collapsetimestamp:6一个个解释url目标页面URL。注意这里传的是原始URL不需要带协议头API会自动匹配http和httpsoutputjson返回JSON格式方便程序解析。如果不加默认返回文本表格解析起来很痛苦fl控制返回哪些字段我常用的是timestamp快照时间、original原始URL、statuscodeHTTP状态码、mimetype内容类型、digest内容哈希filterstatuscode:200只保留正常抓取成功的快照过滤掉404、301等异常状态collapsetimestamp:6按时间戳前6位年月日去重意思是同一天只保留一条快照避免一天被抓了七八次导致数据冗余这里我特别说明一下collapse参数。Wayback Machine对热门页面经常一天抓取多次比如Notion的定价页一天可能被存档三到五次如果全保留后面做版本对比时会看到大量重复或近乎重复的记录纯属噪音。按天折叠后每个自然日最多保留一条快照数据量大幅下降的同时版本粒度依然是天级对定价变动分析来说完全够了。3.2 获取快照清单与时间戳归组核心代码实现我封装了一个函数用来获取指定页面在指定时间段内的所有快照import requests import pandas as pd from datetime import datetime, timezone from dateutil import parser as dt_parser WABase http://web.archive.org/cdx/search/cdx def fetch_snapshots(target_url, start_dateNone, end_dateNone): 从Wayback CDX API获取目标URL的快照清单 :param target_url: 目标页面URL如 notion.so/pricing :param start_date: 开始日期格式20230101可空 :param end_date: 结束日期格式20250301可空 :return: DataFrame列包含timestamp, original, statuscode等 params { url: target_url, output: json, fl: timestamp,original,statuscode,mimetype,digest, filter: statuscode:200, collapse: timestamp:6 } if start_date: params[from] start_date if end_date: params[to] end_date resp requests.get(WABase, paramsparams, timeout60) resp.raise_for_status() rows resp.json() if len(rows) 2: return pd.DataFrame(columns[timestamp, original, statuscode, mimetype, digest]) # 第一行是字段名 df pd.DataFrame(rows[1:], columnsrows[0]) # 时间戳解析成datetime方便后续排序和归组 df[datetime] df[timestamp].apply( lambda ts: dt_parser.parse(ts).replace(tzinfotimezone.utc) ) df df.sort_values(datetime).reset_index(dropTrue) return df这个函数做了三件事请求CDX API、解析JSON数组、将时间戳转成标准datetime对象。特别注意rows[0]是字段名真正的数据从rows[1:]开始这个细节我第一次处理时没注意差点把第一行快照的时间戳当成列名。运行一下看看效果snapshots fetch_snapshots(notion.so/pricing, start_date20240101, end_date20250315) print(snapshots.head())如果一切正常你会看到一个包含每条快照时间、原始URL、状态码等信息的表格。到这里你手里已经握住了Notion定价页在指定时间段的所有历史版本索引。接下来要做的就是把这些快照的内容真正拉下来。3.3 按时间戳组装快照URL从索引到内容Wayback Machine的快照访问URL格式是有规律的http://web.archive.org/web/{timestamp}/{original_url}。比如http://web.archive.org/web/20250315120345/https://www.notion.so/pricing表示2025年3月15日12:03:45抓取的Notion定价页。我把前面的快照列表加上这个访问URL变成一个完整的DataFramesnapshots[archive_url] snapshots.apply( lambda row: fhttp://web.archive.org/web/{row[timestamp]}/{row[original]}, axis1 )然后在每次抓取内容时把archive_url传给requests.get。这里有个细节我希望新手注意不要同时并发请求太多快照Wayback Machine对短时间内的密集请求会做限流我一般控制在每两秒一个请求200个快照大约需要7分钟完全可接受。如果快照数量上千就需要人工评估是不是把时间范围拉得太大了拆成多段慢慢跑。抓取快照内容的函数长这样def fetch_snapshot_content(archive_url, timeout30): 抓取Wayback快照页面的原始HTML headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } resp requests.get(archive_url, headersheaders, timeouttimeout, allow_redirectsTrue) resp.raise_for_status() return resp.text关于返回内容我要提醒一点Wayback Machine在返回快照页面时通常会在页面上插入自己的一个工具条如果人家顶部有一条archived from的横幅那个工具栏是HTML显式注入的不影响我们提取数据但如果你用BeautifulSoup去定位某些DOM节点可能会受到干扰。我的习惯是直接用正则或者简单字符串操作提取绕开工具栏带来的干扰。3.4 从快照HTML中提取价格信息用正则还是用结构解析SaaS定价页的价格信息有相对固定的形态美元符号加数字比如$49或$49.00或者是from $49这类前缀描述。我用一个多模式的正则来提取所有疑似价格的内容再根据业务规则过滤出真正的套餐价格import re def extract_prices(html): 从HTML里提取所有疑似价格内容 patterns [ r\$\s*[\d,]\.?\d*, # $49 或 $49.00 rUSD\s*[\d,]\.?\d*, # USD 49 rfrom\s*\$\s*[\d,]\.?\d* # from $49 ] prices [] for pat in patterns: matches re.findall(pat, html, flagsre.IGNORECASE) prices.extend(match.strip() for match in matches) return list(set(prices))有人会问我为什么不用XPath或CSS选择器去精确定位价格区块。原因是Wayback的历史快照里页面结构在不同时期可能完全不一样。三年前的定价页可能用的是一套CSS类名现在的又换了一套如果你用固定选择器换版后就会大面积提取失败。正则虽然看上去笨但它对价格长什么样的刻画是稳定的只要SaaS产品还在用美元数字的表达方式这套正则就能工作。如果你的目标产品用的是欧元、英镑或者其他币种符号记得在正则里把符号替换掉。提取完价格之后我还习惯把原始HTML保存一份到本地文件名带上时间戳。这既是为了备份也是为了方便后续人工抽查——机器判断价格变动时可能会漏但原始HTML永远是最终真相def save_html(raw_html, timestamp): file_path fsnapshots/{timestamp}.html with open(file_path, w, encodingutf-8) as f: f.write(raw_html) return file_path4. 变化检测与时间戳管理让采集结果真正形成版本史现在你已经有了一堆历史快照的HTML文件和提取出的价格列表但如果只是把这些数据堆在一起还称不上版本史。我在实际使用中很快就发现两个新问题第一快照数量多但很多快照之间根本没有任何变化如果全部展示出来等于把噪音也记进了历史第二同一天的多个快照如果不做归组会出现同一天内不同时间版本的冲突记录。这两个问题不解决后续做趋势分析时数据质量会很差。4.1 内容哈希与重复快照过滤一个月只留下几张有效版本我用的方案是给每个快照的HTML内容计算SHA-256哈希然后只保留哈希发生变化的版本。如果一个快照和相邻快照的哈希完全一致说明页面内容一字未改这个快照对版本历史来说就是冗余的。import hashlib import pandas as pd def compute_hash(html): return hashlib.sha256(html.encode(utf-8)).hexdigest() snapshots[content_hash] snapshots[archive_url].apply( lambda url: compute_hash(fetch_snapshot_content(url)) ) # 只保留哈希变化的地方 snapshots[changed] snapshots[content_hash] ! snapshots[content_hash].shift(1) version_df snapshots[snapshots[changed]].reset_index(dropTrue)这里有两点需要注意。首先shift(1)的比较是基于DataFrame排序后的顺序所以务必确保数据按时间排序了前面已经做了sort_values。其次哈希比较是全量比较只要页面里任何一个字节变了哈希就会变。这意味着一个动态的今天访问人数数字、一段随机推荐的文案都会导致哈希变化。对于定价页来说这种噪声相对少但如果你的目标页面里有动态元素建议还是用下一步的结构化提取值对比来辅助判断。4.2 结构化版本对比价格变动才是关键信号哈希只能告诉你页面变了不能告诉你价格变了没。为了精准捕捉定价变动我需要把每个版本的提取结果组合成一个结构化记录时间戳、原始URL、价格列表、内容哈希。然后把相邻版本的价格集合做对比只有价格集合发生变化的才标记为定价版本变更。def prices_changed(row): # 当前价格集合和上一条记录的价格集合做差集 current set(row[prices]) previous set(version_df.iloc[row.name - 1][prices]) if row.name 0 else set() return current ! previous version_df[prices] version_df[html_content].apply(extract_prices) version_df[price_change] version_df.apply(prices_changed, axis1)这个字段的价值在后续分析时才会体现出来。比如我想看某产品在2024年全年调价了几次只需要查price_changeTrue的记录得到的就是一张干净的调价时间表——哪一天、从什么价格变成什么价格。这比对着几十条原始快照肉眼看要高效得多。4.3 生成版本记录CSV最终交付的数据结构最后把版本记录导出成CSV每一行是一个有内容变化的版本version_df[[datetime, archive_url, prices, content_hash, price_change]].to_csv( pricing_versions.csv, indexFalse )CSV内容大致是这样的datetimearchive_urlpricesprice_change2024-01-15 08:12:3300:00http://web.archive.org/web/20240115081233/...[$49]True2024-04-02 14:33:0100:00http://web.archive.org/web/20240402143301/...[$49, $79]True2024-07-20 09:05:4400:00http://web.archive.org/web/20240720090544/...[$59, $89]True看到这张表你基本就能回答某产品什么时候调过价、从多少调到多少这个问题了。如果还想做可视化直接把datetime和prices字段丢进Excel或ECharts画时间线走势图都非常方便。5. 定时调度与防封策略从手动脚本到自动值守脚本写完之后我一开始是手动跑的想起来了就执行一次。但一忙起来就忘结果历史数据出现断档。后来我配置了定时调度跑了两周终于稳定下来。这部分的经验对任何爬虫项目都有通用价值。5.1 调度方案选择Windows计划任务还是GitHub Actions我同时配了两套调度方案互为备份。方案一Windows计划任务适合在我自己的电脑上跑步骤很简单把Python脚本路径、虚拟环境的python.exe路径、工作目录配置到任务计划程序里触发器设置每天上午九点执行一次。脚本内部先拉取当前快照做实时存档再周期性调用CDX API回溯最近半个月的快照做补漏。Windows计划任务偶尔有个问题是系统休眠或关机时任务会被跳过所以我还在脚本外层加了个今天没跑过就补跑的逻辑。方案二GitHub Actions适合云端异地备份如果你的代码放在GitHub上建立一个.github/workflows/pricing.yml每天定时跑一遍采集逻辑然后把生成的CSV提交回仓库。这样做有额外的好处所有版本历史天然进了Git每次变动都有记录且不依赖本机是否开机。我用过一段时间可靠性很高缺点是一天只跑一次无法做到小时级监控。5.2 请求频率与User-Agent合理设置别把自己搞进黑名单不管是请求Wayback Machine还是直连目标站点都要注意请求频率。Wayback Machine的整体策略相对宽松但也不是完全没限制。我的实践做法单线程顺序请求不用asyncio或concurrent.futures每个请求之间至少间隔2秒设置超时时间30秒避免某个请求长时间卡死每次请求都带上正常的User-Agent模拟浏览器访问有人会问为什么要带浏览器的User-Agent因为默认的python-requests/2.31.0这个UA太容易被识别为爬虫虽然Wayback不一定会封但有些目标站点如果采集它的当前版本页看到这个UA大概率直接拒绝服务。5.3 关于robots.txt与数据使用的边界必须提前想清楚的事到这里必须提一个合规问题。用Wayback采集历史快照是从第三方存档取数绕开了目标站点本身的访问限制但这并不意味着可以无限制地使用这些数据。我给自己定的原则是采集的数据只用于竞品分析和内部决策不对外发布不用于可能侵犯对方商业利益的场景。另外如果脚本里有直连目标站点获取当前版本的逻辑务必先检查目标站点的robots.txt看它的Disallow规则是怎么写的百度搜索robots.txt 怎么看就能快速了解。一个负责任的爬虫程序应该在请求前先读取目标站点的robots规则而不是不管三七二十一直接开爬。6. 实战中的坑与排查链路一个真实踩坑的完整过程这一节我把整个开发过程中遇到的最典型的三个问题写出来都是纯技术层面的坑每个坑背后都有一段真实的排查过程。6.1 坑一CDX返回了大量重定向记录导致重复版本激增第一次跑完整脚本时我拉回来的快照清单有300多条但冷静一看其中将近一半是original字段带www.和不带www.的重复还有一些是http和https之间的重复。Wayback对同一个页面会同时记录多种URL形态CDX默认不做归并。这个问题不加处理会导致同一个页面被当成多个页面采集版本对比彻底失真。排查链路先打印snapshots[original].value_counts()发现notion.so/pricing和www.notion.so/pricing被当成两条记录然后我去CDX API文档里查发现有一个matchTypeexact参数可以精确匹配URL但这样会把不同形态的URL全当成不同页面也不合适。最终我的解法是在请求CDX时把URL统一成目标站点实际使用的那个形态一般是不带www的那个同时在拿到结果后再做一次original字段的字符串归一化去掉http(s)://前缀、去掉末尾斜杠再按归一化后的URL排序去重。这样处理的逻辑简单也最容易验证。6.2 坑二某些快照没有渲染出动态内容价格字段为空SaaS定价页如果在Wayback抓取时JavaScript执行失败存档的HTML里可能只有空壳价格信息完全缺失。我在调用extract_prices后返回空列表导致版本对比时误判为降价到0元。对比离散价格时$xx - $0这种结果极其刺眼一眼就能发现不对。排查链路拿一条价格为空的快照URL直接在浏览器里打开发现Wayback页面显示正常但查看原始HTML后发现动态接口的数据被剥掉了页面里确实没有价格文本。确认是抓取时JS未执行导致的快照缺陷而不是我的正则写错。处理方案是在extract_prices后面加一个空值判定如果某个快照的价格集合为空则不把它记入版本变更只记录一条快照内容缺失的日志方便人工回头补查。这个坑没有完美解法因为快照缺陷是存档服务自身的问题只能靠数据规侧来规避。6.3 坑三目标页面有随机参数或签名token导致哈希频繁变化我盯的一个SEO工具定价页URL后面带一个?refabc123之类的参数每次Wayback爬虫抓到的URL参数值不同页面内容里还嵌入了当前时间戳。结果哈希对比几乎每次都是变化版本记录被撑到几百条但实际上价格一条没动。排查链路先看页面HTML里哪些部分在变用diff工具直接对比两个快照的HTML一目了然是时间戳和跳转参数在变。解法分两步第一步正则把所有URL参数里的refxxx、utm_*之类的键值对删除再算哈希第二步更狠一点直接只对extract_prices提取出来的价格文本计算哈希价格没变就算无变化。这一步做完之后有效版本记录立刻从300多条缩到了12条清爽多了。7. 功能的扩展方向从价格追踪到小型竞品情报系统主体功能跑通之后我的项目基本满足了自己最初的需求。但这套东西的价值是可以往外延伸的我列几个我已经做了或者准备做的方向供你参考。7.1 多目标批量管理一个配置搞定十个竞品目前我做的事情是把目标SaaS产品的列表写在一个配置文件里脚本启动时循环处理。每个产品一行记录格式是目标名称, 目标URL, 开始日期, 采集策略。这样当我需要新增一个竞品时只需要在配置里加一行不需要动代码。注意不同产品的站点结构差异会影响提取逻辑我一般每个产品单独配一个小函数来解析价格但正则库是共享的。7.2 价格变动通知三行代码把变动推到企业企微/钉钉群价格变动如果全靠人工盯CSV依然会错过。我加了一个Webhook通知的功能当检测到某产品price_changeTrue时脚本自动把产品名、时间、旧价格、新价格、快照链接拼成一条消息通过企微群机器人或钉钉机器人推送到群里。实现只需要几行Python代码核心就是用requests.post发一个JSON到Webhook地址。这样即使我一个月不看脚本产出的CSV群里也会有完整的调价记录。7.3 历史数据可视化价格走势图与套餐结构演变最后一个扩展方向是可视化。我目前的做法是把版本记录CSV导入到一个简单的前端页面用ECharts的时间线折线图展示价格走势横轴是日期纵轴是价格每条线代表一个套餐Basic、Pro、Enterprise。这种图对决策特别有用——你可以直观看到某个竞品一年内涨了几次价、涨价的幅度是不是一次比一次大以及涨价前是不是伴随着免费额度的缩减。套餐结构的演变则可以用表格做对比2023年的时候有没有某个档位2025年这个档位是被删了还是改名为别的这些都是销售策略分析里非常关键的信息。说到底定价页历史版本采集这个项目的本质是把对方的商业决策过程通过互联网存档技术还原出来让你在做竞品分析、预算评估或者产品定价决策时手里有一份扎实的数据而不是感觉和猜。这套东西的技术门槛不高难度在于把数据链路做扎实从CDX API拿索引到抓快照内容到提取结构化字段再到变化检测和通知每一步都需要考虑边界情况和历史数据本身的噪声。跑通主流程不难真正花时间的是那些页面结构变了接口失败参数干扰这类看似不起眼但每个都会让结果失真的小问题希望这篇记录能帮你少走一点弯路。