普通视图

发现新文章,点击刷新页面。
昨天以前谢乾坤|青南

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

作者 青南
2025年7月27日 03:18

初级爬虫工程师有时候又叫做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"]

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

一日一技:如何使用Cursor学习开源项目

作者 青南
2025年1月30日 07:09

大家肯定经常在微信公众号里面看到类似于《30秒使用Cursor开发xxx》这种文章。典型的标题党装逼货,大家当个笑话看就行了。

Cursor目前还没有强到真的让一个完全不懂代码的人轻轻松松开发一个有用的软件,但Cursor确实可以让懂代码的人如虎添翼。正好最近有不少同学在群里面问我,如何正确使用Cursor:

那么今天我就来讲讲我使用Cursor的一个场景:快速理解开源项目的核心逻辑。

Cline为例,这是一个VSCode插件,能够让VSCode实现Cursor的功能,配合DeepSeek最新模型,有人声称可以完美平替Cursor。那么,如果我完全看懂了Cline的原理,也就相当于看懂了Cursor的实现原理了。那么我们来看看如何使用Cursor辅助我学习Cline的源代码。

首先把Cline的代码clone到本地,然后用Cursor打开。如下图所示:

这个时候,如果是完全不懂代码的人,肯定一上来就让Cursor解释这个项目的原理。但这个插件的代码量还是挺大的,完全没有重点的让Cursor来解释,只会得到一个大而空的整体解释,对你的学习没有任何帮助。

我们作为工程师,在提问之前,一定要对我们想问的东西有一个初步的了解,否则没有办法提出有用的问题。要初步了解一个程序项目,第一步肯定是看一下这个项目的文件结构,通过它的文件结构,应该能够知道它每个文件夹里面的代码大概是什么功能。这样一来可以直接略过不太重要的部分。例如这个项目是一个VSCode插件,那么里面肯定有一部分代码是为了让他能被VSCode识别和调用。这种代码我们完全不需要关心。我们只需要关心它怎么让AI生成代码,怎么自动修改代码就可以了。

这就像是在拿到一本新书的时候,我一般会先看书的目录,知道这本书的整体结构,然后再带着问题来读书。

浏览一下这个项目文件结构,可以看到,AI生成代码的相关逻辑,应该在src/core文件夹里面。其中src/core/prompts里面是相关的提示词,src/core/assistant-message里面是解析大模型返回的XML并实现自动化操作的逻辑。

Cline的功能跟Cursor很像,能自动执行命令,能自动生成文件,能修改已经有的文件。

以Cline自动修改已有文件这个功能为例。假设我们自己的程序已经有不少代码了,现在我在安装了Cline的VSCode中,让AI帮我给这个项目增加一些功能。它的流程肯定是这样的:

  1. 读取已经有的代码
  2. 构造出一段Prompt,里面包含已经有的代码以及我们的新需求,调用大模型
  3. 大模型返回一段内容,Cline解析这段内容,根据里面的提示,修改对应的文件中的对应的部分。

现在,我就想学习一下,大模型返回的内容长什么样?Cline是怎么解析这段内容,并让他变成对文件的操作的?

所以,我首先在Cursor中提出第一个问题:

1
2
@folder src/core/assistant-message
这是cline这个自动化编程copilot在收到大模型返回的信息以后,对信息进行处理的逻辑。请阅读它的代码并告诉我它的解析逻辑。

如下图所示

从它返回的内容,我们可以知道,大模型返回给Cline的内容是XML格式,Cline解析这个XML,从而进一步执行具体的操作。在它返回的内容中,支持的操作包含下面这一段内容:

我最关心的就是replace_in_file这个功能是怎么实现的,所以我进一步提问:

1
详细解释一下replace_in_file的具体逻辑和流程

返回的部分内容如下:

这段内容比较长,我总结一下它返回的重点:

  1. 显示了大模型返回的内容格式
  2. 代码里面如何解析大模型返回的内容
  3. 如何修改代码

它解释得已经比较清楚了,但由于Cline是使用JavaScript语法写的,有些同学可能对JS没有Python熟悉,所以,我们让大模型再做一步翻译,把核心代码改写成Python,并且创建一个Demo来运行这段Python代码:

1
2
3
4
5
6
7
现在,为了便于我的理解,请帮我实现一个replace_in_file 的Python版本。请在项目根目录创建一个example文件夹。这个文件夹里面有4个文件,分别为:

1. example_llm_response.txt:假设一段从大模型返回的内容
2. example_old.py:一段需要被修改的代码
3. replacer.py: Python版本的replace_in_file

当我运行replacer.py以后,它应该能够根据example_llm_response.txt中的内容,修改example_old.py,然后生成example_new.py

如下图所示:

我们可以先看一下它生成的example_llm_response.txt,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
我会帮你修复calculate_multiply函数中的bug。

<replace_in_file>
<diff>
<<<<<<< SEARCH
def calculate_multiply(a, b):
# 这是一个有bug的乘法函数
return a + b # 这里错误地使用了加法
=======
def calculate_multiply(a, b):
# 修复后的乘法函数
return a * b # 修正为正确的乘法运算
>>>>>>> REPLACE
</diff>
</replace_in_file>

现在乘法函数已经修复了,它会返回正确的结果。

需要被修改的,有问题的example_old.py如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def calculate_sum(a, b):
# 计算两个数的和
return a + b

def calculate_multiply(a, b):
# 这是一个有bug的乘法函数
return a + b # 这里错误地使用了加法

def greet(name):
# 打招呼函数
print("Hello " + name)

if __name__ == "__main__":
result = calculate_multiply(3, 4)
print(f"3 x 4 = {result}") # 这里会输出错误结果

直接运行,会看到最后输出的结果是错误的:

现在运行replacer.py,会自动生成example_new.py,内容如下:

可以看到,输出的结果已经正确了。虽然新代码最后一行的注释还有问题,但毕竟这个返回的内容是模拟的,所以可以理解。

现在,我们直接阅读replacer.py文件,就可以用Python的语法来理解Cline的逻辑了。生成的代码不依赖任何第三方库,因此理论上可以在任何能够运行Python的环境运行。大家还可以把自己的一些想法直接改到代码上,来测试它的运行效果。

生成的代码这里是使用正则表达式来提取XML。在正式项目中,肯定需要使用专门的XML解析模块来解析。不过这个Demo使用正则表达式反而帮我们能更好理解代码。

完整的代码我就不贴上来了,有Cursor的同学可以使用Cursor试一试。没有Cursor的同学可以使用Cline + DeepSeek来试一试,得到的结果应该跟我这个是一样的。

再附上我使用Cusror解析Bolt.new的代码结构,并通过Mermaid语法生成的时序图:

总结

Cursor不仅可以写代码,还能帮我们学习代码。大家在提问时,一定要针对某个功能精确提问,只有你的问题越具体,它返回的内容才会越具体。

一日一技:如何使用大模型提取结构化数据

作者 青南
2025年1月21日 04:52

经常有同学在微信群里面咨询,如何使用大模型从非结构化的信息里面提取出结构化的内容。最常见的就是从网页源代码或者长报告中提取各种字段和数据。

最直接,最常规的方法,肯定就是直接写Prompt,然后把非结构化的长文本放到Prompt里面,类似于下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from zhipuai import ZhipuAI
client = ZhipuAI(api_key="") # 填写您自己的APIKey
response = client.chat.completions.create(
model="glm-4-air-0111",
messages=[
{"role": "system", "content": '''你是一个数据提取专家,非常善于从
从长文本中,提取结构化的数据。
'''},
{"role": "user", "content": '''你需要从下面的文本中,提取出姓名,工资,地址,然后以JSON格式返回。返回字段示例:{"name": "xxx", "salary": "yyy", "address": "zzz"}.只需要返回JSON字符串就可以了,不要解释,不要返回无关的内容。

"""
长文本
"""
'''}
],
)
print(response.choices[0].message)

