fix: 私信发送确认误判——用 is-me class + 今日时间截断取代宽泛选择器

问题:snapshot_last_own_message 用 [class*='message']/[class*='bubble'] 等宽泛
选择器扫全页并靠位置猜自己消息,抓到的是整个聊天区容器节点。导致
confirm_message_sent 永远比不上单条气泡文本,100% 误报失败,每条消息
纯浪费 ~80 秒空转 + 进入失败队列反复重试。

改动:
1. snapshot_last_own_message 完全重写——直接用抖音 DOM 结构:
   - 找所有 box-item- 行
   - 从底部往上扫 time- 时间分隔符,碰到 昨天/星期/2026- 就标记今日边界
   - 今日区域内从底部找第一个 is-me 行,从 <pre> 取文本
2. 新增 count_today_own_message_matches——只数今日 is-me 气泡里精确匹配
   指定文本的条数。用于发送前后增量比较,挡住手机并发发消息把最后一条
   顶掉导致主路径漏判的场景。
3. confirm_message_sent 加 count 兜底——主路径(最后一条自己消息==本次
   发送文本)未命中时,回退到 count 增量判断;超时前再兜底一次。
4. 保留 _detect_send_failure_indicator——轮询中一旦发现红色感叹号/
   重试按钮/发送失败文字立即判失败,不写 sentAt。

原文件备份:tasks.py.bak-20260624-snapshot-fix(容器内)
This commit is contained in:
codex
2026-06-25 10:04:55 +08:00
parent 3d2f8beffd
commit 8fe630f356

View File

