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:
2026-01-08 09:31:47 +08:00
parent 28c57a040a
commit 317dbf3c58
37 changed files with 3065 additions and 10 deletions
+59
View File
@@ -797,3 +797,62 @@
2026-01-07 17:36:12 - __main__ - INFO - 筛选后剩余 0 条公告
2026-01-07 17:36:12 - storage.md_generator - INFO - Markdown文件已保存到: onu.md (共 0 条公告)
2026-01-07 17:36:12 - __main__ - INFO - 爬取任务完成: {'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}}
2026-01-08 08:17:30 - __main__ - INFO - === 广西政府采购网公告监控系统启动 ===
2026-01-08 08:17:30 - __main__ - INFO - 版本: 1.0.0
2026-01-08 08:17:30 - __main__ - INFO - 配置文件: 默认配置
2026-01-08 08:17:30 - core.reliability - INFO - 健康检查通过
2026-01-08 08:17:30 - core.database - INFO - 数据库连接池初始化成功
2026-01-08 08:17:30 - core.database - INFO - 开始初始化数据库表结构
2026-01-08 08:17:30 - core.database - INFO - 数据库表结构初始化完成
2026-01-08 08:17:30 - storage.postgresql - INFO - 存储初始化完成
2026-01-08 08:17:30 - __main__ - INFO - 应用初始化完成
2026-01-08 08:17:30 - __main__ - INFO - 开始执行爬取任务
2026-01-08 08:17:30 - crawler.spider - INFO - 开始爬取 13 个公告来源
2026-01-08 08:17:30 - gx_gp_monitor - INFO - 开始爬取 采购公告
2026-01-08 08:17:31 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 0.53秒
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 开始爬取 结果公告
2026-01-08 08:17:31 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.19秒
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 开始爬取 合同公告
2026-01-08 08:17:31 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.20秒
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 开始爬取 更正公告
2026-01-08 08:17:31 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.28秒
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
2026-01-08 08:17:31 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 08:17:31 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
2026-01-08 08:17:32 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:32 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.16秒
2026-01-08 08:17:32 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
2026-01-08 08:17:32 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:32 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.34秒
2026-01-08 08:17:32 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
2026-01-08 08:17:32 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:32 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.48秒
2026-01-08 08:17:32 - gx_gp_monitor - INFO - 开始爬取 工程类公告
2026-01-08 08:17:33 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.23秒
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
2026-01-08 08:17:33 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
2026-01-08 08:17:33 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.23秒
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
2026-01-08 08:17:33 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.16秒
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
2026-01-08 08:17:33 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 08:17:33 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.19秒
2026-01-08 08:17:33 - crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
2026-01-08 08:17:33 - __main__ - INFO - 爬取到 1300 条原始公告
2026-01-08 08:17:33 - filters.filters - INFO - 来源筛选: 1300 -> 1300 条公告
2026-01-08 08:17:34 - filters.filters - INFO - 去重筛选: 移除了 17 条重复公告
2026-01-08 08:17:34 - filters.filters - INFO - 关键词筛选: 1283 -> 0 条公告
2026-01-08 08:17:34 - filters.filters - INFO - 筛选完成: 总数 1300 -> 筛选后 0 (关键词: 1283, 日期: 0, 去重: 17, 来源: 0)
2026-01-08 08:17:34 - __main__ - INFO - 筛选后剩余 0 条公告
2026-01-08 08:17:34 - storage.md_generator - INFO - Markdown文件已保存到: onu.md (共 0 条公告)
2026-01-08 08:17:34 - __main__ - INFO - 爬取任务完成: {'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}}
+8 -3
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
# 广西政府采购网公告监控
**更新时间**: 2026-01-07 17:36:12
**更新时间**: 2026-01-08 08:17:34
## 无新公告
+128 -6
View File
@@ -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:
"""
清理存储中的过期数据
+191
View File
@@ -21,3 +21,194 @@
2026-01-07 17:29:21 - gx_gp_monitor.scheduler.scheduler - INFO - 添加间隔任务: custom_task (30秒)
2026-01-07 17:32:05 - gx_gp_monitor.scheduler.scheduler - INFO - 添加定时任务: data_cleanup (0 2 * * *)
2026-01-07 17:32:05 - gx_gp_monitor.scheduler.scheduler - INFO - 移除任务: data_cleanup
2026-01-08 09:29:31 - __main__ - INFO - === 广西政府采购网公告监控系统启动 ===
2026-01-08 09:29:31 - __main__ - INFO - 版本: 1.0.0
2026-01-08 09:29:31 - __main__ - INFO - 配置文件: 默认配置
2026-01-08 09:29:32 - core.reliability - INFO - 健康检查通过
2026-01-08 09:29:32 - core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:32 - core.database - INFO - 开始初始化数据库表结构
2026-01-08 09:29:32 - core.database - INFO - 数据库表结构初始化完成
2026-01-08 09:29:32 - storage.postgresql - INFO - 存储初始化完成
2026-01-08 09:29:32 - __main__ - INFO - 应用初始化完成
2026-01-08 09:29:33 - __main__ - INFO - === 广西政府采购网公告监控系统启动 ===
2026-01-08 09:29:33 - __main__ - INFO - 版本: 1.0.0
2026-01-08 09:29:33 - __main__ - INFO - 配置文件: 默认配置
2026-01-08 09:29:33 - core.reliability - INFO - 健康检查通过
2026-01-08 09:29:33 - core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:33 - core.database - INFO - 开始初始化数据库表结构
2026-01-08 09:29:33 - core.database - INFO - 数据库表结构初始化完成
2026-01-08 09:29:33 - storage.postgresql - INFO - 存储初始化完成
2026-01-08 09:29:33 - __main__ - INFO - 应用初始化完成
2026-01-08 09:29:33 - __main__ - INFO - 开始执行爬取任务
2026-01-08 09:29:33 - crawler.spider - INFO - 开始爬取 13 个公告来源
2026-01-08 09:29:33 - gx_gp_monitor - INFO - 开始爬取 采购公告
2026-01-08 09:29:34 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 0.47秒
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 开始爬取 结果公告
2026-01-08 09:29:34 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.24秒
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 开始爬取 合同公告
2026-01-08 09:29:34 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.26秒
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 开始爬取 更正公告
2026-01-08 09:29:34 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.19秒
2026-01-08 09:29:34 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
2026-01-08 09:29:35 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.17秒
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
2026-01-08 09:29:35 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.22秒
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
2026-01-08 09:29:35 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.37秒
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
2026-01-08 09:29:35 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.17秒
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 开始爬取 工程类公告
2026-01-08 09:29:35 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 09:29:35 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
2026-01-08 09:29:36 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.25秒
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
2026-01-08 09:29:36 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
2026-01-08 09:29:36 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
2026-01-08 09:29:36 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:36 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 09:29:36 - crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
2026-01-08 09:29:36 - __main__ - INFO - 爬取到 1300 条原始公告
2026-01-08 09:29:36 - storage.postgresql - INFO - 开始按来源保存 1300 条公告,每个来源最多保留 100 条
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement1 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement2 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement3 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement4 保存了 0 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement5 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement6 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement7 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement10 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement11 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement20 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement21 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 ZcyAnnouncement23 保存了 1 条公告
2026-01-08 09:29:36 - core.database - INFO - 批量保存公告完成,成功保存 1 条
2026-01-08 09:29:36 - storage.postgresql - INFO - 来源 61-266648 保存了 1 条公告
2026-01-08 09:29:36 - storage.postgresql - INFO - 按来源保存完成,总计保存 12 条公告
2026-01-08 09:29:36 - __main__ - INFO - 保存所有公告完成:共保存 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}
2026-01-08 09:29:36 - filters.filters - INFO - 来源筛选: 1300 -> 1300 条公告
2026-01-08 09:29:37 - filters.filters - INFO - 去重筛选: 移除了 1300 条重复公告
2026-01-08 09:29:37 - filters.filters - INFO - 关键词筛选: 0 -> 0 条公告
2026-01-08 09:29:37 - filters.filters - INFO - 筛选完成: 总数 1300 -> 筛选后 0 (关键词: 0, 日期: 0, 去重: 1300, 来源: 0)
2026-01-08 09:29:37 - __main__ - INFO - 筛选后剩余 0 条公告
2026-01-08 09:29:37 - storage.md_generator - INFO - Markdown文件已保存到: onu.md (共 0 条公告)
2026-01-08 09:29:37 - __main__ - INFO - 爬取任务完成: {'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}}
2026-01-08 09:29:38 - __main__ - INFO - === 广西政府采购网公告监控系统启动 ===
2026-01-08 09:29:38 - __main__ - INFO - 版本: 1.0.0
2026-01-08 09:29:38 - __main__ - INFO - 配置文件: 默认配置
2026-01-08 09:29:39 - core.reliability - INFO - 健康检查通过
2026-01-08 09:29:39 - core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:39 - core.database - INFO - 开始初始化数据库表结构
2026-01-08 09:29:39 - core.database - INFO - 数据库表结构初始化完成
2026-01-08 09:29:39 - storage.postgresql - INFO - 存储初始化完成
2026-01-08 09:29:39 - __main__ - INFO - 应用初始化完成
2026-01-08 09:29:39 - __main__ - INFO - 开始执行爬取任务
2026-01-08 09:29:39 - crawler.spider - INFO - 开始爬取 13 个公告来源
2026-01-08 09:29:39 - gx_gp_monitor - INFO - 开始爬取 采购公告
2026-01-08 09:29:39 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:39 - gx_gp_monitor - INFO - 采购公告 爬取完成,共获取 100 条公告,耗时 0.46秒
2026-01-08 09:29:39 - gx_gp_monitor - INFO - 开始爬取 结果公告
2026-01-08 09:29:39 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:39 - gx_gp_monitor - INFO - 结果公告 爬取完成,共获取 100 条公告,耗时 0.21秒
2026-01-08 09:29:39 - gx_gp_monitor - INFO - 开始爬取 合同公告
2026-01-08 09:29:40 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 合同公告 爬取完成,共获取 100 条公告,耗时 0.20秒
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 开始爬取 更正公告
2026-01-08 09:29:40 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 更正公告 爬取完成,共获取 100 条公告,耗时 0.19秒
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 开始爬取 招标文件预公示
2026-01-08 09:29:40 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 招标文件预公示 爬取完成,共获取 100 条公告,耗时 0.23秒
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 开始爬取 单一来源公示
2026-01-08 09:29:40 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 单一来源公示 爬取完成,共获取 100 条公告,耗时 0.20秒
2026-01-08 09:29:40 - gx_gp_monitor - INFO - 开始爬取 电子卖场公示
2026-01-08 09:29:41 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 电子卖场公示 爬取完成,共获取 100 条公告,耗时 0.43秒
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 开始爬取 履约验收公示
2026-01-08 09:29:41 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 履约验收公示 爬取完成,共获取 100 条公告,耗时 0.22秒
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 开始爬取 工程类公告
2026-01-08 09:29:41 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 工程类公告 爬取完成,共获取 100 条公告,耗时 0.22秒
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 开始爬取 框架协议征集公告
2026-01-08 09:29:41 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 框架协议征集公告 爬取完成,共获取 100 条公告,耗时 0.21秒
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 开始爬取 框架协议入围结果公告
2026-01-08 09:29:41 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 框架协议入围结果公告 爬取完成,共获取 100 条公告,耗时 0.20秒
2026-01-08 09:29:41 - gx_gp_monitor - INFO - 开始爬取 框架协议成交结果汇总公告
2026-01-08 09:29:42 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:42 - gx_gp_monitor - INFO - 框架协议成交结果汇总公告 爬取完成,共获取 100 条公告,耗时 0.20秒
2026-01-08 09:29:42 - gx_gp_monitor - INFO - 开始爬取 采购意向公开
2026-01-08 09:29:42 - crawler.parsers - INFO - 成功解析 100/100 条公告记录
2026-01-08 09:29:42 - gx_gp_monitor - INFO - 采购意向公开 爬取完成,共获取 100 条公告,耗时 0.18秒
2026-01-08 09:29:42 - crawler.spider - INFO - 爬取完成: 共处理 13 个来源,成功 13 个,失败 0 个,获取 1300 条公告
2026-01-08 09:29:42 - __main__ - INFO - 爬取到 1300 条原始公告
2026-01-08 09:29:42 - storage.postgresql - INFO - 开始按来源保存 1300 条公告,每个来源最多保留 100 条
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement1 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement2 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement3 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement4 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement5 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement6 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement7 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement10 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement11 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement20 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement21 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 ZcyAnnouncement23 保存了 0 条公告
2026-01-08 09:29:42 - core.database - INFO - 批量保存公告完成,成功保存 0 条
2026-01-08 09:29:42 - storage.postgresql - INFO - 来源 61-266648 保存了 0 条公告
2026-01-08 09:29:42 - storage.postgresql - INFO - 按来源保存完成,总计保存 0 条公告
2026-01-08 09:29:42 - __main__ - INFO - 保存所有公告完成:共保存 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}
2026-01-08 09:29:42 - filters.filters - INFO - 来源筛选: 1300 -> 1300 条公告
2026-01-08 09:29:42 - filters.filters - INFO - 去重筛选: 移除了 1300 条重复公告
2026-01-08 09:29:42 - filters.filters - INFO - 关键词筛选: 0 -> 0 条公告
2026-01-08 09:29:42 - filters.filters - INFO - 筛选完成: 总数 1300 -> 筛选后 0 (关键词: 0, 日期: 0, 去重: 1300, 来源: 0)
2026-01-08 09:29:42 - __main__ - INFO - 筛选后剩余 0 条公告
2026-01-08 09:29:42 - storage.md_generator - INFO - Markdown文件已保存到: onu.md (共 0 条公告)
2026-01-08 09:29:42 - __main__ - INFO - 爬取任务完成: {'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}}
2026-01-08 09:29:47 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:47 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:49 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:49 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:52 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
2026-01-08 09:29:52 - gx_gp_monitor.core.database - INFO - 数据库连接池初始化成功
+11
View File
@@ -0,0 +1,11 @@
# 广西政府采购网公告监控
**更新时间**: 2026-01-08 09:29:42
## 无新公告
当前时间范围内没有找到符合条件的公告。
---
*由广西政府采购网公告监控系统生成*
+53
View File
@@ -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()
+104
View File
@@ -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第三方库,请开发者自行安装此库再使用。
+108
View File
@@ -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&timestamp=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&timestamp=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:成功0sEncryptMsg,失败返回对应的错误码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&timestamp=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&timestamp=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:成功0sEncryptMsg,失败返回对应的错误码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&timestamp=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&timestamp=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:成功0sEncryptMsg,失败返回对应的错误码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&timestamp=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&timestamp=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:成功0sEncryptMsg,失败返回对应的错误码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
+16
View File
@@ -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
View File
@@ -0,0 +1,77 @@
发送应用消息
最后更新:2025/09/24
接口定义
应用支持推送文本、图片、视频、文件、图文等类型。
请求方式:POSTHTTPS
请求地址: 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属性即可,具体用法请参考上面的示例。