travel-agent-cli 0.2.0 → 0.2.1
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.
- package/bin/cli.js +6 -6
- package/package.json +2 -2
- package/python/agents/__init__.py +19 -0
- package/python/agents/analysis_agent.py +234 -0
- package/python/agents/base.py +377 -0
- package/python/agents/collector_agent.py +304 -0
- package/python/agents/manager_agent.py +251 -0
- package/python/agents/planning_agent.py +161 -0
- package/python/agents/product_agent.py +672 -0
- package/python/agents/report_agent.py +172 -0
- package/python/analyzers/__init__.py +10 -0
- package/python/analyzers/hot_score.py +123 -0
- package/python/analyzers/ranker.py +225 -0
- package/python/analyzers/route_planner.py +86 -0
- package/python/cli/commands.py +254 -0
- package/python/collectors/__init__.py +14 -0
- package/python/collectors/ota/ctrip.py +120 -0
- package/python/collectors/ota/fliggy.py +152 -0
- package/python/collectors/weibo.py +235 -0
- package/python/collectors/wenlv.py +155 -0
- package/python/collectors/xiaohongshu.py +170 -0
- package/python/config/__init__.py +30 -0
- package/python/config/models.py +119 -0
- package/python/config/prompts.py +105 -0
- package/python/config/settings.py +172 -0
- package/python/export/__init__.py +6 -0
- package/python/export/report.py +192 -0
- package/python/main.py +632 -0
- package/python/pyproject.toml +51 -0
- package/python/scheduler/tasks.py +77 -0
- package/python/tools/fliggy_mcp.py +553 -0
- package/python/tools/flyai_tools.py +251 -0
- package/python/tools/mcp_tools.py +412 -0
- package/python/utils/__init__.py +9 -0
- package/python/utils/http.py +73 -0
- package/python/utils/storage.py +288 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""HTTP 工具模块"""
|
|
2
|
+
import httpx
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional, Dict, Any
|
|
5
|
+
from config.settings import get_settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_client() -> httpx.AsyncClient:
|
|
9
|
+
"""获取配置好的 HTTP 客户端"""
|
|
10
|
+
settings = get_settings()
|
|
11
|
+
|
|
12
|
+
headers = {
|
|
13
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
14
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
15
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# 新版 httpx 使用 transport 来配置代理
|
|
19
|
+
if settings.http_proxy or settings.https_proxy:
|
|
20
|
+
# httpx 使用 mounts 来配置代理
|
|
21
|
+
proxy_url = settings.https_proxy or settings.http_proxy
|
|
22
|
+
if proxy_url:
|
|
23
|
+
return httpx.AsyncClient(
|
|
24
|
+
timeout=httpx.Timeout(settings.request_timeout),
|
|
25
|
+
proxies=proxy_url,
|
|
26
|
+
headers=headers,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return httpx.AsyncClient(
|
|
30
|
+
timeout=httpx.Timeout(settings.request_timeout),
|
|
31
|
+
headers=headers,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def fetch_with_retry(
|
|
36
|
+
url: str,
|
|
37
|
+
method: str = "GET",
|
|
38
|
+
headers: Optional[Dict[str, str]] = None,
|
|
39
|
+
params: Optional[Dict[str, Any]] = None,
|
|
40
|
+
data: Optional[Dict[str, Any]] = None,
|
|
41
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
42
|
+
) -> Optional[httpx.Response]:
|
|
43
|
+
"""带重试的 HTTP 请求"""
|
|
44
|
+
settings = get_settings()
|
|
45
|
+
|
|
46
|
+
async with get_client() as client:
|
|
47
|
+
for attempt in range(settings.max_retries):
|
|
48
|
+
try:
|
|
49
|
+
if method == "GET":
|
|
50
|
+
response = await client.get(url, headers=headers, params=params)
|
|
51
|
+
elif method == "POST":
|
|
52
|
+
response = await client.post(
|
|
53
|
+
url, headers=headers, params=params, data=data, json=json_data
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError(f"不支持的 HTTP 方法:{method}")
|
|
57
|
+
|
|
58
|
+
if response.status_code == 200:
|
|
59
|
+
return response
|
|
60
|
+
elif response.status_code == 429:
|
|
61
|
+
# rate limited, wait and retry
|
|
62
|
+
await asyncio.sleep(settings.request_delay * (attempt + 1) * 2)
|
|
63
|
+
else:
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
|
|
66
|
+
except httpx.HTTPError as e:
|
|
67
|
+
if attempt < settings.max_retries - 1:
|
|
68
|
+
await asyncio.sleep(settings.request_delay * (attempt + 1))
|
|
69
|
+
else:
|
|
70
|
+
print(f"请求失败 {url}: {e}")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
return None
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""数据存储工具"""
|
|
2
|
+
import aiosqlite
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, List, Dict, Any
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from config.models import (
|
|
9
|
+
SocialPost,
|
|
10
|
+
WenlvInfo,
|
|
11
|
+
FlightInfo,
|
|
12
|
+
HotelInfo,
|
|
13
|
+
DestinationRecommendation,
|
|
14
|
+
)
|
|
15
|
+
from config.settings import get_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Storage:
|
|
19
|
+
"""SQLite 数据存储"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, db_path: Optional[str] = None):
|
|
22
|
+
settings = get_settings()
|
|
23
|
+
self.db_path = db_path or settings.database_path
|
|
24
|
+
self._initialized = False
|
|
25
|
+
|
|
26
|
+
async def ensure_init(self):
|
|
27
|
+
"""确保数据库已初始化"""
|
|
28
|
+
if not self._initialized:
|
|
29
|
+
await self.init_db()
|
|
30
|
+
self._initialized = True
|
|
31
|
+
|
|
32
|
+
async def init_db(self):
|
|
33
|
+
"""初始化数据库表"""
|
|
34
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
35
|
+
# 社交媒体帖子表
|
|
36
|
+
await db.execute(
|
|
37
|
+
"""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS social_posts (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
source TEXT NOT NULL,
|
|
41
|
+
title TEXT,
|
|
42
|
+
content TEXT,
|
|
43
|
+
author TEXT,
|
|
44
|
+
author_id TEXT,
|
|
45
|
+
images TEXT,
|
|
46
|
+
likes INTEGER DEFAULT 0,
|
|
47
|
+
comments INTEGER DEFAULT 0,
|
|
48
|
+
shares INTEGER DEFAULT 0,
|
|
49
|
+
tags TEXT,
|
|
50
|
+
url TEXT,
|
|
51
|
+
published_at TIMESTAMP,
|
|
52
|
+
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
53
|
+
)
|
|
54
|
+
"""
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# 文旅信息表
|
|
58
|
+
await db.execute(
|
|
59
|
+
"""
|
|
60
|
+
CREATE TABLE IF NOT EXISTS wenlv_info (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
source TEXT NOT NULL,
|
|
63
|
+
title TEXT,
|
|
64
|
+
content TEXT,
|
|
65
|
+
region TEXT,
|
|
66
|
+
url TEXT,
|
|
67
|
+
info_type TEXT,
|
|
68
|
+
published_at TIMESTAMP,
|
|
69
|
+
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
70
|
+
)
|
|
71
|
+
"""
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 航班信息表
|
|
75
|
+
await db.execute(
|
|
76
|
+
"""
|
|
77
|
+
CREATE TABLE IF NOT EXISTS flight_info (
|
|
78
|
+
id TEXT PRIMARY KEY,
|
|
79
|
+
departure_city TEXT,
|
|
80
|
+
arrival_city TEXT,
|
|
81
|
+
price REAL,
|
|
82
|
+
currency TEXT DEFAULT 'CNY',
|
|
83
|
+
airline TEXT,
|
|
84
|
+
departure_time TEXT,
|
|
85
|
+
travel_time TEXT,
|
|
86
|
+
platform TEXT DEFAULT 'fliggy',
|
|
87
|
+
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
88
|
+
)
|
|
89
|
+
"""
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# 酒店信息表
|
|
93
|
+
await db.execute(
|
|
94
|
+
"""
|
|
95
|
+
CREATE TABLE IF NOT EXISTS hotel_info (
|
|
96
|
+
id TEXT PRIMARY KEY,
|
|
97
|
+
name TEXT,
|
|
98
|
+
destination TEXT,
|
|
99
|
+
price_per_night REAL,
|
|
100
|
+
currency TEXT DEFAULT 'CNY',
|
|
101
|
+
rating REAL,
|
|
102
|
+
stars INTEGER,
|
|
103
|
+
platform TEXT DEFAULT 'fliggy',
|
|
104
|
+
url TEXT,
|
|
105
|
+
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
106
|
+
)
|
|
107
|
+
"""
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# 推荐结果表
|
|
111
|
+
await db.execute(
|
|
112
|
+
"""
|
|
113
|
+
CREATE TABLE IF NOT EXISTS recommendations (
|
|
114
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
115
|
+
name TEXT,
|
|
116
|
+
rank INTEGER,
|
|
117
|
+
score REAL,
|
|
118
|
+
reason TEXT,
|
|
119
|
+
estimated_cost TEXT,
|
|
120
|
+
suggested_days INTEGER,
|
|
121
|
+
best_time TEXT,
|
|
122
|
+
highlights TEXT,
|
|
123
|
+
flight_info_id TEXT,
|
|
124
|
+
hotel_info_id TEXT,
|
|
125
|
+
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
126
|
+
FOREIGN KEY (flight_info_id) REFERENCES flight_info(id),
|
|
127
|
+
FOREIGN KEY (hotel_info_id) REFERENCES hotel_info(id)
|
|
128
|
+
)
|
|
129
|
+
"""
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
await db.commit()
|
|
133
|
+
|
|
134
|
+
async def save_post(self, post: SocialPost):
|
|
135
|
+
"""保存社交媒体帖子"""
|
|
136
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
137
|
+
await db.execute(
|
|
138
|
+
"""
|
|
139
|
+
INSERT OR REPLACE INTO social_posts
|
|
140
|
+
(id, source, title, content, author, author_id, images, likes, comments, shares, tags, url, published_at, collected_at)
|
|
141
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
142
|
+
""",
|
|
143
|
+
(
|
|
144
|
+
post.id,
|
|
145
|
+
post.source.value,
|
|
146
|
+
post.title,
|
|
147
|
+
post.content,
|
|
148
|
+
post.author,
|
|
149
|
+
post.author_id,
|
|
150
|
+
json.dumps(post.images),
|
|
151
|
+
post.likes,
|
|
152
|
+
post.comments,
|
|
153
|
+
post.shares,
|
|
154
|
+
json.dumps(post.tags),
|
|
155
|
+
post.url,
|
|
156
|
+
post.published_at,
|
|
157
|
+
post.collected_at,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
await db.commit()
|
|
161
|
+
|
|
162
|
+
async def save_wenlv(self, info: WenlvInfo):
|
|
163
|
+
"""保存文旅信息"""
|
|
164
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
165
|
+
await db.execute(
|
|
166
|
+
"""
|
|
167
|
+
INSERT OR REPLACE INTO wenlv_info
|
|
168
|
+
(id, source, title, content, region, url, info_type, published_at, collected_at)
|
|
169
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
170
|
+
""",
|
|
171
|
+
(
|
|
172
|
+
info.id,
|
|
173
|
+
info.source.value,
|
|
174
|
+
info.title,
|
|
175
|
+
info.content,
|
|
176
|
+
info.region,
|
|
177
|
+
info.url,
|
|
178
|
+
info.info_type,
|
|
179
|
+
info.published_at,
|
|
180
|
+
info.collected_at,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
await db.commit()
|
|
184
|
+
|
|
185
|
+
async def save_flight(self, flight: FlightInfo):
|
|
186
|
+
"""保存航班信息"""
|
|
187
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
188
|
+
await db.execute(
|
|
189
|
+
"""
|
|
190
|
+
INSERT OR REPLACE INTO flight_info
|
|
191
|
+
(id, departure_city, arrival_city, price, currency, airline, departure_time, travel_time, platform)
|
|
192
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
193
|
+
""",
|
|
194
|
+
(
|
|
195
|
+
flight.id,
|
|
196
|
+
flight.departure_city,
|
|
197
|
+
flight.arrival_city,
|
|
198
|
+
flight.price,
|
|
199
|
+
flight.currency,
|
|
200
|
+
flight.airline,
|
|
201
|
+
flight.departure_time,
|
|
202
|
+
flight.travel_time,
|
|
203
|
+
flight.platform,
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
await db.commit()
|
|
207
|
+
|
|
208
|
+
async def save_hotel(self, hotel: HotelInfo):
|
|
209
|
+
"""保存酒店信息"""
|
|
210
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
211
|
+
await db.execute(
|
|
212
|
+
"""
|
|
213
|
+
INSERT OR REPLACE INTO hotel_info
|
|
214
|
+
(id, name, destination, price_per_night, currency, rating, stars, platform, url)
|
|
215
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
216
|
+
""",
|
|
217
|
+
(
|
|
218
|
+
hotel.id,
|
|
219
|
+
hotel.name,
|
|
220
|
+
hotel.destination,
|
|
221
|
+
hotel.price_per_night,
|
|
222
|
+
hotel.currency,
|
|
223
|
+
hotel.rating,
|
|
224
|
+
hotel.stars,
|
|
225
|
+
hotel.platform,
|
|
226
|
+
hotel.url,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
await db.commit()
|
|
230
|
+
|
|
231
|
+
async def get_recent_posts(
|
|
232
|
+
self, source: Optional[str] = None, limit: int = 100
|
|
233
|
+
) -> List[SocialPost]:
|
|
234
|
+
"""获取最近的帖子"""
|
|
235
|
+
await self.ensure_init()
|
|
236
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
237
|
+
db.row_factory = aiosqlite.Row
|
|
238
|
+
query = "SELECT * FROM social_posts ORDER BY collected_at DESC LIMIT ?"
|
|
239
|
+
if source:
|
|
240
|
+
query = (
|
|
241
|
+
"SELECT * FROM social_posts WHERE source = ? ORDER BY collected_at DESC LIMIT ?"
|
|
242
|
+
)
|
|
243
|
+
cursor = await db.execute(query, (source, limit))
|
|
244
|
+
else:
|
|
245
|
+
cursor = await db.execute(query, (limit,))
|
|
246
|
+
|
|
247
|
+
rows = await cursor.fetchall()
|
|
248
|
+
return [
|
|
249
|
+
SocialPost(
|
|
250
|
+
id=row["id"],
|
|
251
|
+
source=row["source"],
|
|
252
|
+
title=row["title"],
|
|
253
|
+
content=row["content"],
|
|
254
|
+
author=row["author"],
|
|
255
|
+
author_id=row["author_id"],
|
|
256
|
+
images=json.loads(row["images"] or "[]"),
|
|
257
|
+
likes=row["likes"],
|
|
258
|
+
comments=row["comments"],
|
|
259
|
+
shares=row["shares"],
|
|
260
|
+
tags=json.loads(row["tags"] or "[]"),
|
|
261
|
+
url=row["url"],
|
|
262
|
+
published_at=row["published_at"],
|
|
263
|
+
)
|
|
264
|
+
for row in rows
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
async def get_recent_wenlv(self, limit: int = 100) -> List[WenlvInfo]:
|
|
268
|
+
"""获取最近的文旅信息"""
|
|
269
|
+
await self.ensure_init()
|
|
270
|
+
async with aiosqlite.connect(self.db_path) as db:
|
|
271
|
+
db.row_factory = aiosqlite.Row
|
|
272
|
+
cursor = await db.execute(
|
|
273
|
+
"SELECT * FROM wenlv_info ORDER BY collected_at DESC LIMIT ?", (limit,)
|
|
274
|
+
)
|
|
275
|
+
rows = await cursor.fetchall()
|
|
276
|
+
return [
|
|
277
|
+
WenlvInfo(
|
|
278
|
+
id=row["id"],
|
|
279
|
+
source=row["source"],
|
|
280
|
+
title=row["title"],
|
|
281
|
+
content=row["content"],
|
|
282
|
+
region=row["region"],
|
|
283
|
+
url=row["url"],
|
|
284
|
+
info_type=row["info_type"],
|
|
285
|
+
published_at=row["published_at"],
|
|
286
|
+
)
|
|
287
|
+
for row in rows
|
|
288
|
+
]
|