如果你每次只需要提取一两个数据,用这种方式确实没什么问题。不过正如我之前一篇文章《一日一技:超简单方法显著提高大模型答案质量》中所说,返回的JSON不一定是标准格式,你需要通过多种方式来强迫大模型以标准JSON返回。并且要使用一些Prompt技巧,来让大模型返回你需要的字段,不要随意乱编字段名。

当你需要提取的数据非常多时,使用上面这种方法就非常麻烦了。例如我们打开某个二手房网站,它上面某个楼盘的信息如下图所示:

一方面是因为字段比较多,你使用纯文本的Prompt并不好描述字段。另一方面是HTML原文很长,这种情况基于纯Prompt的提取,字段名会不稳定,例如占地面积,有时候它给你返回floor_area有时候返回floorArea有时候又是其他词。但如果你直接在Prompt给出一个字段示例,例如:

1
2
3
4
5
6
7
8
9
……上面是一大堆描述……

返回的字段必须按如下示例返回:

{
"floor_area": 100,
"building_area": 899
...
}

有时候你会发现,对于多个不同的楼盘,大模型返回给里的floor_area的值都是100,因为它直接把你的例子中的示例数据给返回了。

如果你只是写个Demo,你可能会觉得大模型真是天然适合做结构化数据的提取,又方便又准确。但当你真的尝试过几百次,几千次不同文本中的结构化数据提取后,你会发现里面太多的坑。

好在,Python有一个专门的第三方库,用来从非结构化的数据中提取结构化的信息,并且已经经过了深度的优化,大量常见的坑都已经被解决掉了。配合Python专门的结构化数据校验模块Pydantic,能够让提取出来的数据直接以类的形式储存,方便后续的使用。

这个模块叫做Instructor。使用这个模块,我们只需要先在Pydantic中定义好结果的数据结构,就能从长文本中提取数据。并且代码非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import instructor
from pydantic import BaseModel
from openai import OpenAI

# Define your desired output structure
class ExtractUser(BaseModel):
name: str
age: int

# Patch the OpenAI client
client = instructor.from_openai(OpenAI())

# Extract structured data from natural language
res = client.chat.completions.create(
model="gpt-4o-mini",
response_model=ExtractUser,
messages=[{"role": "user", "content": "John Doe is 30 years old."}],
)

assert res.name == "John Doe"
assert res.age == 30

当然,正如我前面说的,一个小小的Demo能够完美运行并不能说明任何问题,我们要使用更多的实际例子来进行测试。假设我们的场景就是爬虫解析HTML,从上面的二手房网站提取房屋信息。

考虑到大部分情况下,HTML都非常长,即便我们提前对HTML代码做了精简,移除了<style><script>等等标签,剩余的内容都会消耗大量的Token。因此我们需要选择一个支持长上下文,同时价格又相对便宜的大模型来进行提取。

正好智谱最近升级了GLM-4-Air系列大模型,最新的GLM-4-Air-0111模型,Token费用直接减半,每1000 Token只需要0.0005 元,每100万Token只需要5毛钱。而模型的智力跟旗舰模型GLM-4-Plus相差不大,因此非常适合用来做数据提取的任务。

Instructor本身不直接支持智谱的模型,因此需要使用它提供的LiteLLM配合智谱的OpenAI兼容接口来实现对接。

首先使用pip命令安装支持LiteLLM的Instructor:

1
pip install 'instructor[litellm]'

然后通过下面这样的代码就可以借助LiteLLM来链接智谱大模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import instructor
from litellm import completion
client = instructor.from_litellm(completion)
resp = client.chat.completions.create(
model="openai/glm-4-air-0111",
api_key="对应的API Key",
api_base="https://open.bigmodel.cn/api/paas/v4/",
max_tokens=1024,
messages=[
{
"role": "user",
"content": html,
}
],
response_model=HouseInfo,
)

其中的HouseInfo定义的类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pydantic import BaseModel, Field

class HouseInfo(BaseModel):
floor_area: int = Field(description="占地面积")
building_area: int = Field(description="建筑面积")
plot_ratio: int = Field(description="容积率")
greening_rate: int = Field(description="绿化率")
total_buildings: int = Field(description="楼栋总数")
total_households: int = Field(description="总户数")
property_management_company: str = Field(description="物业公司")
property_management_fee: str = Field(description="物业费")
property_management_fee_description: str = Field(description="物业费描述")
parking_spaces: str = Field(description="停车位")
parking_space_description: str = Field(description="停车位描述")
floor_status: str = Field(description="楼层状况")

这就是一个标准的Pydantic类,定义了字段的名字,类型和意义。在调用Instructor时,传入这个类,传入精简以后的网页源代码,就能直接从网页中提取出对应的字段了。完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import instructor
from litellm import completion
from pydantic import BaseModel, Field

class HouseInfo(BaseModel):
floor_area: int = Field(description="占地面积")
building_area: int = Field(description="建筑面积")
plot_ratio: int = Field(description="容积率")
greening_rate: int = Field(description="绿化率")
total_buildings: int = Field(description="楼栋总数")
total_households: int = Field(description="总户数")
property_management_company: str = Field(description="物业公司")
property_management_fee: str = Field(description="物业费")
property_management_fee_description: str = Field(description="物业费描述")
parking_spaces: str = Field(description="停车位")
parking_space_description: str = Field(description="停车位描述")
floor_status: str = Field(description="楼层状况")

client = instructor.from_litellm(completion)

html = '''
精简以后的HTML代码
'''

resp = client.chat.completions.create(
model="openai/glm-4-air-0111",
api_key="你的API Key",
api_base="https://open.bigmodel.cn/api/paas/v4/",
max_tokens=1024,
messages=[
{
"role": "user",
"content": html,
}
],
response_model=HouseInfo,
)

print(resp.model_dump_json(indent=2))
print(f'提取到的占地面积是:{resp.floor_area}')

运行情况如下图所示:

得到的resp就是一个Pydantic对象,可以直接使用resp.floor_area来查看每个字段,也可以使用resp.model_dump_json转成JSON字符串。

Pydantic还可以指定一些字段是可选字段,一些字段是必选字段,也可以自动做类型转换,这些语法都可以在Instructor的Tips中看到。

总结一下,使用Instructor,配合智谱GLM-4-Air-0111模型,可以大大提高结构化信息的提取效率。

一日一技:如何正确对Python第三方库做二次开发

作者 青南
2024年12月24日 07:16

今天,有同学在知识星球上给我提了一个问题:如何在Simplemind中接入Azure的GPT接口。如下图所示。

在使用Python时经常会出现这样的情况,某一个第三方库,满足我们99%的需求,但碰巧有一个小需求不满足。遇到这种情况,有些同学会忍痛割爱,换一个库;还有一些同学,会继续使用这个第三方库,但是缺的那个功能,他就完全自己单独写;剩下的同学,可能是把这个第三方库下载下来,放到自己项目的根目录中,然后当做项目的一部分来修改并导入使用。今天我们就来讲一下这个问题。

前两个方法不需要多说什么。第三个方法从功能上来说没什么问题,但会给自己的项目引入大量其他代码,导致项目在做安全性检查、静态类型检查、Code Review时变得很麻烦。而且这个第三方库必须放到项目的根目录,否则在导入时,它的导入语句就跟正常pip安装的导入语句不一样,以后如果官方库支持了这个缺失的功能,你得改很多个导入语句,才能再换回来,无形中引入了很多的不确定性和隐患。

我们今天想实现的功能是,调用这个二次开发的第三方库时,我自己的代码不需要做任何修改,甚至包括环境变量也不需要修改,直接像是调用任何pip安装的第三方库一样使用。

