htmlgen-mcp 0.2.0__py3-none-any.whl

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.

Potentially problematic release.


This version of htmlgen-mcp might be problematic. Click here for more details.

@@ -0,0 +1,541 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ EdgeOne Pages 部署工具
5
+ 用于将构建好的网站文件夹或ZIP文件部署到腾讯云EdgeOne Pages
6
+ """
7
+
8
+ import os
9
+ import json
10
+ import time
11
+ import zipfile
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Any, Tuple
15
+ import requests
16
+ from dotenv import load_dotenv
17
+
18
+ # 加载环境变量
19
+ load_dotenv()
20
+
21
+ # API端点配置
22
+ BASE_API_URL1 = 'https://pages-api.cloud.tencent.com/v1'
23
+ BASE_API_URL2 = 'https://pages-api.edgeone.ai/v1'
24
+
25
+ class EdgeOneDeployError(Exception):
26
+ """EdgeOne部署相关异常"""
27
+ pass
28
+
29
+ class EdgeOneDeployer:
30
+ """EdgeOne Pages 部署器"""
31
+
32
+ def __init__(self):
33
+ self.base_api_url = ""
34
+ self.api_token = self._get_api_token()
35
+ self.project_name = os.getenv('EDGEONE_PAGES_PROJECT_NAME', '')
36
+ self.temp_project_name = None
37
+ self.deployment_logs = []
38
+
39
+ def _get_api_token(self) -> str:
40
+ """获取API令牌"""
41
+ token = os.getenv('EDGEONE_PAGES_API_TOKEN')
42
+ if not token:
43
+ raise EdgeOneDeployError(
44
+ 'Missing EDGEONE_PAGES_API_TOKEN. Please set it as an environment variable.'
45
+ )
46
+ return token
47
+
48
+ def _get_temp_project_name(self) -> str:
49
+ """生成临时项目名"""
50
+ if not self.temp_project_name:
51
+ # 基于当前工作目录生成项目名,或者如果有local_path,使用其基名
52
+ import os
53
+ if hasattr(self, 'local_path') and self.local_path:
54
+ folder_name = os.path.basename(os.path.abspath(self.local_path))
55
+ else:
56
+ folder_name = os.path.basename(os.getcwd())
57
+
58
+ # 清理文件夹名称,确保符合EdgeOne项目名称要求:只能包含小写字母、数字和短划线
59
+ clean_name = ''.join(c.lower() if c.isalnum() else '-' for c in folder_name if c.isalnum() or c in '-_')
60
+ # 移除开头和结尾的短划线,并去除连续的短划线
61
+ clean_name = '-'.join(filter(None, clean_name.split('-')))
62
+
63
+ if not clean_name or len(clean_name) < 3:
64
+ clean_name = "local-upload"
65
+
66
+ # 添加时间戳确保唯一性
67
+ self.temp_project_name = f"{clean_name}-{int(time.time())}"
68
+ return self.temp_project_name
69
+
70
+ def _log(self, level: str, message: str) -> None:
71
+ """记录日志"""
72
+ log_entry = {
73
+ 'timestamp': time.time(),
74
+ 'level': level,
75
+ 'message': message
76
+ }
77
+ self.deployment_logs.append(log_entry)
78
+ print(f"[{level}] {message}")
79
+
80
+ async def _check_and_set_base_url(self) -> None:
81
+ """检测并设置可用的API端点"""
82
+ headers = {
83
+ 'Authorization': f'Bearer {self.api_token}',
84
+ 'Content-Type': 'application/json'
85
+ }
86
+
87
+ body = {
88
+ 'Action': 'DescribePagesProjects',
89
+ 'PageNumber': 1,
90
+ 'PageSize': 10
91
+ }
92
+
93
+ # 测试第一个端点
94
+ try:
95
+ response1 = requests.post(BASE_API_URL1, headers=headers, json=body, timeout=10)
96
+ json1 = response1.json()
97
+ if json1.get('Code') == 0:
98
+ self.base_api_url = BASE_API_URL1
99
+ self._log('INFO', 'Using BASE_API_URL1 endpoint')
100
+ return
101
+ except Exception:
102
+ pass
103
+
104
+ # 测试第二个端点
105
+ try:
106
+ response2 = requests.post(BASE_API_URL2, headers=headers, json=body, timeout=10)
107
+ json2 = response2.json()
108
+ if json2.get('Code') == 0:
109
+ self.base_api_url = BASE_API_URL2
110
+ self._log('INFO', 'Using BASE_API_URL2 endpoint')
111
+ return
112
+ except Exception:
113
+ pass
114
+
115
+ raise EdgeOneDeployError(
116
+ 'Invalid EDGEONE_PAGES_API_TOKEN or API endpoints unreachable. '
117
+ 'Please check your API token and network connection.'
118
+ )
119
+
120
+ def _make_api_request(self, action: str, data: Dict[str, Any]) -> Dict[str, Any]:
121
+ """发起API请求"""
122
+ headers = {
123
+ 'Authorization': f'Bearer {self.api_token}',
124
+ 'Content-Type': 'application/json'
125
+ }
126
+
127
+ body = {'Action': action, **data}
128
+
129
+ try:
130
+ response = requests.post(self.base_api_url, headers=headers, json=body, timeout=30)
131
+ response.raise_for_status()
132
+ return response.json()
133
+ except requests.RequestException as e:
134
+ raise EdgeOneDeployError(f'API request failed: {str(e)}')
135
+
136
+ def _describe_pages_projects(self, project_id: Optional[str] = None,
137
+ project_name: Optional[str] = None) -> Dict[str, Any]:
138
+ """查询Pages项目"""
139
+ filters = []
140
+ if project_id:
141
+ filters.append({'Name': 'ProjectId', 'Values': [project_id]})
142
+ if project_name:
143
+ filters.append({'Name': 'Name', 'Values': [project_name]})
144
+
145
+ data = {
146
+ 'Filters': filters,
147
+ 'Offset': 0,
148
+ 'Limit': 10,
149
+ 'OrderBy': 'CreatedOn'
150
+ }
151
+
152
+ return self._make_api_request('DescribePagesProjects', data)
153
+
154
+ def _create_pages_project(self) -> Dict[str, Any]:
155
+ """创建Pages项目"""
156
+ project_name = self.project_name or self._get_temp_project_name()
157
+
158
+ # 验证和清理项目名称,确保符合EdgeOne要求
159
+ def validate_project_name(name: str) -> str:
160
+ """验证并清理项目名称,确保只包含小写字母、数字和短划线"""
161
+ if not name or name == "your_project_name":
162
+ # 如果名称无效,使用临时名称
163
+ return self._get_temp_project_name()
164
+
165
+ # 清理名称:转换为小写,替换无效字符为短划线
166
+ clean_name = ''.join(c.lower() if c.isalnum() else '-' for c in name if c.isalnum() or c in '-_')
167
+ # 移除开头和结尾的短划线,并去除连续的短划线
168
+ clean_name = '-'.join(filter(None, clean_name.split('-')))
169
+
170
+ if not clean_name or len(clean_name) < 3:
171
+ return self._get_temp_project_name()
172
+
173
+ return clean_name
174
+
175
+ project_name = validate_project_name(project_name)
176
+
177
+ data = {
178
+ 'Name': project_name,
179
+ 'Provider': 'Upload',
180
+ 'Channel': 'Custom',
181
+ 'Area': 'global'
182
+ }
183
+
184
+ self._log('INFO', f'Creating new project: {project_name}')
185
+ result = self._make_api_request('CreatePagesProject', data)
186
+
187
+ # 添加调试信息以了解API响应结构
188
+ self._log('DEBUG', f'CreatePagesProject API response: {json.dumps(result, ensure_ascii=False, indent=2)}')
189
+
190
+ if result.get('Code') != 0:
191
+ raise EdgeOneDeployError(f"Failed to create project: {result.get('Message', 'Unknown error')}")
192
+
193
+ # 检查API响应中是否有错误
194
+ response_data = result.get('Data', {}).get('Response', {})
195
+ if 'Error' in response_data:
196
+ error_info = response_data['Error']
197
+ raise EdgeOneDeployError(f"API Error [{error_info.get('Code', 'Unknown')}]: {error_info.get('Message', 'Unknown error')}")
198
+
199
+ # 尝试从不同可能的响应结构中获取项目ID
200
+ project_id = None
201
+ if 'Data' in result and 'Response' in result['Data']:
202
+ response = result['Data']['Response']
203
+ # 尝试不同的可能字段名
204
+ project_id = response.get('ProjectId') or response.get('ProjectID') or response.get('Id') or response.get('ID')
205
+
206
+ if not project_id:
207
+ # 如果没有找到项目ID,抛出详细错误
208
+ raise EdgeOneDeployError(f"ProjectId not found in API response. Response structure: {json.dumps(result, ensure_ascii=False, indent=2)}")
209
+
210
+ self._log('INFO', f'Created project with ID: {project_id}')
211
+ return self._describe_pages_projects(project_id=project_id)
212
+
213
+ def _get_or_create_project(self) -> str:
214
+ """获取或创建项目,返回项目ID"""
215
+ if self.project_name:
216
+ # 查找现有项目
217
+ result = self._describe_pages_projects(project_name=self.project_name)
218
+ projects = result['Data']['Response']['Projects']
219
+ if projects:
220
+ self._log('INFO', f'Project {self.project_name} already exists')
221
+ return projects[0]['ProjectId']
222
+
223
+ # 创建新项目
224
+ self._log('INFO', 'Creating new project')
225
+ result = self._create_pages_project()
226
+ projects = result['Data']['Response']['Projects']
227
+ if not projects:
228
+ raise EdgeOneDeployError('Failed to retrieve project after creation')
229
+
230
+ return projects[0]['ProjectId']
231
+
232
+ def _get_cos_temp_token(self, project_id: Optional[str] = None) -> Dict[str, Any]:
233
+ """获取COS临时令牌"""
234
+ if project_id:
235
+ data = {'ProjectId': project_id}
236
+ else:
237
+ data = {'ProjectName': self._get_temp_project_name()}
238
+
239
+ result = self._make_api_request('DescribePagesCosTempToken', data)
240
+
241
+ if result.get('Code') != 0:
242
+ raise EdgeOneDeployError(f"Failed to get COS token: {result.get('Message', 'Unknown error')}")
243
+
244
+ return result['Data']['Response']
245
+
246
+ def _validate_path(self, local_path: str) -> Tuple[bool, bool]:
247
+ """验证路径,返回(是否存在, 是否为ZIP文件)"""
248
+ path_obj = Path(local_path)
249
+
250
+ if not path_obj.exists():
251
+ raise EdgeOneDeployError(f'Path does not exist: {local_path}')
252
+
253
+ is_zip = path_obj.suffix.lower() == '.zip'
254
+
255
+ if not path_obj.is_dir() and not is_zip:
256
+ raise EdgeOneDeployError('Path must be a directory or ZIP file')
257
+
258
+ return True, is_zip
259
+
260
+ def _list_folder_files(self, folder_path: str) -> List[Dict[str, Any]]:
261
+ """递归列出文件夹中的所有文件"""
262
+ files = []
263
+ folder_path = Path(folder_path)
264
+
265
+ for item in folder_path.rglob('*'):
266
+ if item.is_file():
267
+ relative_path = item.relative_to(folder_path)
268
+ files.append({
269
+ 'path': str(item),
270
+ 'relative_path': str(relative_path).replace('\\', '/'),
271
+ 'size': item.stat().st_size
272
+ })
273
+
274
+ if len(files) > 1000000:
275
+ raise EdgeOneDeployError('Too many files (>1M), operation cancelled')
276
+
277
+ return files
278
+
279
+ def _upload_to_cos(self, local_path: str, cos_config: Dict[str, Any], is_zip: bool) -> str:
280
+ """上传文件到COS"""
281
+ try:
282
+ # 这里需要安装 cos-python-sdk-v5
283
+ from qcloud_cos import CosConfig, CosS3Client
284
+ except ImportError:
285
+ raise EdgeOneDeployError(
286
+ 'Missing cos-python-sdk-v5 dependency. Please install: pip install cos-python-sdk-v5'
287
+ )
288
+
289
+ credentials = cos_config['Credentials']
290
+ bucket = cos_config['Bucket']
291
+ region = cos_config['Region']
292
+ target_path = cos_config['TargetPath']
293
+
294
+ # 初始化COS客户端
295
+ config = CosConfig(
296
+ Region=region,
297
+ SecretId=credentials['TmpSecretId'],
298
+ SecretKey=credentials['TmpSecretKey'],
299
+ Token=credentials['Token']
300
+ )
301
+ client = CosS3Client(config)
302
+
303
+ if is_zip:
304
+ # 上传ZIP文件
305
+ filename = os.path.basename(local_path)
306
+ key = f"{target_path}/{filename}"
307
+
308
+ self._log('INFO', f'Uploading ZIP file: {filename}')
309
+
310
+ with open(local_path, 'rb') as f:
311
+ client.put_object(
312
+ Bucket=bucket,
313
+ Body=f,
314
+ Key=key
315
+ )
316
+
317
+ self._log('INFO', 'ZIP file upload completed')
318
+ return key
319
+ else:
320
+ # 上传文件夹
321
+ files = self._list_folder_files(local_path)
322
+ self._log('INFO', f'Uploading {len(files)} files to COS')
323
+
324
+ for file_info in files:
325
+ key = f"{target_path}/{file_info['relative_path']}"
326
+
327
+ with open(file_info['path'], 'rb') as f:
328
+ client.put_object(
329
+ Bucket=bucket,
330
+ Body=f,
331
+ Key=key
332
+ )
333
+
334
+ self._log('INFO', 'Folder upload completed')
335
+ return target_path
336
+
337
+ def _create_pages_deployment(self, project_id: str, target_path: str,
338
+ is_zip: bool, env: str = 'Production') -> str:
339
+ """创建Pages部署"""
340
+ data = {
341
+ 'ProjectId': project_id,
342
+ 'ViaMeta': 'Upload',
343
+ 'Provider': 'Upload',
344
+ 'Env': env,
345
+ 'DistType': 'Zip' if is_zip else 'Folder',
346
+ 'TempBucketPath': target_path
347
+ }
348
+
349
+ self._log('INFO', f'Creating deployment in {env} environment')
350
+ result = self._make_api_request('CreatePagesDeployment', data)
351
+
352
+ if result.get('Code') != 0:
353
+ raise EdgeOneDeployError(f"Deployment creation failed: {result.get('Message', 'Unknown error')}")
354
+
355
+ if result.get('Data', {}).get('Response', {}).get('Error'):
356
+ error_msg = result['Data']['Response']['Error']['Message']
357
+ raise EdgeOneDeployError(f"Deployment creation failed: {error_msg}")
358
+
359
+ return result['Data']['Response']['DeploymentId']
360
+
361
+ def _describe_pages_deployments(self, project_id: str) -> Dict[str, Any]:
362
+ """查询Pages部署状态"""
363
+ data = {
364
+ 'ProjectId': project_id,
365
+ 'Offset': 0,
366
+ 'Limit': 50,
367
+ 'OrderBy': 'CreatedOn',
368
+ 'Order': 'Desc'
369
+ }
370
+
371
+ return self._make_api_request('DescribePagesDeployments', data)
372
+
373
+ def _poll_deployment_status(self, project_id: str, deployment_id: str) -> Dict[str, Any]:
374
+ """轮询部署状态直到完成"""
375
+ self._log('INFO', 'Waiting for deployment to complete')
376
+
377
+ while True:
378
+ result = self._describe_pages_deployments(project_id)
379
+ deployments = result['Data']['Response']['Deployments']
380
+
381
+ deployment = None
382
+ for deploy in deployments:
383
+ if deploy['DeploymentId'] == deployment_id:
384
+ deployment = deploy
385
+ break
386
+
387
+ if not deployment:
388
+ raise EdgeOneDeployError(f'Deployment {deployment_id} not found')
389
+
390
+ status = deployment['Status']
391
+ self._log('INFO', f'Deployment status: {status}')
392
+
393
+ if status != 'Process':
394
+ return deployment
395
+
396
+ time.sleep(5)
397
+
398
+ def _describe_pages_encipher_token(self, url: str) -> Dict[str, Any]:
399
+ """获取页面访问令牌"""
400
+ data = {'Text': url}
401
+ return self._make_api_request('DescribePagesEncipherToken', data)
402
+
403
+ def _get_project_console_url(self, project_id: str) -> str:
404
+ """获取项目控制台URL"""
405
+ if self.base_api_url == BASE_API_URL1:
406
+ return f"https://console.cloud.tencent.com/edgeone/pages/project/{project_id}/index"
407
+ else:
408
+ return f"https://console.tencentcloud.com/edgeone/pages/project/{project_id}/index"
409
+
410
+ def _format_deployment_result(self, deployment: Dict[str, Any],
411
+ project_id: str, env: str = 'Production') -> Dict[str, Any]:
412
+ """格式化部署结果"""
413
+ if deployment['Status'] != 'Success':
414
+ raise EdgeOneDeployError(f"Deployment failed with status: {deployment['Status']}")
415
+
416
+ # 获取项目信息
417
+ project_result = self._describe_pages_projects(project_id=project_id)
418
+ projects = project_result['Data']['Response']['Projects']
419
+ if not projects:
420
+ raise EdgeOneDeployError('Failed to retrieve project information')
421
+
422
+ project = projects[0]
423
+
424
+ # 检查是否有自定义域名
425
+ if (env == 'Production' and
426
+ project.get('CustomDomains') and
427
+ len(project['CustomDomains']) > 0):
428
+
429
+ custom_domain = project['CustomDomains'][0]
430
+ if custom_domain['Status'] == 'Pass':
431
+ return {
432
+ 'type': 'custom',
433
+ 'url': f"https://{custom_domain['Domain']}",
434
+ 'project_id': project_id,
435
+ 'project_name': project['Name'],
436
+ 'console_url': self._get_project_console_url(project_id)
437
+ }
438
+
439
+ # 使用预设域名
440
+ domain = deployment.get('PreviewUrl', '').replace('https://', '') or project['PresetDomain']
441
+
442
+ # 获取访问令牌
443
+ token_result = self._describe_pages_encipher_token(domain)
444
+
445
+ if token_result.get('Code') != 0:
446
+ raise EdgeOneDeployError(f"Failed to get access token: {token_result.get('Message', 'Unknown error')}")
447
+
448
+ token_data = token_result['Data']['Response']
449
+ token = token_data['Token']
450
+ timestamp = token_data['Timestamp']
451
+
452
+ url = f"https://{domain}?eo_token={token}&eo_time={timestamp}"
453
+
454
+ return {
455
+ 'type': 'temporary',
456
+ 'url': url,
457
+ 'project_id': project_id,
458
+ 'project_name': project['Name'],
459
+ 'console_url': self._get_project_console_url(project_id)
460
+ }
461
+
462
+ def deploy_folder_or_zip(self, local_path: str, env: str = 'Production') -> str:
463
+ """
464
+ 部署文件夹或ZIP文件到EdgeOne Pages
465
+
466
+ Args:
467
+ local_path: 本地文件夹或ZIP文件路径
468
+ env: 部署环境,'Production' 或 'Preview'
469
+
470
+ Returns:
471
+ 部署结果的JSON字符串
472
+ """
473
+ try:
474
+ # 保存local_path以供其他方法使用
475
+ self.local_path = local_path
476
+ self.deployment_logs = []
477
+ self._log('INFO', f'Starting deployment of {local_path}')
478
+
479
+ # 验证路径
480
+ _, is_zip = self._validate_path(local_path)
481
+
482
+ # 检查API端点
483
+ import asyncio
484
+ asyncio.run(self._check_and_set_base_url())
485
+
486
+ # 获取或创建项目
487
+ project_id = self._get_or_create_project()
488
+ self._log('INFO', f'Using project ID: {project_id}')
489
+
490
+ # 获取COS配置
491
+ cos_config = self._get_cos_temp_token(project_id)
492
+
493
+ # 上传到COS
494
+ target_path = self._upload_to_cos(local_path, cos_config, is_zip)
495
+
496
+ # 创建部署
497
+ deployment_id = self._create_pages_deployment(project_id, target_path, is_zip, env)
498
+
499
+ # 等待部署完成
500
+ time.sleep(5) # 等待5秒让部署开始
501
+ deployment = self._poll_deployment_status(project_id, deployment_id)
502
+
503
+ # 格式化结果
504
+ result = self._format_deployment_result(deployment, project_id, env)
505
+
506
+ # 准备返回结果
507
+ logs_text = "\n".join([f"{log['level']}: {log['message']}" for log in self.deployment_logs])
508
+
509
+ final_result = {
510
+ 'status': 'success',
511
+ 'deployment_logs': logs_text,
512
+ 'result': result
513
+ }
514
+
515
+ return json.dumps(final_result, ensure_ascii=False, indent=2)
516
+
517
+ except Exception as e:
518
+ # 错误处理
519
+ logs_text = "\n".join([f"{log['level']}: {log['message']}" for log in self.deployment_logs])
520
+ error_result = {
521
+ 'status': 'error',
522
+ 'deployment_logs': logs_text,
523
+ 'error': str(e)
524
+ }
525
+ raise EdgeOneDeployError(json.dumps(error_result, ensure_ascii=False, indent=2))
526
+
527
+
528
+ # 为了兼容原来的函数调用方式
529
+ def deploy_folder_or_zip_to_edgeone(local_path: str, env: str = 'Production') -> str:
530
+ """
531
+ 部署文件夹或ZIP文件到EdgeOne Pages(函数式接口)
532
+
533
+ Args:
534
+ local_path: 本地文件夹或ZIP文件的绝对路径
535
+ env: 部署环境,'Production' 或 'Preview'
536
+
537
+ Returns:
538
+ 部署结果的JSON字符串
539
+ """
540
+ deployer = EdgeOneDeployer()
541
+ return deployer.deploy_folder_or_zip(local_path, env)