weixin.py 108 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377
  1. # -*- coding: utf-8 -*-
  2. """
  3. 微信视频号发布器
  4. 参考: matrix/tencent_uploader/main.py
  5. """
  6. import asyncio
  7. import json
  8. import os
  9. from datetime import datetime
  10. from typing import List
  11. from .base import (
  12. BasePublisher, PublishParams, PublishResult,
  13. WorkItem, WorksResult, CommentItem, CommentsResult
  14. )
  15. import os
  16. import time
  17. # 允许通过环境变量手动指定“上传视频入口”的选择器,便于在页面结构频繁变更时快速调整
  18. WEIXIN_UPLOAD_SELECTOR = os.environ.get("WEIXIN_UPLOAD_SELECTOR", "").strip()
  19. def format_short_title(origin_title: str) -> str:
  20. """
  21. 格式化短标题
  22. - 移除特殊字符
  23. - 长度限制在 6-16 字符
  24. """
  25. allowed_special_chars = "《》"":+?%°"
  26. filtered_chars = [
  27. char if char.isalnum() or char in allowed_special_chars
  28. else ' ' if char == ',' else ''
  29. for char in origin_title
  30. ]
  31. formatted_string = ''.join(filtered_chars)
  32. if len(formatted_string) > 16:
  33. formatted_string = formatted_string[:16]
  34. elif len(formatted_string) < 6:
  35. formatted_string += ' ' * (6 - len(formatted_string))
  36. return formatted_string
  37. class WeixinPublisher(BasePublisher):
  38. """
  39. 微信视频号发布器
  40. 使用 Playwright 自动化操作视频号创作者中心
  41. 注意: 需要使用 Chrome 浏览器,否则可能出现 H264 编码错误
  42. """
  43. platform_name = "weixin"
  44. login_url = "https://channels.weixin.qq.com/platform"
  45. publish_url = "https://channels.weixin.qq.com/platform/post/create"
  46. cookie_domain = ".weixin.qq.com"
  47. def _parse_count(self, count_str: str) -> int:
  48. """解析数字(支持带'万'的格式)"""
  49. try:
  50. count_str = count_str.strip()
  51. if '万' in count_str:
  52. return int(float(count_str.replace('万', '')) * 10000)
  53. return int(count_str)
  54. except:
  55. return 0
  56. async def ai_find_upload_selector(self, frame_html: str, frame_name: str = "main") -> str:
  57. """
  58. 使用 AI 从 HTML 中识别“上传视频/选择文件”相关元素的 CSS 选择器。
  59. 设计思路:
  60. - 仅在常规 DOM 选择器都失败时调用,避免频繁占用 AI 配额;
  61. - 通过 DashScope 文本模型(与验证码识别同一套配置)分析 HTML;
  62. - 返回一个适合用于 frame.locator(selector) 的 CSS 选择器。
  63. """
  64. import json
  65. import re
  66. import requests
  67. import os
  68. # 避免 HTML 过长导致 token 超限,只截取前 N 字符
  69. if not frame_html:
  70. return ""
  71. max_len = 20000
  72. if len(frame_html) > max_len:
  73. frame_html = frame_html[:max_len]
  74. ai_api_key = os.environ.get("DASHSCOPE_API_KEY", "")
  75. ai_base_url = os.environ.get("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
  76. ai_text_model = os.environ.get("AI_TEXT_MODEL", "qwen-plus")
  77. if not ai_api_key:
  78. print(f"[{self.platform_name}] AI上传入口识别: 未配置 AI API Key,跳过")
  79. return ""
  80. prompt = f"""
  81. 你是熟悉微信视频号后台的前端工程师,现在需要在一段 HTML 中找到“上传视频文件”的入口。
  82. 页面说明:
  83. - 平台:微信视频号(channels.weixin.qq.com)
  84. - 目标:用于上传视频文件的按钮或 input(一般会触发文件选择框)
  85. - 你会收到某个 frame 的完整 HTML 片段(不包含截图)。
  86. 请你根据下面的 HTML,推断最适合用于上传视频文件的元素,并输出一个可以被 Playwright 使用的 CSS 选择器。
  87. 要求:
  88. 1. 只考虑“上传/选择视频文件”的入口,不要返回“发布/发表/下一步”等按钮;
  89. 2. 选择器需要尽量稳定,不要使用自动生成的随机类名(例如带很多随机字母/数字的类名可以用前缀匹配);
  90. 3. 选择器必须是 CSS 选择器(不要返回 XPath);
  91. 4. 如果确实找不到合理的上传入口,返回 selector 为空字符串。
  92. 请以 JSON 格式输出,严格遵守以下结构(不要添加任何解释文字):
  93. ```json
  94. {{
  95. "selector": "CSS 选择器字符串,比如:input[type='file'] 或 div.upload-content input[type='file']"
  96. }}
  97. ```
  98. 下面是 frame=\"{frame_name}\" 的 HTML:
  99. ```html
  100. {frame_html}
  101. ```"""
  102. payload = {
  103. "model": ai_text_model,
  104. "messages": [
  105. {
  106. "role": "user",
  107. "content": prompt,
  108. }
  109. ],
  110. "max_tokens": 600,
  111. }
  112. headers = {
  113. "Authorization": f"Bearer {ai_api_key}",
  114. "Content-Type": "application/json",
  115. }
  116. try:
  117. print(f"[{self.platform_name}] AI上传入口识别: 正在分析 frame={frame_name} HTML...")
  118. resp = requests.post(
  119. f"{ai_base_url}/chat/completions",
  120. headers=headers,
  121. json=payload,
  122. timeout=40,
  123. )
  124. if resp.status_code != 200:
  125. print(f"[{self.platform_name}] AI上传入口识别: API 返回错误 {resp.status_code}")
  126. return ""
  127. data = resp.json()
  128. content = data.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
  129. # 尝试从 ```json``` 代码块中解析
  130. json_match = re.search(r"```json\\s*([\\s\\S]*?)\\s*```", content)
  131. if json_match:
  132. json_str = json_match.group(1)
  133. else:
  134. json_match = re.search(r"\\{[\\s\\S]*\\}", content)
  135. json_str = json_match.group(0) if json_match else "{}"
  136. try:
  137. result = json.loads(json_str)
  138. except Exception:
  139. result = {}
  140. selector = (result.get("selector") or "").strip()
  141. print(f"[{self.platform_name}] AI上传入口识别结果: selector='{selector}'")
  142. return selector
  143. except Exception as e:
  144. print(f"[{self.platform_name}] AI上传入口识别异常: {e}")
  145. return ""
  146. async def ai_pick_selector_from_candidates(self, candidates: list, goal: str, frame_name: str = "main") -> str:
  147. """
  148. 将“候选元素列表(包含 css selector + 文本/属性)”发给 AI,让 AI 直接挑选最符合 goal 的元素。
  149. 适用于:HTML 里看不出上传入口、或页面大量动态渲染时。
  150. """
  151. import json
  152. import re
  153. import requests
  154. import os
  155. if not candidates:
  156. return ""
  157. ai_api_key = os.environ.get("DASHSCOPE_API_KEY", "")
  158. ai_base_url = os.environ.get("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
  159. ai_text_model = os.environ.get("AI_TEXT_MODEL", "qwen-plus")
  160. if not ai_api_key:
  161. print(f"[{self.platform_name}] AI候选选择器: 未配置 AI API Key,跳过")
  162. return ""
  163. # 控制长度,最多取前 120 个候选
  164. candidates = candidates[:120]
  165. prompt = f"""
  166. 你是自动化发布工程师。现在要在微信视频号(channels.weixin.qq.com)发布页面里找到“{goal}”相关的入口元素。
  167. 我会给你一组候选元素,每个候选都包含:
  168. - css: 可直接用于 Playwright 的 CSS 选择器
  169. - tag / type / role / ariaLabel / text / id / className(部分字段可能为空)
  170. 你的任务:
  171. - 从候选中选出最可能用于“{goal}”的元素,返回它的 css 选择器;
  172. - 如果没有任何候选符合,返回空字符串。
  173. 注意:
  174. - 如果 goal 是“上传视频入口”,优先选择 input[type=file] 或看起来会触发选择文件/上传的区域;
  175. - 不要选择“发布/发表/下一步”等按钮(除非 goal 明确是发布按钮)。
  176. 请严格按 JSON 输出(不要解释):
  177. ```json
  178. {{ "selector": "..." }}
  179. ```
  180. 候选列表(frame={frame_name}):
  181. ```json
  182. {json.dumps(candidates, ensure_ascii=False)}
  183. ```"""
  184. payload = {
  185. "model": ai_text_model,
  186. "messages": [{"role": "user", "content": prompt}],
  187. "max_tokens": 400,
  188. }
  189. headers = {
  190. "Authorization": f"Bearer {ai_api_key}",
  191. "Content-Type": "application/json",
  192. }
  193. try:
  194. print(f"[{self.platform_name}] AI候选选择器: 正在分析 frame={frame_name}, goal={goal} ...")
  195. resp = requests.post(
  196. f"{ai_base_url}/chat/completions",
  197. headers=headers,
  198. json=payload,
  199. timeout=40,
  200. )
  201. if resp.status_code != 200:
  202. print(f"[{self.platform_name}] AI候选选择器: API 返回错误 {resp.status_code}")
  203. return ""
  204. data = resp.json()
  205. content = data.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
  206. json_match = re.search(r"```json\\s*([\\s\\S]*?)\\s*```", content)
  207. if json_match:
  208. json_str = json_match.group(1)
  209. else:
  210. json_match = re.search(r"\\{[\\s\\S]*\\}", content)
  211. json_str = json_match.group(0) if json_match else "{}"
  212. try:
  213. result = json.loads(json_str)
  214. except Exception:
  215. result = {}
  216. selector = (result.get("selector") or "").strip()
  217. print(f"[{self.platform_name}] AI候选选择器结果: selector='{selector}'")
  218. return selector
  219. except Exception as e:
  220. print(f"[{self.platform_name}] AI候选选择器异常: {e}")
  221. return ""
  222. async def _extract_relevant_html_snippets(self, html: str) -> str:
  223. """
  224. 从 HTML 中抽取与上传相关的片段,减少 token,提升 AI 命中率。
  225. - 优先抓取包含 upload/上传/file/input 等关键词的窗口片段
  226. - 若未命中关键词,返回“开头 + 结尾”的拼接
  227. """
  228. import re
  229. if not html:
  230. return ""
  231. patterns = [
  232. r"upload",
  233. r"uploader",
  234. r"file",
  235. r"type\\s*=\\s*['\\\"]file['\\\"]",
  236. r"input",
  237. r"drag",
  238. r"drop",
  239. r"选择",
  240. r"上传",
  241. r"添加",
  242. r"视频",
  243. ]
  244. regex = re.compile("|".join(patterns), re.IGNORECASE)
  245. snippets = []
  246. for m in regex.finditer(html):
  247. start = max(0, m.start() - 350)
  248. end = min(len(html), m.end() + 350)
  249. snippets.append(html[start:end])
  250. if len(snippets) >= 18:
  251. break
  252. if snippets:
  253. # 去重(粗略)
  254. unique = []
  255. seen = set()
  256. for s in snippets:
  257. key = hash(s)
  258. if key not in seen:
  259. seen.add(key)
  260. unique.append(s)
  261. return "\n\n<!-- SNIPPET -->\n\n".join(unique)[:20000]
  262. # fallback: head + tail
  263. head = html[:9000]
  264. tail = html[-9000:] if len(html) > 9000 else ""
  265. return (head + "\n\n<!-- TAIL -->\n\n" + tail)[:20000]
  266. async def init_browser(self, storage_state: str = None):
  267. """初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
  268. from playwright.async_api import async_playwright
  269. playwright = await async_playwright().start()
  270. # 参考 matrix: 使用系统内的 Chrome 浏览器,避免 H264 编码错误
  271. # 非 headless 时添加 slow_mo 便于观察点击操作
  272. launch_opts = {"headless": self.headless}
  273. if not self.headless:
  274. launch_opts["slow_mo"] = 400 # 每个操作间隔 400ms,便于观看
  275. print(f"[{self.platform_name}] 有头模式 + slow_mo=400ms,浏览器将可见", flush=True)
  276. try:
  277. launch_opts["channel"] = "chrome"
  278. self.browser = await playwright.chromium.launch(**launch_opts)
  279. print(f"[{self.platform_name}] 使用系统 Chrome 浏览器", flush=True)
  280. except Exception as e:
  281. print(f"[{self.platform_name}] Chrome 不可用,使用 Chromium: {e}", flush=True)
  282. if "channel" in launch_opts:
  283. del launch_opts["channel"]
  284. self.browser = await playwright.chromium.launch(**launch_opts)
  285. # 设置 HTTP Headers 防止重定向
  286. headers = {
  287. "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  288. "Referer": "https://channels.weixin.qq.com/platform/post/list",
  289. }
  290. self.context = await self.browser.new_context(
  291. extra_http_headers=headers,
  292. ignore_https_errors=True,
  293. viewport={"width": 1920, "height": 1080},
  294. user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
  295. )
  296. self.page = await self.context.new_page()
  297. return self.page
  298. async def set_schedule_time(self, publish_date: datetime):
  299. """设置定时发布"""
  300. if not self.page:
  301. return
  302. print(f"[{self.platform_name}] 设置定时发布...")
  303. # 点击定时选项
  304. label_element = self.page.locator("label").filter(has_text="定时").nth(1)
  305. await label_element.click()
  306. # 选择日期
  307. await self.page.click('input[placeholder="请选择发表时间"]')
  308. publish_month = f"{publish_date.month:02d}"
  309. current_month = f"{publish_month}月"
  310. # 检查月份
  311. page_month = await self.page.inner_text('span.weui-desktop-picker__panel__label:has-text("月")')
  312. if page_month != current_month:
  313. await self.page.click('button.weui-desktop-btn__icon__right')
  314. # 选择日期
  315. elements = await self.page.query_selector_all('table.weui-desktop-picker__table a')
  316. for element in elements:
  317. class_name = await element.evaluate('el => el.className')
  318. if 'weui-desktop-picker__disabled' in class_name:
  319. continue
  320. text = await element.inner_text()
  321. if text.strip() == str(publish_date.day):
  322. await element.click()
  323. break
  324. # 输入时间
  325. await self.page.click('input[placeholder="请选择时间"]')
  326. await self.page.keyboard.press("Control+KeyA")
  327. await self.page.keyboard.type(str(publish_date.hour))
  328. # 点击其他地方确认
  329. await self.page.locator("div.input-editor").click()
  330. async def handle_upload_error(self, video_path: str):
  331. """处理上传错误"""
  332. if not self.page:
  333. return
  334. print(f"[{self.platform_name}] 视频出错了,重新上传中...")
  335. await self.page.locator('div.media-status-content div.tag-inner:has-text("删除")').click()
  336. await self.page.get_by_role('button', name="删除", exact=True).click()
  337. file_input = self.page.locator('input[type="file"]')
  338. await file_input.set_input_files(video_path)
  339. async def add_title_tags(self, params: PublishParams):
  340. """添加标题和话题"""
  341. if not self.page:
  342. return
  343. await self.page.locator("div.input-editor").click()
  344. await self.page.keyboard.type(params.title)
  345. if params.tags:
  346. await self.page.keyboard.press("Enter")
  347. for tag in params.tags:
  348. await self.page.keyboard.type("#" + tag)
  349. await self.page.keyboard.press("Space")
  350. print(f"[{self.platform_name}] 成功添加标题和 {len(params.tags)} 个话题")
  351. async def add_short_title(self):
  352. """添加短标题"""
  353. if not self.page:
  354. return
  355. try:
  356. short_title_element = self.page.get_by_text("短标题", exact=True).locator("..").locator(
  357. "xpath=following-sibling::div").locator('span input[type="text"]')
  358. if await short_title_element.count():
  359. # 获取已有内容作为短标题
  360. pass
  361. except:
  362. pass
  363. async def upload_cover(self, cover_path: str):
  364. """上传封面图"""
  365. if not self.page or not cover_path or not os.path.exists(cover_path):
  366. return
  367. try:
  368. await asyncio.sleep(2)
  369. preview_btn_info = await self.page.locator(
  370. 'div.finder-tag-wrap.btn:has-text("更换封面")').get_attribute('class')
  371. if "disabled" not in preview_btn_info:
  372. await self.page.locator('div.finder-tag-wrap.btn:has-text("更换封面")').click()
  373. await self.page.locator('div.single-cover-uploader-wrap > div.wrap').hover()
  374. # 删除现有封面
  375. if await self.page.locator(".del-wrap > .svg-icon").count():
  376. await self.page.locator(".del-wrap > .svg-icon").click()
  377. # 上传新封面
  378. preview_div = self.page.locator("div.single-cover-uploader-wrap > div.wrap")
  379. async with self.page.expect_file_chooser() as fc_info:
  380. await preview_div.click()
  381. preview_chooser = await fc_info.value
  382. await preview_chooser.set_files(cover_path)
  383. await asyncio.sleep(2)
  384. await self.page.get_by_role("button", name="确定").click()
  385. await asyncio.sleep(1)
  386. await self.page.get_by_role("button", name="确认").click()
  387. print(f"[{self.platform_name}] 封面上传成功")
  388. except Exception as e:
  389. print(f"[{self.platform_name}] 封面上传失败: {e}")
  390. async def check_captcha(self) -> dict:
  391. """检查页面是否需要验证码"""
  392. if not self.page:
  393. return {'need_captcha': False, 'captcha_type': ''}
  394. try:
  395. # 检查各种验证码
  396. captcha_selectors = [
  397. 'text="请输入验证码"',
  398. 'text="滑动验证"',
  399. '[class*="captcha"]',
  400. '[class*="verify"]',
  401. ]
  402. for selector in captcha_selectors:
  403. try:
  404. if await self.page.locator(selector).count() > 0:
  405. print(f"[{self.platform_name}] 检测到验证码: {selector}")
  406. return {'need_captcha': True, 'captcha_type': 'image'}
  407. except:
  408. pass
  409. # 检查登录弹窗
  410. login_selectors = [
  411. 'text="请登录"',
  412. 'text="扫码登录"',
  413. '[class*="login-dialog"]',
  414. ]
  415. for selector in login_selectors:
  416. try:
  417. if await self.page.locator(selector).count() > 0:
  418. print(f"[{self.platform_name}] 检测到需要登录: {selector}")
  419. return {'need_captcha': True, 'captcha_type': 'login'}
  420. except:
  421. pass
  422. except Exception as e:
  423. print(f"[{self.platform_name}] 验证码检测异常: {e}")
  424. return {'need_captcha': False, 'captcha_type': ''}
  425. async def publish(self, cookies: str, params: PublishParams) -> PublishResult:
  426. """发布视频到视频号"""
  427. print(f"\n{'='*60}")
  428. print(f"[{self.platform_name}] 开始发布视频")
  429. print(f"[{self.platform_name}] 视频路径: {params.video_path}")
  430. print(f"[{self.platform_name}] 标题: {params.title}")
  431. print(f"[{self.platform_name}] Headless: {self.headless}")
  432. print(f"{'='*60}")
  433. self.report_progress(5, "正在初始化浏览器...")
  434. # 初始化浏览器(使用 Chrome)
  435. await self.init_browser()
  436. print(f"[{self.platform_name}] 浏览器初始化完成")
  437. # 解析并设置 cookies
  438. cookie_list = self.parse_cookies(cookies)
  439. print(cookie_list)
  440. print(f"[{self.platform_name}] 解析到 {len(cookie_list)} 个 cookies")
  441. await self.set_cookies(cookie_list)
  442. if not self.page:
  443. raise Exception("Page not initialized")
  444. # 检查视频文件
  445. if not os.path.exists(params.video_path):
  446. raise Exception(f"视频文件不存在: {params.video_path}")
  447. print(f"[{self.platform_name}] 视频文件存在,大小: {os.path.getsize(params.video_path)} bytes")
  448. self.report_progress(10, "正在打开上传页面...")
  449. # 访问上传页面
  450. await self.page.goto(self.publish_url, wait_until="networkidle", timeout=60000)
  451. await asyncio.sleep(3)
  452. # 检查是否跳转到登录页
  453. current_url = self.page.url
  454. print(f"[{self.platform_name}] 当前页面: {current_url}")
  455. if "login" in current_url:
  456. screenshot_base64 = await self.capture_screenshot()
  457. return PublishResult(
  458. success=False,
  459. platform=self.platform_name,
  460. error="Cookie 已过期,需要重新登录",
  461. need_captcha=True,
  462. captcha_type='login',
  463. screenshot_base64=screenshot_base64,
  464. page_url=current_url,
  465. status='need_captcha'
  466. )
  467. # 使用 AI 检查验证码
  468. ai_captcha = await self.ai_check_captcha()
  469. if ai_captcha['has_captcha']:
  470. print(f"[{self.platform_name}] AI检测到验证码: {ai_captcha['captcha_type']}", flush=True)
  471. screenshot_base64 = await self.capture_screenshot()
  472. return PublishResult(
  473. success=False,
  474. platform=self.platform_name,
  475. error=f"检测到{ai_captcha['captcha_type']}验证码,需要使用有头浏览器完成验证",
  476. need_captcha=True,
  477. captcha_type=ai_captcha['captcha_type'],
  478. screenshot_base64=screenshot_base64,
  479. page_url=current_url,
  480. status='need_captcha'
  481. )
  482. # 传统方式检查验证码
  483. captcha_result = await self.check_captcha()
  484. if captcha_result['need_captcha']:
  485. screenshot_base64 = await self.capture_screenshot()
  486. return PublishResult(
  487. success=False,
  488. platform=self.platform_name,
  489. error=f"需要{captcha_result['captcha_type']}验证码,请使用有头浏览器完成验证",
  490. need_captcha=True,
  491. captcha_type=captcha_result['captcha_type'],
  492. screenshot_base64=screenshot_base64,
  493. page_url=current_url,
  494. status='need_captcha'
  495. )
  496. self.report_progress(15, "正在选择视频文件...")
  497. # 上传视频
  498. # 说明:视频号发布页在不同账号/地区/灰度下 DOM 结构差异较大,且上传组件可能在 iframe 中。
  499. # 因此这里按 matrix 的思路“点击触发 file chooser”,同时增加“遍历全部 frame + 精确挑选 video input”的兜底。
  500. upload_success = False
  501. if not self.page:
  502. raise Exception("Page not initialized")
  503. # 等待页面把上传区域渲染出来(避免过早判断)
  504. try:
  505. await self.page.wait_for_selector("div.upload-content, input[type='file'], iframe", timeout=20000)
  506. except Exception:
  507. pass
  508. async def _try_set_files_in_frame(frame, frame_name: str) -> bool:
  509. """在指定 frame 中尝试触发上传"""
  510. nonlocal upload_success
  511. if upload_success:
  512. return True
  513. # 方法0:如果用户通过环境变量显式配置了选择器,优先尝试这个
  514. if WEIXIN_UPLOAD_SELECTOR:
  515. try:
  516. el = frame.locator(WEIXIN_UPLOAD_SELECTOR).first
  517. if await el.count() > 0 and await el.is_visible():
  518. print(f"[{self.platform_name}] [{frame_name}] 使用环境变量 WEIXIN_UPLOAD_SELECTOR: {WEIXIN_UPLOAD_SELECTOR}")
  519. try:
  520. async with self.page.expect_file_chooser(timeout=5000) as fc_info:
  521. await el.click()
  522. chooser = await fc_info.value
  523. await chooser.set_files(params.video_path)
  524. upload_success = True
  525. print(f"[{self.platform_name}] [{frame_name}] 通过环境变量选择器上传成功")
  526. return True
  527. except Exception as e:
  528. print(f"[{self.platform_name}] [{frame_name}] 环境变量选择器点击失败,尝试直接 set_input_files: {e}")
  529. try:
  530. await el.set_input_files(params.video_path)
  531. upload_success = True
  532. print(f"[{self.platform_name}] [{frame_name}] 环境变量选择器 set_input_files 成功")
  533. return True
  534. except Exception as e2:
  535. print(f"[{self.platform_name}] [{frame_name}] 环境变量选择器 set_input_files 仍失败: {e2}")
  536. except Exception as e:
  537. print(f"[{self.platform_name}] [{frame_name}] 使用环境变量选择器定位元素失败: {e}")
  538. # 先尝试点击上传区域触发 chooser(最贴近 matrix)
  539. click_selectors = [
  540. "div.upload-content",
  541. "div[class*='upload-content']",
  542. "div[class*='upload']",
  543. "div.add-wrap",
  544. "[class*='uploader']",
  545. "text=点击上传",
  546. "text=上传视频",
  547. "text=选择视频",
  548. ]
  549. for selector in click_selectors:
  550. try:
  551. el = frame.locator(selector).first
  552. if await el.count() > 0 and await el.is_visible():
  553. print(f"[{self.platform_name}] [{frame_name}] 找到可点击上传区域: {selector}")
  554. try:
  555. async with self.page.expect_file_chooser(timeout=5000) as fc_info:
  556. await el.click()
  557. chooser = await fc_info.value
  558. await chooser.set_files(params.video_path)
  559. upload_success = True
  560. print(f"[{self.platform_name}] [{frame_name}] 通过 file chooser 上传成功")
  561. return True
  562. except Exception as e:
  563. print(f"[{self.platform_name}] [{frame_name}] 点击触发 chooser 失败: {e}")
  564. except Exception:
  565. pass
  566. # 再尝试直接设置 input[type=file](iframe/隐藏 input 常见)
  567. try:
  568. inputs = frame.locator("input[type='file']")
  569. cnt = await inputs.count()
  570. if cnt > 0:
  571. best_idx = 0
  572. best_score = -1
  573. for i in range(cnt):
  574. try:
  575. inp = inputs.nth(i)
  576. accept = (await inp.get_attribute("accept")) or ""
  577. multiple = (await inp.get_attribute("multiple")) or ""
  578. score = 0
  579. if "video" in accept:
  580. score += 10
  581. if "mp4" in accept:
  582. score += 3
  583. if multiple:
  584. score += 1
  585. if score > best_score:
  586. best_score = score
  587. best_idx = i
  588. except Exception:
  589. continue
  590. target = inputs.nth(best_idx)
  591. print(f"[{self.platform_name}] [{frame_name}] 尝试对 input[{best_idx}] set_input_files (score={best_score})")
  592. await target.set_input_files(params.video_path)
  593. upload_success = True
  594. print(f"[{self.platform_name}] [{frame_name}] 通过 file input 上传成功")
  595. return True
  596. except Exception as e:
  597. print(f"[{self.platform_name}] [{frame_name}] file input 上传失败: {e}")
  598. # 不直接返回,让后面的 AI 兜底有机会执行
  599. # 方法4: 兜底使用 AI 分析 HTML,猜测上传入口
  600. try:
  601. frame_url = getattr(frame, "url", "")
  602. html_full = await frame.content()
  603. html_for_ai = await self._extract_relevant_html_snippets(html_full)
  604. print(f"[{self.platform_name}] [{frame_name}] frame_url={frame_url}, html_len={len(html_full)}, html_for_ai_len={len(html_for_ai)}")
  605. ai_selector = await self.ai_find_upload_selector(html_for_ai, frame_name=frame_name)
  606. if ai_selector:
  607. try:
  608. el = frame.locator(ai_selector).first
  609. if await el.count() > 0:
  610. print(f"[{self.platform_name}] [{frame_name}] 使用 AI 选择器点击上传入口: {ai_selector}")
  611. try:
  612. async with self.page.expect_file_chooser(timeout=5000) as fc_info:
  613. await el.click()
  614. chooser = await fc_info.value
  615. await chooser.set_files(params.video_path)
  616. upload_success = True
  617. print(f"[{self.platform_name}] [{frame_name}] 通过 AI 选择器上传成功")
  618. return True
  619. except Exception as e:
  620. print(f"[{self.platform_name}] [{frame_name}] AI 选择器点击失败,改为直接 set_input_files: {e}")
  621. try:
  622. await el.set_input_files(params.video_path)
  623. upload_success = True
  624. print(f"[{self.platform_name}] [{frame_name}] AI 选择器直接 set_input_files 成功")
  625. return True
  626. except Exception as e2:
  627. print(f"[{self.platform_name}] [{frame_name}] AI 选择器 set_input_files 仍失败: {e2}")
  628. except Exception as e:
  629. print(f"[{self.platform_name}] [{frame_name}] 使用 AI 选择器定位元素失败: {e}")
  630. else:
  631. # 如果 AI 无法从 HTML 推断,退一步:构造候选元素列表交给 AI 选择
  632. try:
  633. candidates = await frame.evaluate("""
  634. () => {
  635. function cssEscape(s) {
  636. try { return CSS.escape(s); } catch (e) { return s.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); }
  637. }
  638. function buildSelector(el) {
  639. if (!el || el.nodeType !== 1) return '';
  640. if (el.id) return `#${cssEscape(el.id)}`;
  641. let parts = [];
  642. let cur = el;
  643. for (let depth = 0; cur && cur.nodeType === 1 && depth < 5; depth++) {
  644. let part = cur.tagName.toLowerCase();
  645. const role = cur.getAttribute('role');
  646. const type = cur.getAttribute('type');
  647. if (type) part += `[type="${type}"]`;
  648. if (role) part += `[role="${role}"]`;
  649. const cls = (cur.className || '').toString().trim().split(/\\s+/).filter(Boolean);
  650. if (cls.length) part += '.' + cls.slice(0, 2).map(cssEscape).join('.');
  651. // nth-of-type
  652. let idx = 1;
  653. let sib = cur;
  654. while (sib && (sib = sib.previousElementSibling)) {
  655. if (sib.tagName === cur.tagName) idx++;
  656. }
  657. part += `:nth-of-type(${idx})`;
  658. parts.unshift(part);
  659. cur = cur.parentElement;
  660. }
  661. return parts.join(' > ');
  662. }
  663. const nodes = Array.from(document.querySelectorAll('input, button, a, div, span'))
  664. .filter(el => {
  665. const tag = el.tagName.toLowerCase();
  666. const type = (el.getAttribute('type') || '').toLowerCase();
  667. const role = (el.getAttribute('role') || '').toLowerCase();
  668. const aria = (el.getAttribute('aria-label') || '').toLowerCase();
  669. const txt = (el.innerText || '').trim().slice(0, 60);
  670. const cls = (el.className || '').toString().toLowerCase();
  671. const isFile = tag === 'input' && type === 'file';
  672. const looksClickable =
  673. tag === 'button' || tag === 'a' || role === 'button' || el.onclick ||
  674. cls.includes('upload') || cls.includes('uploader') || cls.includes('drag') ||
  675. aria.includes('上传') || aria.includes('选择') || aria.includes('添加') ||
  676. txt.includes('上传') || txt.includes('选择') || txt.includes('添加') || txt.includes('点击上传');
  677. if (!isFile && !looksClickable) return false;
  678. const r = el.getBoundingClientRect();
  679. const visible = r.width > 5 && r.height > 5;
  680. return visible;
  681. });
  682. const limited = nodes.slice(0, 120).map(el => ({
  683. css: buildSelector(el),
  684. tag: el.tagName.toLowerCase(),
  685. type: el.getAttribute('type') || '',
  686. role: el.getAttribute('role') || '',
  687. ariaLabel: el.getAttribute('aria-label') || '',
  688. text: (el.innerText || '').trim().slice(0, 80),
  689. id: el.id || '',
  690. className: (el.className || '').toString().slice(0, 120),
  691. accept: el.getAttribute('accept') || '',
  692. }));
  693. return limited;
  694. }
  695. """)
  696. ai_selector2 = await self.ai_pick_selector_from_candidates(
  697. candidates=candidates,
  698. goal="上传视频入口",
  699. frame_name=frame_name
  700. )
  701. if ai_selector2:
  702. el2 = frame.locator(ai_selector2).first
  703. if await el2.count() > 0:
  704. print(f"[{self.platform_name}] [{frame_name}] 使用 AI 候选选择器点击上传入口: {ai_selector2}")
  705. try:
  706. async with self.page.expect_file_chooser(timeout=5000) as fc_info:
  707. await el2.click()
  708. chooser2 = await fc_info.value
  709. await chooser2.set_files(params.video_path)
  710. upload_success = True
  711. print(f"[{self.platform_name}] [{frame_name}] 通过 AI 候选选择器上传成功")
  712. return True
  713. except Exception as e:
  714. print(f"[{self.platform_name}] [{frame_name}] AI 候选选择器点击失败,尝试 set_input_files: {e}")
  715. try:
  716. await el2.set_input_files(params.video_path)
  717. upload_success = True
  718. print(f"[{self.platform_name}] [{frame_name}] AI 候选选择器 set_input_files 成功")
  719. return True
  720. except Exception as e2:
  721. print(f"[{self.platform_name}] [{frame_name}] AI 候选选择器 set_input_files 仍失败: {e2}")
  722. except Exception as e:
  723. print(f"[{self.platform_name}] [{frame_name}] 构造候选并交给 AI 失败: {e}")
  724. except Exception as e:
  725. print(f"[{self.platform_name}] [{frame_name}] AI 上传入口识别整体失败: {e}")
  726. return False
  727. # 先尝试主 frame
  728. try:
  729. await _try_set_files_in_frame(self.page.main_frame, "main")
  730. except Exception as e:
  731. print(f"[{self.platform_name}] main frame 上传尝试异常: {e}")
  732. # 再遍历所有子 frame
  733. if not upload_success:
  734. try:
  735. frames = self.page.frames
  736. print(f"[{self.platform_name}] 发现 frames: {len(frames)}")
  737. for idx, fr in enumerate(frames):
  738. if upload_success:
  739. break
  740. # main_frame 已尝试过
  741. if fr == self.page.main_frame:
  742. continue
  743. name = fr.name or f"frame-{idx}"
  744. await _try_set_files_in_frame(fr, name)
  745. except Exception as e:
  746. print(f"[{self.platform_name}] 遍历 frames 异常: {e}")
  747. if not upload_success:
  748. screenshot_base64 = await self.capture_screenshot()
  749. return PublishResult(
  750. success=False,
  751. platform=self.platform_name,
  752. error="未找到上传入口(可能在 iframe 中或页面结构已变更)",
  753. screenshot_base64=screenshot_base64,
  754. page_url=await self.get_page_url(),
  755. status='failed'
  756. )
  757. self.report_progress(20, "正在填充标题和话题...")
  758. # 添加标题和话题
  759. await self.add_title_tags(params)
  760. self.report_progress(30, "等待视频上传完成...")
  761. # 等待上传完成
  762. for _ in range(120):
  763. try:
  764. button_info = await self.page.get_by_role("button", name="发表").get_attribute('class')
  765. if "weui-desktop-btn_disabled" not in button_info:
  766. print(f"[{self.platform_name}] 视频上传完毕")
  767. # 上传封面
  768. self.report_progress(50, "正在上传封面...")
  769. await self.upload_cover(params.cover_path)
  770. break
  771. else:
  772. # 检查上传错误
  773. if await self.page.locator('div.status-msg.error').count():
  774. if await self.page.locator('div.media-status-content div.tag-inner:has-text("删除")').count():
  775. await self.handle_upload_error(params.video_path)
  776. await asyncio.sleep(3)
  777. except:
  778. await asyncio.sleep(3)
  779. self.report_progress(60, "处理视频设置...")
  780. # 添加短标题
  781. try:
  782. short_title_el = self.page.get_by_text("短标题", exact=True).locator("..").locator(
  783. "xpath=following-sibling::div").locator('span input[type="text"]')
  784. if await short_title_el.count():
  785. short_title = format_short_title(params.title)
  786. await short_title_el.fill(short_title)
  787. except:
  788. pass
  789. # 定时发布
  790. if params.publish_date:
  791. self.report_progress(70, "设置定时发布...")
  792. await self.set_schedule_time(params.publish_date)
  793. self.report_progress(80, "正在发布...")
  794. # 点击发布 - 参考 matrix
  795. for i in range(30):
  796. try:
  797. # 参考 matrix: div.form-btns button:has-text("发表")
  798. publish_btn = self.page.locator('div.form-btns button:has-text("发表")')
  799. if await publish_btn.count():
  800. print(f"[{self.platform_name}] 点击发布按钮...")
  801. await publish_btn.click()
  802. # 等待跳转到作品列表页面 - 参考 matrix
  803. await self.page.wait_for_url(
  804. "https://channels.weixin.qq.com/platform/post/list",
  805. timeout=10000
  806. )
  807. self.report_progress(100, "发布成功")
  808. print(f"[{self.platform_name}] 视频发布成功!")
  809. screenshot_base64 = await self.capture_screenshot()
  810. return PublishResult(
  811. success=True,
  812. platform=self.platform_name,
  813. message="发布成功",
  814. screenshot_base64=screenshot_base64,
  815. page_url=self.page.url,
  816. status='success'
  817. )
  818. except Exception as e:
  819. current_url = self.page.url
  820. if "https://channels.weixin.qq.com/platform/post/list" in current_url:
  821. self.report_progress(100, "发布成功")
  822. print(f"[{self.platform_name}] 视频发布成功!")
  823. screenshot_base64 = await self.capture_screenshot()
  824. return PublishResult(
  825. success=True,
  826. platform=self.platform_name,
  827. message="发布成功",
  828. screenshot_base64=screenshot_base64,
  829. page_url=current_url,
  830. status='success'
  831. )
  832. else:
  833. print(f"[{self.platform_name}] 视频正在发布中... {i+1}/30, URL: {current_url}")
  834. await asyncio.sleep(1)
  835. # 发布超时
  836. screenshot_base64 = await self.capture_screenshot()
  837. page_url = await self.get_page_url()
  838. return PublishResult(
  839. success=False,
  840. platform=self.platform_name,
  841. error="发布超时,请检查发布状态",
  842. screenshot_base64=screenshot_base64,
  843. page_url=page_url,
  844. status='need_action'
  845. )
  846. async def _get_works_fallback_dom(self, page_size: int) -> tuple:
  847. """API 失败时从当前页面 DOM 抓取作品列表(兼容新账号/不同入口)"""
  848. works: List[WorkItem] = []
  849. total = 0
  850. has_more = False
  851. try:
  852. for selector in ["div.post-feed-item", "[class*='post-feed']", "[class*='feed-item']", "div[class*='post']"]:
  853. try:
  854. await self.page.wait_for_selector(selector, timeout=8000)
  855. break
  856. except Exception:
  857. continue
  858. post_items = self.page.locator("div.post-feed-item")
  859. item_count = await post_items.count()
  860. if item_count == 0:
  861. post_items = self.page.locator("[class*='post-feed']")
  862. item_count = await post_items.count()
  863. for i in range(min(item_count, page_size)):
  864. try:
  865. item = post_items.nth(i)
  866. cover_el = item.locator("div.media img.thumb").first
  867. cover_url = await cover_el.get_attribute("src") or "" if await cover_el.count() > 0 else ""
  868. if not cover_url:
  869. cover_el = item.locator("img").first
  870. cover_url = await cover_el.get_attribute("src") or "" if await cover_el.count() > 0 else ""
  871. title_el = item.locator("div.post-title").first
  872. title = (await title_el.text_content() or "").strip() if await title_el.count() > 0 else ""
  873. time_el = item.locator("div.post-time span").first
  874. publish_time = (await time_el.text_content() or "").strip() if await time_el.count() > 0 else ""
  875. play_count = like_count = comment_count = share_count = collect_count = 0
  876. data_items = item.locator("div.post-data div.data-item")
  877. for j in range(await data_items.count()):
  878. data_item = data_items.nth(j)
  879. count_text = (await data_item.locator("span.count").text_content() or "0").strip()
  880. if await data_item.locator("span.weui-icon-outlined-eyes-on").count() > 0:
  881. play_count = self._parse_count(count_text)
  882. elif await data_item.locator("span.weui-icon-outlined-like").count() > 0:
  883. like_count = self._parse_count(count_text)
  884. elif await data_item.locator("span.weui-icon-outlined-comment").count() > 0:
  885. comment_count = self._parse_count(count_text)
  886. elif await data_item.locator("use[xlink\\:href='#icon-share']").count() > 0:
  887. share_count = self._parse_count(count_text)
  888. elif await data_item.locator("use[xlink\\:href='#icon-thumb']").count() > 0:
  889. collect_count = self._parse_count(count_text)
  890. work_id = f"weixin_{i}_{hash(title)}_{hash(publish_time)}"
  891. works.append(WorkItem(
  892. work_id=work_id,
  893. title=title or "无标题",
  894. cover_url=cover_url,
  895. duration=0,
  896. status="published",
  897. publish_time=publish_time,
  898. play_count=play_count,
  899. like_count=like_count,
  900. comment_count=comment_count,
  901. share_count=share_count,
  902. collect_count=collect_count,
  903. ))
  904. except Exception as e:
  905. print(f"[{self.platform_name}] DOM 解析作品 {i} 失败: {e}", flush=True)
  906. continue
  907. total = len(works)
  908. has_more = item_count > page_size
  909. print(f"[{self.platform_name}] DOM 回退获取 {len(works)} 条", flush=True)
  910. except Exception as e:
  911. print(f"[{self.platform_name}] DOM 回退失败: {e}", flush=True)
  912. return (works, total, has_more, "")
  913. async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
  914. """获取视频号作品列表(调用 post_list 接口)
  915. page: 页码从 0 开始,或上一页返回的 rawKeyBuff/lastBuff 字符串
  916. """
  917. # 分页:首页 currentPage=1/rawKeyBuff=null,下一页用 currentPage 递增或 rawKeyBuff
  918. if page is None or page == "" or (isinstance(page, int) and page == 0):
  919. current_page = 1
  920. raw_key_buff = None
  921. elif isinstance(page, int):
  922. current_page = page + 1
  923. raw_key_buff = None
  924. else:
  925. current_page = 1
  926. raw_key_buff = str(page)
  927. ts_ms = str(int(time.time() * 1000))
  928. print(f"\n{'='*60}")
  929. print(f"[{self.platform_name}] 获取作品列表 currentPage={current_page}, pageSize={page_size}, rawKeyBuff={raw_key_buff[:40] if raw_key_buff else 'null'}...")
  930. print(f"{'='*60}")
  931. works: List[WorkItem] = []
  932. total = 0
  933. has_more = False
  934. next_page = ""
  935. try:
  936. await self.init_browser()
  937. cookie_list = self.parse_cookies(cookies)
  938. await self.set_cookies(cookie_list)
  939. if not self.page:
  940. raise Exception("Page not initialized")
  941. await self.page.goto("https://channels.weixin.qq.com/platform/post/list", timeout=30000)
  942. await asyncio.sleep(3)
  943. current_url = self.page.url
  944. if "login" in current_url:
  945. raise Exception("Cookie 已过期,请重新登录")
  946. api_url = "https://channels.weixin.qq.com/micro/content/cgi-bin/mmfinderassistant-bin/post/post_list"
  947. req_body = {
  948. "pageSize": page_size,
  949. "currentPage": current_page,
  950. "userpageType": 11,
  951. "stickyOrder": True,
  952. "timestamp": ts_ms,
  953. "_log_finder_uin": "",
  954. "_log_finder_id": "",
  955. "rawKeyBuff": raw_key_buff,
  956. "pluginSessionId": None,
  957. "scene": 7,
  958. "reqScene": 7,
  959. }
  960. body_str = json.dumps(req_body)
  961. response = await self.page.evaluate("""
  962. async ([url, bodyStr]) => {
  963. try {
  964. const resp = await fetch(url, {
  965. method: 'POST',
  966. credentials: 'include',
  967. headers: {
  968. 'Content-Type': 'application/json',
  969. 'Accept': '*/*',
  970. 'Referer': 'https://channels.weixin.qq.com/platform/post/list'
  971. },
  972. body: bodyStr
  973. });
  974. return await resp.json();
  975. } catch (e) {
  976. return { error: e.toString() };
  977. }
  978. }
  979. """, [api_url, body_str])
  980. is_first_page = current_page == 1 and raw_key_buff is None
  981. if response.get("error"):
  982. print(f"[{self.platform_name}] API 请求失败: {response.get('error')}", flush=True)
  983. if is_first_page:
  984. works, total, has_more, next_page = await self._get_works_fallback_dom(page_size)
  985. if works:
  986. return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
  987. return WorksResult(success=False, platform=self.platform_name, error=response.get("error", "API 请求失败"))
  988. err_code = response.get("errCode", -1)
  989. if err_code != 0:
  990. err_msg = response.get("errMsg", "unknown")
  991. print(f"[{self.platform_name}] API errCode={err_code}, errMsg={err_msg}, 完整响应(前800字): {json.dumps(response, ensure_ascii=False)[:800]}", flush=True)
  992. if is_first_page:
  993. works, total, has_more, next_page = await self._get_works_fallback_dom(page_size)
  994. if works:
  995. return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
  996. return WorksResult(success=False, platform=self.platform_name, error=f"errCode={err_code}, errMsg={err_msg}")
  997. data = response.get("data") or {}
  998. raw_list = data.get("list") or []
  999. total = int(data.get("totalCount") or 0)
  1000. has_more = bool(data.get("continueFlag", False))
  1001. next_page = (data.get("lastBuff") or "").strip()
  1002. print(f"[{self.platform_name}] API 响应: list_len={len(raw_list)}, totalCount={total}, continueFlag={has_more}, lastBuff={next_page[:50] if next_page else ''}...")
  1003. if is_first_page and len(raw_list) == 0:
  1004. works_fb, total_fb, has_more_fb, _ = await self._get_works_fallback_dom(page_size)
  1005. if works_fb:
  1006. return WorksResult(success=True, platform=self.platform_name, works=works_fb, total=total_fb, has_more=has_more_fb, next_page="")
  1007. for item in raw_list:
  1008. try:
  1009. # 存 works.platform_video_id 统一用 post_list 接口回参中的 exportId(如 export/xxx)
  1010. work_id = str(item.get("exportId") or item.get("objectId") or item.get("id") or "").strip()
  1011. if not work_id:
  1012. work_id = f"weixin_{hash(item.get('createTime',0))}_{hash(item.get('desc', {}).get('description',''))}"
  1013. desc = item.get("desc") or {}
  1014. title = (desc.get("description") or "").strip() or "无标题"
  1015. cover_url = ""
  1016. duration = 0
  1017. media_list = desc.get("media") or []
  1018. if media_list and isinstance(media_list[0], dict):
  1019. m = media_list[0]
  1020. cover_url = (m.get("coverUrl") or m.get("thumbUrl") or "").strip()
  1021. duration = int(m.get("videoPlayLen") or 0)
  1022. create_ts = item.get("createTime") or 0
  1023. if isinstance(create_ts, (int, float)) and create_ts:
  1024. publish_time = datetime.fromtimestamp(create_ts).strftime("%Y-%m-%d %H:%M:%S")
  1025. else:
  1026. publish_time = str(create_ts) if create_ts else ""
  1027. read_count = int(item.get("readCount") or 0)
  1028. like_count = int(item.get("likeCount") or 0)
  1029. comment_count = int(item.get("commentCount") or 0)
  1030. forward_count = int(item.get("forwardCount") or 0)
  1031. fav_count = int(item.get("favCount") or 0)
  1032. works.append(WorkItem(
  1033. work_id=work_id,
  1034. title=title,
  1035. cover_url=cover_url,
  1036. duration=duration,
  1037. status="published",
  1038. publish_time=publish_time,
  1039. play_count=read_count,
  1040. like_count=like_count,
  1041. comment_count=comment_count,
  1042. share_count=forward_count,
  1043. collect_count=fav_count,
  1044. ))
  1045. except Exception as e:
  1046. print(f"[{self.platform_name}] 解析作品项失败: {e}", flush=True)
  1047. continue
  1048. if total == 0 and works:
  1049. total = len(works)
  1050. print(f"[{self.platform_name}] 本页获取 {len(works)} 条,totalCount={total}, next_page={bool(next_page)}")
  1051. except Exception as e:
  1052. import traceback
  1053. traceback.print_exc()
  1054. return WorksResult(success=False, platform=self.platform_name, error=str(e))
  1055. return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
  1056. async def sync_work_daily_stats_via_browser(
  1057. self, cookies: str, work_id: int, platform_video_id: str
  1058. ) -> dict:
  1059. """
  1060. 通过浏览器自动化同步单个作品的每日数据到 work_day_statistics。
  1061. 流程:
  1062. 1. 打开 statistic/post 页,点击单篇视频 tab,点击近30天
  1063. 2. 监听 post_list 接口,根据 exportId 匹配 platform_video_id 得到 objectId
  1064. 3. 找到 data-row-key=objectId 的行,点击「查看」
  1065. 4. 进入详情页,点击数据详情的近30天,点击下载表格
  1066. 5. 解析 CSV 并返回 statistics 列表(供 Node 保存)
  1067. """
  1068. import csv
  1069. import tempfile
  1070. from pathlib import Path
  1071. result = {"success": False, "error": "", "statistics": [], "inserted": 0, "updated": 0}
  1072. post_list_data = {"list": []}
  1073. async def handle_response(response):
  1074. try:
  1075. if "statistic/post_list" in response.url and response.request.method == "POST":
  1076. try:
  1077. body = await response.json()
  1078. if body.get("errCode") == 0 and body.get("data"):
  1079. post_list_data["list"] = body.get("data", {}).get("list", [])
  1080. except Exception:
  1081. pass
  1082. except Exception:
  1083. pass
  1084. try:
  1085. await self.init_browser()
  1086. cookie_list = self.parse_cookies(cookies)
  1087. await self.set_cookies(cookie_list)
  1088. if not self.page:
  1089. raise Exception("Page not initialized")
  1090. self.page.on("response", handle_response)
  1091. # 1. 打开数据分析-作品数据页
  1092. print(f"[{self.platform_name}] 打开数据分析页...", flush=True)
  1093. await self.page.goto("https://channels.weixin.qq.com/platform/statistic/post", timeout=30000)
  1094. if not self.headless:
  1095. print(f"[{self.platform_name}] 浏览器已打开,请将窗口置于前台观看操作(等待 5 秒)...", flush=True)
  1096. await asyncio.sleep(5)
  1097. else:
  1098. await asyncio.sleep(3)
  1099. if "login" in self.page.url:
  1100. raise Exception("Cookie 已过期,请重新登录")
  1101. # 2. 点击「单篇视频」tab
  1102. tab_sel = "div.weui-desktop-tab__navs ul li:nth-child(2) a"
  1103. try:
  1104. await self.page.wait_for_selector(tab_sel, timeout=8000)
  1105. await self.page.click(tab_sel)
  1106. except Exception:
  1107. tab_sel = "a:has-text('单篇视频')"
  1108. await self.page.click(tab_sel)
  1109. await asyncio.sleep(2)
  1110. # 3. 点击「近30天」(单篇视频页的日期范围筛选)
  1111. # 选择器优先级:精确匹配单篇视频区域内的日期范围 radio 组
  1112. radio_selectors = [
  1113. "div.post-single-wrap div.weui-desktop-radio-group.radio-group label:has-text('近30天')",
  1114. "div.post-single-wrap div.filter-wrap div.weui-desktop-radio-group label:nth-child(2)",
  1115. "div.post-single-wrap div.card-body div.filter-wrap div:nth-child(2) label:nth-child(2)",
  1116. "div.post-single-wrap label:has-text('近30天')",
  1117. "div.weui-desktop-radio-group label:has-text('近30天')",
  1118. "label:has-text('近30天')",
  1119. ]
  1120. clicked = False
  1121. for sel in radio_selectors:
  1122. try:
  1123. el = self.page.locator(sel).first
  1124. if await el.count() > 0:
  1125. await el.click()
  1126. clicked = True
  1127. print(f"[{self.platform_name}] 已点击近30天按钮 (selector: {sel[:50]}...)", flush=True)
  1128. break
  1129. except Exception as e:
  1130. continue
  1131. if not clicked:
  1132. print(f"[{self.platform_name}] 警告: 未找到近30天按钮,继续尝试...", flush=True)
  1133. await asyncio.sleep(3)
  1134. # 4. 从 post_list 响应中找 exportId -> objectId
  1135. export_id_to_object = {}
  1136. for item in post_list_data["list"]:
  1137. eid = (item.get("exportId") or "").strip()
  1138. oid = (item.get("objectId") or "").strip()
  1139. if eid and oid:
  1140. export_id_to_object[eid] = oid
  1141. object_id = export_id_to_object.get(platform_video_id) or export_id_to_object.get(
  1142. platform_video_id.strip()
  1143. )
  1144. if not object_id:
  1145. # 尝试宽松匹配(platform_video_id 可能带前缀)
  1146. for eid, oid in export_id_to_object.items():
  1147. if platform_video_id in eid or eid in platform_video_id:
  1148. object_id = oid
  1149. break
  1150. if not object_id:
  1151. result["error"] = f"未在 post_list 中匹配到 exportId={platform_video_id}"
  1152. print(f"[{self.platform_name}] {result['error']}", flush=True)
  1153. return result
  1154. # 5. 找到 data-row-key=objectId 的行,点击「查看」
  1155. view_btn = self.page.locator(f'tr[data-row-key="{object_id}"] a.detail-wrap, tr[data-row-key="{object_id}"] a:has-text("查看")')
  1156. try:
  1157. await view_btn.first.wait_for(timeout=5000)
  1158. await view_btn.first.click()
  1159. except Exception as e:
  1160. view_btn = self.page.locator(f'tr[data-row-key="{object_id}"] a')
  1161. if await view_btn.count() > 0:
  1162. await view_btn.first.click()
  1163. else:
  1164. raise Exception(f"未找到 objectId={object_id} 的查看按钮: {e}")
  1165. await asyncio.sleep(3)
  1166. # 6. 详情页:点击数据详情的「近30天」,再点击「下载表格」
  1167. detail_radio = "div.post-statistic-common div.filter-wrap label:nth-child(2)"
  1168. for sel in [detail_radio, "div.main-body label:has-text('近30天')"]:
  1169. try:
  1170. el = self.page.locator(sel).first
  1171. if await el.count() > 0:
  1172. await el.click()
  1173. break
  1174. except Exception:
  1175. continue
  1176. await asyncio.sleep(2)
  1177. # 保存到 server/tmp 目录
  1178. download_dir = Path(__file__).resolve().parent.parent.parent / "tmp"
  1179. download_dir.mkdir(parents=True, exist_ok=True)
  1180. async with self.page.expect_download(timeout=15000) as download_info:
  1181. download_btn = self.page.locator("div.post-statistic-common div.filter-extra a, a:has-text('下载表格')")
  1182. if await download_btn.count() == 0:
  1183. raise Exception("未找到「下载表格」按钮")
  1184. await download_btn.first.click()
  1185. download = await download_info.value
  1186. save_path = download_dir / f"work_{work_id}_{int(time.time())}.csv"
  1187. await download.save_as(save_path)
  1188. # 7. 解析 CSV -> statistics
  1189. stats_list = []
  1190. with open(save_path, "r", encoding="utf-8-sig", errors="replace") as f:
  1191. reader = csv.DictReader(f)
  1192. rows = list(reader)
  1193. for row in rows:
  1194. date_val = (
  1195. row.get("日期")
  1196. or row.get("date")
  1197. or row.get("时间")
  1198. or row.get("时间周期", "")
  1199. ).strip()
  1200. if not date_val:
  1201. continue
  1202. dt = None
  1203. norm = date_val[:10].replace("年", "-").replace("月", "-").replace("日", "-").replace("/", "-")
  1204. if len(norm) >= 8 and norm.count("-") >= 2:
  1205. parts = norm.split("-")
  1206. if len(parts) == 3:
  1207. try:
  1208. y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
  1209. if 2000 <= y <= 2100 and 1 <= m <= 12 and 1 <= d <= 31:
  1210. dt = datetime(y, m, d)
  1211. except (ValueError, IndexError):
  1212. pass
  1213. if not dt:
  1214. for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y"]:
  1215. try:
  1216. dt = datetime.strptime((date_val.split()[0] if date_val else "")[:10], fmt)
  1217. break
  1218. except (ValueError, IndexError):
  1219. dt = None
  1220. if not dt:
  1221. continue
  1222. rec_date = dt.strftime("%Y-%m-%d")
  1223. play = self._parse_count(row.get("播放", "") or row.get("播放量", "") or row.get("play_count", "0"))
  1224. like = self._parse_count(row.get("点赞", "") or row.get("like_count", "0"))
  1225. comment = self._parse_count(row.get("评论", "") or row.get("comment_count", "0"))
  1226. share = self._parse_count(row.get("分享", "") or row.get("share_count", "0"))
  1227. collect = self._parse_count(row.get("收藏", "") or row.get("collect_count", "0"))
  1228. comp_rate = (row.get("完播率", "") or row.get("completion_rate", "0")).strip().rstrip("%") or "0"
  1229. avg_dur = (row.get("平均播放时长", "") or row.get("avg_watch_duration", "0")).strip()
  1230. stats_list.append({
  1231. "work_id": work_id,
  1232. "record_date": rec_date,
  1233. "play_count": play,
  1234. "like_count": like,
  1235. "comment_count": comment,
  1236. "share_count": share,
  1237. "collect_count": collect,
  1238. "completion_rate": comp_rate,
  1239. "avg_watch_duration": avg_dur,
  1240. })
  1241. result["statistics"] = stats_list
  1242. result["success"] = True
  1243. try:
  1244. os.remove(save_path)
  1245. except Exception:
  1246. pass
  1247. except Exception as e:
  1248. import traceback
  1249. traceback.print_exc()
  1250. result["error"] = str(e)
  1251. finally:
  1252. try:
  1253. await self.close_browser()
  1254. except Exception:
  1255. pass
  1256. return result
  1257. async def sync_account_works_daily_stats_via_browser(
  1258. self,
  1259. cookies: str,
  1260. works: List[dict],
  1261. save_fn=None,
  1262. update_works_fn=None,
  1263. headless: bool = True,
  1264. ) -> dict:
  1265. """
  1266. 纯浏览器批量同步账号下所有作品(在库的)的每日数据到 work_day_statistics。
  1267. 流程:
  1268. 1. 打开 statistic/post → 点击单篇视频 → 点击近30天
  1269. 2. 【首次】监听 post_list 接口 → 解析响应更新 works 表 yesterday_* 字段
  1270. 3. 监听 post_list 获取 exportId->objectId 映射
  1271. 4. 遍历 post_list 的每一条:
  1272. - 若 exportId 在 works 的 platform_video_id 中无匹配 → 跳过
  1273. - 若匹配 → 找到 data-row-key=objectId 的行,点击「查看」
  1274. - 详情页:默认近7天,直接监听 feed_aggreagate_data_by_tab_type 接口
  1275. - 从「全部」tab 解析 browse/like/comment/forward/fav/follow,日期从昨天往前推
  1276. - 通过 save_fn 存入 work_day_statistics
  1277. - 返回列表页,继续下一条
  1278. works: [{"work_id": int, "platform_video_id": str}, ...]
  1279. save_fn: (stats_list: List[dict]) -> {inserted, updated},由调用方传入,用于调用 Node batch-dates
  1280. update_works_fn: (updates: List[dict]) -> {updated},由调用方传入,用于将 post_list 解析数据更新到 works 表(仅首次调用)
  1281. """
  1282. from pathlib import Path
  1283. from datetime import timedelta
  1284. result = {
  1285. "success": True,
  1286. "error": "",
  1287. "total_processed": 0,
  1288. "total_skipped": 0,
  1289. "inserted": 0,
  1290. "updated": 0,
  1291. "works_updated": 0,
  1292. }
  1293. # platform_video_id(exportId) -> work_id
  1294. export_id_to_work = {}
  1295. for w in works:
  1296. pvid = (w.get("platform_video_id") or w.get("platformVideoId") or "").strip()
  1297. wid = w.get("work_id") or w.get("workId")
  1298. if pvid and wid is not None:
  1299. export_id_to_work[pvid] = int(wid)
  1300. # 兼容可能带/不带前缀(如 export/xxx vs xxx)
  1301. if "/" in pvid:
  1302. export_id_to_work[pvid.split("/")[-1]] = int(wid)
  1303. post_list_data = {"list": []}
  1304. feed_aggreagate_data = {"body": None}
  1305. async def handle_response(response):
  1306. try:
  1307. url = response.url
  1308. if "statistic/post_list" in url:
  1309. try:
  1310. body = await response.json()
  1311. if body.get("errCode") == 0 and body.get("data"):
  1312. post_list_data["list"] = body.get("data", {}).get("list", [])
  1313. except Exception:
  1314. pass
  1315. elif "feed_aggreagate_data_by_tab_type" in url:
  1316. try:
  1317. body = await response.json()
  1318. if body.get("errCode") == 0 and body.get("data"):
  1319. feed_aggreagate_data["body"] = body
  1320. except Exception:
  1321. pass
  1322. except Exception:
  1323. pass
  1324. try:
  1325. await self.init_browser()
  1326. cookie_list = self.parse_cookies(cookies)
  1327. await self.set_cookies(cookie_list)
  1328. if not self.page:
  1329. raise Exception("Page not initialized")
  1330. self.page.on("response", handle_response)
  1331. # 1. 打开数据分析-作品数据页
  1332. print(f"[{self.platform_name}] 打开数据分析页...", flush=True)
  1333. await self.page.goto("https://channels.weixin.qq.com/platform/statistic/post", timeout=30000)
  1334. if not headless:
  1335. print(f"[{self.platform_name}] 浏览器已打开,请将窗口置于前台观看操作(等待 5 秒)...", flush=True)
  1336. await asyncio.sleep(5)
  1337. else:
  1338. await asyncio.sleep(3)
  1339. if "login" in self.page.url:
  1340. raise Exception("Cookie 已过期,请重新登录")
  1341. # 2. 点击「单篇视频」tab
  1342. tab_sel = "div.weui-desktop-tab__navs ul li:nth-child(2) a"
  1343. try:
  1344. await self.page.wait_for_selector(tab_sel, timeout=8000)
  1345. await self.page.click(tab_sel)
  1346. except Exception:
  1347. tab_sel = "a:has-text('单篇视频')"
  1348. await self.page.click(tab_sel)
  1349. await asyncio.sleep(2)
  1350. # 3. 点击「近30天」前清空 list,点击后等待 handler 捕获带 fullPlayRate 的 post_list
  1351. post_list_data["list"] = []
  1352. radio_selectors = [
  1353. "div.post-single-wrap div.weui-desktop-radio-group.radio-group label:has-text('近30天')",
  1354. "div.post-single-wrap div.filter-wrap div.weui-desktop-radio-group label:nth-child(2)",
  1355. "div.post-single-wrap label:has-text('近30天')",
  1356. "div.weui-desktop-radio-group label:has-text('近30天')",
  1357. "label:has-text('近30天')",
  1358. ]
  1359. clicked = False
  1360. for sel in radio_selectors:
  1361. try:
  1362. el = self.page.locator(sel).first
  1363. if await el.count() > 0:
  1364. await el.click()
  1365. clicked = True
  1366. print(f"[{self.platform_name}] 已点击近30天 (selector: {sel[:40]}...)", flush=True)
  1367. break
  1368. except Exception:
  1369. continue
  1370. if not clicked:
  1371. print(f"[{self.platform_name}] 警告: 未找到近30天按钮", flush=True)
  1372. await asyncio.sleep(5)
  1373. # 4. 从 post_list 获取列表
  1374. items = post_list_data["list"]
  1375. if not items:
  1376. result["error"] = "未监听到 post_list 或列表为空"
  1377. print(f"[{self.platform_name}] {result['error']}", flush=True)
  1378. return result
  1379. # 4.5 【仅首次】从 post_list 接口响应解析数据 → 更新 works 表(不再下载 CSV)
  1380. # post_list 返回字段映射: readCount->播放量, likeCount->点赞, commentCount->评论, forwardCount->分享,
  1381. # fullPlayRate->完播率(0-1小数), avgPlayTimeSec->平均播放时长(秒), exportId->匹配 work_id
  1382. if update_works_fn and items:
  1383. try:
  1384. updates = []
  1385. for it in items:
  1386. eid = (it.get("exportId") or "").strip()
  1387. if not eid:
  1388. continue
  1389. work_id = export_id_to_work.get(eid)
  1390. if work_id is None:
  1391. for k, v in export_id_to_work.items():
  1392. if eid in k or k in eid:
  1393. work_id = v
  1394. break
  1395. if work_id is None:
  1396. continue
  1397. read_count = int(it.get("readCount") or 0)
  1398. like_count = int(it.get("likeCount") or 0)
  1399. comment_count = int(it.get("commentCount") or 0)
  1400. forward_count = int(it.get("forwardCount") or 0)
  1401. follow_count = int(it.get("followCount") or 0)
  1402. full_play_rate = it.get("fullPlayRate")
  1403. if full_play_rate is not None:
  1404. comp_rate = f"{float(full_play_rate) * 100:.2f}%"
  1405. else:
  1406. comp_rate = "0"
  1407. avg_sec = it.get("avgPlayTimeSec")
  1408. if avg_sec is not None:
  1409. avg_dur = f"{float(avg_sec):.2f}秒"
  1410. else:
  1411. avg_dur = "0"
  1412. updates.append({
  1413. "work_id": work_id,
  1414. "yesterday_play_count": read_count,
  1415. "yesterday_like_count": like_count,
  1416. "yesterday_comment_count": comment_count,
  1417. "yesterday_share_count": forward_count,
  1418. "yesterday_follow_count": follow_count,
  1419. "yesterday_completion_rate": comp_rate,
  1420. "yesterday_avg_watch_duration": avg_dur,
  1421. })
  1422. if updates:
  1423. try:
  1424. save_result = update_works_fn(updates)
  1425. result["works_updated"] = save_result.get("updated", 0)
  1426. except Exception as api_err:
  1427. import traceback
  1428. traceback.print_exc()
  1429. except Exception as e:
  1430. import traceback
  1431. traceback.print_exc()
  1432. print(f"[{self.platform_name}] 解析 post_list 更新 works 失败: {e}", flush=True)
  1433. # 辅助:点击单篇视频 + 近30天,恢复列表视图(go_back 后会回到全部视频页)
  1434. async def ensure_single_video_near30():
  1435. tab_sel = "div.weui-desktop-tab__navs ul li:nth-child(2) a"
  1436. try:
  1437. await self.page.wait_for_selector(tab_sel, timeout=8000)
  1438. await self.page.click(tab_sel)
  1439. except Exception:
  1440. await self.page.click("a:has-text('单篇视频')")
  1441. await asyncio.sleep(2)
  1442. for sel in [
  1443. "div.post-single-wrap div.weui-desktop-radio-group.radio-group label:has-text('近30天')",
  1444. "div.post-single-wrap label:has-text('近30天')",
  1445. "div.weui-desktop-radio-group label:has-text('近30天')",
  1446. "label:has-text('近30天')",
  1447. ]:
  1448. try:
  1449. el = self.page.locator(sel).first
  1450. if await el.count() > 0:
  1451. await el.click()
  1452. break
  1453. except Exception:
  1454. continue
  1455. await asyncio.sleep(3)
  1456. # 5. 遍历每一条,按 exportId 匹配作品
  1457. processed_export_ids = set()
  1458. for idx, item in enumerate(items):
  1459. eid = (item.get("exportId") or "").strip()
  1460. oid = (item.get("objectId") or "").strip()
  1461. if not oid:
  1462. continue
  1463. # 已处理过的跳过(理论上循环顺序即处理顺序,此处做双重保险)
  1464. if eid in processed_export_ids:
  1465. print(f"[{self.platform_name}] 跳过 [{idx+1}] exportId={eid} (已处理)", flush=True)
  1466. continue
  1467. # go_back 后回到全部视频页,需重新点击单篇视频+近30天
  1468. if idx > 0:
  1469. await ensure_single_video_near30()
  1470. # 匹配 work_id
  1471. work_id = export_id_to_work.get(eid)
  1472. if work_id is None:
  1473. for k, v in export_id_to_work.items():
  1474. if eid in k or k in eid:
  1475. work_id = v
  1476. break
  1477. if work_id is None:
  1478. result["total_skipped"] += 1
  1479. print(f"[{self.platform_name}] 跳过 [{idx+1}] exportId={eid} (库中无对应作品)", flush=True)
  1480. continue
  1481. # 点击「查看」:Ant Design 表格 tr[data-row-key] > td > div.slot-wrap > a.detail-wrap
  1482. # 操作列可能在 ant-table-fixed-right 内,优先尝试
  1483. view_selectors = [
  1484. f'div.ant-table-fixed-right tr[data-row-key="{oid}"] a.detail-wrap',
  1485. f'tr[data-row-key="{oid}"] a.detail-wrap',
  1486. f'tr[data-row-key="{oid}"] td a.detail-wrap',
  1487. f'tr[data-row-key="{oid}"] a:has-text("查看")',
  1488. f'tr[data-row-key="{oid}"] a',
  1489. ]
  1490. clicked = False
  1491. for sel in view_selectors:
  1492. view_btn = self.page.locator(sel)
  1493. if await view_btn.count() > 0:
  1494. try:
  1495. await view_btn.first.wait_for(timeout=3000)
  1496. await view_btn.first.click()
  1497. clicked = True
  1498. print(f"[{self.platform_name}] 已点击查看 (selector: {sel[:40]}...)", flush=True)
  1499. break
  1500. except Exception as e:
  1501. continue
  1502. if not clicked:
  1503. print(f"[{self.platform_name}] 未找到 objectId={oid} 的查看按钮", flush=True)
  1504. result["total_skipped"] += 1
  1505. continue
  1506. await asyncio.sleep(3)
  1507. # 详情页:默认展示近7天,页面加载时自动请求 feed_aggreagate,不清空 body 避免覆盖已监听到的响应
  1508. await asyncio.sleep(4)
  1509. # 从 feed_aggreagate 响应解析「全部」数据
  1510. # 数据结构: data.dataByFanstype[].dataByTabtype[] 中 tabTypeName="全部" 或 tabType=999
  1511. # 日期:从昨天往前推 N 天(含昨天),数组从最早到最晚排列
  1512. body = feed_aggreagate_data.get("body")
  1513. if not body or not body.get("data"):
  1514. print(f"[{self.platform_name}] work_id={work_id} 未监听到 feed_aggreagate 有效响应", flush=True)
  1515. await self.page.go_back()
  1516. await asyncio.sleep(2)
  1517. continue
  1518. tab_all = None
  1519. for fan_item in body.get("data", {}).get("dataByFanstype", []):
  1520. for tab_item in fan_item.get("dataByTabtype", []):
  1521. if tab_item.get("tabTypeName") == "全部" or tab_item.get("tabType") == 999:
  1522. tab_all = tab_item.get("data")
  1523. break
  1524. if tab_all is not None:
  1525. break
  1526. if not tab_all:
  1527. tab_all = body.get("data", {}).get("feedData", [{}])[0].get("totalData")
  1528. if not tab_all:
  1529. print(f"[{self.platform_name}] work_id={work_id} 未找到「全部」数据", flush=True)
  1530. await self.page.go_back()
  1531. await asyncio.sleep(2)
  1532. continue
  1533. browse = tab_all.get("browse", [])
  1534. n = len(browse)
  1535. if n == 0:
  1536. print(f"[{self.platform_name}] work_id={work_id} browse 为空", flush=True)
  1537. await self.page.go_back()
  1538. await asyncio.sleep(2)
  1539. continue
  1540. # 日期:昨天往前推 n 天,index 0 = 最早日
  1541. today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
  1542. yesterday = today - timedelta(days=1)
  1543. start_date = yesterday - timedelta(days=n - 1)
  1544. like_arr = tab_all.get("like", [])
  1545. comment_arr = tab_all.get("comment", [])
  1546. forward_arr = tab_all.get("forward", [])
  1547. fav_arr = tab_all.get("fav", [])
  1548. follow_arr = tab_all.get("follow", [])
  1549. stats_list = []
  1550. for i in range(n):
  1551. rec_dt = start_date + timedelta(days=i)
  1552. rec_date = rec_dt.strftime("%Y-%m-%d")
  1553. play = self._parse_count(browse[i] if i < len(browse) else "0")
  1554. like = self._parse_count(like_arr[i] if i < len(like_arr) else "0")
  1555. comment = self._parse_count(comment_arr[i] if i < len(comment_arr) else "0")
  1556. share = self._parse_count(forward_arr[i] if i < len(forward_arr) else "0")
  1557. follow = self._parse_count(follow_arr[i] if i < len(follow_arr) else "0")
  1558. # fav[i] 不入库,follow[i] 入 follow_count
  1559. stats_list.append({
  1560. "work_id": work_id,
  1561. "record_date": rec_date,
  1562. "play_count": play,
  1563. "like_count": like,
  1564. "comment_count": comment,
  1565. "share_count": share,
  1566. "collect_count": 0,
  1567. "follow_count": follow,
  1568. "completion_rate": "0",
  1569. "avg_watch_duration": "0",
  1570. })
  1571. print(f"[{self.platform_name}] work_id={work_id} 从 feed_aggreagate 解析得到 {len(stats_list)} 条日统计", flush=True)
  1572. # 存入 work_day_statistics(通过 save_fn 调用 Node)
  1573. if save_fn and stats_list:
  1574. try:
  1575. save_result = save_fn(stats_list)
  1576. result["inserted"] += save_result.get("inserted", 0)
  1577. result["updated"] += save_result.get("updated", 0)
  1578. except Exception as e:
  1579. print(f"[{self.platform_name}] work_id={work_id} 保存失败: {e}", flush=True)
  1580. result["total_processed"] += 1
  1581. processed_export_ids.add(eid)
  1582. # 返回列表页,继续下一条(会回到全部视频页,下次循环会重新点击单篇视频+近30天)
  1583. await self.page.go_back()
  1584. await asyncio.sleep(2)
  1585. print(f"[{self.platform_name}] 批量同步完成: 处理 {result['total_processed']} 个作品, 跳过 {result['total_skipped']} 个", flush=True)
  1586. except Exception as e:
  1587. import traceback
  1588. traceback.print_exc()
  1589. result["success"] = False
  1590. result["error"] = str(e)
  1591. finally:
  1592. try:
  1593. await self.close_browser()
  1594. except Exception:
  1595. pass
  1596. return result
  1597. async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
  1598. """
  1599. 获取视频号作品评论(完全参考 get_weixin_work_comments.py 的接口监听逻辑)
  1600. 支持递归提取二级评论,正确处理 parent_comment_id
  1601. """
  1602. print(f"\n{'='*60}")
  1603. print(f"[{self.platform_name}] 获取作品评论")
  1604. print(f"[{self.platform_name}] work_id={work_id}")
  1605. print(f"{'='*60}")
  1606. comments: List[CommentItem] = []
  1607. total = 0
  1608. has_more = False
  1609. try:
  1610. await self.init_browser()
  1611. cookie_list = self.parse_cookies(cookies)
  1612. await self.set_cookies(cookie_list)
  1613. if not self.page:
  1614. raise Exception("Page not initialized")
  1615. # 访问评论管理页面
  1616. print(f"[{self.platform_name}] 正在打开评论页面...")
  1617. await self.page.goto("https://channels.weixin.qq.com/platform/interaction/comment", timeout=30000)
  1618. await asyncio.sleep(2)
  1619. # 检查登录状态
  1620. current_url = self.page.url
  1621. if "login" in current_url:
  1622. raise Exception("Cookie 已过期,请重新登录")
  1623. # === 步骤1: 监听 post_list 接口获取作品列表 ===
  1624. posts = []
  1625. try:
  1626. async with self.page.expect_response(
  1627. lambda res: "/post/post_list" in res.url,
  1628. timeout=20000
  1629. ) as post_resp_info:
  1630. await self.page.wait_for_selector('.scroll-list .comment-feed-wrap', timeout=15000)
  1631. post_resp = await post_resp_info.value
  1632. post_data = await post_resp.json()
  1633. if post_data.get("errCode") == 0:
  1634. posts = post_data.get("data", {}).get("list", [])
  1635. print(f"[{self.platform_name}] ✅ 获取 {len(posts)} 个作品")
  1636. else:
  1637. err_msg = post_data.get("errMsg", "未知错误")
  1638. print(f"[{self.platform_name}] ❌ post_list 业务错误: {err_msg}")
  1639. return CommentsResult(
  1640. success=False,
  1641. platform=self.platform_name,
  1642. work_id=work_id,
  1643. error=f"post_list 业务错误: {err_msg}"
  1644. )
  1645. except Exception as e:
  1646. print(f"[{self.platform_name}] ❌ 获取 post_list 失败: {e}")
  1647. return CommentsResult(
  1648. success=False,
  1649. platform=self.platform_name,
  1650. work_id=work_id,
  1651. error=f"获取 post_list 失败: {e}"
  1652. )
  1653. # === 步骤2: 在 DOM 中查找目标作品 ===
  1654. feed_wraps = await self.page.query_selector_all('.scroll-list .comment-feed-wrap')
  1655. target_feed = None
  1656. target_post = None
  1657. target_index = -1
  1658. for i, feed in enumerate(feed_wraps):
  1659. if i >= len(posts):
  1660. break
  1661. post = posts[i]
  1662. object_nonce = post.get("objectNonce", "")
  1663. post_work_id = post.get("objectId", "") or object_nonce
  1664. # 匹配 work_id(支持 objectId 或 objectNonce 匹配)
  1665. if work_id in [post_work_id, object_nonce] or post_work_id in work_id or object_nonce in work_id:
  1666. target_feed = feed
  1667. target_post = post
  1668. target_index = i
  1669. work_title = post.get("desc", {}).get("description", "无标题")
  1670. print(f"[{self.platform_name}] ✅ 找到目标作品: {work_title}")
  1671. continue
  1672. if not target_feed or not target_post:
  1673. print(f"[{self.platform_name}] ❌ 未找到 work_id={work_id} 对应的作品")
  1674. return CommentsResult(
  1675. success=True,
  1676. platform=self.platform_name,
  1677. work_id=work_id,
  1678. comments=[],
  1679. total=0,
  1680. has_more=False
  1681. )
  1682. # 准备作品信息(用于递归函数)
  1683. object_nonce = target_post.get("objectNonce", f"nonce_{target_index}")
  1684. work_title = target_post.get("desc", {}).get("description", f"作品{target_index+1}")
  1685. work_info = {
  1686. "work_id": object_nonce,
  1687. "work_title": work_title
  1688. }
  1689. # === 步骤3: 点击作品触发 comment_list 接口 ===
  1690. content_wrap = await target_feed.query_selector('.feed-content') or target_feed
  1691. try:
  1692. async with self.page.expect_response(
  1693. lambda res: "/comment/comment_list" in res.url,
  1694. timeout=15000
  1695. ) as comment_resp_info:
  1696. await content_wrap.click()
  1697. await asyncio.sleep(0.8)
  1698. comment_resp = await comment_resp_info.value
  1699. comment_data = await comment_resp.json()
  1700. if comment_data.get("errCode") != 0:
  1701. err_msg = comment_data.get("errMsg", "未知错误")
  1702. print(f"[{self.platform_name}] ❌ 评论接口错误: {err_msg}")
  1703. return CommentsResult(
  1704. success=False,
  1705. platform=self.platform_name,
  1706. work_id=work_id,
  1707. error=f"评论接口错误: {err_msg}"
  1708. )
  1709. raw_comments = comment_data.get("data", {}).get("comment", [])
  1710. total = comment_data.get("data", {}).get("totalCount", len(raw_comments))
  1711. print(f"[{self.platform_name}] 📊 原始评论数: {len(raw_comments)}, 总数: {total}")
  1712. # === 步骤4: 递归提取所有评论(含子评论)===
  1713. extracted = self._extract_comments(raw_comments, parent_id="", work_info=work_info)
  1714. # === 步骤5: 转换为 CommentItem 列表(保留 weixin.py 的数据结构)===
  1715. for c in extracted:
  1716. # 使用接口返回的 comment_id
  1717. comment_id = c.get("comment_id", "")
  1718. parent_comment_id = c.get("parent_comment_id", "")
  1719. # 构建 CommentItem(保留原有数据结构用于数据库入库)
  1720. comment_item = CommentItem(
  1721. comment_id=comment_id,
  1722. parent_comment_id=parent_comment_id,
  1723. work_id=work_id,
  1724. content=c.get("content", ""),
  1725. author_id=c.get("username", ""), # 使用 username 作为 author_id
  1726. author_name=c.get("nickname", ""),
  1727. author_avatar=c.get("avatar", ""),
  1728. like_count=c.get("like_count", 0),
  1729. reply_count=0,
  1730. create_time=c.get("create_time", ""),
  1731. )
  1732. # 添加扩展字段(用于数据库存储和后续处理)
  1733. # comment_item.parent_comment_id = c.get("parent_comment_id", "")
  1734. comment_item.is_author = c.get("is_author", False)
  1735. comment_item.create_time_unix = c.get("create_time_unix", 0)
  1736. comment_item.work_title = c.get("work_title", "")
  1737. print(comment_item)
  1738. comments.append(comment_item)
  1739. # 打印日志
  1740. author_tag = " 👤(作者)" if c.get("is_author") else ""
  1741. parent_tag = f" [回复: {c.get('parent_comment_id', '')}]" if c.get("parent_comment_id") else ""
  1742. print(f"[{self.platform_name}] - [{c.get('nickname', '')}] {c.get('content', '')[:30]}... "
  1743. f"({c.get('create_time', '')}){author_tag}{parent_tag}")
  1744. # 判断是否还有更多(优先使用接口返回的 continueFlag,否则根据数量判断)
  1745. has_more = comment_data.get("data", {}).get("continueFlag", False) or len(extracted) < total
  1746. print(f"[{self.platform_name}] ✅ 共提取 {len(comments)} 条评论(含子评论)")
  1747. except Exception as e:
  1748. print(f"[{self.platform_name}] ❌ 获取评论失败: {e}")
  1749. import traceback
  1750. traceback.print_exc()
  1751. return CommentsResult(
  1752. success=False,
  1753. platform=self.platform_name,
  1754. work_id=work_id,
  1755. error=f"获取评论失败: {e}"
  1756. )
  1757. except Exception as e:
  1758. import traceback
  1759. traceback.print_exc()
  1760. return CommentsResult(
  1761. success=False,
  1762. platform=self.platform_name,
  1763. work_id=work_id,
  1764. error=str(e)
  1765. )
  1766. return CommentsResult(
  1767. success=True,
  1768. platform=self.platform_name,
  1769. work_id=work_id,
  1770. comments=comments,
  1771. total=total,
  1772. has_more=has_more
  1773. )
  1774. def _extract_comments(self, comment_list: list, parent_id: str = "", work_info: dict = None) -> list:
  1775. """
  1776. 递归提取一级和二级评论(完全参考 get_weixin_work_comments.py 的 extract_comments 函数)
  1777. Args:
  1778. comment_list: 评论列表(原始接口数据)
  1779. parent_id: 父评论ID(一级评论为空字符串"",二级评论为父级评论ID)
  1780. work_info: 作品信息字典
  1781. Returns:
  1782. list: 扁平化的评论列表,包含一级和二级评论
  1783. """
  1784. result = []
  1785. # 获取当前用户 username(用于判断是否为作者)
  1786. # 优先从环境变量获取,也可通过其他方式配置
  1787. my_username = getattr(self, 'my_username', '') or os.environ.get('WEIXIN_MY_USERNAME', '')
  1788. for cmt in comment_list:
  1789. # 处理时间戳
  1790. create_ts = int(cmt.get("commentCreatetime", 0) or 0)
  1791. readable_time = (
  1792. datetime.fromtimestamp(create_ts).strftime('%Y-%m-%d %H:%M:%S')
  1793. if create_ts > 0 else ""
  1794. )
  1795. # 判断是否作者(如果配置了 my_username)
  1796. username = cmt.get("username", "") or ""
  1797. is_author = (my_username != "") and (username == my_username)
  1798. # 构建评论条目 - 完全参考 get_weixin_work_comments.py 的字段
  1799. entry = {
  1800. "work_id": work_info.get("work_id", "") if work_info else "",
  1801. "work_title": work_info.get("work_title", "") if work_info else "",
  1802. "comment_id": cmt.get("commentId"),
  1803. "parent_comment_id": parent_id, # 关键:一级评论为空字符串"",二级评论为父评论ID
  1804. "username": username,
  1805. "nickname": cmt.get("commentNickname", ""),
  1806. "avatar": cmt.get("commentHeadurl", ""),
  1807. "content": cmt.get("commentContent", ""),
  1808. "create_time_unix": create_ts,
  1809. "create_time": readable_time,
  1810. "is_author": is_author,
  1811. "like_count": cmt.get("commentLikeCount", 0) or 0
  1812. }
  1813. result.append(entry)
  1814. # 递归处理二级评论(levelTwoComment)
  1815. # 关键:二级评论的 parent_id 应该是当前这条评论的 comment_id
  1816. level_two = cmt.get("levelTwoComment", []) or []
  1817. if level_two and isinstance(level_two, list) and len(level_two) > 0:
  1818. # 当前评论的 ID 作为其子评论的 parent_id
  1819. current_comment_id = cmt.get("commentId", "")
  1820. result.extend(
  1821. self._extract_comments(level_two, parent_id=current_comment_id, work_info=work_info)
  1822. )
  1823. return result
  1824. async def auto_reply_private_messages(self, cookies: str) -> dict:
  1825. """自动回复私信 - 集成自 pw3.py"""
  1826. print(f"\n{'='*60}")
  1827. print(f"[{self.platform_name}] 开始自动回复私信")
  1828. print(f"{'='*60}")
  1829. try:
  1830. await self.init_browser()
  1831. cookie_list = self.parse_cookies(cookies)
  1832. await self.set_cookies(cookie_list)
  1833. if not self.page:
  1834. raise Exception("Page not initialized")
  1835. # 访问私信页面
  1836. await self.page.goto("https://channels.weixin.qq.com/platform/private_msg", timeout=30000)
  1837. await asyncio.sleep(3)
  1838. # 检查登录状态
  1839. current_url = self.page.url
  1840. print(f"[{self.platform_name}] 当前 URL: {current_url}")
  1841. if "login" in current_url:
  1842. raise Exception("Cookie 已过期,请重新登录")
  1843. # 等待私信页面加载(使用多个选择器容错)
  1844. try:
  1845. await self.page.wait_for_selector('.private-msg-list-header', timeout=15000)
  1846. except:
  1847. # 尝试其他选择器
  1848. try:
  1849. await self.page.wait_for_selector('.weui-desktop-tab__navs__inner', timeout=10000)
  1850. print(f"[{self.platform_name}] 使用备用选择器加载成功")
  1851. except:
  1852. # 截图调试
  1853. screenshot_path = f"weixin_private_msg_{int(asyncio.get_event_loop().time())}.png"
  1854. await self.page.screenshot(path=screenshot_path)
  1855. print(f"[{self.platform_name}] 页面加载失败,截图: {screenshot_path}")
  1856. raise Exception(f"私信页面加载超时,当前 URL: {current_url}")
  1857. print(f"[{self.platform_name}] 私信页面加载完成")
  1858. # 处理两个 tab
  1859. total_replied = 0
  1860. for tab_name in ["打招呼消息", "私信"]:
  1861. replied_count = await self._process_tab_sessions(tab_name)
  1862. total_replied += replied_count
  1863. print(f"[{self.platform_name}] 自动回复完成,共回复 {total_replied} 条消息")
  1864. return {
  1865. 'success': True,
  1866. 'platform': self.platform_name,
  1867. 'replied_count': total_replied,
  1868. 'message': f'成功回复 {total_replied} 条私信'
  1869. }
  1870. except Exception as e:
  1871. import traceback
  1872. traceback.print_exc()
  1873. return {
  1874. 'success': False,
  1875. 'platform': self.platform_name,
  1876. 'error': str(e)
  1877. }
  1878. async def _process_tab_sessions(self, tab_name: str) -> int:
  1879. """处理指定 tab 下的所有会话"""
  1880. print(f"\n🔄 正在处理「{tab_name}」中的所有会话...")
  1881. if not self.page:
  1882. return 0
  1883. replied_count = 0
  1884. try:
  1885. # 点击 tab
  1886. if tab_name == "私信":
  1887. tab_link = self.page.locator('.weui-desktop-tab__navs__inner li').first.locator('a')
  1888. elif tab_name == "打招呼消息":
  1889. tab_link = self.page.locator('.weui-desktop-tab__navs__inner li').nth(1).locator('a')
  1890. else:
  1891. return 0
  1892. if await tab_link.is_visible():
  1893. await tab_link.click()
  1894. print(f" ➤ 已点击「{tab_name}」tab")
  1895. else:
  1896. print(f" ❌ 「{tab_name}」tab 不可见")
  1897. return 0
  1898. # 等待会话列表加载
  1899. try:
  1900. await self.page.wait_for_function("""
  1901. () => {
  1902. const hasSession = document.querySelectorAll('.session-wrap').length > 0;
  1903. const hasEmpty = !!document.querySelector('.empty-text');
  1904. return hasSession || hasEmpty;
  1905. }
  1906. """, timeout=8000)
  1907. print(" ✅ 会话列表区域已加载")
  1908. except:
  1909. print(" ⚠️ 等待会话列表超时,继续尝试读取...")
  1910. # 获取会话
  1911. session_wraps = self.page.locator('.session-wrap')
  1912. session_count = await session_wraps.count()
  1913. print(f" 💬 共找到 {session_count} 个会话")
  1914. if session_count == 0:
  1915. return 0
  1916. # 遍历每个会话
  1917. for idx in range(session_count):
  1918. try:
  1919. current_sessions = self.page.locator('.session-wrap')
  1920. if idx >= await current_sessions.count():
  1921. break
  1922. session = current_sessions.nth(idx)
  1923. user_name = await session.locator('.name').inner_text()
  1924. last_preview = await session.locator('.feed-info').inner_text()
  1925. print(f"\n ➤ [{idx+1}/{session_count}] 正在处理: {user_name} | 最后消息: {last_preview}")
  1926. await session.click()
  1927. await asyncio.sleep(2)
  1928. # 提取聊天历史
  1929. history = await self._extract_chat_history()
  1930. need_reply = (not history) or (not history[-1]["is_author"])
  1931. if need_reply:
  1932. reply_text = await self._generate_reply_with_ai(history)
  1933. if reply_text=="":
  1934. reply_text = self._generate_reply(history)
  1935. # # 生成回复
  1936. # if history and history[-1]["is_author"]:
  1937. # reply_text = await self._generate_reply_with_ai(history)
  1938. # else:
  1939. # reply_text = self._generate_reply(history)
  1940. if reply_text:
  1941. print(f" 📝 回复内容: {reply_text}")
  1942. try:
  1943. textarea = self.page.locator('.edit_area').first
  1944. send_btn = self.page.locator('button:has-text("发送")').first
  1945. if await textarea.is_visible() and await send_btn.is_visible():
  1946. await textarea.fill(reply_text)
  1947. await asyncio.sleep(0.5)
  1948. await send_btn.click()
  1949. print(" ✅ 已发送")
  1950. replied_count += 1
  1951. await asyncio.sleep(1.5)
  1952. else:
  1953. print(" ❌ 输入框或发送按钮不可见")
  1954. except Exception as e:
  1955. print(f" ❌ 发送失败: {e}")
  1956. else:
  1957. print(" ➤ 无需回复")
  1958. else:
  1959. print(" ➤ 最后一条是我发的,跳过回复")
  1960. except Exception as e:
  1961. print(f" ❌ 处理会话 {idx+1} 时出错: {e}")
  1962. continue
  1963. except Exception as e:
  1964. print(f"❌ 处理「{tab_name}」失败: {e}")
  1965. return replied_count
  1966. async def _extract_chat_history(self) -> list:
  1967. """精准提取聊天记录,区分作者(自己)和用户"""
  1968. if not self.page:
  1969. return []
  1970. history = []
  1971. message_wrappers = self.page.locator('.session-content-wrapper > div:not(.footer) > .text-wrapper')
  1972. count = await message_wrappers.count()
  1973. for i in range(count):
  1974. try:
  1975. wrapper = message_wrappers.nth(i)
  1976. # 判断方向
  1977. is_right = await wrapper.locator('.content-right').count() > 0
  1978. is_left = await wrapper.locator('.content-left').count() > 0
  1979. if not (is_left or is_right):
  1980. continue
  1981. # 提取消息文本
  1982. pre_el = wrapper.locator('pre.message-plain')
  1983. content = ''
  1984. if await pre_el.count() > 0:
  1985. content = await pre_el.inner_text()
  1986. content = content.strip()
  1987. if not content:
  1988. continue
  1989. # 获取头像
  1990. avatar_img = wrapper.locator('.avatar').first
  1991. avatar_src = ''
  1992. if await avatar_img.count() > 0:
  1993. avatar_src = await avatar_img.get_attribute("src") or ''
  1994. # 右侧 = 作者(自己)
  1995. is_author = is_right
  1996. # 获取用户名
  1997. if is_left:
  1998. name_el = wrapper.locator('.profile .name')
  1999. author_name = '用户'
  2000. if await name_el.count() > 0:
  2001. author_name = await name_el.inner_text()
  2002. else:
  2003. author_name = "我"
  2004. history.append({
  2005. "author": author_name,
  2006. "content": content,
  2007. "is_author": is_author,
  2008. "avatar": avatar_src
  2009. })
  2010. except Exception as e:
  2011. print(f" ⚠️ 解析第 {i+1} 条消息失败: {e}")
  2012. continue
  2013. return history
  2014. async def _generate_reply_with_ai(self, chat_history: list) -> str:
  2015. """使用 AI 生成智能回复"""
  2016. import requests
  2017. import json
  2018. try:
  2019. # 获取 AI 配置
  2020. ai_api_key = os.environ.get('DASHSCOPE_API_KEY', '')
  2021. ai_base_url = os.environ.get('DASHSCOPE_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1')
  2022. ai_model = os.environ.get('AI_MODEL', 'qwen-plus')
  2023. if not ai_api_key:
  2024. print("⚠️ 未配置 AI API Key,使用规则回复")
  2025. return self._generate_reply(chat_history)
  2026. # 构建对话上下文
  2027. messages = [{"role": "system", "content": "你是一个友好的微信视频号创作者助手,负责回复粉丝私信。请保持简洁、友好、专业的语气。回复长度不超过20字。"}]
  2028. for msg in chat_history:
  2029. role = "assistant" if msg["is_author"] else "user"
  2030. messages.append({
  2031. "role": role,
  2032. "content": msg["content"]
  2033. })
  2034. # 调用 AI API
  2035. headers = {
  2036. 'Authorization': f'Bearer {ai_api_key}',
  2037. 'Content-Type': 'application/json'
  2038. }
  2039. payload = {
  2040. "model": ai_model,
  2041. "messages": messages,
  2042. "max_tokens": 150,
  2043. "temperature": 0.8
  2044. }
  2045. print(" 🤖 正在调用 AI 生成回复...")
  2046. response = requests.post(
  2047. f"{ai_base_url}/chat/completions",
  2048. headers=headers,
  2049. json=payload,
  2050. timeout=30
  2051. )
  2052. if response.status_code != 200:
  2053. print(f" ⚠️ AI API 返回错误 {response.status_code},使用规则回复")
  2054. return self._generate_reply(chat_history)
  2055. result = response.json()
  2056. ai_reply = result.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
  2057. if ai_reply:
  2058. print(f" ✅ AI 生成回复: {ai_reply}")
  2059. return ai_reply
  2060. else:
  2061. print(" ⚠️ AI 返回空内容,使用规则回复")
  2062. return self._generate_reply(chat_history)
  2063. except Exception as e:
  2064. print(f" ⚠️ AI 回复生成失败: {e},使用规则回复")
  2065. return self._generate_reply(chat_history)
  2066. def _generate_reply(self, chat_history: list) -> str:
  2067. """根据完整聊天历史生成回复(规则回复方式)"""
  2068. if not chat_history:
  2069. return "你好!感谢联系~"
  2070. # 检查最后一条是否是作者发的
  2071. if chat_history[-1]["is_author"]:
  2072. return "" # 不回复
  2073. # 找最后一条用户消息
  2074. last_user_msg = chat_history[-1]["content"]
  2075. # 简单规则回复
  2076. if "谢谢" in last_user_msg or "感谢" in last_user_msg:
  2077. return "不客气!欢迎常来交流~"
  2078. elif "你好" in last_user_msg or "在吗" in last_user_msg:
  2079. return "你好!请问有什么可以帮您的?"
  2080. elif "视频" in last_user_msg or "怎么拍" in last_user_msg:
  2081. return "视频是用手机拍摄的,注意光线和稳定哦!"
  2082. else:
  2083. return "收到!我会认真阅读您的留言~"