Enhance announcement storage functionality by adding a method to save all announcements by source, retaining the latest 100 per source. Update related logging and comments for clarity. Adjusted the main script to utilize the new storage method and log the saving process. Updated the markdown file timestamp.
This commit is contained in:
@@ -797,3 +797,62 @@
|
||||
2026-01-07 17:36:12 - __main__ - [32mINFO[0m - [32m筛选后剩余 0 条公告[0m
|
||||
2026-01-07 17:36:12 - storage.md_generator - [32mINFO[0m - [32mMarkdown文件已保存到: onu.md (共 0 条公告)[0m
|
||||
2026-01-07 17:36:12 - __main__ - [32mINFO[0m - [32m爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': True, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 1282, 'date_filtered': 0, 'duplicate_filtered': 18, 'source_filtered': 0}}[0m
|
||||
2026-01-08 08:17:30 - __main__ - [32mINFO[0m - [32m=== 广西政府采购网公告监控系统启动 ===[0m
|
||||
2026-01-08 08:17:30 - __main__ - [32mINFO[0m - [32m版本: 1.0.0[0m
|
||||
2026-01-08 08:17:30 - __main__ - [32mINFO[0m - [32m配置文件: 默认配置[0m
|
||||
2026-01-08 08:17:30 - core.reliability - [32mINFO[0m - [32m健康检查通过[0m
|
||||
2026-01-08 08:17:30 - core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 08:17:30 - core.database - [32mINFO[0m - [32m开始初始化数据库表结构[0m
|
||||
2026-01-08 08:17:30 - core.database - [32mINFO[0m - [32m数据库表结构初始化完成[0m
|
||||
2026-01-08 08:17:30 - storage.postgresql - [32mINFO[0m - [32m存储初始化完成[0m
|
||||
2026-01-08 08:17:30 - __main__ - [32mINFO[0m - [32m应用初始化完成[0m
|
||||
2026-01-08 08:17:30 - __main__ - [32mINFO[0m - [32m开始执行爬取任务[0m
|
||||
2026-01-08 08:17:30 - crawler.spider - [32mINFO[0m - [32m开始爬取 13 个公告来源[0m
|
||||
2026-01-08 08:17:30 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购公告[0m
|
||||
2026-01-08 08:17:31 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m采购公告 爬取完成,共获取 100 条公告,耗时 0.53秒[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 结果公告[0m
|
||||
2026-01-08 08:17:31 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m结果公告 爬取完成,共获取 100 条公告,耗时 0.19秒[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 合同公告[0m
|
||||
2026-01-08 08:17:31 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m合同公告 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 更正公告[0m
|
||||
2026-01-08 08:17:31 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m更正公告 爬取完成,共获取 100 条公告,耗时 0.28秒[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 招标文件预公示[0m
|
||||
2026-01-08 08:17:31 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 08:17:31 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 单一来源公示[0m
|
||||
2026-01-08 08:17:32 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:32 - gx_gp_monitor - [32mINFO[0m - [32m单一来源公示 爬取完成,共获取 100 条公告,耗时 0.16秒[0m
|
||||
2026-01-08 08:17:32 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 电子卖场公示[0m
|
||||
2026-01-08 08:17:32 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:32 - gx_gp_monitor - [32mINFO[0m - [32m电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.34秒[0m
|
||||
2026-01-08 08:17:32 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 履约验收公示[0m
|
||||
2026-01-08 08:17:32 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:32 - gx_gp_monitor - [32mINFO[0m - [32m履约验收公示 爬取完成,共获取 100 条公告,耗时 0.48秒[0m
|
||||
2026-01-08 08:17:32 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 工程类公告[0m
|
||||
2026-01-08 08:17:33 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m工程类公告 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议征集公告[0m
|
||||
2026-01-08 08:17:33 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议入围结果公告[0m
|
||||
2026-01-08 08:17:33 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议成交结果汇总公告[0m
|
||||
2026-01-08 08:17:33 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.16秒[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购意向公开[0m
|
||||
2026-01-08 08:17:33 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 08:17:33 - gx_gp_monitor - [32mINFO[0m - [32m采购意向公开 爬取完成,共获取 100 条公告,耗时 0.19秒[0m
|
||||
2026-01-08 08:17:33 - crawler.spider - [32mINFO[0m - [32m爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告[0m
|
||||
2026-01-08 08:17:33 - __main__ - [32mINFO[0m - [32m爬取到 1300 条原始公告[0m
|
||||
2026-01-08 08:17:33 - filters.filters - [32mINFO[0m - [32m来源筛选: 1300 -> 1300 条公告[0m
|
||||
2026-01-08 08:17:34 - filters.filters - [32mINFO[0m - [32m去重筛选: 移除了 17 条重复公告[0m
|
||||
2026-01-08 08:17:34 - filters.filters - [32mINFO[0m - [32m关键词筛选: 1283 -> 0 条公告[0m
|
||||
2026-01-08 08:17:34 - filters.filters - [32mINFO[0m - [32m筛选完成: 总数 1300 -> 筛选后 0 (关键词: 1283, 日期: 0, 去重: 17, 来源: 0)[0m
|
||||
2026-01-08 08:17:34 - __main__ - [32mINFO[0m - [32m筛选后剩余 0 条公告[0m
|
||||
2026-01-08 08:17:34 - storage.md_generator - [32mINFO[0m - [32mMarkdown文件已保存到: onu.md (共 0 条公告)[0m
|
||||
2026-01-08 08:17:34 - __main__ - [32mINFO[0m - [32m爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': True, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 1283, 'date_filtered': 0, 'duplicate_filtered': 17, 'source_filtered': 0}}[0m
|
||||
|
||||
@@ -20,7 +20,7 @@ try:
|
||||
from .core.reliability import check_system_health
|
||||
from .crawler.spider import crawl_announcements
|
||||
from .filters.filters import filter_from_config
|
||||
from .storage.postgresql import init_storage, save_announcements_to_storage, cleanup_storage
|
||||
from .storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, cleanup_storage
|
||||
from .storage.md_generator import generate_onu_md
|
||||
from .notification.wechat import send_announcements_notification, send_system_notification
|
||||
except ImportError:
|
||||
@@ -31,7 +31,7 @@ except ImportError:
|
||||
from core.reliability import check_system_health
|
||||
from crawler.spider import crawl_announcements
|
||||
from filters.filters import filter_from_config
|
||||
from storage.postgresql import init_storage, save_announcements_to_storage, cleanup_storage
|
||||
from storage.postgresql import init_storage, save_announcements_to_storage, save_all_announcements_by_source_to_storage, cleanup_storage
|
||||
from storage.md_generator import generate_onu_md
|
||||
from notification.wechat import send_announcements_notification, send_system_notification
|
||||
except ImportError as e:
|
||||
@@ -98,13 +98,18 @@ class GXGPMonitorApp:
|
||||
|
||||
logger.info(f"爬取到 {len(all_announcements)} 条原始公告")
|
||||
|
||||
# 先保存所有公告(按来源分组,每源保留最新100条)
|
||||
all_saved_stats = save_all_announcements_by_source_to_storage(all_announcements, max_per_source=100)
|
||||
all_saved_count = sum(all_saved_stats.values())
|
||||
logger.info(f"保存所有公告完成:共保存 {all_saved_count} 条,按来源统计: {all_saved_stats}")
|
||||
|
||||
# 筛选公告
|
||||
filter_obj = filter_from_config()
|
||||
filtered_announcements, filter_stats = filter_obj.filter(all_announcements)
|
||||
|
||||
logger.info(f"筛选后剩余 {len(filtered_announcements)} 条公告")
|
||||
|
||||
# 保存到数据库
|
||||
# 保存筛选后的公告(用于标记关键词匹配等)
|
||||
saved_count = save_announcements_to_storage(filtered_announcements)
|
||||
|
||||
# 生成Markdown文件
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 广西政府采购网公告监控
|
||||
|
||||
**更新时间**: 2026-01-07 17:36:12
|
||||
**更新时间**: 2026-01-08 08:17:34
|
||||
|
||||
## 无新公告
|
||||
|
||||
|
||||
Binary file not shown.
@@ -32,7 +32,7 @@ class PostgreSQLStorage:
|
||||
|
||||
def save_announcements(self, announcements: List[Announcement]) -> int:
|
||||
"""
|
||||
保存公告列表到数据库
|
||||
保存公告列表到数据库(经过筛选的公告)
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
@@ -43,14 +43,14 @@ class PostgreSQLStorage:
|
||||
if not announcements:
|
||||
return 0
|
||||
|
||||
logger.info(f"开始保存 {len(announcements)} 条公告到数据库")
|
||||
logger.info(f"开始保存 {len(announcements)} 条筛选后公告到数据库")
|
||||
|
||||
try:
|
||||
# 批量保存
|
||||
saved_count = self.db_manager.save_announcements_batch(announcements)
|
||||
|
||||
if saved_count > 0:
|
||||
logger.info(f"成功保存 {saved_count} 条公告到数据库")
|
||||
logger.info(f"成功保存 {saved_count} 条筛选后公告到数据库")
|
||||
|
||||
# 标记新公告
|
||||
self._mark_new_announcements(announcements[:saved_count])
|
||||
@@ -58,10 +58,112 @@ class PostgreSQLStorage:
|
||||
return saved_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"保存公告到数据库失败: {str(e)}")
|
||||
logger.error(f"保存筛选后公告到数据库失败: {str(e)}")
|
||||
# 尝试逐个保存
|
||||
return self._save_announcements_fallback(announcements)
|
||||
|
||||
def save_all_announcements_by_source(self, announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""
|
||||
按来源保存所有公告,每个来源保留最新的max_per_source条
|
||||
|
||||
Args:
|
||||
announcements: 所有公告列表(未经关键词筛选)
|
||||
max_per_source: 每个来源最大保留数量
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: 各来源保存的数量
|
||||
"""
|
||||
if not announcements:
|
||||
return {}
|
||||
|
||||
logger.info(f"开始按来源保存 {len(announcements)} 条公告,每个来源最多保留 {max_per_source} 条")
|
||||
|
||||
try:
|
||||
# 按来源分组
|
||||
source_groups = {}
|
||||
for announcement in announcements:
|
||||
source_code = announcement.source_code
|
||||
if source_code not in source_groups:
|
||||
source_groups[source_code] = []
|
||||
source_groups[source_code].append(announcement)
|
||||
|
||||
saved_stats = {}
|
||||
|
||||
for source_code, source_announcements in source_groups.items():
|
||||
# 对每个来源的公告按发布时间排序(最新的在前)
|
||||
sorted_announcements = sorted(
|
||||
source_announcements,
|
||||
key=lambda x: x.publish_date or x.crawled_at or datetime.min,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 取最新的max_per_source条
|
||||
to_save = sorted_announcements[:max_per_source]
|
||||
|
||||
# 为这些公告生成哈希
|
||||
for announcement in to_save:
|
||||
if not announcement.content_hash:
|
||||
announcement.generate_content_hash()
|
||||
|
||||
# 批量保存
|
||||
saved_count = self.db_manager.save_announcements_batch(to_save)
|
||||
|
||||
saved_stats[source_code] = saved_count
|
||||
|
||||
# 清理该来源超出限制的旧数据
|
||||
if len(sorted_announcements) > max_per_source:
|
||||
self._cleanup_old_announcements_by_source(source_code, max_per_source)
|
||||
|
||||
logger.info(f"来源 {source_code} 保存了 {saved_count} 条公告")
|
||||
|
||||
total_saved = sum(saved_stats.values())
|
||||
logger.info(f"按来源保存完成,总计保存 {total_saved} 条公告")
|
||||
|
||||
return saved_stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按来源保存公告失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _cleanup_old_announcements_by_source(self, source_code: str, keep_count: int):
|
||||
"""
|
||||
清理指定来源超出限制的旧公告
|
||||
|
||||
Args:
|
||||
source_code: 来源代码
|
||||
keep_count: 保留数量
|
||||
"""
|
||||
try:
|
||||
# 使用窗口函数删除超出限制的记录
|
||||
sql = """
|
||||
DELETE FROM announcements
|
||||
WHERE source_code = %s
|
||||
AND id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (ORDER BY publish_date DESC, crawled_at DESC) as rn
|
||||
FROM announcements
|
||||
WHERE source_code = %s
|
||||
) ranked
|
||||
WHERE rn > %s
|
||||
)
|
||||
"""
|
||||
|
||||
with get_db_cursor() as cursor:
|
||||
cursor.execute(sql, (source_code, source_code, keep_count))
|
||||
deleted_count = cursor.rowcount
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.debug(f"清理来源 {source_code} 的 {deleted_count} 条旧公告")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"清理来源 {source_code} 旧公告失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"按来源保存公告失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _save_announcements_fallback(self, announcements: List[Announcement]) -> int:
|
||||
"""逐个保存公告的降级方案"""
|
||||
logger.info("使用降级方案逐个保存公告")
|
||||
@@ -279,9 +381,14 @@ class StorageManager:
|
||||
self._current_storage = self.postgresql # 默认使用PostgreSQL
|
||||
|
||||
def save_announcements(self, announcements: List[Announcement]) -> int:
|
||||
"""保存公告"""
|
||||
"""保存筛选后的公告"""
|
||||
return self._current_storage.save_announcements(announcements)
|
||||
|
||||
def save_all_announcements_by_source(self, announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""按来源保存所有公告"""
|
||||
return self._current_storage.save_all_announcements_by_source(announcements, max_per_source)
|
||||
|
||||
def save_crawl_results(self, results: List[CrawlResult]) -> int:
|
||||
"""保存爬取结果"""
|
||||
return self._current_storage.save_crawl_results(results)
|
||||
@@ -343,7 +450,7 @@ def init_storage():
|
||||
|
||||
def save_announcements_to_storage(announcements: List[Announcement]) -> int:
|
||||
"""
|
||||
保存公告到存储
|
||||
保存筛选后的公告到存储
|
||||
|
||||
Args:
|
||||
announcements: 公告列表
|
||||
@@ -354,6 +461,21 @@ def save_announcements_to_storage(announcements: List[Announcement]) -> int:
|
||||
return get_storage_manager().save_announcements(announcements)
|
||||
|
||||
|
||||
def save_all_announcements_by_source_to_storage(announcements: List[Announcement],
|
||||
max_per_source: int = 100) -> Dict[str, int]:
|
||||
"""
|
||||
按来源保存所有公告到存储
|
||||
|
||||
Args:
|
||||
announcements: 所有公告列表
|
||||
max_per_source: 每个来源最大保留数量
|
||||
|
||||
Returns:
|
||||
Dict[str, int]: 各来源保存的数量
|
||||
"""
|
||||
return get_storage_manager().save_all_announcements_by_source(announcements, max_per_source)
|
||||
|
||||
|
||||
def cleanup_storage(days: Optional[int] = None) -> int:
|
||||
"""
|
||||
清理存储中的过期数据
|
||||
|
||||
@@ -21,3 +21,194 @@
|
||||
2026-01-07 17:29:21 - gx_gp_monitor.scheduler.scheduler - [32mINFO[0m - [32m添加间隔任务: custom_task (30秒)[0m
|
||||
2026-01-07 17:32:05 - gx_gp_monitor.scheduler.scheduler - [32mINFO[0m - [32m添加定时任务: data_cleanup (0 2 * * *)[0m
|
||||
2026-01-07 17:32:05 - gx_gp_monitor.scheduler.scheduler - [32mINFO[0m - [32m移除任务: data_cleanup[0m
|
||||
2026-01-08 09:29:31 - __main__ - [32mINFO[0m - [32m=== 广西政府采购网公告监控系统启动 ===[0m
|
||||
2026-01-08 09:29:31 - __main__ - [32mINFO[0m - [32m版本: 1.0.0[0m
|
||||
2026-01-08 09:29:31 - __main__ - [32mINFO[0m - [32m配置文件: 默认配置[0m
|
||||
2026-01-08 09:29:32 - core.reliability - [32mINFO[0m - [32m健康检查通过[0m
|
||||
2026-01-08 09:29:32 - core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:32 - core.database - [32mINFO[0m - [32m开始初始化数据库表结构[0m
|
||||
2026-01-08 09:29:32 - core.database - [32mINFO[0m - [32m数据库表结构初始化完成[0m
|
||||
2026-01-08 09:29:32 - storage.postgresql - [32mINFO[0m - [32m存储初始化完成[0m
|
||||
2026-01-08 09:29:32 - __main__ - [32mINFO[0m - [32m应用初始化完成[0m
|
||||
2026-01-08 09:29:33 - __main__ - [32mINFO[0m - [32m=== 广西政府采购网公告监控系统启动 ===[0m
|
||||
2026-01-08 09:29:33 - __main__ - [32mINFO[0m - [32m版本: 1.0.0[0m
|
||||
2026-01-08 09:29:33 - __main__ - [32mINFO[0m - [32m配置文件: 默认配置[0m
|
||||
2026-01-08 09:29:33 - core.reliability - [32mINFO[0m - [32m健康检查通过[0m
|
||||
2026-01-08 09:29:33 - core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:33 - core.database - [32mINFO[0m - [32m开始初始化数据库表结构[0m
|
||||
2026-01-08 09:29:33 - core.database - [32mINFO[0m - [32m数据库表结构初始化完成[0m
|
||||
2026-01-08 09:29:33 - storage.postgresql - [32mINFO[0m - [32m存储初始化完成[0m
|
||||
2026-01-08 09:29:33 - __main__ - [32mINFO[0m - [32m应用初始化完成[0m
|
||||
2026-01-08 09:29:33 - __main__ - [32mINFO[0m - [32m开始执行爬取任务[0m
|
||||
2026-01-08 09:29:33 - crawler.spider - [32mINFO[0m - [32m开始爬取 13 个公告来源[0m
|
||||
2026-01-08 09:29:33 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购公告[0m
|
||||
2026-01-08 09:29:34 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m采购公告 爬取完成,共获取 100 条公告,耗时 0.47秒[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 结果公告[0m
|
||||
2026-01-08 09:29:34 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m结果公告 爬取完成,共获取 100 条公告,耗时 0.24秒[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 合同公告[0m
|
||||
2026-01-08 09:29:34 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m合同公告 爬取完成,共获取 100 条公告,耗时 0.26秒[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 更正公告[0m
|
||||
2026-01-08 09:29:34 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m更正公告 爬取完成,共获取 100 条公告,耗时 0.19秒[0m
|
||||
2026-01-08 09:29:34 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 招标文件预公示[0m
|
||||
2026-01-08 09:29:35 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.17秒[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 单一来源公示[0m
|
||||
2026-01-08 09:29:35 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m单一来源公示 爬取完成,共获取 100 条公告,耗时 0.22秒[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 电子卖场公示[0m
|
||||
2026-01-08 09:29:35 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.37秒[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 履约验收公示[0m
|
||||
2026-01-08 09:29:35 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m履约验收公示 爬取完成,共获取 100 条公告,耗时 0.17秒[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 工程类公告[0m
|
||||
2026-01-08 09:29:35 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m工程类公告 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 09:29:35 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议征集公告[0m
|
||||
2026-01-08 09:29:36 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.25秒[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议入围结果公告[0m
|
||||
2026-01-08 09:29:36 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议成交结果汇总公告[0m
|
||||
2026-01-08 09:29:36 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购意向公开[0m
|
||||
2026-01-08 09:29:36 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:36 - gx_gp_monitor - [32mINFO[0m - [32m采购意向公开 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 09:29:36 - crawler.spider - [32mINFO[0m - [32m爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告[0m
|
||||
2026-01-08 09:29:36 - __main__ - [32mINFO[0m - [32m爬取到 1300 条原始公告[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m开始按来源保存 1300 条公告,每个来源最多保留 100 条[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement1 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement2 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement3 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement4 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement5 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement6 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement7 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement10 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement11 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement20 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement21 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement23 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 1 条[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m来源 61-266648 保存了 1 条公告[0m
|
||||
2026-01-08 09:29:36 - storage.postgresql - [32mINFO[0m - [32m按来源保存完成,总计保存 12 条公告[0m
|
||||
2026-01-08 09:29:36 - __main__ - [32mINFO[0m - [32m保存所有公告完成:共保存 12 条,按来源统计: {'ZcyAnnouncement1': 1, 'ZcyAnnouncement2': 1, 'ZcyAnnouncement3': 1, 'ZcyAnnouncement4': 0, 'ZcyAnnouncement5': 1, 'ZcyAnnouncement6': 1, 'ZcyAnnouncement7': 1, 'ZcyAnnouncement10': 1, 'ZcyAnnouncement11': 1, 'ZcyAnnouncement20': 1, 'ZcyAnnouncement21': 1, 'ZcyAnnouncement23': 1, '61-266648': 1}[0m
|
||||
2026-01-08 09:29:36 - filters.filters - [32mINFO[0m - [32m来源筛选: 1300 -> 1300 条公告[0m
|
||||
2026-01-08 09:29:37 - filters.filters - [32mINFO[0m - [32m去重筛选: 移除了 1300 条重复公告[0m
|
||||
2026-01-08 09:29:37 - filters.filters - [32mINFO[0m - [32m关键词筛选: 0 -> 0 条公告[0m
|
||||
2026-01-08 09:29:37 - filters.filters - [32mINFO[0m - [32m筛选完成: 总数 1300 -> 筛选后 0 (关键词: 0, 日期: 0, 去重: 1300, 来源: 0)[0m
|
||||
2026-01-08 09:29:37 - __main__ - [32mINFO[0m - [32m筛选后剩余 0 条公告[0m
|
||||
2026-01-08 09:29:37 - storage.md_generator - [32mINFO[0m - [32mMarkdown文件已保存到: onu.md (共 0 条公告)[0m
|
||||
2026-01-08 09:29:37 - __main__ - [32mINFO[0m - [32m爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': True, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 0, 'date_filtered': 0, 'duplicate_filtered': 1300, 'source_filtered': 0}}[0m
|
||||
2026-01-08 09:29:38 - __main__ - [32mINFO[0m - [32m=== 广西政府采购网公告监控系统启动 ===[0m
|
||||
2026-01-08 09:29:38 - __main__ - [32mINFO[0m - [32m版本: 1.0.0[0m
|
||||
2026-01-08 09:29:38 - __main__ - [32mINFO[0m - [32m配置文件: 默认配置[0m
|
||||
2026-01-08 09:29:39 - core.reliability - [32mINFO[0m - [32m健康检查通过[0m
|
||||
2026-01-08 09:29:39 - core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:39 - core.database - [32mINFO[0m - [32m开始初始化数据库表结构[0m
|
||||
2026-01-08 09:29:39 - core.database - [32mINFO[0m - [32m数据库表结构初始化完成[0m
|
||||
2026-01-08 09:29:39 - storage.postgresql - [32mINFO[0m - [32m存储初始化完成[0m
|
||||
2026-01-08 09:29:39 - __main__ - [32mINFO[0m - [32m应用初始化完成[0m
|
||||
2026-01-08 09:29:39 - __main__ - [32mINFO[0m - [32m开始执行爬取任务[0m
|
||||
2026-01-08 09:29:39 - crawler.spider - [32mINFO[0m - [32m开始爬取 13 个公告来源[0m
|
||||
2026-01-08 09:29:39 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购公告[0m
|
||||
2026-01-08 09:29:39 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:39 - gx_gp_monitor - [32mINFO[0m - [32m采购公告 爬取完成,共获取 100 条公告,耗时 0.46秒[0m
|
||||
2026-01-08 09:29:39 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 结果公告[0m
|
||||
2026-01-08 09:29:39 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:39 - gx_gp_monitor - [32mINFO[0m - [32m结果公告 爬取完成,共获取 100 条公告,耗时 0.21秒[0m
|
||||
2026-01-08 09:29:39 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 合同公告[0m
|
||||
2026-01-08 09:29:40 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m合同公告 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 更正公告[0m
|
||||
2026-01-08 09:29:40 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m更正公告 爬取完成,共获取 100 条公告,耗时 0.19秒[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 招标文件预公示[0m
|
||||
2026-01-08 09:29:40 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.23秒[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 单一来源公示[0m
|
||||
2026-01-08 09:29:40 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m单一来源公示 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 09:29:40 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 电子卖场公示[0m
|
||||
2026-01-08 09:29:41 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.43秒[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 履约验收公示[0m
|
||||
2026-01-08 09:29:41 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m履约验收公示 爬取完成,共获取 100 条公告,耗时 0.22秒[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 工程类公告[0m
|
||||
2026-01-08 09:29:41 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m工程类公告 爬取完成,共获取 100 条公告,耗时 0.22秒[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议征集公告[0m
|
||||
2026-01-08 09:29:41 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.21秒[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议入围结果公告[0m
|
||||
2026-01-08 09:29:41 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 09:29:41 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 框架协议成交结果汇总公告[0m
|
||||
2026-01-08 09:29:42 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:42 - gx_gp_monitor - [32mINFO[0m - [32m框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.20秒[0m
|
||||
2026-01-08 09:29:42 - gx_gp_monitor - [32mINFO[0m - [32m开始爬取 采购意向公开[0m
|
||||
2026-01-08 09:29:42 - crawler.parsers - [32mINFO[0m - [32m成功解析 100/100 条公告记录[0m
|
||||
2026-01-08 09:29:42 - gx_gp_monitor - [32mINFO[0m - [32m采购意向公开 爬取完成,共获取 100 条公告,耗时 0.18秒[0m
|
||||
2026-01-08 09:29:42 - crawler.spider - [32mINFO[0m - [32m爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告[0m
|
||||
2026-01-08 09:29:42 - __main__ - [32mINFO[0m - [32m爬取到 1300 条原始公告[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m开始按来源保存 1300 条公告,每个来源最多保留 100 条[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement1 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement2 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement3 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement4 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement5 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement6 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement7 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement10 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement11 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement20 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement21 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 ZcyAnnouncement23 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - core.database - [32mINFO[0m - [32m批量保存公告完成,成功保存 0 条[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m来源 61-266648 保存了 0 条公告[0m
|
||||
2026-01-08 09:29:42 - storage.postgresql - [32mINFO[0m - [32m按来源保存完成,总计保存 0 条公告[0m
|
||||
2026-01-08 09:29:42 - __main__ - [32mINFO[0m - [32m保存所有公告完成:共保存 0 条,按来源统计: {'ZcyAnnouncement1': 0, 'ZcyAnnouncement2': 0, 'ZcyAnnouncement3': 0, 'ZcyAnnouncement4': 0, 'ZcyAnnouncement5': 0, 'ZcyAnnouncement6': 0, 'ZcyAnnouncement7': 0, 'ZcyAnnouncement10': 0, 'ZcyAnnouncement11': 0, 'ZcyAnnouncement20': 0, 'ZcyAnnouncement21': 0, 'ZcyAnnouncement23': 0, '61-266648': 0}[0m
|
||||
2026-01-08 09:29:42 - filters.filters - [32mINFO[0m - [32m来源筛选: 1300 -> 1300 条公告[0m
|
||||
2026-01-08 09:29:42 - filters.filters - [32mINFO[0m - [32m去重筛选: 移除了 1300 条重复公告[0m
|
||||
2026-01-08 09:29:42 - filters.filters - [32mINFO[0m - [32m关键词筛选: 0 -> 0 条公告[0m
|
||||
2026-01-08 09:29:42 - filters.filters - [32mINFO[0m - [32m筛选完成: 总数 1300 -> 筛选后 0 (关键词: 0, 日期: 0, 去重: 1300, 来源: 0)[0m
|
||||
2026-01-08 09:29:42 - __main__ - [32mINFO[0m - [32m筛选后剩余 0 条公告[0m
|
||||
2026-01-08 09:29:42 - storage.md_generator - [32mINFO[0m - [32mMarkdown文件已保存到: onu.md (共 0 条公告)[0m
|
||||
2026-01-08 09:29:42 - __main__ - [32mINFO[0m - [32m爬取任务完成: {'success': True, 'total_crawled': 1300, 'filtered': 0, 'saved': 0, 'markdown_generated': True, 'notification_sent': False, 'filter_stats': {'keyword_filtered': 0, 'date_filtered': 0, 'duplicate_filtered': 1300, 'source_filtered': 0}}[0m
|
||||
2026-01-08 09:29:47 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:47 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:49 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:49 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:52 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
2026-01-08 09:29:52 - gx_gp_monitor.core.database - [32mINFO[0m - [32m数据库连接池初始化成功[0m
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 广西政府采购网公告监控
|
||||
|
||||
**更新时间**: 2026-01-08 09:29:42
|
||||
|
||||
## 无新公告
|
||||
|
||||
当前时间范围内没有找到符合条件的公告。
|
||||
|
||||
---
|
||||
|
||||
*由广西政府采购网公告监控系统生成*
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
# About
|
||||
weworkapi_python 是为了简化开发者对企业微信API接口的使用而设计的,API调用库系列之python版本
|
||||
本库仅做示范用,并不保证完全无bug;
|
||||
作者会不定期更新本库,但不保证与官方API接口文档同步,因此一切以[官方文档](https://work.weixin.qq.com/api/doc)为准。
|
||||
|
||||
更多来自个人开发者的其它语言的库推荐:
|
||||
python : https://github.com/sbzhu/weworkapi_python abelzhu@tencent.com(企业微信团队)
|
||||
ruby : https://github.com/mycolorway/wework MyColorway(个人开发者)
|
||||
php : https://github.com/sbzhu/weworkapi_php abelzhu@tencent.com(企业微信团队)
|
||||
golang : https://github.com/sbzhu/weworkapi_golang ryanjelin@tencent.com(企业微信团队)
|
||||
golang : https://github.com/doubliekill/EnterpriseWechatSDK 1006401052yh@gmail.com(个人开发者)
|
||||
|
||||
# Director
|
||||
├── api // API 接口
|
||||
│ ├── examples // API接口的测试用例
|
||||
│ ├── README.md
|
||||
│ └── src // API接口的关键逻辑
|
||||
├── callback // 加解密库,python2, xml格式)
|
||||
|
||||
├── callback_json // 加解密库,Python2, json格式, 仅适用于企业机器人/智能机器人
|
||||
|
||||
├── callback_json_python3 // 加解密库,Python3, json格式, 仅适用于企业机器人/智能机器人
|
||||
|
||||
├── callback_python3 // 加解密库,python2, xml格式
|
||||
|
||||
├── conf.py
|
||||
└── README.md
|
||||
|
||||
# Usage
|
||||
将本项目下载到你的目录,既可直接引用相关文件
|
||||
详细使用方法参考examples路径下的测试用例
|
||||
|
||||
# 关于token的缓存
|
||||
token是需要缓存的,不能每次调用都去获取token,[否则会中频率限制](https://work.weixin.qq.com/api/doc#10013/%E7%AC%AC%E5%9B%9B%E6%AD%A5%EF%BC%9A%E7%BC%93%E5%AD%98%E5%92%8C%E5%88%B7%E6%96%B0access_token)
|
||||
在本库的设计里,token是以类里的一个变量缓存的
|
||||
比如api/src/CorpApi.py 里的access_token变量
|
||||
在类的生命周期里,这个accessToken都是存在的, 当且仅当发现token过期,CorpAPI类会自动刷新token
|
||||
刷新机制在 api/src/AbstractApi.py
|
||||
所以,使用时,只需要全局实例化一个CorpAPI类,不要析构它,就可一直用它调函数,不用关心 token
|
||||
```
|
||||
api = CorpAPI(corpid, corpsecret);
|
||||
api.dosomething()
|
||||
api.dosomething()
|
||||
api.dosomething()
|
||||
....
|
||||
```
|
||||
当然,如果要更严格的做的话,建议自行修改,全局缓存token,比如存redis、存文件等,失效周期设置为2小时。
|
||||
|
||||
# Contact us
|
||||
abelzhu@tencent.com
|
||||
|
||||
#
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
import random
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['APP_SECRET'])
|
||||
|
||||
chatid = "test210";
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_CREATE'],
|
||||
{
|
||||
'name' : 'appchat_test',
|
||||
'owner' : 'ZhuBiaoYi',
|
||||
'userlist' : ['LiShuang', 'ZhuShengBen', 'LinJianEn', 'ZhuBiaoYi', 'XuBin', 'yangpeiyi', 'HaLuoTeQu', 'lucky', 'raindong', 'simon', 'Wang', 'ZhaoDong', 'DengLinSheng', 'Li'],
|
||||
'chatid' : chatid,
|
||||
})
|
||||
print response
|
||||
chatid = response['chatid']
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_UPDATE'],
|
||||
{
|
||||
'chatid' : chatid,
|
||||
'name' : 'appchat_test_new_name',
|
||||
'owner' : 'ZhuShengBen',
|
||||
'add_user_list' : ['huqiqi', 'Wang']
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_UPDATE'],
|
||||
{
|
||||
'chatid' : chatid,
|
||||
'name' : '应用发消息测试',
|
||||
'owner' : 'ZhuBiaoYi',
|
||||
'del_user_list' : 'huqiqi',
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'text',
|
||||
'text' : {'content':'我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党我是文本消息热爱祖国热爱人民热爱中国共产党'},
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'image',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'image' : {
|
||||
'media_id':'3A9Jo9CHit_5UTfOVE38_067dUJQlLs30mOa9FC0a4jEGeoQgpLCZgc7rEza6TbfB',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'file',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'file' : {
|
||||
'media_id':'35L7MmcpGdyFfqjbGhbECCkGcaNsUajaPQifGLJq_H5E',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'msgtype' : 'voice',
|
||||
'voice' : {
|
||||
'media_id':'3x1yb34061fDXjyUXy2rWNd-a-hWe-l8eTw2VKyh3bDQ',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'msgtype' : 'video',
|
||||
'video' : {
|
||||
'media_id':'3neA1ypnC3k5QnAZqvyVvCesFYUrXietU5F-Ipnj6ZobiD-PuFlXngzPplWXibw9r',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'msgtype' : 'news',
|
||||
"news" : {
|
||||
"articles" : [
|
||||
{
|
||||
"title" : "图文消息",
|
||||
"description" : "今年中秋节公司有豪礼相送",
|
||||
"url" : "URL",
|
||||
"picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png",
|
||||
"btntxt":"更多",
|
||||
},
|
||||
{
|
||||
"title" : "图文消息",
|
||||
"description" : "今年中秋节公司有豪礼相送",
|
||||
"url" : "URL",
|
||||
"picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png",
|
||||
"btntxt":"更多",
|
||||
},
|
||||
{
|
||||
"title" : "图文消息",
|
||||
"description" : "今年中秋节公司有豪礼相送",
|
||||
"url" : "URL",
|
||||
"picurl" : "http://res.mail.qq.com/node/ww/wwopenmng/images/independent/doc/test_pic_msg1.png",
|
||||
"btntxt":"更多",
|
||||
},
|
||||
]},
|
||||
'safe' : 1,
|
||||
},
|
||||
)
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
'msgtype' : 'textcard',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'textcard' : {
|
||||
'title':'我是文本卡片消息',
|
||||
'description' : 'aaaaaaa',
|
||||
'url' : 'www.qq.com',
|
||||
'btntxt' : '更多',
|
||||
},
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['APP_CHAT_SEND'],
|
||||
{
|
||||
'chatid':chatid,
|
||||
"msgtype" : "mpnews",
|
||||
"mpnews": {
|
||||
"articles" : [
|
||||
{
|
||||
"title" : "图文消息(mpnews)",
|
||||
"thumb_media_id" : "3uFTZs4MRTr-OwUArqaoXPyqtuedcwCUW1x4sgKcOeQc",
|
||||
"author" : "author",
|
||||
"content" : "content",
|
||||
"digest" : "我是图文"
|
||||
},
|
||||
{
|
||||
"title" : "图文消息(mpnews)",
|
||||
"thumb_media_id" : "3uFTZs4MRTr-OwUArqaoXPyqtuedcwCUW1x4sgKcOeQc",
|
||||
"author" : "author",
|
||||
"content" : "content",
|
||||
"digest" : "我是图文"
|
||||
},
|
||||
{
|
||||
"title" : "图文消息(mpnews)",
|
||||
"thumb_media_id" : "3uFTZs4MRTr-OwUArqaoXPyqtuedcwCUW1x4sgKcOeQc",
|
||||
"author" : "author",
|
||||
"content" : "content",
|
||||
"digest" : "我是图文"
|
||||
},
|
||||
]
|
||||
},
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'safe' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
import random
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['APP_SECRET'])
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['MESSAGE_SEND'],
|
||||
{
|
||||
"touser": "ZhuShengBen",
|
||||
"agentid": 1000002,
|
||||
'msgtype' : 'text',
|
||||
'climsgid' : 'climsgidclimsgid_%f' % (random.random()),
|
||||
'text' : {
|
||||
'content':'方法论',
|
||||
},
|
||||
'safe' : 0,
|
||||
})
|
||||
print response
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
import random
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['APP_SECRET'])
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['MINIPROGRAM_CODE_TO_SESSION_KEY'],
|
||||
{
|
||||
"js_code" : "sVqtL3itg0L30LTGJtZ_isKC0efG5FqGw470fVp8Dpw",
|
||||
"grant_type" : "authorization_code"
|
||||
})
|
||||
print response
|
||||
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceCorpTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
from ServiceCorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
|
||||
## 第三方服务商接口的使用方法
|
||||
api = ServiceCorpApi(
|
||||
"SUITE_ID",
|
||||
"SUITE_SECRET",
|
||||
"SUITE_TICKET"
|
||||
);
|
||||
|
||||
try :
|
||||
pre_auth_code = api.httpCall(SERVICE_CORP_API_TYPE['GET_PRE_AUTH_CODE']).get('pre_auth_code')
|
||||
print pre_auth_code
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
|
||||
## 第三方服务商使用永久授权码调用企业接口的方法
|
||||
api = ServiceCorpApi(
|
||||
"SUITE_ID",
|
||||
"SUITE_SECRET",
|
||||
"SUITE_TICKET",
|
||||
'AUTH_CORPID',
|
||||
'PERMANENT_CODE'
|
||||
);
|
||||
try :
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_GET'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceProviderTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-26
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
from ServiceProviderApi import *
|
||||
from TestConf import *
|
||||
|
||||
api = ServiceProviderApi('CORPID', 'PROVIDER_SECRET')
|
||||
|
||||
try :
|
||||
response = api.httpCall(
|
||||
SERVICE_PROVIDER_API_TYPE['GET_LOGIN_INFO'],
|
||||
{
|
||||
'auth_code' : 'XXXXXXX',
|
||||
})
|
||||
print response
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File conf.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
# 请将下面参数改为自己的企业相关参数再进行测试
|
||||
|
||||
TestConf = {
|
||||
|
||||
# 企业的id,在管理端->"我的企业" 可以看到
|
||||
"CORP_ID" : "ww55ca070cb9b7eb22",
|
||||
|
||||
# "通讯录同步"应用的secret, 开启api接口同步后,可以在管理端->"通讯录同步"看到
|
||||
"CONTACT_SYNC_SECRET" : "ktmzrVIlUH0UW63zi7-JyzsgTL9NfwUhHde6or6zwQY",
|
||||
|
||||
# 某个自建应用的id及secret, 在管理端 -> 企业应用 -> 自建应用, 点进相应应用可以看到
|
||||
"APP_ID" : 1000002,
|
||||
"APP_SECRET" : "v1Z2KSw2WqPFECAwn2R0a1dFsanVF5sE4IE6X5ogveQ",
|
||||
|
||||
# 打卡应用的 id 及secrete, 在管理端 -> 企业应用 -> 基础应用 -> 打卡,
|
||||
# 点进去,有个"api"按钮,点开后,会看到
|
||||
"CHECKIN_APP_ID" : 3010011,
|
||||
"CHECKIN_APP_SECRET" : "3Qz2OGPvE1Eb6WKpEDfczvyQjL5Lr1CjrDTKn0RHdLE",
|
||||
|
||||
# 审批应用的 id 及secrete, 在管理端 -> 企业应用 -> 基础应用 -> 审批,
|
||||
# 点进去,有个"api"按钮,点开后,会看到
|
||||
"APPROVAL_APP_ID" : 3010040,
|
||||
"APPROVAL_APP_SECRET" : "1vrlwItWpz_5Qkud55aImQPCvpzi51H3F2j-1OQzhYE",
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File UserTest.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
sys.path.append("../src/")
|
||||
|
||||
from CorpApi import *
|
||||
from TestConf import *
|
||||
|
||||
## test
|
||||
api = CorpApi(TestConf['CORP_ID'], TestConf['CONTACT_SYNC_SECRET'])
|
||||
|
||||
try :
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_CREATE'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
'name' : 'zhangsanfeng',
|
||||
'mobile' : '131488888888',
|
||||
'email' : 'zhangsan@ipp.cas.cn',
|
||||
'department' : 1,
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_GET'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_DELETE'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
|
||||
except ApiException as e :
|
||||
print e.errCode, e.errMsg
|
||||
|
||||
##
|
||||
response = api.httpCall(
|
||||
CORP_API_TYPE['USER_DELETE'],
|
||||
{
|
||||
'userid' : 'zhangsan',
|
||||
})
|
||||
print response
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File AbstractApi.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
import json
|
||||
import requests
|
||||
|
||||
sys.path.append("../../")
|
||||
|
||||
from conf import DEBUG
|
||||
|
||||
class ApiException(Exception) :
|
||||
def __init__(self, errCode, errMsg) :
|
||||
self.errCode = errCode
|
||||
self.errMsg = errMsg
|
||||
|
||||
class AbstractApi(object) :
|
||||
def __init__(self) :
|
||||
return
|
||||
|
||||
def getAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
def refreshAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
|
||||
def getSuiteAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
def refreshSuiteAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
|
||||
def getProviderAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
def refreshProviderAccessToken(self) :
|
||||
raise NotImplementedError
|
||||
|
||||
def httpCall(self, urlType, args=None) :
|
||||
shortUrl = urlType[0]
|
||||
method = urlType[1]
|
||||
response = {}
|
||||
for retryCnt in range(0, 3) :
|
||||
if 'POST' == method :
|
||||
url = self.__makeUrl(shortUrl)
|
||||
response = self.__httpPost(url, args)
|
||||
elif 'GET' == method :
|
||||
url = self.__makeUrl(shortUrl)
|
||||
url = self.__appendArgs(url, args)
|
||||
response = self.__httpGet(url)
|
||||
else :
|
||||
raise ApiException(-1, "unknown method type")
|
||||
|
||||
# check if token expired
|
||||
if self.__tokenExpired(response.get('errcode')) :
|
||||
self.__refreshToken(shortUrl)
|
||||
retryCnt += 1
|
||||
continue
|
||||
else :
|
||||
break
|
||||
|
||||
return self.__checkResponse(response)
|
||||
|
||||
@staticmethod
|
||||
def __appendArgs(url, args) :
|
||||
if args is None :
|
||||
return url
|
||||
|
||||
for key, value in args.items() :
|
||||
if '?' in url :
|
||||
url += ('&' + key + '=' + value)
|
||||
else :
|
||||
url += ('?' + key + '=' + value)
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def __makeUrl(shortUrl) :
|
||||
base = "https://qyapi.weixin.qq.com"
|
||||
if shortUrl[0] == '/' :
|
||||
return base + shortUrl
|
||||
else :
|
||||
return base + '/' + shortUrl
|
||||
|
||||
def __appendToken(self, url) :
|
||||
if 'SUITE_ACCESS_TOKEN' in url :
|
||||
return url.replace('SUITE_ACCESS_TOKEN', self.getSuiteAccessToken())
|
||||
elif 'PROVIDER_ACCESS_TOKEN' in url :
|
||||
return url.replace('PROVIDER_ACCESS_TOKEN', self.getProviderAccessToken())
|
||||
elif 'ACCESS_TOKEN' in url :
|
||||
return url.replace('ACCESS_TOKEN', self.getAccessToken())
|
||||
else :
|
||||
return url
|
||||
|
||||
def __httpPost(self, url, args) :
|
||||
realUrl = self.__appendToken(url)
|
||||
|
||||
if DEBUG is True :
|
||||
print realUrl, args
|
||||
|
||||
return requests.post(realUrl, data = json.dumps(args, ensure_ascii = False).encode('utf-8')).json()
|
||||
|
||||
def __httpGet(self, url) :
|
||||
realUrl = self.__appendToken(url)
|
||||
|
||||
if DEBUG is True :
|
||||
print realUrl
|
||||
|
||||
return requests.get(realUrl).json()
|
||||
|
||||
def __post_file(self, url, media_file):
|
||||
return requests.post(url, file=media_file).json()
|
||||
|
||||
@staticmethod
|
||||
def __checkResponse(response):
|
||||
errCode = response.get('errcode')
|
||||
errMsg = response.get('errmsg')
|
||||
|
||||
if errCode is 0:
|
||||
return response
|
||||
else:
|
||||
raise ApiException(errCode, errMsg)
|
||||
|
||||
@staticmethod
|
||||
def __tokenExpired(errCode) :
|
||||
if errCode == 40014 or errCode == 42001 or errCode == 42007 or errCode == 42009 :
|
||||
return True
|
||||
else :
|
||||
return False
|
||||
|
||||
def __refreshToken(self, url) :
|
||||
if 'SUITE_ACCESS_TOKEN' in url :
|
||||
self.refreshSuiteAccessToken()
|
||||
elif 'PROVIDER_ACCESS_TOKEN' in url :
|
||||
self.refreshProviderAccessToken()
|
||||
elif 'ACCESS_TOKEN' in url :
|
||||
self.refreshAccessToken()
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File CorpApi.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
from AbstractApi import *
|
||||
|
||||
CORP_API_TYPE = {
|
||||
'GET_ACCESS_TOKEN' : ['/cgi-bin/gettoken', 'GET'],
|
||||
'USER_CREATE' : ['/cgi-bin/user/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_GET' : ['/cgi-bin/user/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USER_UPDATE' : ['/cgi-bin/user/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_DELETE' : ['/cgi-bin/user/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USER_BATCH_DELETE': ['/cgi-bin/user/batchdelete?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_SIMPLE_LIST': ['/cgi-bin/user/simplelist?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USER_LIST' : ['/cgi-bin/user/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'USERID_TO_OPENID' : ['/cgi-bin/user/convert_to_openid?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'OPENID_TO_USERID' : ['/cgi-bin/user/convert_to_userid?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'USER_AUTH_SUCCESS': ['/cgi-bin/user/authsucc?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'DEPARTMENT_CREATE': ['/cgi-bin/department/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'DEPARTMENT_UPDATE': ['/cgi-bin/department/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'DEPARTMENT_DELETE': ['/cgi-bin/department/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'DEPARTMENT_LIST' : ['/cgi-bin/department/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'TAG_CREATE' : ['/cgi-bin/tag/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_UPDATE' : ['/cgi-bin/tag/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_DELETE' : ['/cgi-bin/tag/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'TAG_GET_USER' : ['/cgi-bin/tag/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'TAG_ADD_USER' : ['/cgi-bin/tag/addtagusers?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_DELETE_USER' : ['/cgi-bin/tag/deltagusers?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'TAG_GET_LIST' : ['/cgi-bin/tag/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'BATCH_JOB_GET_RESULT' : ['/cgi-bin/batch/getresult?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'BATCH_INVITE' : ['/cgi-bin/batch/invite?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'AGENT_GET' : ['/cgi-bin/agent/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'AGENT_SET' : ['/cgi-bin/agent/set?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'AGENT_GET_LIST' : ['/cgi-bin/agent/list?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'MENU_CREATE' : ['/cgi-bin/menu/create?access_token=ACCESS_TOKEN', 'POST'], ## TODO
|
||||
'MENU_GET' : ['/cgi-bin/menu/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'MENU_DELETE' : ['/cgi-bin/menu/delete?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'MESSAGE_SEND' : ['/cgi-bin/message/send?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'MESSAGE_REVOKE' : ['/cgi-bin/message/revoke?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'MEDIA_GET' : ['/cgi-bin/media/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'GET_USER_INFO_BY_CODE' : ['/cgi-bin/user/getuserinfo?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'GET_USER_DETAIL' : ['/cgi-bin/user/getuserdetail?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'GET_TICKET' : ['/cgi-bin/ticket/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'GET_JSAPI_TICKET' : ['/cgi-bin/get_jsapi_ticket?access_token=ACCESS_TOKEN', 'GET'],
|
||||
|
||||
'GET_CHECKIN_OPTION' : ['/cgi-bin/checkin/getcheckinoption?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'GET_CHECKIN_DATA' : ['/cgi-bin/checkin/getcheckindata?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'GET_APPROVAL_DATA': ['/cgi-bin/corp/getapprovaldata?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'GET_INVOICE_INFO' : ['/cgi-bin/card/invoice/reimburse/getinvoiceinfo?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'UPDATE_INVOICE_STATUS' :
|
||||
['/cgi-bin/card/invoice/reimburse/updateinvoicestatus?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'BATCH_UPDATE_INVOICE_STATUS' :
|
||||
['/cgi-bin/card/invoice/reimburse/updatestatusbatch?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'BATCH_GET_INVOICE_INFO' :
|
||||
['/cgi-bin/card/invoice/reimburse/getinvoiceinfobatch?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'APP_CHAT_CREATE' : ['/cgi-bin/appchat/create?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'APP_CHAT_GET' : ['/cgi-bin/appchat/get?access_token=ACCESS_TOKEN', 'GET'],
|
||||
'APP_CHAT_UPDATE' : ['/cgi-bin/appchat/update?access_token=ACCESS_TOKEN', 'POST'],
|
||||
'APP_CHAT_SEND' : ['/cgi-bin/appchat/send?access_token=ACCESS_TOKEN', 'POST'],
|
||||
|
||||
'MINIPROGRAM_CODE_TO_SESSION_KEY' : ['/cgi-bin/miniprogram/jscode2session?access_token=ACCESS_TOKEN', 'GET'],
|
||||
}
|
||||
|
||||
class CorpApi(AbstractApi) :
|
||||
def __init__(self, corpid, secret) :
|
||||
self.corpid = corpid
|
||||
self.secret = secret
|
||||
self.access_token = None
|
||||
|
||||
def getAccessToken(self) :
|
||||
if self.access_token is None :
|
||||
self.refreshAccessToken()
|
||||
return self.access_token
|
||||
|
||||
def refreshAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
CORP_API_TYPE['GET_ACCESS_TOKEN'],
|
||||
{
|
||||
'corpid' : self.corpid,
|
||||
'corpsecret': self.secret,
|
||||
})
|
||||
self.access_token = response.get('access_token')
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceCorp.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-24
|
||||
#
|
||||
#
|
||||
|
||||
from CorpApi import *
|
||||
|
||||
SERVICE_CORP_API_TYPE = {
|
||||
'GET_CORP_TOKEN' : ['/cgi-bin/service/get_corp_token?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_SUITE_TOKEN' : ['/cgi-bin/service/get_suite_token', 'POST'],
|
||||
'GET_PRE_AUTH_CODE' : ['/cgi-bin/service/get_pre_auth_code?suite_access_token=SUITE_ACCESS_TOKEN', 'GET'],
|
||||
'SET_SESSION_INFO' : ['/cgi-bin/service/set_session_info?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_PERMANENT_CODE': ['/cgi-bin/service/get_permanent_code?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_AUTH_INFO' : ['/cgi-bin/service/get_auth_info?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_ADMIN_LIST' : ['/cgi-bin/service/get_admin_list?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
'GET_USER_INFO_BY_3RD' : ['/cgi-bin/service/getuserinfo3rd?suite_access_token=SUITE_ACCESS_TOKEN', 'GET'],
|
||||
'GET_USER_DETAIL_BY_3RD' : ['/cgi-bin/service/getuserdetail3rd?suite_access_token=SUITE_ACCESS_TOKEN', 'POST'],
|
||||
}
|
||||
|
||||
class ServiceCorpApi(CorpApi) :
|
||||
def __init__(self, suite_id, suite_secret, suite_ticket, auth_corpid=None, permanent_code=None) :
|
||||
self.suite_id = suite_id
|
||||
self.suite_secret = suite_secret
|
||||
self.suite_ticket = suite_ticket
|
||||
|
||||
# 调用 CorpAPI 的function, 需要设置这两个参数
|
||||
self.auth_corpid = auth_corpid
|
||||
self.permanent_code = permanent_code
|
||||
|
||||
self.access_token = None
|
||||
self.suite_access_token = None
|
||||
|
||||
## override CorpApi 的 refreshAccessToken, 使用第三方服务商的方法
|
||||
def getAccessToken(self) :
|
||||
if self.access_token is None :
|
||||
self.refreshAccessToken()
|
||||
return self.access_token
|
||||
def refreshAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
SERVICE_CORP_API_TYPE['GET_CORP_TOKEN'],
|
||||
{
|
||||
"auth_corpid" : self.auth_corpid,
|
||||
"permanent_code": self.permanent_code,
|
||||
})
|
||||
self.access_token = response.get('access_token')
|
||||
|
||||
##
|
||||
def getSuiteAccessToken(self) :
|
||||
if self.suite_access_token is None :
|
||||
self.refreshSuiteAccessToken()
|
||||
return self.suite_access_token
|
||||
|
||||
def refreshSuiteAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
SERVICE_CORP_API_TYPE['GET_SUITE_TOKEN'],
|
||||
{
|
||||
"suite_id" : self.suite_id,
|
||||
"suite_secret" : self.suite_secret,
|
||||
"suite_ticket" : self.suite_ticket,
|
||||
})
|
||||
self.suite_access_token= response.get('suite_access_token')
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File ServiceProviderApi.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-26
|
||||
#
|
||||
#
|
||||
|
||||
from AbstractApi import *
|
||||
|
||||
SERVICE_PROVIDER_API_TYPE = {
|
||||
'GET_PROVIDER_TOKEN': ['/cgi-bin/service/get_provider_token', 'POST'],
|
||||
'GET_LOGIN_INFO' : ['/cgi-bin/service/get_login_info?access_token=PROVIDER_ACCESS_TOKEN', 'POST'],
|
||||
'GET_REGISTER_CODE' : ['/cgi-bin/service/get_register_code?provider_access_token=PROVIDER_ACCESS_TOKEN', 'POST'],
|
||||
'GET_REGISTER_INFO' : ['/cgi-bin/service/get_register_info?provider_access_token=PROVIDER_ACCESS_TOKEN', 'POST'],
|
||||
'SET_AGENT_SCOPE' : ['/cgi-bin/agent/set_scope', 'POST'], ### TODO
|
||||
'SET_CONTACT_SYNC_SUCCESS' : ['/cgi-bin/sync/contact_sync_success', 'GET'],
|
||||
}
|
||||
|
||||
class ServiceProviderApi(AbstractApi) :
|
||||
def __init__(self, corpid, provider_secret) :
|
||||
self.corpid = corpid
|
||||
self.provider_secret = provider_secret
|
||||
|
||||
self.provider_access_token = None
|
||||
|
||||
def getProviderAccessToken(self) :
|
||||
if self.provider_access_token is None :
|
||||
self.refreshProviderAccessToken()
|
||||
return self.provider_access_token
|
||||
|
||||
def refreshProviderAccessToken(self) :
|
||||
response = self.httpCall(
|
||||
SERVICE_PROVIDER_API_TYPE['GET_PROVIDER_TOKEN'],
|
||||
{
|
||||
'corpid' : self.corpid,
|
||||
'provider_secret': self.provider_secret,
|
||||
})
|
||||
self.provider_access_token = response.get('provider_access_token')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类(Python3以及以上版本使用 WXBizMsgCrypt3.py),提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizMsgCrypt import WXBizMsgCrypt
|
||||
import xml.etree.cElementTree as ET
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
#sVerifyMsgSig=HttpUtils.ParseUrl("msg_signature")
|
||||
#ret = wxcpt.VerifyAESKey()
|
||||
#print ret
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
#sVerifyTimeStamp=HttpUtils.ParseUrl("timestamp")
|
||||
sVerifyTimeStamp="1476416373"
|
||||
#sVerifyNonce=HttpUitls.ParseUrl("nonce")
|
||||
sVerifyNonce="47744683"
|
||||
#sVerifyEchoStr=HttpUtils.ParseUrl("echostr")
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print "ERR: VerifyURL ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
#HttpUtils.SetResponse(sEchoStr)
|
||||
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=477715d11cdb4164915debcba66cb864d751f3e6×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
Content-Length: 613
|
||||
<xml> <ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName><Encrypt><![CDATA[RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q==]]></Encrypt>
|
||||
<AgentID><![CDATA[218]]></AgentID>
|
||||
</xml>
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行xml解析,并将<Encrypt>标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
# sReqMsgSig = HttpUtils.ParseUrl("msg_signature")
|
||||
sReqMsgSig = "0c3914025cb4b4d68103f6bfc8db550f79dcf48e"
|
||||
sReqTimeStamp = "1476422779"
|
||||
sReqNonce = "1597212914"
|
||||
sReqData = "<xml><ToUserName><![CDATA[ww1436e0e65a779aee]]></ToUserName>\n<Encrypt><![CDATA[Kl7kjoSf6DMD1zh7rtrHjFaDapSCkaOnwu3bqLc5tAybhhMl9pFeK8NslNPVdMwmBQTNoW4mY7AIjeLvEl3NyeTkAgGzBhzTtRLNshw2AEew+kkYcD+Fq72Kt00fT0WnN87hGrW8SqGc+NcT3mu87Ha3dz1pSDi6GaUA6A0sqfde0VJPQbZ9U+3JWcoD4Z5jaU0y9GSh010wsHF8KZD24YhmZH4ch4Ka7ilEbjbfvhKkNL65HHL0J6EYJIZUC2pFrdkJ7MhmEbU2qARR4iQHE7wy24qy0cRX3Mfp6iELcDNfSsPGjUQVDGxQDCWjayJOpcwocugux082f49HKYg84EpHSGXAyh+/oxwaWbvL6aSDPOYuPDGOCI8jmnKiypE+]]></Encrypt>\n<AgentID><![CDATA[1000002]]></AgentID>\n</xml>"
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
print ret,sMsg
|
||||
if( ret!=0 ):
|
||||
print "ERR: DecryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
# 解密成功,sMsg即为xml格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# For example:
|
||||
xml_tree = ET.fromstring(sMsg)
|
||||
content = xml_tree.find("Content").text
|
||||
print content
|
||||
# ...
|
||||
# ...
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的xml串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
<xml>
|
||||
<ToUserName><![CDATA[mycreate]]></ToUserName>
|
||||
<FromUserName><![CDATA[wx5823bf96d3bd56c7]]></FromUserName>
|
||||
<CreateTime>1348831860</CreateTime>
|
||||
<MsgType><![CDATA[text]]></MsgType>
|
||||
<Content><![CDATA[this is a test]]></Content>
|
||||
<MsgId>1234567890123456</MsgId>
|
||||
<AgentID>128</AgentID>
|
||||
</xml>
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成xml格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
sRespData = "<xml><ToUserName>ww1436e0e65a779aee</ToUserName><FromUserName>ChenJiaShun</FromUserName><CreateTime>1476422779</CreateTime><MsgType>text</MsgType><Content>你好</Content><MsgId>1456453720</MsgId><AgentID>1000002</AgentID></xml>"
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print "ERR: EncryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
#TODO:
|
||||
#HttpUitls.SetResponse(sEncryptMsg) #测试解密
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python
|
||||
#-*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2014 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import xml.etree.cElementTree as ET
|
||||
import sys
|
||||
import socket
|
||||
stdi,stdo,stde=sys.stdin,sys.stdout,sys.stderr
|
||||
reload(sys)
|
||||
sys.stdin,sys.stdout,sys.stderr=stdi,stdo,stde
|
||||
import ierror
|
||||
sys.setdefaultencoding('utf-8')
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist))
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class XMLParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# xml消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
|
||||
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
|
||||
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
|
||||
<TimeStamp>%(timestamp)s</TimeStamp>
|
||||
<Nonce><![CDATA[%(nonce)s]]></Nonce>
|
||||
</xml>"""
|
||||
|
||||
def extract(self, xmltext):
|
||||
"""提取出xml数据包中的加密消息
|
||||
@param xmltext: 待提取的xml字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
xml_tree = ET.fromstring(xmltext)
|
||||
encrypt = xml_tree.find("Encrypt")
|
||||
return ierror.WXBizMsgCrypt_OK, encrypt.text
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ParseXml_Error,None
|
||||
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成xml消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的xml字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt' : encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp' : timestamp,
|
||||
'nonce' : nonce,
|
||||
}
|
||||
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_xml
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad<1 or pad >32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self,key):
|
||||
|
||||
#self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
|
||||
def encrypt(self,text,receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = self.get_random_str() + struct.pack("I",socket.htonl(len(text))) + text + receiveid
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error,None
|
||||
|
||||
def decrypt(self,text,receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error,None
|
||||
try:
|
||||
pad = ord(plain_text[-1])
|
||||
# 去掉补位字符串
|
||||
#pkcs7 = PKCS7Encoder()
|
||||
#plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
xml_len = socket.ntohl(struct.unpack("I",content[ : 4])[0])
|
||||
xml_content = content[4 : xml_len+4]
|
||||
from_receiveid = content[xml_len+4:]
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer,None
|
||||
if from_receiveid != receiveid:
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None
|
||||
return 0,xml_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
rule = string.letters + string.digits
|
||||
str = random.sample(rule, 16)
|
||||
return "".join(str)
|
||||
|
||||
class WXBizMsgCrypt(object):
|
||||
#构造函数
|
||||
def __init__(self,sToken,sEncodingAESKey,sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey+"=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
#验证URL
|
||||
#@param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
#@param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
#@param sNonce: 随机串,对应URL参数的nonce
|
||||
#@param sEchoStr: 随机串,对应URL参数的echostr
|
||||
#@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
#@return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId)
|
||||
return ret,sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None):
|
||||
#将企业回复用户的消息加密打包
|
||||
#@param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串
|
||||
#@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
#@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
#sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
|
||||
#return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
xmlParse = XMLParse()
|
||||
return ret,xmlParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# xml_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
xmlParse = XMLParse()
|
||||
ret,encrypt = xmlParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,xml_content = pc.decrypt(encrypt,self.m_sReceiveId)
|
||||
return ret,xml_content
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseXml_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnXml_Error = -40011
|
||||
@@ -0,0 +1,5 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类,提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizJsonMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
sVerifyTimeStamp="1476416373"
|
||||
sVerifyNonce="47744683"
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print "ERR: VerifyURL ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print "done VerifyURL"
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
|
||||
print "=============================="
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=",
|
||||
"agentid": 218
|
||||
}
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
|
||||
sReqNonce = "1372623149"
|
||||
sReqTimeStamp = "1409659813"
|
||||
|
||||
sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e"
|
||||
sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }';
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print "ERR: DecryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print sMsg
|
||||
# 解密成功,sMsg即为json格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# ...
|
||||
# ...
|
||||
|
||||
print "=============================="
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
|
||||
{
|
||||
"ToUserName": "mycreate",
|
||||
"FromUserName":"wx5823bf96d3bd56c7",
|
||||
"CreateTime": 1348831860,
|
||||
"MsgType": "text",
|
||||
"Content": "this is a test",
|
||||
"MsgId": 1234567890123456,
|
||||
"AgentID": 128
|
||||
}
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
#sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }';
|
||||
sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}'
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print "ERR: EncryptMsg ret: " + str(ret)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print sEncryptMsg
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
print "=============================="
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env python
|
||||
#-*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2020 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import sys
|
||||
import socket
|
||||
import json
|
||||
|
||||
reload(sys)
|
||||
import ierror
|
||||
sys.setdefaultencoding('utf-8')
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist))
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class JsonParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# json消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = '''{
|
||||
"encrypt": "%(msg_encrypt)s",
|
||||
"msgsignature": "%(msg_signaturet)s",
|
||||
"timestamp": "%(timestamp)s",
|
||||
"nonce": "%(nonce)s"
|
||||
}'''
|
||||
|
||||
def extract(self, jsontext):
|
||||
"""提取出json数据包中的加密消息
|
||||
@param jsontext: 待提取的json字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
json_dict = json.loads(jsontext)
|
||||
return ierror.WXBizMsgCrypt_OK, json_dict['encrypt']
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_ParseJson_Error, None
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成json消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的json字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt' : encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp' : timestamp,
|
||||
'nonce' : nonce,
|
||||
}
|
||||
resp_json = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_json
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad<1 or pad >32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self,key):
|
||||
|
||||
#self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
|
||||
def encrypt(self,text,receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = self.get_random_str() + struct.pack("I",socket.htonl(len(text))) + text + receiveid
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error,None
|
||||
|
||||
def decrypt(self,text,receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error,None
|
||||
try:
|
||||
pad = ord(plain_text[-1])
|
||||
# 去掉补位字符串
|
||||
#pkcs7 = PKCS7Encoder()
|
||||
#plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
json_len = socket.ntohl(struct.unpack("I",content[ : 4])[0])
|
||||
json_content = content[4 : json_len+4]
|
||||
from_receiveid = content[json_len+4:]
|
||||
except Exception,e:
|
||||
print e
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer,None
|
||||
if from_receiveid != receiveid:
|
||||
print "receiveid not match"
|
||||
print from_receiveid
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None
|
||||
return 0,json_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
rule = string.letters + string.digits
|
||||
str = random.sample(rule, 16)
|
||||
return "".join(str)
|
||||
|
||||
class WXBizJsonMsgCrypt(object):
|
||||
#构造函数
|
||||
def __init__(self,sToken,sEncodingAESKey,sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey+"=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
#验证URL
|
||||
#@param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
#@param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
#@param sNonce: 随机串,对应URL参数的nonce
|
||||
#@param sEchoStr: 随机串,对应URL参数的echostr
|
||||
#@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
#@return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId)
|
||||
return ret,sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None):
|
||||
#将企业回复用户的消息加密打包
|
||||
#@param sReplyMsg: 企业号待回复用户的消息,json格式的字符串
|
||||
#@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
#@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
#sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串,
|
||||
#return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
jsonParse = JsonParse()
|
||||
return ret,jsonParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# json_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
jsonParse = JsonParse()
|
||||
ret,encrypt = jsonParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
print "signature not match"
|
||||
print signature
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,json_content = pc.decrypt(encrypt,self.m_sReceiveId)
|
||||
return ret,json_content
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||
@@ -0,0 +1,5 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类,提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizJsonMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt
|
||||
import sys
|
||||
import json
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
sVerifyTimeStamp="1476416373"
|
||||
sVerifyNonce="47744683"
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print("ERR: VerifyURL ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("done VerifyURL")
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
|
||||
print("==============================")
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=",
|
||||
"agentid": 218
|
||||
}
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
|
||||
sReqNonce = "1372623149"
|
||||
sReqTimeStamp = "1409659813"
|
||||
|
||||
sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e"
|
||||
sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }';
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
# 解密成功,sMsg即为json格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# ...
|
||||
# ...
|
||||
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
|
||||
{
|
||||
"ToUserName": "mycreate",
|
||||
"FromUserName":"wx5823bf96d3bd56c7",
|
||||
"CreateTime": 1348831860,
|
||||
"MsgType": "text",
|
||||
"Content": "this is a test",
|
||||
"MsgId": 1234567890123456,
|
||||
"AgentID": 128
|
||||
}
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
#sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }';
|
||||
sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}'
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print("ERR: EncryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sEncryptMsg)
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
对上面加密的包进行解密
|
||||
'''
|
||||
sReqMsgSig = json.loads(sEncryptMsg)['msgsignature']
|
||||
sReqTimeStamp = json.loads(sEncryptMsg)['timestamp']
|
||||
sReqNonce = json.loads(sEncryptMsg)['nonce']
|
||||
|
||||
ret,sMsg=wxcpt.DecryptMsg( sEncryptMsg, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python
|
||||
#-*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2020 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
import base64
|
||||
import string
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import sys
|
||||
import socket
|
||||
import json
|
||||
|
||||
import ierror
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
# 确保所有输入都是字符串类型
|
||||
if isinstance(encrypt, bytes):
|
||||
encrypt = encrypt.decode('utf-8')
|
||||
|
||||
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode('utf-8'))
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class JsonParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# json消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = '''{
|
||||
"encrypt": "%(msg_encrypt)s",
|
||||
"msgsignature": "%(msg_signaturet)s",
|
||||
"timestamp": "%(timestamp)s",
|
||||
"nonce": "%(nonce)s"
|
||||
}'''
|
||||
|
||||
def extract(self, jsontext):
|
||||
"""提取出json数据包中的加密消息
|
||||
@param jsontext: 待提取的json字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
json_dict = json.loads(jsontext)
|
||||
return ierror.WXBizMsgCrypt_OK, json_dict['encrypt']
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_ParseJson_Error, None
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成json消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的json字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt' : encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp' : timestamp,
|
||||
'nonce' : nonce,
|
||||
}
|
||||
resp_json = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_json
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文(bytes类型)
|
||||
@return: 补齐明文字符串(bytes类型)
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = bytes([amount_to_pad])
|
||||
# 确保text是bytes类型
|
||||
if isinstance(text, str):
|
||||
text = text.encode('utf-8')
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad<1 or pad >32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self,key):
|
||||
|
||||
#self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
|
||||
def encrypt(self, text, receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = text.encode()
|
||||
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode()
|
||||
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self,text,receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key,self.mode,self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error,None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
# 去掉补位字符串
|
||||
#pkcs7 = PKCS7Encoder()
|
||||
#plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
json_len = socket.ntohl(struct.unpack("I",content[ : 4])[0])
|
||||
json_content = content[4 : json_len+4].decode('utf-8')
|
||||
from_receiveid = content[json_len+4:].decode('utf-8')
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer,None
|
||||
if from_receiveid != receiveid:
|
||||
print("receiveid not match", receiveid, from_receiveid)
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error,None
|
||||
return 0,json_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
class WXBizJsonMsgCrypt(object):
|
||||
#构造函数
|
||||
def __init__(self,sToken,sEncodingAESKey,sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey+"=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
#验证URL
|
||||
#@param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
#@param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
#@param sNonce: 随机串,对应URL参数的nonce
|
||||
#@param sEchoStr: 随机串,对应URL参数的echostr
|
||||
#@param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
#@return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,sReplyEchoStr = pc.decrypt(sEchoStr,self.m_sReceiveId)
|
||||
return ret,sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp = None):
|
||||
#将企业回复用户的消息加密打包
|
||||
#@param sReplyMsg: 企业号待回复用户的消息,json格式的字符串
|
||||
#@param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
#@param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
#sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的json格式的字符串,
|
||||
#return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode('utf-8')
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret,None
|
||||
jsonParse = JsonParse()
|
||||
return ret,jsonParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# json_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
jsonParse = JsonParse()
|
||||
ret,encrypt = jsonParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret,signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
print("signature not match")
|
||||
print(signature)
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret,json_content = pc.decrypt(encrypt,self.m_sReceiveId)
|
||||
return ret,json_content
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||
@@ -0,0 +1,5 @@
|
||||
注意事项
|
||||
1.WXBizMsgCrypt.py文件封装了WXBizMsgCrypt接口类(Python3以及以上版本使用 WXBizMsgCrypt3.py),提供了用户接入企业微信的三个接口,Sample.py文件提供了如何使用这三个接口的示例,ierror.py提供了错误码。
|
||||
2.WXBizMsgCrypt封装了VerifyURL, DecryptMsg, EncryptMsg三个接口,分别用于开发者验证回调url,收到用户回复消息的解密以及开发者回复消息的加密过程。使用方法可以参考Sample.py文件。
|
||||
3.加解密协议请参考企业微信官方文档。
|
||||
4.本代码用到了pycrypto第三方库,请开发者自行安装此库再使用。
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 03:55:41 PM CST
|
||||
# File Name: Sample.py
|
||||
# Description: WXBizJsonMsgCrypt 使用demo文件
|
||||
#########################################################################
|
||||
from WXBizJsonMsgCrypt import WXBizJsonMsgCrypt
|
||||
import sys
|
||||
import json
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 企业在企业微信后台上设置的密钥相关配置在这里 TODO
|
||||
sToken = "xxxxxxx"
|
||||
sEncodingAESKey = "xxxxxxx"
|
||||
sCorpID = "ww1436e0e65a779aee"
|
||||
'''
|
||||
------------使用示例一:验证回调URL---------------
|
||||
*企业开启回调模式时,企业号会向验证url发送一个get请求
|
||||
假设点击验证时,企业收到类似请求:
|
||||
* GET /cgi-bin/wxpush?msg_signature=5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3×tamp=1409659589&nonce=263014780&echostr=P9nAzCzyDtyTWESHep1vC5X9xho%2FqYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp%2B4RPcs8TgAE7OaBO%2BFZXvnaqQ%3D%3D
|
||||
* HTTP/1.1 Host: qy.weixin.qq.com
|
||||
|
||||
接收到该请求时,企业应 1.解析出Get请求的参数,包括消息体签名(msg_signature),时间戳(timestamp),随机数字串(nonce)以及企业微信推送过来的随机加密字符串(echostr),
|
||||
这一步注意作URL解码。
|
||||
2.验证消息体签名的正确性
|
||||
3. 解密出echostr原文,将原文当作Get请求的response,返回给企业微信
|
||||
第2,3步可以用企业微信提供的库函数VerifyURL来实现。
|
||||
'''
|
||||
wxcpt=WXBizJsonMsgCrypt(sToken,sEncodingAESKey,sCorpID)
|
||||
sVerifyMsgSig="012bc692d0a58dd4b10f8dfe5c4ac00ae211ebeb"
|
||||
sVerifyTimeStamp="1476416373"
|
||||
sVerifyNonce="47744683"
|
||||
sVerifyEchoStr="fsi1xnbH4yQh0+PJxcOdhhK6TDXkjMyhEPA7xB2TGz6b+g7xyAbEkRxN/3cNXW9qdqjnoVzEtpbhnFyq6SVHyA=="
|
||||
ret,sEchoStr=wxcpt.VerifyURL(sVerifyMsgSig, sVerifyTimeStamp,sVerifyNonce,sVerifyEchoStr)
|
||||
if(ret!=0):
|
||||
print("ERR: VerifyURL ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("done VerifyURL")
|
||||
#验证URL成功,将sEchoStr返回给企业号
|
||||
|
||||
print("==============================")
|
||||
'''
|
||||
------------使用示例二:对用户回复的消息解密---------------
|
||||
用户回复消息或者点击事件响应时,企业会收到回调消息,此消息是经过企业微信加密之后的密文以post形式发送给企业,密文格式请参考官方文档
|
||||
假设企业收到企业微信的回调消息如下:
|
||||
POST /cgi-bin/wxpush? msg_signature=e3647471e395139e2308c1fa963f2d648a00b90e×tamp=1409659813&nonce=1372623149 HTTP/1.1
|
||||
Host: qy.weixin.qq.com
|
||||
|
||||
{
|
||||
"tousername": "wx5823bf96d3bd56c7",
|
||||
"encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=",
|
||||
"agentid": 218
|
||||
}
|
||||
|
||||
企业收到post请求之后应该 1.解析出url上的参数,包括消息体签名(msg_signature),时间戳(timestamp)以及随机数字串(nonce)
|
||||
2.验证消息体签名的正确性。 3.将post请求的数据进行json解析,并将"encrypt"标签的内容进行解密,解密出来的明文即是用户回复消息的明文,明文格式请参考官方文档
|
||||
第2,3步可以用企业微信提供的库函数DecryptMsg来实现。
|
||||
'''
|
||||
|
||||
sReqNonce = "1372623149"
|
||||
sReqTimeStamp = "1409659813"
|
||||
|
||||
sReqMsgSig = "e3647471e395139e2308c1fa963f2d648a00b90e"
|
||||
sReqData = '{ "tousername": "wx5823bf96d3bd56c7", "encrypt": "cjhLUX7UU4yCSelv1vz7T0zT8huF51bAMVWriNvO1FMegHrQZNrtvRxbwf0fUPsFvwqR0U0fgiJNEA5Y30F2MoI2S7vv3EjVQ68C0cjw9frBoUE2Hj0BvFp9h3u6Vbsg4lc1C8AtHdaN8orKuNKkLRLuYEL52R1J3v8olJGZRLnRdVKIivixmX/eQpzgeExtp20jI1HxRP1AAZ6xZoILdqDPO549LO4WeG+685JRUTdiwcY5fjZlqeMxuT4PpMn1X9OWsS7NRj06Wa5E3Tvg4twjWp39KPfOdRte6P1T4JU=", "agentid": 218 }';
|
||||
ret,sMsg=wxcpt.DecryptMsg( sReqData, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
# 解密成功,sMsg即为json格式的明文
|
||||
# TODO: 对明文的处理
|
||||
# ...
|
||||
# ...
|
||||
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
------------使用示例三:企业回复用户消息的加密---------------
|
||||
企业被动回复用户的消息也需要进行加密,并且拼接成密文格式的json串。
|
||||
假设企业需要回复用户的明文如下:
|
||||
|
||||
{
|
||||
"ToUserName": "mycreate",
|
||||
"FromUserName":"wx5823bf96d3bd56c7",
|
||||
"CreateTime": 1348831860,
|
||||
"MsgType": "text",
|
||||
"Content": "this is a test",
|
||||
"MsgId": 1234567890123456,
|
||||
"AgentID": 128
|
||||
}
|
||||
|
||||
为了将此段明文回复给用户,企业应: 1.自己生成时间时间戳(timestamp),随机数字串(nonce)以便生成消息体签名,也可以直接用从企业微信的post url上解析出的对应值。
|
||||
2.将明文加密得到密文。 3.用密文,步骤1生成的timestamp,nonce和企业在企业微信设定的token生成消息体签名。 4.将密文,消息体签名,时间戳,随机数字串拼接成json格式的字符串,发送给企业号。
|
||||
以上2,3,4步可以用企业微信提供的库函数EncryptMsg来实现。
|
||||
'''
|
||||
#sRespData = ' { "ToUserName": "mycreate", "FromUserName":"wx5823bf96d3bd56c7", "CreateTime": 1348831860, "MsgType": "text", "Content": "this is a test", "MsgId": 1234567890123456, "AgentID": 128 }';
|
||||
sRespData = '{ "ToUserName": "wx5823bf96d3bd56c7", "FromUserName": :mycreate", "CreateTime": 1409659813, "MsgType": "text", "Content": "hello", "MsgId": 4561255354251345929, "AgentID": 218}'
|
||||
ret,sEncryptMsg=wxcpt.EncryptMsg(sRespData, sReqNonce, sReqTimeStamp)
|
||||
if( ret!=0 ):
|
||||
print("ERR: EncryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sEncryptMsg)
|
||||
#ret == 0 加密成功,企业需要将sEncryptMsg返回给企业号
|
||||
print("==============================")
|
||||
|
||||
'''
|
||||
对上面加密的包进行解密
|
||||
'''
|
||||
sReqMsgSig = json.loads(sEncryptMsg)['msgsignature']
|
||||
sReqTimeStamp = json.loads(sEncryptMsg)['timestamp']
|
||||
sReqNonce = json.loads(sEncryptMsg)['nonce']
|
||||
|
||||
ret,sMsg=wxcpt.DecryptMsg( sEncryptMsg, sReqMsgSig, sReqTimeStamp, sReqNonce)
|
||||
if( ret!=0 ):
|
||||
print("ERR: DecryptMsg ret: " + str(ret))
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(sMsg)
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding:utf-8 -*-
|
||||
|
||||
""" 对企业微信发送给企业后台的消息加解密示例代码.
|
||||
@copyright: Copyright (c) 1998-2014 Tencent Inc.
|
||||
|
||||
"""
|
||||
# ------------------------------------------------------------------------
|
||||
import logging
|
||||
import base64
|
||||
import random
|
||||
import hashlib
|
||||
import time
|
||||
import struct
|
||||
from Crypto.Cipher import AES
|
||||
import xml.etree.cElementTree as ET
|
||||
import socket
|
||||
|
||||
import ierror
|
||||
|
||||
|
||||
"""
|
||||
关于Crypto.Cipher模块,ImportError: No module named 'Crypto'解决方案
|
||||
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
|
||||
下载后,按照README中的“Installation”小节的提示进行pycrypto安装。
|
||||
"""
|
||||
|
||||
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def throw_exception(message, exception_class=FormatException):
|
||||
"""my define raise exception function"""
|
||||
raise exception_class(message)
|
||||
|
||||
|
||||
class SHA1:
|
||||
"""计算企业微信的消息签名接口"""
|
||||
|
||||
def getSHA1(self, token, timestamp, nonce, encrypt):
|
||||
"""用SHA1算法生成安全签名
|
||||
@param token: 票据
|
||||
@param timestamp: 时间戳
|
||||
@param encrypt: 密文
|
||||
@param nonce: 随机字符串
|
||||
@return: 安全签名
|
||||
"""
|
||||
try:
|
||||
sortlist = [token, timestamp, nonce, encrypt]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode())
|
||||
return ierror.WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class XMLParse:
|
||||
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
|
||||
|
||||
# xml消息模板
|
||||
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
|
||||
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
|
||||
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
|
||||
<TimeStamp>%(timestamp)s</TimeStamp>
|
||||
<Nonce><![CDATA[%(nonce)s]]></Nonce>
|
||||
</xml>"""
|
||||
|
||||
def extract(self, xmltext):
|
||||
"""提取出xml数据包中的加密消息
|
||||
@param xmltext: 待提取的xml字符串
|
||||
@return: 提取出的加密消息字符串
|
||||
"""
|
||||
try:
|
||||
xml_tree = ET.fromstring(xmltext)
|
||||
encrypt = xml_tree.find("Encrypt")
|
||||
return ierror.WXBizMsgCrypt_OK, encrypt.text
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_ParseXml_Error, None
|
||||
|
||||
def generate(self, encrypt, signature, timestamp, nonce):
|
||||
"""生成xml消息
|
||||
@param encrypt: 加密后的消息密文
|
||||
@param signature: 安全签名
|
||||
@param timestamp: 时间戳
|
||||
@param nonce: 随机字符串
|
||||
@return: 生成的xml字符串
|
||||
"""
|
||||
resp_dict = {
|
||||
'msg_encrypt': encrypt,
|
||||
'msg_signaturet': signature,
|
||||
'timestamp': timestamp,
|
||||
'nonce': nonce,
|
||||
}
|
||||
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
|
||||
return resp_xml
|
||||
|
||||
|
||||
class PKCS7Encoder():
|
||||
"""提供基于PKCS7算法的加解密接口"""
|
||||
|
||||
block_size = 32
|
||||
|
||||
def encode(self, text):
|
||||
""" 对需要加密的明文进行填充补位
|
||||
@param text: 需要进行填充补位操作的明文
|
||||
@return: 补齐明文字符串
|
||||
"""
|
||||
text_length = len(text)
|
||||
# 计算需要填充的位数
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
# 获得补位所用的字符
|
||||
pad = chr(amount_to_pad)
|
||||
return text + (pad * amount_to_pad).encode()
|
||||
|
||||
def decode(self, decrypted):
|
||||
"""删除解密后明文的补位字符
|
||||
@param decrypted: 解密后的明文
|
||||
@return: 删除补位字符后的明文
|
||||
"""
|
||||
pad = ord(decrypted[-1])
|
||||
if pad < 1 or pad > 32:
|
||||
pad = 0
|
||||
return decrypted[:-pad]
|
||||
|
||||
|
||||
class Prpcrypt(object):
|
||||
"""提供接收和推送给企业微信消息的加解密接口"""
|
||||
|
||||
def __init__(self, key):
|
||||
|
||||
# self.key = base64.b64decode(key+"=")
|
||||
self.key = key
|
||||
# 设置加解密模式为AES的CBC模式
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
def encrypt(self, text, receiveid):
|
||||
"""对明文进行加密
|
||||
@param text: 需要加密的明文
|
||||
@return: 加密得到的字符串
|
||||
"""
|
||||
# 16位随机字符串添加到明文开头
|
||||
text = text.encode()
|
||||
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode()
|
||||
|
||||
# 使用自定义的填充方式对明文进行补位填充
|
||||
pkcs7 = PKCS7Encoder()
|
||||
text = pkcs7.encode(text)
|
||||
# 加密
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
try:
|
||||
ciphertext = cryptor.encrypt(text)
|
||||
# 使用BASE64对加密后的字符串进行编码
|
||||
return ierror.WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self, text, receiveid):
|
||||
"""对解密后的明文进行补位删除
|
||||
@param text: 密文
|
||||
@return: 删除填充补位后的明文
|
||||
"""
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
# 使用BASE64对密文进行解码,然后AES-CBC解密
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_DecryptAES_Error, None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
# 去掉补位字符串
|
||||
# pkcs7 = PKCS7Encoder()
|
||||
# plain_text = pkcs7.encode(plain_text)
|
||||
# 去除16位随机字符串
|
||||
content = plain_text[16:-pad]
|
||||
xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0])
|
||||
xml_content = content[4: xml_len + 4]
|
||||
from_receiveid = content[xml_len + 4:]
|
||||
except Exception as e:
|
||||
logger = logging.getLogger()
|
||||
logger.error(e)
|
||||
return ierror.WXBizMsgCrypt_IllegalBuffer, None
|
||||
|
||||
if from_receiveid.decode('utf8') != receiveid:
|
||||
return ierror.WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||
return 0, xml_content
|
||||
|
||||
def get_random_str(self):
|
||||
""" 随机生成16位字符串
|
||||
@return: 16位字符串
|
||||
"""
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WXBizMsgCrypt(object):
|
||||
# 构造函数
|
||||
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
|
||||
try:
|
||||
self.key = base64.b64decode(sEncodingAESKey + "=")
|
||||
assert len(self.key) == 32
|
||||
except:
|
||||
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
|
||||
# return ierror.WXBizMsgCrypt_IllegalAesKey,None
|
||||
self.m_sToken = sToken
|
||||
self.m_sReceiveId = sReceiveId
|
||||
|
||||
# 验证URL
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sEchoStr: 随机串,对应URL参数的echostr
|
||||
# @param sReplyEchoStr: 解密之后的echostr,当return返回0时有效
|
||||
# @return:成功0,失败返回对应的错误码
|
||||
|
||||
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
|
||||
return ret, sReplyEchoStr
|
||||
|
||||
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
|
||||
# 将企业回复用户的消息加密打包
|
||||
# @param sReplyMsg: 企业号待回复用户的消息,xml格式的字符串
|
||||
# @param sTimeStamp: 时间戳,可以自己生成,也可以用URL参数的timestamp,如为None则自动用当前时间
|
||||
# @param sNonce: 随机串,可以自己生成,也可以用URL参数的nonce
|
||||
# sEncryptMsg: 加密后的可以直接回复用户的密文,包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
|
||||
# return:成功0,sEncryptMsg,失败返回对应的错误码None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
|
||||
encrypt = encrypt.decode('utf8')
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
# 生成安全签名
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
xmlParse = XMLParse()
|
||||
return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)
|
||||
|
||||
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
|
||||
# 检验消息的真实性,并且获取解密后的明文
|
||||
# @param sMsgSignature: 签名串,对应URL参数的msg_signature
|
||||
# @param sTimeStamp: 时间戳,对应URL参数的timestamp
|
||||
# @param sNonce: 随机串,对应URL参数的nonce
|
||||
# @param sPostData: 密文,对应POST请求的数据
|
||||
# xml_content: 解密后的原文,当return返回0时有效
|
||||
# @return: 成功0,失败返回对应的错误码
|
||||
# 验证安全签名
|
||||
xmlParse = XMLParse()
|
||||
ret, encrypt = xmlParse.extract(sPostData)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
sha1 = SHA1()
|
||||
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if not signature == sMsgSignature:
|
||||
return ierror.WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = Prpcrypt(self.key)
|
||||
ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)
|
||||
return ret, xml_content
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
#########################################################################
|
||||
# Author: jonyqin
|
||||
# Created Time: Thu 11 Sep 2014 01:53:58 PM CST
|
||||
# File Name: ierror.py
|
||||
# Description:定义错误码含义
|
||||
#########################################################################
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseXml_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnXml_Error = -40011
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding:utf-8 -*-
|
||||
##
|
||||
# Copyright (C) 2018 All rights reserved.
|
||||
#
|
||||
# @File conf.py
|
||||
# @Brief
|
||||
# @Author abelzhu, abelzhu@tencent.com
|
||||
# @Version 1.0
|
||||
# @Date 2018-02-23
|
||||
#
|
||||
#
|
||||
|
||||
## 设置为true会打印一些调试信息
|
||||
DEBUG = True
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
发送应用消息
|
||||
最后更新:2025/09/24
|
||||
接口定义
|
||||
应用支持推送文本、图片、视频、文件、图文等类型。
|
||||
|
||||
请求方式:POST(HTTPS)
|
||||
请求地址: https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN
|
||||
|
||||
参数说明:
|
||||
|
||||
参数 是否必须 说明
|
||||
access_token 是 调用接口凭证
|
||||
- 各个消息类型的具体POST格式请阅后续“消息类型”部分。
|
||||
- 如果有在管理端对应用设置“在微工作台中始终进入主页”,应用在微信端只能接收到文本消息,并且文本消息的长度限制为20字节,超过20字节会被截断。同时其他消息类型也会转换为文本消息,提示用户到企业微信查看。
|
||||
- 支持id转译,将userid/部门id转成对应的用户名/部门名,在企业授权了会话内容存档接口权限时,也可以将消息id和群id转成对应的消息内容/群名称,目前仅文本/文本卡片/图文/图文(mpnews)/任务卡片/小程序通知/模版消息/模板卡片消息这八种消息类型的部分字段支持。具体支持的范围和语法,请查看附录id转译说明。
|
||||
- 支持重复消息检查,当指定 "enable_duplicate_check": 1开启: 表示在一定时间间隔内,同样内容(请求json)的消息,不会重复收到;时间间隔可通过duplicate_check_interval指定,默认1800秒。
|
||||
- 从2021年2月4日开始,企业关联添加的「小程序」应用,也可以发送文本、图片、视频、文件、图文等各种类型的消息了。
|
||||
调用建议:大部分企业应用在每小时的0分或30分触发推送消息,容易造成资源挤占,从而投递不够及时,建议尽量避开这两个时间点进行调用。
|
||||
频率限制:每应用不可超过账号上限数*200人次/天(注:若调用api一次发给1000人,算1000人次;若企业账号上限是500人,则每个应用每天可发送100000人次的消息)。每应用对同一个成员不可超过30次/分钟,1000次/小时,超过部分会被丢弃不下发
|
||||
返回示例:
|
||||
|
||||
{
|
||||
"errcode" : 0,
|
||||
"errmsg" : "ok",
|
||||
"invaliduser" : "userid1|userid2",
|
||||
"invalidparty" : "partyid1|partyid2",
|
||||
"invalidtag": "tagid1|tagid2",
|
||||
"unlicenseduser" : "userid3|userid4",
|
||||
"msgid": "xxxx",
|
||||
"response_code": "xyzxyz"
|
||||
}
|
||||
如果部分接收人无权限或不存在,发送仍然执行,但会返回无效的部分(即invaliduser或invalidparty或invalidtag或unlicenseduser),常见的原因是接收人不在应用的可见范围内。
|
||||
权限包含应用可见范围和基础接口权限(基础账号、互通账号均可),unlicenseduser中的用户在应用可见范围内但没有基础接口权限。
|
||||
如果全部接收人无权限或不存在,则本次调用返回失败,errcode为81013。
|
||||
返回包中的userid,不区分大小写,统一转为小写
|
||||
|
||||
|
||||
|
||||
---
|
||||
文本卡片消息
|
||||
请求示例:
|
||||
```
|
||||
{
|
||||
"touser" : "UserID1|UserID2|UserID3",
|
||||
"toparty" : "PartyID1 | PartyID2",
|
||||
"totag" : "TagID1 | TagID2",
|
||||
"msgtype" : "textcard",
|
||||
"agentid" : 1,
|
||||
"textcard" : {
|
||||
"title" : "领奖通知",
|
||||
"description" : "<div class=\"gray\">2016年9月26日</div> <div class=\"normal\">恭喜你抽中iPhone 7一台,领奖码:xxxx</div><div class=\"highlight\">请于2016年10月10日前联系行政同事领取</div>",
|
||||
"url" : "URL",
|
||||
"btntxt":"更多"
|
||||
},
|
||||
"enable_id_trans": 0,
|
||||
"enable_duplicate_check": 0,
|
||||
"duplicate_check_interval": 1800
|
||||
}
|
||||
```
|
||||
参数说明:
|
||||
|
||||
参数 是否必须 说明
|
||||
touser 否 成员ID列表(消息接收者,多个接收者用‘|’分隔,最多支持1000个)。特殊情况:指定为@all,则向关注该企业应用的全部成员发送
|
||||
toparty 否 部门ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数
|
||||
totag 否 标签ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数
|
||||
msgtype 是 消息类型,此时固定为:textcard
|
||||
agentid 是 企业应用的id,整型。企业内部开发,可在应用的设置页面查看;第三方服务商,可通过接口 获取企业授权信息 获取该参数值
|
||||
title 是 标题,不超过128个字符,超过会自动截断(支持id转译)
|
||||
description 是 描述,不超过512个字符,超过会自动截断(支持id转译)
|
||||
url 是 点击后跳转的链接。最长2048字节,请确保包含了协议头(http/https)
|
||||
btntxt 否 按钮文字。 默认为“详情”, 不超过4个文字,超过自动截断。
|
||||
enable_id_trans 否 表示是否开启id转译,0表示否,1表示是,默认0
|
||||
enable_duplicate_check 否 表示是否开启重复消息检查,0表示否,1表示是,默认0
|
||||
duplicate_check_interval 否 表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时
|
||||
|
||||
特殊说明:
|
||||
卡片消息的展现形式非常灵活,支持使用br标签或者空格来进行换行处理,也支持使用div标签来使用不同的字体颜色,目前内置了3种文字颜色:灰色(gray)、高亮(highlight)、默认黑色(normal),将其作为div标签的class属性即可,具体用法请参考上面的示例。
|
||||
Reference in New Issue
Block a user