MoreRSS

site iconKingname | 谢乾坤修改

精通数据抓取、爬虫。网易 - 字节 - 红杉中国。 微软 MVP。 出版了《Python爬虫开发 从入门到实战》。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Kingname | 谢乾坤的 RSS 预览

一日一技:写XPath也并不总是这么简单

2025-07-27 03:18:03


摄影:产品经理
烤乳鸽

初级爬虫工程师有时候又叫做XPath编写员,他们的工作非常简单也非常繁琐,就是拿到网页的HTML以后,写XPath。并且他们觉得使用模拟浏览器可以解决一切爬虫问题。

很多人都看不起这个工作,觉得写XPath没有任何技术含量,随便找个实习生就能做。这种看法大部分情况下是正确的,但偶尔也有例外,例如今天我要讲的这个Case,可能实习生还搞不定。

下面我们来看一下这个视频。

点击查看视频

在这个视频中,你首先点击Linkedin的信息流中,帖子右上角的三个点,想使用模拟浏览器点击Copy link to post链接,从而把帖子的链接复制到剪贴板。

但现在出现了一个问题,你无法看到这个弹出框对应的HTML代码。因为这个弹出框是在你点击了三个点以后动态生成的,它会动态修改HTML,从而出现这个下拉框。但当你想在开发中工具里面查看这个弹出框的源代码时,这个源代码就会自动消失,于是源代码就会变成没有弹出框的HTML。实际上,你在任何地方点一下鼠标左键——无论是网页内还是网页外,无论是浏览器还是系统桌面,只要在任何地方点击了鼠标左键,这个弹出框就会自动关闭。

那怎么写XPath呢?可能有人会想到使用关键字匹配,把XPath写成下面这样:

1
//*[text()="Copy link to post"]  # 你甚至不能确定这个链接对应的标签是不是<a>

但由于Linkedin的页面文本会根据你的浏览器语言而变化,因此换了一个国家,甚至换了浏览器语言设置,你的这个XPath就不能用了。

那遇到这种问题怎么解决呢?其实也不难,他不是一个技术性难题,而是一个经验性问题。当你知道某个工具,你马上就能解决问题。当你不知道某个工具,你做5年爬虫也搞不定这个问题。

今天我们来说一个简单方法。当然方法有很多,但我觉得这个方法是最简单的。很多人在使用模拟浏览器开发爬虫的时候,会先开个真实浏览器,然后通过真实浏览器获取各个XPath,再直接写代码。那么遇到这个问题就会抓瞎了。

其实,如果你直接在模拟浏览器中开发代码,你就会发现问题根本不是问题。

我们使用DrissionPage来演示。首先直接在终端启动Python交互环境,或者使用Jupyter启动一个浏览器窗口:

1
2
>>> from DrissionPage import ChromiumPage
>>> page = ChromiumPage()

命令执行以后,会自动打开一个新的浏览器。现在,你直接在这个浏览器上面手动登录浏览器,进入信息流页面。

现在,直接在新的浏览器中,打开开发者工具,定位到帖子右上角三个点对应的标签,如下图所示:

这三个点的idember47,所以,我们回到终端或者Jupyter里面,让DrissionPage来点击这三个点。这里非常重要,必须让DrissionPage来点击,不能手动操作。

1
>>> page.ele('x://button[@id="ember47"]').click()

此时,这个弹出框出现了。但这次跟之前不一样,你在开发者工具里面展开HTML的时候,弹出框不会消失!如下图所示。

这样一来,你就可以直接找到Copy link to post对应的HTML元素,并编写对应的XPath:

1
//h5[@class="feed-shared-control-menu__headline t-14 t-black t-bold"]

这个方案适用于任何弹出框。

一日一技:如何正确渲染大模型返回的Markdown?

2025-06-05 04:26:20


摄影:产品经理
简单做个家宴

我们经常让大模型返回Markdown格式的文本,然后通过Python的markdown库把文本渲染成HTML。

但不知道大家有没有发现,大模型返回的Markdown并不是标准的Markdown。特别是当返回的内容包含列表时,大模型返回的内容有问题。例如下面这段文本:

1
2
3
4
**关于这个问题,我有以下看法**
* 第一点
* 第二点
* 第三点

你粗看起来没有问题,但当你使用markdown模块去把它渲染成HTML时,你会发现渲染出来的结果不符合你的预期,如下图所示:

这是因为标准的Markdown对换行非常敏感,列表项与它上面的文本之间,必须有一个空行,才能正确解析,如下图所示:

不仅是空行,还有多级列表的缩进问题。标准Markdown的子列表项缩进应该是4个空格,但大模型返回的子列表缩进经常只有3个空格,这就导致解析依然有问题。如下图所示:

而且这个空行问题和缩进问题,我尝试过反复在Prompt里面强调,但大模型依然会我行我素,无论是国产大模型还是Claude或者Gemini 2.5 Pro这些最新大模型,都有这个问题。

我曾经一度被憋得没办法,让大模型给我返回JSON,我再写代码把JSON解析出来手动拼接成标准Markdown。

后来,我发现主要的问题还是Python的markdown库对格式要求太严格了,其实换一个更宽容的库就可以解决问题。于是我找到了mistune这个库。使用它,直接就解决了所有问题。如下图所示:

mistune的用法非常简单:

1
2
3
import mistune

html = mistune.html('一段markdown')

并且它天然支持数学公式、脚注等等高级语法。更多高级操作,可以查看它的官方文档

一日一技:Scrapy如何发起假请求?

2025-05-27 05:20:33


摄影:产品经理
韩国章肥虾。

在使用Scrapy的时候,我们可以通过在pipelines.py里面定义一些数据处理流程,让爬虫在爬到数据以后,先处理数据再储存。这本来是一个很好的功能,但容易被一些垃圾程序员拿来乱用。

我看到过一些Scrapy爬虫项目,它的代码是这样写的:

1
2
3
4
5
6
7
8
9
10
11
...

def start_requests(self):
yield scrapy.Request('https://baidu.com')

def parse(self, response):
import pymongo
handler = pymongo.MongoClient().xxdb.yycol
rows = handler.find()
for row in rows:
yield row

这种垃圾代码之所以会出现,是因为有一些垃圾程序员想偷懒,想复用Pipeline里面的代码,但又不想单独把它抽出来。于是他们没有皱褶的脑子一转,想到在Scrapy里面从数据库读取现成的数据,然后直接yield出来给Pipeline。但因为Scrapy必须在start_requests里面发起请求,不能直接yield数据,因此他们就想到先随便请求一个url,例如百度,等Scrapy的callback进入了parse方法以后,再去读取数据。

虽然请求百度,不用担心反爬问题,响应大概率也是HTTP 200,肯定能进入parse,但这样写代码怎么看怎么蠢。

有没有什么办法让代码看起来,即便蠢也蠢得高级一些呢?有,那就是发送假请求。让Scrapy看起来发起了HTTP请求,但实际上直接跳过。

方法非常简单,就是把URL写成:data:,,注意末尾这个英文逗号不能省略。

于是你的代码就会写成:

1
2
3
4
5
6
7
8
9
def start_requests(self):
yield scrapy.Request('data:,')

def parse(self, response):
import pymongo
handler = pymongo.MongoClient().xxdb.yycol
rows = handler.find()
for row in rows:
yield row

这样写以后,即使你没有外网访问权限也没问题,因为它不会真正发起请求,而是直接一晃而过,进入parse方法中。我把这种方法叫做发送假请求。

这个方法还有另外一个应用场景。看下面这个代码:

1
2
3
4
5
6
7
8
9
10
def start_requests(self):
while True:
yield scrapy.Request('https://kingname.info/atom.xml', callback=self.parse, dont_filter=True)
time.sleep(60)

def parse(self, response):
...对rss接口返回的数据进行处理...
for item in xxx['items']:
url = row['url']
yield scrapy.Request(url, callback=self.parse_detail)

假如你需要让爬虫每分钟监控一个URL,你可能会像上面这样写代码。但由于Scrapy是基于Twisted实现的异步并发,因此time.sleep这种同步阻塞等待会把爬虫卡住,导致在sleep的时候,parse里面发起的子请求全都会被卡住,于是爬虫的并发数基本上等于1.

可能有同学知道Scrapy支持asyncio,于是想这样写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
import asyncio


async def start_requests(self):
while True:
yield scrapy.Request('https://kingname.info/atom.xml', callback=self.parse, dont_filter=True)
asyncio.sleep(60)

def parse(self, response):
...对rss接口返回的数据进行处理...
for item in xxx['items']:
url = row['url']
yield scrapy.Request(url, callback=self.parse_detail)

但这样写会报错,如下图所示:

这个问题的原因就在于start_requests这个入口方法不能使用async来定义。他需要至少经过一次请求,进入任何一个callback以后,才能使用async来定义。

这种情况下,也可以使用假请求来解决问题。我们可以把代码改为:

1
2
3
4
5
6
7
8
9
10
def start_requests(self):
yield scrapy.Request('data:,', callback=self.make_really_req)

async def make_really_req(self, _):
while True:
yield scrapy.Request(url="https://kingname.com", callback=self.parse)
await asyncio.sleep(60)

def parse(self, response):
print(response.text)

这样一来,使用了asyncio.sleep,既能实现60秒请求一次,又不会阻塞子请求了。

当然,最新版的Scrapy已经废弃了start_requests方法,改为start方法了,这个方法天生就是async方法,可以直接在里面asyncio.sleep,也就不会再有上面的问题了。不过如果你使用的还是老版本的Scrapy,上面这个假请求的方法还是有点用处。

一日一技:如何正确解析超大JSON列表

2025-05-07 07:34:23


摄影:产品经理
回锅肉

当我们采购数据集时,有时候供应商会以JSON Lines的形式交付给我们。这种格式,本质上是文本格式,它每一行是一个JSON。例如,供应商给我们了一个文件小红书全量笔记.json文件,我们可以使用如下Python代码来一行一行读取:

1
2
3
4
5
6
import json
with open('小红书全量笔记.json') as f:
for line in f:
info = json.loads(line)
note = info['note']
print('笔记内容为:', note)

这个格式的好处在于,每一次只需要把少量内容读取到内存中。即便这个文件有1TB,我们也可以使用一个4GB内存的电脑来处理。

今天出了一个乌龙事件,某数据供应商在给我数据的时候,说的是以JSON Lines格式给我。但我拿过来解压缩以后一看,100GB的文件,里面只有1行,如下图所示:


也就是说,他用的是一个超大JSON直接导出给我,并没有使用JSON Lines格式。正常情况下,如果我要直接解析这个数据,需要我的电脑内存超过100GB。

这个大JSON大概格式是这样的:

1
[{"question": "xxx111", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, {"question": "xxx222", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, {"question": "xxx333", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, ...]

要解决这个问题,有三种方法。

如果这个JSON里面没有嵌套数据,只有一层key: value。那么非常简单。一个字符,一个字符读取。遇到}的时候,说明一条子JSON数据已经读取完成,解析以后再读取下一条子JSON。

如果这个JSON里面有嵌套结构,那么可以使用经典算法题里面的数括号算法来解决。当发现}的数量等于{的时候,说明一个子JSON已经读取完成,可以解析了。

今天我们来介绍第三种方法,使用一个第三方库,叫做ijson。它天然支持解析这种超大的JSON,并且代码非常简单:

1
2
3
4
5
6
7
8
9
import ijson

a = '''
[{"question": "xxx111", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, {"question": "xxx222", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}, {"question": "xxx333", "answer": "aaa", "crawled_time": "2025-05-01 12:13:14"}]
'''

items = ijson.items(a, 'item')
for item in items:
print(item)

运行效果如下图所示:

既不会占用大量内存,又能正常解析超大JSON。

一日一技:315晚会曝光的获客软件是什么原理

2025-03-16 07:44:29

今年315晚会曝光了几个获客软件,号称可以拦截任何人的网络浏览记录,并根据对方在直播软件的留言、打过的电话、浏览过的网址,获取对方的手机号和微信号。还有在地图上随便画一个圈,就能找到圈里面130万人的联系方式。

作为一个软件工程师,我来说说我对他们背后原理的猜测。

晚会里面笼统的说到他们使用了爬虫技术。其实这种说法并不准确。爬虫做不到这种程度。爬虫只能爬取到人眼能看到的各种公开数据。例如有人在直播软件下面回复了评论,爬虫能爬到评论人的用户昵称、评论的内容。但是因为评论人的真名、手机号码和微信号并没有显示在直播软件上,所以爬虫是不能爬到的。它后续还需要使用撞库、社工库、社会工程学等等一系列操作,才能定位到用户的手机号。

以它直播软件获客这个例子,我觉得它背后的原理是这样的:

  1. 获客公司有大量的爬虫,他会在各种社交网站上面爬取每个人公开的信息。例如微博、小红书、某些论坛等等。然后把这些信息储存在数据库中。也会记录他们的发帖、回帖。
  2. 收集各种社工库泄露出来的信息,也储存在数据库中。这些社工库里面可能包含了某些著名的社交网站。
  3. 根据用户需求,在某个特定的直播中,抓到其他用户的评论,发现这个评论显示用户对直播的产品有兴趣。
  4. 根据这个用户的用户名,去撞库。因为根据社会工程学的原理,很多人在多个不同的网站,会使用相同的用户名,因此通过用户名去撞库,能够把某人在不同社交网站上面的账号关联起来。
  5. 先看社工库里面,这个用户名对应的用户有没有联系方式,如果有,搞定
  6. 如果社工库没有联系方式,再去搜索这个人其他社交网络上面的发帖回帖记录,有很多人会在别人的帖子下面回复自己的手机号或者邮箱。(例如早期很多人在贴吧、在58同城、在某些招聘论坛的帖子下面,都会发布自己的联系方式)
  7. 某些国产手机的系统里面,会内置广告联盟的SDK,这些SDK会监控手机屏幕上面的各种操作,甚至截屏上传。这些SDK厂商也会出售获得的用户信息。

再说说它在地图上随便画一个圈,就能找到联系方式这个能力。我怀疑它是使用了WIFI探针加上商场的WIFI。

如果我今天刚刚买了一个新的手机卡,把它插在手机上,我不太相信他们能够随便画一个圈,就把我的新手机号获取到了。肯定有一个地方会泄露手机号。那么泄露途径可能有如下几个:

  1. 快递订单。他们通过各种渠道,获取到快递订单。订单上面有地址和手机号。这样简单直接把地址和手机号建立了联系。
  2. WIFI探针+商场WIFI。很多商场为了定位客流量,都会安装WIFI探针。当我们拿着手机在商场走的时候,即便我们没有连接商场的WIFI,他们也能拿到我的手机无线网卡的mac地址。但这个时候它还没有办法拿到我的手机号。它只能知道有一个人,此刻站在第几层哪个门店前面。但由于提供这种客流定位系统的公司,一般都是那几家大公司,因此他们此时已经收集到了大量的手机无线网卡mac地址。如果某一天,我在某个商场正好连了他们的WIFI,一般连这种公共WIFI都需要输入手机号的,这个时候我的手机号就跟mac地址绑定了。以后即使我走到了另一个城市另一个商场,即使我没有连WIFI,只要这个WIFI探针的供应商或者客流定位系统是同一个公司,那么他们立刻就能知道这个手机号现在到这里了。
  3. 有了手机号,结合社工库,各种信息也都能获取到。

再说一说根据网站访问记录获取手机号。这个我只能说是运营商信息泄露了。2017年,我在北京某公司工作的时候,就拿到过这种运营商数据。不过当时这种数据是脱敏过的。用户信息是md5值,只能根据不同的md5值判断这些请求是不同人的设备发送的,但无法知道具体是谁。这种情况是合法的,本来就有这种公开运营商数据买卖。市面上很多做尽职调查的公司都会采购。提供这种运营商数据的公司,他们会在运营商的机房里面安装记录设备,记录详细信息,然后经过脱敏以后卖给下游公司。

但说不定他们自己也会把没有脱敏的数据经过特殊渠道卖出去,于是就有了今年晚会上的这种功能。

有同学可能会担心这种运营商数据,是不是会把自己访问的每一个URL都记录下来?其实大可不必担心,我们要相信HTTPS。对于使用了HTTPS的网站,运营商那边拿到的数据只能定位到你访问的域名,但无法知道具体的网址。例如你访问了https://xxx.com/aa/bb/cc,运营商记录只能拿到https://xxx.com。无法拿到后面的具体地址。除非他们在你的手机上安装了根证书。所以不要安装来历不明的证书,是保证数据安全的重要前提。

实际上不仅是运营商数据会被出售,银行卡、信用卡、POS机数据也会被出售。有一些做尽职调查的公司,如果要调查某教育机构的学生报名情况,他们会从刷卡数据中筛选出支付给这个教育机构的费用,这样就能算出机构的课程报名情况了。

从上面的分析可以看出,其实要获取一个人的个人信息,爬虫在里面发挥的作用其实是最无足轻重的。随便一个数据的泄露,产生的影响远远超过爬虫。

以上技术方法都是我个人的猜测。都是基于著名的直播软件不可能主动买用户手机号这个前提来做的猜测。

一日一技:我的Cursor开发经验

2025-03-14 05:41:41

这两天我使用Cursor开发了一个新闻网站的前端+后端。在开发的过程中,我总结了一些适合于我自己的最佳实践。这些方法让我在使用Cursor的时候,几乎没有遇到任何阻碍,非常顺利,非常流畅地完成了网站的开发。

我的开发经验,总结起来一句话就能说清楚:多写文档少聊天。下面我来详细说一下具体方法。

我全程使用Cursor的agent模式,模型使用Claude 3.7 Sonnet。这个项目是一个新闻网站,需要写前端+后端。

前端我首先使用Trickle生成了页面。大家也可以使用Bolt.new或者lovable,效果都差不多。需要和后端交互的地方都先使用假数据模拟。生成好以后,把代码下载到本地。

改写前端代码

使用Cursor打开下载的前端代码,让它阅读代码,并使用Next.js + tailwind css + shadcn/ui改写代码。并特别提醒,新版本的shadcn/ui对应的命令应该是npx shadcn xxx,让他不要再使用老版本的写法。

改写完成以后,执行npm run dev预览前端页面,确保改写以后的效果跟你之前的一样。

创建临时API文档

由于前端页面本来就是你设计的,因此你肯定很清楚这个前端页面在哪些地方需要跟后端做交互。

现在,在代码根目录创建一个markdown文件,例如叫做api_desc.md,然后在里面描述你的后端API。这里描述不需要写得很细节,关键是要写清楚api的功能,路径,参数和返回示例。如下图所示。

由于新闻列表页和详情页需要从MongoDB里面读取,因此再创建一个article_schema.md,在里面描述数据在MongoDB中的储存结构。如下图所示:

接下来,在Cursor的聊天窗口中,新开一个后端代码专用的对话,让它创建一个后端服务:

1
请使用FastAPI帮我创建一个后端服务。这个服务包含多个API接口,接口的需求请参考api_desc.md。列表页的数据和详情页的数据,需要从MongoDB读取,数据结构参考article_schema.md。后端代码写好以后,需要生成一个api_doc.md文件,里面详细描述API的请求地址、参数和返回值格式。以后每次对后端API做修改,都需要同步更新api_doc.md

Cursor执行完成以后,不仅生成了后端代码,还生成了API文档,文档如下图所示。

这个API文档可以说是相当的标准。

前后端对接

回到前端的对话历史中,让Cursor对接后端:

1
现在,我们要开始对接后端API。后端的接口我已经写到了api_doc.md文件中,请仔细阅读,然后修改前端代码的对应位置,把原来的假数据代码改为请求后端API获得正式数据。

Cursor修改以后,第一个前后端联动的版本就已经完成了。正常情况下应该已经可以使用了。如果遇到一些小的报错,你可以通过对话的形式让它修复。

进一步开发

接下来,你可能需要在已有的API上面修改字段,或者新增API接口。这种情况下一般都是先让Cursor修改后端代码。它修改以后,会自动更新api_doc.md文件。然后再让前端代码基于api_doc.md适配后端的修改。

如果你的修改是小修改,例如在列表页API中添加一个tags参数,用来根据tags过滤新闻,那么你可以直接通过聊天对话的形式跟Cursor沟通,让它完成。

如果你的修改是一个相当完整独立的新功能,那么你可以新增一个需求文档,例如要做一个新闻订阅页面,这个时候就可以创建subscribe_api.md文件,在里面描述自己需要的API,从而先生成后端代码和API文档,再创建一个subscribe.md,在里面描述前端页面的需求,并让Cursor基于这个需求加上API文档,生成前端代码。

总结

在使用Cursor的时候,我更倾向于人机协作开发而不是让你当甩手掌柜。你需要给Cursor提供必要的指导,从而让它顺着你的思路来做开发。你的脑子里面要有这个系统的开发路线和架构,你需要知道系统由哪些部分组成,每个部分需要怎么做。软件开发是系统设计+编码。让Cursor去做编码工作,而不是去做设计工作。

不要相信网上那些完全不懂代码的人纯靠文本描述就做出复杂功能的说辞,要不就是他们嘴里的复杂功能其实是简单功能,要不就是他在吹牛。

你应该多写文档,通过文档来描述你的需求。这样Cursor以后的每次修改都会注意不违背你的需求文档。聊天窗口一般是告诉Cursor应该使用哪个文档来进行开发。尽量不要在聊天窗口里面提需求。