实际上,在pip设计的时候,就已经预料到了这种情况。所以pip install有一个-e参数,可以用来指定某个特定文件夹里面的代码为一个可编辑的第三方库。对这个文件夹里面的所有修改会立刻生效,同时对于使用这个第三方库的代码来说,它不需要做任何修改,就像是在用正常的第三方库一样。它原本是用来方便在开发者自己写第三方库时,测试功能调用的,现在我们对现有的第三方库做二次开发,正好也可以使用它。

就以知识星球上面这个问题为例,来说明如何对Simplemind进行二次开发。Simplemind目前支持的大模型如下图所示:

其中的openai.py代码如下,可以看到它初始化OpenAI连接对象时,只使用了api_key参数。因此Simplemind目前只支持OpenAI官方的GPT模型,无法使用Azure提供的GPT模型。

要使用Azure的GPT连接对象,我们需要使用如下的代码:

1
2
from openai.lib.azure import AzureOpenAI
client = AzureOpenAI(api_key=..., azure_endpoint=..., api_version=...)

因为Azure的GPT和OpenAI的GPT除了初始化的参数不同,其他调用上的代码完全相同,因此我们可以继承openai.py中的这个OpenAI类,然后自己只需要复写def client这个属性(注意,这里使用了@cached_property,所以它不是方法,而是属性),就可以让Simplemind支持Azure的GPT了。

来看看具体的实现方法。从Github上面克隆Simplemind的代码到本地,然后把它安装成可编辑的第三方库:

1
2
3
git clone git@github.com:kennethreitz/simplemind.git
cd simplemind
pip install -e .

这三行代码就够了,这个时候,你在PyCharm中输入import simplemind,会发现可以正常导入。如果你有OpenAI官方的API,那么你可以直接使用Simplemind文档中的代码,立刻测试,会发现它和pip安装的没有任何区别。

现在,我们打开刚刚克隆下来的simplemind/simplemind/providers文件夹,创建一个azure_openai.py文件。里面的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from .openai import OpenAI
import os
from functools import cached_property

class AzureOpenAI(OpenAI):
NAME = 'azure_openai'
def __init__(self, api_key: str | None = None):
super().__init__(api_key=api_key)
self.api_key = os.getenv('OPENAI_API_KEY')
self.azure_endpoint='你的AzureGPT的url'
self.api_version = '2024-07-01-preview'


@cached_property
def client(self):
"""The raw OpenAI client."""
if not self.api_key:
raise ValueError("OpenAI API key is required")
try:
from openai.lib.azure import AzureOpenAI
except ImportError as exc:
raise ImportError(
"Please install the `openai` package: `pip install openai`"
) from exc
return AzureOpenAI(api_key=self.api_key, azure_endpoint=self.azure_endpoint, api_version=self.api_version)

如下图所示。

然后编辑这个文件夹里面的__init__.py文件,在其中添加上刚创建的这个新类,如下图所示。

改好了,以上就是全部的修改。现在开始编写调用代码,跟官方文档中的示例完全一样:

1
2
3
4
5
6
7
8
import simplemind as sm
from dotenv import load_dotenv


load_dotenv()

resp = sm.generate_text(prompt='太阳为什么是圆的?', llm_model='gpt-4o-mini', llm_provider='azure_openai')
print(resp)

运行效果如下图所示,成功接上了Azure的GPT。

再来测试一下文档里面的记忆功能和工具调用,也全部正常运行:

国产大模型基本都支持直接使用openai库调用,因此理论上使用这个方法,稍作修改,可以接入任意国产大模型。如果你改成使用LiteLLM,甚至可以实现支持任意大模型。

一日一技:为什么我很讨厌LangChain

作者 青南
2024年12月15日 05:29

一说到RAG或者Agent,很多人就会想到LangChan或者LlamaIndex,他们似乎觉得这两个东西是大模型应用开发的标配。

但对我来说,我特别讨厌这两个东西。因为这两个东西就是过度封装的典型代表。特别是里面大量使用依赖注入,让人使用起来非常难受。

什么是依赖注入

假设我们要在Python里面模拟出各种动物的声音,那么使用依赖注入可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def make_sound(animal):
sound = animal.bark()
print(f'这个动物在{sound}')


class Duck:
def bark(self):
return '嘎嘎叫'


class Dog:
def bark(self):
return '汪汪叫'


class Cat:
def bark(self):
return '喵喵叫'


small_cat = Cat()
make_sound(small_cat)

对于make_sound函数,你不需要知道animal这个对象的bark方法具体是怎么实现的,你只需要调用它并获取它的返回值就可以使用了。

当你要添加一个新的动物时,你只需要实现一个类,这个类里面有一个方法叫做bark。那么,当这个动物需要发出声音时,把这个动物实例传入给make_sound函数就可以了。

看起来很方便是吧?不同的动物类互不影响,屏蔽了细节。

为什么我讨厌依赖注入

上面这段代码,看起来很好,符合设计模式。如果这段代码是你自己写的,确实很方便。但如果这段代码是别人写的,并且你不知道它的细节,那么这些依赖注入就是灾难。我们来看看LlamaIndex文档里面给出的代码:

这段代码是一个简化版的RAG。把文本文件向量化并存入向量数据库。用户输入问题以后,程序自动去向量数据库查询数据。看起来代码非常简洁对吧?文本转向量的逻辑隐藏起来了,读写向量数据库的逻辑隐藏起来了。开发者不需要关心这些不重要的细节,只需要修改data文件夹里面的文档就能索引原始文档。修改query_engine.query的参数,就可以实现一个RAG。开发者把注意力放在了真正重要的地方,节约了时间,提高了效率。真是太完美了!

完美个屁!

上面这种狗屎代码,也就只能用来做个Demo。当开发者真正需要做二次开发的时候,上面的代码根本就不能用。

为什么不能用?因为我不知道query_engine.query背后是怎么查询index的。我也不知道VectorStoreIndex在索引文档时,具体是怎么操作的。LlamaIndex似乎还沾沾自喜地在这个文档下面,预设了用户可能会问的几个问题:

它觉得用户要把文档拆分成不同的段落时,可以使用SentenceSplitter。下面还有如何使用其他的向量数据库、查询更多文档、使用不同的大模型、使用流式返回……

看起来想得很周到对吧,它觉得用户能想到的需求,它都已经通过不同的类、不同的方法、不同的参数想到了。狗屎!

它根本不可能穷举用户所有的需求。例如:

  1. 我希望程序从向量数据库查询到多个chunk以后,执行一段我自己的逻辑来过滤掉显然有问题的问题,然后再进行ReRank
  2. 从向量数据库查询数据以后,我需要自己插入几条固定的chunk。然后再给大模型问答

这些需求,它根本想不到!而我作为开发者,我需要。但是我应该怎么插入到它的流程里面?

上图中,SentenceSplitter的实例作为参数传给了VectorStoreIndex.from_documents。那么如果我对拆分文档的逻辑有一些自己的要求,我怎么加进去?我自己写一个MyCustomSentenceSplitter?现在问题来了,这个类有哪些方法应该怎么写?from_documents里面调用的是哪个方法?上面make_sound之所以看起来很简洁,是因为这个代码是我自己写的,我知道它会调用animal.bark。但现在LlamaIndex是别人写的,我甚至都不知道它里面会怎么使用SentenceSplitter。难道为了实现一个非常简单的文档分Token的逻辑,我还必须去翻阅它的语法文档甚至看它的源代码?那基本上要实现一个我想要的代码,我得把它整个文档先全部看完,源代码也看完,我才能开工。

LangChain和LlamaIndex使用大量的依赖注入,给开发者画了一个框,它内部控制了所有的流程。开发者不知道这个流程,开发者只能做完形填空,把代码缺的地方填写进去,就能有一个将将可以工作的程序出来。