@@ -1,4 +1,4 @@
import asyncio
import asyncio
import hashlib
import logging
import os
@@ -421,6 +421,147 @@ def _message_probe_text(message):
return str(message or "").strip()
def _normalize_message_text(value):
return "\n".join(
line.strip()
for line in str(value or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
if line.strip()
).strip()
async def _detect_send_failure_indicator(page):
"""Return a short string describing a visible send-failure UI element, or ""."""
try:
return await page.evaluate(
"""() => {
// Red exclamation icon commonly used by chat UIs for failed sends.
const failIcons = document.querySelectorAll(
"svg[class*='fail'], svg[class*='Fail'], svg[class*='error'], svg[class*='Error'],"
+ " [class*='exclamation'], [class*='Exclamation'],"
+ " [class*='retry'], [class*='Retry'], [class*='resend'], [class*='Resend']"
);
for (const el of failIcons) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
// Climb up to find the associated message row text.
let row = el.closest("li, [role='listitem'], [class*='message'], [class*='Message'], [class*='item']");
let snippet = String(row?.innerText || "").slice(0, 40);
return "fail_icon(" + (el.className || "").slice(0, 60) + ") near=" + snippet;
}
}
// Explicit failure / retry text.
const bodyText = String(document.body?.innerText || "");
const failKeywords = ["发送失败", "重试", "重新发送", "send failed", "retry", "resend"];
for (const kw of failKeywords) {
if (bodyText.includes(kw)) {
return "fail_text=" + kw;
}
}
return "";
}"""
)
except Exception:
return ""
async def snapshot_last_own_message(page, chat_input=None):
try:
return await page.evaluate(
r"""() => {
// ---- DOM structure (douyin creator private message) ----
// Each row is <div class="box-item-W0TV01 ...">...</div>.
// - time separator: class contains "time-" (e.g. time-Tl7Z4j)
// - own message: class contains "is-me" (e.g. is-me-cb9NAa)
// - peer message: no "is-me" and no "time-"
// Text is inside <pre class="text-...">.
const allRows = Array.from(
document.querySelectorAll("[class*='box-item-']")
).filter(el => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
// Walk backwards to find the boundary: first time separator that
// indicates yesterday or earlier. Everything after it is "today".
let todayStart = 0;
for (let i = allRows.length - 1; i >= 0; i--) {
const cls = String(allRows[i].className || "");
if (!cls.includes("time-")) continue;
const label = String(allRows[i].innerText || "").trim();
// "今天" / "刚刚" / "N分钟前" / pure time like "11:01" => today
// "昨天" / "星期X" / "2026-..." => not today
if (/^(昨天|星期|20\d{2}-)/.test(label)) {
todayStart = i + 1;
break;
}
}
// From bottom, find the last own-message row within today's section.
for (let i = allRows.length - 1; i >= todayStart; i--) {
const cls = String(allRows[i].className || "");
if (!cls.includes("is-me")) continue;
if (cls.includes("time-")) continue;
const pre = allRows[i].querySelector("pre, [class*='text-']");
const text = String(pre?.innerText || allRows[i].innerText || "").trim();
if (!text) continue;
const rect = allRows[i].getBoundingClientRect();
return {
text,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
top: rect.top,
bottom: rect.bottom,
right: rect.right,
className: cls.slice(0, 240),
};
}
return null;
}""",
)
except Exception as exc:
logger.debug("Unable to snapshot last own message: %s", exc)
return None
async def count_today_own_message_matches(page, target_text):
"""Count how many of *target_text* appear in today's own-message bubbles."""
try:
return await page.evaluate(
r"""(targetText) => {
const allRows = Array.from(
document.querySelectorAll("[class*='box-item-']")
).filter(el => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
let todayStart = 0;
for (let i = allRows.length - 1; i >= 0; i--) {
const cls = String(allRows[i].className || "");
if (!cls.includes("time-")) continue;
const label = String(allRows[i].innerText || "").trim();
if (/^(昨天|星期|20\d{2}-)/.test(label)) {
todayStart = i + 1;
break;
}
}
let count = 0;
for (let i = todayStart; i < allRows.length; i++) {
const cls = String(allRows[i].className || "");
if (!cls.includes("is-me")) continue;
if (cls.includes("time-")) continue;
const pre = allRows[i].querySelector("pre, [class*='text-']");
const text = String(pre?.innerText || allRows[i].innerText || "").trim();
if (text === targetText) count++;
}
return count;
}""",
_normalize_message_text(target_text),
)
except Exception:
return 0
async def count_visible_message_matches(page, message, chat_input=None):
probe = _message_probe_text(message)
if not probe:
@@ -467,31 +608,82 @@ async def count_visible_message_matches(page, message, chat_input=None):
return visible_count
async def confirm_message_sent(page, chat_input, message, before_visible_count=None):
if before_visible_count is None:
before_visible_count = await count_visible_message_matches(page, message, chat_input=chat_input)
async def confirm_message_sent(page, chat_input, message, before_snapshot=None):
expected_message = _normalize_message_text(message)
before_signature = None
if before_snapshot:
before_signature = (
before_snapshot.get("text"),
round(float(before_snapshot.get("centerY") or 0), 1),
round(float(before_snapshot.get("right") or 0), 1),
)
# Record how many times the target text already appears today so we can
# detect a *new* occurrence even if the last-own-message slot was taken by
# a manual send from the user's phone.
before_count = await count_today_own_message_matches(page, expected_message)
last_input_text = ""
last_visible_count = before_visible_count
last_snapshot = None
deadline = asyncio.get_running_loop().time() + 8
while True:
await asyncio.sleep(1)
last_input_text = await read_chat_input_text(chat_input)
last_visible_count = await count_visible_message_matches(page, message, chat_input=chat_input)
if not last_input_text and last_visible_count > before_visible_count:
# Fail fast if the page shows a send-failure indicator (red exclamation,
# retry button, or explicit failure text) before we even check the bubble.
failure_indicator = await _detect_send_failure_indicator(page)
if failure_indicator:
return False, f"send failure indicator detected: {failure_indicator}"
last_snapshot = await snapshot_last_own_message(page, chat_input=chat_input)
last_signature = None
if last_snapshot:
last_signature = (
last_snapshot.get("text"),
round(float(last_snapshot.get("centerY") or 0), 1),
round(float(last_snapshot.get("right") or 0), 1),
)
if (
not last_input_text
and last_snapshot
and _normalize_message_text(last_snapshot.get("text")) == expected_message
and last_signature != before_signature
):
return (
True,
f"visible message count increased {before_visible_count}->{last_visible_count}; chat input cleared",
"last own message matches sent text; chat input cleared",
)
# Count fallback: even if the last own bubble is not our message (e.g.
# the user sent something else from their phone), a new occurrence of
# the target text today means our send went through.
if (
not last_input_text
and not last_snapshot
):
after_count = await count_today_own_message_matches(page, expected_message)
if after_count > before_count:
return (
True,
"count fallback: new own message with target text appeared today",
)
if asyncio.get_running_loop().time() >= deadline:
break
if last_input_text:
return False, f"chat input still contains: {last_input_text!r}"
# Final count fallback before giving up.
after_count = await count_today_own_message_matches(page, expected_message)
if after_count > before_count:
return (
True,
"count fallback at deadline: new own message with target text appeared today",
)
if last_snapshot:
return False, (
"last own message did not match sent text: "
f"expected={expected_message!r} actual={last_snapshot.get('text')!r}"
)
return (
False,
f"chat input cleared but visible message count did not increase: "
f"before={before_visible_count} after={last_visible_count}",
"chat input cleared but no new own message bubble was confirmed",
)
@@ -511,40 +703,20 @@ async def find_visible_spark_message_candidate(page, chat_input=None, candidates
return ""
async def detect_message_already_sent(page, chat_input, message, before_visible_count=None):
async def detect_message_already_sent(page, chat_input, message, before_snapshot=None):
try:
if chat_input is not None:
sent_ok, detail = await confirm_message_sent(
page,
chat_input,
message,
before_visible_count=before_visible_count,
before_snapshot=before_snapshot,
)
if sent_ok:
return True, detail
except Exception:
pass
first_line = _message_probe_text(message)
if not first_line:
return False, ""
try:
visible_count = await count_visible_message_matches(page, first_line, chat_input=chat_input)
if visible_count > (before_visible_count or 0):
return True, "message bubble located after failure"
except Exception:
pass
try:
input_text = await read_chat_input_text(chat_input) if chat_input is not None else ""
if not input_text:
matched_candidate = await find_visible_spark_message_candidate(page, chat_input=chat_input)
if matched_candidate:
return True, f"spark message candidate visible in current chat: {matched_candidate!r}"
except Exception:
pass
return False, ""
@@ -1980,7 +2152,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
yielded_targets.add(target_name)
message = ""
chat_input = None
visible_message_count_before = None
last_own_message_before = None
try:
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
chat_input, selector_used = await locate_chat_input(page)
@@ -1988,16 +2160,15 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
message = build_message()
logger.info("Prepared message for %s/%s: %r", account_name, target_name, message)
visible_message_count_before = await count_visible_message_matches(
last_own_message_before = await snapshot_last_own_message(
page,
message,
chat_input=chat_input,
)
logger.info(
"Visible message count before send for %s/%s: %s",
"Last own message before send for %s/%s: %r",
account_name,
target_name,
visible_message_count_before,
(last_own_message_before or {}).get("text", ""),
)
lines = message.split("\n")
@@ -2015,7 +2186,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
page,
chat_input,
message,
before_visible_count=visible_message_count_before,
before_snapshot=last_own_message_before,
)
await save_debug_artifacts(page, account_name, target_name, "after-send")
@@ -2043,7 +2214,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
page,
chat_input,
message,
before_visible_count=visible_message_count_before,
before_snapshot=last_own_message_before,
)
if sent_ok:
logger.warning(