简介:通过搜狗搜索引擎定向采集微信公众号文章的Python工程,基于Scrapy框架构建,面向需要批量获取公众号历史文章、快速产出数据集的开发者和数据分析人员。工具支持按公众号ID与关键词两种检索方式,自动抓取标题、正文、发布时间和公众号名称,并以正确编码格式写入数据库;关键词模式下会按时间顺序对文章排序,同时支持翻页持续爬取,还可识别新增内容实现增量入库。压缩包共15个文件,以Python源码、编译生成的字节码文件以及Scrapy配置为主,整体仅19KB,轻量简洁,适合直接运行或二次改造;目前已有878人学习下载。通过这份资源,可以掌握Scrapy请求发送与响应解析的完整流程,理解如何对接搜狗搜索页、处理分页规律以及设计简单的增量存储策略。此外,还能为后续搭建自媒体数据监控系统、开展内容分析与人工智能应用积累语料与工程经验。
1. 搜狗微信搜索这个入口,为什么值得专门为它写一只爬虫
做内容运营和舆情监测的人,几乎都绕不开一个需求:按关键词批量收集微信公众号文章。公众号本身没有开放的站内搜索,而搜狗搜索引擎是目前实际可用的微信文章搜索入口,它聚合了公众号标题、摘要和原文链接。手动搜一两个关键词还好,一旦要维护“行业竞品每日推送”“政策相关文章库”“某话题热度变化”这类任务,靠人工复制粘贴根本撑不住。于是就有了本文要讲的方案:通过搜狗搜索引擎的微信频道构造查询,用 Python 自动抓取搜索结果、提取正文并持久化。这套做法适合已经会基础 Python、想给自己的知识库或分析脚本加数据源的读者,不需要分布式爬虫,一台电脑加一个稳定的网络就能跑起来。
2. 先读懂搜狗搜索结果页:URL参数、反爬特征和请求头
动手写爬虫前,我习惯先用浏览器打开搜狗微信搜索,手动搜一个关键词,同时打开开发者工具看网络请求。这个步骤能帮你省掉至少一半的踩坑时间。搜狗微信搜索的入口域名是weixin.sogou.com,并不是平常搜索用的sogou.com,路径也不一样。先搞清它背后的 URL 规则,后面写代码才不会靠猜。
2.1 搜狗微信搜索的 URL 是怎么拼出来的
在浏览器里访问https://weixin.sogou.com/weixin,输入关键词“爬虫”并搜索,地址栏会变成类似这样的 URL:
https://weixin.sogou.com/weixin?type=2&query=爬虫&ie=utf8&s_from=input&_sug_=n&_sug_type_=其中type=2表示搜索文章,type=1表示搜索公众号;query就是关键词;ie=utf8指定编码;后面几个参数是搜狗自己加的统计和来源标记,不传也能出结果,只是可能影响排序或页面行为。实际写代码时,我一般只构造前三个参数,再加一个page翻页,长这样:
import requests from urllib.parse import urlencode keyword = "爬虫" base_url = "https://weixin.sogou.com/weixin" params = { "query": keyword, "type": "2", "ie": "utf8", "page": "1", } url = base_url + "?" + urlencode(params) print(url)这段代码里的urlencode会把中文关键词转成 URL 编码,避免请求头里出现非法字符。page参数要注意:搜狗微信搜索第一页是page=1,不是从 0 开始。后面翻页时只要改page的值,其他参数保持不变。
2.2 请求头和 Cookie 里藏着的三个坑
我最初写的时候,直接用requests.get(url)去请求,返回的却是一个验证码页面。后来对比浏览器请求才发现,问题基本都出在请求头不够“真实”。
第一个坑是User-Agent。搜狗对裸 Python 请求识别很直接,没有浏览器 UA 或者 UA 太老,大概率直接弹验证码。我一般用当前 Chrome 的 UA,并且保持和浏览器一致。
第二个坑是Referer。访问搜索结果页时,Referer 应该是https://weixin.sogou.com/或者上一页的搜索地址。直接空 Referer 偶尔会触发风控。我习惯在 session 里统一设置:
session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Referer": "https://weixin.sogou.com/", })第三个坑是 Cookie。搜狗会在首次访问时种下SUV等标识 Cookie。如果你的请求没有先访问一次首页或搜索页,直接带 Cookie 去请求深层页面,也可能被拦。所以我每次跑任务都会让 session 先访问一下https://weixin.sogou.com/获取初始 Cookie,再开始搜索。
2.3 用开发者工具抓出真实请求样例
与其自己猜参数,不如直接看真实请求。操作很简单:打开 Chrome 的无痕窗口,按 F12 切到 Network 面板,勾选 Preserve log,然后访问搜狗微信搜索,输入关键词回车。在请求列表里找到weixin?type=2...那条,右键 Copy as cURL,就能看到浏览器实际发送的请求头和 Cookie。把这个 cURL 转成 Python 代码,最快的方式是交给 Postman 或 requests 库转换工具,但手写也很简单:
session.get( "https://weixin.sogou.com/weixin?type=2&query=python&ie=utf8&page=1", headers={ "User-Agent": "Mozilla/5.0 ...", "Referer": "https://weixin.sogou.com/", } )注意 cURL 里的 Cookie 很长,那是搜狗根据你的浏览器行为临时生成的。代码里不必全部复制,只要保证 session 里保留初始 Cookie 即可。如果你打开 Network 后看到https://weixin.sogou.com/antispider/开头的请求,说明被验证码拦了,这时候先手动过一下验证,再看请求特征。这一关过了,后面爬接口才稳。
3. 用 requests 跑通第一版:搜索、解析列表、提取正文
这一章的目标是让你在十分钟内跑出第一版可用的代码,能搜到文章列表,也能把正文存下来。我不追求一开始就写得很重,先把链路打通,再逐步加健壮性。
3.1 最小请求代码:既能出结果又不触发验证码
先写一个最小函数,负责“给定关键词和第几页,返回搜索结果页 HTML”。为了不给搜狗服务器造成压力,我默认每次请求之间 sleep 2 秒。这个延迟可以按需调,但不能为 0。
import time import requests from urllib.parse import urlencode HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Referer": "https://weixin.sogou.com/", } session = requests.Session() session.headers.update(HEADERS) def fetch_search_page(session, keyword, page): params = { "query": keyword, "type": "2", "ie": "utf8", "page": str(page), } url = "https://weixin.sogou.com/weixin?" + urlencode(params) resp = session.get(url, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" if "请输入验证码" in resp.text: raise RuntimeError("触发验证码,请手动处理后再继续") time.sleep(2) return resp.text这里先调用一次session.get("https://weixin.sogou.com/")来初始化 Cookie,然后再搜索。resp.encoding = "utf-8"是必要的,搜狗页面本身是 UTF-8,但 requests 根据响应头推测编码时可能猜测错误,导致中文乱码。检测验证码的经验规则是判断页面里是否出现“请输入验证码”这段文字,实际页面里还有一个专门的验证码 iframe,但文字判断最简单可靠。
3.2 解析搜索结果列表:文章标题、链接、公众号名、摘要
拿到 HTML 后,用 BeautifulSoup 解析列表。搜狗微信搜索的结果列表结构比较稳定:每个结果是一个<li>,带id="sogou_vr_110026b7_0"这种动态属性,不能作为选择器。我一般直接找ul.news-list下的所有li,然后在每个 li 里找标题链接、公众号名称和摘要。
from bs4 import BeautifulSoup import re def parse_search_list(html): soup = BeautifulSoup(html, "html.parser") items = [] for li in soup.select("ul.news-list > li"): title_elem = li.select_one("h3 a") if not title_elem: continue title = title_elem.get_text(strip=True) # 搜索结果中的 href 是搜狗跳转地址 link = title_elem.get("href", "") if link.startswith("/"): link = "https://weixin.sogou.com" + link account_elem = li.select_one(".account") account = account_elem.get_text(strip=True) if account_elem else "" # 有的结果会带“热度”标识,不影响解析 date_elem = li.select_one(".s2") date_text = date_elem.get_text(strip=True) if date_elem else "" # 摘要一般在 .txt-info 里,可能为空 summary_elem = li.select_one(".txt-info") summary = summary_elem.get_text(strip=True) if summary_elem else "" items.append({ "title": title, "link": link, "account": account, "date_text": date_text, "summary": summary, }) return items这段代码里选择器.account对应公众号名称,.txt-info是摘要,.s2是发布时间文本。需要注意,link拿到的是搜狗中间跳转地址,不是微信文章原始地址,后面还需要额外处理。解析完之后,我还习惯把标题里夹带的“视频号”字样去掉,但不是必须的。
3.3 从搜狗跳转链接到微信正文页:两种跳转方式的处理
搜索结果列表里的链接,指向的是https://weixin.sogou.com/link?url=...。直接访问它,搜狗会做一次 302 跳转,最终落到mp.weixin.qq.com/s?...文章页。但实际跑到这一步会发现两种情况:
第一种是 302 跳转,响应头里带Location;第二种是返回一个 HTML 中间页,里面嵌了一段 JS 再跳转。如果只用requests默认的自动跟随重定向,第二种情况拿不到最终地址,因为 requests 不执行 JS。
我的做法是先不跟随重定向,请求一次link地址,然后看响应的特征:
def resolve_link(session, link): resp = session.get(link, timeout=10, allow_redirects=False) if resp.status_code in (301, 302, 303, 307): return resp.headers.get("Location", "") # 如果返回的是中间页,尝试从 HTML 中提取跳转地址 if 'url \+= ' in resp.text or 'href="' in resp.text: m = re.search(r"href='(.*?)'", resp.text) if m: return m.group(1) # 如果带了 weixin 域名,说明是跳转后页面 if "mp.weixin.qq.com" in resp.url: return resp.url return ""这段逻辑没有覆盖所有中间页变种,但能处理常见的两种。真实场景里,搜狗偶尔会返回一个包含url +=加密串的脚本页面,这时候就需要执行 JS 或者引入playwright了。如果只是做少量爬取,建议直接把allow_redirects=False后拿到的 Location 当作跳转地址,如果为空就丢弃这条结果,别硬解。
拿到微信文章 URL 后,提取正文相对简单。微信文章页是静态 HTML,正文在<div id="js_content">里面。用 BeautifulSoup 把它转成纯文本或 Markdown 都可以。下面先给一个提取纯文本的版本:
def extract_content(article_url): resp = session.get(article_url, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") content_div = soup.select_one("#js_content") if not content_div: return "" # 去掉脚本和样式,保留文字 for tag in content_div.find_all(["script", "style"]): tag.decompose() return content_div.get_text("\n", strip=True)这一步要注意:微信文章页如果在未转发的浏览器里打开,可能需要验证。但直接从搜狗跳转过来的请求头里已经带了正确的 Referer 和 Cookie,通常能正常访问。如果文章被删除或作者关闭了转载,#js_content会不存在,直接返回空字符串即可。
4. 翻页、去重和断点续爬:把脚本变成能跑批的任务
第一版脚本能抓一页后,接下来要解决三个问题:翻页是否无限循环,重复文章是否会被再次抓取,以及任务中断后能否从上次位置继续。这三个问题不处理,脚本只能叫“演示”,不能叫“爬虫任务”。
4.1 翻页参数:page 是 1 开始,但页容量有限
搜狗微信搜索翻页很简单,把page参数加一即可。但有一个实际限制:它不会返回无限多页,对同一个关键词,通常最多能看到 100 页左右,每页 10 条。再往后翻,页面会显示“没有更多结果”或者直接返回空列表。所以脚本里要有一个终止条件:如果某页解析出的结果列表为空,停止翻页。
keyword = "爬虫" all_items = [] for page in range(1, 101): print(f"fetching page {page}") html = fetch_search_page(session, keyword, page) items = parse_search_list(html) if not items: break all_items.extend(items) # 控制翻页频率,避免触发验证码 time.sleep(2)这里range(1, 101)只是一个安全上限,实际遇到空页会提前退出。我在真实任务里会在fetch_search_page函数内部统一 sleep,所以这里没有额外加。但要注意:如果某个关键词结果非常多,连续翻页超过 20 页,风险显著增加。我一般设置一个最大页数配置,默认 50,并允许通过命令行参数覆盖。
4.2 基于 URL hash 和公众号名的去重策略
去重不能只靠文章标题,因为不同公众号可能发同题文章。更可靠的方式是取微信文章 URL 中的__biz、mid和idx参数。搜狗跳转后的最终 URL 形如:
https://mp.weixin.qq.com/s?__biz=MzAxNTEyMjg= &mid=265111&idx=1&sn=xxxx这三个参数组合起来可以唯一确定一篇文章。所以我们把解析列表和跳转后的 URL 都保存下来,计算一个签名。最简单的方式是直接保存最终 URL,然后对 URL 做 hash。但同一篇文章如果被不同搜索词重复搜到,最终 URL 是一样的,所以 URL 去重已经足够。如果想更精细一点,可以用urlparse拆出query,再按__biz,mid,idx排序拼接。
from urllib.parse import urlparse, parse_qs def article_key(url): query = parse_qs(urlparse(url).query) biz = query.get("__biz", [""])[0] mid = query.get("mid", [""])[0] idx = query.get("idx", [""])[0] return f"{biz}_{mid}_{idx}"这个函数返回的字符串作为去重键。如果页面 URL 里没有这些参数,退回到整个 URL 的 hash。我一般会在写入数据库时建一个唯一索引,让数据库帮我们做最终去重,脚本里只做内存去重,防止重复下载正文。
4.3 增量抓取与断点续爬:保存进度和失败重试
跑批任务最怕的是爬到第 30 页时网络抖动导致中断,然后从头再来。我常用的做法是维护一个任务文件progress.json,记录每个关键词已经抓到的最大页数和最后处理时间。每次启动脚本时先读这个文件,从上次页码继续。
import json import os PROGRESS_FILE = "progress.json" def load_progress(): if os.path.exists(PROGRESS_FILE): with open(PROGRESS_FILE, "r") as f: return json.load(f) return {} def save_progress(progress): with open(PROGRESS_FILE, "w") as f: json.dump(progress, f, ensure_ascii=False, indent=2)这里progress是一个字典,结构建议为:
{ "爬虫": {"page": 12, "next_keyword": ""}, "python": {"page": 30} }每次翻页成功后,立刻更新当前关键词的页码并保存。这样中断后重跑,只从第 13 页继续。对于抓正文阶段失败的文章,可以把链接写进一个retry.txt,下次启动时单独重试。这个方案虽然简陋,但比引入 Celery 等任务队列轻得多,单机完全够用。
5. 搜狗微信爬虫的避坑指南:从验证码到空结果的 5 个高频问题
这一章是血泪经验。我第一版脚本上线后的头两个小时,几乎把能踩的坑全踩了一遍。下面五条按“现象 → 原因 → 解决”的顺序写,每条都是实际遇到过的,直接照着排查。
5.1 返回页面是“请输入验证码”
现象:fetch_search_page抛异常,resp.text里含有“请输入验证码”并且页面里能看到一个拼图或滑块验证码框。
原因:触发频率限制,一般是单位时间内请求次数太多,或 User-Agent 等请求头太薄。有时候同一个 IP 连续搜索超过 10 次就会触发。
解决:第一步,确认请求头里有和浏览器一致的 User-Agent,并且 Referer 是搜狗域名。第二步,把fetch_search_page里的time.sleep(2)调大到 5 秒或更长。第三步,如果已经触发了验证码,不要立刻重试,停止脚本等待 10 分钟,让风控状态冷却。部分情况下需要手动打开浏览器访问搜狗微信搜索,完成一次验证,之后再用浏览器里的 Cookie 到脚本里。
5.2 结果列表里有链接,点进去却是 404
现象:解析出的link跳转后,resp.status_code是 404,或#js_content为空。
原因:微信文章被作者删除或设为仅自己可见。搜狗索引库更新滞后,仍然保留收录链接。
解决:在抓正文前先判断状态码,404 就直接跳过。不要反复重试,因为重试只会加剧风控。我一般会把这类链接记录到dead_links.log,后面统计“失效比例”用。如果你发现某个关键词下失效链接特别多,说明该领域文章下架频繁,考虑改用更短的时间窗口增量采集。
5.3 正文里的图片下载失败,返回 403
现象:用requests直接下载mmbiz.qpic.cn图片地址,返回 403。
原因:微信图片服务器做防盗链,校验Referer必须是mp.weixin.qq.com开头的 URL,而不仅仅是搜狗域名。
解决:下载图片时把Referer设为当前文章页 URL。代码如下:
def download_image(session, img_url, article_url): headers = { "Referer": article_url, "User-Agent": HEADERS["User-Agent"], } resp = session.get(img_url, headers=headers, timeout=10) resp.raise_for_status() return resp.content注意article_url必须是微信文章页地址。如果你只拿到搜狗跳转前的 link,图片下载也会失败。这再次说明,必须先解析出最终的文章 URL 再处理正文。
5.4 搜索结果页显示“暂无文章”,但浏览器能看到结果
现象:脚本请求fetch_search_page返回的 HTML 里没有任何li,但同一时间手动用浏览器打开同一个 URL,结果正常。
原因:反爬虫系统对无 Cookie 或低置信度请求返回“降级结果”,常见做法是去掉搜索列表,只留一个空壳页面。有时候是请求头里的 Accept 语言缺失导致的。
解决:将请求头补齐,特别是Accept-Language字段。并在 session 初始化时,先访问搜狗首页,再访问一次微信搜索主页https://weixin.sogou.com/weixin,等 Cookie 种好后再搜索。如果仍然为空,把page参数改为不带page的版本试一下,部分请求对page=1反而识别异常。
5.5 连续抓取半小时后,任何请求都触发验证码
现象:最开始还能正常返回,跑了很多页之后,session.get搜索页和首页都返回验证码,且手动用同一浏览器访问也要求验证。
原因:IP 被搜狗风控标记为“高风险”,短时间请求量超过阈值。这种情况往往不是单条代码能解决的。
解决:最直接的做法是停止脚本,等待 30 分钟以上;同时把请求间隔从 2 秒拉长到 6 到 8 秒,每抓完一页随机 sleep 2 到 5 秒。如果你有多个网络出口,可以轮换着用,但不要同时从同一出口开多个进程。还有一个很实用的习惯:在脚本里打一个运行时长日志,如果连续运行超过 20 分钟,自动暂停 5 分钟。这个策略能有效降低封禁概率。
6. 把爬到的文章接进知识库:持久化、去 HTML 和增量更新
到了这一步,你已经有一个能持续跑、不重不漏的爬虫框架。最后一章介绍怎么把文章整理成知识库能用的格式,并验证结果是否可靠。
6.1 转成 Markdown 或纯文本的净化流程
我习惯把正文存成 Markdown,因为后续要接知识库或向量化。用 BeautifulSoup 把#js_content里的标签转成 Markdown 有点复杂,只保留纯文本更省事。但纯文本会丢失标题层级和图片位置,所以我一般保留一个简单的自定义转换:图片用表示,标题标签映射为#。
def html_to_markdown(content_div): lines = [] for elem in content_div.descendants: if elem.name == "img": src = elem.get("src", "") lines.append(f"") elif elem.name in ("p", "section"): text = elem.get_text("", strip=True) if text: lines.append(text) elif elem.name and elem.name.startswith("h"): level = int(elem.name[1]) lines.append("#" * level + " " + elem.get_text(strip=True)) return "\n".join(lines)这个小函数只覆盖了常见标签,遇到代码块、引用等复杂结构会丢失,但对于大多数公众号推文已经够用。如果你需要高保真转换,建议直接用html2text库,它支持markdown输出。
6.2 用 SQLite 保存文章并支持按公众号、关键词查询
存储我首选 SQLite,不需要额外部署,字段设计也简单。一个最小表结构如下:
CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, article_key TEXT UNIQUE, title TEXT, account TEXT, article_url TEXT, content_md TEXT, keyword TEXT, crawled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );article_key就是前面article_key函数的返回值,设置 UNIQUE 约束可以在写入时直接防重。写入用INSERT OR IGNORE:
import sqlite3 conn = sqlite3.connect("wechat_articles.db") sql = """ INSERT OR IGNORE INTO articles (article_key, title, account, article_url, content_md, keyword) VALUES (?, ?, ?, ?, ?, ?) """ conn.execute(sql, (key, title, account, url, content_md, keyword)) conn.commit()这样每次跑批,同一篇文章只会被插入一次。后续你想按公众号筛选或按关键词分组,都可以直接用 SQL 完成。比如统计某个公众号被收录文章数量:
SELECT account, COUNT(*) FROM articles GROUP BY account ORDER BY COUNT(*) DESC;6.3 定时增量更新的写法与验证结果
增量更新的核心是:每次启动脚本,只抓“上次记录之后”更新的文章。搜狗搜索本身不提供时间过滤参数,但搜索结果里带日期文本,你可以解析date_text并过滤三天以内的。更简单的方式是只抓搜索结果第一页,因为最新文章通常排在最前面。我自己的定时任务是每小时跑一次,每个关键词只抓前 5 页,抓完立即入库。
验证结果时,我习惯跑一个“无新增”测试:连续跑两次同一关键词,第二次的新增文章数应该为 0。如果出现大量新增,说明去重键没生效,或者 URL 里缺少mid参数导致 key 变化。这个验证方法我很推荐,它能让你在提交任务给知识库前先发现脏数据。
坦白说,这个爬虫方案并非零维护。搜狗的反爬策略会不定期调整,今天能用的选择器明天可能失效。我的习惯是每周检查一次日志,如果连续三天没有新增,就去浏览器里看看页面结构是否变化。但只要你把请求节流、去重和断点续爬这三件事做扎实,这套脚本足够支撑个人或小团队的知识库数据需求。希望帮到你。
本文还有配套的精品资源,点击获取