weixin.py 108 KB

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