travel-agent-cli 0.2.0 → 0.2.2

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.
Files changed (37) hide show
  1. package/bin/cli.js +6 -6
  2. package/package.json +2 -2
  3. package/python/agents/__init__.py +19 -0
  4. package/python/agents/analysis_agent.py +234 -0
  5. package/python/agents/base.py +377 -0
  6. package/python/agents/collector_agent.py +304 -0
  7. package/python/agents/manager_agent.py +251 -0
  8. package/python/agents/planning_agent.py +161 -0
  9. package/python/agents/product_agent.py +672 -0
  10. package/python/agents/report_agent.py +172 -0
  11. package/python/analyzers/__init__.py +10 -0
  12. package/python/analyzers/hot_score.py +123 -0
  13. package/python/analyzers/ranker.py +225 -0
  14. package/python/analyzers/route_planner.py +86 -0
  15. package/python/cli/commands.py +254 -0
  16. package/python/collectors/__init__.py +14 -0
  17. package/python/collectors/ota/ctrip.py +120 -0
  18. package/python/collectors/ota/fliggy.py +152 -0
  19. package/python/collectors/weibo.py +235 -0
  20. package/python/collectors/wenlv.py +155 -0
  21. package/python/collectors/xiaohongshu.py +170 -0
  22. package/python/config/__init__.py +30 -0
  23. package/python/config/models.py +119 -0
  24. package/python/config/prompts.py +105 -0
  25. package/python/config/settings.py +172 -0
  26. package/python/export/__init__.py +6 -0
  27. package/python/export/report.py +192 -0
  28. package/python/main.py +632 -0
  29. package/python/pyproject.toml +51 -0
  30. package/python/scheduler/tasks.py +77 -0
  31. package/python/tools/fliggy_mcp.py +553 -0
  32. package/python/tools/flyai_tools.py +251 -0
  33. package/python/tools/mcp_tools.py +412 -0
  34. package/python/utils/__init__.py +9 -0
  35. package/python/utils/http.py +73 -0
  36. package/python/utils/storage.py +288 -0
  37. package/scripts/postinstall.js +59 -65