但作为开发者,我需要的是控制这个流程,而不是去填空。

有人可能会说,那你可以去看LlamaIndex的源代码,看它内部是怎么查询向量数据库的,然后你自己写个类,把你自己的代码写进去啊。

如果有人这样想,我觉得你就是被人虐待了还在想是不是自己躺好一点让别人打你的时候没有那么累。

我想要的是什么

在使用做大模型应用开发时,我需要的是控制程序的流程。我需要简化的地方,是流程中的每个节点的调用方式,而不是简化这个流程。流程是我控制的,该不该简化,我自己知道!

来看看Requests作者Kenneth Reitz的新作品:SimpleMind。这是我认为符合AI for Human的项目。Kenneth真正知道使用这个库的人需要什么。我们来看看SimpleMind的使用方法:

基本使用

1
2
3
4
5
6
7
8

# 首先通过环境变量设置大模型的参数

import simplemind as sm

conv = sm.create_conversation()
conv.add_message("user", "Hi there, how are you?")
resp = conv.send()

上下文记忆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SimpleMemoryPlugin(sm.BasePlugin):
def __init__(self):
self.memories = [
"the earth has fictionally beeen destroyed.",
"the moon is made of cheese.",
]

def yield_memories(self):
return (m for m in self.memories)

def pre_send_hook(self, conversation: sm.Conversation):
for m in self.yield_memories():
conversation.add_message(role="system", text=m)


conversation = sm.create_conversation()
conversation.add_plugin(SimpleMemoryPlugin())


conversation.add_message(
role="user",
text="Please write a poem about the moon",
)

工具调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def get_weather(
location: Annotated[
str, Field(description="The city and state, e.g. San Francisco, CA")
],
unit: Annotated[
Literal["celcius", "fahrenheit"],
Field(
description="The unit of temperature, either 'celsius' or 'fahrenheit'"
),
] = "celcius",
):
"""
Get the current weather in a given location
"""
return f"42 {unit}"

# Add your function as a tool
conversation = sm.create_conversation()
conversation.add_message("user", "What's the weather in San Francisco?")
response = conversation.send(tools=[get_weather])

控制流程

SimpleMind简化了我调用大模型这个节点。那么如果我就能自己来控制程序的逻辑了。还是以RAG为例,我希望在简化了节点以后,代码是这样的:

1
2
3
4
5
6
7
def rag_ask(question):
question_embedding = text2embedding(question)
chunks = query_vector_db(question_embedding)
clean_chunks = my_logic_to_clean_chunks(chunks)
sorted_chunks = rerank(clean_chunks)
prompt = '使用sorted_chunks和question构造出rag的prompt'
answer = ask_llm(prompt)

其中,text2embedding/query_vector_db/rerank/ask_llm这几个函数,我能够使用简单的几行代码就实现,我可以在这个流程里面的任意两个节点之间,随意添加我自己的逻辑。这才是我想要的。

总结

实话实说,看到LangChain的使用方法,我就觉得这东西是一群写Java或者写C#的人,强行来写Python搞出来的缝合怪,整个代码我看不到Python的任何编码哲学,我能看到的只有过度封装,为了抽象而抽象。LangChain的作者,根本就没有站在Python开发者的角度制定它的使用方法。

一日一技:使用大模型实现全自动爬虫(一)

作者 青南
2024年10月17日 16:33

在文章一日一技:图文结合,大模型自动抓取列表页中,我提到可以使用大模型实现一个全自动爬虫。只需要输入起始URL加上需求,就可以借助模拟浏览器自动完成所有的抓取任务。

在实现的过程中,我发现涉及到的知识点可能一篇文章讲不完,因此拆分成了多篇文章。

爬虫演示

今天是第一部分,我们暂时不依赖模拟浏览器,而是使用httpx(你也可以使用requests)实现全自动爬虫,传入我博客文章列表页,爬虫会自动抓取前三页所有博客文章的标题、正文、作者、发布时间。

爬取结果如下图所示:

运行过程如下图所示:


爬虫首先会进入起始列表页,抓取上面的所有文章。然后进入列表页第二页,再抓取所有文章,最后进入第三页,再抓取所有文章。整个过程都是全自动的。不需要写任何XPath,也不需要告诉爬虫哪里是翻页按钮,文章的标题在哪里,发布时间在哪里,正文在哪里。

模块拆解

代码我已经放到Github:AutoCrawler。由于最近智谱又免费送了1亿的Token,所以还是使用他们最新的基座大模型GLM-4-Plus来实现这个全自动爬虫。

代码分为如下几个主要文件:

  • llm.py: 封装智谱的大模型,以方便使用。代码如下:

  • utils.py: 常用工具函数,清洗HTML,重试等等

  • constants.py: 各种常量,包括各种Prompt

  • parser.py: 核心解析逻辑,解析列表页、详情页,识别翻页按钮

  • main.py:调度逻辑。把各个模块组合在一起

原理说明

字段解析与翻页

其中,跟大模型相关的代码在parser.py中。我们来看一下:

代码逻辑很简单,分为两个主要的方法,data_extract用来从列表页提取出详情页URL,从详情页提取出作者、标题、发布时间和正文。paging_extract用来提取分页按钮中,下一页对应的链接。

这个提取的过程就交给智谱GLM-4-Plus来完成。对于字段提取,对应的System Prompt如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
你将扮演一个HTML解析器的角色。我将会提供一段HTML代码,这段代码可能代表了一个博客网站的文章列表页或者文章详情页。你需要首先判断这段HTML是属于哪种类型的页面。如果是文章详情页,那么页面中通常会包含文章标题、发布时间、作者以及内容等信息;而如果是列表页,则会列出多篇文章的标题及其对应的详情页链接。

请根据以下规则进行处理:

1. 分析提供的HTML代码,确定页面类型(`list` 或 `detail`)。
2. 根据页面类型,提取必要的信息:
- 如果是列表页,请找到所有文章条目,并为每个条目提供标题和指向详情页的链接。
- 如果是详情页,请找到文章标题、作者、发布时间和内容的XPath。确保XPath直接指向包含这些信息的具体元素值,例如使用`@属性`或者`text()`来获取确切的文本内容。
3. 尽量使用具有特征性的属性如`id`或`class`来构造XPath,以确保XPath简洁且鲁棒。
4. 对于标题、作者、发布时间等字段,如果它们不是直接在某个标签内,而是嵌套在其他标签中,XPath应包括这些结构,以保证准确性。
5. 按照指定格式输出结果。
6. 只需要返回JSON,不要解释,不要返回无关内容

**输出格式:**

