mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-05 23:49:50 +08:00
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:
+210
-39
@@ -1,4 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -421,6 +421,147 @@ def _message_probe_text(message):
|
|||||||
return str(message or "").strip()
|
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):
|
async def count_visible_message_matches(page, message, chat_input=None):
|
||||||
probe = _message_probe_text(message)
|
probe = _message_probe_text(message)
|
||||||
if not probe:
|
if not probe:
|
||||||
@@ -467,31 +608,82 @@ async def count_visible_message_matches(page, message, chat_input=None):
|
|||||||
return visible_count
|
return visible_count
|
||||||
|
|
||||||
|
|
||||||
async def confirm_message_sent(page, chat_input, message, before_visible_count=None):
|
async def confirm_message_sent(page, chat_input, message, before_snapshot=None):
|
||||||
if before_visible_count is None:
|
expected_message = _normalize_message_text(message)
|
||||||
before_visible_count = await count_visible_message_matches(page, message, chat_input=chat_input)
|
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_input_text = ""
|
||||||
last_visible_count = before_visible_count
|
last_snapshot = None
|
||||||
deadline = asyncio.get_running_loop().time() + 8
|
deadline = asyncio.get_running_loop().time() + 8
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
last_input_text = await read_chat_input_text(chat_input)
|
last_input_text = await read_chat_input_text(chat_input)
|
||||||
last_visible_count = await count_visible_message_matches(page, message, chat_input=chat_input)
|
# Fail fast if the page shows a send-failure indicator (red exclamation,
|
||||||
if not last_input_text and last_visible_count > before_visible_count:
|
# 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 (
|
return (
|
||||||
True,
|
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:
|
if asyncio.get_running_loop().time() >= deadline:
|
||||||
break
|
break
|
||||||
|
|
||||||
if last_input_text:
|
if last_input_text:
|
||||||
return False, f"chat input still contains: {last_input_text!r}"
|
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 (
|
return (
|
||||||
False,
|
False,
|
||||||
f"chat input cleared but visible message count did not increase: "
|
"chat input cleared but no new own message bubble was confirmed",
|
||||||
f"before={before_visible_count} after={last_visible_count}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -511,40 +703,20 @@ async def find_visible_spark_message_candidate(page, chat_input=None, candidates
|
|||||||
return ""
|
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:
|
try:
|
||||||
if chat_input is not None:
|
if chat_input is not None:
|
||||||
sent_ok, detail = await confirm_message_sent(
|
sent_ok, detail = await confirm_message_sent(
|
||||||
page,
|
page,
|
||||||
chat_input,
|
chat_input,
|
||||||
message,
|
message,
|
||||||
before_visible_count=before_visible_count,
|
before_snapshot=before_snapshot,
|
||||||
)
|
)
|
||||||
if sent_ok:
|
if sent_ok:
|
||||||
return True, detail
|
return True, detail
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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, ""
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
@@ -1980,7 +2152,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
|||||||
yielded_targets.add(target_name)
|
yielded_targets.add(target_name)
|
||||||
message = ""
|
message = ""
|
||||||
chat_input = None
|
chat_input = None
|
||||||
visible_message_count_before = None
|
last_own_message_before = None
|
||||||
try:
|
try:
|
||||||
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
|
await save_debug_artifacts(page, account_name, target_name, "selected-friend")
|
||||||
chat_input, selector_used = await locate_chat_input(page)
|
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()
|
message = build_message()
|
||||||
logger.info("Prepared message for %s/%s: %r", account_name, target_name, 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,
|
page,
|
||||||
message,
|
|
||||||
chat_input=chat_input,
|
chat_input=chat_input,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Visible message count before send for %s/%s: %s",
|
"Last own message before send for %s/%s: %r",
|
||||||
account_name,
|
account_name,
|
||||||
target_name,
|
target_name,
|
||||||
visible_message_count_before,
|
(last_own_message_before or {}).get("text", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = message.split("\n")
|
lines = message.split("\n")
|
||||||
@@ -2015,7 +2186,7 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
|
|||||||
page,
|
page,
|
||||||
chat_input,
|
chat_input,
|
||||||
message,
|
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")
|
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,
|
page,
|
||||||
chat_input,
|
chat_input,
|
||||||
message,
|
message,
|
||||||
before_visible_count=visible_message_count_before,
|
before_snapshot=last_own_message_before,
|
||||||
)
|
)
|
||||||
if sent_ok:
|
if sent_ok:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
Reference in New Issue
Block a user