jevhttp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jevhttp-0.1.0/LICENSE +21 -0
- jevhttp-0.1.0/PKG-INFO +165 -0
- jevhttp-0.1.0/README.md +152 -0
- jevhttp-0.1.0/jevhttp/__init__.py +34 -0
- jevhttp-0.1.0/jevhttp/client.py +314 -0
- jevhttp-0.1.0/jevhttp/crawler.py +138 -0
- jevhttp-0.1.0/jevhttp/decisions.py +101 -0
- jevhttp-0.1.0/jevhttp/errors.py +25 -0
- jevhttp-0.1.0/jevhttp/extract.py +5 -0
- jevhttp-0.1.0/jevhttp/http.py +7 -0
- jevhttp-0.1.0/jevhttp/page.py +169 -0
- jevhttp-0.1.0/jevhttp/url.py +66 -0
- jevhttp-0.1.0/jevhttp.egg-info/PKG-INFO +165 -0
- jevhttp-0.1.0/jevhttp.egg-info/SOURCES.txt +26 -0
- jevhttp-0.1.0/jevhttp.egg-info/dependency_links.txt +1 -0
- jevhttp-0.1.0/jevhttp.egg-info/requires.txt +3 -0
- jevhttp-0.1.0/jevhttp.egg-info/top_level.txt +1 -0
- jevhttp-0.1.0/pyproject.toml +14 -0
- jevhttp-0.1.0/setup.cfg +4 -0
- jevhttp-0.1.0/test/test_10086txt_boolean.py +19 -0
- jevhttp-0.1.0/test/test_10086txt_choice.py +28 -0
- jevhttp-0.1.0/test/test_10086txt_http.py +22 -0
- jevhttp-0.1.0/test/test_10086txt_multi_decision.py +31 -0
- jevhttp-0.1.0/test/test_10086txt_performance.py +32 -0
- jevhttp-0.1.0/test/test_10086txt_score.py +21 -0
- jevhttp-0.1.0/test/test_boolean.py +32 -0
- jevhttp-0.1.0/test/test_choice.py +50 -0
- jevhttp-0.1.0/test/test_score.py +40 -0
jevhttp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 XUEHANG AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jevhttp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jevhttp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Synchronous HTTP client and structured web decision toolkit for OpenAI-compatible models
|
|
5
|
+
Author-email: XUEHANGAI <xuehang.ai@outlook.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: openai>=3.16.2
|
|
10
|
+
Requires-Dist: pydantic>=2.7
|
|
11
|
+
Requires-Dist: requests>=2.34.2
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# jevhttp
|
|
15
|
+
|
|
16
|
+
[English README](README.en.md)
|
|
17
|
+
|
|
18
|
+
`jevhttp` 是一个本地模型优先的同步HTTP与结构化决策工具包。
|
|
19
|
+
|
|
20
|
+
它把网页请求、网页元数据抽取和OpenAI-compatible模型决策组合成一个轻量Python接口。
|
|
21
|
+
|
|
22
|
+
主要用于本地模型推理,同时兼容在线OpenAI-compatible推理服务。
|
|
23
|
+
|
|
24
|
+
## 为什么使用 jevhttp
|
|
25
|
+
|
|
26
|
+
传统网页采集通常需要分别处理HTTP请求、HTML解析、链接发现和业务分类。
|
|
27
|
+
|
|
28
|
+
`jevhttp`将这些步骤组合起来,让网页分类、相关性判断和后续动作成为可校验的结构化结果,而不是自由文本。
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
HTTP请求 → Page网页对象 → 精简State → 结构化决策 → 业务结果
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
模型不会直接访问网页。程序负责获取和清洗网页,模型只处理用户明确提供的State。
|
|
35
|
+
|
|
36
|
+
## 安装
|
|
37
|
+
|
|
38
|
+
项目发布到PyPI后,可以直接安装:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install jevhttp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
从源码使用uv管理环境:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv sync
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 快速开始
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from jevhttp import JevHttp
|
|
54
|
+
|
|
55
|
+
client = JevHttp(
|
|
56
|
+
base_url='http://127.0.0.1:8000/v1',
|
|
57
|
+
api_key='EMPTY',
|
|
58
|
+
model='openbmb/MiniCPM5-2B',
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
page = client.get('https://example.com')
|
|
62
|
+
state = [
|
|
63
|
+
{
|
|
64
|
+
'网址': page.final_url,
|
|
65
|
+
'标题': page.title,
|
|
66
|
+
'描述': page.description,
|
|
67
|
+
'状态码': page.status_code,
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
result = client.system_one(
|
|
72
|
+
state=state,
|
|
73
|
+
decisions={
|
|
74
|
+
'页面类型': client.choice(['文章页', '列表页', '其他']),
|
|
75
|
+
'是否相关': client.boolean('这个网页与当前任务相关吗?'),
|
|
76
|
+
'相关性': client.score('请评估网页相关性', min=0, max=10),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
print(result['页面类型'])
|
|
81
|
+
print(result['是否相关'])
|
|
82
|
+
print(result['相关性'])
|
|
83
|
+
print(result.decision_time)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`base_url`只用于模型API,不影响`client.get()`访问的网页地址。
|
|
87
|
+
|
|
88
|
+
## 主要特性
|
|
89
|
+
|
|
90
|
+
- 基于`requests.Session`的同步HTTP请求、连接复用、超时和有限重试
|
|
91
|
+
- `Page`和`Link`网页对象,提供标题、描述、标题列表、正文、链接和HTTP响应信息
|
|
92
|
+
- 自动把相对链接转换为绝对URL,并移除fragment和常见追踪参数
|
|
93
|
+
- `system_one()`一次请求执行一个或多个结构化决策
|
|
94
|
+
- 内置`Choice`、`Boolean`和`Score`决策类型
|
|
95
|
+
- 中文优先的模型提示词和分类结果校验
|
|
96
|
+
- 支持中文分类兜底项,避免小模型偶发同义词导致批处理终止
|
|
97
|
+
- 支持Pydantic结构化抽取:`client.extract(state, Model)`
|
|
98
|
+
- 支持带域名、深度、页数、robots.txt和内网地址保护的同步爬虫
|
|
99
|
+
- 每次决策提供原始JSON、模型名称、Token使用量和决策耗时
|
|
100
|
+
- 通过OpenAI-compatible接口兼容本地vLLM和远程模型服务
|
|
101
|
+
|
|
102
|
+
## 适合做什么
|
|
103
|
+
|
|
104
|
+
`jevhttp`适合需要“先获取网页,再进行结构化判断”的任务,例如:
|
|
105
|
+
|
|
106
|
+
- 学校、机构、企业网站的页面类型识别
|
|
107
|
+
- 新闻、文章、目录和详情页分类
|
|
108
|
+
- 网页相关性筛选和采集入口发现
|
|
109
|
+
- 本地小模型驱动的大规模网页预处理和批量决策
|
|
110
|
+
- 将网页状态抽取为业务Pydantic模型
|
|
111
|
+
- 对特定域名进行受限、可恢复的同步采集
|
|
112
|
+
|
|
113
|
+
它不是浏览器自动化框架,也不负责JavaScript渲染、验证码绕过、代理池、数据库、向量数据库或分布式任务调度。
|
|
114
|
+
|
|
115
|
+
## 性能验证
|
|
116
|
+
|
|
117
|
+
以下数据来自本地vLLM部署`openbmb/MiniCPM5-2B`的批量网页决策验证,使用8个并发工作线程。实际速度会受到GPU、网络和网页响应时间影响。
|
|
118
|
+
|
|
119
|
+
### 分阶段耗时
|
|
120
|
+
|
|
121
|
+
| 阶段 | 任务 | 并发数 | 平均耗时 | 中位数 | P95 | 备注 |
|
|
122
|
+
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
|
123
|
+
| HTTP | 请求网页并解析HTML | 8 | 2.875秒 | 2.752秒 | 4.071秒 | 包含网络耗时 |
|
|
124
|
+
| Choice | 单项页面类型分类 | 1 | 0.376秒 | - | - | 本地小模型单次测试 |
|
|
125
|
+
| Boolean | 单项布尔判断 | 1 | 0.362秒 | - | - | 本地小模型单次测试 |
|
|
126
|
+
| Score | 单项相关性评分 | 1 | 0.398秒 | - | - | 本地小模型单次测试 |
|
|
127
|
+
| Choice | 批量页面类型分类 | 8 | 0.125秒 | 0.108秒 | 0.238秒 | 9998次成功分类 |
|
|
128
|
+
| 全流程 | 请求、解析、决策和落盘 | 8 | 约0.463秒/页 | - | - | 10000页约77分钟 |
|
|
129
|
+
|
|
130
|
+
### 页面处理结果
|
|
131
|
+
|
|
132
|
+
| 指标 | 数值 |
|
|
133
|
+
| --- | ---: |
|
|
134
|
+
| 处理页面数 | 10000 |
|
|
135
|
+
| 成功分类页面 | 9998 |
|
|
136
|
+
| 异常页面 | 2 |
|
|
137
|
+
| 成功率 | 99.98% |
|
|
138
|
+
| 唯一URL数量 | 10000 |
|
|
139
|
+
| 主要详情页分类 | 8983 |
|
|
140
|
+
| 主要列表页分类 | 1015 |
|
|
141
|
+
|
|
142
|
+
测试代码位于`test/`目录,包含HTTP解析、Choice、Boolean、Score、多决策、性能基准和实时JSONL采集测试。
|
|
143
|
+
|
|
144
|
+
## 版本范围
|
|
145
|
+
|
|
146
|
+
当前版本优先保证同步调用和可预测行为,暂不包含:
|
|
147
|
+
|
|
148
|
+
- asyncio和异步API
|
|
149
|
+
- 浏览器渲染
|
|
150
|
+
- Playwright和Selenium
|
|
151
|
+
- 数据库和ORM
|
|
152
|
+
- 向量数据库和RAG
|
|
153
|
+
- 代理池、验证码绕过和分布式队列
|
|
154
|
+
|
|
155
|
+
## 生态
|
|
156
|
+
|
|
157
|
+
- [awesome-jev](https://github.com/kraayenjon/awesome-jev)
|
|
158
|
+
- [Made with jev](https://madewithjev.com/)
|
|
159
|
+
- [xuehang.ai](https://xuehang.ai/)
|
|
160
|
+
|
|
161
|
+
## 许可证
|
|
162
|
+
|
|
163
|
+
本项目采用[MIT License](LICENSE)。
|
|
164
|
+
|
|
165
|
+
Copyright © 2026 [XUEHANG AI](https://xuehang.ai/)。
|
jevhttp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# jevhttp
|
|
2
|
+
|
|
3
|
+
[English README](README.en.md)
|
|
4
|
+
|
|
5
|
+
`jevhttp` 是一个本地模型优先的同步HTTP与结构化决策工具包。
|
|
6
|
+
|
|
7
|
+
它把网页请求、网页元数据抽取和OpenAI-compatible模型决策组合成一个轻量Python接口。
|
|
8
|
+
|
|
9
|
+
主要用于本地模型推理,同时兼容在线OpenAI-compatible推理服务。
|
|
10
|
+
|
|
11
|
+
## 为什么使用 jevhttp
|
|
12
|
+
|
|
13
|
+
传统网页采集通常需要分别处理HTTP请求、HTML解析、链接发现和业务分类。
|
|
14
|
+
|
|
15
|
+
`jevhttp`将这些步骤组合起来,让网页分类、相关性判断和后续动作成为可校验的结构化结果,而不是自由文本。
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
HTTP请求 → Page网页对象 → 精简State → 结构化决策 → 业务结果
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
模型不会直接访问网页。程序负责获取和清洗网页,模型只处理用户明确提供的State。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
项目发布到PyPI后,可以直接安装:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install jevhttp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
从源码使用uv管理环境:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv sync
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 快速开始
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from jevhttp import JevHttp
|
|
41
|
+
|
|
42
|
+
client = JevHttp(
|
|
43
|
+
base_url='http://127.0.0.1:8000/v1',
|
|
44
|
+
api_key='EMPTY',
|
|
45
|
+
model='openbmb/MiniCPM5-2B',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
page = client.get('https://example.com')
|
|
49
|
+
state = [
|
|
50
|
+
{
|
|
51
|
+
'网址': page.final_url,
|
|
52
|
+
'标题': page.title,
|
|
53
|
+
'描述': page.description,
|
|
54
|
+
'状态码': page.status_code,
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
result = client.system_one(
|
|
59
|
+
state=state,
|
|
60
|
+
decisions={
|
|
61
|
+
'页面类型': client.choice(['文章页', '列表页', '其他']),
|
|
62
|
+
'是否相关': client.boolean('这个网页与当前任务相关吗?'),
|
|
63
|
+
'相关性': client.score('请评估网页相关性', min=0, max=10),
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
print(result['页面类型'])
|
|
68
|
+
print(result['是否相关'])
|
|
69
|
+
print(result['相关性'])
|
|
70
|
+
print(result.decision_time)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`base_url`只用于模型API,不影响`client.get()`访问的网页地址。
|
|
74
|
+
|
|
75
|
+
## 主要特性
|
|
76
|
+
|
|
77
|
+
- 基于`requests.Session`的同步HTTP请求、连接复用、超时和有限重试
|
|
78
|
+
- `Page`和`Link`网页对象,提供标题、描述、标题列表、正文、链接和HTTP响应信息
|
|
79
|
+
- 自动把相对链接转换为绝对URL,并移除fragment和常见追踪参数
|
|
80
|
+
- `system_one()`一次请求执行一个或多个结构化决策
|
|
81
|
+
- 内置`Choice`、`Boolean`和`Score`决策类型
|
|
82
|
+
- 中文优先的模型提示词和分类结果校验
|
|
83
|
+
- 支持中文分类兜底项,避免小模型偶发同义词导致批处理终止
|
|
84
|
+
- 支持Pydantic结构化抽取:`client.extract(state, Model)`
|
|
85
|
+
- 支持带域名、深度、页数、robots.txt和内网地址保护的同步爬虫
|
|
86
|
+
- 每次决策提供原始JSON、模型名称、Token使用量和决策耗时
|
|
87
|
+
- 通过OpenAI-compatible接口兼容本地vLLM和远程模型服务
|
|
88
|
+
|
|
89
|
+
## 适合做什么
|
|
90
|
+
|
|
91
|
+
`jevhttp`适合需要“先获取网页,再进行结构化判断”的任务,例如:
|
|
92
|
+
|
|
93
|
+
- 学校、机构、企业网站的页面类型识别
|
|
94
|
+
- 新闻、文章、目录和详情页分类
|
|
95
|
+
- 网页相关性筛选和采集入口发现
|
|
96
|
+
- 本地小模型驱动的大规模网页预处理和批量决策
|
|
97
|
+
- 将网页状态抽取为业务Pydantic模型
|
|
98
|
+
- 对特定域名进行受限、可恢复的同步采集
|
|
99
|
+
|
|
100
|
+
它不是浏览器自动化框架,也不负责JavaScript渲染、验证码绕过、代理池、数据库、向量数据库或分布式任务调度。
|
|
101
|
+
|
|
102
|
+
## 性能验证
|
|
103
|
+
|
|
104
|
+
以下数据来自本地vLLM部署`openbmb/MiniCPM5-2B`的批量网页决策验证,使用8个并发工作线程。实际速度会受到GPU、网络和网页响应时间影响。
|
|
105
|
+
|
|
106
|
+
### 分阶段耗时
|
|
107
|
+
|
|
108
|
+
| 阶段 | 任务 | 并发数 | 平均耗时 | 中位数 | P95 | 备注 |
|
|
109
|
+
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
|
110
|
+
| HTTP | 请求网页并解析HTML | 8 | 2.875秒 | 2.752秒 | 4.071秒 | 包含网络耗时 |
|
|
111
|
+
| Choice | 单项页面类型分类 | 1 | 0.376秒 | - | - | 本地小模型单次测试 |
|
|
112
|
+
| Boolean | 单项布尔判断 | 1 | 0.362秒 | - | - | 本地小模型单次测试 |
|
|
113
|
+
| Score | 单项相关性评分 | 1 | 0.398秒 | - | - | 本地小模型单次测试 |
|
|
114
|
+
| Choice | 批量页面类型分类 | 8 | 0.125秒 | 0.108秒 | 0.238秒 | 9998次成功分类 |
|
|
115
|
+
| 全流程 | 请求、解析、决策和落盘 | 8 | 约0.463秒/页 | - | - | 10000页约77分钟 |
|
|
116
|
+
|
|
117
|
+
### 页面处理结果
|
|
118
|
+
|
|
119
|
+
| 指标 | 数值 |
|
|
120
|
+
| --- | ---: |
|
|
121
|
+
| 处理页面数 | 10000 |
|
|
122
|
+
| 成功分类页面 | 9998 |
|
|
123
|
+
| 异常页面 | 2 |
|
|
124
|
+
| 成功率 | 99.98% |
|
|
125
|
+
| 唯一URL数量 | 10000 |
|
|
126
|
+
| 主要详情页分类 | 8983 |
|
|
127
|
+
| 主要列表页分类 | 1015 |
|
|
128
|
+
|
|
129
|
+
测试代码位于`test/`目录,包含HTTP解析、Choice、Boolean、Score、多决策、性能基准和实时JSONL采集测试。
|
|
130
|
+
|
|
131
|
+
## 版本范围
|
|
132
|
+
|
|
133
|
+
当前版本优先保证同步调用和可预测行为,暂不包含:
|
|
134
|
+
|
|
135
|
+
- asyncio和异步API
|
|
136
|
+
- 浏览器渲染
|
|
137
|
+
- Playwright和Selenium
|
|
138
|
+
- 数据库和ORM
|
|
139
|
+
- 向量数据库和RAG
|
|
140
|
+
- 代理池、验证码绕过和分布式队列
|
|
141
|
+
|
|
142
|
+
## 生态
|
|
143
|
+
|
|
144
|
+
- [awesome-jev](https://github.com/kraayenjon/awesome-jev)
|
|
145
|
+
- [Made with jev](https://madewithjev.com/)
|
|
146
|
+
- [xuehang.ai](https://xuehang.ai/)
|
|
147
|
+
|
|
148
|
+
## 许可证
|
|
149
|
+
|
|
150
|
+
本项目采用[MIT License](LICENSE)。
|
|
151
|
+
|
|
152
|
+
Copyright © 2026 [XUEHANG AI](https://xuehang.ai/)。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""导出jevhttp公共接口"""
|
|
2
|
+
|
|
3
|
+
from .client import DecisionResult, JevHttp
|
|
4
|
+
from .crawler import CrawlConfig, CrawlItem, Crawler
|
|
5
|
+
from .decisions import Boolean, Choice, Decision, Score
|
|
6
|
+
from .errors import (
|
|
7
|
+
AIResponseError,
|
|
8
|
+
AITransportError,
|
|
9
|
+
DecisionParseError,
|
|
10
|
+
DecisionValueError,
|
|
11
|
+
HTTPRequestError,
|
|
12
|
+
JevHttpError,
|
|
13
|
+
)
|
|
14
|
+
from .page import Link, Page
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
'JevHttp',
|
|
18
|
+
'Page',
|
|
19
|
+
'Link',
|
|
20
|
+
'Decision',
|
|
21
|
+
'Choice',
|
|
22
|
+
'Boolean',
|
|
23
|
+
'Score',
|
|
24
|
+
'DecisionResult',
|
|
25
|
+
'Crawler',
|
|
26
|
+
'CrawlConfig',
|
|
27
|
+
'CrawlItem',
|
|
28
|
+
'JevHttpError',
|
|
29
|
+
'HTTPRequestError',
|
|
30
|
+
'AITransportError',
|
|
31
|
+
'AIResponseError',
|
|
32
|
+
'DecisionParseError',
|
|
33
|
+
'DecisionValueError',
|
|
34
|
+
]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""实现网页请求、结构化决策和模型结构化抽取"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Mapping, Type
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from openai import OpenAI
|
|
13
|
+
|
|
14
|
+
from .decisions import Boolean, Choice, Decision, Score
|
|
15
|
+
from .errors import (
|
|
16
|
+
AIResponseError,
|
|
17
|
+
AITransportError,
|
|
18
|
+
DecisionParseError,
|
|
19
|
+
DecisionValueError,
|
|
20
|
+
HTTPRequestError,
|
|
21
|
+
)
|
|
22
|
+
from .page import Page
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class DecisionResult:
|
|
27
|
+
"""保存结构化决策结果和模型调试信息"""
|
|
28
|
+
|
|
29
|
+
values: dict[str, Any]
|
|
30
|
+
raw: Any = None
|
|
31
|
+
model: str | None = None
|
|
32
|
+
usage: Any = None
|
|
33
|
+
decision_time: float | None = None
|
|
34
|
+
state_truncated: bool = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def latency(self) -> float | None:
|
|
38
|
+
"""返回本次模型决策耗时,单位为秒"""
|
|
39
|
+
return self.decision_time
|
|
40
|
+
|
|
41
|
+
def __getattr__(self, name: str) -> Any:
|
|
42
|
+
"""支持通过属性名读取决策结果"""
|
|
43
|
+
try:
|
|
44
|
+
return self.values[name]
|
|
45
|
+
except KeyError as exc:
|
|
46
|
+
raise AttributeError(name) from exc
|
|
47
|
+
|
|
48
|
+
def __getitem__(self, name: str) -> Any:
|
|
49
|
+
"""支持通过键读取决策结果"""
|
|
50
|
+
return self.values[name]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class JevHttp:
|
|
54
|
+
"""提供同步网页请求和OpenAI-compatible结构化决策"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
base_url: str,
|
|
59
|
+
api_key: str,
|
|
60
|
+
model: str,
|
|
61
|
+
timeout: float = 30,
|
|
62
|
+
max_retries: int = 2,
|
|
63
|
+
temperature: float = 0,
|
|
64
|
+
max_tokens: int | None = None,
|
|
65
|
+
extra_body: dict | None = None,
|
|
66
|
+
headers: Mapping[str, str] | None = None,
|
|
67
|
+
):
|
|
68
|
+
"""初始化客户端及其网页、模型请求配置"""
|
|
69
|
+
self.base_url = base_url
|
|
70
|
+
self.api_key = api_key
|
|
71
|
+
self.model = model
|
|
72
|
+
self.timeout = timeout
|
|
73
|
+
self.max_retries = max_retries
|
|
74
|
+
self.temperature = temperature
|
|
75
|
+
self.max_tokens = max_tokens
|
|
76
|
+
self.extra_body = extra_body or {}
|
|
77
|
+
self.session = requests.Session()
|
|
78
|
+
self.session.headers.update({'User-Agent': 'jevhttp/0.1'})
|
|
79
|
+
if headers:
|
|
80
|
+
self.session.headers.update(headers)
|
|
81
|
+
self._ai = OpenAI(
|
|
82
|
+
base_url=base_url,
|
|
83
|
+
api_key=api_key,
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
max_retries=0,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_env(cls, **kwargs) -> 'JevHttp':
|
|
90
|
+
"""从环境变量创建客户端"""
|
|
91
|
+
return cls(
|
|
92
|
+
os.environ['JEVHTTP_BASE_URL'],
|
|
93
|
+
os.environ['JEVHTTP_API_KEY'],
|
|
94
|
+
os.environ['JEVHTTP_MODEL'],
|
|
95
|
+
**kwargs,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def choice(
|
|
99
|
+
self,
|
|
100
|
+
choices,
|
|
101
|
+
question: str = '',
|
|
102
|
+
fallback: str | None = None,
|
|
103
|
+
) -> Choice:
|
|
104
|
+
"""创建中文枚举决策"""
|
|
105
|
+
choices = tuple(str(choice) for choice in choices)
|
|
106
|
+
if not choices:
|
|
107
|
+
raise ValueError('choices不能为空')
|
|
108
|
+
if fallback is not None and fallback not in choices:
|
|
109
|
+
raise ValueError('fallback必须是choices中的一项')
|
|
110
|
+
return Choice('choice', question, choices, fallback)
|
|
111
|
+
|
|
112
|
+
def boolean(self, question: str = '') -> Boolean:
|
|
113
|
+
"""创建布尔决策"""
|
|
114
|
+
return Boolean('boolean', question)
|
|
115
|
+
|
|
116
|
+
noul = boolean
|
|
117
|
+
|
|
118
|
+
def score(
|
|
119
|
+
self,
|
|
120
|
+
question: str = '',
|
|
121
|
+
min: float = 0,
|
|
122
|
+
max: float = 1,
|
|
123
|
+
) -> Score:
|
|
124
|
+
"""创建指定范围内的数值决策"""
|
|
125
|
+
if min > max:
|
|
126
|
+
raise ValueError('min不能大于max')
|
|
127
|
+
return Score('score', question, min, max)
|
|
128
|
+
|
|
129
|
+
def _request(self, method: str, url: str, **kwargs):
|
|
130
|
+
"""发送网页请求并执行有限重试"""
|
|
131
|
+
last_error = None
|
|
132
|
+
timeout = kwargs.pop('timeout', self.timeout)
|
|
133
|
+
retry_post = kwargs.pop('retry_post', False)
|
|
134
|
+
for attempt in range(self.max_retries + 1):
|
|
135
|
+
try:
|
|
136
|
+
response = self.session.request(
|
|
137
|
+
method,
|
|
138
|
+
url,
|
|
139
|
+
timeout=timeout,
|
|
140
|
+
**kwargs,
|
|
141
|
+
)
|
|
142
|
+
if response.status_code == 429 or response.status_code >= 500:
|
|
143
|
+
response.raise_for_status()
|
|
144
|
+
return response
|
|
145
|
+
except requests.RequestException as exc:
|
|
146
|
+
last_error = exc
|
|
147
|
+
should_stop = attempt >= self.max_retries
|
|
148
|
+
should_stop = should_stop or (
|
|
149
|
+
method.upper() == 'POST' and not retry_post
|
|
150
|
+
)
|
|
151
|
+
if should_stop:
|
|
152
|
+
break
|
|
153
|
+
time.sleep(min(2**attempt * 0.25, 2))
|
|
154
|
+
raise HTTPRequestError(
|
|
155
|
+
f'HTTP请求失败: method={method}, url={url}, error={last_error}'
|
|
156
|
+
) from last_error
|
|
157
|
+
|
|
158
|
+
def get(self, url: str, **kwargs) -> Page:
|
|
159
|
+
"""获取网页并返回解析后的``Page``对象"""
|
|
160
|
+
return self._page_request('GET', url, **kwargs)
|
|
161
|
+
|
|
162
|
+
def post(self, url: str, data=None, **kwargs) -> Page:
|
|
163
|
+
"""提交表单并返回解析后的``Page``对象"""
|
|
164
|
+
return self._page_request('POST', url, data=data, **kwargs)
|
|
165
|
+
|
|
166
|
+
def _page_request(self, method: str, url: str, depth: int = 0, **kwargs):
|
|
167
|
+
"""发送请求并将响应转换为``Page``"""
|
|
168
|
+
response = self._request(method, url, **kwargs)
|
|
169
|
+
return Page.from_response(response, depth=depth)
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _state_json(state, max_chars: int = 8000) -> tuple[str, bool]:
|
|
173
|
+
"""序列化并限制送入模型的状态长度"""
|
|
174
|
+
value = state.to_state() if isinstance(state, Page) else state
|
|
175
|
+
encoded = json.dumps(
|
|
176
|
+
value,
|
|
177
|
+
ensure_ascii=False,
|
|
178
|
+
separators=(',', ':'),
|
|
179
|
+
default=str,
|
|
180
|
+
)
|
|
181
|
+
return encoded[:max_chars], len(encoded) > max_chars
|
|
182
|
+
|
|
183
|
+
def _chat_json(self, system: str, user: str, schema: dict):
|
|
184
|
+
"""调用模型并优先请求JSON Schema输出"""
|
|
185
|
+
request_body = {
|
|
186
|
+
'model': self.model,
|
|
187
|
+
'temperature': self.temperature,
|
|
188
|
+
'messages': [
|
|
189
|
+
{'role': 'system', 'content': system},
|
|
190
|
+
{'role': 'user', 'content': user},
|
|
191
|
+
],
|
|
192
|
+
'response_format': {
|
|
193
|
+
'type': 'json_schema',
|
|
194
|
+
'json_schema': {
|
|
195
|
+
'name': 'jevhttp_result',
|
|
196
|
+
'strict': True,
|
|
197
|
+
'schema': schema,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
}
|
|
201
|
+
if self.max_tokens is not None:
|
|
202
|
+
request_body['max_tokens'] = self.max_tokens
|
|
203
|
+
request_body.update(self.extra_body)
|
|
204
|
+
try:
|
|
205
|
+
response = self._ai.chat.completions.create(**request_body)
|
|
206
|
+
content = response.choices[0].message.content if response.choices else None
|
|
207
|
+
except Exception as first_error:
|
|
208
|
+
content = None
|
|
209
|
+
if not content:
|
|
210
|
+
request_body['response_format'] = {'type': 'json_object'}
|
|
211
|
+
try:
|
|
212
|
+
response = self._ai.chat.completions.create(**request_body)
|
|
213
|
+
except Exception as second_error:
|
|
214
|
+
raise AITransportError(
|
|
215
|
+
f'模型请求失败: {second_error}'
|
|
216
|
+
) from locals().get('first_error')
|
|
217
|
+
if not response.choices:
|
|
218
|
+
raise AIResponseError('模型响应没有choices')
|
|
219
|
+
content = response.choices[0].message.content
|
|
220
|
+
if not content:
|
|
221
|
+
finish_reason = getattr(response.choices[0], 'finish_reason', None)
|
|
222
|
+
raise AIResponseError(
|
|
223
|
+
f'模型响应没有文本内容,finish_reason={finish_reason!r}'
|
|
224
|
+
)
|
|
225
|
+
return response, content
|
|
226
|
+
|
|
227
|
+
def system_one(
|
|
228
|
+
self,
|
|
229
|
+
state,
|
|
230
|
+
decisions: Mapping[str, Decision],
|
|
231
|
+
max_chars: int = 8000,
|
|
232
|
+
) -> DecisionResult:
|
|
233
|
+
"""使用一次中文提示词完成多个结构化决策"""
|
|
234
|
+
if not decisions:
|
|
235
|
+
raise ValueError('decisions不能为空')
|
|
236
|
+
state_text, state_truncated = self._state_json(state, max_chars)
|
|
237
|
+
schema = {
|
|
238
|
+
'type': 'object',
|
|
239
|
+
'properties': {
|
|
240
|
+
name: decision.schema()
|
|
241
|
+
for name, decision in decisions.items()
|
|
242
|
+
},
|
|
243
|
+
'required': list(decisions),
|
|
244
|
+
'additionalProperties': False,
|
|
245
|
+
}
|
|
246
|
+
system = (
|
|
247
|
+
'你是一个严谨的中文结构化信息判断模型。'
|
|
248
|
+
'请只返回符合JSON Schema的JSON对象。'
|
|
249
|
+
'所有Choice分类必须原样使用候选项中的中文文本,禁止输出英文、翻译结果或新分类。'
|
|
250
|
+
'只能依据提供的网页状态判断,不要补充网页中不存在的事实。'
|
|
251
|
+
)
|
|
252
|
+
decision_text = '\n'.join(
|
|
253
|
+
f'{name}: {decision.question or "请根据网页状态判断"}'
|
|
254
|
+
for name, decision in decisions.items()
|
|
255
|
+
)
|
|
256
|
+
user = (
|
|
257
|
+
f'网页状态(JSON):\n{state_text}\n\n'
|
|
258
|
+
f'待判断项目:\n{decision_text}'
|
|
259
|
+
)
|
|
260
|
+
started = time.perf_counter()
|
|
261
|
+
response, content = self._chat_json(system, user, schema)
|
|
262
|
+
try:
|
|
263
|
+
values = json.loads(content)
|
|
264
|
+
except json.JSONDecodeError as exc:
|
|
265
|
+
raise DecisionParseError(
|
|
266
|
+
f'模型返回内容不是有效JSON: {content[:300]!r}'
|
|
267
|
+
) from exc
|
|
268
|
+
if not isinstance(values, dict):
|
|
269
|
+
raise DecisionParseError('模型结果必须是JSON对象')
|
|
270
|
+
validated = {}
|
|
271
|
+
for name, decision in decisions.items():
|
|
272
|
+
try:
|
|
273
|
+
validated[name] = decision.validate(values[name])
|
|
274
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
275
|
+
raise DecisionValueError(
|
|
276
|
+
f'决策{name!r}的结果未通过校验: {exc}'
|
|
277
|
+
) from exc
|
|
278
|
+
return DecisionResult(
|
|
279
|
+
validated,
|
|
280
|
+
raw=content,
|
|
281
|
+
model=getattr(response, 'model', self.model),
|
|
282
|
+
usage=getattr(response, 'usage', None),
|
|
283
|
+
decision_time=time.perf_counter() - started,
|
|
284
|
+
state_truncated=state_truncated,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def extract(
|
|
288
|
+
self,
|
|
289
|
+
state,
|
|
290
|
+
model: Type,
|
|
291
|
+
max_chars: int = 8000,
|
|
292
|
+
instruction: str = '',
|
|
293
|
+
):
|
|
294
|
+
"""使用中文提示词将状态抽取为Pydantic模型"""
|
|
295
|
+
if not hasattr(model, 'model_json_schema'):
|
|
296
|
+
raise TypeError('model必须是Pydantic模型类')
|
|
297
|
+
state_text, _ = self._state_json(state, max_chars)
|
|
298
|
+
system = (
|
|
299
|
+
'你是一个严谨的中文结构化信息抽取模型。'
|
|
300
|
+
'请只返回符合JSON Schema的JSON对象,不要输出解释。'
|
|
301
|
+
'只能依据输入内容抽取,无法确认的字段按Schema允许的空值处理。'
|
|
302
|
+
)
|
|
303
|
+
user = f'网页状态(JSON):\n{state_text}\n\n补充要求:\n{instruction}'
|
|
304
|
+
_, content = self._chat_json(system, user, model.model_json_schema())
|
|
305
|
+
try:
|
|
306
|
+
return model.model_validate_json(content)
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
raise DecisionValueError(f'抽取结果未通过Pydantic校验: {exc}') from exc
|
|
309
|
+
|
|
310
|
+
def crawl(self, seed_url: str, goal: str, **kwargs):
|
|
311
|
+
"""创建同步广度优先网页爬虫"""
|
|
312
|
+
from .crawler import Crawler
|
|
313
|
+
|
|
314
|
+
return Crawler(self).crawl(seed_url, goal, **kwargs)
|