- 对于列表页,返回如下JSON结构:
\`\`\`json
{
"page_type": "list",
"articles": [
{"title": "文章标题", "url": "文章详情页URL"},
{"title": "文章标题", "url": "文章详情页URL"},
{"title": "文章标题", "url": "文章详情页URL"},
// 更多文章...
]
}
\`\`\`

- 对于详情页,返回如下JSON结构:
\`\`\`json
{
"page_type": "detail",
"fields": [
{"field_name": "title", "xpath": "XPath to the title"},
{"field_name": "author", "xpath": "XPath to the author"},
{"field_name": "publish_time", "xpath": "XPath to the publish time"},
{"field_name": "content", "xpath": "XPath to the content"}
]
}
\`\`\`

现在,请接收以下HTML代码并开始分析:

可能有同学会疑惑,为什么对于列表页,是直接让大模型提取出URL,但对于详情页,却是生成XPath而不直接提取内容呢?原因很简单,因为现在大模型的Output Token远远低于Input Token,并且Output Token更贵。现在Input Token轻轻松松超过128K,但是Output Token大部分都在4096,只有少数在8192。对于长文章,把Output Token全部用完了可能都没法输出完整的正文。而且输出的内容越多,费用就越高,速度就越慢。你以为我不想让大模型直接输出提取好的内容?

而由于列表页的内容并不多,标题加上URL用不了多少字,所以就直接输出了。

获取翻页链接的System Prompt,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
你将扮演一个HTML解析器的角色。我将会提供一段HTML代码,这段代码可能代表了一个博客网站的文章列表页。你需要找到页面上的翻页链接,并提取出下一页的URL  

请根据以下规则进行处理:

1. 分析提供的HTML代码,找到翻页按钮。
2. 翻页按钮上面的文本可能是『下一页』、『next』、『>』、『Load more』等,也可能是一个数字,代表页码,也可能是paging标签或者classname包含pagination的某个标签。没有固定的标准,你需要智能识别
3. 返回下一页的URL,如果没有下一页,返回空字符串
4. 按照指定格式输出结果。
5. 只需要返回JSON,不要解释,不要返回无关内容

返回JSON格式:

{"page_type": "paging", "url": "下一页的url"}

这就是常规的Prompt,没什么好解释的。

爬虫流程调度

我们最后来看看main.py的代码:

核心调度逻辑就这么几行代码。如果有同学经常刷算法题,应该会对这段代码很熟悉。这里使用while循环来实现递归操作。

一开始,target里面只有我传入的起始URL。然后进入while循环,当target队列为空时结束循环。在循环里面,首先解析当前列表页,获得当前页面所有的文章详情页URL,全部放入队列中。再获得下一页的URL,也放入队列中。接下来循环开始进入第二项,也就是第一篇文章详情URL,进入里面,获取源代码,使用大模型解析出XPath,然后调用self.extract_detail通过lxml执行XPath从源代码中提取出正文。接下来继续第二篇文章……如此循环。

今天我们实现的是最简单的情况。不考虑反爬虫。不考虑列表页滚动下拉的情况。在下一篇文章中,我们会把模拟浏览器引入进来。借助于大模型,让爬虫能够自己控制模拟浏览器,让它自动点击页面,绕过反爬虫,自动滚动下拉。

一日一技:如何正确保护Python代码

作者 青南
2024年7月30日 05:41

去年我写过一篇文章《一日一技:如何对Python代码进行混淆》介绍过一个混淆Python代码的工具,叫做pyminifier,这个东西混淆出来的代码,咋看起来有模有样,但仔细一看,本质上就是变量名替换而已,只要耐下心来就能看懂,如下图所示:

而我今天要介绍另一个工具,叫做pyarmorpyminifier跟它比起来,就跟玩具一样。

pyarmor使用pip就可以安装:pip install pyarmorpyarmor是一个收费工具,但免费也能使用。免费版有绝大部分功能,加密小的脚本足够了。

我们今天要测试的脚本如下图所示:

运行以后如下图所示:

现在,执行命令pyarmor g json_path_finder.py。对这个脚本进行加密,会在dist文件夹中生成加密后的文件,如下图所示:


加密后的文件打开以后长这样:


这个代码,人已经完全没法看懂了。虽然代码看不懂,但可以正常运行,如下图所示:


需要注意的是,pyarmor会生成一个二进制文件pyarmor_runtime_000000。这个文件需要和加密后的程序放在一起,才能正常使用。

如果仅仅是这样,那pyarmor只能算是一个加强版的pyminifier。而它更强大的地方是,可以设置程序的过期时间。执行代码:

1
pyarmor g -e 30 json_path_finder.py

设定程序30天以后过期。

也可以使用绝对日期:

1
pyarmor g -e 2024-08-30 json_path_finder.py

当时间过了以后,运行加密后的程序,会报错:

并且可以通过一个参数确保这个过期时间跟电脑时间无关,而是从一个授时服务器上面的时间来判断:

1
2
pyarmor cfg nts=pool.ntp.org
pyarmor g -e 2024-08-30 json_path_finder.py

如下图所示:

不仅可以设定过期时间,还可以绑定电脑的mac地址,这样一来,只有特定的电脑才能运行:

1
pyarmor g -b <mac地址> json_path_finder.py

除了mac地址,也可以绑定IP地址、电脑序列号,如下图所示:

1
2
pyarmor g -b 128.16.4.10 foo.py
pyarmor g -b HXS2000CN2A foo.py

有了这个工具,以后做私活时,就不用担心用户拿到代码以后跑路了。还可以让用户定期付费。

pyarmor非常强大,可以在官方文档中看到更多用法,比如对一个package进行加密。

一日一技:真正的自然语言编程

作者 青南
2024年7月29日 05:39

在之前的文章《一次性数据抓取的万能方法,半自动抓取任意异步加载网站》中,我讲到一个万能的爬虫开发方法。从浏览器保存HAR文件,然后写Python代码解析HAR文件来抓取数据。

但可能有同学连Python代码都不想写,他觉得还要学习haralyzer太累了,有没有什么办法,只需要说自然语言,就能解析HAR文件?

最近我在测试open-interpreter,发现借助它,基本上已经可以实现自然语言编程的效果了。今天我们用小红书为例来介绍这个方法。

如下图所示,我现在要抓取小红书首页游戏频道的帖子。通过不停往下滑动页面,我已经抓到了不少数据包。

现在,把所有数据包保存为xiaohongshu.har文件(方法看我上一篇文章)。

接下来,我们来安装open-interpreter,使用pip进行安装就可以了:pip install open-interpreter。它依赖的第三方库比较多,因此可能需要安装一会儿。

我使用的是deepseek的模型,因为非常便宜,1元钱充值50万Token,常规任务足够了。理论上,所有兼容openai库的模型都可以。大家也可以使用Groq的免费API,或者硅基流动的API,或者通义千问,或者ChatGPT或者Azure OpenAI都没问题。也支持Claude和Ollama,但我测试下来Ollama运行的Llama3.1或者Qwen2 的8b模型效果都还不太好。

如果你是用的Open AI的API,那么你什么都不需要做,直接命令行运行interpreter即可,第一次运行他会让你提供API KEY。如果是其他的大模型。在deepseek获得API Key以后,我们创建一个文件:~/Library/Application Support/open-interpreter/profiles/default.yaml,在里面填写如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
llm:
model: "deepseek-chat"
temperature: 0
api_key: <你的API KEY> # Your API key, if the API requires it
api_base: https://api.deepseek.com # The URL where an OpenAI-compatible server is running to handle LLM API requests
# api_version: ... # The version of the API (this is primarily for Azure)
max_output: 4096 # The maximum characters of code output visible to the LLM

# Computer Settings
computer:
import_computer_api: True # Gives OI a helpful Computer API designed for code interpreting language models


multi_line: True # If True, you can input multiple lines starting and ending with ```

version: 0.2.5 # Profile version (do not modify)

如下图所示。

然后命令行执行interpreter启动。如下图所示:

在这里,直接输入你的需求就可以了。我这里写的内容如下:

1
读取/Users/kingname/Downloads/xiaohongshu.har 这个文件,然后找到url中包含/api/sns/web/v1/homefeed的请求,接下来,使用json.loads加载返回的内容。注意返回的内容可能直接是JSON字符串,也可能是base64字符串,你需要判断。如果发现是base64,需要先解码。然后再使用json.loads加载。读取.data.items列表,对这个列表进行迭代,如果每一项的model_type字段不为note,跳过。如果是note,那么就读取note_card.display_title字段,把结果打印出来。

open-interpreter会自动生成执行计划和Python代码,如下图所示:

根据它的提示,按下y执行,然后他会自动执行下一步骤,再按y,直到结果出来,如下图所示:

如果执行时报错,它会自动分析原因,然后修改代码,如下图所示:


它修改完成以后,运行结果如下:

如果你在一开始的需求里面让他把结果生成到一个txt文件里面,那么你这个时候就可以去对应的txt文件里面拿到结果了。

