feat: add history replay and work filters
This commit is contained in:
+154
-11
@@ -5,12 +5,37 @@ import logging
|
||||
import time
|
||||
|
||||
from account_browser import BrowserManager, validate_config
|
||||
from account_log import DailyLog
|
||||
from account_log import DailyLog, visible
|
||||
from account_session import Session, SessionError
|
||||
from account_store import Store, decode
|
||||
from account_store import Store, decode, notice_kind, notice_work_id
|
||||
from subscribe_notifications import notice_ids
|
||||
|
||||
|
||||
def history_preview(notice):
|
||||
kind = notice_kind(notice)
|
||||
detail = notice.get(kind) or notice.get("favorite") or notice.get("collect") or {}
|
||||
users = detail.get("from_user") or []
|
||||
if isinstance(users, dict):
|
||||
users = [users]
|
||||
comment = detail.get("comment") or {}
|
||||
if not users and comment.get("user"):
|
||||
users = [comment["user"]]
|
||||
aweme = detail.get("aweme") or notice.get("aweme") or {}
|
||||
return {
|
||||
"nid": notice.get("nid_str") or str(notice.get("nid", "")),
|
||||
"create_time": notice.get("create_time") or notice.get("createTime") or "",
|
||||
"kind": kind,
|
||||
"work_id": notice_work_id(notice),
|
||||
"work_desc": aweme.get("desc") or "",
|
||||
"actor_uids": [str(user.get("uid", "")) for user in users],
|
||||
"actor_names": [
|
||||
user.get("nickname") or user.get("short_id") or "" for user in users
|
||||
],
|
||||
"comment": comment.get("text") or detail.get("content") or "",
|
||||
"business": notice,
|
||||
}
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self, root, playwright):
|
||||
self.store = Store(root)
|
||||
@@ -31,6 +56,8 @@ class Engine:
|
||||
self.ready = set()
|
||||
self.last_write = {}
|
||||
self.verified = set()
|
||||
self.history_cache = {}
|
||||
self.works_cache = {}
|
||||
self.logger = self.audit.logger
|
||||
self.audit.record(
|
||||
"历史摘要",
|
||||
@@ -279,16 +306,13 @@ class Engine:
|
||||
rule = decode(task["params"])
|
||||
if task["action"] == "follow":
|
||||
result = await session.follow(task["target"])
|
||||
return result.get("status", "unknown"), result
|
||||
return result.get("status", "unknown"), visible(result)
|
||||
result = await session.im(
|
||||
"send", task["target"], text=rule["text"], confirm=True
|
||||
)
|
||||
output = visible(result)
|
||||
if type(result.get("success")) is bool and result["success"]:
|
||||
message = result.get("message") or {}
|
||||
return "succeeded", {
|
||||
"client_id": message.get("client_id"),
|
||||
"server_id": message.get("server_id"),
|
||||
}
|
||||
return "succeeded", output
|
||||
known = {
|
||||
"WRONG_ORIGIN",
|
||||
"LOGIN_CHECK_FAILED",
|
||||
@@ -300,7 +324,7 @@ class Engine:
|
||||
"MESSAGE_BUILD_FAILED",
|
||||
}
|
||||
if result.get("error") in known:
|
||||
return "failed", {"code": result["error"]}
|
||||
return "failed", output
|
||||
# Explicit SDK rejection is a failed attempt; missing/ambiguous results are never resent.
|
||||
if (
|
||||
type(result.get("success")) is bool
|
||||
@@ -308,8 +332,10 @@ class Engine:
|
||||
and type(result.get("status_code")) is int
|
||||
and result["status_code"] != 0
|
||||
):
|
||||
return "failed", {"code": result["status_code"]}
|
||||
return "unknown", {"code": "SDK_RESULT_UNCONFIRMED"}
|
||||
return "failed", output
|
||||
if isinstance(output, dict):
|
||||
output.setdefault("code", "SDK_RESULT_UNCONFIRMED")
|
||||
return "unknown", output
|
||||
|
||||
async def perform_batch(self, session, tasks):
|
||||
results = await asyncio.gather(
|
||||
@@ -387,6 +413,110 @@ class Engine:
|
||||
session = None
|
||||
await self.pause(ident, 5)
|
||||
|
||||
async def fetch_history(self, ident):
|
||||
account = self.store.account(ident)
|
||||
if account["role"] != "main" or account["uid"] is None:
|
||||
raise ValueError("请选择已绑定身份的大号")
|
||||
session = await self.open(ident)
|
||||
self.store.log(
|
||||
"历史获取", "开始只读分页获取全部互动通知;不标记已读", source=ident
|
||||
)
|
||||
notices = await session.history_notices()
|
||||
self.history_cache[ident] = {
|
||||
notice.get("nid_str") or str(notice.get("nid", "")): notice
|
||||
for notice in notices
|
||||
}
|
||||
previews = [history_preview(notice) for notice in notices]
|
||||
self.store.log(
|
||||
"历史获取",
|
||||
"全部可用历史事件已加载,等待用户预览和勾选;尚未创建任务",
|
||||
source=ident,
|
||||
事件数=len(previews),
|
||||
)
|
||||
return {"account": ident, "items": previews}
|
||||
|
||||
def enqueue_history(self, ident, ids):
|
||||
if (
|
||||
not isinstance(ids, list)
|
||||
or len(ids) > 50000
|
||||
or any(
|
||||
not isinstance(item, str) or not item.isascii() or not item.isdecimal()
|
||||
for item in ids
|
||||
)
|
||||
):
|
||||
raise ValueError("历史事件选择无效")
|
||||
ids = list(dict.fromkeys(ids))
|
||||
cache = self.history_cache.get(ident)
|
||||
if cache is None:
|
||||
raise ValueError("历史事件预览已失效,请重新获取")
|
||||
if any(item not in cache for item in ids):
|
||||
raise ValueError("选择包含未加载的历史事件")
|
||||
rule = decode(self.store.account(ident)["rule"])
|
||||
if not rule["enabled"] or not (rule["follow"] or rule["dm"]):
|
||||
raise ValueError("请先启用大号规则并至少选择关注或私信")
|
||||
before = self.store.db.execute("SELECT count(*) FROM tasks").fetchone()[0]
|
||||
added = sum(
|
||||
self.store.ingest(ident, cache[item], origin="history") for item in ids
|
||||
)
|
||||
tasks = (
|
||||
self.store.db.execute("SELECT count(*) FROM tasks").fetchone()[0] - before
|
||||
)
|
||||
self.store.log(
|
||||
"历史确认",
|
||||
"用户已确认选择;历史事件按低优先级分配,新消息始终优先",
|
||||
source=ident,
|
||||
选择数=len(ids),
|
||||
新增事件数=added,
|
||||
新增任务数=tasks,
|
||||
)
|
||||
return {"selected": len(ids), "events": added, "tasks": tasks}
|
||||
|
||||
async def fetch_works(self, ident, all_pages):
|
||||
account = self.store.account(ident)
|
||||
if account["role"] != "main" or account["uid"] is None:
|
||||
raise ValueError("请选择已绑定身份的大号")
|
||||
if type(all_pages) is not bool:
|
||||
raise ValueError("作品获取范围无效")
|
||||
session = await self.open(ident)
|
||||
self.store.log(
|
||||
"作品获取",
|
||||
"开始只读获取全部历史作品" if all_pages else "开始只读获取近期作品",
|
||||
source=ident,
|
||||
)
|
||||
works = await session.works(all_pages)
|
||||
self.works_cache[ident] = {work["aweme_id"]: work for work in works}
|
||||
self.store.log(
|
||||
"作品获取",
|
||||
"作品列表已加载,等待用户选择监控范围",
|
||||
source=ident,
|
||||
作品数=len(works),
|
||||
范围="全部历史" if all_pages else "近期",
|
||||
)
|
||||
rule = decode(account["rule"])
|
||||
return {
|
||||
"account": ident,
|
||||
"items": works,
|
||||
"mode": rule["work_mode"],
|
||||
"selected": rule["work_ids"],
|
||||
"all_pages": all_pages,
|
||||
}
|
||||
|
||||
def save_work_filter(self, ident, mode, ids):
|
||||
if mode not in ("all", "selected") or not isinstance(ids, list):
|
||||
raise ValueError("作品监控选择无效")
|
||||
ids = list(dict.fromkeys(ids))
|
||||
if mode == "selected":
|
||||
if not ids:
|
||||
raise ValueError("指定作品模式至少选择一个作品")
|
||||
cache = self.works_cache.get(ident)
|
||||
existing = decode(self.store.account(ident)["rule"])["work_ids"]
|
||||
if cache is None or any(
|
||||
item not in cache and item not in existing for item in ids
|
||||
):
|
||||
raise ValueError("作品列表已失效,请重新获取")
|
||||
self.store.set_work_filter(ident, mode, ids if mode == "selected" else [])
|
||||
return {"mode": mode, "count": len(ids) if mode == "selected" else 0}
|
||||
|
||||
async def command(self, name, data):
|
||||
labels = {
|
||||
"add": "添加账号",
|
||||
@@ -401,6 +531,11 @@ class Engine:
|
||||
"close_browser": "关闭浏览器",
|
||||
"erase": "清除登录数据",
|
||||
"delete_worker": "删除小号",
|
||||
"history_fetch": "获取全部历史事件",
|
||||
"history_enqueue": "确认历史事件操作",
|
||||
"works_recent": "获取近期作品",
|
||||
"works_all": "获取全部历史作品",
|
||||
"work_filter": "保存作品监控范围",
|
||||
}
|
||||
label = labels.get(name, "未知操作")
|
||||
self.store.log("操作请求", label, account=data.get("id"))
|
||||
@@ -439,6 +574,14 @@ class Engine:
|
||||
elif name == "open":
|
||||
self.store.account(data["id"])
|
||||
self.begin_login(data["id"])
|
||||
elif name == "history_fetch":
|
||||
return await self.fetch_history(data["id"])
|
||||
elif name == "history_enqueue":
|
||||
return self.enqueue_history(data["id"], data.get("ids"))
|
||||
elif name in ("works_recent", "works_all"):
|
||||
return await self.fetch_works(data["id"], name == "works_all")
|
||||
elif name == "work_filter":
|
||||
return self.save_work_filter(data["id"], data.get("mode"), data.get("ids"))
|
||||
elif name == "move":
|
||||
self.store.move(data["id"], data.get("owner"))
|
||||
owner = data.get("owner")
|
||||
|
||||
+25
-19
@@ -1,6 +1,6 @@
|
||||
"""Daily UTF-8 business logs; bounded UI history, no raw requests or message bodies."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import deque
|
||||
@@ -11,20 +11,35 @@ from pathlib import Path
|
||||
UI_LIMIT = 2000
|
||||
|
||||
|
||||
CREDENTIAL = re.compile(
|
||||
r"(?i)(cookie|authorization|sessionid|mstoken|a_bogus|password|token|secret|verifyfp|signature|x-bogus|x-tt-params)"
|
||||
)
|
||||
|
||||
|
||||
def clean(value):
|
||||
text = str(value).replace("\r", r"\r").replace("\n", r"\n")
|
||||
text = re.sub(r"[\x00-\x1f\x7f\u2028\u2029\u202a-\u202e]", " ", text)
|
||||
text = re.sub(r"https?://\S+", "[地址已隐藏]", text)
|
||||
text = re.sub(
|
||||
r"(?i)\b(cookie|authorization|sessionid|msToken|a_bogus|password|token|secret)[\"']?\s*[:=].*",
|
||||
"[敏感字段已隐藏]",
|
||||
r"(?i)([?&](?:sessionid|mstoken|a_bogus|token|verifyfp|signature|x-bogus|x-tt-params)=)[^&\s]+",
|
||||
r"\1[凭据已隐藏]",
|
||||
text,
|
||||
)
|
||||
return text[:2000]
|
||||
text = re.sub(
|
||||
r"(?i)\b(cookie|authorization)[\"']?\s*[:=].*",
|
||||
r"\1=[凭据已隐藏]",
|
||||
text,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def reference(value):
|
||||
return "N" + hashlib.sha256(str(value).encode()).hexdigest()[:12]
|
||||
def visible(value, key=""):
|
||||
if CREDENTIAL.search(str(key)):
|
||||
return "[凭据已隐藏]"
|
||||
if isinstance(value, dict):
|
||||
return {str(k): visible(v, k) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [visible(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def recent_lines(directory, limit=UI_LIMIT):
|
||||
@@ -86,18 +101,9 @@ class DailyLog(logging.Handler):
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
if key.lower() in {
|
||||
"text",
|
||||
"body",
|
||||
"cookie",
|
||||
"headers",
|
||||
"params",
|
||||
"result",
|
||||
"profile",
|
||||
}:
|
||||
value = "[已隐藏]"
|
||||
elif not isinstance(value, (str, int, float, bool)):
|
||||
value = "[复杂数据未记录]"
|
||||
value = visible(value, key)
|
||||
if not isinstance(value, (str, int, float, bool)):
|
||||
value = json.dumps(value, ensure_ascii=False, default=str)
|
||||
values.append(f"{clean(key)}={clean(value)}")
|
||||
self.logger.log(
|
||||
level, "[%s] %s %s", clean(stage), clean(message), " ".join(values)
|
||||
|
||||
+112
-3
@@ -9,8 +9,14 @@ from urllib.parse import urlsplit
|
||||
from account_store import decode
|
||||
from douyin_im import EXPRESSION
|
||||
from follow_user import validate_uid
|
||||
from get_current_user import BROWSER_SCRIPT, compact_user, parse_user_response
|
||||
from subscribe_notifications import INSTALL, WAIT, detail_request_script
|
||||
from get_current_user import BROWSER_SCRIPT, business_user, parse_user_response
|
||||
from subscribe_notifications import (
|
||||
INSTALL,
|
||||
WAIT,
|
||||
detail_request_script,
|
||||
history_request_script,
|
||||
works_request_script,
|
||||
)
|
||||
|
||||
|
||||
class SessionError(RuntimeError):
|
||||
@@ -139,7 +145,7 @@ class Session:
|
||||
|
||||
async def profile(self):
|
||||
try:
|
||||
user = compact_user(
|
||||
user = business_user(
|
||||
parse_user_response(
|
||||
await self.evaluate(BROWSER_SCRIPT, main_world=True)
|
||||
)
|
||||
@@ -226,6 +232,109 @@ class Session:
|
||||
raise SessionError("通知详情返回了未请求的 ID,已停止处理")
|
||||
return notices
|
||||
|
||||
async def history_notices(self):
|
||||
notices = {}
|
||||
min_time = max_time = 0
|
||||
seen_cursors = set()
|
||||
for _ in range(1000):
|
||||
response = await self.json(
|
||||
history_request_script(min_time, max_time), main_world=True
|
||||
)
|
||||
try:
|
||||
status = int(response["status"])
|
||||
payload = json.loads(response["body"])
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise SessionError("历史通知响应格式无效") from exc
|
||||
if status != 200 or payload.get("status_code") != 0:
|
||||
raise SessionError(
|
||||
f"历史通知请求失败:HTTP {status},业务码 {payload.get('status_code')}"
|
||||
)
|
||||
rows = payload.get("notice_list_v2")
|
||||
if rows is None:
|
||||
rows = []
|
||||
if not isinstance(rows, list) or any(
|
||||
not isinstance(row, dict) for row in rows
|
||||
):
|
||||
raise SessionError("历史通知列表格式无效")
|
||||
for row in rows:
|
||||
if str(row.get("user_id")) != self.uid:
|
||||
raise SessionError("历史通知所属身份不符")
|
||||
nid = row.get("nid_str") or row.get("nid")
|
||||
validate_uid(nid)
|
||||
notices.setdefault(nid, row)
|
||||
if not payload.get("has_more"):
|
||||
return list(notices.values())
|
||||
cursor = (payload.get("min_time"), payload.get("max_time"))
|
||||
if cursor in seen_cursors or cursor == (min_time, max_time):
|
||||
raise SessionError("历史通知分页游标未推进")
|
||||
if any(type(value) not in (int, float) for value in cursor):
|
||||
raise SessionError("历史通知分页游标格式无效")
|
||||
seen_cursors.add(cursor)
|
||||
min_time, max_time = cursor
|
||||
raise SessionError("历史通知超过 50000 条,已停止以避免无限分页")
|
||||
|
||||
async def works(self, all_pages=False):
|
||||
profile = await self.identity()
|
||||
sec_uid = profile.get("sec_uid") or profile.get("secUid")
|
||||
if not isinstance(sec_uid, str) or not sec_uid:
|
||||
raise SessionError("当前账号缺少作品列表身份参数")
|
||||
works = {}
|
||||
cursor = 0
|
||||
seen_cursors = set()
|
||||
pages = 1000 if all_pages else 1
|
||||
for _ in range(pages):
|
||||
response = await self.json(
|
||||
works_request_script(sec_uid, cursor), main_world=True
|
||||
)
|
||||
try:
|
||||
status = int(response["status"])
|
||||
payload = json.loads(response["body"])
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise SessionError("作品列表响应格式无效") from exc
|
||||
if status != 200 or payload.get("status_code") != 0:
|
||||
raise SessionError(
|
||||
f"作品列表请求失败:HTTP {status},业务码 {payload.get('status_code')}"
|
||||
)
|
||||
rows = payload.get("aweme_list")
|
||||
if rows is None:
|
||||
rows = []
|
||||
if not isinstance(rows, list) or any(
|
||||
not isinstance(row, dict) for row in rows
|
||||
):
|
||||
raise SessionError("作品列表格式无效")
|
||||
for row in rows:
|
||||
author = row.get("author") or {}
|
||||
if str(author.get("uid")) != self.uid:
|
||||
raise SessionError("作品列表所属身份不符")
|
||||
ident = row.get("aweme_id") or row.get("awemeId")
|
||||
validate_uid(ident)
|
||||
cover = ((row.get("video") or {}).get("cover") or {}).get(
|
||||
"url_list"
|
||||
) or []
|
||||
if not cover and row.get("images"):
|
||||
cover = (row["images"][0] or {}).get("url_list") or []
|
||||
works.setdefault(
|
||||
ident,
|
||||
{
|
||||
"aweme_id": ident,
|
||||
"desc": row.get("desc") or "",
|
||||
"create_time": row.get("create_time") or "",
|
||||
"statistics": row.get("statistics") or {},
|
||||
"cover": cover[0] if cover else "",
|
||||
"business": row,
|
||||
},
|
||||
)
|
||||
if not all_pages or not payload.get("has_more"):
|
||||
return list(works.values())
|
||||
next_cursor = payload.get("max_cursor")
|
||||
if type(next_cursor) not in (int, float) or next_cursor < 0:
|
||||
raise SessionError("作品列表分页游标格式无效")
|
||||
if next_cursor in seen_cursors or next_cursor == cursor:
|
||||
raise SessionError("作品列表分页游标未推进")
|
||||
seen_cursors.add(next_cursor)
|
||||
cursor = next_cursor
|
||||
raise SessionError("作品列表超过 18000 条,已停止以避免无限分页")
|
||||
|
||||
async def follow(self, target, check=False):
|
||||
self.require_bound()
|
||||
validate_uid(target)
|
||||
|
||||
+167
-49
@@ -8,7 +8,7 @@ import uuid
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from account_log import UI_LIMIT, reference
|
||||
from account_log import UI_LIMIT, visible
|
||||
from follow_user import validate_uid
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ def validate_rule(rule):
|
||||
rule = dict(rule)
|
||||
rule["require_follow"] = False
|
||||
rule.setdefault("cooldown", 14400)
|
||||
rule.setdefault("work_mode", "all")
|
||||
rule.setdefault("work_ids", [])
|
||||
kinds = rule.get("kinds", [])
|
||||
if not isinstance(kinds, list) or any(
|
||||
k not in ("digg", "follow", "comment", "general_notice") for k in kinds
|
||||
@@ -34,6 +36,18 @@ def validate_rule(rule):
|
||||
raise ValueError("执行间隔需为 5..86400 秒")
|
||||
if type(rule["cooldown"]) is not int or not 0 <= rule["cooldown"] <= 31536000:
|
||||
raise ValueError("同 UID 冷却需为 0..31536000 秒")
|
||||
if rule["work_mode"] not in ("all", "selected"):
|
||||
raise ValueError("作品监控模式无效")
|
||||
if (
|
||||
not isinstance(rule["work_ids"], list)
|
||||
or len(rule["work_ids"]) > 50000
|
||||
or any(
|
||||
not isinstance(item, str) or not item.isascii() or not item.isdecimal()
|
||||
for item in rule["work_ids"]
|
||||
)
|
||||
):
|
||||
raise ValueError("作品 ID 列表无效")
|
||||
rule["work_ids"] = list(dict.fromkeys(rule["work_ids"]))
|
||||
return rule
|
||||
|
||||
|
||||
@@ -46,6 +60,8 @@ DEFAULT_RULE = {
|
||||
"text": "",
|
||||
"interval": 30,
|
||||
"cooldown": 14400,
|
||||
"work_mode": "all",
|
||||
"work_ids": [],
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +72,36 @@ def decode(value):
|
||||
raise ValueError("持久化数据损坏,请从备份恢复") from exc
|
||||
|
||||
|
||||
def notice_work_id(notice):
|
||||
values = [notice]
|
||||
values.extend(
|
||||
notice.get(key)
|
||||
for key in ("comment", "digg", "favorite", "collect", "general_notice")
|
||||
)
|
||||
for value in values:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
for candidate in (value, value.get("aweme"), value.get("item")):
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
ident = candidate.get("aweme_id") or candidate.get("awemeId")
|
||||
if isinstance(ident, str) and ident.isascii() and ident.isdecimal():
|
||||
return ident
|
||||
if type(ident) is int and ident > 0:
|
||||
return str(ident)
|
||||
return ""
|
||||
|
||||
|
||||
def notice_kind(notice):
|
||||
if notice.get("comment"):
|
||||
return "comment"
|
||||
if notice.get("follow"):
|
||||
return "follow"
|
||||
if notice.get("digg"):
|
||||
return "digg"
|
||||
return "general_notice"
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, root, audit=None):
|
||||
self.audit = audit
|
||||
@@ -86,12 +132,14 @@ class Store:
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY, source TEXT NOT NULL REFERENCES accounts(id), nid TEXT NOT NULL,
|
||||
created REAL NOT NULL, targets TEXT NOT NULL, rule TEXT NOT NULL, state TEXT NOT NULL,
|
||||
origin TEXT NOT NULL DEFAULT 'live' CHECK(origin IN ('live','history')),
|
||||
UNIQUE(source,nid));
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY, event INTEGER NOT NULL REFERENCES events(id),
|
||||
source TEXT NOT NULL REFERENCES accounts(id), worker TEXT NOT NULL REFERENCES accounts(id),
|
||||
target TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('follow','dm')),
|
||||
params TEXT NOT NULL, dependency INTEGER REFERENCES tasks(id),
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','succeeded','failed','unknown','cancelled')),
|
||||
result TEXT NOT NULL DEFAULT '', updated REAL NOT NULL,
|
||||
UNIQUE(event,worker,target,action));
|
||||
@@ -109,6 +157,10 @@ class Store:
|
||||
"ALTER TABLE accounts ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
columns = {r["name"] for r in self.db.execute("PRAGMA table_info(inbox)")}
|
||||
event_columns = {
|
||||
r["name"] for r in self.db.execute("PRAGMA table_info(events)")
|
||||
}
|
||||
task_columns = {r["name"] for r in self.db.execute("PRAGMA table_info(tasks)")}
|
||||
with self.transaction():
|
||||
if "retry_count" not in columns:
|
||||
self.db.execute(
|
||||
@@ -118,9 +170,20 @@ class Store:
|
||||
self.db.execute(
|
||||
"ALTER TABLE inbox ADD COLUMN next_retry_at REAL NOT NULL DEFAULT 0"
|
||||
)
|
||||
if "origin" not in event_columns:
|
||||
self.db.execute(
|
||||
"ALTER TABLE events ADD COLUMN origin TEXT NOT NULL DEFAULT 'live' CHECK(origin IN ('live','history'))"
|
||||
)
|
||||
if "priority" not in task_columns:
|
||||
self.db.execute(
|
||||
"ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 100"
|
||||
)
|
||||
for row in self.db.execute("SELECT id,rule FROM accounts").fetchall():
|
||||
rule = validate_rule(decode(row["rule"]))
|
||||
self.db.execute("UPDATE accounts SET rule=? WHERE id=?", (json.dumps(rule), row["id"]))
|
||||
self.db.execute(
|
||||
"UPDATE accounts SET rule=? WHERE id=?",
|
||||
(json.dumps(rule), row["id"]),
|
||||
)
|
||||
self.db.executescript("""
|
||||
CREATE TRIGGER IF NOT EXISTS owner_insert BEFORE INSERT ON accounts
|
||||
WHEN NEW.owner IS NOT NULL AND NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.owner AND role='main')
|
||||
@@ -184,10 +247,6 @@ class Store:
|
||||
if row
|
||||
else "不存在的账号"
|
||||
)
|
||||
elif key == "nid":
|
||||
value = reference(value)
|
||||
elif key == "target":
|
||||
value = "UID尾号 ***" + (str(value)[-4:] if len(str(value)) > 4 else "")
|
||||
elif key == "action":
|
||||
value = {"follow": "关注", "dm": "私信"}.get(value, value)
|
||||
values[labels.get(key, key)] = value
|
||||
@@ -272,19 +331,10 @@ class Store:
|
||||
|
||||
def bind_profile(self, ident, profile):
|
||||
uid = validate_uid(profile.get("uid"))
|
||||
# Store only an explicit allow-list, never cookies, phone numbers or response blobs.
|
||||
saved = {
|
||||
key: profile.get(key)
|
||||
for key in (
|
||||
"uid",
|
||||
"nickname",
|
||||
"douyin_id",
|
||||
"following_count",
|
||||
"follower_count",
|
||||
"total_favorited",
|
||||
"aweme_count",
|
||||
)
|
||||
}
|
||||
saved = visible(profile)
|
||||
if not isinstance(saved, dict):
|
||||
raise ValueError("账号资料格式无效")
|
||||
saved["uid"] = uid
|
||||
with self.transaction():
|
||||
account = self.account(ident)
|
||||
if account["uid"] is not None and account["uid"] != uid:
|
||||
@@ -298,8 +348,10 @@ class Store:
|
||||
if account["uid"] is None:
|
||||
self.log(
|
||||
"身份绑定",
|
||||
"首次身份核验通过,绑定已锁定;不记录真实 UID 或平台资料",
|
||||
"首次身份核验通过,绑定已锁定;业务资料按原文输出,认证凭据保持隐藏",
|
||||
account=ident,
|
||||
UID=uid,
|
||||
平台资料=saved,
|
||||
)
|
||||
self.db.execute(
|
||||
"UPDATE accounts SET uid=?,nickname=?,avatar=?,profile=? WHERE id=?",
|
||||
@@ -321,7 +373,7 @@ class Store:
|
||||
)
|
||||
self.log(
|
||||
"规则更新",
|
||||
"已保存;已入队任务保留原快照,私信正文不写日志",
|
||||
"已保存;已入队任务保留原快照,业务字段按原文写日志",
|
||||
source=ident,
|
||||
启用=rule["enabled"],
|
||||
通知类型=",".join(rule["kinds"]),
|
||||
@@ -330,8 +382,17 @@ class Store:
|
||||
双动作="关注和私信并行" if rule["follow"] and rule["dm"] else "单动作",
|
||||
动作间隔秒=rule["interval"],
|
||||
同组UID冷却秒=rule["cooldown"],
|
||||
作品监控模式=rule["work_mode"],
|
||||
作品ID=rule["work_ids"],
|
||||
私信正文=rule["text"],
|
||||
)
|
||||
|
||||
def set_work_filter(self, ident, mode, ids):
|
||||
rule = decode(self.account(ident)["rule"])
|
||||
rule["work_mode"] = mode
|
||||
rule["work_ids"] = ids
|
||||
self.set_rule(ident, rule)
|
||||
|
||||
def move(self, worker, owner):
|
||||
with self.transaction():
|
||||
account = self.account(worker)
|
||||
@@ -460,16 +521,17 @@ class Store:
|
||||
"SELECT COUNT(*) FROM inbox WHERE source=? AND state='pending'", (source,)
|
||||
).fetchone()[0]
|
||||
|
||||
def ingest(self, source, notice):
|
||||
def ingest(self, source, notice, origin="live"):
|
||||
if origin not in ("live", "history"):
|
||||
raise ValueError("事件来源无效")
|
||||
account = self.account(source)
|
||||
if account["role"] != "main" or str(notice.get("user_id")) != account["uid"]:
|
||||
raise ValueError("通知所属身份不符")
|
||||
nid = validate_uid(notice.get("nid_str") or str(notice.get("nid", "")))
|
||||
kind = next(
|
||||
(k for k in ("digg", "follow", "comment") if notice.get(k)),
|
||||
"general_notice",
|
||||
kind = notice_kind(notice)
|
||||
detail = (
|
||||
notice.get(kind) or notice.get("favorite") or notice.get("collect") or {}
|
||||
)
|
||||
detail = notice.get(kind) or {}
|
||||
users = detail.get("from_user") or []
|
||||
if isinstance(users, dict):
|
||||
users = [users]
|
||||
@@ -477,66 +539,100 @@ class Store:
|
||||
users = [detail["comment"]["user"]]
|
||||
targets = sorted({validate_uid(str(u.get("uid", ""))) for u in users})
|
||||
rule = validate_rule(decode(account["rule"]))
|
||||
enabled = rule["enabled"] and kind in rule["kinds"] and bool(targets)
|
||||
work_id = notice_work_id(notice)
|
||||
work_allowed = (
|
||||
kind == "follow"
|
||||
or rule["work_mode"] == "all"
|
||||
or work_id in rule["work_ids"]
|
||||
)
|
||||
enabled = (
|
||||
rule["enabled"] and kind in rule["kinds"] and bool(targets) and work_allowed
|
||||
)
|
||||
created = notice.get("create_time") or notice.get("createTime") or time.time()
|
||||
if type(created) not in (int, float) or created <= 0:
|
||||
created = time.time()
|
||||
if created > 10_000_000_000:
|
||||
created /= 1000
|
||||
with self.transaction():
|
||||
cur = self.db.execute(
|
||||
"INSERT OR IGNORE INTO events(source,nid,created,targets,rule,state) VALUES (?,?,?,?,?,?)",
|
||||
"INSERT OR IGNORE INTO events(source,nid,created,targets,rule,state,origin) VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
source,
|
||||
nid,
|
||||
time.time(),
|
||||
created,
|
||||
json.dumps(targets),
|
||||
json.dumps(rule),
|
||||
"waiting" if enabled else "ignored",
|
||||
origin,
|
||||
),
|
||||
)
|
||||
event = self.db.execute(
|
||||
"SELECT id FROM events WHERE source=? AND nid=?", (source, nid)
|
||||
).fetchone()["id"]
|
||||
"SELECT id,origin FROM events WHERE source=? AND nid=?", (source, nid)
|
||||
).fetchone()
|
||||
if not cur.rowcount and origin == "live" and event["origin"] == "history":
|
||||
self.db.execute(
|
||||
"UPDATE events SET origin='live' WHERE id=?", (event["id"],)
|
||||
)
|
||||
promoted = self.db.execute(
|
||||
"UPDATE tasks SET priority=100 WHERE event=? AND status='pending'",
|
||||
(event["id"],),
|
||||
).rowcount
|
||||
self.log(
|
||||
"新消息优先",
|
||||
"该通知此前从历史列表进入;已提升未执行任务优先级",
|
||||
source=source,
|
||||
event=event["id"],
|
||||
nid=nid,
|
||||
提升任务数=promoted,
|
||||
)
|
||||
if cur.rowcount:
|
||||
reason = (
|
||||
"规则匹配,等待组内分配"
|
||||
if enabled
|
||||
else (
|
||||
"规则未启用,本通知不生成任务"
|
||||
if not rule["enabled"]
|
||||
else "通知类型未勾选,本通知不生成任务"
|
||||
if kind not in rule["kinds"]
|
||||
else "未提取到有效通知来源用户,不生成任务"
|
||||
)
|
||||
else "规则未启用,本通知不生成任务"
|
||||
if not rule["enabled"]
|
||||
else "通知类型未勾选,本通知不生成任务"
|
||||
if kind not in rule["kinds"]
|
||||
else "未提取到有效通知来源用户,不生成任务"
|
||||
if not targets
|
||||
else "通知不属于当前选择的作品,不生成任务"
|
||||
)
|
||||
self.log(
|
||||
"详情入库",
|
||||
"详情已取得且所属身份一致",
|
||||
source=source,
|
||||
nid=nid,
|
||||
event=event,
|
||||
event=event["id"],
|
||||
来源="实时通知" if origin == "live" else "历史列表",
|
||||
通知类型={
|
||||
"digg": "点赞",
|
||||
"comment": "评论",
|
||||
"follow": "关注",
|
||||
"general_notice": "其他",
|
||||
"general_notice": "其他作品互动",
|
||||
}[kind],
|
||||
作品ID=work_id,
|
||||
目标数量=len(targets),
|
||||
业务数据=notice,
|
||||
)
|
||||
self.log(
|
||||
"规则匹配" if enabled else "通知跳过",
|
||||
reason,
|
||||
source=source,
|
||||
event=event,
|
||||
event=event["id"],
|
||||
)
|
||||
else:
|
||||
elif not (origin == "live" and event["origin"] == "history"):
|
||||
self.log(
|
||||
"详情去重",
|
||||
"已有该通知,不重复生成事件或任务",
|
||||
source=source,
|
||||
event=event,
|
||||
event=event["id"],
|
||||
nid=nid,
|
||||
)
|
||||
self.db.execute(
|
||||
"UPDATE inbox SET state='done' WHERE source=? AND nid=?", (source, nid)
|
||||
)
|
||||
self._dispatch(source)
|
||||
return bool(cur.rowcount)
|
||||
|
||||
def dispatch(self, source):
|
||||
with self.transaction():
|
||||
@@ -545,7 +641,7 @@ class Store:
|
||||
def _dispatch(self, source):
|
||||
main = self.account(source)
|
||||
events = self.db.execute(
|
||||
"SELECT * FROM events WHERE source=? AND state='waiting' ORDER BY id",
|
||||
"SELECT * FROM events WHERE source=? AND state='waiting' ORDER BY CASE origin WHEN 'live' THEN 0 ELSE 1 END,id",
|
||||
(source,),
|
||||
).fetchall()
|
||||
if not events:
|
||||
@@ -600,9 +696,30 @@ class Store:
|
||||
)
|
||||
continue
|
||||
active = self.db.execute(
|
||||
"SELECT status FROM tasks WHERE source=? AND target=? AND status IN ('pending','running','unknown') ORDER BY id DESC LIMIT 1",
|
||||
"""SELECT t.id,t.event,t.status,e.origin FROM tasks t JOIN events e ON e.id=t.event
|
||||
WHERE t.source=? AND t.target=? AND t.status IN ('pending','running','unknown') ORDER BY t.id""",
|
||||
(source, target),
|
||||
).fetchone()
|
||||
).fetchall()
|
||||
if event["origin"] == "live":
|
||||
history = [
|
||||
row
|
||||
for row in active
|
||||
if row["status"] == "pending" and row["origin"] == "history"
|
||||
]
|
||||
for row in history:
|
||||
self.db.execute(
|
||||
"UPDATE tasks SET status='cancelled',result='新消息优先,取消未执行历史任务',updated=? WHERE id=? AND status='pending'",
|
||||
(time.time(), row["id"]),
|
||||
)
|
||||
self.log(
|
||||
"新消息优先",
|
||||
"同 UID 新消息到达,取消尚未执行的历史任务,不重复打扰",
|
||||
source=source,
|
||||
event=event["id"],
|
||||
task=row["id"],
|
||||
target=target,
|
||||
)
|
||||
active = [row for row in active if row not in history]
|
||||
if active:
|
||||
skipped += 1
|
||||
self.log(
|
||||
@@ -611,7 +728,7 @@ class Store:
|
||||
source=source,
|
||||
event=event["id"],
|
||||
target=target,
|
||||
现有状态=active["status"],
|
||||
现有状态=active[-1]["status"],
|
||||
)
|
||||
continue
|
||||
cooldown = rule.get("cooldown", 14400)
|
||||
@@ -635,7 +752,7 @@ class Store:
|
||||
if not rule[action]:
|
||||
continue
|
||||
cur = self.db.execute(
|
||||
"INSERT INTO tasks(event,source,worker,target,action,params,dependency,updated) VALUES (?,?,?,?,?,?,NULL,?)",
|
||||
"INSERT INTO tasks(event,source,worker,target,action,params,dependency,priority,updated) VALUES (?,?,?,?,?,?,NULL,?,?)",
|
||||
(
|
||||
event["id"],
|
||||
source,
|
||||
@@ -643,6 +760,7 @@ class Store:
|
||||
target,
|
||||
action,
|
||||
json.dumps(rule),
|
||||
100 if event["origin"] == "live" else 0,
|
||||
now,
|
||||
),
|
||||
)
|
||||
@@ -706,7 +824,7 @@ class Store:
|
||||
first = self.db.execute(
|
||||
"""SELECT t.* FROM tasks t WHERE worker=? AND source=? AND status='pending'
|
||||
AND (dependency IS NULL OR EXISTS(SELECT 1 FROM tasks d WHERE d.id=t.dependency AND d.status='succeeded'))
|
||||
ORDER BY id LIMIT 1""",
|
||||
ORDER BY priority DESC,id LIMIT 1""",
|
||||
(worker, account["owner"]),
|
||||
).fetchone()
|
||||
if first is None:
|
||||
|
||||
+257
-2
@@ -48,6 +48,7 @@ class Backend(QThread):
|
||||
snapshot_ready = Signal(dict)
|
||||
response = Signal(str, bool, str)
|
||||
logs_ready = Signal(list)
|
||||
data_ready = Signal(str, object)
|
||||
stopped = Signal()
|
||||
|
||||
def __init__(self, root):
|
||||
@@ -76,7 +77,9 @@ class Backend(QThread):
|
||||
if name == "quit":
|
||||
break
|
||||
try:
|
||||
await engine.command(name, data)
|
||||
result = await engine.command(name, data)
|
||||
if result is not None:
|
||||
self.data_ready.emit(name, result)
|
||||
self.response.emit(name, True, "操作完成")
|
||||
except Exception as exc:
|
||||
# Only explicitly safe human-facing errors; never emit CDP stack/URLs.
|
||||
@@ -207,6 +210,9 @@ class Window(QMainWindow):
|
||||
("关闭浏览器", lambda: self.selected_command("close_browser")),
|
||||
("删除登录数据", self.erase),
|
||||
("删除小号", self.delete_worker),
|
||||
("历史事件操作", self.history_events),
|
||||
("获取近期作品", lambda: self.fetch_works(False)),
|
||||
("获取全部历史作品", lambda: self.fetch_works(True)),
|
||||
]:
|
||||
self.button(actions, text, handler)
|
||||
self.tabs.addTab(accounts, "账号组")
|
||||
@@ -269,6 +275,7 @@ class Window(QMainWindow):
|
||||
backend.snapshot_ready.connect(self.refresh)
|
||||
backend.response.connect(self.response)
|
||||
backend.logs_ready.connect(self.append_logs)
|
||||
backend.data_ready.connect(self.command_data)
|
||||
backend.stopped.connect(self.finished)
|
||||
|
||||
def button(self, layout, text, handler):
|
||||
@@ -329,6 +336,252 @@ class Window(QMainWindow):
|
||||
):
|
||||
self.send("delete_worker", {"id": account["id"]})
|
||||
|
||||
def history_events(self):
|
||||
account = self.selected()
|
||||
if not account or account["role"] != "main" or not account["uid"]:
|
||||
QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。")
|
||||
return
|
||||
self.send("history_fetch", {"id": account["id"]})
|
||||
|
||||
def fetch_works(self, all_pages):
|
||||
account = self.selected()
|
||||
if not account or account["role"] != "main" or not account["uid"]:
|
||||
QMessageBox.information(self, "提示", "请先选择已登录并绑定身份的大号。")
|
||||
return
|
||||
self.send("works_all" if all_pages else "works_recent", {"id": account["id"]})
|
||||
|
||||
def command_data(self, name, payload):
|
||||
if name == "history_fetch":
|
||||
self.history_dialog(payload)
|
||||
elif name in ("works_recent", "works_all"):
|
||||
self.works_dialog(payload)
|
||||
elif name == "history_enqueue":
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"历史事件已确认",
|
||||
f"选择 {payload['selected']} 条;新增事件 {payload['events']} 条;创建任务 {payload['tasks']} 条。\n重复、规则不匹配、作品范围外、冷却或自操作事件不会创建任务。新消息优先。",
|
||||
)
|
||||
elif name == "work_filter":
|
||||
text = (
|
||||
"全部作品"
|
||||
if payload["mode"] == "all"
|
||||
else f"指定的 {payload['count']} 个作品"
|
||||
)
|
||||
QMessageBox.information(self, "作品监控已保存", "当前监控:" + text)
|
||||
|
||||
@staticmethod
|
||||
def set_table_checks(table, state):
|
||||
for row in range(table.rowCount()):
|
||||
item = table.item(row, 0)
|
||||
if item is not None:
|
||||
item.setCheckState(state)
|
||||
|
||||
@staticmethod
|
||||
def checked_table_data(table):
|
||||
values = []
|
||||
for row in range(table.rowCount()):
|
||||
item = table.item(row, 0)
|
||||
if item is not None and item.checkState() == Qt.CheckState.Checked:
|
||||
values.append(item.data(Qt.ItemDataRole.UserRole))
|
||||
return values
|
||||
|
||||
def history_dialog(self, payload):
|
||||
items = payload["items"]
|
||||
win = QDialog(self)
|
||||
win.setWindowTitle(f"历史事件预览 — 共 {len(items)} 条(尚未创建任务)")
|
||||
win.resize(1500, 800)
|
||||
layout = QVBoxLayout(win)
|
||||
note = QLabel(
|
||||
"勾选并确认后才按当前规则分配。历史任务优先级低于新消息;同组 UID 冷却、作品筛选和自操作保护继续生效。业务字段完整显示,认证凭据仍不输出。"
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
table = QTableWidget(len(items), 10)
|
||||
table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"选择",
|
||||
"时间",
|
||||
"类型",
|
||||
"来源UID",
|
||||
"来源昵称",
|
||||
"作品ID",
|
||||
"作品描述",
|
||||
"评论/内容",
|
||||
"通知ID",
|
||||
"完整业务数据",
|
||||
]
|
||||
)
|
||||
for row, item in enumerate(items):
|
||||
check = QTableWidgetItem()
|
||||
check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
check.setCheckState(Qt.CheckState.Unchecked)
|
||||
check.setData(Qt.ItemDataRole.UserRole, item["nid"])
|
||||
table.setItem(row, 0, check)
|
||||
values = [
|
||||
item["create_time"],
|
||||
{
|
||||
"digg": "点赞/作品互动",
|
||||
"comment": "评论",
|
||||
"follow": "关注",
|
||||
"general_notice": "其他作品互动",
|
||||
}.get(item["kind"], item["kind"]),
|
||||
", ".join(item["actor_uids"]),
|
||||
", ".join(item["actor_names"]),
|
||||
item["work_id"],
|
||||
item["work_desc"],
|
||||
item["comment"],
|
||||
item["nid"],
|
||||
json.dumps(item["business"], ensure_ascii=False, default=str),
|
||||
]
|
||||
for column, value in enumerate(values, 1):
|
||||
cell = QTableWidgetItem(str(value))
|
||||
cell.setToolTip(str(value))
|
||||
table.setItem(row, column, cell)
|
||||
table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
table.horizontalHeader().setSectionResizeMode(9, QHeaderView.ResizeMode.Stretch)
|
||||
layout.addWidget(table)
|
||||
controls = QHBoxLayout()
|
||||
select_all = QPushButton("全选")
|
||||
clear = QPushButton("清空选择")
|
||||
select_all.clicked.connect(
|
||||
lambda: self.set_table_checks(table, Qt.CheckState.Checked)
|
||||
)
|
||||
clear.clicked.connect(
|
||||
lambda: self.set_table_checks(table, Qt.CheckState.Unchecked)
|
||||
)
|
||||
controls.addWidget(select_all)
|
||||
controls.addWidget(clear)
|
||||
controls.addStretch()
|
||||
layout.addLayout(controls)
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(win.accept)
|
||||
buttons.rejected.connect(win.reject)
|
||||
layout.addWidget(buttons)
|
||||
if win.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
selected = self.checked_table_data(table)
|
||||
if not selected:
|
||||
QMessageBox.information(self, "没有选择", "未创建任何历史任务。")
|
||||
return
|
||||
if (
|
||||
QMessageBox.question(
|
||||
self,
|
||||
"确认历史批量操作",
|
||||
f"确认将 {len(selected)} 条历史事件交给小号按当前规则处理?\n这可能产生真实关注和私信;新消息优先,冷却和作品筛选仍会跳过部分事件。",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
== QMessageBox.StandardButton.Yes
|
||||
):
|
||||
self.send("history_enqueue", {"id": payload["account"], "ids": selected})
|
||||
|
||||
def works_dialog(self, payload):
|
||||
items = payload["items"]
|
||||
loaded = {item["aweme_id"] for item in items}
|
||||
existing = set(payload["selected"])
|
||||
hidden = existing - loaded
|
||||
win = QDialog(self)
|
||||
win.setWindowTitle(
|
||||
("全部历史作品" if payload["all_pages"] else "近期作品")
|
||||
+ f" — {len(items)} 条"
|
||||
)
|
||||
win.resize(1500, 800)
|
||||
layout = QVBoxLayout(win)
|
||||
all_mode = QCheckBox("监控全部作品(保持兼容;新作品自动包含)")
|
||||
all_mode.setChecked(payload["mode"] == "all")
|
||||
layout.addWidget(all_mode)
|
||||
note = QLabel(
|
||||
"取消上方选项后,仅监控勾选作品的点赞、评论、收藏或其他带作品 ID 的互动;关注通知不受作品筛选。指定模式下新发布作品不会自动加入。"
|
||||
+ (
|
||||
f"\n当前还有 {len(hidden)} 个已选历史作品未包含在本次近期列表中,将继续保留;使用“获取全部历史作品”可统一管理。"
|
||||
if hidden
|
||||
else ""
|
||||
)
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
table = QTableWidget(len(items), 7)
|
||||
table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"选择",
|
||||
"发布时间",
|
||||
"作品描述",
|
||||
"作品ID",
|
||||
"统计",
|
||||
"封面URL",
|
||||
"完整业务数据",
|
||||
]
|
||||
)
|
||||
for row, item in enumerate(items):
|
||||
check = QTableWidgetItem()
|
||||
check.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
check.setCheckState(
|
||||
Qt.CheckState.Checked
|
||||
if item["aweme_id"] in existing
|
||||
else Qt.CheckState.Unchecked
|
||||
)
|
||||
check.setData(Qt.ItemDataRole.UserRole, item["aweme_id"])
|
||||
table.setItem(row, 0, check)
|
||||
values = [
|
||||
item["create_time"],
|
||||
item["desc"],
|
||||
item["aweme_id"],
|
||||
json.dumps(item["statistics"], ensure_ascii=False, default=str),
|
||||
item["cover"],
|
||||
json.dumps(item["business"], ensure_ascii=False, default=str),
|
||||
]
|
||||
for column, value in enumerate(values, 1):
|
||||
cell = QTableWidgetItem(str(value))
|
||||
cell.setToolTip(str(value))
|
||||
table.setItem(row, column, cell)
|
||||
table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeMode.Stretch)
|
||||
table.setDisabled(all_mode.isChecked())
|
||||
all_mode.toggled.connect(table.setDisabled)
|
||||
layout.addWidget(table)
|
||||
controls = QHBoxLayout()
|
||||
select_all = QPushButton("全选当前列表")
|
||||
clear = QPushButton("清空当前列表")
|
||||
select_all.clicked.connect(
|
||||
lambda: self.set_table_checks(table, Qt.CheckState.Checked)
|
||||
)
|
||||
clear.clicked.connect(
|
||||
lambda: self.set_table_checks(table, Qt.CheckState.Unchecked)
|
||||
)
|
||||
controls.addWidget(select_all)
|
||||
controls.addWidget(clear)
|
||||
controls.addStretch()
|
||||
layout.addLayout(controls)
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Save
|
||||
| QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(win.accept)
|
||||
buttons.rejected.connect(win.reject)
|
||||
layout.addWidget(buttons)
|
||||
if win.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
mode = "all" if all_mode.isChecked() else "selected"
|
||||
selected = list(hidden)
|
||||
selected.extend(self.checked_table_data(table))
|
||||
selected = list(dict.fromkeys(selected))
|
||||
if mode == "selected" and not selected:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"没有选择作品",
|
||||
"指定作品模式至少选择一个作品;如需暂停请关闭自动操作规则。",
|
||||
)
|
||||
return
|
||||
self.send(
|
||||
"work_filter", {"id": payload["account"], "mode": mode, "ids": selected}
|
||||
)
|
||||
|
||||
def selected(self):
|
||||
item = self.tree.currentItem()
|
||||
ident = item.data(0, Qt.ItemDataRole.UserRole) if item else None
|
||||
@@ -434,7 +687,7 @@ class Window(QMainWindow):
|
||||
("digg", "点赞"),
|
||||
("follow", "关注"),
|
||||
("comment", "评论"),
|
||||
("general_notice", "其他(仅有明确来源 UID 时)"),
|
||||
("general_notice", "收藏/其他作品互动(仅有明确来源 UID 时)"),
|
||||
]:
|
||||
check = QCheckBox(label)
|
||||
check.setChecked(kind in rule["kinds"])
|
||||
@@ -469,6 +722,8 @@ class Window(QMainWindow):
|
||||
"text": text.toPlainText(),
|
||||
"interval": interval.value(),
|
||||
"cooldown": cooldown.value() * 60,
|
||||
"work_mode": rule.get("work_mode", "all"),
|
||||
"work_ids": rule.get("work_ids", []),
|
||||
}
|
||||
try:
|
||||
validate_rule(value)
|
||||
|
||||
+11
-19
@@ -6,7 +6,9 @@ import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from account_log import visible
|
||||
from cdp_explicit import ( # pyright: ignore[reportMissingImports]
|
||||
add_cdp_args,
|
||||
configure,
|
||||
@@ -62,24 +64,14 @@ def parse_user_response(value) -> dict:
|
||||
return {**user, "uid": str(uid)}
|
||||
|
||||
|
||||
def compact_user(user: dict) -> dict:
|
||||
"""保留个人信息常用字段,避免把手机号等敏感字段写入文件。"""
|
||||
avatars = (user.get("avatar_300x300") or user.get("avatar_thumb") or {}).get(
|
||||
"url_list"
|
||||
) or []
|
||||
return {
|
||||
"uid": user.get("uid"),
|
||||
"sec_uid": user.get("sec_uid"),
|
||||
"nickname": user.get("nickname"),
|
||||
"douyin_id": user.get("short_id") or user.get("unique_id"),
|
||||
"signature": user.get("signature", ""),
|
||||
"following_count": user.get("following_count", 0),
|
||||
"follower_count": user.get("follower_count", 0),
|
||||
"total_favorited": user.get("total_favorited", 0),
|
||||
"aweme_count": user.get("aweme_count", 0),
|
||||
"gender": user.get("gender", 0),
|
||||
"avatar_url": avatars[0] if avatars else None,
|
||||
}
|
||||
def business_user(user: dict) -> dict:
|
||||
"""Return full platform business data while retaining credential protection."""
|
||||
result = cast(dict[str, Any], visible(user))
|
||||
avatar = result.get("avatar_300x300") or result.get("avatar_thumb") or {}
|
||||
avatars = avatar.get("url_list") if isinstance(avatar, dict) else []
|
||||
result["douyin_id"] = result.get("short_id") or result.get("unique_id") or ""
|
||||
result["avatar_url"] = avatars[0] if avatars else result.get("avatar_url") or ""
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -89,7 +81,7 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
configure(args)
|
||||
try:
|
||||
user = compact_user(get_user_from_browser())
|
||||
user = business_user(get_user_from_browser())
|
||||
args.output.write_text(
|
||||
json.dumps(
|
||||
{"fetched_at": datetime.now().astimezone().isoformat(), "user": user},
|
||||
|
||||
@@ -185,6 +185,116 @@ def detail_request_script(ids):
|
||||
})(IDS)""".replace("IDS", json.dumps(ids))
|
||||
|
||||
|
||||
def native_read_script(
|
||||
endpoint, module_needle, method_needle, export_name, request, expected
|
||||
):
|
||||
config = {
|
||||
"endpoint": endpoint,
|
||||
"moduleNeedle": module_needle,
|
||||
"methodNeedle": method_needle,
|
||||
"exportName": export_name,
|
||||
"request": request,
|
||||
"expected": expected,
|
||||
}
|
||||
return r"""(async config => {
|
||||
if (location.origin !== 'https://www.douyin.com') throw Error('抖音页面已关闭');
|
||||
const chunks = window.webpackChunkdouyin_web;
|
||||
if (!chunks) throw Error('未找到抖音运行时');
|
||||
let require;
|
||||
chunks.push([['native-read-' + Date.now()], {}, r => { require = r; }]);
|
||||
chunks.pop();
|
||||
const endpointLiteral = JSON.stringify(config.endpoint);
|
||||
const entry = Object.entries(require.m).find(([, f]) => {
|
||||
const source = String(f);
|
||||
return source.includes(config.moduleNeedle) && source.includes(endpointLiteral)
|
||||
&& (!config.methodNeedle || source.includes(config.methodNeedle));
|
||||
});
|
||||
const api = entry && require(entry[0]);
|
||||
const method = config.exportName ? api?.[config.exportName] : Object.values(api || {}).find(
|
||||
value => typeof value === 'function' && String(value).includes(endpointLiteral)
|
||||
&& (!config.methodNeedle || String(value).includes(config.methodNeedle)));
|
||||
const client = window.axiosInstance;
|
||||
if (typeof method !== 'function' || !client?.interceptors?.response)
|
||||
throw Error('只读请求 SDK 未就绪或已变化');
|
||||
let raw, timer;
|
||||
const observer = client.interceptors.response.use(response => {
|
||||
const params = response.config?.params || {};
|
||||
const url = new URL(response.config?.url || '', location.origin);
|
||||
const matched = Object.entries(config.expected).every(([key, value]) => String(params[key]) === String(value));
|
||||
const xhr = response.request;
|
||||
if (url.origin === location.origin && url.pathname === config.endpoint && matched
|
||||
&& xhr && (!xhr.responseType || xhr.responseType === 'text')
|
||||
&& typeof xhr.responseText === 'string')
|
||||
raw = {status: response.status, body: xhr.responseText};
|
||||
return response;
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
method(config.request),
|
||||
new Promise((_, reject) => { timer = setTimeout(() => reject(Error('timeout')), 20000); })
|
||||
]);
|
||||
} catch (_) {
|
||||
throw Error('只读 SDK 请求失败或超时,请检查登录与网络');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
client.interceptors.response.eject(observer);
|
||||
}
|
||||
if (!raw) throw Error('无法读取只读请求原始响应,SDK 可能已变化');
|
||||
return JSON.stringify(raw);
|
||||
})(CONFIG)""".replace("CONFIG", json.dumps(config, ensure_ascii=False))
|
||||
|
||||
|
||||
def history_request_script(min_time=0, max_time=0, count=50):
|
||||
if type(min_time) not in (int, float) or type(max_time) not in (int, float):
|
||||
raise ValueError("历史通知游标无效")
|
||||
if type(count) is not int or not 1 <= count <= 50:
|
||||
raise ValueError("历史通知每页数量需为 1..50")
|
||||
params = {
|
||||
"notice_group": 700,
|
||||
"count": count,
|
||||
"min_time": min_time,
|
||||
"max_time": max_time,
|
||||
"is_mark_read": 0,
|
||||
}
|
||||
return native_read_script(
|
||||
"/aweme/v1/web/notice/",
|
||||
"/aweme/v1/web/notice/",
|
||||
"is_new_notice",
|
||||
"",
|
||||
params,
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def works_request_script(sec_uid, cursor=0, count=18):
|
||||
if (
|
||||
not isinstance(sec_uid, str)
|
||||
or not sec_uid
|
||||
or len(sec_uid) > 256
|
||||
or any(ord(char) < 32 for char in sec_uid)
|
||||
):
|
||||
raise ValueError("作品列表身份参数无效")
|
||||
if type(cursor) is not int or cursor < 0:
|
||||
raise ValueError("作品列表游标无效")
|
||||
if type(count) is not int or not 1 <= count <= 50:
|
||||
raise ValueError("作品列表每页数量需为 1..50")
|
||||
request = {
|
||||
"userId": sec_uid,
|
||||
"maxCursor": cursor,
|
||||
"count": count,
|
||||
"needTimeList": True,
|
||||
}
|
||||
expected = {"sec_user_id": sec_uid, "max_cursor": cursor, "count": count}
|
||||
return native_read_script(
|
||||
"/aweme/v1/web/aweme/post/",
|
||||
"/aweme/v1/web/aweme/post/",
|
||||
"",
|
||||
"fetchUserPost",
|
||||
request,
|
||||
expected,
|
||||
)
|
||||
|
||||
|
||||
def details(ids, uid):
|
||||
expression = detail_request_script(ids)
|
||||
# 推送可能先于详情入库;仅对这次事件重试,不做定时通知扫描。
|
||||
|
||||
+291
-16
@@ -39,6 +39,17 @@ def notice(uid, nid="111", target="999"):
|
||||
return {"user_id": uid, "nid_str": nid, "follow": {"from_user": [{"uid": target}]}}
|
||||
|
||||
|
||||
def work_notice(uid, nid, target, work, kind="digg"):
|
||||
return {
|
||||
"user_id": uid,
|
||||
"nid_str": nid,
|
||||
kind: {
|
||||
"from_user": [{"uid": target, "nickname": "完整昵称"}],
|
||||
"aweme": {"aweme_id": work, "desc": "完整作品描述"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class StoreTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
@@ -63,7 +74,9 @@ class StoreTest(unittest.TestCase):
|
||||
validate_rule(rule(interval=True))
|
||||
with self.assertRaises(ValueError):
|
||||
validate_rule(rule(cooldown=True))
|
||||
normalized = validate_rule({k: v for k, v in rule(require_follow=True).items() if k != "cooldown"})
|
||||
normalized = validate_rule(
|
||||
{k: v for k, v in rule(require_follow=True).items() if k != "cooldown"}
|
||||
)
|
||||
self.assertFalse(normalized["require_follow"])
|
||||
self.assertEqual(normalized["cooldown"], 14400)
|
||||
self.assertFalse(json.loads(self.store.account(self.other)["rule"])["enabled"])
|
||||
@@ -91,7 +104,9 @@ class StoreTest(unittest.TestCase):
|
||||
self.assertEqual(saved["uid"], "301")
|
||||
self.assertEqual(saved["nickname"], "自动昵称")
|
||||
self.assertEqual(json.loads(saved["profile"])["follower_count"], 123)
|
||||
self.assertNotIn("must-not-store", saved["profile"])
|
||||
stored_profile = json.loads(saved["profile"])
|
||||
self.assertEqual(stored_profile["cookie"], "[凭据已隐藏]")
|
||||
self.assertEqual(stored_profile["phone"], "must-not-store")
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.bind_profile(b, profile)
|
||||
self.assertIsNone(self.store.account(b)["uid"])
|
||||
@@ -141,7 +156,12 @@ class StoreTest(unittest.TestCase):
|
||||
|
||||
def test_dedup_round_robin_and_parallel_batch(self):
|
||||
second = self.store.add("worker", "第二小号", "202", self.main)
|
||||
for nid, target in (("111", "901"), ("111", "901"), ("112", "902"), ("113", "903")):
|
||||
for nid, target in (
|
||||
("111", "901"),
|
||||
("111", "901"),
|
||||
("112", "902"),
|
||||
("113", "903"),
|
||||
):
|
||||
self.store.ingest(self.main, notice("101", nid, target))
|
||||
tasks = sorted(self.store.tasks(), key=lambda t: t["id"])
|
||||
self.assertEqual(len(tasks), 6)
|
||||
@@ -150,13 +170,85 @@ class StoreTest(unittest.TestCase):
|
||||
)
|
||||
batch = self.store.claim_batch(self.worker)
|
||||
self.assertEqual({task["action"] for task in batch}, {"follow", "dm"})
|
||||
self.assertEqual({task["status"] for task in self.store.tasks() if task["id"] in {t["id"] for t in batch}}, {"running"})
|
||||
self.assertEqual(
|
||||
{
|
||||
task["status"]
|
||||
for task in self.store.tasks()
|
||||
if task["id"] in {t["id"] for t in batch}
|
||||
},
|
||||
{"running"},
|
||||
)
|
||||
self.assertIsNone(self.store.claim(self.worker))
|
||||
self.store.finish(batch[0]["id"], "failed", {})
|
||||
self.store.finish(batch[1]["id"], "unknown", {})
|
||||
next_batch = self.store.claim_batch(self.worker)
|
||||
self.assertEqual({task["target"] for task in next_batch}, {"903"})
|
||||
|
||||
def test_selected_work_filter_covers_work_interactions_but_not_follow(self):
|
||||
self.store.set_rule(
|
||||
self.main,
|
||||
rule(
|
||||
dm=False,
|
||||
cooldown=0,
|
||||
kinds=["digg", "follow", "comment", "general_notice"],
|
||||
work_mode="selected",
|
||||
work_ids=["700"],
|
||||
),
|
||||
)
|
||||
self.assertTrue(
|
||||
self.store.ingest(self.main, work_notice("101", "1011", "901", "700"))
|
||||
)
|
||||
self.assertTrue(
|
||||
self.store.ingest(self.main, work_notice("101", "1012", "902", "701"))
|
||||
)
|
||||
self.assertTrue(
|
||||
self.store.ingest(
|
||||
self.main, work_notice("101", "1013", "903", "700", "favorite")
|
||||
)
|
||||
)
|
||||
self.assertTrue(self.store.ingest(self.main, notice("101", "1014", "904")))
|
||||
self.assertEqual(len(self.store.tasks()), 3)
|
||||
states = dict(
|
||||
self.store.db.execute(
|
||||
"SELECT nid,state FROM events WHERE source=?", (self.main,)
|
||||
)
|
||||
)
|
||||
self.assertEqual(states["1012"], "ignored")
|
||||
self.assertEqual(
|
||||
{states[nid] for nid in ("1011", "1013", "1014")}, {"dispatched"}
|
||||
)
|
||||
|
||||
def test_live_tasks_preempt_history_and_promote_same_notification(self):
|
||||
self.store.set_rule(self.main, rule(dm=False, cooldown=0))
|
||||
self.store.ingest(self.main, notice("101", "2001", "901"), origin="history")
|
||||
self.store.ingest(self.main, notice("101", "2002", "902"), origin="history")
|
||||
self.store.ingest(self.main, notice("101", "2003", "903"), origin="live")
|
||||
first = self.store.claim_batch(self.worker)
|
||||
self.assertEqual({task["target"] for task in first}, {"903"})
|
||||
for task in first:
|
||||
self.store.finish(task["id"], "succeeded", {})
|
||||
self.store.ingest(self.main, notice("101", "2004", "901"), origin="live")
|
||||
old = self.store.db.execute(
|
||||
"SELECT status FROM tasks WHERE event=(SELECT id FROM events WHERE nid='2001')"
|
||||
).fetchall()
|
||||
self.assertEqual({row["status"] for row in old}, {"cancelled"})
|
||||
promoted = notice("101", "2005", "904")
|
||||
self.store.ingest(self.main, promoted, origin="history")
|
||||
self.store.ingest(self.main, promoted, origin="live")
|
||||
event = self.store.db.execute(
|
||||
"SELECT id,origin FROM events WHERE source=? AND nid='2005'", (self.main,)
|
||||
).fetchone()
|
||||
self.assertEqual(event["origin"], "live")
|
||||
self.assertEqual(
|
||||
{
|
||||
row["priority"]
|
||||
for row in self.store.db.execute(
|
||||
"SELECT priority FROM tasks WHERE event=?", (event["id"],)
|
||||
)
|
||||
},
|
||||
{100},
|
||||
)
|
||||
|
||||
def test_group_cooldown_persists_is_shared_and_other_groups_are_independent(self):
|
||||
second = self.store.add("worker", "第二小号", "202", self.main)
|
||||
other_worker = self.store.add("worker", "其他组小号", "203", self.other)
|
||||
@@ -172,18 +264,26 @@ class StoreTest(unittest.TestCase):
|
||||
batch = self.store.claim_batch(self.worker)
|
||||
self.assertEqual(len(batch), 2)
|
||||
self.assertEqual(
|
||||
self.store.db.execute("SELECT last_at FROM cooldowns WHERE source=? AND target='999'", (self.main,)).fetchone()[0], 100
|
||||
self.store.db.execute(
|
||||
"SELECT last_at FROM cooldowns WHERE source=? AND target='999'",
|
||||
(self.main,),
|
||||
).fetchone()[0],
|
||||
100,
|
||||
)
|
||||
for task in batch:
|
||||
self.store.finish(task["id"], "succeeded", {})
|
||||
# The same UID in a different main-account group is independent.
|
||||
self.store.ingest(self.other, notice("102", "211", "999"))
|
||||
self.assertEqual(len([t for t in self.store.tasks() if t["worker"] == other_worker]), 2)
|
||||
self.assertEqual(
|
||||
len([t for t in self.store.tasks() if t["worker"] == other_worker]), 2
|
||||
)
|
||||
self.store.close()
|
||||
self.store = Store(self.temp.name)
|
||||
with patch("account_store.time.time", return_value=200):
|
||||
self.store.ingest(self.main, notice("101", "113", "999"))
|
||||
self.assertEqual(len([t for t in self.store.tasks() if t["source"] == self.main]), 2)
|
||||
self.assertEqual(
|
||||
len([t for t in self.store.tasks() if t["source"] == self.main]), 2
|
||||
)
|
||||
with patch("account_store.time.time", return_value=14501):
|
||||
self.store.ingest(self.main, notice("101", "114", "999"))
|
||||
new_tasks = [t for t in self.store.tasks() if t["source"] == self.main]
|
||||
@@ -239,7 +339,9 @@ class StoreTest(unittest.TestCase):
|
||||
self.store.finish(batch[0]["id"], "failed", {})
|
||||
self.store.finish(batch[1]["id"], "unknown", {})
|
||||
self.assertEqual(self.store.claim_batch(self.worker), [])
|
||||
self.assertEqual({t["status"] for t in self.store.tasks()}, {"failed", "unknown"})
|
||||
self.assertEqual(
|
||||
{t["status"] for t in self.store.tasks()}, {"failed", "unknown"}
|
||||
)
|
||||
|
||||
def test_disabled_and_cross_group_identity(self):
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -297,7 +399,9 @@ class StoreTest(unittest.TestCase):
|
||||
self.assertEqual(migrated["cooldown"], 14400)
|
||||
self.assertFalse(migrated["require_follow"])
|
||||
self.assertIsNotNone(
|
||||
self.store.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='cooldowns'").fetchone()
|
||||
self.store.db.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='cooldowns'"
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
def test_legacy_inbox_migration_preserves_pending_ids(self):
|
||||
@@ -553,16 +657,18 @@ class EngineTest(unittest.IsolatedAsyncioTestCase):
|
||||
await self.engine.start([ident])
|
||||
self.assertEqual(self.engine.desired, set())
|
||||
|
||||
async def test_im_result_classification_and_redaction(self):
|
||||
async def test_im_result_classification_keeps_business_data_and_hides_credentials(self):
|
||||
session = AsyncMock()
|
||||
task = {"params": json.dumps(rule()), "action": "dm", "target": "999"}
|
||||
session.im.return_value = {
|
||||
"success": True,
|
||||
"message": {"client_id": "c1", "server_id": "s1", "content": "secret"},
|
||||
"message": {"client_id": "c1", "server_id": "s1", "content": "完整私信正文"},
|
||||
"token": "AUTH-CREDENTIAL",
|
||||
}
|
||||
status, result = await self.engine.perform(session, task)
|
||||
self.assertEqual(status, "succeeded")
|
||||
self.assertNotIn("secret", json.dumps(result))
|
||||
self.assertIn("完整私信正文", json.dumps(result, ensure_ascii=False))
|
||||
self.assertNotIn("AUTH-CREDENTIAL", json.dumps(result))
|
||||
session.im.return_value = {"error": "SDK_REQUEST_FAILED"}
|
||||
self.assertEqual((await self.engine.perform(session, task))[0], "unknown")
|
||||
session.im.return_value = {"error": "LOGIN_REQUIRED"}
|
||||
@@ -663,6 +769,125 @@ class EngineTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(len(self.engine.store.tasks()), 2)
|
||||
self.assertIn("不阻塞新通知", self.engine.states[self.main])
|
||||
|
||||
async def test_history_and_works_paginate_read_only_with_exact_ids(self):
|
||||
session = Session(None, "http://127.0.0.1:9222", "101")
|
||||
first = {
|
||||
"status": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"notice_list_v2": [notice("101", "9223372036854775701")],
|
||||
"has_more": 1,
|
||||
"min_time": 100,
|
||||
"max_time": 200,
|
||||
}
|
||||
),
|
||||
}
|
||||
second = {
|
||||
"status": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"notice_list_v2": [notice("101", "9223372036854775702")],
|
||||
"has_more": 0,
|
||||
"min_time": 90,
|
||||
"max_time": 190,
|
||||
}
|
||||
),
|
||||
}
|
||||
session.json = AsyncMock(side_effect=[first, second])
|
||||
history = await session.history_notices()
|
||||
self.assertEqual(
|
||||
[row["nid_str"] for row in history],
|
||||
["9223372036854775701", "9223372036854775702"],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
"is_mark_read" in call.args[0] and "700" in call.args[0]
|
||||
for call in session.json.await_args_list
|
||||
)
|
||||
)
|
||||
session.identity = AsyncMock(return_value={"uid": "101", "sec_uid": "SEC-UID"})
|
||||
session.json = AsyncMock(
|
||||
side_effect=[
|
||||
{
|
||||
"status": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"aweme_list": [
|
||||
{
|
||||
"aweme_id": "9223372036854775703",
|
||||
"desc": "完整作品",
|
||||
"create_time": 123,
|
||||
"author": {"uid": "101"},
|
||||
"statistics": {"digg_count": 9},
|
||||
"video": {
|
||||
"cover": {
|
||||
"url_list": [
|
||||
"https://example.invalid/full.jpg"
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
"has_more": 1,
|
||||
"max_cursor": 321,
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
"status": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"aweme_list": [],
|
||||
"has_more": 0,
|
||||
"max_cursor": 0,
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
)
|
||||
works = await session.works(True)
|
||||
self.assertEqual(works[0]["aweme_id"], "9223372036854775703")
|
||||
self.assertEqual(works[0]["desc"], "完整作品")
|
||||
self.assertEqual(works[0]["cover"], "https://example.invalid/full.jpg")
|
||||
self.assertEqual(session.json.await_count, 2)
|
||||
|
||||
async def test_history_and_works_reject_cursor_loops_and_foreign_identity(self):
|
||||
session = Session(None, "http://127.0.0.1:9222", "101")
|
||||
repeating = {
|
||||
"status": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"notice_list_v2": [],
|
||||
"has_more": 1,
|
||||
"min_time": 0,
|
||||
"max_time": 0,
|
||||
}
|
||||
),
|
||||
}
|
||||
session.json = AsyncMock(return_value=repeating)
|
||||
with self.assertRaisesRegex(SessionError, "游标未推进"):
|
||||
await session.history_notices()
|
||||
session.identity = AsyncMock(return_value={"uid": "101", "sec_uid": "SEC-UID"})
|
||||
session.json = AsyncMock(
|
||||
return_value={
|
||||
"status": 200,
|
||||
"body": json.dumps(
|
||||
{
|
||||
"status_code": 0,
|
||||
"aweme_list": [{"aweme_id": "700", "author": {"uid": "other"}}],
|
||||
"has_more": 0,
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
with self.assertRaisesRegex(SessionError, "身份不符"):
|
||||
await session.works(False)
|
||||
|
||||
async def test_detail_64bit_ids_preserved(self):
|
||||
session = Session(None, "http://127.0.0.1:9222", "101")
|
||||
session.identity = AsyncMock(return_value={"uid": "101"})
|
||||
@@ -681,6 +906,52 @@ class EngineTest(unittest.IsolatedAsyncioTestCase):
|
||||
result = await session.details([nid])
|
||||
self.assertEqual(str(result[0]["nid"]), nid)
|
||||
|
||||
async def test_history_preview_confirmation_and_work_filter_commands(self):
|
||||
session = AsyncMock()
|
||||
history = [work_notice("101", "3001", "901", "700")]
|
||||
works = [
|
||||
{
|
||||
"aweme_id": "700",
|
||||
"desc": "完整作品描述",
|
||||
"create_time": 123,
|
||||
"statistics": {"digg_count": 5},
|
||||
"cover": "https://example.invalid/cover.jpg",
|
||||
"business": {"aweme_id": "700", "desc": "完整作品描述"},
|
||||
}
|
||||
]
|
||||
session.history_notices.return_value = history
|
||||
session.works.return_value = works
|
||||
self.engine.store.set_rule(
|
||||
self.main,
|
||||
rule(kinds=["digg", "follow", "comment", "general_notice"]),
|
||||
)
|
||||
self.engine.open = AsyncMock(return_value=session)
|
||||
preview = await self.engine.command("history_fetch", {"id": self.main})
|
||||
self.assertEqual(preview["items"][0]["nid"], "3001")
|
||||
self.assertEqual(preview["items"][0]["work_id"], "700")
|
||||
self.assertEqual(preview["items"][0]["actor_names"], ["完整昵称"])
|
||||
self.assertEqual(self.engine.store.tasks(), [])
|
||||
with self.assertRaises(ValueError):
|
||||
await self.engine.command(
|
||||
"history_enqueue", {"id": self.main, "ids": ["not-loaded"]}
|
||||
)
|
||||
result = await self.engine.command(
|
||||
"history_enqueue", {"id": self.main, "ids": ["3001"]}
|
||||
)
|
||||
self.assertEqual(result, {"selected": 1, "events": 1, "tasks": 2})
|
||||
fetched = await self.engine.command("works_recent", {"id": self.main})
|
||||
self.assertEqual(fetched["items"], works)
|
||||
self.assertFalse(fetched["all_pages"])
|
||||
saved = await self.engine.command(
|
||||
"work_filter", {"id": self.main, "mode": "selected", "ids": ["700"]}
|
||||
)
|
||||
self.assertEqual(saved, {"mode": "selected", "count": 1})
|
||||
rule_value = json.loads(self.engine.store.account(self.main)["rule"])
|
||||
self.assertEqual(rule_value["work_mode"], "selected")
|
||||
self.assertEqual(rule_value["work_ids"], ["700"])
|
||||
session.works.assert_awaited_once_with(False)
|
||||
session.history_notices.assert_awaited_once()
|
||||
|
||||
async def test_move_during_identity_check_does_not_cross_groups(self):
|
||||
other = self.engine.store.add("main", "另一组", "102")
|
||||
self.engine.store.set_rule(other, rule())
|
||||
@@ -737,10 +1008,14 @@ class EngineTest(unittest.IsolatedAsyncioTestCase):
|
||||
if entered == 2:
|
||||
both_entered.set()
|
||||
await release.wait()
|
||||
return {"status": "succeeded"} if kind == "follow" else {
|
||||
"success": True,
|
||||
"message": {"client_id": "client", "server_id": "server"},
|
||||
}
|
||||
return (
|
||||
{"status": "succeeded"}
|
||||
if kind == "follow"
|
||||
else {
|
||||
"success": True,
|
||||
"message": {"client_id": "client", "server_id": "server"},
|
||||
}
|
||||
)
|
||||
|
||||
async def follow_action(*args, **kwargs):
|
||||
return await action("follow", *args)
|
||||
|
||||
@@ -72,14 +72,10 @@ def test_sensitive_data_single_line_and_file_failure():
|
||||
)
|
||||
audit.record("安全", 'authorization="AUTH_SECRET"')
|
||||
text = all_logs(audit)
|
||||
assert "PRIVATE_BODY" in text and "https://site.invalid/" in text
|
||||
assert not any(
|
||||
secret in text
|
||||
for secret in (
|
||||
"PRIVATE_BODY",
|
||||
"COOKIE_SECRET",
|
||||
"AUTH_SECRET",
|
||||
"URL_SECRET",
|
||||
)
|
||||
for secret in ("COOKIE_SECRET", "AUTH_SECRET", "URL_SECRET")
|
||||
)
|
||||
assert "\u2029" not in text and len(text.splitlines()) == 2
|
||||
finally:
|
||||
@@ -124,7 +120,7 @@ def test_business_stages_skips_dependencies_and_rollback():
|
||||
assert not store.tasks()
|
||||
assert "触发者就是执行小号自身" in all_logs(audit)
|
||||
assert "生成任务数=0" in all_logs(audit)
|
||||
assert "1234567890123456789" not in all_logs(audit)
|
||||
assert "1234567890123456789" in all_logs(audit)
|
||||
store.record_push(main, ["2"])
|
||||
store.ingest(main, notification("2"))
|
||||
batch = store.claim_batch(worker)
|
||||
@@ -152,7 +148,7 @@ def test_business_stages_skips_dependencies_and_rollback():
|
||||
"人工核对",
|
||||
):
|
||||
assert f"[{stage}]" in text
|
||||
assert "PRIVATE_TEMPLATE" not in text
|
||||
assert "PRIVATE_TEMPLATE" in text
|
||||
with pytest.raises(ValueError), store.transaction():
|
||||
store.log("测试", "ROLLBACK_MUST_NOT_APPEAR")
|
||||
raise ValueError("rollback")
|
||||
|
||||
@@ -36,9 +36,69 @@ def fixture_for(expected):
|
||||
"nickname": "offline fixture",
|
||||
"follower_count": 42,
|
||||
"aweme_count": 3,
|
||||
"sec_uid": "SEC-" + expected,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif "/aweme/v1/web/notice/?" in route.request.url:
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
query = parse_qs(urlsplit(route.request.url).query)
|
||||
assert query["is_mark_read"] == ["0"] and query["notice_group"] == ["700"]
|
||||
first = query["min_time"] == ["0"] and query["max_time"] == ["0"]
|
||||
await route.fulfill(
|
||||
json={
|
||||
"status_code": 0,
|
||||
"notice_list_v2": [
|
||||
{
|
||||
"nid": 9223372036854775701
|
||||
if first
|
||||
else 9223372036854775702,
|
||||
"nid_str": "9223372036854775701"
|
||||
if first
|
||||
else "9223372036854775702",
|
||||
"user_id": expected,
|
||||
"create_time": 100 if first else 90,
|
||||
"digg": {
|
||||
"from_user": [{"uid": "999", "nickname": "完整昵称"}],
|
||||
"aweme": {"aweme_id": "800", "desc": "完整作品描述"},
|
||||
},
|
||||
}
|
||||
],
|
||||
"has_more": 1 if first else 0,
|
||||
"min_time": 100 if first else 90,
|
||||
"max_time": 200 if first else 190,
|
||||
}
|
||||
)
|
||||
elif "/aweme/v1/web/aweme/post/?" in route.request.url:
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
query = parse_qs(urlsplit(route.request.url).query)
|
||||
assert query["sec_user_id"] == ["SEC-" + expected]
|
||||
first = query["max_cursor"] == ["0"]
|
||||
await route.fulfill(
|
||||
json={
|
||||
"status_code": 0,
|
||||
"aweme_list": [
|
||||
{
|
||||
"aweme_id": "9223372036854775703",
|
||||
"desc": "完整作品描述",
|
||||
"create_time": 123,
|
||||
"author": {"uid": expected},
|
||||
"statistics": {"digg_count": 9},
|
||||
"video": {
|
||||
"cover": {
|
||||
"url_list": ["https://example.invalid/full.jpg"]
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
if first
|
||||
else [],
|
||||
"has_more": 1 if first else 0,
|
||||
"max_cursor": 321 if first else 0,
|
||||
}
|
||||
)
|
||||
elif "/aweme/v1/web/notice/detail/" in route.request.url:
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
@@ -73,16 +133,18 @@ SDK_FIXTURE = r"""uid => {
|
||||
class NoticeFrontier {} NoticeFrontier.frontierInstance=f;
|
||||
const observers=new Map([[0,response=>response]]);let token=0;
|
||||
window.axiosInstance={interceptors:{response:{use:fn=>{observers.set(++token,fn);return token;},eject:id=>observers.delete(id)}}};
|
||||
const api={getNoticeDetail:params=>new Promise((resolve,reject)=>{
|
||||
const path='/aweme/v1/web/notice/detail/',xhr=new XMLHttpRequest();
|
||||
xhr.open('GET',path+'?'+new URLSearchParams({...params,sdk_fixture:'1'}));
|
||||
const request=(path,params)=>new Promise((resolve,reject)=>{
|
||||
const xhr=new XMLHttpRequest();xhr.open('GET',path+'?'+new URLSearchParams(params));
|
||||
xhr.onload=()=>{const response={status:xhr.status,config:{url:path,params},request:xhr};
|
||||
for(const observe of observers.values())observe(response);
|
||||
resolve(JSON.parse(xhr.responseText));};
|
||||
xhr.onerror=()=>reject(Error('fixture XHR failed'));xhr.send();
|
||||
})};
|
||||
const req=id=>id==='notice'?{NoticeFrontier}:id==='api'?api:{decodedFrame:()=>({service:20313,payload:new TextEncoder().encode('[]')})};
|
||||
req.m={notice:function(){/* NOTICE_PUSH_EVENT_NAMES:function */},codec:function(){/* .decodedFrame= .encodeFrame= */},api:function(){/* getNoticeDetail: */}};
|
||||
});
|
||||
const api={getNoticeDetail:params=>request('/aweme/v1/web/notice/detail/',{...params,sdk_fixture:'1'})};
|
||||
function history(params){"/aweme/v1/web/notice/";"is_new_notice";return request('/aweme/v1/web/notice/',params);}
|
||||
function fetchUserPost(params){"/aweme/v1/web/aweme/post/";return request('/aweme/v1/web/aweme/post/',{sec_user_id:params.userId,max_cursor:params.maxCursor,count:params.count});}
|
||||
const req=id=>id==='notice'?{NoticeFrontier}:id==='api'?api:id==='history'?{x:history}:id==='posts'?{fetchUserPost}:{decodedFrame:()=>({service:20313,payload:new TextEncoder().encode('[]')})};
|
||||
req.m={notice:function(){/* NOTICE_PUSH_EVENT_NAMES:function */},codec:function(){/* .decodedFrame= .encodeFrame= */},api:function(){/* getNoticeDetail: */},history:function(){return "/aweme/v1/web/notice/"+"is_new_notice";},posts:function(){return "/aweme/v1/web/aweme/post/";}};
|
||||
window.webpackChunkdouyin_web=[];
|
||||
window.webpackChunkdouyin_web.push=function(chunk){Array.prototype.push.call(this,chunk);chunk[2](req);};
|
||||
window.__offlineSdk={observers:()=>observers.size,count:()=>listeners.get('message').size,
|
||||
@@ -193,6 +255,23 @@ async def matrix(chrome):
|
||||
results[
|
||||
"native_detail_transport_partial_null_precision_and_cleanup"
|
||||
] = True
|
||||
history = await sessions[0].history_notices()
|
||||
assert [row["nid_str"] for row in history] == [
|
||||
"9223372036854775701",
|
||||
"9223372036854775702",
|
||||
]
|
||||
works = await sessions[0].works(True)
|
||||
assert len(works) == 1 and works[0]["aweme_id"] == "9223372036854775703"
|
||||
assert works[0]["desc"] == "完整作品描述"
|
||||
assert (
|
||||
await sessions[0].evaluate(
|
||||
"window.__offlineSdk.observers()", main_world=True
|
||||
)
|
||||
== 1
|
||||
)
|
||||
results[
|
||||
"native_history_and_works_pagination_identity_precision_and_cleanup"
|
||||
] = True
|
||||
results["identity_isolated"] = (
|
||||
await sessions[0].evaluate("localStorage.identity") == "101"
|
||||
and await sessions[1].evaluate("localStorage.identity") == "102"
|
||||
|
||||
+107
-1
@@ -9,7 +9,15 @@ from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QLineEdit, QMessageBox
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QTableWidget,
|
||||
)
|
||||
|
||||
from account_store import DEFAULT_RULE
|
||||
from accounts_app import Backend, Window
|
||||
@@ -135,6 +143,104 @@ class OnboardingUiTest(unittest.TestCase):
|
||||
self.window.delete_worker()
|
||||
send.assert_not_called()
|
||||
|
||||
def test_history_and_works_buttons_require_bound_main(self):
|
||||
main = {"id": "main", "role": "main", "uid": "101"}
|
||||
with (
|
||||
patch.object(self.window, "selected", return_value=main),
|
||||
patch.object(self.window, "send") as send,
|
||||
):
|
||||
self.window.history_events()
|
||||
self.window.fetch_works(False)
|
||||
self.window.fetch_works(True)
|
||||
self.assertEqual(
|
||||
[call.args for call in send.call_args_list],
|
||||
[
|
||||
("history_fetch", {"id": "main"}),
|
||||
("works_recent", {"id": "main"}),
|
||||
("works_all", {"id": "main"}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_history_preview_requires_selection_and_explicit_confirmation(self):
|
||||
payload = {
|
||||
"account": "main",
|
||||
"items": [
|
||||
{
|
||||
"nid": "9007199254740993",
|
||||
"create_time": 123,
|
||||
"kind": "comment",
|
||||
"work_id": "700",
|
||||
"work_desc": "完整作品描述",
|
||||
"actor_uids": ["999"],
|
||||
"actor_names": ["完整昵称"],
|
||||
"comment": "完整评论正文",
|
||||
"business": {
|
||||
"nid_str": "9007199254740993",
|
||||
"comment": "完整评论正文",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def choose(dialog):
|
||||
table = dialog.findChild(QTableWidget)
|
||||
assert (
|
||||
table is not None and table.item(0, 9).text().find("完整评论正文") >= 0
|
||||
)
|
||||
table.item(0, 0).setCheckState(Qt.CheckState.Checked)
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
with (
|
||||
patch.object(QDialog, "exec", choose),
|
||||
patch.object(
|
||||
QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes
|
||||
),
|
||||
patch.object(self.window, "send") as send,
|
||||
):
|
||||
self.window.history_dialog(payload)
|
||||
send.assert_called_once_with(
|
||||
"history_enqueue", {"id": "main", "ids": ["9007199254740993"]}
|
||||
)
|
||||
|
||||
def test_works_preview_saves_selected_mode_and_full_business_data(self):
|
||||
payload = {
|
||||
"account": "main",
|
||||
"mode": "selected",
|
||||
"selected": [],
|
||||
"all_pages": True,
|
||||
"items": [
|
||||
{
|
||||
"aweme_id": "700",
|
||||
"desc": "完整作品描述",
|
||||
"create_time": 123,
|
||||
"statistics": {"collect_count": 8},
|
||||
"cover": "https://example.invalid/full.jpg",
|
||||
"business": {"aweme_id": "700", "desc": "完整作品描述"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def choose(dialog):
|
||||
table = dialog.findChild(QTableWidget)
|
||||
assert table is not None and "完整作品描述" in table.item(0, 6).text()
|
||||
table.item(0, 0).setCheckState(Qt.CheckState.Checked)
|
||||
all_mode = next(
|
||||
box
|
||||
for box in dialog.findChildren(QCheckBox)
|
||||
if "监控全部作品" in box.text()
|
||||
)
|
||||
all_mode.setChecked(False)
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
with (
|
||||
patch.object(QDialog, "exec", choose),
|
||||
patch.object(self.window, "send") as send,
|
||||
):
|
||||
self.window.works_dialog(payload)
|
||||
send.assert_called_once_with(
|
||||
"work_filter", {"id": "main", "mode": "selected", "ids": ["700"]}
|
||||
)
|
||||
|
||||
def test_anonymous_profile_is_never_bound(self):
|
||||
for user in ({}, {"uid": "0"}, {"uid": True}, {"uid": "not-a-uid"}):
|
||||
with self.assertRaises(RuntimeError):
|
||||
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, cast
|
||||
from unittest.mock import patch
|
||||
|
||||
import subscribe_notifications as sub
|
||||
@@ -212,6 +213,64 @@ window.webpackChunkdouyin_web.push=function(chunk){chunk[2](req);return Array.pr
|
||||
assert json.loads(raw["body"])["notice_list_v2"][0]["nid"] == 9007199254740993
|
||||
|
||||
|
||||
def test_native_history_and_works_transport():
|
||||
history_script = cast(Any, sub.history_request_script)
|
||||
for args in ((0, 0, 0), (0, 0, 51), ("bad", 0, 10)):
|
||||
try:
|
||||
history_script(*args)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("必须拒绝无效历史分页参数")
|
||||
for args in (("", 0, 18), ("SEC", -1, 18), ("SEC", 0, 51)):
|
||||
try:
|
||||
sub.works_request_script(*args)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("必须拒绝无效作品分页参数")
|
||||
script = r"""
|
||||
const assert=require('node:assert/strict');
|
||||
const input=JSON.parse(require('node:fs').readFileSync(0,'utf8'));
|
||||
const observers=new Map([[0,response=>response]]);let seq=0;
|
||||
const client={interceptors:{response:{use:fn=>{observers.set(++seq,fn);return seq;},eject:id=>observers.delete(id)}}};
|
||||
const respond=(endpoint,params,body)=>{
|
||||
if(input.missing)return Promise.resolve({});
|
||||
const response={status:200,config:{url:endpoint,params},request:{responseType:'text',responseText:body}};
|
||||
for(const callback of observers.values())assert.equal(callback(response),response);
|
||||
return Promise.resolve(JSON.parse(body));
|
||||
};
|
||||
function notice(params){"/aweme/v1/web/notice/";"is_new_notice";assert.equal(params.is_mark_read,0);assert.equal(params.notice_group,700);return respond('/aweme/v1/web/notice/',params,'{"status_code":0,"notice_list_v2":[],"has_more":0}');}
|
||||
function fetchUserPost(params){"/aweme/v1/web/aweme/post/";const query={sec_user_id:params.userId,max_cursor:params.maxCursor,count:params.count};return respond('/aweme/v1/web/aweme/post/',query,'{"status_code":0,"aweme_list":[],"has_more":0}');}
|
||||
const modules={notice:{x:notice},posts:{fetchUserPost}};
|
||||
const req=id=>modules[id];
|
||||
req.m={notice:function(){return "/aweme/v1/web/notice/"+"is_new_notice";},posts:function(){return "/aweme/v1/web/aweme/post/";}};
|
||||
global.location={origin:'https://www.douyin.com'};
|
||||
global.window={axiosInstance:client,webpackChunkdouyin_web:[]};
|
||||
window.webpackChunkdouyin_web.push=function(chunk){chunk[2](req);return Array.prototype.push.call(this,chunk);};
|
||||
(async()=>{
|
||||
if(input.missing)await assert.rejects(()=>eval(input.expression));
|
||||
else {const result=JSON.parse(await eval(input.expression));assert.equal(result.status,200);}
|
||||
assert.deepEqual([...observers.keys()],[0]);
|
||||
console.log('ok');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
"""
|
||||
expressions = [
|
||||
sub.history_request_script(0, 0, 50),
|
||||
sub.works_request_script("SEC-UID", 0, 18),
|
||||
]
|
||||
for expression in expressions:
|
||||
for missing in (False, True):
|
||||
subprocess.run(
|
||||
["node", "-e", script],
|
||||
input=json.dumps({"expression": expression, "missing": missing}),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def test_js_bridge():
|
||||
# 假 Webpack/FWS;只测自有监听器,不向真实账号发送通知。
|
||||
script = r"""
|
||||
@@ -273,5 +332,6 @@ if __name__ == "__main__":
|
||||
test_details()
|
||||
test_subscription_and_cli()
|
||||
test_native_detail_transport()
|
||||
test_native_history_and_works_transport()
|
||||
test_js_bridge()
|
||||
print("subscription checks passed")
|
||||
|
||||
Reference in New Issue
Block a user