├── icon.png ├── .vscode └── settings.json ├── .gitignore ├── requirements.txt ├── main.spec ├── wx_video_sdk ├── cache.py ├── api_feilds.py ├── utils.py └── __init__.py ├── config.toml ├── config_test.toml ├── README.md └── main.py /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dsxksss/wx_video_api/HEAD/icon.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | build/ 3 | __pycache__/ 4 | .venv/ 5 | logs/ 6 | caches/ 7 | 视频数据/ 8 | wx_video_sdk_cache.json -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | toml==0.10.2 2 | questionary==2.0.1 3 | requests==2.31.0 4 | qrcode==7.4.2 5 | pycryptodome==3.19.0 6 | pyinstaller==6.3.0 7 | python-dotenv==1.0.0 8 | pytest==7.4.3 9 | black==23.10.1 10 | pylint==3.0.2 11 | certifi==2023.11.17 -------------------------------------------------------------------------------- /main.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['main.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | pyz = PYZ(a.pure) 18 | 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | a.binaries, 23 | a.datas, 24 | [], 25 | name='main', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | upx_exclude=[], 31 | runtime_tmpdir=None, 32 | console=True, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | icon=['icon.png'], 39 | ) 40 | -------------------------------------------------------------------------------- /wx_video_sdk/cache.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | from tinydb import TinyDB, Query 3 | 4 | 5 | class CacheHandler: 6 | def __init__(self, save_path: str): 7 | self.db = TinyDB(save_path) 8 | 9 | # 检查数据是否已存在 10 | def isExists(self, name: str) -> bool: 11 | result = self.db.search(Query().name == name) 12 | return len(result) > 0 13 | 14 | # 存储缓存 15 | def saveCache(self, name: str, key: str, value: Any) -> None: 16 | if not self.isExists(name): 17 | self.db.insert({"name": name, key: value}) 18 | 19 | # 更新缓存 20 | def updateCache(self, name: str, key: str, value: Any) -> None: 21 | self.db.update({key: value}, Query().name == name) 22 | 23 | # 获取缓存 24 | def getCache(self, name: str) -> Dict[str, Any]: 25 | data = self.db.search(Query().name == name) 26 | result = data[0] 27 | return result if result else dict() 28 | 29 | # 获取全部缓存 30 | def getCacheList(self) -> List[Any]: 31 | return self.db.all() 32 | 33 | # 根据缓存名删除某个缓存 34 | def removeCache(self, name: str) -> None: 35 | self.db.remove(Query().name == name) 36 | 37 | # 清空缓存 38 | def clearCache(self) -> None: 39 | self.db.clear_cache() 40 | -------------------------------------------------------------------------------- /wx_video_sdk/api_feilds.py: -------------------------------------------------------------------------------- 1 | class WxVApiFields: 2 | class Auth: 3 | prefix = "/auth" 4 | auth_login_code = prefix + "/auth_login_code" 5 | auth_data = prefix + "/auth_data" 6 | auth_login_status = prefix + "/auth_login_status" 7 | 8 | class Helper: 9 | prefix = "/helper" 10 | helper_upload_params = prefix + "/helper_upload_params" 11 | hepler_merlin_mmdata = prefix + "/hepler_merlin_mmdata" 12 | 13 | class Comment: 14 | prefix = "/comment" 15 | comment_list = prefix + "/comment_list" 16 | create_comment = prefix + "/create_comment" 17 | 18 | class Post: 19 | prefix = "/post" 20 | post_list = prefix + "/post_list" 21 | post_update_visible = prefix + "/post_update_visible" 22 | new_post_total_data = "/statistic/new_post_total_data" 23 | 24 | class PrivateMsg: 25 | prefix = "/private-msg" 26 | get_login_cookie = prefix + "/get-login-cookie" 27 | get_new_msg = prefix + "/get-new-msg" 28 | get_history_msg = prefix + "/get-history-msg" 29 | send_private_msg = prefix + "/send-private-msg" 30 | upload_media_info = prefix + "/upload-media-info" 31 | 32 | 33 | class VideoVisibleTypes: 34 | # 视频可见类型 35 | # Public: 所有人可见 36 | # Private: 仅自己可见 37 | Public = 1 38 | Private = 3 39 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | # 脚本配置 2 | # 配置说明: 3 | # 生效时间区间包含了days字样的配置效果如下 4 | # days = 2 表示昨天指,days = 3 表示前天至今天 5 | 6 | [run_config] 7 | # 运行延迟间隔,值越小脚本处理速度越快,不建议小于1,默认4 8 | run_delay = 4 9 | # 设置最近输出几天之内要查看的视频数据(按该视频创建的时间),默认2,设置成0则不会处理 10 | create_video_report_days = 2 11 | # 心跳检查间隔(秒),默认60秒检查一次会话状态 12 | heartbeat_interval = 60 13 | # 是否开启日志详细模式(0:关闭, 1:开启),默认0 14 | verbose_logging = 0 15 | 16 | 17 | # 操作配置 18 | # 视频是否可见操作配置 19 | [auto_video_visible] 20 | # 是否开启自动修改视频可见功能(0:关闭, 1:开启),默认1 21 | visible_target = 1 22 | # 设置最近几天之内要处理的视频(按该视频创建的时间),默认2,设置成0则不会处理 23 | auto_video_visible_days = 2 24 | # 设置当浏览量大于多少要处理的视频(和days配置配合使用),默认5500 25 | max_video_count = 5500 26 | # 设置当以上两个配置都触发后的视频是否公开或隐藏(1:所有人可见,3:仅自己可见),默认值3 27 | video_visible_type = 3 28 | # 任务执行间隔(秒),默认300秒执行一次 29 | task_interval = 300 30 | 31 | # 视频自动回复评论操作配置 32 | [auto_send_comment] 33 | # 是否开启评论自动回复(0:关闭, 1:开启),默认1 34 | comment_target = 1 35 | # 回复自己的评论(0:关闭, 1:开启),默认0 36 | self_comment_target = 0 37 | # 设置最近几天之内要处理的评论(按该评论创建的时间),默认2,设置成0则不会处理 38 | auto_send_comment_days = 2 39 | # 自动回复评论内容 40 | auto_send_comment_text = "你好我是config toml test 评论回复" 41 | # 任务执行间隔(秒),默认120秒执行一次 42 | task_interval = 120 43 | # 多条随机回复文本,用英文分号;分隔,如果不设置则使用auto_send_comment_text 44 | random_replies = "感谢您的评论;谢谢支持;已收到您的评论,感谢反馈" 45 | 46 | # 视频自动回复私信操作配置 (这里只处理当脚本开启之后,用户再发送过来的私信) 47 | [auto_send_private_msg] 48 | # 是否开启消息发送(0:关闭, 1:开启),默认1 49 | private_msg_target = 1 50 | # 是否开启图片发送(0:关闭, 1:开启),默认1 51 | private_img_target = 1 52 | # 设置最近几天之内要处理的私信(按该私信创建的时间),默认1,设置成0则不会处理 53 | auto_send_msg_days = 1 54 | # 自动回复私信文字内容 55 | auto_send_private_msg = "你好我是私信的消息" 56 | # 自动回复私信图片文件路径 57 | auto_send_img_path = "./icon.png" 58 | # 任务执行间隔(秒),默认60秒执行一次 59 | task_interval = 60 60 | # 多条随机回复文本,用英文分号;分隔,如果不设置则使用auto_send_private_msg 61 | random_replies = "您好,感谢私信;已收到您的消息,稍后回复;谢谢您的关注" 62 | 63 | # 数据导出配置 64 | [data_export] 65 | # 是否开启数据导出(0:关闭, 1:开启),默认1 66 | export_target = 1 67 | # 数据导出间隔(秒),默认3600秒(1小时)执行一次 68 | export_interval = 3600 69 | # 数据导出路径,默认./视频数据 70 | export_path = "./视频数据" 71 | # 是否导出CSV格式(0:关闭, 1:开启),默认1 72 | export_csv = 1 73 | -------------------------------------------------------------------------------- /config_test.toml: -------------------------------------------------------------------------------- 1 | # 脚本配置 2 | # 配置说明: 3 | # 生效时间区间包含了days字样的配置效果如下 4 | # days = 2 表示昨天指,days = 3 表示前天至今天 5 | 6 | [run_config] 7 | # 运行延迟间隔,值越小脚本处理速度越快,不建议小于1,默认4 8 | run_delay = 2 9 | # 设置最近输出几天之内要查看的视频数据(按该视频创建的时间),默认2,设置成0则不会处理 10 | create_video_report_days = 2 11 | # 心跳检查间隔(秒),默认60秒检查一次会话状态 12 | heartbeat_interval = 30 13 | # 是否开启日志详细模式(0:关闭, 1:开启),默认0 14 | verbose_logging = 1 15 | 16 | 17 | # 操作配置 18 | # 视频是否可见操作配置 19 | [auto_video_visible] 20 | # 是否开启自动修改视频可见功能(0:关闭, 1:开启),默认1 21 | visible_target = 1 22 | # 设置最近几天之内要处理的视频(按该视频创建的时间),默认2,设置成0则不会处理 23 | auto_video_visible_days = 2 24 | # 设置当浏览量大于多少要处理的视频(和days配置配合使用),默认5500 25 | max_video_count = 5500 26 | # 设置当以上两个配置都触发后的视频是否公开或隐藏(1:所有人可见,3:仅自己可见),默认值3 27 | video_visible_type = 3 28 | # 任务执行间隔(秒),默认300秒执行一次 29 | task_interval = 120 30 | 31 | # 视频自动回复评论操作配置 32 | [auto_send_comment] 33 | # 是否开启评论自动回复(0:关闭, 1:开启),默认1 34 | comment_target = 1 35 | # 回复自己的评论(0:关闭, 1:开启),默认0 36 | self_comment_target = 0 37 | # 设置最近几天之内要处理的评论(按该评论创建的时间),默认2,设置成0则不会处理 38 | auto_send_comment_days = 2 39 | # 自动回复评论内容 40 | auto_send_comment_text = "你好,测试配置的评论回复" 41 | # 任务执行间隔(秒),默认120秒执行一次 42 | task_interval = 60 43 | # 多条随机回复文本,用英文分号;分隔,如果不设置则使用auto_send_comment_text 44 | random_replies = "谢谢您的评论;感谢观看本视频;您的评论收到了,谢谢" 45 | 46 | # 视频自动回复私信操作配置 (这里只处理当脚本开启之后,用户再发送过来的私信) 47 | [auto_send_private_msg] 48 | # 是否开启消息发送(0:关闭, 1:开启),默认1 49 | private_msg_target = 1 50 | # 是否开启图片发送(0:关闭, 1:开启),默认1 51 | private_img_target = 1 52 | # 设置最近几天之内要处理的私信(按该私信创建的时间),默认1,设置成0则不会处理 53 | auto_send_msg_days = 1 54 | # 自动回复私信文字内容 55 | auto_send_private_msg = "你好,测试配置的私信回复" 56 | # 自动回复私信图片文件路径 57 | auto_send_img_path = "./icon.png" 58 | # 任务执行间隔(秒),默认60秒执行一次 59 | task_interval = 30 60 | # 多条随机回复文本,用英文分号;分隔,如果不设置则使用auto_send_private_msg 61 | random_replies = "你好,很高兴收到您的私信;感谢关注,稍后回复您;收到您的消息了" 62 | 63 | # 数据导出配置 64 | [data_export] 65 | # 是否开启数据导出(0:关闭, 1:开启),默认1 66 | export_target = 1 67 | # 数据导出间隔(秒),默认3600秒(1小时)执行一次 68 | export_interval = 900 69 | # 数据导出路径,默认./视频数据 70 | export_path = "./视频数据/测试" 71 | # 是否导出CSV格式(0:关闭, 1:开启),默认1 72 | export_csv = 1 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 微信视频号助手 (wx_video_sdk) v1.1.0 2 | 3 | ## 功能介绍 4 | 5 | 微信视频号助手是一个自动化工具,可以帮助视频号运营者自动执行以下任务: 6 | 7 | 1. **数据统计与导出**:自动收集视频播放、点赞、评论等数据,并支持CSV格式导出 8 | 2. **视频可见性管理**:根据设定的条件(如播放量)自动调整视频可见性 9 | 3. **自动回复评论**:自动回复视频下的评论,支持随机回复内容 10 | 4. **自动回复私信**:自动回复用户私信,支持文字和图片,支持随机回复内容 11 | 5. **数据报告生成**:自动生成视频数据报告 12 | 13 | ## 安装方法 14 | 15 | ### 打包为可执行文件 16 | 17 | ```bash 18 | pyinstaller -F -i icon.png main.py 19 | ``` 20 | 21 | ### 运行要求 22 | 23 | - Python 3.6+ 24 | - 所需依赖包已在requirements.txt中列出 25 | 26 | ## 使用说明 27 | 28 | 1. 修改`config.toml`配置文件,根据需要调整各项参数 29 | 2. 运行程序: 30 | - 直接运行Python脚本:`python main.py` 31 | - 或者运行打包后的可执行文件 32 | 33 | ## 配置文件说明 34 | 35 | `config.toml`配置文件详细说明: 36 | 37 | ```toml 38 | # 脚本配置 39 | [run_config] 40 | # 运行延迟间隔,值越小脚本处理速度越快,不建议小于1,默认4 41 | run_delay = 4 42 | # 设置最近输出几天之内要查看的视频数据(按该视频创建的时间),默认2,设置成0则不会处理 43 | create_video_report_days = 2 44 | # 心跳检查间隔(秒),默认60秒检查一次会话状态 45 | heartbeat_interval = 60 46 | # 是否开启日志详细模式(0:关闭, 1:开启),默认0 47 | verbose_logging = 0 48 | 49 | # 视频可见性管理配置 50 | [auto_video_visible] 51 | # 是否开启自动修改视频可见功能(0:关闭, 1:开启),默认1 52 | visible_target = 1 53 | # 设置最近几天之内要处理的视频(按该视频创建的时间),默认2,设置成0则不会处理 54 | auto_video_visible_days = 2 55 | # 设置当浏览量大于多少要处理的视频(和days配置配合使用),默认5500 56 | max_video_count = 5500 57 | # 设置当以上两个配置都触发后的视频是否公开或隐藏(1:所有人可见,3:仅自己可见),默认值3 58 | video_visible_type = 3 59 | # 任务执行间隔(秒),默认300秒执行一次 60 | task_interval = 300 61 | 62 | # 评论自动回复配置 63 | [auto_send_comment] 64 | # 是否开启评论自动回复(0:关闭, 1:开启),默认1 65 | comment_target = 1 66 | # 回复自己的评论(0:关闭, 1:开启),默认0 67 | self_comment_target = 0 68 | # 设置最近几天之内要处理的评论(按该评论创建的时间),默认2,设置成0则不会处理 69 | auto_send_comment_days = 2 70 | # 自动回复评论内容 71 | auto_send_comment_text = "感谢您的评论" 72 | # 任务执行间隔(秒),默认120秒执行一次 73 | task_interval = 120 74 | # 多条随机回复文本,用英文分号;分隔,如果不设置则使用auto_send_comment_text 75 | random_replies = "感谢您的评论;谢谢支持;已收到您的评论,感谢反馈" 76 | 77 | # 私信自动回复配置 78 | [auto_send_private_msg] 79 | # 是否开启消息发送(0:关闭, 1:开启),默认1 80 | private_msg_target = 1 81 | # 是否开启图片发送(0:关闭, 1:开启),默认1 82 | private_img_target = 1 83 | # 设置最近几天之内要处理的私信(按该私信创建的时间),默认1,设置成0则不会处理 84 | auto_send_msg_days = 1 85 | # 自动回复私信文字内容 86 | auto_send_private_msg = "你好,感谢私信" 87 | # 自动回复私信图片文件路径 88 | auto_send_img_path = "./icon.png" 89 | # 任务执行间隔(秒),默认60秒执行一次 90 | task_interval = 60 91 | # 多条随机回复文本,用英文分号;分隔,如果不设置则使用auto_send_private_msg 92 | random_replies = "您好,感谢私信;已收到您的消息,稍后回复;谢谢您的关注" 93 | 94 | # 数据导出配置 95 | [data_export] 96 | # 是否开启数据导出(0:关闭, 1:开启),默认1 97 | export_target = 1 98 | # 数据导出间隔(秒),默认3600秒(1小时)执行一次 99 | export_interval = 3600 100 | # 数据导出路径,默认./视频数据 101 | export_path = "./视频数据" 102 | # 是否导出CSV格式(0:关闭, 1:开启),默认1 103 | export_csv = 1 104 | ``` 105 | 106 | ## 新功能说明 107 | 108 | ### v1.1.0更新内容 109 | 110 | 1. **随机回复功能**:评论和私信支持设置多条回复内容,随机选择一条回复 111 | 2. **数据导出功能**:定期自动导出视频数据为CSV格式,方便后续分析 112 | 3. **任务间隔设置**:每种任务可单独设置执行间隔,避免频繁操作 113 | 4. **优化错误处理**:更完善的错误处理和日志记录 114 | 5. **优雅退出机制**:捕获退出信号,确保程序能够安全退出并保存数据 115 | 116 | ## 常见问题 117 | 118 | 1. **登录失败**: 119 | - 检查网络连接 120 | - 清空caches目录后重新登录 121 | 122 | 2. **API调用失败**: 123 | - 检查日志文件了解详细错误 124 | - 可能是接口变更,请等待更新 125 | 126 | 3. **频繁操作导致账号风险**: 127 | - 适当调大各任务的执行间隔 128 | 129 | ## 注意事项 130 | 131 | - 本工具仅用于辅助运营,请勿用于违规内容 132 | - 避免过于频繁的操作,以免触发微信风控 133 | - 定期备份导出的数据 134 | -------------------------------------------------------------------------------- /wx_video_sdk/utils.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import hashlib 3 | import logging 4 | import os 5 | import time 6 | from typing import Any, Dict 7 | from sys import argv 8 | from qrcode.main import QRCode 9 | from datetime import datetime, timedelta 10 | 11 | from wx_video_sdk.api_feilds import WxVApiFields 12 | 13 | 14 | # 生成二维码 15 | def create_qc_code( 16 | url: str, save_img: bool = False, save_img_filename: str = "qrcode.png" 17 | ): 18 | qr = QRCode(box_size=10, border=2) 19 | 20 | # 添加链接 21 | qr.add_data(url) 22 | 23 | if save_img: 24 | # 生成二维码,默认是常规白底黑色填充的 25 | img = qr.make_image(fill_color="black", back_color="white") 26 | # 保存二维码为png格式 27 | img.save(save_img_filename) 28 | 29 | # 显示二维码至终端 30 | qr.print_ascii() 31 | 32 | 33 | def is_within_days(days: int, new_timestamp: float, old_timestamp: float): 34 | """ 35 | new_timestamp = 1714722954 # 新时间戳 36 | old_timestamp = 1714636554 # 旧时间戳 37 | 38 | if is_within_days(2, new_timestamp, old_timestamp): 39 | print("旧时间戳处于新时间戳的前两天之内") 40 | else: 41 | print("旧时间戳不在新时间戳的前两天之内") 42 | """ 43 | # 将时间戳转换为 datetime 对象 44 | new_date = datetime.fromtimestamp(new_timestamp) 45 | old_date = datetime.fromtimestamp(old_timestamp) 46 | 47 | # 计算两个日期之间的差值 48 | delta = new_date - old_date 49 | 50 | # 判断差值是否在两天之内 51 | return delta <= timedelta(days=days) 52 | 53 | 54 | def get_sha256_hash_of_file(file_path): 55 | sha256_hash = hashlib.sha256() 56 | with open(file_path, "rb") as f: 57 | # 读取文件直到结束 58 | for byte_block in iter(lambda: f.read(4096), b""): 59 | sha256_hash.update(byte_block) 60 | return sha256_hash.hexdigest() 61 | 62 | 63 | def parse_timestamp(timestamp, custom_strfmt="%Y-%m-%d %H:%M:%S"): 64 | # 将时间戳转换为datetime对象 65 | dt = datetime.fromtimestamp(timestamp) 66 | 67 | # 格式化日期和时间 68 | formatted = dt.strftime(custom_strfmt) 69 | 70 | return formatted 71 | 72 | 73 | def mkdir_if_not_exist(path: str) -> None: 74 | if not os.path.exists(path): 75 | os.makedirs(path) 76 | 77 | 78 | def is_dev(): 79 | parser = argparse.ArgumentParser(description="Chembl Command Line Tool") 80 | parser.add_argument( 81 | "-d", 82 | action="store_true", 83 | default=False, 84 | help="是否开启开发模式", 85 | ) 86 | parsed_args = parser.parse_args(argv[1:]) 87 | return parsed_args.d 88 | 89 | 90 | def setLoggingDefaultConfig() -> None: 91 | Log_level = 15 92 | logging.addLevelName(15, "WX_VIDEIO_SDK_DEBUG") 93 | 94 | console_handler = logging.StreamHandler() 95 | save_dir = "./logs" 96 | mkdir_if_not_exist(save_dir) 97 | 98 | file_handler = logging.FileHandler( 99 | f"./{save_dir}/wx_video_sdk-{parse_timestamp(float(time.time()),'%Y-%m-%d-%H-%M-%S')}.log", 100 | encoding="utf-8", 101 | ) 102 | 103 | if is_dev(): 104 | console_handler.setLevel(Log_level) 105 | else: 106 | console_handler.setLevel(logging.INFO) 107 | file_handler.setLevel(Log_level) 108 | 109 | console_format = logging.Formatter( 110 | "[%(asctime)s] %(funcName)s - %(levelname)s | %(message)s" 111 | ) 112 | file_format = logging.Formatter( 113 | "[%(asctime)s] %(funcName)s - %(levelname)s | %(message)s" 114 | ) 115 | 116 | console_handler.setFormatter(console_format) 117 | file_handler.setFormatter(file_format) 118 | 119 | logging.basicConfig(level=Log_level, handlers=[console_handler, file_handler]) 120 | 121 | 122 | def create_video_report(video: Any, video_day: int): 123 | video_title = video["desc"]["description"] 124 | video_like_count = video["likeCount"] 125 | video_favorite_count = video["favCount"] 126 | video_comment_count = video["commentCount"] 127 | video_read_count = video["readCount"] 128 | video_forward_count = video["forwardCount"] 129 | video_create_date = parse_timestamp(video["createTime"]) 130 | current_date = parse_timestamp(float(time.time())) 131 | 132 | if is_within_days( 133 | days=video_day, 134 | new_timestamp=float(time.time()), 135 | old_timestamp=video["createTime"], 136 | ): 137 | 138 | file_path = f"./视频数据" 139 | mkdir_if_not_exist(file_path) 140 | 141 | with open( 142 | f"{file_path}/[{video_title}]-[{parse_timestamp(video['createTime'],'%Y_%m_%d_%H_%M_%S')}].txt", 143 | "w", 144 | encoding="utf-8", 145 | ) as w: 146 | w.write(f"数据更新于: {current_date}\n\n") 147 | w.write(f"视频标题: {video_title}\n") 148 | w.write(f"视频创建时间: {video_create_date}\n") 149 | w.write(f"浏览数: {video_read_count}\n") 150 | w.write(f"点赞数: {video_like_count}\n") 151 | w.write(f"推荐数: {video_favorite_count}\n") 152 | w.write(f"转发数: {video_forward_count}\n") 153 | w.write(f"评论数: {video_comment_count}\n") 154 | 155 | 156 | def create_msg_tip(url: str, data: Dict) -> str: 157 | if url == WxVApiFields.PrivateMsg.send_private_msg: 158 | if data["msgPack"]["msgType"] == 3: 159 | return "/private-msg/send-private-img" 160 | return "/private-msg/send-private-msg" 161 | return url 162 | 163 | 164 | def install_ssl_cert(): 165 | """ 166 | 尝试安装SSL证书,解决证书验证失败问题 167 | """ 168 | import os 169 | import ssl 170 | import certifi 171 | 172 | try: 173 | # 尝试设置SSL证书路径 174 | os.environ['SSL_CERT_FILE'] = certifi.where() 175 | os.environ['REQUESTS_CA_BUNDLE'] = certifi.where() 176 | 177 | # 更新SSL上下文 178 | ssl._create_default_https_context = ssl._create_unverified_context 179 | 180 | logging.info("已配置SSL证书环境") 181 | return True 182 | except Exception as e: 183 | logging.error(f"配置SSL证书环境失败: {str(e)}") 184 | return False 185 | 186 | 187 | if __name__ == "__main__": 188 | # 旧时间戳 189 | old_timestamp = 1714636554 190 | # 新时间戳 191 | new_timestamp = 1714722954 192 | assert is_within_days( 193 | 2, new_timestamp, old_timestamp 194 | ), "旧时间戳应处于新时间戳的前两天之内,可是不符合设定" 195 | 196 | # 旧时间戳 197 | old_timestamp = 1614636554 198 | # 新时间戳 199 | new_timestamp = 1714722954 200 | assert not is_within_days( 201 | 2, new_timestamp, old_timestamp 202 | ), "旧时间戳应不处于新时间戳的前两天之内,可是不符合设定" 203 | -------------------------------------------------------------------------------- /wx_video_sdk/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import logging 4 | from Crypto.Random import get_random_bytes 5 | from requests.sessions import RequestsCookieJar 6 | import os 7 | import time 8 | from typing import Any, Dict, List 9 | import uuid 10 | import requests 11 | from wx_video_sdk.cache import CacheHandler 12 | from wx_video_sdk.utils import create_msg_tip, create_qc_code, get_sha256_hash_of_file 13 | from wx_video_sdk.api_feilds import WxVApiFields 14 | 15 | CACHE_COOKIE_FIELD = "CACHE_COOKIES" 16 | CACHE_AUTH_FIELD = "CACHE_AUTH" 17 | 18 | 19 | class WXVideoSDK: 20 | uin = "0000000000" 21 | nick_name = "" 22 | token = "" 23 | cookie = None 24 | login_cookie = {} 25 | finder_username = "" 26 | private_already_sender = set() 27 | comment_already_sender = set() 28 | 29 | def __init__(self, cache_file_name: str) -> None: 30 | self.cache_name = cache_file_name.split("s/")[1].split(".")[0] 31 | self.is_use_cache_login = False 32 | 33 | if self.cache_name == "None" or self.cache_name == "扫码登录新账号": 34 | if self.cache_name == "None": 35 | logging.info("没有找到已经添加的账号缓存,请扫描登录") 36 | 37 | self.login() 38 | return 39 | 40 | self.is_use_cache_login = True 41 | self.cache_handler = CacheHandler(cache_file_name) 42 | self.cache_login() 43 | 44 | def request( 45 | self, 46 | url, 47 | ext_params={}, 48 | ext_data={}, 49 | ext_headers={}, 50 | use_params=False, 51 | use_json_headers=False, 52 | ): 53 | # 为了可读性,在同url但是不同作用的情况下用来输出日志做区分 54 | msg_tip = create_msg_tip(url, ext_data) 55 | 56 | prefix_url = ( 57 | "https://channels.weixin.qq.com/cgi-bin/mmfinderassistant-bin" + url 58 | ) 59 | 60 | logging.log(15, "request url [%s]", msg_tip) 61 | # 获取当前时间戳 62 | timestamp = str(int(time.time() * 1000)) 63 | headers = { 64 | "X-Wechat-Uin": self.uin, 65 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", 66 | } 67 | if use_json_headers: 68 | headers["Content-Type"] = "application/json" 69 | 70 | data = { 71 | "timestamp": timestamp, 72 | "_log_finder_uin": "", 73 | "_log_finder_id": self.finder_username, 74 | "rawKeyBuff": None, 75 | "pluginSessionId": None, 76 | "scene": 7, 77 | "reqScene": 7, 78 | } 79 | 80 | params = { 81 | "token": self.token, 82 | "timestamp": timestamp, 83 | "_log_finder_uin": "", 84 | "_log_finder_id": "", 85 | "scene": 7, 86 | "reqScene": 7, 87 | } 88 | 89 | for key, value in ext_params.items(): 90 | params[key] = value 91 | 92 | for key, value in ext_data.items(): 93 | data[key] = value 94 | 95 | for key, value in ext_headers.items(): 96 | headers[key] = value 97 | 98 | response: requests.Response = requests.post( 99 | prefix_url, 100 | headers=headers, 101 | data=json.dumps(data) if use_json_headers else data, 102 | params=params if use_params else None, 103 | cookies=self.cookie, 104 | verify=False, 105 | ) 106 | 107 | if response.status_code >= 400: 108 | msg = f"请求 [{msg_tip}] 失败!, response.status_code = [{response.status_code}], response.reason = {response.reason}" 109 | logging.error(msg) 110 | raise ValueError(msg) 111 | 112 | res = response.json() 113 | 114 | if res["errCode"] != 0: 115 | if url == WxVApiFields.Helper.hepler_merlin_mmdata: 116 | self.cache_handler.removeCache("self") 117 | self.cache_handler.removeCache("auth_data") 118 | msg = "你的身份验证失败,请关闭程序重新扫描登录" 119 | logging.error(msg) 120 | raise ValueError(msg) 121 | 122 | msg = f"调用 [{msg_tip}] 发生网络问题!,errCode = [{res['errCode']}], errMsg = {res['errMsg']}" 123 | logging.error(msg) 124 | raise ValueError(msg) 125 | 126 | return res["data"], response 127 | 128 | def cache_login(self): 129 | self.cookie, is_can_login = self._get_cookie("self") 130 | if is_can_login: 131 | self.get_auth_data() 132 | return 133 | 134 | logging.error("不可使用缓存登录,请重新扫描登录") 135 | self.login() 136 | 137 | def login(self): 138 | is_can_login = False 139 | self.get_qrcode() 140 | while not is_can_login: 141 | is_can_login = self.create_session() 142 | time.sleep(2) 143 | 144 | def get_qrcode(self): 145 | data, _ = self.request(WxVApiFields.Auth.auth_login_code) 146 | self.token = data["token"] 147 | 148 | if self.token: 149 | create_qc_code( 150 | f"https://channels.weixin.qq.com/mobile/confirm_login.html?token={self.token}" 151 | ) 152 | else: 153 | logging.error("二维码或token获取失败") 154 | 155 | def _set_cookie(self, name, cookie: RequestsCookieJar) -> None: 156 | cookies_text = "; ".join([f"{name}={value}" for name, value in cookie.items()]) 157 | 158 | if self.cache_handler.isExists(name): 159 | self.cache_handler.updateCache(name, CACHE_COOKIE_FIELD, cookies_text) 160 | else: 161 | self.cache_handler.saveCache(name, CACHE_COOKIE_FIELD, cookies_text) 162 | 163 | self.cookies = cookie 164 | 165 | def _get_cookie(self, name) -> tuple[Dict | None, bool]: 166 | if not self.cache_handler.isExists(name): 167 | return (None, False) 168 | 169 | cookies_text = self.cache_handler.getCache(name)[CACHE_COOKIE_FIELD] 170 | cookies = dict(cookie.split("=") for cookie in cookies_text.split("; ")) 171 | self.cookies = cookies 172 | return (cookies, True) 173 | 174 | def create_session(self) -> bool: 175 | """创建会话 176 | return true if the session is created successfully 177 | """ 178 | data, res = self.request( 179 | WxVApiFields.Auth.auth_login_status, 180 | ext_data={ 181 | "token": self.token, 182 | }, 183 | ext_params={ 184 | "token": self.token, 185 | }, 186 | use_params=True, 187 | ) 188 | status = data["status"] 189 | acct_status = data["acctStatus"] 190 | 191 | msg_dict = { 192 | (0, 0): "未登录", 193 | (5, 1): "已扫码, 等待确认", 194 | (1, 1): "登录成功", 195 | (5, 2): "没有可登录的视频号", 196 | (4, 0): "二维码已经过期", 197 | (3, 0): "已取消登录.", 198 | } 199 | 200 | if status == 1 and acct_status == 1: 201 | logging.info(msg_dict[(status, acct_status)]) 202 | self.cookie = res.cookies.get_dict() 203 | self.res_cookies = res.cookies 204 | 205 | if not self.cookie: 206 | logging.error("Cookie获取失败") 207 | raise ValueError("Cookie获取失败") 208 | 209 | # 获取用户信息 210 | self.get_auth_data() 211 | return True 212 | 213 | logging.info(msg_dict[(status, acct_status)]) 214 | return False 215 | 216 | def hepler_merlin_mmdata(self): 217 | time10 = time.time() 218 | time13 = int(time.time() * 1000) 219 | data = { 220 | "id": 23865, 221 | "data": { 222 | "12": "", 223 | "13": "", 224 | "14": "", 225 | "15": "", 226 | "16": "", 227 | "17": time10, 228 | "18": time10, 229 | "19": 1, 230 | "20": "", 231 | "21": 2, 232 | "22": uuid.uuid4(), 233 | "23": "", 234 | "24": time13, 235 | "25": "", 236 | "26": 0, 237 | "27": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", 238 | "28": "", 239 | "29": "", 240 | "30": "", 241 | "31": "LoginForIframe", 242 | "32": "", 243 | "33": uuid.uuid4(), 244 | "34": "", 245 | "35": "", 246 | "36": 1, 247 | "37": "{}", 248 | "38": "", 249 | "39": "{}", 250 | "40": "pageEnter", 251 | "41": "{}", 252 | "42": '{"screenHeight":1032;"screenWidth":1920;"clientHeight":0;"clientWidth":0}', 253 | "43": "", 254 | }, 255 | "_log_finder_id": "", 256 | } 257 | self.request(WxVApiFields.Helper.hepler_merlin_mmdata, ext_data=data) 258 | 259 | def get_auth_data(self): 260 | 261 | # 保存获取的用户标识 262 | if not self.is_use_cache_login: 263 | data, _ = self.request(WxVApiFields.Auth.auth_data) 264 | self.finder_username = data["finderUser"]["finderUsername"] 265 | self.nick_name = data["finderUser"]["nickname"] 266 | self.uin = self.get_x_wechat_uin() 267 | self.login_cookie = self.get_login_cookie() 268 | auth_data_dict = { 269 | "finder_username": self.finder_username, 270 | "nick_name": self.nick_name, 271 | "uin": self.uin, 272 | "login_cookie": self.login_cookie, 273 | } 274 | self.cache_handler: CacheHandler = CacheHandler( 275 | f"./caches/{self.nick_name}.json" 276 | ) 277 | self._set_cookie("self", self.res_cookies) 278 | self.cache_handler.saveCache("auth_data", CACHE_AUTH_FIELD, auth_data_dict) 279 | return 280 | 281 | auth_data_dict = self.cache_handler.getCache("auth_data")[CACHE_AUTH_FIELD] 282 | self.finder_username = auth_data_dict["finder_username"] 283 | self.nick_name = auth_data_dict["nick_name"] 284 | self.uin = auth_data_dict["uin"] 285 | self.login_cookie = auth_data_dict["login_cookie"] 286 | 287 | def get_x_wechat_uin(self) -> str: 288 | data, _ = self.request(WxVApiFields.Helper.helper_upload_params) 289 | if not data: 290 | raise Exception("获取wechat_uin失败") 291 | return str(data["uin"]) 292 | 293 | def get_login_cookie(self) -> str: 294 | data, _ = self.request(WxVApiFields.PrivateMsg.get_login_cookie) 295 | cookie = data["cookie"] 296 | if not cookie: 297 | logging.error("登录cookie获取失败") 298 | 299 | return cookie 300 | 301 | def get_video_list( 302 | self, unread: bool = False, need_comment_count: bool = True 303 | ) -> List[Any]: 304 | data = { 305 | "pageSize": 10, 306 | "currentPage": 1, 307 | "onlyUnread": unread, 308 | "userpageType": 3, 309 | "needAllCommentCount": need_comment_count, 310 | "forMcn": False, 311 | } 312 | data, _ = self.request(WxVApiFields.Post.post_list, ext_data=data) 313 | 314 | if not data["list"]: 315 | logging.error("视频列表获取失败, 列表可能为空或者数据问题") 316 | return [] 317 | 318 | video_list = data["list"] 319 | 320 | return video_list 321 | 322 | def get_comment_list( 323 | self, export_id, video, cb: Any = lambda comment: None 324 | ) -> List[Any]: 325 | 326 | data = { 327 | "lastBuff": "", 328 | "exportId": export_id, 329 | "commentSelection": False, 330 | "forMcn": False, 331 | } 332 | data, _ = self.request(WxVApiFields.Comment.comment_list, ext_data=data) 333 | 334 | if not data["comment"]: 335 | logging.log(15, "评论列表可能为空或者数据(如果觉得不重要即可忽略)") 336 | return [] 337 | 338 | return data["comment"] 339 | 340 | def change_video_visible(self, object_id: str, visible_type: int) -> bool: 341 | data = { 342 | "objectId": object_id, 343 | "visibleType": visible_type, 344 | } 345 | data, _ = self.request( 346 | WxVApiFields.Post.post_update_visible, 347 | use_json_headers=True, 348 | ext_data=data, 349 | ) 350 | if data["errorCode"] != 0: 351 | return False 352 | 353 | return True 354 | 355 | # 回复私信消息 356 | def send_private_msg( 357 | self, session_id, from_username: str, to_username: str, msg_content: str 358 | ): 359 | uid = str(uuid.uuid4()) 360 | 361 | data = { 362 | "msgPack": { 363 | "sessionId": session_id, 364 | "fromUsername": from_username, 365 | "toUsername": to_username, 366 | "msgType": 1, 367 | "textMsg": {"content": msg_content}, 368 | "cliMsgId": uid, 369 | }, 370 | } 371 | 372 | data, _ = self.request( 373 | WxVApiFields.PrivateMsg.send_private_msg, 374 | use_json_headers=True, 375 | ext_data=data, 376 | ) 377 | logging.log(15, data) 378 | 379 | def upload_media_info(self, from_username, to_username, file_path) -> Any: 380 | 381 | # 生成AES密钥并且转换为 base64 格式以便于存储和传输 382 | aes_key = base64.b64encode(get_random_bytes(32)).decode() 383 | with open(file_path, "rb") as file: 384 | file_size = os.path.getsize(file_path) 385 | file_md5 = get_sha256_hash_of_file(file_path) 386 | chunk_size = 512 * 1024 387 | chunks = -(-file_size // chunk_size) 388 | img_msg = {} 389 | 390 | for chunk in range(chunks): 391 | file.seek(chunk * chunk_size) 392 | data = file.read(chunk_size) 393 | 394 | # 将数据编码为 base64 395 | base64_data = base64.b64encode(data).decode() 396 | 397 | data = { 398 | "aesKey": aes_key, 399 | "chunk": chunk, 400 | "chunks": chunks, 401 | "content": f"data:application/octet-stream;base64,{base64_data}", 402 | "fromUsername": from_username, 403 | "toUsername": to_username, 404 | "md5": file_md5, 405 | "mediaSize": file_size, 406 | "mediaType": 3, 407 | } 408 | 409 | data, _ = self.request( 410 | WxVApiFields.PrivateMsg.upload_media_info, 411 | use_json_headers=True, 412 | ext_data=data, 413 | ) 414 | img_msg = data["imgMsg"] 415 | 416 | return img_msg 417 | 418 | def send_private_img( 419 | self, session_id, from_username: str, to_username: str, img_path: str 420 | ): 421 | # 切片上传图片 422 | img_msg = self.upload_media_info( 423 | from_username=from_username, to_username=to_username, file_path=img_path 424 | ) 425 | uid = str(uuid.uuid4()) 426 | 427 | data = { 428 | "msgPack": { 429 | "sessionId": session_id, 430 | "fromUsername": from_username, 431 | "toUsername": to_username, 432 | "msgType": 3, 433 | "imgMsg": img_msg, 434 | "cliMsgId": uid, 435 | } 436 | } 437 | data, _ = self.request( 438 | WxVApiFields.PrivateMsg.send_private_msg, 439 | use_json_headers=True, 440 | ext_data=data, 441 | ) 442 | logging.log(15, data) 443 | 444 | # 回复视频评论 445 | def send_comment(self, export_id, comment, comment_content: str): 446 | uid = str(uuid.uuid4()) 447 | data = { 448 | "replyCommentId": comment["commentId"], 449 | "content": comment_content, 450 | "clientId": uid, 451 | "rootCommentId": comment["commentId"], 452 | "comment": comment, 453 | "exportId": export_id, 454 | } 455 | self.request( 456 | WxVApiFields.Comment.create_comment, 457 | use_json_headers=True, 458 | ext_data=data, 459 | ) 460 | 461 | # 接收未读的私信消息 462 | def get_new_msgs(self) -> List[Any]: 463 | data = { 464 | "cookie": self.login_cookie, 465 | } 466 | data, _ = self.request(WxVApiFields.PrivateMsg.get_new_msg, ext_data=data) 467 | logging.log(15, data) 468 | msgs = data["msg"] 469 | return msgs 470 | 471 | # 接收历史私信消息 472 | def get_history_msgs(self) -> List[Any]: 473 | data, _ = self.request( 474 | WxVApiFields.PrivateMsg.get_history_msg, 475 | ext_data={ 476 | "cookie": self.login_cookie, 477 | }, 478 | ) 479 | msgs = data["msg"] 480 | return msgs 481 | 482 | def on_video_readcount_upper_do( 483 | self, 484 | read_count: int, 485 | cb: Any, 486 | is_all_video: bool = True, 487 | object_id: str | None = None, 488 | ) -> None: 489 | """_summary_ 490 | 491 | Args: 492 | readcount (int): _description_ 493 | cb (function(sdk: WXVideoSDK,object_id:str,read_count:int,create_time:float)): _description_ 494 | is_all_video (bool, optional): _description_. Defaults to True. 495 | """ 496 | video_list = self.get_video_list() 497 | if is_all_video: 498 | for video in video_list: 499 | if video["readCount"] > read_count: 500 | cb(self, video["objectId"], video["readCount"], video["createTime"]) 501 | elif object_id is not None: 502 | exist = False 503 | video_readcount: int = 0 504 | video_create_time: float = 0 505 | for video in video_list: 506 | if object_id == video["objectId"]: 507 | exist = True 508 | video_readcount = video["readCount"] 509 | video_create_time = video["createTime"] 510 | break 511 | if exist: 512 | if video_readcount > read_count: 513 | cb(self, object_id, video_readcount, video_create_time) 514 | 515 | def on_video_comment_do(self, cb: Any): 516 | video_list = self.get_video_list() 517 | for video in video_list: 518 | export_id = video["exportId"] 519 | # 获取评论列表 520 | comment_list = self.get_comment_list(export_id, video) 521 | for comment in comment_list: 522 | cb(self, export_id, comment) 523 | 524 | def on_get_new_msg_do(self, cb: Any) -> None: 525 | msgs = self.get_new_msgs() 526 | 527 | if msgs: 528 | for msg in msgs: 529 | is_sended = cb( 530 | self, 531 | msg["sessionId"], 532 | msg["toUsername"], 533 | msg["fromUsername"], 534 | msg["ts"], 535 | ) 536 | if is_sended: 537 | self.private_already_sender.add(msg["sessionId"]) 538 | 539 | def load_private_history_already_senders(self, send_text: str): 540 | # 确保消息已经发送过了 541 | history_msgs = self.get_history_msgs() 542 | for msg in history_msgs: 543 | if msg["rawContent"] == send_text: 544 | self.private_already_sender.add(msg["sessionId"]) 545 | 546 | def load_comment_already_senders(self, send_comment: str): 547 | # 确保消息已经发送过了 548 | video_list = self.get_video_list() 549 | for video in video_list: 550 | exportId = video["exportId"] 551 | # 获取评论列表 552 | comment_list = self.get_comment_list(exportId, video) 553 | for comment in comment_list: 554 | level_two_comments = comment["levelTwoComment"] 555 | for level_comment in level_two_comments: 556 | if level_comment["commentContent"] == send_comment: 557 | self.comment_already_sender.add(comment["commentId"]) 558 | break 559 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import time 5 | import traceback 6 | import toml 7 | import questionary 8 | import signal 9 | import datetime 10 | import random 11 | import csv 12 | import urllib3 13 | from wx_video_sdk import WXVideoSDK 14 | from wx_video_sdk.utils import ( 15 | create_video_report, 16 | is_dev, 17 | is_within_days, 18 | mkdir_if_not_exist, 19 | setLoggingDefaultConfig, 20 | parse_timestamp, 21 | install_ssl_cert, 22 | ) 23 | 24 | # 禁用SSL证书验证警告 25 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 26 | 27 | 28 | def read_config(file_path): 29 | try: 30 | config = toml.load(file_path) 31 | logging.info(f"配置文件 [ {file_path} ] 已载入.") 32 | 33 | # 获取run_config部分的值 34 | run_delay = config["run_config"]["run_delay"] 35 | create_video_report_days = config["run_config"]["create_video_report_days"] 36 | heartbeat_interval = config["run_config"].get("heartbeat_interval", 60) 37 | verbose_logging = config["run_config"].get("verbose_logging", 0) 38 | 39 | # 获取auto_video_visible部分的值 40 | visible_target = config["auto_video_visible"]["visible_target"] 41 | auto_video_visible_days = config["auto_video_visible"][ 42 | "auto_video_visible_days" 43 | ] 44 | max_video_count = config["auto_video_visible"]["max_video_count"] 45 | video_visible_type = config["auto_video_visible"]["video_visible_type"] 46 | video_task_interval = config["auto_video_visible"].get("task_interval", 300) 47 | 48 | # 获取auto_send_comment部分的值 49 | comment_target = config["auto_send_comment"]["comment_target"] 50 | self_comment_target = config["auto_send_comment"]["self_comment_target"] 51 | auto_send_comment_days = config["auto_send_comment"]["auto_send_comment_days"] 52 | auto_send_comment_text = config["auto_send_comment"]["auto_send_comment_text"] 53 | comment_task_interval = config["auto_send_comment"].get("task_interval", 120) 54 | comment_random_replies = config["auto_send_comment"].get("random_replies", "") 55 | 56 | # 获取auto_send_private_msg部分的值 57 | private_msg_target = config["auto_send_private_msg"]["private_msg_target"] 58 | private_img_target = config["auto_send_private_msg"]["private_img_target"] 59 | auto_send_msg_days = config["auto_send_private_msg"]["auto_send_msg_days"] 60 | auto_send_private_msg = config["auto_send_private_msg"]["auto_send_private_msg"] 61 | auto_send_img_path = config["auto_send_private_msg"]["auto_send_img_path"] 62 | private_msg_task_interval = config["auto_send_private_msg"].get( 63 | "task_interval", 60 64 | ) 65 | private_msg_random_replies = config["auto_send_private_msg"].get( 66 | "random_replies", "" 67 | ) 68 | 69 | # 获取data_export部分的值 70 | export_target = config.get("data_export", {}).get("export_target", 1) 71 | export_interval = config.get("data_export", {}).get("export_interval", 3600) 72 | export_path = config.get("data_export", {}).get("export_path", "./视频数据") 73 | export_csv = config.get("data_export", {}).get("export_csv", 1) 74 | 75 | return ( 76 | run_delay, 77 | create_video_report_days, 78 | heartbeat_interval, 79 | verbose_logging, 80 | visible_target, 81 | auto_video_visible_days, 82 | max_video_count, 83 | video_visible_type, 84 | video_task_interval, 85 | comment_target, 86 | self_comment_target, 87 | auto_send_comment_days, 88 | auto_send_comment_text, 89 | comment_task_interval, 90 | comment_random_replies, 91 | private_msg_target, 92 | private_img_target, 93 | auto_send_msg_days, 94 | auto_send_private_msg, 95 | private_msg_task_interval, 96 | private_msg_random_replies, 97 | auto_send_img_path, 98 | export_target, 99 | export_interval, 100 | export_path, 101 | export_csv, 102 | ) 103 | except Exception as e: 104 | logging.error(f"读取配置文件失败: {str(e)}") 105 | raise 106 | 107 | 108 | class VideoAssistant: 109 | def __init__(self, config_path): 110 | self.config_path = config_path 111 | self.sdk = None 112 | self.running = True 113 | self.last_task_time = {} # 记录各任务上次执行时间 114 | self.video_data_cache = {} # 缓存视频数据用于导出 115 | 116 | # 读取配置 117 | self.load_config() 118 | 119 | # 初始化日志 120 | self.setup_logging() 121 | 122 | # 设置信号处理 123 | signal.signal(signal.SIGINT, self.signal_handler) 124 | signal.signal(signal.SIGTERM, self.signal_handler) 125 | 126 | def signal_handler(self, sig, frame): 127 | logging.info("接收到终止信号,程序正在优雅退出...") 128 | # 导出最终数据 129 | if self.export_target == 1 and hasattr(self, "sdk") and self.sdk: 130 | self.export_video_data() 131 | self.running = False 132 | 133 | def setup_logging(self): 134 | setLoggingDefaultConfig() 135 | if self.verbose_logging == 1: 136 | logging.getLogger().setLevel(15) # 设置为详细日志级别 137 | else: 138 | logging.getLogger().setLevel(logging.INFO) 139 | 140 | def load_config(self): 141 | ( 142 | self.run_delay, 143 | self.create_video_report_days, 144 | self.heartbeat_interval, 145 | self.verbose_logging, 146 | self.visible_target, 147 | self.auto_video_visible_days, 148 | self.max_video_count, 149 | self.video_visible_type, 150 | self.video_task_interval, 151 | self.comment_target, 152 | self.self_comment_target, 153 | self.auto_send_comment_days, 154 | self.auto_send_comment_text, 155 | self.comment_task_interval, 156 | self.comment_random_replies, 157 | self.private_msg_target, 158 | self.private_img_target, 159 | self.auto_send_msg_days, 160 | self.auto_send_private_msg, 161 | self.private_msg_task_interval, 162 | self.private_msg_random_replies, 163 | self.auto_send_img_path, 164 | self.export_target, 165 | self.export_interval, 166 | self.export_path, 167 | self.export_csv, 168 | ) = read_config(self.config_path) 169 | 170 | # 处理随机回复列表 171 | self.comment_random_replies_list = ( 172 | self.comment_random_replies.split(";") 173 | if self.comment_random_replies 174 | else [] 175 | ) 176 | self.private_msg_random_replies_list = ( 177 | self.private_msg_random_replies.split(";") 178 | if self.private_msg_random_replies 179 | else [] 180 | ) 181 | 182 | # 确保导出目录存在 183 | if self.export_target == 1: 184 | mkdir_if_not_exist(self.export_path) 185 | 186 | def login(self): 187 | caches_dir = "./caches/" 188 | mkdir_if_not_exist(caches_dir) 189 | options = os.listdir(caches_dir) 190 | selected = "None" 191 | 192 | if len(options) > 0: 193 | options.append("扫码登录新账号") 194 | selected = questionary.select( 195 | "检测到存在账号缓存,请使用上下方向键选择你要登录的账号:", options 196 | ).ask() 197 | 198 | if selected is None: 199 | logging.info("用户取消登录") 200 | return False 201 | 202 | if not selected.endswith(".json"): 203 | selected = f"{selected}.json" 204 | 205 | try: 206 | self.sdk = WXVideoSDK(os.path.join(caches_dir, selected)) 207 | 208 | # 载入历史聊天中已经发送过的用户 209 | self.sdk.load_private_history_already_senders(self.auto_send_private_msg) 210 | self.sdk.load_comment_already_senders(self.auto_send_comment_text) 211 | 212 | return True 213 | except Exception as e: 214 | logging.error(f"登录失败: {str(e)}") 215 | return False 216 | 217 | def update_video_list_visible(self, object_id, read_count, create_time): 218 | current_timestamp = round(float(time.time())) 219 | video_create_timestamp = create_time 220 | 221 | if is_within_days( 222 | days=self.auto_video_visible_days, 223 | new_timestamp=current_timestamp, 224 | old_timestamp=video_create_timestamp, 225 | ): 226 | try: 227 | if self.sdk: 228 | self.sdk.change_video_visible(object_id, self.video_visible_type) 229 | logging.info( 230 | f"已将视频 {object_id} 的可见性设置为 {self.video_visible_type}" 231 | ) 232 | except Exception as e: 233 | logging.error(f"修改视频可见性失败: {str(e)}") 234 | 235 | def get_random_comment_reply(self): 236 | """获取随机评论回复内容""" 237 | if self.comment_random_replies_list: 238 | return random.choice(self.comment_random_replies_list) 239 | return self.auto_send_comment_text 240 | 241 | def get_random_private_msg(self): 242 | """获取随机私信回复内容""" 243 | if self.private_msg_random_replies_list: 244 | return random.choice(self.private_msg_random_replies_list) 245 | return self.auto_send_private_msg 246 | 247 | def send_ones_custom_video_comment(self, export_id, comment): 248 | if ( 249 | self.sdk 250 | and not comment["commentId"] in self.sdk.comment_already_sender 251 | and is_within_days( 252 | days=self.auto_send_comment_days, 253 | new_timestamp=round(float(time.time())), 254 | old_timestamp=float(comment["commentCreatetime"]), 255 | ) 256 | ): 257 | try: 258 | # 获取随机回复内容 259 | reply_content = self.get_random_comment_reply() 260 | 261 | if self.self_comment_target == 0: 262 | if comment["commentNickname"] != self.sdk.nick_name: 263 | self.sdk.send_comment( 264 | export_id=export_id, 265 | comment=comment, 266 | comment_content=reply_content, 267 | ) 268 | self.sdk.comment_already_sender.add(comment["commentId"]) 269 | logging.info( 270 | f"已回复评论: {comment['commentNickname']} -> {reply_content}" 271 | ) 272 | elif self.self_comment_target == 1: 273 | if comment["commentNickname"] == self.sdk.nick_name: 274 | self.sdk.send_comment( 275 | export_id=export_id, 276 | comment=comment, 277 | comment_content=reply_content, 278 | ) 279 | self.sdk.comment_already_sender.add(comment["commentId"]) 280 | logging.info(f"已回复自己的评论 -> {reply_content}") 281 | except Exception as e: 282 | logging.error(f"回复评论失败: {str(e)}") 283 | 284 | def send_ones_custom_private_msg( 285 | self, session_id, from_username, to_username, msg_ts 286 | ): 287 | if ( 288 | self.sdk 289 | and session_id not in self.sdk.private_already_sender 290 | and is_within_days( 291 | days=self.auto_send_msg_days, 292 | new_timestamp=round(float(time.time())), 293 | old_timestamp=float(msg_ts), 294 | ) 295 | ): 296 | try: 297 | sent = False 298 | if self.private_msg_target == 1: 299 | # 获取随机回复内容 300 | reply_content = self.get_random_private_msg() 301 | 302 | self.sdk.send_private_msg( 303 | session_id=session_id, 304 | from_username=from_username, 305 | to_username=to_username, 306 | msg_content=reply_content, 307 | ) 308 | sent = True 309 | logging.info( 310 | f"已发送私信文本给: {from_username} -> {reply_content}" 311 | ) 312 | 313 | if self.private_img_target == 1: 314 | if os.path.exists(self.auto_send_img_path): 315 | self.sdk.send_private_img( 316 | session_id=session_id, 317 | from_username=from_username, 318 | to_username=to_username, 319 | img_path=self.auto_send_img_path, 320 | ) 321 | sent = True 322 | logging.info(f"已发送私信图片给: {from_username}") 323 | else: 324 | logging.error(f"图片路径不存在: {self.auto_send_img_path}") 325 | 326 | if sent: 327 | self.sdk.private_already_sender.add(session_id) 328 | return sent 329 | except Exception as e: 330 | logging.error(f"发送私信失败: {str(e)}") 331 | 332 | return False 333 | 334 | def should_run_task(self, task_name, interval_seconds=None): 335 | """判断是否应该执行特定任务""" 336 | now = time.time() 337 | if interval_seconds is None: 338 | interval_seconds = max(1, self.run_delay) 339 | 340 | if ( 341 | task_name not in self.last_task_time 342 | or now - self.last_task_time[task_name] >= interval_seconds 343 | ): 344 | self.last_task_time[task_name] = now 345 | return True 346 | return False 347 | 348 | def update_video_data_cache(self, video_list): 349 | """更新视频数据缓存,用于后续的数据导出""" 350 | for video in video_list: 351 | video_id = video.get("objectId", "unknown") 352 | if video_id not in self.video_data_cache: 353 | self.video_data_cache[video_id] = [] 354 | 355 | # 添加带时间戳的数据点 356 | self.video_data_cache[video_id].append( 357 | { 358 | "timestamp": time.time(), 359 | "time": parse_timestamp(time.time()), 360 | "title": video.get("desc", {}).get("description", ""), 361 | "create_time": parse_timestamp(video.get("createTime", 0)), 362 | "read_count": video.get("readCount", 0), 363 | "like_count": video.get("likeCount", 0), 364 | "fav_count": video.get("favCount", 0), 365 | "forward_count": video.get("forwardCount", 0), 366 | "comment_count": video.get("commentCount", 0), 367 | } 368 | ) 369 | 370 | def export_video_data(self): 371 | """导出视频数据到CSV文件""" 372 | if not self.video_data_cache: 373 | logging.info("没有可导出的视频数据") 374 | return 375 | 376 | try: 377 | timestamp = parse_timestamp(time.time(), "%Y%m%d_%H%M%S") 378 | export_file = f"{self.export_path}/视频数据统计_{timestamp}.csv" 379 | 380 | with open(export_file, "w", newline="", encoding="utf-8-sig") as f: 381 | writer = csv.writer(f) 382 | # 写入表头 383 | writer.writerow( 384 | [ 385 | "视频ID", 386 | "视频标题", 387 | "创建时间", 388 | "数据时间", 389 | "播放量", 390 | "点赞数", 391 | "收藏数", 392 | "转发数", 393 | "评论数", 394 | ] 395 | ) 396 | 397 | # 写入每个视频的数据 398 | for video_id, data_points in self.video_data_cache.items(): 399 | # 取最新的一条数据 400 | if data_points: 401 | latest = data_points[-1] 402 | writer.writerow( 403 | [ 404 | video_id, 405 | latest["title"], 406 | latest["create_time"], 407 | latest["time"], 408 | latest["read_count"], 409 | latest["like_count"], 410 | latest["fav_count"], 411 | latest["forward_count"], 412 | latest["comment_count"], 413 | ] 414 | ) 415 | 416 | logging.info(f"视频数据已导出到: {export_file}") 417 | except Exception as e: 418 | logging.error(f"导出视频数据失败: {str(e)}") 419 | 420 | def run(self): 421 | if not self.login(): 422 | logging.error("登录失败,程序退出") 423 | return 424 | 425 | logging.info("视频号助手脚本运行中...(ctrl+c或关闭窗口结束脚本)") 426 | 427 | last_heartbeat_time = time.time() 428 | last_export_time = time.time() 429 | 430 | while self.running: 431 | try: 432 | # 定期检查会话状态 433 | now = time.time() 434 | if now - last_heartbeat_time >= self.heartbeat_interval: 435 | if self.sdk: 436 | self.sdk.hepler_merlin_mmdata() 437 | last_heartbeat_time = now 438 | logging.log(15, f"会话心跳检查 - {parse_timestamp(now)}") 439 | 440 | # 检查数据导出 441 | if ( 442 | self.export_target == 1 443 | and now - last_export_time >= self.export_interval 444 | ): 445 | self.export_video_data() 446 | last_export_time = now 447 | 448 | # 处理视频报告 449 | if self.should_run_task("video_report", 300): # 5分钟更新一次视频报告 450 | if self.sdk: 451 | video_list = self.sdk.get_video_list() 452 | # 更新数据缓存 453 | self.update_video_data_cache(video_list) 454 | for video in video_list: 455 | create_video_report( 456 | video, video_day=self.create_video_report_days 457 | ) 458 | logging.info(f"已更新视频报告 - {len(video_list)}个视频") 459 | 460 | # 处理视频可见性 461 | if self.visible_target == 1 and self.should_run_task( 462 | "video_visible", self.video_task_interval 463 | ): 464 | logging.log(15, "执行视频可见性任务") 465 | if self.sdk: 466 | self.sdk.on_video_readcount_upper_do( 467 | self.max_video_count, self.update_video_list_visible 468 | ) 469 | 470 | # 处理评论回复 471 | if self.comment_target == 1 and self.should_run_task( 472 | "comment", self.comment_task_interval 473 | ): 474 | logging.log(15, "执行评论回复任务") 475 | if self.sdk: 476 | self.sdk.on_video_comment_do( 477 | self.send_ones_custom_video_comment 478 | ) 479 | 480 | # 处理私信回复 481 | if self.should_run_task("private_msg", self.private_msg_task_interval): 482 | logging.log(15, "执行私信回复任务") 483 | if self.sdk: 484 | self.sdk.on_get_new_msg_do(self.send_ones_custom_private_msg) 485 | 486 | # 休眠一小段时间,避免CPU占用过高 487 | time.sleep(1) 488 | 489 | except Exception as e: 490 | logging.error(f"运行过程中发生错误: {str(e)}") 491 | logging.error(traceback.format_exc()) 492 | # 出错后稍微等待一下再继续 493 | time.sleep(5) 494 | 495 | 496 | def main(): 497 | config_path = "./config_test.toml" if is_dev() else "./config.toml" 498 | 499 | try: 500 | # 尝试安装SSL证书 501 | install_ssl_cert() 502 | 503 | assistant = VideoAssistant(config_path) 504 | assistant.run() 505 | except KeyboardInterrupt: 506 | logging.info("用户手动中断程序") 507 | except Exception as e: 508 | logging.error(f"程序崩溃: {str(e)}") 509 | logging.error(traceback.format_exc()) 510 | input("按任意键结束") 511 | sys.exit(1) 512 | 513 | 514 | if __name__ == "__main__": 515 | main() 516 | --------------------------------------------------------------------------------