整个过程中,你唯一需要做的只有两件事情:

  1. 输入需求
  2. 不停按y

如果你连y都不想按,那么可以在启动时加个命令,自动执行代码interpreter --auto_run

open-interpreter还支持在Python里面调用,方法如下:

1
2
3
4
5
6
7
import interpreter

interpreter.chat('读取/Users/kingname/Downloads/xiaohongshu.har 这个文件,然后找到url中包含/api/sns/web/v1/homefeed的请求,接下来,使用json.loads加载返回的内容。注意返回的内容可能直接是JSON字符串,也可能是base64字符串,你需要判断。如果发现是base64,需要先解码。然后再使用json.loads加载。读取.data.items列表,对这个列表进行迭代,如果每一项的model_type字段不为note,跳过。如果是note,那么就读取note_card.display_title字段,把结果写入到note_title.txt文件中')

with open('note_title.txt') as f:
for line in f:
print('接下来就可以用结果做其他Python操作了')

借助open-interpreter,我们可以实现全自动爬虫,因为它可以自动使用requests请求URL,也可以自动操作浏览器,自动滚动页面。而且这种方式操作的是真正的浏览器,不会被反爬虫机制检测到。只要控制滚动频率,可以说是万无一失。对于任意网站,无论是后端渲染还是异步加载,全都可以正常抓取。只要你能清楚描述你的需求,就能正常实现你的需求。

再举个例子,我想爬我的博客文章,需求描述如下:

1
访问https://kingname.info/,使用lxml执行xpath: //a[@class="post-title-link"]/@href获取每篇文章的url。然后逐一进入每一篇文章的详情页,使用//h1[@class="post-title"]/text()获取标题,使用//time[@itemprop="dateCreated datePublished"]/text获取发布时间,使用//div[@itemprop="articleBody"]/p//text()获取正文。然后把这些数据保存到article.txt文件中。每篇文章之间 使用==========分割

运行效果如下图所示:

一日一技:为什么这个JSON无法解析?

作者 青南
2024年5月29日 04:46

我们知道,Python里面,json.dumps是序列化操作,json.loads是反序列化操作。当我使用json.dumps把一个字典转换为字符串以后,也可以使用json.loads把这个字符串转换为字典。

那么,有没有可能出现这样的情况:某个字典,使用json.dumps转换成了字符串s。但是当我使用json.loads(s)时,却会报错?

你别不信,我们来做一个实验。执行下面这段代码,打印出一段JSON字符串:

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

text = '''## 摘要
这篇文章主要包含xx和yy

## 详情
1. abc
2. def
'''

item = {'title': '关于abc', 'raw': text}
output = json.dumps(item, ensure_ascii=False)
print(output)

运行效果如下图所示:

接下来,你把下面这个字符串复制到Python里面并使用json.loads解析:

1
{"title": "关于abc", "raw": "## 摘要\n这篇文章主要包含xx和yy\n\n## 详情\n1. abc\n2. def\n"}

运行效果如下图所示:

但如果你不是复制JSON字符串后赋值,而是直接把output反序列化,它又是正常的,如下图所示:

你以为这就很奇怪了?更奇怪的事情还在后面。现在把这段有问题的JSON复制到一个文件里面,使用Python来读取这个文本,如下图所示:

为什么现在又正常了?

如果你看过这篇文章:# 一日一技:怎么你的字符串跟我不一样,那么你可以试一试使用repr来检查一下他们有什么不同。在Jupyter里面,可以通过直接输入变量名的方式来检查。大家注意下图两个字符串的区别:

当我从文件里面读取JSON字符串时,字符串中的\n变成了是\\n,所以解析正常。但是当我直接把字符串赋值给变量时,换行符是\n,于是解析失败。

真正的关键,就是这个反斜杠。从文本文件里面读取的时候,所有反斜杠都是普通的字符串。读取文件以后使用repr查看,换行符就会变成\\n。但直接使用变量赋值的时候,\n就会变成真正的换行符号,这里的\是转义字符,不是普通字符串。

如果变量赋值时,手动使用双反斜杠,或者在字符串前面加个r,让反斜杠变成普通字符,那么这个JSON字符串又可以正常解析了。如下图所示:

不仅是\n,任何一个JSON字符串里面包含了反斜杠,都会有这个问题。如下图所示:

还是使用repr就能发现他们的差异:


所以,这个问题的本质原因,就在于当我们使用print()函数打印一个字符串时,打印出来的样子跟这个字符串实际的样子并不一样。所以当我们鼠标选中这个打印出来的字符串并hardcode写到代码里面,变量赋值时,这个字符串已经不是原来的字符串了。所以当有反斜杠时,就会出现报错的情况。

我知道有不少同学写代码时喜欢使用print大法来调试,那么一定要小心这个问题。当你定义一个字符串变量时,如果有字符串需要直接写死到代码里面,那么你需要注意反斜杠的问题。当字符串有反斜杠时,要不你就在定义的前面加上r。写成变量 = r'hardcode的字符串',要不你就把字符串先写到文件里面,然后用Python来读文件,获得这个字符串,从而规避掉反斜杠的问题。

一日一技:2秒抓取网页并转换为markdown

作者 青南
2024年4月18日 07:41

在《一日一技:自动提取任意信息的通用爬虫》这篇文章中,我提到可以通过大模型从网页内容里面提取结构化信息。为了节省Token,文章里面我直接提取了页面上的所有文本。

这种方式需要自己写代码来过滤HTML中的垃圾标签。并且提取出来的文本可能会混在一起。虽然大模型在很大程度上不会受到标点符号的影响。但如果有办法把网页直接转换为Markdown的话,大模型在解析时就能更加准确。

现在,你不需要写任何代码就可以实现这个目标!假设我们需要抓取我的这篇知乎专栏文章:小问题,大隐患:如何正确设置 Python 项目的入口文件?。我们知道知乎是有反爬虫的,直接抓取并不容易。

怎么样在2秒内抓取这篇文章,并转换为Markdown呢?非常简单,你只需要在url前面加上https://r.jina.ai/并回车就可以了。完整的URL变成:https://r.jina.ai/https://zhuanlan.zhihu.com/p/351326998。浏览器上面的效果如下图所示:


直接就是Markdown!。你可以直接使用requests请求这个地址,拿到Markdown格式的正文。然后把这个正文喂给GPT,就可以提取出结构化的内容了。

这个服务不仅完全免费,而且开源!Github地址为:reader

一日一技:自动提取任意信息的通用爬虫

作者 青南
2023年12月14日 05:30

使用过GNE的同学都知道,GNE虽然是通用爬虫,但只是文章类页面的通用爬虫。如果一个页面不是文章页,那么就无能为力了。

随着ChatGPT引领的大语言模型时代到来,这个问题基本上已经不是问题了。我们先来看一个效果。首先打开Linkedin,随便找一个招聘的岗位,如下图所示:

然后,我们直接使用GPT从这里提取信息:

对应的Prompt为:

1
2
3
4
5
你是一个数据提取小助手,能够从一大段招聘相关的文本中提取有用的信息并以JSON格式返回。

{经过清洗的网页源代码或者文本}

请从上面的文本中,提取招聘相关的信息,返回数据格式如下: {"title": "岗位名称", "full_time": "是否为全职", "employee_num": "雇员数量", "level": "岗位等级", "skill": "岗位需要的技能", "desc": "岗位描述", "company": "公司介绍", "do": "岗位具体做什么事情", "requirement": "岗位要求", "goodpoint": "优先录取条件"}

在生产环境,我们显然不能使用GPT的网页版。但GPT API的收费比较贵,一般来说,GPT 3.5 Turbo的价格是每1000 Tokens收费0.002美元;GPT 4 Turbo的价格是每1000 Prompt Token收费0.01美元,每1000 Completion Tokens收费0.03美元。