@@ -0,0 +1,254 @@
1
+ """旅行 Agent CLI - 参考 Claude Code 设计模式
2
+
3
+ 命令设计原则:
4
+ 1. 简洁的命令定义,包含清晰的描述
5
+ 2. 支持别名(aliases)
6
+ 3. 参数提示(argumentHint)
7
+ 4. 即时执行(immediate)vs 延迟执行
8
+ 5. 支持交互模式和非交互模式
9
+ 6. 输出格式化(使用 rich 库)
10
+ """
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable, Awaitable, Optional, List, Dict, Any, Union
13
+ from enum import Enum
14
+
15
+
16
+ class CommandMode(Enum):
17
+ """命令执行模式"""
18
+ INTERACTIVE = "interactive" # 交互模式(默认)
19
+ NON_INTERACTIVE = "non-interactive" # 非交互模式(脚本化)
20
+ BOTH = "both" # 两种模式都支持
21
+
22
+
23
+ class CommandType(Enum):
24
+ """命令类型"""
25
+ PROMPT = "prompt" # 触发 LLM 对话
26
+ LOCAL = "local" # 本地执行
27
+ LOCAL_JSX = "local-jsx" # 本地执行带 UI 组件
28
+
29
+
30
+ @dataclass
31
+ class CommandArg:
32
+ """命令参数定义"""
33
+ name: str
34
+ description: str
35
+ required: bool = False
36
+ default: Any = None
37
+ type_hint: str = "TEXT"
38
+ choices: Optional[List[str]] = None
39
+
40
+
41
+ @dataclass
42
+ class Command:
43
+ """命令定义(参考 Claude Code Command 接口)
44
+
45
+ 示例:
46
+ run_command = Command(
47
+ name="run",
48
+ aliases=["start", "execute"],
49
+ description="运行完整工作流",
50
+ argument_hint="<keyword>",
51
+ immediate=True,
52
+ supports_non_interactive=True,
53
+ handler=run_workflow,
54
+ args=[
55
+ CommandArg("keyword", "搜索关键词", default="旅行"),
56
+ CommandArg("top_n", "推荐数量", type_hint="INTEGER", default=10),
57
+ ]
58
+ )
59
+ """
60
+ name: str
61
+ description: str
62
+ handler: Callable[..., Awaitable[Any]]
63
+
64
+ # 命令元数据
65
+ aliases: List[str] = field(default_factory=list)
66
+ argument_hint: Optional[str] = None # 参数提示,如 "[options]" 或 "<prompt>"
67
+ immediate: bool = False # 是否立即执行(不等待确认)
68
+ supports_non_interactive: bool = True # 是否支持非交互模式
69
+ hidden: bool = False # 是否在帮助中隐藏
70
+
71
+ # 参数定义
72
+ args: List[CommandArg] = field(default_factory=list)
73
+
74
+ # 执行模式
75
+ mode: CommandMode = CommandMode.BOTH
76
+
77
+ # 短描述(用于帮助列表,长描述的截取版)
78
+ short_description: Optional[str] = None
79
+
80
+ def __post_init__(self):
81
+ if self.short_description is None:
82
+ # 截取 description 的前 60 个字符作为短描述
83
+ self.short_description = self.description[:60] + (
84
+ "..." if len(self.description) > 60 else ""
85
+ )
86
+
87
+
88
+ class CommandRegistry:
89
+ """命令注册表(参考 Claude Code 的命令系统)
90
+
91
+ 使用示例:
92
+ registry = CommandRegistry()
93
+
94
+ # 注册命令
95
+ @registry.command(
96
+ name="run",
97
+ aliases=["start"],
98
+ description="运行完整工作流",
99
+ argument_hint="[options]",
100
+ )
101
+ async def run_workflow(keyword: str = "旅行"):
102
+ ...
103
+
104
+ # 获取命令
105
+ cmd = registry.get("run")
106
+
107
+ # 列出所有命令
108
+ for cmd in registry.list_commands():
109
+ print(f" {cmd.name}: {cmd.short_description}")
110
+ """
111
+
112
+ def __init__(self):
113
+ self._commands: Dict[str, Command] = {}
114
+
115
+ def command(
116
+ self,
117
+ name: str,
118
+ aliases: Optional[List[str]] = None,
119
+ description: str = "",
120
+ argument_hint: Optional[str] = None,
121
+ immediate: bool = False,
122
+ supports_non_interactive: bool = True,
123
+ ):
124
+ """装饰器:注册命令
125
+
126
+ 使用示例:
127
+ @registry.command(
128
+ name="run",
129
+ description="运行完整工作流",
130
+ argument_hint="[options]",
131
+ )
132
+ async def run_workflow(args):
133
+ ...
134
+ """
135
+ def decorator(func: Callable[..., Awaitable[Any]]) -> Command:
136
+ cmd = Command(
137
+ name=name,
138
+ description=description,
139
+ handler=func,
140
+ aliases=aliases or [],
141
+ argument_hint=argument_hint,
142
+ immediate=immediate,
143
+ supports_non_interactive=supports_non_interactive,
144
+ )
145
+ self.register(cmd)
146
+ return cmd
147
+ return decorator
148
+
149
+ def register(self, cmd: Command) -> None:
150
+ """注册命令"""
151
+ # 注册主名称
152
+ self._commands[cmd.name] = cmd
153
+
154
+ # 注册别名
155
+ for alias in cmd.aliases:
156
+ if alias in self._commands:
157
+ raise ValueError(f"命令别名 '{alias}' 已存在")
158
+ self._commands[alias] = cmd
159
+
160
+ def get(self, name: str) -> Optional[Command]:
161
+ """获取命令"""
162
+ return self._commands.get(name)
163
+
164
+ def list_commands(self, include_hidden: bool = False) -> List[Command]:
165
+ """列出所有命令"""
166
+ cmds = list(self._commands.values())
167
+ # 去重(因为别名指向同一个 Command 对象)
168
+ seen = set()
169
+ unique = []
170
+ for cmd in cmds:
171
+ if cmd.name not in seen:
172
+ seen.add(cmd.name)
173
+ if not cmd.hidden or include_hidden:
174
+ unique.append(cmd)
175
+ return unique
176
+
177
+ def has(self, name: str) -> bool:
178
+ """检查命令是否存在"""
179
+ return name in self._commands
180
+
181
+ def execute(
182
+ self,
183
+ name: str,
184
+ args: Optional[List[str]] = None,
185
+ **kwargs
186
+ ) -> Awaitable[Any]:
187
+ """执行命令"""
188
+ cmd = self.get(name)
189
+ if not cmd:
190
+ raise ValueError(f"未知命令:{name}")
191
+ return cmd.handler(*args or [], **kwargs)
192
+
193
+
194
+ # =============================================================================
195
+ # 输出格式化(参考 Claude Code print.ts)
196
+ # =============================================================================
197
+
198
+ class OutputFormatter:
199
+ """输出格式化工具"""
200
+
201
+ @staticmethod
202
+ def format_command_list(commands: List[Command]) -> str:
203
+ """格式化命令列表输出"""
204
+ lines = []
205
+
206
+ # 分组:常用命令和其他命令
207
+ common_commands = [c for c in commands if c.immediate]
208
+ other_commands = [c for c in commands if not c.immediate and not c.hidden]
209
+
210
+ if common_commands:
211
+ lines.append("常用命令:")
212
+ for cmd in sorted(common_commands, key=lambda x: x.name):
213
+ hint = f" {cmd.argument_hint}" if cmd.argument_hint else ""
214
+ lines.append(f" {cmd.name}{hint}")
215
+ lines.append(f" {cmd.short_description}")
216
+ lines.append("")
217
+
218
+ if other_commands:
219
+ lines.append("其他命令:")
220
+ for cmd in sorted(other_commands, key=lambda x: x.name):
221
+ hint = f" {cmd.argument_hint}" if cmd.argument_hint else ""
222
+ aliases_str = f" (别名:{', '.join(cmd.aliases)})" if cmd.aliases else ""
223
+ lines.append(f" {cmd.name}{hint}{aliases_str}")
224
+ lines.append(f" {cmd.short_description}")
225
+
226
+ return "\n".join(lines)
227
+
228
+ @staticmethod
229
+ def format_command_help(cmd: Command) -> str:
230
+ """格式化单个命令的帮助信息"""
231
+ lines = []
232
+
233
+ # 命令名称和参数提示
234
+ header = cmd.name
235
+ if cmd.argument_hint:
236
+ header += f" {cmd.argument_hint}"
237
+ if cmd.aliases:
238
+ header += f" (别名:{', '.join(cmd.aliases)})"
239
+
240
+ lines.append(f"用法:{header}")
241
+ lines.append("")
242
+ lines.append(cmd.description)
243
+ lines.append("")
244
+
245
+ # 参数列表
246
+ if cmd.args:
247
+ lines.append("参数:")
248
+ for arg in cmd.args:
249
+ req = "必填" if arg.required else "可选"
250
+ default = f" [默认:{arg.default}]" if arg.default is not None else ""
251
+ lines.append(f" {arg.name} ({arg.type_hint}, {req}){default}")
252
+ lines.append(f" {arg.description}")
253
+
254
+ return "\n".join(lines)
@@ -0,0 +1,14 @@
1
+ """数据采集模块"""
2
+ from collectors.xiaohongshu import XiaohongshuCollector
3
+ from collectors.weibo import WeiboCollector
4
+ from collectors.wenlv import WenlvCollector
5
+ from collectors.ota.fliggy import FliggyCollector
6
+ from collectors.ota.ctrip import CtripCollector
7
+
8
+ __all__ = [
9
+ "XiaohongshuCollector",
10
+ "WeiboCollector",
11
+ "WenlvCollector",
12
+ "FliggyCollector",
13
+ "CtripCollector",
14
+ ]
@@ -0,0 +1,120 @@
1
+ """携程数据采集器
2
+
3
+ 爬取携程平台的机票和酒店价格
4
+ 参考:知乎 2025 爬虫教程
5
+ """
6
+ import asyncio
7
+ from typing import List, Optional
8
+ from datetime import datetime
9
+
10
+ from config.models import FlightInfo, HotelInfo
11
+ from utils.storage import Storage
12
+ from utils.http import get_client
13
+
14
+
15
+ class CtripCollector:
16
+ """携程 OTA 采集器"""
17
+
18
+ def __init__(self):
19
+ self.storage = Storage()
20
+ self.base_url = "https://www.ctrip.com"
21
+
22
+ async def search_flights(
23
+ self,
24
+ departure_city: str,
25
+ arrival_city: str,
26
+ departure_date: Optional[str] = None,
27
+ ) -> List[FlightInfo]:
28
+ """搜索机票价格"""
29
+ print(f"携程搜索机票:{departure_city} -> {arrival_city}")
30
+
31
+ # 返回示例数据
32
+ return self._get_sample_flights(departure_city, arrival_city)
33
+
34
+ async def search_hotels(
35
+ self,
36
+ destination: str,
37
+ check_in: Optional[str] = None,
38
+ check_out: Optional[str] = None,
39
+ ) -> List[HotelInfo]:
40
+ """搜索酒店价格"""
41
+ print(f"携程搜索酒店:{destination}")
42
+
43
+ # 返回示例数据
44
+ return self._get_sample_hotels(destination)
45
+
46
+ def _get_sample_flights(
47
+ self, departure: str, arrival: str
48
+ ) -> List[FlightInfo]:
49
+ """示例航班数据"""
50
+ samples = [
51
+ {
52
+ "id": f"ctrip_flight_{departure}_{arrival}_001",
53
+ "departure_city": departure,
54
+ "arrival_city": arrival,
55
+ "price": 750 + hash(departure + arrival) % 500,
56
+ "airline": "中国国航",
57
+ "departure_time": "07:30",
58
+ "travel_time": "2h 30m",
59
+ },
60
+ {
61
+ "id": f"ctrip_flight_{departure}_{arrival}_002",
62
+ "departure_city": departure,
63
+ "arrival_city": arrival,
64
+ "price": 680 + hash(departure + arrival) % 400,
65
+ "airline": "海南航空",
66
+ "departure_time": "12:00",
67
+ "travel_time": "2h 40m",
68
+ },
69
+ ]
70
+
71
+ return [
72
+ FlightInfo(
73
+ id=s["id"],
74
+ departure_city=s["departure_city"],
75
+ arrival_city=s["arrival_city"],
76
+ price=s["price"],
77
+ airline=s.get("airline"),
78
+ departure_time=s.get("departure_time"),
79
+ travel_time=s.get("travel_time"),
80
+ platform="ctrip",
81
+ )
82
+ for s in samples
83
+ ]
84
+
85
+ def _get_sample_hotels(self, destination: str) -> List[HotelInfo]:
86
+ """示例酒店数据"""
87
+ samples = [
88
+ {
89
+ "id": f"ctrip_hotel_{destination}_001",
90
+ "name": f"{destination}希尔顿酒店",
91
+ "destination": destination,
92
+ "price_per_night": 600 + hash(destination) % 300,
93
+ "rating": 4.7,
94
+ "stars": 5,
95
+ "url": f"https://www.ctrip.com/hotel/{destination}_001",
96
+ },
97
+ {
98
+ "id": f"ctrip_hotel_{destination}_002",
99
+ "name": f"{destination}亚朵酒店",
100
+ "destination": destination,
101
+ "price_per_night": 350 + hash(destination) % 200,
102
+ "rating": 4.6,
103
+ "stars": 4,
104
+ "url": f"https://www.ctrip.com/hotel/{destination}_002",
105
+ },
106
+ ]
107
+
108
+ return [
109
+ HotelInfo(
110
+ id=s["id"],
111
+ name=s["name"],
112
+ destination=s["destination"],
113
+ price_per_night=s["price_per_night"],
114
+ rating=s.get("rating"),
115
+ stars=s.get("stars"),
116
+ platform="ctrip",
117
+ url=s.get("url"),
118
+ )
119
+ for s in samples
120
+ ]
@@ -0,0 +1,152 @@
1
+ """飞猪数据采集器
2
+
3
+ 爬取飞猪平台的机票和酒店价格
4
+ 参考:RealDataAPI, 知乎 2025 爬虫教程
5
+ """
6
+ import asyncio
7
+ from typing import List, Optional, Dict
8
+ from datetime import datetime, timedelta
9
+
10
+ from config.models import FlightInfo, HotelInfo
11
+ from utils.storage import Storage
12
+ from utils.http import get_client
13
+
14
+
15
+ class FliggyCollector:
16
+ """飞猪 OTA 采集器"""
17
+
18
+ def __init__(self):
19
+ self.storage = Storage()
20
+ self.base_url = "https://www.fliggy.com"
21
+
22
+ async def search_flights(
23
+ self,
24
+ departure_city: str,
25
+ arrival_city: str,
26
+ departure_date: Optional[str] = None,
27
+ ) -> List[FlightInfo]:
28
+ """搜索机票价格
29
+
30
+ Args:
31
+ departure_city: 出发城市
32
+ arrival_city: 到达城市
33
+ departure_date: 出发日期,格式 YYYY-MM-DD
34
+
35
+ 注意:飞猪有严格反爬,需要处理:
36
+ 1. 动态加载(使用 Playwright)
37
+ 2. 验证码
38
+ 3. Cookie 验证
39
+ """
40
+ # 简化示例,实际需要集成 Playwright
41
+ print(f"搜索机票:{departure_city} -> {arrival_city}")
42
+
43
+ # 返回示例数据
44
+ return self._get_sample_flights(departure_city, arrival_city)
45
+
46
+ async def search_hotels(
47
+ self,
48
+ destination: str,
49
+ check_in: Optional[str] = None,
50
+ check_out: Optional[str] = None,
51
+ price_min: Optional[int] = None,
52
+ price_max: Optional[int] = None,
53
+ ) -> List[HotelInfo]:
54
+ """搜索酒店价格"""
55
+ print(f"搜索酒店:{destination}")
56
+
57
+ # 返回示例数据
58
+ return self._get_sample_hotels(destination)
59
+
60
+ def _get_sample_flights(
61
+ self, departure: str, arrival: str
62
+ ) -> List[FlightInfo]:
63
+ """示例航班数据"""
64
+ samples = [
65
+ {
66
+ "id": f"fliggy_flight_{departure}_{arrival}_001",
67
+ "departure_city": departure,
68
+ "arrival_city": arrival,
69
+ "price": 800 + hash(departure + arrival) % 500,
70
+ "airline": "中国国航",
71
+ "departure_time": "08:00",
72
+ "travel_time": "2h 30m",
73
+ },
74
+ {
75
+ "id": f"fliggy_flight_{departure}_{arrival}_002",
76
+ "departure_city": departure,
77
+ "arrival_city": arrival,
78
+ "price": 650 + hash(departure + arrival) % 400,
79
+ "airline": "南方航空",
80
+ "departure_time": "14:30",
81
+ "travel_time": "2h 45m",
82
+ },
83
+ {
84
+ "id": f"fliggy_flight_{departure}_{arrival}_003",
85
+ "departure_city": departure,
86
+ "arrival_city": arrival,
87
+ "price": 1200 + hash(departure + arrival) % 600,
88
+ "airline": "东方航空",
89
+ "departure_time": "19:00",
90
+ "travel_time": "2h 20m",
91
+ },
92
+ ]
93
+
94
+ return [
95
+ FlightInfo(
96
+ id=s["id"],
97
+ departure_city=s["departure_city"],
98
+ arrival_city=s["arrival_city"],
99
+ price=s["price"],
100
+ airline=s.get("airline"),
101
+ departure_time=s.get("departure_time"),
102
+ travel_time=s.get("travel_time"),
103
+ platform="fliggy",
104
+ )
105
+ for s in samples
106
+ ]
107
+
108
+ def _get_sample_hotels(self, destination: str) -> List[HotelInfo]:
109
+ """示例酒店数据"""
110
+ samples = [
111
+ {
112
+ "id": f"fliggy_hotel_{destination}_001",
113
+ "name": f"{destination}国际大酒店",
114
+ "destination": destination,
115
+ "price_per_night": 500 + hash(destination) % 300,
116
+ "rating": 4.8,
117
+ "stars": 5,
118
+ "url": f"https://www.fliggy.com/hotel/{destination}_001",
119
+ },
120
+ {
121
+ "id": f"fliggy_hotel_{destination}_002",
122
+ "name": f"{destination}精品民宿",
123
+ "destination": destination,
124
+ "price_per_night": 280 + hash(destination) % 200,
125
+ "rating": 4.5,
126
+ "stars": None,
127
+ "url": f"https://www.fliggy.com/hotel/{destination}_002",
128
+ },
129
+ {
130
+ "id": f"fliggy_hotel_{destination}_003",
131
+ "name": f"{destination}经济酒店",
132
+ "destination": destination,
133
+ "price_per_night": 150 + hash(destination) % 100,
134
+ "rating": 4.2,
135
+ "stars": 3,
136
+ "url": f"https://www.fliggy.com/hotel/{destination}_003",
137
+ },
138
+ ]
139
+
140
+ return [
141
+ HotelInfo(
142
+ id=s["id"],
143
+ name=s["name"],
144
+ destination=s["destination"],
145
+ price_per_night=s["price_per_night"],
146
+ rating=s.get("rating"),
147
+ stars=s.get("stars"),
148
+ platform="fliggy",
149
+ url=s.get("url"),
150
+ )
151
+ for s in samples
152
+ ]