This commit is contained in:
Your Name
2026-08-18 17:25:22 +08:00
parent 1048b9ba29
commit f8c78739e7
261 changed files with 16253 additions and 7399 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

+58 -58
View File
@@ -1,58 +1,58 @@
"""按现场探针结论清掉 4 个永远服务不了的待回复任务,给监听一个干净起点。
两个是订阅号/系统号行(打卡、行业资讯),指纹分毫不差但 _is_real_conversation
永远否掉;两个在整份会话列表里已经不存在。代码现在会自己淘汰它们,但要熬过
退避,这里一次性清完。
"""
import json
import os
import shutil
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PENDING = os.path.join(ROOT, "pending_replies.json")
UNREPLIABLE = os.path.join(ROOT, "unrepliable_sessions.json")
NOT_A_CONVERSATION = [
"071f171f101f1f00", # 行业资讯(订阅号行)
"87878f8f8f878682", # 打卡(系统号行)
]
GONE_FROM_LIST = [
"f0e0f0f0f0f0f0f8",
"787818181f0f0f00",
]
def main() -> None:
stamp = time.strftime("%Y%m%d_%H%M%S")
shutil.copy2(PENDING, f"{PENDING}.{stamp}.bak")
with open(PENDING, encoding="utf-8") as handle:
data = json.load(handle)
pending = data.get("pending", data)
doomed = tuple(NOT_A_CONVERSATION + GONE_FROM_LIST)
removed = [k for k in pending if k.startswith(doomed)]
for key in removed:
pending.pop(key)
with open(PENDING, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
print(f"已清理 {len(removed)} 个任务,剩余 {len(pending)}")
blacklist = {}
if os.path.exists(UNREPLIABLE):
try:
with open(UNREPLIABLE, encoding="utf-8") as handle:
blacklist = json.load(handle) or {}
except Exception:
blacklist = {}
now = time.time()
for key in removed:
if key.startswith(tuple(NOT_A_CONVERSATION)):
blacklist[key] = now
with open(UNREPLIABLE, "w", encoding="utf-8") as handle:
json.dump(blacklist, handle, ensure_ascii=False, indent=2)
print(f"订阅号/系统号名单 {len(blacklist)} 个,6 小时后自动复查")
if __name__ == "__main__":
main()
"""按现场探针结论清掉 4 个永远服务不了的待回复任务,给监听一个干净起点。
两个是订阅号/系统号行(打卡、行业资讯),指纹分毫不差但 _is_real_conversation
永远否掉;两个在整份会话列表里已经不存在。代码现在会自己淘汰它们,但要熬过
退避,这里一次性清完。
"""
import json
import os
import shutil
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PENDING = os.path.join(ROOT, "pending_replies.json")
UNREPLIABLE = os.path.join(ROOT, "unrepliable_sessions.json")
NOT_A_CONVERSATION = [
"071f171f101f1f00", # 行业资讯(订阅号行)
"87878f8f8f878682", # 打卡(系统号行)
]
GONE_FROM_LIST = [
"f0e0f0f0f0f0f0f8",
"787818181f0f0f00",
]
def main() -> None:
stamp = time.strftime("%Y%m%d_%H%M%S")
shutil.copy2(PENDING, f"{PENDING}.{stamp}.bak")
with open(PENDING, encoding="utf-8") as handle:
data = json.load(handle)
pending = data.get("pending", data)
doomed = tuple(NOT_A_CONVERSATION + GONE_FROM_LIST)
removed = [k for k in pending if k.startswith(doomed)]
for key in removed:
pending.pop(key)
with open(PENDING, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
print(f"已清理 {len(removed)} 个任务,剩余 {len(pending)}")
blacklist = {}
if os.path.exists(UNREPLIABLE):
try:
with open(UNREPLIABLE, encoding="utf-8") as handle:
blacklist = json.load(handle) or {}
except Exception:
blacklist = {}
now = time.time()
for key in removed:
if key.startswith(tuple(NOT_A_CONVERSATION)):
blacklist[key] = now
with open(UNREPLIABLE, "w", encoding="utf-8") as handle:
json.dump(blacklist, handle, ensure_ascii=False, indent=2)
print(f"订阅号/系统号名单 {len(blacklist)} 个,6 小时后自动复查")
if __name__ == "__main__":
main()
+76 -76
View File
@@ -1,76 +1,76 @@
"""身份会不会随时间漂?这是改用昵称之后最要命的失败方式。
列表里的时间戳一直在走(「1分钟前」→「12:08」)。如果它混进了昵称的裁剪框,
同一个人过一会儿就换一个 md5,任务永远对不上。隔一段时间连读几轮,看 ID 变不变;
顺带对照聊天标题读出来的名字,是否和列表行算出同一个身份。
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_bot as bot_module # noqa: E402
ROUNDS = 4
GAP_SECONDS = 20
def main() -> None:
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = True
if not bot.connect(activate=False, wait_if_missing=True):
print("[-] 未挂载到企业微信窗口")
return
if not bot._ensure_visible():
print("[-] 企业微信主界面未还原")
return
history = {}
for attempt in range(ROUNDS):
listing = bot.capture_session_list()
item_h = max(1, int(bot.session_item_h))
rows = max(1, listing.shape[0] // item_h)
for index in range(min(rows, 8)):
center = index * item_h + item_h // 2
name = bot._row_display_name(listing, center, row_center=True)
fp = bot._session_fingerprint(listing, center, row_center=True)
history.setdefault(index, []).append((name, bot.session_id_of(fp)))
print(f"{attempt + 1}/{ROUNDS} 轮已采集({time.strftime('%H:%M:%S')}")
if attempt < ROUNDS - 1:
time.sleep(GAP_SECONDS)
print()
print(" 行 昵称 结果")
print(" " + "-" * 70)
unstable = 0
for index, samples in sorted(history.items()):
ids = {sid for _name, sid in samples}
names = {name for name, _sid in samples}
first = samples[0][0] or "(读不到)"
if len(ids) == 1:
verdict = f"稳定 {samples[0][1][:16]}"
else:
unstable += 1
verdict = f"★漂移★ 读到过 {names}"
print(f" {index:<3} {first:<20} {verdict}")
print()
title_name = bot._open_chat_display_name()
title_fp = bot._fp_from_name(title_name)
print(f" 聊天标题读到: {title_name!r}{bot.session_id_of(title_fp)}")
listing = bot.capture_session_list()
selected_y = bot.detect_selected_row(listing)
if selected_y >= 0:
row_fp = bot._session_fingerprint(listing, selected_y, row_center=True)
row_name = bot._row_display_name(listing, selected_y, row_center=True)
print(f" 选中行读到: {row_name!r}{bot.session_id_of(row_fp)}")
print(f" 标题与列表行是同一个身份?{bot._session_fp_matches(title_fp, row_fp)}")
print()
print("★ 漂移行数:" if unstable else "全部稳定,没有一行的身份随时间变化。", unstable or "")
if __name__ == "__main__":
main()
"""身份会不会随时间漂?这是改用昵称之后最要命的失败方式。
列表里的时间戳一直在走(「1分钟前」→「12:08」)。如果它混进了昵称的裁剪框,
同一个人过一会儿就换一个 md5,任务永远对不上。隔一段时间连读几轮,看 ID 变不变;
顺带对照聊天标题读出来的名字,是否和列表行算出同一个身份。
"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_bot as bot_module # noqa: E402
ROUNDS = 4
GAP_SECONDS = 20
def main() -> None:
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = True
if not bot.connect(activate=False, wait_if_missing=True):
print("[-] 未挂载到企业微信窗口")
return
if not bot._ensure_visible():
print("[-] 企业微信主界面未还原")
return
history = {}
for attempt in range(ROUNDS):
listing = bot.capture_session_list()
item_h = max(1, int(bot.session_item_h))
rows = max(1, listing.shape[0] // item_h)
for index in range(min(rows, 8)):
center = index * item_h + item_h // 2
name = bot._row_display_name(listing, center, row_center=True)
fp = bot._session_fingerprint(listing, center, row_center=True)
history.setdefault(index, []).append((name, bot.session_id_of(fp)))
print(f"{attempt + 1}/{ROUNDS} 轮已采集({time.strftime('%H:%M:%S')}")
if attempt < ROUNDS - 1:
time.sleep(GAP_SECONDS)
print()
print(" 行 昵称 结果")
print(" " + "-" * 70)
unstable = 0
for index, samples in sorted(history.items()):
ids = {sid for _name, sid in samples}
names = {name for name, _sid in samples}
first = samples[0][0] or "(读不到)"
if len(ids) == 1:
verdict = f"稳定 {samples[0][1][:16]}"
else:
unstable += 1
verdict = f"★漂移★ 读到过 {names}"
print(f" {index:<3} {first:<20} {verdict}")
print()
title_name = bot._open_chat_display_name()
title_fp = bot._fp_from_name(title_name)
print(f" 聊天标题读到: {title_name!r}{bot.session_id_of(title_fp)}")
listing = bot.capture_session_list()
selected_y = bot.detect_selected_row(listing)
if selected_y >= 0:
row_fp = bot._session_fingerprint(listing, selected_y, row_center=True)
row_name = bot._row_display_name(listing, selected_y, row_center=True)
print(f" 选中行读到: {row_name!r}{bot.session_id_of(row_fp)}")
print(f" 标题与列表行是同一个身份?{bot._session_fp_matches(title_fp, row_fp)}")
print()
print("★ 漂移行数:" if unstable else "全部稳定,没有一行的身份随时间变化。", unstable or "")
if __name__ == "__main__":
main()
+87 -87
View File
@@ -1,87 +1,87 @@
"""实拍会话列表,逐行报告名称字形哈希的置位数,并导出取样带截图。
用途:定位为什么某些联系人的 256 位名称哈希退化成全零。
"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import cv2 # noqa: E402
from wechat_bot import WeChatBot, _NAME_FP_BYTES # noqa: E402
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "namehash")
def main() -> None:
os.makedirs(OUT, exist_ok=True)
bot = WeChatBot()
if not bot.connect(activate=False):
raise SystemExit("挂载企业微信失败")
img = bot.capture_session_list()
if img is None:
raise SystemExit("截取会话列表失败")
cv2.imwrite(os.path.join(OUT, "list.png"), img)
scale = max(0.75, float(bot.scale or 1.0))
print(f"列表尺寸 {img.shape[1]}x{img.shape[0]} scale={scale}")
centers = bot._avatar_row_centers(img)
print(f"检出 {len(centers)} 行头像,中心={centers}")
# 名称哈希实际取样的两条带(与 _session_name_fingerprint 保持一致)
bands = {
"prefix": (60 * scale, 84 * scale, -8 * scale, -2 * scale),
"main": (80 * scale, img.shape[1] - 60 * scale, -21 * scale, -2 * scale),
}
for idx, raw_center in enumerate(centers):
y_c = bot._avatar_anchor(img, raw_center)
name_fp = bot._session_name_fingerprint(img, y_c, row_center=True)
avatar_fp = bot._session_fingerprint(img, y_c, row_center=True)
bits = int.from_bytes(name_fp or b"", "big").bit_count()
head = (avatar_fp or b"")[:8].hex()
print(
f"{idx}: 锚点 {raw_center}->{y_c} 名称置位={bits:3d}/256 "
f"长度={len(name_fp or b'')} 头像={head}"
)
for tag, (x1, x2, dy1, dy2) in bands.items():
xa, xb = int(max(0, x1)), int(min(img.shape[1], x2))
ya, yb = int(max(0, y_c + dy1)), int(min(img.shape[0], y_c + dy2))
crop = img[ya:yb, xa:xb]
if crop.size:
gray = crop[:, :, :3].astype(np.float32).mean(axis=2)
background = float(np.median(gray))
ink = float((np.abs(gray - background) >= 16.0).mean())
print(
f" {tag:6s} x[{xa},{xb}) y[{ya},{yb}) "
f"底色={background:.0f} 墨迹占比={ink:.3f}"
)
cv2.imwrite(os.path.join(OUT, f"row{idx}_{tag}.png"), crop)
else:
print(f" {tag:6s} 取样区为空 x[{xa},{xb}) y[{ya},{yb})")
print(f"\n截图已写入 {OUT}")
print(f"_NAME_FP_BYTES={_NAME_FP_BYTES}")
# 不同联系人之间名称哈希的距离下界,决定容差能开到多大
print("\n当前列表内不同行的名称哈希距离:")
fps = []
for idx, raw_center in enumerate(centers):
y_c = bot._avatar_anchor(img, raw_center)
fps.append((idx, bot._session_name_fingerprint(img, y_c, row_center=True)))
worst = 999
for i in range(len(fps)):
for j in range(i + 1, len(fps)):
a, b = fps[i][1], fps[j][1]
if len(a) != _NAME_FP_BYTES or len(b) != _NAME_FP_BYTES:
continue
dist = (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).bit_count()
worst = min(worst, dist)
print(f"{fps[i][0]} vs 行{fps[j][0]}: {dist}")
print(f"不同联系人名称距离下界 = {worst}")
if __name__ == "__main__":
main()
"""实拍会话列表,逐行报告名称字形哈希的置位数,并导出取样带截图。
用途:定位为什么某些联系人的 256 位名称哈希退化成全零。
"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import cv2 # noqa: E402
from wechat_bot import WeChatBot, _NAME_FP_BYTES # noqa: E402
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "namehash")
def main() -> None:
os.makedirs(OUT, exist_ok=True)
bot = WeChatBot()
if not bot.connect(activate=False):
raise SystemExit("挂载企业微信失败")
img = bot.capture_session_list()
if img is None:
raise SystemExit("截取会话列表失败")
cv2.imwrite(os.path.join(OUT, "list.png"), img)
scale = max(0.75, float(bot.scale or 1.0))
print(f"列表尺寸 {img.shape[1]}x{img.shape[0]} scale={scale}")
centers = bot._avatar_row_centers(img)
print(f"检出 {len(centers)} 行头像,中心={centers}")
# 名称哈希实际取样的两条带(与 _session_name_fingerprint 保持一致)
bands = {
"prefix": (60 * scale, 84 * scale, -8 * scale, -2 * scale),
"main": (80 * scale, img.shape[1] - 60 * scale, -21 * scale, -2 * scale),
}
for idx, raw_center in enumerate(centers):
y_c = bot._avatar_anchor(img, raw_center)
name_fp = bot._session_name_fingerprint(img, y_c, row_center=True)
avatar_fp = bot._session_fingerprint(img, y_c, row_center=True)
bits = int.from_bytes(name_fp or b"", "big").bit_count()
head = (avatar_fp or b"")[:8].hex()
print(
f"{idx}: 锚点 {raw_center}->{y_c} 名称置位={bits:3d}/256 "
f"长度={len(name_fp or b'')} 头像={head}"
)
for tag, (x1, x2, dy1, dy2) in bands.items():
xa, xb = int(max(0, x1)), int(min(img.shape[1], x2))
ya, yb = int(max(0, y_c + dy1)), int(min(img.shape[0], y_c + dy2))
crop = img[ya:yb, xa:xb]
if crop.size:
gray = crop[:, :, :3].astype(np.float32).mean(axis=2)
background = float(np.median(gray))
ink = float((np.abs(gray - background) >= 16.0).mean())
print(
f" {tag:6s} x[{xa},{xb}) y[{ya},{yb}) "
f"底色={background:.0f} 墨迹占比={ink:.3f}"
)
cv2.imwrite(os.path.join(OUT, f"row{idx}_{tag}.png"), crop)
else:
print(f" {tag:6s} 取样区为空 x[{xa},{xb}) y[{ya},{yb})")
print(f"\n截图已写入 {OUT}")
print(f"_NAME_FP_BYTES={_NAME_FP_BYTES}")
# 不同联系人之间名称哈希的距离下界,决定容差能开到多大
print("\n当前列表内不同行的名称哈希距离:")
fps = []
for idx, raw_center in enumerate(centers):
y_c = bot._avatar_anchor(img, raw_center)
fps.append((idx, bot._session_name_fingerprint(img, y_c, row_center=True)))
worst = 999
for i in range(len(fps)):
for j in range(i + 1, len(fps)):
a, b = fps[i][1], fps[j][1]
if len(a) != _NAME_FP_BYTES or len(b) != _NAME_FP_BYTES:
continue
dist = (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).bit_count()
worst = min(worst, dist)
print(f"{fps[i][0]} vs 行{fps[j][0]}: {dist}")
print(f"不同联系人名称距离下界 = {worst}")
if __name__ == "__main__":
main()
+98 -98
View File
@@ -1,98 +1,98 @@
"""为什么每次 [待回复恢复] 之后都跟着 [页面校验] 点击前会话列表已变化?
定位器 _pending_rows_on_page 找到了行,click_session 用同一个 y 复核却不过。
两边的判据不一样,这里把 click_session 的三个子条件拆开单独打印,看是哪一个
把点击否掉的。纯读,不点击、不发送。
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_bot as bot_module # noqa: E402
from wechat_bot import ( # noqa: E402
_AVATAR_FP_BYTES,
_SESSION_FP_BYTES,
_LEGACY_SESSION_FP_BYTES,
)
def hamming(a: bytes, b: bytes) -> int:
if len(a) != len(b) or not a:
return -1
return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).bit_count()
def main() -> None:
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = False
if not bot.connect(activate=False, wait_if_missing=False):
print("[-] 未挂载到企业微信窗口")
return
pending = dict(getattr(bot, "_pending_reply_sessions", {}))
print(f"待回复任务 {len(pending)}\n")
for key, state in pending.items():
target_fp = bytes.fromhex(key)
print("=" * 72)
print(f"任务 {key[:16]}… send_state={state.get('send_state')!r}")
found = bot._find_pending_session(target_fp)
if found is None:
incomplete = getattr(bot, "_pending_scan_incomplete", False)
print(
f" _find_pending_session: 没找到"
f"(扫描{'被时间预算截断,结论不可信' if incomplete else '已走完全部分页,确实不存在'}"
)
continue
_page, row_center = found
print(f" _find_pending_session: 命中 row_center={row_center}")
latest = bot.capture_session_list()
if latest is None:
print(" 重新截取列表失败")
continue
legacy = len(target_fp) == _LEGACY_SESSION_FP_BYTES
if legacy:
actual = bot._legacy_session_fingerprint(latest, row_center, row_center=True)
fp_ok = actual == target_fp
else:
actual = bot._session_fingerprint(latest, row_center, row_center=True)
fp_ok = bot._session_fp_matches(actual, target_fp)
print(f" 条件1 指纹匹配 = {fp_ok}")
if actual and len(actual) == _SESSION_FP_BYTES and not legacy:
name_ok, name_d = bot._name_fp_matches(
actual[_AVATAR_FP_BYTES:], target_fp[_AVATAR_FP_BYTES:]
)
print(
f" 头像距离={hamming(actual[:_AVATAR_FP_BYTES], target_fp[:_AVATAR_FP_BYTES])} "
f"(容差 {bot._FP_HAMMING_TOL_NAMED}/{bot._FP_HAMMING_TOL}) "
f"名称匹配={name_ok} 距离={name_d} "
f"名称有效={bot._name_fp_is_substantive(actual[_AVATAR_FP_BYTES:])}"
)
pair = tuple(sorted((actual.hex(), target_fp.hex())))
print(f" 别名已登记={pair in getattr(bot, '_live_session_fp_aliases', set())}")
pending_state = bot._pending_reply_state(target_fp)
allow_flat = bool(
(pending_state or {}).get("requires_visual_proof", False)
or bot._flat_session_is_known(target_fp)
)
allow_unrounded = bool(
len(target_fp) == _SESSION_FP_BYTES
and (pending_state or {}).get("requires_visual_proof", False)
and not bot._flat_session_rejected(target_fp)
)
shape_ok = bot._is_real_conversation(
latest, int(row_center), quiet=True, row_center=True, allow_flat=allow_flat
)
print(f" 条件2 会话形态 = {shape_ok} (allow_flat={allow_flat})")
print(f" 条件3 兜底放行 = {allow_unrounded}")
verdict = fp_ok and (shape_ok or allow_unrounded)
print(f" → click_session 会{'放行' if verdict else '取消这次点击'}")
if __name__ == "__main__":
main()
"""为什么每次 [待回复恢复] 之后都跟着 [页面校验] 点击前会话列表已变化?
定位器 _pending_rows_on_page 找到了行,click_session 用同一个 y 复核却不过。
两边的判据不一样,这里把 click_session 的三个子条件拆开单独打印,看是哪一个
把点击否掉的。纯读,不点击、不发送。
"""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_bot as bot_module # noqa: E402
from wechat_bot import ( # noqa: E402
_AVATAR_FP_BYTES,
_SESSION_FP_BYTES,
_LEGACY_SESSION_FP_BYTES,
)
def hamming(a: bytes, b: bytes) -> int:
if len(a) != len(b) or not a:
return -1
return (int.from_bytes(a, "big") ^ int.from_bytes(b, "big")).bit_count()
def main() -> None:
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = False
if not bot.connect(activate=False, wait_if_missing=False):
print("[-] 未挂载到企业微信窗口")
return
pending = dict(getattr(bot, "_pending_reply_sessions", {}))
print(f"待回复任务 {len(pending)}\n")
for key, state in pending.items():
target_fp = bytes.fromhex(key)
print("=" * 72)
print(f"任务 {key[:16]}… send_state={state.get('send_state')!r}")
found = bot._find_pending_session(target_fp)
if found is None:
incomplete = getattr(bot, "_pending_scan_incomplete", False)
print(
f" _find_pending_session: 没找到"
f"(扫描{'被时间预算截断,结论不可信' if incomplete else '已走完全部分页,确实不存在'}"
)
continue
_page, row_center = found
print(f" _find_pending_session: 命中 row_center={row_center}")
latest = bot.capture_session_list()
if latest is None:
print(" 重新截取列表失败")
continue
legacy = len(target_fp) == _LEGACY_SESSION_FP_BYTES
if legacy:
actual = bot._legacy_session_fingerprint(latest, row_center, row_center=True)
fp_ok = actual == target_fp
else:
actual = bot._session_fingerprint(latest, row_center, row_center=True)
fp_ok = bot._session_fp_matches(actual, target_fp)
print(f" 条件1 指纹匹配 = {fp_ok}")
if actual and len(actual) == _SESSION_FP_BYTES and not legacy:
name_ok, name_d = bot._name_fp_matches(
actual[_AVATAR_FP_BYTES:], target_fp[_AVATAR_FP_BYTES:]
)
print(
f" 头像距离={hamming(actual[:_AVATAR_FP_BYTES], target_fp[:_AVATAR_FP_BYTES])} "
f"(容差 {bot._FP_HAMMING_TOL_NAMED}/{bot._FP_HAMMING_TOL}) "
f"名称匹配={name_ok} 距离={name_d} "
f"名称有效={bot._name_fp_is_substantive(actual[_AVATAR_FP_BYTES:])}"
)
pair = tuple(sorted((actual.hex(), target_fp.hex())))
print(f" 别名已登记={pair in getattr(bot, '_live_session_fp_aliases', set())}")
pending_state = bot._pending_reply_state(target_fp)
allow_flat = bool(
(pending_state or {}).get("requires_visual_proof", False)
or bot._flat_session_is_known(target_fp)
)
allow_unrounded = bool(
len(target_fp) == _SESSION_FP_BYTES
and (pending_state or {}).get("requires_visual_proof", False)
and not bot._flat_session_rejected(target_fp)
)
shape_ok = bot._is_real_conversation(
latest, int(row_center), quiet=True, row_center=True, allow_flat=allow_flat
)
print(f" 条件2 会话形态 = {shape_ok} (allow_flat={allow_flat})")
print(f" 条件3 兜底放行 = {allow_unrounded}")
verdict = fp_ok and (shape_ok or allow_unrounded)
print(f" → click_session 会{'放行' if verdict else '取消这次点击'}")
if __name__ == "__main__":
main()
+76 -76
View File
@@ -1,76 +1,76 @@
"""复现"把广告文本当成登录二维码"的误判,并验证替代判据。
纯读:只截图、只计算,不点击、不发送。
"""
import os
import sys
import cv2
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_bot as bot_module # noqa: E402
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "secgate")
def report(img: np.ndarray, label: str) -> None:
height, width = img.shape[:2]
rgb = img[:, :, :3].astype(np.float32)
gray = rgb.mean(axis=2)
nav_width = max(16, int(width * 0.06))
nav = gray[int(height * 0.08):int(height * 0.92), :nav_width]
center = gray[
int(height * 0.32):int(height * 0.70),
int(width * 0.32):int(width * 0.68),
]
nav_dark_ratio = float((nav < 120).mean())
dark = center < 75
light_ratio = float((center > 220).mean())
transition_ratio = float(
(dark[:, 1:] != dark[:, :-1]).mean() + (dark[1:, :] != dark[:-1, :]).mean()
)
qr_like = (
float(dark.mean()) >= 0.025 and light_ratio >= 0.45 and transition_ratio >= 0.035
)
print(f"--- {label} {width}x{height}")
print(f" nav_dark_ratio = {nav_dark_ratio:.4f} (需 < 0.12)")
print(f" center dark = {float(dark.mean()):.4f} (需 >= 0.025)")
print(f" light_ratio = {light_ratio:.4f} (需 >= 0.45)")
print(f" transition = {transition_ratio:.4f} (需 >= 0.035)")
print(f" qr_like = {qr_like}")
print(f" 旧判据结论 = {nav_dark_ratio < 0.12 and qr_like}")
gray8 = cv2.cvtColor(img[:, :, :3], cv2.COLOR_BGR2GRAY)
detector = cv2.QRCodeDetector()
ok, points = detector.detect(gray8)
print(f" cv2 真的找到二维码 = {bool(ok)}")
if ok and points is not None:
for quad in np.asarray(points).reshape(-1, 4, 2):
w = np.linalg.norm(quad[0] - quad[1])
h = np.linalg.norm(quad[1] - quad[2])
print(f" 四角={quad.astype(int).tolist()} 边长≈{w:.0f}x{h:.0f}")
def main() -> None:
os.makedirs(OUT, exist_ok=True)
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = False
if not bot.connect(activate=False, wait_if_missing=False):
print("[-] 未挂载到企业微信窗口")
return
img = bot._capture_full_window()
if img is None:
print("[-] 截屏失败")
return
cv2.imwrite(os.path.join(OUT, "full.png"), img[:, :, :3])
report(img, "当前企业微信主界面(不该被判为验证页)")
print()
print(f"looks_like_security_verification() = "
f"{bot_module.looks_like_security_verification(img)}")
if __name__ == "__main__":
main()
"""复现"把广告文本当成登录二维码"的误判,并验证替代判据。
纯读:只截图、只计算,不点击、不发送。
"""
import os
import sys
import cv2
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import wechat_bot as bot_module # noqa: E402
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "secgate")
def report(img: np.ndarray, label: str) -> None:
height, width = img.shape[:2]
rgb = img[:, :, :3].astype(np.float32)
gray = rgb.mean(axis=2)
nav_width = max(16, int(width * 0.06))
nav = gray[int(height * 0.08):int(height * 0.92), :nav_width]
center = gray[
int(height * 0.32):int(height * 0.70),
int(width * 0.32):int(width * 0.68),
]
nav_dark_ratio = float((nav < 120).mean())
dark = center < 75
light_ratio = float((center > 220).mean())
transition_ratio = float(
(dark[:, 1:] != dark[:, :-1]).mean() + (dark[1:, :] != dark[:-1, :]).mean()
)
qr_like = (
float(dark.mean()) >= 0.025 and light_ratio >= 0.45 and transition_ratio >= 0.035
)
print(f"--- {label} {width}x{height}")
print(f" nav_dark_ratio = {nav_dark_ratio:.4f} (需 < 0.12)")
print(f" center dark = {float(dark.mean()):.4f} (需 >= 0.025)")
print(f" light_ratio = {light_ratio:.4f} (需 >= 0.45)")
print(f" transition = {transition_ratio:.4f} (需 >= 0.035)")
print(f" qr_like = {qr_like}")
print(f" 旧判据结论 = {nav_dark_ratio < 0.12 and qr_like}")
gray8 = cv2.cvtColor(img[:, :, :3], cv2.COLOR_BGR2GRAY)
detector = cv2.QRCodeDetector()
ok, points = detector.detect(gray8)
print(f" cv2 真的找到二维码 = {bool(ok)}")
if ok and points is not None:
for quad in np.asarray(points).reshape(-1, 4, 2):
w = np.linalg.norm(quad[0] - quad[1])
h = np.linalg.norm(quad[1] - quad[2])
print(f" 四角={quad.astype(int).tolist()} 边长≈{w:.0f}x{h:.0f}")
def main() -> None:
os.makedirs(OUT, exist_ok=True)
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = False
if not bot.connect(activate=False, wait_if_missing=False):
print("[-] 未挂载到企业微信窗口")
return
img = bot._capture_full_window()
if img is None:
print("[-] 截屏失败")
return
cv2.imwrite(os.path.join(OUT, "full.png"), img[:, :, :3])
report(img, "当前企业微信主界面(不该被判为验证页)")
print()
print(f"looks_like_security_verification() = "
f"{bot_module.looks_like_security_verification(img)}")
if __name__ == "__main__":
main()
+71 -71
View File
@@ -1,71 +1,71 @@
"""用 session_name 模块跑一遍真实窗口:标题和列表各行的昵称、md5、耗时。"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np # noqa: E402
import session_name # noqa: E402
import wechat_bot as bot_module # noqa: E402
def main() -> None:
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = False
if not bot.connect(activate=False, wait_if_missing=False):
print("[-] 未挂载到企业微信窗口")
return
reader = session_name.NameReader()
if not reader.available:
print("[-] OCR 引擎不可用")
return
scale = max(0.75, float(bot.scale or 1.0))
full = bot._capture_full_window()
x1 = int(bot._list_x + bot._list_w)
print("=" * 78)
print("当前打开的会话(聊天标题)")
print("=" * 78)
panel = np.ascontiguousarray(
full[int(20 * scale):int(78 * scale),
x1 + int(8 * scale):x1 + int(600 * scale), :3]
)
start = time.perf_counter()
name = reader.read(panel)
cost = (time.perf_counter() - start) * 1000
print(f" 昵称 {name!r}")
print(f" 会话ID {session_name.session_id_for(name)}")
print(f" 耗时 {cost:.0f} ms")
print()
print("=" * 78)
print("会话列表")
print("=" * 78)
listing = bot.capture_session_list()
item_h = max(1, int(bot.session_item_h))
rows = max(1, listing.shape[0] // item_h)
total = 0.0
for index in range(min(rows, 10)):
y0 = index * item_h
panel = np.ascontiguousarray(
listing[y0 + int(item_h * 0.14):y0 + int(item_h * 0.50),
int(72 * scale):int(310 * scale), :3]
)
start = time.perf_counter()
name = reader.read(panel)
cost = (time.perf_counter() - start) * 1000
total += cost
sid = session_name.session_id_for(name)
print(f"{index}{cost:5.0f} ms {name!r:<24} {sid[:16] if sid else '(读不到)'}")
print(f" 整列共 {total:.0f} ms")
print()
print(f"已认识的昵称: {reader.known_names()}")
if __name__ == "__main__":
main()
"""用 session_name 模块跑一遍真实窗口:标题和列表各行的昵称、md5、耗时。"""
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np # noqa: E402
import session_name # noqa: E402
import wechat_bot as bot_module # noqa: E402
def main() -> None:
bot = bot_module.WeChatBot()
bot.safe_window_mode = True
bot.auto_activate_window = False
if not bot.connect(activate=False, wait_if_missing=False):
print("[-] 未挂载到企业微信窗口")
return
reader = session_name.NameReader()
if not reader.available:
print("[-] OCR 引擎不可用")
return
scale = max(0.75, float(bot.scale or 1.0))
full = bot._capture_full_window()
x1 = int(bot._list_x + bot._list_w)
print("=" * 78)
print("当前打开的会话(聊天标题)")
print("=" * 78)
panel = np.ascontiguousarray(
full[int(20 * scale):int(78 * scale),
x1 + int(8 * scale):x1 + int(600 * scale), :3]
)
start = time.perf_counter()
name = reader.read(panel)
cost = (time.perf_counter() - start) * 1000
print(f" 昵称 {name!r}")
print(f" 会话ID {session_name.session_id_for(name)}")
print(f" 耗时 {cost:.0f} ms")
print()
print("=" * 78)
print("会话列表")
print("=" * 78)
listing = bot.capture_session_list()
item_h = max(1, int(bot.session_item_h))
rows = max(1, listing.shape[0] // item_h)
total = 0.0
for index in range(min(rows, 10)):
y0 = index * item_h
panel = np.ascontiguousarray(
listing[y0 + int(item_h * 0.14):y0 + int(item_h * 0.50),
int(72 * scale):int(310 * scale), :3]
)
start = time.perf_counter()
name = reader.read(panel)
cost = (time.perf_counter() - start) * 1000
total += cost
sid = session_name.session_id_for(name)
print(f"{index}{cost:5.0f} ms {name!r:<24} {sid[:16] if sid else '(读不到)'}")
print(f" 整列共 {total:.0f} ms")
print()
print(f"已认识的昵称: {reader.known_names()}")
if __name__ == "__main__":
main()
+89 -89
View File
@@ -1,89 +1,89 @@
"""修复 wechat_bot.py 的字节级损坏。
损坏形态:个别字节被就地替换成 0x3f('?'),字节长度不变,行结构完好。
因此可以用 git HEAD 版本做参照:把损坏行里的 0x3f 当通配符,在 HEAD 里找
长度相同、其余字节全部一致的唯一候选行来还原。
默认只报告不写入;加 --write 才真正落盘。
"""
import subprocess
import sys
def load_head() -> bytes:
return subprocess.run(
["git", "show", "HEAD:wechat_rpa/wechat_bot.py"],
capture_output=True,
check=True,
cwd="..",
).stdout
def is_valid(line: bytes) -> bool:
try:
line.decode("utf-8")
return True
except UnicodeDecodeError:
return False
def candidates(broken: bytes, pool: dict) -> list:
"""在同长度的候选里找出「除 0x3f 位置外完全一致」的行。"""
found = []
for other in pool.get(len(broken), ()): # 长度相同才可能是同一行
if all(
b == o or b == 0x3F
for b, o in zip(broken, other)
):
found.append(other)
return found
def main():
write = "--write" in sys.argv
current = open("wechat_bot.py", "rb").read()
head = load_head()
cur_lines = current.split(b"\n")
head_lines = head.split(b"\n")
pool = {}
for line in head_lines:
pool.setdefault(len(line), []).append(line)
broken_idx = [i for i, line in enumerate(cur_lines) if not is_valid(line)]
print(f"总行数 {len(cur_lines)},损坏行 {len(broken_idx)}")
repaired = list(cur_lines)
fixed = unresolved = ambiguous = 0
unresolved_lines = []
for i in broken_idx:
found = set(candidates(cur_lines[i], pool))
if len(found) == 1:
repaired[i] = found.pop()
fixed += 1
elif len(found) > 1:
ambiguous += 1
unresolved_lines.append((i, cur_lines[i], len(found)))
else:
unresolved += 1
unresolved_lines.append((i, cur_lines[i], 0))
print(f"可唯一还原 {fixed},歧义 {ambiguous}HEAD 里找不到 {unresolved}")
if unresolved_lines:
print("\n需要人工确认的行(最多列 40 条):")
for i, line, n in unresolved_lines[:40]:
print(f"{i + 1} 行 候选={n}: {line.decode('utf-8', 'replace')!r}")
if not write:
print("\n(只报告,未写入。加 --write 才落盘)")
return
out = b"\n".join(repaired)
open("wechat_bot.py", "wb").write(out)
print(f"\n已写回 {len(out)} 字节")
if __name__ == "__main__":
main()
"""修复 wechat_bot.py 的字节级损坏。
损坏形态:个别字节被就地替换成 0x3f('?'),字节长度不变,行结构完好。
因此可以用 git HEAD 版本做参照:把损坏行里的 0x3f 当通配符,在 HEAD 里找
长度相同、其余字节全部一致的唯一候选行来还原。
默认只报告不写入;加 --write 才真正落盘。
"""
import subprocess
import sys
def load_head() -> bytes:
return subprocess.run(
["git", "show", "HEAD:wechat_rpa/wechat_bot.py"],
capture_output=True,
check=True,
cwd="..",
).stdout
def is_valid(line: bytes) -> bool:
try:
line.decode("utf-8")
return True
except UnicodeDecodeError:
return False
def candidates(broken: bytes, pool: dict) -> list:
"""在同长度的候选里找出「除 0x3f 位置外完全一致」的行。"""
found = []
for other in pool.get(len(broken), ()): # 长度相同才可能是同一行
if all(
b == o or b == 0x3F
for b, o in zip(broken, other)
):
found.append(other)
return found
def main():
write = "--write" in sys.argv
current = open("wechat_bot.py", "rb").read()
head = load_head()
cur_lines = current.split(b"\n")
head_lines = head.split(b"\n")
pool = {}
for line in head_lines:
pool.setdefault(len(line), []).append(line)
broken_idx = [i for i, line in enumerate(cur_lines) if not is_valid(line)]
print(f"总行数 {len(cur_lines)},损坏行 {len(broken_idx)}")
repaired = list(cur_lines)
fixed = unresolved = ambiguous = 0
unresolved_lines = []
for i in broken_idx:
found = set(candidates(cur_lines[i], pool))
if len(found) == 1:
repaired[i] = found.pop()
fixed += 1
elif len(found) > 1:
ambiguous += 1
unresolved_lines.append((i, cur_lines[i], len(found)))
else:
unresolved += 1
unresolved_lines.append((i, cur_lines[i], 0))
print(f"可唯一还原 {fixed},歧义 {ambiguous}HEAD 里找不到 {unresolved}")
if unresolved_lines:
print("\n需要人工确认的行(最多列 40 条):")
for i, line, n in unresolved_lines[:40]:
print(f"{i + 1} 行 候选={n}: {line.decode('utf-8', 'replace')!r}")
if not write:
print("\n(只报告,未写入。加 --write 才落盘)")
return
out = b"\n".join(repaired)
open("wechat_bot.py", "wb").write(out)
print(f"\n已写回 {len(out)} 字节")
if __name__ == "__main__":
main()
+47 -47
View File
@@ -1,47 +1,47 @@
"""会话身份从「像素哈希」换成「昵称 md5」,旧键再也匹配不上,全部作废重建。
留着不清的后果就是日志里那个循环:待回复恢复找到一堆旧任务 → 按旧指纹去开 →
「实际打开对象与目标不一致」→ 任务留在队列里,下一轮再来一遍,真正的新消息
被挤在后面。原文件都留 .bak。
"""
import json
import os
import shutil
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STAMP = time.strftime("%Y%m%d_%H%M%S")
TARGETS = {
"pending_replies.json": {"pending": {}},
"unrepliable_sessions.json": {},
"conversations.json": {},
"false_pos_cache.json": {},
}
def main() -> None:
for name, empty in TARGETS.items():
path = os.path.join(ROOT, name)
if not os.path.exists(path):
print(f" {name:28} 不存在,跳过")
continue
size = os.path.getsize(path)
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
count = len(data.get("pending", data)) if isinstance(data, dict) else 0
except Exception:
count = -1
if size > 2:
shutil.copy2(path, f"{path}.{STAMP}.bak")
with open(path, "w", encoding="utf-8") as handle:
json.dump(empty, handle, ensure_ascii=False, indent=2)
print(f" {name:28} 清空(原有 {count} 条,{size} 字节,已备份)")
print()
print("旧身份数据已作废。下一轮监听会按昵称重新建立会话身份和档案。")
if __name__ == "__main__":
main()
"""会话身份从「像素哈希」换成「昵称 md5」,旧键再也匹配不上,全部作废重建。
留着不清的后果就是日志里那个循环:待回复恢复找到一堆旧任务 → 按旧指纹去开 →
「实际打开对象与目标不一致」→ 任务留在队列里,下一轮再来一遍,真正的新消息
被挤在后面。原文件都留 .bak。
"""
import json
import os
import shutil
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STAMP = time.strftime("%Y%m%d_%H%M%S")
TARGETS = {
"pending_replies.json": {"pending": {}},
"unrepliable_sessions.json": {},
"conversations.json": {},
"false_pos_cache.json": {},
}
def main() -> None:
for name, empty in TARGETS.items():
path = os.path.join(ROOT, name)
if not os.path.exists(path):
print(f" {name:28} 不存在,跳过")
continue
size = os.path.getsize(path)
try:
with open(path, encoding="utf-8") as handle:
data = json.load(handle)
count = len(data.get("pending", data)) if isinstance(data, dict) else 0
except Exception:
count = -1
if size > 2:
shutil.copy2(path, f"{path}.{STAMP}.bak")
with open(path, "w", encoding="utf-8") as handle:
json.dump(empty, handle, ensure_ascii=False, indent=2)
print(f" {name:28} 清空(原有 {count} 条,{size} 字节,已备份)")
print()
print("旧身份数据已作废。下一轮监听会按昵称重新建立会话身份和档案。")
if __name__ == "__main__":
main()
+174 -174
View File
@@ -1,174 +1,174 @@
"""带决策追踪的真实监听:在普通日志之上,额外打印每一步用到的会话指纹。
用法:
python tmp/run_listener_traced.py [轮数]
目的是定位「同一个客户回两次之后就再也不回」——重点观察每一轮为同一个联系人
铸出来的指纹是否稳定,以及第 3 条消息被哪一步判断拦下。
追踪全部用猴子补丁挂在实例上,不改动生产代码。日志写到 tmp/traced_<时间戳>.log。
"""
import functools
import os
import sys
import threading
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_HERE)
sys.path.insert(0, _ROOT)
sys.path.insert(0, _HERE)
from run_listener_logged import Tee, describe_gates, load_runtime_settings
def _fp(value) -> str:
"""把指纹压成「头像8字节…名称前4字节」的短形式,方便逐轮肉眼比对。"""
try:
raw = bytes(value or b"")
except Exception:
return repr(value)
if not raw:
return ""
if len(raw) >= 40:
return f"头像={raw[:8].hex()} 名称={raw[8:12].hex()}"
return raw.hex()[:24]
def install_tracing(bot):
"""给关键决策点挂上入参/返回值打印。"""
def trace(name, fmt_args=None, fmt_result=None):
original = getattr(bot, name)
@functools.wraps(original)
def wrapper(*args, **kwargs):
result = original(*args, **kwargs)
try:
shown_args = fmt_args(*args, **kwargs) if fmt_args else ""
shown_result = fmt_result(result) if fmt_result else repr(result)
print(f" <追踪> {name}({shown_args}) -> {shown_result}")
except Exception as exc:
print(f" <追踪> {name} 打印失败: {exc}")
return result
setattr(bot, name, wrapper)
# 铸造/使用会话身份的地方——指纹漂移会直接暴露在这里。
trace(
"_selected_session_fingerprint",
fmt_result=_fp,
)
trace(
"_mark_reply_pending",
fmt_args=lambda fp, *a, **k: f"{_fp(fp)} {k}",
)
trace(
"_session_fp_matches",
fmt_args=lambda a, b, *rest, **k: f"{_fp(a)} vs {_fp(b)}",
)
trace(
"_find_pending_session",
fmt_args=lambda fp, *a, **k: _fp(fp),
fmt_result=lambda r: "找到" if r else "未找到",
)
trace(
"_ensure_session_archive_key",
fmt_args=lambda fp, *a, **k: _fp(fp),
fmt_result=_fp,
)
# 第 3 条消息最可能被拦下的几道闸门。
trace(
"_has_pending_customer_message",
fmt_args=lambda text, fp, *a, **k: (
f"末行={str(text or '').strip().splitlines()[-1][:30] if str(text or '').strip() else ''!r} "
f"{_fp(fp)}"
),
)
trace(
"_wait_for_message_batch",
fmt_args=lambda fp, *a, **k: _fp(fp),
)
trace(
"_generate_ai_reply",
fmt_args=lambda fp, *a, **k: _fp(fp),
fmt_result=lambda r: f"{str(r)[:40]!r}" if r else "无回复",
)
trace(
"send_reply",
fmt_args=lambda text, *a, **k: f"{str(text)[:30]!r}",
)
trace("_check_selected_session")
trace("_resume_orphaned_pending_reply")
def main():
max_rounds = int(sys.argv[1]) if len(sys.argv) > 1 else 0
log_path = os.path.join(_HERE, f"traced_{time.strftime('%Y%m%d_%H%M%S')}.log")
handle = open(log_path, "w", encoding="utf-8")
sys.stdout = Tee(sys.__stdout__, handle)
sys.stderr = sys.stdout
settings = load_runtime_settings()
print(f"[启动] 日志文件: {log_path}")
print(f"[启动] 运行配置: {settings}")
import wechat_bot as bot_module
bot_module.AUTO_REPLY_TEXT = settings["auto_reply_text"]
bot = bot_module.WeChatBot()
bot.mouse_idle_enabled = settings["mouse_idle_enabled"]
bot.mouse_idle_seconds = settings["mouse_idle_seconds"]
bot.message_batch_window_seconds = settings["message_batch_window_seconds"]
stop_event = threading.Event()
bot._stop_check = stop_event
bot.safe_window_mode = True
bot.auto_activate_window = True
if not bot.connect(activate=False, wait_if_missing=True):
print("[-] 未能挂载企业微信主窗口,退出。")
return
install_tracing(bot)
print(
f"[启动] HWND=0x{bot.hwnd:08X} "
f"尺寸={bot.R - bot.L}x{bot.B - bot.T} 输入框=({bot.input_x}, {bot.input_y})"
)
print("[启动] 已挂上决策追踪;请向同一个会话连续发 3 条消息复现。")
round_index = 0
try:
while not stop_event.is_set():
round_index += 1
if max_rounds and round_index > max_rounds:
print(f"[结束] 已完成 {max_rounds} 轮。")
break
print("=" * 72)
print(f"[轮询 {round_index}] 开始")
describe_gates(bot)
started = time.monotonic()
try:
bot._poll_once()
except Exception as exc:
import traceback
print(f"[!] 本轮异常: {exc}")
traceback.print_exc()
print(f"[轮询 {round_index}] 结束,耗时 {time.monotonic() - started:.1f}s")
if bot.security_verification_required:
print("[!] 企业微信要求安全验证,已停止。")
break
stop_event.wait(settings["poll_interval"])
except KeyboardInterrupt:
print("[结束] 收到 Ctrl+C,已停止监听。")
finally:
stop_event.set()
handle.flush()
print(f"[结束] 日志已保存: {log_path}")
if __name__ == "__main__":
main()
"""带决策追踪的真实监听:在普通日志之上,额外打印每一步用到的会话指纹。
用法:
python tmp/run_listener_traced.py [轮数]
目的是定位「同一个客户回两次之后就再也不回」——重点观察每一轮为同一个联系人
铸出来的指纹是否稳定,以及第 3 条消息被哪一步判断拦下。
追踪全部用猴子补丁挂在实例上,不改动生产代码。日志写到 tmp/traced_<时间戳>.log。
"""
import functools
import os
import sys
import threading
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_HERE)
sys.path.insert(0, _ROOT)
sys.path.insert(0, _HERE)
from run_listener_logged import Tee, describe_gates, load_runtime_settings
def _fp(value) -> str:
"""把指纹压成「头像8字节…名称前4字节」的短形式,方便逐轮肉眼比对。"""
try:
raw = bytes(value or b"")
except Exception:
return repr(value)
if not raw:
return ""
if len(raw) >= 40:
return f"头像={raw[:8].hex()} 名称={raw[8:12].hex()}"
return raw.hex()[:24]
def install_tracing(bot):
"""给关键决策点挂上入参/返回值打印。"""
def trace(name, fmt_args=None, fmt_result=None):
original = getattr(bot, name)
@functools.wraps(original)
def wrapper(*args, **kwargs):
result = original(*args, **kwargs)
try:
shown_args = fmt_args(*args, **kwargs) if fmt_args else ""
shown_result = fmt_result(result) if fmt_result else repr(result)
print(f" <追踪> {name}({shown_args}) -> {shown_result}")
except Exception as exc:
print(f" <追踪> {name} 打印失败: {exc}")
return result
setattr(bot, name, wrapper)
# 铸造/使用会话身份的地方——指纹漂移会直接暴露在这里。
trace(
"_selected_session_fingerprint",
fmt_result=_fp,
)
trace(
"_mark_reply_pending",
fmt_args=lambda fp, *a, **k: f"{_fp(fp)} {k}",
)
trace(
"_session_fp_matches",
fmt_args=lambda a, b, *rest, **k: f"{_fp(a)} vs {_fp(b)}",
)
trace(
"_find_pending_session",
fmt_args=lambda fp, *a, **k: _fp(fp),
fmt_result=lambda r: "找到" if r else "未找到",
)
trace(
"_ensure_session_archive_key",
fmt_args=lambda fp, *a, **k: _fp(fp),
fmt_result=_fp,
)
# 第 3 条消息最可能被拦下的几道闸门。
trace(
"_has_pending_customer_message",
fmt_args=lambda text, fp, *a, **k: (
f"末行={str(text or '').strip().splitlines()[-1][:30] if str(text or '').strip() else ''!r} "
f"{_fp(fp)}"
),
)
trace(
"_wait_for_message_batch",
fmt_args=lambda fp, *a, **k: _fp(fp),
)
trace(
"_generate_ai_reply",
fmt_args=lambda fp, *a, **k: _fp(fp),
fmt_result=lambda r: f"{str(r)[:40]!r}" if r else "无回复",
)
trace(
"send_reply",
fmt_args=lambda text, *a, **k: f"{str(text)[:30]!r}",
)
trace("_check_selected_session")
trace("_resume_orphaned_pending_reply")
def main():
max_rounds = int(sys.argv[1]) if len(sys.argv) > 1 else 0
log_path = os.path.join(_HERE, f"traced_{time.strftime('%Y%m%d_%H%M%S')}.log")
handle = open(log_path, "w", encoding="utf-8")
sys.stdout = Tee(sys.__stdout__, handle)
sys.stderr = sys.stdout
settings = load_runtime_settings()
print(f"[启动] 日志文件: {log_path}")
print(f"[启动] 运行配置: {settings}")
import wechat_bot as bot_module
bot_module.AUTO_REPLY_TEXT = settings["auto_reply_text"]
bot = bot_module.WeChatBot()
bot.mouse_idle_enabled = settings["mouse_idle_enabled"]
bot.mouse_idle_seconds = settings["mouse_idle_seconds"]
bot.message_batch_window_seconds = settings["message_batch_window_seconds"]
stop_event = threading.Event()
bot._stop_check = stop_event
bot.safe_window_mode = True
bot.auto_activate_window = True
if not bot.connect(activate=False, wait_if_missing=True):
print("[-] 未能挂载企业微信主窗口,退出。")
return
install_tracing(bot)
print(
f"[启动] HWND=0x{bot.hwnd:08X} "
f"尺寸={bot.R - bot.L}x{bot.B - bot.T} 输入框=({bot.input_x}, {bot.input_y})"
)
print("[启动] 已挂上决策追踪;请向同一个会话连续发 3 条消息复现。")
round_index = 0
try:
while not stop_event.is_set():
round_index += 1
if max_rounds and round_index > max_rounds:
print(f"[结束] 已完成 {max_rounds} 轮。")
break
print("=" * 72)
print(f"[轮询 {round_index}] 开始")
describe_gates(bot)
started = time.monotonic()
try:
bot._poll_once()
except Exception as exc:
import traceback
print(f"[!] 本轮异常: {exc}")
traceback.print_exc()
print(f"[轮询 {round_index}] 结束,耗时 {time.monotonic() - started:.1f}s")
if bot.security_verification_required:
print("[!] 企业微信要求安全验证,已停止。")
break
stop_event.wait(settings["poll_interval"])
except KeyboardInterrupt:
print("[结束] 收到 Ctrl+C,已停止监听。")
finally:
stop_event.set()
handle.flush()
print(f"[结束] 日志已保存: {log_path}")
if __name__ == "__main__":
main()
+26 -26
View File
@@ -1,26 +1,26 @@
"""Read a listener log tolerantly and print either a time window or keyword hits.
Usage:
python tmp/scan_log.py <log> --window HH:MM:SS HH:MM:SS
python tmp/scan_log.py <log> --find "key1|key2"
"""
import re
import sys
path = sys.argv[1]
mode = sys.argv[2]
with open(path, "rb") as fh:
text = fh.read().decode("utf-8", errors="replace")
lines = text.splitlines()
if mode == "--window":
start, end = sys.argv[3], sys.argv[4]
for line in lines:
stamp = line[:8]
if re.match(r"\d\d:\d\d:\d\d", stamp) and start <= stamp <= end:
print(line)
else:
keys = sys.argv[3].split("|")
for line in lines:
if any(k in line for k in keys):
print(line)
"""Read a listener log tolerantly and print either a time window or keyword hits.
Usage:
python tmp/scan_log.py <log> --window HH:MM:SS HH:MM:SS
python tmp/scan_log.py <log> --find "key1|key2"
"""
import re
import sys
path = sys.argv[1]
mode = sys.argv[2]
with open(path, "rb") as fh:
text = fh.read().decode("utf-8", errors="replace")
lines = text.splitlines()
if mode == "--window":
start, end = sys.argv[3], sys.argv[4]
for line in lines:
stamp = line[:8]
if re.match(r"\d\d:\d\d:\d\d", stamp) and start <= stamp <= end:
print(line)
else:
keys = sys.argv[3].split("|")
for line in lines:
if any(k in line for k in keys):
print(line)
+87 -87
View File
@@ -1,87 +1,87 @@
"""把当前源码编译结果与最后一版正确源码的 .pyc 逐个 code 对象比对。
这是修复正确性的最终判据:注释吞掉语句、误拆行等问题都会在字节码上暴露。
行号允许不同(注释多一行少一行不影响语义),比对的是 co_code、常量与名字。
"""
import marshal
import os
import types
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_HERE)
def index_code(root, prefix=""):
table = {}
def walk(c, path):
key = path
suffix = 0
while key in table:
suffix += 1
key = f"{path}#{suffix}"
table[key] = c
for const in c.co_consts:
if isinstance(const, types.CodeType):
walk(const, f"{path}/{const.co_name}")
walk(root, prefix or root.co_name)
return table
def scalars(c):
return tuple(
x for x in c.co_consts if not isinstance(x, types.CodeType)
)
def main():
source = open(os.path.join(_ROOT, "wechat_bot.py"), encoding="utf-8").read()
current = compile(source, "wechat_bot.py", "exec")
good = marshal.loads(
open(os.path.join(_HERE, "wechat_bot.lastgood.pyc"), "rb").read()[16:]
)
left = index_code(current)
right = index_code(good)
only_current = sorted(set(left) - set(right))
only_good = sorted(set(right) - set(left))
print(f"当前 code 对象 {len(left)} 个,正确版 {len(right)}")
if only_good:
print(f"缺失的 code 对象 {len(only_good)} 个(说明有语句被吞进注释):")
for name in only_good[:20]:
print(" ", name)
if only_current:
print(f"多出来的 code 对象 {len(only_current)} 个:")
for name in only_current[:20]:
print(" ", name)
differing = []
for name in sorted(set(left) & set(right)):
a, b = left[name], right[name]
if a.co_code != b.co_code:
differing.append((name, "字节码"))
elif scalars(a) != scalars(b):
differing.append((name, "常量"))
elif a.co_names != b.co_names or a.co_varnames != b.co_varnames:
differing.append((name, "名字表"))
if differing:
print(f"\n有差异的 code 对象 {len(differing)} 个:")
for name, kind in differing[:25]:
print(f" [{kind}] {name}")
if kind == "常量":
a, b = scalars(left[name]), scalars(right[name])
for x, y in zip(a, b):
if x != y:
print(f" 当前={x!r}")
print(f" 应为={y!r}")
break
else:
print("\n所有 code 对象逐字节一致:代码语义与最后一版正确源码完全相同。")
if __name__ == "__main__":
main()
"""把当前源码编译结果与最后一版正确源码的 .pyc 逐个 code 对象比对。
这是修复正确性的最终判据:注释吞掉语句、误拆行等问题都会在字节码上暴露。
行号允许不同(注释多一行少一行不影响语义),比对的是 co_code、常量与名字。
"""
import marshal
import os
import types
_HERE = os.path.dirname(os.path.abspath(__file__))
_ROOT = os.path.dirname(_HERE)
def index_code(root, prefix=""):
table = {}
def walk(c, path):
key = path
suffix = 0
while key in table:
suffix += 1
key = f"{path}#{suffix}"
table[key] = c
for const in c.co_consts:
if isinstance(const, types.CodeType):
walk(const, f"{path}/{const.co_name}")
walk(root, prefix or root.co_name)
return table
def scalars(c):
return tuple(
x for x in c.co_consts if not isinstance(x, types.CodeType)
)
def main():
source = open(os.path.join(_ROOT, "wechat_bot.py"), encoding="utf-8").read()
current = compile(source, "wechat_bot.py", "exec")
good = marshal.loads(
open(os.path.join(_HERE, "wechat_bot.lastgood.pyc"), "rb").read()[16:]
)
left = index_code(current)
right = index_code(good)
only_current = sorted(set(left) - set(right))
only_good = sorted(set(right) - set(left))
print(f"当前 code 对象 {len(left)} 个,正确版 {len(right)}")
if only_good:
print(f"缺失的 code 对象 {len(only_good)} 个(说明有语句被吞进注释):")
for name in only_good[:20]:
print(" ", name)
if only_current:
print(f"多出来的 code 对象 {len(only_current)} 个:")
for name in only_current[:20]:
print(" ", name)
differing = []
for name in sorted(set(left) & set(right)):
a, b = left[name], right[name]
if a.co_code != b.co_code:
differing.append((name, "字节码"))
elif scalars(a) != scalars(b):
differing.append((name, "常量"))
elif a.co_names != b.co_names or a.co_varnames != b.co_varnames:
differing.append((name, "名字表"))
if differing:
print(f"\n有差异的 code 对象 {len(differing)} 个:")
for name, kind in differing[:25]:
print(f" [{kind}] {name}")
if kind == "常量":
a, b = scalars(left[name]), scalars(right[name])
for x, y in zip(a, b):
if x != y:
print(f" 当前={x!r}")
print(f" 应为={y!r}")
break
else:
print("\n所有 code 对象逐字节一致:代码语义与最后一版正确源码完全相同。")
if __name__ == "__main__":
main()