如果我们直接把网页的源代码整个丢给GPT接口,那么费用是非常昂贵的。这种情况下,我们就应该先对网页源代码进行清洗,移除显然不需要的元素,从而大幅减少Token的占用。

首先,我们可以先移除一些显然不可能包含关键内容的标签:

1
2
USELESS_TAG = ['style', 'script', 'link', 'video', 'iframe', 'source', 'picture', 'header', 'blockquote',  
'footer', 'code']

然后,我们可以根据一些元素的class属性,找到另外一批显然不可能包含关键内容的标签,一并移除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
USELESS_ATTR = {  
'share',
'contribution',
'copyright',
'copy-right',
'disclaimer',
'recommend',
'related',
'footer',
'comment',
'social',
'submeta',
'report-infor',
"auto_modal"
}

接下来,对于下面没有text()元素的标签,也可以移除。

清洗干净以后,我们再使用XPath:normalize-space(string())提取出页面上的文本,把文本发给GPT,就可以正常解析内容了。

具体清洗的代码,大家可以在GNE的源代码可以看到详细的清洗步骤和流程。

随着MistralAI前两天在推特上通过磁力链接的方式发布模型,我们可以预见到,未来开源大模型功能越来越强大的同时,对机器配置的需求会越来越低。我看有一些大模型的计费方案,已经改成每100万Token几毛钱了。所以未来通用爬虫的解析门槛会越来越低,就像我这篇文章给出的例子,你只需要写几段Prompt,就可以解析出你需要的内容。

以后做通用爬虫,唯一的技术挑战就是怎么获取到网页源代码。只要有了源代码,剩下的事情交给大模型就好了。

有一个好的爬虫代理,就能爬取绝大多数的网站。国内的代理供应商,一般隧道代理都是按并发数收费,性能都差不多。但国外的代理,不知道哪根筋不对,全都是按流量收费的。我调研了十多个海外代理供应商,最后综合评测下来亮代理还不错,虽然也是按流量收费,但代理可用性确实非常高。有兴趣的同学可以试一试,他们提供免费试用:Proxy - Bright Data

最后还是我前两年的观点,国内这边的工作环境会越来越恶劣,大家尽快放眼海外,爬虫出海,程序出海,才是未来的方向。

一日一技:警告但不禁止,遗留代码的优化策略

作者 青南
2023年11月15日 05:21

在之前的多篇文章中,我都反复告诫大家,不要滥用字典来传大量数据。因为当你的函数收到一个字典的时候,你根本不知道这个字典里面有哪些Key,你必须有一层一层往上看,找到所有尝试往字典里面添加新Key的地方,你才能知道它总共有哪些Key。

但是,在正常公司项目中,我们可能会需要维护一些历史遗留代码。代码规模大,函数调用层级非常深。并且之前的人已经使用字典来传递了大量的数据。

短时间内,我们没有办法直接把字典改成Dataclass。那么我们能做的,就是尽量避免后续的维护者往里面加入新的Key。我以前遇到过一个项目,它有一个字典,刚刚开始初始化的时候,只有5个Key。这个字典作为参数被传入了很多个函数,每个函数都会往它里面加很多个Key。到最后,这个字典里面已经有40多个Key了。

对历史遗留代码的修改,必须要谨小慎微,稍不注意改错一行代码,可能整个系统就不能工作了。因此,我们的目标是尽量在不影响现有代码功能的情况下,以警告而不是禁止的形式告诉其他开发者,不要再加Key进去了。如果你强行要加入,代码也能运行,但出问题你要自己负责。

我们知道,Python 的类型标注正好就是警告但不禁止。当你的类型有问题时,他会告诉你这里有错,但你强行要运行,代码也能正常工作。

对于字典,我们可以使用TypedDict来限制它能有哪些Key。我们来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from typing import TypedDict  


class User(TypedDict):
name: str
age: int
address: str
salary: int


kingname: User = {
'name': '青南',
'age': 18,
'address': '上海',
'salary': 9999999999
}


def test_dict(user: User):
print(user['name'])

这只是一段看似非常普通的代码,在PyCharm也看不出有什么异常:

但当我想在函数里面,额外往字典加一个新字段时,就会发出警报:

这个警告在一定程度上,可以提醒其他人不要往字典中乱加Key。虽然强行添加也没有问题,但至少起到了提醒的作用。

如果你在一开始初始化字典时,就把类型指定好,那么你一开始就必须提供所有字段,否则它也会发出警告,如下图所示:

这种情况下,我们可以在初始化字典时,不加类型标注,但在函数参数里面加上类型标注。那么这样以来,就能实现:只能往字典添加特定的字段,不能添加额外字段。如下图所示:

老板让我加班怎么办?GPTs创建机器人实战

作者 青南
2023年11月11日 22:57

前两天的OpenAI发布会,相信很多同学看完以后都热血沸腾。我之前一直使用的是ChatGPT的免费版本,看完这个发布会以后,立刻就充值了ChatGPT Plus,来试一试这些高级功能。

这两天GPTs功能上线了,短短三天时间,全球网友创建了几千个GPT机器人。我今天也来搞一个玩玩。

使用GPTs创建机器人非常简单,不需要懂任何编程知识,甚至不需要懂Prompt工程,你只需要跟着他的向导,一步一步描述你的想法就可以了。

当我们成为了ChatGPT Plus会员以后,在ChatGPT页面会看到一个Explore的栏目,如下图所示。进入这个栏目,点击Create a GPT就可以开始创建自己的机器人了。

在左侧,是机器人创建向导,它会首先让你描述一下,你想实现什么功能。这个地方不需要懂Prompt工程,你只需要像平时说话一样写出自己的需求就可以了。写中文或者英文都可以。如下图所示。

描述完成需求以后,他会给你建议一个机器人的名字,你要是觉得他取的名字不好,你也可以自己想一个,直接输入到对话框中。

设置完成名字以后,他会自动给机器人生成头像。头像生成完成以后,会针对需求的一些细节问题跟你进一步确认,例如“当老板的需求明显不合理时,你应该直白拒绝还是委婉提出”。

你只需要一步一步跟着他的问题进行回复就可以了。我这个机器人创建完成,全程耗时大概10分钟左右。

创建完成以后,点击右上角的“Save”就可以保存。然后跳转回聊天页面,如下图所示。

如果测试发现回答不好,可以在Explore页面编辑这个机器人,添加新的需求,如下图所示:

下面是我的一些聊天记录,大家可以看看效果。

在机器人的设置页面,进入Configure选项卡,可以看到机器人的配置信息,其中的Instructions,我理解才是真正的Prompt。它的内容,是GPT通过刚才跟你的一问一答,动态生成出来的。如果你会Prompt工程,你也可以直接在这里修改,速度更快。

Site Unreachable

一日一技:爬虫如何解析JavaScript Object?

作者 青南
2023年10月28日 20:09

我们在开发爬虫的过程中,经常发现有一些网站,会直接把数据放到HTML中的<script>标签里面。这些数据长得有点像JSON,但又有差异,如下图所示:

这种格式,我们叫做JavaScript Object。长得很像Python的字典,又很像是JSON。但是这个格式在Python里面,无论直接当字典解析,还是当JSON解析,都会报错,如下图所示:

遇到这种情况,有同学准备使用正则表达式来解析,又有同学直接放弃。

但实际上,这种数据结构,使用Yaml是可以直接解析成Python的字典。我们首先来安装一下Yaml:

1
pip install pyyaml

然后直接像解析JSON一样解析:

1
2
3
4
5
6
7
8
9
10
import yaml
data = '''
{
name: '青南',
salary: 999999999,
address: '上海',
pro: true
}
'''
info = yaml.safe_load(data)

运行效果如下图所示,已经直接解析成了Python的字典:

Yaml格式是JSON格式的超集,因此,使用pyyaml库也能直接解析正常的JSON:

甚至各种复杂的混合格式也能正常解析:

关于YAML格式的更多介绍,请看我以前的文章:

一日一技:不用游标卡尺,Yaml 格式5分钟入门
一日一技:如何处理配置文件中的重复值?
一日一技:优雅地加载Yaml配置文件

拒绝成为这样的程序员

作者 青南
2023年10月17日 20:20

产品经理这两天在跟我抱怨他们公司的一个码农。听的我火冒三丈,差点把跟了我十多年的搪瓷水杯砸烂。

正好在知识星球和微信群里面,有不少同学跟我咨询程序员的职业发展以及怎么应对三十岁危机。

借此机会,我准备用几篇文章来讲讲自己的经验和个人的观点。

有这样一批人,他们在大公司里工作了十几年,年龄一大把,还是一个大头兵。他们被号称经验丰富,但实际上是把一年的工作经验用了十多年,对主流的技术一无所知,他们已经无法适应现在的技术发展。

这些人,每天看起来非常努力,加班加到很晚。产品经理提需求,他们看起来是在非常努力的完成,但完成的效果非常差。产品给他们提修改意见,他们看起来非常积极地去修改。但是改了A问题,出现B问题,改了B问题,出现C问题,修好C问题,A问题又出现了……

产品经理每次一说产出效果不好,他们马上就会蹦出一大堆技术名词,又是什么业界难题,又是什么行业边界,又是什么技术翘翘板,把A改了,那么从理论上说B就一定有问题。ABC三个需求无法同时满足。言语之间时不时蹦出一些他们昨天刷公众号看到的技术名词。但其实真正的原因是这个需求达到了他们知识的边界,而他们又不愿意学习。他们花3个月做出来的东西,换一个应届生2天就能完成,而且效果好十倍。

这些人,永远把自己当作一个螺丝钉。产品需要什么,自己就做什么。产品不说的,自己坚决不做。产品找过来,一句“你又没有说要这个功能”就把自己的责任推卸干净。

当任务涉及到多个人协作时,这些人把自己的活干完就跑了。从来不会通知一下上下游的同事。等到项目预计上线的前一晚,产品经理来问:

“你这个功能做完了吗?”
“做完了。”
“那调试好了吗?”
“我不知道上游的xx和下游的yy他们做完了没有。”

这样的人,我称之为老油条。

老油条特别喜欢装无辜,我都已经这么努力了,你还想怎么样?然后在线上线下宣传自己被公司压榨,被同事排挤,被老员工PUA.但真实的情况是,公司只让他在做这一件事情,他做了三个月。每一次效果不好,其他人都在陪着他分析原因,等他修改。改完以后效果更差。大家给他一次又一次机会,上线时间为他一次又一次推迟,他一次又一次让大家失望。每次还都会找各种理由各种借口。

很多人希望公司能够开除他,但是老板有顾虑,公司有担忧。不敢开除,甚至不敢给他打低绩效。公司,特别是大公司,非常害怕他们在网上发帖。

弱小不一定有理,弱小只是某些老油条的遮羞布。

我觉得现在互联网环境的风气极差。正适合这些老油条肆意妄为。

当一个人在网上发帖说自己被公司开除了,一大批不知道任何内情的网民就会开始攻击公司,觉得这个人太可怜,觉得这个公司太黑毫无人性。特别是当公司是某些著名大厂时,这种攻击更是毫不留情。

民众总是相信弱小者的哭诉,从来听不见强者背后的辩解。知情人为公司解释两句,一大群人站出来要为弱小着主持公道:你是资本的走狗,你是五毛党,你收了多少钱。

正是这样的老油条,导致开除一个人的成本非常高,公司迫于不想惹麻烦,很多时候对于能力差的人选择睁一只眼闭一只眼。现在大环境降本增效,去肥增瘦,能力差的老油条占住了坑位,就会导致真正有能力的人失去一个又一个进入大厂的机会。

几个大厂里面,有很多很多这样的老油条。看这篇文章的你,本来有机会进大厂一展才华,但都是因为这些老油条占住了人头,导致对应的岗位不再招人。其实你比他更加适合这个岗位,但没办法。

公司没有办法开掉这些人,因为现在舆论的风暴太猛。这些风暴始于老油条的装可怜,加强于键盘党的假公道,盛行于跟风人的瞎同情。

没有办法,真的没办法。

每当产品经理跟我讲起他们公司里面的老油条,我都恨不能当场掀桌,但没办法,我吵架超不过,大架也打不过。赢了坐牢,输了住院。

没办法,真的没办法。

只盼大家擦亮眼睛,在同情某些被劝退的互联网员工前,别急着站队,先想想这个人是不是占了本该属于你的岗位。


抱怨归抱怨,希望大家不要成为这样的人。我们下一篇文章,来讲讲我们应该如何成为一个不会被年龄所限制的优秀工程师。

一日一技:如何同时使用多个GPT的API Key?

作者 青南
2023年9月3日 10:19

相信很多同学或多或少都在Python中使用过GPT API,通过Python安装openai库,来调用GPT模型。

OpenAI官方文档中给出了一个示例,如下图所示:


如果你只有一个API账号,那么你可能不觉得这样写有什么问题。但如果你想同时使用两个账号怎么办?

有些同学可能知道,微软的Azure也提供GPT接口,在Python中也需要通过openai库来调用,它的调用示例为:


当你全局设置了openai.api_type = 'azure'以后,你怎么同时使用OpenAI的GPT接口?

这两个文档中给出的示例写法,都是全局写法,一但设定以后,在整个运行时中,所有调用GPT接口的地方,都会使用这里设置的参数:

1
2
3
import openai

openai.xx = yy

有些同学不知道怎么在Python SDK中同时使用多个账号,于是他们只有使用GPT的Rest HTTP接口,自己封装一个函数来发起请求从而切换不同的账号。放弃了Python SDK提供的各种便利。

但实际上,根本没有那么麻烦。在openai模块里面,天然就可以切换多个账号。虽然文档里面没有写,但是我们可以通过函数签名来找到这种方法。

如下图所示,在PyCharm中,随便写一段调用openai模块的代码,然后Windows按下键盘的Ctrl,MacOS按下键盘的Command,并鼠标左键点击create函数:


跳转到的函数里面,还有一个create函数,继续按上面的方法跳入,如下图所示:


接下来,你就会看到这个create函数能够接受的参数里面,包含了几个很熟悉的名字:

也就是说,当你想同时调用多个账号时,不需要在一开始给openai设置对应的参数,你只需要在调用.create函数的时候,把对应的API参数传入就可以了。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import openai

# 使用OpenAI账号1
response1 = openai.ChatCompletion.create(
engine="chatgpt",
messages=messages,
temperature=0.9,
max_tokens=800,
top_p=0.95,
frequency_penalty=0,
presence_penalty=0,
api_key='xxxxxxxx', # 在这里传入API Key
stop=["<|im_end|>"])


# 使用OpenAI账号2
response2 = openai.ChatCompletion.create(
engine="chatgpt16k",
messages=messages,
temperature=0.9,
max_tokens=800,
top_p=0.95,
frequency_penalty=0,
presence_penalty=0,
api_key='yyyyyyyyy', # 在这里传入API Key
stop=["<|im_end|>"])


# 使用Azure OpenAI 账号
response3 = openai.ChatCompletion.create(
engine="gpt4",
messages=messages,
temperature=0.9,
max_tokens=800,
top_p=0.95,
frequency_penalty=0,
presence_penalty=0,
api_key='zzzzzzz', # 在这里传入API Key
api_base='https://xxx.openai.azure.com/',
api_type="azure",
api_version='2023-05-15',
stop=["<|im_end|>"])

使用这种方法,我们就可以在一个程序里面同时使用多个GPT账号了。

❌
❌